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
18 changes: 18 additions & 0 deletions src/translator/chat-to-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ describe("chatCompletionsToResponses (batch)", () => {
expect((r.output[0] as { content: { type: string; text: string }[] }).content[0]).toEqual({ type: "output_text", text: "hello" });
});

it("carries cache-inclusive Chat usage into the Responses usage details", () => {
const r = chatCompletionsToResponses(chatResp({
usage: {
prompt_tokens: 1224,
completion_tokens: 3,
total_tokens: 1227,
prompt_tokens_details: { cached_tokens: 1216 },
},
}), "glm-5.2");

expect(r.usage).toEqual({
input_tokens: 1224,
output_tokens: 3,
total_tokens: 1227,
input_tokens_details: { cached_tokens: 1216 },
});
});

it("emits reasoning output item before message", () => {
const r = chatCompletionsToResponses(chatResp({
choices: [{
Expand Down
97 changes: 97 additions & 0 deletions src/translator/openai-to-anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { describe, it, expect } from "bun:test";
import {
translateRequestOpenAIToAnthropic,
translateResponseAnthropicToOpenAI,
anthropicUsageToOpenAI,
} from "./openai-to-anthropic.js";
import {
translateRequestAnthropicToOpenAI,
translateResponseOpenAIToAnthropic,
openaiUsageToAnthropic,
} from "./anthropic-to-openai.js";
import type {
OpenAIChatRequest,
Expand Down Expand Up @@ -466,6 +468,76 @@ describe("translateRequestOpenAIToAnthropic", () => {
});
});

describe("anthropicUsageToOpenAI", () => {
it("folds cache reads into prompt_tokens and reports them as cached_tokens", () => {
expect(anthropicUsageToOpenAI({
input_tokens: 8,
output_tokens: 3,
cache_read_input_tokens: 1216,
cache_creation_input_tokens: 0,
})).toEqual({
prompt_tokens: 1224,
completion_tokens: 3,
total_tokens: 1227,
prompt_tokens_details: { cached_tokens: 1216 },
});
});

it("counts both cache buckets but exposes only the read bucket", () => {
const usage = anthropicUsageToOpenAI({
input_tokens: 100,
output_tokens: 5,
cache_read_input_tokens: 20,
cache_creation_input_tokens: 30,
});

expect(usage).toEqual({
prompt_tokens: 150,
completion_tokens: 5,
total_tokens: 155,
prompt_tokens_details: { cached_tokens: 20 },
});
expect(usage).not.toHaveProperty("cache_read_input_tokens");
expect(usage).not.toHaveProperty("cache_creation_input_tokens");
});

it("does not fabricate prompt_tokens_details when no cache bucket is reported", () => {
expect(anthropicUsageToOpenAI({ input_tokens: 10, output_tokens: 2 })).toEqual({
prompt_tokens: 10,
completion_tokens: 2,
total_tokens: 12,
});
});

it("preserves an explicitly reported zero cache read", () => {
expect(anthropicUsageToOpenAI({
input_tokens: 10,
output_tokens: 2,
cache_read_input_tokens: 0,
}).prompt_tokens_details).toEqual({ cached_tokens: 0 });
});

it("round-trips fresh input and cache reads through openaiUsageToAnthropic", () => {
const anthropic = { input_tokens: 8, output_tokens: 3, cache_read_input_tokens: 1216 };
expect(openaiUsageToAnthropic(anthropicUsageToOpenAI(anthropic))).toEqual(anthropic);
});

it("round-trips totals but not the cache-creation bucket, which OpenAI cannot express", () => {
const anthropic = {
input_tokens: 8,
output_tokens: 3,
cache_read_input_tokens: 100,
cache_creation_input_tokens: 20,
};
const back = openaiUsageToAnthropic(anthropicUsageToOpenAI(anthropic));

expect(back.input_tokens + (back.cache_read_input_tokens ?? 0) + (back.cache_creation_input_tokens ?? 0))
.toBe(128);
expect(back.cache_creation_input_tokens).toBeUndefined();
expect(back.input_tokens).toBe(28);
});
});

describe("translateResponseAnthropicToOpenAI", () => {
it("extracts text content from response", () => {
const resp: AnthropicMessagesResponse = {
Expand Down Expand Up @@ -535,6 +607,31 @@ describe("translateResponseAnthropicToOpenAI", () => {
expect(result.usage!.total_tokens).toBe(150);
});

it("reports cache-inclusive prompt_tokens for a cached response", () => {
const resp: AnthropicMessagesResponse = {
id: "msg_1",
type: "message",
role: "assistant",
content: [{ type: "text", text: "Hi" }],
model: "glm-4.6",
stop_reason: "end_turn",
stop_sequence: null,
usage: {
input_tokens: 8,
output_tokens: 3,
cache_read_input_tokens: 1216,
cache_creation_input_tokens: 0,
},
};

expect(translateResponseAnthropicToOpenAI(resp, "glm-4.6").usage).toEqual({
prompt_tokens: 1224,
completion_tokens: 3,
total_tokens: 1227,
prompt_tokens_details: { cached_tokens: 1216 },
});
});

it("preserves thinking blocks as OpenAI reasoning_content", () => {
const resp: AnthropicMessagesResponse = {
id: "msg_1",
Expand Down
42 changes: 37 additions & 5 deletions src/translator/openai-to-anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type {
OpenAIChatRequest,
OpenAIChatResponse,
OpenAIUsage,
OpenAIMessage,
OpenAIToolDefinition,
AnthropicMessagesRequest,
Expand All @@ -13,6 +14,7 @@ import type {
AnthropicContentBlock,
AnthropicToolDefinition,
AnthropicThinkingConfig,
AnthropicUsage,
} from "./types.js";
import { MODELS } from "../provider/models.js";

Expand Down Expand Up @@ -203,6 +205,40 @@ function imageUrlToAnthropicBlock(url: string): AnthropicContentBlock {
return { type: "text", text: url };
}

/**
* Convert an Anthropic usage block into OpenAI's cache-inclusive usage
* semantics — the counterpart of `openaiUsageToAnthropic`.
*
* Anthropic reports the fresh (uncached) input in `input_tokens` and keeps the
* three buckets mutually exclusive; OpenAI's `prompt_tokens` is *inclusive* of
* cache hits. So prompt = input + cache_read + cache_creation, with the cache
* read additionally surfaced through the standard `prompt_tokens_details`.
* The Anthropic-style cache fields are deliberately not mirrored onto the
* OpenAI usage object — strict-schema clients reject unknown members.
*
* Totals round-trip exactly; buckets do not. OpenAI has no cache-creation
* member, so a converted-and-converted-back usage reclassifies those tokens as
* fresh input. `prompt_tokens` and `total_tokens` stay correct either way.
*/
export function anthropicUsageToOpenAI(usage: AnthropicUsage | undefined): OpenAIUsage {
const inputTokens = usage?.input_tokens ?? 0;
const outputTokens = usage?.output_tokens ?? 0;
const cacheRead = usage?.cache_read_input_tokens ?? 0;
const cacheCreation = usage?.cache_creation_input_tokens ?? 0;
const promptTokens = inputTokens + cacheRead + cacheCreation;

return {
prompt_tokens: promptTokens,
completion_tokens: outputTokens,
total_tokens: promptTokens + outputTokens,
// Presence-preserving: an upstream explicitly reporting 0 cache reads stays
// distinguishable from one reporting no cache breakdown at all.
...(usage?.cache_read_input_tokens != null
? { prompt_tokens_details: { cached_tokens: cacheRead } }
: {}),
};
}

/** Translate an Anthropic messages response into an OpenAI chat completion response. */
export function translateResponseAnthropicToOpenAI(
resp: AnthropicMessagesResponse,
Expand Down Expand Up @@ -242,11 +278,7 @@ export function translateResponseAnthropicToOpenAI(
},
finish_reason: finishReason,
}],
usage: {
prompt_tokens: resp.usage?.input_tokens ?? 0,
completion_tokens: resp.usage?.output_tokens ?? 0,
total_tokens: (resp.usage?.input_tokens ?? 0) + (resp.usage?.output_tokens ?? 0),
},
usage: anthropicUsageToOpenAI(resp.usage),
};
}

Expand Down
Loading