mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user