From cb8249ed95d77c9c91da8fcef2b2ac03d87c116b Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 19 May 2026 07:42:27 +0200 Subject: [PATCH] Address review: surface served model via ChatResponse.model Apply blocking review feedback from PR #5910: - Use ChatResponse.model / ChatResponseUpdate.model as the source of truth for the Azure x-ms-served-model header value, instead of stashing it in additional_properties and overriding it again in observability. Observability already reads response.model; the chat client now overwrites it post-parse when the served-model header is present. Empirically the Azure Responses API returns the deployment alias in body.model and the actual snapshot (e.g. gpt-5-nano-2025-08-07) in this header. - Move the AZURE_OPENAI_SERVED_MODEL_HEADER constant out of observability.py and into RawOpenAIChatClient (as the SERVED_MODEL_HEADER ClassVar). The header is Azure-OpenAI-Responses-API-specific so observability does not need to know about it. - Revert the streaming text_format path to client.responses.stream(...) and drop the _pydantic_model_to_text_format_param helper. That helper imported from openai.lib._parsing._responses (a private SDK path) and the swap to responses.create(stream=True) dropped client-side output_parsed for structured-output streaming. The streaming-with-text_format path is the only one that does not surface the served-model header - documented inline. - Wrap the raw streaming responses in async with so the underlying socket closes deterministically (continuation_token retrieve + create paths). - Fix the empty-string / whitespace-only header at the source by stripping in _extract_served_model and returning None when nothing remains. - Revert unrelated formatting-only churn in _skills.py and test_mcp.py. - Update unit tests to assert against chat_response.model / update.model and add an aggregated streaming assertion plus a pin that the streaming-with-text_format path does not get the header. Verified end-to-end against Azure OpenAI Responses API: deployment alias gpt-5-nano now reports gpt-5-nano-2025-08-07 as ChatResponse.model in both the non-streaming and streaming paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/observability.py | 14 -- .../core/tests/core/test_observability.py | 94 ---------- .../agent_framework_openai/_chat_client.py | 152 +++++++--------- .../tests/openai/test_openai_chat_client.py | 165 ++++++++++++++---- 4 files changed, 195 insertions(+), 230 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 4806ea6823..d324caa757 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -101,12 +101,6 @@ INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS: Final[contextvars.ContextVar[set[str] INNER_RESPONSE_ID_CAPTURED_FIELD: Final[str] = "response_id" INNER_USAGE_CAPTURED_FIELD: Final[str] = "usage" -# Response header set by Azure OpenAI naming the model that actually served the -# request (which can differ from the deployment alias the caller sent). Chat -# clients may surface this on ``ChatResponse.additional_properties`` so the -# telemetry layer can promote it to ``gen_ai.response.model``. -AZURE_OPENAI_SERVED_MODEL_HEADER: Final[str] = "x-ms-served-model" - # Tracks accumulated token usage from all inner chat completion spans within an agent invoke. INNER_ACCUMULATED_USAGE: Final[contextvars.ContextVar[UsageDetails | None]] = contextvars.ContextVar( "inner_accumulated_usage", default=None @@ -2131,14 +2125,6 @@ def _get_response_attributes( attributes[OtelAttr.FINISH_REASONS] = json.dumps([finish_reason]) if model := getattr(response, "model", None): attributes[OtelAttr.RESPONSE_MODEL] = model - # If the underlying provider reports the actually served model via the - # ``x-ms-served-model`` response header (Azure OpenAI), prefer it over the - # model reported on the response body for the response model attribute. - additional_properties = getattr(response, "additional_properties", None) - if isinstance(additional_properties, Mapping): - candidate = cast("Mapping[str, Any]", additional_properties).get(AZURE_OPENAI_SERVED_MODEL_HEADER) - if isinstance(candidate, str) and candidate: - attributes[OtelAttr.RESPONSE_MODEL] = candidate if capture_usage and (usage := response.usage_details): input_tokens = usage.get("input_token_count") if input_tokens: diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 9a1ed11a29..71b59a351b 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1739,78 +1739,6 @@ def test_get_response_attributes_capture_response_id_false(): assert OtelAttr.RESPONSE_ID not in result -def test_get_response_attributes_served_model_overrides_response_model(): - """When the response carries the Azure ``x-ms-served-model`` header, it should override RESPONSE_MODEL.""" - from unittest.mock import Mock - - from agent_framework.observability import ( - AZURE_OPENAI_SERVED_MODEL_HEADER, - OtelAttr, - _get_response_attributes, - ) - - response = Mock() - response.response_id = None - response.finish_reason = None - response.raw_representation = None - response.usage_details = None - response.model = "gpt-4" - response.additional_properties = {AZURE_OPENAI_SERVED_MODEL_HEADER: "gpt-4o-2024-08-06"} - - attrs = {OtelAttr.REQUEST_MODEL: "my-deployment-alias"} - result = _get_response_attributes(attrs, response) - - # REQUEST_MODEL is left untouched; RESPONSE_MODEL is overridden by the served-model header. - assert result[OtelAttr.REQUEST_MODEL] == "my-deployment-alias" - assert result[OtelAttr.RESPONSE_MODEL] == "gpt-4o-2024-08-06" - - -def test_get_response_attributes_no_served_model_keeps_response_model(): - """Without the served-model header RESPONSE_MODEL should reflect the response's reported model.""" - from unittest.mock import Mock - - from agent_framework.observability import OtelAttr, _get_response_attributes - - response = Mock() - response.response_id = None - response.finish_reason = None - response.raw_representation = None - response.usage_details = None - response.model = "gpt-4" - response.additional_properties = {} - - attrs = {OtelAttr.REQUEST_MODEL: "my-deployment-alias"} - result = _get_response_attributes(attrs, response) - - assert result[OtelAttr.REQUEST_MODEL] == "my-deployment-alias" - assert result[OtelAttr.RESPONSE_MODEL] == "gpt-4" - - -def test_get_response_attributes_ignores_non_string_served_model(): - """A non-string / empty value in the served-model header should not override RESPONSE_MODEL.""" - from unittest.mock import Mock - - from agent_framework.observability import ( - AZURE_OPENAI_SERVED_MODEL_HEADER, - OtelAttr, - _get_response_attributes, - ) - - response = Mock() - response.response_id = None - response.finish_reason = None - response.raw_representation = None - response.usage_details = None - response.model = "gpt-4" - response.additional_properties = {AZURE_OPENAI_SERVED_MODEL_HEADER: ""} - - attrs = {OtelAttr.REQUEST_MODEL: "my-deployment-alias"} - result = _get_response_attributes(attrs, response) - - assert result[OtelAttr.REQUEST_MODEL] == "my-deployment-alias" - assert result[OtelAttr.RESPONSE_MODEL] == "gpt-4" - - # region Test _get_exporters_from_env @@ -2553,28 +2481,6 @@ def test_capture_response(span_exporter: InMemorySpanExporter): assert spans[0].attributes.get(OtelAttr.OUTPUT_TOKENS) == 50 -def test_capture_response_does_not_update_span_name_with_request_model(span_exporter: InMemorySpanExporter): - """_capture_response should not rename the span even when REQUEST_MODEL is set.""" - from agent_framework.observability import OtelAttr, _capture_response, get_tracer - - span_exporter.clear() - tracer = get_tracer() - - attrs = { - OtelAttr.OPERATION: "chat", - OtelAttr.REQUEST_MODEL: "my-deployment-alias", - OtelAttr.RESPONSE_MODEL: "gpt-4o-2024-08-06", - } - - with tracer.start_as_current_span("chat my-deployment-alias") as span: - _capture_response(span=span, attributes=attrs) - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == "chat my-deployment-alias" - assert spans[0].attributes.get(OtelAttr.RESPONSE_MODEL) == "gpt-4o-2024-08-06" - - async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: InMemorySpanExporter): """Test that with correct layer ordering, spans appear in the expected sequence. diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 9b6f66c633..c0b58a5b74 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -62,7 +62,7 @@ from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidRequestException, ) -from agent_framework.observability import AZURE_OPENAI_SERVED_MODEL_HEADER, ChatTelemetryLayer +from agent_framework.observability import ChatTelemetryLayer from openai import AsyncAzureOpenAI, AsyncOpenAI, BadRequestError from openai.types.responses import FunctionShellTool from openai.types.responses.file_search_tool_param import FileSearchToolParam @@ -257,38 +257,6 @@ OpenAIChatOptionsT = TypeVar( # region Helpers -# Guarded import of the OpenAI SDK's helper that converts a Pydantic model into the -# Responses API ``text.format`` JSON-schema config. The helper lives under a private -# (underscored) module path, so it may move or change across SDK releases. We import -# it lazily and fall back to a clear error message so a minor SDK refactor surfaces a -# helpful exception instead of an ImportError at module load time. -try: # pragma: no cover - import guard - from openai.lib._parsing._responses import ( - type_to_text_format_param as _openai_type_to_text_format_param, - ) -except ImportError: # pragma: no cover - exercised only when SDK refactors - _openai_type_to_text_format_param = None # type: ignore[assignment] - - -def _pydantic_model_to_text_format_param(text_format: type[BaseModel]) -> dict[str, Any]: - """Build a Responses API ``text.format`` JSON-schema config for a Pydantic model. - - Prefers the OpenAI SDK's helper (which produces a fully strict schema) when - available. If the helper has moved/been removed by an SDK refactor, raises a - ``ChatClientInvalidRequestException`` with a clear remediation hint rather than - failing at import time. Callers can work around by passing a pre-built - ``text.format`` config via ``OpenAIChatOptions["text"]`` instead of ``text_format``. - """ - if _openai_type_to_text_format_param is not None: - return cast(dict[str, Any], _openai_type_to_text_format_param(text_format)) - raise ChatClientInvalidRequestException( - "Unable to translate `text_format` into the Responses API `text.format` config: the " - "OpenAI SDK helper `openai.lib._parsing._responses.type_to_text_format_param` is not " - "available (likely due to an SDK version change). Pass a pre-built `text.format` " - "JSON-schema config via the `text` option instead, or pin a compatible `openai` version." - ) - - def _annotations_to_output_text(annotations: Sequence[Annotation] | None) -> list[dict[str, Any]]: """Convert framework `Annotation` objects to Responses API `output_text` annotation dicts. @@ -391,10 +359,13 @@ class RawOpenAIChatClient( # type: ignore[misc] STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc] SUPPORTS_RICH_FUNCTION_OUTPUT: ClassVar[bool] = True - # Azure OpenAI may include this header in responses for the actual model that served the request, - # instead of the deployment name sent in the request. Surface it on the ChatResponse when present - # for better observability. - SERVED_MODEL_HEADER: ClassVar[str] = AZURE_OPENAI_SERVED_MODEL_HEADER + # Azure OpenAI Responses API may include this header in responses naming the actual model that + # served the request (e.g. ``gpt-5-nano-2025-08-07``), which can differ from the deployment alias + # that the request was addressed to and that ``response.model`` reports. When present, we use it + # as the value of ``ChatResponse.model`` / ``ChatResponseUpdate.model`` so telemetry and callers + # see the actually served model. (Chat Completions API already returns the snapshot in + # ``response.model``, so this header only matters for the Responses API.) + SERVED_MODEL_HEADER: ClassVar[str] = "x-ms-served-model" FILE_SEARCH_MAX_RESULTS: int = 50 @@ -656,19 +627,17 @@ class RawOpenAIChatClient( # type: ignore[misc] stream=True, ) served_model = self._extract_served_model(raw_stream_response.headers) - stream_response = raw_stream_response.parse() - async for chunk in stream_response: - update = self._parse_chunk_from_openai( - chunk, - options=validated_options, - function_call_ids=function_call_ids, - seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, - ) - if served_model is not None: - if update.additional_properties is None: - update.additional_properties = {} - update.additional_properties[self.SERVED_MODEL_HEADER] = served_model - yield update + async with raw_stream_response.parse() as stream_response: + async for chunk in stream_response: + update = self._parse_chunk_from_openai( + chunk, + options=validated_options, + function_call_ids=function_call_ids, + seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, + ) + if served_model is not None: + update.model = served_model + yield update except Exception as ex: self._handle_request_error(ex) else: @@ -677,35 +646,38 @@ class RawOpenAIChatClient( # type: ignore[misc] run_options, validated_options, ) = await self._prepare_request(messages, options) - # Translate `text_format` (Pydantic model) into `text.format` JSON-schema - # config so we can always go through `responses.create(stream=True, ...)` — - # the only path that exposes raw HTTP headers via `with_raw_response`. - # The SDK's `responses.stream(...)` helper does the same translation - # internally but does not surface headers, so we replicate it here. - text_format = run_options.pop("text_format", None) - if text_format is not None: - text_cfg = dict(run_options.get("text") or {}) - if "format" in text_cfg: - raise ChatClientInvalidRequestException("Cannot mix and match text.format with text_format") - text_cfg["format"] = _pydantic_model_to_text_format_param(text_format) - run_options["text"] = text_cfg try: - raw_create_response = await client.responses.with_raw_response.create( - stream=True, **run_options - ) - served_model = self._extract_served_model(raw_create_response.headers) - async for chunk in raw_create_response.parse(): - update = self._parse_chunk_from_openai( - chunk, - options=validated_options, - function_call_ids=function_call_ids, - seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, + if "text_format" in run_options: + # The SDK's ``responses.stream(text_format=...)`` helper preserves + # client-side ``output_parsed`` partial parsing for structured outputs, + # but it does not expose the raw HTTP response (no ``x-ms-served-model`` + # access). We accept that trade-off: this single streaming path keeps + # the deployment alias as the reported model name. All other paths + # surface the served-model header. + async with client.responses.stream(**run_options) as response: + async for chunk in response: + yield self._parse_chunk_from_openai( + chunk, + options=validated_options, + function_call_ids=function_call_ids, + seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, + ) + else: + raw_create_response = await client.responses.with_raw_response.create( + stream=True, **run_options ) - if served_model is not None: - if update.additional_properties is None: - update.additional_properties = {} - update.additional_properties[self.SERVED_MODEL_HEADER] = served_model - yield update + served_model = self._extract_served_model(raw_create_response.headers) + async with raw_create_response.parse() as stream_response: + async for chunk in stream_response: + update = self._parse_chunk_from_openai( + chunk, + options=validated_options, + function_call_ids=function_call_ids, + seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, + ) + if served_model is not None: + update.model = served_model + yield update except Exception as ex: self._handle_request_error(ex) @@ -724,7 +696,7 @@ class RawOpenAIChatClient( # type: ignore[misc] except Exception as ex: self._handle_request_error(ex) chat_response = self._parse_response_from_openai(response, options=validated_options) - self._attach_served_model_header(chat_response, raw_response.headers) + self._apply_served_model_header(chat_response, raw_response.headers) # Once the background response completes, drop the continuation_token from # the caller's options dict. FunctionInvocationLayer reuses the same dict # across tool-loop iterations, so leaving it in place makes the next iteration @@ -744,27 +716,33 @@ class RawOpenAIChatClient( # type: ignore[misc] except Exception as ex: self._handle_request_error(ex) chat_response = self._parse_response_from_openai(response, options=validated_options) - self._attach_served_model_header(chat_response, raw_response.headers) + self._apply_served_model_header(chat_response, raw_response.headers) return chat_response return _get_response() @classmethod - def _attach_served_model_header(cls, chat_response: ChatResponse, headers: Any) -> None: - """Surface the ``x-ms-served-model`` response header on the ChatResponse when present.""" + def _apply_served_model_header(cls, chat_response: ChatResponse, headers: Any) -> None: + """Override ``ChatResponse.model`` with the Azure OpenAI served-model header when present. + + Azure OpenAI Responses API returns the deployment alias in ``response.model`` but the actual + snapshot served via the ``x-ms-served-model`` response header. When present, the served + snapshot is the source of truth for observability and downstream callers. + """ served_model = cls._extract_served_model(headers) - if served_model is None or len(served_model) == 0: - return - chat_response.additional_properties[cls.SERVED_MODEL_HEADER] = served_model + if served_model is not None: + chat_response.model = served_model @classmethod def _extract_served_model(cls, headers: Any) -> str | None: - """Return the ``x-ms-served-model`` response header value when present.""" + """Return the ``x-ms-served-model`` response header value when present and non-empty.""" if headers is None: return None served_model = headers.get(cls.SERVED_MODEL_HEADER) - if isinstance(served_model, str) and len(served_model) > 0: - return served_model + if isinstance(served_model, str): + stripped = served_model.strip() + if stripped: + return stripped return None def _prepare_response_and_text_format( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 7fb6f3139f..abf3aa51e2 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -89,7 +89,9 @@ class _FakeAsyncEventStream: # The chat client now consumes the streaming response via ``with_raw_response``, # which returns a wrapper exposing ``.parse()`` (the underlying iterable) and - # ``.headers``. Mimic that interface so test mocks remain a single object. + # ``.headers``. The chat client then ``async with``-s the parsed stream so the + # underlying socket is closed deterministically. Mimic both interfaces here so + # test mocks remain a single object. def parse(self) -> "_FakeAsyncEventStream": return self @@ -97,8 +99,19 @@ class _FakeAsyncEventStream: def headers(self) -> dict[str, str]: return self._headers + async def __aenter__(self) -> "_FakeAsyncEventStream": + return self -def _as_raw(mock_response: MagicMock) -> MagicMock: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object | None, + ) -> None: + return None + + +def _as_raw(mock_response: MagicMock, *, headers: dict[str, str] | None = None) -> MagicMock: """Make ``mock_response`` look like an OpenAI ``with_raw_response`` wrapper. The chat client now calls ``responses.with_raw_response.{create,parse,retrieve}`` @@ -110,7 +123,7 @@ def _as_raw(mock_response: MagicMock) -> MagicMock: itself lets the existing assertions on ``mock_response.id`` etc. continue to work. """ mock_response.parse = MagicMock(return_value=mock_response) - mock_response.headers = {} + mock_response.headers = headers or {} return mock_response @@ -581,15 +594,16 @@ async def test_response_format_dict_parse_path() -> None: assert response.value["answer"] == "Parsed" -async def test_served_model_header_captured_on_response() -> None: - """The ``x-ms-served-model`` Azure response header should be surfaced on ChatResponse.additional_properties.""" - from agent_framework.observability import AZURE_OPENAI_SERVED_MODEL_HEADER +_SERVED_MODEL_HEADER = "x-ms-served-model" + +async def test_served_model_header_overrides_response_model() -> None: + """The ``x-ms-served-model`` Azure response header should overwrite ChatResponse.model.""" client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.id = "response_123" - mock_response.model = "test-model" + mock_response.model = "test-model" # deployment alias returned in the body mock_response.created_at = 1000000000 mock_response.metadata = {} mock_response.output_parsed = None @@ -599,21 +613,18 @@ async def test_served_model_header_captured_on_response() -> None: mock_response.conversation = None mock_response.status = "completed" - raw = _as_raw(mock_response) - raw.headers = {AZURE_OPENAI_SERVED_MODEL_HEADER: "gpt-4o-2024-08-06"} + raw = _as_raw(mock_response, headers={_SERVED_MODEL_HEADER: "gpt-4o-2024-08-06"}) with patch.object(client.client.responses, "create", return_value=raw): response = await client.get_response( messages=[Message(role="user", contents=["Test message"])], ) - assert response.additional_properties.get(AZURE_OPENAI_SERVED_MODEL_HEADER) == "gpt-4o-2024-08-06" + assert response.model == "gpt-4o-2024-08-06" -async def test_served_model_header_absent_does_not_set_property() -> None: - """When the served-model header is missing the key should not appear on additional_properties.""" - from agent_framework.observability import AZURE_OPENAI_SERVED_MODEL_HEADER - +async def test_served_model_header_absent_keeps_response_model() -> None: + """When the served-model header is missing ChatResponse.model should come from the response body.""" client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() @@ -634,13 +645,37 @@ async def test_served_model_header_absent_does_not_set_property() -> None: messages=[Message(role="user", contents=["Test message"])], ) - assert AZURE_OPENAI_SERVED_MODEL_HEADER not in response.additional_properties + assert response.model == "test-model" + + +async def test_served_model_header_empty_string_does_not_override() -> None: + """Empty/whitespace header values should not overwrite the response body's model name.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.id = "response_123" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.metadata = {} + mock_response.output_parsed = None + mock_response.output = [] + mock_response.usage = None + mock_response.finish_reason = None + mock_response.conversation = None + mock_response.status = "completed" + + raw = _as_raw(mock_response, headers={_SERVED_MODEL_HEADER: " "}) + + with patch.object(client.client.responses, "create", return_value=raw): + response = await client.get_response( + messages=[Message(role="user", contents=["Test message"])], + ) + + assert response.model == "test-model" async def test_served_model_header_captured_on_parse_path() -> None: """The served-model header should also be captured on the structured-output (parse) path.""" - from agent_framework.observability import AZURE_OPENAI_SERVED_MODEL_HEADER - client = OpenAIChatClient(model="test-model", api_key="test-key") mock_parsed_response = MagicMock() @@ -654,8 +689,7 @@ async def test_served_model_header_captured_on_parse_path() -> None: mock_parsed_response.finish_reason = None mock_parsed_response.conversation = None - raw = _as_raw(mock_parsed_response) - raw.headers = {AZURE_OPENAI_SERVED_MODEL_HEADER: "gpt-4o-2024-08-06"} + raw = _as_raw(mock_parsed_response, headers={_SERVED_MODEL_HEADER: "gpt-4o-2024-08-06"}) with patch.object(client.client.responses, "parse", return_value=raw): response = await client.get_response( @@ -663,13 +697,11 @@ async def test_served_model_header_captured_on_parse_path() -> None: options={"response_format": OutputStruct, "store": True}, ) - assert response.additional_properties.get(AZURE_OPENAI_SERVED_MODEL_HEADER) == "gpt-4o-2024-08-06" + assert response.model == "gpt-4o-2024-08-06" async def test_served_model_header_propagated_to_streaming_updates() -> None: - """In streaming mode the served-model header should be set on every ChatResponseUpdate.""" - from agent_framework.observability import AZURE_OPENAI_SERVED_MODEL_HEADER - + """In streaming mode the served-model header should overwrite update.model on every chunk.""" client = OpenAIChatClient(model="test-model", api_key="test-key") events = [ @@ -693,10 +725,7 @@ async def test_served_model_header_propagated_to_streaming_updates() -> None: ), ] - fake_stream = _FakeAsyncEventStream( - events, - headers={AZURE_OPENAI_SERVED_MODEL_HEADER: "gpt-4o-2024-08-06"}, - ) + fake_stream = _FakeAsyncEventStream(events, headers={_SERVED_MODEL_HEADER: "gpt-4o-2024-08-06"}) with ( patch.object(client, "_prepare_request", new=AsyncMock(return_value=(client.client, {}, {}))), @@ -708,14 +737,41 @@ async def test_served_model_header_propagated_to_streaming_updates() -> None: assert updates, "Expected at least one streaming update" for update in updates: - assert update.additional_properties is not None - assert update.additional_properties.get(AZURE_OPENAI_SERVED_MODEL_HEADER) == "gpt-4o-2024-08-06" + assert update.model == "gpt-4o-2024-08-06" + + +async def test_served_model_header_aggregates_into_final_streaming_response() -> None: + """Aggregating updates via to_chat_response() should preserve the served-model value.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + events = [ + ResponseTextDeltaEvent( + type="response.output_text.delta", + content_index=0, + item_id="text_item", + output_index=0, + sequence_number=1, + logprobs=[], + delta="Hello", + ), + ] + + fake_stream = _FakeAsyncEventStream(events, headers={_SERVED_MODEL_HEADER: "gpt-4o-2024-08-06"}) + + with ( + patch.object(client, "_prepare_request", new=AsyncMock(return_value=(client.client, {}, {}))), + patch.object(client.client.responses, "create", new=AsyncMock(return_value=fake_stream)), + patch.object(client, "_get_metadata_from_response", return_value={}), + ): + stream = client._inner_get_response(messages=[Message(role="user", contents=["Hi"])], options={}, stream=True) + updates = [update async for update in stream] + + final = ChatResponse.from_updates(updates) + assert final.model == "gpt-4o-2024-08-06" async def test_served_model_header_absent_in_streaming_updates() -> None: - """When the header is missing in streaming mode it should not be added to update properties.""" - from agent_framework.observability import AZURE_OPENAI_SERVED_MODEL_HEADER - + """When the header is missing in streaming mode update.model should fall back to the deployment alias.""" client = OpenAIChatClient(model="test-model", api_key="test-key") events = [ @@ -742,8 +798,47 @@ async def test_served_model_header_absent_in_streaming_updates() -> None: assert updates, "Expected at least one streaming update" for update in updates: - if update.additional_properties is not None: - assert AZURE_OPENAI_SERVED_MODEL_HEADER not in update.additional_properties + # Without the header, _parse_chunk_from_openai's default is the client's model name. + assert update.model == "test-model" + + +async def test_served_model_header_not_captured_for_streaming_text_format() -> None: + """The streaming structured-output path uses ``responses.stream(...)`` and therefore cannot + surface the served-model header. Pin this behavior so any future change is intentional.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + events = [ + ResponseTextDeltaEvent( + type="response.output_text.delta", + content_index=0, + item_id="text_item", + output_index=0, + sequence_number=1, + logprobs=[], + delta="Hello", + ), + ] + + # `responses.stream(...)` returns an async context manager. The headers attribute + # is irrelevant because this code path never asks for it. + fake_stream_ctx = _FakeAsyncEventStreamContext(events) + + with ( + patch.object( + client, + "_prepare_request", + new=AsyncMock(return_value=(client.client, {"text_format": OutputStruct}, {})), + ), + patch.object(client.client.responses, "stream", return_value=fake_stream_ctx), + patch.object(client, "_get_metadata_from_response", return_value={}), + ): + stream = client._inner_get_response(messages=[Message(role="user", contents=["Hi"])], options={}, stream=True) + updates = [update async for update in stream] + + assert updates, "Expected at least one streaming update" + for update in updates: + # No header override; model stays the deployment alias. + assert update.model == "test-model" async def test_bad_request_error_non_content_filter() -> None: @@ -3435,7 +3530,7 @@ async def test_inner_get_response_streaming_with_response_format_tracks_reasonin "_prepare_request", new=AsyncMock(return_value=(client.client, {"text_format": OutputStruct}, {})), ), - patch.object(client.client.responses, "create", new=AsyncMock(return_value=_FakeAsyncEventStream(events))), + patch.object(client.client.responses, "stream", return_value=_FakeAsyncEventStreamContext(events)), patch.object(client, "_get_metadata_from_response", return_value={}), ): stream = client._inner_get_response(messages=messages, options={}, stream=True)