Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d35592b
merge dev into main for the v2.32.1 release
lidge-jun Aug 25, 2026
71c57ea
release: v2.32.1
lidge-jun Aug 25, 2026
d560ac6
merge dev into main for the v2.33.0 release
lidge-jun Aug 25, 2026
08ada6f
Merge pull request #2553 from lidge-jun/codex/promote-main-2330
lidge-jun Aug 25, 2026
ec51e42
release: v2.33.0
lidge-jun Aug 25, 2026
e25b653
merge dev into main for the v2.34.0 release
lidge-jun Aug 27, 2026
80fff9a
Merge pull request #2760 from lidge-jun/codex/promote-main-2340
lidge-jun Aug 27, 2026
fc4de77
Merge pull request #2826 from lidge-jun/codex/promote-main-2350
lidge-jun Aug 28, 2026
c7d8407
Merge pull request #3002 from lidge-jun/codex/promote-main-2360
lidge-jun Aug 30, 2026
54e2274
Merge pull request #3037 from lidge-jun/codex/promote-main-2370
lidge-jun Aug 31, 2026
2c4dca1
merge dev into the promotion branch for v2.38.0
lidge-jun Aug 31, 2026
a34e8b7
merge dev into the promotion branch for v2.38.0 (picks up the ReDoS fix)
lidge-jun Aug 31, 2026
ebb4d55
Merge pull request #3073 from lidge-jun/codex/promote-main-2380
lidge-jun Aug 31, 2026
682112e
Merge remote-tracking branch 'origin/dev' into codex/promote-main-2390
lidge-jun Sep 1, 2026
af6113a
merge dev into main for the v2.39.0 release
lidge-jun Sep 1, 2026
847f4f1
merge dev into main for the v2.40.0 release
Sep 2, 2026
ac78647
Merge pull request #3261 from lidge-jun/codex/promote-main-2400
lidge-jun Sep 2, 2026
aaa9eaf
fix(release): pass the bump job's permissions through the reusable-wo…
lidge-jun Sep 2, 2026
35ff3a4
Merge pull request #3263 from lidge-jun/codex/promote-main-2400-relfix
lidge-jun Sep 2, 2026
8f7d576
fix(responses): bound streaming citation spans
luvs01 Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ jobs:
bump-dev-version:
needs: publish
if: ${{ inputs.dry-run != true }}
# A reusable-workflow CALL cannot grant the callee more than the calling job holds,
# and GitHub refuses the whole run at startup when the called workflow's own job
# declares permissions the caller did not pass down ("startup_failure", runs
# 33615174183 / 33615177849 — the first dispatches since #3129 wired this call).
# The callee's job declares exactly these two; nothing else in this file gains them.
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/dev-version-bump.yml
with:
released-version: v${{ inputs.version }}
Expand Down
83 changes: 68 additions & 15 deletions src/responses/citation-markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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);
Comment on lines +114 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scan each delimiter only once per delta

When one text delta contains many START characters and no END (for example, a model or upstream emits START.repeat(n)), every iteration calls indexOf(END, index) over the entire remaining suffix before advancing to the next START, making push() 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 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep oversized spans consistent in delta and done events

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, closeCurrentMessage() in src/bridge.ts still passes the accumulated raw text through the unbounded stripCitationMarkers(), so response.output_text.done and response.content_part.done omit the entire span. The concatenated deltas therefore disagree with the terminal text; preserve the oversized-span decision when producing the final text, or make both paths use the same filtering state.

Useful? React with 👍 / 👎.

}

return out.join("");
},
flush(): string {
const rest = held;
held = "";
return rest;
return takeHeld();
},
};
}

16 changes: 16 additions & 0 deletions tests/citation-markers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,20 @@ describe("streaming citation marker filter (#3150)", () => {
const filter = createCitationMarkerFilter();
expect(filter.push(`visible now ${S}cite`)).toBe("visible now ");
});

test("a long unterminated span is released incrementally as malformed text", () => {
const filter = createCitationMarkerFilter();
let out = filter.push(S);
for (let i = 0; i < 5_000; i += 1) out += filter.push("x");

// The bounded parser must release malformed input before close rather than retaining
// and repeatedly scanning an attacker-controlled, ever-growing span.
expect(out.length).toBeGreaterThan(0);
expect(out + filter.flush()).toBe(`${S}${"x".repeat(5_000)}`);
});

test("a valid span after an oversized malformed span is still removed", () => {
const malformed = `${S}${"x".repeat(5_000)}`;
expect(drain([malformed, `before${span}after`])).toBe(`${malformed}beforeafter`);
});
});
Loading