Description
When a Python MAF agent is served through agent_framework_foundry_hosting.ResponsesHostServer, the OpenAI-compatible /responses result omits response.usage from the terminal event. This affects both streaming and non-streaming requests, regular agents and workflow agents.
The underlying MAF response data already carries usage:
- Non-streaming
AgentResponse exposes usage_details.
- Streaming providers emit
Content(type="usage", usage_details=...), which ChatResponse.from_updates normally aggregates.
azure-ai-agentserver-responses supports ResponseEventStream.emit_completed(usage=...) and emit_failed(..., usage=...).
However, Foundry Hosting never passes usage to those terminal event builders.
On current main at ce5ee8a9c711288c59d411782e9c393d020d54f4:
- The non-streaming regular-agent path obtains the complete response, but converts only
response.messages; response.usage_details is ignored. It then calls emit_completed() without usage:
|
if not is_streaming_request: |
|
# Run the agent in non-streaming mode |
|
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType] |
|
|
|
async for item in _to_outputs_for_messages( |
|
response_event_stream, |
|
response.messages, |
|
approval_storage=approval_storage, |
|
): |
|
yield item |
|
else: |
|
if tracker is None: # pragma: no cover - defensive, set above |
|
raise RuntimeError("Streaming tracker was not initialized.") |
|
# Run the agent in streaming mode |
|
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType] |
|
for content in update.contents: |
|
for event in tracker.handle(content): |
|
yield event |
|
if tracker.needs_async: |
|
async for item in _to_outputs( |
|
response_event_stream, |
|
content, |
|
approval_storage=approval_storage, |
|
): |
|
yield item |
|
tracker.needs_async = False |
|
|
|
# Close any remaining active builder |
|
for event in tracker.close(): |
|
yield event |
|
yield response_event_stream.emit_completed() |
- The streaming regular-agent path processes every
update.contents item but has no usage accumulator, then also calls emit_completed() without usage:
|
else: |
|
if tracker is None: # pragma: no cover - defensive, set above |
|
raise RuntimeError("Streaming tracker was not initialized.") |
|
# Run the agent in streaming mode |
|
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType] |
|
for content in update.contents: |
|
for event in tracker.handle(content): |
|
yield event |
|
if tracker.needs_async: |
|
async for item in _to_outputs( |
|
response_event_stream, |
|
content, |
|
approval_storage=approval_storage, |
|
): |
|
yield item |
|
tracker.needs_async = False |
|
|
|
# Close any remaining active builder |
|
for event in tracker.close(): |
|
yield event |
|
yield response_event_stream.emit_completed() |
- Workflow-agent paths have the same omission:
|
if not is_streaming_request: |
|
# Run the agent in non-streaming mode with the new user input. |
|
response = await self._agent.run( |
|
input_messages, |
|
stream=False, |
|
checkpoint_storage=write_storage, |
|
) |
|
|
|
async for item in _to_outputs_for_messages( |
|
response_event_stream, |
|
response.messages, |
|
approval_storage=approval_storage, |
|
): |
|
yield item |
|
|
|
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name) |
|
yield response_event_stream.emit_completed() |
|
return |
|
|
|
tracker = _OutputItemTracker(response_event_stream) |
|
|
|
# Run the workflow agent in streaming mode with the new user input. |
|
async for update in self._agent.run( |
|
input_messages, |
|
stream=True, |
|
checkpoint_storage=write_storage, |
|
): |
|
for content in update.contents: |
|
for event in tracker.handle(content): |
|
yield event |
|
if tracker.needs_async: |
|
async for item in _to_outputs( |
|
response_event_stream, content, approval_storage=approval_storage |
|
): |
|
yield item |
|
tracker.needs_async = False |
|
|
|
# Close any remaining active builder |
|
for event in tracker.close(): |
|
yield event |
|
|
|
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name) |
|
yield response_event_stream.emit_completed() |
- The failure path calls
emit_failed(message=...) without any accumulated usage:
|
def _emit_failure( |
|
response_event_stream: ResponseEventStream, |
|
tracker: _OutputItemTracker | None, |
|
ex: BaseException, |
|
) -> Generator[ResponseStreamEvent]: |
|
"""Yield a terminal ``response.failed`` event for ``ex``. |
|
|
|
Drains any in-progress streaming output item first so the resulting |
|
SSE stream stays well-formed, then emits ``response.failed`` carrying |
|
the exception's message (falling back to the exception type name when |
|
``str(ex)`` is empty). Any error raised while draining the tracker is |
|
logged and otherwise ignored so that the original failure is always |
|
what the client sees. |
|
""" |
|
if tracker is not None: |
|
try: |
|
yield from tracker.close() |
|
except Exception: |
|
logger.exception("Error while closing streaming tracker after failure") |
|
message = str(ex) or type(ex).__name__ |
|
yield response_event_stream.emit_failed(message=message) |
MAF's existing usage-content aggregation can be seen here:
|
case "usage": |
|
if response.usage_details is None: |
|
response.usage_details = UsageDetails() |
|
# mypy doesn't narrow type based on match/case, but we know this is UsageContent |
|
response.usage_details = add_usage_details(response.usage_details, content.usage_details) |
Expected: the terminal response includes aggregate usage:
{
"type": "response.completed",
"response": {
"usage": {
"input_tokens": 100,
"input_tokens_details": {
"cache_write_tokens": 0,
"cached_tokens": 0
},
"output_tokens": 25,
"output_tokens_details": {
"reasoning_tokens": 0
},
"total_tokens": 125
}
}
}
Actual: the terminal response.completed.response has no usage key.
This prevents OpenAI-compatible clients of a Foundry-hosted MAF agent from performing token accounting, cost estimation, cache analysis, or reasoning-token analysis.
The .NET Foundry Hosting implementation already follows the expected pattern: it accumulates UsageContent and passes the result to EmitCompleted(accumulatedUsage).
Code Sample
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url=HOSTED_AGENT_ENDPOINT, api_key=API_KEY)
stream = await client.responses.create(
model="my-agent",
input="Hello",
stream=True,
)
async for event in stream:
if event.type == "response.completed":
print(event.response.usage) # None; the wire payload omits "usage"
Error Messages / Stack Traces
No error is raised. The response succeeds but silently omits usage.
Package Versions
agent-framework-foundry-hosting: current main at ce5ee8a9c711288c59d411782e9c393d020d54f4; azure-ai-agentserver-responses: 1.0.0b8
Python Version
Python 3.12
Additional Context
A fix should:
- Convert MAF
UsageDetails fields to the Responses ResponseUsage schema.
- Preserve
input_token_count, output_token_count, total_token_count, cache_creation_input_token_count, cache_read_input_token_count, and reasoning_output_token_count.
- Accumulate usage across streaming updates, tool-loop iterations, and workflow executors.
- Pass accumulated usage to successful and failed terminal events when available.
- Add assertions for the serialized numeric usage payload, not only the terminal event type.
Related but not duplicate:
Description
When a Python MAF agent is served through
agent_framework_foundry_hosting.ResponsesHostServer, the OpenAI-compatible/responsesresult omitsresponse.usagefrom the terminal event. This affects both streaming and non-streaming requests, regular agents and workflow agents.The underlying MAF response data already carries usage:
AgentResponseexposesusage_details.Content(type="usage", usage_details=...), whichChatResponse.from_updatesnormally aggregates.azure-ai-agentserver-responsessupportsResponseEventStream.emit_completed(usage=...)andemit_failed(..., usage=...).However, Foundry Hosting never passes usage to those terminal event builders.
On current
mainatce5ee8a9c711288c59d411782e9c393d020d54f4:response.messages;response.usage_detailsis ignored. It then callsemit_completed()without usage:agent-framework/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py
Lines 622 to 652 in ce5ee8a
update.contentsitem but has no usage accumulator, then also callsemit_completed()without usage:agent-framework/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py
Lines 632 to 652 in ce5ee8a
agent-framework/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py
Lines 772 to 814 in ce5ee8a
emit_failed(message=...)without any accumulated usage:agent-framework/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py
Lines 834 to 854 in ce5ee8a
MAF's existing usage-content aggregation can be seen here:
agent-framework/python/packages/core/agent_framework/_types.py
Lines 1982 to 1986 in ce5ee8a
Expected: the terminal response includes aggregate usage:
{ "type": "response.completed", "response": { "usage": { "input_tokens": 100, "input_tokens_details": { "cache_write_tokens": 0, "cached_tokens": 0 }, "output_tokens": 25, "output_tokens_details": { "reasoning_tokens": 0 }, "total_tokens": 125 } } }Actual: the terminal
response.completed.responsehas nousagekey.This prevents OpenAI-compatible clients of a Foundry-hosted MAF agent from performing token accounting, cost estimation, cache analysis, or reasoning-token analysis.
The .NET Foundry Hosting implementation already follows the expected pattern: it accumulates
UsageContentand passes the result toEmitCompleted(accumulatedUsage).Code Sample
Error Messages / Stack Traces
No error is raised. The response succeeds but silently omits usage.
Package Versions
agent-framework-foundry-hosting: current main atce5ee8a9c711288c59d411782e9c393d020d54f4;azure-ai-agentserver-responses:1.0.0b8Python Version
Python 3.12
Additional Context
A fix should:
UsageDetailsfields to the ResponsesResponseUsageschema.input_token_count,output_token_count,total_token_count,cache_creation_input_token_count,cache_read_input_token_count, andreasoning_output_token_count.Related but not duplicate:
usageandfinish_reasonwhen a response is truncated atmax_output_tokens#7051 covered usage loss while PythonFoundryChatClientconsumed an incomplete upstream response, not while Python Foundry Hosting produced/responsesevents.