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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+21
View File
@@ -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"