diff --git a/packages/proxy/src/providers/google-converter.ts b/packages/proxy/src/providers/google-converter.ts index 84fd9c47..b3f35459 100644 --- a/packages/proxy/src/providers/google-converter.ts +++ b/packages/proxy/src/providers/google-converter.ts @@ -147,7 +147,14 @@ const geminiUsageToOpenAIUsage = ( const usage: OpenAICompletionUsage = { prompt_tokens: usageMetadata.promptTokenCount || 0, - completion_tokens: usageMetadata.candidatesTokenCount || 0, + // Gemini reports visible output (candidatesTokenCount) and thinking + // (thoughtsTokenCount) separately, and totalTokenCount is their sum. Follow + // the OpenAI convention (also used by the Anthropic converter and Lingua) + // where completion_tokens includes reasoning, so downstream cost estimation + // prices thinking tokens as output. reasoning_tokens below stays the subset + // breakdown. + completion_tokens: + (usageMetadata.candidatesTokenCount || 0) + (thoughtsTokenCount || 0), total_tokens: usageMetadata.totalTokenCount || 0, }; diff --git a/packages/proxy/src/providers/google.test.ts b/packages/proxy/src/providers/google.test.ts index 7c91ffd0..3f8a75bd 100644 --- a/packages/proxy/src/providers/google.test.ts +++ b/packages/proxy/src/providers/google.test.ts @@ -1412,6 +1412,37 @@ describe("googleCompletionToOpenAICompletion", () => { expect(result.choices[0].finish_reason).toBe("stop"); }); + it("should include thinking tokens in completion_tokens for cost estimation", () => { + const geminiResponse: GenerateContentResponse = { + candidates: [ + { + content: { + role: "model", + parts: [{ text: "The answer is 42." }], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + thoughtsTokenCount: 25, + totalTokenCount: 175, + }, + }; + + const result = googleCompletionToOpenAICompletion( + "gemini-2.5-flash", + geminiResponse, + ); + + // completion_tokens must include thinking tokens (50 + 25) so downstream + // cost estimation prices reasoning as output. reasoning_tokens is the subset. + expect(result.usage?.completion_tokens).toBe(75); + expect(result.usage?.completion_tokens_details?.reasoning_tokens).toBe(25); + expect(result.usage?.prompt_tokens).toBe(100); + }); + it("should map safety finish reasons to content_filter", () => { const geminiResponse: GenerateContentResponse = { candidates: [ @@ -1755,6 +1786,38 @@ describe("googleEventToOpenAIChatEvent", () => { }); expect(result.event!.choices[0].finish_reason).toBe("tool_calls"); }); + + it("should include thinking tokens in streaming completion_tokens", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const geminiEvent: any = { + candidates: [ + { + content: { + role: "model", + parts: [{ text: "The answer is 42." }], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + thoughtsTokenCount: 25, + totalTokenCount: 175, + }, + }; + + const result = googleEventToOpenAIChatEvent( + "gemini-2.5-flash", + geminiEvent, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usage = (result.event as any)?.usage; + expect(usage?.completion_tokens).toBe(75); + expect(usage?.completion_tokens_details?.reasoning_tokens).toBe(25); + expect(usage?.prompt_tokens).toBe(100); + }); }); describe("geminiParamsToOpenAITools", () => {