Skip to content

Commit b0d9535

Browse files
authored
Merge branch 'main' into feat/httpx-connection-pool-config
2 parents 13d3e31 + 4001d57 commit b0d9535

7 files changed

Lines changed: 567 additions & 26 deletions

File tree

src/ogx/providers/inline/responses/builtin/responses/streaming.py

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,10 @@
113113
ChatCompletionResult,
114114
)
115115
from .utils import (
116+
StreamingCitationCleaner,
116117
convert_chat_choice_to_response_message,
117118
convert_mcp_tool_choice,
119+
extract_citations_from_text,
118120
is_function_tool_call,
119121
run_guardrails,
120122
should_summarize_reasoning,
@@ -1103,6 +1105,10 @@ async def _process_streaming_chunks(
11031105
refusal_text_accumulated = []
11041106
pending_guardrail_events: list[OpenAIResponseObjectStream] = []
11051107
chars_since_last_check = 0
1108+
# Cleans citation markers out of delta text as it streams, so a client
1109+
# reconstructing output from deltas sees the same text as content_part.done /
1110+
# output_item.done (which clean the fully accumulated text once streaming ends).
1111+
citation_cleaner = StreamingCitationCleaner(self.citation_files)
11061112

11071113
async for raw_chunk in completion_result:
11081114
# Providers returning OpenAIChatCompletionChunkWithReasoning wrap
@@ -1165,21 +1171,27 @@ async def _process_streaming_chunks(
11651171
),
11661172
sequence_number=self.sequence_number,
11671173
)
1168-
self.sequence_number += 1
1174+
# Withhold citation markers (and any text that might still turn into
1175+
# one) from the delta stream, so it stays consistent with the cleaned
1176+
# text in content_part.done / output_item.done below. A marker split
1177+
# across chunk boundaries can cause this to yield nothing for a chunk.
1178+
cleaned_delta = citation_cleaner.feed(chunk_choice.delta.content)
1179+
if cleaned_delta:
1180+
self.sequence_number += 1
11691181

1170-
text_delta_event = OpenAIResponseObjectStreamResponseOutputTextDelta(
1171-
content_index=content_index,
1172-
delta=chunk_choice.delta.content,
1173-
item_id=message_item_id,
1174-
logprobs=chunk_logprobs if chunk_logprobs is not None else [],
1175-
output_index=message_output_index,
1176-
sequence_number=self.sequence_number,
1177-
)
1178-
# Buffer text delta events for guardrail check
1179-
if self.enable_guardrails:
1180-
pending_guardrail_events.append(text_delta_event)
1181-
else:
1182-
yield text_delta_event
1182+
text_delta_event = OpenAIResponseObjectStreamResponseOutputTextDelta(
1183+
content_index=content_index,
1184+
delta=cleaned_delta,
1185+
item_id=message_item_id,
1186+
logprobs=chunk_logprobs if chunk_logprobs is not None else [],
1187+
output_index=message_output_index,
1188+
sequence_number=self.sequence_number,
1189+
)
1190+
# Buffer text delta events for guardrail check
1191+
if self.enable_guardrails:
1192+
pending_guardrail_events.append(text_delta_event)
1193+
else:
1194+
yield text_delta_event
11831195

11841196
# Collect content for final response
11851197
content_delta = chunk_choice.delta.content or ""
@@ -1368,15 +1380,34 @@ async def _process_streaming_chunks(
13681380

13691381
# Emit content_part.done event if text content was streamed (before content gets cleared)
13701382
if content_part_emitted:
1383+
# Flush any text the citation cleaner was still withholding (e.g. a marker-like
1384+
# sequence that never completed) so delta-reconstructed text catches up with
1385+
# the cleaned text below before the round closes out. Guardrail buffering
1386+
# doesn't apply here: the moderation check above already ran over the full raw
1387+
# accumulated text, which includes whatever this flush contains.
1388+
flushed_delta = citation_cleaner.flush()
1389+
if flushed_delta:
1390+
self.sequence_number += 1
1391+
yield OpenAIResponseObjectStreamResponseOutputTextDelta(
1392+
content_index=content_index,
1393+
delta=flushed_delta,
1394+
item_id=message_item_id,
1395+
logprobs=[],
1396+
output_index=message_output_index,
1397+
sequence_number=self.sequence_number,
1398+
)
1399+
13711400
final_text = "".join(chat_response_content)
1401+
part_annotations, part_clean_text = extract_citations_from_text(final_text, self.citation_files)
13721402
self.sequence_number += 1
13731403
yield OpenAIResponseObjectStreamResponseContentPartDone(
13741404
content_index=content_index,
13751405
response_id=self.response_id,
13761406
item_id=message_item_id,
13771407
output_index=message_output_index,
13781408
part=OpenAIResponseContentPartOutputText(
1379-
text=final_text,
1409+
text=part_clean_text,
1410+
annotations=list(part_annotations),
13801411
logprobs=[],
13811412
),
13821413
sequence_number=self.sequence_number,
@@ -1411,10 +1442,11 @@ async def _process_streaming_chunks(
14111442
content_parts = []
14121443
if content_part_emitted:
14131444
final_text = "".join(chat_response_content)
1445+
final_annotations, final_clean_text = extract_citations_from_text(final_text, self.citation_files)
14141446
content_parts.append(
14151447
OpenAIResponseOutputMessageContentOutputText(
1416-
text=final_text,
1417-
annotations=[],
1448+
text=final_clean_text,
1449+
annotations=list(final_annotations),
14181450
logprobs=chat_response_logprobs if chat_response_logprobs else [],
14191451
)
14201452
)

src/ogx/providers/inline/responses/builtin/responses/tool_executor.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,11 @@ async def search_single_store(vector_store_id):
221221
)
222222

223223
# handling missing attributes for old versions
224+
# Iterate in descending score order so that when a model doesn't cite inline and
225+
# extract_citations_from_text falls back to a single file, it picks the most relevant
226+
# one: dict insertion order determines which file_id lands first.
224227
citation_files = {}
225-
for result in search_results:
228+
for result in sorted(search_results, key=lambda r: r.score, reverse=True):
226229
file_id = result.file_id
227230
if not file_id and result.attributes:
228231
file_id = result.attributes.get("document_id")
@@ -233,7 +236,8 @@ async def search_single_store(vector_store_id):
233236
if not filename:
234237
filename = "unknown"
235238

236-
citation_files[file_id] = filename
239+
if file_id not in citation_files:
240+
citation_files[file_id] = filename
237241

238242
# Cast to proper InterleavedContent type (list invariance)
239243
return ToolInvocationResult(

src/ogx/providers/inline/responses/builtin/responses/utils.py

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
483502
def _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+
530634
def is_function_tool_call(
531635
tool_call: OpenAIChatCompletionToolCall,
532636
tools: list[OpenAIResponseInputTool],
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
id: chat-completion-457
2+
choices:
3+
- message:
4+
content: 'Global warming is caused by greenhouse gases <|file-abc123|>.'
5+
role: assistant
6+
finish_reason: stop
7+
index: 0
8+
created: 1234567891
9+
model: ollama/llama3.2:3b
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
id: chat-completion-456
2+
choices:
3+
- message:
4+
tool_calls:
5+
- id: tool_call_456
6+
type: function
7+
function:
8+
name: file_search
9+
arguments: '{"query":"What is global warming?"}'
10+
role: assistant
11+
finish_reason: stop
12+
index: 0
13+
created: 1234567890
14+
model: ollama/llama3.2:3b

0 commit comments

Comments
 (0)