Skip to content

feat(dashboard): add token cache meter and overview refinements#428

Merged
SantiagoDePolonia merged 6 commits into
mainfrom
feat/cache-meter
Jun 26, 2026
Merged

feat(dashboard): add token cache meter and overview refinements#428
SantiagoDePolonia merged 6 commits into
mainfrom
feat/cache-meter

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a cache meter to the dashboard overview plus several overview refinements. The meter answers "what share of what we send is actually cached?" — something the existing absolute Cache Hits / Local Cache cards didn't show.

What's included

  • Tokens cache meter — a 100%-stacked, input-token bar split into regular (uncached) / locally cached / prompt cached, with on-bar percentages, a legend key, and an empty state. Colors are on-palette (GoModel brown / emerald --success / lime --prompt-cache-color) and theme-correct.
  • Provider prompt-cache split in UsageSummary — new additive fields uncached_input_tokens / cached_input_tokens / cache_write_input_tokens, aggregated per row via a shared Go fold that reuses EntryInputSegments across the SQLite/PostgreSQL/MongoDB readers (the provider quirks — field-name coalescing, Anthropic additive-vs-subset accounting — stay in one place rather than being re-encoded in SQL).
  • Total Requests now includes local cache hits (with a provider + cache breakdown tooltip), computed on the frontend so the uncached-mode summary the meter relies on stays clean.
  • Per-provider docs link — provider cards show the standard ? help icon linking to …/docs/providers/<slug>, but only for provider types that have a docs page (allowlist derived from docs/providers/*.mdx; handles opencode_goopencode-go).
  • Moved the Daily/Weekly/Monthly/Yearly switch onto the usage chart header (it only affects that chart's grouping).
  • Reordered overview cards so Cache Hits sits before Estimated Cost.
  • Regenerated the API reference for the new UsageSummary fields.

Provider-specific behavior

The prompt-cache split is normalized across providers via the existing EntryInputSegments: Anthropic reports cache reads/writes as additive to input_tokens, while OpenAI/Gemini/etc. report them as a subset — the aggregate reconciles both so the meter is accurate per provider.

Testing

  • go build ./..., go vet, gofmt clean; all Go tests pass — including a SQLite cross-check that the aggregated split equals Σ EntryInputSegments over a mixed-provider fixture (and catches the invalid sum-per-field shortcut), plus handler tests for the new JSON fields.
  • All dashboard JS module tests pass (added coverage for the meter math, empty state, percentages summing to 100, Total-Requests-with-hits, and the provider docs link).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added cache-aware usage breakdown fields (uncached, cached, and cache-write input tokens) to the usage API schema.
    • Added a Cache Meter to the Usage Overview (token-segment visualization, legend with percentages/tokens, and empty state).
    • Added provider documentation help (“?” links) where provider-specific docs are available.
  • Bug Fixes
    • Prevented stale cache hits and cache-meter data after failed refreshes or period/range changes.
  • Documentation
    • Updated Swagger/OpenAPI to expose the new cache-split fields.
  • Tests
    • Expanded API/UI/reader tests for cache splitting, meter calculations/rounding, provider doc URL logic, and failure-state handling.

SantiagoDePolonia and others added 2 commits June 25, 2026 17:55
- Aggregate provider prompt-cache split (uncached/cached/cache-write) into
  UsageSummary via a shared Go fold reusing EntryInputSegments across readers.
- Add overview cache meter: 100%-stacked input-token bar (regular/locally
  cached/prompt cached) with empty state and on-bar percentages.
- Total Requests now includes local cache hits (with breakdown tooltip).
- Provider cards link to per-provider docs via the standard help icon.
- Move the daily/weekly/monthly/yearly switch onto the usage chart header.
- Regenerate API reference for the new UsageSummary fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Place the Cache Hits summary card directly after Total Requests, ahead of
Estimated Cost, on the overview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mintlify

mintlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Jun 25, 2026, 5:43 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@codecov-commenter

codecov-commenter commented Jun 25, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 45.16129% with 34 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/usage/reader_mongodb.go 0.00% 22 Missing ⚠️
internal/usage/reader_postgresql.go 0.00% 8 Missing ⚠️
internal/usage/reader_sqlite.go 50.00% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5bd0ee8e-a6a4-43df-a08a-c65ab8b6c094

📥 Commits

Reviewing files that changed from the base of the PR and between dc0bf5a and 82bc86b.

📒 Files selected for processing (2)
  • internal/admin/dashboard/static/js/modules/usage.js
  • internal/admin/dashboard/static/js/modules/usage.test.cjs

📝 Walkthrough

Walkthrough

Usage summaries now include cache-split input token totals through readers, API responses, and generated schemas. The dashboard adds cache-aware totals, a cache meter, and provider documentation links.

Changes

Cache-aware usage summary

Layer / File(s) Summary
Usage summary contract and folding
internal/usage/reader.go, internal/usage/reader_fold_test.go
UsageSummary carries cache-split input token totals, and shared helpers and tests fold provider segment rows into those fields.
Backend summary accumulation
internal/usage/reader_mongodb.go, internal/usage/reader_postgresql.go, internal/usage/reader_sqlite.go, internal/usage/reader_sqlite_cache_split_test.go
Each reader performs a second pass over matched usage rows to fold input_tokens, provider, and raw_data, and the SQLite test checks provider splits and local-cache exclusions.
API and schema exposure
cmd/gomodel/docs/docs.go, docs/openapi.json, internal/admin/handler_test.go
The generated OpenAPI docs and handler tests expose the new cache-split fields in JSON responses and verify zero values when no usage reader is present.
Dashboard cache meter and totals
internal/admin/dashboard/static/js/modules/usage.js, internal/admin/dashboard/static/js/modules/usage.test.cjs, internal/admin/dashboard/templates/page-overview.html, internal/admin/dashboard/static/css/dashboard.css
Usage totals include local cache hits when enabled, the cache meter derives segments and percentages from the new usage fields, and the overview page plus styles render the meter and updated chart header.
Provider documentation links
internal/admin/dashboard/static/js/modules/providers.js, internal/admin/dashboard/static/js/modules/providers.test.cjs, internal/admin/dashboard/templates/page-overview.html, internal/admin/dashboard/static/css/dashboard.css
Provider cards compute documentation URLs from provider types and render an inline help link with matching styling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ENTERPILOT/GoModel#196 — Adds cache analytics plumbing that internal/admin/dashboard/static/js/modules/usage.js now uses for cache-hit totals and cache-meter data.
  • ENTERPILOT/GoModel#336 — Uses the same provider-cache token split logic around uncached, cached, and cache-write inputs.

Poem

I hopped through tokens, soft and bright,
uncached, cached, and prompt-cache light.
The meter hummed; I twitched my nose,
with docs for friends and charts in rows.
🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main dashboard change: adding a token cache meter plus related overview refinements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cache-meter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown

Confidence Score: 3/5

The dashboard can display stale cache data after a failed usage refresh.

  • The normal dashboard contract for the cache meter and overview refinements is supported by executed validation.
  • The remaining issue is in the failure path where fetchUsage() clears usage summary state without clearing the cache overview state.
  • That stale state can affect user-visible request totals and cache-meter values after a failed period refresh.

internal/admin/dashboard/static/js/modules/usage.js

T-Rex T-Rex Logs

What T-Rex did

  • Ran the test suite and observed 29 module tests passing.
  • Executed the focused contract harness to validate token splits of 600/100/300 tokens, which map to 60/10/30 percent, with 100% total and empty meter categories at 0%.
  • Verified the Total Requests tooltip reports 40 to providers and 10 from cache.
  • Checked provider docs behavior and confirmed allowlisting including mapping opencode_go to opencode-go.
  • Reviewed overview layout refinements to improve arrangement and readability.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (2): Last reviewed commit: "test(usage): cover foldInputSegments; a1..." | Re-trigger Greptile

return nil, fmt.Errorf("failed to query usage summary: %w", err)
}

if err := r.accumulateInputSegments(ctx, where, args, summary); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Split Uses A Later Snapshot

This second pass reads the matching rows after the aggregate query has already returned. If usage rows are inserted or removed between the two queries, TotalRequests and TotalInput come from one snapshot while the new cache-split fields come from another, so the dashboard meter can show a split that does not match the reported totals.

Context Used: CLAUDE.md (source)

Comment on lines +178 to +183
for cursor.Next(ctx) {
var row struct {
InputTokens int `bson:"input_tokens"`
Provider string `bson:"provider"`
RawData map[string]any `bson:"raw_data"`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Malformed Raw Data Aborts Summary

The MongoDB split pass now decodes raw_data directly into map[string]any. If an existing document has raw_data stored as a string, array, or other non-object value, the decode error aborts /admin/usage/summary even though the aggregate totals could still be returned; the SQL path degrades to a warning for malformed raw JSON.

Context Used: CLAUDE.md (source)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/admin/dashboard/templates/page-overview.html`:
- Around line 101-107: The interval toggle buttons in the chart header only
expose selection via styling, so assistive tech can’t tell which option is
active. Update the button group in the chart header template to add a
descriptive label on the wrapper and bind aria-pressed on each of the interval
buttons in the interval picker, using the existing interval state and
setInterval behavior to reflect the current selection.

In `@internal/admin/handler_test.go`:
- Around line 273-286: The nil-reader test in
TestUsageSummary_NilReaderZeroesCacheSplit only checks zero values after
unmarshalling into usage.UsageSummary, so it can miss missing JSON fields.
Update the test to assert the raw response body from UsageSummary contains the
keys uncached_input_tokens, cached_input_tokens, and cache_write_input_tokens,
while still verifying they deserialize to zero. Use the existing handler setup
and response recorder to validate both shape and normalization.

In `@internal/usage/reader_mongodb.go`:
- Around line 154-193: The two-pass read in MongoDBReader from the summary query
and accumulateInputSegments is not snapshot-consistent, so the grouped totals
and split totals can diverge under concurrent writes. Fix this by making both
Aggregate and the follow-up Find run in the same read session/transaction
snapshot, or by moving the raw_data split accumulation into the aggregation
pipeline so a single read produces both results. Use the MongoDBReader methods
that perform these reads as the place to apply the change.

In `@internal/usage/reader_postgresql.go`:
- Around line 55-74: The summary query in PostgreSQLReader currently runs the
aggregate and accumulateInputSegments as separate reads, so they can see
different committed rows; update the usage summary path to use a single read
snapshot/transaction for both passes, or combine them into one statement. Make
the fix in the summary-building flow and the accumulateInputSegments helper so
the totals remain consistent with TotalRequests and TotalInput.

In `@internal/usage/reader_sqlite_cache_split_test.go`:
- Around line 18-128: The SQLite-only coverage in
TestSQLiteReaderSummary_AggregatesProviderCacheSplit does not exercise the new
second-pass logic in reader_postgresql.go and reader_mongodb.go. Reuse the same
fixture and expected totals to add parity assertions for NewPostgreSQLReader and
NewMongoDBReader, so the same summary checks run against those reader
implementations as well.

In `@internal/usage/reader_sqlite.go`:
- Around line 50-69: The summary logic in GetSummary/accumulateInputSegments
currently uses two separate SQLite reads, which can see different data if writes
happen between them. Update SQLiteReader so both the totals pass and the
input-segment pass run against the same snapshot, either by wrapping the
existing query path and accumulateInputSegments in a single read transaction or
by collapsing the aggregation into one query flow. Keep the fix localized around
GetSummary, accumulateInputSegments, and foldInputSegments so the row set used
for TotalRequests/TotalInput matches the cache-split fields.

In `@internal/usage/reader.go`:
- Around line 70-92: Add direct unit tests for foldInputSegments to cover its
shared row-processing behavior: create a small fake inputSegmentRows and verify
the happy path plus malformed raw_data JSON, Scan failures from rows.Scan, and
terminal rows.Err handling. Use the foldInputSegments helper and the
inputSegmentRows interface to locate the logic, and assert the summary updates
and wrapped error behavior without relying on a real database.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 57f163ee-9b45-4799-a3bf-35026a372ed4

📥 Commits

Reviewing files that changed from the base of the PR and between 2662963 and aa3ea59.

📒 Files selected for processing (14)
  • cmd/gomodel/docs/docs.go
  • docs/openapi.json
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/modules/providers.js
  • internal/admin/dashboard/static/js/modules/providers.test.cjs
  • internal/admin/dashboard/static/js/modules/usage.js
  • internal/admin/dashboard/static/js/modules/usage.test.cjs
  • internal/admin/dashboard/templates/page-overview.html
  • internal/admin/handler_test.go
  • internal/usage/reader.go
  • internal/usage/reader_mongodb.go
  • internal/usage/reader_postgresql.go
  • internal/usage/reader_sqlite.go
  • internal/usage/reader_sqlite_cache_split_test.go

Comment thread internal/admin/dashboard/templates/page-overview.html Outdated
Comment thread internal/admin/handler_test.go
Comment on lines +154 to +193
if err := r.accumulateInputSegments(ctx, matchFilters, summary); err != nil {
return nil, err
}

return summary, nil
}

// accumulateInputSegments streams the matched documents and folds each row's
// provider prompt-cache split into the summary, reusing EntryInputSegments. The
// $group aggregate above cannot return per-document raw_data, so this is a
// second pass over the same filter projecting only the needed fields. This is
// the dashboard summary path, not a request hot path.
func (r *MongoDBReader) accumulateInputSegments(ctx context.Context, matchFilters bson.D, summary *UsageSummary) error {
projection := bson.D{
{Key: "input_tokens", Value: 1},
{Key: "provider", Value: 1},
{Key: "raw_data", Value: 1},
}
cursor, err := r.collection.Find(ctx, matchFilters, options.Find().SetProjection(projection))
if err != nil {
return fmt.Errorf("failed to query usage input segments: %w", err)
}
defer cursor.Close(ctx)

for cursor.Next(ctx) {
var row struct {
InputTokens int `bson:"input_tokens"`
Provider string `bson:"provider"`
RawData map[string]any `bson:"raw_data"`
}
if err := cursor.Decode(&row); err != nil {
return fmt.Errorf("failed to decode usage input segment row: %w", err)
}
summary.addInputSegments(row.InputTokens, row.Provider, row.RawData)
}
if err := cursor.Err(); err != nil {
return fmt.Errorf("error iterating usage input segment cursor: %w", err)
}
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

MongoDB needs a consistent snapshot across both passes.

Aggregate and the follow-up Find are independent reads, so concurrent inserts/updates can make the cache-split totals disagree with the grouped totals in the same response. Please use one consistent read snapshot/session for both operations, or fold the split accumulation into the aggregation pipeline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader_mongodb.go` around lines 154 - 193, The two-pass read
in MongoDBReader from the summary query and accumulateInputSegments is not
snapshot-consistent, so the grouped totals and split totals can diverge under
concurrent writes. Fix this by making both Aggregate and the follow-up Find run
in the same read session/transaction snapshot, or by moving the raw_data split
accumulation into the aggregation pipeline so a single read produces both
results. Use the MongoDBReader methods that perform these reads as the place to
apply the change.

Comment on lines +55 to +74
if err := r.accumulateInputSegments(ctx, where, args, summary); err != nil {
return nil, err
}

return summary, nil
}

// accumulateInputSegments streams the matched rows and folds each row's
// provider prompt-cache split into the summary. It runs a second pass (the
// aggregate above cannot also return per-row raw_data) over the same filter,
// selecting only the columns EntryInputSegments needs. This is the dashboard
// summary path, not a request hot path.
func (r *PostgreSQLReader) accumulateInputSegments(ctx context.Context, where string, args []any, summary *UsageSummary) error {
rows, err := r.pool.Query(ctx, `SELECT input_tokens, provider, raw_data FROM "usage"`+where, args...)
if err != nil {
return fmt.Errorf("failed to query usage input segments: %w", err)
}
defer rows.Close()
return foldInputSegments(rows, summary)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use one PostgreSQL snapshot for both summary reads.

These two statements can observe different committed rows, so the cache-split totals may no longer reconcile with TotalRequests and TotalInput. Please execute both passes inside the same read snapshot/transaction, or rewrite this as one statement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader_postgresql.go` around lines 55 - 74, The summary query
in PostgreSQLReader currently runs the aggregate and accumulateInputSegments as
separate reads, so they can see different committed rows; update the usage
summary path to use a single read snapshot/transaction for both passes, or
combine them into one statement. Make the fix in the summary-building flow and
the accumulateInputSegments helper so the totals remain consistent with
TotalRequests and TotalInput.

Comment on lines +18 to +128
func TestSQLiteReaderSummary_AggregatesProviderCacheSplit(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open sqlite database: %v", err)
}
defer db.Close()

store, err := NewSQLiteStore(db, 0)
if err != nil {
t.Fatalf("failed to create sqlite store: %v", err)
}

ts := time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC)

// Provider (uncached-mode) rows — these feed both GetSummary and the oracle.
providerEntries := []*UsageEntry{
{
ID: "openai-subset", RequestID: "r1", ProviderID: "p1", Timestamp: ts,
Model: "gpt-5", Provider: "openai", Endpoint: "/v1/chat/completions",
InputTokens: 120, OutputTokens: 30, TotalTokens: 150,
RawData: map[string]any{"prompt_cached_tokens": 80},
},
{
ID: "anthropic-split", RequestID: "r2", ProviderID: "p2", Timestamp: ts,
Model: "claude-sonnet-4-6", Provider: "anthropic", Endpoint: "/v1/messages",
InputTokens: 50, OutputTokens: 20, TotalTokens: 70,
RawData: map[string]any{"cache_read_input_tokens": 90, "cache_creation_input_tokens": 30},
},
{
ID: "anthropic-nofields", RequestID: "r3", ProviderID: "p3", Timestamp: ts,
Model: "claude-sonnet-4-6", Provider: "anthropic", Endpoint: "/v1/messages",
InputTokens: 50, OutputTokens: 20, TotalTokens: 70,
},
{
ID: "gemini-subset", RequestID: "r4", ProviderID: "p4", Timestamp: ts,
Model: "gemini-2.5-pro", Provider: "gemini", Endpoint: "/v1/chat/completions",
InputTokens: 200, OutputTokens: 40, TotalTokens: 240,
RawData: map[string]any{"cached_tokens": 120},
},
{
ID: "groq-generic", RequestID: "r5", ProviderID: "p5", Timestamp: ts,
Model: "llama-3.3", Provider: "groq", Endpoint: "/v1/chat/completions",
InputTokens: 100, OutputTokens: 10, TotalTokens: 110,
RawData: map[string]any{"cached_tokens": 10},
},
}

// Local-cache hit — seeded but must be excluded from the uncached-mode summary.
localHit := &UsageEntry{
ID: "local-hit", RequestID: "r6", ProviderID: "p6", Timestamp: ts,
Model: "gpt-5", Provider: "openai", Endpoint: "/v1/chat/completions",
CacheType: CacheTypeExact, InputTokens: 999, OutputTokens: 999, TotalTokens: 1998,
RawData: map[string]any{"prompt_cached_tokens": 500},
}

ctx := context.Background()
if err := store.WriteBatch(ctx, append(append([]*UsageEntry{}, providerEntries...), localHit)); err != nil {
t.Fatalf("failed to seed usage entries: %v", err)
}

reader, err := NewSQLiteReader(db)
if err != nil {
t.Fatalf("failed to create sqlite reader: %v", err)
}

summary, err := reader.GetSummary(ctx, UsageQueryParams{
StartDate: time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC),
TimeZone: "UTC",
})
if err != nil {
t.Fatalf("GetSummary returned error: %v", err)
}

// Local-cache row excluded by the default uncached mode.
if summary.TotalRequests != len(providerEntries) {
t.Fatalf("TotalRequests = %d, want %d (local-cache row must be excluded)", summary.TotalRequests, len(providerEntries))
}

// Oracle: sum EntryInputSegments over the same provider rows independently.
var wantUncached, wantCached, wantWrite int64
for _, e := range providerEntries {
u, c, w := EntryInputSegments(UsageLogEntry{InputTokens: e.InputTokens, Provider: e.Provider, RawData: e.RawData})
wantUncached += u
wantCached += c
wantWrite += w
}

if summary.UncachedInputTokens != wantUncached {
t.Fatalf("UncachedInputTokens = %d, want %d", summary.UncachedInputTokens, wantUncached)
}
if summary.CachedInputTokens != wantCached {
t.Fatalf("CachedInputTokens = %d, want %d", summary.CachedInputTokens, wantCached)
}
if summary.CacheWriteInputTokens != wantWrite {
t.Fatalf("CacheWriteInputTokens = %d, want %d", summary.CacheWriteInputTokens, wantWrite)
}

// Explicit magic numbers guard against a regression that still happens to be
// self-consistent with a broken oracle. cached = 80+90+0+120+10 = 300 can only
// be reached by per-row max-coalescing, not max(sum-per-field).
if summary.CachedInputTokens != 300 {
t.Fatalf("CachedInputTokens = %d, want 300", summary.CachedInputTokens)
}
if summary.CacheWriteInputTokens != 30 {
t.Fatalf("CacheWriteInputTokens = %d, want 30", summary.CacheWriteInputTokens)
}
if summary.UncachedInputTokens != 310 {
t.Fatalf("UncachedInputTokens = %d, want 310", summary.UncachedInputTokens)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Backfill parity tests for PostgreSQL and MongoDB.

This fixture is solid for SQLite, but the new second-pass logic in internal/usage/reader_postgresql.go and internal/usage/reader_mongodb.go still has no equivalent coverage. Add the same expected totals against those readers too, otherwise driver-specific scan/decode regressions can slip through unnoticed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader_sqlite_cache_split_test.go` around lines 18 - 128, The
SQLite-only coverage in TestSQLiteReaderSummary_AggregatesProviderCacheSplit
does not exercise the new second-pass logic in reader_postgresql.go and
reader_mongodb.go. Reuse the same fixture and expected totals to add parity
assertions for NewPostgreSQLReader and NewMongoDBReader, so the same summary
checks run against those reader implementations as well.

Comment on lines +50 to +69
if err := r.accumulateInputSegments(ctx, where, args, summary); err != nil {
return nil, err
}

return summary, nil
}

// accumulateInputSegments streams the matched rows and folds each row's
// provider prompt-cache split into the summary. It runs a second pass (the
// aggregate above cannot also return per-row raw_data) over the same filter,
// selecting only the columns EntryInputSegments needs. This is the dashboard
// summary path, not a request hot path.
func (r *SQLiteReader) accumulateInputSegments(ctx context.Context, where string, args []any, summary *UsageSummary) error {
rows, err := r.db.QueryContext(ctx, `SELECT input_tokens, provider, raw_data FROM usage`+where, args...)
if err != nil {
return fmt.Errorf("failed to query usage input segments: %w", err)
}
defer rows.Close()
return foldInputSegments(rows, summary)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep both SQLite summary passes on the same snapshot.

GetSummary now reads totals and cache-split rows in two separate statements. A write between those calls can make TotalRequests/TotalInput come from one row set while the cache-split fields come from another. Please run both passes inside one read transaction or collapse them into a single query path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader_sqlite.go` around lines 50 - 69, The summary logic in
GetSummary/accumulateInputSegments currently uses two separate SQLite reads,
which can see different data if writes happen between them. Update SQLiteReader
so both the totals pass and the input-segment pass run against the same
snapshot, either by wrapping the existing query path and accumulateInputSegments
in a single read transaction or by collapsing the aggregation into one query
flow. Keep the fix localized around GetSummary, accumulateInputSegments, and
foldInputSegments so the row set used for TotalRequests/TotalInput matches the
cache-split fields.

Comment thread internal/usage/reader.go
Comment on lines +70 to +92
// foldInputSegments scans (input_tokens, provider, raw_data) rows and folds each
// into the summary via addInputSegments, so the SQLite and PostgreSQL readers
// share one row-handling path (only the query/driver differs between them).
func foldInputSegments(rows inputSegmentRows, summary *UsageSummary) error {
for rows.Next() {
var inputTokens int
var provider string
var rawDataJSON *string
if err := rows.Scan(&inputTokens, &provider, &rawDataJSON); err != nil {
return fmt.Errorf("failed to scan usage input segment row: %w", err)
}
var rawData map[string]any
if rawDataJSON != nil && *rawDataJSON != "" {
if err := json.Unmarshal([]byte(*rawDataJSON), &rawData); err != nil {
slog.Warn("failed to unmarshal raw_data JSON", "error", err)
}
}
summary.addInputSegments(inputTokens, provider, rawData)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("error iterating usage input segment rows: %w", err)
}
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add direct tests for foldInputSegments.

This helper now carries the shared scan/unmarshal/error-wrapping behavior for the SQL readers, but the current coverage only exercises the SQLite happy path. A tiny fake inputSegmentRows test would lock down malformed raw_data, Scan failures, and rows.Err() handling without needing a real database.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader.go` around lines 70 - 92, Add direct unit tests for
foldInputSegments to cover its shared row-processing behavior: create a small
fake inputSegmentRows and verify the happy path plus malformed raw_data JSON,
Scan failures from rows.Scan, and terminal rows.Err handling. Use the
foldInputSegments helper and the inputSegmentRows interface to locate the logic,
and assert the summary updates and wrapped error behavior without relying on a
real database.

Address review feedback on PR #428:
- Add aria-pressed + a group label to the chart interval picker so assistive
  tech can tell which interval is active (not just the .active class).
- Assert the nil-reader usage summary response includes the cache-split keys,
  so a dropped field can't masquerade as a zero after unmarshalling.
- Add direct unit tests for the shared foldInputSegments helper: happy path,
  malformed raw_data (graceful degradation), scan error, and iteration error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai and @greptile-apps. Dispositions:

Fixed (5ce08f8):

  • a11y: role="group" + aria-pressed on the interval picker.
  • Direct unit tests for foldInputSegments (happy path, malformed raw_data, scan error, iteration error).
  • nil-reader summary test now asserts the cache-split keys are present.

Won't fix (intentional):

  • Two-pass snapshot (sqlite/pg/mongo): real but cosmetic for a 30-day dashboard aggregate — nothing displayed ties the split to the totals (the meter uses the split fields; uncached+cached+write ≠ total_input for Anthropic by design), and any sub-ms skew self-heals on the next load. A read-transaction across 3 backends or a full-scan single pass isn't worth it here.
  • Mongo malformed raw_data: pre-existing behavior (GetUsageLog decodes raw_data the same way); it's always written as a JSON object, and decoding into any would yield bson.D and silently drop the split for all Mongo rows.
  • PG/Mongo parity tests: no live-DB harness in this package. The shared split logic is identical across backends and is now covered by the foldInputSegments unit tests above; only the per-driver scan differs.

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown

Thanks for the detailed follow-up. All dispositions are reasonable:

  • The a11y and foldInputSegments fixes in 5ce08f8 address the actionable items directly.
  • The two-pass snapshot trade-off is defensible — the split and totals are intentionally independent (Anthropic's additive accounting means they shouldn't sum), so there's no correctness gap to fix.
  • The Mongo raw_data explanation makes sense; matching the existing GetUsageLog behavior and avoiding silent bson.D drops is the right call.
  • Accepting the PG/Mongo gap given the shared foldInputSegments coverage is a reasonable line to draw without a live-DB harness.

LGTM.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@SantiagoDePolonia Thanks for the clear breakdown — the a11y/test updates sound good, and your rationale for the intentional won’t-fix items makes sense for this PR’s scope. I won’t push further on the snapshot/parity points here.


✏️ Learnings added
Learnt from: SantiagoDePolonia
URL: https://github.com/ENTERPILOT/GoModel/pull/428

Timestamp: 2026-06-25T18:25:24.176Z
Learning: In the Go usage readers (`internal/usage/reader_sqlite.go`, `internal/usage/reader_postgresql.go`, and `internal/usage/reader_mongodb.go`), the current two-pass summary/cache-split accumulation is an intentional tradeoff for the dashboard's aggregate view: minor snapshot skew is considered cosmetic and not worth adding cross-backend read transactions or a full-scan single-pass implementation.

Learnt from: SantiagoDePolonia
URL: https://github.com/ENTERPILOT/GoModel/pull/428

Timestamp: 2026-06-25T18:25:24.176Z
Learning: In the Go MongoDB usage reader (`internal/usage/reader_mongodb.go`), `raw_data` is expected to always be written as a JSON object. Decoding it into `any` would yield `bson.D` and would silently break cache-split extraction for Mongo rows, so the current object-oriented decoding behavior is intentional and consistent with `GetUsageLog`.

Learnt from: SantiagoDePolonia
URL: https://github.com/ENTERPILOT/GoModel/pull/428

Timestamp: 2026-06-25T18:25:24.176Z
Learning: The Go `internal/usage` package does not have a live PostgreSQL/MongoDB test harness. Shared cache-split behavior is validated through direct unit tests of `foldInputSegments` in `internal/usage/reader_fold_test.go`, while backend-specific PostgreSQL/MongoDB tests are intentionally not added unless a live-DB harness exists.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Comment on lines +70 to +72
const requests = Number((this.summary && this.summary.total_requests) || 0);
return (Number.isFinite(requests) ? requests : 0) + this.summaryCacheHits();
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale Cache Hits Survive Failure

When a later usage summary or daily request fails, fetchUsage() resets summary to zeros but leaves the previous cacheOverview in place. This new total then adds old cache hits to the empty summary, so the Total Requests card and cache meter can show data from the previous period after a failed refresh.

When the usage summary/daily refresh fails, fetchUsage reset summary to zeros
but left the previous period's cacheOverview in place. Because Total Requests
and the cache meter now derive from cacheOverview, a failed refresh showed the
old cache hits next to an empty summary. Reset cacheOverview alongside summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Good catch @greptile-apps — valid P1. Fixed in ab86ba2: fetchUsage now resets cacheOverview alongside summary on a failed refresh, so Total Requests and the cache meter can't show the previous period's cache hits next to an empty summary. Added a regression test.

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown

Fix looks correct — cacheOverview is now cleared atomically with summary on the failure path, and the regression test covers the exact scenario (failed refresh leaving stale cache hits visible). Thanks for the quick turnaround.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/admin/dashboard/static/js/modules/usage.js`:
- Around line 297-302: The success-path range switch in usage.js still renders
`renderChart()` with a stale `cacheOverview`, so the cache meter and request
breakdown can briefly show the previous period. Update the success flow in the
usage state reset/render path to clear `cacheOverview` before the first render
when cache analytics are enabled, or defer the overview-related render until
after `fetchCacheOverview()` completes; use the existing `summary`, `daily`,
`cacheOverview`, `renderChart()`, and `fetchCacheOverview()` symbols to place
the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fd223882-9767-4f0c-ad32-e0e6598c68f5

📥 Commits

Reviewing files that changed from the base of the PR and between 5ce08f8 and ab86ba2.

📒 Files selected for processing (2)
  • internal/admin/dashboard/static/js/modules/usage.js
  • internal/admin/dashboard/static/js/modules/usage.test.cjs

Comment thread internal/admin/dashboard/static/js/modules/usage.js
On a range switch, summary updated immediately while cacheOverview was only
replaced when the async fetchCacheOverview resolved, so the cache meter and
Total Requests briefly mixed the new summary with the previous period's cache
data. Clear cacheOverview before the first render when the overview is visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Valid, @coderabbitai — fixed in dc0bf5a. The success path now clears cacheOverview before the first render (when the overview is visible), so a range switch no longer briefly mixes the new summary with the previous period's cache data; fetchCacheOverview() then repopulates it. Added a regression test.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@SantiagoDePolonia Thanks — that addresses the issue. Clearing cacheOverview before the first success-path render is the right fix for the transient mixed-period state, and the regression test is a nice safeguard.

🐇✨

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/admin/dashboard/static/js/modules/usage.js (2)

297-303: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clear the dashboard state in the exception path too.

This reset only covers the handleFetchResponse() failure branch. If either fetch() rejects or summaryRes.json() / dailyRes.json() throws, control jumps to Lines 328-333 and the previous summary, daily, and cacheOverview stay rendered. That still shows stale-period data after a failed refresh.

Suggested fix
                 } catch (e) {
                     if (typeof this._isAbortError === 'function' && this._isAbortError(e)) {
                         return;
                     }
                     console.error('Failed to fetch usage:', e);
+                    this.summary = this.emptyUsageSummary();
+                    this.daily = [];
+                    this.cacheOverview = this.emptyCacheOverview();
+                    this.renderChart();
                 } finally {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/js/modules/usage.js` around lines 297 - 303,
The exception path in the usage dashboard refresh still leaves stale data
rendered because only the `handleFetchResponse()` failure branch clears state.
Update the `refreshUsage()` flow in `usage.js` so that any rejection from
`fetch()` or `summaryRes.json()` / `dailyRes.json()` also resets `summary`,
`daily`, and `cacheOverview` via `emptyUsageSummary()` and
`emptyCacheOverview()`, then re-renders through `renderChart()` before exiting
the error path.

61-65: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align the new cache-hit math with the existing cache-visibility contract.

summaryCacheHits() and the local meter slice both return zero unless cacheAnalyticsEnabled() is true, but internal/admin/dashboard/static/js/modules/workflows.js:89-116 already treats Redis/semantic cache config as cache-enabled even when CACHE_ENABLED is unset. In that configuration, the new Total Requests breakdown and cache meter silently fall back to provider-only numbers. Reuse the same visibility predicate here (or delegate to workflowCacheVisible()) so the overview follows the runtime contract.

Also applies to: 111-111

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/js/modules/usage.js` around lines 61 - 65,
The cache-hit summary logic is still gated only by cacheAnalyticsEnabled(), so
it ignores the broader cache-visibility contract already used by workflows.js.
Update summaryCacheHits() and the local meter slice in usage.js to use the same
visibility predicate as workflowCacheVisible() (or delegate to it) so
Redis/semantic cache setups without CACHE_ENABLED still show cache data
consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/admin/dashboard/static/js/modules/usage.js`:
- Around line 297-303: The exception path in the usage dashboard refresh still
leaves stale data rendered because only the `handleFetchResponse()` failure
branch clears state. Update the `refreshUsage()` flow in `usage.js` so that any
rejection from `fetch()` or `summaryRes.json()` / `dailyRes.json()` also resets
`summary`, `daily`, and `cacheOverview` via `emptyUsageSummary()` and
`emptyCacheOverview()`, then re-renders through `renderChart()` before exiting
the error path.
- Around line 61-65: The cache-hit summary logic is still gated only by
cacheAnalyticsEnabled(), so it ignores the broader cache-visibility contract
already used by workflows.js. Update summaryCacheHits() and the local meter
slice in usage.js to use the same visibility predicate as workflowCacheVisible()
(or delegate to it) so Redis/semantic cache setups without CACHE_ENABLED still
show cache data consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ff2dff4f-f325-4a35-a110-5a84386e2ac3

📥 Commits

Reviewing files that changed from the base of the PR and between ab86ba2 and dc0bf5a.

📒 Files selected for processing (2)
  • internal/admin/dashboard/static/js/modules/usage.js
  • internal/admin/dashboard/static/js/modules/usage.test.cjs

The fetchUsage catch block only logged, so a fetch() rejection or json() parse
error left the previous period's summary/daily/cacheOverview rendered — unlike
the handled HTTP-failure branch which clears them. Reset and re-render on the
exception path too, so all failure modes behave consistently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai:

  • Exception path leaves stale data — valid. Fixed in 82bc86b: the fetchUsage catch now resets summary/daily/cacheOverview and re-renders on a fetch()/json() rejection, matching the handled HTTP-failure branch. Added a regression test.
  • Use workflowCacheVisible() for cache-hit logic — skipping. workflowCacheVisible() governs whether the workflow editor offers a cache toggle (defaults visible when Redis/semantic infra is present). It's the wrong predicate for cache analytics: /admin/cache/overview hard-gates on CacheEnabled == "on", so without CACHE_ENABLED there's no cache data to show. cacheAnalyticsEnabled() already mirrors that gate and matches the existing Cache Hits / Local Cache cards (x-show="cacheAnalyticsEnabled()"); broadening it wouldn't surface data and would make the meter inconsistent with those cards.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@SantiagoDePolonia Thanks — the catch-path reset plus regression test addresses the stale-state issue.

And your explanation on workflowCacheVisible() vs cacheAnalyticsEnabled() makes sense: the former is a workflow-editor UI toggle, while the latter correctly matches the cache analytics data gate for /admin/cache/overview, so keeping the analytics logic on cacheAnalyticsEnabled() is the right call here.

🐇👍


✏️ Learnings added
Learnt from: SantiagoDePolonia
URL: https://github.com/ENTERPILOT/GoModel/pull/428

Timestamp: 2026-06-26T07:59:37.274Z
Learning: In the dashboard JavaScript (`internal/admin/dashboard/static/js/modules/usage.js` and related overview UI), cache analytics visibility should be gated by `cacheAnalyticsEnabled()`, not `workflowCacheVisible()`. `workflowCacheVisible()` controls whether the workflow editor shows a cache toggle based on Redis/semantic infrastructure, while cache analytics data from `/admin/cache/overview` is only available when `CacheEnabled == "on"`, and the existing Cache Hits / Local Cache cards already use `x-show="cacheAnalyticsEnabled()"` to mirror that server-side gate.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@SantiagoDePolonia SantiagoDePolonia merged commit f291083 into main Jun 26, 2026
20 checks passed
SantiagoDePolonia added a commit that referenced this pull request Jun 29, 2026
)

* fix(virtualmodels): don't gate IaC startup on catalog availability

Declarative virtual models (VIRTUAL_MODELS / config.yaml) were validated at
startup against the model catalog, which loads asynchronously and is empty on a
cold cache. ValidateManagedConfig required every managed redirect target to be
catalog-supported, so a brand-new deployment with valid IaC virtual models and
no warm cache aborted startup with "target model not found".

Separate the two concerns the check conflated:

- Structural invariants (valid selector, no self-/cross-redirect target) are pure
  properties of the declaration, so they still fail startup loudly
  (validateRedirectStructure).
- Catalog availability is runtime state, already handled by skipping unavailable
  targets at resolve time, so it is no longer a startup gate. The admin write
  path keeps it (firstUnsupportedTarget) since it runs against a warm catalog and
  an unknown target there is a caller mistake.

A managed redirect with an unknown target now boots and is simply unavailable
until the catalog provides it, consistent with the resolve-time skip and the
background refresh that already tolerates a transient catalog gap.

Tests: cold-catalog startup no longer aborts and resolves once warm; admin upsert
still rejects an unsupported target; structural invalids still abort. Adds the
standalone tests/e2e/test-iac-virtualmodels.sh IaC harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): add release scenarios for load balancing, throughput, and cache analytics

Cover the features shipped since v0.1.44 that the matrix had no behavioral
coverage of (only the single-target alias path via S13/S24/S31):

- S118-S125 load-balanced virtual models (#433): round-robin, weighted, cost,
  rename via old_source, plus negatives (unknown strategy, unknown target,
  rename of a non-existent source).
- S126-S128 token throughput (#434): window shape across granularities, negative
  granularity handling, and live-traffic reflection.
- S129-S132 cache analytics (#428): cache_mode on the usage summary, cache
  overview availability gating, and locally-cached token accounting from an
  exact-cache hit.

Each scenario is self-contained ($QA_SUFFIX-scoped, cleans up after itself) and
validated through run-release-e2e.sh. IaC virtual-model behavior needs gateways
booted with custom config, so it lives in the standalone
tests/e2e/test-iac-virtualmodels.sh harness instead of this running-stack matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants