11import asyncio
2+ from collections import defaultdict
23import json
34from collections .abc import AsyncIterator
45from datetime import datetime
3637from openai .types .responses .response_output_text import (
3738 Annotation as ResponsesAnnotation ,
3839)
40+ from openai .types .responses .response_output_text import (
41+ AnnotationContainerFileCitation ,
42+ AnnotationFileCitation ,
43+ AnnotationFilePath ,
44+ AnnotationURLCitation ,
45+ )
3946from pydantic import BaseModel , ConfigDict , SkipValidation , TypeAdapter
4047from typing_extensions import assert_never
4148
4552 Annotation ,
4653 AssistantMessageContent ,
4754 AssistantMessageContentPartAdded ,
55+ AssistantMessageContentPartAnnotationAdded ,
4856 AssistantMessageContentPartDone ,
4957 AssistantMessageContentPartTextDelta ,
5058 AssistantMessageItem ,
@@ -207,9 +215,8 @@ def _complete(self):
207215
208216def _convert_content (content : Content ) -> AssistantMessageContent :
209217 if content .type == "output_text" :
210- annotations = []
211- for annotation in content .annotations :
212- annotations .extend (_convert_annotation (annotation ))
218+ annotations = [_convert_annotation (annotation ) for annotation in content .annotations ]
219+ annotations = [a for a in annotations if a is not None ]
213220 return AssistantMessageContent (
214221 text = content .text ,
215222 annotations = annotations ,
@@ -222,36 +229,47 @@ def _convert_content(content: Content) -> AssistantMessageContent:
222229
223230
224231def _convert_annotation (
225- annotation : ResponsesAnnotation ,
226- ) -> list [ Annotation ] :
232+ raw_annotation : object
233+ ) -> Annotation | None :
227234 # There is a bug in the OpenAPI client that sometimes parses the annotation delta event into the wrong class
228- # resulting into annotation being a dict instead of a ResponsesAnnotation
229- if isinstance (annotation , dict ):
230- annotation = TypeAdapter (ResponsesAnnotation ).validate_python (annotation )
235+ # resulting into annotation being a dict.
236+ match raw_annotation :
237+ case AnnotationFileCitation () | AnnotationURLCitation () | AnnotationContainerFileCitation () | AnnotationFilePath ():
238+ annotation = raw_annotation
239+ case _:
240+ annotation = TypeAdapter [ResponsesAnnotation ](ResponsesAnnotation ).validate_python (raw_annotation )
241+
231242
232- result : list [Annotation ] = []
233243 if annotation .type == "file_citation" :
234244 filename = annotation .filename
235245 if not filename :
236- return []
237- result . append (
238- Annotation (
246+ return None
247+
248+ return Annotation (
239249 source = FileSource (filename = filename , title = filename ),
240250 index = annotation .index ,
241251 )
252+
253+ if annotation .type == "url_citation" :
254+ return Annotation (
255+ source = URLSource (
256+ url = annotation .url ,
257+ title = annotation .title ,
258+ ),
259+ index = annotation .end_index ,
242260 )
243- elif annotation .type == "url_citation" :
244- result .append (
245- Annotation (
246- source = URLSource (
247- url = annotation .url ,
248- title = annotation .title ,
249- ),
261+
262+ if annotation .type == "container_file_citation" :
263+ filename = annotation .filename
264+ if not filename :
265+ return None
266+
267+ return Annotation (
268+ source = FileSource (filename = filename , title = filename ),
250269 index = annotation .end_index ,
251270 )
252- )
253271
254- return result
272+ return None
255273
256274
257275T1 = TypeVar ("T1" )
@@ -349,6 +367,8 @@ async def stream_agent_response(
349367 queue_iterator = _AsyncQueueIterator (context ._events )
350368 produced_items = set ()
351369 streaming_thought : None | StreamingThoughtTracker = None
370+ # item_id -> content_index -> annotation count
371+ item_annotation_count : defaultdict [str , defaultdict [int , int ]] = defaultdict (lambda : defaultdict (int ))
352372
353373 # check if the last item in the thread was a workflow or a client tool call
354374 # if it was a client tool call, check if the second last item was a workflow
@@ -462,7 +482,20 @@ def end_workflow(item: WorkflowItem):
462482 ),
463483 )
464484 elif event .type == "response.output_text.annotation.added" :
465- # Ignore annotation-added events; annotations are reflected in the final item content.
485+ annotation = _convert_annotation (event .annotation )
486+ if annotation :
487+ # Manually track annotation indices per content part in case we drop an annotation that
488+ # we can't convert to our internal representation (e.g. missing filename).
489+ annotation_index = item_annotation_count [event .item_id ][event .content_index ]
490+ item_annotation_count [event .item_id ][event .content_index ] = annotation_index + 1
491+ yield ThreadItemUpdated (
492+ item_id = event .item_id ,
493+ update = AssistantMessageContentPartAnnotationAdded (
494+ content_index = event .content_index ,
495+ annotation_index = annotation_index ,
496+ annotation = annotation ,
497+ )
498+ )
466499 continue
467500 elif event .type == "response.output_item.added" :
468501 item = event .item
0 commit comments