From 4c4b77c17b52be321b06ff772c002115754b7e14 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sat, 4 Jul 2026 12:46:20 +0700 Subject: [PATCH] fix(openai): record outgoingTextChars for Responses transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transformOpenAIChatCompletions set info.outgoingTextChars = countOutgoingTextChars(req) before returning; transformOpenAIResponses set info.compressed = true and returned without it. That field is the regression denominator (tokens ≈ α·outgoingTextChars + β·imagePixels), so compressed Responses rows silently carried no denominator. - add countResponsesOutgoingTextChars(): instructions + message-item text (string or input_text parts, via responsesContentText so input_image base64 is excluded) + flat tool name/description/parameters - set it on the Responses success path, mirroring the Chat path - test asserts outgoingTextChars > 0 for a compressed Responses request --- src/core/openai.ts | 29 +++++++++++++++++++++++++++++ tests/openai-gpt5.test.ts | 13 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/core/openai.ts b/src/core/openai.ts index 60d90dd..168b594 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -418,6 +418,32 @@ function countOutgoingTextChars(req: OpenAIChatRequest): number { return n; } +/** Outgoing text-char denominator for the GPT Responses regression, mirroring + * countOutgoingTextChars for Chat: instructions + message-item text (string or + * input_text parts) + flat tool name/description/parameters. input_image base64 + * is excluded on purpose — it is image cost, not text (responsesContentText + * already drops non-text parts). */ +function countResponsesOutgoingTextChars(req: ResponsesRequest): number { + let n = 0; + if (typeof req.instructions === 'string') n += req.instructions.length; + if (typeof req.input === 'string') { + n += req.input.length; + } else if (Array.isArray(req.input)) { + for (const item of req.input) { + n += responsesContentText((item as ResponsesInputItem).content).length; + } + } + if (Array.isArray(req.tools)) { + for (const tool of req.tools) { + if (!isFlatFunctionTool(tool)) continue; + if (typeof tool.name === 'string') n += tool.name.length; + if (typeof tool.description === 'string') n += tool.description.length; + if (tool.parameters !== undefined) n += safeStringifyLen(tool.parameters); + } + } + return n; +} + function safeStringifyLen(v: unknown): number { try { return JSON.stringify(v)?.length ?? 0; @@ -986,6 +1012,9 @@ export async function transformOpenAIResponses( if (rewrittenTools !== undefined) req.tools = rewrittenTools; + // Regression denominator, same as the Chat path — Responses was the only + // transform that never recorded it. + info.outgoingTextChars = countResponsesOutgoingTextChars(req); info.compressed = true; return { body: new TextEncoder().encode(JSON.stringify(req)), info }; } diff --git a/tests/openai-gpt5.test.ts b/tests/openai-gpt5.test.ts index eb64038..1648191 100644 --- a/tests/openai-gpt5.test.ts +++ b/tests/openai-gpt5.test.ts @@ -347,6 +347,19 @@ describe('transformOpenAIResponses (gpt-5.6)', () => { expect(textParts.some((p) => p.text?.includes('Do the thing'))).toBe(true); }); + it('records outgoingTextChars for compressed Responses requests (denominator parity with Chat)', async () => { + const body = enc.encode(JSON.stringify({ + model: 'gpt-5.6', + instructions: BIG_INSTRUCTIONS, + input: [{ role: 'user', content: 'Please do the thing.' }], + })); + const result = await transformOpenAIResponses(body, { charsPerToken: 1, minCompressChars: 1 }); + expect(result.info.compressed).toBe(true); + // Chat set this via countOutgoingTextChars; Responses never recorded it, so + // the tokens ≈ α·outgoingTextChars + β·imagePixels denominator was missing. + expect(result.info.outgoingTextChars).toBeGreaterThan(0); + }); + it('returns compressed=false with not_profitable/below_min reason for small input', async () => { const body = enc.encode(JSON.stringify({ model: 'gpt-5.6',