From 7f4ee39c9a8a43dfb1487b76d92f7f4c6aca102a Mon Sep 17 00:00:00 2001 From: Shivakishore14 Date: Thu, 30 Jul 2026 09:59:03 +0000 Subject: [PATCH 1/3] Restore response model ergonomics Keep request payloads dict-native while exposing response protocol models as dict-backed objects with attribute access and dictionary conversion helpers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 21 ++--- .../agentserver/responses/models/__init__.py | 21 +++++ .../responses/models/_object_model.py | 85 +++++++++++++++++++ .../ai/agentserver/responses/models/_wire.py | 6 ++ .../unit/test_object_model_ergonomics.py | 64 ++++++++++++++ 5 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/_object_model.py create mode 100644 sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_object_model_ergonomics.py diff --git a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md index d80b49616451..f7c56142a37d 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md @@ -11,8 +11,8 @@ ### Breaking Changes - Removed a-prefixed async convenience generator methods from the sync `ResponseEventStream` and sync builder classes. Use `azure.ai.agentserver.responses.aio.ResponseEventStream` for async streaming convenience methods. -- Replaced generated model classes in `azure.ai.agentserver.responses.models` with dict-native `TypedDict` contracts. Model constructors such as `ItemMessage(...)` and `CreateResponse(...)` now produce plain dictionaries instead of generated model instances. -- Removed runtime model-class behavior from response protocol models. Code should no longer rely on attribute access, `isinstance(..., ModelType)`, `.as_dict()`, or generated model base-class behavior. +- Request payloads now use dict-native `TypedDict` contracts, while response protocol models keep object-style construction, attribute access, and `to_dict()` / `as_dict()` conversion helpers. +- Response protocol model internals are backed by JSON-compatible dictionaries so they can be serialized, streamed, and persisted without conversion. - Replaced most generated enum classes with string literal type aliases. Use string values directly for protocol fields, for example `"completed"`, `"message"`, or `"function_call_output"`. ### Migration Guide @@ -41,7 +41,7 @@ async for event in stream.output_item_message(token_stream()): Builder async helpers follow the same pattern: use builders from `azure.ai.agentserver.responses.aio.streaming` and drop the `a` prefix. For example, `atext_content(...)` becomes `text_content(...)`, `aarguments(...)` becomes `arguments(...)`, and `asummary_part(...)` becomes `summary_part(...)`. -Protocol models are now dict-native. Construction still works, but the result is a dictionary: +Request payloads such as `CreateResponse` are now dict-native. Response protocol models keep object-style access while remaining wire-serializable: ```python from azure.ai.agentserver.responses.models import ItemMessage, MessageContentInputTextContent @@ -52,19 +52,14 @@ message = ItemMessage( ) ``` -Before: +Response model access continues to use attributes: ```python -if isinstance(item, ItemMessage): +if item.type == "message": text = item.content[0].text ``` -After: - -```python -if item.get("type") == "message": - text = item.get("content", [{}])[0].get("text") -``` +Use `item.to_dict()` or `item.as_dict()` when a plain JSON-compatible dictionary is needed. Before: @@ -80,8 +75,8 @@ status = "completed" ### Other Changes -- Updated response hosting, persistence, streaming, validation, samples, and tests to operate on JSON-compatible wire dictionaries. -- Updated model generation tooling to use TypeSpec Python `models-mode=typeddict` and removed generated model shim files. +- Updated response hosting, persistence, streaming, validation, samples, and tests to operate on JSON-compatible wire dictionaries internally. +- Updated model generation tooling to use TypeSpec Python `models-mode=typeddict` for request payloads and added response object-model compatibility wrappers for public response payloads. ## 1.0.0b9 (2026-07-22) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/__init__.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/__init__.py index 6ec108549734..3af54bed99e0 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/__init__.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/__init__.py @@ -17,6 +17,7 @@ get_input_expanded, get_tool_choice_expanded, ) +from ._object_model import ResponseModel, create_response_model_type from .runtime import ( # pylint: disable=unused-import ResponseStatus, TerminalResponseStatus, @@ -53,6 +54,25 @@ def _is_public_generated_export(value: object) -> bool: ] +def _is_request_payload_name(name: str) -> bool: + return name == "CreateResponse" or "Param" in name or name.endswith("Request") + + +def _install_response_object_models() -> list[str]: + model_names: list[str] = [] + for name in _generated_all: + if _is_request_payload_name(name): + continue + value = globals().get(name) + if isinstance(value, type): + globals()[name] = create_response_model_type(name, __name__) + model_names.append(name) + return model_names + + +_object_model_names = _install_response_object_models() + + class ResponseIncompleteReason(str, Enum, metaclass=CaseInsensitiveEnumMeta): """Reason a response finished as incomplete.""" @@ -63,6 +83,7 @@ class ResponseIncompleteReason(str, Enum, metaclass=CaseInsensitiveEnumMeta): __all__ = [ + "ResponseModel", "ResponseIncompleteReason", "ResponseStatus", "TerminalResponseStatus", diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/_object_model.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/_object_model.py new file mode 100644 index 000000000000..841ae1b6ce94 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/_object_model.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Object-style compatibility models for response payloads.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Mapping + + +class ResponseModel(dict[str, Any]): + """Dictionary-backed response model with attribute access. + + The Responses server stores and streams JSON-compatible dictionaries + internally. This wrapper preserves that wire-native representation while + restoring the object-style access pattern used by client SDK response + models. + """ + + def __init__(self, mapping: Mapping[str, Any] | None = None, **kwargs: Any) -> None: + values: dict[str, Any] = {} + if mapping is not None: + values.update(mapping) + values.update(kwargs) + super().__init__((key, _wrap_value(value)) for key, value in values.items()) + + def __getattr__(self, name: str) -> Any: + try: + return self[name] + except KeyError as exc: + raise AttributeError(name) from exc + + def __setattr__(self, name: str, value: Any) -> None: + self[name] = _wrap_value(value) + + def __delattr__(self, name: str) -> None: + try: + del self[name] + except KeyError as exc: + raise AttributeError(name) from exc + + def copy(self) -> "ResponseModel": # type: ignore[override] + """Return a shallow object-model copy.""" + return type(self)(self) + + def as_dict(self) -> dict[str, Any]: + """Return a plain dictionary representation of the model.""" + return _unwrap_value(self) + + def to_dict(self) -> dict[str, Any]: + """Return a plain dictionary representation of the model.""" + return self.as_dict() + + +def _wrap_value(value: Any) -> Any: + if isinstance(value, ResponseModel): + return value + if isinstance(value, Mapping): + return ResponseModel(value) + if isinstance(value, list): + return [_wrap_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_wrap_value(item) for item in value) + return value + + +def _unwrap_value(value: Any) -> Any: + if isinstance(value, ResponseModel): + return {key: _unwrap_value(item) for key, item in value.items()} + if isinstance(value, Mapping): + return {str(key): _unwrap_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_unwrap_value(item) for item in value] + if isinstance(value, tuple): + return [_unwrap_value(item) for item in value] + return deepcopy(value) + + +def create_response_model_type(name: str, module_name: str) -> type[ResponseModel]: + """Create a named response model class backed by :class:`ResponseModel`.""" + + return type(name, (ResponseModel,), {"__module__": module_name}) + + +__all__ = ["ResponseModel", "create_response_model_type"] diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/_wire.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/_wire.py index a82a92370a4d..2f4e529dc554 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/_wire.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/_wire.py @@ -23,6 +23,8 @@ def get_field(payload: Any, field: str, default: Any = None) -> Any: """ if isinstance(payload, Mapping): return payload.get(field, default) + if hasattr(payload, field): + return getattr(payload, field) return default @@ -52,6 +54,10 @@ def to_wire_dict(value: Any) -> Any: return value if isinstance(value, datetime): return int(value.timestamp()) + if hasattr(value, "as_dict") and callable(value.as_dict): + return to_wire_dict(value.as_dict()) + if hasattr(value, "to_dict") and callable(value.to_dict): + return to_wire_dict(value.to_dict()) if isinstance(value, Mapping): return {str(k): to_wire_dict(v) for k, v in value.items()} if isinstance(value, (list, tuple)): diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_object_model_ergonomics.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_object_model_ergonomics.py new file mode 100644 index 000000000000..d1b968df7a63 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_object_model_ergonomics.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Object-model ergonomics for response payloads.""" + +from __future__ import annotations + +import json + +from azure.ai.agentserver.responses.models import ( + CreateResponse, + ItemMessage, + MessageContentInputTextContent, + ResponseModel, + ResponseObject, +) +from azure.ai.agentserver.responses.models._wire import to_wire_dict + + +def test_create_response_remains_dict_native_request_payload() -> None: + request = CreateResponse(model="test-model", input="hello") + + assert type(request) is dict + assert request["model"] == "test-model" + + +def test_response_models_support_attribute_access_and_to_dict() -> None: + response = ResponseObject( + id="resp_123", + status="completed", + output=[ + ItemMessage( + type="message", + role="assistant", + content=[MessageContentInputTextContent(type="input_text", text="hello")], + ) + ], + ) + + assert isinstance(response, ResponseModel) + assert response.output[0].content[0].text == "hello" + assert response.to_dict() == { + "id": "resp_123", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "input_text", "text": "hello"}], + } + ], + } + + +def test_response_models_remain_wire_serializable() -> None: + message = ItemMessage( + type="message", + role="user", + content=[MessageContentInputTextContent(type="input_text", text="hi")], + ) + + wire = to_wire_dict(message) + + assert wire == {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]} + assert json.dumps(wire) From ffbec9c01acaea86da05dd265ec0782228e38122 Mon Sep 17 00:00:00 2001 From: Shivakishore14 Date: Thu, 30 Jul 2026 10:23:57 +0000 Subject: [PATCH 2/3] Document request payload migration Clarify that CreateResponse construction now returns a dict-native request payload while response models retain object-style access. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-ai-agentserver-responses/CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md index f7c56142a37d..3219387db117 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md @@ -41,7 +41,21 @@ async for event in stream.output_item_message(token_stream()): Builder async helpers follow the same pattern: use builders from `azure.ai.agentserver.responses.aio.streaming` and drop the `a` prefix. For example, `atext_content(...)` becomes `text_content(...)`, `aarguments(...)` becomes `arguments(...)`, and `asummary_part(...)` becomes `summary_part(...)`. -Request payloads such as `CreateResponse` are now dict-native. Response protocol models keep object-style access while remaining wire-serializable: +Request payloads such as `CreateResponse` are now dict-native. Previously, request model construction returned generated model objects with attribute access: + +```python +request = CreateResponse(model="test-model", input="hello") +model = request.model +``` + +Now, request construction returns a plain dictionary: + +```python +request = CreateResponse(model="test-model", input="hello") +model = request["model"] +``` + +Response protocol models keep object-style access while remaining wire-serializable: ```python from azure.ai.agentserver.responses.models import ItemMessage, MessageContentInputTextContent From 9ba0bcc23c7f7651651090d1d088fde45de36a52 Mon Sep 17 00:00:00 2001 From: Shivakishore14 Date: Thu, 30 Jul 2026 10:49:34 +0000 Subject: [PATCH 3/3] Test response model reuse as request input Cover multi-turn scenarios where response model output is passed into a later CreateResponse payload and normalized back to wire dictionaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../unit/test_object_model_ergonomics.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_object_model_ergonomics.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_object_model_ergonomics.py index d1b968df7a63..bf2c8ce057cb 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_object_model_ergonomics.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_object_model_ergonomics.py @@ -62,3 +62,31 @@ def test_response_models_remain_wire_serializable() -> None: assert wire == {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]} assert json.dumps(wire) + + +def test_response_models_can_be_reused_as_later_request_input() -> None: + response = ResponseObject( + id="resp_123", + status="completed", + output=[ + ItemMessage( + type="message", + role="assistant", + content=[MessageContentInputTextContent(type="input_text", text="previous answer")], + ) + ], + ) + request = CreateResponse(model="test-model", input=response.output) + + wire = to_wire_dict(request) + + assert wire == { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "input_text", "text": "previous answer"}], + } + ], + }