feat(dashboard): budget and user management with two-layer model access#322
Conversation
Add the standalone dashboard surfaces for budgets and users, and complete the per-user model-access layer. Budgets (#305): CRUD with an optional name, a per-budget usage rollup (assigned users + aggregate spend/reserved), spent-vs-limit indicator, per-period reset config, reset-history drill-down, and optional user assignment at creation. Users (#300): CRUD, budget assignment, block/unblock, a per-user usage drill-down, and the per-user default model-access policy. Virtual users are hidden behind a toggle. Model access: User.allowed_models is the default a key inherits; a key may only narrow it (subset-on-write), enforced at the catalog and inference sites through a shared resolver. The key control is reframed as narrowing within the owner's access. Key ownership is user-first in the dashboard (required owner picker), and no-user API keys now attach to a shared "default" user instead of a per-key virtual one. Closes #305, closes #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughThe change adds budget naming, usage rollups, reset-log APIs, per-user model access inheritance, shared default-user ownership, and dashboard Users and Budgets management pages with supporting tests and bundled assets. ChangesManagement, access control, and dashboard
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
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. Comment |
…le scoping Two CI fixes for the budget/user management change: - Regenerate docs/public/otari.postman_collection.json, which was left stale when openapi.json was regenerated (postman-check was failing). - Update test_files_are_user_scoped for the shared "default" user: keys created without a user_id now share one owner, so file isolation is per-user, not per-key. The isolation test now gives the second key its own owner, and a new test_no_owner_keys_share_default_user_files pins the deliberate shared-default behavior (no-owner keys are not isolated from each other). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
tests/integration/test_passthrough_enforcement.py (1)
184-189: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winRevert mutations to shared entities to avoid test pollution.
By patching the shared
"default"user toblocked = Truein this test, you might inadvertently cause subsequent tests—which expect to use the unblocked"default"user for orphaned keys—to fail depending on execution order.Consider using a
try...finallyblock or anaddfinalizercleanup fixture to restore theblockedstatus toFalseonce this test completes.🤖 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 `@tests/integration/test_passthrough_enforcement.py` around lines 184 - 189, Restore the shared default user’s blocked status after the mutation in the test, using a try...finally block or pytest cleanup fixture around the client.patch call. Ensure cleanup sets blocked back to False even when assertions or later test steps fail, while preserving the existing status-code assertion.
🧹 Nitpick comments (4)
src/gateway/services/model_access.py (1)
61-63: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueConsider caching the user's default allowlist.
Fetching the user's default allowlist directly from the database here introduces an unconditional scalar query on the hot path for every request made with an inheriting API key. While the budget reservation downstream already queries the database per request, you might consider caching this user-level allowlist (or fetching it alongside the API key cache) in the future to further reduce database load. This looks solid for now, though!
🤖 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 `@src/gateway/services/model_access.py` around lines 61 - 63, Consider caching the user-level allowlist queried in the API-key request path around user_default, or loading it alongside the API-key cache, so inheriting-key requests avoid an unconditional scalar database query on every request.web/src/pages/BudgetsPage.test.tsx (1)
1-263: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSolid coverage — mocking
fetchper method and asserting the exact request bodies is exactly the right call. One gentle nudge: there's no test for editing a budget that has a limit back to "unlimited"/"No reset", which is precisely the path called out in the consolidated cross-layer comment. Worth adding once that contract is settled.🤖 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/BudgetsPage.test.tsx` around lines 1 - 263, Add a test alongside the existing edit coverage for BudgetsPage that starts with a budget having a finite limit and reset period, opens the edit form, clears the spending limit and selects “No reset,” then saves. Assert the PATCH request body sends max_budget and budget_duration_sec as null, and verify the updated row displays “Unlimited” and “No reset.”web/src/components/UserComboBox.tsx (2)
32-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
textwon't follow externalvaluechanges after mount.
textseeds fromvalueonly once viauseState(value). If this component is ever reused where the parent resets or programmatically changesvalueafter mount (e.g. an edit flow), the displayed text would go stale since ComboBox'sinputValuedoesn't auto-sync with prop updates — you'd need to sync it yourself (e.g. auseEffectkeyed onvalue). Not an issue today sinceCreateKeyFormonly ever updatesuserIdthrough this component's ownonChange, but worth a defensive note for future reuse.🤖 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/components/UserComboBox.tsx` at line 32, Synchronize the UserComboBox text state with external value changes by adding an effect keyed on value that updates setText(value). Keep the existing local onChange behavior intact while ensuring programmatic or parent-driven value resets are reflected in the displayed input.
29-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the "apikey-" virtual-user check into one shared helper. Both new components reimplement the exact same
user_id.startsWith("apikey-")predicate that already exists inKeysPage.tsx'sisVirtualUser, so the "what counts as a virtual user" rule now lives in three-plus places.
web/src/components/UserComboBox.tsx#L29: replace the inline.filter((u) => !u.user_id.startsWith("apikey-"))with a sharedisVirtualUser(u.user_id)helper.web/src/components/UserMultiSelect.tsx#L34: replace the equivalent inline filter the same way.🤖 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/components/UserComboBox.tsx` at line 29, The virtual-user predicate is duplicated across the user selectors. Reuse the existing shared isVirtualUser helper from KeysPage.tsx in UserComboBox.tsx at line 29 and UserMultiSelect.tsx at line 34, replacing each inline user_id.startsWith("apikey-") filter while preserving the exclusion behavior.
🤖 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/api/routes/budgets.py`:
- Around line 149-171: Scope the grouped usage query in the budget-list handler
to the budget IDs present in the current budgets page, while retaining the
existing deleted-user and non-null filters and single aggregate query. Apply the
page ID filter before building the usage mapping so GET /v1/budgets does not
aggregate users belonging to other budgets; preserve the existing BudgetResponse
lookup and zero defaults.
In `@src/gateway/repositories/users_repository.py`:
- Around line 21-36: Update get_or_create_default_user to flush the newly added
default User so concurrent inserts detect conflicts within this helper, then
recover from a losing insert race by rolling back the failed flush and
re-querying DEFAULT_USER_ID to return the winner’s row. Preserve revival of an
existing soft-deleted user and the caller’s responsibility to commit successful
writes.
---
Outside diff comments:
In `@tests/integration/test_passthrough_enforcement.py`:
- Around line 184-189: Restore the shared default user’s blocked status after
the mutation in the test, using a try...finally block or pytest cleanup fixture
around the client.patch call. Ensure cleanup sets blocked back to False even
when assertions or later test steps fail, while preserving the existing
status-code assertion.
---
Nitpick comments:
In `@src/gateway/services/model_access.py`:
- Around line 61-63: Consider caching the user-level allowlist queried in the
API-key request path around user_default, or loading it alongside the API-key
cache, so inheriting-key requests avoid an unconditional scalar database query
on every request.
In `@web/src/components/UserComboBox.tsx`:
- Line 32: Synchronize the UserComboBox text state with external value changes
by adding an effect keyed on value that updates setText(value). Keep the
existing local onChange behavior intact while ensuring programmatic or
parent-driven value resets are reflected in the displayed input.
- Line 29: The virtual-user predicate is duplicated across the user selectors.
Reuse the existing shared isVirtualUser helper from KeysPage.tsx in
UserComboBox.tsx at line 29 and UserMultiSelect.tsx at line 34, replacing each
inline user_id.startsWith("apikey-") filter while preserving the exclusion
behavior.
In `@web/src/pages/BudgetsPage.test.tsx`:
- Around line 1-263: Add a test alongside the existing edit coverage for
BudgetsPage that starts with a budget having a finite limit and reset period,
opens the edit form, clears the spending limit and selects “No reset,” then
saves. Assert the PATCH request body sends max_budget and budget_duration_sec as
null, and verify the updated row displays “Unlimited” and “No reset.”
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: afc070da-6e63-4e14-99d6-78d6b2a07f72
⛔ Files ignored due to path filters (1)
docs/public/openapi.jsonis excluded by!docs/public/openapi.json
📒 Files selected for processing (40)
alembic/versions/c9f3a1b5d7e2_add_budget_name.pyalembic/versions/d1e4b8c6f0a3_add_user_allowed_models.pysrc/gateway/api/routes/_passthrough.pysrc/gateway/api/routes/_pipeline.pysrc/gateway/api/routes/batches.pysrc/gateway/api/routes/budgets.pysrc/gateway/api/routes/keys.pysrc/gateway/api/routes/models.pysrc/gateway/api/routes/users.pysrc/gateway/models/entities.pysrc/gateway/repositories/users_repository.pysrc/gateway/services/bootstrap_service.pysrc/gateway/services/model_access.pysrc/gateway/static/dashboard/assets/index-B7c8vlYS.jssrc/gateway/static/dashboard/assets/index-BDzQSGi5.csssrc/gateway/static/dashboard/assets/index-CGfT4huk.jssrc/gateway/static/dashboard/assets/index-DXv57ubR.csssrc/gateway/static/dashboard/index.htmltests/integration/test_budget_dashboard.pytests/integration/test_model_access_user_layer.pytests/integration/test_passthrough_enforcement.pytests/integration/test_user_budget.pytests/unit/test_gateway_bootstrap.pytests/unit/test_model_access.pytests/unit/test_pipeline_vision_billing.pytests/unit/test_shared_helpers_pure.pyweb/e2e/dashboard.spec.tsweb/src/App.tsxweb/src/api/hooks.tsweb/src/api/types.tsweb/src/components/AppShell.tsxweb/src/components/ModelScopeControl.tsxweb/src/components/UserComboBox.tsxweb/src/components/UserMultiSelect.tsxweb/src/pages/BudgetsPage.test.tsxweb/src/pages/BudgetsPage.tsxweb/src/pages/KeysPage.test.tsxweb/src/pages/KeysPage.tsxweb/src/pages/UsersPage.test.tsxweb/src/pages/UsersPage.tsx
Picking an existing owner when creating an API key silently created a second user instead of reusing the selected one. The owner options are labelled "user_id (alias)". Selecting one fires onSelectionChange with the correct id, but the ComboBox then writes that option's display text back into the input, re-firing onInputChange, which passed the raw text straight to onChange. The label won, so the POST carried user_id "alice (Alice)"; the keys API does not know that id, so it created a new user aliased "User alice (Alice)". Resolve either form (raw id, or "id (alias)" label) back to the canonical id before reporting it, and use the resolved id for the "creates a new user" hint so it no longer fires for a user that already exists. The gap was untested: KeysPage's fetch mock had no /v1/users branch, so the picker had zero options and the one owner test typed free text rather than selecting. Adds the mock plus a regression test that actually picks an option (verified failing before the fix: expected 'alice (Alice)' to be 'alice'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ult user race-safe Both from CodeRabbit review on #322. Scope the budget list's grouped usage query to the current page's budget ids. It filtered only on "has a budget and is not deleted", so it grouped over every budgeted user and then discarded all but the page, making each GET /v1/budgets pay for the whole users table regardless of skip/limit. Skips the query outright for an empty page. Make get_or_create_default_user tolerate losing a creation race. user_id is the primary key, so two concurrent no-owner key creations both staged the same row and the loser's commit raised, surfacing as a 500 for a request that should just have reused the winner's row. The insert now goes through a SAVEPOINT and adopts the winner on conflict; scoping the undo to a savepoint rather than rolling back the session keeps the caller's already-staged writes intact, which the helper's "caller owns the commit" contract requires. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/gateway/repositories/users_repository.py (1)
24-54: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMake
_revivea standard synchronous function.Fantastic work using the
begin_nestedsavepoint here! It elegantly handles the race condition without blowing away the caller's previously staged writes—a really clean, robust solution.Since
_revivejust mutates an attribute on the object in memory and doesn't perform any database I/O, we can safely drop theasync/await. It saves a tiny bit of overhead by not scheduling a coroutine on the event loop, and keeps things nicely simplified.♻️ Proposed refactor
-async def _revive(user: User) -> User: +def _revive(user: User) -> User: """Clear a soft delete so the shared default owner is usable again.""" if user.deleted_at is not None: user.deleted_at = None return user async def get_or_create_default_user(db: AsyncSession) -> User: """Return the shared ``default`` user, creating (or reviving) it if needed. The caller still owns the final commit. The insert goes through a SAVEPOINT so that losing a race to a concurrent creator rolls back only this row, not whatever the caller has already staged: ``user_id`` is the primary key, so without this the loser's commit would raise and surface as a 500 for a request that should simply have reused the row the winner just created. """ existing = (await db.execute(select(User).where(User.user_id == DEFAULT_USER_ID))).scalar_one_or_none() if existing is not None: - return await _revive(existing) + return _revive(existing) user = User(user_id=DEFAULT_USER_ID, alias="Default") try: async with db.begin_nested(): db.add(user) return user except IntegrityError: # Someone else inserted it between our select and our flush; adopt theirs. winner = (await db.execute(select(User).where(User.user_id == DEFAULT_USER_ID))).scalar_one_or_none() if winner is None: raise - return await _revive(winner) + return _revive(winner)🤖 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 `@src/gateway/repositories/users_repository.py` around lines 24 - 54, Convert _revive to a synchronous function and remove the await keyword from both callers in get_or_create_default_user, while preserving its existing deleted_at mutation and return behavior.
🤖 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.
Nitpick comments:
In `@src/gateway/repositories/users_repository.py`:
- Around line 24-54: Convert _revive to a synchronous function and remove the
await keyword from both callers in get_or_create_default_user, while preserving
its existing deleted_at mutation and return behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b7e39f4a-54ed-4169-b65e-a67c7564a1f6
📒 Files selected for processing (2)
src/gateway/api/routes/budgets.pysrc/gateway/repositories/users_repository.py
🚧 Files skipped from review as they are similar to previous changes (1)
- src/gateway/api/routes/budgets.py
There was a problem hiding this comment.
Pull request overview
Adds standalone admin-dashboard management surfaces for Users and Budgets, and completes the two-layer model access semantics by introducing a per-user default allowlist that API keys inherit (with key-level allowlists restricted to narrowing). It also changes ownerless API keys to attach to a shared, visible default user, and updates backend routes, migrations, and tests to support these behaviors.
Changes:
- Add dashboard pages/components/tests for Users + Budgets management, plus navigation/routes wiring and e2e coverage.
- Implement per-user
allowed_modelsand enforce “key may only narrow user default” (subset-on-write), with shared runtime enforcement viaresolve_request_allowlist. - Add budget
name, budget usage rollups + reset-log endpoint, switch ownerless keys (including bootstrap) to the shareddefaultuser, and ship migrations + docs artifacts.
Reviewed changes
Copilot reviewed 39 out of 43 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/pages/UsersPage.tsx | New Users management page (CRUD, budget assignment, block/unblock, per-user model access default). |
| web/src/pages/UsersPage.test.tsx | Component tests for Users page behaviors. |
| web/src/pages/KeysPage.tsx | Key creation made user-first (required owner), reframes per-key scope as narrowing within owner access. |
| web/src/pages/KeysPage.test.tsx | Tests for new owner picker and submission semantics. |
| web/src/pages/BudgetsPage.tsx | New Budgets page (CRUD, rollups, reset history drilldown, optional assignment on create). |
| web/src/pages/BudgetsPage.test.tsx | Component tests for Budgets page behaviors. |
| web/src/components/UserMultiSelect.tsx | Multi-select user picker for budget assignment. |
| web/src/components/UserComboBox.tsx | Required owner combobox for key creation (existing or new user id). |
| web/src/components/ModelScopeControl.tsx | Makes scope control reusable across user-default vs key-narrowing contexts via parameterized copy/labels. |
| web/src/components/AppShell.tsx | Adds Users/Budgets to sidebar navigation. |
| web/src/App.tsx | Registers new /users and /budgets routes. |
| web/src/api/types.ts | Adds Budget/User types and request payload types for new APIs. |
| web/src/api/hooks.ts | Adds budgets/users query + mutation hooks and reset-log query hook. |
| web/e2e/dashboard.spec.ts | Extends e2e flows to cover Users/Budgets navigation and creation, plus owner-required key creation. |
| tests/unit/test_shared_helpers_pure.py | Updates budget response unit expectations (name + rollup defaults). |
| tests/unit/test_pipeline_vision_billing.py | Stubs new allowlist resolver dependency for pure unit testing. |
| tests/unit/test_model_access.py | Adds unit tests for per-user inheritance + subset checking helpers. |
| tests/unit/test_gateway_bootstrap.py | Pins bootstrap key ownership to the shared default user. |
| tests/integration/test_user_budget.py | Updates integration test expectations for ownerless key behavior (default user). |
| tests/integration/test_passthrough_enforcement.py | Adjusts enforcement test to block the default owner user. |
| tests/integration/test_model_access_user_layer.py | New integration tests for per-user model access layer + key subset-on-write enforcement. |
| tests/integration/test_files_endpoint.py | Updates user scoping tests given ownerless keys now share default user; adds explicit “shared files” test. |
| tests/integration/test_budget_dashboard.py | New integration coverage for budget name, rollups, and reset-log ordering/404 behavior. |
| src/gateway/static/dashboard/index.html | Updates committed dashboard bundle asset hashes. |
| src/gateway/services/model_access.py | Adds resolve_request_allowlist and is_allowlist_subset to enforce two-layer allowlist behavior. |
| src/gateway/services/bootstrap_service.py | Bootstrap key now attaches to shared default user. |
| src/gateway/repositories/users_repository.py | Adds get_or_create_default_user with concurrency-safe create (SAVEPOINT) and revive logic. |
| src/gateway/models/entities.py | Adds Budget.name and User.allowed_models columns to ORM models. |
| src/gateway/api/routes/users.py | Adds per-user allowed_models support (create + PATCH tri-state) with validation. |
| src/gateway/api/routes/models.py | Uses resolve_request_allowlist for catalog filtering with inherited user defaults. |
| src/gateway/api/routes/keys.py | Ownerless keys attach to default user; enforces key allowlist ⊆ user default on create/patch. |
| src/gateway/api/routes/budgets.py | Adds budget name, usage rollups (grouped query), and reset-logs endpoint. |
| src/gateway/api/routes/batches.py | Enforces model access via resolve_request_allowlist (inherits user default). |
| src/gateway/api/routes/_pipeline.py | Enforces model access via resolve_request_allowlist on inference pipeline. |
| src/gateway/api/routes/_passthrough.py | Enforces model access via resolve_request_allowlist on passthrough routes. |
| docs/public/otari.postman_collection.json | Updates Postman collection for new/updated budget/user fields and endpoints. |
| docs/public/openapi.json | Regenerated OpenAPI with new schemas/endpoints and updated budget/user models. |
| alembic/versions/d1e4b8c6f0a3_add_user_allowed_models.py | Migration: add users.allowed_models (nullable JSON). |
| alembic/versions/c9f3a1b5d7e2_add_budget_name.py | Migration: add budgets.name (nullable string). |
Comments suppressed due to low confidence (1)
docs/public/openapi.json:5081
- OpenAPI descriptions still say “API key without user field: Use virtual user created with API key”, but this PR changes ownerless keys to attach to the shared
defaultuser. This makes the generated API docs misleading across multiple endpoints; update the route docstrings (chat/audio/embeddings/images/rerank/batches/moderations) and regenerateopenapi.json(and any derived artifacts like the Postman collection) so the contract matches runtime behavior.
"/v1/chat/completions": {
"post": {
"description": "OpenAI-compatible chat completions endpoint.\n\nSupports both streaming and non-streaming responses.\nHandles reasoning content from otari providers.\n\nAuthentication modes:\n- Master key + user field: Use specified user (must exist)\n- API key + user field: Use specified user (must exist)\n- API key without user field: Use virtual user created with API key",
"operationId": "chat_completions_v1_chat_completions_post",
khaledosman
left a comment
There was a problem hiding this comment.
Reviewed against AGENTS.md, the backend/frontend skills, and the security-review instructions. Strong, well-tested change; no blocking issues.
Validated locally on the PR branch: ruff clean, mypy --strict clean (233 files), frontend tsc -b clean, Vitest for BudgetsPage/UsersPage/KeysPage green (36 passed). Committed dashboard bundle matches index.html; OpenAPI + Postman regenerated in-diff. Integration tests (Docker/Postgres) not run locally, but coverage looks thorough.
The budget rollup avoids N+1 (one page-scoped grouped query), the migrations are nullable/reversible with sound server_default reasoning, and the get_or_create_default_user SAVEPOINT race pattern is correct. Three notes inline: one Low security-model point on where narrow-only is enforced, plus two optional consistency nits.
Review created by Claude Code.
From the Copilot review on #322. Index users.budget_id and budget_reset_logs.budget_id. Both are foreign keys the new dashboard reads on every page load, and neither was indexed: the budgets list groups users by budget_id to build its rollup, and the reset-log drill-down filters on it. Sibling FKs on these tables are already indexed, so this restores the table's own convention. Index-only migration, reversible, no data change. Correct the docstrings that still described the removed per-key virtual user. Seven inference routes advertised "API key without user field: Use virtual user created with API key" and create_key said a new user is created automatically; ownerless keys now attach to the shared "default" user. These strings are published in openapi.json, so the public contract was describing behavior this PR removed. Regenerated the OpenAPI spec and Postman collection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cy (#330) * feat(dashboard): activity / request log viewer with per-request latency Adds the Activity page: a per-request log of what the gateway served (tokens, cost, latency, status, errors), plus the backend it reads from. Backend - Record `usage_logs.latency_ms`: a monotonic start is captured once at the top of the shared handler preamble (`resolve_request_context`) and carried on `RequestContext`, then threaded into `log_usage` via `_elapsed_ms`. Covers non-streaming success, the failure/refund path, all three streaming settlement callbacks, and the two direct `UsageLog` constructors in `_passthrough.py`. Batch rows keep a NULL latency on purpose: a batch is an asynchronous job, so it has no synchronous request duration. - For streaming, settlement runs after the stream drains, so the value is total server-side time. It is labelled "Total time" rather than a bare "latency" so it is not mistaken for time to first token. - `/v1/usage` gains additive `status`, `model`, and `endpoint` filters and returns `latency_ms`. The response stays a bare JSON array so the existing billing/analytics export contract is unchanged; the paginator's total is served by a new `GET /v1/usage/count` that shares one filter helper, so the count cannot drift from the list. - Migration adds the nullable column and an `(status, timestamp)` index for the primary "show errors, newest first" query. `model` is high cardinality and left unindexed. Dashboard - New Activity page: time range presets (24h default), status / endpoint / user / model filters, error rows surfaced in the red status treatment, an expandable detail row with the full provider error text and a copy control, a pager backed by the count endpoint, and distinct empty states for "no matches" versus "nothing recorded yet". - Activity leads a new "Observability" sidebar section. "Access" covers who may call the gateway; a request log is what it did. The section is where usage analytics and an overview dashboard will live. No request or response content is captured. `UsageLog` stores counts, timing, and provider-reported status only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dashboard): reach batch rows in the endpoint filter, keep pager usable Review follow-ups on the activity log viewer. - The endpoint filter's curated list omitted `/v1/batches` and `/v1/batches/results`, which `log_batch_usage` writes, so batch rows could not be reached by that filter at all. Adds both, notes that the list has to grow with new billable routes, and covers it with a regression test. - The pager derived `hasNext` from the total alone, which is zero when the count request fails. A failed count therefore disabled Next and stranded the operator on the first page with rows they could not reach. Falls back to "a full page came back, so there is probably more" when the count is absent. - Documents why the vision describe side-call records a NULL latency: the row bills the describe model, so the enclosing request's duration would be misattributed to it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dashboard): show the visible row range when the usage count is unavailable The pager derived its label from the total, which is zero when the count request fails. It therefore read "0 of 0" while a full page of rows was on screen, contradicting what the operator could see. The range is now anchored on the rows actually rendered, and the "of N" suffix is dropped when no total is available. Covered by the existing count-failure test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dashboard): drive the pager off an exact count, not stale query data Review follow-up from @Copilot on #330. `count.data` is not a test for "the current count succeeded": TanStack keeps the last successful data when a refetch errors, and `keepPreviousData` serves the previous filters' total while a new one loads. Either case would show a stale total and could disable Next while more rows were reachable. The pager now gates the exact-total path on `count.isSuccess && !count.isPlaceholderData` and falls back to the visible row range otherwise. Also corrects the model filter's hint. The filter is an exact match on the model recorded in the log (the resolved target, with no provider prefix), so the placeholder now shows a realistic stored value and says so, rather than implying a family name like "gpt-4o" would match a version suffixed row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): chain the latency migration after the budget FK index Rebasing onto main put this migration alongside e5a7c9d1b3f4 (index the budget foreign keys), which arrived with #322 and declares the same parent, d1e4b8c6f0a3. That left two sibling heads, so `alembic upgrade head` would fail with "Multiple head revisions are present" on startup and in the integration tests. Re-chains this revision after e5a7c9d1b3f4 so the history is linear again. Index-only and column-only additions that do not touch the same objects, so the order between them carries no behavioral meaning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Description
Screen.Recording.2026-07-20.at.8.32.35.PM.mov
Adds the standalone dashboard surfaces for budgets and users, and completes the two-layer model access control. This is management-only: no request/token usage analytics are displayed yet.
Budgets (#305)
BudgetResetLog).PATCH /v1/usersso each user's reset clock is set correctly).Users (#300)
user_idsurfaced. Auto-created virtual users are hidden behind a toggle.Model access (two layers)
User.allowed_modelsis the default every one of that user's keys inherits; a key with no list of its own inherits it, and a key that sets its own list must be a subset (narrow-only, validated on write). Enforced at the catalog and inference sites through a shared resolver (resolve_request_allowlist). The per-key control is reframed in the UI as "restrict within the owner's access".users.py.Key ownership
user_idnow attach to a shared, visibledefaultuser instead of a per-keyapikey-<uuid>virtual user (non-breaking; the bootstrap key uses it too).Migrations:
budgets.name,users.allowed_models(both nullable, reversible).PR Type
Relevant issues
Closes #305. Closes #300. Closes #301. Part of #299.
#301 (API key management) is finished here: PR #318 implemented the Keys page but
left #301 open because its "create a key for a user" task depended on user
management. This PR adds that user-first owner picker, completing the issue.
Checklist
tests/unit,tests/integration).make lint,make typecheck; web typecheck + Vitest; Playwright E2E; integration tests run isolated on SQLite since Docker/Postgres is unavailable in the sandbox, so Postgres CI is the real signal).AI Usage
AI Model/Tool used: Claude Code (Opus 4.8, 1M context)
Any additional AI details you'd like to share: Built and verified through back-and-forth with @njbrake, who drove the design decisions (user-first keys, a shared default user, keeping model access two-layered with the key reframed as narrowing, budgets per-user only). The reasoning and product calls are his; the code and prose are the agent's.
🤖 Generated with Claude Code
Summary
Benefits