-
Notifications
You must be signed in to change notification settings - Fork 0
fix(responses): bound streaming citation spans #418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: Dev
Are you sure you want to change the base?
Changes from all commits
d35592b
71c57ea
d560ac6
08ada6f
ec51e42
e25b653
80fff9a
fc4de77
c7d8407
54e2274
2c4dca1
a34e8b7
ebb4d55
682112e
af6113a
847f4f1
ac78647
aaa9eaf
35ff3a4
8f7d576
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -68,6 +68,10 @@ export interface CitationMarkerFilter { | |
| flush(): string; | ||
| } | ||
|
|
||
| // Citation references are short opaque identifiers. Bounding malformed spans keeps the | ||
| // streaming parser's retained state small while still preserving their text verbatim. | ||
| const MAX_STREAMING_MARKER_SPAN_LENGTH = 4_096; | ||
|
|
||
| /** | ||
| * Streaming filter. | ||
| * | ||
|
|
@@ -77,25 +81,74 @@ export interface CitationMarkerFilter { | |
| * (removed) or the stream ends (verbatim, so nothing the model actually said is lost). | ||
| */ | ||
| export function createCitationMarkerFilter(): CitationMarkerFilter { | ||
| // Text from an open START that has not been terminated yet. | ||
| let held = ""; | ||
| // Keep chunks separately so one-character deltas do not repeatedly copy the complete | ||
| // unterminated span. They are joined at most once, when emitted or flushed. | ||
| let held: string[] = []; | ||
| let heldLength = 0; | ||
|
|
||
| const takeHeld = (): string => { | ||
| const text = held.join(""); | ||
| held = []; | ||
| heldLength = 0; | ||
| return text; | ||
| }; | ||
|
|
||
| return { | ||
| push(delta: string): string { | ||
| const combined = held + delta; | ||
| held = ""; | ||
| const start = combined.lastIndexOf(CITATION_MARKER_START); | ||
| if (start === -1) return stripCitationMarkers(combined); | ||
| const endAfterStart = combined.indexOf(CITATION_MARKER_END, start + 1); | ||
| if (endAfterStart !== -1) return stripCitationMarkers(combined); | ||
| // The trailing span is still open: emit everything before it, hold the rest. | ||
| held = combined.slice(start); | ||
| return stripCitationMarkers(combined.slice(0, start)); | ||
| const out: string[] = []; | ||
| let index = 0; | ||
|
|
||
| while (index < delta.length) { | ||
| if (heldLength === 0) { | ||
| const start = delta.indexOf(CITATION_MARKER_START, index); | ||
| if (start === -1) { | ||
| out.push(delta.slice(index)); | ||
| break; | ||
| } | ||
| out.push(delta.slice(index, start)); | ||
| held.push(CITATION_MARKER_START); | ||
| heldLength = 1; | ||
| index = start + 1; | ||
| } | ||
|
|
||
| const end = delta.indexOf(CITATION_MARKER_END, index); | ||
| const nextStart = delta.indexOf(CITATION_MARKER_START, index); | ||
| if (nextStart !== -1 && (end === -1 || nextStart < end)) { | ||
| // As in the whole-string filter, a newer unmatched START makes the older one | ||
| // malformed. Release the older text and begin withholding at the newer START. | ||
| const between = delta.slice(index, nextStart); | ||
| out.push(takeHeld(), between); | ||
| held.push(CITATION_MARKER_START); | ||
| heldLength = 1; | ||
| index = nextStart + 1; | ||
| continue; | ||
| } | ||
|
|
||
| if (end !== -1) { | ||
| // A complete citation span is discarded without ever joining its chunks. | ||
| held = []; | ||
| heldLength = 0; | ||
| index = end + 1; | ||
| continue; | ||
| } | ||
|
|
||
| const rest = delta.slice(index); | ||
| if (heldLength + rest.length <= MAX_STREAMING_MARKER_SPAN_LENGTH) { | ||
| if (rest) held.push(rest); | ||
| heldLength += rest.length; | ||
| break; | ||
| } | ||
|
|
||
| // An implausibly large unterminated span is malformed ordinary text. Releasing | ||
| // it bounds both retained memory and work per delta; subsequent STARTs can still | ||
| // begin valid citation spans. | ||
| out.push(takeHeld()); | ||
|
Comment on lines
+142
to
+145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a marker remains open beyond 4,096 characters but an END arrives later, this branch releases the opener and content as ordinary streaming deltas, and the later END is emitted too. However, Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| return out.join(""); | ||
| }, | ||
| flush(): string { | ||
| const rest = held; | ||
| held = ""; | ||
| return rest; | ||
| return takeHeld(); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When one text delta contains many START characters and no END (for example, a model or upstream emits
START.repeat(n)), every iteration callsindexOf(END, index)over the entire remaining suffix before advancing to the next START, makingpush()O(n²). This creates another event-loop denial-of-service path in the code intended to eliminate one; scan forward with parser state so each character is examined only a bounded number of times.Useful? React with 👍 / 👎.