Skip to content

.NET: Python: [Bug]: Foundry Hosting omits usage from /responses terminal events #7416

Description

@herohua

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:

  1. 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()
  2. 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()
  3. 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()
  4. 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:

Metadata

Metadata

Assignees

Labels

.NETUsage: [Issues, PRs], Target: .NethostingUsage: [Issues, PRs], Target: all hosting related solutionspythonUsage: [Issues, PRs], Target: Python

Type

No type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions