diff --git a/containers/api-proxy/token-parsers.js b/containers/api-proxy/token-parsers.js index 65935aa84..5653813de 100644 --- a/containers/api-proxy/token-parsers.js +++ b/containers/api-proxy/token-parsers.js @@ -217,6 +217,28 @@ function buildStreamingFinalChunkUsage(usage) { return out; } +/** + * Build usage from Gemini's usageMetadata format. + * + * Gemini API returns token counts in a different structure: + * usageMetadata: { + * promptTokenCount: , + * candidatesTokenCount: , + * totalTokenCount: , + * cachedContentTokenCount: , // optional + * thoughtsTokenCount: , // optional, for thinking models + * } + */ +function buildGeminiUsage(usageMetadata) { + const out = {}; + if (typeof usageMetadata.promptTokenCount === 'number') out.input_tokens = usageMetadata.promptTokenCount; + if (typeof usageMetadata.candidatesTokenCount === 'number') out.output_tokens = usageMetadata.candidatesTokenCount; + if (typeof usageMetadata.totalTokenCount === 'number') out.total_tokens = usageMetadata.totalTokenCount; + if (typeof usageMetadata.cachedContentTokenCount === 'number') out.cache_read_input_tokens = usageMetadata.cachedContentTokenCount; + if (typeof usageMetadata.thoughtsTokenCount === 'number') out.reasoning_tokens = usageMetadata.thoughtsTokenCount; + return Object.keys(out).length > 0 ? out : null; +} + /** * Extract the authoritative per-type token breakdown from a Copilot * `copilot_usage.token_details` array. @@ -307,6 +329,14 @@ function extractUsageFromJson(body) { result.usage = buildUsageFromSource(usageSource); result.usage = mergeCopilotBreakdown(result.usage, json); + // Gemini API: usage is in usageMetadata with different field names + if (!result.usage && json.usageMetadata && typeof json.usageMetadata === 'object') { + result.usage = buildGeminiUsage(json.usageMetadata); + if (!result.model) { + result.model = json.modelVersion || null; + } + } + return result; } catch { return { usage: null, model: null }; @@ -363,6 +393,13 @@ function extractUsageFromSseLine(line) { return result; } + // Gemini streaming: usageMetadata in each chunk (last chunk has final totals) + if (json.usageMetadata && typeof json.usageMetadata === 'object') { + result.usage = buildGeminiUsage(json.usageMetadata); + if (!result.model) result.model = json.modelVersion || null; + return result; + } + return result; } catch { return { usage: null, model: null }; diff --git a/containers/api-proxy/token-parsers.json.test.js b/containers/api-proxy/token-parsers.json.test.js index 670b2fe5c..7c2863d3d 100644 --- a/containers/api-proxy/token-parsers.json.test.js +++ b/containers/api-proxy/token-parsers.json.test.js @@ -396,4 +396,86 @@ describe('extractUsageFromJson with copilot_usage', () => { reasoning_tokens: 0, }); }); + + // ── Gemini usageMetadata ────────────────────────────────────────────── + + test('extracts Gemini usageMetadata from non-streaming response', () => { + const body = Buffer.from(JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'hello' }] } }], + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }, + modelVersion: 'gemini-3-flash-preview', + })); + + const result = extractUsageFromJson(body); + expect(result.model).toBe('gemini-3-flash-preview'); + expect(result.usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }); + }); + + test('extracts Gemini usageMetadata with cachedContentTokenCount', () => { + const body = Buffer.from(JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'hello' }] } }], + usageMetadata: { + promptTokenCount: 5000, + candidatesTokenCount: 200, + totalTokenCount: 5200, + cachedContentTokenCount: 4000, + }, + })); + + const result = extractUsageFromJson(body); + expect(result.usage).toEqual({ + input_tokens: 5000, + output_tokens: 200, + total_tokens: 5200, + cache_read_input_tokens: 4000, + }); + expect(normalizeUsage(result.usage)).toEqual({ + input_tokens: 5000, + output_tokens: 200, + cache_read_tokens: 4000, + cache_write_tokens: 0, + reasoning_tokens: 0, + }); + }); + + test('extracts Gemini usageMetadata with thoughtsTokenCount', () => { + const body = Buffer.from(JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'hello' }] } }], + usageMetadata: { + promptTokenCount: 1000, + candidatesTokenCount: 500, + totalTokenCount: 2500, + thoughtsTokenCount: 1000, + }, + modelVersion: 'gemini-3-pro', + })); + + const result = extractUsageFromJson(body); + expect(result.model).toBe('gemini-3-pro'); + expect(result.usage).toEqual({ + input_tokens: 1000, + output_tokens: 500, + total_tokens: 2500, + reasoning_tokens: 1000, + }); + }); + + test('prefers usage field over usageMetadata when both present', () => { + const body = Buffer.from(JSON.stringify({ + model: 'some-model', + usage: { input_tokens: 10, output_tokens: 5 }, + usageMetadata: { promptTokenCount: 99, candidatesTokenCount: 99 }, + })); + + const result = extractUsageFromJson(body); + expect(result.usage).toEqual({ input_tokens: 10, output_tokens: 5 }); + }); }); diff --git a/containers/api-proxy/token-parsers.sse.test.js b/containers/api-proxy/token-parsers.sse.test.js index a1d8a2799..dfd035574 100644 --- a/containers/api-proxy/token-parsers.sse.test.js +++ b/containers/api-proxy/token-parsers.sse.test.js @@ -391,4 +391,67 @@ describe('extractUsageFromSseLine with copilot_usage', () => { reasoning_tokens: 0, }); }); + + // ── Gemini streaming usageMetadata ────────────────────────────────────── + + test('extracts Gemini usageMetadata from SSE chunk', () => { + const line = JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'hello' }] } }], + usageMetadata: { + promptTokenCount: 304854, + candidatesTokenCount: 2313, + totalTokenCount: 307167, + cachedContentTokenCount: 201702, + }, + modelVersion: 'gemini-3-flash-preview', + }); + + const { usage, model } = extractUsageFromSseLine(line); + expect(model).toBe('gemini-3-flash-preview'); + expect(usage).toEqual({ + input_tokens: 304854, + output_tokens: 2313, + total_tokens: 307167, + cache_read_input_tokens: 201702, + }); + expect(normalizeUsage(usage)).toEqual({ + input_tokens: 304854, + output_tokens: 2313, + cache_read_tokens: 201702, + cache_write_tokens: 0, + reasoning_tokens: 0, + }); + }); + + test('extracts Gemini usageMetadata with thoughtsTokenCount from SSE', () => { + const line = JSON.stringify({ + usageMetadata: { + promptTokenCount: 1000, + candidatesTokenCount: 500, + totalTokenCount: 2500, + thoughtsTokenCount: 1000, + }, + modelVersion: 'gemini-3-pro', + }); + + const { usage, model } = extractUsageFromSseLine(line); + expect(model).toBe('gemini-3-pro'); + expect(usage).toEqual({ + input_tokens: 1000, + output_tokens: 500, + total_tokens: 2500, + reasoning_tokens: 1000, + }); + }); + + test('prefers usage over usageMetadata in SSE line', () => { + const line = JSON.stringify({ + model: 'some-model', + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + usageMetadata: { promptTokenCount: 99, candidatesTokenCount: 99 }, + }); + + const { usage } = extractUsageFromSseLine(line); + expect(usage).toEqual({ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }); + }); });