From b26628f6c7d8cd78fa28ca799c247125128dc7dd Mon Sep 17 00:00:00 2001 From: Wayland Date: Sat, 5 Sep 2026 19:01:10 +0200 Subject: [PATCH] fix(pricing): strip dated snapshot suffix from model IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code sends Haiku 4.5 as "claude-haiku-4-5-20251001", but the price table is keyed undated and canonicalID only stripped the tier suffix ("[1m]"). Every Haiku span therefore missed the lookup and was stored with cost_usd left NULL behind a "pricing: unknown model" warning — ingest keeps the column unset when Compute returns 0 rather than writing a zero, so the spans were absent from spend totals rather than counted as free. canonicalID now strips a trailing "-YYYYMMDD" after the tier suffix, since a real ID can carry both. The tail must be exactly eight digits, so a version segment ("claude-haiku-4-5") is not read as a date. The table is untouched: undated keys remain the one canonical form. Co-Authored-By: Wayland Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + internal/ingest/handler_test.go | 29 +++++++++++++++++ internal/pricing/prices.go | 27 +++++++++++++--- internal/pricing/prices_test.go | 57 +++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80383fa..097cca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `GET /api/v1/history` returns `heatmap_covered_since`. Both heatmaps resolve hour of day, which `daily_usage` does not keep, so they stay raw-only however far back the charts above them now reach. The History page states that window under each one instead of drawing empty cells that read as "no activity" for days that were merely rolled up ### Fixed +- A dated model id resolves to a price. Claude Code sends Haiku 4.5 as `claude-haiku-4-5-20251001`, but the price table is keyed undated and the lookup only stripped the tier suffix (`[1m]`), so every Haiku span was stored with `cost_usd` left NULL behind a `pricing: unknown model` warning — ingest keeps the column unset rather than writing a zero, so the spans were absent from spend totals rather than counted as free. The trailing `-YYYYMMDD` is now stripped after the tier suffix — a real id can carry both — and only when the tail is exactly eight digits, so a version segment (`claude-haiku-4-5`) is not mistaken for a date. The table is unchanged: undated keys stay the one canonical form - `GET /api/v1/overview`'s `users_count` obeyed neither the time window nor the `user_id` filter — it was a bare `SELECT COUNT(DISTINCT user_id) FROM spans`, so the "Users" KPI answered all-time and unscoped next to four KPIs that did not. It now counts the distinct principals active in the selected range, and counts unattributed spans as the single `__anonymous__` principal the users list shows rather than as zero - The Overview's `total_cost_usd` and token KPIs no longer drop spans that carry no `session_id`. They were computed under the same `session_id IS NOT NULL` clause as the session count, so the page's cost total could sit below the Costs page's for the same window - A bare `WHERE col = ` on `spans` returns the matching rows again instead of silently returning none. `duration_ms` was a VIRTUAL generated column declared mid-table: it took a logical slot but no storage slot, so every column after it had a logical index one ahead of its physical index, and an equality on such a column probed an unrelated ART index and found nothing — a wrong result, not an error. Schema version 10 drops the column and computes the duration at the four queries that read it, so logical and physical indexes line up and the trap is gone rather than worked around; the engine-fragile `COALESCE(tool_name, '') = 'Bash'` workaround (DuckDB 1.4+ pushes `COALESCE` down too) goes with it. Verified against a copy of production (108 MB, 34 706 spans): `tool_name = 'Bash'` counted 0 rows before the migration and 6 440 after, `service_name = 'claude-code'` 0 before and 34 705 after, with the span count unchanged ([ADR-0013](docs/decisions/0013-spans-has-no-derived-columns.md)) diff --git a/internal/ingest/handler_test.go b/internal/ingest/handler_test.go index 29ad761..c0bbeb4 100644 --- a/internal/ingest/handler_test.go +++ b/internal/ingest/handler_test.go @@ -258,6 +258,35 @@ func TestProducerCostUSDUsedWhenPresent(t *testing.T) { } } +// TestCostComputedForDatedModelID verifies the shape Claude Code actually sends +// for Haiku 4.5 — a dated snapshot id — resolves to a price and not to a +// "pricing: unknown model" warning with cost_usd left nil. +func TestCostComputedForDatedModelID(t *testing.T) { + // claude-haiku-4-5: $1/MTok in → 1M tokens = $1.00 + attrs := `{"key":"model","value":{"stringValue":"claude-haiku-4-5-20251001"}},` + + `{"key":"input_tokens","value":{"intValue":"1000000"}}` + store := &memStore{} + h := ingest.New(store) + + req := httptest.NewRequest(http.MethodPost, "/v1/traces", + bytes.NewBufferString(buildMinimalPayload(attrs))) + req.Header.Set("Content-Type", "application/json") + h.ServeHTTP(httptest.NewRecorder(), req) + h.Flush() + + if len(store.spans) != 1 { + t.Fatalf("expected 1 span, got %d", len(store.spans)) + } + s := store.spans[0] + if s.CostUSD == nil { + t.Fatal("dated model id must resolve to a price, got nil cost_usd") + } + const want = 1.00 // 1M * $1/MTok + if got := *s.CostUSD; got < want-0.001 || got > want+0.001 { + t.Errorf("computed cost: got %.6f, want %.6f", got, want) + } +} + // TestUnknownModelCostIsNil verifies that an unrecognised model id does not // cause an ingest failure — cost_usd stays nil so it is distinguishable from // a genuine zero-cost span. diff --git a/internal/pricing/prices.go b/internal/pricing/prices.go index ed9d03f..586adb2 100644 --- a/internal/pricing/prices.go +++ b/internal/pricing/prices.go @@ -18,7 +18,8 @@ type ModelPrices struct { } // table maps canonical model IDs to pricing. Keys must be lowercase and match -// what Claude Code emits in the "model" span attribute (without tier suffixes). +// what Claude Code emits in the "model" span attribute, minus the tier and dated +// snapshot suffixes canonicalID strips. // // Cache rates follow Anthropic's convention: CacheRead = 0.1 x input, // CacheWrite = 1.25 x input (the 5-minute ephemeral TTL). @@ -103,15 +104,33 @@ var table = map[string]ModelPrices{ }, } -// canonicalID strips tier suffixes like "[1m]" from model identifiers before lookup. -// Claude Code may append these for extended-context variants that share base pricing. +// canonicalID reduces a model identifier to a table key by stripping a tier +// suffix like "[1m]" and then a dated snapshot suffix like "-20251001". Claude +// Code emits either, or both at once, for variants that share base pricing. func canonicalID(model string) string { if i := strings.IndexByte(model, '['); i != -1 { - return model[:i] + model = model[:i] + } + if i := strings.LastIndexByte(model, '-'); i > 0 && isSnapshotDate(model[i+1:]) { + model = model[:i] } return model } +// isSnapshotDate reports whether s is exactly eight digits. The length check is +// what keeps a version segment ("claude-haiku-4-5") from being read as a date. +func isSnapshotDate(s string) bool { + if len(s) != 8 { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + // Compute returns the estimated cost in USD for the given token counts and model. // If the model is not in the price table it logs a warning and returns 0 so that // an unknown model never causes an ingest failure. diff --git a/internal/pricing/prices_test.go b/internal/pricing/prices_test.go index 3efb82d..82a8ca0 100644 --- a/internal/pricing/prices_test.go +++ b/internal/pricing/prices_test.go @@ -38,6 +38,63 @@ func TestComputeTierSuffix(t *testing.T) { } } +func TestComputeSnapshotSuffix(t *testing.T) { + // Claude Code sends Haiku 4.5 as a dated snapshot ID; the table is keyed + // undated, so the "-20251001" tail must be stripped before lookup. + dated := pricing.Compute("claude-haiku-4-5-20251001", 1_000_000, 500_000, 0, 0) + undated := pricing.Compute("claude-haiku-4-5", 1_000_000, 500_000, 0, 0) + if dated != undated { + t.Errorf("snapshot suffix not stripped: dated=%v undated=%v", dated, undated) + } + // 1M input @ $1 + 0.5M output @ $5 = 1 + 2.5 = 3.5 + want := 3.5 + if math.Abs(dated-want) > 0.000001 { + t.Errorf("got %.8f, want %.8f", dated, want) + } +} + +func TestComputeSnapshotAndTierSuffix(t *testing.T) { + // Both tails at once — tier is stripped first, then the date. + both := pricing.Compute("claude-opus-5-20250930[1m]", 1_000_000, 500_000, 0, 0) + bare := pricing.Compute("claude-opus-5", 1_000_000, 500_000, 0, 0) + if both != bare { + t.Errorf("combined suffixes not stripped: both=%v bare=%v", both, bare) + } + if both <= 0 { + t.Errorf("claude-opus-5-20250930[1m] must have non-zero cost, got %v", both) + } +} + +func TestComputeSnapshotStripIsExactlyEightDigits(t *testing.T) { + // The strip must not eat a version segment or a digit run of another length. + for _, model := range []string{ + "claude-haiku-4-5", + "claude-3-5-haiku", + "claude-opus-5", + } { + if got := pricing.Compute(model, 1_000_000, 0, 0, 0); got <= 0 { + t.Errorf("%s: undated ID mangled, got %v", model, got) + } + } + for _, model := range []string{ + "claude-haiku-4-5-2025100", // 7 digits + "claude-haiku-4-5-202510011", // 9 digits + "claude-haiku-4-5-2025100a", // not all digits + } { + if got := pricing.Compute(model, 1_000_000, 0, 0, 0); got != 0 { + t.Errorf("%s: expected no strip and cost 0, got %v", model, got) + } + } +} + +func TestComputeUnknownDatedModel(t *testing.T) { + // A dated ID whose base is still unknown must return 0, not panic. + got := pricing.Compute("claude-unknown-99-20251001", 1_000_000, 500_000, 0, 0) + if got != 0 { + t.Errorf("unknown dated model: expected 0, got %v", got) + } +} + func TestComputeUnknownModel(t *testing.T) { // Unknown model must return 0, not panic or error. got := pricing.Compute("claude-unknown-99", 1_000_000, 500_000, 0, 0)