diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index de273f7862..5f4bc2e939 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -1322,12 +1322,16 @@ def _sanitize_consumer_codex_request( rejected by the provider. """ sanitized = dict(request) + # Resolved defensively on purpose: run_codex_stream is also driven with + # lightweight stand-in agents that carry only the attributes a given path + # needs (see tests/agent/test_codex_request_transport_diagnostics.py), so a + # bare agent._is_codex_backend() here would raise AttributeError on them. backend_predicate = getattr(agent, "_is_codex_backend", None) is_consumer_codex = ( bool(backend_predicate()) if callable(backend_predicate) else False ) if is_consumer_codex and "prompt_cache_retention" in sanitized: - sanitized.pop("prompt_cache_retention", None) + sanitized.pop("prompt_cache_retention") logger.warning( "Dropped unsupported prompt_cache_retention at consumer Codex " "wire boundary (model=%s).", diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index a67091024b..bbe14ae79d 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -602,6 +602,63 @@ def test_consumer_codex_wire_guard_preserves_compatible_endpoint_retention(): assert sanitized is not request +@pytest.mark.parametrize( + "base_url,model,expect_dropped", + [ + # Consumer ChatGPT Codex: the endpoint rejects retention outright. + ("https://chatgpt.com/backend-api/codex", "gpt-5.6-sol", True), + ("https://chatgpt.com/backend-api/codex", "gpt-5-codex", True), + # Hosts that support 24h retention must keep it (#70083, #88601). + ("https://api.meta.ai/v1", "gpt-5.6-sol", False), + ("https://bedrock-mantle.us-east-1.api.aws/v1", "openai.gpt-5.5", False), + # Non-Codex OpenAI and a same-host/different-path backend are both + # outside the consumer-Codex contract the guard enforces. + ("https://api.openai.com/v1", "gpt-5.6-sol", False), + ("https://chatgpt.com/backend-api/other", "gpt-5.6-sol", False), + ], +) +def test_wire_guard_scopes_retention_drop_by_real_endpoint( + monkeypatch, + base_url, + model, + expect_dropped, +): + """The drop is scoped by the real endpoint, not by a stubbed predicate. + + The sibling test above pins the helper's contract against an explicit + boolean. This one drives ``_is_codex_backend()`` off a real ``AIAgent`` + built on each base URL, so a change to the hostname/path predicate that + widened the drop onto retention-supporting hosts would fail here. + """ + from agent.codex_runtime import _sanitize_consumer_codex_request + + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model=model, + api_mode="codex_responses", + base_url=base_url, + api_key="codex-token", + quiet_mode=True, + max_iterations=4, + skip_context_files=True, + skip_memory=True, + ) + + request = { + "model": model, + "prompt_cache_key": "cache-key-sentinel", + "prompt_cache_retention": "24h", + "input": [{"role": "user", "content": "hi"}], + } + sanitized = _sanitize_consumer_codex_request(agent, request) + + assert ("prompt_cache_retention" not in sanitized) is expect_dropped + # Cache-key routing is independent of retention: the guard must never + # disturb the prompt-cache key, on any endpoint. + assert sanitized["prompt_cache_key"] == "cache-key-sentinel" + assert request["prompt_cache_retention"] == "24h" + + def test_run_codex_stream_returns_collected_items_when_stream_ends_without_terminal(monkeypatch): """The event-driven path tolerates streams that end without a terminal frame.