Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 60 additions & 0 deletions src/adapters/openai-responses/request-strips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,66 @@ export function stripProviderInputItemMetadata(body: unknown): unknown {
input ??= [...body.input];
input[index] = next;
}
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 (isUrlCitationIndex(value)) return value;
if (typeof value === "string" && /^\d+$/.test(value)) {
const index = Number(value);
if (isUrlCitationIndex(index)) return index;
}
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 = isUrlCitationIndex(ann.start_index);
const endOk = isUrlCitationIndex(ann.end_index);
if (titleOk && startOk && endOk) continue;
const next: Record<string, unknown> = { ...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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/bridge/response-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
4 changes: 2 additions & 2 deletions tests/adapters/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)?.type === "message")!.data.item as Record<string, unknown>;
const streamingAnnotations = (streamingMessage.content as Record<string, unknown>[])[0].annotations as Record<string, unknown>[];
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<string, unknown>[];
Expand Down
153 changes: 153 additions & 0 deletions tests/responses/responses-url-citation-title-repair.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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<string, unknown>[] {
return ((body as { input: { content: { annotations: Record<string, unknown>[] }[] }[] }).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("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);
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<string, unknown>[] }[] }).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<string, unknown>[]).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<string, unknown> }[] = [];
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<string, unknown>)?.type === "message");
const item = done!.data.item as { content: { annotations: unknown[] }[] };
expect(item.content[0]!.annotations).toEqual(expected);
});
});
Loading