chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,51 @@
|
||||
# agent-framework-hosting-responses
|
||||
|
||||
OpenAI Responses-shaped helpers for app-owned Agent Framework hosting.
|
||||
|
||||
This package provides the Responses-specific conversion layer:
|
||||
|
||||
- `responses_to_run(...)` — convert a Responses request body into Agent
|
||||
Framework run values.
|
||||
- `responses_session_id(...)` — extract a prior `resp_*` response id or
|
||||
`conv_*` conversation id from the request body when present.
|
||||
- `create_response_id(...)` — mint a Responses-shaped response id.
|
||||
- `responses_from_run(...)` — convert an `AgentResponse` into a
|
||||
Responses-compatible JSON payload.
|
||||
- `responses_from_streaming_run(...)` — convert an Agent Framework
|
||||
`ResponseStream` into Responses-compatible SSE events.
|
||||
|
||||
FastAPI/Starlette/Django/Azure Functions code owns route registration,
|
||||
authentication, status codes, response construction, and background work.
|
||||
|
||||
```python
|
||||
from agent_framework_hosting import AgentState
|
||||
from agent_framework_hosting_responses import (
|
||||
create_response_id,
|
||||
responses_from_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
from fastapi import Body, FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
app = FastAPI()
|
||||
state = AgentState(agent)
|
||||
|
||||
|
||||
@app.post("/responses")
|
||||
async def responses(body: dict = Body(...)) -> JSONResponse:
|
||||
run = responses_to_run(body)
|
||||
session_id = responses_session_id(body)
|
||||
response_id = create_response_id()
|
||||
session = await state.get_or_create_session(session_id or response_id)
|
||||
result = await (await state.get_target()).run(
|
||||
run["messages"],
|
||||
session=session,
|
||||
options=run["options"],
|
||||
)
|
||||
await state.set_session(response_id, session)
|
||||
return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id))
|
||||
```
|
||||
|
||||
The base execution-state helpers live in
|
||||
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI Responses-shaped helpers for app-owned Agent Framework hosting."""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._parsing import (
|
||||
create_response_id,
|
||||
messages_from_responses_input,
|
||||
responses_from_run,
|
||||
responses_from_streaming_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"create_response_id",
|
||||
"messages_from_responses_input",
|
||||
"responses_from_run",
|
||||
"responses_from_streaming_run",
|
||||
"responses_session_id",
|
||||
"responses_to_run",
|
||||
]
|
||||
@@ -0,0 +1,938 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Parsing helpers for the OpenAI Responses-API request body.
|
||||
|
||||
The Responses API accepts ``input`` as either a string or a list of "input
|
||||
items". An item is either a content part (``input_text`` / ``input_image``
|
||||
/ ``input_file``) or a message envelope ``{type: "message", role,
|
||||
content: [...]}``. We translate that into an Agent Framework ``Message``
|
||||
list and remap the generation-control fields the API also carries into
|
||||
``ChatOptions``-shaped keys. App-owned route code decides which options to
|
||||
pass through to ``agent.run(...)`` and which request-owned fields to drop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, ChatOptions, Content, Message, ResponseStream
|
||||
from agent_framework_hosting import AgentRunArgs
|
||||
from openai.types.responses import (
|
||||
Response as OpenAIResponse,
|
||||
)
|
||||
from openai.types.responses import (
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputFile,
|
||||
ResponseInputImage,
|
||||
ResponseInputText,
|
||||
ResponseOutputItem,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
)
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
_RESPONSE_OUTPUT_ITEM_ADAPTER: TypeAdapter[Any] = TypeAdapter(ResponseOutputItem)
|
||||
|
||||
# OpenAI Responses field name → Agent Framework ChatOptions field name.
|
||||
_RESPONSES_OPTION_REMAP = {
|
||||
"max_output_tokens": "max_tokens",
|
||||
"parallel_tool_calls": "allow_multiple_tool_calls",
|
||||
}
|
||||
# Fields the Responses transport owns; they are consumed separately and must
|
||||
# not also appear in options.
|
||||
_RESPONSES_RUN_TRANSPORT_KEYS = frozenset({"input", "stream", "previous_response_id", "conversation_id"})
|
||||
|
||||
|
||||
def _content_from_input_item(item: Mapping[str, Any]) -> Content:
|
||||
"""Convert a single OpenAI Responses ``input`` item into a :class:`Content` part.
|
||||
|
||||
Handles the ``input_text``/``output_text``/``text`` text variants,
|
||||
``input_image`` URL references, and ``input_file`` references via either
|
||||
a public URL or a hosted ``file_id``. Raises ``ValueError`` for any
|
||||
unsupported item type so the surrounding parser can return a 422.
|
||||
"""
|
||||
item_type = item.get("type")
|
||||
if item_type in ("input_text", "output_text", "text"):
|
||||
return Content.from_text(text=str(item.get("text", "")))
|
||||
if item_type == "input_image":
|
||||
image_url: Any = item.get("image_url")
|
||||
if isinstance(image_url, Mapping):
|
||||
image_url = cast("Mapping[str, Any]", image_url).get("url")
|
||||
if not isinstance(image_url, str):
|
||||
raise ValueError("input_image requires `image_url`")
|
||||
return Content.from_uri(uri=image_url, media_type="image/*")
|
||||
if item_type == "input_file":
|
||||
if (uri := item.get("file_url")) and isinstance(uri, str):
|
||||
return Content.from_uri(uri=uri, media_type=item.get("mime_type"))
|
||||
if file_id := item.get("file_id"):
|
||||
return Content(type="hosted_file", file_id=str(file_id))
|
||||
raise ValueError("input_file requires `file_url` or `file_id`")
|
||||
raise ValueError(f"Unsupported Responses input content type: {item_type!r}")
|
||||
|
||||
|
||||
def messages_from_responses_input(value: Any) -> list[Message]:
|
||||
"""Translate ``input`` (string or list of items) into :class:`Message` objects."""
|
||||
if isinstance(value, str):
|
||||
return [Message("user", [Content.from_text(text=value)])]
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("`input` must be a non-empty string or list")
|
||||
|
||||
messages: list[Message] = []
|
||||
pending_user_parts: list[Content] = []
|
||||
|
||||
def flush() -> None:
|
||||
"""Emit any buffered loose user content as a single user message."""
|
||||
if pending_user_parts:
|
||||
messages.append(Message("user", list(pending_user_parts)))
|
||||
pending_user_parts.clear()
|
||||
|
||||
for item in cast("list[Any]", value):
|
||||
if not isinstance(item, Mapping):
|
||||
raise ValueError("each `input` item must be an object")
|
||||
item_map = cast("Mapping[str, Any]", item)
|
||||
if item_map.get("type") == "message":
|
||||
flush()
|
||||
role = str(item_map.get("role") or "user")
|
||||
content: Any = item_map.get("content") or []
|
||||
parts: list[Content]
|
||||
if isinstance(content, str):
|
||||
parts = [Content.from_text(text=content)]
|
||||
elif isinstance(content, list):
|
||||
parts = []
|
||||
for content_item in cast("list[Any]", content):
|
||||
if not isinstance(content_item, Mapping):
|
||||
raise ValueError("each message `content` item must be an object")
|
||||
parts.append(_content_from_input_item(cast("Mapping[str, Any]", content_item)))
|
||||
else:
|
||||
raise ValueError("message `content` must be a string or list")
|
||||
messages.append(Message(role, parts))
|
||||
else:
|
||||
pending_user_parts.append(_content_from_input_item(item_map))
|
||||
|
||||
flush()
|
||||
if not messages:
|
||||
raise ValueError("`input` produced no messages")
|
||||
return messages
|
||||
|
||||
|
||||
def create_response_id() -> str:
|
||||
"""Create a Responses-shaped response id."""
|
||||
return f"resp_{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def responses_session_id(body: Mapping[str, Any]) -> str | None:
|
||||
"""Return the Responses session id from request body, if present.
|
||||
|
||||
The returned value can be a ``resp_*`` previous response id or a ``conv_*``
|
||||
conversation id. Callers choose whether this request-derived value is
|
||||
trusted for their route and deployment.
|
||||
|
||||
Args:
|
||||
body: OpenAI Responses-shaped request body.
|
||||
|
||||
Returns:
|
||||
Previous response id, conversation id, or ``None``.
|
||||
"""
|
||||
previous_response_id = body.get("previous_response_id")
|
||||
if isinstance(previous_response_id, str) and previous_response_id:
|
||||
return previous_response_id
|
||||
conversation_id = body.get("conversation_id")
|
||||
if isinstance(conversation_id, str) and conversation_id:
|
||||
return conversation_id
|
||||
return None
|
||||
|
||||
|
||||
def responses_to_run(body: Mapping[str, Any]) -> AgentRunArgs:
|
||||
"""Convert a Responses request body into Agent Framework run values.
|
||||
|
||||
Args:
|
||||
body: OpenAI Responses-shaped request body.
|
||||
|
||||
Returns:
|
||||
Arguments corresponding to ``Agent.run``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the request body has invalid ``input``.
|
||||
"""
|
||||
messages = messages_from_responses_input(body.get("input"))
|
||||
options: dict[str, Any] = {}
|
||||
for key, value in body.items():
|
||||
if key in _RESPONSES_RUN_TRANSPORT_KEYS or value is None:
|
||||
continue
|
||||
options[_RESPONSES_OPTION_REMAP.get(key, key)] = value
|
||||
return AgentRunArgs(
|
||||
messages=messages,
|
||||
options=cast("ChatOptions[Any]", options),
|
||||
stream=bool(body.get("stream", False)),
|
||||
)
|
||||
|
||||
|
||||
def responses_from_run(
|
||||
result: AgentResponse[Any],
|
||||
*,
|
||||
response_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert an Agent Framework response into a Responses payload.
|
||||
|
||||
Args:
|
||||
result: Agent response returned by a run.
|
||||
|
||||
Keyword Args:
|
||||
response_id: Id for the response being created.
|
||||
session_id: Optional prior ``resp_*`` or ``conv_*`` session id. When it
|
||||
is a conversation id, the helper renders it in the Responses
|
||||
conversation field.
|
||||
|
||||
Returns:
|
||||
Responses-compatible JSON payload.
|
||||
"""
|
||||
output_items = _result_to_output_items(result, status="completed")
|
||||
response_kwargs: dict[str, Any] = {
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"created_at": int(time.time()),
|
||||
"status": "completed",
|
||||
"model": _model_from_result(result),
|
||||
"output": output_items,
|
||||
"parallel_tool_calls": False,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"metadata": {},
|
||||
}
|
||||
if session_id is not None and session_id.startswith("conv_"):
|
||||
response_kwargs["conversation"] = {"id": session_id}
|
||||
return _response_payload(OpenAIResponse(**response_kwargs))
|
||||
|
||||
|
||||
def _model_from_update(update: AgentResponseUpdate) -> str | None:
|
||||
"""Best-effort model id from one streamed update's raw representation.
|
||||
|
||||
``AgentResponse.from_updates`` does not carry a chunk's raw representation
|
||||
forward onto the finalized response (see ``_finalize_response`` in core),
|
||||
so ``_model_from_result`` can never find a model for a streamed result.
|
||||
Each ``AgentResponseUpdate`` still has its own raw chat chunk, which
|
||||
usually reports the model, so the streaming SSE helper captures it here
|
||||
instead.
|
||||
"""
|
||||
raw = update.raw_representation
|
||||
model = getattr(raw, "model", None)
|
||||
return model if isinstance(model, str) and model else None
|
||||
|
||||
|
||||
def _model_from_result(result: Any) -> str:
|
||||
model = getattr(result, "model", None)
|
||||
if isinstance(model, str) and model:
|
||||
return model
|
||||
raw = getattr(result, "raw_representation", None)
|
||||
raw_model = getattr(raw, "model", None)
|
||||
if isinstance(raw_model, str) and raw_model:
|
||||
return raw_model
|
||||
additional_properties = getattr(result, "additional_properties", None)
|
||||
if isinstance(additional_properties, Mapping):
|
||||
additional_model = cast(Mapping[str, Any], additional_properties).get("model")
|
||||
if isinstance(additional_model, str) and additional_model:
|
||||
return additional_model
|
||||
return "agent"
|
||||
|
||||
|
||||
def _result_to_output_items(result: Any, *, status: str) -> list[ResponseOutputItem]:
|
||||
"""Render an agent or workflow result as Responses output items."""
|
||||
messages = getattr(result, "messages", None)
|
||||
if isinstance(messages, Sequence) and not isinstance(messages, (str, bytes, bytearray)):
|
||||
return _messages_to_output_items(cast("Sequence[Any]", messages), status=status)
|
||||
|
||||
if isinstance(result, Message):
|
||||
return _messages_to_output_items([result], status=status)
|
||||
if isinstance(result, Content):
|
||||
return _contents_to_output_items([result], status=status)
|
||||
|
||||
get_outputs = getattr(result, "get_outputs", None)
|
||||
if callable(get_outputs):
|
||||
output_items: list[ResponseOutputItem] = []
|
||||
for output in cast("Sequence[Any]", get_outputs()):
|
||||
output_items.extend(_output_to_output_items(output, status=status))
|
||||
return output_items
|
||||
|
||||
text = getattr(result, "text", None)
|
||||
if isinstance(text, str):
|
||||
return _text_output_items(text, status=status)
|
||||
return _text_output_items(_result_to_text(result), status=status)
|
||||
|
||||
|
||||
def _output_to_output_items(output: Any, *, status: str) -> list[ResponseOutputItem]:
|
||||
if isinstance(output, Message):
|
||||
return _messages_to_output_items([output], status=status)
|
||||
if isinstance(output, Content):
|
||||
return _contents_to_output_items([output], status=status)
|
||||
messages = getattr(output, "messages", None)
|
||||
if isinstance(messages, Sequence) and not isinstance(messages, (str, bytes, bytearray)):
|
||||
return _messages_to_output_items(cast("Sequence[Any]", messages), status=status)
|
||||
text = getattr(output, "text", None)
|
||||
if isinstance(text, str):
|
||||
return _text_output_items(text, status=status)
|
||||
return _text_output_items(str(output), status=status)
|
||||
|
||||
|
||||
def _messages_to_output_items(messages: Sequence[Any], *, status: str) -> list[ResponseOutputItem]:
|
||||
output_items: list[ResponseOutputItem] = []
|
||||
message_contents: list[Content] = []
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, Message):
|
||||
if message_contents:
|
||||
output_items.extend(_contents_to_output_items(message_contents, status=status))
|
||||
message_contents.clear()
|
||||
output_items.extend(_output_to_output_items(message, status=status))
|
||||
continue
|
||||
message_contents.extend(message.contents)
|
||||
|
||||
if message_contents:
|
||||
output_items.extend(_contents_to_output_items(message_contents, status=status))
|
||||
|
||||
return output_items
|
||||
|
||||
|
||||
def _contents_to_output_items(
|
||||
contents: Sequence[Content],
|
||||
*,
|
||||
status: str,
|
||||
seen_raw_items: dict[tuple[str, str], int] | None = None,
|
||||
) -> list[ResponseOutputItem]:
|
||||
output_items: list[ResponseOutputItem] = []
|
||||
message_content: list[Any] = []
|
||||
seen: dict[tuple[str, str], int] = seen_raw_items if seen_raw_items is not None else {}
|
||||
|
||||
def flush_message() -> None:
|
||||
if not message_content:
|
||||
return
|
||||
output_items.append(_message_output_item(message_content, status=status))
|
||||
message_content.clear()
|
||||
|
||||
content_list = list(contents)
|
||||
index = 0
|
||||
while index < len(content_list):
|
||||
content = content_list[index]
|
||||
raw_item = _raw_response_output_item(content.raw_representation)
|
||||
if raw_item is not None:
|
||||
raw_key = _response_output_item_key(raw_item)
|
||||
if raw_key in seen:
|
||||
output_items[seen[raw_key]] = raw_item
|
||||
else:
|
||||
flush_message()
|
||||
seen[raw_key] = len(output_items)
|
||||
output_items.append(raw_item)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
next_content = content_list[index + 1] if index + 1 < len(content_list) else None
|
||||
if _is_matching_code_interpreter_result(content, next_content):
|
||||
flush_message()
|
||||
output_items.append(_code_interpreter_output_item(content, status=status, result_content=next_content))
|
||||
index += 2
|
||||
continue
|
||||
if _is_matching_image_generation_result(content, next_content):
|
||||
flush_message()
|
||||
output_items.append(_image_generation_output_item(content, status=status, result_content=next_content))
|
||||
index += 2
|
||||
continue
|
||||
if _is_matching_mcp_result(content, next_content):
|
||||
flush_message()
|
||||
output_items.append(_mcp_call_output_item(content, status=status, result_content=next_content))
|
||||
index += 2
|
||||
continue
|
||||
|
||||
match content.type:
|
||||
case "text":
|
||||
message_content.append(_message_text_content(content))
|
||||
case "text_reasoning":
|
||||
flush_message()
|
||||
output_items.append(_reasoning_output_item(content, status=status))
|
||||
case "function_call":
|
||||
flush_message()
|
||||
output_items.append(_function_call_output_item(content, status=status))
|
||||
case "function_result":
|
||||
flush_message()
|
||||
output_items.append(_function_result_output_item(content, status=status))
|
||||
case "code_interpreter_tool_call" | "code_interpreter_tool_result":
|
||||
flush_message()
|
||||
output_items.append(_code_interpreter_output_item(content, status=status))
|
||||
case "image_generation_tool_call" | "image_generation_tool_result":
|
||||
flush_message()
|
||||
output_items.append(_image_generation_output_item(content, status=status))
|
||||
case "mcp_server_tool_call":
|
||||
flush_message()
|
||||
output_items.append(_mcp_call_output_item(content, status=status))
|
||||
case "mcp_server_tool_result":
|
||||
flush_message()
|
||||
output_items.append(_mcp_result_output_item(content, status=status))
|
||||
case "shell_tool_call":
|
||||
flush_message()
|
||||
output_items.append(_shell_call_output_item(content, status=status))
|
||||
case "shell_tool_result":
|
||||
flush_message()
|
||||
output_items.append(_shell_result_output_item(content, status=status))
|
||||
case "function_approval_request":
|
||||
flush_message()
|
||||
output_items.append(_function_approval_request_output_item(content))
|
||||
case "function_approval_response":
|
||||
flush_message()
|
||||
output_items.append(_function_approval_response_output_item(content))
|
||||
case "data" | "uri" | "hosted_file":
|
||||
flush_message()
|
||||
output_items.append(_media_content_output_item(content, status=status))
|
||||
case "error":
|
||||
message_content.append(ResponseOutputText(type="output_text", text=str(content), annotations=[]))
|
||||
case _:
|
||||
flush_message()
|
||||
output_items.extend(_text_output_items(json.dumps(content.to_dict(), default=str), status=status))
|
||||
index += 1
|
||||
|
||||
flush_message()
|
||||
return output_items
|
||||
|
||||
|
||||
def _is_matching_code_interpreter_result(content: Content, next_content: Content | None) -> bool:
|
||||
return (
|
||||
content.type == "code_interpreter_tool_call"
|
||||
and next_content is not None
|
||||
and next_content.type == "code_interpreter_tool_result"
|
||||
and content.call_id == next_content.call_id
|
||||
)
|
||||
|
||||
|
||||
def _is_matching_image_generation_result(content: Content, next_content: Content | None) -> bool:
|
||||
return (
|
||||
content.type == "image_generation_tool_call"
|
||||
and next_content is not None
|
||||
and next_content.type == "image_generation_tool_result"
|
||||
and content.image_id == next_content.image_id
|
||||
)
|
||||
|
||||
|
||||
def _is_matching_mcp_result(content: Content, next_content: Content | None) -> bool:
|
||||
return (
|
||||
content.type == "mcp_server_tool_call"
|
||||
and next_content is not None
|
||||
and next_content.type == "mcp_server_tool_result"
|
||||
and content.call_id == next_content.call_id
|
||||
)
|
||||
|
||||
|
||||
def _message_status(status: str) -> str:
|
||||
return status if status in ("in_progress", "completed", "incomplete") else "incomplete"
|
||||
|
||||
|
||||
def _text_output_items(text: str, *, status: str, message_id: str | None = None) -> list[ResponseOutputItem]:
|
||||
return [
|
||||
_message_output_item(
|
||||
[ResponseOutputText(type="output_text", text=text, annotations=[])],
|
||||
status=status,
|
||||
message_id=message_id,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _message_output_item(content: Sequence[Any], *, status: str, message_id: str | None = None) -> ResponseOutputItem:
|
||||
return cast(
|
||||
ResponseOutputItem,
|
||||
ResponseOutputMessage(
|
||||
id=message_id or f"msg_{uuid.uuid4().hex}",
|
||||
type="message",
|
||||
role="assistant",
|
||||
status=_message_status(status), # type: ignore[arg-type]
|
||||
content=list(content),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _message_text_content(content: Content) -> Any:
|
||||
raw_type = _raw_type(content.raw_representation)
|
||||
if raw_type in ("output_text", "refusal"):
|
||||
return content.raw_representation
|
||||
return ResponseOutputText(type="output_text", text=content.text or "", annotations=[])
|
||||
|
||||
|
||||
def _reasoning_output_item(content: Content, *, status: str) -> ResponseOutputItem:
|
||||
item_data: dict[str, Any] = {
|
||||
"id": content.id or f"rs_{uuid.uuid4().hex}",
|
||||
"type": "reasoning",
|
||||
"summary": [],
|
||||
"status": _message_status(status),
|
||||
}
|
||||
if content.text:
|
||||
item_data["content"] = [{"type": "reasoning_text", "text": content.text}]
|
||||
if content.protected_data:
|
||||
item_data["encrypted_content"] = content.protected_data
|
||||
return _response_output_item(item_data)
|
||||
|
||||
|
||||
def _function_call_output_item(content: Content, *, status: str) -> ResponseOutputItem:
|
||||
return cast(
|
||||
ResponseOutputItem,
|
||||
ResponseFunctionToolCall(
|
||||
id=content.additional_properties.get("fc_id") if content.additional_properties else None,
|
||||
type="function_call",
|
||||
call_id=content.call_id or f"call_{uuid.uuid4().hex}",
|
||||
name=content.name or "tool",
|
||||
arguments=_arguments_to_str(content.arguments),
|
||||
status=_message_status(status), # type: ignore[arg-type]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _function_result_output_item(content: Content, *, status: str) -> ResponseOutputItem:
|
||||
if content.exception:
|
||||
output: str | list[Any] = content.exception
|
||||
elif output_parts := _content_parts_to_input_items(content.items):
|
||||
output = output_parts
|
||||
elif isinstance(content.result, str):
|
||||
output = content.result
|
||||
elif content.result is None:
|
||||
output = ""
|
||||
else:
|
||||
output = json.dumps(content.result, default=str)
|
||||
return cast(
|
||||
ResponseOutputItem,
|
||||
ResponseFunctionToolCallOutputItem(
|
||||
id=f"fcout_{uuid.uuid4().hex}",
|
||||
type="function_call_output",
|
||||
call_id=content.call_id or f"call_{uuid.uuid4().hex}",
|
||||
output=output,
|
||||
status=_message_status(status), # type: ignore[arg-type]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _code_interpreter_output_item(
|
||||
content: Content,
|
||||
*,
|
||||
status: str,
|
||||
result_content: Content | None = None,
|
||||
) -> ResponseOutputItem:
|
||||
output_parts: list[dict[str, Any]] = []
|
||||
outputs_value: Any = result_content.outputs if result_content is not None else content.outputs
|
||||
if isinstance(outputs_value, Sequence) and not isinstance(outputs_value, (str, bytes, bytearray)):
|
||||
for item in cast(Sequence[Any], outputs_value):
|
||||
if isinstance(item, Content) and item.type == "text":
|
||||
output_parts.append({"type": "logs", "logs": item.text or ""})
|
||||
elif isinstance(item, Content) and item.type in ("data", "uri") and item.uri:
|
||||
output_parts.append({"type": "image", "url": item.uri})
|
||||
|
||||
return _response_output_item({
|
||||
"id": _content_item_id(content, result_content) or f"ci_{uuid.uuid4().hex}",
|
||||
"type": "code_interpreter_call",
|
||||
"code": _content_sequence_text(content.inputs),
|
||||
"container_id": str(_content_property(content, result_content, "container_id") or "agent_framework"),
|
||||
"outputs": output_parts or None,
|
||||
"status": _code_interpreter_status(status),
|
||||
})
|
||||
|
||||
|
||||
def _image_generation_output_item(
|
||||
content: Content,
|
||||
*,
|
||||
status: str,
|
||||
result_content: Content | None = None,
|
||||
) -> ResponseOutputItem:
|
||||
result_source = result_content.outputs if result_content is not None else content.outputs
|
||||
image_id = content.image_id or (result_content.image_id if result_content is not None else None)
|
||||
return _response_output_item({
|
||||
"id": image_id or f"ig_{uuid.uuid4().hex}",
|
||||
"type": "image_generation_call",
|
||||
"result": _image_generation_result(result_source),
|
||||
"status": _image_generation_status(status),
|
||||
})
|
||||
|
||||
|
||||
def _mcp_call_output_item(
|
||||
content: Content,
|
||||
*,
|
||||
status: str,
|
||||
result_content: Content | None = None,
|
||||
) -> ResponseOutputItem:
|
||||
return _response_output_item({
|
||||
"id": content.call_id or f"mcp_{uuid.uuid4().hex}",
|
||||
"type": "mcp_call",
|
||||
"server_label": content.server_name or "default",
|
||||
"name": content.tool_name or "tool",
|
||||
"arguments": _arguments_to_str(content.arguments),
|
||||
"output": _stringify_output(result_content.output) if result_content is not None else None,
|
||||
"status": _mcp_status(status),
|
||||
})
|
||||
|
||||
|
||||
def _mcp_result_output_item(content: Content, *, status: str) -> ResponseOutputItem:
|
||||
return _response_output_item({
|
||||
"id": content.call_id or f"mcp_{uuid.uuid4().hex}",
|
||||
"type": "mcp_call",
|
||||
"server_label": content.server_name or "default",
|
||||
"name": content.tool_name or "tool",
|
||||
"arguments": "",
|
||||
"output": _stringify_output(content.output),
|
||||
"status": _mcp_status(status),
|
||||
})
|
||||
|
||||
|
||||
def _shell_call_output_item(content: Content, *, status: str) -> ResponseOutputItem:
|
||||
return _response_output_item({
|
||||
"id": content.additional_properties.get("item_id") or f"shell_{uuid.uuid4().hex}",
|
||||
"type": "shell_call",
|
||||
"call_id": content.call_id or f"call_{uuid.uuid4().hex}",
|
||||
"action": {
|
||||
"commands": content.commands or [],
|
||||
"timeout_ms": content.timeout_ms,
|
||||
"max_output_length": content.max_output_length,
|
||||
},
|
||||
"environment": {"type": "local"},
|
||||
"status": _message_status(status),
|
||||
})
|
||||
|
||||
|
||||
def _shell_result_output_item(content: Content, *, status: str) -> ResponseOutputItem:
|
||||
outputs: list[dict[str, Any]] = []
|
||||
outputs_value: Any = content.outputs
|
||||
if isinstance(outputs_value, Sequence) and not isinstance(outputs_value, (str, bytes, bytearray)):
|
||||
for item in cast(Sequence[Any], outputs_value):
|
||||
if not isinstance(item, Content):
|
||||
continue
|
||||
outcome = {"type": "timeout"} if item.timed_out else {"type": "exit", "exit_code": item.exit_code or 0}
|
||||
outputs.append({"stdout": item.stdout or "", "stderr": item.stderr or "", "outcome": outcome})
|
||||
|
||||
return _response_output_item({
|
||||
"id": content.additional_properties.get("item_id") or f"shellout_{uuid.uuid4().hex}",
|
||||
"type": "shell_call_output",
|
||||
"call_id": content.call_id or f"call_{uuid.uuid4().hex}",
|
||||
"output": outputs,
|
||||
"max_output_length": content.max_output_length,
|
||||
"status": _message_status(status),
|
||||
})
|
||||
|
||||
|
||||
def _function_approval_request_output_item(content: Content) -> ResponseOutputItem:
|
||||
function_call = content.function_call
|
||||
return _response_output_item({
|
||||
"id": content.id or f"approval_{uuid.uuid4().hex}",
|
||||
"type": "mcp_approval_request",
|
||||
"server_label": (
|
||||
function_call.additional_properties.get("server_label", "agent_framework")
|
||||
if function_call is not None
|
||||
else "agent_framework"
|
||||
),
|
||||
"name": function_call.name if function_call is not None and function_call.name else "tool",
|
||||
"arguments": _arguments_to_str(function_call.arguments if function_call is not None else None),
|
||||
})
|
||||
|
||||
|
||||
def _function_approval_response_output_item(content: Content) -> ResponseOutputItem:
|
||||
return _response_output_item({
|
||||
"id": content.id or f"approval_{uuid.uuid4().hex}",
|
||||
"type": "mcp_approval_response",
|
||||
"approval_request_id": content.id or "",
|
||||
"approve": bool(content.approved),
|
||||
})
|
||||
|
||||
|
||||
def _media_content_output_item(content: Content, *, status: str) -> ResponseOutputItem:
|
||||
parts = _content_parts_to_input_items([content])
|
||||
if parts:
|
||||
return cast(
|
||||
ResponseOutputItem,
|
||||
ResponseFunctionToolCallOutputItem(
|
||||
id=f"content_{uuid.uuid4().hex}",
|
||||
type="function_call_output",
|
||||
call_id=f"content_{uuid.uuid4().hex}",
|
||||
output=parts,
|
||||
status=_message_status(status), # type: ignore[arg-type]
|
||||
),
|
||||
)
|
||||
return _text_output_items(json.dumps(content.to_dict(), default=str), status=status)[0]
|
||||
|
||||
|
||||
def _content_parts_to_input_items(contents: Sequence[Content] | None) -> list[Any]:
|
||||
if not contents:
|
||||
return []
|
||||
|
||||
parts: list[Any] = []
|
||||
for content in contents:
|
||||
match content.type:
|
||||
case "text":
|
||||
parts.append(ResponseInputText(type="input_text", text=content.text or ""))
|
||||
case "data" | "uri":
|
||||
if not content.uri:
|
||||
continue
|
||||
if _is_image_content(content):
|
||||
parts.append(ResponseInputImage(type="input_image", image_url=content.uri, detail="auto"))
|
||||
else:
|
||||
parts.append(ResponseInputFile(type="input_file", file_url=content.uri))
|
||||
case "hosted_file":
|
||||
if content.file_id:
|
||||
parts.append(ResponseInputFile(type="input_file", file_id=content.file_id))
|
||||
case _:
|
||||
parts.append(ResponseInputText(type="input_text", text=json.dumps(content.to_dict(), default=str)))
|
||||
return parts
|
||||
|
||||
|
||||
def _content_sequence_text(contents: Sequence[Content] | None) -> str | None:
|
||||
if not contents:
|
||||
return None
|
||||
text = "".join(content.text or "" for content in contents if content.type == "text")
|
||||
return text or None
|
||||
|
||||
|
||||
def _is_image_content(content: Content) -> bool:
|
||||
media_type = content.media_type or ""
|
||||
if media_type.startswith("image/"):
|
||||
return True
|
||||
return (content.uri or "").startswith("data:image/")
|
||||
|
||||
|
||||
def _image_generation_result(outputs: Any) -> str | None:
|
||||
if isinstance(outputs, Content):
|
||||
return _image_generation_content_result(outputs)
|
||||
if isinstance(outputs, Sequence) and not isinstance(outputs, (str, bytes, bytearray)):
|
||||
for output in cast(Sequence[Any], outputs):
|
||||
if isinstance(output, Content) and (result := _image_generation_content_result(output)):
|
||||
return result
|
||||
if isinstance(outputs, str):
|
||||
return outputs
|
||||
return None
|
||||
|
||||
|
||||
def _image_generation_content_result(content: Content) -> str | None:
|
||||
uri = content.uri
|
||||
if not uri:
|
||||
return None
|
||||
if ";base64," in uri:
|
||||
return uri.split(";base64,", 1)[1]
|
||||
return uri
|
||||
|
||||
|
||||
def _content_item_id(content: Content, result_content: Content | None = None) -> str | None:
|
||||
item_id = content.additional_properties.get("item_id")
|
||||
if isinstance(item_id, str) and item_id:
|
||||
return item_id
|
||||
if result_content is not None:
|
||||
result_item_id = result_content.additional_properties.get("item_id")
|
||||
if isinstance(result_item_id, str) and result_item_id:
|
||||
return result_item_id
|
||||
return content.call_id or (result_content.call_id if result_content is not None else None)
|
||||
|
||||
|
||||
def _content_property(content: Content, result_content: Content | None, key: str) -> Any:
|
||||
if key in content.additional_properties:
|
||||
return content.additional_properties[key]
|
||||
if result_content is not None and key in result_content.additional_properties:
|
||||
return result_content.additional_properties[key]
|
||||
return None
|
||||
|
||||
|
||||
def _code_interpreter_status(status: str) -> str:
|
||||
if status in ("in_progress", "completed", "incomplete", "failed"):
|
||||
return status
|
||||
return "incomplete"
|
||||
|
||||
|
||||
def _image_generation_status(status: str) -> str:
|
||||
if status in ("in_progress", "completed", "failed"):
|
||||
return status
|
||||
return "failed"
|
||||
|
||||
|
||||
def _mcp_status(status: str) -> str:
|
||||
if status in ("in_progress", "completed", "incomplete", "failed"):
|
||||
return status
|
||||
return "incomplete"
|
||||
|
||||
|
||||
def _arguments_to_str(arguments: Any | None) -> str:
|
||||
if arguments is None:
|
||||
return ""
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
return json.dumps(arguments, default=str)
|
||||
|
||||
|
||||
def _stringify_output(output: Any) -> str:
|
||||
if output is None:
|
||||
return ""
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
|
||||
return "".join(_stringify_output(item) for item in cast(Sequence[Any], output))
|
||||
return json.dumps(output, default=str)
|
||||
|
||||
|
||||
def _raw_response_output_item(raw: Any) -> ResponseOutputItem | None:
|
||||
if _raw_type(raw) is None:
|
||||
return None
|
||||
try:
|
||||
return cast(ResponseOutputItem, _RESPONSE_OUTPUT_ITEM_ADAPTER.validate_python(raw))
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _response_output_item(value: Mapping[str, Any]) -> ResponseOutputItem:
|
||||
return cast(ResponseOutputItem, _RESPONSE_OUTPUT_ITEM_ADAPTER.validate_python(value))
|
||||
|
||||
|
||||
def _response_output_item_key(item: ResponseOutputItem) -> tuple[str, str]:
|
||||
item_type = _raw_type(item) or "unknown"
|
||||
item_id = getattr(item, "id", None) or getattr(item, "call_id", None)
|
||||
if isinstance(item_id, str) and item_id:
|
||||
return item_type, item_id
|
||||
return item_type, str(id(item))
|
||||
|
||||
|
||||
def _raw_type(raw: Any) -> str | None:
|
||||
raw_type = getattr(raw, "type", None)
|
||||
if isinstance(raw_type, str):
|
||||
return raw_type
|
||||
if isinstance(raw, Mapping):
|
||||
mapping_type = cast(Mapping[str, Any], raw).get("type")
|
||||
if isinstance(mapping_type, str):
|
||||
return mapping_type
|
||||
return None
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
text = getattr(result, "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
get_outputs = getattr(result, "get_outputs", None)
|
||||
if callable(get_outputs):
|
||||
return "".join(_output_to_text(output) for output in cast(Sequence[Any], get_outputs()))
|
||||
return str(result)
|
||||
|
||||
|
||||
def _output_to_text(output: Any) -> str:
|
||||
text = getattr(output, "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(output)
|
||||
|
||||
|
||||
def _response_payload(response: OpenAIResponse) -> dict[str, Any]:
|
||||
payload = response.model_dump(mode="json", exclude_none=True)
|
||||
created_at = payload.get("created_at")
|
||||
if isinstance(created_at, float):
|
||||
payload["created_at"] = int(created_at)
|
||||
return payload
|
||||
|
||||
|
||||
def _sse_event(event_type: str, payload: Mapping[str, Any]) -> str:
|
||||
"""Format one Server-Sent Event."""
|
||||
return f"event: {event_type}\ndata: {_json_dumps(payload)}\n\n"
|
||||
|
||||
|
||||
def _json_dumps(payload: Mapping[str, Any]) -> str:
|
||||
"""Serialize a Responses SSE payload."""
|
||||
return json.dumps(payload, separators=(",", ":"))
|
||||
|
||||
|
||||
async def responses_from_streaming_run(
|
||||
stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]],
|
||||
*,
|
||||
response_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Convert an Agent Framework response stream into Responses SSE events.
|
||||
|
||||
Args:
|
||||
stream: Agent Framework response stream returned by ``agent.run(...,
|
||||
stream=True)``.
|
||||
|
||||
Keyword Args:
|
||||
response_id: Id for the response being created.
|
||||
session_id: Optional prior ``resp_*`` or ``conv_*`` session id.
|
||||
|
||||
Yields:
|
||||
Server-Sent Event strings.
|
||||
"""
|
||||
yield _sse_event(
|
||||
"response.created",
|
||||
{
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"created_at": int(time.time()),
|
||||
"status": "in_progress",
|
||||
"model": "agent",
|
||||
"output": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
model: str | None = None
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
try:
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
if model is None:
|
||||
model = _model_from_update(update)
|
||||
if update.text:
|
||||
yield _sse_event(
|
||||
"response.output_text.delta",
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"delta": update.text,
|
||||
},
|
||||
)
|
||||
|
||||
final = await stream.get_final_response()
|
||||
payload = responses_from_run(final, response_id=response_id, session_id=session_id)
|
||||
if model is not None:
|
||||
# The finalized `AgentResponse` never carries a raw representation
|
||||
# (see `_model_from_update`), so prefer the model observed on the
|
||||
# stream's own chunks over `responses_from_run`'s "agent" fallback.
|
||||
payload["model"] = model
|
||||
yield _sse_event(
|
||||
"response.completed",
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": payload,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
partial_text = "".join(update.text for update in updates if update.text)
|
||||
response_kwargs: dict[str, Any] = {
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"created_at": int(time.time()),
|
||||
"status": "failed",
|
||||
"model": model or "agent",
|
||||
"output": _text_output_items(partial_text, status="failed"),
|
||||
"parallel_tool_calls": False,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"metadata": {},
|
||||
"error": {
|
||||
"code": "server_error",
|
||||
"message": str(exc),
|
||||
},
|
||||
}
|
||||
if session_id is not None and session_id.startswith("conv_"):
|
||||
response_kwargs["conversation"] = {"id": session_id}
|
||||
yield _sse_event(
|
||||
"response.failed",
|
||||
{
|
||||
"type": "response.failed",
|
||||
"response": _response_payload(OpenAIResponse(**response_kwargs)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_response_id",
|
||||
"messages_from_responses_input",
|
||||
"responses_from_run",
|
||||
"responses_from_streaming_run",
|
||||
"responses_session_id",
|
||||
"responses_to_run",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
[project]
|
||||
name = "agent-framework-hosting-responses"
|
||||
description = "OpenAI Responses-shaped helpers for agent-framework-hosting."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260709"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.11.0,<2",
|
||||
"agent-framework-hosting==1.0.0a260709",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
"fastapi>=0.115.0,<0.138.1",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_hosting_responses"]
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_hosting_responses"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_responses --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,271 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""HTTP round-trip tests: POST -> FastAPI route -> JSON/SSE response.
|
||||
|
||||
These exercise the same wiring as the `local_responses` sample: helpers from
|
||||
`agent_framework_hosting_responses` convert between the Responses protocol and
|
||||
Agent Framework run values, `agent_framework_hosting`'s `AgentState` /
|
||||
`SessionStore` hold shared execution state, and a small FastAPI route owns
|
||||
everything else (parsing, policy, response construction). Requests go through
|
||||
`httpx.AsyncClient` with `ASGITransport` -- no real server process or live
|
||||
model is involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Awaitable, Mapping
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentRunInputs,
|
||||
AgentSession,
|
||||
Content,
|
||||
Message,
|
||||
ResponseStream,
|
||||
)
|
||||
from agent_framework_hosting import AgentState
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from agent_framework_hosting_responses import (
|
||||
create_response_id,
|
||||
responses_from_run,
|
||||
responses_from_streaming_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
|
||||
|
||||
class _StubAgent:
|
||||
"""Deterministic ``SupportsAgentRun`` stub that tracks session continuity.
|
||||
|
||||
Each call records the ``session_id`` of the ``AgentSession`` it was
|
||||
invoked with and a per-session turn counter, so tests can assert that a
|
||||
chain of requests reused one session instead of silently starting fresh
|
||||
ones.
|
||||
"""
|
||||
|
||||
id = "stub-agent"
|
||||
name: str | None = "stub-agent"
|
||||
description: str | None = "stub agent for HTTP round-trip tests"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session_ids_seen: list[str | None] = []
|
||||
self.turn_counts: dict[str | None, int] = {}
|
||||
|
||||
def create_session(self, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(session_id=session_id)
|
||||
|
||||
def get_session(self, service_session_id: Any, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(session_id=session_id, service_session_id=service_session_id)
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> Awaitable[AgentResponse[Any]]: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
|
||||
session_id = session.session_id if session is not None else None
|
||||
self.session_ids_seen.append(session_id)
|
||||
self.turn_counts[session_id] = self.turn_counts.get(session_id, 0) + 1
|
||||
text = f"turn {self.turn_counts[session_id]} for session {session_id}"
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text(text=text)], role="assistant")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=lambda updates: AgentResponse.from_updates(updates))
|
||||
|
||||
async def _get_response() -> AgentResponse[Any]:
|
||||
return AgentResponse(messages=Message(role="assistant", contents=[Content.from_text(text=text)]))
|
||||
|
||||
return _get_response()
|
||||
|
||||
|
||||
def _build_app(agent: _StubAgent) -> FastAPI:
|
||||
"""Build a minimal FastAPI app mirroring the `local_responses` sample's route."""
|
||||
app = FastAPI()
|
||||
state = AgentState(agent)
|
||||
|
||||
@app.post("/responses", response_model=None)
|
||||
async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008
|
||||
try:
|
||||
run = responses_to_run(body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
session_id = responses_session_id(body)
|
||||
response_id = create_response_id()
|
||||
|
||||
target = await state.get_target()
|
||||
lookup_id = session_id or response_id
|
||||
session = await state.get_or_create_session(lookup_id)
|
||||
|
||||
if run["stream"]:
|
||||
stream = target.run(run["messages"], stream=True, session=session)
|
||||
if not isinstance(stream, ResponseStream):
|
||||
raise HTTPException(status_code=500, detail="agent did not return a response stream")
|
||||
|
||||
async def stream_events() -> AsyncIterator[str]:
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id=response_id,
|
||||
session_id=session_id,
|
||||
):
|
||||
yield event
|
||||
await state.set_session(response_id, session)
|
||||
|
||||
return StreamingResponse(
|
||||
stream_events(),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
result = await target.run(run["messages"], session=session)
|
||||
await state.set_session(response_id, session)
|
||||
return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _post(app: FastAPI, payload: dict[str, Any]) -> httpx.Response:
|
||||
"""Send a POST /responses request through the ASGI app, no real socket involved."""
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.post("/responses", json=payload, timeout=30)
|
||||
|
||||
|
||||
def _parse_sse_events(body: str) -> list[dict[str, Any]]:
|
||||
"""Parse SSE text into a list of `{"event": ..., "data": ...}` dicts."""
|
||||
events: list[dict[str, Any]] = []
|
||||
for block in body.split("\n\n"):
|
||||
if not block.strip():
|
||||
continue
|
||||
event_type: str | None = None
|
||||
data: str | None = None
|
||||
for line in block.split("\n"):
|
||||
if line.startswith("event: "):
|
||||
event_type = line[len("event: ") :]
|
||||
elif line.startswith("data: "):
|
||||
data = line[len("data: ") :]
|
||||
if event_type is not None and data is not None:
|
||||
events.append({"event": event_type, "data": json.loads(data)})
|
||||
return events
|
||||
|
||||
|
||||
class TestNonStreamingRoundTrip:
|
||||
async def test_returns_responses_shaped_payload(self) -> None:
|
||||
app = _build_app(_StubAgent())
|
||||
response = await _post(app, {"input": "hello"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["object"] == "response"
|
||||
assert payload["status"] == "completed"
|
||||
assert payload["id"].startswith("resp_")
|
||||
assert any(item["type"] == "message" for item in payload["output"])
|
||||
|
||||
async def test_invalid_input_returns_400_not_500(self) -> None:
|
||||
app = _build_app(_StubAgent())
|
||||
response = await _post(app, {})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "input" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestStreamingRoundTrip:
|
||||
async def test_stream_emits_created_delta_and_completed_events(self) -> None:
|
||||
app = _build_app(_StubAgent())
|
||||
response = await _post(app, {"input": "hello", "stream": True})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/event-stream" in response.headers["content-type"]
|
||||
|
||||
events = _parse_sse_events(response.text)
|
||||
event_types = [e["event"] for e in events]
|
||||
assert event_types[0] == "response.created"
|
||||
assert event_types[-1] == "response.completed"
|
||||
assert "response.output_text.delta" in event_types
|
||||
|
||||
completed = events[-1]["data"]["response"]
|
||||
assert completed["status"] == "completed"
|
||||
assert completed["id"].startswith("resp_")
|
||||
|
||||
|
||||
class TestSessionContinuity:
|
||||
"""Regression coverage for the `previous_response_id` aliasing fix.
|
||||
|
||||
`previous_response_id` rotates every turn. Without aliasing the newly
|
||||
minted response id to the same session, turn 3 would silently resolve to
|
||||
a brand-new, empty session instead of the one from turns 1-2.
|
||||
"""
|
||||
|
||||
async def test_previous_response_id_chain_preserves_session_across_three_turns(self) -> None:
|
||||
agent = _StubAgent()
|
||||
app = _build_app(agent)
|
||||
|
||||
turn1 = await _post(app, {"input": "hi"})
|
||||
assert turn1.status_code == 200
|
||||
turn2 = await _post(app, {"input": "still there?", "previous_response_id": turn1.json()["id"]})
|
||||
assert turn2.status_code == 200
|
||||
turn3 = await _post(app, {"input": "still there?", "previous_response_id": turn2.json()["id"]})
|
||||
assert turn3.status_code == 200
|
||||
|
||||
assert len(agent.session_ids_seen) == 3
|
||||
# All three turns must have run against the same underlying session,
|
||||
# not three independent ones.
|
||||
first_session_id = agent.session_ids_seen[0]
|
||||
assert first_session_id is not None
|
||||
assert agent.session_ids_seen == [first_session_id] * 3
|
||||
assert agent.turn_counts[first_session_id] == 3
|
||||
|
||||
async def test_conversation_id_preserves_session_across_turns(self) -> None:
|
||||
agent = _StubAgent()
|
||||
app = _build_app(agent)
|
||||
|
||||
turn1 = await _post(app, {"input": "hi", "conversation_id": "conv_stable"})
|
||||
assert turn1.status_code == 200
|
||||
turn2 = await _post(app, {"input": "still there?", "conversation_id": "conv_stable"})
|
||||
assert turn2.status_code == 200
|
||||
|
||||
assert agent.session_ids_seen == ["conv_stable", "conv_stable"]
|
||||
assert agent.turn_counts["conv_stable"] == 2
|
||||
|
||||
async def test_unrelated_requests_get_independent_sessions(self) -> None:
|
||||
agent = _StubAgent()
|
||||
app = _build_app(agent)
|
||||
|
||||
first = await _post(app, {"input": "hi"})
|
||||
second = await _post(app, {"input": "unrelated"})
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert agent.session_ids_seen[0] != agent.session_ids_seen[1]
|
||||
@@ -0,0 +1,289 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the OpenAI Responses request-body parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream
|
||||
|
||||
from agent_framework_hosting_responses import (
|
||||
create_response_id,
|
||||
messages_from_responses_input,
|
||||
responses_from_run,
|
||||
responses_from_streaming_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
|
||||
|
||||
def _sse_payload(event: str) -> dict[str, object]:
|
||||
data_line = next(line for line in event.splitlines() if line.startswith("data: "))
|
||||
return cast("dict[str, object]", json.loads(data_line.removeprefix("data: ")))
|
||||
|
||||
|
||||
class TestMessagesFromResponsesInput:
|
||||
def test_string_input_becomes_single_user_message(self) -> None:
|
||||
msgs = messages_from_responses_input("hello")
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].role == "user"
|
||||
assert msgs[0].text == "hello"
|
||||
|
||||
def test_input_text_items_collapse_into_one_user_message(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_text", "text": "a"}, {"type": "input_text", "text": "b"}])
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].role == "user"
|
||||
assert msgs[0].text == "a b"
|
||||
|
||||
def test_message_envelope_with_string_content(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "message", "role": "system", "content": "be brief"},
|
||||
{"type": "message", "role": "user", "content": "hi"},
|
||||
])
|
||||
assert [m.role for m in msgs] == ["system", "user"]
|
||||
assert msgs[0].text == "be brief"
|
||||
|
||||
def test_message_envelope_with_content_parts(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "describe this"}],
|
||||
}
|
||||
])
|
||||
assert msgs[0].text == "describe this"
|
||||
|
||||
def test_message_envelope_rejects_non_object_content_item(self) -> None:
|
||||
with pytest.raises(ValueError, match="content.*object"):
|
||||
messages_from_responses_input([{"type": "message", "role": "user", "content": ["bad"]}])
|
||||
|
||||
def test_message_envelope_rejects_invalid_content_shape(self) -> None:
|
||||
with pytest.raises(ValueError, match="content.*string or list"):
|
||||
messages_from_responses_input([{"type": "message", "role": "user", "content": 42}])
|
||||
|
||||
def test_input_file_via_url(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_file", "file_url": "https://example.com/report.pdf", "mime_type": "application/pdf"}
|
||||
])
|
||||
assert msgs[0].contents[0].uri == "https://example.com/report.pdf"
|
||||
|
||||
def test_input_file_via_file_id(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_file", "file_id": "file_123"}])
|
||||
assert msgs[0].contents[0].file_id == "file_123"
|
||||
|
||||
def test_input_file_missing_anchor_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="input_file"):
|
||||
messages_from_responses_input([{"type": "input_file"}])
|
||||
|
||||
def test_pending_text_flushes_before_message_envelope(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_text", "text": "first"},
|
||||
{"type": "message", "role": "user", "content": "second"},
|
||||
])
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0].text == "first"
|
||||
assert msgs[1].text == "second"
|
||||
|
||||
def test_image_url_via_string(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_image", "image_url": "https://example.com/cat.png"}])
|
||||
assert len(msgs) == 1
|
||||
# Image content present.
|
||||
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
|
||||
|
||||
def test_image_url_via_object(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_image", "image_url": {"url": "https://example.com/cat.png"}}
|
||||
])
|
||||
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
|
||||
|
||||
def test_unknown_input_type_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unsupported"):
|
||||
messages_from_responses_input([{"type": "weird"}])
|
||||
|
||||
def test_empty_list_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
messages_from_responses_input([])
|
||||
|
||||
def test_non_string_non_list_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
messages_from_responses_input(42) # type: ignore[arg-type]
|
||||
|
||||
def test_image_url_missing_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="image_url"):
|
||||
messages_from_responses_input([{"type": "input_image"}])
|
||||
|
||||
|
||||
class TestResponsesRunHelpers:
|
||||
def test_create_response_id_shape(self) -> None:
|
||||
response_id = create_response_id()
|
||||
|
||||
assert response_id.startswith("resp_")
|
||||
|
||||
def test_responses_session_id_prefers_previous_response(self) -> None:
|
||||
assert responses_session_id({"previous_response_id": "resp_1", "conversation_id": "conv_1"}) == "resp_1"
|
||||
|
||||
def test_responses_session_id_uses_conversation_id(self) -> None:
|
||||
assert responses_session_id({"conversation_id": "conv_1"}) == "conv_1"
|
||||
|
||||
def test_responses_session_id_returns_none_when_absent(self) -> None:
|
||||
assert responses_session_id({"input": "hi"}) is None
|
||||
|
||||
def test_responses_to_run_returns_messages_options_and_stream(self) -> None:
|
||||
run = responses_to_run({
|
||||
"input": "hi",
|
||||
"stream": True,
|
||||
"previous_response_id": "resp_1",
|
||||
"conversation_id": "conv_1",
|
||||
"max_output_tokens": 32,
|
||||
"model": "gpt-x",
|
||||
})
|
||||
|
||||
# `responses_to_run` always produces a `list[Message]`; the TypedDict
|
||||
# field is typed as the wider `Agent.run` input shape, so narrow here.
|
||||
messages = cast("list[Message]", run["messages"])
|
||||
assert messages[0].text == "hi"
|
||||
assert run["stream"] is True
|
||||
assert run["options"] == {"max_tokens": 32, "model": "gpt-x"}
|
||||
|
||||
def test_responses_from_run_returns_response_payload(self) -> None:
|
||||
result = AgentResponse(
|
||||
messages=Message(role="assistant", contents=[Content.from_text("hello")]),
|
||||
additional_properties={"model": "test-model"},
|
||||
)
|
||||
|
||||
payload = responses_from_run(result, response_id="resp_new")
|
||||
|
||||
assert payload["id"] == "resp_new"
|
||||
assert payload["model"] == "test-model"
|
||||
assert payload["output"][0]["content"][0]["text"] == "hello"
|
||||
|
||||
def test_responses_from_run_preserves_multimodal_output_items(self) -> None:
|
||||
result = AgentResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text_reasoning(id="rs_1", text="checking"),
|
||||
Content.from_function_call("call_1", "collect_media", arguments={"city": "Seattle"}),
|
||||
Content.from_function_result(
|
||||
"call_1",
|
||||
result=[
|
||||
Content.from_text("caption"),
|
||||
Content.from_uri("https://example.com/cat.png", media_type="image/png"),
|
||||
Content.from_hosted_file("file_pdf", media_type="application/pdf"),
|
||||
],
|
||||
),
|
||||
Content.from_text("done"),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
payload = responses_from_run(result, response_id="resp_new")
|
||||
|
||||
output = payload["output"]
|
||||
assert [item["type"] for item in output] == [
|
||||
"reasoning",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
"message",
|
||||
]
|
||||
assert output[0]["content"][0]["text"] == "checking"
|
||||
assert output[1]["name"] == "collect_media"
|
||||
assert output[1]["arguments"] == '{"city": "Seattle"}'
|
||||
assert output[2]["output"] == [
|
||||
{"text": "caption", "type": "input_text"},
|
||||
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"},
|
||||
{"type": "input_file", "file_id": "file_pdf"},
|
||||
]
|
||||
assert output[3]["content"][0]["text"] == "done"
|
||||
|
||||
def test_responses_from_run_maps_conversation_session(self) -> None:
|
||||
result = AgentResponse(messages=Message(role="assistant", contents=[Content.from_text("hello")]))
|
||||
|
||||
payload = responses_from_run(result, response_id="resp_new", session_id="conv_1")
|
||||
|
||||
assert payload["conversation"] == {"id": "conv_1"}
|
||||
|
||||
def test_responses_from_run_omits_previous_response_session(self) -> None:
|
||||
result = AgentResponse(messages=Message(role="assistant", contents=[Content.from_text("hello")]))
|
||||
|
||||
payload = responses_from_run(result, response_id="resp_new", session_id="resp_1")
|
||||
|
||||
assert "conversation" not in payload
|
||||
|
||||
async def test_responses_from_streaming_run(self) -> None:
|
||||
async def updates() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("hel")], role="assistant")
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("lo")], role="assistant")
|
||||
|
||||
def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse:
|
||||
return AgentResponse.from_updates(items)
|
||||
|
||||
stream = ResponseStream(updates(), finalizer=finalizer)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id="resp_new",
|
||||
session_id="conv_1",
|
||||
)
|
||||
]
|
||||
|
||||
assert events[0].startswith("event: response.created")
|
||||
assert "response.output_text.delta" in events[1]
|
||||
assert "hel" in events[1]
|
||||
assert "lo" in events[2]
|
||||
assert events[-1].startswith("event: response.completed")
|
||||
assert '"conversation":{"id":"conv_1"}' in events[-1]
|
||||
|
||||
async def test_responses_from_streaming_run_emits_failed_when_iteration_raises(self) -> None:
|
||||
async def updates() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant")
|
||||
raise RuntimeError("upstream blew up")
|
||||
|
||||
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id="resp_new",
|
||||
session_id="conv_1",
|
||||
)
|
||||
]
|
||||
|
||||
assert events[0].startswith("event: response.created")
|
||||
assert "response.output_text.delta" in events[1]
|
||||
assert events[-1].startswith("event: response.failed")
|
||||
payload = _sse_payload(events[-1])
|
||||
response = cast("dict[str, object]", payload["response"])
|
||||
error = cast("dict[str, object]", response["error"])
|
||||
assert payload["type"] == "response.failed"
|
||||
assert response["status"] == "failed"
|
||||
assert response["conversation"] == {"id": "conv_1"}
|
||||
assert error["message"] == "upstream blew up"
|
||||
assert "partial" in events[-1]
|
||||
|
||||
async def test_responses_from_streaming_run_emits_failed_when_finalizer_raises(self) -> None:
|
||||
async def updates() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant")
|
||||
|
||||
def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse:
|
||||
raise RuntimeError("finalizer blew up")
|
||||
|
||||
stream = ResponseStream(updates(), finalizer=finalizer)
|
||||
|
||||
events = [event async for event in responses_from_streaming_run(stream, response_id="resp_new")]
|
||||
|
||||
assert events[0].startswith("event: response.created")
|
||||
assert "response.output_text.delta" in events[1]
|
||||
assert events[-1].startswith("event: response.failed")
|
||||
payload = _sse_payload(events[-1])
|
||||
response = cast("dict[str, object]", payload["response"])
|
||||
error = cast("dict[str, object]", response["error"])
|
||||
assert response["status"] == "failed"
|
||||
assert error["message"] == "finalizer blew up"
|
||||
Reference in New Issue
Block a user