Skip to content

feat(usage): aggregated usage + cost analytics endpoint (per provider/harness/model; admin all-users) - #4

Draft
marko-k0 wants to merge 2 commits into
mainfrom
feat/usage-cost-analytics
Draft

feat(usage): aggregated usage + cost analytics endpoint (per provider/harness/model; admin all-users)#4
marko-k0 wants to merge 2 commits into
mainfrom
feat/usage-cost-analytics

Conversation

@marko-k0

Copy link
Copy Markdown

Draft. Backend-only first cut of a usage & cost analytics API. Lets a developer see their own spend and an admin see spend across all users, broken down by provider, by harness, and by model over a time window.

Endpoint

GET /v1/usage/summary?period={today|7days|30days}&group_by={provider|harness|model}&user={<id>|all}UsageSummaryResponse:

  • window totals (total_input_tokens, total_output_tokens, total_cost_usd)
  • groups[]{group_key, input_tokens, output_tokens, total_cost_usd} (sorted by cost desc)
  • buckets[] — per-day time series (per-hour for today)

Requirement mapping

  • Per provider / harness / modelgroup_by (provider derived from the model id; by_model walked so multi-model sessions split correctly).
  • User sees their own spend → default scope = caller's owned conversations.
  • Admin sees all usersusers.is_admin may pass ?user=<id> or ?user=all; non-admins get 403; single-user mode rejects ?user with 400.

Design notes (for review)

  • No double-counting: sums each conversation's OWN per-node session_usage blob, not load_session_usage (which subtree-sums and would fold a sub-agent's spend into every ancestor). All conversation kinds included as disjoint spend. Verified end-to-end on SQLite (parent + sub-agent + sibling → correct totals).
  • Owner attribution: session_permissions LEVEL_OWNER (matches get_session_owner_id).
  • Harness: harness_override else the bound agent's canonicalized spec harness (trimmed _resolve_harness), cached per agent_id per request.
  • Store method added: list_usage_records(start_epoch, end_epoch, owner_user_id) (time-window + owner-scoped SELECT, per-node blob parse) — query stays in the store layer.

Scope / caveats

  • Backend only. Web UI (user Usage panel + admin dashboard) is a deliberate follow-up.
  • Cost fidelity: uses the display total_cost_usd. For subscription-auth harnesses this is a notional figure (subscriptions don't bill per token) — the UI should label it as an estimate.
  • Bucketing is by conversation created_at (session_usage is cumulative-at-node, no per-turn timestamps); switch to updated_at in the store SELECT if last-activity attribution is preferred.
  • Admin source: uses the DB users.is_admin flag (like the session-list route), not the file-backed admin_list. Inject admin_list if parity with /auth/me is wanted.
  • Rows with cost but no by_model breakdown bucket under "unknown" for provider/model grouping (spend never dropped).

Validation

  • ruff check on all touched files: pass. mypy on the new route + store interface: 0 errors (pre-existing sqlalchemy_store.py findings are outside the added block). Not yet run against the full project test suite — please run CI/lint locally.
  • Base is main; retarget to your build branch (e.g. staging) if that's where this should land.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/XL Pull request size: XL label Jul 24, 2026
marko-k0 pushed a commit that referenced this pull request Jul 24, 2026
…3061)

* feat(projects): first-class projects in the web sidebar

Wires the web app to the first-class projects entity (omnigent-ai#2765/omnigent-ai#3053), keeping
the legacy omni_project label path working via dual-read so no migration is
forced. Folders are keyed by name (the union key that merges a first-class
project and a like-named label-project into one folder), carrying the
first-class id when one exists.

Backend
- GET /v1/sessions/projects now dual-reads: unions first-class projects
  (project_store.list — incl. empty, with id) and legacy label-projects
  (id=None), merged by name and sorted. Response shape list[str] →
  list[{id, name}]; still owner-scoped. openapi.json regenerated.

Frontend
- projectsApi.ts: typed /v1/projects CRUD client (list/create/rename/delete).
- Hooks: useProjects → ProjectSummary[] ({id, name}); new useCreateProject,
  useRenameProject; reworked useDeleteProject (archive + unfile every member,
  then delete the container). Filing/moving files via project_id, resolving
  the picked name to an id and creating the first-class row on demand for a
  label-only folder; "" unfiles. Conversation.project_id added.
- Sidebar: folders keyed by {id, name}, members matched by project_id OR the
  legacy label; always-visible Projects section with a "New project"
  (create-empty) control extracted to NewProjectButton.tsx; Rename dialog;
  delete threads id; a row's current-project dual-reads project_id→name so a
  pinned first-class member keeps its project flyout; "Remove from project"
  unfiles silently (a first-class project persists when emptied); empty
  folders read "No sessions".
- NewChatDialog: composer files new sessions via project_id.

Tests
- projectsApi unit tests; reworked hook tests (resolve→file, create-on-demand,
  archive+unfile+delete); sidebar/composer suites updated; server union test;
  e2e_ui docstrings + fixtures updated for the project_id membership flow.

Deferred (kept on the label path via dual-read): the new-session prefill state
machine and the Settings archived-only project picker; retiring label reads is
gated on the Phase 4 backfill.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* fix(projects): rename-dialog Enter, checked promote PATCH, typed projects schema

Addresses the review on omnigent-ai#3061:

- Rename-project dialog: wrap the body in a <form> so Enter submits natively
  (Radix Dialog doesn't provide one, and the prior manual key handler looked
  for the confirm button inside the <input> and never fired).
- useRenameProject label-only promote: check res.ok on each re-file PATCH and
  throw on failure, so a 4xx/5xx no longer reports success with members left
  unfiled.
- GET /v1/sessions/projects: return a typed SessionProjectSummary list instead
  of list[dict] + response_model=None, which produced an empty ("schema": {})
  OpenAPI response and broke client generation. openapi.json regenerated.
- Drop the stale test comment describing the removed last-session remove-confirm
  gate.

Copilot #2 (recreate missing metadata row) and #4 (...->NotImplementedError in
the abstract method) intentionally declined, consistent with prior rounds.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* fix(projects): keep dual-read membership coherent on move/rename; lift row lookup

Addresses the second web-UI review round on omnigent-ai#3061:

- moveConversationToProject now clears the legacy omni_project label in the same
  PATCH as it sets project_id. The sidebar groups a folder by project_id OR the
  label during the dual-read transition, so a stale label would keep a moved
  session in its old label-folder (and match two folders at once). project_id is
  the single source of truth after a move.
- useRenameProject reconciles members for BOTH paths (first-class rename and
  label-only promote): sweep the folder's members via ?project=<oldName>, re-file
  each onto the target project_id, and clear the legacy label — so a first-class
  rename no longer strands label-matched members in an oldName folder.
- resolveOrCreateProjectId tolerates the create-on-demand race: a concurrent
  move to the same new name can 409 on the second POST; re-list and use the
  winner's id instead of failing.
- ConversationRow no longer calls useProjects() per row. A list-level
  id->name map is provided via context (ProjectNamesContext), so row renders are
  O(1) with no per-row query observer.

Test PATCH-body assertions updated for the added labels field.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* fix(projects): preserve the original error when create-on-demand truly fails

resolveOrCreateProjectId caught the create error to tolerate the 409 race
(a concurrent move created the same name), but a genuine 500/network failure
was indistinguishable and surfaced as a generic "Could not resolve or create"
message. Re-list to disambiguate: if the row now exists a racer won — use it;
otherwise rethrow the ORIGINAL error so the true cause isn't masked.

Addresses a non-blocking note on omnigent-ai#3061.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* test(e2e-ui): stub /v1/sessions/projects with the {id,name} shape in prefill test

The project-prefill e2e test stubbed GET /v1/sessions/projects with the old
bare-string body, but this PR changed the endpoint to return
SessionProjectSummary objects. The sidebar parsed no folder, so the project
header never rendered and header.hover() timed out.

Return the dual-read union shape ({id: None, name} for the label-only project
the test seeds), matching the endpoint contract and the sibling sidebar tests.

Co-authored-by: Isaac

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
@marko-k0
marko-k0 force-pushed the feat/usage-cost-analytics branch from 1b9614e to 8d16b01 Compare July 28, 2026 15:56
marko-k0 and others added 2 commits July 28, 2026 16:35
Adds GET /v1/usage/summary — rolls up token usage and USD spend across a
caller's conversations (or, for admins, any user's or all users'), broken
down by provider, harness, or model over today/7days/30days, with a
per-day (per-hour for today) time series.

- Sums each conversation's OWN per-node session_usage blob (not the
  subtree-summed load_session_usage, which would double-count parent +
  sub-agent). Every conversation kind is included as disjoint spend.
- Owner scope via session_permissions LEVEL_OWNER; admin (users.is_admin)
  may target ?user=<id> or ?user=all, non-admins get 403; single-user
  mode rejects ?user with 400.
- Provider derived from model id (vendor/model prefix; by_model walked so
  multi-model sessions split correctly). Harness from harness_override
  else the bound agent's canonicalized spec harness.
- New store method list_usage_records(start_epoch, end_epoch,
  owner_user_id); UsageGroupEntry/UsageBucketEntry/UsageSummaryResponse
  schemas; router wired in app.py under /v1.

Backend only; web UI (user panel + admin dashboard) is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…shboard)

Adds the "Usage" settings section that renders GET /v1/usage/summary:
KPI tiles (total cost, input/output tokens), a spend-over-time bar chart,
and a per-group table (provider / harness / model) with a share-of-total
bar. Window (Today / 7d / 30d) and grouping map to the query params.

- Admins get a scope selector (own / all users / per-user drill-down);
  the control is chrome-gated via the mode-agnostic useIsAdmin probe, and
  the server enforces the `user` scope regardless. Per-user list degrades
  gracefully to me/all when listUsers is unavailable (header/OIDC modes).
- Cost labelled as an estimate — subscription-auth harnesses aren't billed
  per token.
- Dashboard is lazy-loaded so the settings shell stays lean.

New: lib/usageApi.ts (typed client + formatters), hooks/useUsageSummary.ts,
components/usage/{UsageDashboard,UsageChart}.tsx. Wires the section into
SettingsPage + settingsNav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marko-k0
marko-k0 force-pushed the feat/usage-cost-analytics branch from 8d16b01 to fc23cc0 Compare July 28, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant