From ba09c9f732c9755020ffb38fd1dac0eedc800b6a Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 23 Aug 2026 19:45:22 +0100 Subject: [PATCH] feat(admin): surface usage-provenance summary in the Providers overview Add adminProviderView.Provenance to GET /admin/api/overview: a compact, per-provider map keyed by provenance kind, populated only for "estimated" and "unbilled" observations. "reported" never appears here, since it is the healthy default and AttemptsDay/FailuresDay already describe it; the full reported/estimated/unbilled breakdown, by request and by token, stays on GET /metrics. buildAdminProvenanceViews mirrors buildAdminLatencyViews' own shape: one batched g.provenance.snapshot() read per response, grouped by provider, so this adds no additional store round trip and keeps the already-large overview response small, matching the existing latency summary's own size budget. A test proves the reported/estimated/unbilled filtering, verified against a real mutation of the exclusion guard before being kept. --- admin.go | 64 ++++++++++++++++++++++++++++++++++++++++++--- admin_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/admin.go b/admin.go index 31141d1..6f25c54 100644 --- a/admin.go +++ b/admin.go @@ -303,9 +303,20 @@ type adminProviderView struct { // declared after the scalar block as costing extra GC pointer-scan // bytes. See timeout.go's watchdogBody for the identical convention. Latency map[string]adminLatencyView `json:"latency,omitempty"` - Type string `json:"type"` - BaseURL string `json:"baseUrl"` - LastErr string `json:"lastErr,omitempty"` + // Provenance summarizes non-reported usage accounting for this + // provider (feat: expose token-accounting provenance) — keyed by + // provenance kind, "estimated" or "unbilled" only (see + // adminProvenanceView's own doc comment for why "reported" is never a + // key here). Sourced from the SAME in-process g.provenance + // accumulator /metrics reads (metrics.go). nil (omitted) for a + // provider with no estimated/unbilled outcome yet, or for a + // deployment with metrics collection gated off entirely — mirroring + // Latency's identical "nil means no observations yet" convention + // immediately above. + Provenance map[string]adminProvenanceView `json:"provenance,omitempty"` + Type string `json:"type"` + BaseURL string `json:"baseUrl"` + LastErr string `json:"lastErr,omitempty"` // HealthState is this provider's discovery circuit breaker state // (feat/provider-health): "closed" (normal), "open" (backing off // after repeated discovery failures — maybeRefresh skips it until @@ -417,6 +428,48 @@ func buildAdminLatencyViews(snaps []latencySnapshot) map[string]map[string]admin return out } +// adminProvenanceView is one provider's one non-reported provenance +// kind's compact usage-accounting summary (feat: expose token-accounting +// provenance) — adminProviderView.Provenance's value type, keyed by +// provenance kind ("estimated" or "unbilled" — provenanceEstimated/ +// provenanceUnbilled, metrics.go) in adminProviderView.Provenance's outer +// map. "reported" is deliberately never a key here: it is the default, +// healthy case AttemptsDay/FailuresDay above already partially describe, +// and duplicating its own count would not answer any operator question +// this compact surface (buildAdminLatencyViews' own "compact, no +// per-model breakdown" ruling, GET /admin/api/overview's ~113KB/5s-poll +// budget) is not already better placed to answer — the full reported/ +// estimated/unbilled breakdown, by request AND by token, lives on +// /metrics (llmgateway_usage_provenance_requests_total/-_tokens_total) +// for an operator who wants the precise fraction. +type adminProvenanceView struct { + Requests int64 `json:"requests"` + Tokens int64 `json:"tokens"` +} + +// buildAdminProvenanceViews groups snaps (g.provenance.snapshot(), +// metrics.go) by provider, discarding the "reported" provenance entirely +// (adminProvenanceView's own doc comment) — mirroring +// buildAdminLatencyViews' identical "compact, provider-keyed outer map" +// shape immediately above. A provider with no estimated/unbilled +// observations at all is simply absent from the outer map, so +// adminOverviewResponse's own lookup (buildAdminOverview, below) yields +// nil for it — adminProviderView.Provenance's own documented "nil means +// no estimated/unbilled accounting yet". +func buildAdminProvenanceViews(snaps []provenanceSnapshot) map[string]map[string]adminProvenanceView { + out := make(map[string]map[string]adminProvenanceView) + for _, s := range snaps { + if s.key.provenance == provenanceReported { + continue + } + if out[s.key.provider] == nil { + out[s.key.provider] = make(map[string]adminProvenanceView) + } + out[s.key.provider][s.key.provenance] = adminProvenanceView{Requests: s.requests, Tokens: s.tokens} + } + return out +} + // adminModelMetaView is one upstream model's resolved metadata (feature // v0.23: context window, per-token cost) within its provider — // adminProviderView.ModelMeta's value type. Every field is a pointer, @@ -559,6 +612,10 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { // for the whole response, mirroring providerUsage's own one-batched- // read-for-every-provider shape above, not a per-provider read. latencyViews := buildAdminLatencyViews(g.latency.snapshot()) + // feat: expose token-accounting provenance — one g.provenance. + // snapshot() call for the whole response, mirroring g.latency. + // snapshot()'s own one-batched-read-for-every-provider shape above. + provenanceViews := buildAdminProvenanceViews(g.provenance.snapshot()) providers := make([]adminProviderView, len(snaps)) mi := 0 @@ -593,6 +650,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { ModelRates: modelRates, ModelMeta: modelMeta, Latency: latencyViews[s.name], + Provenance: provenanceViews[s.name], } } diff --git a/admin_test.go b/admin_test.go index 3c11eaa..4f3a84b 100644 --- a/admin_test.go +++ b/admin_test.go @@ -631,6 +631,78 @@ func TestAdminOverview_LatencySummary_AvgReflectsSeededObservations(t *testing.T } } +// TestAdminOverview_ProvenanceSummary_ExcludesReportedIncludesEstimatedAndUnbilled +// proves buildAdminProvenanceViews' own filtering (admin.go, feat: +// expose token-accounting provenance): "reported" observations never +// surface on this compact admin summary (the operator already sees +// AttemptsDay/FailuresDay for the healthy default), while "estimated" +// and "unbilled" both do, as DISTINCT keys — never merged. +// +// MUTATION VERIFIED: removing the `if s.key.provenance == +// provenanceReported { continue }` guard from buildAdminProvenanceViews +// (admin.go) made this test fail — zeta.Provenance gained a leaked +// "reported" entry (Requests=1, Tokens=15) that must never appear here. +// Reverted before committing. +func TestAdminOverview_ProvenanceSummary_ExcludesReportedIncludesEstimatedAndUnbilled(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + + // zeta: one reported observation (must never surface below) and one + // estimated observation. + gw.provenance.record("zeta", provenanceReported, 15) + gw.provenance.record("zeta", provenanceEstimated, 7) + + // alpha: two unbilled observations, seeded separately to prove + // requests accumulates across calls rather than overwriting. + gw.provenance.record("alpha", provenanceUnbilled, 0) + gw.provenance.record("alpha", provenanceUnbilled, 0) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminOverviewResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + var zeta, alpha *adminProviderView + for i := range got.Providers { + switch got.Providers[i].Name { + case "zeta": + zeta = &got.Providers[i] + case "alpha": + alpha = &got.Providers[i] + } + } + if zeta == nil || alpha == nil { + t.Fatalf("providers = %+v, want entries named zeta and alpha", got.Providers) + } + + if _, ok := zeta.Provenance[provenanceReported]; ok { + t.Errorf(`zeta.Provenance has a %q entry: %+v — "reported" must never surface on this compact admin summary`, provenanceReported, zeta.Provenance) + } + est, ok := zeta.Provenance[provenanceEstimated] + if !ok { + t.Fatalf("zeta.Provenance missing %q entry: %+v", provenanceEstimated, zeta.Provenance) + } + if est.Requests != 1 || est.Tokens != 7 { + t.Errorf("zeta estimated = %+v, want {Requests:1 Tokens:7}", est) + } + + unb, ok := alpha.Provenance[provenanceUnbilled] + if !ok { + t.Fatalf("alpha.Provenance missing %q entry: %+v", provenanceUnbilled, alpha.Provenance) + } + if unb.Requests != 2 || unb.Tokens != 0 { + t.Errorf("alpha unbilled = %+v, want {Requests:2 Tokens:0}", unb) + } + if _, ok := alpha.Provenance[provenanceEstimated]; ok { + t.Errorf(`alpha.Provenance has a %q entry despite only unbilled observations being seeded: %+v — estimated and unbilled must never conflate`, provenanceEstimated, alpha.Provenance) + } +} + // --- usage: math against seeded counters --- func TestAdminUsage_MathAgainstSeededCounters(t *testing.T) {