Skip to content

feat(usage): ingest external usage events for API-equivalent cost tracking - #392

Merged
njbrake merged 6 commits into
mainfrom
fix-378
Jul 23, 2026
Merged

feat(usage): ingest external usage events for API-equivalent cost tracking#392
njbrake merged 6 commits into
mainfrom
fix-378

Conversation

@njbrake

@njbrake njbrake commented Jul 23, 2026

Copy link
Copy Markdown
Member

Description

Adds an external usage ingestion API so subscription-backed usage (Claude Code first) shows up in Otari at API-equivalent cost, plus a general per-key budget-exemption flag the same usage rides on.

POST /v1/usage/external-events: content-free batches (token counts and metadata only; any prompt/completion/tool field is rejected with 422 at both the event and the batch envelope, never stored), idempotent on (source, source_event_id), priced at the effective rate for each event's timestamp (historical pricing, Anthropic cache read/write) or cost: null when the model is unpriced. Authenticated with a budget-exempt API key (usage binds to the key's user and stamps its id) or the master key (may name any user). source is a generic provenance dimension; claude_code is the first source.

POST /v1/logs and POST /v1/traces: OTLP receivers, so an OpenTelemetry export can point straight at Otari with no collector or sidecar. The logs signal recognizes Claude Code (api_request) and Codex (codex.sse_event / codex.api_request); the traces signal maps any app instrumented with the GenAI semantic conventions (gen_ai.*, with otari.client_name as provenance). Both protobuf and JSON payloads are accepted, optionally gzipped, with body/event caps and 4xx responses for malformed bodies so exporters never retry-loop. Only content-free usage attributes are read; prompts, tool results, the event's own cost_usd (Otari re-prices at its own rate), and user identity are ignored. Usage binds to the import key's user; the master key is refused (it has no user to bind to), and rejected events are reported through the OTLP partialSuccess response. Token conventions are normalized per emitter: Claude Code's additive cache counts are booked at the 1-hour write rate, while OpenAI-shaped emitters' cached tokens are de-included from input so cache reads are never double-charged.

General exclude_from_budget flag on API keys: imported usage is retrospective and can never be blocked, so an import key must be budget-exempt (a budgeted key is refused with 403). The same flag governs a key's live proxied traffic, which is logged with cost but never reserved, folded into User.spend, or gated by require_pricing. Implemented by threading a counts_toward_budget decision through ReservationHandle and gating the spend write in reconcile_reservation, so every billable site (chat, messages, responses, passthrough, batches, streaming, vision, attachment top-up) inherits it.

Read surface and dashboard: a source filter and a by_source breakdown across list, count, summary, and CSV; an unpriced_requests count in the summary so a $0 total is not read as free; an API key column on the Activity page, with source and session in the expanded detail; a Tracked cost total (with an unpriced-request count) and an API key filter on the Usage page, keeping Spend for the enforced ledger; an Exempt from budget control (under Advanced) and a Budget-exempt badge on the Keys page. One reversible migration adds the columns and a (source, source_event_id) unique constraint; existing rows backfill to source = gateway.

Docs: OpenTelemetry is the single documented import path, with dedicated setup guides for Claude Code and Codex (point the telemetry at Otari with an exempt key); external-usage.md covers the endpoints, attribute mapping, pricing, and the budget-exempt invariant. Deferred to follow-up issues: per-model and per-request exemption, a subscription-versus-API break-even view, more vendor special-cases, and OTLP over gRPC.

PR Type

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

Relevant issues

Closes #378

Note: #378 explicitly deferred a full OTLP receiver from the first version. This PR ships one anyway (replacing the issue's collector-plus-importer-script criterion) because it removes the sidecar/collector operational burden and the same mapping generalizes to Codex and any GenAI-instrumented app. Flagging the scope change for the record rather than treating it as incidental.

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

AI Usage

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

AI Model/Tool used: Claude Code (Claude Opus 4.8, review hardening by Claude Fable 5)

Any additional AI details you'd like to share:

The scope, design decisions, and prioritization are @njbrake's, worked out through back-and-forth; the implementation and prose were written by Claude. The change was driven end to end against a live gateway: real claude -p sessions reporting over OpenTelemetry, unpriced turns landing with cost: null and priced turns computing full cost including cache, with a non-exempt key correctly refused and User.spend unchanged throughout. A six-agent review pass then hardened the OTLP receiver (gzip and size-bound handling, master-key refusal, partialSuccess reporting) and ingestion edge cases, with tests for each fix.

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

  • Added an “external usage” import API for subscription-backed usage, with strict batch validation, event deduplication, and attribution to users/sources.
  • Added OTLP ingestion endpoints (logs and traces) to import Claude Code, Codex, and GenAI telemetry into the same usage system, supporting JSON/protobuf and gzip, with partial-success reporting for rejected records.
  • Introduced a budget-exempt option for API keys: imported usage (and exempt-key live gateway traffic) is recorded for analytics but does not reserve budget, affect User.spend, enforcement, or pricing gating.
  • Expanded usage APIs, CSV exports, and dashboard analytics to filter and break down results by source and API key, show whether items were priced, and include counts of unpriced requests.
  • Updated database schema (migration), added integration/unit tests, refreshed documentation and Postman collection, and regenerated dashboard frontend assets.

Technical notes

  • External/OTLP events are content-free and deduplicated by (source, source_event_id).
  • Pricing is computed using the configured rate effective at each event’s timestamp (including Anthropic cache read/write pricing); if pricing is missing, cost is left unset and counted as “unpriced.”
  • Existing gateway usage is backfilled/kept as source = gateway, and imported events cannot claim the gateway source.
  • Budget-exempt behavior propagates through request settlement and usage logging via a per-record “counts toward budget” flag, ensuring isolation from reservation/spend/enforcement even for live gateway traffic.

@github-actions github-actions Bot added the missing-template PR is missing required template sections label Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 076c6003-285a-4f7a-b732-95d18538257a

📥 Commits

Reviewing files that changed from the base of the PR and between 5483fd1 and 5946f8d.

📒 Files selected for processing (2)
  • docs/use-with-claude-code.md
  • docs/use-with-codex.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/use-with-codex.md
  • docs/use-with-claude-code.md

Walkthrough

Changes

Usage persistence and budget accounting

Layer / File(s) Summary
Schema and budget settlement
alembic/versions/..., src/gateway/models/..., src/gateway/services/..., src/gateway/api/routes/...
Adds provenance and budget-exclusion fields, idempotency constraints, exempt-key handling, and budget-counting propagation through request, streaming, vision, passthrough, and batch flows.
External usage ingestion and analytics
src/gateway/services/external_usage_service.py, src/gateway/api/routes/usage.py, tests/integration/...
Adds validated, attributed, historically priced, deduplicated external-event ingestion and source/API-key-aware usage reporting.
OTLP ingestion
src/gateway/api/routes/otlp.py, src/gateway/api/main.py, tests/integration/test_otlp.py
Adds standalone OTLP traces/logs endpoints with JSON, protobuf, gzip, GenAI, Claude Code, Codex, authentication, and partial-success handling.
Dashboard and documentation surfaces
web/src/..., docs/..., src/gateway/static/dashboard/...
Adds budget-exempt key controls, API-key usage filters, provenance/session display, tracked-cost and unpriced totals, integration documentation, API examples, and rebuilt dashboard assets.

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

Possibly related PRs

Suggested reviewers: khaledosman

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning #378 is mostly covered, but the PR ships a full OTLP receiver despite the issue explicitly deferring that scope. Split OTLP receiver support into a follow-up and keep this PR limited to the external-events ingestion path described in #378.
Out of Scope Changes check ⚠️ Warning The OTLP /v1/logs and /v1/traces receiver work is outside the linked issue's first-version scope. Move OTLP receiver code and docs to a separate PR, or remove them here so the change stays within #378.
Docstring Coverage ⚠️ Warning Docstring coverage is 10.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title is descriptive, related to the change, and follows a Conventional Commit-style feat prefix.
Description check ✅ Passed The description matches the repository template and fills the required sections with concrete details.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-378
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix-378

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 23, 2026 18:04
@github-actions github-actions Bot removed the missing-template PR is missing required template sections label Jul 23, 2026

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

🧹 Nitpick comments (3)
docs/use-with-claude-code.md (1)

159-165: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Avoid rescanning the entire transcript tree every three seconds.

Each loop rereads every transcript from the beginning, while seen grows for the lifetime of the process. Long-running users will incur increasing disk/CPU work and memory usage. Track per-file offsets or use a file-watching mechanism instead.

🤖 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 `@docs/use-with-claude-code.md` around lines 159 - 165, Update the transcript
polling loop around turns() to avoid rescanning the entire transcript tree on
every iteration. Track each transcript file’s read offset, or use the existing
file-watching mechanism if available, so only newly appended events are
processed while preserving the current backlog exclusion and seen-event
behavior.
tests/integration/test_require_pricing.py (1)

99-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the assertion to match the documented expectation.

The docstring claims the call "fails as a provider error, never a 402," but the test only asserts != 402. Asserting the actual expected status (e.g. == 502) would verify the documented behavior precisely, instead of just ruling out one code.

🧪 Suggested tightening
-    assert resp.status_code != 402
+    assert resp.status_code == 502
🤖 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_require_pricing.py` around lines 99 - 116, Update
test_budget_exempt_key_skips_pricing_gate to assert the documented
provider-error status, 502, instead of merely checking that the response is not
402; preserve the existing request setup and exemption behavior.
src/gateway/api/routes/batches.py (1)

293-307: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding integration coverage for batch budget-exemption.

test_exclude_from_budget.py covers chat completions but I don't see a batch-specific counterpart in this cohort exercising create_batch/retrieve_batch_results with an exempt key end-to-end (reservation skipped, spend untouched, counts_toward_budget=False on the logged row). Given the exactly-once accounting logic here is fairly intricate, a dedicated test would be a good safety net.

Also applies to: 343-371

🤖 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/api/routes/batches.py` around lines 293 - 307, The batch
budget-exemption flow around create_batch and retrieve_batch_results lacks
end-to-end integration coverage. Add a dedicated test mirroring
test_exclude_from_budget.py that uses an exclude_from_budget key to create and
retrieve a batch, verifies reservation is skipped, user spend remains unchanged,
and the logged cost row records counts_toward_budget=False while preserving
exactly-once accounting.
🤖 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/use-with-claude-code.md`:
- Around line 153-157: Update ship to call urlopen with a finite timeout and
retry transient HTTP or network failures using backoff, without terminating the
watcher. Preserve unseen events across failed uploads and remove or advance them
only after a successful acknowledgment.
- Around line 104-117: Update the “Get a budget-exempt import key” section to
explicitly state that exclude_from_budget also bypasses gateway reservations,
spend updates, and budget enforcement, so it is not import-only. Instruct
readers to use a dedicated user and key exclusively for imports, restrict
distribution to the importer, and handle the credential as a sensitive secret;
do not present it as a generally shareable key.
- Around line 180-186: Update the always-on pipeline guidance near the Claude
Code telemetry instructions to explicitly require OTEL_LOGS_EXPORTER=otlp and
document the OTLP endpoint and protocol settings alongside
CLAUDE_CODE_ENABLE_TELEMETRY=1. Keep the existing Collector and external-events
mapping guidance unchanged.
- Around line 159-164: Update the outbound loop around turns() so unseen events
are split into batches within the ingestion endpoint’s bounded limit. Send each
chunk independently, and only add that chunk’s source_event_id values to seen
after its ship call succeeds; preserve already-processed events and retry
behavior for failed chunks.

In `@src/gateway/services/external_usage_service.py`:
- Around line 54-69: Update the validation definitions for source_event_id,
provider, model, and session_label in the event model to enforce a bounded
identifier-safe grammar, allowing only the intended identifier/label characters
and lengths while rejecting arbitrary prompt, response, or payload text before
persistence. Preserve the existing required/optional semantics and length
bounds, and do not include sensitive values in errors or logs.
- Around line 313-345: Refactor the event-processing flow around
_resolve_target_user and _resolve_pricing to eliminate per-event database
queries: collect distinct target user IDs and provider/model pricing keys first,
fetch active users with a single IN-based query, and load the applicable pricing
histories in bulk. Resolve user validity and timestamp-effective pricing from
those in-memory results while building rows, preserving rejection, duplicate
handling, and pricing behavior.

In `@src/gateway/static/dashboard/assets/UsagePage-CR8SlxkC.js`:
- Line 1: The cost display paths in UsagePage’s `we`, `O`, and `Y` functions
incorrectly format null costs as zero. Preserve null pricing as `—` or
“Unpriced” in summary cards, tooltips, and source/model rows, and exclude null
costs from cost totals and percentage calculations so their bars have no
misleading width; retain existing formatting for numeric costs.

In `@web/src/pages/ActivityPage.tsx`:
- Around line 363-370: Add provenance display to the activity records near the
source filter: update the activity table or its expanded RequestDetail rendering
to show each entry’s entry.source or source_label, while preserving the existing
source filtering behavior. Use the existing activity row/detail symbols and
sourceLabel helper where applicable so gateway and imported records are visually
distinguishable.

In `@web/src/pages/KeysPage.test.tsx`:
- Around line 300-324: Add a test alongside the existing budget-exemption tests
that uses mockApi with an existing key, opens its edit flow, enables the “Exempt
this key from budget” toggle, and saves the key. Inspect the captured PATCH
request to /v1/keys and assert its JSON body contains exclude_from_budget set to
true, following the existing create test’s request-matching pattern.

In `@web/src/pages/UsagePage.test.tsx`:
- Around line 38-41: Add a user-facing test around the by_source fixture that
verifies the “Cost by source” section renders the friendly “Claude Code” label,
clicks it, and asserts the resulting navigation includes source=claude_code. Use
the existing UsagePage test setup and preserve the current fixture data.

---

Nitpick comments:
In `@docs/use-with-claude-code.md`:
- Around line 159-165: Update the transcript polling loop around turns() to
avoid rescanning the entire transcript tree on every iteration. Track each
transcript file’s read offset, or use the existing file-watching mechanism if
available, so only newly appended events are processed while preserving the
current backlog exclusion and seen-event behavior.

In `@src/gateway/api/routes/batches.py`:
- Around line 293-307: The batch budget-exemption flow around create_batch and
retrieve_batch_results lacks end-to-end integration coverage. Add a dedicated
test mirroring test_exclude_from_budget.py that uses an exclude_from_budget key
to create and retrieve a batch, verifies reservation is skipped, user spend
remains unchanged, and the logged cost row records counts_toward_budget=False
while preserving exactly-once accounting.

In `@tests/integration/test_require_pricing.py`:
- Around line 99-116: Update test_budget_exempt_key_skips_pricing_gate to assert
the documented provider-error status, 502, instead of merely checking that the
response is not 402; preserve the existing request setup and exemption behavior.
🪄 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 Plus

Run ID: 130251bf-3751-4f8d-9abf-05ca645a4d50

📥 Commits

Reviewing files that changed from the base of the PR and between 06ea099 and f568527.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (45)
  • TODOS.md
  • alembic/versions/b8c1d2e3f4a5_add_usage_source_and_budget_exclusion.py
  • docs/api-reference.md
  • docs/external-usage.md
  • docs/use-with-claude-code.md
  • src/gateway/api/routes/_passthrough.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/batches.py
  • src/gateway/api/routes/keys.py
  • src/gateway/api/routes/usage.py
  • src/gateway/models/entities.py
  • src/gateway/services/budget_service.py
  • src/gateway/services/external_usage_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-BYun6blj.js
  • src/gateway/static/dashboard/assets/ActivityPage-C0gVlZW-.js
  • src/gateway/static/dashboard/assets/AliasesPage-91aYO2TQ.js
  • src/gateway/static/dashboard/assets/BudgetsPage-BAkOGUnL.js
  • src/gateway/static/dashboard/assets/KeysPage-BhG598Pa.js
  • src/gateway/static/dashboard/assets/KeysPage-vrCpn4_2.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DEMY0vFB.js
  • src/gateway/static/dashboard/assets/ModelsPage-uB0xWqdk.js
  • src/gateway/static/dashboard/assets/OverviewPage-BcmQl9vA.js
  • src/gateway/static/dashboard/assets/ProvidersPage-Jyzddv9l.js
  • src/gateway/static/dashboard/assets/SettingsPage-B-7cjmL2.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-Dp2kIXPX.js
  • src/gateway/static/dashboard/assets/UsagePage-BVzIiui8.js
  • src/gateway/static/dashboard/assets/UsagePage-CR8SlxkC.js
  • src/gateway/static/dashboard/assets/UsersPage-5a0iIZCd.js
  • src/gateway/static/dashboard/assets/index-CSyrpBqZ.js
  • src/gateway/static/dashboard/assets/index-DcR_oS0q.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_exclude_from_budget.py
  • tests/integration/test_external_usage_events.py
  • tests/integration/test_require_pricing.py
  • tests/integration/test_usage_endpoint.py
  • tests/unit/test_pipeline_vision_billing.py
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/KeysPage.test.tsx
  • web/src/pages/KeysPage.tsx
  • web/src/pages/OverviewPage.test.tsx
  • web/src/pages/UsagePage.test.tsx
  • web/src/pages/UsagePage.tsx
💤 Files with no reviewable changes (4)
  • src/gateway/static/dashboard/assets/UsagePage-BVzIiui8.js
  • src/gateway/static/dashboard/assets/KeysPage-BhG598Pa.js
  • src/gateway/static/dashboard/assets/ActivityPage-C0gVlZW-.js
  • src/gateway/static/dashboard/assets/index-CSyrpBqZ.js

Comment thread docs/use-with-claude-code.md Outdated
Comment thread docs/use-with-claude-code.md Outdated
Comment thread docs/use-with-claude-code.md Outdated
Comment thread docs/use-with-claude-code.md Outdated
Comment thread src/gateway/services/external_usage_service.py Outdated
Comment thread src/gateway/services/external_usage_service.py
Comment thread src/gateway/static/dashboard/assets/UsagePage-CR8SlxkC.js Outdated
Comment thread web/src/pages/ActivityPage.tsx Outdated
Comment thread web/src/pages/KeysPage.test.tsx
Comment thread web/src/pages/UsagePage.test.tsx Outdated
@njbrake

njbrake commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

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

@coderabbitai thanks for the review. All 10 findings are addressed in cc0f8df:

  • N+1 (external_usage_service.py): removed. Users are now validated with a single IN query, and pricing is loaded per distinct (provider, model) once, then the effective row is resolved in memory per event timestamp. Historical and cache pricing behavior is unchanged (integration tests confirm).
  • Metadata grammar: source_event_id, provider, model, and session_label are constrained to a token-only pattern so prose cannot be smuggled into the content-free fields.
  • Unpriced shown as $0.00: added unpriced_requests to the summary totals; the dashboard Tracked cost tile now shows an "N unpriced" hint so a $0 total is not read as free.
  • Provenance on activity records: the Source column was intentionally dropped earlier (the API key column carries provenance), so source and session are now shown in the expanded request detail instead.
  • Docs, budget-exempt key: warns that the flag also exempts live gateway traffic, so it must be treated as a secret and scoped to a dedicated import key/user.
  • Docs, sidecar: now uses a finite urlopen timeout, catches transient errors while preserving unseen events, and chunks batches to the 1000-event cap.
  • Docs, OTel: specifies OTEL_LOGS_EXPORTER=otlp plus endpoint/protocol, since api_request rides the logs signal.
  • Tests: added coverage for the key edit-toggle PATCH (exclude_from_budget) and the by_source drill-down navigation.

The one I did not change structurally is the per-group unpriced count in the breakdown; I kept it at the totals level to avoid threading a new field through the top-N fold, which felt heavier than the Minor rating warranted. Happy to reconsider if you disagree.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --no-ext-diff --stat --oneline cc0f8df3
printf '\n--- changed files ---\n'
git diff-tree --no-commit-id --name-only -r cc0f8df3

printf '\n--- focused diff ---\n'
git show --no-ext-diff --format= -- \
  src/gateway/services/external_usage_service.py \
  src/gateway/api/routes/usage.py \
  web/src/pages/UsagePage.tsx \
  web/src/pages/ActivityPage.tsx \
  docs/external-usage.md \
  docs/use-with-claude-code.md \
  tests/integration/test_external_usage_events.py \
  web/src/pages/KeysPage.test.tsx \
  web/src/pages/UsagePage.test.tsx \
  cc0f8df3

printf '\n--- service outline ---\n'
ast-grep outline src/gateway/services/external_usage_service.py --items all --view expanded

printf '\n--- relevant symbols and tests ---\n'
rg -n -C 3 \
  'unpriced_requests|token-only|pattern=|source_event_id|session_label|select\(.*User|Pricing|IN \(|by_source|Source|session|urlopen|timeout|OTEL_LOGS_EXPORTER|exclude_from_budget' \
  src/gateway/services/external_usage_service.py \
  src/gateway/api/routes/usage.py \
  web/src/pages/UsagePage.tsx \
  web/src/pages/ActivityPage.tsx \
  docs/external-usage.md \
  docs/use-with-claude-code.md \
  tests/integration/test_external_usage_events.py \
  web/src/pages/KeysPage.test.tsx \
  web/src/pages/UsagePage.test.tsx

Length of output: 91846


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- pricing service outline ---'
ast-grep outline src/gateway/services/pricing_service.py --items all --view expanded

printf '%s\n' '--- pricing resolution implementation ---'
rg -n -A100 -B12 \
  '^(async )?def (find_model_pricing|default_model_pricing|normalize_effective_at)\b' \
  src/gateway/services/pricing_service.py

printf '%s\n' '--- external resolver and historical pricing test ---'
sed -n '160,230p' src/gateway/services/external_usage_service.py
sed -n '280,320p' tests/integration/test_external_usage_events.py

printf '%s\n' '--- pricing service test candidates ---'
fd -t f -i 'pricing' tests src | sort | while IFS= read -r file; do
  printf '\n### %s\n' "$file"
  rg -n -C 2 \
    'find_model_pricing|default_model_pricing|effective_at|legacy|model_key' \
    "$file" || true
done

Length of output: 58779


@njbrake Verified cc0f8df3—the follow-up addresses the findings.

In particular, the bulk resolver preserves find_model_pricing behavior exactly: canonical key → legacy key → optional default pricing, with the same normalized timestamp comparison. The user lookup and pricing retrieval are now batch-bounded rather than per-event.

I also confirmed the validation, unpriced total/UI disclosure, Activity expanded provenance, documentation hardening, and the added PATCH/drilldown coverage.

I agree with keeping unpriced counts at the totals level. It fixes the misleading “$0 means free” interpretation without adding disproportionate complexity to the folded breakdown API.

🐇 ✅


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

Thank you for using CodeRabbit!

@njbrake
njbrake removed the request for review from khaledosman July 23, 2026 18:39
@njbrake
njbrake marked this pull request as draft July 23, 2026 18:39
…cking

Squashed rebase of fix-378 onto main. External usage ingestion
(POST /v1/usage/external-events), OTLP receivers (/v1/logs, /v1/traces)
for Claude Code, Codex, and GenAI-instrumented apps, a per-key
exclude_from_budget flag threaded through every billable site, a
source-aware read surface and dashboard, and review hardening (gzip and
size bounds, master-key refusal, partialSuccess reporting, batch-envelope
extra-field rejection, collision-retry fallback, exempt-batch accounting).

Rebase resolutions: migration re-parented onto a9c1e3b5d7f9 (dashboard
sessions) to keep a single Alembic head; docs/index.md bullets re-slotted
into main's sectioned list; UsagePage keeps main's sparkline chart plus
the unpriced-count hint; dashboard bundle rebuilt against main's recharts
dependency; OpenAPI regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 21:03 — with GitHub Actions Inactive
@njbrake
njbrake marked this pull request as ready for review July 23, 2026 21:03
@njbrake
njbrake requested a review from khaledosman July 23, 2026 21:03
@coderabbitai
coderabbitai Bot requested a review from tbille July 23, 2026 21:05
The Cost by source card and the Activity Source filter were removed in
earlier iterations, leaving UsageFilters.source, the hooks query param,
UsageSummary.by_source, and BreakdownTable's columnLabel/formatKey props
with no consumer. Delete them; the backend API keeps its source filter
and by_source breakdown, and the Activity detail still shows provenance
via source/source_label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 21:06 — 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: 3

🧹 Nitpick comments (1)
web/src/pages/UsagePage.tsx (1)

67-86: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing "Spend by source" wiring is the root cause behind both the unused BreakdownTable props and the missing drill-down test. columnLabel/formatKey were added to support a friendly per-source breakdown (mirroring sourceLabel in ActivityPage.tsx), and by_source data is fetched and fixtured in tests, but no BreakdownTable call actually renders it — so there's nothing for a "Cost by source" test to click on either, despite a past-review thread marking that test as already addressed.

  • web/src/pages/UsagePage.tsx#L67-L86: wire up a third BreakdownTable (e.g. "Spend by source") using data?.by_source, a sourceLabel-style formatKey, and adjust the breakdowns grid at L455-472 accordingly — or confirm this was intentionally dropped and remove the now-dead columnLabel/formatKey props if so.
  • web/src/pages/UsagePage.test.tsx#L37-L40: once the source breakdown is wired up, add the drill-down test the earlier review asked for (click "Claude Code", assert navigation includes source=claude_code).
🤖 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 67 - 86, Wire a third
BreakdownTable in web/src/pages/UsagePage.tsx around lines 67-86 using
data?.by_source, a sourceLabel-style formatKey, and the appropriate “Spend by
source” labeling; update the breakdowns grid around lines 455-472 to include it.
In web/src/pages/UsagePage.test.tsx around lines 37-40, add coverage that clicks
“Claude Code” and asserts navigation includes source=claude_code.
🤖 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/static/dashboard/assets/ModelsPage-DUKmygcZ.js`:
- Line 1: Update the current-price selection in gt so models with only future
effective_at entries do not select a[0] as active. Restrict the fallback to rows
whose effective_at is at or before the supplied timestamp i; leave the model
without an active rate when no such row exists, while preserving existing
sorting and selection for eligible rows.

In `@src/gateway/static/dashboard/assets/UsagePage-BiTBt0H6.js`:
- Line 1: Update the UsagePage empty-state message and the ActivityPage page
description to acknowledge that data may come from externally ingested events as
well as gateway-served requests. In
src/gateway/static/dashboard/assets/UsagePage-BiTBt0H6.js:1-1, revise the “No
usage yet” text; in
src/gateway/static/dashboard/assets/ActivityPage-gqHNxdk2.js:1-1, revise the
page description. Keep the existing behavior unchanged.
- Line 1: Complete source-aware dashboard reporting in UsagePage’s Ue component
by adding source to filter state, query parameters, controls, and clear-filter
handling, and render the API’s per-source summary alongside the existing model
and user breakdowns. In ActivityPage-gqHNxdk2.js, update the Activity filter
state and request-parameter construction to include source. Apply these changes
at UsagePage-BiTBt0H6.js:1-1 and ActivityPage-gqHNxdk2.js:1-1.

---

Nitpick comments:
In `@web/src/pages/UsagePage.tsx`:
- Around line 67-86: Wire a third BreakdownTable in web/src/pages/UsagePage.tsx
around lines 67-86 using data?.by_source, a sourceLabel-style formatKey, and the
appropriate “Spend by source” labeling; update the breakdowns grid around lines
455-472 to include it. In web/src/pages/UsagePage.test.tsx around lines 37-40,
add coverage that clicks “Claude Code” and asserts navigation includes
source=claude_code.
🪄 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 Plus

Run ID: 976687e3-8b78-44b7-95e9-582223ccfb22

📥 Commits

Reviewing files that changed from the base of the PR and between f568527 and cc33356.

⛔ Files ignored due to path filters (2)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
  • uv.lock is excluded by !**/*.lock, !**/uv.lock
📒 Files selected for processing (50)
  • TODOS.md
  • alembic/versions/b8c1d2e3f4a5_add_usage_source_and_budget_exclusion.py
  • docs/api-reference.md
  • docs/external-usage.md
  • docs/index.md
  • docs/use-with-claude-code.md
  • docs/use-with-codex.md
  • pyproject.toml
  • src/gateway/api/main.py
  • src/gateway/api/routes/_passthrough.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/batches.py
  • src/gateway/api/routes/keys.py
  • src/gateway/api/routes/otlp.py
  • src/gateway/api/routes/usage.py
  • src/gateway/models/entities.py
  • src/gateway/services/budget_service.py
  • src/gateway/services/external_usage_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-gqHNxdk2.js
  • src/gateway/static/dashboard/assets/AliasesPage-CI0LrDEg.js
  • src/gateway/static/dashboard/assets/BudgetsPage-zaCyrkoB.js
  • src/gateway/static/dashboard/assets/KeysPage-CoSXJN5o.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DSH2_jCr.js
  • src/gateway/static/dashboard/assets/ModelsPage-DUKmygcZ.js
  • src/gateway/static/dashboard/assets/OverviewPage-D1H6fN9J.js
  • src/gateway/static/dashboard/assets/ProvidersPage-_OHjx3AR.js
  • src/gateway/static/dashboard/assets/SettingsPage-DvoxV4gJ.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-YLfgPmBP.js
  • src/gateway/static/dashboard/assets/UsagePage-BiTBt0H6.js
  • src/gateway/static/dashboard/assets/UsersPage-Byt0myM4.js
  • src/gateway/static/dashboard/assets/index-BNKS2gvW.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_exclude_from_budget.py
  • tests/integration/test_external_usage_events.py
  • tests/integration/test_otlp.py
  • tests/integration/test_require_pricing.py
  • tests/integration/test_usage_endpoint.py
  • tests/integration/test_usage_summary.py
  • tests/unit/test_pipeline_vision_billing.py
  • tests/unit/test_usage_timestamp_serialization.py
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/KeysPage.test.tsx
  • web/src/pages/KeysPage.tsx
  • web/src/pages/OverviewPage.test.tsx
  • web/src/pages/OverviewPage.tsx
  • web/src/pages/UsagePage.test.tsx
  • web/src/pages/UsagePage.tsx
🚧 Files skipped from review as they are similar to previous changes (19)
  • web/src/pages/OverviewPage.test.tsx
  • src/gateway/static/dashboard/index.html
  • TODOS.md
  • tests/integration/test_usage_endpoint.py
  • tests/integration/test_require_pricing.py
  • web/src/pages/KeysPage.test.tsx
  • tests/integration/test_exclude_from_budget.py
  • web/src/api/hooks.ts
  • docs/api-reference.md
  • alembic/versions/b8c1d2e3f4a5_add_usage_source_and_budget_exclusion.py
  • src/gateway/api/routes/_passthrough.py
  • src/gateway/models/entities.py
  • web/src/pages/KeysPage.tsx
  • docs/external-usage.md
  • src/gateway/services/budget_service.py
  • src/gateway/api/routes/batches.py
  • tests/integration/test_external_usage_events.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/services/external_usage_service.py

Comment thread src/gateway/static/dashboard/assets/ModelsPage-DUKmygcZ.js Outdated
Comment thread src/gateway/static/dashboard/assets/UsagePage-BiTBt0H6.js Outdated
main added the postman-check CI gate after this branch was cut; the
rebase brought the check in without the regenerated collection covering
/v1/usage/external-events, /v1/logs, and /v1/traces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 21:14 — with GitHub Actions Inactive
- Cap token and duration fields at the 32-bit usage_logs column width so
  an absurd count is a 422, not a database error that 500s the batch.
- Chunk the candidate-user IN query like the event-id and pricing
  lookups; a full batch with per-event user_ids could exceed SQLite's
  bind-variable limit.
- Reserve source "gateway": a REST import claiming it is refused with a
  422, and an OTLP client name sanitizing to it falls back to "otel",
  so imported rows can never masquerade as native traffic.
- Correct the migration docstring header to match the re-parented
  down_revision; document the global (per-source, not per-user)
  idempotency scope.

Verified end to end against a live gateway with real clients: claude -p
over http/protobuf and http/json, codex exec via its [otel] config, with
per-event costs matching seeded rates and User.spend unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 21:50 — with GitHub Actions Inactive
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… gateway

The same call would land twice (source=gateway enforced, source=claude_code
or codex exempt); budgets stay correct but cost analytics double-count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 22:56 — with GitHub Actions Inactive
@njbrake

njbrake commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

As mentioned to Khaled over Slack: merging in order to give time for testing and stabilizition before I head of on vaca.

@njbrake
njbrake merged commit bf90e94 into main Jul 23, 2026
14 checks passed
@njbrake
njbrake deleted the fix-378 branch July 23, 2026 22:59
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.

feat: ingest external usage events for API-equivalent cost tracking

1 participant