From d93414c8dd280ffd2cb948266b5aa02eac58eec5 Mon Sep 17 00:00:00 2001 From: Catapult291 Date: Sat, 31 Oct 2026 15:12:07 +0800 Subject: [PATCH 1/3] fix(translator): always emit client-required fields on the Responses wire The Responses client (async-openai fork 95b52eb) deserializes ResponseUsage, OutputTextContent, ReasoningItem, SummaryTextContent and FunctionToolCall without serde defaults: an absent field fails the whole turn with `missing field `x``. - usage: always emit input_tokens_details / output_tokens_details (upstream Chat usage often omits both; the conditional spread used to drop them -> `missing field input_tokens_details`) - output_text parts: always emit annotations: [] - reasoning items: emit summary: [] on open; summary parts carry text: "" - function_call items: emit arguments: "" on added (deltas stream after) Adds a "client-required wire fields" test block pinning the contract, plus a usage regression test for detail-less upstream Chat usage. --- src/translator/chat-to-responses.test.ts | 117 ++++++++++++++++++++++- src/translator/chat-to-responses.ts | 35 ++++--- src/translator/responses-types.ts | 8 +- 3 files changed, 145 insertions(+), 15 deletions(-) diff --git a/src/translator/chat-to-responses.test.ts b/src/translator/chat-to-responses.test.ts index aa9779d..076ca7d 100644 --- a/src/translator/chat-to-responses.test.ts +++ b/src/translator/chat-to-responses.test.ts @@ -53,7 +53,7 @@ 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" }); + expect((r.output[0] as { content: { type: string; text: string }[] }).content[0]).toEqual({ type: "output_text", text: "hello", annotations: [] }); }); it("carries cache-inclusive Chat usage into the Responses usage details", () => { @@ -68,9 +68,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 +350,100 @@ 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. +// ───────────────────────────────────────────── +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), + ]; + for (const e of events) { + if (e.type === "response.output_item.added" || e.type === "response.output_item.done") { + if (e.item.type !== "message") continue; + for (const part of (e.item as { content: Record[] }).content) { + expect(part).toHaveProperty("text"); + expect(part).toHaveProperty("annotations"); + } + } + if (e.type === "response.content_part.added" || e.type === "response.content_part.done") { + expect(e.part).toHaveProperty("text"); + expect(e.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), + ]; + for (const e of events) { + if (e.type === "response.output_item.added" || e.type === "response.output_item.done") { + if (e.item.type !== "reasoning") continue; + expect((e.item as { summary?: unknown[] }).summary).toBeInstanceOf(Array); + } + if (e.type === "response.reasoning_summary_part.added" || e.type === "response.reasoning_summary_part.done") { + expect(e.part).toHaveProperty("text"); + } + } + }); + + it("stream: function_call items always carry arguments", () => { + const state = newResponsesStreamState("glm-5.2"); + const events = [ + ...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { tool_calls: [ + { index: 0, id: "c0", function: { name: "fn", arguments: "{\"a\":1}" } }, + ] } }] }), state), + ...finalizeResponsesStream(state), + ]; + for (const e of events) { + if (e.type === "response.output_item.added" || e.type === "response.output_item.done") { + if (e.item.type !== "function_call") continue; + expect((e.item as { arguments?: string }).arguments).toBeTypeOf("string"); + } + } + }); + + 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 terminal = finalizeResponsesStream(state).find(e => e.type === "response.completed"); + 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("batch: reasoning + message + usage fields satisfy the client contract", () => { + const r = chatCompletionsToResponses({ + id: "c", object: "chat.completion", 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 { summary: Record[] }; + expect(reasoning.summary[0]).toHaveProperty("text"); + const message = r.output.find(i => i.type === "message") 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..e11ac02 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,18 @@ 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 are therefore always emitted; the upstream Chat usage may omit them + // (Anthropic translator only reports cache buckets when the upstream sends them, + // and never reports reasoning tokens). 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 +456,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 +466,7 @@ function toolCallAddedItem( id: entry.itemId, call_id: entry.id, name: entry.name, + arguments: "", status: "in_progress", }; } @@ -563,14 +572,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 +658,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 +671,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 +691,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 +702,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 +824,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..9bc3391 100644 --- a/src/translator/responses-types.ts +++ b/src/translator/responses-types.ts @@ -28,6 +28,8 @@ interface ResponsesReasoning { export interface ResponsesContentPart { type: "input_text" | "output_text" | "text" | "input_image" | "image_url" | "summary_text" | "input_video" | "video_url" | "input_file" | "file"; text?: string; + /** Output-text annotations — required (empty array) on the wire for output parts. */ + annotations?: unknown[]; image_url?: string | { url: string; detail?: string }; /** Video part (`input_video`/`video_url`): data URL or http(s) URL. */ video_url?: string | { url: string }; @@ -97,10 +99,12 @@ export interface ResponsesRequest { export interface ResponsesUsage { input_tokens: number; + // Required on the wire: the Responses client treats both detail objects as + // mandatory (serde `missing field` otherwise) — always emit them. + 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 { From 2fa3078272e956dc3dad4a6163201292eda0025a Mon Sep 17 00:00:00 2001 From: Catapult291 Date: Sun, 1 Nov 2026 09:52:22 +0800 Subject: [PATCH 2/3] fix(test/types): harden client-required wire tests; tighten usage types Address review on PR #77: - Fix all 5 tsc errors in chat-to-responses.test.ts: declare `annotations` in the asserted content-part shape, route union casts through `unknown`, add the missing `created` field to the batch fixture. - Stream tests now assert target events EXIST before inspecting payloads (the old filter-loops passed vacuously when the event under test was missing), and also cover the terminal `response.output` fields, the namespaced function_call branch, and preservation of non-zero cached_tokens / reasoning_tokens from upstream usage. - Tighten types: ResponsesUsage detail counts (cached_tokens / reasoning_tokens) are now required, matching the wire; output-text annotations moved from the shared ResponsesContentPart to a dedicated ResponsesOutputTextPart with required `text` + `annotations`. - Document the "0 means unavailable as well as zero" semantics of the usage fallback in chatUsageToResponsesUsage. Full suite: bun test 1109 pass / 0 fail; bun x tsc --noEmit clean. --- src/translator/chat-to-responses.test.ts | 146 ++++++++++++++++++----- src/translator/chat-to-responses.ts | 14 ++- src/translator/responses-types.ts | 23 +++- 3 files changed, 142 insertions(+), 41 deletions(-) diff --git a/src/translator/chat-to-responses.test.ts b/src/translator/chat-to-responses.test.ts index 076ca7d..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", annotations: [] }); + 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", () => { @@ -357,6 +358,8 @@ describe("streaming", () => { // 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", () => { @@ -365,19 +368,36 @@ describe("client-required wire fields", () => { ...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { content: "hi" } }] }), state), ...finalizeResponsesStream(state), ]; - for (const e of events) { - if (e.type === "response.output_item.added" || e.type === "response.output_item.done") { - if (e.item.type !== "message") continue; - for (const part of (e.item as { content: Record[] }).content) { - expect(part).toHaveProperty("text"); - expect(part).toHaveProperty("annotations"); - } - } - if (e.type === "response.content_part.added" || e.type === "response.content_part.done") { - expect(e.part).toHaveProperty("text"); - expect(e.part).toHaveProperty("annotations"); + 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", () => { @@ -387,30 +407,69 @@ describe("client-required wire fields", () => { ...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }] }), state), ...finalizeResponsesStream(state), ]; - for (const e of events) { - if (e.type === "response.output_item.added" || e.type === "response.output_item.done") { - if (e.item.type !== "reasoning") continue; - expect((e.item as { summary?: unknown[] }).summary).toBeInstanceOf(Array); - } - if (e.type === "response.reasoning_summary_part.added" || e.type === "response.reasoning_summary_part.done") { - expect(e.part).toHaveProperty("text"); - } + 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", () => { - const state = newResponsesStreamState("glm-5.2"); + 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), ]; - for (const e of events) { - if (e.type === "response.output_item.added" || e.type === "response.output_item.done") { - if (e.item.type !== "function_call") continue; - expect((e.item as { arguments?: string }).arguments).toBeTypeOf("string"); - } + 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); } }); @@ -419,8 +478,10 @@ describe("client-required wire fields", () => { // 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 terminal = finalizeResponsesStream(state).find(e => e.type === "response.completed"); - if (terminal?.type !== "response.completed") throw new Error("missing completed event"); + 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 }, @@ -430,17 +491,38 @@ describe("client-required wire fields", () => { }); }); + 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", model: "glm-5.2", + 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 { summary: Record[] }; + 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 { content: Record[] }; + 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 }); diff --git a/src/translator/chat-to-responses.ts b/src/translator/chat-to-responses.ts index e11ac02..7b81ba4 100644 --- a/src/translator/chat-to-responses.ts +++ b/src/translator/chat-to-responses.ts @@ -231,9 +231,17 @@ 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 are therefore always emitted; the upstream Chat usage may omit them - // (Anthropic translator only reports cache buckets when the upstream sends them, - // and never reports reasoning tokens). + // 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 }, diff --git a/src/translator/responses-types.ts b/src/translator/responses-types.ts index 9bc3391..bf6ffbe 100644 --- a/src/translator/responses-types.ts +++ b/src/translator/responses-types.ts @@ -28,8 +28,6 @@ interface ResponsesReasoning { export interface ResponsesContentPart { type: "input_text" | "output_text" | "text" | "input_image" | "image_url" | "summary_text" | "input_video" | "video_url" | "input_file" | "file"; text?: string; - /** Output-text annotations — required (empty array) on the wire for output parts. */ - annotations?: unknown[]; image_url?: string | { url: string; detail?: string }; /** Video part (`input_video`/`video_url`): data URL or http(s) URL. */ video_url?: string | { url: string }; @@ -41,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 } @@ -99,11 +108,13 @@ export interface ResponsesRequest { export interface ResponsesUsage { input_tokens: number; - // Required on the wire: the Responses client treats both detail objects as - // mandatory (serde `missing field` otherwise) — always emit them. - input_tokens_details: { cached_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 }; + output_tokens_details: { reasoning_tokens: number }; total_tokens: number; } From 93277256404f2b7055bab7047f82107c6378bb48 Mon Sep 17 00:00:00 2001 From: Catapult291 Date: Sun, 1 Nov 2026 09:59:41 +0800 Subject: [PATCH 3/3] refactor(types): wire ResponsesOutputTextPart into message output items The dedicated output-text part type introduced for the tightened annotations contract is now used by the message variant of ResponsesOutputItem, instead of widening through ResponsesContentPart. --- src/translator/responses-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/translator/responses-types.ts b/src/translator/responses-types.ts index bf6ffbe..76fc7cc 100644 --- a/src/translator/responses-types.ts +++ b/src/translator/responses-types.ts @@ -124,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 }