Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion open-sse/handlers/responseSanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { stripObfuscationZeroWidth } from "../utils/zeroWidth.ts";
import { normalizeArrayContentChunk } from "../utils/arrayContentDelta.ts";
import { assignAliasCacheWrite } from "../utils/pickCacheCreationTokens.ts";
import { findBalancedJsonEnd } from "./responseSanitizer/balancedJson.ts";
import { normalizeResponsesContextUsage } from "../utils/normalizeResponsesContextUsage.ts";
export {
extractThinkingFromContent,
shouldParseTextualReasoningTags,
Expand Down Expand Up @@ -561,7 +562,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown {
sanitized.output_tokens = outputTokens;
sanitized.total_tokens = totalTokens;

return sanitized;
return normalizeResponsesContextUsage(sanitized);
}

/**
Expand Down Expand Up @@ -773,6 +774,10 @@ function sanitizeResponsesStreamingEvent(parsedRecord: JsonRecord): JsonRecord {
} else {
delete sanitizedResponse.output_text;
}
const responseUsage = toRecord(responseRecord.usage);
if (responseUsage) {
sanitizedResponse.usage = normalizeResponsesContextUsage(responseUsage);
}
sanitized.response = sanitizedResponse;
}

Expand Down
4 changes: 3 additions & 1 deletion open-sse/transformer/responsesTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as fs from "fs";
import * as path from "path";
import { plaintextCollaborationFields } from "../translator/response/openai-responses/collaborationPlaintextMarker.ts";
import { finalizeResponsesTerminalStatus } from "../translator/helpers/responsesTerminalStatus.ts";
import { normalizeResponsesContextUsage } from "../utils/normalizeResponsesContextUsage.ts";

// #10223: threshold for detecting corrupted request_id fields. Normal
// request IDs are <100 chars. DeepSeek's SSE encoder bug produces 200+
Expand Down Expand Up @@ -613,7 +614,8 @@ export function createResponsesApiTransformStream(
};

if (state.usage) {
response.usage = state.usage;
// Keep aggregation in upstream units; only emitted Responses usage includes cache writes.
response.usage = normalizeResponsesContextUsage(state.usage);
}

const eventType = finalizeResponsesTerminalStatus(response, state.finishReason);
Expand Down
4 changes: 3 additions & 1 deletion open-sse/translator/response/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { finalizeResponsesTerminalStatus } from "../helpers/responsesTerminalSta
import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
import { getReadableReasoningValue } from "../../utils/reasoningFields.ts";
import { resolveResponsesCacheUsageDetails } from "../../utils/resolveResponsesCacheUsageDetails.ts";
import { normalizeResponsesContextUsage } from "../../utils/normalizeResponsesContextUsage.ts";
import { pickCacheCreationInPrompt } from "../../utils/pickCacheCreationTokens.ts";
import {
isInternalReasoningPlaceholder,
Expand Down Expand Up @@ -870,7 +871,8 @@ function sendCompleted(state, emit) {
}

if (state.usage) {
response.usage = state.usage;
// Preserve upstream accounting in state; expose the full context only to Responses clients.
response.usage = normalizeResponsesContextUsage(state.usage);
}

const eventType = finalizeResponsesTerminalStatus(response, state.finishReason, !!upstreamErr);
Expand Down
34 changes: 34 additions & 0 deletions open-sse/utils/normalizeResponsesContextUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Responses clients use input_tokens as the full context size. The internal
* Claude-to-Chat billing contract can exclude cache writes and mark that fact
* explicitly; fold those writes back in only at the Responses boundary.
* Mark the result inclusive so subsequent sanitization cannot add them twice.
*/
export function normalizeResponsesContextUsage<T extends Record<string, unknown>>(usage: T): T {
const details = usage.input_tokens_details;
if (!details || typeof details !== "object" || Array.isArray(details)) return usage;
const inputDetails = details as Record<string, unknown>;
const creation = inputDetails.cache_creation_tokens;
const input = usage.input_tokens;
if (
inputDetails.cache_creation_in_prompt !== false ||
typeof creation !== "number" ||
!Number.isFinite(creation) ||
creation <= 0 ||
typeof input !== "number" ||
!Number.isFinite(input)
)
return usage;

const output = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
const total =
typeof usage.total_tokens === "number" && Number.isFinite(usage.total_tokens)
? usage.total_tokens
: input + output;
return {
...usage,
input_tokens: input + creation,
total_tokens: total + creation,
input_tokens_details: { ...inputDetails, cache_creation_in_prompt: true },
};
}
27 changes: 18 additions & 9 deletions tests/unit/cache-creation-marker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,27 @@ for (const included of [false, true]) {
);
});

test(`nonstream Chat -> Responses preserves ${included} without changing token counts`, () => {
test(`nonstream Responses includes the whole context for cache-in-prompt ${included}`, () => {
const converted = translateNonStreamingResponse(
chatBody(input, included),
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES
);
const result = sanitizeResponsesApiResponse(converted);
assert.equal(readPath(result, "usage", "input_tokens"), input);
assert.equal(readPath(result, "usage", "input_tokens"), 714029);
assert.equal(readPath(result, "usage", "total_tokens"), 714164);
assert.equal(readPath(converted, "usage", "prompt_tokens"), input);
assert.equal(
readPath(result, "usage", "input_tokens_details", "cache_creation_tokens"),
713119
);
assert.equal(
readPath(result, "usage", "input_tokens_details", "cache_creation_in_prompt"),
included
true
);
});

test(`stream Chat -> Responses preserves ${included} without changing token counts`, () => {
test(`stream Responses includes context and preserves internal cache-in-prompt ${included}`, () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
const usage = chatBody(input, included).usage;
const events: unknown[] = [];
Expand Down Expand Up @@ -104,11 +106,17 @@ for (const included of [false, true]) {
.map((event) => record(event).data ?? event)
.find((event) => record(event).type === "response.completed");
assert.ok(terminal, "missing terminal Responses event");
assert.equal(readPath(terminal, "response", "usage", "input_tokens"), input);
assert.equal(readPath(terminal, "response", "usage", "input_tokens"), 714029);
assert.equal(readPath(terminal, "response", "usage", "total_tokens"), 714164);
assert.equal(readPath(state, "usage", "input_tokens"), input);
assert.equal(
readPath(terminal, "response", "usage", "input_tokens_details", "cache_creation_in_prompt"),
readPath(state, "usage", "input_tokens_details", "cache_creation_in_prompt"),
included
);
assert.equal(
readPath(terminal, "response", "usage", "input_tokens_details", "cache_creation_in_prompt"),
true
);
});
}

Expand Down Expand Up @@ -235,10 +243,11 @@ for (const included of [false, true]) {
assert.equal(readPath(usage, "prompt_tokens_details", "cache_creation_in_prompt"), included);
});

test(`Responses transform retains cache-in-prompt ${included}`, async () => {
test(`Responses transform includes the whole context for cache-in-prompt ${included}`, async () => {
const usage = await streamUsage(chunks, createResponsesApiTransformStream());
assert.equal(usage.input_tokens, input);
assert.equal(readPath(usage, "input_tokens_details", "cache_creation_in_prompt"), included);
assert.equal(usage.input_tokens, 714029);
assert.equal(usage.total_tokens, 714164);
assert.equal(readPath(usage, "input_tokens_details", "cache_creation_in_prompt"), true);
});
}

Expand Down
Loading
Loading