feat(metrics): expose token-accounting provenance as a metric dimension

Token accounting has three provenances today: reported (real provider
usage), estimated (a non-streaming response with no usage, prompt
substituted from request body size), and unbilled (a streaming response
with no usage, request counted but zero tokens billed). Nothing on any
read surface distinguished them before this change.

Add a per-process provenanceStore (metrics.go), recorded in
runUnified right after the existing estimated/unbilled classification
(routes_unified.go), for a candidate whose response actually completed.
A failed-over candidate is never recorded, so ordinary failover noise
never pollutes the unbilled signal.

Expose two new counter families on GET /metrics:
llmgateway_usage_provenance_requests_total and
llmgateway_usage_provenance_tokens_total, both labeled by provider and
provenance. Together they answer the three operator questions this
change targets: what fraction of billed tokens came from real reported
usage, which providers produce estimated or unbilled accounting, and
how often the unbilled case happens at all. Both families are
in-process only, so sum() is the correct cross-replica aggregation,
matching this file's other per-process families.

Billing itself is unchanged: this only records which provenance an
already-billed number took, never a new number.

Tests cover reported/estimated/unbilled classification, that billing
matches the pre-existing contract exactly, and that estimated and
unbilled never collapse into one series. Each behavioral assertion was
verified against a real mutation of the source before being kept.
This commit is contained in:
2026-08-23 19:44:50 +01:00
parent b02203a650
commit 7c27233ca3
5 changed files with 573 additions and 5 deletions
+119
View File
@@ -493,6 +493,8 @@ func TestMetrics_OutputParsesAsValidExposition(t *testing.T) {
"llmgateway_limit_store_up",
"llmgateway_upstream_ttfb_seconds",
"llmgateway_upstream_duration_seconds",
"llmgateway_usage_provenance_requests_total",
"llmgateway_usage_provenance_tokens_total",
}
for _, name := range wantFamilies {
if _, ok := types[name]; !ok {
@@ -1334,6 +1336,123 @@ func TestMetrics_LatencyHistogram_StreamingAndNonStreaming_DistinctLabelSets(t *
}
}
// --- usage provenance (feat: expose token-accounting provenance) ---
// TestMetrics_UsageProvenance_EstimatedAndUnbilled_DistinctSeries proves
// this feature's central correctness requirement end to end, through the
// real /metrics exposition: a non-streaming zero-usage response
// (estimated) and a streaming zero-usage response (unbilled) against the
// SAME provider render as two DISTINCT series, never merged into one
// "not reported" signal. Distinct provider names would make this trivial
// (two unrelated series); using the identical provider for both is the
// stronger proof — the two provenances would collide into a single
// series if the recording code ever conflated them, exactly the
// regression this test exists to catch.
//
// MUTATION VERIFIED: changing routes_unified.go's recording switch case
// `case streaming && result.total() == 0:` to `case result.estimated:`
// (merging the unbilled classification into the estimated one) made this
// test fail: the "unbilled" series vanished from /metrics entirely and
// the "estimated" series' requests count doubled to 2, proving this test
// actually catches the two provenances collapsing into one — the exact
// regression this feature exists to prevent. Reverted before committing.
func TestMetrics_UsageProvenance_EstimatedAndUnbilled_DistinctSeries(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if streaming, _ := body["stream"].(bool); streaming {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fl := w.(http.Flusher)
_, _ = fmt.Fprint(w, "data: {\"id\":\"c\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n")
fl.Flush()
_, _ = fmt.Fprint(w, "data: [DONE]\n\n") // no usage chunk: unbilled
fl.Flush()
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// No "usage" field at all: estimated.
_, _ = w.Write([]byte(`{"id":"c1","object":"chat.completion","model":"gpt-test","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}`))
}))
defer srv.Close()
cfg := newMetricsTestConfig()
cfg.Providers["openai"].BaseURL = srv.URL
h, _ := newMetricsGatewayHandle(t, cfg)
nonStreamBody := map[string]any{"model": "gpt-test", "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
streamBody := map[string]any{"model": "gpt-test", "stream": true, "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
rec1 := httptest.NewRecorder()
h.ServeHTTP(rec1, newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", nonStreamBody))
if rec1.Code != http.StatusOK {
t.Fatalf("non-streaming request status = %d, want 200, body=%s", rec1.Code, rec1.Body.String())
}
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", streamBody))
if rec2.Code != http.StatusOK {
t.Fatalf("streaming request status = %d, want 200, body=%s", rec2.Code, rec2.Body.String())
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, metricsRequest(http.MethodGet, metricsPathDefault, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("GET /metrics status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
_, samples := parsePrometheusText(t, rec.Body.Bytes())
var estimatedReq, unbilledReq *promSample
for i := range samples {
s := &samples[i]
if s.name != "llmgateway_usage_provenance_requests_total" || s.labels["provider"] != "openai" {
continue
}
switch s.labels["provenance"] {
case provenanceEstimated:
estimatedReq = s
case provenanceUnbilled:
unbilledReq = s
}
}
if estimatedReq == nil {
t.Fatal(`no llmgateway_usage_provenance_requests_total{provider="openai",provenance="estimated"} sample — the non-streaming zero-usage request must land under "estimated"`)
}
if unbilledReq == nil {
t.Fatal(`no llmgateway_usage_provenance_requests_total{provider="openai",provenance="unbilled"} sample — the streaming zero-usage request must land under "unbilled", DISTINCT from "estimated"`)
}
if estimatedReq.value != "1" {
t.Errorf(`estimated requests = %s, want 1`, estimatedReq.value)
}
if unbilledReq.value != "1" {
t.Errorf(`unbilled requests = %s, want 1`, unbilledReq.value)
}
var estimatedTok, unbilledTok *promSample
for i := range samples {
s := &samples[i]
if s.name != "llmgateway_usage_provenance_tokens_total" || s.labels["provider"] != "openai" {
continue
}
switch s.labels["provenance"] {
case provenanceEstimated:
estimatedTok = s
case provenanceUnbilled:
unbilledTok = s
}
}
if estimatedTok == nil || unbilledTok == nil {
t.Fatalf("llmgateway_usage_provenance_tokens_total missing estimated/unbilled series: estimated=%v unbilled=%v", estimatedTok, unbilledTok)
}
if estimatedTok.value == "0" {
t.Error(`estimated tokens = 0, want > 0 — estimated substitutes a nonzero prompt count, unlike unbilled`)
}
if unbilledTok.value != "0" {
t.Errorf(`unbilled tokens = %s, want 0 — unbilled charges nothing at all, unlike estimated`, unbilledTok.value)
}
}
// TestRecordLatency_MetricsDisabled_NoObservationCollected proves the
// "no collection cost, no series emitted" requirement at the
// Gateway/collection level (not merely "the renderer skips an empty