Skip to content

perf(dashboard): take provider dials and whole-table reads off page loads - #535

Merged
njbrake merged 6 commits into
mainfrom
post
Aug 7, 2026
Merged

perf(dashboard): take provider dials and whole-table reads off page loads#535
njbrake merged 6 commits into
mainfrom
post

Conversation

@njbrake

@njbrake njbrake commented Aug 7, 2026

Copy link
Copy Markdown
Member

Description

Four dashboard GETs dialed upstream providers on the request path, bounded only by model_discovery_timeout_seconds (10s per unreachable provider) and the models.dev fetch timeout (15s). Each held a browser connection slot and a pooled database session for the whole dial.

Discovery and the models.dev catalog now refresh in the background and reads answer from cache, using the same refresher shape the alias, policy, provider and price caches already use in the lifespan. A provider that has never been dialed still dials, so a cold worker never claims it has no models. ?refresh=true forces a live re-dial on /v1/models/discoverable and /v1/providers/health. /v1/models deliberately has no such flag: it takes any API key, and a fanout across every provider is an operator action. No new config; model_cache_ttl_seconds = 0 still means "dial on read" and now also sets the refresh interval (floored at 30s).

Usage and Activity no longer read the users and api_keys tables. They paged both in full on every visit to name filter options and label rows. /v1/usage now returns user_alias and api_key_name per row (outer-joined, so a row whose owner was deleted survives, unlabelled), and by_user / by_api_key carry a label resolved in the same GROUP BY. Both pages build their pickers from a summary they already request.

Filter options are now the in-window entities ranked by spend, capped at 100, rather than every entity that ever existed. The User and API-key pickers accept a typed or pasted id on Enter, so an entity below that rank is still reachable.

Also: retry: false on the four discovery-backed queries, and a 30s bound in apiFetch (5 minutes for the bulk usage delete and reprice, whose duration scales with the data).

Follow-up fixes in this PR

Review found a shutdown hang that CI hit as two 120s unit-test timeouts. Cancelling a task is a request, not a guarantee: anyio's CancelScope.__exit__ calls host_task.uncancel() whenever its own scope was cancelling, and httpx runs its per-operation timeouts as those scopes, so a shutdown cancel racing one of them is absorbed. The refresher loop then resumes and sleeps out a full interval, and the lifespan's unbounded await task never returns. _stop_refresher now waits a bounded 5s and abandons the task with a warning; it applies to all six refreshers, and it also stops a refresher's unexpected error from aborting the rest of shutdown. Unit tests were starting the real refreshers too, so the suppression fixture moved to the root conftest.

Plus: a failed models.dev fetch is no longer served at any age (a transient outage disabled enrichment for 24h); both refreshers re-check their runtime-settable knob per tick; apiFetch converts a timeout on the body read, not just on fetch().

PR Type

  • Bug Fix
  • New Feature
  • Refactor
  • Documentation
  • Infrastructure / CI

Relevant issues

None filed; found while profiling dashboard page loads.

Measured

Same browser repro, gateway with six unreachable providers:

Request Before After
/v1/providers/health 10009ms 14ms
/v1/models 8577ms 11ms
/v1/models/discoverable 8577ms 3ms
/v1/models/metadata 236ms 3ms

Activity and Usage now issue 4 and 3 requests respectively, all under 25ms, and neither touches /v1/users or /v1/keys.

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change (tests/unit, tests/integration).
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test).
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec (uv run python scripts/generate_openapi.py).

Regression tests verified in both directions, failing before the fix and passing after: the three *_serves_an_expired_cache_without_dialing tests, never reads the whole users or api_keys table, the _stop_refresher shutdown tests, the models.dev negative-TTL test, and the apiFetch body-stall test. 1414 unit, 1020 integration, 488 dashboard. make lint, make openapi-check, make postman-check clean. Dashboard bundle rebuilt.

AI Usage

  • No AI was used.
  • AI was used for drafting/refactoring.
  • This is fully AI-generated.

AI Model/Tool used:

Claude Opus 5 (1M context) via Claude Code.

Any additional AI details you'd like to share:

Implemented by Claude Code, then reviewed by a separate Claude Code agent with no access to the implementing session's reasoning. That review found the shutdown hang, the models.dev negative-TTL regression, and the filter-picker gap, each reproduced in isolation before being fixed.

NOTE:
When responding to reviewer questions, please respond yourself rather than copy/pasting reviewer comments into an AI and pasting back its answer. We want to discuss with you, not your AI :)

  • I am an AI Agent filling out this form (check box if true)

Summary

  • Moved model discovery and catalog updates to background refresh tasks.
  • Served model and provider data from cache to reduce dashboard request time.
  • Added forced live refresh support for discovery and provider health endpoints.
  • Reduced Usage and Activity data loading by using labels from usage results instead of full user and API-key lists.
  • Added request time limits and longer limits for bulk operations.
  • Improved refresh recovery, shutdown handling, and runtime cache configuration updates.
  • Updated dashboard bundles, documentation, Postman examples, and regression tests.

Technical notes

  • Added cache refresh and stale-read controls to model discovery, provider health, and catalog services.
  • Preserved dial-on-read behavior when model_cache_ttl_seconds is 0.
  • Added user and API-key labels to usage rows and grouped summaries.
  • Disabled retries for discovery-backed queries.
  • Added tests for caching, refresh failures, shutdown timeouts, usage labels, and frontend request timeouts.

…oads

Four dashboard GETs dialed upstream providers on the request path, bounded
only by model_discovery_timeout_seconds (10s per unreachable provider) and
the models.dev fetch timeout (15s). Each held a browser connection and a
pooled database session for the whole dial.

Discovery and the models.dev catalog now refresh from background tasks, the
same shape the alias, policy, provider and price caches already use, and the
reads answer from cache. A provider never dialed still dials, so a cold
worker never claims it has no models. ?refresh=true forces a live re-dial on
/v1/models/discoverable and /v1/providers/health; /v1/models deliberately has
no such flag, since it takes any API key and a provider-wide fanout is an
operator action. Measured on a gateway with six unreachable providers:
/v1/providers/health 10009ms to 14ms, /v1/models 8577ms to 11ms,
/v1/models/discoverable 8577ms to 3ms.

Separately, the Usage and Activity pages paged the entire users and api_keys
tables on every visit, to name filter options and label rows. /v1/usage now
resolves user_alias and api_key_name per row, and the by_user / by_api_key
breakdowns carry a label resolved in the same GROUP BY, so both pages build
their pickers from a summary they already request. Filter options become the
in-window entities ranked by spend rather than every entity that ever
existed.

Also: no retry on the four discovery-backed dashboard queries, since the
global default turned one slow failure into three, and a 30s bound in
apiFetch so a hung request returns its connection slot on a known deadline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake had a problem deploying to integration-tests August 7, 2026 12:54 — with GitHub Actions Failure
@github-actions github-actions Bot added the missing-template PR is missing required template sections label Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@njbrake, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 87498f26-b146-479b-b20f-55d60c415b90

📥 Commits

Reviewing files that changed from the base of the PR and between 279e902 and 3543b12.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (26)
  • docs/configuration.md
  • docs/public/otari.postman_collection.json
  • src/gateway/core/config.py
  • src/gateway/main.py
  • src/gateway/static/dashboard/assets/ActivityPage-B-JrbzNG.js
  • src/gateway/static/dashboard/assets/BudgetsPage-Bb-ZzB9q.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-gmtoFRlO.js
  • src/gateway/static/dashboard/assets/DocsPage-wd6etVlE.js
  • src/gateway/static/dashboard/assets/KeysPage-D1MvNEen.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-W-k32mVk.js
  • src/gateway/static/dashboard/assets/ModelsPage-CjADRmXg.js
  • src/gateway/static/dashboard/assets/OverviewPage-CvMKYScf.js
  • src/gateway/static/dashboard/assets/ProvidersPage-CKBJgQmn.js
  • src/gateway/static/dashboard/assets/RoutingPage-CeSk6-Fe.js
  • src/gateway/static/dashboard/assets/SettingsPage-LbV0e7qd.js
  • src/gateway/static/dashboard/assets/TablePagination-D9yR_FiC.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-DnkIwA9f.js
  • src/gateway/static/dashboard/assets/UsagePage-Bxv4_uW3.js
  • src/gateway/static/dashboard/assets/UsersPage-C9eMut1u.js
  • src/gateway/static/dashboard/assets/index-D6WO6K2k.js
  • src/gateway/static/dashboard/index.html
  • tests/unit/test_gateway_lifespan_shutdown.py
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx

Walkthrough

The change adds background refresh and stale-cache handling for model discovery and models.dev metadata, enriches usage responses with user and API-key labels, updates dashboard filtering and request handling, and regenerates dashboard assets.

Changes

Model caching and refresh

Layer / File(s) Summary
Cache and refresher services
src/gateway/services/model_discovery_service.py, src/gateway/services/model_catalog_service.py
Discovery and catalog services now support stale reads, forced refreshes, retry intervals, cancellation, and cache resets.
Endpoint and lifecycle integration
src/gateway/api/routes/models.py, src/gateway/api/routes/providers.py, src/gateway/api/routes/usage.py, src/gateway/main.py, src/gateway/core/config.py, docs/configuration.md, docs/public/otari.postman_collection.json
Routes use cached or forced discovery based on request parameters. Application startup and shutdown manage refresher tasks. Configuration and API documentation describe the cache behavior.
Cache lifecycle validation
tests/conftest.py, tests/integration/test_model_discovery.py, tests/unit/test_gateway_model_discovery.py, tests/unit/test_model_catalog_service.py, tests/unit/test_provider_health_service.py, tests/unit/test_gateway_lifespan_shutdown.py
Tests cover stale results, forced refreshes, negative caching, disabled caching, refresh cadence, runtime settings, and shutdown behavior.

Usage labels and analytics

Layer / File(s) Summary
Usage label contracts and queries
src/gateway/api/routes/usage.py, web/src/api/types.ts, tests/integration/test_usage_endpoint.py
Usage rows and groups now include optional server-resolved labels. Outer joins preserve rows for deleted or unlabeled users and API keys.
Analytics filter and chart integration
web/src/pages/ActivityPage.tsx, web/src/pages/UsagePage.tsx, web/src/pages/ActivityPage.test.tsx, web/src/pages/UsagePage.test.tsx
Dashboard filters and chart labels now use usage summaries and row metadata instead of separate user and API-key listings. Custom filter values and identifier fallbacks are supported.

Dashboard runtime and assets

Layer / File(s) Summary
Frontend request and query behavior
web/src/api/client.ts, web/src/api/hooks.ts, web/src/api/types.ts, web/src/api/client.test.ts
API requests now have default or long-request deadlines. Timeout errors use ApiError, and model-related queries disable retries.
Generated dashboard application assets
src/gateway/static/dashboard/assets/*, src/gateway/static/dashboard/index.html
Dashboard bundles were regenerated, including the application entry point, page modules, documentation, activity and usage pages, updated imports, and the renamed HTML entry asset.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • mozilla-ai/otari#342: Extends the same model-discovery cache and single-flight mechanisms with stale serving and background refresh.
  • mozilla-ai/otari#492: Shares model discovery, catalog behavior, and refresher lifecycle changes.
  • mozilla-ai/otari#345: Shares usage analytics endpoints and dashboard label handling.

Suggested reviewers: tbille, khaledosman

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.74% 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
Title check ✅ Passed The title uses the valid perf(dashboard): Conventional Commit prefix, uses imperative wording, and accurately describes the main performance change despite being slightly over 70 characters.
Description check ✅ Passed The description follows the template, explains the changes and rationale, identifies issue status, records testing, and completes the checklist and AI usage sections.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch post
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch post

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
web/src/pages/UsagePage.tsx (1)

601-607: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use server labels for user groups.

Line 606 uses row.label only for api_key_id. The grouped-series endpoint also returns labels for user_id. The User chart legend and User breakdown therefore show opaque IDs instead of aliases.

Use row.label for every non-null labeled group. Keep the key as the fallback. Update BreakdownTable with the same fallback. Add tests for the User breakdown and group_by=user_id.

Proposed fix
-            : effectiveGroupBy === "api_key_id"
-              ? (row.label ?? `${row.key.slice(0, 8)}…`)
-              : row.key,
+            : row.label ?? (effectiveGroupBy === "api_key_id" ? `${row.key.slice(0, 8)}…` : row.key),
-                  : row.key}
+                  : row.label ?? row.key}
🤖 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 `@web/src/pages/UsagePage.tsx` around lines 601 - 607, Update the group label
selection in the UsagePage chart data and BreakdownTable so every non-null group
uses row.label when available, regardless of whether effectiveGroupBy is
api_key_id or user_id, and falls back to row.key otherwise; preserve the
existing Other and unknown handling. Add coverage for the User breakdown and
grouped-series requests using group_by=user_id.
🤖 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 `@docs/configuration.md`:
- Line 84: Revise the model_cache_ttl_seconds documentation to describe cache
and refresh behavior without claiming every read dials when TTL is 0. Exclude
GET /v1/models/{model_id}, clarify that discovery endpoints may dial on
cold-cache requests, and state that provider health can reuse coalesced recent
checks rather than always dialing at request time.

In `@src/gateway/main.py`:
- Around line 184-194: Update the lifespan startup flow around
background_discovery_enabled and background_catalog_enabled so both refreshers
are also started when config.is_hybrid_mode is true, preventing indefinitely
stale cache results. Preserve the documented standalone behavior and add a
hybrid-lifespan regression test verifying the selected refresher-start behavior.

In `@web/src/api/client.ts`:
- Around line 91-101: Extend timeout handling in apiFetch and
extractErrorMessage to cover response-body reads, including response.json(),
while preserving existing ApiError handling and non-timeout JSON errors. Convert
AbortSignal timeout DOMException failures to the same descriptive ApiError used
for fetch timeouts, and add a regression test simulating headers arriving before
a stalled body.

---

Outside diff comments:
In `@web/src/pages/UsagePage.tsx`:
- Around line 601-607: Update the group label selection in the UsagePage chart
data and BreakdownTable so every non-null group uses row.label when available,
regardless of whether effectiveGroupBy is api_key_id or user_id, and falls back
to row.key otherwise; preserve the existing Other and unknown handling. Add
coverage for the User breakdown and grouped-series requests using
group_by=user_id.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b9882c0c-f180-419c-9ffb-8bf5bbf272c6

📥 Commits

Reviewing files that changed from the base of the PR and between d6d686b and 18b333a.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (43)
  • docs/configuration.md
  • docs/public/otari.postman_collection.json
  • src/gateway/api/routes/models.py
  • src/gateway/api/routes/providers.py
  • src/gateway/api/routes/usage.py
  • src/gateway/main.py
  • src/gateway/services/model_catalog_service.py
  • src/gateway/services/model_discovery_service.py
  • src/gateway/services/provider_health_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-CfIPwmDz.js
  • src/gateway/static/dashboard/assets/ActivityPage-zqcCQMke.js
  • src/gateway/static/dashboard/assets/BudgetsPage-CXkIKoXy.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-h1eFJM-m.js
  • src/gateway/static/dashboard/assets/DocsPage-gokYiawK.js
  • src/gateway/static/dashboard/assets/KeysPage-CD3OUZzC.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-qtT3hmbU.js
  • src/gateway/static/dashboard/assets/ModelsPage-DojQrJDI.js
  • src/gateway/static/dashboard/assets/OverviewPage-djfSANxc.js
  • src/gateway/static/dashboard/assets/ProvidersPage-B610aRno.js
  • src/gateway/static/dashboard/assets/RoutingPage-CjI_jYub.js
  • src/gateway/static/dashboard/assets/SettingsPage-3ii2VWM1.js
  • src/gateway/static/dashboard/assets/TablePagination-BaVngp9V.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-BiBzJlq4.js
  • src/gateway/static/dashboard/assets/UsagePage-9xByG5m8.js
  • src/gateway/static/dashboard/assets/UsagePage-oSvga8ZJ.js
  • src/gateway/static/dashboard/assets/UsersPage-C2TSN4yb.js
  • src/gateway/static/dashboard/assets/index-CLcUiuVX.js
  • src/gateway/static/dashboard/assets/index-CWtP4OuS.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/conftest.py
  • tests/integration/test_model_discovery.py
  • tests/integration/test_usage_endpoint.py
  • tests/unit/test_gateway_model_discovery.py
  • tests/unit/test_model_catalog_service.py
  • tests/unit/test_provider_health_service.py
  • web/src/api/client.test.ts
  • web/src/api/client.ts
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/UsagePage.test.tsx
  • web/src/pages/UsagePage.tsx
💤 Files with no reviewable changes (3)
  • src/gateway/static/dashboard/assets/ActivityPage-CfIPwmDz.js
  • src/gateway/static/dashboard/assets/UsagePage-9xByG5m8.js
  • src/gateway/static/dashboard/assets/index-CLcUiuVX.js

Comment thread docs/configuration.md Outdated
Comment thread src/gateway/main.py Outdated
Comment thread web/src/api/client.ts

Copilot AI 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.

Pull request overview

This PR reduces dashboard page-load latency by moving provider/model discovery and models.dev catalog refreshes off the synchronous request path (serving reads from cache and refreshing in the background), and by removing whole-table reads for Usage/Activity labeling by returning user/key labels directly from /v1/usage and its breakdowns.

Changes:

  • Add background refreshers for model discovery and models.dev catalog; serve stale cache on dashboard GETs with optional ?refresh=true for operator-only endpoints.
  • Extend usage endpoints to outer-join users/api_keys for per-row labels and labeled breakdowns, eliminating dashboard-side /v1/users and /v1/keys table scans.
  • Add dashboard-side request timeout + disable retries for discovery-backed queries; update unit/integration/dashboard tests and regenerate bundled assets + OpenAPI/Postman artifacts.

Reviewed changes

Copilot reviewed 40 out of 44 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
web/src/pages/UsagePage.tsx Builds user/key/model filter options from usage summary breakdown labels instead of loading full users/keys tables.
web/src/pages/UsagePage.test.tsx Updates summary fixtures to include label and asserts picker labeling behavior.
web/src/pages/ActivityPage.tsx Removes useUsers/useKeys usage; labels API key column from usage rows and builds filter options from summary breakdowns.
web/src/pages/ActivityPage.test.tsx Ensures Activity no longer calls /v1/users or /v1/keys; validates labeling fallbacks.
web/src/api/types.ts Adds user_alias / api_key_name on UsageEntry and label on UsageGroupRow.
web/src/api/hooks.ts Disables retries (retry: false) for discovery/models.dev-backed queries to avoid serial slow-failure amplification.
web/src/api/client.ts Adds a 30s request timeout (via AbortSignal) and differentiates timeout vs unreachable-network errors.
web/src/api/client.test.ts Adds tests covering apiFetch timeout behavior and signal passthrough.
tests/unit/test_provider_health_service.py Updates discovery mocks for new serve_stale parameter and adds tests for cached health reads.
tests/unit/test_model_catalog_service.py Adds coverage for serving stale models.dev cache and background-catalog enablement rules.
tests/unit/test_gateway_model_discovery.py Adds extensive coverage for background discovery behavior and refresh interval floor.
tests/integration/test_usage_endpoint.py Asserts /v1/usage rows and summary breakdowns carry joined labels; verifies outer-join behavior for deleted entities.
tests/integration/test_model_discovery.py Adds integration coverage ensuring read endpoints serve expired cache without dialing; validates refresh=true dials.
tests/integration/conftest.py Autouse fixture suppresses background refreshers during integration tests to prevent real outbound calls at app startup.
src/gateway/static/dashboard/index.html Updates hashed JS asset references for rebuilt dashboard bundle.
src/gateway/static/dashboard/assets/UsersPage-C2TSN4yb.js Regenerated bundled dashboard asset.
src/gateway/static/dashboard/assets/UsagePage-oSvga8ZJ.js Regenerated bundled dashboard asset (new hash).
src/gateway/static/dashboard/assets/UsagePage-9xByG5m8.js Removes old bundled asset (hash rollover).
src/gateway/static/dashboard/assets/ToolsGuardrailsPage-BiBzJlq4.js Regenerated bundled dashboard asset.
src/gateway/static/dashboard/assets/TablePagination-BaVngp9V.js Regenerated bundled dashboard asset.
src/gateway/static/dashboard/assets/OverviewPage-djfSANxc.js Regenerated bundled dashboard asset.
src/gateway/static/dashboard/assets/ModelScopeControl-qtT3hmbU.js Regenerated bundled dashboard asset.
src/gateway/static/dashboard/assets/ConfirmDialog-h1eFJM-m.js Regenerated bundled dashboard asset.
src/gateway/static/dashboard/assets/BudgetsPage-CXkIKoXy.js Regenerated bundled dashboard asset.
src/gateway/services/provider_health_service.py Adds serve_stale plumbing so polled health can serve cache while refresher owns dialing.
src/gateway/services/model_discovery_service.py Adds serve-stale reads, force refresh, and a background refresher with an interval floor.
src/gateway/services/model_catalog_service.py Adds serve-stale/force cache reads and a background catalog refresher with an interval floor.
src/gateway/main.py Starts/stops the new discovery + catalog refreshers in the app lifespan and clears caches on shutdown.
src/gateway/api/routes/usage.py Adds labeled usage rows + labeled breakdowns (outer joins) to avoid dashboard whole-table reads.
src/gateway/api/routes/providers.py Serves cached provider health by default when background discovery is enabled; keeps explicit refresh behavior.
src/gateway/api/routes/models.py Serves cached discovery and models.dev metadata; adds refresh param to discoverable models and includes checked_at.
docs/public/otari.postman_collection.json Regenerates Postman collection to reflect new/updated endpoint docs and query params.
docs/public/openapi.json Regenerates OpenAPI schema (checked_at, labels, refresh param).
docs/configuration.md Documents new background refresh behavior and refresh escape hatch semantics.
Files not reviewed (3)
  • src/gateway/static/dashboard/assets/ActivityPage-zqcCQMke.js: Generated file
  • src/gateway/static/dashboard/assets/UsagePage-oSvga8ZJ.js: Generated file
  • src/gateway/static/dashboard/assets/index-CWtP4OuS.js: Generated file

Comment thread tests/unit/test_provider_health_service.py Outdated

@khaledosman khaledosman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving so this is not held up on review. Eight findings inline. The two serve_stale ones are the same bug in two caches (a cached failure is served as an answer at any age, so both negative TTLs are dead on every read path and one transient error is pinned for the refresh interval); those are the two I would fix before this ships.


🤖 Review generated with Claude Code

Comment thread src/gateway/services/model_discovery_service.py
Comment thread src/gateway/services/model_catalog_service.py Outdated
Comment thread web/src/api/client.ts
Comment thread web/src/pages/ActivityPage.tsx Outdated
Comment thread web/src/pages/ActivityPage.tsx
Comment thread src/gateway/main.py Outdated
Comment thread src/gateway/api/routes/models.py
Comment thread src/gateway/services/model_discovery_service.py
…alog fetch

Cancelling a task is a request, not a guarantee. The CancelledError is
delivered at whatever the task is awaiting, and a nested anyio cancel scope
there can consume it: CancelScope.__exit__ calls host_task.uncancel() for each
pending uncancellation whenever its own scope was cancelling, then swallows the
error it sees. httpx and the provider SDKs implement their per-operation
timeouts as exactly those scopes, so a shutdown cancel that races one of their
timeouts is absorbed, the refresher loop resumes, and it sleeps out a whole
interval (a day, for the models.dev catalog). The unbounded `await task` in the
lifespan then never returns, so shutdown hangs. That is what timed out two unit
tests on CI at 120s, both inside TestClient.__exit__, and it is a production
restart hazard for any gateway with a fetch in flight.

_stop_refresher cancels, waits a bounded 5s, and abandons the task with a
warning if it will not stop. asyncio.wait rather than `await task`, so a
refresher that died on an unexpected error is logged instead of aborting the
rest of shutdown (the log writer and pooled search client are closed after it).
Applied to all six refreshers, not just the two new ones.

Separately, unit tests were starting the real refreshers, so every
TestClient(create_app(...)) in tests/unit fetched models.dev for real on CI.
The suppression fixture moves from tests/integration/conftest.py to the root
conftest so both suites share one definition.

Also from review:

- A failed models.dev fetch was served at any age under serve_stale, so a
  transient outage disabled enrichment until the next refresh tick, 24h by
  default, with no ?refresh flag on /v1/models/metadata to escape it. Only a
  successful blob is served stale now; a failure falls back to the 60s negative
  TTL it already had.
- The discovery and catalog refreshers re-check their setting per tick instead
  of being started only when it holds. model_cache_ttl_seconds and
  models_dev_cache_ttl_seconds are runtime-settable, and raising either from 0
  flipped every read onto the serve-from-cache path with no refresher running,
  which is the "cache nothing refreshes" mode these knobs do not offer.
- apiFetch converts a timeout on the response-body read, not just on fetch():
  headers can arrive before a stalled body, and a raw DOMException reached
  callers that only handle ApiError.
- The bulk usage delete, the bulk reprice, and the pricing-snapshot refresh get
  a 5 minute deadline instead of the 30s default. Their duration scales with the
  data, and the server commits whether or not the browser is still listening, so
  a 30s abort reported failure for work that succeeded.
- allowsCustom on the User and API-key filter pickers. Their options are the
  in-window top spenders capped at 100, so an entity below that rank was
  unreachable from the UI; Enter now commits a pasted id, as Model already did.
- Documented that a cold provider's first read still dials, that
  model_discovery_negative_ttl_seconds no longer governs the refresher-owned
  path, and that models_dev_cache_ttl_seconds now sets a refresh interval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 7, 2026 13:39 — with GitHub Actions Inactive
@github-actions github-actions Bot removed the missing-template PR is missing required template sections label Aug 7, 2026
…uggestions

Review follow-ups from @khaledosman, Copilot, and CodeRabbit.

Reads serve a cached failure at any age, so the refresh interval, not
model_discovery_negative_ttl_seconds, decided how fast a recovered provider
reappeared: one timed-out dial dropped a provider's models from GET /v1/models
and marked it unreachable on /v1/providers/health for the full 300s window,
where a read used to re-dial after 30s. A round that reports any failure now
comes back on the negative TTL instead of the success cadence, floored the same
way, so reads stay off the dial path and recovery keeps its old bound.

background_discovery_enabled now also requires model_discovery. An operator who
turned that off did so to stop the gateway dialing providers, and a refresher
fanning out every 5 minutes for the life of the process is unrequested traffic
against a provider that may meter list_models. The operator endpoints still
dial when asked.

GET /v1/models/{model_id} reads stale-tolerantly like the listing. Nothing on
the request path renews cached_at any more and the refresher sleeps after each
round, so an entry is expired from the moment the next round starts until its
dials finish; a TTL-bounded peek 404'd a model the listing was serving in the
same instant, for any provider model with no pricing row and no genai-prices
fallback. A negatively cached provider still reports no models.

The model typeahead and source picker get their entity filters back. Dropping
user_id/api_key_id was only ever needed by the user and key pickers, which now
have their own summary; sharing one query meant Activity filtered to one user
suggested models only other users had called, and picking one returned an empty
table with nothing saying the combination could not match. Same split on the
Usage page.

Also: a stray fourth quote opened a test docstring, and the
model_cache_ttl_seconds and model_discovery rows now describe what the detail
endpoint does, what a zero TTL means for the refresher, and that a health
re-check coalesces with a recent dial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 7, 2026 13:57 — with GitHub Actions Inactive
@njbrake

njbrake commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Note: this comment was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's.

All review threads addressed and resolved. Map of comment to fix, since two commits are involved:

Comment Fix
stale() serves a cached failure at any age 279e902, though not as suggested. See the thread reply.
_read_cache(serve_stale=True) returns before the ok check 4d9debe. Only a successful blob is served stale; a failure falls back to the 60s negative TTL.
30s deadline on unbounded destructive mutations 4d9debe. The bulk usage delete, the bulk reprice, and the pricing-snapshot refresh get 5 minutes.
User and key pickers capped at the top 100 by spend 4d9debe. allowsCustom on both, so Enter commits a pasted id as Model already did.
Dropping the entity filters changed the model typeahead and source picker 279e902. Those two keep their entity filters; the user and key pickers get their own summary. Same split on the Usage page.
Refresher created once from a runtime-settable predicate 4d9debe. Both refreshers re-check their knob per tick.
get_model_detail still peeks with a TTL-bounded read 279e902. Stale-tolerant like the listing, so it cannot 404 a model the listing is serving. A negatively cached provider still reports no models.
background_discovery_enabled ignores model_discovery 279e902. Predicate now requires it, so a gateway with discovery off makes no unattended list_models calls.
Stray fourth quote opening a docstring 279e902.

Also from CodeRabbit: the model_cache_ttl_seconds and model_discovery rows now say what the detail endpoint does, what a zero TTL means for the refresher, and that a health re-check coalesces with a recent dial.

The one finding I disagreed with is CodeRabbit's suggestion to start the refreshers in hybrid mode. register_routers never mounts the models or providers routers there, so no read path could serve a stale result, and _validate_platform_config rejects a non-empty providers block in hybrid anyway.

New tests: TestRefresherCadence, test_model_detail_agrees_with_the_listing_on_a_stale_entry, test_model_detail_does_not_serve_a_cached_failure_as_a_model, and an ActivityPage test pinning that the model typeahead keeps the user filter while the user picker drops it. The detail-endpoint one was verified failing against the TTL-bounded read.

@coderabbitai
coderabbitai Bot requested review from khaledosman and tbille August 7, 2026 13:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (1)
web/src/api/client.ts (1)

132-141: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Catch TimeoutError around extractErrorMessage too.

apiFetch converts timeout failures during fetch and the success-path response.json() read to ApiError, but the error-status paths still call await extractErrorMessage(response) without timeout handling. If response.json() in extractErrorMessage stalls on a 401/403 or non-OK response, it escapes as a raw DOMException; wrap it in a try/catch and rethrow ApiError(0, timeoutMessage) for isTimeout(error).

🤖 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 `@web/src/api/client.ts` around lines 132 - 141, Update the non-success
response handling in apiFetch, including the 401/403 branch and the general
!response.ok branch, to catch timeout errors from extractErrorMessage. When
isTimeout(error) is true, rethrow ApiError with status 0 and the existing
timeout message; preserve the current ApiError status and extracted message for
non-timeout errors.
🧹 Nitpick comments (1)
web/src/api/client.test.ts (1)

66-78: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Good coverage of the caller-signal message path, one gap remains.

This test correctly exercises the case where a caller-supplied signal exists and the message stays generic. Nice touch pairing it with a comment that explains why quoting "30s" would mislead an operator who set a five-minute budget.

To fully close the loop from the earlier review thread, consider adding a companion test where the mocked fetch resolves successfully with a 401/403 or non-OK status and a body read that rejects with a TimeoutError. That would confirm extractErrorMessage converts the failure to an ApiError the same way the success-path JSON read does.

🤖 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 `@web/src/api/client.test.ts` around lines 66 - 78, Add a companion test near
the existing apiFetch timeout coverage that mocks a successful fetch response
with a 401, 403, or other non-OK status whose body-reading method rejects with a
TimeoutError, then assert apiFetch rejects with an ApiError matching the
expected timeout message and status behavior. This should exercise
extractErrorMessage through the non-OK response path and mirror the existing
success-path JSON-read timeout test.
🤖 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 `@src/gateway/main.py`:
- Around line 249-264: Update the shutdown flow containing alias_refresher,
policy_refresher, provider_refresher, price_refresher, discovery_refresher, and
catalog_refresher to cancel all present refreshers before awaiting any stops,
then await their _stop_refresher operations concurrently so shutdown remains
bounded by one timeout period. Preserve each refresher’s corresponding cache
reset, and add a regression test that uses at least two stuck refreshers to
verify the bounded completion time.

In `@tests/unit/test_gateway_lifespan_shutdown.py`:
- Around line 63-64: Update the test cleanup around task.cancel() to await the
cancelled task inside pytest.raises(asyncio.CancelledError), performing the
second cancellation as requested. Ensure the task is fully awaited before the
test exits, while preserving the existing assertion that it is initially
pending.

In `@web/src/api/client.ts`:
- Around line 84-100: Update the provider-credentials re-encrypt mutation in
hooks.ts to pass signal: longRequestSignal() to its apiFetch call, matching the
existing bulk usage delete, reprice, and pricing-snapshot refresh mutations
while preserving the default timeout for other requests.

In `@web/src/pages/ActivityPage.tsx`:
- Around line 921-925: Update entitySuggestFilters in ActivityPage to spread
filters instead of modelSuggestFilters, while still clearing only user_id and
api_key_id. Keep the active model filter intact when entitySummary uses these
filters, without changing the model picker’s existing modelSuggestFilters
behavior.
- Around line 909-929: Add entitySummary.refetch() to the Activity refresh
handler alongside the existing window-scoped refetch calls, using void as with
the other refetches. Ensure pressing Refresh reloads the user and API-key picker
options.

---

Outside diff comments:
In `@web/src/api/client.ts`:
- Around line 132-141: Update the non-success response handling in apiFetch,
including the 401/403 branch and the general !response.ok branch, to catch
timeout errors from extractErrorMessage. When isTimeout(error) is true, rethrow
ApiError with status 0 and the existing timeout message; preserve the current
ApiError status and extracted message for non-timeout errors.

---

Nitpick comments:
In `@web/src/api/client.test.ts`:
- Around line 66-78: Add a companion test near the existing apiFetch timeout
coverage that mocks a successful fetch response with a 401, 403, or other non-OK
status whose body-reading method rejects with a TimeoutError, then assert
apiFetch rejects with an ApiError matching the expected timeout message and
status behavior. This should exercise extractErrorMessage through the non-OK
response path and mirror the existing success-path JSON-read timeout test.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6fe63d5d-2046-4c10-bb22-743a0a81979b

📥 Commits

Reviewing files that changed from the base of the PR and between 18b333a and 279e902.

📒 Files selected for processing (35)
  • docs/configuration.md
  • src/gateway/api/routes/models.py
  • src/gateway/core/config.py
  • src/gateway/main.py
  • src/gateway/services/model_catalog_service.py
  • src/gateway/services/model_discovery_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-BiZrg9Wc.js
  • src/gateway/static/dashboard/assets/BudgetsPage-CMiX_1TW.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-SJU1oKiR.js
  • src/gateway/static/dashboard/assets/DocsPage-CGwTgwR_.js
  • src/gateway/static/dashboard/assets/KeysPage-DaHoC_xH.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-Df0CgnHC.js
  • src/gateway/static/dashboard/assets/ModelsPage-CXuELAcH.js
  • src/gateway/static/dashboard/assets/OverviewPage-CucRHBgP.js
  • src/gateway/static/dashboard/assets/ProvidersPage-BAVrb9dJ.js
  • src/gateway/static/dashboard/assets/RoutingPage-Ime0-_3U.js
  • src/gateway/static/dashboard/assets/SettingsPage-CLKqwvOz.js
  • src/gateway/static/dashboard/assets/TablePagination-D6qbx-Og.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-BBbCaYZB.js
  • src/gateway/static/dashboard/assets/UsagePage-CJ2pGI3w.js
  • src/gateway/static/dashboard/assets/UsersPage-Ci3xqgbL.js
  • src/gateway/static/dashboard/assets/index-BcPLnG91.js
  • src/gateway/static/dashboard/index.html
  • tests/conftest.py
  • tests/integration/test_model_discovery.py
  • tests/unit/test_gateway_lifespan_shutdown.py
  • tests/unit/test_gateway_model_discovery.py
  • tests/unit/test_model_catalog_service.py
  • tests/unit/test_provider_health_service.py
  • web/src/api/client.test.ts
  • web/src/api/client.ts
  • web/src/api/hooks.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/UsagePage.tsx
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/gateway/static/dashboard/index.html
  • tests/unit/test_provider_health_service.py
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/UsagePage.tsx
  • src/gateway/services/model_catalog_service.py
  • src/gateway/api/routes/models.py
  • src/gateway/services/model_discovery_service.py

Comment thread src/gateway/main.py Outdated
Comment thread tests/unit/test_gateway_lifespan_shutdown.py
Comment thread web/src/api/client.ts
Comment thread web/src/pages/ActivityPage.tsx
Comment thread web/src/pages/ActivityPage.tsx
njbrake added 2 commits August 7, 2026 17:05
# Conflicts:
#	src/gateway/static/dashboard/assets/ActivityPage-fH63Z1Im.js
#	src/gateway/static/dashboard/assets/BudgetsPage-C3eMHXLY.js
#	src/gateway/static/dashboard/assets/BudgetsPage-DJMj9z5Z.js
#	src/gateway/static/dashboard/assets/BudgetsPage-Di_q043l.js
#	src/gateway/static/dashboard/assets/ConfirmDialog-C-RWVwB6.js
#	src/gateway/static/dashboard/assets/ConfirmDialog-Dvkwda0f.js
#	src/gateway/static/dashboard/assets/ConfirmDialog-lRO7CIis.js
#	src/gateway/static/dashboard/assets/DocsPage-Crh3hB4y.js
#	src/gateway/static/dashboard/assets/DocsPage-D4q8s7aN.js
#	src/gateway/static/dashboard/assets/DocsPage-omWiBiUs.js
#	src/gateway/static/dashboard/assets/KeysPage-D2ySTl5G.js
#	src/gateway/static/dashboard/assets/KeysPage-DBP0rqYO.js
#	src/gateway/static/dashboard/assets/KeysPage-DvXgAgzE.js
#	src/gateway/static/dashboard/assets/ModelScopeControl-CYPgEOWk.js
#	src/gateway/static/dashboard/assets/ModelScopeControl-Cnf_HjRm.js
#	src/gateway/static/dashboard/assets/ModelScopeControl-DQpF54p9.js
#	src/gateway/static/dashboard/assets/ModelsPage-BVAOlcUn.js
#	src/gateway/static/dashboard/assets/OverviewPage-33CntAQu.js
#	src/gateway/static/dashboard/assets/OverviewPage-DIEI5QfB.js
#	src/gateway/static/dashboard/assets/OverviewPage-W9tjAThu.js
#	src/gateway/static/dashboard/assets/ProvidersPage-CkpZWNPU.js
#	src/gateway/static/dashboard/assets/ProvidersPage-Igqia_Xr.js
#	src/gateway/static/dashboard/assets/ProvidersPage-ooS_k3AK.js
#	src/gateway/static/dashboard/assets/RoutingPage-CQcIne5l.js
#	src/gateway/static/dashboard/assets/SettingsPage-CDU9M0qn.js
#	src/gateway/static/dashboard/assets/SettingsPage-eRH6Zlbl.js
#	src/gateway/static/dashboard/assets/SettingsPage-knFypvb1.js
#	src/gateway/static/dashboard/assets/TablePagination-BpT-8wzM.js
#	src/gateway/static/dashboard/assets/TablePagination-CMcmgCgb.js
#	src/gateway/static/dashboard/assets/TablePagination-aSWyG4WX.js
#	src/gateway/static/dashboard/assets/ToolsGuardrailsPage-CkNbc6ta.js
#	src/gateway/static/dashboard/assets/ToolsGuardrailsPage-DgYf8d8e.js
#	src/gateway/static/dashboard/assets/ToolsGuardrailsPage-oZnn27P0.js
#	src/gateway/static/dashboard/assets/UsagePage-Bt6OQ6El.js
#	src/gateway/static/dashboard/assets/UsersPage-B5TbXNQ2.js
#	src/gateway/static/dashboard/assets/UsersPage-CWfTsLqm.js
#	src/gateway/static/dashboard/assets/UsersPage-D5FDH2kZ.js
#	src/gateway/static/dashboard/index.html
@njbrake
njbrake temporarily deployed to integration-tests August 7, 2026 17:06 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests August 7, 2026 17:10 — with GitHub Actions Inactive
@njbrake
njbrake merged commit 4b63dce into main Aug 7, 2026
11 checks passed
@njbrake
njbrake deleted the post branch August 7, 2026 17:14
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.

3 participants