diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index a0bae8a4c..1d0730a7f 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -157,12 +157,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = append(output, stopCodexTextBlock(params)...) template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasEmittedToolUse)) template = setClaudeStopSequence(template, "delta.stop_sequence", responseData) - inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage")) + inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractResponsesUsage(responseData.Get("usage")) template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens) template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens) if cachedTokens > 0 { template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", cachedTokens) } + if cacheWriteTokens > 0 { + template, _ = sjson.SetBytes(template, "usage.cache_creation_input_tokens", cacheWriteTokens) + } output = translatorcommon.AppendSSEEventBytes(output, "message_delta", template, 2) output = translatorcommon.AppendSSEEventBytes(output, "message_stop", []byte(`{"type":"message_stop"}`), 2) @@ -361,12 +364,15 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`) out, _ = sjson.SetBytes(out, "id", responseData.Get("id").String()) out, _ = sjson.SetBytes(out, "model", responseData.Get("model").String()) - inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage")) + inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractResponsesUsage(responseData.Get("usage")) out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens) out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens) if cachedTokens > 0 { out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens) } + if cacheWriteTokens > 0 { + out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens) + } hasToolCall := false webSearchSeen := make(map[string]struct{}) @@ -794,14 +800,18 @@ func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) s return name } -func extractResponsesUsage(usage gjson.Result) (int64, int64, int64) { +func extractResponsesUsage(usage gjson.Result) (int64, int64, int64, int64) { if !usage.Exists() || usage.Type == gjson.Null { - return 0, 0, 0 + return 0, 0, 0, 0 } inputTokens := usage.Get("input_tokens").Int() outputTokens := usage.Get("output_tokens").Int() cachedTokens := usage.Get("input_tokens_details.cached_tokens").Int() + cacheWriteTokens := usage.Get("input_tokens_details.cache_write_tokens").Int() + if cacheWriteTokens == 0 { + cacheWriteTokens = usage.Get("input_tokens_details.cache_creation_tokens").Int() + } if cachedTokens > 0 { if inputTokens >= cachedTokens { @@ -811,7 +821,7 @@ func extractResponsesUsage(usage gjson.Result) (int64, int64, int64) { } } - return inputTokens, outputTokens, cachedTokens + return inputTokens, outputTokens, cachedTokens, cacheWriteTokens } // buildReverseMapFromClaudeOriginalShortToOriginal builds a map[short]original from original Claude request tools. diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index 3ed49a4db..582bca4de 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -3,6 +3,7 @@ package claude import ( "bytes" "context" + "fmt" "strings" "testing" @@ -1328,3 +1329,196 @@ func firstClaudeStreamPayloadForEvent(output, event string) (gjson.Result, bool) } return gjson.Result{}, false } + +func TestConvertCodexResponseToClaude_StreamPreservesCacheWriteUsage(t *testing.T) { + tests := []struct { + name string + terminalUsageJSON string + wantInputTokens int64 + wantOutputTokens int64 + wantCacheReadTokens int64 + wantCacheWriteTokens int64 + }{ + { + name: "cache_write_tokens field", + terminalUsageJSON: `{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":150}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 150, + }, + { + name: "cache_creation_tokens field alias", + terminalUsageJSON: `{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_creation_tokens":150}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 150, + }, + { + name: "cached_tokens greater than input_tokens clamps input_tokens to zero", + terminalUsageJSON: `{"input_tokens":500,"output_tokens":100,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":50}}`, + wantInputTokens: 0, + wantOutputTokens: 100, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 50, + }, + { + name: "zero cache_write_tokens does not emit cache_creation_input_tokens", + terminalUsageJSON: `{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":0}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}`), + []byte(fmt.Sprintf(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":%s}}`, tt.terminalUsageJSON)), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + delta, ok := findClaudeStreamMessageDelta(outputs) + if !ok { + t.Fatalf("missing message_delta event; outputs=%q", outputs) + } + + usage := delta.Get("usage") + if got := usage.Get("input_tokens").Int(); got != tt.wantInputTokens { + t.Fatalf("input_tokens = %d, want %d", got, tt.wantInputTokens) + } + if got := usage.Get("output_tokens").Int(); got != tt.wantOutputTokens { + t.Fatalf("output_tokens = %d, want %d", got, tt.wantOutputTokens) + } + if got := usage.Get("cache_read_input_tokens").Int(); got != tt.wantCacheReadTokens { + t.Fatalf("cache_read_input_tokens = %d, want %d", got, tt.wantCacheReadTokens) + } + if tt.wantCacheWriteTokens == 0 { + if usage.Get("cache_creation_input_tokens").Exists() { + t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", usage.Get("cache_creation_input_tokens").Raw) + } + } else if got := usage.Get("cache_creation_input_tokens").Int(); got != tt.wantCacheWriteTokens { + t.Fatalf("cache_creation_input_tokens = %d, want %d", got, tt.wantCacheWriteTokens) + } + }) + } +} + +func TestConvertCodexResponseToClaudeNonStream_PreservesCacheWriteUsage(t *testing.T) { + tests := []struct { + name string + responseJSON string + wantInputTokens int64 + wantOutputTokens int64 + wantCacheReadTokens int64 + wantCacheWriteTokens int64 + }{ + { + name: "cache_write_tokens field", + responseJSON: `{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "usage":{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":150}}, + "output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}] + } + }`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 150, + }, + { + name: "cache_creation_tokens alias", + responseJSON: `{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "usage":{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_creation_tokens":150}}, + "output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}] + } + }`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 150, + }, + { + name: "cached_tokens greater than input_tokens clamps input_tokens to zero", + responseJSON: `{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "usage":{"input_tokens":500,"output_tokens":100,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":50}}, + "output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}] + } + }`, + wantInputTokens: 0, + wantOutputTokens: 100, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 50, + }, + { + name: "zero cache_write_tokens does not emit cache_creation_input_tokens", + responseJSON: `{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "usage":{"input_tokens":1000,"output_tokens":200,"input_tokens_details":{"cached_tokens":800,"cache_write_tokens":0}}, + "output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}] + } + }`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, []byte(tt.responseJSON), nil) + parsed := gjson.ParseBytes(out) + + usage := parsed.Get("usage") + if got := usage.Get("input_tokens").Int(); got != tt.wantInputTokens { + t.Fatalf("input_tokens = %d, want %d", got, tt.wantInputTokens) + } + if got := usage.Get("output_tokens").Int(); got != tt.wantOutputTokens { + t.Fatalf("output_tokens = %d, want %d", got, tt.wantOutputTokens) + } + if got := usage.Get("cache_read_input_tokens").Int(); got != tt.wantCacheReadTokens { + t.Fatalf("cache_read_input_tokens = %d, want %d", got, tt.wantCacheReadTokens) + } + if tt.wantCacheWriteTokens == 0 { + if usage.Get("cache_creation_input_tokens").Exists() { + t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", usage.Get("cache_creation_input_tokens").Raw) + } + } else if got := usage.Get("cache_creation_input_tokens").Int(); got != tt.wantCacheWriteTokens { + t.Fatalf("cache_creation_input_tokens = %d, want %d", got, tt.wantCacheWriteTokens) + } + }) + } +} diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 151f86111..615c20ec7 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -310,8 +310,8 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI usage := root.Get("usage") if usage.Exists() && usage.Type != gjson.Null { finalizeOpenAIAnthropicContentBlocks(param, &results) - inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(usage) - emitAnthropicMessageDelta(param, &results, inputTokens, outputTokens, cachedTokens) + inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractOpenAIUsage(usage) + emitAnthropicMessageDelta(param, &results, inputTokens, outputTokens, cachedTokens, cacheWriteTokens) emitMessageStopIfNeeded(param, &results) } } @@ -326,7 +326,7 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams) finalizeOpenAIAnthropicContentBlocks(param, &results) if !param.MessageDeltaSent { - emitAnthropicMessageDelta(param, &results, 0, 0, 0) + emitAnthropicMessageDelta(param, &results, 0, 0, 0, 0) } emitMessageStopIfNeeded(param, &results) @@ -400,12 +400,15 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte { // Set usage information if usage := root.Get("usage"); usage.Exists() { - inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(usage) + inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractOpenAIUsage(usage) out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens) out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens) if cachedTokens > 0 { out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens) } + if cacheWriteTokens > 0 { + out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens) + } } return [][]byte{out} @@ -570,7 +573,7 @@ func finalizeOpenAIAnthropicContentBlocks(param *ConvertOpenAIResponseToAnthropi } } -func emitAnthropicMessageDelta(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte, inputTokens, outputTokens, cachedTokens int64) { +func emitAnthropicMessageDelta(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte, inputTokens, outputTokens, cachedTokens, cacheWriteTokens int64) { if param == nil || param.MessageDeltaSent { return } @@ -581,6 +584,9 @@ func emitAnthropicMessageDelta(param *ConvertOpenAIResponseToAnthropicParams, re if cachedTokens > 0 { messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.cache_read_input_tokens", cachedTokens) } + if cacheWriteTokens > 0 { + messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.cache_creation_input_tokens", cacheWriteTokens) + } *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "message_delta", messageDeltaJSON, 2)) param.MessageDeltaSent = true } @@ -748,12 +754,15 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina } if respUsage := root.Get("usage"); respUsage.Exists() { - inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(respUsage) + inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractOpenAIUsage(respUsage) out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens) out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens) if cachedTokens > 0 { out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens) } + if cacheWriteTokens > 0 { + out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens) + } } if !stopReasonSet { @@ -771,14 +780,18 @@ func ClaudeTokenCount(ctx context.Context, count int64) []byte { return translatorcommon.ClaudeInputTokensJSON(count) } -func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) { +func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64, int64) { if !usage.Exists() || usage.Type == gjson.Null { - return 0, 0, 0 + return 0, 0, 0, 0 } inputTokens := usage.Get("prompt_tokens").Int() outputTokens := usage.Get("completion_tokens").Int() cachedTokens := usage.Get("prompt_tokens_details.cached_tokens").Int() + cacheWriteTokens := usage.Get("prompt_tokens_details.cache_write_tokens").Int() + if cacheWriteTokens == 0 { + cacheWriteTokens = usage.Get("prompt_tokens_details.cache_creation_tokens").Int() + } if cachedTokens > 0 { if inputTokens >= cachedTokens { @@ -788,5 +801,5 @@ func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) { } } - return inputTokens, outputTokens, cachedTokens + return inputTokens, outputTokens, cachedTokens, cacheWriteTokens } diff --git a/internal/translator/openai/claude/openai_claude_response_test.go b/internal/translator/openai/claude/openai_claude_response_test.go index 2910a64ea..f1aa8dbfb 100644 --- a/internal/translator/openai/claude/openai_claude_response_test.go +++ b/internal/translator/openai/claude/openai_claude_response_test.go @@ -3,6 +3,7 @@ package claude import ( "bytes" "context" + "fmt" "strings" "testing" @@ -577,3 +578,165 @@ func TestStreamingTool_OmittedToolCallIndexPreservesParallelCalls(t *testing.T) t.Fatalf("stop_reason = %q, want %q", got, "tool_use") } } + +func TestStreamingUsage_PreservesCacheWriteTokens(t *testing.T) { + tests := []struct { + name string + usageJSON string + wantInputTokens int64 + wantOutputTokens int64 + wantCacheReadTokens int64 + wantCacheWriteTokens int64 + }{ + { + name: "cache_write_tokens field", + usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":150}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 150, + }, + { + name: "cache_creation_tokens alias", + usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_creation_tokens":150}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 150, + }, + { + name: "cached_tokens greater than prompt_tokens clamps input_tokens to zero", + usageJSON: `{"prompt_tokens":500,"completion_tokens":100,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":50}}`, + wantInputTokens: 0, + wantOutputTokens: 100, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 50, + }, + { + name: "zero cache_write_tokens does not emit cache_creation_input_tokens", + usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":0}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + events := runStream(t, streamReq, + `{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"}}]}`, + `{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + fmt.Sprintf(`{"id":"c1","model":"m","choices":[],"usage":%s}`, tt.usageJSON), + ) + + var deltaEvent *sseEvent + for _, e := range events { + if e.Type == "message_delta" { + deltaEvent = &e + break + } + } + if deltaEvent == nil { + t.Fatalf("missing message_delta event") + } + if input := gjson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != tt.wantInputTokens { + t.Fatalf("input_tokens = %d, want %d", input, tt.wantInputTokens) + } + if output := gjson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != tt.wantOutputTokens { + t.Fatalf("output_tokens = %d, want %d", output, tt.wantOutputTokens) + } + if cacheRead := gjson.Get(deltaEvent.Payload, "usage.cache_read_input_tokens").Int(); cacheRead != tt.wantCacheReadTokens { + t.Fatalf("cache_read_input_tokens = %d, want %d", cacheRead, tt.wantCacheReadTokens) + } + if tt.wantCacheWriteTokens == 0 { + if gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Exists() { + t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Raw) + } + } else if cacheWrite := gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Int(); cacheWrite != tt.wantCacheWriteTokens { + t.Fatalf("cache_creation_input_tokens = %d, want %d", cacheWrite, tt.wantCacheWriteTokens) + } + }) + } +} + +func TestNonStreamingUsage_PreservesCacheWriteTokens(t *testing.T) { + tests := []struct { + name string + usageJSON string + wantInputTokens int64 + wantOutputTokens int64 + wantCacheReadTokens int64 + wantCacheWriteTokens int64 + }{ + { + name: "cache_write_tokens field", + usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":150}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 150, + }, + { + name: "cache_creation_tokens alias", + usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_creation_tokens":150}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 150, + }, + { + name: "cached_tokens greater than prompt_tokens clamps input_tokens to zero", + usageJSON: `{"prompt_tokens":500,"completion_tokens":100,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":50}}`, + wantInputTokens: 0, + wantOutputTokens: 100, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 50, + }, + { + name: "zero cache_write_tokens does not emit cache_creation_input_tokens", + usageJSON: `{"prompt_tokens":1000,"completion_tokens":200,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":0}}`, + wantInputTokens: 200, + wantOutputTokens: 200, + wantCacheReadTokens: 800, + wantCacheWriteTokens: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rawJSON := []byte(fmt.Sprintf(`{ + "id":"chatcmpl-123", + "object":"chat.completion", + "created":1677652288, + "model":"gpt-5.4", + "choices":[{"index":0,"message":{"role":"assistant","content":"Hello world"},"finish_reason":"stop"}], + "usage":%s + }`, tt.usageJSON)) + + ctx := context.Background() + reqJSON := []byte(`{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"Hello"}]}`) + + out := ConvertOpenAIResponseToClaudeNonStream(ctx, "", reqJSON, reqJSON, rawJSON, nil) + parsed := gjson.ParseBytes(out) + + usage := parsed.Get("usage") + if got := usage.Get("input_tokens").Int(); got != tt.wantInputTokens { + t.Fatalf("input_tokens = %d, want %d", got, tt.wantInputTokens) + } + if got := usage.Get("output_tokens").Int(); got != tt.wantOutputTokens { + t.Fatalf("output_tokens = %d, want %d", got, tt.wantOutputTokens) + } + if got := usage.Get("cache_read_input_tokens").Int(); got != tt.wantCacheReadTokens { + t.Fatalf("cache_read_input_tokens = %d, want %d", got, tt.wantCacheReadTokens) + } + if tt.wantCacheWriteTokens == 0 { + if usage.Get("cache_creation_input_tokens").Exists() { + t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", usage.Get("cache_creation_input_tokens").Raw) + } + } else if got := usage.Get("cache_creation_input_tokens").Int(); got != tt.wantCacheWriteTokens { + t.Fatalf("cache_creation_input_tokens = %d, want %d", got, tt.wantCacheWriteTokens) + } + }) + } +}