From abbb1c7534f340969c3bda71537b2260a5d09d24 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 02 Aug 2026 20:12:31 +0000 Subject: [PATCH] fix(translator): accept snake_case Gemini usage metadata in interactions conversion Closes: #4741 --- internal/translator/gemini/interactions/interactions_gemini_common.go | 25 +++++++++++++++++-------- internal/translator/gemini/interactions/interactions_gemini_common_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 file(s) changed, 58 insertion(s)(+), 8 deletion(s)(-) diff --git a/internal/translator/gemini/interactions/interactions_gemini_common.go b/internal/translator/gemini/interactions/interactions_gemini_common.go --- a/internal/translator/gemini/interactions/interactions_gemini_common.go +++ b/internal/translator/gemini/interactions/interactions_gemini_common.go @@ -1054,6 +1054,15 @@ return content } +func firstInteractionsGeminiUsage(usage gjson.Result, paths ...string) gjson.Result { + for _, path := range paths { + if value := usage.Get(path); value.Exists() { + return value + } + } + return gjson.Result{} +} + func setInteractionsUsageFromGemini(out []byte, path string, root gjson.Result) []byte { usage := root.Get("usageMetadata") if !usage.Exists() { @@ -1062,12 +1071,12 @@ if !usage.Exists() { return out } - out, _ = sjson.SetBytes(out, path+".input_tokens", usage.Get("promptTokenCount").Int()) - out, _ = sjson.SetBytes(out, path+".output_tokens", usage.Get("candidatesTokenCount").Int()) - if reasoning := usage.Get("thoughtsTokenCount"); reasoning.Exists() { + out, _ = sjson.SetBytes(out, path+".input_tokens", firstInteractionsGeminiUsage(usage, "promptTokenCount", "prompt_token_count").Int()) + out, _ = sjson.SetBytes(out, path+".output_tokens", firstInteractionsGeminiUsage(usage, "candidatesTokenCount", "candidates_token_count").Int()) + if reasoning := firstInteractionsGeminiUsage(usage, "thoughtsTokenCount", "thoughts_token_count"); reasoning.Exists() { out, _ = sjson.SetBytes(out, path+".reasoning_tokens", reasoning.Int()) } - out, _ = sjson.SetBytes(out, path+".total_tokens", usage.Get("totalTokenCount").Int()) + out, _ = sjson.SetBytes(out, path+".total_tokens", firstInteractionsGeminiUsage(usage, "totalTokenCount", "total_token_count").Int()) if cached := usage.Get("cachedContentTokenCount"); cached.Exists() { out, _ = sjson.SetBytes(out, path+".cached_tokens", cached.Int()) } else if cached := usage.Get("cached_content_token_count"); cached.Exists() { @@ -1084,10 +1093,10 @@ if !usage.Exists() { return out } - inputTokens := usage.Get("promptTokenCount").Int() - outputTokens := usage.Get("candidatesTokenCount").Int() - totalTokens := usage.Get("totalTokenCount").Int() - thoughtTokens := usage.Get("thoughtsTokenCount").Int() + inputTokens := firstInteractionsGeminiUsage(usage, "promptTokenCount", "prompt_token_count").Int() + outputTokens := firstInteractionsGeminiUsage(usage, "candidatesTokenCount", "candidates_token_count").Int() + totalTokens := firstInteractionsGeminiUsage(usage, "totalTokenCount", "total_token_count").Int() + thoughtTokens := firstInteractionsGeminiUsage(usage, "thoughtsTokenCount", "thoughts_token_count").Int() cachedTokens := usage.Get("cachedContentTokenCount").Int() if cachedTokens == 0 { cachedTokens = usage.Get("cached_content_token_count").Int() diff --git a/internal/translator/gemini/interactions/interactions_gemini_common_test.go b/internal/translator/gemini/interactions/interactions_gemini_common_test.go --- a/internal/translator/gemini/interactions/interactions_gemini_common_test.go +++ b/internal/translator/gemini/interactions/interactions_gemini_common_test.go @@ -65,6 +65,24 @@ } } +func TestConvertGeminiResponseToInteractionsNonStreamSnakeCaseUsage(t *testing.T) { + out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_snake","candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usage_metadata":{"prompt_token_count":11,"candidates_token_count":22,"total_token_count":33,"thoughts_token_count":44,"cached_content_token_count":55}}`)) + for _, test := range []struct { + path string + want int64 + }{ + {"usage.input_tokens", 11}, + {"usage.output_tokens", 22}, + {"usage.reasoning_tokens", 44}, + {"usage.total_tokens", 33}, + {"usage.cached_tokens", 55}, + } { + if got := gjson.GetBytes(out, test.path).Int(); got != test.want { + t.Fatalf("%s = %d, want %d. Output: %s", test.path, got, test.want, string(out)) + } + } +} + func TestConvertInteractionsResponseToGeminiStreamFunctionCall(t *testing.T) { var param any created := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"interaction":{"id":"i1","model":"gemini-3.1-flash-lite"},"event_type":"interaction.created"}`), ¶m) @@ -328,6 +346,29 @@ } if got := gjson.GetBytes(completed, "interaction.usage.total_thought_tokens").Int(); got != 2 { t.Fatalf("total_thought_tokens = %d, want 2. Payload: %s", got, string(completed)) + } +} + +func TestConvertGeminiResponseToInteractionsStreamSnakeCaseUsage(t *testing.T) { + var param any + out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usage_metadata":{"prompt_token_count":11,"candidates_token_count":22,"total_token_count":33,"thoughts_token_count":44,"cached_content_token_count":55}}`), ¶m) + if got := countEventType(out, "interaction.completed"); got != 1 { + t.Fatalf("interaction.completed count = %d, want 1. Events: %s", got, eventTypes(out)) + } + completed := findCompletedPayload(out) + for _, test := range []struct { + path string + want int64 + }{ + {"interaction.usage.total_input_tokens", 11}, + {"interaction.usage.total_output_tokens", 22}, + {"interaction.usage.total_thought_tokens", 44}, + {"interaction.usage.total_tokens", 33}, + {"interaction.usage.total_cached_tokens", 55}, + } { + if got := gjson.GetBytes(completed, test.path).Int(); got != test.want { + t.Fatalf("%s = %d, want %d. Payload: %s", test.path, got, test.want, string(completed)) + } } } -- tangled.sh