fix(openai): record outgoingTextChars for Responses transforms

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
This commit is contained in:
Danil Silantyev
2026-07-04 12:46:20 +07:00
committed by Steven
parent 0e4a61695f
commit 4c4b77c17b
2 changed files with 42 additions and 0 deletions
+29
View File
@@ -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 };
}
+13
View File
@@ -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',