Skip to content

feat(dashboard): budget and user management with two-layer model access#322

Merged
njbrake merged 6 commits into
mainfrom
feat/dashboard-budgets
Jul 21, 2026
Merged

feat(dashboard): budget and user management with two-layer model access#322
njbrake merged 6 commits into
mainfrom
feat/dashboard-budgets

Conversation

@njbrake

@njbrake njbrake commented Jul 20, 2026

Copy link
Copy Markdown
Member

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)

  • CRUD with an optional name, a per-budget usage rollup (assigned-user count plus aggregate spend/reserved, one grouped query, not N+1), a spent-vs-limit indicator, per-period reset configuration, and a reset-history drill-down (BudgetResetLog).
  • Optionally assign the new budget to existing users at creation (reuses PATCH /v1/users so each user's reset clock is set correctly).

Users (#300)

  • CRUD, budget assignment, block/unblock, a per-user default model-access policy, and the routing user_id surfaced. Auto-created virtual users are hidden behind a toggle.

Model access (two layers)

  • User.allowed_models is 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".
  • Scope of that guarantee: narrow-only is enforced at write time, not as a runtime ceiling. A key with its own list is used verbatim at request time, so tightening a user's default constrains only the keys that inherit it; keys that already carry a broader list of their own keep it until they are rewritten. Deliberate (a stored key list always means what it says, and the inference path stays free of an extra resolution step), and documented alongside the tri-state in users.py.

Key ownership

  • Key creation in the dashboard is user-first (a required Owner picker; pick an existing user or create one inline). API keys created without a user_id now attach to a shared, visible default user instead of a per-key apikey-<uuid> virtual user (non-breaking; the bootstrap key uses it too).

Migrations: budgets.name, users.allowed_models (both nullable, reversible).

PR Type

  • New Feature

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

  • 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; 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).
  • Documentation was updated where necessary (in-app copy; no external docs affected).
  • If the API contract changed, I regenerated the OpenAPI spec.

AI Usage

  • This is fully AI-generated.

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.

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

🤖 Generated with Claude Code

Summary

  • Added standalone dashboard pages for Budgets and Users, including complete create/edit/delete flows, plus supporting UI components and routes.
  • Budgets: added an optional name, display spend vs reserved rollups and assigned user counts, support reset-period configuration, and added a reset-history drill-down via a new reset-log endpoint.
  • Users: added budget assignment, alias, block/unblock, and a default model access editor; auto-created “virtual” users are hidden by default with a toggle to show them.
  • API keys: key creation now requires an owner (with inline owner creation). If no owner is provided, the key attaches to a shared default user; the owner identity is also used for dashboard display and enforcement.
  • Completed model-access enforcement so keys inherit the owner’s access, and key-level changes can only restrict what the owner grants (validated on create and update across the request path).
  • Added backend and frontend wiring for the new dashboard sections (navigation, page routing, and new API hooks/types).
  • Added/updated reversible database migrations for budget names and user model-access defaults, plus indexes to speed up dashboard drill-down queries.
  • Expanded test coverage (integration, unit, and dashboard E2E) and updated API examples/documentation to reflect the shared default user behavior for ownerless keys.

Benefits

  • Makes budget and user administration in the dashboard more complete and actionable, including clear usage indicators and reset history.
  • Improves safety and predictability of model access by ensuring API keys can’t accidentally grant broader access than their owner.
  • Provides smoother key ownership behavior (including ownerless/bootstrapped keys) with stronger test and UI coverage.

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>
@njbrake
njbrake had a problem deploying to integration-tests July 20, 2026 16:30 — with GitHub Actions Failure
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

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

Review profile: CHILL

Plan: Pro

Run ID: 18c18d78-416c-4e33-8d91-3c7b64f19fd2

📥 Commits

Reviewing files that changed from the base of the PR and between 766e88a and 67a7afa.

📒 Files selected for processing (1)
  • src/gateway/api/routes/_pipeline.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/gateway/api/routes/_pipeline.py

Walkthrough

The 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.

Changes

Management, access control, and dashboard

Layer / File(s) Summary
Budget schema, rollups, reset logs, and API coverage
alembic/versions/..., src/gateway/api/routes/budgets.py, src/gateway/models/entities.py, tests/integration/test_budget_dashboard.py
Adds budget names, usage rollups, reset-log listing, nullable-name updates, and schema indexes.
User access defaults and shared ownership
src/gateway/services/..., src/gateway/repositories/..., src/gateway/api/routes/..., tests/integration/..., tests/unit/...
Adds user model allow-lists, inherited request resolution, narrow-only key scopes, and shared default-user ownership.
Dashboard data wiring and management UI
web/src/api/..., web/src/components/..., web/src/pages/..., web/src/App.tsx
Adds budget/user types, hooks, routes, navigation, forms, selectors, usage displays, reset history, and owner-based key creation.
Validation and packaged assets
web/src/pages/*.test.tsx, web/e2e/dashboard.spec.ts, src/gateway/static/dashboard/..., docs/public/otari.postman_collection.json
Adds unit, integration, component, and end-to-end coverage and updates the committed dashboard assets and API examples.

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

Possibly related PRs

  • mozilla-ai/otari#130 — Restructures pipeline request-context logic extended here with asynchronous allow-list resolution.
  • mozilla-ai/otari#273 — Also changes batch creation authorization and ownership handling.
  • mozilla-ai/otari#318 — Adds related per-key model-access plumbing used by user inheritance and narrowing rules.

Suggested reviewers: tbille, khaledosman, dpoulopoulos

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title is relevant, but it does not use an approved Conventional Commit prefix and is slightly over the stated length. Use an approved prefix such as feat: and trim the title under ~70 characters, e.g. feat: add budget and user management with model access.
Docstring Coverage ⚠️ Warning Docstring coverage is 4.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description follows the template with Description, PR Type, Relevant issues, Checklist, and AI Usage sections filled in.
Linked Issues check ✅ Passed The changes cover the budget, user, and key-management requirements, including user-default model access and owner selection, for [#305 #300 #301].
Out of Scope Changes check ✅ Passed No clear out-of-scope changes stand out; the asset rebuilds, docs, migrations, and tests all align with the stated feature work.
✨ 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/dashboard-budgets
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/dashboard-budgets

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 requested a review from khaledosman July 20, 2026 16:32
…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>
@njbrake
njbrake temporarily deployed to integration-tests July 20, 2026 16:39 — with GitHub Actions Inactive

@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: 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 win

Revert mutations to shared entities to avoid test pollution.

By patching the shared "default" user to blocked = True in 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...finally block or an addfinalizer cleanup fixture to restore the blocked status to False once 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 value

Consider 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 win

Solid coverage — mocking fetch per 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

text won't follow external value changes after mount.

text seeds from value only once via useState(value). If this component is ever reused where the parent resets or programmatically changes value after mount (e.g. an edit flow), the displayed text would go stale since ComboBox's inputValue doesn't auto-sync with prop updates — you'd need to sync it yourself (e.g. a useEffect keyed on value). Not an issue today since CreateKeyForm only ever updates userId through this component's own onChange, 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 win

Extract the "apikey-" virtual-user check into one shared helper. Both new components reimplement the exact same user_id.startsWith("apikey-") predicate that already exists in KeysPage.tsx's isVirtualUser, 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 shared isVirtualUser(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

📥 Commits

Reviewing files that changed from the base of the PR and between 4fe10c1 and edaf86b.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (40)
  • alembic/versions/c9f3a1b5d7e2_add_budget_name.py
  • alembic/versions/d1e4b8c6f0a3_add_user_allowed_models.py
  • src/gateway/api/routes/_passthrough.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/batches.py
  • src/gateway/api/routes/budgets.py
  • src/gateway/api/routes/keys.py
  • src/gateway/api/routes/models.py
  • src/gateway/api/routes/users.py
  • src/gateway/models/entities.py
  • src/gateway/repositories/users_repository.py
  • src/gateway/services/bootstrap_service.py
  • src/gateway/services/model_access.py
  • src/gateway/static/dashboard/assets/index-B7c8vlYS.js
  • src/gateway/static/dashboard/assets/index-BDzQSGi5.css
  • src/gateway/static/dashboard/assets/index-CGfT4huk.js
  • src/gateway/static/dashboard/assets/index-DXv57ubR.css
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_budget_dashboard.py
  • tests/integration/test_model_access_user_layer.py
  • tests/integration/test_passthrough_enforcement.py
  • tests/integration/test_user_budget.py
  • tests/unit/test_gateway_bootstrap.py
  • tests/unit/test_model_access.py
  • tests/unit/test_pipeline_vision_billing.py
  • tests/unit/test_shared_helpers_pure.py
  • web/e2e/dashboard.spec.ts
  • web/src/App.tsx
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/components/AppShell.tsx
  • web/src/components/ModelScopeControl.tsx
  • web/src/components/UserComboBox.tsx
  • web/src/components/UserMultiSelect.tsx
  • web/src/pages/BudgetsPage.test.tsx
  • web/src/pages/BudgetsPage.tsx
  • web/src/pages/KeysPage.test.tsx
  • web/src/pages/KeysPage.tsx
  • web/src/pages/UsersPage.test.tsx
  • web/src/pages/UsersPage.tsx

Comment thread src/gateway/api/routes/budgets.py Outdated
Comment thread src/gateway/repositories/users_repository.py Outdated
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>
@njbrake
njbrake temporarily deployed to integration-tests July 21, 2026 00:33 — with GitHub Actions Inactive
…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>
@njbrake
njbrake temporarily deployed to integration-tests July 21, 2026 00:40 — with GitHub Actions Inactive

@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.

🧹 Nitpick comments (1)
src/gateway/repositories/users_repository.py (1)

24-54: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make _revive a standard synchronous function.

Fantastic work using the begin_nested savepoint here! It elegantly handles the race condition without blowing away the caller's previously staged writes—a really clean, robust solution.

Since _revive just mutates an attribute on the object in memory and doesn't perform any database I/O, we can safely drop the async/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

📥 Commits

Reviewing files that changed from the base of the PR and between b235832 and e345b2a.

📒 Files selected for processing (2)
  • src/gateway/api/routes/budgets.py
  • src/gateway/repositories/users_repository.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/gateway/api/routes/budgets.py

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

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_models and enforce “key may only narrow user default” (subset-on-write), with shared runtime enforcement via resolve_request_allowlist.
  • Add budget name, budget usage rollups + reset-log endpoint, switch ownerless keys (including bootstrap) to the shared default user, 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 default user. This makes the generated API docs misleading across multiple endpoints; update the route docstrings (chat/audio/embeddings/images/rerank/batches/moderations) and regenerate openapi.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",

Comment thread src/gateway/api/routes/budgets.py
Comment thread src/gateway/api/routes/budgets.py
Comment thread docs/public/otari.postman_collection.json
Comment thread docs/public/otari.postman_collection.json
Comment thread src/gateway/api/routes/keys.py

@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.

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.

Comment thread src/gateway/services/model_access.py
Comment thread web/src/pages/UsersPage.tsx
Comment thread web/src/pages/BudgetsPage.tsx
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>
@njbrake
njbrake temporarily deployed to integration-tests July 21, 2026 09:54 — with GitHub Actions Inactive
@coderabbitai
coderabbitai Bot requested a review from khaledosman July 21, 2026 09:55
@njbrake
njbrake temporarily deployed to integration-tests July 21, 2026 10:05 — with GitHub Actions Inactive
@coderabbitai
coderabbitai Bot requested a review from dpoulopoulos July 21, 2026 10:06
@njbrake
njbrake merged commit eac679e into main Jul 21, 2026
14 checks passed
@njbrake
njbrake deleted the feat/dashboard-budgets branch July 21, 2026 10:12
njbrake added a commit that referenced this pull request Jul 21, 2026
…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>
@njbrake njbrake mentioned this pull request Jul 22, 2026
4 tasks
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.

Dashboard: budget management & monitoring Dashboard: API key management page Dashboard: user management page

3 participants