fix(proxy): preserve Responses memory continuations with store=false (#1103)

## Description

Previously, Responses API memory tools could execute successfully but
fail on the follow-up request when the client sent `store=false`.
Headroom sends memory tool results back with `previous_response_id`, but
upstream cannot continue from a response that was not stored.

This PR forces `store=true` only when Headroom actually injects
Responses memory tools, keeping ordinary `store=false` requests
unchanged.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added `_ensure_responses_store_for_memory_tools` to make the Responses
memory-tool continuation precondition explicit.
- Call it only after Responses memory tools are injected.
- Added regression coverage for `store=false`, plus no-op coverage for
unrelated requests.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ /opt/homebrew/bin/uv run --extra dev pytest tests/test_openai_responses_context_compaction.py -q
bind: Invalid command `vi-cmd-mode`.
bind: Invalid command `vi-cmd-mode`.
============================= test session starts ==============================
platform darwin -- Python 3.12.11, pytest-9.0.3, pluggy-1.6.0
rootdir: /Users/ianks/src/github.com/chopratejas/headroom
configfile: pyproject.toml
plugins: anyio-4.12.1, langsmith-0.8.0, asyncio-1.3.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 11 items

tests/test_openai_responses_context_compaction.py ...........            [100%]

======================= 11 passed, 14 warnings in 4.27s ========================

$ /opt/homebrew/bin/uv run --extra dev ruff check headroom/proxy/handlers/openai.py tests/test_openai_responses_context_compaction.py
All checks passed!

$ git diff --check

$ /opt/homebrew/bin/uv run --extra dev mypy headroom
headroom/proxy/server.py:1151: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom/proxy/server.py:1221: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom/proxy/server.py:1225: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
Success: no issues found in 374 source files
```

## Real Behavior Proof

- Environment: macOS, `headroom-ai` 0.25.0 local proxy, OpenAI Responses
traffic through `http://127.0.0.1:8787/v1` to
`https://proxy.shopify.ai`.
- Exact command / steps: sent a Responses request with `store=false`
asking the model to save `HEADROOM_MEMORY_TEST_MARKER_1781746500`, then
sent another `store=false` Responses request asking the model to recall
it via memory search.
- Observed result: before the local patch, `memory_save` persisted
SQLite but continuation failed with `previous_response_not_found`; after
the local patch, the same recall path returned `200` and replied
`HEADROOM_MEMORY_TEST_MARKER_1781746500 means Headroom memory tools
tested pi.`
- Not tested: full upstream integration test against the real OpenAI API
in CI; this PR covers the payload precondition with unit tests and local
proxy manual verification.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated CHANGELOG.md if applicable

## Additional Notes

Changelog updated. No docs update; this is a small proxy bug fix.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
This commit is contained in:
Ian Ker-Seymer 2026-06-22 23:56:30 -04:00 committed by GitHub
parent b4fde0c3a4
commit cdfeeacc63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 80 additions and 0 deletions

View file

@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug Fixes
* **proxy:** force Responses API `store=true` when Headroom injects memory tools so `previous_response_id` continuations work after memory tool calls from clients that requested `store=false` ([#1103](https://github.com/chopratejas/headroom/pull/1103)).
* **proxy:** build SSL contexts for custom CA bundles so enterprise/private PKI roots work with Python/OpenSSL strict verification.
* **proxy:** route Codex OAuth image generation and edit requests through the ChatGPT Codex image backend, while preserving OpenAI API-key image passthrough ([#1215](https://github.com/chopratejas/headroom/pull/1215)).
* **wrap (codex):** keep RTK guidance in the global Codex `AGENTS.md` instead of modifying the shared project `AGENTS.md` ([#1235](https://github.com/chopratejas/headroom/issues/1235)).

View file

@ -321,6 +321,29 @@ def _compact_openai_responses_tools(
return updated, True, before, after
def _ensure_responses_store_for_memory_tools(
payload: dict[str, Any],
*,
memory_tools_injected: bool,
) -> bool:
"""Keep Responses API memory-tool continuations addressable.
Memory tools are transparent to clients: Headroom executes the emitted
function_call, then sends function_call_output in a continuation request
using previous_response_id. OpenAI only allows that continuation when the
previous response was stored. Clients such as pi/Codex can set store=false
to avoid retaining ordinary responses, but that makes memory-tool
continuations fail with previous_response_not_found.
Return True when this function changes the payload.
"""
if memory_tools_injected and payload.get("store") is False:
payload["store"] = True
return True
return False
def _responses_input_item_text_bytes(item: Any) -> int:
if not isinstance(item, dict):
return _json_byte_len(item)
@ -3093,6 +3116,14 @@ class OpenAIHandlerMixin:
if mem_tools_injected:
body["tools"] = resp_tools
logger.info(f"[{request_id}] Memory: Injected memory tools (openai/responses)")
if _ensure_responses_store_for_memory_tools(
body,
memory_tools_injected=True,
):
logger.info(
f"[{request_id}] Memory: forced store=true for Responses memory tool continuation"
)
except Exception as e:
logger.warning(f"[{request_id}] Memory injection failed (responses): {e}")
elif self.memory_handler and memory_user_id and _bypass:

View file

@ -6,6 +6,7 @@ from typing import Any
from headroom.proxy.handlers.openai import (
OpenAIHandlerMixin,
_compact_openai_responses_tools,
_ensure_responses_store_for_memory_tools,
_openai_responses_context_budget,
)
from headroom.transforms.content_router import (
@ -435,3 +436,50 @@ def test_content_router_retries_kompress_when_structured_strategy_noops(monkeypa
assert compressed_tokens == 2
# The fallback chain must record both strategies it tried.
assert strategy_chain == ["smart_crusher", "kompress"]
def test_responses_memory_tools_store_false_regression() -> None:
"""Regression: store=false makes previous_response_id continuations fail."""
payload = {"model": "gpt-5.5", "input": "remember this", "store": False}
changed = _ensure_responses_store_for_memory_tools(
payload,
memory_tools_injected=True,
)
assert changed is True
assert payload["store"] is True
def test_responses_memory_tools_do_not_change_unrelated_requests() -> None:
no_memory_payload = {"model": "gpt-5.5", "input": "plain", "store": False}
already_stored_payload = {"model": "gpt-5.5", "input": "plain", "store": True}
default_store_payload = {"model": "gpt-5.5", "input": "plain"}
assert (
_ensure_responses_store_for_memory_tools(
no_memory_payload,
memory_tools_injected=False,
)
is False
)
assert no_memory_payload["store"] is False
assert (
_ensure_responses_store_for_memory_tools(
already_stored_payload,
memory_tools_injected=True,
)
is False
)
assert already_stored_payload["store"] is True
assert (
_ensure_responses_store_for_memory_tools(
default_store_payload,
memory_tools_injected=True,
)
is False
)
assert "store" not in default_store_payload