fix(provider): treat explicit zero Anthropic cache deltas as measured
2 files · +27 -3
M packages/provider/anthropic.go +2 -2
| @@ -854,11 +854,11 @@ func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, re | |||
| 854 | 854 | if m.Usage.OutputTokens > 0 { | |
| 855 | 855 | usage.OutputTokens = m.Usage.OutputTokens | |
| 856 | 856 | } | |
| 857 | - | if r := m.Usage.CacheReadInputTokens; r != nil && *r > 0 { | |
| 857 | + | if r := m.Usage.CacheReadInputTokens; r != nil { | |
| 858 | 858 | usage.CacheReadTokens = *r | |
| 859 | 859 | cacheReported = true | |
| 860 | 860 | } | |
| 861 | - | if w := m.Usage.CacheCreationInputTokens; w != nil && *w > 0 { | |
| 861 | + | if w := m.Usage.CacheCreationInputTokens; w != nil { | |
| 862 | 862 | usage.CacheWriteTokens = *w | |
| 863 | 863 | cacheReported = true | |
| 864 | 864 | } | |
| @@ -854,11 +854,11 @@ func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, re | |||||
| 854 | if m.Usage.OutputTokens > 0 { | 854 | if m.Usage.OutputTokens > 0 { | ||
| 855 | usage.OutputTokens = m.Usage.OutputTokens | 855 | usage.OutputTokens = m.Usage.OutputTokens | ||
| 856 | } | 856 | } | ||
| 857 | - | if r := m.Usage.CacheReadInputTokens; r != nil && *r > 0 { | 857 | + | if r := m.Usage.CacheReadInputTokens; r != nil { |
| 858 | usage.CacheReadTokens = *r | 858 | usage.CacheReadTokens = *r | ||
| 859 | cacheReported = true | 859 | cacheReported = true | ||
| 860 | } | 860 | } | ||
| 861 | - | if w := m.Usage.CacheCreationInputTokens; w != nil && *w > 0 { | 861 | + | if w := m.Usage.CacheCreationInputTokens; w != nil { |
| 862 | usage.CacheWriteTokens = *w | 862 | usage.CacheWriteTokens = *w | ||
| 863 | cacheReported = true | 863 | cacheReported = true | ||
| 864 | } | 864 | } | ||
M packages/provider/provider_test.go +25 -1
| @@ -555,12 +555,17 @@ func TestAnthropicStreamHappyPath(t *testing.T) { | |||
| 555 | 555 | } | |
| 556 | 556 | ||
| 557 | 557 | func anthropicStreamUsage(t *testing.T, messageStart string) Usage { | |
| 558 | + | t.Helper() | |
| 559 | + | return anthropicStreamUsageWithDelta(t, messageStart, `{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`) | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | func anthropicStreamUsageWithDelta(t *testing.T, messageStart, messageDelta string) Usage { | |
| 558 | 563 | t.Helper() | |
| 559 | 564 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 560 | 565 | w.Header().Set("content-type", "text/event-stream") | |
| 561 | 566 | w.WriteHeader(http.StatusOK) | |
| 562 | 567 | _, _ = w.Write([]byte("event: message_start\ndata: " + messageStart + "\n\n" + | |
| 563 | - | "event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n" + | |
| 568 | + | "event: message_delta\ndata: " + messageDelta + "\n\n" + | |
| 564 | 569 | "event: message_stop\ndata: {}\n\n")) | |
| 565 | 570 | })) | |
| 566 | 571 | defer srv.Close() | |
| @@ -596,6 +601,25 @@ func TestAnthropicStreamKnownCacheMissReportsZeroRatio(t *testing.T) { | |||
| 596 | 601 | } | |
| 597 | 602 | } | |
| 598 | 603 | ||
| 604 | + | func TestAnthropicStreamCacheFieldsInDeltaOnly(t *testing.T) { | |
| 605 | + | start := `{"message":{"usage":{"input_tokens":100,"output_tokens":0}}}` | |
| 606 | + | for _, tc := range []struct { | |
| 607 | + | name string | |
| 608 | + | delta string | |
| 609 | + | want float64 | |
| 610 | + | }{ | |
| 611 | + | {"known miss", `{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}`, 0}, | |
| 612 | + | {"hit", `{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5,"cache_read_input_tokens":300,"cache_creation_input_tokens":0}}`, 0.75}, | |
| 613 | + | } { | |
| 614 | + | t.Run(tc.name, func(t *testing.T) { | |
| 615 | + | usage := anthropicStreamUsageWithDelta(t, start, tc.delta) | |
| 616 | + | if ratio, ok := usage.CacheHitRatio(); !ok || math.Abs(ratio-tc.want) > 1e-9 { | |
| 617 | + | t.Fatalf("cache hit ratio = %v, %v, want %v, true", ratio, ok, tc.want) | |
| 618 | + | } | |
| 619 | + | }) | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | ||
| 599 | 623 | func TestAnthropicStreamWithoutCacheFieldsLeavesRatioUnknown(t *testing.T) { | |
| 600 | 624 | usage := anthropicStreamUsage(t, `{"message":{"usage":{"input_tokens":100,"output_tokens":0}}}`) | |
| 601 | 625 | if ratio, ok := usage.CacheHitRatio(); ok { | |
| @@ -555,12 +555,17 @@ func TestAnthropicStreamHappyPath(t *testing.T) { | |||||
| 555 | } | 555 | } | ||
| 556 | 556 | ||||
| 557 | func anthropicStreamUsage(t *testing.T, messageStart string) Usage { | 557 | func anthropicStreamUsage(t *testing.T, messageStart string) Usage { | ||
| 558 | + | t.Helper() | |||
| 559 | + | return anthropicStreamUsageWithDelta(t, messageStart, `{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`) | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | func anthropicStreamUsageWithDelta(t *testing.T, messageStart, messageDelta string) Usage { | |||
| 558 | t.Helper() | 563 | t.Helper() | ||
| 559 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 564 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 560 | w.Header().Set("content-type", "text/event-stream") | 565 | w.Header().Set("content-type", "text/event-stream") | ||
| 561 | w.WriteHeader(http.StatusOK) | 566 | w.WriteHeader(http.StatusOK) | ||
| 562 | _, _ = w.Write([]byte("event: message_start\ndata: " + messageStart + "\n\n" + | 567 | _, _ = w.Write([]byte("event: message_start\ndata: " + messageStart + "\n\n" + | ||
| 563 | - | "event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n" + | 568 | + | "event: message_delta\ndata: " + messageDelta + "\n\n" + |
| 564 | "event: message_stop\ndata: {}\n\n")) | 569 | "event: message_stop\ndata: {}\n\n")) | ||
| 565 | })) | 570 | })) | ||
| 566 | defer srv.Close() | 571 | defer srv.Close() | ||
| @@ -596,6 +601,25 @@ func TestAnthropicStreamKnownCacheMissReportsZeroRatio(t *testing.T) { | |||||
| 596 | } | 601 | } | ||
| 597 | } | 602 | } | ||
| 598 | 603 | ||||
| 604 | + | func TestAnthropicStreamCacheFieldsInDeltaOnly(t *testing.T) { | |||
| 605 | + | start := `{"message":{"usage":{"input_tokens":100,"output_tokens":0}}}` | |||
| 606 | + | for _, tc := range []struct { | |||
| 607 | + | name string | |||
| 608 | + | delta string | |||
| 609 | + | want float64 | |||
| 610 | + | }{ | |||
| 611 | + | {"known miss", `{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}`, 0}, | |||
| 612 | + | {"hit", `{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5,"cache_read_input_tokens":300,"cache_creation_input_tokens":0}}`, 0.75}, | |||
| 613 | + | } { | |||
| 614 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 615 | + | usage := anthropicStreamUsageWithDelta(t, start, tc.delta) | |||
| 616 | + | if ratio, ok := usage.CacheHitRatio(); !ok || math.Abs(ratio-tc.want) > 1e-9 { | |||
| 617 | + | t.Fatalf("cache hit ratio = %v, %v, want %v, true", ratio, ok, tc.want) | |||
| 618 | + | } | |||
| 619 | + | }) | |||
| 620 | + | } | |||
| 621 | + | } | |||
| 622 | + | ||||
| 599 | func TestAnthropicStreamWithoutCacheFieldsLeavesRatioUnknown(t *testing.T) { | 623 | func TestAnthropicStreamWithoutCacheFieldsLeavesRatioUnknown(t *testing.T) { | ||
| 600 | usage := anthropicStreamUsage(t, `{"message":{"usage":{"input_tokens":100,"output_tokens":0}}}`) | 624 | usage := anthropicStreamUsage(t, `{"message":{"usage":{"input_tokens":100,"output_tokens":0}}}`) | ||
| 601 | if ratio, ok := usage.CacheHitRatio(); ok { | 625 | if ratio, ok := usage.CacheHitRatio(); ok { | ||