From 2930372215805c6cef1508bb89e9c70db8bea750 Mon Sep 17 00:00:00 2001 From: npmind Date: Fri, 23 Oct 2026 09:46:01 +0900 Subject: [PATCH 1/2] fix(responses): always send a title on url_citation and repair old history on replay A web-search source without a title produced a url_citation with no title key. Replaying that message to OpenAI Responses, most visibly through /responses/compact, fails every time with "Missing required parameter: input[N].content[0].annotations[M].title". Generation now falls back to the URL, and the OpenAI-bound request projection fills title and integer start_index/end_index on url_citation annotations already sitting in history. Other gateways are left alone. --- .../openai-responses/request-strips.ts | 52 +++++++ src/bridge/response-json.ts | 2 +- src/bridge/sse.ts | 2 +- tests/adapters/bridge.test.ts | 4 +- ...esponses-url-citation-title-repair.test.ts | 142 ++++++++++++++++++ 5 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 tests/responses/responses-url-citation-title-repair.test.ts diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 25587bb6c5b..4b16e0fbbfb 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -24,6 +24,58 @@ export function stripProviderInputItemMetadata(body: unknown): unknown { input ??= [...body.input]; input[index] = next; } + return repairUrlCitationAnnotations(input ? { ...body, input } : body); +} + +function repairUrlCitationIndex(value: unknown): number { + if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value; + if (typeof value === "string" && /^\d+$/.test(value)) return Number(value); + return 0; +} + +/** + * OpenAI Responses requires `title` (string) and integer `start_index`/`end_index` on every + * `url_citation` annotation of a replayed `output_text` part. History written by an older bridge + * (title omitted when the search source had none) or a lossy client otherwise fails every later + * request carrying it, most visibly remote compaction, with a deterministic HTTP 400 + * (`Missing required parameter: input[N].content[M].annotations[K].title`). + * Repair only those fields on `url_citation` annotations of assistant message content; other + * annotation types, unknown keys and every other item are untouched. Copy on write: an already + * valid body is returned by reference. + */ +export function repairUrlCitationAnnotations(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + let input: unknown[] | undefined; + for (let itemIndex = 0; itemIndex < body.input.length; itemIndex += 1) { + const item = body.input[itemIndex]; + if (!isPlainObject(item) || !Array.isArray(item.content)) continue; + let content: unknown[] | undefined; + for (let partIndex = 0; partIndex < item.content.length; partIndex += 1) { + const part = item.content[partIndex]; + if (!isPlainObject(part) || part.type !== "output_text" || !Array.isArray(part.annotations)) continue; + let annotations: unknown[] | undefined; + for (let annIndex = 0; annIndex < part.annotations.length; annIndex += 1) { + const ann = part.annotations[annIndex]; + if (!isPlainObject(ann) || ann.type !== "url_citation") continue; + const titleOk = typeof ann.title === "string"; + const startOk = typeof ann.start_index === "number" && Number.isInteger(ann.start_index); + const endOk = typeof ann.end_index === "number" && Number.isInteger(ann.end_index); + if (titleOk && startOk && endOk) continue; + const next: Record = { ...ann }; + if (!titleOk) next.title = typeof ann.url === "string" ? ann.url : ""; + if (!startOk) next.start_index = repairUrlCitationIndex(ann.start_index); + if (!endOk) next.end_index = repairUrlCitationIndex(ann.end_index); + annotations ??= [...part.annotations]; + annotations[annIndex] = next; + } + if (!annotations) continue; + content ??= [...item.content]; + content[partIndex] = { ...part, annotations }; + } + if (!content) continue; + input ??= [...body.input]; + input[itemIndex] = { ...item, content }; + } return input ? { ...body, input } : body; } diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index f9a5ae621ce..a169e72256a 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -253,7 +253,7 @@ function buildResponseJSONWithBudget( const text = stripCitationMarkers(currentTextStr); const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0); const annotations = pendingWebSources.map(s => ({ - type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0, + type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0, })); pendingWebSources = []; const item = { diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index c8ecb5ffb32..3b8116e0a55 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -488,7 +488,7 @@ export function bridgeToResponsesSSE( const takeWebAnnotations = (): { type: string; url: string; title?: string; start_index: number; end_index: number }[] => { if (pendingWebSources.length === 0) return []; const anns = pendingWebSources.map(s => ({ - type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0, + type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0, })); const annotationBytes = bytesOf(JSON.stringify(anns)); const reservation = budget?.reserveTransient(annotationBytes, { kind: "retained_collectors" }); diff --git a/tests/adapters/bridge.test.ts b/tests/adapters/bridge.test.ts index 52130707e74..9d99f0b63cd 100644 --- a/tests/adapters/bridge.test.ts +++ b/tests/adapters/bridge.test.ts @@ -1277,8 +1277,8 @@ describe("Responses bridge web_search_call native item", () => { const streamingMessage = frames.find(f => f.event === "response.output_item.done" && (f.data.item as Record)?.type === "message")!.data.item as Record; const streamingAnnotations = (streamingMessage.content as Record[])[0].annotations as Record[]; - expect(streamingAnnotations.map(({ url, title }) => ({ url, ...(title ? { title } : {}) }))) - .toEqual(streamingSources); + expect(streamingAnnotations.map(({ url, title }) => ({ url, title }))) + .toEqual(streamingSources.map(({ url, title }) => ({ url, title: title ?? url }))); const json = buildResponseJSON(events, "routed/model"); const output = json.output as Record[]; diff --git a/tests/responses/responses-url-citation-title-repair.test.ts b/tests/responses/responses-url-citation-title-repair.test.ts new file mode 100644 index 00000000000..70c918b9ed7 --- /dev/null +++ b/tests/responses/responses-url-citation-title-repair.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses/passthrough"; +import { repairUrlCitationAnnotations, stripProviderInputItemMetadata } from "../../src/adapters/openai-responses/request-strips"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; +import type { AdapterEvent, OcxProviderConfig } from "../../src/types"; + +// OpenAI Responses requires string `title` and integer `start_index`/`end_index` on every replayed +// url_citation. A history written without them made every later request (notably remote compaction) +// fail with a deterministic HTTP 400 `Missing required parameter: input[N].content[M].annotations[K].title`. + +function assistantWith(annotations: unknown[]) { + return { type: "message", role: "assistant", id: "msg_synthetic", status: "completed", + content: [{ type: "output_text", text: "synthetic answer", annotations }] }; +} +function annotationsOf(body: unknown, itemIndex = 1): Record[] { + return ((body as { input: { content: { annotations: Record[] }[] }[] }).input[itemIndex]!.content[0]!.annotations); +} + +describe("url_citation annotation repair on outbound history", () => { + test("fills title from url, coerces numeric-string indexes, preserves everything else", () => { + const body = { + model: "gpt-6-luna", + input: [ + { role: "user", content: "hi" }, + assistantWith([ + { type: "url_citation", url: "https://a.example/x", start_index: 0, end_index: 0 }, + { type: "url_citation", url: "https://b.example/y", title: null, start_index: "3", end_index: "9", extra: "keep" }, + { type: "url_citation", title: 7, start_index: 1, end_index: 2 }, + { type: "url_citation", url: "https://ok.example", title: "Fine", start_index: 1, end_index: 2 }, + { type: "file_citation", file_id: "file_1", index: 0 }, + ]), + ], + }; + const before = structuredClone(body); + const out = repairUrlCitationAnnotations(body); + expect(body).toEqual(before); + expect(annotationsOf(out)).toEqual([ + { type: "url_citation", url: "https://a.example/x", title: "https://a.example/x", start_index: 0, end_index: 0 }, + { type: "url_citation", url: "https://b.example/y", title: "https://b.example/y", start_index: 3, end_index: 9, extra: "keep" }, + { type: "url_citation", title: "", start_index: 1, end_index: 2 }, + { type: "url_citation", url: "https://ok.example", title: "Fine", start_index: 1, end_index: 2 }, + { type: "file_citation", file_id: "file_1", index: 0 }, + ]); + expect((out as { input: unknown[] }).input[0]).toBe(body.input[0]); + }); + + test("a valid body is returned by reference and non-array input is untouched", () => { + const valid = { input: [assistantWith([{ type: "url_citation", url: "https://ok.example", title: "t", start_index: 0, end_index: 1 }])] }; + expect(repairUrlCitationAnnotations(valid)).toBe(valid); + expect(repairUrlCitationAnnotations({ input: "hello" })).toEqual({ input: "hello" }); + expect(repairUrlCitationAnnotations(null)).toBeNull(); + }); + + test("stripProviderInputItemMetadata (the OpenAI-operated outbound projection) applies it", () => { + const body = { input: [{ role: "user", content: "hi" }, assistantWith([{ type: "url_citation", url: "https://a.example", start_index: 0, end_index: 0 }])] }; + expect(annotationsOf(stripProviderInputItemMetadata(body))[0]!.title).toBe("https://a.example"); + }); + + const native: OcxProviderConfig[] = [ + { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key" }, + ]; + for (const provider of native) { + test(`reaches the wire repaired for ${provider.baseUrl} over HTTP and WS`, () => { + const rawBody = { + model: "gpt-6-luna", stream: true, store: false, + input: [{ role: "user", content: "hi" }, + assistantWith([{ type: "url_citation", url: "https://a.example/x", start_index: 0, end_index: 0 }])], + }; + const budget = createTranslatorBudget(); + try { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: rawBody.model, context: { messages: [] }, stream: true, options: {}, _rawBody: rawBody, + }, { headers: new Headers(), translatorBudget: budget }); + try { + const ws = prepareCodexWsRequest(request.url, { method: request.method, headers: request.headers, body: request.body }); + expect(ws).not.toBeNull(); + for (const payload of [JSON.parse(request.body), JSON.parse(ws!.frameText), JSON.parse(ws!.httpInit.body as string)]) { + expect(annotationsOf(payload)).toEqual([ + { type: "url_citation", url: "https://a.example/x", title: "https://a.example/x", start_index: 0, end_index: 0 }, + ]); + } + expect((rawBody.input[1] as { content: { annotations: Record[] }[] }).content[0]!.annotations[0]).not.toHaveProperty("title"); + } finally { request.releaseBodyObservation?.(); } + } finally { budget.dispose(); } + }); + } + + test("a third-party gateway destination is not rewritten", () => { + const rawBody = { model: "custom", stream: true, input: [{ role: "user", content: "hi" }, + assistantWith([{ type: "url_citation", url: "https://a.example/x", start_index: 0, end_index: 0 }])] }; + const budget = createTranslatorBudget(); + try { + const request = createResponsesPassthroughAdapter({ adapter: "openai-responses", baseUrl: "https://gateway.example/v1", authMode: "key" }) + .buildRequest({ modelId: "custom", context: { messages: [] }, stream: true, options: {}, _rawBody: rawBody }, { headers: new Headers(), translatorBudget: budget }); + try { expect(annotationsOf(JSON.parse(request.body))[0]).not.toHaveProperty("title"); } + finally { request.releaseBodyObservation?.(); } + } finally { budget.dispose(); } + }); +}); + +describe("web-search citation generation always carries a string title", () => { + const events: AdapterEvent[] = [ + { type: "web_search_call_begin", id: "ws_t1" }, + { type: "web_search_call_end", id: "ws_t1", queries: ["q"], sources: [{ url: "https://notitle.example/a" }, { url: "https://titled.example/b", title: "Titled" }] }, + { type: "text_delta", text: "answer" }, + { type: "done" }, + ]; + const expected = [ + { type: "url_citation", url: "https://notitle.example/a", title: "https://notitle.example/a", start_index: 0, end_index: 0 }, + { type: "url_citation", url: "https://titled.example/b", title: "Titled", start_index: 0, end_index: 0 }, + ]; + + test("non-streaming JSON", () => { + const json = buildResponseJSON(events, "routed/model"); + const message = (json.output as Record[]).find(item => item.type === "message") as { content: { annotations: unknown[] }[] }; + expect(message.content[0]!.annotations).toEqual(expected); + }); + + test("streaming SSE", async () => { + async function* replay() { for (const event of events) yield event; } + const frames: { event?: string; data: Record }[] = []; + const reader = bridgeToResponsesSSE(replay(), "routed/model").getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (value) buffer += decoder.decode(value, { stream: true }); + if (done) break; + } + for (const block of buffer.split("\n\n")) { + const event = /^event: (.*)$/m.exec(block)?.[1]; + const data = /^data: (.*)$/m.exec(block)?.[1]; + if (data && data !== "[DONE]") frames.push({ event, data: JSON.parse(data) }); + } + const done = frames.find(f => f.event === "response.output_item.done" && (f.data.item as Record)?.type === "message"); + const item = done!.data.item as { content: { annotations: unknown[] }[] }; + expect(item.content[0]!.annotations).toEqual(expected); + }); +}); From 0038450abcdd833624e263021c825f1b1454b04f Mon Sep 17 00:00:00 2001 From: npmind Date: Fri, 23 Oct 2026 10:22:51 +0900 Subject: [PATCH 2/2] fix(responses): reject negative and unsafe url_citation indexes in replay repair --- src/adapters/openai-responses/request-strips.ts | 16 ++++++++++++---- .../responses-url-citation-title-repair.test.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 4b16e0fbbfb..6fb0f591854 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -27,9 +27,17 @@ export function stripProviderInputItemMetadata(body: unknown): unknown { return repairUrlCitationAnnotations(input ? { ...body, input } : body); } +function isUrlCitationIndex(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +/** Coerce a replayed citation index to a non-negative safe integer, falling back to 0. */ function repairUrlCitationIndex(value: unknown): number { - if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value; - if (typeof value === "string" && /^\d+$/.test(value)) return Number(value); + if (isUrlCitationIndex(value)) return value; + if (typeof value === "string" && /^\d+$/.test(value)) { + const index = Number(value); + if (isUrlCitationIndex(index)) return index; + } return 0; } @@ -58,8 +66,8 @@ export function repairUrlCitationAnnotations(body: unknown): unknown { const ann = part.annotations[annIndex]; if (!isPlainObject(ann) || ann.type !== "url_citation") continue; const titleOk = typeof ann.title === "string"; - const startOk = typeof ann.start_index === "number" && Number.isInteger(ann.start_index); - const endOk = typeof ann.end_index === "number" && Number.isInteger(ann.end_index); + const startOk = isUrlCitationIndex(ann.start_index); + const endOk = isUrlCitationIndex(ann.end_index); if (titleOk && startOk && endOk) continue; const next: Record = { ...ann }; if (!titleOk) next.title = typeof ann.url === "string" ? ann.url : ""; diff --git a/tests/responses/responses-url-citation-title-repair.test.ts b/tests/responses/responses-url-citation-title-repair.test.ts index 70c918b9ed7..aba11a1b83b 100644 --- a/tests/responses/responses-url-citation-title-repair.test.ts +++ b/tests/responses/responses-url-citation-title-repair.test.ts @@ -46,6 +46,17 @@ describe("url_citation annotation repair on outbound history", () => { expect((out as { input: unknown[] }).input[0]).toBe(body.input[0]); }); + test("negative and unsafe indexes are replaced with 0", () => { + const body = { input: [assistantWith([ + { type: "url_citation", url: "https://a.example", title: "t", start_index: -1, end_index: 4 }, + { type: "url_citation", url: "https://b.example", title: "t", start_index: "9".repeat(400), end_index: 2 ** 60 }, + ])] }; + expect(annotationsOf(repairUrlCitationAnnotations(body), 0)).toEqual([ + { type: "url_citation", url: "https://a.example", title: "t", start_index: 0, end_index: 4 }, + { type: "url_citation", url: "https://b.example", title: "t", start_index: 0, end_index: 0 }, + ]); + }); + test("a valid body is returned by reference and non-array input is untouched", () => { const valid = { input: [assistantWith([{ type: "url_citation", url: "https://ok.example", title: "t", start_index: 0, end_index: 1 }])] }; expect(repairUrlCitationAnnotations(valid)).toBe(valid);