diff --git a/src/translator/chat-to-responses.test.ts b/src/translator/chat-to-responses.test.ts index aa9779d..9cdedde 100644 --- a/src/translator/chat-to-responses.test.ts +++ b/src/translator/chat-to-responses.test.ts @@ -53,7 +53,8 @@ describe("chatCompletionsToResponses (batch)", () => { const r = chatCompletionsToResponses(chatResp(), "glm-5.2"); expect(r.status).toBe("completed"); expect(r.output[0].type).toBe("message"); - expect((r.output[0] as { content: { type: string; text: string }[] }).content[0]).toEqual({ type: "output_text", text: "hello" }); + const content = (r.output[0] as unknown as { content: { type: string; text: string; annotations: unknown[] }[] }).content; + expect(content[0]).toEqual({ type: "output_text", text: "hello", annotations: [] }); }); it("carries cache-inclusive Chat usage into the Responses usage details", () => { @@ -68,9 +69,25 @@ describe("chatCompletionsToResponses (batch)", () => { expect(r.usage).toEqual({ input_tokens: 1224, + input_tokens_details: { cached_tokens: 1216 }, output_tokens: 3, + output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 1227, - input_tokens_details: { cached_tokens: 1216 }, + }); + }); + + it("always emits both usage detail objects even when the Chat usage omits them", () => { + // The Responses client requires input_tokens_details / output_tokens_details + // on the wire — omitting them fails the turn with a serde `missing field`. + const r = chatCompletionsToResponses(chatResp({ + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), "glm-5.2"); + expect(r.usage).toEqual({ + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 15, }); }); @@ -334,3 +351,181 @@ describe("streaming", () => { } }); }); + +// ───────────────────────────────────────────── +// Client-required field audit (async-openai ResponseUsage / OutputTextContent / +// ReasoningItem / SummaryTextContent / FunctionToolCall). Every field checked +// here is deserialized WITHOUT a serde default by the Responses client: an +// absent field fails the whole turn (`missing field \`x\``). Keep these in +// sync with the wire contract the client enforces. +// Target events are asserted to EXIST first — a filter-based loop that matches +// nothing would pass vacuously and stop guarding the contract it pins. +// ───────────────────────────────────────────── +describe("client-required wire fields", () => { + it("stream: every output_text part carries text and annotations", () => { + const state = newResponsesStreamState("glm-5.2"); + const events = [ + ...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { content: "hi" } }] }), state), + ...finalizeResponsesStream(state), + ]; + const itemEvents = events.filter( + (e) => (e.type === "response.output_item.added" || e.type === "response.output_item.done") && e.item.type === "message", + ); + const partEvents = events.filter( + (e) => e.type === "response.content_part.added" || e.type === "response.content_part.done", + ); + // The target events must be present — otherwise the assertions below are vacuous. + expect(itemEvents.length).toBeGreaterThanOrEqual(2); + expect(partEvents.length).toBe(2); + for (const e of itemEvents) { + const content = (e.item as unknown as { content: Record[] }).content; + for (const part of content) { + expect(part).toHaveProperty("text"); + expect(part).toHaveProperty("annotations"); + } + } + for (const e of partEvents) { + if (e.type !== "response.content_part.added" && e.type !== "response.content_part.done") continue; + expect(e.part).toHaveProperty("text"); + expect(e.part).toHaveProperty("annotations"); + } + // Terminal `response.output` message carries the same fields. + const terminal = events.find(e => e.type === "response.completed"); + if (terminal?.type !== "response.completed") throw new Error("missing completed event"); + const outMsg = terminal.response.output.find(i => i.type === "message") as unknown as { content: Record[] }; + expect(outMsg).toBeDefined(); + for (const part of outMsg.content) { + expect(part).toHaveProperty("text"); + expect(part).toHaveProperty("annotations"); + } + }); + + it("stream: reasoning item always carries summary, summary parts always carry text", () => { + const state = newResponsesStreamState("glm-5.2"); + const events = [ + ...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { reasoning_content: "thinking" } }] }), state), + ...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }] }), state), + ...finalizeResponsesStream(state), + ]; + const itemEvents = events.filter( + (e) => (e.type === "response.output_item.added" || e.type === "response.output_item.done") && e.item.type === "reasoning", + ); + const partEvents = events.filter( + (e) => e.type === "response.reasoning_summary_part.added" || e.type === "response.reasoning_summary_part.done", + ); + expect(itemEvents.length).toBe(2); + expect(partEvents.length).toBe(2); + for (const e of itemEvents) { + const summary = (e.item as unknown as { summary?: unknown[] }).summary; + expect(summary).toBeInstanceOf(Array); + } + for (const e of partEvents) { + if (e.type !== "response.reasoning_summary_part.added" && e.type !== "response.reasoning_summary_part.done") continue; + expect(e.part).toHaveProperty("text"); + } + // Terminal `response.output` reasoning item carries a non-empty summary with text. + const terminal = events.find(e => e.type === "response.completed"); + if (terminal?.type !== "response.completed") throw new Error("missing completed event"); + const outReasoning = terminal.response.output.find(i => i.type === "reasoning") as unknown as { summary: Record[] }; + expect(outReasoning).toBeDefined(); + expect(outReasoning.summary.length).toBeGreaterThan(0); + expect(outReasoning.summary[0]).toHaveProperty("text"); + }); + + it("stream: function_call items always carry arguments (plain and namespaced)", () => { + const state = newResponsesStreamState("glm-5.2", { + meta: { customToolNames: new Set(), namespaceMap: new Map([["gmail__send", { namespace: "gmail", name: "send" }]]), hasToolSearch: false }, + }); + const events = [ + ...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { tool_calls: [ + { index: 0, id: "c0", function: { name: "fn", arguments: "{\"a\":1}" } }, + ] } }] }), state), + ...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { tool_calls: [ + { index: 1, id: "c1", function: { name: "gmail__send", arguments: "{}" } }, + ] } }] }), state), + ...finalizeResponsesStream(state), + ]; + const added = events.filter((e) => e.type === "response.output_item.added" && e.item.type === "function_call"); + const done = events.filter((e) => e.type === "response.output_item.done" && e.item.type === "function_call"); + // Both the plain and the namespaced branch must be exercised. + expect(added.length).toBe(2); + expect(done.length).toBe(2); + for (const e of added) { + const args = (e.item as unknown as { arguments?: string }).arguments; + expect(args).toBeTypeOf("string"); + } + for (const e of done) { + const args = (e.item as unknown as { arguments?: string }).arguments; + expect(args).toBeTypeOf("string"); + expect(args!.length).toBeGreaterThan(0); + } + const namespaced = done.find(e => (e.item as unknown as { namespace?: string }).namespace === "gmail"); + expect(namespaced).toBeDefined(); + // Terminal `response.output` function_call items carry final arguments. + const terminal = events.find(e => e.type === "response.completed"); + if (terminal?.type !== "response.completed") throw new Error("missing completed event"); + const outCalls = terminal.response.output.filter(i => i.type === "function_call"); + expect(outCalls.length).toBe(2); + for (const call of outCalls) { + const args = (call as unknown as { arguments?: string }).arguments; + expect(args).toBeTypeOf("string"); + expect(args!.length).toBeGreaterThan(0); + } + }); + + it("stream: terminal usage always carries both detail objects", () => { + const state = newResponsesStreamState("glm-5.2"); + // Upstream Chat usage WITHOUT any details — the shape that used to fail. + const usage = { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 }; + chatChunkToResponsesEvents(chunk({ usage, choices: [] }), state); + const terminalEvents = finalizeResponsesStream(state).filter(e => e.type === "response.completed"); + expect(terminalEvents.length).toBe(1); + const terminal = terminalEvents[0]; + if (terminal.type !== "response.completed") throw new Error("missing completed event"); + expect(terminal.response.usage).toEqual({ + input_tokens: 7, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 10, + }); + }); + + it("stream: preserves non-zero cached_tokens / reasoning_tokens from upstream usage", () => { + const state = newResponsesStreamState("glm-5.2"); + const usage = { + prompt_tokens: 1224, completion_tokens: 5, total_tokens: 1229, + prompt_tokens_details: { cached_tokens: 1216 }, + completion_tokens_details: { reasoning_tokens: 3 }, + }; + chatChunkToResponsesEvents(chunk({ usage, choices: [] }), state); + const terminalEvents = finalizeResponsesStream(state).filter(e => e.type === "response.completed"); + expect(terminalEvents.length).toBe(1); + const terminal = terminalEvents[0]; + if (terminal.type !== "response.completed") throw new Error("missing completed event"); + expect(terminal.response.usage).toEqual({ + input_tokens: 1224, + input_tokens_details: { cached_tokens: 1216 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 3 }, + total_tokens: 1229, + }); + }); + + it("batch: reasoning + message + usage fields satisfy the client contract", () => { + const r = chatCompletionsToResponses({ + id: "c", object: "chat.completion", created: 1, model: "glm-5.2", + choices: [{ index: 0, finish_reason: "stop", message: { + role: "assistant", content: "answer", reasoning_content: "why", + } }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }, "glm-5.2"); + const reasoning = r.output.find(i => i.type === "reasoning") as unknown as { summary: Record[] }; + expect(reasoning.summary[0]).toHaveProperty("text"); + const message = r.output.find(i => i.type === "message") as unknown as { content: Record[] }; + expect(message.content[0]).toHaveProperty("annotations"); + expect(message.content[0]).toHaveProperty("text"); + expect(r.usage?.input_tokens_details).toEqual({ cached_tokens: 0 }); + expect(r.usage?.output_tokens_details).toEqual({ reasoning_tokens: 0 }); + }); +}); diff --git a/src/translator/chat-to-responses.ts b/src/translator/chat-to-responses.ts index fd075d9..7b81ba4 100644 --- a/src/translator/chat-to-responses.ts +++ b/src/translator/chat-to-responses.ts @@ -120,7 +120,7 @@ function chatChoiceToOutputItems( type: "message", id: generateItemId(), role: "assistant", - content: [{ type: "output_text", text }], + content: [{ type: "output_text", text, annotations: [] }], status: "completed", }); } @@ -194,7 +194,7 @@ function emptyMessageOutput(): ResponsesOutputItem { type: "message", id: generateItemId(), role: "assistant", - content: [{ type: "output_text", text: "" }], + content: [{ type: "output_text", text: "", annotations: [] }], status: "completed", }; } @@ -228,12 +228,26 @@ export function extractCustomToolInput(rawArgs: string): string { } function chatUsageToResponsesUsage(u: OpenAIUsage): ResponsesUsage { + // The Responses client (async-openai) deserializes ResponseUsage with ALL + // five fields required — an absent `input_tokens_details` / `output_tokens_details` + // fails the whole turn with `missing field \`input_tokens_details\``. Both detail + // objects (and their inner counts) are therefore always emitted. + // + // SEMANTICS OF 0: an emitted `cached_tokens: 0` / `reasoning_tokens: 0` + // means "upstream did not report this breakdown", NOT a measured zero. + // The Anthropic translation layer only reports cache buckets when the + // upstream sends them and never reports reasoning tokens, so responses + // containing reasoning can legitimately show `reasoning_tokens: 0`. + // This fallback keeps the three known totals (input/output/total) instead + // of omitting the whole usage object — a deliberate tradeoff, and a + // semantic change from "field absent" to "field present with 0" for + // clients that consume these numbers. return { input_tokens: u.prompt_tokens ?? 0, + input_tokens_details: { cached_tokens: u.prompt_tokens_details?.cached_tokens ?? 0 }, output_tokens: u.completion_tokens ?? 0, + output_tokens_details: { reasoning_tokens: u.completion_tokens_details?.reasoning_tokens ?? 0 }, total_tokens: u.total_tokens ?? (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0), - ...(u.prompt_tokens_details?.cached_tokens ? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } } : {}), - ...(u.completion_tokens_details?.reasoning_tokens !== undefined ? { output_tokens_details: { reasoning_tokens: u.completion_tokens_details.reasoning_tokens } } : {}), }; } @@ -450,6 +464,8 @@ function toolCallAddedItem( call_id: entry.id, name: ns.name, namespace: ns.namespace, + // arguments is required on FunctionToolCall; deltas stream afterwards. + arguments: "", status: "in_progress", }; } @@ -458,6 +474,7 @@ function toolCallAddedItem( id: entry.itemId, call_id: entry.id, name: entry.name, + arguments: "", status: "in_progress", }; } @@ -563,14 +580,18 @@ function ensureReasoningItem(state: ResponsesStreamState): ResponsesStreamEvent[ seq(state, { type: "response.output_item.added", output_index: state.reasoningIndex, - item: { type: "reasoning", id: state.reasoningItemId, status: "in_progress" }, + // `summary` is required on ReasoningItem (async-openai deserializes it + // with no default) — emit an empty list until the part closes. + item: { type: "reasoning", id: state.reasoningItemId, summary: [], status: "in_progress" }, }), seq(state, { type: "response.reasoning_summary_part.added", output_index: state.reasoningIndex, summary_index: 0, item_id: state.reasoningItemId, - part: { type: "summary_text" }, + // SummaryTextContent.text is required — the part opens with "" and the + // text arrives via reasoning_summary_text.delta. + part: { type: "summary_text", text: "" }, }), ]; } @@ -645,7 +666,7 @@ function ensureMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[] id: state.messageItemId, role: "assistant", status: "in_progress", - content: [{ type: "output_text" }], + content: [{ type: "output_text", text: "", annotations: [] }], }, })]; } @@ -658,7 +679,7 @@ function ensureTextPart(state: ResponsesStreamState): ResponsesStreamEvent[] { output_index: state.messageIndex, content_index: 0, item_id: state.messageItemId, - part: { type: "output_text", text: "" }, + part: { type: "output_text", text: "", annotations: [] }, })]; } @@ -678,7 +699,7 @@ function closeMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[] { output_index: state.messageIndex, content_index: 0, item_id: state.messageItemId, - part: { type: "output_text", text: state.text }, + part: { type: "output_text", text: state.text, annotations: [] }, })); } events.push(seq(state, { @@ -689,7 +710,7 @@ function closeMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[] { id: state.messageItemId, role: "assistant", status: "completed", - content: [{ type: "output_text", text: state.text }], + content: [{ type: "output_text", text: state.text, annotations: [] }], }, })); return events; @@ -811,7 +832,7 @@ function buildFinalOutput(state: ResponsesStreamState): ResponsesOutputItem[] { type: "message", id: state.messageItemId ?? generateItemId(), role: "assistant", - content: [{ type: "output_text", text: state.text }], + content: [{ type: "output_text", text: state.text, annotations: [] }], status: "completed", }); } diff --git a/src/translator/responses-types.ts b/src/translator/responses-types.ts index 33c1907..76fc7cc 100644 --- a/src/translator/responses-types.ts +++ b/src/translator/responses-types.ts @@ -39,6 +39,17 @@ export interface ResponsesContentPart { filename?: string; } +/** + * An `output_text` part inside a Responses output message. `annotations` is + * required on the wire (the Responses client deserializes OutputTextContent + * with no serde default) — always emit it, as `[]` when empty. + */ +export interface ResponsesOutputTextPart { + type: "output_text"; + text: string; + annotations: unknown[]; +} + /** Tool definitions on a Responses request. */ export type ResponsesTool = | { type: "function"; name: string; description?: string; parameters?: Record; strict?: boolean } @@ -97,10 +108,14 @@ export interface ResponsesRequest { export interface ResponsesUsage { input_tokens: number; + // Required on the wire: the Responses client treats both detail objects — + // and their inner token counts — as mandatory (serde `missing field` + // otherwise) — always emit them. `0` means "absent upstream", not a + // measured count (see chatUsageToResponsesUsage). + input_tokens_details: { cached_tokens: number }; output_tokens: number; + output_tokens_details: { reasoning_tokens: number }; total_tokens: number; - input_tokens_details?: { cached_tokens?: number }; - output_tokens_details?: { reasoning_tokens?: number }; } interface ResponsesIncompleteDetails { @@ -109,7 +124,7 @@ interface ResponsesIncompleteDetails { /** An output item in a completed Responses payload. */ export type ResponsesOutputItem = - | { type: "message"; id?: string; role: "assistant"; content: ResponsesContentPart[]; status?: string; [k: string]: unknown } + | { type: "message"; id?: string; role: "assistant"; content: ResponsesOutputTextPart[]; status?: string; [k: string]: unknown } | { type: "reasoning"; id?: string; summary?: ResponsesContentPart[]; content?: ResponsesContentPart[]; status?: string; [k: string]: unknown } | { type: "function_call"; id?: string; call_id: string; name: string; arguments: string; status?: string; namespace?: string; [k: string]: unknown } | { type: "custom_tool_call"; id?: string; call_id: string; name: string; input: string; status?: string; [k: string]: unknown }