Skip to content

feat(pricing): meter cache writes and long context - #379

Merged
njbrake merged 8 commits into
mainfrom
feat/provider-metered-pricing
Jul 23, 2026
Merged

feat(pricing): meter cache writes and long context#379
njbrake merged 8 commits into
mainfrom
feat/provider-metered-pricing

Conversation

@njbrake

@njbrake njbrake commented Jul 22, 2026

Copy link
Copy Markdown
Member

Description

Adds provider-metered usage pricing for cache reads, regular cache writes, Anthropic one-hour cache writes, and request-level long-context price tiers. Usage records now retain billing meters and a pricing breakdown for auditability.

The standalone dashboard now compares base and effective rates at a selected context size, presents compact cache and tier policies, supports inline tier editing, and avoids repeating the provider instance in model labels.

PR Type

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

Relevant issues

Closes #357
Closes #372
Refs #376
Refs mozilla-ai/any-llm#1195

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

Validation: make lint, make typecheck, uv run pytest tests/unit (857 passed, 1 skipped), npm --prefix web run typecheck, npm --prefix web test (195 passed), dashboard production build, and OpenAPI freshness check all pass. Integration tests could not run locally because Docker is unavailable.

AI Usage

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

AI Model/Tool used: Codex

Any additional AI details you would like to share: Implementation and validation were performed interactively with maintainer direction.

  • I am an AI Agent filling out this form

Summary

  • Added flexible, tiered pricing for long-context requests, plus separate pricing for standard cache writes and Anthropic “one-hour” cache writes.
  • Extended usage logging and usage/analytics endpoints to store cache-write hour metrics and keep an auditable breakdown of how charges were computed.
  • Updated pre-dispatch budgeting/estimation to reserve and price cache-write tokens correctly based on both the cache-write TTL (5m vs 1h) and the applicable context tier.
  • Enhanced the dashboard to compare base vs effective rates at a selected context size, show cache and pricing-policy details more compactly, support inline editing of pricing tiers, display “1h cache writes,” and avoid repeating provider names in model labels.
  • Updated pricing configuration, API schemas, and documentation; added/extended migrations and validation; regenerated dashboard assets and refreshed related frontend/backend tests and response contracts.

Technical notes

  • The “one-hour” cache-write rate falls back to the regular cache-write rate when not configured, while explicitly configured zero rates remain free.
  • Pricing tiers act as whole-request thresholds (a tier applies to the entire request once its threshold is reached).
  • The change also includes stricter validation for pricing tiers (e.g., no duplicate thresholds; tiers must override at least one price field).
  • Unit tests, dashboard tests, lint/type checks, OpenAPI freshness, and production build passed; integration tests could not be run locally because Docker was unavailable.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds tiered pricing, one-hour cache-write rates and usage tracking, centralized metered cost calculation with billing breakdowns, expanded pricing and usage APIs, and dashboard editing, comparison, and reporting support.

Changes

Pricing contracts and persistence

Layer / File(s) Summary
Pricing contracts and persistence
src/gateway/core/config.py, src/gateway/models/entities.py, src/gateway/api/routes/pricing.py, src/gateway/services/*, alembic/versions/*, web/src/api/types.ts, docs/configuration.md
Pricing tiers and one-hour cache-write rates are validated, persisted, exposed through APIs, initialized from configuration and defaults, documented, and added to frontend types.

Usage extraction and cost calculation

Layer / File(s) Summary
Usage extraction and cost calculation
src/gateway/services/metered_pricing.py, src/gateway/api/routes/_pipeline.py, src/gateway/api/routes/messages.py, src/gateway/core/usage.py, src/gateway/streaming.py, src/gateway/services/budget_service.py
Provider usage is normalized into billable meters, cache-write TTL is forwarded to estimates, and calculated costs return persisted meter totals and pricing breakdown lines.

Usage analytics and validation

Layer / File(s) Summary
Usage analytics and validation
src/gateway/api/routes/usage.py, tests/integration/*, tests/unit/*
Usage responses aggregate one-hour cache writes and expose billing metadata, while tests cover tier validation, persistence, estimation, cache extraction, and pricing behavior.

Dashboard pricing and usage experience

Layer / File(s) Summary
Dashboard pricing and usage experience
web/src/pages/*, src/gateway/static/dashboard/*
The dashboard adds tier editing, context-based price comparison, cache and billed-meter displays, one-hour usage totals, updated activity errors, and regenerated frontend assets.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • mozilla-ai/otari#376 — Covers the broader provider-metered pricing engine implemented by this change.
  • mozilla-ai/any-llm#1195 — Relates to preserving normalized Anthropic 5-minute and 1-hour cache-write usage fields.

Possibly related PRs

  • mozilla-ai/otari#356 — Provides the preceding cached-token pricing plumbing extended here with one-hour rates and metered breakdowns.
  • mozilla-ai/otari#196 — Previously extended gateway usage and cache-token reporting used by this change.
  • mozilla-ai/otari#345 — Overlaps with the usage analytics response and totals implementation.

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 uses a scoped form instead of one of the required prefixes. Change it to start with the required Conventional Commit prefix, for example: feat: meter cache writes and long context.
Docstring Coverage ⚠️ Warning Docstring coverage is 9.57% 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 required sections are present and mostly filled in, with only minor non-critical gaps.
Linked Issues check ✅ Passed The PR implements 1h vs 5m cache-write metering and tiered long-context pricing, matching [#357,#372].
Out of Scope Changes check ✅ Passed No obvious unrelated code changes stand out; the bundle/hash updates and docs/tests support the pricing and dashboard 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/provider-metered-pricing
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/provider-metered-pricing

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 tbille July 22, 2026 20:25
njbrake added 3 commits July 22, 2026 20:26
Track Anthropic 1-hour cache writes, preserve tiered context rates, and expose auditable pricing controls and usage breakdowns.\n\nRefs #376
Make cache and long-context pricing policy visible in the model table while preserving full selectors for editing and routing.
@coderabbitai
coderabbitai Bot requested a review from khaledosman July 22, 2026 20:26
@njbrake
njbrake force-pushed the feat/provider-metered-pricing branch from 14ab324 to 855f095 Compare July 22, 2026 20:27
@njbrake
njbrake had a problem deploying to integration-tests July 22, 2026 20:27 — with GitHub Actions Failure

@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 (2)
web/src/pages/ModelsPage.test.tsx (1)

647-759: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mock apiFetch rather than global fetch.

These new cases continue through mockApi(), which replaces globalThis.fetch. Mock the apiFetch boundary instead so dashboard tests stay aligned with the supported API contract and are less coupled to its transport implementation. As per coding guidelines, “mock only the apiFetch network boundary, not hooks.”

🤖 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/ModelsPage.test.tsx` around lines 647 - 759, Update the tests
in ModelsPage.test.tsx to mock the apiFetch boundary instead of relying on
mockApi() to replace globalThis.fetch. Adjust the relevant test setup and POST
request assertions to use the supported apiFetch mock while preserving the
existing pricing behavior and expectations; do not mock hooks or the underlying
transport.

Source: Coding guidelines

src/gateway/core/config.py (1)

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

Nice addition, but consider requiring at least one override per tier.

PricingTierConfig happily accepts a tier with only min_input_tokens set and no actual price overrides — a silent no-op tier that a config author might not notice is doing nothing. The dashboard's validTiers() in ModelsPage.tsx already enforces "at least one override" client-side; mirroring that here would catch the same mistake for anyone authoring config.yml directly.

♻️ Optional validator
+    `@model_validator`(mode="after")
+    def validate_has_override(self) -> "PricingTierConfig":
+        fields = (
+            self.input_price_per_million,
+            self.output_price_per_million,
+            self.cache_read_price_per_million,
+            self.cache_write_price_per_million,
+            self.cache_write_1h_price_per_million,
+        )
+        if all(f is None for f in fields):
+            raise ValueError("pricing tier must override at least one price field")
+        return self
🤖 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/core/config.py` around lines 78 - 87, Update PricingTierConfig to
validate that at least one price override field is provided, covering input,
output, cache-read, cache-write, and cache-write-1h prices; reject tiers
containing only min_input_tokens while preserving the existing individual field
constraints.
🤖 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/services/pricing_service.py`:
- Line 247: Update the default_model_pricing() caveat in
src/gateway/services/pricing_service.py at lines 247-247 to state that default
pricing retains tier breakpoints instead of flattening to a base rate. Revise
the limitation in docs/configuration.md at lines 297-312 consistently, removing
or correcting the claim that imported tiers are billed at the base rate.

In `@src/gateway/static/dashboard/assets/ActivityPage-HAV6P_tZ.js`:
- Line 1: Update the source component that renders the expanded activity entry,
specifically the Pe component and its error_message display/copy behavior, so
raw provider errors are never rendered or passed to Te. Replace them with a
sanitized generic error message while retaining raw diagnostics server-side,
then rebuild the generated ActivityPage asset.

In `@web/src/pages/ModelsPage.tsx`:
- Around line 1101-1109: Update PricingPolicyCell to sort row.pricingTiers by
the raw tier.min_input_tokens values before applying formatContext, then build
thresholds from that ordered result. Preserve the existing labels and tier-count
display while ensuring thresholds spanning K/M boundaries appear in numeric
ascending order.

---

Nitpick comments:
In `@src/gateway/core/config.py`:
- Around line 78-87: Update PricingTierConfig to validate that at least one
price override field is provided, covering input, output, cache-read,
cache-write, and cache-write-1h prices; reject tiers containing only
min_input_tokens while preserving the existing individual field constraints.

In `@web/src/pages/ModelsPage.test.tsx`:
- Around line 647-759: Update the tests in ModelsPage.test.tsx to mock the
apiFetch boundary instead of relying on mockApi() to replace globalThis.fetch.
Adjust the relevant test setup and POST request assertions to use the supported
apiFetch mock while preserving the existing pricing behavior and expectations;
do not mock hooks or the underlying transport.
🪄 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: ae71560e-81cb-4de9-b286-59d24979324c

📥 Commits

Reviewing files that changed from the base of the PR and between e5bdbb8 and 14ab324.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (43)
  • alembic/versions/7d9e1f3a5b7c_add_metered_pricing_fields.py
  • docs/configuration.md
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/messages.py
  • src/gateway/api/routes/models.py
  • src/gateway/api/routes/pricing.py
  • src/gateway/api/routes/usage.py
  • src/gateway/core/config.py
  • src/gateway/core/usage.py
  • src/gateway/models/entities.py
  • src/gateway/services/metered_pricing.py
  • src/gateway/services/pricing_init_service.py
  • src/gateway/services/pricing_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-Dv1t5m0G.js
  • src/gateway/static/dashboard/assets/ActivityPage-HAV6P_tZ.js
  • src/gateway/static/dashboard/assets/AliasesPage-BWvbF6Ai.js
  • src/gateway/static/dashboard/assets/BudgetsPage-CgMXKHu3.js
  • src/gateway/static/dashboard/assets/KeysPage-DyFw8uY7.js
  • src/gateway/static/dashboard/assets/ModelComboBox-CF4wm-YK.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-_-_rnmkv.js
  • src/gateway/static/dashboard/assets/ModelsPage-Bp26JK8Z.js
  • src/gateway/static/dashboard/assets/ModelsPage-CCg9xDDB.js
  • src/gateway/static/dashboard/assets/OverviewPage-BbByzEFd.js
  • src/gateway/static/dashboard/assets/ProvidersPage-Cls9MgL7.js
  • src/gateway/static/dashboard/assets/SettingsPage-D6ZrjyKX.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-D9fLjk2y.js
  • src/gateway/static/dashboard/assets/UsagePage-BiFDzqgv.js
  • src/gateway/static/dashboard/assets/UsagePage-Cf1iUtft.js
  • src/gateway/static/dashboard/assets/UsersPage-Bq_wHrmP.js
  • src/gateway/static/dashboard/assets/index-CSaFgccK.js
  • src/gateway/static/dashboard/assets/index-DtlrpKAk.css
  • src/gateway/static/dashboard/index.html
  • src/gateway/streaming.py
  • tests/integration/test_pricing_budget_validation.py
  • tests/integration/test_pricing_config.py
  • tests/unit/test_compute_cost.py
  • tests/unit/test_default_model_pricing.py
  • tests/unit/test_usage_cache_tokens.py
  • web/src/api/types.ts
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/ModelsPage.test.tsx
  • web/src/pages/ModelsPage.tsx
  • web/src/pages/UsagePage.tsx
💤 Files with no reviewable changes (3)
  • src/gateway/static/dashboard/assets/UsagePage-Cf1iUtft.js
  • src/gateway/static/dashboard/assets/ModelsPage-CCg9xDDB.js
  • src/gateway/static/dashboard/assets/ActivityPage-Dv1t5m0G.js

Comment thread src/gateway/services/pricing_service.py
@@ -0,0 +1 @@
import{j as e}from"./tanstack-query-W9y7rsMr.js";import{u as re,r as n}from"./react-q-ooZ0ti.js";import{u as le,a as ne,b as ie,c as oe,P as ce,E as de,F as G,d as Y}from"./index-CSaFgccK.js";import{T as ue,a as me,b as d,L as xe,c as he,d as pe,e as u}from"./Table-DEsIhjZo.js";import{B as j,S as ge}from"./heroui-CewI8xK4.js";const je=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function _(s){return s===null?"—":je.format(s)}function o(s){return s===null?"—":s.toLocaleString()}function Z(s){return s===null?"—":s<1e3?`${s} ms`:`${(s/1e3).toFixed(s<1e4?2:1)} s`}function fe(s){const a=new Date(s);return Number.isNaN(a.getTime())?s:a.toLocaleString()}function ve(s){const a=new Date(s).getTime();if(Number.isNaN(a))return s;const r=Math.max(0,Math.round((Date.now()-a)/1e3));if(r<60)return`${r}s ago`;const i=Math.round(r/60);if(i<60)return`${i}m ago`;const m=Math.round(i/60);return m<24?`${m}h ago`:`${Math.round(m/24)}d ago`}const be=3600,w=86400,Se=[{label:"Last hour",seconds:be},{label:"24h",seconds:w},{label:"7d",seconds:7*w},{label:"All",seconds:null}],Ne=[{label:"All",value:""},{label:"Success",value:"success"},{label:"Error",value:"error"}],_e=["/v1/chat/completions","/v1/messages","/v1/responses","/v1/embeddings","/v1/moderations","/v1/audio/transcriptions","/v1/audio/speech","/v1/images/generations","/v1/rerank","/v1/batches","/v1/batches/results"],S=50;function E(s){return new Date(Date.now()-s*1e3).toISOString()}function we({status:s}){const a=s==="error"?"border-red-200 bg-red-50 text-red-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return e.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${a}`,children:s})}async function ke(s,a=navigator.clipboard){if(!a)return!1;try{return await a.writeText(s),!0}catch{return!1}}function Te({text:s}){const[a,r]=n.useState(!1);return e.jsx(j,{size:"sm",variant:"ghost",onPress:async()=>{await ke(s)&&(r(!0),window.setTimeout(()=>r(!1),1500))},children:a?"Copied":"Copy"})}function l({label:s,children:a}){return e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:s}),e.jsx("span",{className:"text-sm text-[var(--otari-ink)] break-all",children:a})]})}function Pe({entry:s}){var a;return e.jsxs("div",{className:"flex flex-col gap-4 px-4 py-4",children:[s.error_message?e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Error (provider-reported)"}),e.jsx(Te,{text:s.error_message})]}),e.jsx("pre",{className:"max-h-48 overflow-auto rounded-lg border border-red-200 bg-red-50 p-3 text-xs whitespace-pre-wrap break-all text-red-700",children:s.error_message})]}):null,e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2 lg:grid-cols-4",children:[e.jsx(l,{label:"Provider",children:s.provider??"—"}),e.jsx(l,{label:"Endpoint",children:s.endpoint}),e.jsx(l,{label:"User",children:s.user_id??"—"}),e.jsx(l,{label:"API key",children:s.api_key_id??"—"}),e.jsx(l,{label:"Prompt tokens",children:o(s.prompt_tokens)}),e.jsx(l,{label:"Completion tokens",children:o(s.completion_tokens)}),e.jsx(l,{label:"Total tokens",children:o(s.total_tokens)}),e.jsx(l,{label:"Cost",children:_(s.cost)}),e.jsx(l,{label:"Cache read tokens",children:o(s.cache_read_tokens)}),e.jsx(l,{label:"Cache write tokens",children:o(s.cache_write_tokens)}),e.jsx(l,{label:"1h cache writes",children:o(s.cache_write_1h_tokens??null)}),e.jsx(l,{label:"Total time",children:Z(s.latency_ms)}),e.jsx(l,{label:"Request ID",children:s.id})]}),(a=s.pricing_breakdown)!=null&&a.length?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Billed meters"}),e.jsx("div",{className:"grid gap-2 sm:grid-cols-2 lg:grid-cols-3",children:s.pricing_breakdown.map(r=>e.jsxs(l,{label:r.meter.replaceAll("_"," "),children:[o(r.units)," at ",_(r.rate_per_million)," / 1M, ",_(r.cost)]},r.meter))})]}):null]})}const D=7;function $e(){var q,z,y;const s=le(),[a]=re(),r=a.get("start_date")??void 0,[i,m]=n.useState(r?null:w),[N,k]=n.useState(()=>r??E(w)),[x,A]=n.useState(()=>a.get("status")??""),[c,M]=n.useState(()=>a.get("model")??""),[h,$]=n.useState(""),[p,U]=n.useState(()=>a.get("user_id")??""),[f,T]=n.useState(0),[J,K]=n.useState(null),P=n.useMemo(()=>({start_date:N,status:x||void 0,model:c.trim()||void 0,endpoint:h||void 0,user_id:p||void 0}),[N,x,c,h,p]);n.useEffect(()=>{T(0)},[P]);const g=ne(P,f,S),v=ie(P),Q=n.useMemo(()=>({start_date:N,status:x||void 0,endpoint:h||void 0,user_id:p||void 0}),[N,x,h,p]),F=((z=(q=oe(Q,"day").data)==null?void 0:q.by_model)==null?void 0:z.filter(t=>!t.is_other&&t.key!==null).map(t=>t.key))??[],b=g.data??[],C=((y=v.data)==null?void 0:y.total)??0,L=!!(x||c.trim()||h||p||i!==null),V=t=>{m(t),k(t===null?void 0:E(t))},W=()=>{m(null),k(void 0),A(""),M(""),$(""),U("")},X=()=>{i!==null&&k(E(i)),g.refetch(),v.refetch()},I=b.length>0,O=I?f*S+1:0,R=f*S+b.length,B=v.isSuccess&&!v.isPlaceholderData,ee=B?C===0?"0 of 0":`${O}–${R} of ${C.toLocaleString()}`:I?`${O}–${R}`:"0",se=B?(f+1)*S<C:b.length===S;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ce,{title:"Activity",description:"A per-request log of what the gateway served: tokens, cost, latency, and failures. No request or response content is stored.",action:e.jsx(j,{variant:"outline",onPress:X,isDisabled:g.isFetching,children:"Refresh"})}),e.jsx(de,{error:g.error??v.error}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx("div",{className:"flex flex-wrap gap-2",children:Se.map(t=>e.jsx(j,{size:"sm",variant:i===t.seconds?"primary":"outline",onPress:()=>V(t.seconds),children:t.label},t.label))}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(G,{id:"filter-status",label:"Status",value:x,onChange:A,children:Ne.map(t=>e.jsx("option",{value:t.value,children:t.label},t.value))}),e.jsxs(G,{id:"filter-endpoint",label:"Endpoint",value:h,onChange:$,children:[e.jsx("option",{value:"",children:"All endpoints"}),_e.map(t=>e.jsx("option",{value:t,children:t},t))]}),e.jsx(Y,{label:"User",value:p,onChange:U,placeholder:"All users",options:(s.data??[]).map(t=>({value:t.user_id,label:t.alias?`${t.alias} (${t.user_id})`:t.user_id}))}),e.jsx(Y,{label:"Model",value:c,onChange:M,allowsCustom:!0,placeholder:"Any model",options:(c&&!F.includes(c)?[c,...F]:F).map(t=>({value:t,label:t}))}),L?e.jsx(j,{size:"sm",variant:"ghost",onPress:W,children:"Clear filters"}):null]})]}),e.jsxs(ue,{children:[e.jsx(me,{children:e.jsxs("tr",{children:[e.jsx(d,{children:"Time"}),e.jsx(d,{children:"User"}),e.jsx(d,{children:"Model"}),e.jsx(d,{className:"text-right",children:"Tokens"}),e.jsx(d,{className:"text-right",children:"Cost"}),e.jsx(d,{className:"text-right",children:"Total time"}),e.jsx(d,{children:"Status"})]})}),e.jsx("tbody",{children:g.isLoading?e.jsx(xe,{colSpan:D}):b.length===0?e.jsx(he,{colSpan:D,children:L?"No requests match these filters.":"No requests recorded yet."}):b.map(t=>{const te=t.status==="error",H=J===t.id;return e.jsxs(n.Fragment,{children:[e.jsxs(pe,{selected:H,className:te?"bg-red-50":"",onClick:()=>K(ae=>ae===t.id?null:t.id),children:[e.jsx(u,{className:"text-[var(--otari-muted)]",children:e.jsx("span",{title:fe(t.timestamp),children:ve(t.timestamp)})}),e.jsx(u,{className:"text-[var(--otari-ink)]",children:t.user_id??"—"}),e.jsx(u,{className:"text-[var(--otari-ink)]",children:t.model}),e.jsx(u,{className:"text-right tabular-nums",children:o(t.total_tokens)}),e.jsx(u,{className:"text-right tabular-nums",children:_(t.cost)}),e.jsx(u,{className:"text-right tabular-nums",children:Z(t.latency_ms)}),e.jsx(u,{children:e.jsx(we,{status:t.status})})]}),H?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:D,children:e.jsx(Pe,{entry:t})})}):null]},t.id)})})]}),e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("span",{className:"inline-flex items-center gap-2 text-sm text-[var(--otari-muted)]",children:[ee,g.isFetching?e.jsx(ge,{size:"sm"}):null]}),e.jsxs("span",{className:"inline-flex gap-1.5",children:[e.jsx(j,{size:"sm",variant:"outline",isDisabled:f===0,onPress:()=>T(t=>Math.max(0,t-1)),children:"Previous"}),e.jsx(j,{size:"sm",variant:"outline",isDisabled:!se,onPress:()=>T(t=>t+1),children:"Next"})]})]})]})}export{$e as ActivityPage,ke as copyToClipboard};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not render raw provider errors in the dashboard.

error_message is displayed and copied verbatim. Provider responses can include sensitive request details or credentials; expose only a sanitized generic message and retain raw diagnostics server-side. Fix the source component and rebuild this generated asset.

🤖 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/static/dashboard/assets/ActivityPage-HAV6P_tZ.js` at line 1,
Update the source component that renders the expanded activity entry,
specifically the Pe component and its error_message display/copy behavior, so
raw provider errors are never rendered or passed to Te. Replace them with a
sanitized generic error message while retaining raw diagnostics server-side,
then rebuild the generated ActivityPage asset.

Source: Coding guidelines

Comment thread web/src/pages/ModelsPage.tsx
@njbrake
njbrake had a problem deploying to integration-tests July 22, 2026 20:38 — with GitHub Actions Failure
@njbrake

njbrake commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Addressed the current CodeRabbit findings in 156e798:

  • reserve long-context and explicitly requested Anthropic cache-write charges before provider dispatch
  • reject no-op pricing tiers and correct tiered-default documentation
  • sanitize provider diagnostics in the dashboard activity detail
  • order context thresholds numerically and update the dashboard tests to mock apiFetch

Also updated the five stale exact-response integration assertions that caused the previous CI failure. Local validation: 870 unit tests passed, dashboard tests passed, lint, type checks, OpenAPI freshness, and dashboard build passed. CodeRabbit is currently rate-limited for a follow-up review.

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 provider-metered pricing support for cache reads/writes (including Anthropic 1-hour TTL) and whole-request long-context price tiers, persisting billing meters and a per-request pricing breakdown for auditing. The dashboard is updated to surface these new meters and pricing policies.

Changes:

  • Introduces tier-aware, meter-based cost calculation and persists billing_meters + pricing_breakdown on UsageLog.
  • Extends pricing/config/models/APIs to support cache_write_1h_price_per_million and pricing_tiers.
  • Updates the dashboard UI + tests to display/edit tier + TTL pricing and show 1-hour cache-write usage.

Reviewed changes

Copilot reviewed 46 out of 50 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
web/src/pages/UsagePage.tsx Adds a “1h cache write” stat tile to usage analytics.
web/src/pages/ModelsPage.test.tsx Updates mocks and expands coverage for TTL + tier pricing UI behavior.
web/src/pages/ActivityPage.tsx Avoids exposing provider diagnostics; displays 1h cache writes and pricing breakdown.
web/src/pages/ActivityPage.test.tsx Updates assertions to ensure provider error details are not exposed.
web/src/api/types.ts Extends API types for 1h cache write rates/tokens, billing meters, tier rules, and breakdown lines.
tests/unit/test_usage_cache_tokens.py Adds coverage for TTL detection and Anthropic 1h cache-write usage extraction.
tests/unit/test_default_model_pricing.py Ensures genai-prices tier data is preserved into pricing tiers.
tests/unit/test_compute_cost.py Adds tests for tier “whole-request cliff” semantics and 1h cache-write pricing.
tests/unit/test_budget_cost_estimate.py Adds unit tests for threshold-aware reservation estimates (including cache TTL).
tests/integration/test_usage_summary.py Extends usage totals response coverage to include 1h cache write totals.
tests/integration/test_usage_endpoint.py Extends usage row response shape coverage for new billing/audit fields.
tests/integration/test_pricing_config.py Adds integration coverage for persisting tiers + 1h cache write pricing via API.
tests/integration/test_pricing_budget_validation.py Adds integration validation coverage for tier rules.
tests/integration/test_model_aliases.py Updates alias pricing surface expectations to include new pricing fields.
tests/integration/test_alias_api.py Updates alias API expectations to include new pricing fields.
src/gateway/streaming.py Merges streamed usage including the new 1h cache-write token meter.
src/gateway/services/pricing_service.py Preserves genai-prices tier thresholds and exports them as pricing tiers.
src/gateway/services/pricing_init_service.py Initializes DB pricing from config including 1h cache-write rate and tiers.
src/gateway/services/metered_pricing.py Adds canonical meters, tier rate selection, reservation estimation, and auditable charge lines.
src/gateway/services/budget_service.py Switches budget estimation to the meter-based estimator and adds cache TTL input.
src/gateway/models/entities.py Adds DB fields for tier rules and usage auditability (meters + breakdown).
src/gateway/core/usage.py Adds cache_write_1h_tokens to gateway usage and accessor helpers.
src/gateway/core/config.py Adds config models/validation for tier rules + 1h cache-write pricing.
src/gateway/api/routes/usage.py Extends usage API models and totals aggregation for new fields.
src/gateway/api/routes/pricing.py Extends pricing API schemas/storage for tiers and 1h cache-write rates.
src/gateway/api/routes/models.py Surfaces new pricing fields on model listing/details and alias pricing views.
src/gateway/api/routes/messages.py Captures Anthropic 1h cache-write usage and detects requested cache TTL for estimation.
src/gateway/api/routes/_pipeline.py Switches to metered calculator, persists breakdown, and threads TTL into reservations.
docs/public/openapi.json Regenerates OpenAPI to include new pricing/usage fields.
docs/configuration.md Documents 1h cache-write pricing and long-context tier configuration semantics.
alembic/versions/7d9e1f3a5b7c_add_metered_pricing_fields.py Adds schema migrations for new pricing and usage audit columns.
src/gateway/static/dashboard/index.html Updates built dashboard asset references.
src/gateway/static/dashboard/assets/UsersPage-BUuslhzL.js Updated built dashboard bundle output.
src/gateway/static/dashboard/assets/UsagePage-ChSRIieH.js Updated built dashboard bundle output (new usage UI).
src/gateway/static/dashboard/assets/UsagePage-CFdEpDOA.js Removes old built dashboard chunk.
src/gateway/static/dashboard/assets/ToolsGuardrailsPage-B8ikQfKj.js Updated built dashboard bundle output.
src/gateway/static/dashboard/assets/OverviewPage-BOBh0Ajn.js Updated built dashboard bundle output.
src/gateway/static/dashboard/assets/ModelsPage-Cgm8YsX7.js Removes old built dashboard chunk.
src/gateway/static/dashboard/assets/ModelScopeControl-BpXA5VMI.js Updated built dashboard bundle output.
src/gateway/static/dashboard/assets/KeysPage-CvexGYae.js Updated built dashboard bundle output.
src/gateway/static/dashboard/assets/AliasesPage-BSp3jVNj.js Updated built dashboard bundle output.
src/gateway/static/dashboard/assets/ActivityPage-DYwWFsLV.js Updated built dashboard bundle output (new activity UI).
src/gateway/static/dashboard/assets/ActivityPage-CKgTCz2o.js Removes old built dashboard chunk.
Files not reviewed (3)
  • src/gateway/static/dashboard/assets/ActivityPage-DYwWFsLV.js: Generated file
  • src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js: Generated file
  • src/gateway/static/dashboard/assets/UsagePage-ChSRIieH.js: Generated file

Comment on lines +28 to +37
class PricingTier(BaseModel):
"""Whole-request price cliff selected by total billable input tokens."""

min_input_tokens: int = Field(gt=0)
input_price_per_million: float | None = Field(default=None, ge=0)
output_price_per_million: float | None = Field(default=None, ge=0)
cache_read_price_per_million: float | None = Field(default=None, ge=0)
cache_write_price_per_million: float | None = Field(default=None, ge=0)
cache_write_1h_price_per_million: float | None = Field(default=None, ge=0)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in fc10f6c, which adds the matching per-tier override validator to the pricing API and regression coverage.

Comment thread src/gateway/services/metered_pricing.py Outdated
Comment on lines +93 to +112
input_tokens = max(estimated_input_tokens, 0.0)
output_tokens = max(estimated_output_tokens, 0)
cache_write_tokens = input_tokens if cache_write_ttl is not None else 0.0
rates = effective_rates(pricing, input_tokens + cache_write_tokens)

input_rate = rates["input_price_per_million"]
output_rate = rates["output_price_per_million"]
assert input_rate is not None
assert output_rate is not None
cost = (input_tokens * input_rate + output_tokens * output_rate) / 1_000_000

if cache_write_ttl == "1h":
cache_write_rate = rates["cache_write_1h_price_per_million"]
if cache_write_rate is None:
cache_write_rate = rates["cache_write_price_per_million"]
else:
cache_write_rate = rates["cache_write_price_per_million"]
if cache_write_rate is not None:
cost += cache_write_tokens * cache_write_rate / 1_000_000
return cost

@njbrake njbrake Jul 23, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in e1c2d4c. Conservative reservations now price potential cache-write tokens at the selected normal input rate when no cache-write rate is configured, matching final reconciliation. The regression test now asserts the doubled input charge.

@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 the full diff against the backend/security/performance and dashboard standards. Billing path is sound: fresh_input stays non-negative in both the additive (Anthropic) and subset (OpenAI) conventions, the 1h-cache-write rate fallback is correct, a configured $0 rate is preserved, and the reserve/reconcile lifecycle is unchanged. Migration is reversible with a server_default, OpenAPI + dashboard bundle regenerated, docs updated, and the unit/integration coverage hits the tricky cases (whole-request cliff, 1h vs 5m rate, $0 rate, conservative over-reservation).

Approving. Three low-severity notes inline, none blocking.

Review created by Claude Code.

output_price_per_million: float | None = Field(default=None, ge=0)
cache_read_price_per_million: float | None = Field(default=None, ge=0)
cache_write_price_per_million: float | None = Field(default=None, ge=0)
cache_write_1h_price_per_million: float | None = Field(default=None, ge=0)

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.

This API tier model lacks the "at least one rate override" check that PricingTierConfig enforces in core/config.py (validate_has_rate_override). Via POST /v1/pricing a tier of just {min_input_tokens} validates and is stored as a silent no-op at billing time. Add the matching model_validator for parity so config and API reject the same input. (Master-key only, so not a security issue.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in fc10f6c. The API now rejects no-op tiers with the same validation as config, and unit plus route coverage cover the rule.

</div>
<pre className="max-h-48 overflow-auto rounded-lg border border-red-200 bg-red-50 p-3 text-xs whitespace-pre-wrap break-all text-red-700">
{entry.error_message}
The provider returned an error. Inspect gateway logs for details.

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.

The provider-error redaction (dropping CopyButton + the verbatim error_message) is unrelated to pricing; CONTRIBUTING asks to keep diffs focused, so this reads more naturally as its own PR. Also note error_message is still returned by the /v1/usage API, so this hides it in the UI only, not at the boundary. Reasonable hardening either way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Kept this focused UI hardening because it resolves the CodeRabbit security finding: the dashboard no longer renders or copies raw provider diagnostics. Agreed that the master-key usage API remains a separate operator/API-boundary policy, so I did not broaden that change here.

user_id_from_request=str(user_from_metadata) if user_from_metadata else None,
estimate_prompt_chars=len(str(request.messages)) + len(str(request.system or "")),
estimate_max_output_tokens=request.max_tokens,
estimate_cache_write_ttl=_requested_cache_write_ttl(

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.

estimate_cache_write_ttl is wired only into the Anthropic /v1/messages path. Anthropic prompt caching driven through /v1/chat/completions won't be reflected in the up-front reservation, so those requests under-reserve (final spend still reconciles correctly). Consistent with prior behavior; flagging only as a known asymmetry.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verified before changing: ChatCompletionRequest has no cache_control field and its default Pydantic config drops unknown extras, so the OpenAI-compatible endpoint cannot request Anthropic prompt-cache creation. Native Anthropic cache controls are accepted only on /v1/messages, where the conservative reservation is applied.

@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 09:23 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 09:24 — with GitHub Actions Inactive
@coderabbitai
coderabbitai Bot requested a review from khaledosman July 23, 2026 09:24

@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

🧹 Nitpick comments (1)
src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js (1)

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

Use dashboard design tokens instead of raw colors.

The tooltip uses border-[#c2843a] and text-[#b45309]. Replace these with --otari-* tokens in the source stylesheet, then rebuild the generated bundle. As per coding guidelines, dashboard colors must use --otari-* design tokens rather than scattered literals.

🤖 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/static/dashboard/assets/ModelsPage-5SIMv0Ug.js` at line 1, The
tooltip component se uses raw warning colors in its className. Replace
border-[`#c2843a`] and text-[`#b45309`] with the appropriate --otari-* dashboard
design tokens in the source stylesheet, then rebuild the generated ModelsPage
bundle so the compiled asset reflects the token-based classes.

Source: Coding guidelines

🤖 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/ActivityPage-DYwWFsLV.js`:
- Line 1: Update the shared dashboard table-row interaction pattern so rows with
mouse onClick handlers also support keyboard activation via appropriate
focusability and keyboard semantics, preserving existing click behavior. Apply
the fix to request-detail expansion in
src/gateway/static/dashboard/assets/ActivityPage-DYwWFsLV.js:1-1, model-detail
selection in src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js:1-1, and
usage drill-down navigation in
src/gateway/static/dashboard/assets/UsagePage-ChSRIieH.js:1-1, then regenerate
all affected bundles.

In `@src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js`:
- Line 1: The model-row selection behavior in Kt currently relies only on
onClick and is inaccessible from the keyboard. Update the row selection
implementation to use a focusable interactive control or add Enter/Space
handling with appropriate focusability and selected-row semantics, while
preserving the existing onSelect behavior that opens model details.

---

Nitpick comments:
In `@src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js`:
- Line 1: The tooltip component se uses raw warning colors in its className.
Replace border-[`#c2843a`] and text-[`#b45309`] with the appropriate --otari-*
dashboard design tokens in the source stylesheet, then rebuild the generated
ModelsPage bundle so the compiled asset reflects the token-based classes.
🪄 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: 67be0a8b-fb3a-4eef-8cd7-c211d7d0be9f

📥 Commits

Reviewing files that changed from the base of the PR and between 14ab324 and e1c2d4c.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (47)
  • alembic/versions/7d9e1f3a5b7c_add_metered_pricing_fields.py
  • docs/configuration.md
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/messages.py
  • src/gateway/api/routes/models.py
  • src/gateway/api/routes/pricing.py
  • src/gateway/api/routes/usage.py
  • src/gateway/core/config.py
  • src/gateway/core/usage.py
  • src/gateway/models/entities.py
  • src/gateway/services/budget_service.py
  • src/gateway/services/metered_pricing.py
  • src/gateway/services/pricing_init_service.py
  • src/gateway/services/pricing_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-DYwWFsLV.js
  • src/gateway/static/dashboard/assets/AliasesPage-BSp3jVNj.js
  • src/gateway/static/dashboard/assets/BudgetsPage-DlL0354P.js
  • src/gateway/static/dashboard/assets/KeysPage-CvexGYae.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-BpXA5VMI.js
  • src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js
  • src/gateway/static/dashboard/assets/OverviewPage-BOBh0Ajn.js
  • src/gateway/static/dashboard/assets/ProvidersPage-DPfGIa86.js
  • src/gateway/static/dashboard/assets/SettingsPage-C0JYm9ni.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-B8ikQfKj.js
  • src/gateway/static/dashboard/assets/UsagePage-ChSRIieH.js
  • src/gateway/static/dashboard/assets/UsersPage-BUuslhzL.js
  • src/gateway/static/dashboard/assets/index-DkHDQ_jO.js
  • src/gateway/static/dashboard/assets/index-Dl_VAUga.css
  • src/gateway/static/dashboard/index.html
  • src/gateway/streaming.py
  • tests/integration/test_alias_api.py
  • tests/integration/test_model_aliases.py
  • tests/integration/test_pricing_budget_validation.py
  • tests/integration/test_pricing_config.py
  • tests/integration/test_usage_endpoint.py
  • tests/integration/test_usage_summary.py
  • tests/unit/test_budget_cost_estimate.py
  • tests/unit/test_compute_cost.py
  • tests/unit/test_default_model_pricing.py
  • tests/unit/test_pricing_schema.py
  • tests/unit/test_usage_cache_tokens.py
  • web/src/api/types.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/ModelsPage.test.tsx
  • web/src/pages/ModelsPage.tsx
  • web/src/pages/UsagePage.tsx
🚧 Files skipped from review as they are similar to previous changes (18)
  • web/src/pages/UsagePage.tsx
  • tests/unit/test_default_model_pricing.py
  • src/gateway/static/dashboard/index.html
  • src/gateway/services/pricing_init_service.py
  • tests/integration/test_pricing_budget_validation.py
  • src/gateway/services/pricing_service.py
  • src/gateway/streaming.py
  • src/gateway/core/config.py
  • src/gateway/api/routes/usage.py
  • alembic/versions/7d9e1f3a5b7c_add_metered_pricing_fields.py
  • src/gateway/core/usage.py
  • src/gateway/api/routes/messages.py
  • web/src/pages/ModelsPage.test.tsx
  • web/src/api/types.ts
  • src/gateway/models/entities.py
  • src/gateway/api/routes/pricing.py
  • src/gateway/api/routes/_pipeline.py
  • web/src/pages/ModelsPage.tsx

@@ -0,0 +1 @@
import{j as e}from"./tanstack-query-W9y7rsMr.js";import{u as le,r as n}from"./react-q-ooZ0ti.js";import{u as re,a as ne,b as ie,c as oe,P as ce,E as de,F as G,d as Y}from"./index-DkHDQ_jO.js";import{T as ue,a as me,b as d,L as xe,c as he,d as pe,e as u}from"./Table-DEsIhjZo.js";import{B as f,S as ge}from"./heroui-CewI8xK4.js";const je=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function _(t){return t===null?"—":je.format(t)}function o(t){return t===null?"—":t.toLocaleString()}function Z(t){return t===null?"—":t<1e3?`${t} ms`:`${(t/1e3).toFixed(t<1e4?2:1)} s`}function ve(t){const a=new Date(t);return Number.isNaN(a.getTime())?t:a.toLocaleString()}function be(t){const a=new Date(t).getTime();if(Number.isNaN(a))return t;const r=Math.max(0,Math.round((Date.now()-a)/1e3));if(r<60)return`${r}s ago`;const i=Math.round(r/60);if(i<60)return`${i}m ago`;const m=Math.round(i/60);return m<24?`${m}h ago`:`${Math.round(m/24)}d ago`}const fe=3600,k=86400,Ne=[{label:"Last hour",seconds:fe},{label:"24h",seconds:k},{label:"7d",seconds:7*k},{label:"All",seconds:null}],Se=[{label:"All",value:""},{label:"Success",value:"success"},{label:"Error",value:"error"}],_e=["/v1/chat/completions","/v1/messages","/v1/responses","/v1/embeddings","/v1/moderations","/v1/audio/transcriptions","/v1/audio/speech","/v1/images/generations","/v1/rerank","/v1/batches","/v1/batches/results"],N=50;function D(t){return new Date(Date.now()-t*1e3).toISOString()}function ke({status:t}){const a=t==="error"?"border-red-200 bg-red-50 text-red-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return e.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${a}`,children:t})}function l({label:t,children:a}){return e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:t}),e.jsx("span",{className:"text-sm text-[var(--otari-ink)] break-all",children:a})]})}function we({entry:t}){var a;return e.jsxs("div",{className:"flex flex-col gap-4 px-4 py-4",children:[t.error_message?e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsx("div",{className:"flex items-center justify-between",children:e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Error"})}),e.jsx("pre",{className:"max-h-48 overflow-auto rounded-lg border border-red-200 bg-red-50 p-3 text-xs whitespace-pre-wrap break-all text-red-700",children:"The provider returned an error. Inspect gateway logs for details."})]}):null,e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2 lg:grid-cols-4",children:[e.jsx(l,{label:"Provider",children:t.provider??"—"}),e.jsx(l,{label:"Endpoint",children:t.endpoint}),e.jsx(l,{label:"User",children:t.user_id??"—"}),e.jsx(l,{label:"API key",children:t.api_key_id??"—"}),e.jsx(l,{label:"Prompt tokens",children:o(t.prompt_tokens)}),e.jsx(l,{label:"Completion tokens",children:o(t.completion_tokens)}),e.jsx(l,{label:"Total tokens",children:o(t.total_tokens)}),e.jsx(l,{label:"Cost",children:_(t.cost)}),e.jsx(l,{label:"Cache read tokens",children:o(t.cache_read_tokens)}),e.jsx(l,{label:"Cache write tokens",children:o(t.cache_write_tokens)}),e.jsx(l,{label:"1h cache writes",children:o(t.cache_write_1h_tokens??null)}),e.jsx(l,{label:"Total time",children:Z(t.latency_ms)}),e.jsx(l,{label:"Request ID",children:t.id})]}),(a=t.pricing_breakdown)!=null&&a.length?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Billed meters"}),e.jsx("div",{className:"grid gap-2 sm:grid-cols-2 lg:grid-cols-3",children:t.pricing_breakdown.map(r=>e.jsxs(l,{label:r.meter.replaceAll("_"," "),children:[o(r.units)," at ",_(r.rate_per_million)," / 1M, ",_(r.cost)]},r.meter))})]}):null]})}const A=7;function Ce(){var B,z,H;const t=re(),[a]=le(),r=a.get("start_date")??void 0,[i,m]=n.useState(r?null:k),[S,w]=n.useState(()=>r??D(k)),[x,C]=n.useState(()=>a.get("status")??""),[c,M]=n.useState(()=>a.get("model")??""),[h,$]=n.useState(""),[p,U]=n.useState(()=>a.get("user_id")??""),[j,T]=n.useState(0),[J,K]=n.useState(null),P=n.useMemo(()=>({start_date:S,status:x||void 0,model:c.trim()||void 0,endpoint:h||void 0,user_id:p||void 0}),[S,x,c,h,p]);n.useEffect(()=>{T(0)},[P]);const g=ne(P,j,N),v=ie(P),Q=n.useMemo(()=>({start_date:S,status:x||void 0,endpoint:h||void 0,user_id:p||void 0}),[S,x,h,p]),F=((z=(B=oe(Q,"day").data)==null?void 0:B.by_model)==null?void 0:z.filter(s=>!s.is_other&&s.key!==null).map(s=>s.key))??[],b=g.data??[],E=((H=v.data)==null?void 0:H.total)??0,I=!!(x||c.trim()||h||p||i!==null),V=s=>{m(s),w(s===null?void 0:D(s))},W=()=>{m(null),w(void 0),C(""),M(""),$(""),U("")},X=()=>{i!==null&&w(D(i)),g.refetch(),v.refetch()},L=b.length>0,O=L?j*N+1:0,R=j*N+b.length,q=v.isSuccess&&!v.isPlaceholderData,ee=q?E===0?"0 of 0":`${O}–${R} of ${E.toLocaleString()}`:L?`${O}–${R}`:"0",se=q?(j+1)*N<E:b.length===N;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ce,{title:"Activity",description:"A per-request log of what the gateway served: tokens, cost, latency, and failures. No request or response content is stored.",action:e.jsx(f,{variant:"outline",onPress:X,isDisabled:g.isFetching,children:"Refresh"})}),e.jsx(de,{error:g.error??v.error}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx("div",{className:"flex flex-wrap gap-2",children:Ne.map(s=>e.jsx(f,{size:"sm",variant:i===s.seconds?"primary":"outline",onPress:()=>V(s.seconds),children:s.label},s.label))}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(G,{id:"filter-status",label:"Status",value:x,onChange:C,children:Se.map(s=>e.jsx("option",{value:s.value,children:s.label},s.value))}),e.jsxs(G,{id:"filter-endpoint",label:"Endpoint",value:h,onChange:$,children:[e.jsx("option",{value:"",children:"All endpoints"}),_e.map(s=>e.jsx("option",{value:s,children:s},s))]}),e.jsx(Y,{label:"User",value:p,onChange:U,placeholder:"All users",options:(t.data??[]).map(s=>({value:s.user_id,label:s.alias?`${s.alias} (${s.user_id})`:s.user_id}))}),e.jsx(Y,{label:"Model",value:c,onChange:M,allowsCustom:!0,placeholder:"Any model",options:(c&&!F.includes(c)?[c,...F]:F).map(s=>({value:s,label:s}))}),I?e.jsx(f,{size:"sm",variant:"ghost",onPress:W,children:"Clear filters"}):null]})]}),e.jsxs(ue,{children:[e.jsx(me,{children:e.jsxs("tr",{children:[e.jsx(d,{children:"Time"}),e.jsx(d,{children:"User"}),e.jsx(d,{children:"Model"}),e.jsx(d,{className:"text-right",children:"Tokens"}),e.jsx(d,{className:"text-right",children:"Cost"}),e.jsx(d,{className:"text-right",children:"Total time"}),e.jsx(d,{children:"Status"})]})}),e.jsx("tbody",{children:g.isLoading?e.jsx(xe,{colSpan:A}):b.length===0?e.jsx(he,{colSpan:A,children:I?"No requests match these filters.":"No requests recorded yet."}):b.map(s=>{const te=s.status==="error",y=J===s.id;return e.jsxs(n.Fragment,{children:[e.jsxs(pe,{selected:y,className:te?"bg-red-50":"",onClick:()=>K(ae=>ae===s.id?null:s.id),children:[e.jsx(u,{className:"text-[var(--otari-muted)]",children:e.jsx("span",{title:ve(s.timestamp),children:be(s.timestamp)})}),e.jsx(u,{className:"text-[var(--otari-ink)]",children:s.user_id??"—"}),e.jsx(u,{className:"text-[var(--otari-ink)]",children:s.model}),e.jsx(u,{className:"text-right tabular-nums",children:o(s.total_tokens)}),e.jsx(u,{className:"text-right tabular-nums",children:_(s.cost)}),e.jsx(u,{className:"text-right tabular-nums",children:Z(s.latency_ms)}),e.jsx(u,{children:e.jsx(ke,{status:s.status})})]}),y?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:A,children:e.jsx(we,{entry:s})})}):null]},s.id)})})]}),e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("span",{className:"inline-flex items-center gap-2 text-sm text-[var(--otari-muted)]",children:[ee,g.isFetching?e.jsx(ge,{size:"sm"}):null]}),e.jsxs("span",{className:"inline-flex gap-1.5",children:[e.jsx(f,{size:"sm",variant:"outline",isDisabled:j===0,onPress:()=>T(s=>Math.max(0,s-1)),children:"Previous"}),e.jsx(f,{size:"sm",variant:"outline",isDisabled:!se,onPress:()=>T(s=>s+1),children:"Next"})]})]})]})}export{Ce as ActivityPage};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add keyboard semantics to all interactive dashboard table rows.

These rows use mouse-only onClick handlers, preventing keyboard users from expanding details, opening model details, or drilling into activity. Fix the shared source pattern and regenerate the affected bundles.

  • src/gateway/static/dashboard/assets/ActivityPage-DYwWFsLV.js#L1-L1: make request-detail expansion keyboard accessible.
  • src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js#L1-L1: make model-detail selection keyboard accessible.
  • src/gateway/static/dashboard/assets/UsagePage-ChSRIieH.js#L1-L1: make usage drill-down navigation keyboard accessible.
📍 Affects 3 files
  • src/gateway/static/dashboard/assets/ActivityPage-DYwWFsLV.js#L1-L1 (this comment)
  • src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js#L1-L1
  • src/gateway/static/dashboard/assets/UsagePage-ChSRIieH.js#L1-L1
🤖 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/static/dashboard/assets/ActivityPage-DYwWFsLV.js` at line 1,
Update the shared dashboard table-row interaction pattern so rows with mouse
onClick handlers also support keyboard activation via appropriate focusability
and keyboard semantics, preserving existing click behavior. Apply the fix to
request-detail expansion in
src/gateway/static/dashboard/assets/ActivityPage-DYwWFsLV.js:1-1, model-detail
selection in src/gateway/static/dashboard/assets/ModelsPage-5SIMv0Ug.js:1-1, and
usage drill-down navigation in
src/gateway/static/dashboard/assets/UsagePage-ChSRIieH.js:1-1, then regenerate
all affected bundles.

@@ -0,0 +1 @@
import{j as e}from"./tanstack-query-W9y7rsMr.js";import{g as rt,u as nt,r as c}from"./react-q-ooZ0ti.js";import{v as at,w as st,e as lt,x as ct,P as ot,E as ut,F as E,I as dt,y as ne,z as pt,A as ht,B as xt,D as A,G as $e,H as Ee,C as Ae,h as me}from"./index-DkHDQ_jO.js";import{T as mt,a as ft,d as Ce,b as re,L as vt,e as U,c as bt}from"./Table-DEsIhjZo.js";import{B as L,c as ke,f as K}from"./heroui-CewI8xK4.js";function We(t){const i=t.indexOf(":");return i>0?t.slice(0,i):"—"}function gt(t,i=Date.now()){const r=new Map;for(const n of t){const a=r.get(n.model_key)??[];a.push(n),r.set(n.model_key,a)}const s=[];for(const n of r.values()){const a=[...n].sort((h,p)=>Date.parse(h.effective_at)-Date.parse(p.effective_at)),d=[...a].reverse().find(h=>Date.parse(h.effective_at)<=i);s.push(d??a[0])}return s.sort((n,a)=>n.model_key.localeCompare(a.model_key))}const _t="otari",Me=[{value:"vision",label:"Vision",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("image")},{value:"tool_call",label:"Tool calling",test:t=>!!t.tool_call},{value:"reasoning",label:"Reasoning",test:t=>!!t.reasoning},{value:"structured_output",label:"Structured output",test:t=>!!t.structured_output},{value:"attachment",label:"Attachments",test:t=>!!t.attachment},{value:"audio",label:"Audio",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("audio")},{value:"pdf",label:"PDF",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("pdf")}],jt=[{key:"reasoning",label:"Reasoning"},{key:"tool_call",label:"Tool calling"},{key:"structured_output",label:"Structured output"},{key:"attachment",label:"Attachments"},{key:"temperature",label:"Temperature"}],Te={text:"Text",image:"Image",audio:"Audio",video:"Video",pdf:"PDF"},Pt=[{value:"0",label:"Any context"},{value:"8000",label:"≥ 8K"},{value:"32000",label:"≥ 32K"},{value:"128000",label:"≥ 128K"},{value:"200000",label:"≥ 200K"},{value:"1000000",label:"≥ 1M"}],yt=[{value:"",label:"Any price"},{value:"1",label:"≤ $1 / 1M in"},{value:"3",label:"≤ $3 / 1M in"},{value:"10",label:"≤ $10 / 1M in"},{value:"30",label:"≤ $30 / 1M in"}],Nt=[{value:"",label:"Base prices"},{value:"8000",label:"Compare at 8K"},{value:"128000",label:"Compare at 128K"},{value:"200000",label:"Compare at 200K"},{value:"500000",label:"Compare at 500K"},{value:"1000000",label:"Compare at 1M"}],St=[{value:"all",label:"Any release date"},{value:"365",label:"Past year"},{value:"730",label:"Past 2 years"},{value:"1095",label:"Past 3 years"}],Ct=1440*60*1e3;function Ie(t,i){const r=`${i}:`;return t.startsWith(r)?t.slice(r.length):t}function kt(t,i){const r={inputPrice:t.inputPrice,outputPrice:t.outputPrice,cacheReadPrice:t.cacheReadPrice,cacheWritePrice:t.cacheWritePrice,cacheWrite1hPrice:t.cacheWrite1hPrice};if(i==null)return r;const s=t.pricingTiers.filter(n=>n.min_input_tokens<=i).sort((n,a)=>a.min_input_tokens-n.min_input_tokens)[0];return s?{inputPrice:s.input_price_per_million??r.inputPrice,outputPrice:s.output_price_per_million??r.outputPrice,cacheReadPrice:s.cache_read_price_per_million??r.cacheReadPrice,cacheWritePrice:s.cache_write_price_per_million??r.cacheWritePrice,cacheWrite1hPrice:s.cache_write_1h_price_per_million??r.cacheWrite1hPrice}:r}function ae(t){const i=Number(t);return t.trim()!==""&&Number.isFinite(i)&&i>=0}function R(t){if(t.trim()==="")return!0;const i=Number(t);return Number.isFinite(i)&&i>=0}function V(t){return t.trim()===""?null:Number(t)}function De(t){return t.map((i,r)=>({id:r,minInputTokens:String(i.min_input_tokens),input:i.input_price_per_million==null?"":String(i.input_price_per_million),output:i.output_price_per_million==null?"":String(i.output_price_per_million),cacheRead:i.cache_read_price_per_million==null?"":String(i.cache_read_price_per_million),cacheWrite:i.cache_write_price_per_million==null?"":String(i.cache_write_price_per_million),cacheWrite1h:i.cache_write_1h_price_per_million==null?"":String(i.cache_write_1h_price_per_million)}))}function Oe(t){const i=new Set;return t.every(r=>{const s=Number(r.minInputTokens),n=[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].some(a=>a.trim()!=="");return!Number.isInteger(s)||s<=0||i.has(s)||!n?!1:(i.add(s),[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].every(R))})}function Fe(t){return t.map(i=>({min_input_tokens:Number(i.minInputTokens),...i.input.trim()===""?{}:{input_price_per_million:Number(i.input)},...i.output.trim()===""?{}:{output_price_per_million:Number(i.output)},...i.cacheRead.trim()===""?{}:{cache_read_price_per_million:Number(i.cacheRead)},...i.cacheWrite.trim()===""?{}:{cache_write_price_per_million:Number(i.cacheWrite)},...i.cacheWrite1h.trim()===""?{}:{cache_write_1h_price_per_million:Number(i.cacheWrite1h)}}))}function j({value:t,onChange:i,ariaLabel:r}){return e.jsx("input",{type:"number",step:"any",min:"0",inputMode:"decimal","aria-label":r,value:t,onChange:s=>i(s.target.value),className:"w-28 rounded-md border border-[var(--otari-line)] bg-white px-2 py-1 text-right text-sm tabular-nums focus:border-[var(--otari-brand)] focus:outline-none"})}function ze({tiers:t,onChange:i}){const r=(n,a,d)=>{i(t.map(h=>h.id===n?{...h,[a]:d}:h))},s=()=>{const n=t.reduce((a,d)=>Math.max(a,d.id),-1)+1;i([...t,{id:n,minInputTokens:"128000",input:"",output:"",cacheRead:"",cacheWrite:"",cacheWrite1h:""}])};return e.jsxs("div",{className:"flex flex-col gap-2 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("div",{className:"text-xs font-medium text-[var(--otari-ink)]",children:"Long-context price tiers"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"At a threshold, listed rates replace the base rate for the whole request."})]}),e.jsx(L,{size:"sm",variant:"outline",onPress:s,children:"Add tier"})]}),t.map(n=>e.jsxs("div",{className:"flex flex-wrap items-end gap-2 border-t border-[var(--otari-line)] pt-2",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Context ≥ tokens",e.jsx("input",{type:"number",min:"1",step:"1",inputMode:"numeric","aria-label":"Tier context threshold",value:n.minInputTokens,onChange:a=>r(n.id,"minInputTokens",a.target.value),className:"w-28 rounded-md border border-[var(--otari-line)] bg-white px-2 py-1 text-right text-sm tabular-nums focus:border-[var(--otari-brand)] focus:outline-none"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Input",e.jsx(j,{value:n.input,onChange:a=>r(n.id,"input",a),ariaLabel:"Tier input price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Output",e.jsx(j,{value:n.output,onChange:a=>r(n.id,"output",a),ariaLabel:"Tier output price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Cache read",e.jsx(j,{value:n.cacheRead,onChange:a=>r(n.id,"cacheRead",a),ariaLabel:"Tier cache read price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Cache write",e.jsx(j,{value:n.cacheWrite,onChange:a=>r(n.id,"cacheWrite",a),ariaLabel:"Tier cache write price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["1h write",e.jsx(j,{value:n.cacheWrite1h,onChange:a=>r(n.id,"cacheWrite1h",a),ariaLabel:"Tier 1 hour cache write price"})]}),e.jsx(L,{size:"sm",variant:"ghost",onPress:()=>i(t.filter(a=>a.id!==n.id)),children:"Remove"})]},n.id))]})}function Wt({source:t}){return t==="configured"?e.jsx(K,{size:"sm",color:"default",children:"configured"}):t==="default"||t==="alias"?e.jsx(K,{size:"sm",color:"accent",children:t}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not priced"})}function se({label:t,tone:i="info",children:r}){const s=c.useId();return e.jsxs("span",{className:"group relative inline-flex items-center font-normal normal-case",children:[e.jsx("button",{type:"button","aria-label":t,"aria-describedby":s,className:`inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] leading-none ${i==="warning"?"border-[#c2843a] text-[#b45309]":"border-[var(--otari-line)] text-[var(--otari-muted)] hover:border-[var(--otari-brand)] hover:text-[var(--otari-brand)]"}`,children:"i"}),e.jsx("span",{id:s,role:"tooltip",className:"pointer-events-none absolute top-full right-0 z-20 mt-1.5 w-72 rounded-lg border border-[var(--otari-line)] bg-[var(--otari-surface)] px-3 py-2 text-left text-xs font-normal whitespace-normal break-words text-[var(--otari-ink)] opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100",children:r})]})}function Mt(){const t=xt();return t.data?t.data.default_pricing?e.jsx(se,{label:"How unpriced models are metered",tone:"info",children:"Default pricing is on: models without a configured price are metered using community-maintained rates (the bundled genai-prices dataset). Set a price to override the fallback."}):e.jsxs(se,{label:"How unpriced models are metered",tone:"warning",children:["Default pricing is off: only models with a configured price are metered.",t.data.require_pricing?" Requests for any other model are rejected (HTTP 402) because require_pricing is on.":" Other models are served without cost tracking."]}):null}function T({label:t,value:i}){return e.jsxs("div",{className:"flex items-baseline justify-between gap-3",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:t}),e.jsx("span",{className:"text-right text-sm text-[var(--otari-ink)] tabular-nums",children:i})]})}function ie({title:t,children:i}){return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-xs font-semibold uppercase tracking-wide text-[var(--otari-muted)]",children:t}),i]})}function Tt({row:t}){const i=$e(),r=Ee(),[s,n]=c.useState(!1),[a,d]=c.useState(""),[h,p]=c.useState(""),[v,P]=c.useState(""),[m,S]=c.useState(""),[k,W]=c.useState(""),[D,y]=c.useState([]),Y=()=>{d(t.inputPrice==null?"":String(t.inputPrice)),p(t.outputPrice==null?"":String(t.outputPrice)),P(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),S(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),W(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),y(De(t.pricingTiers)),n(!0)},M=ae(a)&&ae(h)&&R(v)&&R(m)&&R(k)&&Oe(D),G=()=>{M&&i.mutate({model_key:t.key,input_price_per_million:Number(a),output_price_per_million:Number(h),cache_read_price_per_million:V(v),cache_write_price_per_million:V(m),cache_write_1h_price_per_million:V(k),pricing_tiers:Fe(D)},{onSuccess:()=>n(!1)})};return s?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Input $ / 1M"}),e.jsx(j,{value:a,onChange:d,ariaLabel:`Input price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Output $ / 1M"}),e.jsx(j,{value:h,onChange:p,ariaLabel:`Output price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Cache read $ / 1M"}),e.jsx(j,{value:v,onChange:P,ariaLabel:`Cache read price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Cache write $ / 1M"}),e.jsx(j,{value:m,onChange:S,ariaLabel:`Cache write price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"1h cache write $ / 1M"}),e.jsx(j,{value:k,onChange:W,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(ze,{tiers:D,onChange:y}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(L,{size:"sm",variant:"primary",isDisabled:i.isPending||!M,onPress:G,children:"Save"}),e.jsx(L,{size:"sm",variant:"ghost",isDisabled:i.isPending,onPress:()=>n(!1),children:"Cancel"})]}),i.error?e.jsx("span",{className:"text-xs text-red-700",children:me(i.error)}):null]}):e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx(T,{label:"Input",value:t.inputPrice==null?"—":`${A(t.inputPrice)} / 1M`}),e.jsx(T,{label:"Output",value:t.outputPrice==null?"—":`${A(t.outputPrice)} / 1M`}),e.jsx(T,{label:"Cache read",value:t.cacheReadPrice==null?"—":`${A(t.cacheReadPrice)} / 1M`}),e.jsx(T,{label:"Cache write",value:t.cacheWritePrice==null?"—":`${A(t.cacheWritePrice)} / 1M`}),e.jsx(T,{label:"1h cache write",value:t.cacheWrite1hPrice==null?"—":`${A(t.cacheWrite1hPrice)} / 1M`}),e.jsx(T,{label:"Context tiers",value:t.pricingTiers.length?`${t.pricingTiers.length} configured`:"—"}),e.jsxs("div",{className:"flex items-center gap-2 pt-1",children:[e.jsx(L,{size:"sm",variant:"outline",onPress:Y,children:t.source==="configured"?"Edit price":"Set price"}),t.source==="configured"?e.jsxs(e.Fragment,{children:[e.jsx(Ae,{confirmLabel:"Reset",isPending:r.isPending,onConfirm:()=>r.mutate(t.key),children:"Reset"}),e.jsx(se,{label:"What reset does",children:"Removes the custom price. The model reverts to the default rate (genai-prices) when default pricing is on, otherwise it is metered at no cost."})]}):null,r.error?e.jsx("span",{className:"text-xs text-red-700",children:me(r.error)}):null]})]})}function It({row:t,metadata:i,metadataAvailable:r,onMakeAlias:s,onClose:n}){const a=(i==null?void 0:i.input_modalities)??[],d=(i==null?void 0:i.output_modalities)??[],h=jt.filter(({key:p})=>i==null?void 0:i[p]);return e.jsx(ke,{children:e.jsxs(ke.Content,{className:"flex flex-col gap-5 p-5",children:[e.jsxs("div",{className:"flex items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("h2",{className:"text-base font-semibold break-all text-[var(--otari-ink)]",children:t.model}),i!=null&&i.deprecated?e.jsx(K,{size:"sm",color:"danger",children:"deprecated"}):null]}),e.jsxs("p",{className:"mt-1 text-xs break-all text-[var(--otari-muted)]",children:["Selector: ",e.jsx("code",{children:t.key})]}),i!=null&&i.family?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:i.family}):null]}),e.jsx("button",{type:"button","aria-label":"Close model details",onClick:n,className:"-mt-1 -mr-1 shrink-0 rounded-md px-1.5 py-0.5 text-lg leading-none text-[var(--otari-muted)] hover:bg-[var(--otari-bg)] hover:text-[var(--otari-ink)]",children:"✕"})]}),i!=null&&i.description?e.jsx("p",{className:"text-sm text-[var(--otari-ink)]",children:i.description}):null,e.jsxs(ie,{title:"Pricing",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(Wt,{source:t.source}),t.isDiscovered?null:e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not discovered"})]}),e.jsx(Tt,{row:t},t.key),e.jsx(L,{size:"sm",variant:"outline",onPress:()=>s(t.key),children:"Make an alias"})]}),e.jsxs(ie,{title:"Specs",children:[e.jsx(T,{label:"Context window",value:ne(t.contextWindow)}),e.jsx(T,{label:"Max output",value:ne((i==null?void 0:i.max_output_tokens)??null)}),e.jsx(T,{label:"Knowledge cutoff",value:(i==null?void 0:i.knowledge_cutoff)??"—"}),e.jsx(T,{label:"Released",value:ht(i==null?void 0:i.release_date)}),e.jsx(T,{label:"Open weights",value:i?i.open_weights?"Yes":"No":"—"})]}),e.jsx(ie,{title:"Modalities",children:a.length===0&&d.length===0?e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:"Unknown."}):e.jsxs("div",{className:"flex flex-col gap-1.5 text-xs text-[var(--otari-muted)]",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"In:"}),a.map(p=>e.jsx(K,{size:"sm",color:"default",children:Te[p]??p},p))]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"Out:"}),d.map(p=>e.jsx(K,{size:"sm",color:"default",children:Te[p]??p},p))]})]})}),e.jsx(ie,{title:"Capabilities",children:h.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:h.map(({key:p,label:v})=>e.jsx(K,{size:"sm",color:"default",children:v},p))}):e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:r?"None reported.":"Extended metadata unavailable (models.dev disabled or unreachable)."})})]})})}const Lt=15,$t=[{value:"15",label:"15 per page"},{value:"25",label:"25 per page"},{value:"50",label:"50 per page"}];function Et({value:t,onChange:i,placeholder:r}){return e.jsx("input",{type:"search",value:t,onChange:s=>i(s.target.value),placeholder:r,"aria-label":r,className:"w-full max-w-xs rounded-md border border-[var(--otari-line)] bg-white px-3 py-1.5 text-sm focus:border-[var(--otari-brand)] focus:outline-none"})}function At({page:t,pageCount:i,total:r,pageSize:s,onPage:n,onPageSize:a}){return e.jsxs("div",{className:"flex items-center justify-between px-1 pt-1 text-sm text-[var(--otari-muted)]",children:[e.jsxs("span",{children:[i>1?`Page ${t+1} of ${i} · `:"",pt(r)," model",r===1?"":"s"]}),e.jsxs("span",{className:"inline-flex gap-2",children:[e.jsx(E,{ariaLabel:"Rows per page",value:String(s),onChange:d=>a(Number(d)),options:$t}),i>1?e.jsxs(e.Fragment,{children:[e.jsx(L,{size:"sm",variant:"outline",isDisabled:t===0,onPress:()=>n(t-1),children:"Prev"}),e.jsx(L,{size:"sm",variant:"outline",isDisabled:t>=i-1,onPress:()=>n(t+1),children:"Next"})]}):null]})]})}const Be="otari.dashboard.modelsSort",xe={col:"model",dir:"asc"},Dt=["model","released","input","output"];function Ot(){if(typeof window>"u")return xe;try{const t=window.localStorage.getItem(Be);if(!t)return xe;const i=JSON.parse(t);if(Dt.includes(i.col)&&(i.dir==="asc"||i.dir==="desc"))return{col:i.col,dir:i.dir}}catch{}return xe}function Le({label:t,col:i,sort:r,onSort:s,align:n="left",info:a}){const d=r.col===i;return e.jsx(re,{className:n==="right"?"text-right":void 0,ariaSort:d?r.dir==="asc"?"ascending":"descending":"none",children:e.jsxs("span",{className:`inline-flex items-center gap-1.5 ${n==="right"?"flex-row-reverse":""}`,children:[e.jsxs("button",{type:"button",onClick:()=>s(i),className:`inline-flex items-center gap-1 ${n==="right"?"flex-row-reverse":""} hover:text-[var(--otari-ink)]`,children:[t,e.jsx("span",{className:"text-[10px] text-[var(--otari-muted)]",children:d?r.dir==="asc"?"▲":"▼":"↕"})]}),a]})})}function Ft({row:t,onClose:i}){const r=$e(),s=Ee(),[n,a]=c.useState(t.inputPrice==null?"":String(t.inputPrice)),[d,h]=c.useState(t.outputPrice==null?"":String(t.outputPrice)),[p,v]=c.useState(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),[P,m]=c.useState(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),[S,k]=c.useState(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),[W,D]=c.useState(De(t.pricingTiers)),y=ae(n)&&ae(d)&&R(p)&&R(P)&&R(S)&&Oe(W),Y=()=>{y&&r.mutate({model_key:t.key,input_price_per_million:Number(n),output_price_per_million:Number(d),cache_read_price_per_million:V(p),cache_write_price_per_million:V(P),cache_write_1h_price_per_million:V(S),pricing_tiers:Fe(W)},{onSuccess:i})};return e.jsxs("div",{className:"flex flex-col gap-3 px-4 py-3",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("span",{className:"text-xs font-medium break-all text-[var(--otari-muted)]",children:t.key}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Input $ / 1M",e.jsx(j,{value:n,onChange:a,ariaLabel:`Input price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Output $ / 1M",e.jsx(j,{value:d,onChange:h,ariaLabel:`Output price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Cache read $ / 1M",e.jsx(j,{value:p,onChange:v,ariaLabel:`Cache read price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Cache write $ / 1M",e.jsx(j,{value:P,onChange:m,ariaLabel:`Cache write price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["1h cache write $ / 1M",e.jsx(j,{value:S,onChange:k,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(L,{size:"sm",variant:"primary",isDisabled:r.isPending||!y,onPress:Y,children:r.isPending?"Saving…":"Save"}),e.jsx(L,{size:"sm",variant:"ghost",isDisabled:r.isPending,onPress:i,children:"Cancel"}),t.source==="configured"?e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(Ae,{confirmLabel:"Reset",isPending:s.isPending,onConfirm:()=>s.mutate(t.key,{onSuccess:i}),children:"Reset"}),e.jsx(se,{label:"What reset does",children:"Removes the custom price. The model reverts to the default rate (genai-prices) when default pricing is on, otherwise it is metered at no cost."})]}):null,r.error||s.error?e.jsx("span",{className:"text-xs text-red-700",children:me(r.error??s.error)}):null]}),e.jsx(ze,{tiers:W,onChange:D})]})}function zt({primary:t,secondary:i,rowKey:r,primaryLabel:s,secondaryLabel:n,onEdit:a}){const d=(h,p)=>e.jsx("button",{type:"button","aria-label":`Edit ${p} price for ${r}`,className:"tabular-nums hover:text-[var(--otari-brand-dark)] hover:underline",onClick:v=>{v.stopPropagation(),a()},children:h==null?"—":A(h)});return e.jsxs("span",{className:"inline-flex items-center justify-end gap-1",children:[d(t,s),e.jsx("span",{className:"text-[var(--otari-muted)]",children:"/"}),d(i,n)]})}function Bt({rates:t,rowKey:i,onEdit:r}){const s=[t.cacheReadPrice==null?null:`R ${A(t.cacheReadPrice)}`,t.cacheWritePrice==null?null:`W ${A(t.cacheWritePrice)}`,t.cacheWrite1hPrice==null?null:`1h ${A(t.cacheWrite1hPrice)}`].filter(n=>n!==null);return e.jsx("button",{type:"button","aria-label":`Edit caching price for ${i}`,className:"max-w-44 text-right text-xs leading-5 text-[var(--otari-muted)] hover:text-[var(--otari-brand-dark)] hover:underline",onClick:n=>{n.stopPropagation(),r()},children:s.length>0?s.join(" · "):"Input-rate fallback"})}function Rt({row:t,onEdit:i}){const r=[...t.pricingTiers].sort((n,a)=>n.min_input_tokens-a.min_input_tokens).map(n=>ne(n.min_input_tokens)),s=r.length===0?"Base only":`${r.length} tier${r.length===1?"":"s"} · ≥ ${r.join(", ")}`;return e.jsx("button",{type:"button","aria-label":`Edit pricing policy for ${t.key}`,className:"max-w-40 text-right text-xs leading-5 text-[var(--otari-muted)] hover:text-[var(--otari-brand-dark)] hover:underline",onClick:n=>{n.stopPropagation(),i()},children:s})}function Kt({rows:t,isLoading:i,empty:r,sort:s,onSort:n,selectedKey:a,onSelect:d,pricingKey:h,onSetPricingKey:p,comparisonContextTokens:v}){const P=v==null?"Base":`at ${ne(v)}`;return e.jsxs(mt,{children:[e.jsx(ft,{children:e.jsxs(Ce,{children:[e.jsx(Le,{label:"Model",col:"model",sort:s,onSort:n}),e.jsx(re,{children:"Provider"}),e.jsx(Le,{label:`${P} in / out $ / 1M`,col:"input",sort:s,onSort:n,align:"right",info:e.jsx(Mt,{})}),e.jsxs(re,{className:"text-right",children:["Caching ",v==null?"policy":P]}),e.jsx(re,{className:"text-right",children:"Pricing policy"})]})}),e.jsx("tbody",{children:i?e.jsx(vt,{colSpan:5}):t.length>0?t.map(m=>{const S=()=>p(h===m.key?null:m.key),k=kt(m,v);return e.jsxs(c.Fragment,{children:[e.jsxs(Ce,{onClick:()=>d(m.key),selected:m.key===a,children:[e.jsxs(U,{className:"font-medium break-all",children:[m.model,e.jsx("span",{className:"sr-only",children:m.key})]}),e.jsx(U,{className:"text-[var(--otari-muted)]",children:m.provider}),e.jsx(U,{className:"text-right",children:e.jsx(zt,{primary:k.inputPrice,secondary:k.outputPrice,rowKey:m.key,primaryLabel:"input",secondaryLabel:"output",onEdit:S})}),e.jsx(U,{className:"text-right",children:e.jsx(Bt,{rates:k,rowKey:m.key,onEdit:S})}),e.jsx(U,{className:"text-right",children:e.jsx(Rt,{row:m,onEdit:S})})]}),h===m.key?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)] last:border-b-0",children:e.jsx("td",{colSpan:5,children:e.jsx(Ft,{row:m,onClose:()=>p(null)})})}):null]},m.key)}):e.jsx(bt,{colSpan:5,children:r})})]})}function Vt({providers:t}){return t.length===0?null:e.jsxs(dt,{tone:"warning",children:["Could not list ",t.map(i=>i.provider).join(", "),". Check that provider's credentials in config.yml; its models are missing from the list below."]})}function Jt(){var je,Pe,ye;const t=rt(),[i]=nt(),r=at(),s=st(),n=lt(),a=ct(),[d,h]=c.useState(""),[p,v]=c.useState(0),[P,m]=c.useState(Lt),[S,k]=c.useState(null),[W,D]=c.useState(null),[y,Y]=c.useState(Ot);c.useEffect(()=>{try{window.localStorage.setItem(Be,JSON.stringify(y))}catch{}},[y]);const[M,G]=c.useState(i.get("provider")||"all"),[F,Re]=c.useState("all"),[H,Ke]=c.useState("all"),[q,Ve]=c.useState("all"),[le,Ye]=c.useState("0"),[J,He]=c.useState(""),[Z,qe]=c.useState("all"),[ce,we]=c.useState(""),O=((je=a.data)==null?void 0:je.models)??{},Ue=((Pe=a.data)==null?void 0:Pe.available)??!1,fe=c.useMemo(()=>{var l;return new Set((((l=n.data)==null?void 0:l.providers)??[]).flatMap(f=>f.models.map(o=>o.key)))},[n.data]),Ge=l=>{h(l),v(0)},z=l=>f=>{l(f),v(0)},Je=l=>{Y(f=>f.col===l?{col:l,dir:f.dir==="asc"?"desc":"asc"}:{col:l,dir:l==="released"?"desc":"asc"}),v(0)},X=c.useMemo(()=>{var C,g,_,$,I,B,Ne;const l=new Map(gt(s.data??[]).map(u=>[u.model_key,u])),f=[],o=new Set,b=(u,he,Se,x)=>{if(o.has(u))return;o.add(u);const N=l.get(u);f.push({key:u,model:Ie(he,Se),provider:Se,isDiscovered:fe.has(u),contextWindow:(x==null?void 0:x.contextWindow)??null,inputPrice:N?N.input_price_per_million:(x==null?void 0:x.inputPrice)??null,outputPrice:N?N.output_price_per_million:(x==null?void 0:x.outputPrice)??null,cacheReadPrice:N?N.cache_read_price_per_million:(x==null?void 0:x.cacheReadPrice)??null,cacheWritePrice:N?N.cache_write_price_per_million:(x==null?void 0:x.cacheWritePrice)??null,cacheWrite1hPrice:N?N.cache_write_1h_price_per_million??null:(x==null?void 0:x.cacheWrite1hPrice)??null,pricingTiers:N?N.pricing_tiers??[]:(x==null?void 0:x.pricingTiers)??[],source:N?"configured":(x==null?void 0:x.source)??"none"})};for(const u of((C=r.data)==null?void 0:C.data)??[]){if(u.owned_by===_t)continue;const he=u.pricing_source==="default"?"default":u.pricing?"configured":"none";b(u.id,u.id,u.owned_by||We(u.id),{key:u.id,model:u.id,provider:u.owned_by,contextWindow:u.context_window,inputPrice:((g=u.pricing)==null?void 0:g.input_price_per_million)??null,outputPrice:((_=u.pricing)==null?void 0:_.output_price_per_million)??null,cacheReadPrice:(($=u.pricing)==null?void 0:$.cache_read_price_per_million)??null,cacheWritePrice:((I=u.pricing)==null?void 0:I.cache_write_price_per_million)??null,cacheWrite1hPrice:((B=u.pricing)==null?void 0:B.cache_write_1h_price_per_million)??null,pricingTiers:((Ne=u.pricing)==null?void 0:Ne.pricing_tiers)??[],source:he})}for(const u of l.keys())b(u,u,We(u));return f},[r.data,s.data,fe]),Ze=c.useMemo(()=>new Map(X.map(l=>[l.key,l])),[X]),Q=c.useMemo(()=>{var o,b,C;const l=X.map(g=>{var _,$;return{...g,contextWindow:g.contextWindow??((_=O[g.key])==null?void 0:_.context_window)??null,releaseDate:(($=O[g.key])==null?void 0:$.release_date)??null}}),f=new Set(l.map(g=>g.key));for(const g of((o=n.data)==null?void 0:o.providers)??[])for(const _ of g.models)f.has(_.key)||(f.add(_.key),l.push({key:_.key,model:Ie(_.key,g.provider),provider:g.provider,isDiscovered:!0,contextWindow:((b=O[_.key])==null?void 0:b.context_window)??null,releaseDate:((C=O[_.key])==null?void 0:C.release_date)??null,inputPrice:null,outputPrice:null,cacheReadPrice:null,cacheWritePrice:null,cacheWrite1hPrice:null,pricingTiers:[],source:"none"}));return l},[X,n.data,O]),Xe=(((ye=n.data)==null?void 0:ye.providers)??[]).filter(l=>!l.ok),ee=c.useMemo(()=>{const l=Array.from(new Set(Q.map(f=>f.provider))).sort((f,o)=>f.localeCompare(o));return[{value:"all",label:"All providers"},...l.map(f=>({value:f,label:f}))]},[Q]);c.useEffect(()=>{M==="all"||ee.length<=1||ee.some(l=>l.value===M)||G("all")},[ee,M]);const w=d.trim().toLowerCase(),oe=Number(le)||0,ue=J===""?Number.POSITIVE_INFINITY:Number(J),Qe=ce===""?null:Number(ce),de=Z==="all"?null:Date.now()-Number(Z)*Ct,pe=c.useMemo(()=>{const l=o=>{if(w&&!o.key.toLowerCase().includes(w)&&!o.provider.toLowerCase().includes(w)||M!=="all"&&o.provider!==M||F==="configured"&&o.source!=="configured"||F==="default"&&o.source!=="default"||F==="priced"&&o.inputPrice==null||F==="unpriced"&&o.inputPrice!=null||H==="discovered"&&!o.isDiscovered||H==="custom"&&o.isDiscovered)return!1;if(q!=="all"){const b=Me.find(g=>g.value===q),C=O[o.key];if(!b||!C||!b.test(C))return!1}if(oe>0&&(o.contextWindow==null||o.contextWindow<oe)||ue!==Number.POSITIVE_INFINITY&&(o.inputPrice==null||o.inputPrice>ue))return!1;if(de!=null){const b=o.releaseDate?Date.parse(o.releaseDate):Number.NaN;if(Number.isNaN(b)||b<de)return!1}return!0},f=(o,b)=>{const C=y.dir==="asc"?1:-1;if(y.col==="model")return o.model.localeCompare(b.model)*C;if(y.col==="released"){const I=o.releaseDate??null,B=b.releaseDate??null;return!I&&!B?o.model.localeCompare(b.model):I?B?(I<B?-1:I>B?1:0)*C||o.model.localeCompare(b.model):-1:1}const g=I=>y.col==="input"?I.inputPrice:I.outputPrice,_=g(o),$=g(b);return _==null&&$==null?o.model.localeCompare(b.model):_==null?1:$==null?-1:(_-$)*C||o.model.localeCompare(b.model)};return Q.filter(l).sort(f)},[Q,w,M,F,H,q,oe,ue,de,O,y]),ve=pe.length,be=Math.max(1,Math.ceil(ve/P)),ge=Math.min(p,be-1),_e=ge*P,et=pe.slice(_e,_e+P),tt=r.isLoading||s.isLoading||n.isLoading,it=w!==""||M!=="all"||F!=="all"||H!=="all"||q!=="all"||le!=="0"||J!==""||Z!=="all"?"No models match your filters.":"No models yet. Add a provider on the Providers page, or price a model below.",te=W?pe.find(l=>l.key===W)??Ze.get(W)??null:null;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ot,{title:"Models",description:"Every model your providers can serve. Set a price on any model so budgets and usage tracking work."}),e.jsx(ut,{error:r.error??s.error??n.error??a.error}),e.jsxs("div",{className:`grid gap-4 lg:items-start ${te?"lg:grid-cols-[minmax(0,1fr)_360px]":"grid-cols-1"}`,children:[e.jsxs("div",{className:"flex min-w-0 flex-col gap-3",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx(Et,{value:d,onChange:Ge,placeholder:"Search models…"}),e.jsx(E,{ariaLabel:"Filter by provider",value:M,onChange:z(G),options:ee}),e.jsx(E,{ariaLabel:"Filter by pricing",value:F,onChange:z(Re),options:[{value:"all",label:"Any pricing"},{value:"configured",label:"Custom price"},{value:"default",label:"Default price"},{value:"priced",label:"Priced"},{value:"unpriced",label:"Unpriced"}]}),e.jsx(E,{ariaLabel:"Filter by source",value:H,onChange:z(Ke),options:[{value:"all",label:"Any source"},{value:"discovered",label:"Discovered"},{value:"custom",label:"Custom (not discovered)"}]}),e.jsx(E,{ariaLabel:"Filter by capability",value:q,onChange:z(Ve),options:[{value:"all",label:"Any capability"},...Me.map(l=>({value:l.value,label:l.label}))]}),e.jsx(E,{ariaLabel:"Minimum context window",value:le,onChange:z(Ye),options:Pt}),e.jsx(E,{ariaLabel:"Maximum input price",value:J,onChange:z(He),options:yt}),e.jsx(E,{ariaLabel:"Compare prices at context",value:ce,onChange:we,options:Nt}),e.jsx(E,{ariaLabel:"Filter by release date",value:Z,onChange:z(qe),options:St})]}),e.jsx(Vt,{providers:Xe}),e.jsx(Kt,{rows:et,isLoading:tt,empty:it,sort:y,onSort:Je,selectedKey:W,onSelect:D,pricingKey:S,onSetPricingKey:k,comparisonContextTokens:Qe}),e.jsx("div",{className:"flex flex-wrap items-center justify-between gap-2",children:e.jsx(At,{page:ge,pageCount:be,total:ve,pageSize:P,onPage:v,onPageSize:l=>{m(l),v(0)}})})]}),te?e.jsx("aside",{className:"lg:sticky lg:top-4",children:e.jsx(It,{row:te,metadata:O[te.key],metadataAvailable:Ue,onMakeAlias:l=>t(`/aliases?target=${encodeURIComponent(l)}`),onClose:()=>D(null)})}):null]})]})}export{Jt as ModelsPage};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make model-row selection keyboard accessible.

The model table row relies on onClick alone, so keyboard users cannot open model details. Use a focusable control or add Enter/Space handling and appropriate row state semantics.

🤖 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/static/dashboard/assets/ModelsPage-5SIMv0Ug.js` at line 1, The
model-row selection behavior in Kt currently relies only on onClick and is
inaccessible from the keyboard. Update the row selection implementation to use a
focusable interactive control or add Enter/Space handling with appropriate
focusability and selected-row semantics, while preserving the existing onSelect
behavior that opens model details.

The pre-call estimate treated a requested Anthropic cache write as an
additional input meter, charging every estimated prompt token once at the
input rate and again at the cache-write rate, and selecting the context
tier from double the token count. Actual billing charges a cache-write
token only at the write rate, so the reservation over-reserved (roughly
input + write per token) and could reject requests that would comfortably
fit their budget.

Each prompt token is billed as exactly one of fresh input, cache read, or
cache write, so the true upper bound for the input side is the token count
times the dearest applicable rate. Reserve the input at max(input rate,
cache-write rate) when a write is requested, and select the tier from the
estimated input, which approximates the real billable total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 10:53 — with GitHub Actions Inactive
@coderabbitai
coderabbitai Bot requested a review from dpoulopoulos July 23, 2026 10:54
@njbrake
njbrake merged commit 7559257 into main Jul 23, 2026
14 checks passed
@njbrake
njbrake deleted the feat/provider-metered-pricing branch July 23, 2026 10:56

@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 (2)
tests/unit/test_budget_cost_estimate.py (2)

94-103: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover the one-hour fallback separately from unpriced writes.

Setting both cache-write rates to None only verifies input-rate fallback. It does not test the required case where the 1-hour rate is unset but the base cache-write rate remains configured. Keep the base rate configured and set only cache_write_1h_price_per_million=None, then expect the base cache-write rate.

Suggested test adjustment
-        _pricing(cache_write_price_per_million=None, cache_write_1h_price_per_million=None),
+        _pricing(cache_write_1h_price_per_million=None),
...
-    assert estimate == pytest.approx(1_000 * 3.0 / 1_000_000)
+    assert estimate == pytest.approx(1_000 * 3.75 / 1_000_000)
🤖 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/unit/test_budget_cost_estimate.py` around lines 94 - 103, Update
test_estimate_cost_falls_back_to_input_when_cache_write_is_unpriced to keep the
base cache-write price configured while setting only
cache_write_1h_price_per_million to None, and assert the estimate uses the
configured base cache-write rate for the 1-hour TTL.

68-79: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Make the tiered cache-write assertion distinguish the selected meter.

The expected 7.5 can still pass if cache_write_ttl is ignored, because the estimator uses the maximum of the input and cache-write rates. Give the tier’s cache-write rate a distinct, higher value so this test actually catches regressions in tiered cache-write selection. Also, the comment should say “estimated input tokens,” not “real billable total,” since this pre-call estimator selects tiers from the estimate.

🤖 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/unit/test_budget_cost_estimate.py` around lines 68 - 79, The test
test_estimate_cost_reserves_cache_write_using_the_context_tier should configure
the pricing fixture with a distinct, higher tiered cache-write rate so the
expected cost specifically verifies cache-write meter selection rather than the
maximum-rate fallback. Update the assertion to use that cache-write rate and
revise the comment to say the tier is selected from the estimated input tokens.
🤖 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 `@tests/unit/test_budget_cost_estimate.py`:
- Around line 94-103: Update
test_estimate_cost_falls_back_to_input_when_cache_write_is_unpriced to keep the
base cache-write price configured while setting only
cache_write_1h_price_per_million to None, and assert the estimate uses the
configured base cache-write rate for the 1-hour TTL.
- Around line 68-79: The test
test_estimate_cost_reserves_cache_write_using_the_context_tier should configure
the pricing fixture with a distinct, higher tiered cache-write rate so the
expected cost specifically verifies cache-write meter selection rather than the
maximum-rate fallback. Update the assertion to use that cache-write rate and
revise the comment to say the tier is selected from the estimated input tokens.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5186d367-52c7-4ddf-be8b-0fb2249f8347

📥 Commits

Reviewing files that changed from the base of the PR and between e1c2d4c and b68246f.

📒 Files selected for processing (3)
  • src/gateway/services/budget_service.py
  • src/gateway/services/metered_pricing.py
  • tests/unit/test_budget_cost_estimate.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/gateway/services/budget_service.py
  • src/gateway/services/metered_pricing.py

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.

Pricing: handle graded pricing between long and short context Support TTL-aware Anthropic cache-write pricing (5-min vs 1-hour)

3 participants