@@ -116,7 +116,7 @@ async def convert_chat_choice_to_response_message(
116116 """Convert an OpenAI Chat Completion choice into an OpenAI Response output message."""
117117 output_content = choice .message .content or ""
118118
119- annotations , clean_text = _extract_citations_from_text (output_content , citation_files or {})
119+ annotations , clean_text = extract_citations_from_text (output_content , citation_files or {})
120120 logprobs = choice .logprobs .content if choice .logprobs and choice .logprobs .content else []
121121
122122 return OpenAIResponseMessage (
@@ -480,19 +480,41 @@ async def get_message_type_by_role(role: str) -> type[OpenAIMessageParam] | None
480480 return role_to_type .get (role ) # type: ignore[return-value] # Pydantic models use ModelMetaclass
481481
482482
483+ CITATION_MARKER_REGEX = re .compile (
484+ r"<\|(?P<file_id_pipe>file-[A-Za-z0-9_-]+)\|>"
485+ r"|\[(?P<file_id_bracket>file-[A-Za-z0-9_-]+)\]"
486+ r"|\((?P<file_id_paren>file-[A-Za-z0-9_-]+)\)"
487+ )
488+
489+ # Matches an in-progress citation marker at the very end of a string, including a possible
490+ # single space right before it (since a known marker's preceding space gets dropped by
491+ # _extract_citations_from_text, and that only happens correctly if the space and the
492+ # marker end up cleaned together — see StreamingCitationCleaner). Also matches a bare
493+ # trailing space on its own, since it might turn out to precede a marker in the next
494+ # chunk. Used to withhold text from streamed deltas until either a marker completes (and
495+ # gets cleaned) or a later chunk proves it wasn't a marker after all (and gets flushed
496+ # through as literal text).
497+ _PENDING_CITATION_MARKER_TAIL_REGEX = re .compile (
498+ r"(?: ?<(?:\|(?:file-[A-Za-z0-9_-]*)?)?| ?\[(?:file-[A-Za-z0-9_-]*)?| ?\((?:file-[A-Za-z0-9_-]*)?| )$"
499+ )
500+
501+
483502def _extract_citations_from_text (
484503 text : str , citation_files : dict [str , str ]
485504) -> tuple [list [OpenAIResponseAnnotationFileCitation ], str ]:
486505 """Extract citation markers from text and create annotations
487506
488507 Args:
489- text: The text containing citation markers like [file-Cn3MSNn72ENTiiq11Qda4A]
508+ text: The text containing citation markers like <|file-Cn3MSNn72ENTiiq11Qda4A|>.
509+ The primary marker format is `<|file-id|>`, but `[file-id]` and `(file-id)`
510+ are also accepted since weaker models often approximate the instructed
511+ format rather than reproduce it exactly.
490512 citation_files: Dictionary mapping file_id to filename
491513
492514 Returns:
493515 Tuple of (annotations_list, clean_text_without_markers)
494516 """
495- file_id_regex = re . compile ( r"<\|(?P<file_id>file-[A-Za-z0-9_-]+)\|>" )
517+ file_id_regex = CITATION_MARKER_REGEX
496518
497519 annotations = []
498520 parts = []
@@ -503,22 +525,32 @@ def _extract_citations_from_text(
503525 # segment before the marker
504526 prefix = text [last_end : m .start ()]
505527
506- # drop one space if it exists (since marker is at sentence end)
507- if prefix .endswith (" " ):
528+ fid = m .group ("file_id_pipe" ) or m .group ("file_id_bracket" ) or m .group ("file_id_paren" )
529+ is_known = fid in citation_files
530+
531+ # drop one space if it exists (since marker is at sentence end); only do this when
532+ # the marker itself is about to be removed below, otherwise we'd merge the prefix
533+ # and the marker text together with no space between them
534+ if is_known and prefix .endswith (" " ):
508535 prefix = prefix [:- 1 ]
509536
510537 parts .append (prefix )
511538 total_len += len (prefix )
512539
513- fid = m .group (1 )
514- if fid in citation_files :
540+ if is_known :
515541 annotations .append (
516542 OpenAIResponseAnnotationFileCitation (
517543 file_id = fid ,
518544 filename = citation_files [fid ],
519545 index = total_len , # index points to punctuation
520546 )
521547 )
548+ else :
549+ # Unrecognized marker (e.g. a stale/mismatched file id): preserve it verbatim
550+ # rather than silently deleting user-visible text we can't actually attribute.
551+ marker_text = m .group (0 )
552+ parts .append (marker_text )
553+ total_len += len (marker_text )
522554
523555 last_end = m .end ()
524556
@@ -527,6 +559,78 @@ def _extract_citations_from_text(
527559 return annotations , cleaned_text
528560
529561
562+ def extract_citations_from_text (
563+ text : str , citation_files : dict [str , str ]
564+ ) -> tuple [list [OpenAIResponseAnnotationFileCitation ], str ]:
565+ """Extract citation markers from text, with a fallback for models that don't cite inline.
566+
567+ Delegates to `_extract_citations_from_text` for marker-based extraction. Some models
568+ (particularly small/local ones served e.g. via Ollama) don't reliably reproduce the
569+ inline citation marker even when instructed to. If file_search actually retrieved
570+ documents for this response, attribute the answer to the single most relevant one
571+ rather than silently returning no annotations just because the model didn't echo the
572+ marker. Attributing every retrieved file would imply the whole answer draws equally
573+ on all of them, which usually isn't true and isn't what OpenAI's API does.
574+
575+ Args:
576+ text: The text possibly containing citation markers.
577+ citation_files: Dictionary mapping file_id to filename for files retrieved this turn,
578+ ordered by descending relevance score (see tool_executor.py).
579+
580+ Returns:
581+ Tuple of (annotations_list, clean_text_without_markers)
582+ """
583+ annotations , clean_text = _extract_citations_from_text (text , citation_files )
584+ if not annotations and citation_files :
585+ file_id , filename = next (iter (citation_files .items ()))
586+ annotations = [OpenAIResponseAnnotationFileCitation (file_id = file_id , filename = filename , index = len (clean_text ))]
587+ return annotations , clean_text
588+
589+
590+ class StreamingCitationCleaner :
591+ """Incrementally strips citation markers from streamed text deltas.
592+
593+ content_part.done / output_item.done events clean the fully accumulated text via
594+ `extract_citations_from_text`. Without this, delta events would carry the raw,
595+ unprocessed text (markers and all), so a client that reconstructs output purely from
596+ deltas would end up disagreeing with the final payload. Feeding every delta through
597+ this cleaner keeps the two consistent.
598+
599+ Markers can be split across chunk boundaries (e.g. one chunk ends in "<|file-abc" and
600+ the next starts with "123|>"), so a marker-looking sequence at the end of the buffered
601+ text is withheld until it either completes (and gets cleaned) or a later feed()/flush()
602+ call proves it wasn't a marker after all (and gets emitted as literal text).
603+
604+ Note: this only cleans complete markers, so if a space that would normally be dropped
605+ before a marker (see `_extract_citations_from_text`) lands in a different feed() call
606+ than the marker itself, that single space is not retroactively removed. This is a
607+ minor cosmetic difference from the final text, not a correctness issue.
608+ """
609+
610+ def __init__ (self , citation_files : dict [str , str ]):
611+ self ._citation_files = citation_files
612+ self ._buffer = ""
613+
614+ def feed (self , delta : str ) -> str :
615+ """Feed newly arrived raw text; return the portion now safe to emit to the client."""
616+ self ._buffer += delta
617+ pending_match = _PENDING_CITATION_MARKER_TAIL_REGEX .search (self ._buffer )
618+ safe_upto = pending_match .start () if pending_match else len (self ._buffer )
619+ safe_text , self ._buffer = self ._buffer [:safe_upto ], self ._buffer [safe_upto :]
620+ if not safe_text :
621+ return ""
622+ _ , cleaned = _extract_citations_from_text (safe_text , self ._citation_files )
623+ return cleaned
624+
625+ def flush (self ) -> str :
626+ """Flush any remaining buffered text once no more input is coming this round."""
627+ if not self ._buffer :
628+ return ""
629+ _ , cleaned = _extract_citations_from_text (self ._buffer , self ._citation_files )
630+ self ._buffer = ""
631+ return cleaned
632+
633+
530634def is_function_tool_call (
531635 tool_call : OpenAIChatCompletionToolCall ,
532636 tools : list [OpenAIResponseInputTool ],
0 commit comments