Python: add agent-framework-hosting-responses channel (#5639)

* feat(hosting-responses): add OpenAI Responses-shaped channel package

New ``agent-framework-hosting-responses`` package implementing the
OpenAI Responses-shaped HTTP channel for the Hosting framework. Mounts
``POST /responses`` (and a ``/responses/{response_id}`` GET) onto an
``AgentFrameworkHost`` and translates the OpenAI Responses wire shape
to/from the channel-neutral ``ChannelRequest`` / ``HostedRunResult``
plumbing.

Surface (re-exported from ``agent_framework_hosting_responses``):

- ``ResponsesChannel`` -- concrete ``Channel`` implementation. Owns the
  Starlette route(s), parses inbound JSON into ``ChannelRequest``, runs
  the optional ``ChannelRunHook``, calls back into the
  ``ChannelContext`` to invoke the agent target, builds Responses
  envelopes (sync JSON or SSE), and respects
  ``DeliveryReport.include_originating`` so cross-channel push routes
  only ack to the originating Responses caller.
- The minted ``response_id`` is propagated via the host's ContextVar
  machinery so storage-side history providers (e.g.
  ``FoundryHostedAgentHistoryProvider``) persist envelopes against the
  same id the channel returns.
- 48 unit tests covering route wiring, parsing of each Responses input
  shape, hook composition, sync vs streaming paths, and originating
  vs non-originating delivery branches.

Registers the package in ``python/pyproject.toml`` ``[tool.uv.sources]``
and adds the matching pyright ``executionEnvironments`` entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* review: address PR-3 round 2 feedback

- consume IsolationKeys.chat_key from the host-bound contextvar instead
  of the raw `x-agent-chat-isolation-key` header off the wire so the
  host's ASGI isolation middleware (or any operator-supplied
  replacement) is the authoritative point at which the caller is
  authenticated and the bucket key is established
- expand `response_id_factory` docstring to call out partition
  co-location vs. partition-ownership enforcement: the channel forwards
  `previous_response_id` as a hint to the factory; the storage layer
  validates the embedded partition against the bound user/chat
  isolation keys
- on mid-stream failure, call `deliver_response` with the accumulated
  text before emitting `response.failed` so host-side history /
  push-channel state stays consistent with the partial deltas the
  client already saw

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(hosting-responses): fix quickstart to use current Agent API

ChatAgent was renamed to Agent and ChatMessage to Message. Update the
README quickstart to use client.as_agent(...) and refresh the stale
docstring reference in _channel.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-responses): adapt to hosted run result wrapper

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(hosting-responses): add response hooks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-responses): keep instructions in chat options

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-05-28 13:56:43 +02:00
committed by GitHub
Unverified
parent 4c317eb7cf
commit d75f55ee2c
10 changed files with 1337 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,21 @@
# agent-framework-hosting-responses
OpenAI Responses-shaped channel for `agent-framework-hosting`.
Exposes a single `POST /responses` endpoint that accepts the OpenAI
Responses API request body and returns either a Responses-shaped JSON
body or a Server-Sent-Events stream when `stream=True`.
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_responses import ResponsesChannel
agent = OpenAIChatClient().as_agent(name="Assistant")
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
host.serve(port=8000)
```
The base host plumbing lives in
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
@@ -0,0 +1,27 @@
# Copyright (c) Microsoft. All rights reserved.
"""OpenAI Responses-shaped channel for ``agent-framework-hosting``."""
import importlib.metadata
from ._channel import ResponsesChannel
from ._parsing import (
messages_from_responses_input,
parse_response_target,
parse_responses_identity,
parse_responses_request,
)
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"ResponsesChannel",
"__version__",
"messages_from_responses_input",
"parse_response_target",
"parse_responses_identity",
"parse_responses_request",
]
@@ -0,0 +1,427 @@
# Copyright (c) Microsoft. All rights reserved.
"""``ResponsesChannel`` — OpenAI Responses-shaped HTTP surface.
Exposes a single ``POST /responses`` endpoint that accepts
``{"input": "...", "stream": false}`` (and the rest of the Responses API
request body) and returns either a Responses-shaped JSON body
(``stream=False``, default) or a Server-Sent-Events stream
(``stream=True``).
Payload construction reuses the ``openai.types.responses`` Pydantic
models so the OpenAI Python SDK ``stream=True`` consumer parses every
required field without surprises.
"""
from __future__ import annotations
import time
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from typing import Any, cast
from agent_framework import AgentResponse, Content, Message
from agent_framework_hosting import (
ChannelContext,
ChannelContribution,
ChannelRequest,
ChannelResponseContext,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamTransformHook,
HostedRunResult,
apply_response_hook,
apply_run_hook,
get_current_isolation_keys,
logger,
)
from openai.types.responses import (
Response as OpenAIResponse,
)
from openai.types.responses import (
ResponseCompletedEvent,
ResponseCreatedEvent,
ResponseError,
ResponseFailedEvent,
ResponseOutputMessage,
ResponseOutputText,
ResponseTextDeltaEvent,
)
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from starlette.routing import Route
from ._parsing import (
parse_response_target,
parse_responses_identity,
parse_responses_request,
)
def _ack_text() -> str:
"""Tiny acknowledgement string for the originating wire.
Used when the agent reply is delivered out-of-band via :class:`ChannelPush`.
"""
return "[delivered out-of-band]"
def _text_result(text: str) -> HostedRunResult[AgentResponse]:
"""Build a host delivery payload from text accumulated by this channel."""
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
class ResponsesChannel:
"""Minimal OpenAI-Responses-shaped surface.
Mounts ``POST <path>/responses`` (default path ``/responses`` so the
full route is ``/responses/responses`` when the channel is prefixed,
or just ``/responses`` when ``path=""``).
"""
name = "responses"
def __init__(
self,
*,
path: str = "",
run_hook: ChannelRunHook | None = None,
response_hook: ChannelResponseHook | None = None,
stream_transform_hook: ChannelStreamTransformHook | None = None,
response_id_factory: Callable[..., str] | None = None,
) -> None:
"""Create a Responses channel.
Keyword Args:
path: Mount prefix on the host. Default ``""`` mounts the
``POST /responses`` route at the app root, matching the
upstream OpenAI surface.
run_hook: Optional :data:`ChannelRunHook` invoked with the
parsed :class:`ChannelRequest` before the agent target
runs. May return a replacement request.
response_hook: Optional :data:`ChannelResponseHook` invoked
before the channel serializes an originating
:class:`HostedRunResult` into a Responses envelope. The
host also invokes this hook when delivering to this
channel as a non-originating push destination.
stream_transform_hook: Optional per-update transform hook
applied while streaming Server-Sent Events. Return a
replacement update, or ``None`` to drop the update.
response_id_factory: Optional callable that mints the
per-request response id. Default produces
``resp_<uuid hex>`` which matches the OpenAI Responses
wire shape. Override when the host backing storage
requires a different id format (e.g. Foundry storage,
whose partition keys are encoded in the id and which
rejects free-form ``resp_*`` ids with a server error).
The same id is used for the channel envelope and for
the host-side anchoring (``ChannelRequest.attributes``)
so storage and replay agree.
Security note on partition co-location: when a caller
supplies ``previous_response_id`` we forward it to the
factory so id backends that embed partition keys can
co-locate the new record with the chain's existing
partition. The factory passes that hint through to the
storage layer; **partition ownership is enforced at
the storage layer**, not in the channel: the Foundry
storage provider, for example, validates the request
against the bound user/chat isolation keys and rejects
writes whose embedded partition does not match the
authenticated caller's isolation. Channel-level
forwarding is therefore a performance hint, not a
security boundary; the host's isolation middleware
must establish the caller's identity before this
route is entered.
"""
self.path = path
self._hook = run_hook
self.response_hook = response_hook
self._stream_transform_hook = stream_transform_hook
self._ctx: ChannelContext | None = None
self._response_id_factory: Callable[..., str] = (
response_id_factory if response_id_factory is not None else (lambda *_a, **_kw: f"resp_{uuid.uuid4().hex}")
)
def contribute(self, context: ChannelContext) -> ChannelContribution:
"""Capture the host-supplied context and register ``POST /responses``."""
self._ctx = context
return ChannelContribution(routes=[Route("/responses", self._handle, methods=["POST"])])
async def _handle(self, request: Request) -> Response:
"""Handle a single ``POST /responses`` call.
Parses the OpenAI Responses-shaped body into ``Message`` /
``options`` / ``ChannelSession`` triples via :mod:`._parsing`,
applies the optional ``run_hook``, and either streams an SSE
response stream or returns a one-shot OpenAI ``Response`` envelope.
Non-originating ``response_target`` values resolve to a delivery
acknowledgement instead of echoing the agent text on this wire.
"""
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
return JSONResponse({"error": "channel not initialized"}, status_code=500)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "invalid json"}, status_code=400)
try:
messages, options, session = parse_responses_request(body)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=422)
# When no ``previous_response_id`` chain anchor is on the body,
# surface the isolation key the **host** lifted off the request
# (via ``_FoundryIsolationASGIMiddleware`` for the default
# Foundry-platform deployment, or whatever middleware the
# operator configured in front of the host) as the channel
# session id, so callers without an explicit anchor still get
# a stable per-conversation session id (used by non-Foundry
# history providers, routing/idempotency, etc.).
#
# Security note: we consume the host-bound contextvar set by the
# ASGI isolation middleware, NOT the raw header off the wire.
# That middleware is the operator's place to enforce auth and
# gate which callers get to set isolation. If you mount the host
# in front of a custom auth boundary, your middleware should
# validate the caller before stamping ``set_current_isolation_keys``;
# never trust raw wire headers to identify a session bucket.
# The chat-iso value is *not* a valid storage anchor: the
# Foundry history provider deliberately ignores it — multi-turn
# storage chaining goes through the ``previous_response_id`` /
# bound ``response_id`` pair on ``ChannelRequest.attributes``.
bound_keys = get_current_isolation_keys()
chat_iso = bound_keys.chat_key if bound_keys is not None else None
if session is None and chat_iso:
session = ChannelSession(isolation_key=chat_iso)
# Mint the response id once per request so the channel envelope
# (one-shot or streamed) and any host-side anchoring (e.g. the
# Foundry history provider's ``bind_request_context``) agree on
# the same handle. The next turn arrives with this value as
# ``previous_response_id`` and the storage chain walks. We pass
# both anchors via ``ChannelRequest.attributes`` so the host
# can pick them up without a channel-specific contract.
previous_response_id: str | None = None
prev_raw = body.get("previous_response_id")
if isinstance(prev_raw, str) and prev_raw:
previous_response_id = prev_raw
# Pass the previous id (if any) as a hint to the factory so id
# backends that embed partition keys (e.g. Foundry storage) can
# co-locate the new record with the chain's existing partition.
# No-arg factories continue to work via ``Callable[..., str]``.
response_id = self._response_id_factory(previous_response_id)
attributes: dict[str, Any] = {"response_id": response_id}
if previous_response_id is not None:
attributes["previous_response_id"] = previous_response_id
# Honor the OpenAI-Responses ``stream`` flag — non-streaming by
# default, SSE when the caller opts in. Run hooks may still flip
# this per-request (e.g. force non-streaming for a particular user).
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=messages,
session=session,
options=options or None,
stream=bool(body.get("stream", False)),
identity=parse_responses_identity(body, self.name),
response_target=parse_response_target(body),
attributes=attributes,
)
if self._hook is not None:
channel_request = await apply_run_hook(
self._hook,
channel_request,
target=self._ctx.target,
protocol_request=body,
)
if channel_request.stream:
return StreamingResponse(
self._stream_events(channel_request, body, response_id=response_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
result = await self._ctx.run(channel_request)
include_originating = await self._ctx.deliver_response(channel_request, result)
if include_originating:
result = await self._apply_response_hook(result, channel_request)
text = result.result.text if include_originating else _ack_text()
envelope = self._build_response(body, text, status="completed", response_id=response_id)
return JSONResponse(envelope.model_dump(mode="json", exclude_none=True))
async def _apply_response_hook(
self,
result: HostedRunResult[AgentResponse],
request: ChannelRequest,
) -> HostedRunResult[AgentResponse]:
"""Apply the channel-level response hook for an originating reply."""
if self.response_hook is None:
return result
context = ChannelResponseContext(
request=request,
channel_name=self.name,
destination_identity=None,
originating=True,
is_echo=False,
)
shaped = await apply_response_hook(self.response_hook, result, context=context)
return cast("HostedRunResult[AgentResponse]", shaped)
def _build_response(
self,
body: Mapping[str, Any],
text: str,
*,
status: str,
response_id: str | None = None,
) -> OpenAIResponse:
"""Construct an OpenAI ``Response`` for a finished (non-streaming) run.
``status`` mirrors the top-level Response status set values
(``in_progress`` / ``completed`` / ``failed`` / ``incomplete`` /
``cancelled``). The nested ``ResponseOutputMessage.status`` field
only accepts ``in_progress`` / ``completed`` / ``incomplete``, so
terminal-but-non-success states collapse to ``incomplete`` there
— the failure detail still travels via the top-level ``status``
and (for streamed errors) the ``error`` field.
``response_id``: the per-request id minted in :meth:`_handle`.
Passed in so envelope and storage agree on a single handle per
turn (see :meth:`_handle` notes). Falls back to a fresh uuid
when callers (e.g. :meth:`_stream_events`'s skeleton path
before this argument was introduced) don't supply one.
"""
message_status = status if status in ("in_progress", "completed", "incomplete") else "incomplete"
return OpenAIResponse(
id=response_id or self._response_id_factory(None),
object="response",
created_at=time.time(),
status=status, # type: ignore[arg-type]
model=body.get("model", "agent"),
output=[
ResponseOutputMessage(
id=f"msg_{uuid.uuid4().hex}",
type="message",
role="assistant",
status=message_status, # type: ignore[arg-type]
content=[ResponseOutputText(type="output_text", text=text, annotations=[])],
)
],
parallel_tool_calls=False,
tool_choice="auto",
tools=[],
metadata={},
)
async def _stream_events(
self,
request: ChannelRequest,
body: Mapping[str, Any],
*,
response_id: str,
) -> AsyncIterator[str]:
"""Yield SSE events shaped like the OpenAI Responses streaming protocol.
Emits ``response.created`` → many ``response.output_text.delta``
→ ``response.completed`` (or ``response.failed`` on error).
"""
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
return
msg_id = f"msg_{uuid.uuid4().hex}"
seq = 0
def next_seq() -> int:
nonlocal seq
seq += 1
return seq
def sse(event: Any) -> str:
return f"event: {event.type}\ndata: {event.model_dump_json(exclude_none=True)}\n\n"
skeleton = self._build_response(body, "", status="in_progress", response_id=response_id)
yield sse(ResponseCreatedEvent(type="response.created", response=skeleton, sequence_number=next_seq()))
accumulated = ""
try:
stream = self._ctx.run_stream(request)
async for update in stream:
if self._stream_transform_hook is not None:
transformed = self._stream_transform_hook(update)
update = await transformed if isinstance(transformed, Awaitable) else transformed
if update is None:
continue
chunk = getattr(update, "text", None)
if chunk:
accumulated += chunk
yield sse(
ResponseTextDeltaEvent(
type="response.output_text.delta",
item_id=msg_id,
output_index=0,
content_index=0,
delta=chunk,
logprobs=[],
sequence_number=next_seq(),
)
)
try:
# Finalize so context-provider / history hooks on the agent
# still run even though we are emitting our own SSE.
await stream.get_final_response()
except Exception: # pragma: no cover - finalize is best-effort
logger.exception("Responses stream finalize failed")
except Exception as exc:
logger.exception("Responses stream consumption failed")
# Mid-stream failure: the wire already saw partial deltas
# so host-side state must reflect that — call
# ``deliver_response`` with the accumulated text (best-effort)
# before signalling failure to the client. Without this,
# next turn's chain anchored on this ``response_id`` would
# be inconsistent with what the user actually saw, and any
# non-originating push targets would silently miss the turn.
# ``deliver_response`` itself is best-effort; we swallow its
# exceptions so the failure event still reaches the client.
try:
await self._ctx.deliver_response(request, _text_result(accumulated))
except Exception: # pragma: no cover - delivery is best-effort
logger.exception("Responses stream failure deliver_response failed")
failed = self._build_response(body, accumulated, status="failed", response_id=response_id)
failed.error = ResponseError(code="server_error", message=str(exc))
yield sse(
ResponseFailedEvent(
type="response.failed",
response=failed,
sequence_number=next_seq(),
)
)
return
completed_text = accumulated
result = _text_result(accumulated)
include_originating = await self._ctx.deliver_response(request, result)
if include_originating:
result = await self._apply_response_hook(result, request)
completed_text = result.result.text
else:
completed_text = _ack_text()
completed = self._build_response(body, completed_text, status="completed", response_id=response_id)
# Reuse the same message id we emitted deltas under.
if completed.output and isinstance(completed.output[0], ResponseOutputMessage):
completed.output[0].id = msg_id
yield sse(
ResponseCompletedEvent(
type="response.completed",
response=completed,
sequence_number=next_seq(),
)
)
__all__ = ["ResponsesChannel"]
@@ -0,0 +1,234 @@
# 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 split out the ChatOptions-shaped fields the API also carries.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
from agent_framework import Content, Message
from agent_framework_hosting import ChannelIdentity, ChannelSession, ResponseTarget, logger
# 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 we forward to ChatOptions verbatim. ``instructions`` stays here
# because Agent Framework exposes it as a ChatOptions field; it must not be
# lifted into a synthetic system message.
_RESPONSES_OPTION_PASSTHROUGH = {
"instructions",
"temperature",
"top_p",
"metadata",
"user",
"safety_identifier",
"tool_choice",
"tools",
"store",
"response_format",
"stop",
"seed",
"frequency_penalty",
"presence_penalty",
"logit_bias",
}
# Fields the Responses transport owns; they must not be forwarded as options.
_RESPONSES_TRANSPORT_KEYS = {"input", "model", "stream", "previous_response_id", "response_target"}
def parse_response_target(body: Mapping[str, Any]) -> ResponseTarget:
"""Translate the OpenAI Responses ``response_target`` field into a :class:`ResponseTarget`.
Accepted shapes:
- ``"originating"`` / ``"active"`` / ``"all_linked"`` / ``"none"`` — bare strings.
- ``"telegram"`` / ``"telegram:<chat_id>"`` — single channel destination.
- ``["telegram:<id>", "originating"]`` — list of destinations; the
pseudo-name ``"originating"`` includes the originating channel.
- ``{"channels": [...]}`` — same list semantics with the explicit key.
- ``{"kind": "active"}`` / ``{"kind": "all_linked"}`` — explicit kind.
Anything malformed is logged at WARNING and falls back to ``originating``.
"""
raw = body.get("response_target")
if raw is None:
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
if isinstance(raw, str):
keyword = raw.strip()
if keyword == "originating":
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
if keyword == "active":
return ResponseTarget.active # type: ignore[attr-defined,no-any-return]
if keyword == "all_linked":
return ResponseTarget.all_linked # type: ignore[attr-defined,no-any-return]
if keyword == "none":
return ResponseTarget.none # type: ignore[attr-defined,no-any-return]
# Treat any other bare string as a single channel destination.
return ResponseTarget.channel(keyword)
if isinstance(raw, list):
return _parse_channels_list(cast("list[Any]", raw)) # type: ignore[redundant-cast]
if isinstance(raw, Mapping):
raw_map = cast("Mapping[str, Any]", raw)
channels = raw_map.get("channels")
if isinstance(channels, list):
return _parse_channels_list(cast("list[Any]", channels)) # type: ignore[redundant-cast]
kind = raw_map.get("kind")
if kind == "active":
return ResponseTarget.active # type: ignore[attr-defined,no-any-return]
if kind == "all_linked":
return ResponseTarget.all_linked # type: ignore[attr-defined,no-any-return]
if kind == "none":
return ResponseTarget.none # type: ignore[attr-defined,no-any-return]
if kind == "originating":
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
logger.warning("responses: ignoring malformed response_target=%r", cast("Any", raw))
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
def _parse_channels_list(raw: list[Any]) -> ResponseTarget:
"""Build a ``ResponseTarget.channels`` from a raw list, dropping non-string entries.
An empty list (or one with no usable strings) collapses back to
``originating`` so we never silently produce a target that nobody
will deliver to.
"""
tokens = [t for t in raw if isinstance(t, str) and t]
if len(tokens) != len(raw):
logger.warning("responses: dropping non-string entries from response_target=%r", raw)
if not tokens:
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
return ResponseTarget.channels(tokens)
def parse_responses_identity(body: Mapping[str, Any], channel_name: str) -> ChannelIdentity | None:
"""Surface the caller as a :class:`ChannelIdentity` so the host can record it.
OpenAI Responses replaced ``user`` with ``safety_identifier`` — we use
that as the native id, falling back to the legacy ``user`` field.
"""
native = body.get("safety_identifier") or body.get("user")
if not isinstance(native, str) or not native:
return None
return ChannelIdentity(channel=channel_name, native_id=native)
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): # type: ignore[redundant-cast]
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 = [
_content_from_input_item(cast("Mapping[str, Any]", c))
for c in cast("list[Any]", content) # type: ignore[redundant-cast]
if isinstance(c, Mapping)
]
else:
parts = []
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 parse_responses_request(
body: Mapping[str, Any],
) -> tuple[list[Message], dict[str, Any], ChannelSession | None]:
"""Translate a Responses-API request body into Agent Framework constructs.
Returns a triple ``(messages, options, session)`` where:
- ``messages`` is the parsed conversation.
- ``options`` is a ``ChatOptions``-shaped dict with the model-tunable
fields the channel lifted off the body.
- ``session`` is a :class:`ChannelSession` keyed by
``previous_response_id`` when one was supplied, else ``None``.
"""
messages = messages_from_responses_input(body.get("input"))
options: dict[str, Any] = {}
for key, value in body.items():
if key in _RESPONSES_TRANSPORT_KEYS or value is None:
continue
if (mapped := _RESPONSES_OPTION_REMAP.get(key)) is not None:
options[mapped] = value
elif key in _RESPONSES_OPTION_PASSTHROUGH:
options[key] = value
# silently drop everything else (truncation, reasoning, include, ...)
session: ChannelSession | None = None
if (prev := body.get("previous_response_id")) and isinstance(prev, str):
session = ChannelSession(isolation_key=prev)
return messages, options, session
__all__ = [
"messages_from_responses_input",
"parse_response_target",
"parse_responses_identity",
"parse_responses_request",
]
@@ -0,0 +1,98 @@
[project]
name = "agent-framework-hosting-responses"
description = "OpenAI Responses-shaped channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
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.2.0,<2",
"agent-framework-hosting==1.0.0a260424",
"openai>=1.99.0,<3",
]
[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.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_hosting_responses"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_responses"
[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,287 @@
# Copyright (c) Microsoft. All rights reserved.
"""End-to-end tests for :class:`ResponsesChannel` via Starlette's ``TestClient``."""
from __future__ import annotations
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelIdentity,
HostedRunResult,
)
from starlette.testclient import TestClient
from agent_framework_hosting_responses import ResponsesChannel
# --------------------------------------------------------------------------- #
# Fakes #
# --------------------------------------------------------------------------- #
@dataclass
class _FakeAgentResponse:
text: str
@dataclass
class _FakeUpdate:
text: str
class _FakeStream:
"""Minimal stand-in for AF's ``ResponseStream`` returned by ``run(stream=True)``."""
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
self._final = _FakeAgentResponse(text="".join(chunks))
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
async def _gen() -> AsyncIterator[_FakeUpdate]:
for c in self._chunks:
yield _FakeUpdate(c)
return _gen()
async def get_final_response(self) -> _FakeAgentResponse:
return self._final
class _FakeAgent:
def __init__(self, reply: str = "hello", chunks: list[str] | None = None) -> None:
self._reply = reply
self._chunks = chunks or [reply]
self.calls: list[dict[str, Any]] = []
def create_session(self, *, session_id: str | None = None) -> Any:
return {"session_id": session_id}
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _FakeStream(self._chunks)
async def _coro() -> _FakeAgentResponse:
return _FakeAgentResponse(text=self._reply)
return _coro()
class _RecordingPushChannel:
name = "telegram"
path = "/telegram"
def __init__(self) -> None:
self.pushes: list[tuple[ChannelIdentity, HostedRunResult]] = []
def contribute(self, _ctx: Any) -> Any:
from agent_framework_hosting import ChannelContribution
return ChannelContribution()
async def push(self, identity: ChannelIdentity, payload: HostedRunResult) -> None:
self.pushes.append((identity, payload))
# --------------------------------------------------------------------------- #
# Tests #
# --------------------------------------------------------------------------- #
def _make_client(agent: _FakeAgent | None = None) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]:
agent = agent or _FakeAgent()
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
return TestClient(host.app), host, agent
class TestResponsesChannelNonStreaming:
def test_post_responses_returns_completed_envelope(self) -> None:
client, _host, agent = _make_client(_FakeAgent(reply="hi back"))
with client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
body = r.json()
assert body["status"] == "completed"
assert body["object"] == "response"
assert body["id"].startswith("resp_")
assert body["output"][0]["content"][0]["text"] == "hi back"
assert len(agent.calls) == 1
def test_invalid_json_returns_400(self) -> None:
client, *_ = _make_client()
with client:
r = client.post("/responses", content=b"{not json", headers={"content-type": "application/json"})
assert r.status_code == 400
def test_invalid_input_returns_422(self) -> None:
client, *_ = _make_client()
with client:
r = client.post("/responses", json={"input": 42})
assert r.status_code == 422
def test_options_propagate_to_target_run(self) -> None:
client, _host, agent = _make_client()
with client:
r = client.post("/responses", json={"input": "x", "temperature": 0.5, "max_output_tokens": 64})
assert r.status_code == 200
opts = agent.calls[0]["kwargs"]["options"]
assert opts == {"temperature": 0.5, "max_tokens": 64}
def test_previous_response_id_creates_session(self) -> None:
client, _host, agent = _make_client()
with client:
client.post("/responses", json={"input": "x", "previous_response_id": "resp_42"})
# AgentFrameworkHost converts the channel session into an AgentSession.
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
# _FakeAgent.create_session stashes the session_id on the dict it returns.
assert sess["session_id"] == "resp_42"
def test_chat_isolation_header_creates_session_when_no_prev_id(self) -> None:
"""Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor.
First-turn requests have no ``previous_response_id`` (the client
doesn't have one yet), but Foundry Hosted Agents always inject
the isolation headers. The channel must derive a session from the
chat key so the host can build a stable per-conversation session
that history providers persist under.
"""
client, _host, agent = _make_client()
with client:
client.post(
"/responses",
json={"input": "x"},
headers={"x-agent-chat-isolation-key": "chat-abc"},
)
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
assert sess["session_id"] == "chat-abc"
def test_prev_response_id_wins_over_chat_isolation_header(self) -> None:
"""When both anchors are present, ``previous_response_id`` wins.
``previous_response_id`` is the protocol-native chain anchor; the
header fallback is only meant to bootstrap when no protocol
anchor exists.
"""
client, _host, agent = _make_client()
with client:
client.post(
"/responses",
json={"input": "x", "previous_response_id": "resp_99"},
headers={"x-agent-chat-isolation-key": "chat-abc"},
)
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
assert sess["session_id"] == "resp_99"
def test_response_target_channel_returns_ack_text_when_pushed(self) -> None:
agent = _FakeAgent(reply="real reply")
push_ch = _RecordingPushChannel()
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(), push_ch])
with TestClient(host.app) as client:
r = client.post(
"/responses",
json={
"input": "hi",
"response_target": "telegram:42",
},
)
assert r.status_code == 200
body = r.json()
text = body["output"][0]["content"][0]["text"]
assert "delivered out-of-band" in text
assert push_ch.pushes and push_ch.pushes[0][1].result.text == "real reply"
assert push_ch.pushes[0][0].native_id == "42"
def test_response_hook_can_rewrite_originating_reply(self) -> None:
contexts: list[Any] = []
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
contexts.append(kwargs["context"])
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
agent = _FakeAgent(reply="hooked")
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(response_hook=hook)])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
body = r.json()
assert body["output"][0]["content"][0]["text"] == "HOOKED"
assert contexts
assert contexts[0].channel_name == "responses"
assert contexts[0].originating is True
assert contexts[0].destination_identity is None
class TestResponsesChannelStreaming:
def test_sse_emits_created_delta_completed(self) -> None:
agent = _FakeAgent(reply="hello world", chunks=["hello", " ", "world"])
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
body = r.text
# SSE event lines look like "event: <type>\ndata: <json>\n\n".
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
assert events[0] == "response.created"
assert events[-1] == "response.completed"
assert events.count("response.output_text.delta") == 3
def test_sse_transform_hook_can_rewrite_chunks(self) -> None:
agent = _FakeAgent(reply="hello", chunks=["he", "llo"])
def transform(update: _FakeUpdate) -> _FakeUpdate:
return _FakeUpdate(text=update.text.upper())
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_transform_hook=transform)])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
assert '"delta":"HE"' in r.text
assert '"delta":"LLO"' in r.text
assert '"text":"HELLO"' in r.text
def test_sse_emits_failed_when_stream_raises(self) -> None:
# Regression: ResponseOutputMessage.status only accepts in_progress/
# completed/incomplete, so building an OpenAIResponse with status="failed"
# used to crash with a pydantic ValidationError. The channel must map the
# nested message status to "incomplete" while keeping the top-level
# Response.status="failed".
class _BoomStream:
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
async def _gen() -> AsyncIterator[_FakeUpdate]:
yield _FakeUpdate("partial")
raise RuntimeError("upstream blew up")
return _gen()
async def get_final_response(self) -> _FakeAgentResponse: # pragma: no cover
return _FakeAgentResponse(text="")
class _BoomAgent(_FakeAgent):
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _BoomStream()
raise AssertionError("non-streaming path not exercised here")
host = AgentFrameworkHost(target=_BoomAgent(), channels=[ResponsesChannel()])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
body = r.text
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
assert events[0] == "response.created"
assert events[-1] == "response.failed"
# The failed envelope must serialize cleanly — i.e. no ValidationError raised.
assert "upstream blew up" in body
@@ -0,0 +1,204 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the OpenAI Responses request-body parser."""
from __future__ import annotations
import pytest
from agent_framework_hosting import ResponseTarget, ResponseTargetKind
from agent_framework_hosting_responses import (
messages_from_responses_input,
parse_response_target,
parse_responses_identity,
parse_responses_request,
)
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_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 TestParseResponsesRequest:
def test_instructions_are_forwarded_as_chat_options(self) -> None:
msgs, opts, sess = parse_responses_request({"input": "hi", "instructions": "be brief"})
assert len(msgs) == 1
assert msgs[0].role == "user"
assert msgs[0].text == "hi"
assert opts["instructions"] == "be brief"
assert sess is None
def test_options_passthrough(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "temperature": 0.4, "top_p": 0.9, "tool_choice": "auto"})
assert opts["temperature"] == 0.4
assert opts["top_p"] == 0.9
assert opts["tool_choice"] == "auto"
def test_options_remap(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "max_output_tokens": 256, "parallel_tool_calls": False})
assert opts == {"max_tokens": 256, "allow_multiple_tool_calls": False}
def test_transport_keys_not_forwarded(self) -> None:
_, opts, _ = parse_responses_request({
"input": "x",
"model": "gpt-x",
"stream": True,
"previous_response_id": "r",
})
for key in ("input", "model", "stream", "previous_response_id"):
assert key not in opts
def test_unknown_keys_silently_dropped(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "truncation": "auto", "reasoning": {"effort": "low"}})
assert opts == {}
def test_none_values_dropped(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "temperature": None})
assert "temperature" not in opts
def test_previous_response_id_becomes_session(self) -> None:
_, _, sess = parse_responses_request({"input": "x", "previous_response_id": "resp_42"})
assert sess is not None
assert sess.isolation_key == "resp_42"
class TestParseResponseTarget:
def test_default_originating_when_missing(self) -> None:
assert parse_response_target({}).kind is ResponseTargetKind.ORIGINATING
@pytest.mark.parametrize(
"value,expected_kind",
[
("originating", ResponseTargetKind.ORIGINATING),
("active", ResponseTargetKind.ACTIVE),
("all_linked", ResponseTargetKind.ALL_LINKED),
("none", ResponseTargetKind.NONE),
],
)
def test_bare_string_kinds(self, value: str, expected_kind: ResponseTargetKind) -> None:
assert parse_response_target({"response_target": value}).kind is expected_kind
def test_bare_string_other_becomes_channel(self) -> None:
target = parse_response_target({"response_target": "telegram"})
assert target == ResponseTarget.channel("telegram")
def test_bare_string_with_native_id_becomes_channel(self) -> None:
target = parse_response_target({"response_target": "telegram:42"})
assert target.kind is ResponseTargetKind.CHANNELS
assert target.targets == ("telegram:42",)
def test_list_form(self) -> None:
target = parse_response_target({"response_target": ["telegram:42", "originating"]})
assert target == ResponseTarget.channels(["telegram:42", "originating"])
def test_list_drops_non_strings(self) -> None:
target = parse_response_target({"response_target": ["telegram", 42, ""]})
assert target.targets == ("telegram",)
def test_empty_list_falls_back_to_originating(self) -> None:
target = parse_response_target({"response_target": []})
assert target.kind is ResponseTargetKind.ORIGINATING
def test_dict_with_channels(self) -> None:
target = parse_response_target({"response_target": {"channels": ["a", "b"]}})
assert target == ResponseTarget.channels(["a", "b"])
@pytest.mark.parametrize(
"kind,expected",
[
("active", ResponseTargetKind.ACTIVE),
("all_linked", ResponseTargetKind.ALL_LINKED),
("none", ResponseTargetKind.NONE),
("originating", ResponseTargetKind.ORIGINATING),
],
)
def test_dict_kind(self, kind: str, expected: ResponseTargetKind) -> None:
assert parse_response_target({"response_target": {"kind": kind}}).kind is expected
def test_malformed_falls_back_to_originating(self) -> None:
target = parse_response_target({"response_target": 42})
assert target.kind is ResponseTargetKind.ORIGINATING
class TestParseResponsesIdentity:
def test_safety_identifier_preferred(self) -> None:
ident = parse_responses_identity({"safety_identifier": "abc", "user": "legacy"}, "responses")
assert ident is not None
assert ident.native_id == "abc"
assert ident.channel == "responses"
def test_fallback_to_user(self) -> None:
ident = parse_responses_identity({"user": "legacy"}, "responses")
assert ident is not None
assert ident.native_id == "legacy"
def test_returns_none_when_absent(self) -> None:
assert parse_responses_identity({}, "responses") is None
def test_returns_none_for_non_string(self) -> None:
assert parse_responses_identity({"safety_identifier": 42}, "responses") is None
+18
View File
@@ -48,6 +48,7 @@ members = [
"agent-framework-gemini",
"agent-framework-github-copilot",
"agent-framework-hosting",
"agent-framework-hosting-responses",
"agent-framework-hyperlight",
"agent-framework-lab",
"agent-framework-mem0",
@@ -642,6 +643,23 @@ provides-extras = ["serve", "disk"]
[package.metadata.requires-dev]
dev = [{ name = "httpx", specifier = ">=0.28.1" }]
[[package]]
name = "agent-framework-hosting-responses"
version = "1.0.0a260424"
source = { editable = "packages/hosting-responses" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "agent-framework-hosting", editable = "packages/hosting" },
{ name = "openai", specifier = ">=1.99.0,<3" },
]
[[package]]
name = "agent-framework-hyperlight"
version = "1.0.0b260521"