diff --git a/internal/providers/anthropic/anthropic.go b/internal/providers/anthropic/anthropic.go index 1c4c0e74..818328fa 100644 --- a/internal/providers/anthropic/anthropic.go +++ b/internal/providers/anthropic/anthropic.go @@ -340,7 +340,7 @@ func extractToolCalls(blocks []anthropicContent) []core.ToolCall { return out } -// buildAnthropicRawUsage extracts cache fields from anthropicUsage into a RawData map. +// buildAnthropicRawUsage extracts token details from anthropicUsage into a RawData map. func buildAnthropicRawUsage(u anthropicUsage) map[string]any { raw := make(map[string]any) if u.CacheCreationInputTokens > 0 { @@ -349,12 +349,29 @@ func buildAnthropicRawUsage(u anthropicUsage) map[string]any { if u.CacheReadInputTokens > 0 { raw["cache_read_input_tokens"] = u.CacheReadInputTokens } + if u.OutputTokensDetails.ThinkingTokens > 0 { + raw["completion_reasoning_tokens"] = u.OutputTokensDetails.ThinkingTokens + } if len(raw) == 0 { return nil } return raw } +func addAnthropicUsagePayloadDetails(payload map[string]any, usage *anthropicUsage, outputDetailsKey string) { + if usage.CacheReadInputTokens > 0 { + payload["cache_read_input_tokens"] = usage.CacheReadInputTokens + } + if usage.CacheCreationInputTokens > 0 { + payload["cache_creation_input_tokens"] = usage.CacheCreationInputTokens + } + if usage.OutputTokensDetails.ThinkingTokens > 0 { + payload[outputDetailsKey] = map[string]any{ + "reasoning_tokens": usage.OutputTokensDetails.ThinkingTokens, + } + } +} + func malformedAnthropicStreamError(err error) error { return core.NewProviderError("anthropic", http.StatusBadGateway, "failed to decode anthropic stream event: "+err.Error(), err) } @@ -407,6 +424,10 @@ func mergeAnthropicUsage(dst *anthropicUsage, src *anthropicUsage) bool { dst.CacheReadInputTokens = src.CacheReadInputTokens merged = true } + if src.OutputTokensDetails.ThinkingTokens != 0 { + dst.OutputTokensDetails.ThinkingTokens = src.OutputTokensDetails.ThinkingTokens + merged = true + } return merged } diff --git a/internal/providers/anthropic/anthropic_test.go b/internal/providers/anthropic/anthropic_test.go index 1425e9c4..cd62511d 100644 --- a/internal/providers/anthropic/anthropic_test.go +++ b/internal/providers/anthropic/anthropic_test.go @@ -2233,6 +2233,72 @@ func TestConvertFromAnthropicResponse_WithCacheFields(t *testing.T) { } } +func TestConvertFromAnthropicResponse_WithThinkingTokens(t *testing.T) { + tests := []struct { + name string + thinkingTokens int + wantPresent bool + }{ + {name: "zero omitted", thinkingTokens: 0, wantPresent: false}, + {name: "positive preserved", thinkingTokens: 27, wantPresent: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := `{ + "id": "msg_thinking", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": "Done"}], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 31, + "output_tokens": 311, + "output_tokens_details": {"thinking_tokens": ` + strconv.Itoa(tt.thinkingTokens) + `} + } + }` + var resp anthropicResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + result := convertFromAnthropicResponse(&resp) + got, present := result.Usage.RawUsage["completion_reasoning_tokens"] + + if present != tt.wantPresent { + t.Fatalf("completion_reasoning_tokens presence = %v, want %v", present, tt.wantPresent) + } + if tt.wantPresent && got != tt.thinkingTokens { + t.Errorf("RawUsage[completion_reasoning_tokens] = %v, want %d", got, tt.thinkingTokens) + } + }) + } +} + +func TestMergeAnthropicUsage_WithThinkingTokens(t *testing.T) { + dst := anthropicUsage{} + src := anthropicUsage{ + OutputTokensDetails: anthropicOutputTokensDetails{ThinkingTokens: 27}, + } + + if !mergeAnthropicUsage(&dst, &src) { + t.Fatal("mergeAnthropicUsage() = false, want true") + } + if dst.OutputTokensDetails.ThinkingTokens != 27 { + t.Fatalf("ThinkingTokens = %d, want 27", dst.OutputTokensDetails.ThinkingTokens) + } + + chatDetails, ok := anthropicChatUsagePayload(&dst)["completion_tokens_details"].(map[string]any) + if !ok || chatDetails["reasoning_tokens"] != 27 { + t.Fatalf("chat completion token details = %#v, want reasoning_tokens=27", chatDetails) + } + responseDetails, ok := anthropicResponsesUsagePayload(&dst)["output_tokens_details"].(map[string]any) + if !ok || responseDetails["reasoning_tokens"] != 27 { + t.Fatalf("response output token details = %#v, want reasoning_tokens=27", responseDetails) + } +} + func TestConvertFromAnthropicResponse_NoCacheFields(t *testing.T) { resp := &anthropicResponse{ ID: "msg_nocache", diff --git a/internal/providers/anthropic/chat_stream.go b/internal/providers/anthropic/chat_stream.go index 697a671f..da3d5be7 100644 --- a/internal/providers/anthropic/chat_stream.go +++ b/internal/providers/anthropic/chat_stream.go @@ -85,12 +85,7 @@ func anthropicChatUsagePayload(usage *anthropicUsage) map[string]any { "completion_tokens": usage.OutputTokens, "total_tokens": usage.InputTokens + usage.OutputTokens, } - if usage.CacheReadInputTokens > 0 { - payload["cache_read_input_tokens"] = usage.CacheReadInputTokens - } - if usage.CacheCreationInputTokens > 0 { - payload["cache_creation_input_tokens"] = usage.CacheCreationInputTokens - } + addAnthropicUsagePayloadDetails(payload, usage, "completion_tokens_details") return payload } diff --git a/internal/providers/anthropic/responses.go b/internal/providers/anthropic/responses.go index cacca337..21a50f95 100644 --- a/internal/providers/anthropic/responses.go +++ b/internal/providers/anthropic/responses.go @@ -69,12 +69,7 @@ func anthropicResponsesUsagePayload(usage *anthropicUsage) map[string]any { "output_tokens": usage.OutputTokens, "total_tokens": usage.InputTokens + usage.OutputTokens, } - if usage.CacheReadInputTokens > 0 { - payload["cache_read_input_tokens"] = usage.CacheReadInputTokens - } - if usage.CacheCreationInputTokens > 0 { - payload["cache_creation_input_tokens"] = usage.CacheCreationInputTokens - } + addAnthropicUsagePayloadDetails(payload, usage, "output_tokens_details") return payload } diff --git a/internal/providers/anthropic/types.go b/internal/providers/anthropic/types.go index 5be78cb8..4388eb70 100644 --- a/internal/providers/anthropic/types.go +++ b/internal/providers/anthropic/types.go @@ -97,10 +97,15 @@ type anthropicContent struct { // anthropicUsage represents token usage in Anthropic response type anthropicUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CacheCreationInputTokens int `json:"cache_creation_input_tokens"` - CacheReadInputTokens int `json:"cache_read_input_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + OutputTokensDetails anthropicOutputTokensDetails `json:"output_tokens_details"` +} + +type anthropicOutputTokensDetails struct { + ThinkingTokens int `json:"thinking_tokens"` } // anthropicStreamEvent represents a streaming event from Anthropic diff --git a/internal/usage/cost.go b/internal/usage/cost.go index bff2b183..0f581100 100644 --- a/internal/usage/cost.go +++ b/internal/usage/cost.go @@ -86,6 +86,7 @@ var providerMappings = map[string][]tokenCostMapping{ "anthropic": { {rawDataKey: "cache_read_input_tokens", pricingField: func(p *core.ModelPricing) *float64 { return p.CachedInputPerMtok }, side: sideInput, unit: unitPerMtok}, {rawDataKey: "cache_creation_input_tokens", pricingField: func(p *core.ModelPricing) *float64 { return p.CacheWritePerMtok }, side: sideInput, unit: unitPerMtok}, + {rawDataKey: "completion_reasoning_tokens", pricingField: func(p *core.ModelPricing) *float64 { return p.ReasoningOutputPerMtok }, side: sideOutput, unit: unitPerMtok, includedInBase: true}, }, "gemini": { {rawDataKey: "cached_tokens", pricingField: func(p *core.ModelPricing) *float64 { return p.CachedInputPerMtok }, side: sideInput, unit: unitPerMtok, includedInBase: true}, diff --git a/internal/usage/cost_test.go b/internal/usage/cost_test.go index ddae90a4..2bcd7176 100644 --- a/internal/usage/cost_test.go +++ b/internal/usage/cost_test.go @@ -93,6 +93,38 @@ func TestCalculateGranularCost_Anthropic_CacheTokens(t *testing.T) { assertCostNear(t, "OutputCost", result.OutputCost, 1.5) } +func TestCalculateGranularCost_Anthropic_ThinkingTokens(t *testing.T) { + tests := []struct { + name string + reasoningRate *float64 + wantOutput float64 + }{ + {name: "base output rate", wantOutput: 1.5}, + {name: "distinct reasoning rate", reasoningRate: new(30.0), wantOutput: 1.8}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pricing := &core.ModelPricing{ + InputPerMtok: new(3.0), + OutputPerMtok: new(15.0), + ReasoningOutputPerMtok: tt.reasoningRate, + } + rawData := map[string]any{ + "completion_reasoning_tokens": 20_000, + } + + result := CalculateGranularCost(200_000, 100_000, rawData, "anthropic", pricing) + + assertCostNear(t, "InputCost", result.InputCost, 0.6) + assertCostNear(t, "OutputCost", result.OutputCost, tt.wantOutput) + if result.Caveat != "" { + t.Fatalf("expected no caveat for Anthropic thinking tokens, got %q", result.Caveat) + } + }) + } +} + func TestCalculateGranularCost_Gemini_ThoughtTokens(t *testing.T) { pricing := &core.ModelPricing{ InputPerMtok: new(1.25),