Skip to content
Merged
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
23 changes: 22 additions & 1 deletion internal/providers/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
}
Expand Down Expand Up @@ -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
}
Expand Down
66 changes: 66 additions & 0 deletions internal/providers/anthropic/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestConvertFromAnthropicResponse_NoCacheFields(t *testing.T) {
resp := &anthropicResponse{
ID: "msg_nocache",
Expand Down
7 changes: 1 addition & 6 deletions internal/providers/anthropic/chat_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
7 changes: 1 addition & 6 deletions internal/providers/anthropic/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
13 changes: 9 additions & 4 deletions internal/providers/anthropic/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/usage/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
32 changes: 32 additions & 0 deletions internal/usage/cost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down