Skip to content
Open
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
9 changes: 8 additions & 1 deletion packages/proxy/src/providers/google-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
63 changes: 63 additions & 0 deletions packages/proxy/src/providers/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading