2727 ResponseTextDeltaEvent ,
2828 ResponseTraceEventComplete ,
2929 ResponseUsage ,
30- ResponseUsageEventComplete ,
3130 ResponseWorkflowEventComplete ,
3231)
3332
3736EventType = Union [
3837 ResponseStreamEvent ,
3938 ResponseWorkflowEventComplete ,
40- ResponseFunctionResultComplete ,
39+ ResponseOutputItemAddedEvent ,
4140 ResponseTraceEventComplete ,
42- ResponseUsageEventComplete ,
4341]
4442
4543
@@ -56,6 +54,9 @@ def __init__(self, max_contexts: int = 1000) -> None:
5654 self ._conversion_contexts : OrderedDict [int , dict [str , Any ]] = OrderedDict ()
5755 self ._max_contexts = max_contexts
5856
57+ # Track usage per request for final Response.usage (OpenAI standard)
58+ self ._usage_accumulator : dict [str , dict [str , int ]] = {}
59+
5960 # Register content type mappers for all 12 Agent Framework content types
6061 self .content_mappers = {
6162 "TextContent" : self ._map_text_content ,
@@ -171,17 +172,31 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame
171172 status = "completed" ,
172173 )
173174
174- # Create usage object
175- input_token_count = len (str (request .input )) // 4 if request .input else 0
176- output_token_count = len (full_content ) // 4
177-
178- usage = ResponseUsage (
179- input_tokens = input_token_count ,
180- output_tokens = output_token_count ,
181- total_tokens = input_token_count + output_token_count ,
182- input_tokens_details = InputTokensDetails (cached_tokens = 0 ),
183- output_tokens_details = OutputTokensDetails (reasoning_tokens = 0 ),
184- )
175+ # Get usage from accumulator (OpenAI standard)
176+ request_id = str (id (request ))
177+ usage_data = self ._usage_accumulator .get (request_id )
178+
179+ if usage_data :
180+ usage = ResponseUsage (
181+ input_tokens = usage_data ["input_tokens" ],
182+ output_tokens = usage_data ["output_tokens" ],
183+ total_tokens = usage_data ["total_tokens" ],
184+ input_tokens_details = InputTokensDetails (cached_tokens = 0 ),
185+ output_tokens_details = OutputTokensDetails (reasoning_tokens = 0 ),
186+ )
187+ # Cleanup accumulator
188+ del self ._usage_accumulator [request_id ]
189+ else :
190+ # Fallback: estimate if no usage was tracked
191+ input_token_count = len (str (request .input )) // 4 if request .input else 0
192+ output_token_count = len (full_content ) // 4
193+ usage = ResponseUsage (
194+ input_tokens = input_token_count ,
195+ output_tokens = output_token_count ,
196+ total_tokens = input_token_count + output_token_count ,
197+ input_tokens_details = InputTokensDetails (cached_tokens = 0 ),
198+ output_tokens_details = OutputTokensDetails (reasoning_tokens = 0 ),
199+ )
185200
186201 return OpenAIResponse (
187202 id = f"resp_{ uuid .uuid4 ().hex [:12 ]} " ,
@@ -229,6 +244,7 @@ def _get_or_create_context(self, request: AgentFrameworkRequest) -> dict[str, An
229244 "item_id" : f"msg_{ uuid .uuid4 ().hex [:8 ]} " ,
230245 "content_index" : 0 ,
231246 "output_index" : 0 ,
247+ "request_id" : str (request_key ), # For usage accumulation
232248 # Track active function calls: {call_id: {name, item_id, args_chunks}}
233249 "active_function_calls" : {},
234250 }
@@ -272,10 +288,11 @@ async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> S
272288
273289 if content_type in self .content_mappers :
274290 mapped_events = await self .content_mappers [content_type ](content , context )
275- if isinstance (mapped_events , list ):
276- events .extend (mapped_events )
277- else :
278- events .append (mapped_events )
291+ if mapped_events is not None : # Handle None returns (e.g., UsageContent)
292+ if isinstance (mapped_events , list ):
293+ events .extend (mapped_events )
294+ else :
295+ events .append (mapped_events )
279296 else :
280297 # Graceful fallback for unknown content types
281298 events .append (await self ._create_unknown_content_event (content , context ))
@@ -315,10 +332,11 @@ async def _convert_agent_response(self, response: Any, context: dict[str, Any])
315332
316333 if content_type in self .content_mappers :
317334 mapped_events = await self .content_mappers [content_type ](content , context )
318- if isinstance (mapped_events , list ):
319- events .extend (mapped_events )
320- else :
321- events .append (mapped_events )
335+ if mapped_events is not None : # Handle None returns (e.g., UsageContent)
336+ if isinstance (mapped_events , list ):
337+ events .extend (mapped_events )
338+ else :
339+ events .append (mapped_events )
322340 else :
323341 # Graceful fallback for unknown content types
324342 events .append (await self ._create_unknown_content_event (content , context ))
@@ -331,8 +349,8 @@ async def _convert_agent_response(self, response: Any, context: dict[str, Any])
331349 from agent_framework import UsageContent
332350
333351 usage_content = UsageContent (details = usage_details )
334- usage_event = await self ._map_usage_content (usage_content , context )
335- events . append ( usage_event )
352+ await self ._map_usage_content (usage_content , context )
353+ # Note: _map_usage_content returns None - it accumulates usage for final Response.usage
336354
337355 except Exception as e :
338356 logger .warning (f"Error converting agent response: { e } " )
@@ -506,7 +524,11 @@ def _get_active_function_call(self, content: Any, context: dict[str, Any]) -> di
506524 async def _map_function_result_content (
507525 self , content : Any , context : dict [str , Any ]
508526 ) -> ResponseFunctionResultComplete :
509- """Map FunctionResultContent to structured event.
527+ """Map FunctionResultContent to custom DevUI event.
528+
529+ This is a DevUI extension - OpenAI doesn't stream function execution results
530+ because in their model, applications execute functions, not the API.
531+ Agent Framework executes functions, so we emit this event for debugging visibility.
510532
511533 IMPORTANT: Always use Agent Framework's call_id from the content.
512534 Do NOT generate a new call_id - it must match the one from the function call event.
@@ -518,16 +540,22 @@ async def _map_function_result_content(
518540 logger .warning ("FunctionResultContent missing call_id - this will break call/result pairing" )
519541 call_id = f"call_{ uuid .uuid4 ().hex [:8 ]} " # Fallback only if truly missing
520542
543+ # Extract result
544+ result = getattr (content , "result" , None )
545+ exception = getattr (content , "exception" , None )
546+
547+ # Convert result to string
548+ output = result if isinstance (result , str ) else json .dumps (result ) if result is not None else ""
549+
550+ # Determine status
551+ status = "incomplete" if exception else "completed"
552+
553+ # Return custom DevUI event
521554 return ResponseFunctionResultComplete (
522555 type = "response.function_result.complete" ,
523- data = {
524- "call_id" : call_id ,
525- "result" : getattr (content , "result" , None ),
526- "status" : "completed" if not getattr (content , "exception" , None ) else "failed" ,
527- "exception" : str (getattr (content , "exception" , None )) if getattr (content , "exception" , None ) else None ,
528- "timestamp" : datetime .now ().isoformat (),
529- },
530556 call_id = call_id ,
557+ output = output ,
558+ status = status ,
531559 item_id = context ["item_id" ],
532560 output_index = context ["output_index" ],
533561 sequence_number = self ._next_sequence (context ),
@@ -543,37 +571,34 @@ async def _map_error_content(self, content: Any, context: dict[str, Any]) -> Res
543571 sequence_number = self ._next_sequence (context ),
544572 )
545573
546- async def _map_usage_content (self , content : Any , context : dict [str , Any ]) -> ResponseUsageEventComplete :
547- """Map UsageContent to structured usage event."""
548- # Store usage data in context for aggregation
549- if "usage_data" not in context :
550- context ["usage_data" ] = []
551- context ["usage_data" ].append (content )
574+ async def _map_usage_content (self , content : Any , context : dict [str , Any ]) -> None :
575+ """Accumulate usage data for final Response.usage field.
552576
577+ OpenAI does NOT stream usage events. Usage appears only in final Response.
578+ This method accumulates usage data per request for later inclusion in Response.usage.
579+
580+ Returns:
581+ None - no event emitted (usage goes in final Response.usage)
582+ """
553583 # Extract usage from UsageContent.details (UsageDetails object)
554584 details = getattr (content , "details" , None )
555- total_tokens = 0
556- prompt_tokens = 0
557- completion_tokens = 0
585+ total_tokens = getattr ( details , "total_token_count" , 0 ) or 0
586+ prompt_tokens = getattr ( details , "input_token_count" , 0 ) or 0
587+ completion_tokens = getattr ( details , "output_token_count" , 0 ) or 0
558588
559- if details :
560- total_tokens = getattr ( details , "total_token_count " , 0 ) or 0
561- prompt_tokens = getattr ( details , "input_token_count" , 0 ) or 0
562- completion_tokens = getattr ( details , "output_token_count" , 0 ) or 0
589+ # Accumulate for final Response.usage
590+ request_id = context . get ( "request_id " , "default" )
591+ if request_id not in self . _usage_accumulator :
592+ self . _usage_accumulator [ request_id ] = { "input_tokens" : 0 , "output_tokens" : 0 , "total_tokens" : 0 }
563593
564- return ResponseUsageEventComplete (
565- type = "response.usage.complete" ,
566- data = {
567- "usage_data" : details .to_dict () if details and hasattr (details , "to_dict" ) else {},
568- "total_tokens" : total_tokens ,
569- "completion_tokens" : completion_tokens ,
570- "prompt_tokens" : prompt_tokens ,
571- "timestamp" : datetime .now ().isoformat (),
572- },
573- item_id = context ["item_id" ],
574- output_index = context ["output_index" ],
575- sequence_number = self ._next_sequence (context ),
576- )
594+ self ._usage_accumulator [request_id ]["input_tokens" ] += prompt_tokens
595+ self ._usage_accumulator [request_id ]["output_tokens" ] += completion_tokens
596+ self ._usage_accumulator [request_id ]["total_tokens" ] += total_tokens
597+
598+ logger .debug (f"Accumulated usage for { request_id } : { self ._usage_accumulator [request_id ]} " )
599+
600+ # NO EVENT RETURNED - usage goes in final Response only
601+ return
577602
578603 async def _map_data_content (self , content : Any , context : dict [str , Any ]) -> ResponseTraceEventComplete :
579604 """Map DataContent to structured trace event."""
0 commit comments