diff --git a/docs/field-conversions.md b/docs/field-conversions.md index c952521..09fc7c0 100644 --- a/docs/field-conversions.md +++ b/docs/field-conversions.md @@ -188,9 +188,10 @@ Complete mapping of all fields across the 12 pairwise converters between OpenAI | `choices[0].message.annotations` | `content[]` web_search_tool_result | | | `choices[0].message.tool_calls` | `content[]` tool_use | | | `finish_reason` | `stop_reason` | stop→end_turn, tool_calls→tool_use, length→max_tokens, content_filter→refusal | -| `usage.prompt_tokens` | `usage.input_tokens` | | +| `usage.prompt_tokens` - (`cached_tokens` + `cache_write_tokens`) | `usage.input_tokens` | | | `usage.completion_tokens` | `usage.output_tokens` | | | `prompt_tokens_details.cached_tokens` | `cache_read_input_tokens` | | +| `prompt_tokens_details.cache_write_tokens` | `cache_creation_input_tokens` | | | `prompt_tokens_details.web_search` | `server_tool_use.web_search_requests` | | Usage also re-emits enriched OpenAI-style fields (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details`, `completion_tokens_details`, audio token fields); origin `*_details` objects are merged in to preserve extra fields. Streaming reuses the same `buildUsage`, so `message_delta` carries real `output_tokens` from the trailing usage chunk. @@ -233,7 +234,10 @@ Usage also re-emits enriched OpenAI-style fields (`prompt_tokens`, `completion_t | `output[]` function_call | `content[]` tool_use | | | `output[]` message | `content[]` text | | | `status` | `stop_reason` | completed→end_turn/tool_use, incomplete→max_tokens, failed→end_turn | -| `usage.*` | `usage.*` | cached_tokens→cache_read_input_tokens | +| `usage.input_tokens` - (`cached_tokens` + `cache_write_tokens`) | `usage.input_tokens` | | +| `usage.input_tokens_details.cached_tokens` | `usage.cache_read_input_tokens` | | +| `usage.input_tokens_details.cache_write_tokens` | `usage.cache_creation_input_tokens` | | +| Other `usage.*` | `usage.*` | Reasoning and enriched OpenAI-style fields preserved | ### Stream: text ✅ tool_calls ✅ thinking ✅ usage ✅ @@ -273,7 +277,7 @@ Usage also re-emits enriched OpenAI-style fields (`prompt_tokens`, `completion_t | `candidates[0].content.parts[]` text | `content[]` text | | | `candidates[0].content.parts[]` functionCall | `content[]` tool_use | | | `candidates[0].finishReason` | `stop_reason` | STOP→end_turn, MAX_TOKENS→max_tokens, SAFETY→refusal | -| `usageMetadata.promptTokenCount` + `toolUsePromptTokenCount` | `usage.input_tokens` | Combined | +| `usageMetadata.promptTokenCount` + `toolUsePromptTokenCount` - `cachedContentTokenCount` | `usage.input_tokens` | Combined | | `usageMetadata.candidatesTokenCount` + `thoughtsTokenCount` | `usage.output_tokens` | Combined | | `usageMetadata.cachedContentTokenCount` | `cache_read_input_tokens` | | diff --git a/package.json b/package.json index a397e73..117ac7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenmux/rosetta-ai", - "version": "1.0.16", + "version": "1.0.17", "description": "Universal translator between AI provider protocols", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -60,4 +60,4 @@ "typescript": "^6.0.2", "typescript-eslint": "^8.58.2" } -} \ No newline at end of file +} diff --git a/src/messages/__tests__/chat-completions.test.ts b/src/messages/__tests__/chat-completions.test.ts index 9d0bb5b..0083be5 100644 --- a/src/messages/__tests__/chat-completions.test.ts +++ b/src/messages/__tests__/chat-completions.test.ts @@ -972,7 +972,7 @@ describe("MessagesToChatCompletionConverter", () => { expect(converter.convertResponse(input).stop_reason).toBe("tool_use"); }); - it("converts usage with cache details", () => { + it("separates cache read and write tokens from input_tokens", () => { const input: OpenAI.ChatCompletion = { id: "id", object: "chat.completion", @@ -990,13 +990,16 @@ describe("MessagesToChatCompletionConverter", () => { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150, - prompt_tokens_details: { cached_tokens: 30 }, - }, + prompt_tokens_details: { cached_tokens: 30, cache_write_tokens: 20 }, + } as any, }; const result = converter.convertResponse(input); - expect(result.usage.input_tokens).toBe(100); + expect(result.usage.input_tokens).toBe(50); expect(result.usage.output_tokens).toBe(50); expect(result.usage.cache_read_input_tokens).toBe(30); + expect(result.usage.cache_creation_input_tokens).toBe(20); + expect((result.usage as any).prompt_tokens).toBe(100); + expect((result.usage as any).total_tokens).toBe(150); }); it("enriches usage with OpenAI-style fields", () => { @@ -1030,6 +1033,7 @@ describe("MessagesToChatCompletionConverter", () => { expect(usage.prompt_tokens_details).toEqual({ cached_tokens: 30, audio_tokens: 4 }); expect(usage.audio_input_tokens).toBe(4); expect(usage.service_tier).toBe("standard"); + expect(usage.input_tokens).toBe(70); expect(usage.cache_creation_input_tokens).toBe(0); }); @@ -1491,7 +1495,12 @@ describe("MessagesToChatCompletionConverter", () => { created: 1700000000, model: "gpt-4o", choices: [], - usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19 }, + usage: { + prompt_tokens: 12, + completion_tokens: 7, + total_tokens: 19, + prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: 2 }, + }, } as unknown as OpenAI.ChatCompletionChunk, ]; @@ -1502,7 +1511,9 @@ describe("MessagesToChatCompletionConverter", () => { expect(msgDelta).toBeDefined(); expect(msgDelta.usage.output_tokens).toBe(7); - expect(msgDelta.usage.input_tokens).toBe(12); + expect(msgDelta.usage.input_tokens).toBe(7); + expect(msgDelta.usage.cache_read_input_tokens).toBe(3); + expect(msgDelta.usage.cache_creation_input_tokens).toBe(2); expect((msgDelta.usage as any).completion_tokens).toBe(7); expect((msgDelta.usage as any).prompt_tokens).toBe(12); expect((msgDelta.usage as any).total_tokens).toBe(19); diff --git a/src/messages/__tests__/gemini.test.ts b/src/messages/__tests__/gemini.test.ts index 588183b..eae5d79 100644 --- a/src/messages/__tests__/gemini.test.ts +++ b/src/messages/__tests__/gemini.test.ts @@ -693,6 +693,9 @@ describe("MessagesToGeminiConverter", () => { expect(usage.prompt_tokens).toBe(110); expect(usage.completion_tokens).toBe(70); expect(usage.total_tokens).toBe(180); + expect(usage.input_tokens).toBe(80); + expect(usage.cache_read_input_tokens).toBe(30); + expect(usage.cache_creation_input_tokens).toBe(0); expect(usage.prompt_tokens_details.cached_tokens).toBe(30); expect(usage.completion_tokens_details.reasoning_tokens).toBe(20); expect(usage.tool_use).toBe(10); @@ -945,6 +948,7 @@ describe("MessagesToGeminiConverter", () => { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15, + cachedContentTokenCount: 3, } as any, }) ); @@ -952,6 +956,8 @@ describe("MessagesToGeminiConverter", () => { const msgDelta = events.find(e => e.type === "message_delta") as any; expect(msgDelta).toBeDefined(); expect(msgDelta.delta.stop_reason).toBe("end_turn"); + expect(msgDelta.usage.input_tokens).toBe(7); + expect(msgDelta.usage.cache_read_input_tokens).toBe(3); const msgStop = events.find(e => e.type === "message_stop"); expect(msgStop).toBeDefined(); diff --git a/src/messages/__tests__/responses.test.ts b/src/messages/__tests__/responses.test.ts index 8595ac9..8e96738 100644 --- a/src/messages/__tests__/responses.test.ts +++ b/src/messages/__tests__/responses.test.ts @@ -341,22 +341,25 @@ describe("MessagesToResponsesConverter", () => { expect(result.stop_reason).toBe("max_tokens"); }); - it("includes cache tokens in usage", () => { + it("separates cache read and write tokens from input_tokens", () => { const result = converter.convertResponse( makeResponse({ usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150, - input_tokens_details: { cached_tokens: 30 }, + input_tokens_details: { cached_tokens: 30, cache_write_tokens: 20 }, output_tokens_details: { reasoning_tokens: 0 }, - }, + } as any, }) ); - expect(result.usage.input_tokens).toBe(100); + expect(result.usage.input_tokens).toBe(50); expect(result.usage.output_tokens).toBe(50); expect(result.usage.cache_read_input_tokens).toBe(30); + expect(result.usage.cache_creation_input_tokens).toBe(20); + expect((result.usage as any).prompt_tokens).toBe(100); + expect((result.usage as any).total_tokens).toBe(150); }); it("counts only completed web search calls", () => { @@ -651,7 +654,7 @@ describe("MessagesToResponsesConverter", () => { input_tokens: 10, output_tokens: 5, total_tokens: 15, - input_tokens_details: { cached_tokens: 3 }, + input_tokens_details: { cached_tokens: 3, cache_write_tokens: 2 }, output_tokens_details: { reasoning_tokens: 0 }, }, } as any, @@ -661,7 +664,10 @@ describe("MessagesToResponsesConverter", () => { const msgDelta = events.find( e => e.type === "message_delta" ) as Anthropic.RawMessageDeltaEvent; + expect(msgDelta.usage.input_tokens).toBe(5); expect(msgDelta.usage.output_tokens).toBe(5); + expect(msgDelta.usage.cache_read_input_tokens).toBe(3); + expect(msgDelta.usage.cache_creation_input_tokens).toBe(2); }); it("emits tool_use stop_reason when function_call is in output", () => { diff --git a/src/messages/chat-completions.ts b/src/messages/chat-completions.ts index 85347a9..8187a65 100644 --- a/src/messages/chat-completions.ts +++ b/src/messages/chat-completions.ts @@ -183,6 +183,8 @@ export class MessagesToChatCompletionConverter { const outputTokens = u.completion_tokens ?? 0; const totalTokens = u.total_tokens ?? inputTokens + outputTokens; const cachedTokens = u.prompt_tokens_details?.cached_tokens ?? 0; + const cacheWriteTokens = (u.prompt_tokens_details as any)?.cache_write_tokens ?? 0; + const uncachedInputTokens = Math.max(0, inputTokens - cachedTokens - cacheWriteTokens); const reasoningTokens = u.completion_tokens_details?.reasoning_tokens ?? 0; const webSearch: number = (u.prompt_tokens_details as any)?.web_search ?? 0; @@ -199,7 +201,7 @@ export class MessagesToChatCompletionConverter { cached_tokens: cachedTokens, ...(webSearch > 0 ? { web_search: webSearch } : {}), }, - input_tokens: inputTokens, + input_tokens: uncachedInputTokens, output_tokens: outputTokens, cache_read_input_tokens: cachedTokens, server_tool_use: @@ -209,7 +211,7 @@ export class MessagesToChatCompletionConverter { web_search_requests: webSearch, } : null, - cache_creation_input_tokens: 0, + cache_creation_input_tokens: cacheWriteTokens, service_tier: "standard", audio_input_tokens: u.prompt_tokens_details?.audio_tokens ?? 0, audio_cache_read_tokens: (u.prompt_tokens_details as any)?.audio_cached_tokens ?? 0, diff --git a/src/messages/gemini.ts b/src/messages/gemini.ts index 47b8b92..62d5f6b 100644 --- a/src/messages/gemini.ts +++ b/src/messages/gemini.ts @@ -216,6 +216,7 @@ export class MessagesToGeminiConverter { const promptTokens = promptTokenCount + toolUsePromptTokenCount; const completionTokens = candidatesTokenCount + thoughtsTokenCount; + const uncachedInputTokens = Math.max(0, promptTokens - cachedContentTokenCount); return { prompt_tokens: promptTokens, @@ -233,7 +234,7 @@ export class MessagesToGeminiConverter { web_search_queries: webSearchQueriesCount, tool_use: toolUsePromptTokenCount, trafficType, - input_tokens: promptTokens, + input_tokens: uncachedInputTokens, output_tokens: completionTokens, cache_read_input_tokens: cachedContentTokenCount, cache_creation_input_tokens: 0, diff --git a/src/messages/responses.ts b/src/messages/responses.ts index 982f9a2..6ef1b5c 100644 --- a/src/messages/responses.ts +++ b/src/messages/responses.ts @@ -170,6 +170,8 @@ export class MessagesToResponsesConverter { const outputTokens = u.output_tokens ?? 0; const totalTokens = u.total_tokens ?? inputTokens + outputTokens; const cachedTokens = u.input_tokens_details?.cached_tokens ?? 0; + const cacheWriteTokens = u.input_tokens_details?.cache_write_tokens ?? 0; + const uncachedInputTokens = Math.max(0, inputTokens - cachedTokens - cacheWriteTokens); const reasoningTokens = u.output_tokens_details?.reasoning_tokens ?? 0; return { @@ -185,7 +187,7 @@ export class MessagesToResponsesConverter { cached_tokens: cachedTokens, ...(webSearchCount > 0 ? { web_search: webSearchCount } : {}), }, - input_tokens: inputTokens, + input_tokens: uncachedInputTokens, output_tokens: outputTokens, cache_read_input_tokens: cachedTokens, server_tool_use: @@ -195,7 +197,7 @@ export class MessagesToResponsesConverter { web_search_requests: webSearchCount, } : null, - cache_creation_input_tokens: 0, + cache_creation_input_tokens: cacheWriteTokens, service_tier: "standard", audio_input_tokens: 0, audio_cache_read_tokens: 0,