feat(pricing): add reviewable price refresh#367
Conversation
Fetch and persist approved genai-prices snapshots through the Settings dashboard, while retaining custom database prices as higher-priority overrides. Fixes #361
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughChangesPricing refresh workflow
Models dashboard presentation
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/configuration.md`:
- Around line 304-308: Update the configuration documentation near the
accepted-snapshot persistence description to state that snapshots are restored
across standalone restarts only. Preserve the existing precedence and refresh
behavior, while avoiding any implication that hybrid mode restores these
snapshots.
In `@src/gateway/models/entities.py`:
- Around line 211-217: Update the PricingSnapshot.snapshot field in the model to
declare its column using Text, matching the type created by the migration. Leave
source and updated_at unchanged so ORM metadata aligns with the existing schema.
In `@src/gateway/services/pricing_refresh_service.py`:
- Line 31: Replace the process-local _pending_snapshot state with persisted,
versioned pending-review storage so concurrent previews cannot overwrite one
another and preview/confirm requests work across workers. Update the preview
flow to issue a token or content hash, and require and validate that identifier
in the confirm and reject operations before applying or discarding the matching
snapshot; preserve the existing conflict response for missing or mismatched
versions.
In `@web/src/pages/SettingsPage.tsx`:
- Around line 609-611: Update the explanatory copy near the SettingsPage catalog
update controls to describe the fetched changes as a summary or preview, not a
complete review of every proposed price change. Account for truncated API
results and retain the existing guidance about accepting or rejecting changes,
stored genai-prices data, and custom-price precedence.
- Around line 587-636: Update PricingRefreshSection to remove the local preview
useState and use previewRefresh.data as the AlertDialog and PricingRefreshDialog
source. After successful confirmRefresh or rejectRefresh mutations, call
previewRefresh.reset() instead of clearing local state, and derive dialog
visibility from the mutation data.
🪄 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: 20a2c4a3-d586-4272-8fe9-1b8b87cb7a03
⛔ Files ignored due to path filters (2)
docs/public/openapi.jsonis excluded by!docs/public/openapi.jsonuv.lockis excluded by!**/*.lock,!**/uv.lock
📒 Files selected for processing (21)
alembic/versions/d5f7a9b1c3e5_add_pricing_snapshots.pydocs/configuration.mdpyproject.tomlsrc/gateway/api/routes/pricing.pysrc/gateway/db/__init__.pysrc/gateway/main.pysrc/gateway/models/entities.pysrc/gateway/services/pricing_refresh_service.pysrc/gateway/static/dashboard/assets/index-CMLuwMLW.jssrc/gateway/static/dashboard/assets/index-DnD03gwo.jssrc/gateway/static/dashboard/assets/index-cUh--Y2D.csssrc/gateway/static/dashboard/index.htmltests/conftest.pytests/integration/test_pricing_refresh_endpoint.pytests/unit/test_pricing_refresh_service.pyweb/src/api/hooks.tsweb/src/api/types.tsweb/src/pages/ModelsPage.test.tsxweb/src/pages/ModelsPage.tsxweb/src/pages/SettingsPage.test.tsxweb/src/pages/SettingsPage.tsx
There was a problem hiding this comment.
Pull request overview
Adds a master-key-only, reviewable workflow to refresh the bundled genai-prices default pricing catalog, persisting both pending and accepted snapshots in the local database so accepted defaults survive restarts. This also streamlines the dashboard Models page table and introduces configurable page sizes.
Changes:
- Added backend pricing refresh service + persisted
PricingSnapshotmodel/migration, plus master-key endpoints to preview/confirm/reject a refresh. - Added Settings UI + hooks/types to review and apply/reject pricing refreshes, with associated unit/integration/web tests and docs updates.
- Simplified Models table UI and added configurable 15/25/50 row pagination (default 15).
Reviewed changes
Copilot reviewed 20 out of 24 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/pages/SettingsPage.tsx | Adds the Settings UI section + dialog to preview/accept/reject default pricing refreshes. |
| web/src/pages/SettingsPage.test.tsx | Adds UI tests for the pricing refresh accept/reject flows. |
| web/src/pages/ModelsPage.tsx | Simplifies the Models table and adds configurable page size pagination. |
| web/src/pages/ModelsPage.test.tsx | Updates tests to match the simplified table and new pagination behavior. |
| web/src/api/types.ts | Adds API types for pricing refresh preview payloads. |
| web/src/api/hooks.ts | Adds React Query mutations for preview/confirm/reject pricing refresh. |
| uv.lock | Adds the new backend dependency lock entry. |
| pyproject.toml | Adds the new backend dependency declaration. |
| src/gateway/services/pricing_refresh_service.py | Implements snapshot fetch/preview/persist/confirm/reject and startup restore. |
| src/gateway/models/entities.py | Adds the PricingSnapshot ORM entity. |
| alembic/versions/d5f7a9b1c3e5_add_pricing_snapshots.py | Adds the pricing_snapshots table migration. |
| src/gateway/main.py | Loads the persisted accepted snapshot during standalone startup. |
| src/gateway/db/init.py | Exposes PricingSnapshot in the DB module exports. |
| src/gateway/api/routes/pricing.py | Adds master-key endpoints for preview/confirm/reject refresh and response models. |
| tests/unit/test_pricing_refresh_service.py | Unit tests for pricing refresh snapshot persistence and activation behavior. |
| tests/integration/test_pricing_refresh_endpoint.py | Integration tests for the refresh endpoints (auth + conflict cases + protected count). |
| tests/conftest.py | Updates pricing reset fixture to clear refresh state between tests. |
| docs/configuration.md | Documents the operator workflow for refreshing bundled default pricing. |
| docs/public/openapi.json | Updates OpenAPI to include new pricing refresh endpoints/schemas. |
| docs/public/otari.postman_collection.json | Adds Postman requests for preview/confirm/reject refresh endpoints. |
| src/gateway/static/dashboard/index.html | Updates the committed dashboard asset hashes after the web build. |
| expect(screen.queryByRole("alertdialog", { name: "Review default price updates" })).not.toBeInTheDocument(); | ||
| expect( | ||
| fetchMock.mock.calls.some( | ||
| ([url, init]) => String(url).endsWith("/v1/pricing/refresh/confirm") && init?.method === "POST", | ||
| ), | ||
| ).toBe(true); | ||
| }); |
| expect(screen.queryByRole("alertdialog", { name: "Review default price updates" })).not.toBeInTheDocument(); | ||
| expect( | ||
| fetchMock.mock.calls.some( | ||
| ([url, init]) => String(url).endsWith("/v1/pricing/refresh/reject") && init?.method === "POST", | ||
| ), | ||
| ).toBe(true); | ||
| }); |
…dialog tests Address Copilot review feedback: - useConfirmPricingRefresh now invalidates the pricing query so usePricing() views refresh instead of showing stale rates after a confirm. - Wrap dialog-dismissal assertions in waitFor so they don't race the async mutation settling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
khaledosman
left a comment
There was a problem hiding this comment.
Approving to unblock. Solid change. All three endpoints are master-key gated (test confirms 401), the network fetch runs off the event loop via asyncio.to_thread, the DB commit precedes the in-memory set_custom_snapshot, persisted snapshots are re-validated on load, and the billable settlement path is untouched. httpx2 is a legitimate dep (it is genai-prices' own HTTP client). Migration chains correctly to head with a real downgrade().
Inline notes below. One product question: ModelsPage drops "Price a model not listed here" (PriceModelForm/ModelComboBox), so an operator can no longer price a brand-new or unconfigured-provider model from the UI (the /v1/pricing API still supports it). Reads as intentional per the description; just confirming it's the desired call.
Review created by Claude Code.
| await session.rollback() | ||
| raise PricingRefreshError("Unable to save the latest genai-prices data") from exc | ||
|
|
||
| set_custom_snapshot(snapshot) |
There was a problem hiding this comment.
Multi-worker/replica divergence: this runs only in the worker that served the confirm; other workers/replicas keep the old snapshot until restart. Aliases and provider credentials have TTL refreshers for exactly this reason (documented in AGENTS.md); the accepted pricing snapshot has none, so an accepted price change applies inconsistently across a multi-worker deployment. Add a reload hook/refresher or document the restart requirement.
| await session.execute( | ||
| select(PricingSnapshot) | ||
| .where(PricingSnapshot.source == GENAI_PRICES_PENDING_SOURCE) | ||
| .with_for_update() |
There was a problem hiding this comment.
with_for_update() is a no-op on SQLite (the standalone default), so concurrent confirms/rejects aren't actually serialized there. Master-key-only admin path so low risk, but the lock gives false assurance on the default backend.
# Conflicts: # src/gateway/static/dashboard/assets/index-cUh--Y2D.css # src/gateway/static/dashboard/index.html # uv.lock
# Conflicts: # src/gateway/static/dashboard/index.html
Address khaled's review: - Add a TTL snapshot refresher (run_price_snapshot_refresher) wired into the standalone lifespan, so an accepted snapshot applied in the worker that served the confirm propagates to sibling workers and replicas, matching the alias and provider-credential refreshers. Only re-applies (and clears the price cache) when the stored snapshot actually changed. - Note that with_for_update() is a no-op on SQLite so it reads as defensive, not a real serialization guarantee on the default backend. - Read the pending snapshot before delete/commit so expire_on_commit can't re-fetch a deleted row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (3)
src/gateway/static/dashboard/assets/index-D5TTMN5D.css (1)
1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winKeep generated CSS out of source Stylelint checks.
Stylelint reports numerous errors in this generated Tailwind bundle, including intentional duplicate overrides such as
transition-duration,outline-style, andline-height. Please excludesrc/gateway/static/dashboard/assets/**/*.cssfrom source linting and lint the authored CSS instead, or configure generated-asset validation separately; hand-editing this file would be overwritten on the next build.🤖 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/index-D5TTMN5D.css` at line 1, Exclude generated dashboard asset files matching src/gateway/static/dashboard/assets/**/*.css from the source Stylelint configuration, and ensure authored CSS remains covered by linting. Do not modify the generated index-D5TTMN5D.css bundle or suppress its individual rules; use a separate generated-asset validation path only if needed.Source: Linters/SAST tools
src/gateway/static/dashboard/assets/ModelScopeControl-DfDDpsa6.js (1)
1-1: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winUse a
Setfor selected-model membership checks.
f.filter(s => !a.includes(s.id))scans the selected list for every candidate. Buildnew Set(a)once withuseMemoto keep filtering effectively linear as provider catalogs grow.🤖 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/ModelScopeControl-DfDDpsa6.js` at line 1, Update the selected-model filtering in component Q by creating a memoized Set from a, then use set membership instead of a.includes(s.id) when building L. Keep the existing search filtering and result limit unchanged, and ensure the memoized Set recalculates when a changes.tests/unit/test_pricing_refresh_service.py (1)
91-114: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAssert the propagated model price, not only provider identity.
The test computes
model_keybut only checksprovider.idand_applied_snapshot_raw. A regression that loads the snapshot while leaving the model’s effective rate unchanged would still pass. Add an assertion for the changed model’s price.🤖 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_pricing_refresh_service.py` around lines 91 - 114, Update test_refresher_applies_a_snapshot_accepted_on_another_worker to assert the effective price of the changed model identified by model_key after refresh. Keep the existing provider identity and raw snapshot assertions, and ensure the assertion verifies the expected changed rate rather than only successful snapshot loading.
🤖 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/main.py`:
- Around line 139-142: Ensure the startup tasks created for alias_refresher,
provider_refresher, and price_refresher are covered by the same cleanup scope as
log_writer.start(). Move their creation inside the existing managed try/finally
block, or add startup-exception cleanup that cancels and awaits each task before
propagating the failure; preserve normal lifespan cleanup behavior.
In `@src/gateway/static/dashboard/assets/ActivityPage-CKgTCz2o.js`:
- Line 1: Sanitize provider diagnostics at the dashboard boundary: in
src/gateway/static/dashboard/assets/ActivityPage-CKgTCz2o.js:1-1, update Pe and
Te so entry.error_message is never rendered or copied verbatim, exposing only a
generic error message and opaque diagnostic ID; in
src/gateway/static/dashboard/assets/ProvidersPage-B1PWfyPm.js:1-1, apply the
same sanitization to connection-test and health error fields before rendering.
Preserve raw provider details only in protected server-side logs.
In `@src/gateway/static/dashboard/assets/BudgetsPage-BzUF6Aa4.js`:
- Line 1: Update the custom reset-period validation in component pe to require
Number.isInteger(o) in addition to the existing positive, finite checks. Reject
fractional day inputs by storing null and do not round or alter the entered
value; regenerate the BudgetsPage bundle from the updated source.
In `@src/gateway/static/dashboard/assets/ModelsPage-Cgm8YsX7.js`:
- Line 1: The model rows rendered by Mt are clickable but not keyboard
accessible. Update the row selection interaction around Mt and its model-cell
rendering to provide a focusable control or equivalent row focus and keyboard
handling, ensuring Enter/Space opens the same model details as clicking while
preserving access to the alias action.
- Line 1: The compiled ModelsPage asset is stale and should not be committed.
Update the source ModelsPage implementation to remove or adjust the provider and
context-window UI, then regenerate the dashboard bundle so the generated
ModelsPage asset matches the source; remove the checked-in compiled asset if
generated assets are excluded from the repository.
In `@src/gateway/static/dashboard/assets/ToolsGuardrailsPage-Dz4sDo0m.js`:
- Line 1: Update the URL test flow in component $ so its displayed l.data result
is associated with the URL submitted to l.mutate. Track the tested URL and
render success or failure details only when it matches the current trimmed
input, or disable editing while the test is pending; preserve the existing test
and save behavior.
In `@src/gateway/static/dashboard/assets/UsagePage-CFdEpDOA.js`:
- Line 1: Update the drill-down handler B to merge the currently active model
and user_id filters from the existing state with the clicked dimension before
constructing the activity URL. Preserve the clicked value as the selected
dimension while retaining any other active filter, and continue using the
existing start_date navigation behavior.
---
Nitpick comments:
In `@src/gateway/static/dashboard/assets/index-D5TTMN5D.css`:
- Line 1: Exclude generated dashboard asset files matching
src/gateway/static/dashboard/assets/**/*.css from the source Stylelint
configuration, and ensure authored CSS remains covered by linting. Do not modify
the generated index-D5TTMN5D.css bundle or suppress its individual rules; use a
separate generated-asset validation path only if needed.
In `@src/gateway/static/dashboard/assets/ModelScopeControl-DfDDpsa6.js`:
- Line 1: Update the selected-model filtering in component Q by creating a
memoized Set from a, then use set membership instead of a.includes(s.id) when
building L. Keep the existing search filtering and result limit unchanged, and
ensure the memoized Set recalculates when a changes.
In `@tests/unit/test_pricing_refresh_service.py`:
- Around line 91-114: Update
test_refresher_applies_a_snapshot_accepted_on_another_worker to assert the
effective price of the changed model identified by model_key after refresh. Keep
the existing provider identity and raw snapshot assertions, and ensure the
assertion verifies the expected changed rate rather than only successful
snapshot loading.
🪄 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: b5b0ebe6-7828-4854-b283-f17248129feb
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock,!**/uv.lock
📒 Files selected for processing (26)
docs/configuration.mddocs/public/otari.postman_collection.jsonsrc/gateway/main.pysrc/gateway/models/entities.pysrc/gateway/services/pricing_refresh_service.pysrc/gateway/static/dashboard/assets/ActivityPage-CKgTCz2o.jssrc/gateway/static/dashboard/assets/AliasesPage-B5qObkia.jssrc/gateway/static/dashboard/assets/BudgetsPage-BzUF6Aa4.jssrc/gateway/static/dashboard/assets/KeysPage-BeOrjVIO.jssrc/gateway/static/dashboard/assets/ModelScopeControl-DfDDpsa6.jssrc/gateway/static/dashboard/assets/ModelsPage-Cgm8YsX7.jssrc/gateway/static/dashboard/assets/OverviewPage-C5UlyWbe.jssrc/gateway/static/dashboard/assets/ProvidersPage-B1PWfyPm.jssrc/gateway/static/dashboard/assets/SettingsPage-CopNjETz.jssrc/gateway/static/dashboard/assets/ToolsGuardrailsPage-Dz4sDo0m.jssrc/gateway/static/dashboard/assets/UsagePage-CFdEpDOA.jssrc/gateway/static/dashboard/assets/UsersPage-Bqu5E2p8.jssrc/gateway/static/dashboard/assets/index-D5TTMN5D.csssrc/gateway/static/dashboard/assets/index-DvNpC9jo.jssrc/gateway/static/dashboard/index.htmltests/unit/test_pricing_refresh_service.pyweb/src/api/hooks.tsweb/src/pages/ModelsPage.test.tsxweb/src/pages/ModelsPage.tsxweb/src/pages/SettingsPage.test.tsxweb/src/pages/SettingsPage.tsx
🚧 Files skipped from review as they are similar to previous changes (8)
- src/gateway/static/dashboard/index.html
- web/src/pages/SettingsPage.test.tsx
- web/src/api/hooks.ts
- docs/configuration.md
- src/gateway/models/entities.py
- web/src/pages/SettingsPage.tsx
- web/src/pages/ModelsPage.test.tsx
- web/src/pages/ModelsPage.tsx
| @@ -1 +1 @@ | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{u as re,r}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 H,d as G}from"./index-BcI4fr4n.js";import{T as ue,a as me,b as c,L as xe,c as he,d as pe,e as d}from"./Table-DEsIhjZo.js";import{B as g,S as ge}from"./heroui-CewI8xK4.js";const je=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function Y(t){return t===null?"—":je.format(t)}function j(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 fe(t){const a=new Date(t);return Number.isNaN(a.getTime())?t:a.toLocaleString()}function ve(t){const a=new Date(t).getTime();if(Number.isNaN(a))return t;const n=Math.max(0,Math.round((Date.now()-a)/1e3));if(n<60)return`${n}s ago`;const i=Math.round(n/60);if(i<60)return`${i}m ago`;const u=Math.round(i/60);return u<24?`${u}h ago`:`${Math.round(u/24)}d ago`}const be=3600,_=86400,Se=[{label:"Last hour",seconds:be},{label:"24h",seconds:_},{label:"7d",seconds:7*_},{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 C(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})}async function we(t,a=navigator.clipboard){if(!a)return!1;try{return await a.writeText(t),!0}catch{return!1}}function Te({text:t}){const[a,n]=r.useState(!1);return e.jsx(g,{size:"sm",variant:"ghost",onPress:async()=>{await we(t)&&(n(!0),window.setTimeout(()=>n(!1),1500))},children:a?"Copied":"Copy"})}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 Pe({entry:t}){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.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:t.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:t.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: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:j(t.prompt_tokens)}),e.jsx(l,{label:"Completion tokens",children:j(t.completion_tokens)}),e.jsx(l,{label:"Total tokens",children:j(t.total_tokens)}),e.jsx(l,{label:"Cost",children:Y(t.cost)}),e.jsx(l,{label:"Cache read tokens",children:j(t.cache_read_tokens)}),e.jsx(l,{label:"Cache write tokens",children:j(t.cache_write_tokens)}),e.jsx(l,{label:"Total time",children:Z(t.latency_ms)}),e.jsx(l,{label:"Request ID",children:t.id})]})]})}const E=7;function Me(){var y,q,z;const t=le(),[a]=re(),n=a.get("start_date")??void 0,[i,u]=r.useState(n?null:_),[N,k]=r.useState(()=>n??C(_)),[m,D]=r.useState(()=>a.get("status")??""),[o,A]=r.useState(()=>a.get("model")??""),[x,$]=r.useState(""),[h,M]=r.useState(()=>a.get("user_id")??""),[f,w]=r.useState(0),[J,K]=r.useState(null),T=r.useMemo(()=>({start_date:N,status:m||void 0,model:o.trim()||void 0,endpoint:x||void 0,user_id:h||void 0}),[N,m,o,x,h]);r.useEffect(()=>{w(0)},[T]);const p=ne(T,f,S),v=ie(T),Q=r.useMemo(()=>({start_date:N,status:m||void 0,endpoint:x||void 0,user_id:h||void 0}),[N,m,x,h]),P=((q=(y=oe(Q,"day").data)==null?void 0:y.by_model)==null?void 0:q.filter(s=>!s.is_other&&s.key!==null).map(s=>s.key))??[],b=p.data??[],F=((z=v.data)==null?void 0:z.total)??0,U=!!(m||o.trim()||x||h||i!==null),V=s=>{u(s),k(s===null?void 0:C(s))},W=()=>{u(null),k(void 0),D(""),A(""),$(""),M("")},X=()=>{i!==null&&k(C(i)),p.refetch(),v.refetch()},L=b.length>0,I=L?f*S+1:0,O=f*S+b.length,R=v.isSuccess&&!v.isPlaceholderData,ee=R?F===0?"0 of 0":`${I}–${O} of ${F.toLocaleString()}`:L?`${I}–${O}`:"0",se=R?(f+1)*S<F: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(g,{variant:"outline",onPress:X,isDisabled:p.isFetching,children:"Refresh"})}),e.jsx(de,{error:p.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(s=>e.jsx(g,{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(H,{id:"filter-status",label:"Status",value:m,onChange:D,children:Ne.map(s=>e.jsx("option",{value:s.value,children:s.label},s.value))}),e.jsxs(H,{id:"filter-endpoint",label:"Endpoint",value:x,onChange:$,children:[e.jsx("option",{value:"",children:"All endpoints"}),_e.map(s=>e.jsx("option",{value:s,children:s},s))]}),e.jsx(G,{label:"User",value:h,onChange:M,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(G,{label:"Model",value:o,onChange:A,allowsCustom:!0,placeholder:"Any model",options:(o&&!P.includes(o)?[o,...P]:P).map(s=>({value:s,label:s}))}),U?e.jsx(g,{size:"sm",variant:"ghost",onPress:W,children:"Clear filters"}):null]})]}),e.jsxs(ue,{children:[e.jsx(me,{children:e.jsxs("tr",{children:[e.jsx(c,{children:"Time"}),e.jsx(c,{children:"User"}),e.jsx(c,{children:"Model"}),e.jsx(c,{className:"text-right",children:"Tokens"}),e.jsx(c,{className:"text-right",children:"Cost"}),e.jsx(c,{className:"text-right",children:"Total time"}),e.jsx(c,{children:"Status"})]})}),e.jsx("tbody",{children:p.isLoading?e.jsx(xe,{colSpan:E}):b.length===0?e.jsx(he,{colSpan:E,children:U?"No requests match these filters.":"No requests recorded yet."}):b.map(s=>{const te=s.status==="error",B=J===s.id;return e.jsxs(r.Fragment,{children:[e.jsxs(pe,{selected:B,className:te?"bg-red-50":"",onClick:()=>K(ae=>ae===s.id?null:s.id),children:[e.jsx(d,{className:"text-[var(--otari-muted)]",children:e.jsx("span",{title:fe(s.timestamp),children:ve(s.timestamp)})}),e.jsx(d,{className:"text-[var(--otari-ink)]",children:s.user_id??"—"}),e.jsx(d,{className:"text-[var(--otari-ink)]",children:s.model}),e.jsx(d,{className:"text-right tabular-nums",children:j(s.total_tokens)}),e.jsx(d,{className:"text-right tabular-nums",children:Y(s.cost)}),e.jsx(d,{className:"text-right tabular-nums",children:Z(s.latency_ms)}),e.jsx(d,{children:e.jsx(ke,{status:s.status})})]}),B?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:E,children:e.jsx(Pe,{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,p.isFetching?e.jsx(ge,{size:"sm"}):null]}),e.jsxs("span",{className:"inline-flex gap-1.5",children:[e.jsx(g,{size:"sm",variant:"outline",isDisabled:f===0,onPress:()=>w(s=>Math.max(0,s-1)),children:"Previous"}),e.jsx(g,{size:"sm",variant:"outline",isDisabled:!se,onPress:()=>w(s=>s+1),children:"Next"})]})]})]})}export{Me as ActivityPage,we as copyToClipboard}; | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{u as re,r}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 H,d as G}from"./index-DvNpC9jo.js";import{T as ue,a as me,b as c,L as xe,c as he,d as pe,e as d}from"./Table-DEsIhjZo.js";import{B as g,S as ge}from"./heroui-CewI8xK4.js";const je=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function Y(t){return t===null?"—":je.format(t)}function j(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 fe(t){const a=new Date(t);return Number.isNaN(a.getTime())?t:a.toLocaleString()}function ve(t){const a=new Date(t).getTime();if(Number.isNaN(a))return t;const n=Math.max(0,Math.round((Date.now()-a)/1e3));if(n<60)return`${n}s ago`;const i=Math.round(n/60);if(i<60)return`${i}m ago`;const u=Math.round(i/60);return u<24?`${u}h ago`:`${Math.round(u/24)}d ago`}const be=3600,_=86400,Se=[{label:"Last hour",seconds:be},{label:"24h",seconds:_},{label:"7d",seconds:7*_},{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 C(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})}async function we(t,a=navigator.clipboard){if(!a)return!1;try{return await a.writeText(t),!0}catch{return!1}}function Te({text:t}){const[a,n]=r.useState(!1);return e.jsx(g,{size:"sm",variant:"ghost",onPress:async()=>{await we(t)&&(n(!0),window.setTimeout(()=>n(!1),1500))},children:a?"Copied":"Copy"})}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 Pe({entry:t}){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.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:t.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:t.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: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:j(t.prompt_tokens)}),e.jsx(l,{label:"Completion tokens",children:j(t.completion_tokens)}),e.jsx(l,{label:"Total tokens",children:j(t.total_tokens)}),e.jsx(l,{label:"Cost",children:Y(t.cost)}),e.jsx(l,{label:"Cache read tokens",children:j(t.cache_read_tokens)}),e.jsx(l,{label:"Cache write tokens",children:j(t.cache_write_tokens)}),e.jsx(l,{label:"Total time",children:Z(t.latency_ms)}),e.jsx(l,{label:"Request ID",children:t.id})]})]})}const E=7;function Me(){var y,q,z;const t=le(),[a]=re(),n=a.get("start_date")??void 0,[i,u]=r.useState(n?null:_),[N,k]=r.useState(()=>n??C(_)),[m,D]=r.useState(()=>a.get("status")??""),[o,A]=r.useState(()=>a.get("model")??""),[x,$]=r.useState(""),[h,M]=r.useState(()=>a.get("user_id")??""),[f,w]=r.useState(0),[J,K]=r.useState(null),T=r.useMemo(()=>({start_date:N,status:m||void 0,model:o.trim()||void 0,endpoint:x||void 0,user_id:h||void 0}),[N,m,o,x,h]);r.useEffect(()=>{w(0)},[T]);const p=ne(T,f,S),v=ie(T),Q=r.useMemo(()=>({start_date:N,status:m||void 0,endpoint:x||void 0,user_id:h||void 0}),[N,m,x,h]),P=((q=(y=oe(Q,"day").data)==null?void 0:y.by_model)==null?void 0:q.filter(s=>!s.is_other&&s.key!==null).map(s=>s.key))??[],b=p.data??[],F=((z=v.data)==null?void 0:z.total)??0,U=!!(m||o.trim()||x||h||i!==null),V=s=>{u(s),k(s===null?void 0:C(s))},W=()=>{u(null),k(void 0),D(""),A(""),$(""),M("")},X=()=>{i!==null&&k(C(i)),p.refetch(),v.refetch()},L=b.length>0,I=L?f*S+1:0,O=f*S+b.length,R=v.isSuccess&&!v.isPlaceholderData,ee=R?F===0?"0 of 0":`${I}–${O} of ${F.toLocaleString()}`:L?`${I}–${O}`:"0",se=R?(f+1)*S<F: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(g,{variant:"outline",onPress:X,isDisabled:p.isFetching,children:"Refresh"})}),e.jsx(de,{error:p.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(s=>e.jsx(g,{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(H,{id:"filter-status",label:"Status",value:m,onChange:D,children:Ne.map(s=>e.jsx("option",{value:s.value,children:s.label},s.value))}),e.jsxs(H,{id:"filter-endpoint",label:"Endpoint",value:x,onChange:$,children:[e.jsx("option",{value:"",children:"All endpoints"}),_e.map(s=>e.jsx("option",{value:s,children:s},s))]}),e.jsx(G,{label:"User",value:h,onChange:M,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(G,{label:"Model",value:o,onChange:A,allowsCustom:!0,placeholder:"Any model",options:(o&&!P.includes(o)?[o,...P]:P).map(s=>({value:s,label:s}))}),U?e.jsx(g,{size:"sm",variant:"ghost",onPress:W,children:"Clear filters"}):null]})]}),e.jsxs(ue,{children:[e.jsx(me,{children:e.jsxs("tr",{children:[e.jsx(c,{children:"Time"}),e.jsx(c,{children:"User"}),e.jsx(c,{children:"Model"}),e.jsx(c,{className:"text-right",children:"Tokens"}),e.jsx(c,{className:"text-right",children:"Cost"}),e.jsx(c,{className:"text-right",children:"Total time"}),e.jsx(c,{children:"Status"})]})}),e.jsx("tbody",{children:p.isLoading?e.jsx(xe,{colSpan:E}):b.length===0?e.jsx(he,{colSpan:E,children:U?"No requests match these filters.":"No requests recorded yet."}):b.map(s=>{const te=s.status==="error",B=J===s.id;return e.jsxs(r.Fragment,{children:[e.jsxs(pe,{selected:B,className:te?"bg-red-50":"",onClick:()=>K(ae=>ae===s.id?null:s.id),children:[e.jsx(d,{className:"text-[var(--otari-muted)]",children:e.jsx("span",{title:fe(s.timestamp),children:ve(s.timestamp)})}),e.jsx(d,{className:"text-[var(--otari-ink)]",children:s.user_id??"—"}),e.jsx(d,{className:"text-[var(--otari-ink)]",children:s.model}),e.jsx(d,{className:"text-right tabular-nums",children:j(s.total_tokens)}),e.jsx(d,{className:"text-right tabular-nums",children:Y(s.cost)}),e.jsx(d,{className:"text-right tabular-nums",children:Z(s.latency_ms)}),e.jsx(d,{children:e.jsx(ke,{status:s.status})})]}),B?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:E,children:e.jsx(Pe,{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,p.isFetching?e.jsx(ge,{size:"sm"}):null]}),e.jsxs("span",{className:"inline-flex gap-1.5",children:[e.jsx(g,{size:"sm",variant:"outline",isDisabled:f===0,onPress:()=>w(s=>Math.max(0,s-1)),children:"Previous"}),e.jsx(g,{size:"sm",variant:"outline",isDisabled:!se,onPress:()=>w(s=>s+1),children:"Next"})]})]})]})}export{Me as ActivityPage,we as copyToClipboard}; | |||
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Sanitize provider diagnostics before they cross the UI boundary.
Both pages expose provider-supplied error text verbatim. Return only a generic message and opaque diagnostic ID to the dashboard, while retaining raw details in protected server-side logs.
src/gateway/static/dashboard/assets/ActivityPage-CKgTCz2o.js#L1-L1: sanitizeentry.error_messagebefore rendering or copying it.src/gateway/static/dashboard/assets/ProvidersPage-B1PWfyPm.js#L1-L1: sanitize connection-test and health error fields before rendering them.
📍 Affects 2 files
src/gateway/static/dashboard/assets/ActivityPage-CKgTCz2o.js#L1-L1(this comment)src/gateway/static/dashboard/assets/ProvidersPage-B1PWfyPm.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-CKgTCz2o.js` at line 1,
Sanitize provider diagnostics at the dashboard boundary: in
src/gateway/static/dashboard/assets/ActivityPage-CKgTCz2o.js:1-1, update Pe and
Te so entry.error_message is never rendered or copied verbatim, exposing only a
generic error message and opaque diagnostic ID; in
src/gateway/static/dashboard/assets/ProvidersPage-B1PWfyPm.js:1-1, apply the
same sanitization to connection-test and health error fields before rendering.
Preserve raw provider details only in protected server-side logs.
Source: Coding guidelines
| @@ -1 +1 @@ | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{r as u}from"./react-q-ooZ0ti.js";import{i as Y,u as G,j as K,k as Q,l as X,m as J,P as Z,E as R,I as ee,n as se}from"./index-BcI4fr4n.js";import{F as L}from"./Field-gj3-ox4q.js";import{T as te,a as re,b as S,L as ae,c as ne,d as ie,e as C}from"./Table-DEsIhjZo.js";import{C as B,I as le,a as de,b as oe,B as x,c as A,S as ue}from"./heroui-CewI8xK4.js";const ce=50;function me({value:s,onChange:r,users:a,label:i,description:l}){const[g,d]=u.useState(""),o=u.useMemo(()=>a.filter(t=>!t.user_id.startsWith("apikey-")).map(t=>({id:t.user_id,label:t.alias?`${t.user_id} (${t.alias})`:t.user_id})),[a]),c=u.useMemo(()=>{const t=g.trim().toLowerCase();return o.filter(m=>!s.includes(m.id)).filter(m=>!t||m.id.toLowerCase().includes(t)||m.label.toLowerCase().includes(t)).slice(0,ce)},[o,s,g]),h=t=>{s.includes(t)||r([...s,t]),d("")},b=t=>r(s.filter(m=>m!==t));return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:i}),l?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:l}):null]}),s.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:s.map(t=>e.jsxs("span",{className:"inline-flex items-center gap-1 rounded-full bg-[var(--otari-brand-tint)] px-2.5 py-1 font-mono text-xs text-[var(--otari-brand-dark)]",children:[t,e.jsx("button",{type:"button","aria-label":`Remove ${t}`,onClick:()=>b(t),className:"text-[var(--otari-brand-dark)] hover:text-red-700",children:"×"})]},t))}):null,o.length===0?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users yet. Create users first, then assign them here or from the Users page."}):e.jsxs(B.Root,{allowsEmptyCollection:!0,menuTrigger:"input",inputValue:g,onInputChange:d,selectedKey:null,onSelectionChange:t=>{t!=null&&h(String(t))},className:"flex flex-col gap-1",children:[e.jsxs(B.InputGroup,{children:[e.jsx(le,{"aria-label":"Add a user",placeholder:"Search users…",autoComplete:"off"}),e.jsx(B.Trigger,{})]}),e.jsx(B.Popover,{children:e.jsx(de,{items:c,className:"max-h-72 overflow-auto",children:t=>e.jsx(oe,{id:t.id,textValue:t.label,children:t.label})})})]})]})}const xe=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:2});function w(s){return xe.format(s)}const j=86400,F=3600,I=[{label:"No reset",seconds:null},{label:"Daily",seconds:j},{label:"Weekly",seconds:7*j},{label:"Monthly",seconds:30*j}];function ge(s){if(s===null)return"No reset";const r=I.find(a=>a.seconds===s);return r?r.label:s%j===0?`Every ${s/j} days`:s%F===0?`Every ${s/F} hours`:`Every ${s}s`}function $(s){if(!s)return"—";const r=new Date(s);return Number.isNaN(r.getTime())?"—":r.toLocaleString()}function he(s){const r=s.trim();if(r==="")return{value:null,valid:!0};const a=Number(r);return!Number.isFinite(a)||a<0?{value:null,valid:!1}:{value:a,valid:!0}}function pe({value:s,onChange:r}){const a=I.some(d=>d.seconds===s),[i,l]=u.useState(!a),g=s!==null&&s%j===0?String(s/j):"";return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Reset period"}),e.jsxs("div",{className:"flex flex-wrap gap-2",children:[I.map(d=>e.jsx(x,{size:"sm",variant:!i&&s===d.seconds?"primary":"outline",onPress:()=>{l(!1),r(d.seconds)},children:d.label},d.label)),e.jsx(x,{size:"sm",variant:i?"primary":"outline",onPress:()=>l(!0),children:"Custom"})]}),i?e.jsx("div",{className:"flex items-end gap-2",children:e.jsx(L,{label:"Every N days",value:g,onChange:d=>{const o=Number(d.trim()),c=Math.round(o)*j;r(d.trim()===""||!Number.isFinite(o)||o<=0||!Number.isFinite(c)||c<=0?null:c)},placeholder:"14",description:"Whole days between resets."})}):null,e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Spend returns to zero each period. A user’s clock starts when the budget is assigned to them."})]})}function O({title:s,submitLabel:r,initial:a,error:i,isPending:l,onSubmit:g,onClose:d,assignUsers:o}){const[c,h]=u.useState(a.name??""),[b,t]=u.useState(a.max_budget===null?"":String(a.max_budget)),[m,f]=u.useState(a.budget_duration_sec),[v,N]=u.useState([]),_=he(b),P=()=>{l||!_.valid||g({name:c.trim()||null,max_budget:_.value,budget_duration_sec:m},v)};return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:s}),e.jsx(R,{error:i}),e.jsx(L,{label:"Name (optional)",value:c,onChange:h,autoFocus:!0,placeholder:"team-free-tier",description:"A label to recognize this budget later."}),e.jsx(L,{label:"Spending limit (USD)",value:b,onChange:t,placeholder:"100.00",description:_.valid?"The most a single user on this budget may spend per period. Leave blank for no limit.":e.jsx("span",{className:"text-red-700",children:"Enter a non-negative number, or leave blank for no limit."})}),e.jsx(pe,{value:m,onChange:f}),o?e.jsx(me,{label:"Assign to users (optional)",description:"Attach this budget to existing users now. You can also manage assignments later on the Users page.",value:v,onChange:N,users:o}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(x,{variant:"primary",isDisabled:l||!_.valid,onPress:P,children:l?"Saving…":r}),e.jsx(x,{variant:"ghost",isDisabled:l,onPress:d,children:"Cancel"})]})]})})}function fe({budget:s}){if(s.user_count===0)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users assigned"});const r=s.total_spend;if(s.max_budget===null)return e.jsxs("span",{className:"text-xs text-[var(--otari-ink)]",children:[w(r)," spent",e.jsx("span",{className:"text-[var(--otari-muted)]",children:" · no limit"})]});const a=s.max_budget*s.user_count,i=a>0?Math.min(100,r/a*100):0,l=r>a;return e.jsxs("div",{className:"flex min-w-[140px] flex-col gap-1",children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-2 text-xs",children:[e.jsx("span",{className:"text-[var(--otari-ink)]",children:w(r)}),e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["of ",w(a)]})]}),e.jsx("div",{className:"h-1.5 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",role:"progressbar","aria-valuenow":Math.round(i),"aria-valuemin":0,"aria-valuemax":100,"aria-label":"Aggregate spend against total allocation",children:e.jsx("div",{className:`h-full rounded-full ${l?"bg-red-500":"bg-[var(--otari-brand)]"}`,style:{width:`${Math.max(i,l?100:2)}%`}})})]})}function je({budgetId:s}){const r=se(s);if(r.isLoading)return e.jsxs("div",{className:"flex items-center gap-2 px-4 py-4 text-sm text-[var(--otari-muted)]",children:[e.jsx(ue,{size:"sm"})," Loading reset history…"]});if(r.error)return e.jsx("div",{className:"px-4 py-4",children:e.jsx(R,{error:r.error})});const a=r.data??[];return a.length===0?e.jsx("div",{className:"px-4 py-4 text-sm text-[var(--otari-muted)]",children:"No resets recorded yet for this budget."}):e.jsx("div",{className:"overflow-x-auto px-4 py-3",children:e.jsxs("table",{className:"w-full border-collapse text-xs",children:[e.jsx("thead",{className:"text-left text-[var(--otari-muted)]",children:e.jsxs("tr",{children:[e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"User"}),e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"Spend cleared"}),e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"Reset at"}),e.jsx("th",{className:"py-1.5 font-medium",children:"Next reset"})]})}),e.jsx("tbody",{children:a.map(i=>e.jsxs("tr",{className:"border-t border-[var(--otari-line)]",children:[e.jsx("td",{className:"py-1.5 pr-4",children:e.jsx("code",{children:i.user_id??"—"})}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-ink)]",children:w(i.previous_spend)}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-muted)]",children:$(i.reset_at)}),e.jsx("td",{className:"py-1.5 text-[var(--otari-muted)]",children:$(i.next_reset_at)})]},i.id))})]})})}function be({onCreate:s}){return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-6",children:[e.jsxs("div",{children:[e.jsx("h2",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"No budgets yet"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"A budget caps how much a user may spend and, optionally, resets that spend on a schedule. Create one, then assign it to users to enforce a limit."})]}),e.jsx("div",{children:e.jsx(x,{variant:"primary",onPress:s,children:"Create your first budget"})})]})})}function ve({label:s,isPending:r,onConfirm:a}){const[i,l]=u.useState(!1);return i?e.jsxs("div",{className:"flex flex-col items-end gap-1.5 rounded-lg border border-amber-200 bg-amber-50 p-2 text-right",children:[e.jsxs("span",{className:"max-w-xs text-xs text-amber-800",children:["Delete ",e.jsx("strong",{children:s}),"? Users keep their spend but lose this limit. Cannot be undone."]}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(x,{size:"sm",variant:"danger",isDisabled:r,onPress:a,children:"Delete permanently"}),e.jsx(x,{size:"sm",variant:"ghost",isDisabled:r,onPress:()=>l(!1),children:"Cancel"})]})]}):e.jsx(x,{size:"sm",variant:"danger-soft",onPress:()=>l(!0),children:"Delete"})}function V(s){return s.split("-")[0]}function H(s){return s.name??V(s.budget_id)}function Pe(){const s=Y(),r=G(),a=K(),i=Q(),l=X(),g=J(),[d,o]=u.useState(!1),[c,h]=u.useState(null),[b,t]=u.useState(null),[m,f]=u.useState(null),[v,N]=u.useState(null),[_,P]=u.useState(!1),U=s.data??[],T=s.isLoading,y=U.find(n=>n.budget_id===c)??null,M=!T&&U.length===0&&!d,z=async(n,p)=>{P(!0),f(null);const D=await Promise.allSettled(p.map(k=>g.mutateAsync({id:k,body:{budget_id:n}})));P(!1);const E=D.flatMap((k,q)=>k.status==="rejected"?[p[q]]:[]);if(E.length>0){N({budgetId:n,userIds:E}),f(new Error(`Budget created, but could not assign it to: ${E.join(", ")}. Retry to try again.`));return}N(null),o(!1)},W=(n,p)=>{if(v){z(v.budgetId,v.userIds);return}f(null),a.mutate(n,{onSuccess:async D=>{if(p.length>0){await z(D.budget_id,p);return}o(!1)}})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Z,{title:"Budgets",description:"Define spending limits and reset schedules. Assign a budget to users to enforce it.",action:d||M?null:e.jsx(x,{variant:"primary",onPress:()=>{h(null),f(null),N(null),o(!0)},children:"Create budget"})}),e.jsx(R,{error:s.error??a.error??i.error??l.error??g.error}),e.jsx(ee,{children:"Assign a budget to users when you create it, or later from the Users page. Each row’s usage aggregates the spend of the users currently on that budget."}),M?e.jsx(be,{onCreate:()=>{h(null),f(null),N(null),o(!0)}}):null,d?e.jsx(O,{title:"Create budget",submitLabel:v?"Retry assignments":"Create budget",initial:{name:null,max_budget:null,budget_duration_sec:null},error:a.error??m,isPending:a.isPending||_,assignUsers:r.data??[],onSubmit:W,onClose:()=>{f(null),N(null),o(!1)}}):null,y?e.jsx(O,{title:`Edit budget ${H(y)}`,submitLabel:"Save changes",initial:{name:y.name,max_budget:y.max_budget,budget_duration_sec:y.budget_duration_sec},error:i.error,isPending:i.isPending,onSubmit:n=>i.mutate({id:y.budget_id,body:n},{onSuccess:()=>h(null)}),onClose:()=>h(null)},y.budget_id):null,e.jsxs(te,{children:[e.jsx(re,{children:e.jsxs("tr",{children:[e.jsx(S,{children:"Budget"}),e.jsx(S,{children:"Limit (per user)"}),e.jsx(S,{children:"Reset"}),e.jsx(S,{children:"Users"}),e.jsx(S,{children:"Usage"}),e.jsx(S,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:T?e.jsx(ae,{colSpan:6}):U.length===0?e.jsx(ne,{colSpan:6,children:"No budgets yet. Create one to cap spending."}):U.map(n=>e.jsxs(u.Fragment,{children:[e.jsxs(ie,{selected:c===n.budget_id,onClick:()=>{o(!1),h(n.budget_id)},children:[e.jsx(C,{className:"font-medium text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{children:n.name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsx("code",{className:"text-[11px] text-[var(--otari-muted)]",title:n.budget_id,children:V(n.budget_id)})]})}),e.jsx(C,{children:n.max_budget===null?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"Unlimited"}):w(n.max_budget)}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:ge(n.budget_duration_sec)}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:n.user_count}),e.jsx(C,{children:e.jsx(fe,{budget:n})}),e.jsx(C,{children:e.jsxs("div",{className:"flex items-center justify-end gap-1.5",onClick:p=>p.stopPropagation(),children:[e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>t(p=>p===n.budget_id?null:n.budget_id),children:b===n.budget_id?"Hide history":"History"}),e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>{o(!1),h(n.budget_id)},children:"Edit"}),e.jsx(ve,{label:H(n),isPending:l.isPending,onConfirm:()=>l.mutate(n.budget_id)})]})})]}),b===n.budget_id?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:6,children:e.jsx(je,{budgetId:n.budget_id})})}):null]},n.budget_id))})]})]})}export{Pe as BudgetsPage}; | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{r as u}from"./react-q-ooZ0ti.js";import{j as Y,u as G,k as K,l as Q,m as X,n as J,P as Z,E as R,I as ee,o as se}from"./index-DvNpC9jo.js";import{F as L}from"./Field-gj3-ox4q.js";import{T as te,a as re,b as S,L as ae,c as ne,d as ie,e as C}from"./Table-DEsIhjZo.js";import{C as B,I as le,a as de,b as oe,B as x,c as A,S as ue}from"./heroui-CewI8xK4.js";const ce=50;function me({value:s,onChange:r,users:a,label:i,description:l}){const[g,d]=u.useState(""),o=u.useMemo(()=>a.filter(t=>!t.user_id.startsWith("apikey-")).map(t=>({id:t.user_id,label:t.alias?`${t.user_id} (${t.alias})`:t.user_id})),[a]),c=u.useMemo(()=>{const t=g.trim().toLowerCase();return o.filter(m=>!s.includes(m.id)).filter(m=>!t||m.id.toLowerCase().includes(t)||m.label.toLowerCase().includes(t)).slice(0,ce)},[o,s,g]),h=t=>{s.includes(t)||r([...s,t]),d("")},b=t=>r(s.filter(m=>m!==t));return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:i}),l?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:l}):null]}),s.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:s.map(t=>e.jsxs("span",{className:"inline-flex items-center gap-1 rounded-full bg-[var(--otari-brand-tint)] px-2.5 py-1 font-mono text-xs text-[var(--otari-brand-dark)]",children:[t,e.jsx("button",{type:"button","aria-label":`Remove ${t}`,onClick:()=>b(t),className:"text-[var(--otari-brand-dark)] hover:text-red-700",children:"×"})]},t))}):null,o.length===0?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users yet. Create users first, then assign them here or from the Users page."}):e.jsxs(B.Root,{allowsEmptyCollection:!0,menuTrigger:"input",inputValue:g,onInputChange:d,selectedKey:null,onSelectionChange:t=>{t!=null&&h(String(t))},className:"flex flex-col gap-1",children:[e.jsxs(B.InputGroup,{children:[e.jsx(le,{"aria-label":"Add a user",placeholder:"Search users…",autoComplete:"off"}),e.jsx(B.Trigger,{})]}),e.jsx(B.Popover,{children:e.jsx(de,{items:c,className:"max-h-72 overflow-auto",children:t=>e.jsx(oe,{id:t.id,textValue:t.label,children:t.label})})})]})]})}const xe=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:2});function w(s){return xe.format(s)}const j=86400,F=3600,I=[{label:"No reset",seconds:null},{label:"Daily",seconds:j},{label:"Weekly",seconds:7*j},{label:"Monthly",seconds:30*j}];function ge(s){if(s===null)return"No reset";const r=I.find(a=>a.seconds===s);return r?r.label:s%j===0?`Every ${s/j} days`:s%F===0?`Every ${s/F} hours`:`Every ${s}s`}function $(s){if(!s)return"—";const r=new Date(s);return Number.isNaN(r.getTime())?"—":r.toLocaleString()}function he(s){const r=s.trim();if(r==="")return{value:null,valid:!0};const a=Number(r);return!Number.isFinite(a)||a<0?{value:null,valid:!1}:{value:a,valid:!0}}function pe({value:s,onChange:r}){const a=I.some(d=>d.seconds===s),[i,l]=u.useState(!a),g=s!==null&&s%j===0?String(s/j):"";return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Reset period"}),e.jsxs("div",{className:"flex flex-wrap gap-2",children:[I.map(d=>e.jsx(x,{size:"sm",variant:!i&&s===d.seconds?"primary":"outline",onPress:()=>{l(!1),r(d.seconds)},children:d.label},d.label)),e.jsx(x,{size:"sm",variant:i?"primary":"outline",onPress:()=>l(!0),children:"Custom"})]}),i?e.jsx("div",{className:"flex items-end gap-2",children:e.jsx(L,{label:"Every N days",value:g,onChange:d=>{const o=Number(d.trim()),c=Math.round(o)*j;r(d.trim()===""||!Number.isFinite(o)||o<=0||!Number.isFinite(c)||c<=0?null:c)},placeholder:"14",description:"Whole days between resets."})}):null,e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Spend returns to zero each period. A user’s clock starts when the budget is assigned to them."})]})}function O({title:s,submitLabel:r,initial:a,error:i,isPending:l,onSubmit:g,onClose:d,assignUsers:o}){const[c,h]=u.useState(a.name??""),[b,t]=u.useState(a.max_budget===null?"":String(a.max_budget)),[m,f]=u.useState(a.budget_duration_sec),[v,N]=u.useState([]),_=he(b),P=()=>{l||!_.valid||g({name:c.trim()||null,max_budget:_.value,budget_duration_sec:m},v)};return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:s}),e.jsx(R,{error:i}),e.jsx(L,{label:"Name (optional)",value:c,onChange:h,autoFocus:!0,placeholder:"team-free-tier",description:"A label to recognize this budget later."}),e.jsx(L,{label:"Spending limit (USD)",value:b,onChange:t,placeholder:"100.00",description:_.valid?"The most a single user on this budget may spend per period. Leave blank for no limit.":e.jsx("span",{className:"text-red-700",children:"Enter a non-negative number, or leave blank for no limit."})}),e.jsx(pe,{value:m,onChange:f}),o?e.jsx(me,{label:"Assign to users (optional)",description:"Attach this budget to existing users now. You can also manage assignments later on the Users page.",value:v,onChange:N,users:o}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(x,{variant:"primary",isDisabled:l||!_.valid,onPress:P,children:l?"Saving…":r}),e.jsx(x,{variant:"ghost",isDisabled:l,onPress:d,children:"Cancel"})]})]})})}function fe({budget:s}){if(s.user_count===0)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users assigned"});const r=s.total_spend;if(s.max_budget===null)return e.jsxs("span",{className:"text-xs text-[var(--otari-ink)]",children:[w(r)," spent",e.jsx("span",{className:"text-[var(--otari-muted)]",children:" · no limit"})]});const a=s.max_budget*s.user_count,i=a>0?Math.min(100,r/a*100):0,l=r>a;return e.jsxs("div",{className:"flex min-w-[140px] flex-col gap-1",children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-2 text-xs",children:[e.jsx("span",{className:"text-[var(--otari-ink)]",children:w(r)}),e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["of ",w(a)]})]}),e.jsx("div",{className:"h-1.5 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",role:"progressbar","aria-valuenow":Math.round(i),"aria-valuemin":0,"aria-valuemax":100,"aria-label":"Aggregate spend against total allocation",children:e.jsx("div",{className:`h-full rounded-full ${l?"bg-red-500":"bg-[var(--otari-brand)]"}`,style:{width:`${Math.max(i,l?100:2)}%`}})})]})}function je({budgetId:s}){const r=se(s);if(r.isLoading)return e.jsxs("div",{className:"flex items-center gap-2 px-4 py-4 text-sm text-[var(--otari-muted)]",children:[e.jsx(ue,{size:"sm"})," Loading reset history…"]});if(r.error)return e.jsx("div",{className:"px-4 py-4",children:e.jsx(R,{error:r.error})});const a=r.data??[];return a.length===0?e.jsx("div",{className:"px-4 py-4 text-sm text-[var(--otari-muted)]",children:"No resets recorded yet for this budget."}):e.jsx("div",{className:"overflow-x-auto px-4 py-3",children:e.jsxs("table",{className:"w-full border-collapse text-xs",children:[e.jsx("thead",{className:"text-left text-[var(--otari-muted)]",children:e.jsxs("tr",{children:[e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"User"}),e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"Spend cleared"}),e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"Reset at"}),e.jsx("th",{className:"py-1.5 font-medium",children:"Next reset"})]})}),e.jsx("tbody",{children:a.map(i=>e.jsxs("tr",{className:"border-t border-[var(--otari-line)]",children:[e.jsx("td",{className:"py-1.5 pr-4",children:e.jsx("code",{children:i.user_id??"—"})}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-ink)]",children:w(i.previous_spend)}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-muted)]",children:$(i.reset_at)}),e.jsx("td",{className:"py-1.5 text-[var(--otari-muted)]",children:$(i.next_reset_at)})]},i.id))})]})})}function be({onCreate:s}){return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-6",children:[e.jsxs("div",{children:[e.jsx("h2",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"No budgets yet"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"A budget caps how much a user may spend and, optionally, resets that spend on a schedule. Create one, then assign it to users to enforce a limit."})]}),e.jsx("div",{children:e.jsx(x,{variant:"primary",onPress:s,children:"Create your first budget"})})]})})}function ve({label:s,isPending:r,onConfirm:a}){const[i,l]=u.useState(!1);return i?e.jsxs("div",{className:"flex flex-col items-end gap-1.5 rounded-lg border border-amber-200 bg-amber-50 p-2 text-right",children:[e.jsxs("span",{className:"max-w-xs text-xs text-amber-800",children:["Delete ",e.jsx("strong",{children:s}),"? Users keep their spend but lose this limit. Cannot be undone."]}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(x,{size:"sm",variant:"danger",isDisabled:r,onPress:a,children:"Delete permanently"}),e.jsx(x,{size:"sm",variant:"ghost",isDisabled:r,onPress:()=>l(!1),children:"Cancel"})]})]}):e.jsx(x,{size:"sm",variant:"danger-soft",onPress:()=>l(!0),children:"Delete"})}function V(s){return s.split("-")[0]}function H(s){return s.name??V(s.budget_id)}function Pe(){const s=Y(),r=G(),a=K(),i=Q(),l=X(),g=J(),[d,o]=u.useState(!1),[c,h]=u.useState(null),[b,t]=u.useState(null),[m,f]=u.useState(null),[v,N]=u.useState(null),[_,P]=u.useState(!1),U=s.data??[],T=s.isLoading,y=U.find(n=>n.budget_id===c)??null,M=!T&&U.length===0&&!d,z=async(n,p)=>{P(!0),f(null);const D=await Promise.allSettled(p.map(k=>g.mutateAsync({id:k,body:{budget_id:n}})));P(!1);const E=D.flatMap((k,q)=>k.status==="rejected"?[p[q]]:[]);if(E.length>0){N({budgetId:n,userIds:E}),f(new Error(`Budget created, but could not assign it to: ${E.join(", ")}. Retry to try again.`));return}N(null),o(!1)},W=(n,p)=>{if(v){z(v.budgetId,v.userIds);return}f(null),a.mutate(n,{onSuccess:async D=>{if(p.length>0){await z(D.budget_id,p);return}o(!1)}})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Z,{title:"Budgets",description:"Define spending limits and reset schedules. Assign a budget to users to enforce it.",action:d||M?null:e.jsx(x,{variant:"primary",onPress:()=>{h(null),f(null),N(null),o(!0)},children:"Create budget"})}),e.jsx(R,{error:s.error??a.error??i.error??l.error??g.error}),e.jsx(ee,{children:"Assign a budget to users when you create it, or later from the Users page. Each row’s usage aggregates the spend of the users currently on that budget."}),M?e.jsx(be,{onCreate:()=>{h(null),f(null),N(null),o(!0)}}):null,d?e.jsx(O,{title:"Create budget",submitLabel:v?"Retry assignments":"Create budget",initial:{name:null,max_budget:null,budget_duration_sec:null},error:a.error??m,isPending:a.isPending||_,assignUsers:r.data??[],onSubmit:W,onClose:()=>{f(null),N(null),o(!1)}}):null,y?e.jsx(O,{title:`Edit budget ${H(y)}`,submitLabel:"Save changes",initial:{name:y.name,max_budget:y.max_budget,budget_duration_sec:y.budget_duration_sec},error:i.error,isPending:i.isPending,onSubmit:n=>i.mutate({id:y.budget_id,body:n},{onSuccess:()=>h(null)}),onClose:()=>h(null)},y.budget_id):null,e.jsxs(te,{children:[e.jsx(re,{children:e.jsxs("tr",{children:[e.jsx(S,{children:"Budget"}),e.jsx(S,{children:"Limit (per user)"}),e.jsx(S,{children:"Reset"}),e.jsx(S,{children:"Users"}),e.jsx(S,{children:"Usage"}),e.jsx(S,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:T?e.jsx(ae,{colSpan:6}):U.length===0?e.jsx(ne,{colSpan:6,children:"No budgets yet. Create one to cap spending."}):U.map(n=>e.jsxs(u.Fragment,{children:[e.jsxs(ie,{selected:c===n.budget_id,onClick:()=>{o(!1),h(n.budget_id)},children:[e.jsx(C,{className:"font-medium text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{children:n.name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsx("code",{className:"text-[11px] text-[var(--otari-muted)]",title:n.budget_id,children:V(n.budget_id)})]})}),e.jsx(C,{children:n.max_budget===null?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"Unlimited"}):w(n.max_budget)}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:ge(n.budget_duration_sec)}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:n.user_count}),e.jsx(C,{children:e.jsx(fe,{budget:n})}),e.jsx(C,{children:e.jsxs("div",{className:"flex items-center justify-end gap-1.5",onClick:p=>p.stopPropagation(),children:[e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>t(p=>p===n.budget_id?null:n.budget_id),children:b===n.budget_id?"Hide history":"History"}),e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>{o(!1),h(n.budget_id)},children:"Edit"}),e.jsx(ve,{label:H(n),isPending:l.isPending,onConfirm:()=>l.mutate(n.budget_id)})]})})]}),b===n.budget_id?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:6,children:e.jsx(je,{budgetId:n.budget_id})})}):null]},n.budget_id))})]})]})}export{Pe as BudgetsPage}; | |||
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject fractional reset periods instead of rounding them.
The field promises whole days, but Math.round silently converts input such as 1.5 into two days. Validate Number.isInteger(o) and reject the value, rather than changing the operator’s input.
Suggested validation
-const o=Number(d.trim()),c=Math.round(o)*j;
+const o=Number(d.trim()),c=o*j;
-r(d.trim()===""||!Number.isFinite(o)||o<=0||!Number.isFinite(c)||c<=0?null:c)
+r(d.trim()===""||!Number.isInteger(o)||o<=0||!Number.isSafeInteger(c)?null:c)Regenerate this bundle from the source after applying the fix.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{r as u}from"./react-q-ooZ0ti.js";import{j as Y,u as G,k as K,l as Q,m as X,n as J,P as Z,E as R,I as ee,o as se}from"./index-DvNpC9jo.js";import{F as L}from"./Field-gj3-ox4q.js";import{T as te,a as re,b as S,L as ae,c as ne,d as ie,e as C}from"./Table-DEsIhjZo.js";import{C as B,I as le,a as de,b as oe,B as x,c as A,S as ue}from"./heroui-CewI8xK4.js";const ce=50;function me({value:s,onChange:r,users:a,label:i,description:l}){const[g,d]=u.useState(""),o=u.useMemo(()=>a.filter(t=>!t.user_id.startsWith("apikey-")).map(t=>({id:t.user_id,label:t.alias?`${t.user_id} (${t.alias})`:t.user_id})),[a]),c=u.useMemo(()=>{const t=g.trim().toLowerCase();return o.filter(m=>!s.includes(m.id)).filter(m=>!t||m.id.toLowerCase().includes(t)||m.label.toLowerCase().includes(t)).slice(0,ce)},[o,s,g]),h=t=>{s.includes(t)||r([...s,t]),d("")},b=t=>r(s.filter(m=>m!==t));return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:i}),l?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:l}):null]}),s.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:s.map(t=>e.jsxs("span",{className:"inline-flex items-center gap-1 rounded-full bg-[var(--otari-brand-tint)] px-2.5 py-1 font-mono text-xs text-[var(--otari-brand-dark)]",children:[t,e.jsx("button",{type:"button","aria-label":`Remove ${t}`,onClick:()=>b(t),className:"text-[var(--otari-brand-dark)] hover:text-red-700",children:"×"})]},t))}):null,o.length===0?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users yet. Create users first, then assign them here or from the Users page."}):e.jsxs(B.Root,{allowsEmptyCollection:!0,menuTrigger:"input",inputValue:g,onInputChange:d,selectedKey:null,onSelectionChange:t=>{t!=null&&h(String(t))},className:"flex flex-col gap-1",children:[e.jsxs(B.InputGroup,{children:[e.jsx(le,{"aria-label":"Add a user",placeholder:"Search users…",autoComplete:"off"}),e.jsx(B.Trigger,{})]}),e.jsx(B.Popover,{children:e.jsx(de,{items:c,className:"max-h-72 overflow-auto",children:t=>e.jsx(oe,{id:t.id,textValue:t.label,children:t.label})})})]})]})}const xe=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:2});function w(s){return xe.format(s)}const j=86400,F=3600,I=[{label:"No reset",seconds:null},{label:"Daily",seconds:j},{label:"Weekly",seconds:7*j},{label:"Monthly",seconds:30*j}];function ge(s){if(s===null)return"No reset";const r=I.find(a=>a.seconds===s);return r?r.label:s%j===0?`Every ${s/j} days`:s%F===0?`Every ${s/F} hours`:`Every ${s}s`}function $(s){if(!s)return"—";const r=new Date(s);return Number.isNaN(r.getTime())?"—":r.toLocaleString()}function he(s){const r=s.trim();if(r==="")return{value:null,valid:!0};const a=Number(r);return!Number.isFinite(a)||a<0?{value:null,valid:!1}:{value:a,valid:!0}}function pe({value:s,onChange:r}){const a=I.some(d=>d.seconds===s),[i,l]=u.useState(!a),g=s!==null&&s%j===0?String(s/j):"";return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Reset period"}),e.jsxs("div",{className:"flex flex-wrap gap-2",children:[I.map(d=>e.jsx(x,{size:"sm",variant:!i&&s===d.seconds?"primary":"outline",onPress:()=>{l(!1),r(d.seconds)},children:d.label},d.label)),e.jsx(x,{size:"sm",variant:i?"primary":"outline",onPress:()=>l(!0),children:"Custom"})]}),i?e.jsx("div",{className:"flex items-end gap-2",children:e.jsx(L,{label:"Every N days",value:g,onChange:d=>{const o=Number(d.trim()),c=Math.round(o)*j;r(d.trim()===""||!Number.isFinite(o)||o<=0||!Number.isFinite(c)||c<=0?null:c)},placeholder:"14",description:"Whole days between resets."})}):null,e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Spend returns to zero each period. A user’s clock starts when the budget is assigned to them."})]})}function O({title:s,submitLabel:r,initial:a,error:i,isPending:l,onSubmit:g,onClose:d,assignUsers:o}){const[c,h]=u.useState(a.name??""),[b,t]=u.useState(a.max_budget===null?"":String(a.max_budget)),[m,f]=u.useState(a.budget_duration_sec),[v,N]=u.useState([]),_=he(b),P=()=>{l||!_.valid||g({name:c.trim()||null,max_budget:_.value,budget_duration_sec:m},v)};return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:s}),e.jsx(R,{error:i}),e.jsx(L,{label:"Name (optional)",value:c,onChange:h,autoFocus:!0,placeholder:"team-free-tier",description:"A label to recognize this budget later."}),e.jsx(L,{label:"Spending limit (USD)",value:b,onChange:t,placeholder:"100.00",description:_.valid?"The most a single user on this budget may spend per period. Leave blank for no limit.":e.jsx("span",{className:"text-red-700",children:"Enter a non-negative number, or leave blank for no limit."})}),e.jsx(pe,{value:m,onChange:f}),o?e.jsx(me,{label:"Assign to users (optional)",description:"Attach this budget to existing users now. You can also manage assignments later on the Users page.",value:v,onChange:N,users:o}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(x,{variant:"primary",isDisabled:l||!_.valid,onPress:P,children:l?"Saving…":r}),e.jsx(x,{variant:"ghost",isDisabled:l,onPress:d,children:"Cancel"})]})]})})}function fe({budget:s}){if(s.user_count===0)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users assigned"});const r=s.total_spend;if(s.max_budget===null)return e.jsxs("span",{className:"text-xs text-[var(--otari-ink)]",children:[w(r)," spent",e.jsx("span",{className:"text-[var(--otari-muted)]",children:" · no limit"})]});const a=s.max_budget*s.user_count,i=a>0?Math.min(100,r/a*100):0,l=r>a;return e.jsxs("div",{className:"flex min-w-[140px] flex-col gap-1",children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-2 text-xs",children:[e.jsx("span",{className:"text-[var(--otari-ink)]",children:w(r)}),e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["of ",w(a)]})]}),e.jsx("div",{className:"h-1.5 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",role:"progressbar","aria-valuenow":Math.round(i),"aria-valuemin":0,"aria-valuemax":100,"aria-label":"Aggregate spend against total allocation",children:e.jsx("div",{className:`h-full rounded-full ${l?"bg-red-500":"bg-[var(--otari-brand)]"}`,style:{width:`${Math.max(i,l?100:2)}%`}})})]})}function je({budgetId:s}){const r=se(s);if(r.isLoading)return e.jsxs("div",{className:"flex items-center gap-2 px-4 py-4 text-sm text-[var(--otari-muted)]",children:[e.jsx(ue,{size:"sm"})," Loading reset history…"]});if(r.error)return e.jsx("div",{className:"px-4 py-4",children:e.jsx(R,{error:r.error})});const a=r.data??[];return a.length===0?e.jsx("div",{className:"px-4 py-4 text-sm text-[var(--otari-muted)]",children:"No resets recorded yet for this budget."}):e.jsx("div",{className:"overflow-x-auto px-4 py-3",children:e.jsxs("table",{className:"w-full border-collapse text-xs",children:[e.jsx("thead",{className:"text-left text-[var(--otari-muted)]",children:e.jsxs("tr",{children:[e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"User"}),e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"Spend cleared"}),e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"Reset at"}),e.jsx("th",{className:"py-1.5 font-medium",children:"Next reset"})]})}),e.jsx("tbody",{children:a.map(i=>e.jsxs("tr",{className:"border-t border-[var(--otari-line)]",children:[e.jsx("td",{className:"py-1.5 pr-4",children:e.jsx("code",{children:i.user_id??"—"})}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-ink)]",children:w(i.previous_spend)}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-muted)]",children:$(i.reset_at)}),e.jsx("td",{className:"py-1.5 text-[var(--otari-muted)]",children:$(i.next_reset_at)})]},i.id))})]})})}function be({onCreate:s}){return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-6",children:[e.jsxs("div",{children:[e.jsx("h2",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"No budgets yet"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"A budget caps how much a user may spend and, optionally, resets that spend on a schedule. Create one, then assign it to users to enforce a limit."})]}),e.jsx("div",{children:e.jsx(x,{variant:"primary",onPress:s,children:"Create your first budget"})})]})})}function ve({label:s,isPending:r,onConfirm:a}){const[i,l]=u.useState(!1);return i?e.jsxs("div",{className:"flex flex-col items-end gap-1.5 rounded-lg border border-amber-200 bg-amber-50 p-2 text-right",children:[e.jsxs("span",{className:"max-w-xs text-xs text-amber-800",children:["Delete ",e.jsx("strong",{children:s}),"? Users keep their spend but lose this limit. Cannot be undone."]}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(x,{size:"sm",variant:"danger",isDisabled:r,onPress:a,children:"Delete permanently"}),e.jsx(x,{size:"sm",variant:"ghost",isDisabled:r,onPress:()=>l(!1),children:"Cancel"})]})]}):e.jsx(x,{size:"sm",variant:"danger-soft",onPress:()=>l(!0),children:"Delete"})}function V(s){return s.split("-")[0]}function H(s){return s.name??V(s.budget_id)}function Pe(){const s=Y(),r=G(),a=K(),i=Q(),l=X(),g=J(),[d,o]=u.useState(!1),[c,h]=u.useState(null),[b,t]=u.useState(null),[m,f]=u.useState(null),[v,N]=u.useState(null),[_,P]=u.useState(!1),U=s.data??[],T=s.isLoading,y=U.find(n=>n.budget_id===c)??null,M=!T&&U.length===0&&!d,z=async(n,p)=>{P(!0),f(null);const D=await Promise.allSettled(p.map(k=>g.mutateAsync({id:k,body:{budget_id:n}})));P(!1);const E=D.flatMap((k,q)=>k.status==="rejected"?[p[q]]:[]);if(E.length>0){N({budgetId:n,userIds:E}),f(new Error(`Budget created, but could not assign it to: ${E.join(", ")}. Retry to try again.`));return}N(null),o(!1)},W=(n,p)=>{if(v){z(v.budgetId,v.userIds);return}f(null),a.mutate(n,{onSuccess:async D=>{if(p.length>0){await z(D.budget_id,p);return}o(!1)}})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Z,{title:"Budgets",description:"Define spending limits and reset schedules. Assign a budget to users to enforce it.",action:d||M?null:e.jsx(x,{variant:"primary",onPress:()=>{h(null),f(null),N(null),o(!0)},children:"Create budget"})}),e.jsx(R,{error:s.error??a.error??i.error??l.error??g.error}),e.jsx(ee,{children:"Assign a budget to users when you create it, or later from the Users page. Each row’s usage aggregates the spend of the users currently on that budget."}),M?e.jsx(be,{onCreate:()=>{h(null),f(null),N(null),o(!0)}}):null,d?e.jsx(O,{title:"Create budget",submitLabel:v?"Retry assignments":"Create budget",initial:{name:null,max_budget:null,budget_duration_sec:null},error:a.error??m,isPending:a.isPending||_,assignUsers:r.data??[],onSubmit:W,onClose:()=>{f(null),N(null),o(!1)}}):null,y?e.jsx(O,{title:`Edit budget ${H(y)}`,submitLabel:"Save changes",initial:{name:y.name,max_budget:y.max_budget,budget_duration_sec:y.budget_duration_sec},error:i.error,isPending:i.isPending,onSubmit:n=>i.mutate({id:y.budget_id,body:n},{onSuccess:()=>h(null)}),onClose:()=>h(null)},y.budget_id):null,e.jsxs(te,{children:[e.jsx(re,{children:e.jsxs("tr",{children:[e.jsx(S,{children:"Budget"}),e.jsx(S,{children:"Limit (per user)"}),e.jsx(S,{children:"Reset"}),e.jsx(S,{children:"Users"}),e.jsx(S,{children:"Usage"}),e.jsx(S,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:T?e.jsx(ae,{colSpan:6}):U.length===0?e.jsx(ne,{colSpan:6,children:"No budgets yet. Create one to cap spending."}):U.map(n=>e.jsxs(u.Fragment,{children:[e.jsxs(ie,{selected:c===n.budget_id,onClick:()=>{o(!1),h(n.budget_id)},children:[e.jsx(C,{className:"font-medium text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{children:n.name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsx("code",{className:"text-[11px] text-[var(--otari-muted)]",title:n.budget_id,children:V(n.budget_id)})]})}),e.jsx(C,{children:n.max_budget===null?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"Unlimited"}):w(n.max_budget)}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:ge(n.budget_duration_sec)}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:n.user_count}),e.jsx(C,{children:e.jsx(fe,{budget:n})}),e.jsx(C,{children:e.jsxs("div",{className:"flex items-center justify-end gap-1.5",onClick:p=>p.stopPropagation(),children:[e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>t(p=>p===n.budget_id?null:n.budget_id),children:b===n.budget_id?"Hide history":"History"}),e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>{o(!1),h(n.budget_id)},children:"Edit"}),e.jsx(ve,{label:H(n),isPending:l.isPending,onConfirm:()=>l.mutate(n.budget_id)})]})})]}),b===n.budget_id?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:6,children:e.jsx(je,{budgetId:n.budget_id})})}):null]},n.budget_id))})]})]})}export{Pe as BudgetsPage}; | |
| function pe({value:s,onChange:r}){const a=I.some(d=>d.seconds===s),[i,l]=u.useState(!a),g=s!==null&&s%j===0?String(s/j):"";return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Reset period"}),e.jsxs("div",{className:"flex flex-wrap gap-2",children:[I.map(d=>e.jsx(x,{size:"sm",variant:!i&&s===d.seconds?"primary":"outline",onPress:()=>{l(!1),r(d.seconds)},children:d.label},d.label)),e.jsx(x,{size:"sm",variant:i?"primary":"outline",onPress:()=>l(!0),children:"Custom"})]}),i?e.jsx("div",{className:"flex items-end gap-2",children:e.jsx(L,{label:"Every N days",value:g,onChange:d=>{const o=Number(d.trim()),c=o*j;r(d.trim()===""||!Number.isInteger(o)||o<=0||!Number.isSafeInteger(c)?null:c)},placeholder:"14",description:"Whole days between resets."})}):null,e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Spend returns to zero each period. A user’s clock starts when the budget is assigned to them."})]})} |
🤖 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/BudgetsPage-BzUF6Aa4.js` at line 1,
Update the custom reset-period validation in component pe to require
Number.isInteger(o) in addition to the existing positive, finite checks. Reject
fractional day inputs by storing null and do not round or alter the entered
value; regenerate the BudgetsPage bundle from the updated source.
| @@ -0,0 +1 @@ | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{g as qe,u as Ge,r as o}from"./react-q-ooZ0ti.js";import{v as Je,w as Ze,e as Xe,x as Qe,P as et,E as tt,F as I,I as st,y as it,z as je,A as rt,B as nt,D as Me,G as Ie,C as Le,h as oe,H as R}from"./index-DvNpC9jo.js";import{T as lt,a as at,d as ye,b as ue,L as ct,e as J,c as ot}from"./Table-DEsIhjZo.js";import{B as D,c as Pe,f as F}from"./heroui-CewI8xK4.js";function _e(t){const s=t.indexOf(":");return s>0?t.slice(0,s):"—"}function ut(t,s=Date.now()){const i=new Map;for(const n of t){const d=i.get(n.model_key)??[];d.push(n),i.set(n.model_key,d)}const c=[];for(const n of i.values()){const d=[...n].sort((h,p)=>Date.parse(h.effective_at)-Date.parse(p.effective_at)),x=[...d].reverse().find(h=>Date.parse(h.effective_at)<=s);c.push(x??d[0])}return c.sort((n,d)=>n.model_key.localeCompare(d.model_key))}const dt="otari",Ne=[{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")}],pt=[{key:"reasoning",label:"Reasoning"},{key:"tool_call",label:"Tool calling"},{key:"structured_output",label:"Structured output"},{key:"attachment",label:"Attachments"},{key:"temperature",label:"Temperature"}],Se={text:"Text",image:"Image",audio:"Audio",video:"Video",pdf:"PDF"},xt=[{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"}],mt=[{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"}],ht=[{value:"all",label:"Any release date"},{value:"365",label:"Past year"},{value:"730",label:"Past 2 years"},{value:"1095",label:"Past 3 years"}],ft=1440*60*1e3;function X(t){const s=Number(t);return t.trim()!==""&&Number.isFinite(s)&&s>=0}function Q(t){if(t.trim()==="")return!0;const s=Number(t);return Number.isFinite(s)&&s>=0}function ee(t){return t.trim()===""?null:Number(t)}function L({value:t,onChange:s,ariaLabel:i}){return e.jsx("input",{type:"number",step:"any",min:"0",inputMode:"decimal","aria-label":i,value:t,onChange:c=>s(c.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 vt({source:t}){return t==="configured"?e.jsx(F,{size:"sm",color:"default",children:"configured"}):t==="default"||t==="alias"?e.jsx(F,{size:"sm",color:"accent",children:t}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not priced"})}function te({label:t,tone:s="info",children:i}){const c=o.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":c,className:`inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] leading-none ${s==="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:c,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:i})]})}function bt(){const t=nt();return t.data?t.data.default_pricing?e.jsx(te,{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(te,{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 S({label:t,value:s}){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:s})]})}function Z({title:t,children:s}){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}),s]})}function gt({row:t}){const s=Me(),i=Ie(),[c,n]=o.useState(!1),[d,x]=o.useState(""),[h,p]=o.useState(""),[u,j]=o.useState(""),[E,A]=o.useState(""),W=()=>{x(t.inputPrice==null?"":String(t.inputPrice)),p(t.outputPrice==null?"":String(t.outputPrice)),j(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),A(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),n(!0)},T=X(d)&&X(h)&&Q(u)&&Q(E),V=()=>{T&&s.mutate({model_key:t.key,input_price_per_million:Number(d),output_price_per_million:Number(h),cache_read_price_per_million:ee(u),cache_write_price_per_million:ee(E)},{onSuccess:()=>n(!1)})};return c?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(L,{value:d,onChange:x,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(L,{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(L,{value:u,onChange:j,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(L,{value:E,onChange:A,ariaLabel:`Cache write price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(D,{size:"sm",variant:"primary",isDisabled:s.isPending||!T,onPress:V,children:"Save"}),e.jsx(D,{size:"sm",variant:"ghost",isDisabled:s.isPending,onPress:()=>n(!1),children:"Cancel"})]}),s.error?e.jsx("span",{className:"text-xs text-red-700",children:oe(s.error)}):null]}):e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx(S,{label:"Input",value:t.inputPrice==null?"—":`${R(t.inputPrice)} / 1M`}),e.jsx(S,{label:"Output",value:t.outputPrice==null?"—":`${R(t.outputPrice)} / 1M`}),e.jsx(S,{label:"Cache read",value:t.cacheReadPrice==null?"—":`${R(t.cacheReadPrice)} / 1M`}),e.jsx(S,{label:"Cache write",value:t.cacheWritePrice==null?"—":`${R(t.cacheWritePrice)} / 1M`}),e.jsxs("div",{className:"flex items-center gap-2 pt-1",children:[e.jsx(D,{size:"sm",variant:"outline",onPress:W,children:t.source==="configured"?"Edit price":"Set price"}),t.source==="configured"?e.jsxs(e.Fragment,{children:[e.jsx(Le,{confirmLabel:"Reset",isPending:i.isPending,onConfirm:()=>i.mutate(t.key),children:"Reset"}),e.jsx(te,{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,i.error?e.jsx("span",{className:"text-xs text-red-700",children:oe(i.error)}):null]})]})}function jt({row:t,metadata:s,metadataAvailable:i,onMakeAlias:c,onClose:n}){const d=(s==null?void 0:s.input_modalities)??[],x=(s==null?void 0:s.output_modalities)??[],h=pt.filter(({key:p})=>s==null?void 0:s[p]);return e.jsx(Pe,{children:e.jsxs(Pe.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}),s!=null&&s.deprecated?e.jsx(F,{size:"sm",color:"danger",children:"deprecated"}):null]}),s!=null&&s.family?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:s.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:"✕"})]}),s!=null&&s.description?e.jsx("p",{className:"text-sm text-[var(--otari-ink)]",children:s.description}):null,e.jsxs(Z,{title:"Pricing",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(vt,{source:t.source}),t.isDiscovered?null:e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not discovered"})]}),e.jsx(gt,{row:t},t.key),e.jsx(D,{size:"sm",variant:"outline",onPress:()=>c(t.key),children:"Make an alias"})]}),e.jsxs(Z,{title:"Specs",children:[e.jsx(S,{label:"Context window",value:je(t.contextWindow)}),e.jsx(S,{label:"Max output",value:je((s==null?void 0:s.max_output_tokens)??null)}),e.jsx(S,{label:"Knowledge cutoff",value:(s==null?void 0:s.knowledge_cutoff)??"—"}),e.jsx(S,{label:"Released",value:rt(s==null?void 0:s.release_date)}),e.jsx(S,{label:"Open weights",value:s?s.open_weights?"Yes":"No":"—"})]}),e.jsx(Z,{title:"Modalities",children:d.length===0&&x.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:"}),d.map(p=>e.jsx(F,{size:"sm",color:"default",children:Se[p]??p},p))]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"Out:"}),x.map(p=>e.jsx(F,{size:"sm",color:"default",children:Se[p]??p},p))]})]})}),e.jsx(Z,{title:"Capabilities",children:h.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:h.map(({key:p,label:u})=>e.jsx(F,{size:"sm",color:"default",children:u},p))}):e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:i?"None reported.":"Extended metadata unavailable (models.dev disabled or unreachable)."})})]})})}const yt=15,Pt=[{value:"15",label:"15 per page"},{value:"25",label:"25 per page"},{value:"50",label:"50 per page"}];function _t({value:t,onChange:s,placeholder:i}){return e.jsx("input",{type:"search",value:t,onChange:c=>s(c.target.value),placeholder:i,"aria-label":i,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 Nt({page:t,pageCount:s,total:i,pageSize:c,onPage:n,onPageSize:d}){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:[s>1?`Page ${t+1} of ${s} · `:"",it(i)," model",i===1?"":"s"]}),e.jsxs("span",{className:"inline-flex gap-2",children:[e.jsx(I,{ariaLabel:"Rows per page",value:String(c),onChange:x=>d(Number(x)),options:Pt}),s>1?e.jsxs(e.Fragment,{children:[e.jsx(D,{size:"sm",variant:"outline",isDisabled:t===0,onPress:()=>n(t-1),children:"Prev"}),e.jsx(D,{size:"sm",variant:"outline",isDisabled:t>=s-1,onPress:()=>n(t+1),children:"Next"})]}):null]})]})}const De="otari.dashboard.modelsSort",ce={col:"model",dir:"asc"},St=["model","released","input","output"];function kt(){if(typeof window>"u")return ce;try{const t=window.localStorage.getItem(De);if(!t)return ce;const s=JSON.parse(t);if(St.includes(s.col)&&(s.dir==="asc"||s.dir==="desc"))return{col:s.col,dir:s.dir}}catch{}return ce}function ke({label:t,col:s,sort:i,onSort:c,align:n="left",info:d}){const x=i.col===s;return e.jsx(ue,{className:n==="right"?"text-right":void 0,ariaSort:x?i.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:()=>c(s),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:x?i.dir==="asc"?"▲":"▼":"↕"})]}),d]})})}function Ct({row:t,onClose:s}){const i=Me(),c=Ie(),[n,d]=o.useState(t.inputPrice==null?"":String(t.inputPrice)),[x,h]=o.useState(t.outputPrice==null?"":String(t.outputPrice)),[p,u]=o.useState(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),[j,E]=o.useState(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),A=X(n)&&X(x)&&Q(p)&&Q(j),W=()=>{A&&i.mutate({model_key:t.key,input_price_per_million:Number(n),output_price_per_million:Number(x),cache_read_price_per_million:ee(p),cache_write_price_per_million:ee(j)},{onSuccess:s})};return e.jsxs("div",{className:"flex flex-wrap items-center gap-3 px-4 py-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(L,{value:n,onChange:d,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(L,{value:x,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(L,{value:p,onChange:u,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(L,{value:j,onChange:E,ariaLabel:`Cache write price for ${t.key}`})]}),e.jsx(D,{size:"sm",variant:"primary",isDisabled:i.isPending||!A,onPress:W,children:i.isPending?"Saving…":"Save"}),e.jsx(D,{size:"sm",variant:"ghost",isDisabled:i.isPending,onPress:s,children:"Cancel"}),t.source==="configured"?e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(Le,{confirmLabel:"Reset",isPending:c.isPending,onConfirm:()=>c.mutate(t.key,{onSuccess:s}),children:"Reset"}),e.jsx(te,{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,i.error||c.error?e.jsx("span",{className:"text-xs text-red-700",children:oe(i.error??c.error)}):null]})}function Ce({primary:t,secondary:s,rowKey:i,primaryLabel:c,secondaryLabel:n,onEdit:d}){const x=(h,p)=>e.jsx("button",{type:"button","aria-label":`Edit ${p} price for ${i}`,className:"tabular-nums hover:text-[var(--otari-brand-dark)] hover:underline",onClick:u=>{u.stopPropagation(),d()},children:h==null?"—":R(h)});return e.jsxs("span",{className:"inline-flex items-center justify-end gap-1",children:[x(t,c),e.jsx("span",{className:"text-[var(--otari-muted)]",children:"/"}),x(s,n)]})}function Mt({rows:t,isLoading:s,empty:i,sort:c,onSort:n,selectedKey:d,onSelect:x,pricingKey:h,onSetPricingKey:p}){return e.jsxs(lt,{children:[e.jsx(at,{children:e.jsxs(ye,{children:[e.jsx(ke,{label:"Model",col:"model",sort:c,onSort:n}),e.jsx(ue,{children:"Provider"}),e.jsx(ke,{label:"In / Out $ / 1M",col:"input",sort:c,onSort:n,align:"right",info:e.jsx(bt,{})}),e.jsx(ue,{className:"text-right",children:"Cache r / w $ / 1M"})]})}),e.jsx("tbody",{children:s?e.jsx(ct,{colSpan:4}):t.length>0?t.map(u=>{const j=()=>p(h===u.key?null:u.key);return e.jsxs(o.Fragment,{children:[e.jsxs(ye,{onClick:()=>x(u.key),selected:u.key===d,children:[e.jsx(J,{className:"font-medium break-all",children:u.model}),e.jsx(J,{className:"text-[var(--otari-muted)]",children:u.provider}),e.jsx(J,{className:"text-right",children:e.jsx(Ce,{primary:u.inputPrice,secondary:u.outputPrice,rowKey:u.key,primaryLabel:"input",secondaryLabel:"output",onEdit:j})}),e.jsx(J,{className:"text-right",children:e.jsx(Ce,{primary:u.cacheReadPrice,secondary:u.cacheWritePrice,rowKey:u.key,primaryLabel:"cache read",secondaryLabel:"cache write",onEdit:j})})]}),h===u.key?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)] last:border-b-0",children:e.jsx("td",{colSpan:4,children:e.jsx(Ct,{row:u,onClose:()=>p(null)})})}):null]},u.key)}):e.jsx(ot,{colSpan:4,children:i})})]})}function It({providers:t}){return t.length===0?null:e.jsxs(st,{tone:"warning",children:["Could not list ",t.map(s=>s.provider).join(", "),". Check that provider's credentials in config.yml; its models are missing from the list below."]})}function $t(){var ve,be,ge;const t=qe(),[s]=Ge(),i=Je(),c=Ze(),n=Xe(),d=Qe(),[x,h]=o.useState(""),[p,u]=o.useState(0),[j,E]=o.useState(yt),[A,W]=o.useState(null),[T,V]=o.useState(null),[k,Ee]=o.useState(kt);o.useEffect(()=>{try{window.localStorage.setItem(De,JSON.stringify(k))}catch{}},[k]);const[C,de]=o.useState(s.get("provider")||"all"),[O,Ae]=o.useState("all"),[z,Te]=o.useState("all"),[B,Oe]=o.useState("all"),[se,$e]=o.useState("0"),[Y,Fe]=o.useState(""),[H,We]=o.useState("all"),M=((ve=d.data)==null?void 0:ve.models)??{},ze=((be=d.data)==null?void 0:be.available)??!1,pe=o.useMemo(()=>{var r;return new Set((((r=n.data)==null?void 0:r.providers)??[]).flatMap(m=>m.models.map(l=>l.key)))},[n.data]),Be=r=>{h(r),u(0)},$=r=>m=>{r(m),u(0)},Ke=r=>{Ee(m=>m.col===r?{col:r,dir:m.dir==="asc"?"desc":"asc"}:{col:r,dir:r==="released"?"desc":"asc"}),u(0)},w=o.useMemo(()=>{var y,b,g,_,P;const r=new Map(ut(c.data??[]).map(a=>[a.model_key,a])),m=[],l=new Set,f=(a,ae,Ue,v)=>{if(l.has(a))return;l.add(a);const N=r.get(a);m.push({key:a,model:ae,provider:Ue,isDiscovered:pe.has(a),contextWindow:(v==null?void 0:v.contextWindow)??null,inputPrice:N?N.input_price_per_million:(v==null?void 0:v.inputPrice)??null,outputPrice:N?N.output_price_per_million:(v==null?void 0:v.outputPrice)??null,cacheReadPrice:N?N.cache_read_price_per_million:(v==null?void 0:v.cacheReadPrice)??null,cacheWritePrice:N?N.cache_write_price_per_million:(v==null?void 0:v.cacheWritePrice)??null,source:N?"configured":(v==null?void 0:v.source)??"none"})};for(const a of((y=i.data)==null?void 0:y.data)??[]){if(a.owned_by===dt)continue;const ae=a.pricing_source==="default"?"default":a.pricing?"configured":"none";f(a.id,a.id,a.owned_by||_e(a.id),{key:a.id,model:a.id,provider:a.owned_by,contextWindow:a.context_window,inputPrice:((b=a.pricing)==null?void 0:b.input_price_per_million)??null,outputPrice:((g=a.pricing)==null?void 0:g.output_price_per_million)??null,cacheReadPrice:((_=a.pricing)==null?void 0:_.cache_read_price_per_million)??null,cacheWritePrice:((P=a.pricing)==null?void 0:P.cache_write_price_per_million)??null,source:ae})}for(const a of r.keys())f(a,a,_e(a));return m},[i.data,c.data,pe]),Re=o.useMemo(()=>new Map(w.map(r=>[r.key,r])),[w]),U=o.useMemo(()=>{var l,f,y;const r=w.map(b=>{var g,_;return{...b,contextWindow:b.contextWindow??((g=M[b.key])==null?void 0:g.context_window)??null,releaseDate:((_=M[b.key])==null?void 0:_.release_date)??null}}),m=new Set(r.map(b=>b.key));for(const b of((l=n.data)==null?void 0:l.providers)??[])for(const g of b.models)m.has(g.key)||(m.add(g.key),r.push({key:g.key,model:g.key,provider:b.provider,isDiscovered:!0,contextWindow:((f=M[g.key])==null?void 0:f.context_window)??null,releaseDate:((y=M[g.key])==null?void 0:y.release_date)??null,inputPrice:null,outputPrice:null,cacheReadPrice:null,cacheWritePrice:null,source:"none"}));return r},[w,n.data,M]),Ve=(((ge=n.data)==null?void 0:ge.providers)??[]).filter(r=>!r.ok),q=o.useMemo(()=>{const r=Array.from(new Set(U.map(m=>m.provider))).sort((m,l)=>m.localeCompare(l));return[{value:"all",label:"All providers"},...r.map(m=>({value:m,label:m}))]},[U]);o.useEffect(()=>{C==="all"||q.length<=1||q.some(r=>r.value===C)||de("all")},[q,C]);const K=x.trim().toLowerCase(),ie=Number(se)||0,re=Y===""?Number.POSITIVE_INFINITY:Number(Y),ne=H==="all"?null:Date.now()-Number(H)*ft,le=o.useMemo(()=>{const r=l=>{if(K&&!l.key.toLowerCase().includes(K)&&!l.provider.toLowerCase().includes(K)||C!=="all"&&l.provider!==C||O==="configured"&&l.source!=="configured"||O==="default"&&l.source!=="default"||O==="priced"&&l.inputPrice==null||O==="unpriced"&&l.inputPrice!=null||z==="discovered"&&!l.isDiscovered||z==="custom"&&l.isDiscovered)return!1;if(B!=="all"){const f=Ne.find(b=>b.value===B),y=M[l.key];if(!f||!y||!f.test(y))return!1}if(ie>0&&(l.contextWindow==null||l.contextWindow<ie)||re!==Number.POSITIVE_INFINITY&&(l.inputPrice==null||l.inputPrice>re))return!1;if(ne!=null){const f=l.releaseDate?Date.parse(l.releaseDate):Number.NaN;if(Number.isNaN(f)||f<ne)return!1}return!0},m=(l,f)=>{const y=k.dir==="asc"?1:-1;if(k.col==="model")return l.model.localeCompare(f.model)*y;if(k.col==="released"){const P=l.releaseDate??null,a=f.releaseDate??null;return!P&&!a?l.model.localeCompare(f.model):P?a?(P<a?-1:P>a?1:0)*y||l.model.localeCompare(f.model):-1:1}const b=P=>k.col==="input"?P.inputPrice:P.outputPrice,g=b(l),_=b(f);return g==null&&_==null?l.model.localeCompare(f.model):g==null?1:_==null?-1:(g-_)*y||l.model.localeCompare(f.model)};return U.filter(r).sort(m)},[U,K,C,O,z,B,ie,re,ne,M,k]),xe=le.length,me=Math.max(1,Math.ceil(xe/j)),he=Math.min(p,me-1),fe=he*j,Ye=le.slice(fe,fe+j),He=i.isLoading||c.isLoading||n.isLoading,we=K!==""||C!=="all"||O!=="all"||z!=="all"||B!=="all"||se!=="0"||Y!==""||H!=="all"?"No models match your filters.":"No models yet. Add a provider on the Providers page, or price a model below.",G=T?le.find(r=>r.key===T)??Re.get(T)??null:null;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(et,{title:"Models",description:"Every model your providers can serve. Set a price on any model so budgets and usage tracking work."}),e.jsx(tt,{error:i.error??c.error??n.error??d.error}),e.jsxs("div",{className:`grid gap-4 lg:items-start ${G?"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(_t,{value:x,onChange:Be,placeholder:"Search models…"}),e.jsx(I,{ariaLabel:"Filter by provider",value:C,onChange:$(de),options:q}),e.jsx(I,{ariaLabel:"Filter by pricing",value:O,onChange:$(Ae),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(I,{ariaLabel:"Filter by source",value:z,onChange:$(Te),options:[{value:"all",label:"Any source"},{value:"discovered",label:"Discovered"},{value:"custom",label:"Custom (not discovered)"}]}),e.jsx(I,{ariaLabel:"Filter by capability",value:B,onChange:$(Oe),options:[{value:"all",label:"Any capability"},...Ne.map(r=>({value:r.value,label:r.label}))]}),e.jsx(I,{ariaLabel:"Minimum context window",value:se,onChange:$($e),options:xt}),e.jsx(I,{ariaLabel:"Maximum input price",value:Y,onChange:$(Fe),options:mt}),e.jsx(I,{ariaLabel:"Filter by release date",value:H,onChange:$(We),options:ht})]}),e.jsx(It,{providers:Ve}),e.jsx(Mt,{rows:Ye,isLoading:He,empty:we,sort:k,onSort:Ke,selectedKey:T,onSelect:V,pricingKey:A,onSetPricingKey:W}),e.jsx("div",{className:"flex flex-wrap items-center justify-between gap-2",children:e.jsx(Nt,{page:he,pageCount:me,total:xe,pageSize:j,onPage:u,onPageSize:r=>{E(r),u(0)}})})]}),G?e.jsx("aside",{className:"lg:sticky lg:top-4",children:e.jsx(jt,{row:G,metadata:M[G.key],metadataAvailable:ze,onMakeAlias:r=>t(`/aliases?target=${encodeURIComponent(r)}`),onClose:()=>V(null)})}):null]})]})}export{$t as ModelsPage}; | |||
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make model-row selection keyboard accessible.
Mt attaches onClick to <tr> without a focus target or keyboard handler, so keyboard users cannot open model details or reach the alias action through row selection. Use a focusable control in the model cell, or add appropriate focus/keyboard behavior to the row.
🤖 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-Cgm8YsX7.js` at line 1, The
model rows rendered by Mt are clickable but not keyboard accessible. Update the
row selection interaction around Mt and its model-cell rendering to provide a
focusable control or equivalent row focus and keyboard handling, ensuring
Enter/Space opens the same model details as clicking while preserving access to
the alias action.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== repo files matching ModelsPage =="
git ls-files | rg '(^|/)ModelsPage\.(tsx|jsx|ts|js)$|src/gateway/static/dashboard/assets/ModelsPage-.*\.js$' || true
echo
echo "== source if present =="
if [ -f web/src/pages/ModelsPage.tsx ]; then
wc -l web/src/pages/ModelsPage.tsx
rg -n "Provider|provider|Context window|context|context_window|inputPrice|outputPrice|contextWindow" web/src/pages/ModelsPage.tsx || true
else
echo "web/src/pages/ModelsPage.tsx: not present"
fi
echo
echo "== target bundle relevant snippets =="
python3 - <<'PY'
from pathlib import Path
p = Path("src/gateway/static/dashboard/assets/ModelsPage-Cgm8YsX7.js")
text = p.read_text()
for needle in ['"Provider"', "'Provider'", 'contextWindow', '"Context window"', "'Context window'", "inputPrice", "outputPrice"]:
print(f"\n-- {needle!r} occurrences --")
for i,line in enumerate(text.splitlines(),1):
if needle in line:
start=max(1,i-3); end=min(200, i+2000) # compact for minified
lines=line.strip().split("import{j as e}from")[0]+line
print(f"{i}: {line[:600]}")
PYRepository: mozilla-ai/otari
Length of output: 8783
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate and inspect model-related UI definitions =="
rg -n "ModelsPage|mt=|Mt=|jt=|contextWindow|provider|Provider|context_window|input_price|output_price|context window" web/src src/gateway/static/dashboard/assets || trueRepository: mozilla-ai/otari
Length of output: 50373
Regenerate the dashboard bundle for the ModelsPage changes.
web/src/pages/ModelsPage.tsx still contains provider and context-window UI, and src/gateway/static/dashboard/assets/ModelsPage-Cgm8YsX7.js is a compiled asset that shouldn’t live in the repo. Rebuild after removing/adjusting those UI fields so the generated bundle doesn’t diverge from the source.
🤖 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-Cgm8YsX7.js` at line 1, The
compiled ModelsPage asset is stale and should not be committed. Update the
source ModelsPage implementation to remove or adjust the provider and
context-window UI, then regenerate the dashboard bundle so the generated
ModelsPage asset matches the source; remove the checked-in compiled asset if
generated assets are excluded from the repository.
| @@ -1 +1 @@ | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{r as d}from"./react-q-ooZ0ti.js";import{a3 as k,a4 as N,P as S,E as _,g as y,a5 as T,F as C}from"./index-BcI4fr4n.js";import{c as j,B as b}from"./heroui-CewI8xK4.js";function P(s,t){return{[s]:t}}const R=[{key:"web_search",label:"Web search",blurb:"Backend for otari_web_search tools (a SearXNG instance or a search adapter).",order:["web_search_url","web_search_engines","web_search_max_results","web_search_extract","web_search_purpose_hint"]},{key:"sandbox",label:"Code execution",blurb:"Backend for otari_code_execution tools (the sandbox that runs generated code).",order:["sandbox_url","sandbox_purpose_hint"]},{key:"guardrails",label:"Guardrails",blurb:"Default input-guardrails service used when a request does not pass its own guardrail URL.",order:["guardrails_url"]}];function D(){const[s,t]=d.useState(null),a=d.useRef(void 0),n=r=>{t(r),window.clearTimeout(a.current),a.current=window.setTimeout(()=>t(null),2500)};return d.useEffect(()=>()=>window.clearTimeout(a.current),[]),[s,n]}function L({message:s}){return s?e.jsxs("div",{role:"status","aria-live":"polite",className:"fixed right-4 bottom-4 z-50 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700 shadow-lg",children:[e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2","aria-hidden":!0,className:"h-5 w-5",children:e.jsx("path",{d:"M20 6 9 17l-5-5",strokeLinecap:"round",strokeLinejoin:"round"})}),s]}):null}const v="rounded-md border border-[var(--otari-line)] bg-[var(--otari-surface)] px-2 py-1 text-sm focus:border-[var(--otari-brand)] focus:outline-none disabled:opacity-50";function g({field:s,help:t}){return e.jsxs("div",{className:"min-w-0",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:s.key}),s.description?e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:s.description}):null,t?e.jsx("p",{className:"mt-1 text-xs text-[var(--otari-muted)]",children:t}):null]})}function $({field:s,onSave:t,saveError:a,disabled:n}){const r=typeof s.value=="string"?s.value:"",[i,c]=d.useState(r),l=T();d.useEffect(()=>{c(r)},[r]);const m=i.trim()!==r,x=i.trim();return e.jsxs("div",{className:"flex flex-col gap-1.5 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6",children:[e.jsx(g,{field:s,help:"Leave blank and Save to fall back to the configured default."}),e.jsxs("div",{className:"flex shrink-0 flex-col items-start gap-1.5 sm:items-end",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"text",inputMode:"url","aria-label":s.key,value:i,disabled:n,placeholder:"unset",onChange:o=>{c(o.target.value),l.reset()},className:`w-64 ${v}`}),e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!m,onPress:()=>t(x===""?null:x),children:"Save"}),e.jsx(b,{size:"sm",variant:"outline","aria-label":`Test ${s.service}`,isDisabled:x===""||l.isPending,onPress:()=>l.mutate({service:s.service,url:x}),children:l.isPending?"Testing…":"Test"})]}),e.jsx("span",{role:"status","aria-live":"polite",className:"block max-w-md break-words text-xs",children:l.isPending?null:l.error?e.jsx("span",{className:"text-red-700",children:y(l.error)}):l.data?e.jsx("span",{className:l.data.ok?"font-medium text-green-700":"text-red-700",children:l.data.reason}):null}),a?e.jsx("span",{className:"max-w-md break-words text-xs text-red-700",children:a}):null]})]})}function B({field:s,onSave:t,saveError:a,disabled:n}){const r=typeof s.value=="string"?s.value:"",[i,c]=d.useState(r);d.useEffect(()=>{c(r)},[r]);const l=i!==r;return e.jsxs("div",{className:"flex flex-col gap-1.5 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6",children:[e.jsx(g,{field:s}),e.jsxs("div",{className:"flex shrink-0 flex-col items-start gap-1.5 sm:items-end",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"text","aria-label":s.key,value:i,disabled:n,placeholder:"default",onChange:m=>c(m.target.value),className:`w-64 ${v}`}),e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!l,onPress:()=>t(i.trim()===""?null:i.trim()),children:"Save"})]}),a?e.jsx("span",{className:"max-w-md break-words text-xs text-red-700",children:a}):null]})]})}function E({field:s,onSave:t,saveError:a,disabled:n}){const r=typeof s.value=="number"?String(s.value):"",[i,c]=d.useState(r);d.useEffect(()=>{c(r)},[r]);const l=i.trim(),m=Number(l),o=(l===""||Number.isInteger(m)&&m>=1)&&l!==r;return e.jsxs("div",{className:"flex flex-col gap-1.5 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6",children:[e.jsx(g,{field:s,help:"Leave blank to use the backend default."}),e.jsxs("div",{className:"flex shrink-0 flex-col items-start gap-1.5 sm:items-end",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"number",min:"1",step:"1",inputMode:"numeric","aria-label":s.key,value:i,disabled:n,placeholder:"default",onChange:p=>c(p.target.value),className:`w-28 text-right tabular-nums ${v}`}),e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!o,onPress:()=>t(l===""?null:m),children:"Save"})]}),a?e.jsx("span",{className:"max-w-md break-words text-xs text-red-700",children:a}):null]})]})}function M({field:s,onSave:t,saveError:a,disabled:n}){const r=s.value===!0?"on":s.value===!1?"off":"default";return e.jsxs("div",{className:"flex flex-col gap-1.5 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6",children:[e.jsx(g,{field:s}),e.jsxs("div",{className:"flex shrink-0 flex-col items-start gap-1.5 sm:items-end",children:[e.jsx(C,{ariaLabel:s.key,value:r,onChange:i=>t(i==="default"?null:i==="on"),options:[{value:"default",label:"Default"},{value:"on",label:"On"},{value:"off",label:"Off"}],disabled:n}),a?e.jsx("span",{className:"max-w-md break-words text-xs text-red-700",children:a}):null]})]})}function z({field:s,onSave:t,saveError:a,disabled:n}){return s.type==="url"?e.jsx($,{field:s,onSave:t,saveError:a,disabled:n}):s.type==="int"?e.jsx(E,{field:s,onSave:t,saveError:a,disabled:n}):s.type==="bool"?e.jsx(M,{field:s,onSave:t,saveError:a,disabled:n}):e.jsx(B,{field:s,onSave:t,saveError:a,disabled:n})}function q(){const s=k(),t=N(),[a,n]=D(),[r,i]=d.useState({}),c=s.data,l=!c||t.isPending,m=new Map(((c==null?void 0:c.fields)??[]).map(o=>[o.key,o])),x=(o,p)=>{i(h=>{const{[o.key]:f,...u}=h;return u}),t.mutate(P(o.key,p),{onSuccess:()=>n(`${o.key} saved`),onError:h=>i(f=>({...f,[o.key]:y(h)}))})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(S,{title:"Tools & Guardrails",description:"Configure the built-in tool and guardrail service endpoints without a restart. Changes apply immediately and persist. URLs are validated for shape (http/https) and can be tested for reachability before saving; the network-safety gates for these services live on the Settings page."}),e.jsx(_,{error:s.error}),R.map(o=>{const p=o.order.map(u=>m.get(u)).filter(u=>u!==void 0),h=((c==null?void 0:c.fields)??[]).filter(u=>u.service===o.key&&!o.order.includes(u.key)),f=[...p,...h];return f.length===0?null:e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:o.label}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:o.blurb}),e.jsx(j,{children:e.jsx(j.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:f.map(u=>e.jsx(z,{field:u,onSave:w=>x(u,w),saveError:r[u.key],disabled:l},u.key))})})]},o.key)}),e.jsx(L,{message:a})]})}export{q as ToolsGuardrailsPage}; | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{r as d}from"./react-q-ooZ0ti.js";import{a6 as k,a7 as N,P as S,E as _,h as y,a8 as T,F as C}from"./index-DvNpC9jo.js";import{c as j,B as b}from"./heroui-CewI8xK4.js";function P(s,t){return{[s]:t}}const R=[{key:"web_search",label:"Web search",blurb:"Backend for otari_web_search tools (a SearXNG instance or a search adapter).",order:["web_search_url","web_search_engines","web_search_max_results","web_search_extract","web_search_purpose_hint"]},{key:"sandbox",label:"Code execution",blurb:"Backend for otari_code_execution tools (the sandbox that runs generated code).",order:["sandbox_url","sandbox_purpose_hint"]},{key:"guardrails",label:"Guardrails",blurb:"Default input-guardrails service used when a request does not pass its own guardrail URL.",order:["guardrails_url"]}];function D(){const[s,t]=d.useState(null),a=d.useRef(void 0),n=r=>{t(r),window.clearTimeout(a.current),a.current=window.setTimeout(()=>t(null),2500)};return d.useEffect(()=>()=>window.clearTimeout(a.current),[]),[s,n]}function L({message:s}){return s?e.jsxs("div",{role:"status","aria-live":"polite",className:"fixed right-4 bottom-4 z-50 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700 shadow-lg",children:[e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2","aria-hidden":!0,className:"h-5 w-5",children:e.jsx("path",{d:"M20 6 9 17l-5-5",strokeLinecap:"round",strokeLinejoin:"round"})}),s]}):null}const v="rounded-md border border-[var(--otari-line)] bg-[var(--otari-surface)] px-2 py-1 text-sm focus:border-[var(--otari-brand)] focus:outline-none disabled:opacity-50";function g({field:s,help:t}){return e.jsxs("div",{className:"min-w-0",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:s.key}),s.description?e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:s.description}):null,t?e.jsx("p",{className:"mt-1 text-xs text-[var(--otari-muted)]",children:t}):null]})}function $({field:s,onSave:t,saveError:a,disabled:n}){const r=typeof s.value=="string"?s.value:"",[i,c]=d.useState(r),l=T();d.useEffect(()=>{c(r)},[r]);const m=i.trim()!==r,x=i.trim();return e.jsxs("div",{className:"flex flex-col gap-1.5 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6",children:[e.jsx(g,{field:s,help:"Leave blank and Save to fall back to the configured default."}),e.jsxs("div",{className:"flex shrink-0 flex-col items-start gap-1.5 sm:items-end",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"text",inputMode:"url","aria-label":s.key,value:i,disabled:n,placeholder:"unset",onChange:o=>{c(o.target.value),l.reset()},className:`w-64 ${v}`}),e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!m,onPress:()=>t(x===""?null:x),children:"Save"}),e.jsx(b,{size:"sm",variant:"outline","aria-label":`Test ${s.service}`,isDisabled:x===""||l.isPending,onPress:()=>l.mutate({service:s.service,url:x}),children:l.isPending?"Testing…":"Test"})]}),e.jsx("span",{role:"status","aria-live":"polite",className:"block max-w-md break-words text-xs",children:l.isPending?null:l.error?e.jsx("span",{className:"text-red-700",children:y(l.error)}):l.data?e.jsx("span",{className:l.data.ok?"font-medium text-green-700":"text-red-700",children:l.data.reason}):null}),a?e.jsx("span",{className:"max-w-md break-words text-xs text-red-700",children:a}):null]})]})}function B({field:s,onSave:t,saveError:a,disabled:n}){const r=typeof s.value=="string"?s.value:"",[i,c]=d.useState(r);d.useEffect(()=>{c(r)},[r]);const l=i!==r;return e.jsxs("div",{className:"flex flex-col gap-1.5 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6",children:[e.jsx(g,{field:s}),e.jsxs("div",{className:"flex shrink-0 flex-col items-start gap-1.5 sm:items-end",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"text","aria-label":s.key,value:i,disabled:n,placeholder:"default",onChange:m=>c(m.target.value),className:`w-64 ${v}`}),e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!l,onPress:()=>t(i.trim()===""?null:i.trim()),children:"Save"})]}),a?e.jsx("span",{className:"max-w-md break-words text-xs text-red-700",children:a}):null]})]})}function E({field:s,onSave:t,saveError:a,disabled:n}){const r=typeof s.value=="number"?String(s.value):"",[i,c]=d.useState(r);d.useEffect(()=>{c(r)},[r]);const l=i.trim(),m=Number(l),o=(l===""||Number.isInteger(m)&&m>=1)&&l!==r;return e.jsxs("div",{className:"flex flex-col gap-1.5 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6",children:[e.jsx(g,{field:s,help:"Leave blank to use the backend default."}),e.jsxs("div",{className:"flex shrink-0 flex-col items-start gap-1.5 sm:items-end",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"number",min:"1",step:"1",inputMode:"numeric","aria-label":s.key,value:i,disabled:n,placeholder:"default",onChange:p=>c(p.target.value),className:`w-28 text-right tabular-nums ${v}`}),e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!o,onPress:()=>t(l===""?null:m),children:"Save"})]}),a?e.jsx("span",{className:"max-w-md break-words text-xs text-red-700",children:a}):null]})]})}function M({field:s,onSave:t,saveError:a,disabled:n}){const r=s.value===!0?"on":s.value===!1?"off":"default";return e.jsxs("div",{className:"flex flex-col gap-1.5 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6",children:[e.jsx(g,{field:s}),e.jsxs("div",{className:"flex shrink-0 flex-col items-start gap-1.5 sm:items-end",children:[e.jsx(C,{ariaLabel:s.key,value:r,onChange:i=>t(i==="default"?null:i==="on"),options:[{value:"default",label:"Default"},{value:"on",label:"On"},{value:"off",label:"Off"}],disabled:n}),a?e.jsx("span",{className:"max-w-md break-words text-xs text-red-700",children:a}):null]})]})}function z({field:s,onSave:t,saveError:a,disabled:n}){return s.type==="url"?e.jsx($,{field:s,onSave:t,saveError:a,disabled:n}):s.type==="int"?e.jsx(E,{field:s,onSave:t,saveError:a,disabled:n}):s.type==="bool"?e.jsx(M,{field:s,onSave:t,saveError:a,disabled:n}):e.jsx(B,{field:s,onSave:t,saveError:a,disabled:n})}function q(){const s=k(),t=N(),[a,n]=D(),[r,i]=d.useState({}),c=s.data,l=!c||t.isPending,m=new Map(((c==null?void 0:c.fields)??[]).map(o=>[o.key,o])),x=(o,p)=>{i(h=>{const{[o.key]:f,...u}=h;return u}),t.mutate(P(o.key,p),{onSuccess:()=>n(`${o.key} saved`),onError:h=>i(f=>({...f,[o.key]:y(h)}))})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(S,{title:"Tools & Guardrails",description:"Configure the built-in tool and guardrail service endpoints without a restart. Changes apply immediately and persist. URLs are validated for shape (http/https) and can be tested for reachability before saving; the network-safety gates for these services live on the Settings page."}),e.jsx(_,{error:s.error}),R.map(o=>{const p=o.order.map(u=>m.get(u)).filter(u=>u!==void 0),h=((c==null?void 0:c.fields)??[]).filter(u=>u.service===o.key&&!o.order.includes(u.key)),f=[...p,...h];return f.length===0?null:e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:o.label}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:o.blurb}),e.jsx(j,{children:e.jsx(j.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:f.map(u=>e.jsx(z,{field:u,onSave:w=>x(u,w),saveError:r[u.key],disabled:l},u.key))})})]},o.key)}),e.jsx(L,{message:a})]})}export{q as ToolsGuardrailsPage}; | |||
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Prevent stale test results from being shown for a new URL.
The field remains editable while l.mutate(...) is pending, but the returned l.data is displayed without the URL it belongs to. If the operator edits the value before the request completes, the old result can be shown for the new URL. Track the tested URL and only render matching results, or disable the input while testing.
🤖 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/ToolsGuardrailsPage-Dz4sDo0m.js` at line
1, Update the URL test flow in component $ so its displayed l.data result is
associated with the URL submitted to l.mutate. Track the tested URL and render
success or failure details only when it matches the current trimmed input, or
disable editing while the test is pending; preserve the existing test and save
behavior.
| @@ -1 +1 @@ | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{g as re,r as f}from"./react-q-ooZ0ti.js";import{u as oe,c as F,P as ie,E as ce,d as Q,S as p,N as _,L as y,M as U,Q as de,O as w}from"./index-BcI4fr4n.js";import{T as ue,a as he,b as M,L as me,c as xe,d as ge,e as P}from"./Table-DEsIhjZo.js";import{B as k,S as V}from"./heroui-CewI8xK4.js";function O(a){return a.toLocaleString()}function fe(a){return a===null?"—":a<1e3?`${Math.round(a)} ms`:`${(a/1e3).toFixed(2)} s`}const je=3600,$=86400,L=[{label:"Last hour",seconds:je,bucket:"hour"},{label:"24h",seconds:$,bucket:"hour"},{label:"7d",seconds:7*$,bucket:"day"},{label:"30d",seconds:30*$,bucket:"day"},{label:"All",seconds:null,bucket:"day"}],A=L[3],D=15;function E(a){return new Date(Date.now()-a*1e3).toISOString()}function W({title:a,rows:l,totalCost:n,emptyLabel:N,onDrill:x,loading:b}){const[d,m]=f.useState(!1),u=d?l:l.slice(0,D),v=l.length-u.length;return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:a}),e.jsxs(ue,{children:[e.jsx(he,{children:e.jsxs("tr",{children:[e.jsx(M,{children:a.replace("Spend by ","")}),e.jsx(M,{className:"text-right",children:"Requests"}),e.jsx(M,{className:"text-right",children:"Spend"})]})}),e.jsx("tbody",{children:b?e.jsx(me,{colSpan:3}):l.length===0?e.jsx(xe,{colSpan:3,children:N}):u.map((o,i)=>{const g=o.is_other,j=!g&&o.key!==null,h=n>0?o.cost/n:0;return e.jsxs(ge,{onClick:j?()=>x(o.key):void 0,children:[e.jsx(P,{className:"text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"truncate",children:g?`Other (${o.requests.toLocaleString()} req)`:o.key??"(unknown)"}),e.jsx("span",{className:"h-1 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",children:e.jsx("span",{className:"block h-full rounded-full bg-[var(--otari-brand)]",style:{width:`${Math.min(100,h*100)}%`}})})]})}),e.jsx(P,{className:"text-right tabular-nums text-[var(--otari-muted)]",children:O(o.requests)}),e.jsx(P,{className:"text-right tabular-nums text-[var(--otari-ink)]",children:U(o.cost)})]},o.key??`__null_${i}`)})})]}),!b&&v>0?e.jsxs(k,{size:"sm",variant:"ghost",onPress:()=>m(!0),children:["Show all ",l.length]}):null,!b&&d&&l.length>D?e.jsxs(k,{size:"sm",variant:"ghost",onPress:()=>m(!1),children:["Show top ",D]}):null]})}const be=[{key:"cost",label:"Cost"},{key:"tokens",label:"Tokens"},{key:"requests",label:"Requests"}];function Y(a,l){return l==="cost"?a.cost:l==="tokens"?a.tokens:a.requests}function G(a,l){return l==="cost"?U(a):l==="tokens"?w(a):O(a)}function J(a,l){const n=new Date(a);return Number.isNaN(n.getTime())?a:l==="hour"?n.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:"UTC"}):n.toLocaleDateString(void 0,{month:"short",day:"numeric",timeZone:"UTC"})}function ve({series:a,metric:l,bucket:n}){const d=Math.max(1,...a.map(i=>Y(i,l))),m=a.length,u=m>0?704/m:0,v=Math.max(1,u*.7),o=m<=1?[0]:[...new Set([0,Math.floor(m/2),m-1])];return e.jsxs("figure",{className:"flex flex-col gap-2",children:[e.jsxs("svg",{viewBox:"0 0 720 224",preserveAspectRatio:"none",role:"img",className:"w-full","aria-label":`${l} over time`,children:[e.jsx("title",{children:`${l} per ${n}`}),a.map((i,g)=>{const j=Y(i,l),h=j/d*192,T=8+g*u+(u-v)/2,r=200-h;return e.jsx("rect",{x:T,y:r,width:v,height:h,rx:1.5,className:"fill-[var(--otari-brand)]",children:e.jsx("title",{children:`${J(i.bucket_start,n)}: ${G(j,l)}`})},i.bucket_start)}),o.map(i=>a[i]?e.jsx("text",{x:8+i*u+u/2,y:216,textAnchor:"middle",className:"fill-[var(--otari-muted)] text-[10px]",children:J(a[i].bucket_start,n)},`lbl_${i}`):null)]}),e.jsxs("figcaption",{className:"text-xs text-[var(--otari-muted)]",children:[G(d,l)," peak · ",m," ",n==="hour"?"hours":"days"," (times in UTC)"]})]})}function we(){var z,I,H;const a=re(),l=oe(),[n,N]=f.useState(A),[x,b]=f.useState(()=>A.seconds===null?void 0:E(A.seconds)),[d,m]=f.useState(""),[u,v]=f.useState(""),[o,i]=f.useState("cost"),g=f.useMemo(()=>({start_date:x,model:d.trim()||void 0,user_id:u||void 0}),[x,d,u]),j=f.useMemo(()=>n.seconds===null||!x?null:{...g,start_date:new Date(new Date(x).getTime()-n.seconds*1e3).toISOString(),end_date:x},[g,n.seconds,x]),h=F(g,n.bucket),T=F(j??g,n.bucket,j!==null),r=h.data,s=r==null?void 0:r.totals,c=j!==null?(z=T.data)==null?void 0:z.totals:void 0,K=f.useMemo(()=>({...g,model:void 0}),[g]),q=((H=(I=F(K,n.bucket).data)==null?void 0:I.by_model)==null?void 0:H.filter(t=>!t.is_other&&t.key!==null).map(t=>t.key))??[],X=(l.data??[]).map(t=>({value:t.user_id,label:t.alias?`${t.alias} (${t.user_id})`:t.user_id})),ee=(d&&!q.includes(d)?[d,...q]:q).map(t=>({value:t,label:t})),S=!!(d.trim()||u||n.seconds!==null),se=!!(r&&s&&s.request_count===0&&!S),R=t=>{N(t),b(t.seconds===null?void 0:E(t.seconds))},te=()=>{R(L[L.length-1]),m(""),v("")},ae=()=>{n.seconds!==null&&b(E(n.seconds)),h.refetch()},B=t=>{const C=new URLSearchParams;x&&C.set("start_date",x);for(const[ne,Z]of Object.entries(t))Z&&C.set(ne,Z);a(`/activity?${C.toString()}`)},le=s&&s.request_count>0?s.error_count/s.request_count:0;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ie,{title:"Usage & analytics",description:"Aggregate spend, tokens, and request volume over time. Click a model or user to drill into the request log.",action:e.jsx(k,{variant:"outline",onPress:ae,isDisabled:h.isFetching,children:"Refresh"})}),e.jsx(ce,{error:h.error}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx("div",{className:"flex flex-wrap gap-2",children:L.map(t=>e.jsx(k,{size:"sm",variant:n.label===t.label?"primary":"outline",onPress:()=>R(t),children:t.label},t.label))}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(Q,{label:"User",value:u,onChange:v,options:X,placeholder:"All users"}),e.jsx(Q,{label:"Model",value:d,onChange:m,options:ee,placeholder:"All models"}),S?e.jsx(k,{size:"sm",variant:"ghost",onPress:te,children:"Clear filters"}):null]})]}),se?e.jsx("div",{className:"rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] px-4 py-10 text-center text-sm text-[var(--otari-muted)]",children:"No usage yet. Once the gateway serves requests, spend and volume appear here."}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"flex flex-wrap gap-4",children:[e.jsx(p,{label:"Spend",value:s?U(s.cost):"—",hint:s?e.jsx(_,{fraction:y(s.cost,c==null?void 0:c.cost)}):null}),e.jsx(p,{label:"Requests",value:s?O(s.request_count):"—",hint:s?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:[de(le)," errors",c?e.jsxs(e.Fragment,{children:[" · ",e.jsx(_,{fraction:y(s.request_count,c.request_count)})]}):null]}):null}),e.jsx(p,{label:"Tokens",value:s?w(s.total_tokens):"—",hint:s?e.jsx(_,{fraction:y(s.total_tokens,c==null?void 0:c.total_tokens)}):null}),e.jsx(p,{label:"Cache read",value:s?w(s.cache_read_tokens):"—",hint:s?e.jsx(_,{fraction:y(s.cache_read_tokens,c==null?void 0:c.cache_read_tokens)}):null}),e.jsx(p,{label:"Cache write",value:s?w(s.cache_write_tokens):"—",hint:s?e.jsx(_,{fraction:y(s.cache_write_tokens,c==null?void 0:c.cache_write_tokens)}):null}),e.jsx(p,{label:"Avg latency",value:s?fe(s.avg_latency_ms):"—"})]}),e.jsxs("div",{className:"grid gap-6 lg:grid-cols-2",children:[e.jsx(W,{title:"Spend by model",rows:(r==null?void 0:r.by_model)??[],totalCost:(s==null?void 0:s.cost)??0,emptyLabel:S?"No usage matches these filters.":"No usage recorded yet.",onDrill:t=>B({model:t}),loading:h.isLoading}),e.jsx(W,{title:"Spend by user",rows:(r==null?void 0:r.by_user)??[],totalCost:(s==null?void 0:s.cost)??0,emptyLabel:S?"No usage matches these filters.":"No usage recorded yet.",onDrill:t=>B({user_id:t}),loading:h.isLoading})]}),e.jsxs("div",{className:"flex flex-col gap-3 rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] p-4",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Over time"}),e.jsxs("div",{className:"inline-flex gap-1.5",children:[be.map(t=>e.jsx(k,{size:"sm",variant:o===t.key?"primary":"outline",onPress:()=>i(t.key),children:t.label},t.key)),h.isFetching?e.jsx(V,{size:"sm"}):null]})]}),h.isLoading?e.jsx("div",{className:"flex h-48 items-center justify-center",children:e.jsx(V,{size:"sm"})}):((r==null?void 0:r.series.length)??0)===0?e.jsx("div",{className:"flex h-48 items-center justify-center text-sm text-[var(--otari-muted)]",children:"No data in this range."}):e.jsx(ve,{series:(r==null?void 0:r.series)??[],metric:o,bucket:n.bucket})]})]})]})}export{we as UsagePage}; | |||
| import{j as e}from"./tanstack-query-W9y7rsMr.js";import{g as re,r as f}from"./react-q-ooZ0ti.js";import{u as oe,c as F,P as ie,E as ce,d as Q,S as p,N as _,L as y,M as U,Q as de,O as w}from"./index-DvNpC9jo.js";import{T as ue,a as he,b as M,L as me,c as xe,d as ge,e as P}from"./Table-DEsIhjZo.js";import{B as k,S as V}from"./heroui-CewI8xK4.js";function O(a){return a.toLocaleString()}function fe(a){return a===null?"—":a<1e3?`${Math.round(a)} ms`:`${(a/1e3).toFixed(2)} s`}const je=3600,$=86400,L=[{label:"Last hour",seconds:je,bucket:"hour"},{label:"24h",seconds:$,bucket:"hour"},{label:"7d",seconds:7*$,bucket:"day"},{label:"30d",seconds:30*$,bucket:"day"},{label:"All",seconds:null,bucket:"day"}],A=L[3],D=15;function E(a){return new Date(Date.now()-a*1e3).toISOString()}function W({title:a,rows:l,totalCost:n,emptyLabel:N,onDrill:x,loading:b}){const[d,m]=f.useState(!1),u=d?l:l.slice(0,D),v=l.length-u.length;return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:a}),e.jsxs(ue,{children:[e.jsx(he,{children:e.jsxs("tr",{children:[e.jsx(M,{children:a.replace("Spend by ","")}),e.jsx(M,{className:"text-right",children:"Requests"}),e.jsx(M,{className:"text-right",children:"Spend"})]})}),e.jsx("tbody",{children:b?e.jsx(me,{colSpan:3}):l.length===0?e.jsx(xe,{colSpan:3,children:N}):u.map((o,i)=>{const g=o.is_other,j=!g&&o.key!==null,h=n>0?o.cost/n:0;return e.jsxs(ge,{onClick:j?()=>x(o.key):void 0,children:[e.jsx(P,{className:"text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"truncate",children:g?`Other (${o.requests.toLocaleString()} req)`:o.key??"(unknown)"}),e.jsx("span",{className:"h-1 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",children:e.jsx("span",{className:"block h-full rounded-full bg-[var(--otari-brand)]",style:{width:`${Math.min(100,h*100)}%`}})})]})}),e.jsx(P,{className:"text-right tabular-nums text-[var(--otari-muted)]",children:O(o.requests)}),e.jsx(P,{className:"text-right tabular-nums text-[var(--otari-ink)]",children:U(o.cost)})]},o.key??`__null_${i}`)})})]}),!b&&v>0?e.jsxs(k,{size:"sm",variant:"ghost",onPress:()=>m(!0),children:["Show all ",l.length]}):null,!b&&d&&l.length>D?e.jsxs(k,{size:"sm",variant:"ghost",onPress:()=>m(!1),children:["Show top ",D]}):null]})}const be=[{key:"cost",label:"Cost"},{key:"tokens",label:"Tokens"},{key:"requests",label:"Requests"}];function Y(a,l){return l==="cost"?a.cost:l==="tokens"?a.tokens:a.requests}function G(a,l){return l==="cost"?U(a):l==="tokens"?w(a):O(a)}function J(a,l){const n=new Date(a);return Number.isNaN(n.getTime())?a:l==="hour"?n.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:"UTC"}):n.toLocaleDateString(void 0,{month:"short",day:"numeric",timeZone:"UTC"})}function ve({series:a,metric:l,bucket:n}){const d=Math.max(1,...a.map(i=>Y(i,l))),m=a.length,u=m>0?704/m:0,v=Math.max(1,u*.7),o=m<=1?[0]:[...new Set([0,Math.floor(m/2),m-1])];return e.jsxs("figure",{className:"flex flex-col gap-2",children:[e.jsxs("svg",{viewBox:"0 0 720 224",preserveAspectRatio:"none",role:"img",className:"w-full","aria-label":`${l} over time`,children:[e.jsx("title",{children:`${l} per ${n}`}),a.map((i,g)=>{const j=Y(i,l),h=j/d*192,T=8+g*u+(u-v)/2,r=200-h;return e.jsx("rect",{x:T,y:r,width:v,height:h,rx:1.5,className:"fill-[var(--otari-brand)]",children:e.jsx("title",{children:`${J(i.bucket_start,n)}: ${G(j,l)}`})},i.bucket_start)}),o.map(i=>a[i]?e.jsx("text",{x:8+i*u+u/2,y:216,textAnchor:"middle",className:"fill-[var(--otari-muted)] text-[10px]",children:J(a[i].bucket_start,n)},`lbl_${i}`):null)]}),e.jsxs("figcaption",{className:"text-xs text-[var(--otari-muted)]",children:[G(d,l)," peak · ",m," ",n==="hour"?"hours":"days"," (times in UTC)"]})]})}function we(){var z,I,H;const a=re(),l=oe(),[n,N]=f.useState(A),[x,b]=f.useState(()=>A.seconds===null?void 0:E(A.seconds)),[d,m]=f.useState(""),[u,v]=f.useState(""),[o,i]=f.useState("cost"),g=f.useMemo(()=>({start_date:x,model:d.trim()||void 0,user_id:u||void 0}),[x,d,u]),j=f.useMemo(()=>n.seconds===null||!x?null:{...g,start_date:new Date(new Date(x).getTime()-n.seconds*1e3).toISOString(),end_date:x},[g,n.seconds,x]),h=F(g,n.bucket),T=F(j??g,n.bucket,j!==null),r=h.data,s=r==null?void 0:r.totals,c=j!==null?(z=T.data)==null?void 0:z.totals:void 0,K=f.useMemo(()=>({...g,model:void 0}),[g]),q=((H=(I=F(K,n.bucket).data)==null?void 0:I.by_model)==null?void 0:H.filter(t=>!t.is_other&&t.key!==null).map(t=>t.key))??[],X=(l.data??[]).map(t=>({value:t.user_id,label:t.alias?`${t.alias} (${t.user_id})`:t.user_id})),ee=(d&&!q.includes(d)?[d,...q]:q).map(t=>({value:t,label:t})),S=!!(d.trim()||u||n.seconds!==null),se=!!(r&&s&&s.request_count===0&&!S),R=t=>{N(t),b(t.seconds===null?void 0:E(t.seconds))},te=()=>{R(L[L.length-1]),m(""),v("")},ae=()=>{n.seconds!==null&&b(E(n.seconds)),h.refetch()},B=t=>{const C=new URLSearchParams;x&&C.set("start_date",x);for(const[ne,Z]of Object.entries(t))Z&&C.set(ne,Z);a(`/activity?${C.toString()}`)},le=s&&s.request_count>0?s.error_count/s.request_count:0;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ie,{title:"Usage & analytics",description:"Aggregate spend, tokens, and request volume over time. Click a model or user to drill into the request log.",action:e.jsx(k,{variant:"outline",onPress:ae,isDisabled:h.isFetching,children:"Refresh"})}),e.jsx(ce,{error:h.error}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx("div",{className:"flex flex-wrap gap-2",children:L.map(t=>e.jsx(k,{size:"sm",variant:n.label===t.label?"primary":"outline",onPress:()=>R(t),children:t.label},t.label))}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(Q,{label:"User",value:u,onChange:v,options:X,placeholder:"All users"}),e.jsx(Q,{label:"Model",value:d,onChange:m,options:ee,placeholder:"All models"}),S?e.jsx(k,{size:"sm",variant:"ghost",onPress:te,children:"Clear filters"}):null]})]}),se?e.jsx("div",{className:"rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] px-4 py-10 text-center text-sm text-[var(--otari-muted)]",children:"No usage yet. Once the gateway serves requests, spend and volume appear here."}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"flex flex-wrap gap-4",children:[e.jsx(p,{label:"Spend",value:s?U(s.cost):"—",hint:s?e.jsx(_,{fraction:y(s.cost,c==null?void 0:c.cost)}):null}),e.jsx(p,{label:"Requests",value:s?O(s.request_count):"—",hint:s?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:[de(le)," errors",c?e.jsxs(e.Fragment,{children:[" · ",e.jsx(_,{fraction:y(s.request_count,c.request_count)})]}):null]}):null}),e.jsx(p,{label:"Tokens",value:s?w(s.total_tokens):"—",hint:s?e.jsx(_,{fraction:y(s.total_tokens,c==null?void 0:c.total_tokens)}):null}),e.jsx(p,{label:"Cache read",value:s?w(s.cache_read_tokens):"—",hint:s?e.jsx(_,{fraction:y(s.cache_read_tokens,c==null?void 0:c.cache_read_tokens)}):null}),e.jsx(p,{label:"Cache write",value:s?w(s.cache_write_tokens):"—",hint:s?e.jsx(_,{fraction:y(s.cache_write_tokens,c==null?void 0:c.cache_write_tokens)}):null}),e.jsx(p,{label:"Avg latency",value:s?fe(s.avg_latency_ms):"—"})]}),e.jsxs("div",{className:"grid gap-6 lg:grid-cols-2",children:[e.jsx(W,{title:"Spend by model",rows:(r==null?void 0:r.by_model)??[],totalCost:(s==null?void 0:s.cost)??0,emptyLabel:S?"No usage matches these filters.":"No usage recorded yet.",onDrill:t=>B({model:t}),loading:h.isLoading}),e.jsx(W,{title:"Spend by user",rows:(r==null?void 0:r.by_user)??[],totalCost:(s==null?void 0:s.cost)??0,emptyLabel:S?"No usage matches these filters.":"No usage recorded yet.",onDrill:t=>B({user_id:t}),loading:h.isLoading})]}),e.jsxs("div",{className:"flex flex-col gap-3 rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] p-4",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Over time"}),e.jsxs("div",{className:"inline-flex gap-1.5",children:[be.map(t=>e.jsx(k,{size:"sm",variant:o===t.key?"primary":"outline",onPress:()=>i(t.key),children:t.label},t.key)),h.isFetching?e.jsx(V,{size:"sm"}):null]})]}),h.isLoading?e.jsx("div",{className:"flex h-48 items-center justify-center",children:e.jsx(V,{size:"sm"})}):((r==null?void 0:r.series.length)??0)===0?e.jsx("div",{className:"flex h-48 items-center justify-center text-sm text-[var(--otari-muted)]",children:"No data in this range."}):e.jsx(ve,{series:(r==null?void 0:r.series)??[],metric:o,bucket:n.bucket})]})]})]})}export{we as UsagePage}; | |||
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Preserve the other active filter during drill-down.
B drops the current model or user_id filter and only adds the clicked dimension. For example, drilling into a model while filtering by user loses the user constraint and shows unrelated activity. Merge the existing filters with the clicked value before navigating.
🤖 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/UsagePage-CFdEpDOA.js` at line 1, Update
the drill-down handler B to merge the currently active model and user_id filters
from the existing state with the clicked dimension before constructing the
activity URL. Preserve the clicked value as the selected dimension while
retaining any other active filter, and continue using the existing start_date
navigation behavior.
Address CodeRabbit: the alias, provider, and pricing refresher tasks are created before log_writer.start(); starting it inside the try/finally means a failure there still cancels those tasks instead of leaking them. Only stop a writer that actually started. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Description
Adds a reviewable Settings workflow for updating the
genai-pricescatalog. Accepted snapshots and pending reviews are persisted in the local database, custom prices remain higher-priority overrides, and the Models page is simplified with configurable 15, 25, or 50 row pagination.PR Type
Relevant issues
Fixes #361
Checklist
tests/unit,tests/integration).make lint,make typecheck,make test). Integration tests require Docker, which is unavailable in this environment.uv run python scripts/generate_openapi.py).AI Usage
AI Model/Tool used: Codex CLI
Any additional AI details you would like to share: All code and test changes were reviewed locally.
Summary
Technical notes