From 5ea031c2b1602ecf64e3e8b38b244a4691fbd2c0 Mon Sep 17 00:00:00 2001 From: vidarak Date: Sat, 5 Sep 2026 05:52:01 +0000 Subject: [PATCH 1/4] fix(codex): port Azure replay handling after refactor --- agent/auxiliary_client.py | 8 ++++- agent/codex_responses_adapter.py | 58 ++++++++++++++++++++++++++------ agent/transports/codex.py | 8 +++++ agent/turn_api_call.py | 2 ++ agent/turn_api_request.py | 2 ++ 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 7ef6f88ba9583..a7e68f3bdab70 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1380,7 +1380,13 @@ def _build_responses_kwargs(self, kwargs: Dict[str, Any]) -> Tuple[Dict[str, Any input_items = _chat_messages_to_responses_input( replay_messages, is_github_responses=is_copilot, current_issuer_kind=_classify_responses_issuer(base_url=host, **route._asdict()), - current_issuer_model=wire_model, native_compaction_eligible=False, + current_issuer_model=wire_model, + native_compaction_eligible=False, + is_azure_foundry=( + str(_runtime_main_value("provider") or "").strip().lower() == "azure-foundry" + or base_url_host_matches(host, "services.ai.azure.com") + or base_url_host_matches(host, "openai.azure.com") + ), ) resp_kwargs: Dict[str, Any] = { # Codex only knows the base slug; strip the Hermes ``-900k`` picker suffix. diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index c8840300190fb..ab2d1027b0545 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -357,9 +357,21 @@ def _assistant_message_item( ) +def _apply_azure_output_text_annotations(parts: List[Dict[str, Any]]) -> None: + """Add Azure's required annotations field to output_text parts only. + + Azure validates replayed assistant output blocks more strictly than the + generic Responses endpoint. Never add this field to input_image parts. + """ + for part in parts: + if isinstance(part, dict) and part.get("type") == "output_text": + part.setdefault("annotations", []) + + def _replay_reasoning_items( msg: Dict[str, Any], *, seen_item_ids: set, current_issuer_kind: Optional[str], current_issuer_model: Optional[str] = None, native_compaction_eligible: bool, + is_azure_foundry: bool = False, ) -> List[Dict[str, Any]]: """Replay persisted encrypted reasoning/compaction items for one assistant turn. Skips duplicate ids, ``compaction`` checkpoints unless THIS request carries ``context_management`` (else a persisted @@ -392,7 +404,17 @@ def _replay_reasoning_items( ) _CROSS_ISSUER_WARN_EMITTED = True continue - replayed.append({k: v for k, v in ri.items() if k not in ("id", "_issuer_kind", "_issuer_model")}) + if is_azure_foundry: + replay_item = { + "type": "reasoning", + "encrypted_content": ri["encrypted_content"], + "summary": ri.get("summary") if isinstance(ri.get("summary"), list) else [], + } + if isinstance(item_id, str) and item_id: + replay_item["id"] = item_id + else: + replay_item = {k: v for k, v in ri.items() if k not in ("id", "_issuer_kind", "_issuer_model")} + replayed.append(replay_item) if item_id: seen_item_ids.add(item_id) return replayed @@ -493,6 +515,7 @@ def _chat_messages_to_responses_input( messages: List[Dict[str, Any]], *, is_xai_responses: bool = False, is_github_responses: bool = False, replay_encrypted_reasoning: bool = True, current_issuer_kind: Optional[str] = None, current_issuer_model: Optional[str] = None, native_compaction_eligible: bool = False, + is_azure_foundry: bool = False, ) -> List[Dict[str, Any]]: """Convert internal chat-style messages to Responses input items. @@ -570,17 +593,20 @@ def emit(new_items: List[Dict[str, Any]], msg: Dict[str, Any]) -> None: message_items = _replay_message_items( msg, is_github_responses=is_github_responses, current_issuer_kind=current_issuer_kind, ) + if is_azure_foundry: + for message_item in message_items: + _apply_azure_output_text_annotations(message_item.get("content", [])) emit(message_items, msg) fallback = None if not message_items: fallback = content_parts or (content_text if content_text.strip() else "" if reasoning_items else None) tool_items = _replay_tool_call_items(msg, start_index=len(items) + (fallback is not None), wire_ids=wire_ids) - # A function_call already follows its reasoning. Inventing an empty assistant - # message between them changes the replayed turn (Muse can emit corrupt finals). - # Keep a follower only for reasoning with no other following item, and make it - # non-empty: strict Responses-compatible providers reject "" with 400. + # A function_call already follows its reasoning; preserve the current-main + # follower ordering and only add a non-empty follower when needed. if fallback is not None and not (fallback == "" and tool_items): follower = " " if fallback == "" else fallback + if is_azure_foundry and isinstance(follower, list): + _apply_azure_output_text_annotations(follower) emit([{"role": "assistant", "content": follower}], msg) emit(tool_items, msg) # The server renders nothing placed before a compaction item, so pre-checkpoint history is @@ -694,7 +720,8 @@ def estimate_native_responses_preflight_tokens( # --- Input preflight / validation -------------------------------------------- _PreflightCtx = NamedTuple("_PreflightCtx", [ - ("sanitize_text", Callable[[str], str]), ("sanitize_harmony_tokens", bool), ("is_github_responses", bool), ("seen_ids", set), + ("sanitize_text", Callable[[str], str]), ("sanitize_harmony_tokens", bool), ("is_github_responses", bool), + ("is_azure_foundry", bool), ("seen_ids", set), ]) @@ -744,10 +771,13 @@ def _preflight_encrypted(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> return None ctx.seen_ids.add(item_id) summary = _as_list(item.get("summary")) - return { + reasoning_item = { "type": "reasoning", "encrypted_content": encrypted, "summary": _neutralize_harmony_structure(summary) if ctx.sanitize_harmony_tokens else summary, } + if ctx.is_azure_foundry and _nonempty_str(item_id): + reasoning_item["id"] = item_id + return reasoning_item def _preflight_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Dict[str, Any]: @@ -766,6 +796,8 @@ def _preflight_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Di f"Codex Responses input[{idx}] message content[{part_idx}] has unsupported type {part_type!r}." ) normalized_content.append({"type": "output_text", "text": ctx.sanitize_text(_str_or_empty(part.get("text", "")))}) + if ctx.is_azure_foundry: + _apply_azure_output_text_annotations(normalized_content) if not normalized_content: raise ValueError(f"Codex Responses input[{idx}] message item must contain at least one text part.") return _assistant_message_item(item, normalized_content, is_github_responses=ctx.is_github_responses) @@ -801,6 +833,8 @@ def _preflight_role_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) raise ValueError( f"Codex Responses input[{idx}].content[{part_idx}] has unsupported type {part.get('type')!r}." ) + if ctx.is_azure_foundry and role == "assistant": + _apply_azure_output_text_annotations(validated) return {"role": role, "content": validated} @@ -811,12 +845,13 @@ def _preflight_role_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) def _preflight_codex_input_items( - raw_items: Any, *, is_github_responses: bool = False, sanitize_harmony_tokens: bool = False, + raw_items: Any, *, is_github_responses: bool = False, is_azure_foundry: bool = False, + sanitize_harmony_tokens: bool = False, ) -> List[Dict[str, Any]]: if not isinstance(raw_items, list): raise ValueError("Codex Responses input must be a list of input items.") sanitize_text = _neutralize_harmony_tokens if sanitize_harmony_tokens else (lambda text: text) - ctx = _PreflightCtx(sanitize_text, sanitize_harmony_tokens, is_github_responses, set()) + ctx = _PreflightCtx(sanitize_text, sanitize_harmony_tokens, is_github_responses, is_azure_foundry, set()) normalized: List[Dict[str, Any]] = [] for idx, item in enumerate(raw_items): if not isinstance(item, dict): @@ -880,7 +915,7 @@ def _optional_dict(api_kwargs: Dict[str, Any], key: str) -> Optional[Dict[str, A def _preflight_codex_api_kwargs( api_kwargs: Any, *, allow_stream: bool = False, is_github_responses: bool = False, - sanitize_harmony_tokens: bool = False, + is_azure_foundry: bool = False, sanitize_harmony_tokens: bool = False, ) -> Dict[str, Any]: if not isinstance(api_kwargs, dict): raise ValueError("Codex Responses request must be a dict.") @@ -893,7 +928,8 @@ def _preflight_codex_api_kwargs( if sanitize_harmony_tokens: instructions = _neutralize_harmony_tokens(instructions) input_items = _preflight_codex_input_items( - api_kwargs.get("input"), is_github_responses=is_github_responses, sanitize_harmony_tokens=sanitize_harmony_tokens, + api_kwargs.get("input"), is_github_responses=is_github_responses, is_azure_foundry=is_azure_foundry, + sanitize_harmony_tokens=sanitize_harmony_tokens, ) normalized: Dict[str, Any] = { "model": model.strip(), "instructions": instructions, "input": input_items, "store": False, diff --git a/agent/transports/codex.py b/agent/transports/codex.py index ac0076dc37405..f0832e31cbafd 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -517,6 +517,7 @@ def convert_messages(self, messages: list[dict[str, Any]], **kwargs) -> Any: return _chat_messages_to_responses_input( messages, is_xai_responses=kwargs.get("is_xai_responses") is True, is_github_responses=kwargs.get("is_github_responses") is True, + is_azure_foundry=kwargs.get("is_azure_foundry") is True or _is_azure_foundry_responses(kwargs), replay_encrypted_reasoning=bool(kwargs.get("replay_encrypted_reasoning", True)), current_issuer_kind=self._resolve_issuer_kind(kwargs), current_issuer_model=self._last_issuer_model, @@ -566,6 +567,7 @@ def build_kwargs( is_github_responses = params.get("is_github_responses") is True is_codex_backend = params.get("is_codex_backend") is True is_xai_responses = params.get("is_xai_responses") is True + is_azure_foundry = _is_azure_foundry_responses(params) # Foundry 400s on encrypted-reasoning replay only in the post-tool follow-up turn. replay_encrypted_reasoning = bool(params.get("replay_encrypted_reasoning", True)) and not ( _is_azure_foundry_responses(params) and _is_post_tool_replay(payload_messages) @@ -592,6 +594,7 @@ def build_kwargs( "instructions": instructions, "input": self.convert_messages( payload_messages, is_xai_responses=is_xai_responses, is_github_responses=is_github_responses, + is_azure_foundry=is_azure_foundry, replay_encrypted_reasoning=replay_encrypted_reasoning, base_url=params.get("base_url"), is_codex_backend=is_codex_backend, context_management=context_management, model=wire_model, ), @@ -739,6 +742,7 @@ def validate_response(self, response: Any) -> bool: def preflight_kwargs( self, api_kwargs: Any, *, allow_stream: bool = False, is_github_responses: bool = False, + is_azure_foundry: bool = False, provider: str | None = None, base_url: str | None = None, sanitize_harmony_tokens: bool = False, ) -> dict: """Validate and sanitize Codex API kwargs before the call. @@ -747,8 +751,12 @@ def preflight_kwargs( """ from agent.codex_responses_adapter import _preflight_codex_api_kwargs + is_azure_foundry = is_azure_foundry or _is_azure_foundry_responses( + {"provider": provider, "base_url": base_url} + ) normalized = _preflight_codex_api_kwargs( api_kwargs, allow_stream=allow_stream, is_github_responses=is_github_responses, + is_azure_foundry=is_azure_foundry, sanitize_harmony_tokens=sanitize_harmony_tokens, ) _bound_prompt_cache_key_field(normalized) diff --git a/agent/turn_api_call.py b/agent/turn_api_call.py index 127fee02befdf..50a0f241903ed 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -87,6 +87,8 @@ def _perform_api_call(next_api_kwargs): if agent.api_mode == "codex_responses": next_api_kwargs = agent._get_transport().preflight_kwargs( next_api_kwargs, allow_stream=False, is_github_responses=agent._is_copilot_url(), + is_azure_foundry=(agent.provider or "").strip().lower() == "azure-foundry", + provider=getattr(agent, "provider", None), base_url=getattr(agent, "base_url", None), sanitize_harmony_tokens=agent._is_codex_backend(), ) if _use_streaming: diff --git a/agent/turn_api_request.py b/agent/turn_api_request.py index f218023921f60..dba135eb98326 100644 --- a/agent/turn_api_request.py +++ b/agent/turn_api_request.py @@ -124,6 +124,8 @@ def build_api_request( if agent.api_mode == "codex_responses": api_kwargs = agent._get_transport().preflight_kwargs( api_kwargs, allow_stream=False, is_github_responses=agent._is_copilot_url(), + is_azure_foundry=(agent.provider or "").strip().lower() == "azure-foundry", + provider=getattr(agent, "provider", None), base_url=getattr(agent, "base_url", None), sanitize_harmony_tokens=agent._is_codex_backend(), ) # OpenRouter caching replays identical responses, even empty ones; an empty-response From 26e50ce140cd40359ae47652d89e1d6112d759dd Mon Sep 17 00:00:00 2001 From: Vidar Kroslid Date: Sat, 5 Sep 2026 08:06:42 +0000 Subject: [PATCH 2/4] test(codex): restore Azure Foundry wire-shape coverage; match openai.azure.com in transport predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refactor port (a129010fa) was regenerated from a source-only local patch, so the behaviour-contract tests that previously lived on this branch were dropped. Restore them against the current call graph and close the gaps the E2E test found. Fix: `_is_azure_foundry_responses` matched only `services.ai.azure.com`, while the auxiliary client (and the pre-refactor branch) also matched `openai.azure.com` resource endpoints. A custom provider pointed at `https://.openai.azure.com/…` therefore got the Foundry shape on auxiliary calls but not on the main turn. The transport predicate now covers both hostnames (hostname-aware, no substring match). Tests: - tests/agent/test_azure_foundry_preflight_propagation.py (new): drives the real `turn_api_request.build_api_request` and `turn_api_call.perform_api_call` with `ResponsesApiTransport` and asserts the wire payload — provider-detected behind a proxy, host-detected on both hostnames, and untouched for OpenAI / Codex / Copilot / xAI / look-alike hosts. - transports/test_codex_transport.py::TestAzureFoundryWireShape: build_kwargs and preflight_kwargs contracts, predicate table, suppression/wire-shape agreement. - test_codex_responses_adapter.py: id preservation both ways, annotations only on output_text (incl. helper contract vs input_image/input_text/refusal), Foundry + Harmony sanitisation compose. - test_auxiliary_client.py::TestCodexAdapterAzureFoundryReasoningReplay: host detection on both hostnames, runtime-provider detection behind a proxy via `set_runtime_main`, non-Foundry unchanged. Each guard was verified by re-injecting its bug: host-only predicate → 5 failed; annotations on all parts → 1 failed; dropped provider/base_url forwarding in turn_api_request → 4 failed. --- agent/transports/codex.py | 11 +- tests/agent/test_auxiliary_client.py | 130 +++++++++++++ ...est_azure_foundry_preflight_propagation.py | 168 +++++++++++++++++ tests/agent/test_codex_responses_adapter.py | 158 +++++++++++++++- .../agent/transports/test_codex_transport.py | 174 ++++++++++++++++++ 5 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 tests/agent/test_azure_foundry_preflight_propagation.py diff --git a/agent/transports/codex.py b/agent/transports/codex.py index f0832e31cbafd..406f2810386dd 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -352,12 +352,19 @@ def _profile_declared_efforts(provider: Any, model: Optional[str], base_url: Any def _is_azure_foundry_responses(params: dict[str, Any]) -> bool: - """True for Microsoft Foundry's Responses API (provider id, else host match — not substring).""" + """True for Microsoft Foundry's Responses API (provider id, else host match — not substring). + + Single Foundry predicate for the transport: post-tool reasoning suppression and the + Foundry wire shape (reasoning ``id``, ``annotations`` on ``output_text``) both key off it, + and it must agree with the auxiliary client's host list (#63257). Both Foundry hostnames + count — ``services.ai.azure.com`` and the ``openai.azure.com`` resource endpoints. + """ from utils import base_url_host_matches if str(params.get("provider") or "").strip().lower() == "azure-foundry": return True - return base_url_host_matches(str(params.get("base_url") or ""), "services.ai.azure.com") + url = str(params.get("base_url") or "") + return base_url_host_matches(url, "services.ai.azure.com") or base_url_host_matches(url, "openai.azure.com") def _is_post_tool_replay(messages: Optional[list[dict[str, Any]]]) -> bool: diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 396859da5e634..d142fb8038db3 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -5159,3 +5159,133 @@ def test_only_titling_is_in_the_fast_tier(self): _FAST_MODEL_TASKS ) assert not overlap + + +class TestCodexAdapterAzureFoundryReasoningReplay: + """Auxiliary Codex adapter must apply the Azure Foundry wire shape (#63257). + + Auxiliary calls (compression, flush_memories, MoA) bypass + ``ResponsesApiTransport.build_kwargs()``, so Foundry detection has to happen here + too — by client host OR by the live main runtime provider (a registered + ``azure-foundry`` provider behind a proxy URL). + """ + + _HISTORY = [ + {"role": "system", "content": "You are helpful."}, + { + "role": "assistant", + "content": [{"type": "text", "text": "thinking"}], + "codex_reasoning_items": [ + { + "type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob", + "summary": [{"type": "summary_text", "text": "brief"}], + "status": "completed", "response_id": "resp_123", + } + ], + }, + {"role": "user", "content": "continue"}, + ] + + @staticmethod + def _build_adapter(base_url): + from agent.auxiliary_client import _CodexCompletionsAdapter + + message_item = SimpleNamespace( + type="message", role="assistant", status="completed", + content=[SimpleNamespace(type="output_text", text="ok")], + ) + events = [ + SimpleNamespace(type="response.created"), + SimpleNamespace(type="response.output_item.done", item=message_item), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace(status="completed", id="resp_test", usage=None), + ), + ] + + class _FakeCreateStream: + def __iter__(self): + return iter(events) + + def close(self): + pass + + captured = {} + + def _create(**kwargs): + captured.update(kwargs) + return _FakeCreateStream() + + real_client = MagicMock() + real_client.base_url = base_url + real_client.responses.create = _create + return _CodexCompletionsAdapter(real_client, "gpt-5.5"), captured + + @staticmethod + def _wire_input(captured): + # ``_bypass_sdk_request_transform`` may route bulk ``input`` via ``extra_body``; + # the wire body is identical either way. + if "input" in captured: + return captured["input"] + return captured["extra_body"]["input"] + + @classmethod + def _reasoning(cls, captured): + return next(item for item in cls._wire_input(captured) if item.get("type") == "reasoning") + + @classmethod + def _assistant_text_part(cls, captured): + msg = next( + i for i in cls._wire_input(captured) + if i.get("role") == "assistant" and isinstance(i.get("content"), list) + ) + return msg["content"][0] + + @pytest.mark.parametrize( + "base_url", + ["https://paperclip.services.ai.azure.com/models", "https://paperclip.openai.azure.com/openai/v1"], + ) + def test_keeps_reasoning_id_for_azure_foundry_host(self, base_url): + from agent.auxiliary_client import clear_runtime_main + + clear_runtime_main() + adapter, captured = self._build_adapter(base_url=base_url) + adapter.create(messages=list(self._HISTORY)) + + assert self._reasoning(captured) == { + "type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob", + "summary": [{"type": "summary_text", "text": "brief"}], + } + assert self._assistant_text_part(captured)["annotations"] == [] + + def test_keeps_reasoning_id_for_azure_foundry_runtime_provider_behind_proxy(self): + from agent.auxiliary_client import reset_runtime_main, set_runtime_main + + token = set_runtime_main("azure-foundry", "gpt-5.5", base_url="https://gateway.corp.example/v1") + try: + adapter, captured = self._build_adapter(base_url="https://gateway.corp.example/v1") + adapter.create(messages=list(self._HISTORY)) + finally: + reset_runtime_main(token) + + assert self._reasoning(captured)["id"] == "rs_123" + assert self._assistant_text_part(captured)["annotations"] == [] + + @pytest.mark.parametrize( + "base_url", + [ + "https://api.openai.com/v1", + "https://api.githubcopilot.com", + "https://evil.com/services.ai.azure.com/v1", + "https://openai.azure.com.evil.net/v1", + ], + ) + def test_non_foundry_host_keeps_default_shape(self, base_url): + from agent.auxiliary_client import clear_runtime_main + + clear_runtime_main() + adapter, captured = self._build_adapter(base_url=base_url) + adapter.create(messages=list(self._HISTORY)) + + assert "id" not in self._reasoning(captured) + assert "annotations" not in self._assistant_text_part(captured) diff --git a/tests/agent/test_azure_foundry_preflight_propagation.py b/tests/agent/test_azure_foundry_preflight_propagation.py new file mode 100644 index 0000000000000..283537ae4d4e5 --- /dev/null +++ b/tests/agent/test_azure_foundry_preflight_propagation.py @@ -0,0 +1,168 @@ +"""Azure Foundry awareness must reach ``preflight_kwargs`` from the live turn paths (#63257). + +The preflight call sites moved out of ``conversation_loop`` into +``agent.turn_api_request.build_api_request`` (first attempt) and +``agent.turn_api_call.perform_api_call`` (streaming retry). Unit tests that call +``preflight_kwargs`` directly cannot catch a call site that forgets to forward the +agent's provider/base_url — that is exactly how an earlier revision of this fix went +green while stripping the reasoning ``id`` on the primary path. These tests drive +the real request builders with the real ``ResponsesApiTransport`` and assert on the +wire payload that comes out. +""" + +from types import SimpleNamespace + +import pytest + +from agent.transports.codex import ResponsesApiTransport +from agent.turn_api_request import build_api_request + + +_REASONING_KWARGS = { + "model": "gpt-5.5", + "instructions": "You are Hermes.", + "input": [ + { + "type": "reasoning", + "id": "rs_live", + "encrypted_content": "enc_blob", + "summary": [{"type": "summary_text", "text": "brief"}], + "status": "completed", + }, + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "ok"}], + }, + ], + "store": False, +} + + +def _agent(*, provider, base_url): + """Minimal agent double exposing what ``build_api_request`` reads on the codex path.""" + transport = ResponsesApiTransport() + agent = SimpleNamespace( + provider=provider, + base_url=base_url, + api_mode="codex_responses", + model="gpt-5.5", + tools=[], + client=SimpleNamespace(), + session_id="s", + platform="cli", + max_tokens=None, + _use_prompt_caching=False, + _force_ascii_payload=False, + _empty_content_retries=0, + _is_user_initiated_turn=False, + _last_api_first_chunk_at=None, + _reset_stream_delivery_tracking=lambda: None, + _reapply_reasoning_echo_for_provider=lambda msgs: None, + _build_api_kwargs=lambda *a, **k: {k2: (list(v) if isinstance(v, list) else v) + for k2, v in _REASONING_KWARGS.items()}, + _get_transport=lambda: transport, + _is_copilot_url=lambda: False, + _is_codex_backend=lambda: False, + _is_openrouter_url=lambda: False, + _api_request_payload_for_hook=lambda kw: kw, + _dump_api_request_debug=lambda *a, **k: None, + _pending_redirect=None, + _has_pending_redirect=lambda: False, + ) + return agent + + +def _build(agent): + result = build_api_request( + agent, api_messages=[{"role": "user", "content": "hi"}], _moa_prepared_request=None, + tools_for_api=agent.tools, system_message="You are Hermes.", messages=[], + original_user_message="hi", approx_tokens=1, total_chars=2, retry_count=0, + api_call_count=1, api_request_id="r1", api_start_time=0.0, effective_task_id="t", + turn_id="turn", + ) + return result.api_kwargs + + +def _reasoning(kwargs): + return next(i for i in kwargs["input"] if i.get("type") == "reasoning") + + +def _assistant_text_part(kwargs): + msg = next(i for i in kwargs["input"] if i.get("type") == "message") + return msg["content"][0] + + +@pytest.mark.parametrize( + "provider,base_url", + [ + # Host-detected, provider unset (custom endpoint pointing at Foundry). + (None, "https://r.services.ai.azure.com/openai/v1"), + (None, "https://r.openai.azure.com/openai/v1"), + # Provider-detected behind a proxy: the registered azure-foundry provider + # must be honoured even when the URL does not look like Azure. + ("azure-foundry", "https://gateway.corp.example/v1"), + ("Azure-Foundry", None), + ], +) +def test_first_attempt_preflight_keeps_foundry_wire_shape(provider, base_url): + kwargs = _build(_agent(provider=provider, base_url=base_url)) + assert _reasoning(kwargs)["id"] == "rs_live" + assert _assistant_text_part(kwargs)["annotations"] == [] + + +@pytest.mark.parametrize( + "provider,base_url", + [ + ("openai-codex", "https://chatgpt.com/backend-api/codex"), + ("openai", "https://api.openai.com/v1"), + ("copilot", "https://api.githubcopilot.com"), + ("xai", "https://api.x.ai/v1"), + # Look-alikes: hostname-aware matching must not fire on path/suffix hits. + ("custom", "https://evil.com/services.ai.azure.com/v1"), + ("custom", "https://openai.azure.com.evil.net/v1"), + ], +) +def test_first_attempt_preflight_leaves_non_foundry_untouched(provider, base_url): + kwargs = _build(_agent(provider=provider, base_url=base_url)) + assert "id" not in _reasoning(kwargs) + assert "annotations" not in _assistant_text_part(kwargs) + + +def test_streaming_retry_preflight_forwards_the_same_azure_context(monkeypatch): + """``perform_api_call`` re-preflights ``next_api_kwargs`` before streaming; it must + forward the identical provider/base_url context as the first attempt.""" + from agent import turn_api_call + + class _Stop(Exception): + pass + + seen = {} + transport = ResponsesApiTransport() + real_preflight = transport.preflight_kwargs + + def spy(api_kwargs, **kw): + seen.update(kw) + raise _Stop() + + transport.preflight_kwargs = spy + agent = _agent(provider="azure-foundry", base_url="https://gateway.corp.example/v1") + agent._get_transport = lambda: transport + monkeypatch.setattr(turn_api_call, "_should_stream", lambda a: True) + + import inspect + sig = inspect.signature(turn_api_call.perform_api_call) + call_kwargs = {name: None for name in sig.parameters if name != "agent"} + call_kwargs["api_kwargs"] = dict(_REASONING_KWARGS) + call_kwargs["_original_api_kwargs"] = dict(_REASONING_KWARGS) + call_kwargs["_llm_middleware_trace"] = [] + call_kwargs["interrupted"] = False + with pytest.raises(_Stop): + turn_api_call.perform_api_call(agent, **call_kwargs) + + assert seen["is_azure_foundry"] is True + assert seen["provider"] == "azure-foundry" + assert seen["base_url"] == "https://gateway.corp.example/v1" + # And the real preflight with that context keeps the id. + assert _reasoning(real_preflight(dict(_REASONING_KWARGS), **seen))["id"] == "rs_live" diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index f4ff99bab0161..a4bf78e936c00 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -802,4 +802,160 @@ def _xai_reasoning_only_response(reasoning_text): summary=[SimpleNamespace(text=reasoning_text)], ) ], - ) \ No newline at end of file + ) + +# --- Azure AI Foundry wire shape (#63257) ----------------------------------------- + +_AZURE_REASONING_ITEM = { + "type": "reasoning", + "id": "rs_123", + "encrypted_content": "enc_blob", + "summary": [{"type": "summary_text", "text": "brief"}], + "status": "completed", + "response_id": "resp_123", +} + +_AZURE_REASONING_WIRE = { + "type": "reasoning", + "id": "rs_123", + "encrypted_content": "enc_blob", + "summary": [{"type": "summary_text", "text": "brief"}], +} + + +def test_chat_messages_to_responses_input_keeps_reasoning_id_for_azure_foundry(): + messages = [ + { + "role": "assistant", + "content": "thinking", + "codex_reasoning_items": [dict(_AZURE_REASONING_ITEM, _issuer_kind="other")], + } + ] + items = _chat_messages_to_responses_input(messages, is_azure_foundry=True) + reasoning_item = next(item for item in items if item.get("type") == "reasoning") + # Foundry accepts exactly this shape: id kept, Hermes-internal/extra fields dropped. + assert reasoning_item == _AZURE_REASONING_WIRE + + +def test_chat_messages_to_responses_input_strips_reasoning_id_when_not_azure(): + messages = [ + {"role": "assistant", "content": "thinking", "codex_reasoning_items": [dict(_AZURE_REASONING_ITEM)]} + ] + items = _chat_messages_to_responses_input(messages, is_azure_foundry=False) + reasoning_item = next(item for item in items if item.get("type") == "reasoning") + assert "id" not in reasoning_item + assert reasoning_item["encrypted_content"] == "enc_blob" + + +def test_preflight_codex_input_items_keeps_reasoning_id_for_azure_foundry(): + items = _preflight_codex_input_items([dict(_AZURE_REASONING_ITEM)], is_azure_foundry=True) + assert items[0] == _AZURE_REASONING_WIRE + + +def test_preflight_codex_input_items_strips_reasoning_id_when_not_azure(): + items = _preflight_codex_input_items([dict(_AZURE_REASONING_ITEM)], is_azure_foundry=False) + assert "id" not in items[0] + + +def test_azure_annotations_only_on_output_text_parts(): + """``annotations`` belongs to text parts, never to images — in both the + converter and the preflight.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + {"type": "image_url", "image_url": "https://example.com/a.png"}, + ], + }, + {"role": "assistant", "content": [{"type": "text", "text": "seen"}]}, + ] + converted = _chat_messages_to_responses_input(messages, is_azure_foundry=True) + preflighted = _preflight_codex_input_items(converted, is_azure_foundry=True) + + for stage, items in (("convert", converted), ("preflight", preflighted)): + user_parts = {p["type"]: p for p in items[0]["content"]} + assert "annotations" not in user_parts["input_image"], stage + assert "annotations" not in user_parts["input_text"], stage + assistant_parts = items[1]["content"] + assert assistant_parts == [{"type": "output_text", "text": "seen", "annotations": []}], stage + + +def test_azure_annotations_absent_for_non_foundry(): + items = _chat_messages_to_responses_input( + [{"role": "assistant", "content": [{"type": "text", "text": "look"}]}], is_azure_foundry=False, + ) + assert "annotations" not in items[0]["content"][0] + items = _preflight_codex_input_items(items, is_azure_foundry=False) + assert "annotations" not in items[0]["content"][0] + + +def test_azure_and_harmony_sanitization_compose(): + """The Azure wire shape and the Codex Harmony defang touch the same preflight + branches; both must survive the same request.""" + messages = [ + { + "role": "assistant", + "content": [{"type": "text", "text": "before <|end|> after"}], + "codex_reasoning_items": [ + { + "type": "reasoning", + "id": "rs_combined", + "encrypted_content": "enc", + "summary": [{"type": "summary_text", "text": "<|start|>plan"}], + } + ], + } + ] + items = _preflight_codex_input_items( + _chat_messages_to_responses_input(messages, is_azure_foundry=True), + is_azure_foundry=True, sanitize_harmony_tokens=True, + ) + reasoning = next(i for i in items if i.get("type") == "reasoning") + message = next(i for i in items if i.get("role") == "assistant" and isinstance(i.get("content"), list)) + text_part = message["content"][0] + + assert reasoning["id"] == "rs_combined" + assert text_part["annotations"] == [] + assert "<|start|>" not in reasoning["summary"][0]["text"] + assert "\uff5c" in reasoning["summary"][0]["text"] + assert "<|end|>" not in text_part["text"] + assert "\uff5c" in text_part["text"] + + +def test_harmony_sanitization_still_works_without_azure(): + items = _preflight_codex_input_items( + [{"role": "user", "content": "hi <|call|> there"}], is_azure_foundry=False, sanitize_harmony_tokens=True, + ) + assert "<|call|>" not in items[0]["content"] + assert "\uff5c" in items[0]["content"] + + +def test_preflight_api_kwargs_forwards_azure_flag(): + kwargs = _preflight_codex_api_kwargs( + {"model": "gpt-5.5", "instructions": "x", "input": [dict(_AZURE_REASONING_ITEM)], "store": False}, + is_azure_foundry=True, + ) + assert kwargs["input"][0] == _AZURE_REASONING_WIRE + + +def test_apply_azure_annotations_helper_touches_only_output_text(): + """Contract of the helper itself: ``annotations`` is an ``output_text`` field. + Other part types (images, input_text, refusals) must never receive it, and an + existing ``annotations`` value is preserved.""" + from agent.codex_responses_adapter import _apply_azure_output_text_annotations + + parts = [ + {"type": "output_text", "text": "a"}, + {"type": "output_text", "text": "b", "annotations": [{"type": "url_citation"}]}, + {"type": "input_image", "image_url": "https://example.com/a.png"}, + {"type": "input_text", "text": "c"}, + {"type": "refusal", "refusal": "no"}, + "bare-string", + ] + _apply_azure_output_text_annotations(parts) + assert parts[0] == {"type": "output_text", "text": "a", "annotations": []} + assert parts[1]["annotations"] == [{"type": "url_citation"}] + for part in parts[2:5]: + assert "annotations" not in part + assert parts[5] == "bare-string" diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 03b7ded78c930..e28370cf7b825 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -1740,3 +1740,177 @@ def test_non_grok_model_preserves_slash_enum_values(self): assert params["properties"]["model_id"].get("enum") == [ "Qwen/Qwen3.5-0.8B", "plain-id" ] + + +class TestAzureFoundryWireShape: + """Azure AI Foundry Responses wire-shape normalization (#63257). + + Foundry re-validates replayed items against a stricter schema than OpenAI/Codex: + encrypted ``reasoning`` items must keep their ``id``, and assistant ``output_text`` + parts must carry an ``annotations`` array. These pin the *behaviour contract* — + which items get Foundry-only fields, and which endpoints count as Foundry. + """ + + @staticmethod + def _reasoning_history(): + return [ + {"role": "system", "content": "You are Hermes."}, + { + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + "codex_reasoning_items": [ + { + "type": "reasoning", "id": "rs_1", "encrypted_content": "enc", + "summary": [{"type": "summary_text", "text": "brief"}], + "status": "completed", "response_id": "resp_1", + } + ], + }, + {"role": "user", "content": "next"}, + ] + + @staticmethod + def _reasoning_item(kwargs): + items = [i for i in kwargs["input"] if isinstance(i, dict) and i.get("type") == "reasoning"] + assert items, "expected a replayed reasoning item on the wire" + return items[0] + + @staticmethod + def _assistant_text_part(kwargs): + msg = next( + i for i in kwargs["input"] + if isinstance(i, dict) and i.get("role") == "assistant" and isinstance(i.get("content"), list) + ) + return msg["content"][0] + + # ── Detection: provider id and host must agree ──────────────────── + + @pytest.mark.parametrize( + "base_url,provider", + [ + # Host-detected, both Foundry hostnames. + ("https://r.services.ai.azure.com/openai/v1", None), + ("https://r.openai.azure.com/openai/v1", None), + ("https://R.OPENAI.AZURE.COM/openai/v1", None), + # Provider-detected: a registered azure-foundry entry behind a gateway/proxy + # URL is still Foundry and still needs the id. Regression guard for the + # two-predicate drift where post-tool suppression saw Foundry but the wire + # shape did not. + ("https://gateway.corp.example/v1", "azure-foundry"), + ("", "azure-foundry"), + ], + ) + def test_reasoning_id_and_annotations_for_foundry(self, transport, base_url, provider): + kw = transport.build_kwargs( + "gpt-5.5", self._reasoning_history(), [], base_url=base_url, provider=provider, + ) + assert self._reasoning_item(kw) == { + "type": "reasoning", "id": "rs_1", "encrypted_content": "enc", + "summary": [{"type": "summary_text", "text": "brief"}], + } + assert self._assistant_text_part(kw)["annotations"] == [] + + @pytest.mark.parametrize( + "base_url,provider", + [ + ("https://api.openai.com/v1", "openai"), + ("https://chatgpt.com/backend-api/codex", "openai-codex"), + ("https://api.githubcopilot.com", "copilot"), + ("https://api.x.ai/v1", "xai"), + ("https://my-proxy.example/v1/responses", "custom"), + # Substring look-alikes must NOT be treated as Foundry: with store=False a + # non-Foundry surface 404s on a replayed item id. + ("https://evil.com/services.ai.azure.com/v1", None), + ("https://openai.azure.com.evil.net/v1", None), + ], + ) + def test_non_foundry_wire_shape_unchanged(self, transport, base_url, provider): + kw = transport.build_kwargs( + "gpt-5.5", self._reasoning_history(), [], base_url=base_url, provider=provider, + ) + assert "id" not in self._reasoning_item(kw) + assert "annotations" not in self._assistant_text_part(kw) + + @pytest.mark.parametrize( + "params,expected", + [ + ({"base_url": "https://r.services.ai.azure.com/openai/v1"}, True), + ({"base_url": "https://r.openai.azure.com/openai/v1"}, True), + ({"base_url": "https://gateway.corp.example/v1", "provider": "azure-foundry"}, True), + ({"provider": "Azure-Foundry"}, True), + ({"base_url": "https://api.openai.com/v1"}, False), + ({"base_url": "https://evil.com/openai.azure.com/v1"}, False), + ({"base_url": "https://openai.azure.com.evil.net/v1"}, False), + ({"base_url": ""}, False), + ({}, False), + ], + ) + def test_foundry_predicate(self, params, expected): + from agent.transports.codex import _is_azure_foundry_responses + + assert _is_azure_foundry_responses(params) is expected + + def test_post_tool_suppression_and_wire_shape_agree(self, transport): + """Suppression and wire shape key off the same predicate: a Foundry provider + behind a proxy gets BOTH behaviours (id on non-tool turns, no encrypted + reasoning replay on the post-tool turn).""" + params = {"base_url": "https://gateway.corp.example/v1", "provider": "azure-foundry"} + kw = transport.build_kwargs("gpt-5.5", self._reasoning_history(), [], **params) + assert self._reasoning_item(kw)["id"] == "rs_1" + + post_tool = [ + {"role": "system", "content": "You are Hermes."}, + { + "role": "assistant", "content": "", + "tool_calls": [{"id": "call_1", "type": "function", + "function": {"name": "t", "arguments": "{}"}}], + "codex_reasoning_items": [{"type": "reasoning", "id": "rs_2", "encrypted_content": "enc"}], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "done"}, + ] + kw = transport.build_kwargs("gpt-5.5", post_tool, [], **params) + assert not [i for i in kw["input"] if i.get("type") == "reasoning"] + + # ── preflight ───────────────────────────────────────────────────── + + _PREFLIGHT_KW = { + "model": "gpt-5.5", "instructions": "You are Hermes.", "store": False, + "input": [ + {"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob", + "summary": [{"type": "summary_text", "text": "brief"}], + "status": "completed", "response_id": "resp_123"}, + {"type": "message", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": "ok"}]}, + ], + } + + @pytest.mark.parametrize( + "kwargs", + [ + {"is_azure_foundry": True}, + {"provider": "azure-foundry"}, + {"base_url": "https://r.services.ai.azure.com/openai/v1"}, + {"base_url": "https://r.openai.azure.com/openai/v1"}, + ], + ) + def test_preflight_keeps_foundry_shape(self, transport, kwargs): + pre = transport.preflight_kwargs(self._PREFLIGHT_KW, **kwargs) + assert pre["input"][0] == { + "type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob", + "summary": [{"type": "summary_text", "text": "brief"}], + } + assert pre["input"][1]["content"][0]["annotations"] == [] + + @pytest.mark.parametrize( + "kwargs", + [ + {}, + {"provider": "openai", "base_url": "https://api.openai.com/v1"}, + {"is_github_responses": True, "base_url": "https://api.githubcopilot.com"}, + {"base_url": "https://openai.azure.com.evil.net/v1"}, + ], + ) + def test_preflight_non_foundry_unchanged(self, transport, kwargs): + pre = transport.preflight_kwargs(self._PREFLIGHT_KW, **kwargs) + assert "id" not in pre["input"][0] + assert "annotations" not in pre["input"][1]["content"][0] From 91deb2bcf9d5eb103c20e71d3e8c262c0caed96c Mon Sep 17 00:00:00 2001 From: vidarak Date: Fri, 11 Sep 2026 22:38:06 +0000 Subject: [PATCH 3/4] refactor(azure): reuse upstream Responses predicate for wire shape Leave the upstream post-tool suppression predicate unchanged. Resolve Azure context in preflight from provider/base_url instead of forwarding a redundant boolean. Preserve payload coverage and parameterize the streaming path across provider and hostname detection. Verified: independent diff review; 869 targeted tests pass via scripts/run_tests.sh. Policy-boundary test fails on the previous patch; dropping streaming base_url fails both hostname cases. --- agent/transports/codex.py | 17 +++------ agent/turn_api_call.py | 1 - agent/turn_api_request.py | 1 - ...est_azure_foundry_preflight_propagation.py | 18 +++++----- .../agent/transports/test_codex_transport.py | 36 ++++++++++++------- 5 files changed, 39 insertions(+), 34 deletions(-) diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 406f2810386dd..111bd6d854b79 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -352,19 +352,12 @@ def _profile_declared_efforts(provider: Any, model: Optional[str], base_url: Any def _is_azure_foundry_responses(params: dict[str, Any]) -> bool: - """True for Microsoft Foundry's Responses API (provider id, else host match — not substring). - - Single Foundry predicate for the transport: post-tool reasoning suppression and the - Foundry wire shape (reasoning ``id``, ``annotations`` on ``output_text``) both key off it, - and it must agree with the auxiliary client's host list (#63257). Both Foundry hostnames - count — ``services.ai.azure.com`` and the ``openai.azure.com`` resource endpoints. - """ + """True for Microsoft Foundry's Responses API (provider id, else host match — not substring).""" from utils import base_url_host_matches if str(params.get("provider") or "").strip().lower() == "azure-foundry": return True - url = str(params.get("base_url") or "") - return base_url_host_matches(url, "services.ai.azure.com") or base_url_host_matches(url, "openai.azure.com") + return base_url_host_matches(str(params.get("base_url") or ""), "services.ai.azure.com") def _is_post_tool_replay(messages: Optional[list[dict[str, Any]]]) -> bool: @@ -524,7 +517,7 @@ def convert_messages(self, messages: list[dict[str, Any]], **kwargs) -> Any: return _chat_messages_to_responses_input( messages, is_xai_responses=kwargs.get("is_xai_responses") is True, is_github_responses=kwargs.get("is_github_responses") is True, - is_azure_foundry=kwargs.get("is_azure_foundry") is True or _is_azure_foundry_responses(kwargs), + is_azure_foundry=kwargs.get("is_azure_foundry") is True or _is_azure_responses(kwargs), replay_encrypted_reasoning=bool(kwargs.get("replay_encrypted_reasoning", True)), current_issuer_kind=self._resolve_issuer_kind(kwargs), current_issuer_model=self._last_issuer_model, @@ -574,7 +567,7 @@ def build_kwargs( is_github_responses = params.get("is_github_responses") is True is_codex_backend = params.get("is_codex_backend") is True is_xai_responses = params.get("is_xai_responses") is True - is_azure_foundry = _is_azure_foundry_responses(params) + is_azure_foundry = _is_azure_responses(params) # Foundry 400s on encrypted-reasoning replay only in the post-tool follow-up turn. replay_encrypted_reasoning = bool(params.get("replay_encrypted_reasoning", True)) and not ( _is_azure_foundry_responses(params) and _is_post_tool_replay(payload_messages) @@ -758,7 +751,7 @@ def preflight_kwargs( """ from agent.codex_responses_adapter import _preflight_codex_api_kwargs - is_azure_foundry = is_azure_foundry or _is_azure_foundry_responses( + is_azure_foundry = is_azure_foundry or _is_azure_responses( {"provider": provider, "base_url": base_url} ) normalized = _preflight_codex_api_kwargs( diff --git a/agent/turn_api_call.py b/agent/turn_api_call.py index 50a0f241903ed..a911014833a93 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -87,7 +87,6 @@ def _perform_api_call(next_api_kwargs): if agent.api_mode == "codex_responses": next_api_kwargs = agent._get_transport().preflight_kwargs( next_api_kwargs, allow_stream=False, is_github_responses=agent._is_copilot_url(), - is_azure_foundry=(agent.provider or "").strip().lower() == "azure-foundry", provider=getattr(agent, "provider", None), base_url=getattr(agent, "base_url", None), sanitize_harmony_tokens=agent._is_codex_backend(), ) diff --git a/agent/turn_api_request.py b/agent/turn_api_request.py index dba135eb98326..2d14d0b4827c3 100644 --- a/agent/turn_api_request.py +++ b/agent/turn_api_request.py @@ -124,7 +124,6 @@ def build_api_request( if agent.api_mode == "codex_responses": api_kwargs = agent._get_transport().preflight_kwargs( api_kwargs, allow_stream=False, is_github_responses=agent._is_copilot_url(), - is_azure_foundry=(agent.provider or "").strip().lower() == "azure-foundry", provider=getattr(agent, "provider", None), base_url=getattr(agent, "base_url", None), sanitize_harmony_tokens=agent._is_codex_backend(), ) diff --git a/tests/agent/test_azure_foundry_preflight_propagation.py b/tests/agent/test_azure_foundry_preflight_propagation.py index 283537ae4d4e5..184d36c870fd0 100644 --- a/tests/agent/test_azure_foundry_preflight_propagation.py +++ b/tests/agent/test_azure_foundry_preflight_propagation.py @@ -130,7 +130,12 @@ def test_first_attempt_preflight_leaves_non_foundry_untouched(provider, base_url assert "annotations" not in _assistant_text_part(kwargs) -def test_streaming_retry_preflight_forwards_the_same_azure_context(monkeypatch): +@pytest.mark.parametrize("provider,base_url", [ + ("azure-foundry", "https://gateway.corp.example/v1"), + ("az", "https://r.openai.azure.com/openai/v1"), + ("az", "https://r.services.ai.azure.com/openai/v1"), +]) +def test_streaming_retry_preflight_forwards_the_same_azure_context(monkeypatch, provider, base_url): """``perform_api_call`` re-preflights ``next_api_kwargs`` before streaming; it must forward the identical provider/base_url context as the first attempt.""" from agent import turn_api_call @@ -143,11 +148,11 @@ class _Stop(Exception): real_preflight = transport.preflight_kwargs def spy(api_kwargs, **kw): - seen.update(kw) + seen.update(real_preflight(api_kwargs, **kw)) raise _Stop() transport.preflight_kwargs = spy - agent = _agent(provider="azure-foundry", base_url="https://gateway.corp.example/v1") + agent = _agent(provider=provider, base_url=base_url) agent._get_transport = lambda: transport monkeypatch.setattr(turn_api_call, "_should_stream", lambda a: True) @@ -161,8 +166,5 @@ def spy(api_kwargs, **kw): with pytest.raises(_Stop): turn_api_call.perform_api_call(agent, **call_kwargs) - assert seen["is_azure_foundry"] is True - assert seen["provider"] == "azure-foundry" - assert seen["base_url"] == "https://gateway.corp.example/v1" - # And the real preflight with that context keeps the id. - assert _reasoning(real_preflight(dict(_REASONING_KWARGS), **seen))["id"] == "rs_live" + assert _reasoning(seen)["id"] == "rs_live" + assert _assistant_text_part(seen)["annotations"] == [] diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index e28370cf7b825..a44a0ff9e17fe 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -1783,7 +1783,7 @@ def _assistant_text_part(kwargs): ) return msg["content"][0] - # ── Detection: provider id and host must agree ──────────────────── + # ── Wire shape uses the shared Azure Responses classification ──── @pytest.mark.parametrize( "base_url,provider", @@ -1794,8 +1794,7 @@ def _assistant_text_part(kwargs): ("https://R.OPENAI.AZURE.COM/openai/v1", None), # Provider-detected: a registered azure-foundry entry behind a gateway/proxy # URL is still Foundry and still needs the id. Regression guard for the - # two-predicate drift where post-tool suppression saw Foundry but the wire - # shape did not. + # host-only wire-shape detection that missed registered providers. ("https://gateway.corp.example/v1", "azure-foundry"), ("", "azure-foundry"), ], @@ -1845,16 +1844,23 @@ def test_non_foundry_wire_shape_unchanged(self, transport, base_url, provider): ({}, False), ], ) - def test_foundry_predicate(self, params, expected): - from agent.transports.codex import _is_azure_foundry_responses + def test_azure_wire_shape_predicate(self, params, expected): + from agent.transports.codex import _is_azure_responses - assert _is_azure_foundry_responses(params) is expected + assert _is_azure_responses(params) is expected - def test_post_tool_suppression_and_wire_shape_agree(self, transport): - """Suppression and wire shape key off the same predicate: a Foundry provider - behind a proxy gets BOTH behaviours (id on non-tool turns, no encrypted - reasoning replay on the post-tool turn).""" - params = {"base_url": "https://gateway.corp.example/v1", "provider": "azure-foundry"} + @pytest.mark.parametrize("provider,base_url,suppressed", [ + ("azure-foundry", "https://gateway.corp.example/v1", True), + ("az", "https://r.services.ai.azure.com/openai/v1", True), + ("az", "https://r.openai.azure.com/openai/v1", False), + ]) + def test_wire_shape_preserves_upstream_post_tool_policy(self, transport, provider, base_url, suppressed): + """Wire-shape repair must not broaden main's narrower post-tool suppression. + + Resource hosts under a custom provider still replay the newest reasoning; + registered Foundry providers and project gateways suppress it after tools. + """ + params = {"base_url": base_url, "provider": provider} kw = transport.build_kwargs("gpt-5.5", self._reasoning_history(), [], **params) assert self._reasoning_item(kw)["id"] == "rs_1" @@ -1869,7 +1875,13 @@ def test_post_tool_suppression_and_wire_shape_agree(self, transport): {"role": "tool", "tool_call_id": "call_1", "content": "done"}, ] kw = transport.build_kwargs("gpt-5.5", post_tool, [], **params) - assert not [i for i in kw["input"] if i.get("type") == "reasoning"] + reasoning = [i for i in kw["input"] if i.get("type") == "reasoning"] + if suppressed: + assert reasoning == [] + assert kw["include"] == [] + else: + assert reasoning == [{"type": "reasoning", "id": "rs_2", "encrypted_content": "enc", "summary": []}] + assert kw["include"] == ["reasoning.encrypted_content"] # ── preflight ───────────────────────────────────────────────────── From c0f74d27fa66a2426e0321d28b8f04f7cc8736ef Mon Sep 17 00:00:00 2001 From: vidarak Date: Tue, 15 Sep 2026 12:11:12 +0000 Subject: [PATCH 4/4] fix(azure): preserve replay fields on current main --- agent/codex_responses_adapter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index ab2d1027b0545..3a0a850110adb 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -588,6 +588,7 @@ def emit(new_items: List[Dict[str, Any]], msg: Dict[str, Any]) -> None: reasoning_items = [] if not replay_encrypted_reasoning else _replay_reasoning_items( msg, seen_item_ids=seen_item_ids, current_issuer_kind=current_issuer_kind, current_issuer_model=current_issuer_model, native_compaction_eligible=native_compaction_eligible, + is_azure_foundry=is_azure_foundry, ) emit(reasoning_items, msg) message_items = _replay_message_items(