From c24e61b2fb70fbacb8c2e3bde74ef9f65eb16d6e Mon Sep 17 00:00:00 2001 From: tenglong Date: Thu, 25 Jun 2026 15:38:12 +0800 Subject: [PATCH] fix(responses): forward usage as input_tokens on response.completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v1/responses goes through stream.js → openai-responses translator. Map state.usage to Responses API shape in sendCompleted so Codex Desktop can parse response.completed.response.usage. --- open-sse/translator/concerns/usage.js | 20 +++++++ .../translator/response/openai-responses.js | 6 +- .../openai-responses-translator-usage.test.js | 60 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/unit/openai-responses-translator-usage.test.js diff --git a/open-sse/translator/concerns/usage.js b/open-sse/translator/concerns/usage.js index 44622901980..d69b417abca 100644 --- a/open-sse/translator/concerns/usage.js +++ b/open-sse/translator/concerns/usage.js @@ -58,3 +58,23 @@ export function toOpenAIUsage(raw, kind) { if (!extract || !raw || typeof raw !== "object") return null; return buildUsage(extract(raw)); } + +// OpenAI chat-completions usage → Responses API usage (Codex requires input_tokens). +export function toResponsesUsage(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + + const inputTokens = n(raw.input_tokens) || n(raw.prompt_tokens); + const outputTokens = n(raw.output_tokens) || n(raw.completion_tokens); + if (!inputTokens && !outputTokens) return null; + + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: n(raw.total_tokens) || inputTokens + outputTokens, + }; + + const cachedTokens = n(raw.input_tokens_details?.cached_tokens) || n(raw.prompt_tokens_details?.cached_tokens); + if (cachedTokens > 0) usage.input_tokens_details = { cached_tokens: cachedTokens }; + + return usage; +} diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index b93367853f4..81972df1631 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -5,7 +5,7 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { buildChunk } from "../concerns/chunk.js"; -import { buildUsage } from "../concerns/usage.js"; +import { buildUsage, toResponsesUsage } from "../concerns/usage.js"; import { fallbackToolCallId } from "../concerns/toolCall.js"; import { reasoningDelta, extractReasoningText } from "../concerns/reasoning.js"; import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } from "../schema/index.js"; @@ -329,6 +329,7 @@ function closeToolCall(state, emit, idx) { function sendCompleted(state, emit) { if (!state.completedSent) { state.completedSent = true; + const usage = toResponsesUsage(state.usage); emit("response.completed", { type: "response.completed", response: { @@ -337,7 +338,8 @@ function sendCompleted(state, emit) { created_at: state.created, status: "completed", background: false, - error: null + error: null, + ...(usage ? { usage } : {}) } }); } diff --git a/tests/unit/openai-responses-translator-usage.test.js b/tests/unit/openai-responses-translator-usage.test.js new file mode 100644 index 00000000000..fce1acb1fb7 --- /dev/null +++ b/tests/unit/openai-responses-translator-usage.test.js @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { toResponsesUsage } from "../../open-sse/translator/concerns/usage.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { initState, translateResponse } from "../../open-sse/translator/index.js"; +import "../../open-sse/translator/response/openai-responses.js"; + +function completedUsage(chunks) { + const state = initState(FORMATS.OPENAI_RESPONSES); + for (const chunk of chunks) { + if (chunk.usage) state.usage = chunk.usage; + const events = translateResponse(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, chunk, state); + const completed = events.find((e) => e?.event === "response.completed"); + if (completed) return completed.data.response.usage; + } + return undefined; +} + +describe("toResponsesUsage", () => { + it("maps prompt_tokens to input_tokens", () => { + expect(toResponsesUsage({ + prompt_tokens: 12, + completion_tokens: 7, + total_tokens: 19, + prompt_tokens_details: { cached_tokens: 4 }, + })).toEqual({ + input_tokens: 12, + output_tokens: 7, + total_tokens: 19, + input_tokens_details: { cached_tokens: 4 }, + }); + }); + + it("returns null when no token counts are present", () => { + expect(toResponsesUsage(null)).toBeNull(); + expect(toResponsesUsage({})).toBeNull(); + }); +}); + +describe("openai-responses translator", () => { + it("attaches Responses-shaped usage on response.completed", () => { + const usage = completedUsage([ + { id: "chatcmpl-x", choices: [{ index: 0, delta: { role: "assistant", content: "hi" } }] }, + { + id: "chatcmpl-x", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19, prompt_tokens_details: { cached_tokens: 4 } }, + }, + ]); + expect(usage.input_tokens).toBe(12); + expect(usage.input_tokens_details.cached_tokens).toBe(4); + }); + + it("omits usage when upstream never reported token counts", () => { + expect(completedUsage([ + { id: "chatcmpl-z", choices: [{ index: 0, delta: { role: "assistant", content: "ok" } }] }, + { id: "chatcmpl-z", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, + ])).toBeUndefined(); + }); +}); \ No newline at end of file