diff --git a/AGENTS.md b/AGENTS.md index 45055227..36c72f4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ ORM entities are in `src/gateway/models/entities.py` (User, APIKey, Budget, Usag - Docker local build/run: `docker compose up --build` - CI Docker smoke check is implemented in `scripts/docker_liveness_check.sh`. ## Web Dashboard (`web/`) -- The standalone admin dashboard is a React + HeroUI v3 SPA in `web/` (Vite, Tailwind v4, TanStack Query). It browses the model catalogue, sets model pricing, manages aliases, adds/edits provider API keys, and toggles runtime settings; it calls the management API (`/v1/models`, `/v1/pricing`, `/v1/aliases`, `/v1/provider-credentials`, `/v1/settings`) with the master key, entered on a sign-in screen and kept only in the browser tab's session storage. +- The standalone admin dashboard is a React + HeroUI v3 SPA in `web/` (Vite, Tailwind v4, TanStack Query). It browses the model catalogue, sets model pricing, manages aliases, adds/edits provider API keys, and toggles runtime settings; it calls the management API (`/v1/models`, `/v1/pricing`, `/v1/aliases`, `/v1/provider-credentials`, `/v1/settings`) as the master key: entered once on a sign-in screen, the key is exchanged (`POST /v1/auth/session`) for an HttpOnly session cookie with a TTL (`dashboard_session_ttl_hours`, table `dashboard_sessions`), so the raw key is never persisted in the browser and a sign-in survives tab closes and restarts. The auth dependencies in `api/deps.py` accept the cookie only when a request carries no header credentials; master-key rotation revokes every session and re-mints the caller's. - Runtime provider management: the Providers page (`web/src/pages/ProvidersPage.tsx`) manages the `provider_credentials` table via `/v1/provider-credentials` (CRUD + `POST /{instance}/test`). Backend: `services/provider_store_service.py` overlays stored providers onto `config.providers` (per-config baseline on `config._provider_baseline`, refreshed by a TTL refresher wired in the lifespan like the alias refresher, standalone-only); `services/secret_box.py` encrypts keys with `OTARI_SECRET_KEY` (Fernet); `services/master_key_service.py` generates + prints a master key on first run when none is set (hash in `runtime_settings`). Keys are write-only over the API (responses expose only `last4`). - Build: `npm --prefix web ci && npm --prefix web run build`. Output goes to `src/gateway/static/dashboard/` (set in `web/vite.config.ts`), which is committed so the wheel and Docker image ship the dashboard with no Node build stage. Rebuild and commit after any change under `web/src`. - Checks: `npm --prefix web run typecheck`, `npm --prefix web test`. CI runs these and fails if the committed bundle is stale (`.github/workflows/otari-dashboard.yml`). diff --git a/alembic/versions/a9c1e3b5d7f9_add_dashboard_sessions_table.py b/alembic/versions/a9c1e3b5d7f9_add_dashboard_sessions_table.py new file mode 100644 index 00000000..4235ac17 --- /dev/null +++ b/alembic/versions/a9c1e3b5d7f9_add_dashboard_sessions_table.py @@ -0,0 +1,36 @@ +"""Add dashboard_sessions table for cookie-based dashboard sign-in. + +Revision ID: a9c1e3b5d7f9 +Revises: 7d9e1f3a5b7c +Create Date: 2026-07-23 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a9c1e3b5d7f9" +down_revision: str | Sequence[str] | None = "7d9e1f3a5b7c" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "dashboard_sessions", + sa.Column("token_hash", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("token_hash"), + ) + op.create_index("ix_dashboard_sessions_expires_at", "dashboard_sessions", ["expires_at"]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_dashboard_sessions_expires_at", table_name="dashboard_sessions") + op.drop_table("dashboard_sessions") diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 861bbbda..29479577 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1233,6 +1233,21 @@ "title": "CreateKeyResponse", "type": "object" }, + "CreateSessionRequest": { + "description": "Sign in to the dashboard by proving possession of the master key.", + "properties": { + "master_key": { + "description": "The gateway master key; verified once and never stored by the browser.", + "title": "Master Key", + "type": "string" + } + }, + "required": [ + "master_key" + ], + "title": "CreateSessionRequest", + "type": "object" + }, "CreateStoredProviderRequest": { "description": "Create a stored provider. ``api_key`` is write-only and requires OTARI_SECRET_KEY.", "properties": { @@ -3681,6 +3696,22 @@ "title": "RotateMasterKeyResponse", "type": "object" }, + "SessionResponse": { + "description": "A freshly minted dashboard session (the token travels only in the cookie).", + "properties": { + "expires_at": { + "description": "When the session cookie stops being accepted.", + "format": "date-time", + "title": "Expires At", + "type": "string" + } + }, + "required": [ + "expires_at" + ], + "title": "SessionResponse", + "type": "object" + }, "SetPricingRequest": { "description": "Create a versioned per-model price, with optional cache and context tiers.", "properties": { @@ -5668,6 +5699,61 @@ ] } }, + "/v1/auth/session": { + "delete": { + "description": "Sign out: revoke the cookie's session server-side and expire the cookie.\n\nDeliberately unauthenticated and idempotent: it only ever revokes the\nsession named by the caller's own cookie, and the dashboard calls it on the\n401-bounce path where no valid credential exists anymore. Unlike the read\npath in ``deps.py`` it applies no Sec-Fetch-Site check: ``SameSite=Strict``\nalready keeps cross-site requests from carrying the cookie, and the worst a\nforged call could do is sign the operator out.", + "operationId": "delete_session_v1_auth_session_delete", + "responses": { + "204": { + "description": "Successful Response" + } + }, + "summary": "Delete Session", + "tags": [ + "auth" + ] + }, + "post": { + "description": "Verify the master key and set the HttpOnly session cookie.", + "operationId": "create_session_v1_auth_session_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Session", + "tags": [ + "auth" + ] + } + }, "/v1/batches": { "get": { "description": "List batches for a provider.\n\nNon-master keys only see batches they own (plus legacy batches without an\nownership marker); the page is filtered after the provider call, so a page\nmay contain fewer than ``limit`` items.", @@ -8489,7 +8575,7 @@ }, "/v1/settings/master-key/rotate": { "post": { - "description": "Regenerate the database-backed master key and invalidate the old one.\n\nOnly the first-run generated master key can be rotated here. When a master\nkey is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot\ninvalidate it; the operator must change that value and restart instead.", + "description": "Regenerate the database-backed master key and invalidate the old one.\n\nOnly the first-run generated master key can be rotated here. When a master\nkey is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot\ninvalidate it; the operator must change that value and restart instead.\n\nEvery dashboard session is revoked with the rotation (a session only proves\npossession of the now-dead key); the caller's own session is re-minted under\nthe new key so the tab that performed the rotation stays signed in.", "operationId": "rotate_master_key_v1_settings_master_key_rotate_post", "responses": { "200": { diff --git a/docs/public/otari.postman_collection.json b/docs/public/otari.postman_collection.json index f415ba9c..6635c3f6 100644 --- a/docs/public/otari.postman_collection.json +++ b/docs/public/otari.postman_collection.json @@ -211,6 +211,63 @@ ], "name": "audio" }, + { + "item": [ + { + "name": "Create Session", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"master_key\": \"string\"\n}" + }, + "description": "Verify the master key and set the HttpOnly session cookie.", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "auth", + "session" + ], + "raw": "{{baseUrl}}/v1/auth/session" + } + } + }, + { + "name": "Delete Session", + "request": { + "description": "Sign out: revoke the cookie's session server-side and expire the cookie.\n\nDeliberately unauthenticated and idempotent: it only ever revokes the\nsession named by the caller's own cookie, and the dashboard calls it on the\n401-bounce path where no valid credential exists anymore. Unlike the read\npath in ``deps.py`` it applies no Sec-Fetch-Site check: ``SameSite=Strict``\nalready keeps cross-site requests from carrying the cookie, and the worst a\nforged call could do is sign the operator out.", + "header": [], + "method": "DELETE", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "auth", + "session" + ], + "raw": "{{baseUrl}}/v1/auth/session" + } + } + } + ], + "name": "auth" + }, { "item": [ { @@ -1904,7 +1961,7 @@ { "name": "Rotate Master Key", "request": { - "description": "Regenerate the database-backed master key and invalidate the old one.\n\nOnly the first-run generated master key can be rotated here. When a master\nkey is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot\ninvalidate it; the operator must change that value and restart instead.", + "description": "Regenerate the database-backed master key and invalidate the old one.\n\nOnly the first-run generated master key can be rotated here. When a master\nkey is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot\ninvalidate it; the operator must change that value and restart instead.\n\nEvery dashboard session is revoked with the rotation (a session only proves\npossession of the now-dead key); the caller's own session is re-minted under\nthe new key so the tab that performed the rotation stays signed in.", "header": [], "method": "POST", "url": { diff --git a/src/gateway/api/deps.py b/src/gateway/api/deps.py index 833f68fc..902e76d3 100644 --- a/src/gateway/api/deps.py +++ b/src/gateway/api/deps.py @@ -14,6 +14,7 @@ from gateway.log_config import logger from gateway.metrics import record_auth_failure from gateway.models.entities import APIKey +from gateway.services.dashboard_session_service import SESSION_COOKIE_NAME, is_valid_dashboard_session from gateway.services.file_store import FileStore from gateway.services.log_writer import LogWriter from gateway.services.master_key_service import hash_master_key, is_generated_master_key, load_master_key_hash @@ -179,7 +180,55 @@ async def _bump_last_used_at(api_key_id: str, now: datetime) -> None: logger.warning("Failed to update last_used_at for API key %s", api_key_id, exc_info=True) -async def _is_valid_master_key(token: str, config: GatewayConfig, db: AsyncSession) -> bool: +def _header_credentials_present(request: Request) -> bool: + """Whether the request carries any of the header credential forms. + + Header credentials always win over the dashboard session cookie: an API + client that sends a key gets exactly today's behavior (including failures), + and the cookie is only consulted for requests that present nothing else, + i.e. the browser-driven dashboard. + """ + return bool( + request.headers.get(API_KEY_HEADER) + or request.headers.get("Authorization") + or request.headers.get(X_API_KEY_HEADER) + ) + + +# Sec-Fetch-Site values under which a cookie may authenticate a request: +# same-origin fetches (the dashboard itself) and non-site-initiated requests +# ("none", e.g. a direct navigation). "same-site" is deliberately excluded, so a +# sibling-subdomain page cannot ride the cookie. +_COOKIE_SAFE_FETCH_SITES = ("same-origin", "none") + + +async def _session_cookie_authenticates(request: Request, config: GatewayConfig, db: AsyncSession) -> bool: + """Whether a valid dashboard session cookie grants master-key authority. + + ``SameSite=Strict`` on the cookie is the primary CSRF control; the + Sec-Fetch-Site check is belt-and-braces for clients that send the header. + Standalone-only: hybrid mode has no dashboard or management API. + """ + if config.is_hybrid_mode: + return False + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + return False + fetch_site = request.headers.get("Sec-Fetch-Site") + if fetch_site is not None and fetch_site not in _COOKIE_SAFE_FETCH_SITES: + record_auth_failure("cross_site_cookie") + return False + try: + return await is_valid_dashboard_session(db, token) + except SQLAlchemyError as exc: + record_auth_failure("db_error") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Authentication temporarily unavailable, please retry", + ) from exc + + +async def is_valid_master_key(token: str, config: GatewayConfig, db: AsyncSession) -> bool: """Check if token matches the configured key or the current generated key.""" if config.master_key is not None and secrets.compare_digest(token, config.master_key): return True @@ -232,17 +281,23 @@ async def verify_master_key( request: Request, db: Annotated[AsyncSession, Depends(get_db)], config: Annotated[GatewayConfig, Depends(get_config)], -) -> str: - """Verify master key from Otari-Key header. +) -> str | None: + """Verify master key from Otari-Key header or the dashboard session cookie. Args: request: FastAPI request object config: Gateway configuration + Returns: + The raw master key when header-authenticated, or None when a dashboard + session cookie authenticated the request (the raw key is not available). + Raises: HTTPException: If master key is not configured or invalid """ + if not _header_credentials_present(request) and await _session_cookie_authenticates(request, config, db): + return None token = _extract_bearer_token(request, config) if config.master_key is None: @@ -270,6 +325,9 @@ async def verify_api_key_or_master_key( ) -> tuple[APIKey | None, bool]: """Verify either API key or master key from Otari-Key header. + A valid dashboard session cookie also grants master-key authority, but only + when the request carries no header credentials at all. + Args: request: FastAPI request object db: Database session @@ -282,9 +340,12 @@ async def verify_api_key_or_master_key( HTTPException: If key is invalid, inactive, or expired """ + if not _header_credentials_present(request) and await _session_cookie_authenticates(request, config, db): + return None, True + token = _extract_bearer_token(request, config) - if await _is_valid_master_key(token, config, db): + if await is_valid_master_key(token, config, db): return None, True api_key = await _verify_and_update_api_key(db, token) @@ -322,6 +383,7 @@ def get_file_store(request: Request) -> FileStore: "get_db_if_needed", "get_file_store", "get_log_writer", + "is_valid_master_key", "verify_api_key", "verify_api_key_or_master_key", "verify_master_key", diff --git a/src/gateway/api/main.py b/src/gateway/api/main.py index 021858d2..ccbb9d4c 100644 --- a/src/gateway/api/main.py +++ b/src/gateway/api/main.py @@ -3,6 +3,7 @@ from gateway.api.routes import ( aliases, audio, + auth_session, batches, budgets, chat, @@ -39,6 +40,7 @@ def register_routers(app: FastAPI, config: GatewayConfig) -> None: app.include_router(hybrid_mode.router) return # Remaining routers (including batches) are standalone-mode only + app.include_router(auth_session.router) app.include_router(embeddings.router) app.include_router(images.router) app.include_router(audio.router) diff --git a/src/gateway/api/routes/auth_session.py b/src/gateway/api/routes/auth_session.py new file mode 100644 index 00000000..6aaadb19 --- /dev/null +++ b/src/gateway/api/routes/auth_session.py @@ -0,0 +1,100 @@ +"""Dashboard sign-in sessions (standalone mode only). + +``POST /v1/auth/session`` exchanges the master key for a server-issued session +held in an HttpOnly cookie, so the dashboard never persists the raw key in the +browser and a sign-in survives tab closes and restarts. ``DELETE`` is sign-out. +The cookie is honored by the master-key auth dependencies in +``gateway.api.deps`` when a request carries no header credentials. +""" + +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, Field +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.api.deps import get_config, get_db, is_valid_master_key +from gateway.core.config import GatewayConfig +from gateway.log_config import logger +from gateway.metrics import record_auth_failure +from gateway.services.dashboard_session_service import ( + SESSION_COOKIE_NAME, + apply_session_cookie, + clear_session_cookie, + create_dashboard_session, + request_is_https, + revoke_dashboard_session, +) + +router = APIRouter(prefix="/v1/auth/session", tags=["auth"]) + + +class CreateSessionRequest(BaseModel): + """Sign in to the dashboard by proving possession of the master key.""" + + master_key: str = Field(description="The gateway master key; verified once and never stored by the browser.") + + +class SessionResponse(BaseModel): + """A freshly minted dashboard session (the token travels only in the cookie).""" + + expires_at: datetime = Field(description="When the session cookie stops being accepted.") + + +@router.post("") +async def create_session( + body: CreateSessionRequest, + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(get_db)], + config: Annotated[GatewayConfig, Depends(get_config)], +) -> SessionResponse: + """Verify the master key and set the HttpOnly session cookie.""" + if not await is_valid_master_key(body.master_key, config, db): + record_auth_failure("invalid_key") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid master key") + try: + token, expires_at = await create_dashboard_session(db, config.dashboard_session_ttl_hours) + await db.commit() + except SQLAlchemyError: + await db.rollback() + # Generic error to the client; the raw failure is only logged here. + logger.warning("Failed to persist a dashboard session on sign-in", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Database error", + ) from None + apply_session_cookie(response, token, expires_at, secure=request_is_https(request)) + return SessionResponse(expires_at=expires_at) + + +@router.delete("", status_code=status.HTTP_204_NO_CONTENT) +async def delete_session( + request: Request, + response: Response, + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + """Sign out: revoke the cookie's session server-side and expire the cookie. + + Deliberately unauthenticated and idempotent: it only ever revokes the + session named by the caller's own cookie, and the dashboard calls it on the + 401-bounce path where no valid credential exists anymore. Unlike the read + path in ``deps.py`` it applies no Sec-Fetch-Site check: ``SameSite=Strict`` + already keeps cross-site requests from carrying the cookie, and the worst a + forged call could do is sign the operator out. + """ + token = request.cookies.get(SESSION_COOKIE_NAME) + if token: + try: + await revoke_dashboard_session(db, token) + await db.commit() + except SQLAlchemyError: + await db.rollback() + # Raising here would skip the cookie clear below (FastAPI discards + # the injected response on an exception), leaving the browser with + # a live cookie the operator believes is gone. Clear it and return + # 204 anyway; the unrevoked row dies on its TTL. + logger.warning("Failed to revoke the dashboard session on sign-out", exc_info=True) + clear_session_cookie(response) diff --git a/src/gateway/api/routes/settings.py b/src/gateway/api/routes/settings.py index e63fff80..4d936eb4 100644 --- a/src/gateway/api/routes/settings.py +++ b/src/gateway/api/routes/settings.py @@ -17,16 +17,25 @@ from typing import Annotated, Any, Literal from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel, Field from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from gateway.api.deps import get_config, get_db, verify_master_key from gateway.core.config import GatewayConfig +from gateway.services.dashboard_session_service import ( + SESSION_COOKIE_NAME, + apply_session_cookie, + create_dashboard_session, + record_session_key_marker, + request_is_https, + revoke_all_dashboard_sessions, +) from gateway.services.master_key_service import ( MasterKeyRotationConflictError, hash_master_key, + load_master_key_hash, stage_generated_master_key_rotation, ) from gateway.services.runtime_settings_service import ( @@ -350,23 +359,51 @@ async def update_settings( @router.post("/master-key/rotate", dependencies=[Depends(verify_master_key)]) async def rotate_master_key( + request: Request, + response: Response, db: Annotated[AsyncSession, Depends(get_db)], config: Annotated[GatewayConfig, Depends(get_config)], - authenticated_key: Annotated[str, Depends(verify_master_key)], + authenticated_key: Annotated[str | None, Depends(verify_master_key)], ) -> RotateMasterKeyResponse: """Regenerate the database-backed master key and invalidate the old one. Only the first-run generated master key can be rotated here. When a master key is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot invalidate it; the operator must change that value and restart instead. + + Every dashboard session is revoked with the rotation (a session only proves + possession of the now-dead key); the caller's own session is re-minted under + the new key so the tab that performed the rotation stays signed in. """ if config.master_key is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="This gateway uses a configured master key; change OTARI_MASTER_KEY or config.yml and restart.", ) + # A cookie-authenticated caller has no raw key to hash; the stored hash is + # the same value, so the rotation CAS still rejects a concurrent rotation. + if authenticated_key is not None: + current_hash = hash_master_key(authenticated_key) + else: + stored_hash = await load_master_key_hash(db) + if stored_hash is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="The master key was already rotated. Reload and try again.", + ) + current_hash = stored_hash + session_token: str | None = None + session_expires_at = None try: - token, hashed = await stage_generated_master_key_rotation(db, hash_master_key(authenticated_key)) + token, hashed = await stage_generated_master_key_rotation(db, current_hash) + await revoke_all_dashboard_sessions(db) + # Keep the startup key-change check in step, so a restart after this + # rotation does not revoke the session re-minted below. + await record_session_key_marker(db, hashed) + if SESSION_COOKIE_NAME in request.cookies: + session_token, session_expires_at = await create_dashboard_session( + db, config.dashboard_session_ttl_hours + ) await db.commit() except MasterKeyRotationConflictError as exc: await db.rollback() @@ -375,4 +412,6 @@ async def rotate_master_key( await db.rollback() raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error") from None config._master_key_hash = hashed + if session_token is not None and session_expires_at is not None: + apply_session_cookie(response, session_token, session_expires_at, secure=request_is_https(request)) return RotateMasterKeyResponse(master_key=token) diff --git a/src/gateway/core/config.py b/src/gateway/core/config.py index b117d98f..8fa18fbc 100644 --- a/src/gateway/core/config.py +++ b/src/gateway/core/config.py @@ -169,7 +169,7 @@ class GatewayConfig(BaseSettings): # Treat an empty OTARI_ env var as unset, matching the empty-skip # in _apply_otari_env_overrides. Without this, a blank OTARI_MASTER_KEY # (common from container templating) would read as "" instead of None, - # and an empty bearer token would then satisfy _is_valid_master_key. + # and an empty bearer token would then satisfy is_valid_master_key. env_ignore_empty=True, ) @@ -203,6 +203,15 @@ class GatewayConfig(BaseSettings): host: str = Field(default="0.0.0.0", description="Host to bind the server to") # noqa: S104 port: int = Field(default=8000, description="Port to bind the server to") master_key: str | None = Field(default=None, description="Master key for protecting management endpoints") + dashboard_session_ttl_hours: int = Field( + default=168, + ge=1, + description=( + "How long a dashboard sign-in stays valid, in hours. Signing in to the admin " + "dashboard exchanges the master key for an HttpOnly session cookie with this " + "lifetime; the master key itself never expires." + ), + ) rate_limit_rpm: int | None = Field( default=None, ge=1, description="Maximum requests per minute per user (None disables rate limiting)" ) diff --git a/src/gateway/main.py b/src/gateway/main.py index 5a8a099a..fba57e7d 100644 --- a/src/gateway/main.py +++ b/src/gateway/main.py @@ -19,6 +19,7 @@ from gateway.root_page import FAVICON_SVG, ROOT_TUTORIAL_HTML from gateway.services.alias_service import load_aliases_at_startup, reset_alias_cache, run_alias_refresher from gateway.services.bootstrap_service import bootstrap_first_api_key +from gateway.services.dashboard_session_service import revoke_sessions_on_master_key_change from gateway.services.file_store import build_file_store from gateway.services.log_writer import LogWriter, NoopLogWriter, create_log_writer from gateway.services.master_key_service import ensure_master_key @@ -42,6 +43,10 @@ from gateway.version import __version__ _PUBLIC_PREFIXES = ("/health",) +# Paths authenticated by the master key in the request body (sign-in) or the +# session cookie (sign-out) rather than the header schemes; the OpenAPI +# security stamp below skips them. They still get the no-store cache headers. +_COOKIE_AUTH_PREFIXES = ("/v1/auth/session",) # Public, unauthenticated static assets that shared caches may keep. Paths here # set their own Cache-Control at the route (favicon.svg), so the middleware only # fills one in when it is missing. @@ -118,6 +123,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # so the dashboard is reachable without hand-editing config, and # the management API is never left unauthenticated. await ensure_master_key(config, session) + # Dashboard sessions must not outlive the key they were minted + # under: revoke them all when the master key changed across a + # restart (e.g. OTARI_MASTER_KEY was rotated). + await revoke_sessions_on_master_key_change(config, session) # Overlay dashboard-stored providers before pricing init, so a # provider added at runtime is visible to everything that reads # config.providers (pricing seeding, discovery, dispatch). @@ -223,7 +232,7 @@ def custom_openapi() -> dict[str, Any]: } for path, path_item in openapi_schema.get("paths", {}).items(): - if path.startswith(_PUBLIC_PREFIXES): + if path.startswith(_PUBLIC_PREFIXES) or path.startswith(_COOKIE_AUTH_PREFIXES): continue for operation in path_item.values(): if isinstance(operation, dict): diff --git a/src/gateway/models/entities.py b/src/gateway/models/entities.py index 00085bfd..8d7e8385 100644 --- a/src/gateway/models/entities.py +++ b/src/gateway/models/entities.py @@ -203,6 +203,23 @@ class RuntimeSetting(Base): ) +class DashboardSession(Base): + """A server-side admin-dashboard sign-in session. + + Minted when an operator signs in to the dashboard with the master key: the + browser holds only an opaque token in an HttpOnly cookie and this table + stores the token's SHA-256 hash, so neither the master key nor a usable + session credential is ever persisted in JS-readable storage. Sessions + expire on a TTL and are revoked on sign-out and on master-key rotation. + """ + + __tablename__ = "dashboard_sessions" + + token_hash: Mapped[str] = mapped_column(primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + + class PricingSnapshot(Base): """An approved, source-tagged upstream pricing catalog.""" diff --git a/src/gateway/services/dashboard_session_service.py b/src/gateway/services/dashboard_session_service.py new file mode 100644 index 00000000..f983e341 --- /dev/null +++ b/src/gateway/services/dashboard_session_service.py @@ -0,0 +1,170 @@ +"""Dashboard sign-in sessions. + +Signing in to the admin dashboard exchanges the master key for a server-issued +session: an opaque token handed to the browser in an HttpOnly cookie, with only +its SHA-256 hash stored in the ``dashboard_sessions`` table. This lets a +sign-in survive tab closes and browser restarts without ever persisting the +master key (or any JS-readable credential) in the browser. + +Sessions live in the database, not process memory, so every worker and replica +accepts them and a revocation is seen everywhere. They expire on a TTL +(``dashboard_session_ttl_hours``) and are revoked on sign-out and on master-key +rotation. +""" + +import hashlib +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any, cast + +from fastapi import Request, Response +from sqlalchemy import delete, update +from sqlalchemy.engine import CursorResult +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.core.config import GatewayConfig +from gateway.log_config import logger +from gateway.models.entities import DashboardSession, RuntimeSetting +from gateway.services.master_key_service import hash_master_key + +SESSION_COOKIE_NAME = "otari_dashboard_session" +_SESSION_TOKEN_PREFIX = "otari-sess-" +# Stored in runtime_settings; ignored by runtime_settings_service (not a +# SETTABLE_KEY). Hash of the master key that existing sessions were minted +# under, so a key change across a restart revokes them (see +# revoke_sessions_on_master_key_change). +SESSION_KEY_MARKER = "dashboard_session_master_key_hash" + + +def hash_session_token(token: str) -> str: + """SHA-256 hex of a session token; only the hash is ever stored.""" + return hashlib.sha256(token.encode()).hexdigest() + + +def _as_utc(value: datetime) -> datetime: + """Treat a naive stored datetime as the UTC it was written as (SQLite).""" + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +async def create_dashboard_session(db: AsyncSession, ttl_hours: int) -> tuple[str, datetime]: + """Stage a new session row and return ``(token, expires_at)``. + + Expired rows are pruned opportunistically here, so the table stays small + without a background task. The caller owns the transaction and must commit + before handing the token to the browser. + """ + now = datetime.now(UTC) + await db.execute(delete(DashboardSession).where(DashboardSession.expires_at < now)) + token = f"{_SESSION_TOKEN_PREFIX}{secrets.token_urlsafe(32)}" + expires_at = now + timedelta(hours=ttl_hours) + db.add(DashboardSession(token_hash=hash_session_token(token), created_at=now, expires_at=expires_at)) + return token, expires_at + + +async def is_valid_dashboard_session(db: AsyncSession, token: str) -> bool: + """Whether a session token matches a stored, unexpired session.""" + row = await db.get(DashboardSession, hash_session_token(token)) + if row is None: + return False + return _as_utc(row.expires_at) >= datetime.now(UTC) + + +async def revoke_dashboard_session(db: AsyncSession, token: str) -> None: + """Stage removal of one session (sign-out). The caller commits.""" + await db.execute(delete(DashboardSession).where(DashboardSession.token_hash == hash_session_token(token))) + + +async def revoke_all_dashboard_sessions(db: AsyncSession) -> None: + """Stage removal of every session (master-key rotation). The caller commits.""" + await db.execute(delete(DashboardSession)) + + +async def record_session_key_marker(db: AsyncSession, key_hash: str) -> None: + """Stage the marker naming the master key sessions are minted under. + + Update-then-insert so it works whether or not the row exists yet; the + caller commits. + """ + result = cast( + CursorResult[Any], + await db.execute(update(RuntimeSetting).where(RuntimeSetting.key == SESSION_KEY_MARKER).values(value=key_hash)), + ) + if result.rowcount == 0: + db.add(RuntimeSetting(key=SESSION_KEY_MARKER, value=key_hash)) + + +async def revoke_sessions_on_master_key_change(config: GatewayConfig, db: AsyncSession) -> None: + """At startup, revoke every dashboard session if the master key changed. + + A session only proves possession of the master key at mint time, so it must + not outlive the key. The generated key rotates through the dashboard, which + revokes sessions inline; a configured key rotates by changing + ``OTARI_MASTER_KEY``/config and restarting, which no request handler + observes. Comparing a stored hash of the effective key here closes that + path (and any configured/generated regime switch). + + Best-effort like ``ensure_master_key``: a failure only skips this check for + the boot, and concurrent workers racing the first marker INSERT are benign. + """ + current = hash_master_key(config.master_key) if config.master_key is not None else config._master_key_hash + if current is None: + return + try: + row = await db.get(RuntimeSetting, SESSION_KEY_MARKER) + if row is not None and row.value == current: + return + if row is not None: + await revoke_all_dashboard_sessions(db) + await record_session_key_marker(db, current) + await db.commit() + except SQLAlchemyError: + await db.rollback() + logger.warning("Could not check dashboard sessions against the current master key; skipping for this boot.") + + +def request_is_https(request: Request) -> bool: + """Whether the browser leg of this request is HTTPS, for the Secure flag. + + Behind a TLS-terminating proxy, uvicorn only honors ``X-Forwarded-Proto`` + from trusted IPs (loopback by default), so ``request.url.scheme`` reads + "http" on typical PaaS ingress despite an HTTPS browser leg. Honor the + header here regardless of source: it only decides the cookie's ``Secure`` + attribute, and a spoofed "https" over plain HTTP merely denies the spoofer + their own session cookie. + """ + if request.url.scheme == "https": + return True + forwarded = request.headers.get("X-Forwarded-Proto", "") + return forwarded.split(",")[0].strip().lower() == "https" + + +def apply_session_cookie(response: Response, token: str, expires_at: datetime, *, secure: bool) -> None: + """Set the session cookie with its security attributes in one place. + + ``secure`` mirrors the effective request scheme (``request_is_https``) + rather than being hard-coded: a plain HTTP deployment (LAN, localhost + without TLS) would otherwise never receive the cookie back. That is no + worse than such a deployment already sending the raw master key in + cleartext today. ``SameSite=Strict`` keeps cross-site requests from + carrying the cookie, which is the primary CSRF control here (the dashboard + and API are same-origin). ``Path=/`` is as narrow as the surface allows: + the management routes live directly under ``/v1`` beside inference, so the + cookie reaches inference paths too; that grants nothing beyond what master + authority already has, and cross-site use is blocked by SameSite. + """ + max_age = max(0, int((expires_at - datetime.now(UTC)).total_seconds())) + response.set_cookie( + SESSION_COOKIE_NAME, + token, + max_age=max_age, + httponly=True, + secure=secure, + samesite="strict", + path="/", + ) + + +def clear_session_cookie(response: Response) -> None: + """Expire the session cookie in the browser (sign-out).""" + response.delete_cookie(SESSION_COOKIE_NAME, path="/") diff --git a/src/gateway/static/dashboard/assets/ActivityPage-D3mR-Vcr.js b/src/gateway/static/dashboard/assets/ActivityPage-Ixi7_M5Z.js similarity index 99% rename from src/gateway/static/dashboard/assets/ActivityPage-D3mR-Vcr.js rename to src/gateway/static/dashboard/assets/ActivityPage-Ixi7_M5Z.js index 4b472d13..53db752a 100644 --- a/src/gateway/static/dashboard/assets/ActivityPage-D3mR-Vcr.js +++ b/src/gateway/static/dashboard/assets/ActivityPage-Ixi7_M5Z.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{u as le,r as n}from"./react-dgEcD0HR.js";import{u as re,a as ne,b as ie,c as oe,P as ce,E as de,F as G,d as Y}from"./index-Dp4DdBFR.js";import{T as ue,a as me,b as d,L as xe,c as he,d as pe,e as u}from"./Table-CLdjdyTx.js";import{B as f,S as ge}from"./heroui-BX6JwHY-.js";const je=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function _(t){return t===null?"—":je.format(t)}function o(t){return t===null?"—":t.toLocaleString()}function Z(t){return t===null?"—":t<1e3?`${t} ms`:`${(t/1e3).toFixed(t<1e4?2:1)} s`}function ve(t){const a=new Date(t);return Number.isNaN(a.getTime())?t:a.toLocaleString()}function be(t){const a=new Date(t).getTime();if(Number.isNaN(a))return t;const r=Math.max(0,Math.round((Date.now()-a)/1e3));if(r<60)return`${r}s ago`;const i=Math.round(r/60);if(i<60)return`${i}m ago`;const m=Math.round(i/60);return m<24?`${m}h ago`:`${Math.round(m/24)}d ago`}const fe=3600,k=86400,Ne=[{label:"Last hour",seconds:fe},{label:"24h",seconds:k},{label:"7d",seconds:7*k},{label:"All",seconds:null}],Se=[{label:"All",value:""},{label:"Success",value:"success"},{label:"Error",value:"error"}],_e=["/v1/chat/completions","/v1/messages","/v1/responses","/v1/embeddings","/v1/moderations","/v1/audio/transcriptions","/v1/audio/speech","/v1/images/generations","/v1/rerank","/v1/batches","/v1/batches/results"],N=50;function D(t){return new Date(Date.now()-t*1e3).toISOString()}function ke({status:t}){const a=t==="error"?"border-red-200 bg-red-50 text-red-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return e.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${a}`,children:t})}function l({label:t,children:a}){return e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:t}),e.jsx("span",{className:"text-sm text-[var(--otari-ink)] break-all",children:a})]})}function we({entry:t}){var a;return e.jsxs("div",{className:"flex flex-col gap-4 px-4 py-4",children:[t.error_message?e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsx("div",{className:"flex items-center justify-between",children:e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Error"})}),e.jsx("pre",{className:"max-h-48 overflow-auto rounded-lg border border-red-200 bg-red-50 p-3 text-xs whitespace-pre-wrap break-all text-red-700",children:"The provider returned an error. Inspect gateway logs for details."})]}):null,e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2 lg:grid-cols-4",children:[e.jsx(l,{label:"Provider",children:t.provider??"—"}),e.jsx(l,{label:"Endpoint",children:t.endpoint}),e.jsx(l,{label:"User",children:t.user_id??"—"}),e.jsx(l,{label:"API key",children:t.api_key_id??"—"}),e.jsx(l,{label:"Prompt tokens",children:o(t.prompt_tokens)}),e.jsx(l,{label:"Completion tokens",children:o(t.completion_tokens)}),e.jsx(l,{label:"Total tokens",children:o(t.total_tokens)}),e.jsx(l,{label:"Cost",children:_(t.cost)}),e.jsx(l,{label:"Cache read tokens",children:o(t.cache_read_tokens)}),e.jsx(l,{label:"Cache write tokens",children:o(t.cache_write_tokens)}),e.jsx(l,{label:"1h cache writes",children:o(t.cache_write_1h_tokens??null)}),e.jsx(l,{label:"Total time",children:Z(t.latency_ms)}),e.jsx(l,{label:"Request ID",children:t.id})]}),(a=t.pricing_breakdown)!=null&&a.length?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Billed meters"}),e.jsx("div",{className:"grid gap-2 sm:grid-cols-2 lg:grid-cols-3",children:t.pricing_breakdown.map(r=>e.jsxs(l,{label:r.meter.replaceAll("_"," "),children:[o(r.units)," at ",_(r.rate_per_million)," / 1M, ",_(r.cost)]},r.meter))})]}):null]})}const A=7;function Ce(){var B,z,H;const t=re(),[a]=le(),r=a.get("start_date")??void 0,[i,m]=n.useState(r?null:k),[S,w]=n.useState(()=>r??D(k)),[x,C]=n.useState(()=>a.get("status")??""),[c,M]=n.useState(()=>a.get("model")??""),[h,$]=n.useState(""),[p,U]=n.useState(()=>a.get("user_id")??""),[j,T]=n.useState(0),[J,K]=n.useState(null),P=n.useMemo(()=>({start_date:S,status:x||void 0,model:c.trim()||void 0,endpoint:h||void 0,user_id:p||void 0}),[S,x,c,h,p]);n.useEffect(()=>{T(0)},[P]);const g=ne(P,j,N),v=ie(P),Q=n.useMemo(()=>({start_date:S,status:x||void 0,endpoint:h||void 0,user_id:p||void 0}),[S,x,h,p]),F=((z=(B=oe(Q,"day").data)==null?void 0:B.by_model)==null?void 0:z.filter(s=>!s.is_other&&s.key!==null).map(s=>s.key))??[],b=g.data??[],E=((H=v.data)==null?void 0:H.total)??0,I=!!(x||c.trim()||h||p||i!==null),V=s=>{m(s),w(s===null?void 0:D(s))},W=()=>{m(null),w(void 0),C(""),M(""),$(""),U("")},X=()=>{i!==null&&w(D(i)),g.refetch(),v.refetch()},L=b.length>0,O=L?j*N+1:0,R=j*N+b.length,q=v.isSuccess&&!v.isPlaceholderData,ee=q?E===0?"0 of 0":`${O}–${R} of ${E.toLocaleString()}`:L?`${O}–${R}`:"0",se=q?(j+1)*Ne.jsx(f,{size:"sm",variant:i===s.seconds?"primary":"outline",onPress:()=>V(s.seconds),children:s.label},s.label))}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(G,{id:"filter-status",label:"Status",value:x,onChange:C,children:Se.map(s=>e.jsx("option",{value:s.value,children:s.label},s.value))}),e.jsxs(G,{id:"filter-endpoint",label:"Endpoint",value:h,onChange:$,children:[e.jsx("option",{value:"",children:"All endpoints"}),_e.map(s=>e.jsx("option",{value:s,children:s},s))]}),e.jsx(Y,{label:"User",value:p,onChange:U,placeholder:"All users",options:(t.data??[]).map(s=>({value:s.user_id,label:s.alias?`${s.alias} (${s.user_id})`:s.user_id}))}),e.jsx(Y,{label:"Model",value:c,onChange:M,allowsCustom:!0,placeholder:"Any model",options:(c&&!F.includes(c)?[c,...F]:F).map(s=>({value:s,label:s}))}),I?e.jsx(f,{size:"sm",variant:"ghost",onPress:W,children:"Clear filters"}):null]})]}),e.jsxs(ue,{children:[e.jsx(me,{children:e.jsxs("tr",{children:[e.jsx(d,{children:"Time"}),e.jsx(d,{children:"User"}),e.jsx(d,{children:"Model"}),e.jsx(d,{className:"text-right",children:"Tokens"}),e.jsx(d,{className:"text-right",children:"Cost"}),e.jsx(d,{className:"text-right",children:"Total time"}),e.jsx(d,{children:"Status"})]})}),e.jsx("tbody",{children:g.isLoading?e.jsx(xe,{colSpan:A}):b.length===0?e.jsx(he,{colSpan:A,children:I?"No requests match these filters.":"No requests recorded yet."}):b.map(s=>{const te=s.status==="error",y=J===s.id;return e.jsxs(n.Fragment,{children:[e.jsxs(pe,{selected:y,className:te?"bg-red-50":"",onClick:()=>K(ae=>ae===s.id?null:s.id),children:[e.jsx(u,{className:"text-[var(--otari-muted)]",children:e.jsx("span",{title:ve(s.timestamp),children:be(s.timestamp)})}),e.jsx(u,{className:"text-[var(--otari-ink)]",children:s.user_id??"—"}),e.jsx(u,{className:"text-[var(--otari-ink)]",children:s.model}),e.jsx(u,{className:"text-right tabular-nums",children:o(s.total_tokens)}),e.jsx(u,{className:"text-right tabular-nums",children:_(s.cost)}),e.jsx(u,{className:"text-right tabular-nums",children:Z(s.latency_ms)}),e.jsx(u,{children:e.jsx(ke,{status:s.status})})]}),y?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:A,children:e.jsx(we,{entry:s})})}):null]},s.id)})})]}),e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("span",{className:"inline-flex items-center gap-2 text-sm text-[var(--otari-muted)]",children:[ee,g.isFetching?e.jsx(ge,{size:"sm"}):null]}),e.jsxs("span",{className:"inline-flex gap-1.5",children:[e.jsx(f,{size:"sm",variant:"outline",isDisabled:j===0,onPress:()=>T(s=>Math.max(0,s-1)),children:"Previous"}),e.jsx(f,{size:"sm",variant:"outline",isDisabled:!se,onPress:()=>T(s=>s+1),children:"Next"})]})]})]})}export{Ce as ActivityPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{u as le,r as n}from"./react-dgEcD0HR.js";import{u as re,a as ne,b as ie,c as oe,P as ce,E as de,F as G,d as Y}from"./index-hDVMDLdX.js";import{T as ue,a as me,b as d,L as xe,c as he,d as pe,e as u}from"./Table-CLdjdyTx.js";import{B as f,S as ge}from"./heroui-BX6JwHY-.js";const je=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function _(t){return t===null?"—":je.format(t)}function o(t){return t===null?"—":t.toLocaleString()}function Z(t){return t===null?"—":t<1e3?`${t} ms`:`${(t/1e3).toFixed(t<1e4?2:1)} s`}function ve(t){const a=new Date(t);return Number.isNaN(a.getTime())?t:a.toLocaleString()}function be(t){const a=new Date(t).getTime();if(Number.isNaN(a))return t;const r=Math.max(0,Math.round((Date.now()-a)/1e3));if(r<60)return`${r}s ago`;const i=Math.round(r/60);if(i<60)return`${i}m ago`;const m=Math.round(i/60);return m<24?`${m}h ago`:`${Math.round(m/24)}d ago`}const fe=3600,k=86400,Ne=[{label:"Last hour",seconds:fe},{label:"24h",seconds:k},{label:"7d",seconds:7*k},{label:"All",seconds:null}],Se=[{label:"All",value:""},{label:"Success",value:"success"},{label:"Error",value:"error"}],_e=["/v1/chat/completions","/v1/messages","/v1/responses","/v1/embeddings","/v1/moderations","/v1/audio/transcriptions","/v1/audio/speech","/v1/images/generations","/v1/rerank","/v1/batches","/v1/batches/results"],N=50;function D(t){return new Date(Date.now()-t*1e3).toISOString()}function ke({status:t}){const a=t==="error"?"border-red-200 bg-red-50 text-red-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return e.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${a}`,children:t})}function l({label:t,children:a}){return e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:t}),e.jsx("span",{className:"text-sm text-[var(--otari-ink)] break-all",children:a})]})}function we({entry:t}){var a;return e.jsxs("div",{className:"flex flex-col gap-4 px-4 py-4",children:[t.error_message?e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsx("div",{className:"flex items-center justify-between",children:e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Error"})}),e.jsx("pre",{className:"max-h-48 overflow-auto rounded-lg border border-red-200 bg-red-50 p-3 text-xs whitespace-pre-wrap break-all text-red-700",children:"The provider returned an error. Inspect gateway logs for details."})]}):null,e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2 lg:grid-cols-4",children:[e.jsx(l,{label:"Provider",children:t.provider??"—"}),e.jsx(l,{label:"Endpoint",children:t.endpoint}),e.jsx(l,{label:"User",children:t.user_id??"—"}),e.jsx(l,{label:"API key",children:t.api_key_id??"—"}),e.jsx(l,{label:"Prompt tokens",children:o(t.prompt_tokens)}),e.jsx(l,{label:"Completion tokens",children:o(t.completion_tokens)}),e.jsx(l,{label:"Total tokens",children:o(t.total_tokens)}),e.jsx(l,{label:"Cost",children:_(t.cost)}),e.jsx(l,{label:"Cache read tokens",children:o(t.cache_read_tokens)}),e.jsx(l,{label:"Cache write tokens",children:o(t.cache_write_tokens)}),e.jsx(l,{label:"1h cache writes",children:o(t.cache_write_1h_tokens??null)}),e.jsx(l,{label:"Total time",children:Z(t.latency_ms)}),e.jsx(l,{label:"Request ID",children:t.id})]}),(a=t.pricing_breakdown)!=null&&a.length?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Billed meters"}),e.jsx("div",{className:"grid gap-2 sm:grid-cols-2 lg:grid-cols-3",children:t.pricing_breakdown.map(r=>e.jsxs(l,{label:r.meter.replaceAll("_"," "),children:[o(r.units)," at ",_(r.rate_per_million)," / 1M, ",_(r.cost)]},r.meter))})]}):null]})}const A=7;function Ce(){var B,z,H;const t=re(),[a]=le(),r=a.get("start_date")??void 0,[i,m]=n.useState(r?null:k),[S,w]=n.useState(()=>r??D(k)),[x,C]=n.useState(()=>a.get("status")??""),[c,M]=n.useState(()=>a.get("model")??""),[h,$]=n.useState(""),[p,U]=n.useState(()=>a.get("user_id")??""),[j,T]=n.useState(0),[J,K]=n.useState(null),P=n.useMemo(()=>({start_date:S,status:x||void 0,model:c.trim()||void 0,endpoint:h||void 0,user_id:p||void 0}),[S,x,c,h,p]);n.useEffect(()=>{T(0)},[P]);const g=ne(P,j,N),v=ie(P),Q=n.useMemo(()=>({start_date:S,status:x||void 0,endpoint:h||void 0,user_id:p||void 0}),[S,x,h,p]),F=((z=(B=oe(Q,"day").data)==null?void 0:B.by_model)==null?void 0:z.filter(s=>!s.is_other&&s.key!==null).map(s=>s.key))??[],b=g.data??[],E=((H=v.data)==null?void 0:H.total)??0,I=!!(x||c.trim()||h||p||i!==null),V=s=>{m(s),w(s===null?void 0:D(s))},W=()=>{m(null),w(void 0),C(""),M(""),$(""),U("")},X=()=>{i!==null&&w(D(i)),g.refetch(),v.refetch()},L=b.length>0,O=L?j*N+1:0,R=j*N+b.length,q=v.isSuccess&&!v.isPlaceholderData,ee=q?E===0?"0 of 0":`${O}–${R} of ${E.toLocaleString()}`:L?`${O}–${R}`:"0",se=q?(j+1)*Ne.jsx(f,{size:"sm",variant:i===s.seconds?"primary":"outline",onPress:()=>V(s.seconds),children:s.label},s.label))}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(G,{id:"filter-status",label:"Status",value:x,onChange:C,children:Se.map(s=>e.jsx("option",{value:s.value,children:s.label},s.value))}),e.jsxs(G,{id:"filter-endpoint",label:"Endpoint",value:h,onChange:$,children:[e.jsx("option",{value:"",children:"All endpoints"}),_e.map(s=>e.jsx("option",{value:s,children:s},s))]}),e.jsx(Y,{label:"User",value:p,onChange:U,placeholder:"All users",options:(t.data??[]).map(s=>({value:s.user_id,label:s.alias?`${s.alias} (${s.user_id})`:s.user_id}))}),e.jsx(Y,{label:"Model",value:c,onChange:M,allowsCustom:!0,placeholder:"Any model",options:(c&&!F.includes(c)?[c,...F]:F).map(s=>({value:s,label:s}))}),I?e.jsx(f,{size:"sm",variant:"ghost",onPress:W,children:"Clear filters"}):null]})]}),e.jsxs(ue,{children:[e.jsx(me,{children:e.jsxs("tr",{children:[e.jsx(d,{children:"Time"}),e.jsx(d,{children:"User"}),e.jsx(d,{children:"Model"}),e.jsx(d,{className:"text-right",children:"Tokens"}),e.jsx(d,{className:"text-right",children:"Cost"}),e.jsx(d,{className:"text-right",children:"Total time"}),e.jsx(d,{children:"Status"})]})}),e.jsx("tbody",{children:g.isLoading?e.jsx(xe,{colSpan:A}):b.length===0?e.jsx(he,{colSpan:A,children:I?"No requests match these filters.":"No requests recorded yet."}):b.map(s=>{const te=s.status==="error",y=J===s.id;return e.jsxs(n.Fragment,{children:[e.jsxs(pe,{selected:y,className:te?"bg-red-50":"",onClick:()=>K(ae=>ae===s.id?null:s.id),children:[e.jsx(u,{className:"text-[var(--otari-muted)]",children:e.jsx("span",{title:ve(s.timestamp),children:be(s.timestamp)})}),e.jsx(u,{className:"text-[var(--otari-ink)]",children:s.user_id??"—"}),e.jsx(u,{className:"text-[var(--otari-ink)]",children:s.model}),e.jsx(u,{className:"text-right tabular-nums",children:o(s.total_tokens)}),e.jsx(u,{className:"text-right tabular-nums",children:_(s.cost)}),e.jsx(u,{className:"text-right tabular-nums",children:Z(s.latency_ms)}),e.jsx(u,{children:e.jsx(ke,{status:s.status})})]}),y?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)]",children:e.jsx("td",{colSpan:A,children:e.jsx(we,{entry:s})})}):null]},s.id)})})]}),e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("span",{className:"inline-flex items-center gap-2 text-sm text-[var(--otari-muted)]",children:[ee,g.isFetching?e.jsx(ge,{size:"sm"}):null]}),e.jsxs("span",{className:"inline-flex gap-1.5",children:[e.jsx(f,{size:"sm",variant:"outline",isDisabled:j===0,onPress:()=>T(s=>Math.max(0,s-1)),children:"Previous"}),e.jsx(f,{size:"sm",variant:"outline",isDisabled:!se,onPress:()=>T(s=>s+1),children:"Next"})]})]})]})}export{Ce as ActivityPage}; diff --git a/src/gateway/static/dashboard/assets/AliasesPage-DJtTC87k.js b/src/gateway/static/dashboard/assets/AliasesPage-BZiGtRPE.js similarity index 98% rename from src/gateway/static/dashboard/assets/AliasesPage-DJtTC87k.js rename to src/gateway/static/dashboard/assets/AliasesPage-BZiGtRPE.js index f1bc1ca5..9ca48644 100644 --- a/src/gateway/static/dashboard/assets/AliasesPage-DJtTC87k.js +++ b/src/gateway/static/dashboard/assets/AliasesPage-BZiGtRPE.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as x,u as w}from"./react-dgEcD0HR.js";import{e as L,f as E,g as B,P as D,E as N,C as I,h as M,i as A}from"./index-Dp4DdBFR.js";import{F}from"./Field-HzRk1KDP.js";import{C as j,L as R,I as q,a as V,b as H,B as h,f as $,d as C}from"./heroui-BX6JwHY-.js";import{T as z,a as G,d as T,b as f,L as K,c as W,e as v}from"./Table-CLdjdyTx.js";const X=50;function k({label:t,value:n,onChange:a,description:r,placeholder:m="provider:model",autoFocus:i,isRequired:c}){const l=L(),{visible:d,total:s,failed:g}=x.useMemo(()=>{var S;const o=n.trim().toLowerCase(),p=((S=l.data)==null?void 0:S.providers)??[],y=p.flatMap(u=>u.models),P=o?y.filter(u=>u.key.toLowerCase().includes(o)):y;return{visible:P.slice(0,X),total:P.length,failed:p.filter(u=>!u.ok)}},[l.data,n]),b=l.isLoading?"Loading models from your providers…":g.length>0?`Could not list models for ${g.map(p=>p.provider).join(", ")}. Check that provider's credentials, or type the model key directly.`:s>d.length?`Showing ${d.length} of ${s} matches. Keep typing to narrow them.`:r;return e.jsxs(j.Root,{allowsCustomValue:!0,allowsEmptyCollection:!0,menuTrigger:"input",inputValue:n,onInputChange:a,onSelectionChange:o=>{o!=null&&a(String(o))},isRequired:c,className:"flex max-w-md flex-col gap-1",children:[e.jsx(R,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsxs(j.InputGroup,{children:[e.jsx(q,{placeholder:m,autoFocus:i}),e.jsx(j.Trigger,{})]}),e.jsx(j.Popover,{children:e.jsx(V,{items:d,className:"max-h-72 overflow-auto",children:o=>e.jsx(H,{id:o.key,textValue:o.key,children:o.key})})}),b?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:b}):null]})}function _({alias:t,onClose:n}){const a=A(),[r,m]=x.useState(t.target),i=r.trim()!==""&&r.trim()!==t.target,c=()=>{i&&a.mutate({name:t.name,target:r.trim()},{onSuccess:n})};return e.jsx(C,{children:e.jsxs(C.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Edit alias ",e.jsx("code",{children:t.name})]}),e.jsx(N,{error:a.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Alias name"}),e.jsx("code",{className:"text-sm text-[var(--otari-muted)]",children:t.name}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"The alias name is the key and cannot be changed here. Delete and recreate to rename."})]}),e.jsx(k,{label:"Target",value:r,onChange:m,isRequired:!0,description:"The real model this resolves to. Callers never see it."})]}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(h,{variant:"primary",isDisabled:!i||a.isPending,onPress:c,children:a.isPending?"Saving…":"Save changes"}),e.jsx(h,{variant:"ghost",onPress:n,children:"Cancel"})]})]})})}function J({onClose:t,initialTarget:n=""}){const a=A(),[r,m]=x.useState(""),[i,c]=x.useState(n),l=/[:/]/.test(r),d=r.trim()!==""&&i.trim()!==""&&!l,s=()=>{d&&a.mutate({name:r.trim(),target:i.trim()},{onSuccess:t})};return e.jsx(C,{children:e.jsxs(C.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"New alias"}),e.jsx(N,{error:a.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(F,{label:"Alias name",value:r,onChange:m,placeholder:"fast-model",isRequired:!0,autoFocus:!0,description:l?e.jsx("span",{className:"text-red-700",children:"An alias name cannot contain “:” or “/”."}):"What callers send as `model`."}),e.jsx(k,{label:"Target",value:i,onChange:c,isRequired:!0,description:"The real model this resolves to. Callers never see it."})]}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(h,{variant:"primary",isDisabled:!d||a.isPending,onPress:s,children:a.isPending?"Creating…":"Create alias"}),e.jsx(h,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function se(){const t=E(),n=B(),[a]=w(),r=a.get("target")??"",[m,i]=x.useState(r!==""),[c,l]=x.useState(null),d=[...t.data??[]].sort((s,g)=>s.name.localeCompare(g.name));return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(D,{title:"Aliases",description:"Friendly names that map to a real provider:model. Callers send the alias as the model; pricing, budgets, and usage key on the target.",action:m||c?null:e.jsx(h,{variant:"primary",onPress:()=>{l(null),i(!0)},children:"New alias"})}),e.jsx(N,{error:t.error}),m?e.jsx(J,{initialTarget:r,onClose:()=>i(!1)}):null,c?e.jsx(_,{alias:c,onClose:()=>l(null)}):null,e.jsxs(z,{children:[e.jsx(G,{children:e.jsxs(T,{children:[e.jsx(f,{children:"Alias"}),e.jsx(f,{children:"Target"}),e.jsx(f,{children:"Source"}),e.jsx(f,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:t.isLoading?e.jsx(K,{colSpan:4}):d.length===0?e.jsx(W,{colSpan:4,children:"No aliases yet. Create one to give a model a friendly name."}):d.map(s=>e.jsxs(T,{children:[e.jsx(v,{className:"font-medium break-all text-[var(--otari-ink)]",children:s.name}),e.jsx(v,{className:"break-all text-[var(--otari-muted)]",children:s.target}),e.jsx(v,{children:e.jsx($,{size:"sm",color:s.source==="stored"?"accent":"default",children:s.source})}),e.jsx(v,{className:"text-right whitespace-nowrap",children:s.source==="stored"?e.jsxs("span",{className:"inline-flex items-center gap-2",children:[e.jsx(h,{size:"sm",variant:"ghost",onPress:()=>{i(!1),l(s)},children:"Edit"}),e.jsx(I,{confirmLabel:"Delete",isPending:n.isPending,onConfirm:()=>n.mutate(s.name),children:"Delete"}),n.error?e.jsx("span",{className:"text-xs text-red-700",children:M(n.error)}):null]}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"set in config.yml"})})]},s.name))})]})]})}export{se as AliasesPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as x,u as w}from"./react-dgEcD0HR.js";import{e as L,f as E,g as B,P as D,E as N,C as I,h as M,i as A}from"./index-hDVMDLdX.js";import{F}from"./Field-HzRk1KDP.js";import{C as j,L as R,I as q,a as V,b as H,B as h,f as $,d as C}from"./heroui-BX6JwHY-.js";import{T as z,a as G,d as T,b as f,L as K,c as W,e as v}from"./Table-CLdjdyTx.js";const X=50;function k({label:t,value:n,onChange:a,description:r,placeholder:m="provider:model",autoFocus:i,isRequired:c}){const l=L(),{visible:d,total:s,failed:g}=x.useMemo(()=>{var S;const o=n.trim().toLowerCase(),p=((S=l.data)==null?void 0:S.providers)??[],y=p.flatMap(u=>u.models),P=o?y.filter(u=>u.key.toLowerCase().includes(o)):y;return{visible:P.slice(0,X),total:P.length,failed:p.filter(u=>!u.ok)}},[l.data,n]),b=l.isLoading?"Loading models from your providers…":g.length>0?`Could not list models for ${g.map(p=>p.provider).join(", ")}. Check that provider's credentials, or type the model key directly.`:s>d.length?`Showing ${d.length} of ${s} matches. Keep typing to narrow them.`:r;return e.jsxs(j.Root,{allowsCustomValue:!0,allowsEmptyCollection:!0,menuTrigger:"input",inputValue:n,onInputChange:a,onSelectionChange:o=>{o!=null&&a(String(o))},isRequired:c,className:"flex max-w-md flex-col gap-1",children:[e.jsx(R,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsxs(j.InputGroup,{children:[e.jsx(q,{placeholder:m,autoFocus:i}),e.jsx(j.Trigger,{})]}),e.jsx(j.Popover,{children:e.jsx(V,{items:d,className:"max-h-72 overflow-auto",children:o=>e.jsx(H,{id:o.key,textValue:o.key,children:o.key})})}),b?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:b}):null]})}function _({alias:t,onClose:n}){const a=A(),[r,m]=x.useState(t.target),i=r.trim()!==""&&r.trim()!==t.target,c=()=>{i&&a.mutate({name:t.name,target:r.trim()},{onSuccess:n})};return e.jsx(C,{children:e.jsxs(C.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Edit alias ",e.jsx("code",{children:t.name})]}),e.jsx(N,{error:a.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Alias name"}),e.jsx("code",{className:"text-sm text-[var(--otari-muted)]",children:t.name}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"The alias name is the key and cannot be changed here. Delete and recreate to rename."})]}),e.jsx(k,{label:"Target",value:r,onChange:m,isRequired:!0,description:"The real model this resolves to. Callers never see it."})]}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(h,{variant:"primary",isDisabled:!i||a.isPending,onPress:c,children:a.isPending?"Saving…":"Save changes"}),e.jsx(h,{variant:"ghost",onPress:n,children:"Cancel"})]})]})})}function J({onClose:t,initialTarget:n=""}){const a=A(),[r,m]=x.useState(""),[i,c]=x.useState(n),l=/[:/]/.test(r),d=r.trim()!==""&&i.trim()!==""&&!l,s=()=>{d&&a.mutate({name:r.trim(),target:i.trim()},{onSuccess:t})};return e.jsx(C,{children:e.jsxs(C.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"New alias"}),e.jsx(N,{error:a.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(F,{label:"Alias name",value:r,onChange:m,placeholder:"fast-model",isRequired:!0,autoFocus:!0,description:l?e.jsx("span",{className:"text-red-700",children:"An alias name cannot contain “:” or “/”."}):"What callers send as `model`."}),e.jsx(k,{label:"Target",value:i,onChange:c,isRequired:!0,description:"The real model this resolves to. Callers never see it."})]}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(h,{variant:"primary",isDisabled:!d||a.isPending,onPress:s,children:a.isPending?"Creating…":"Create alias"}),e.jsx(h,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function se(){const t=E(),n=B(),[a]=w(),r=a.get("target")??"",[m,i]=x.useState(r!==""),[c,l]=x.useState(null),d=[...t.data??[]].sort((s,g)=>s.name.localeCompare(g.name));return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(D,{title:"Aliases",description:"Friendly names that map to a real provider:model. Callers send the alias as the model; pricing, budgets, and usage key on the target.",action:m||c?null:e.jsx(h,{variant:"primary",onPress:()=>{l(null),i(!0)},children:"New alias"})}),e.jsx(N,{error:t.error}),m?e.jsx(J,{initialTarget:r,onClose:()=>i(!1)}):null,c?e.jsx(_,{alias:c,onClose:()=>l(null)}):null,e.jsxs(z,{children:[e.jsx(G,{children:e.jsxs(T,{children:[e.jsx(f,{children:"Alias"}),e.jsx(f,{children:"Target"}),e.jsx(f,{children:"Source"}),e.jsx(f,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:t.isLoading?e.jsx(K,{colSpan:4}):d.length===0?e.jsx(W,{colSpan:4,children:"No aliases yet. Create one to give a model a friendly name."}):d.map(s=>e.jsxs(T,{children:[e.jsx(v,{className:"font-medium break-all text-[var(--otari-ink)]",children:s.name}),e.jsx(v,{className:"break-all text-[var(--otari-muted)]",children:s.target}),e.jsx(v,{children:e.jsx($,{size:"sm",color:s.source==="stored"?"accent":"default",children:s.source})}),e.jsx(v,{className:"text-right whitespace-nowrap",children:s.source==="stored"?e.jsxs("span",{className:"inline-flex items-center gap-2",children:[e.jsx(h,{size:"sm",variant:"ghost",onPress:()=>{i(!1),l(s)},children:"Edit"}),e.jsx(I,{confirmLabel:"Delete",isPending:n.isPending,onConfirm:()=>n.mutate(s.name),children:"Delete"}),n.error?e.jsx("span",{className:"text-xs text-red-700",children:M(n.error)}):null]}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"set in config.yml"})})]},s.name))})]})]})}export{se as AliasesPage}; diff --git a/src/gateway/static/dashboard/assets/BudgetsPage-BCAQ5J9W.js b/src/gateway/static/dashboard/assets/BudgetsPage-D0M-Cd5s.js similarity index 99% rename from src/gateway/static/dashboard/assets/BudgetsPage-BCAQ5J9W.js rename to src/gateway/static/dashboard/assets/BudgetsPage-D0M-Cd5s.js index 4ff65f87..f52310a3 100644 --- a/src/gateway/static/dashboard/assets/BudgetsPage-BCAQ5J9W.js +++ b/src/gateway/static/dashboard/assets/BudgetsPage-D0M-Cd5s.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as u}from"./react-dgEcD0HR.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-Dp4DdBFR.js";import{F as L}from"./Field-HzRk1KDP.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-CLdjdyTx.js";import{C as B,I as le,a as de,b as oe,B as x,d as A,S as ue}from"./heroui-BX6JwHY-.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-1t81HyiD.js";import{r as u}from"./react-dgEcD0HR.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-hDVMDLdX.js";import{F as L}from"./Field-HzRk1KDP.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-CLdjdyTx.js";import{C as B,I as le,a as de,b as oe,B as x,d as A,S as ue}from"./heroui-BX6JwHY-.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}; diff --git a/src/gateway/static/dashboard/assets/KeysPage-DIt3Gp89.js b/src/gateway/static/dashboard/assets/KeysPage-CQj72SEP.js similarity index 99% rename from src/gateway/static/dashboard/assets/KeysPage-DIt3Gp89.js rename to src/gateway/static/dashboard/assets/KeysPage-CQj72SEP.js index ea9cbb42..f152f6d0 100644 --- a/src/gateway/static/dashboard/assets/KeysPage-DIt3Gp89.js +++ b/src/gateway/static/dashboard/assets/KeysPage-CQj72SEP.js @@ -1,4 +1,4 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as m}from"./react-dgEcD0HR.js";import{p as $,q as E,r as z,s as K,P as H,E as T,t as B,u as M,I as V}from"./index-Dp4DdBFR.js";import{F as k}from"./Field-HzRk1KDP.js";import{M as O,a as A}from"./ModelScopeControl-D_p9BPKF.js";import{C as _,L as U,I as q,a as W,b as G,D as Y,B as g,f as P,d as C}from"./heroui-BX6JwHY-.js";import{T as J,a as Q,b as v,L as X,c as Z,d as ee,e as y}from"./Table-CLdjdyTx.js";function te({value:t,onChange:s,users:n,description:i}){const c=n.filter(a=>!a.user_id.startsWith("apikey-")).map(a=>({id:a.user_id,name:a.alias?`${a.user_id} (${a.alias})`:a.user_id})),[l,o]=m.useState(t),u=l.trim().toLowerCase(),j=c.filter(a=>!u||a.id.toLowerCase().includes(u)||a.name.toLowerCase().includes(u)).slice(0,50),p=a=>{const x=a.trim(),w=c.find(N=>N.id===x||N.name===x);return w?w.id:x},d=p(l),h=c.some(a=>a.id===d),f=d!==""&&!h?e.jsxs("span",{children:["Creates a new user ",e.jsx("code",{children:d}),"."]}):i??"Spend and budgets track against this user.";return e.jsxs(_.Root,{allowsCustomValue:!0,allowsEmptyCollection:!0,menuTrigger:"focus",inputValue:l,onInputChange:a=>{o(a),s(p(a))},onSelectionChange:a=>{if(a!=null){const x=String(a);o(x),s(x)}},className:"flex max-w-md flex-col gap-1",children:[e.jsx(U,{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Owner"}),e.jsxs(_.InputGroup,{children:[e.jsx(q,{placeholder:"Pick a user, or type a new id…",autoComplete:"off","data-1p-ignore":!0,"data-lpignore":"true",onFocus:a=>a.currentTarget.select()}),e.jsx(_.Trigger,{})]}),e.jsx(_.Popover,{children:e.jsx(W,{items:j,className:"max-h-72 overflow-auto",children:a=>e.jsx(G,{id:a.id,textValue:a.name,children:a.name})})}),e.jsx(Y,{className:"text-xs text-[var(--otari-muted)]",children:f})]})}function L(t){if(!t)return"—";const s=new Date(t);return Number.isNaN(s.getTime())?"—":s.toLocaleDateString()}function se(t){if(!t)return null;const s=new Date(t).getTime();if(Number.isNaN(s))return null;const n=Math.round((s-Date.now())/1e3),i=Math.abs(n),c=[["day",86400],["hour",3600],["minute",60]],l=new Intl.RelativeTimeFormat(void 0,{numeric:"auto"});for(const[o,u]of c)if(i>=u)return l.format(Math.round(n/u),o);return l.format(n,"second")}function ne(t){if(!t.expires_at)return!1;const s=new Date(t.expires_at).getTime();return!Number.isNaN(s)&&sString(i).padStart(2,"0");return`${s.getFullYear()}-${n(s.getMonth()+1)}-${n(s.getDate())}T${n(s.getHours())}:${n(s.getMinutes())}`}const ae=t=>(t??"").startsWith("apikey-");function I({label:t,value:s,multiline:n=!1,fieldRef:i}){const c=m.useRef(null),l=i??c,[o,u]=m.useState(!1),[j,p]=m.useState(!1),d=async()=>{var f,a,x;(f=l.current)==null||f.focus(),(a=l.current)==null||a.select();try{if((x=navigator.clipboard)!=null&&x.writeText){await navigator.clipboard.writeText(s),u(!0),p(!1),window.setTimeout(()=>u(!1),2e3);return}}catch{}p(!0)},h="w-full rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 font-mono text-xs text-[var(--otari-ink)]";return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:t}),e.jsx(g,{size:"sm",variant:"outline",onPress:d,children:o?"Copied":"Copy"})]}),n?e.jsx("textarea",{ref:l,readOnly:!0,rows:s.split(` +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as m}from"./react-dgEcD0HR.js";import{p as $,q as E,r as z,s as K,P as H,E as T,t as B,u as M,I as V}from"./index-hDVMDLdX.js";import{F as k}from"./Field-HzRk1KDP.js";import{M as O,a as A}from"./ModelScopeControl-DX341Q9L.js";import{C as _,L as U,I as q,a as W,b as G,D as Y,B as g,f as P,d as C}from"./heroui-BX6JwHY-.js";import{T as J,a as Q,b as v,L as X,c as Z,d as ee,e as y}from"./Table-CLdjdyTx.js";function te({value:t,onChange:s,users:n,description:i}){const c=n.filter(a=>!a.user_id.startsWith("apikey-")).map(a=>({id:a.user_id,name:a.alias?`${a.user_id} (${a.alias})`:a.user_id})),[l,o]=m.useState(t),u=l.trim().toLowerCase(),j=c.filter(a=>!u||a.id.toLowerCase().includes(u)||a.name.toLowerCase().includes(u)).slice(0,50),p=a=>{const x=a.trim(),w=c.find(N=>N.id===x||N.name===x);return w?w.id:x},d=p(l),h=c.some(a=>a.id===d),f=d!==""&&!h?e.jsxs("span",{children:["Creates a new user ",e.jsx("code",{children:d}),"."]}):i??"Spend and budgets track against this user.";return e.jsxs(_.Root,{allowsCustomValue:!0,allowsEmptyCollection:!0,menuTrigger:"focus",inputValue:l,onInputChange:a=>{o(a),s(p(a))},onSelectionChange:a=>{if(a!=null){const x=String(a);o(x),s(x)}},className:"flex max-w-md flex-col gap-1",children:[e.jsx(U,{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Owner"}),e.jsxs(_.InputGroup,{children:[e.jsx(q,{placeholder:"Pick a user, or type a new id…",autoComplete:"off","data-1p-ignore":!0,"data-lpignore":"true",onFocus:a=>a.currentTarget.select()}),e.jsx(_.Trigger,{})]}),e.jsx(_.Popover,{children:e.jsx(W,{items:j,className:"max-h-72 overflow-auto",children:a=>e.jsx(G,{id:a.id,textValue:a.name,children:a.name})})}),e.jsx(Y,{className:"text-xs text-[var(--otari-muted)]",children:f})]})}function L(t){if(!t)return"—";const s=new Date(t);return Number.isNaN(s.getTime())?"—":s.toLocaleDateString()}function se(t){if(!t)return null;const s=new Date(t).getTime();if(Number.isNaN(s))return null;const n=Math.round((s-Date.now())/1e3),i=Math.abs(n),c=[["day",86400],["hour",3600],["minute",60]],l=new Intl.RelativeTimeFormat(void 0,{numeric:"auto"});for(const[o,u]of c)if(i>=u)return l.format(Math.round(n/u),o);return l.format(n,"second")}function ne(t){if(!t.expires_at)return!1;const s=new Date(t.expires_at).getTime();return!Number.isNaN(s)&&sString(i).padStart(2,"0");return`${s.getFullYear()}-${n(s.getMonth()+1)}-${n(s.getDate())}T${n(s.getHours())}:${n(s.getMinutes())}`}const ae=t=>(t??"").startsWith("apikey-");function I({label:t,value:s,multiline:n=!1,fieldRef:i}){const c=m.useRef(null),l=i??c,[o,u]=m.useState(!1),[j,p]=m.useState(!1),d=async()=>{var f,a,x;(f=l.current)==null||f.focus(),(a=l.current)==null||a.select();try{if((x=navigator.clipboard)!=null&&x.writeText){await navigator.clipboard.writeText(s),u(!0),p(!1),window.setTimeout(()=>u(!1),2e3);return}}catch{}p(!0)},h="w-full rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 font-mono text-xs text-[var(--otari-ink)]";return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:t}),e.jsx(g,{size:"sm",variant:"outline",onPress:d,children:o?"Copied":"Copy"})]}),n?e.jsx("textarea",{ref:l,readOnly:!0,rows:s.split(` `).length,value:s,onFocus:f=>f.currentTarget.select(),className:`${h} resize-none whitespace-pre`}):e.jsx("input",{ref:l,readOnly:!0,value:s,onFocus:f=>f.currentTarget.select(),className:h}),e.jsx("span",{"aria-live":"polite",className:"text-xs text-green-700",children:o?"Copied to clipboard.":""}),j?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Selected. Press Ctrl/Cmd-C to copy."}):null]})}function ie({title:t,result:s,onClose:n}){const i=m.useRef(null),c=m.useRef(null),l=typeof window<"u"?window.location.origin:"",o=s.key;m.useEffect(()=>{var d,h;(d=c.current)==null||d.focus(),(h=c.current)==null||h.select()},[]);const u=d=>{var x;if(d.key!=="Tab")return;const h=(x=i.current)==null?void 0:x.querySelectorAll('button, input, textarea, a[href], [tabindex]:not([tabindex="-1"])');if(!h||h.length===0)return;const f=h[0],a=h[h.length-1];d.shiftKey&&document.activeElement===f?(d.preventDefault(),a.focus()):!d.shiftKey&&document.activeElement===a&&(d.preventDefault(),f.focus())},j=[`curl ${l}/v1/chat/completions \\`,` -H "Otari-Key: ${o}" \\`,' -H "Content-Type: application/json" \\',` -d '{"model": "your-model", "messages": [{"role": "user", "content": "Hello"}]}'`].join(` `),p=["from openai import OpenAI","",`client = OpenAI(base_url="${l}/v1", api_key="${o}")`,"resp = client.chat.completions.create(",' model="your-model",',' messages=[{"role": "user", "content": "Hello"}],',")","print(resp.choices[0].message.content)"].join(` `);return e.jsx("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4",role:"presentation",children:e.jsxs("div",{ref:i,role:"dialog","aria-modal":"true","aria-labelledby":"reveal-title",onKeyDown:u,className:"flex max-h-[90vh] w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-xl bg-[var(--otari-surface)] p-6 shadow-xl",children:[e.jsx("h2",{id:"reveal-title",className:"text-lg font-semibold text-[var(--otari-ink)]",children:t}),e.jsx(V,{tone:"warning",children:"Copy this key now. For security it is shown only once and cannot be retrieved later. If you lose it, use Regenerate to issue a new secret."}),e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Model access: ",A(s.allowed_models).text,"."]}),e.jsx(I,{label:"Secret key",value:o,fieldRef:c}),e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{children:[e.jsx("div",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Make your first call"}),e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Replace ",e.jsx("code",{children:"your-model"})," with a model from the Models page."]})]}),e.jsx(I,{label:"curl",value:j,multiline:!0}),e.jsx(I,{label:"Python (OpenAI SDK)",value:p,multiline:!0})]}),e.jsx("div",{className:"flex justify-end",children:e.jsx(g,{variant:"primary",onPress:n,children:"I’ve saved this key"})})]})})}function R({trigger:t,message:s,confirmLabel:n,isPending:i,onConfirm:c}){const[l,o]=m.useState(!1);return l?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.jsx("span",{className:"max-w-xs text-xs text-amber-800",children:s}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(g,{size:"sm",variant:"danger",isDisabled:i,onPress:c,children:n}),e.jsx(g,{size:"sm",variant:"ghost",isDisabled:i,onPress:()=>o(!1),children:"Cancel"})]})]}):e.jsx(g,{size:"sm",variant:"danger-soft",onPress:()=>o(!0),children:t})}function F({userId:t,users:s}){const n=t.trim();if(n==="")return e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Choose an owner above to see the models this key can inherit."});const i=s.find(o=>o.user_id===n);if(!i)return e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["New user ",e.jsx("code",{children:n})," starts unrestricted, so this key may allow any model."]});const{text:c}=A(i.allowed_models),l=i.allowed_models&&i.allowed_models.length>0?i.allowed_models.join(", "):null;return e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Owner ",e.jsx("code",{children:n})," allows ",e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:c.toLowerCase()}),l?e.jsxs(e.Fragment,{children:[" (",e.jsx("span",{className:"font-mono",children:l}),")"]}):null,". This key inherits that, or narrows within it."]})}function le({onClose:t,onCreated:s}){const n=B(),i=M(),[c,l]=m.useState(""),[o,u]=m.useState(""),[j,p]=m.useState(!1),[d,h]=m.useState(""),[f,a]=m.useState(null),[x,w]=m.useState(!0),N=o!==""&&new Date(o).getTime(){if(n.isPending||!x||r)return;const S={key_name:c.trim()||null,user_id:d.trim(),expires_at:o?new Date(o).toISOString():null,allowed_models:f};n.mutate(S,{onSuccess:D=>{s(D),t()}})};return e.jsx(C,{children:e.jsxs(C.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Create API key"}),e.jsx(T,{error:n.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(k,{label:"Name",value:c,onChange:l,placeholder:"ci-bot",autoFocus:!0,description:"A label to recognize this key later."}),e.jsx(k,{label:"Expires (optional)",value:o,onChange:u,type:"datetime-local",description:N?e.jsx("span",{className:"text-red-700",children:"That time is in the past; the key would be rejected immediately."}):"Leave blank for a key that never expires."})]}),e.jsx(te,{value:d,onChange:h,users:i.data??[]}),e.jsx("button",{type:"button",className:"self-start text-xs font-medium text-[var(--otari-brand-dark)]",onClick:()=>p(S=>!S),children:j?"Hide advanced":"Advanced (restrict models)"}),j?e.jsxs("div",{className:"flex flex-col gap-4 rounded-lg border border-[var(--otari-line)] p-4",children:[e.jsx(F,{userId:d,users:i.data??[]}),e.jsx(O,{title:"Restrict this key's models",description:"By default this key inherits its owner's access. Optionally narrow it to a subset; a key can never exceed its owner's allowed models.",anyLabel:"Inherit owner access",initial:null,onChange:(S,D)=>{a(S),w(D)}})]}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(g,{variant:"primary",isDisabled:n.isPending||!x||r,onPress:b,children:n.isPending?"Creating…":"Create key"}),e.jsx(g,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function oe({apiKey:t,onClose:s}){const n=E(),i=M(),[c,l]=m.useState(t.key_name??""),[o,u]=m.useState(re(t.expires_at)),[j,p]=m.useState(t.allowed_models),[d,h]=m.useState(!0),f=()=>{n.isPending||!d||n.mutate({id:t.id,body:{key_name:c.trim()||null,expires_at:o?new Date(o).toISOString():null,allowed_models:j}},{onSuccess:s})};return e.jsx(C,{children:e.jsxs(C.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Edit ",e.jsx("code",{children:t.key_name??t.id})]}),e.jsx(T,{error:n.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(k,{label:"Name",value:c,onChange:l,placeholder:"ci-bot"}),e.jsx(k,{label:"Expires",value:o,onChange:u,type:"datetime-local",description:"Blank clears the expiry."})]}),t.user_id?e.jsx(F,{userId:t.user_id,users:i.data??[]}):null,e.jsx(O,{title:"Restrict this key's models",description:"This key inherits its owner's access by default. Narrow it to a subset here; it can never exceed the owner's allowed models.",anyLabel:"Inherit owner access",initial:t.allowed_models,onChange:(a,x)=>{p(a),h(x)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(g,{variant:"primary",isDisabled:n.isPending||!d,onPress:f,children:n.isPending?"Saving…":"Save changes"}),e.jsx(g,{variant:"ghost",onPress:s,children:"Cancel"})]})]})})}function ce({apiKey:t}){return t.is_active?ne(t)?e.jsx(P,{size:"sm",color:"warning",children:"Expired"}):e.jsx(P,{size:"sm",color:"accent",children:"Active"}):e.jsx(P,{size:"sm",color:"default",children:"Disabled"})}function de({allowed:t}){const{text:s,tone:n}=A(t),i=n==="danger"?"text-red-700 font-medium":n==="muted"?"text-[var(--otari-muted)]":"text-[var(--otari-brand-dark)] font-medium",c=t&&t.length>0?t.join(", "):void 0;return e.jsx("span",{className:`text-xs ${i}`,title:c,children:s})}function ue({onCreate:t}){return e.jsx(C,{children:e.jsxs(C.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 API keys yet"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"An API key authenticates callers to this gateway. Create one to make your first request; the secret is shown once, so keep it somewhere safe."})]}),e.jsx("div",{children:e.jsx(g,{variant:"primary",onPress:t,children:"Create your first key"})})]})})}function ve(){const t=$(),s=E(),n=z(),i=K(),[c,l]=m.useState(!1),[o,u]=m.useState(null),[j,p]=m.useState(null),d=t.data??[],h=t.isLoading,f=d.find(r=>r.id===o)??null,a=!h&&d.length===0&&!c,x=r=>r.key_name??r.id,w=(r,b)=>s.mutate({id:r.id,body:{is_active:b}}),N=r=>n.mutate(r.id,{onSuccess:b=>p({title:`New secret for ${x(r)}`,result:b})});return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(H,{title:"API keys",description:"Issue and revoke the keys that authenticate callers to this gateway. Secrets are shown once at creation.",action:c?null:e.jsx(g,{variant:"primary",onPress:()=>{u(null),l(!0)},children:"Create key"})}),e.jsx(T,{error:t.error??s.error??n.error??i.error}),a?e.jsx(ue,{onCreate:()=>{u(null),l(!0)}}):null,c?e.jsx(le,{onClose:()=>l(!1),onCreated:r=>p({title:"API key created",result:r})}):null,f?e.jsx(oe,{apiKey:f,onClose:()=>u(null)},f.id):null,e.jsxs(J,{children:[e.jsx(Q,{children:e.jsxs("tr",{children:[e.jsx(v,{children:"Name"}),e.jsx(v,{children:"Status"}),e.jsx(v,{children:"Owner"}),e.jsx(v,{children:"Key"}),e.jsx(v,{children:"Created"}),e.jsx(v,{children:"Last used"}),e.jsx(v,{children:"Expires"}),e.jsx(v,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:h?e.jsx(X,{colSpan:8}):d.length===0?e.jsx(Z,{colSpan:8,children:"No API keys yet. Create one to authenticate a caller."}):d.map(r=>e.jsxs(ee,{selected:o===r.id,onClick:()=>{l(!1),u(r.id)},children:[e.jsx(y,{className:"font-medium text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{children:r.key_name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsx(de,{allowed:r.allowed_models})]})}),e.jsx(y,{children:e.jsx(ce,{apiKey:r})}),e.jsx(y,{className:"text-[var(--otari-muted)]",children:ae(r.user_id)?e.jsx("span",{className:"inline-flex items-center gap-1.5",children:e.jsx(P,{size:"sm",color:"default",children:"virtual"})}):e.jsx("code",{className:"text-xs",children:r.user_id??"—"})}),e.jsx(y,{children:e.jsx("code",{className:"text-xs text-[var(--otari-muted)]",children:r.key_prefix?`${r.key_prefix}…`:"—"})}),e.jsx(y,{className:"text-[var(--otari-muted)]",children:L(r.created_at)}),e.jsx(y,{className:"text-[var(--otari-muted)]",children:se(r.last_used_at)??"never"}),e.jsx(y,{className:"text-[var(--otari-muted)]",children:e.jsx("span",{title:r.expires_at?new Date(r.expires_at).toLocaleString():void 0,children:r.expires_at?L(r.expires_at):"never"})}),e.jsx(y,{children:e.jsxs("div",{className:"flex items-center justify-end gap-1.5",onClick:b=>b.stopPropagation(),children:[r.is_active?e.jsx(g,{size:"sm",variant:"outline",isDisabled:s.isPending,onPress:()=>w(r,!1),children:"Disable"}):e.jsx(g,{size:"sm",variant:"outline",isDisabled:s.isPending,onPress:()=>w(r,!0),children:"Enable"}),e.jsx(g,{size:"sm",variant:"ghost",onPress:()=>{l(!1),u(r.id)},children:"Edit"}),e.jsx(R,{trigger:"Regenerate",confirmLabel:"Regenerate",isPending:n.isPending,message:e.jsxs(e.Fragment,{children:["Regenerate the secret for ",e.jsx("strong",{children:x(r)}),"? The current secret stops working immediately, with no grace period."]}),onConfirm:()=>N(r)}),r.is_active?null:e.jsx(R,{trigger:"Delete",confirmLabel:"Delete permanently",isPending:i.isPending,message:e.jsxs(e.Fragment,{children:["Permanently delete ",e.jsx("strong",{children:x(r)}),"? This removes the key and unlinks its usage history. Cannot be undone."]}),onConfirm:()=>i.mutate(r.id)})]})})]},r.id))})]}),j?e.jsx(ie,{title:j.title,result:j.result,onClose:()=>{p(null),n.reset()}}):null]})}export{ve as KeysPage}; diff --git a/src/gateway/static/dashboard/assets/ModelScopeControl-D_p9BPKF.js b/src/gateway/static/dashboard/assets/ModelScopeControl-DX341Q9L.js similarity index 98% rename from src/gateway/static/dashboard/assets/ModelScopeControl-D_p9BPKF.js rename to src/gateway/static/dashboard/assets/ModelScopeControl-DX341Q9L.js index ae6a92a0..75593609 100644 --- a/src/gateway/static/dashboard/assets/ModelScopeControl-D_p9BPKF.js +++ b/src/gateway/static/dashboard/assets/ModelScopeControl-DX341Q9L.js @@ -1 +1 @@ -import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as n}from"./react-dgEcD0HR.js";import{J as A,e as $,f as P}from"./index-Dp4DdBFR.js";import{C as d,I as R,a as T,b as V}from"./heroui-BX6JwHY-.js";function q(r){return r===null?"any":r.length===0?"block":"only"}const O=50;function Q({initial:r,onChange:m,title:N="Model access",description:w,anyLabel:C="Any model"}){const x=A(),u=$(),v=P(),[i,S]=n.useState(q(r)),[a,g]=n.useState(r??[]),[p,y]=n.useState(""),f=n.useMemo(()=>{var j,k;const e=new Set,s=[],l=(o,c)=>{o&&!e.has(o)&&(e.add(o),s.push({id:o,label:c}))};for(const o of((j=x.data)==null?void 0:j.providers)??[])l(`${o.instance}:*`,`${o.instance}:* · all ${o.instance} models`);for(const o of((k=u.data)==null?void 0:k.providers)??[])for(const c of o.models)l(c.key,c.key);for(const o of v.data??[])l(o.target,`${o.name} · alias`);return s},[x.data,u.data,v.data]),L=n.useMemo(()=>{const e=p.trim().toLowerCase();return f.filter(s=>!a.includes(s.id)).filter(s=>!e||s.id.toLowerCase().includes(e)||s.label.toLowerCase().includes(e)).slice(0,O)},[f,a,p]),h=(e,s)=>{e==="any"?m(null,!0):e==="block"?m([],!0):m(s,s.length>0)},B=e=>{S(e),h(e,a)},M=e=>{const s=a.includes(e)?a:[...a,e];g(s),y(""),h("only",s)},E=e=>{const s=a.filter(l=>l!==e);g(s),h("only",s)},b=(e,s)=>t.jsx("button",{type:"button","aria-pressed":i===e,onClick:()=>B(e),className:i===e?"rounded-md bg-white px-3 py-1.5 text-sm font-medium text-[var(--otari-ink)] shadow-sm":"rounded-md px-3 py-1.5 text-sm text-[var(--otari-muted)] hover:text-[var(--otari-ink)]",children:s}),I=!u.isLoading&&!x.isLoading&&f.length===0;return t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsxs("div",{children:[t.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:N}),t.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:w??"Which models this key may list and call. The master key is never restricted, so blocking a key cannot lock you out of the dashboard."})]}),t.jsxs("div",{className:"flex w-fit items-center gap-1 rounded-lg bg-[var(--otari-bg)] p-1",children:[b("any",C),b("only","Only selected"),b("block","Block all")]}),i==="block"?t.jsxs("div",{className:"rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800",children:["Blocked from ",t.jsx("strong",{children:"every"})," model until you change this access."]}):null,i==="only"?t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsx("div",{className:"flex flex-wrap gap-1.5",children:a.length===0?t.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Pick at least one model below, or choose “Block all”."}):a.map(e=>t.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:[e,t.jsx("button",{type:"button","aria-label":`Remove ${e}`,onClick:()=>E(e),className:"text-[var(--otari-brand-dark)] hover:text-red-700",children:"×"})]},e))}),I?t.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No providers or models discovered yet. Configure a provider first, then scope this key."}):t.jsxs(d.Root,{allowsEmptyCollection:!0,menuTrigger:"input",inputValue:p,onInputChange:y,selectedKey:null,onSelectionChange:e=>{e!=null&&M(String(e))},className:"flex max-w-md flex-col gap-1",children:[t.jsxs(d.InputGroup,{children:[t.jsx(R,{"aria-label":"Add a model",placeholder:"Search providers, models, aliases…",autoComplete:"off"}),t.jsx(d.Trigger,{})]}),t.jsx(d.Popover,{children:t.jsx(T,{items:L,className:"max-h-72 overflow-auto",children:e=>t.jsx(V,{id:e.id,textValue:e.label,children:e.label})})})]})]}):null]})}function W(r){return r===null?{text:"All models",tone:"muted"}:r.length===0?{text:"No models",tone:"danger"}:{text:"Selected models",tone:"normal"}}export{Q as M,W as a}; +import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as n}from"./react-dgEcD0HR.js";import{J as A,e as $,f as P}from"./index-hDVMDLdX.js";import{C as d,I as R,a as T,b as V}from"./heroui-BX6JwHY-.js";function q(r){return r===null?"any":r.length===0?"block":"only"}const O=50;function Q({initial:r,onChange:m,title:N="Model access",description:w,anyLabel:C="Any model"}){const x=A(),u=$(),v=P(),[i,S]=n.useState(q(r)),[a,g]=n.useState(r??[]),[p,y]=n.useState(""),f=n.useMemo(()=>{var j,k;const e=new Set,s=[],l=(o,c)=>{o&&!e.has(o)&&(e.add(o),s.push({id:o,label:c}))};for(const o of((j=x.data)==null?void 0:j.providers)??[])l(`${o.instance}:*`,`${o.instance}:* · all ${o.instance} models`);for(const o of((k=u.data)==null?void 0:k.providers)??[])for(const c of o.models)l(c.key,c.key);for(const o of v.data??[])l(o.target,`${o.name} · alias`);return s},[x.data,u.data,v.data]),L=n.useMemo(()=>{const e=p.trim().toLowerCase();return f.filter(s=>!a.includes(s.id)).filter(s=>!e||s.id.toLowerCase().includes(e)||s.label.toLowerCase().includes(e)).slice(0,O)},[f,a,p]),h=(e,s)=>{e==="any"?m(null,!0):e==="block"?m([],!0):m(s,s.length>0)},B=e=>{S(e),h(e,a)},M=e=>{const s=a.includes(e)?a:[...a,e];g(s),y(""),h("only",s)},E=e=>{const s=a.filter(l=>l!==e);g(s),h("only",s)},b=(e,s)=>t.jsx("button",{type:"button","aria-pressed":i===e,onClick:()=>B(e),className:i===e?"rounded-md bg-white px-3 py-1.5 text-sm font-medium text-[var(--otari-ink)] shadow-sm":"rounded-md px-3 py-1.5 text-sm text-[var(--otari-muted)] hover:text-[var(--otari-ink)]",children:s}),I=!u.isLoading&&!x.isLoading&&f.length===0;return t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsxs("div",{children:[t.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:N}),t.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:w??"Which models this key may list and call. The master key is never restricted, so blocking a key cannot lock you out of the dashboard."})]}),t.jsxs("div",{className:"flex w-fit items-center gap-1 rounded-lg bg-[var(--otari-bg)] p-1",children:[b("any",C),b("only","Only selected"),b("block","Block all")]}),i==="block"?t.jsxs("div",{className:"rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800",children:["Blocked from ",t.jsx("strong",{children:"every"})," model until you change this access."]}):null,i==="only"?t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsx("div",{className:"flex flex-wrap gap-1.5",children:a.length===0?t.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Pick at least one model below, or choose “Block all”."}):a.map(e=>t.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:[e,t.jsx("button",{type:"button","aria-label":`Remove ${e}`,onClick:()=>E(e),className:"text-[var(--otari-brand-dark)] hover:text-red-700",children:"×"})]},e))}),I?t.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No providers or models discovered yet. Configure a provider first, then scope this key."}):t.jsxs(d.Root,{allowsEmptyCollection:!0,menuTrigger:"input",inputValue:p,onInputChange:y,selectedKey:null,onSelectionChange:e=>{e!=null&&M(String(e))},className:"flex max-w-md flex-col gap-1",children:[t.jsxs(d.InputGroup,{children:[t.jsx(R,{"aria-label":"Add a model",placeholder:"Search providers, models, aliases…",autoComplete:"off"}),t.jsx(d.Trigger,{})]}),t.jsx(d.Popover,{children:t.jsx(T,{items:L,className:"max-h-72 overflow-auto",children:e=>t.jsx(V,{id:e.id,textValue:e.label,children:e.label})})})]})]}):null]})}function W(r){return r===null?{text:"All models",tone:"muted"}:r.length===0?{text:"No models",tone:"danger"}:{text:"Selected models",tone:"normal"}}export{Q as M,W as a}; diff --git a/src/gateway/static/dashboard/assets/ModelsPage-BR6bzhht.js b/src/gateway/static/dashboard/assets/ModelsPage-BIdxI_hW.js similarity index 99% rename from src/gateway/static/dashboard/assets/ModelsPage-BR6bzhht.js rename to src/gateway/static/dashboard/assets/ModelsPage-BIdxI_hW.js index 658c5198..ff1619f4 100644 --- a/src/gateway/static/dashboard/assets/ModelsPage-BR6bzhht.js +++ b/src/gateway/static/dashboard/assets/ModelsPage-BIdxI_hW.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{i as rt,u as nt,r as c}from"./react-dgEcD0HR.js";import{v as at,w as st,e as lt,x as ct,P as ot,E as ut,F as E,I as dt,y as ne,z as pt,A as ht,B as xt,D as A,G as $e,H as Ee,C as Ae,h as me}from"./index-Dp4DdBFR.js";import{T as mt,a as ft,d as Ce,b as re,L as vt,e as U,c as bt}from"./Table-CLdjdyTx.js";import{B as L,d as ke,f as K}from"./heroui-BX6JwHY-.js";function We(t){const i=t.indexOf(":");return i>0?t.slice(0,i):"—"}function gt(t,i=Date.now()){const r=new Map;for(const n of t){const a=r.get(n.model_key)??[];a.push(n),r.set(n.model_key,a)}const s=[];for(const n of r.values()){const a=[...n].sort((h,p)=>Date.parse(h.effective_at)-Date.parse(p.effective_at)),d=[...a].reverse().find(h=>Date.parse(h.effective_at)<=i);s.push(d??a[0])}return s.sort((n,a)=>n.model_key.localeCompare(a.model_key))}const _t="otari",Me=[{value:"vision",label:"Vision",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("image")},{value:"tool_call",label:"Tool calling",test:t=>!!t.tool_call},{value:"reasoning",label:"Reasoning",test:t=>!!t.reasoning},{value:"structured_output",label:"Structured output",test:t=>!!t.structured_output},{value:"attachment",label:"Attachments",test:t=>!!t.attachment},{value:"audio",label:"Audio",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("audio")},{value:"pdf",label:"PDF",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("pdf")}],jt=[{key:"reasoning",label:"Reasoning"},{key:"tool_call",label:"Tool calling"},{key:"structured_output",label:"Structured output"},{key:"attachment",label:"Attachments"},{key:"temperature",label:"Temperature"}],Te={text:"Text",image:"Image",audio:"Audio",video:"Video",pdf:"PDF"},Pt=[{value:"0",label:"Any context"},{value:"8000",label:"≥ 8K"},{value:"32000",label:"≥ 32K"},{value:"128000",label:"≥ 128K"},{value:"200000",label:"≥ 200K"},{value:"1000000",label:"≥ 1M"}],yt=[{value:"",label:"Any price"},{value:"1",label:"≤ $1 / 1M in"},{value:"3",label:"≤ $3 / 1M in"},{value:"10",label:"≤ $10 / 1M in"},{value:"30",label:"≤ $30 / 1M in"}],Nt=[{value:"",label:"Base prices"},{value:"8000",label:"Compare at 8K"},{value:"128000",label:"Compare at 128K"},{value:"200000",label:"Compare at 200K"},{value:"500000",label:"Compare at 500K"},{value:"1000000",label:"Compare at 1M"}],St=[{value:"all",label:"Any release date"},{value:"365",label:"Past year"},{value:"730",label:"Past 2 years"},{value:"1095",label:"Past 3 years"}],Ct=1440*60*1e3;function Ie(t,i){const r=`${i}:`;return t.startsWith(r)?t.slice(r.length):t}function kt(t,i){const r={inputPrice:t.inputPrice,outputPrice:t.outputPrice,cacheReadPrice:t.cacheReadPrice,cacheWritePrice:t.cacheWritePrice,cacheWrite1hPrice:t.cacheWrite1hPrice};if(i==null)return r;const s=t.pricingTiers.filter(n=>n.min_input_tokens<=i).sort((n,a)=>a.min_input_tokens-n.min_input_tokens)[0];return s?{inputPrice:s.input_price_per_million??r.inputPrice,outputPrice:s.output_price_per_million??r.outputPrice,cacheReadPrice:s.cache_read_price_per_million??r.cacheReadPrice,cacheWritePrice:s.cache_write_price_per_million??r.cacheWritePrice,cacheWrite1hPrice:s.cache_write_1h_price_per_million??r.cacheWrite1hPrice}:r}function ae(t){const i=Number(t);return t.trim()!==""&&Number.isFinite(i)&&i>=0}function R(t){if(t.trim()==="")return!0;const i=Number(t);return Number.isFinite(i)&&i>=0}function V(t){return t.trim()===""?null:Number(t)}function De(t){return t.map((i,r)=>({id:r,minInputTokens:String(i.min_input_tokens),input:i.input_price_per_million==null?"":String(i.input_price_per_million),output:i.output_price_per_million==null?"":String(i.output_price_per_million),cacheRead:i.cache_read_price_per_million==null?"":String(i.cache_read_price_per_million),cacheWrite:i.cache_write_price_per_million==null?"":String(i.cache_write_price_per_million),cacheWrite1h:i.cache_write_1h_price_per_million==null?"":String(i.cache_write_1h_price_per_million)}))}function Oe(t){const i=new Set;return t.every(r=>{const s=Number(r.minInputTokens),n=[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].some(a=>a.trim()!=="");return!Number.isInteger(s)||s<=0||i.has(s)||!n?!1:(i.add(s),[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].every(R))})}function Fe(t){return t.map(i=>({min_input_tokens:Number(i.minInputTokens),...i.input.trim()===""?{}:{input_price_per_million:Number(i.input)},...i.output.trim()===""?{}:{output_price_per_million:Number(i.output)},...i.cacheRead.trim()===""?{}:{cache_read_price_per_million:Number(i.cacheRead)},...i.cacheWrite.trim()===""?{}:{cache_write_price_per_million:Number(i.cacheWrite)},...i.cacheWrite1h.trim()===""?{}:{cache_write_1h_price_per_million:Number(i.cacheWrite1h)}}))}function j({value:t,onChange:i,ariaLabel:r}){return e.jsx("input",{type:"number",step:"any",min:"0",inputMode:"decimal","aria-label":r,value:t,onChange:s=>i(s.target.value),className:"w-28 rounded-md border border-[var(--otari-line)] bg-white px-2 py-1 text-right text-sm tabular-nums focus:border-[var(--otari-brand)] focus:outline-none"})}function ze({tiers:t,onChange:i}){const r=(n,a,d)=>{i(t.map(h=>h.id===n?{...h,[a]:d}:h))},s=()=>{const n=t.reduce((a,d)=>Math.max(a,d.id),-1)+1;i([...t,{id:n,minInputTokens:"128000",input:"",output:"",cacheRead:"",cacheWrite:"",cacheWrite1h:""}])};return e.jsxs("div",{className:"flex flex-col gap-2 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("div",{className:"text-xs font-medium text-[var(--otari-ink)]",children:"Long-context price tiers"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"At a threshold, listed rates replace the base rate for the whole request."})]}),e.jsx(L,{size:"sm",variant:"outline",onPress:s,children:"Add tier"})]}),t.map(n=>e.jsxs("div",{className:"flex flex-wrap items-end gap-2 border-t border-[var(--otari-line)] pt-2",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Context ≥ tokens",e.jsx("input",{type:"number",min:"1",step:"1",inputMode:"numeric","aria-label":"Tier context threshold",value:n.minInputTokens,onChange:a=>r(n.id,"minInputTokens",a.target.value),className:"w-28 rounded-md border border-[var(--otari-line)] bg-white px-2 py-1 text-right text-sm tabular-nums focus:border-[var(--otari-brand)] focus:outline-none"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Input",e.jsx(j,{value:n.input,onChange:a=>r(n.id,"input",a),ariaLabel:"Tier input price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Output",e.jsx(j,{value:n.output,onChange:a=>r(n.id,"output",a),ariaLabel:"Tier output price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Cache read",e.jsx(j,{value:n.cacheRead,onChange:a=>r(n.id,"cacheRead",a),ariaLabel:"Tier cache read price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Cache write",e.jsx(j,{value:n.cacheWrite,onChange:a=>r(n.id,"cacheWrite",a),ariaLabel:"Tier cache write price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["1h write",e.jsx(j,{value:n.cacheWrite1h,onChange:a=>r(n.id,"cacheWrite1h",a),ariaLabel:"Tier 1 hour cache write price"})]}),e.jsx(L,{size:"sm",variant:"ghost",onPress:()=>i(t.filter(a=>a.id!==n.id)),children:"Remove"})]},n.id))]})}function Wt({source:t}){return t==="configured"?e.jsx(K,{size:"sm",color:"default",children:"configured"}):t==="default"||t==="alias"?e.jsx(K,{size:"sm",color:"accent",children:t}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not priced"})}function se({label:t,tone:i="info",children:r}){const s=c.useId();return e.jsxs("span",{className:"group relative inline-flex items-center font-normal normal-case",children:[e.jsx("button",{type:"button","aria-label":t,"aria-describedby":s,className:`inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] leading-none ${i==="warning"?"border-[#c2843a] text-[#b45309]":"border-[var(--otari-line)] text-[var(--otari-muted)] hover:border-[var(--otari-brand)] hover:text-[var(--otari-brand)]"}`,children:"i"}),e.jsx("span",{id:s,role:"tooltip",className:"pointer-events-none absolute top-full right-0 z-20 mt-1.5 w-72 rounded-lg border border-[var(--otari-line)] bg-[var(--otari-surface)] px-3 py-2 text-left text-xs font-normal whitespace-normal break-words text-[var(--otari-ink)] opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100",children:r})]})}function Mt(){const t=xt();return t.data?t.data.default_pricing?e.jsx(se,{label:"How unpriced models are metered",tone:"info",children:"Default pricing is on: models without a configured price are metered using community-maintained rates (the bundled genai-prices dataset). Set a price to override the fallback."}):e.jsxs(se,{label:"How unpriced models are metered",tone:"warning",children:["Default pricing is off: only models with a configured price are metered.",t.data.require_pricing?" Requests for any other model are rejected (HTTP 402) because require_pricing is on.":" Other models are served without cost tracking."]}):null}function T({label:t,value:i}){return e.jsxs("div",{className:"flex items-baseline justify-between gap-3",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:t}),e.jsx("span",{className:"text-right text-sm text-[var(--otari-ink)] tabular-nums",children:i})]})}function ie({title:t,children:i}){return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-xs font-semibold uppercase tracking-wide text-[var(--otari-muted)]",children:t}),i]})}function Tt({row:t}){const i=$e(),r=Ee(),[s,n]=c.useState(!1),[a,d]=c.useState(""),[h,p]=c.useState(""),[v,P]=c.useState(""),[m,S]=c.useState(""),[k,W]=c.useState(""),[D,y]=c.useState([]),Y=()=>{d(t.inputPrice==null?"":String(t.inputPrice)),p(t.outputPrice==null?"":String(t.outputPrice)),P(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),S(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),W(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),y(De(t.pricingTiers)),n(!0)},M=ae(a)&&ae(h)&&R(v)&&R(m)&&R(k)&&Oe(D),G=()=>{M&&i.mutate({model_key:t.key,input_price_per_million:Number(a),output_price_per_million:Number(h),cache_read_price_per_million:V(v),cache_write_price_per_million:V(m),cache_write_1h_price_per_million:V(k),pricing_tiers:Fe(D)},{onSuccess:()=>n(!1)})};return s?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Input $ / 1M"}),e.jsx(j,{value:a,onChange:d,ariaLabel:`Input price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Output $ / 1M"}),e.jsx(j,{value:h,onChange:p,ariaLabel:`Output price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Cache read $ / 1M"}),e.jsx(j,{value:v,onChange:P,ariaLabel:`Cache read price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Cache write $ / 1M"}),e.jsx(j,{value:m,onChange:S,ariaLabel:`Cache write price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"1h cache write $ / 1M"}),e.jsx(j,{value:k,onChange:W,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(ze,{tiers:D,onChange:y}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(L,{size:"sm",variant:"primary",isDisabled:i.isPending||!M,onPress:G,children:"Save"}),e.jsx(L,{size:"sm",variant:"ghost",isDisabled:i.isPending,onPress:()=>n(!1),children:"Cancel"})]}),i.error?e.jsx("span",{className:"text-xs text-red-700",children:me(i.error)}):null]}):e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx(T,{label:"Input",value:t.inputPrice==null?"—":`${A(t.inputPrice)} / 1M`}),e.jsx(T,{label:"Output",value:t.outputPrice==null?"—":`${A(t.outputPrice)} / 1M`}),e.jsx(T,{label:"Cache read",value:t.cacheReadPrice==null?"—":`${A(t.cacheReadPrice)} / 1M`}),e.jsx(T,{label:"Cache write",value:t.cacheWritePrice==null?"—":`${A(t.cacheWritePrice)} / 1M`}),e.jsx(T,{label:"1h cache write",value:t.cacheWrite1hPrice==null?"—":`${A(t.cacheWrite1hPrice)} / 1M`}),e.jsx(T,{label:"Context tiers",value:t.pricingTiers.length?`${t.pricingTiers.length} configured`:"—"}),e.jsxs("div",{className:"flex items-center gap-2 pt-1",children:[e.jsx(L,{size:"sm",variant:"outline",onPress:Y,children:t.source==="configured"?"Edit price":"Set price"}),t.source==="configured"?e.jsxs(e.Fragment,{children:[e.jsx(Ae,{confirmLabel:"Reset",isPending:r.isPending,onConfirm:()=>r.mutate(t.key),children:"Reset"}),e.jsx(se,{label:"What reset does",children:"Removes the custom price. The model reverts to the default rate (genai-prices) when default pricing is on, otherwise it is metered at no cost."})]}):null,r.error?e.jsx("span",{className:"text-xs text-red-700",children:me(r.error)}):null]})]})}function It({row:t,metadata:i,metadataAvailable:r,onMakeAlias:s,onClose:n}){const a=(i==null?void 0:i.input_modalities)??[],d=(i==null?void 0:i.output_modalities)??[],h=jt.filter(({key:p})=>i==null?void 0:i[p]);return e.jsx(ke,{children:e.jsxs(ke.Content,{className:"flex flex-col gap-5 p-5",children:[e.jsxs("div",{className:"flex items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("h2",{className:"text-base font-semibold break-all text-[var(--otari-ink)]",children:t.model}),i!=null&&i.deprecated?e.jsx(K,{size:"sm",color:"danger",children:"deprecated"}):null]}),e.jsxs("p",{className:"mt-1 text-xs break-all text-[var(--otari-muted)]",children:["Selector: ",e.jsx("code",{children:t.key})]}),i!=null&&i.family?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:i.family}):null]}),e.jsx("button",{type:"button","aria-label":"Close model details",onClick:n,className:"-mt-1 -mr-1 shrink-0 rounded-md px-1.5 py-0.5 text-lg leading-none text-[var(--otari-muted)] hover:bg-[var(--otari-bg)] hover:text-[var(--otari-ink)]",children:"✕"})]}),i!=null&&i.description?e.jsx("p",{className:"text-sm text-[var(--otari-ink)]",children:i.description}):null,e.jsxs(ie,{title:"Pricing",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(Wt,{source:t.source}),t.isDiscovered?null:e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not discovered"})]}),e.jsx(Tt,{row:t},t.key),e.jsx(L,{size:"sm",variant:"outline",onPress:()=>s(t.key),children:"Make an alias"})]}),e.jsxs(ie,{title:"Specs",children:[e.jsx(T,{label:"Context window",value:ne(t.contextWindow)}),e.jsx(T,{label:"Max output",value:ne((i==null?void 0:i.max_output_tokens)??null)}),e.jsx(T,{label:"Knowledge cutoff",value:(i==null?void 0:i.knowledge_cutoff)??"—"}),e.jsx(T,{label:"Released",value:ht(i==null?void 0:i.release_date)}),e.jsx(T,{label:"Open weights",value:i?i.open_weights?"Yes":"No":"—"})]}),e.jsx(ie,{title:"Modalities",children:a.length===0&&d.length===0?e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:"Unknown."}):e.jsxs("div",{className:"flex flex-col gap-1.5 text-xs text-[var(--otari-muted)]",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"In:"}),a.map(p=>e.jsx(K,{size:"sm",color:"default",children:Te[p]??p},p))]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"Out:"}),d.map(p=>e.jsx(K,{size:"sm",color:"default",children:Te[p]??p},p))]})]})}),e.jsx(ie,{title:"Capabilities",children:h.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:h.map(({key:p,label:v})=>e.jsx(K,{size:"sm",color:"default",children:v},p))}):e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:r?"None reported.":"Extended metadata unavailable (models.dev disabled or unreachable)."})})]})})}const Lt=15,$t=[{value:"15",label:"15 per page"},{value:"25",label:"25 per page"},{value:"50",label:"50 per page"}];function Et({value:t,onChange:i,placeholder:r}){return e.jsx("input",{type:"search",value:t,onChange:s=>i(s.target.value),placeholder:r,"aria-label":r,className:"w-full max-w-xs rounded-md border border-[var(--otari-line)] bg-white px-3 py-1.5 text-sm focus:border-[var(--otari-brand)] focus:outline-none"})}function At({page:t,pageCount:i,total:r,pageSize:s,onPage:n,onPageSize:a}){return e.jsxs("div",{className:"flex items-center justify-between px-1 pt-1 text-sm text-[var(--otari-muted)]",children:[e.jsxs("span",{children:[i>1?`Page ${t+1} of ${i} · `:"",pt(r)," model",r===1?"":"s"]}),e.jsxs("span",{className:"inline-flex gap-2",children:[e.jsx(E,{ariaLabel:"Rows per page",value:String(s),onChange:d=>a(Number(d)),options:$t}),i>1?e.jsxs(e.Fragment,{children:[e.jsx(L,{size:"sm",variant:"outline",isDisabled:t===0,onPress:()=>n(t-1),children:"Prev"}),e.jsx(L,{size:"sm",variant:"outline",isDisabled:t>=i-1,onPress:()=>n(t+1),children:"Next"})]}):null]})]})}const Be="otari.dashboard.modelsSort",xe={col:"model",dir:"asc"},Dt=["model","released","input","output"];function Ot(){if(typeof window>"u")return xe;try{const t=window.localStorage.getItem(Be);if(!t)return xe;const i=JSON.parse(t);if(Dt.includes(i.col)&&(i.dir==="asc"||i.dir==="desc"))return{col:i.col,dir:i.dir}}catch{}return xe}function Le({label:t,col:i,sort:r,onSort:s,align:n="left",info:a}){const d=r.col===i;return e.jsx(re,{className:n==="right"?"text-right":void 0,ariaSort:d?r.dir==="asc"?"ascending":"descending":"none",children:e.jsxs("span",{className:`inline-flex items-center gap-1.5 ${n==="right"?"flex-row-reverse":""}`,children:[e.jsxs("button",{type:"button",onClick:()=>s(i),className:`inline-flex items-center gap-1 ${n==="right"?"flex-row-reverse":""} hover:text-[var(--otari-ink)]`,children:[t,e.jsx("span",{className:"text-[10px] text-[var(--otari-muted)]",children:d?r.dir==="asc"?"▲":"▼":"↕"})]}),a]})})}function Ft({row:t,onClose:i}){const r=$e(),s=Ee(),[n,a]=c.useState(t.inputPrice==null?"":String(t.inputPrice)),[d,h]=c.useState(t.outputPrice==null?"":String(t.outputPrice)),[p,v]=c.useState(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),[P,m]=c.useState(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),[S,k]=c.useState(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),[W,D]=c.useState(De(t.pricingTiers)),y=ae(n)&&ae(d)&&R(p)&&R(P)&&R(S)&&Oe(W),Y=()=>{y&&r.mutate({model_key:t.key,input_price_per_million:Number(n),output_price_per_million:Number(d),cache_read_price_per_million:V(p),cache_write_price_per_million:V(P),cache_write_1h_price_per_million:V(S),pricing_tiers:Fe(W)},{onSuccess:i})};return e.jsxs("div",{className:"flex flex-col gap-3 px-4 py-3",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("span",{className:"text-xs font-medium break-all text-[var(--otari-muted)]",children:t.key}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Input $ / 1M",e.jsx(j,{value:n,onChange:a,ariaLabel:`Input price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Output $ / 1M",e.jsx(j,{value:d,onChange:h,ariaLabel:`Output price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Cache read $ / 1M",e.jsx(j,{value:p,onChange:v,ariaLabel:`Cache read price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Cache write $ / 1M",e.jsx(j,{value:P,onChange:m,ariaLabel:`Cache write price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["1h cache write $ / 1M",e.jsx(j,{value:S,onChange:k,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(L,{size:"sm",variant:"primary",isDisabled:r.isPending||!y,onPress:Y,children:r.isPending?"Saving…":"Save"}),e.jsx(L,{size:"sm",variant:"ghost",isDisabled:r.isPending,onPress:i,children:"Cancel"}),t.source==="configured"?e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(Ae,{confirmLabel:"Reset",isPending:s.isPending,onConfirm:()=>s.mutate(t.key,{onSuccess:i}),children:"Reset"}),e.jsx(se,{label:"What reset does",children:"Removes the custom price. The model reverts to the default rate (genai-prices) when default pricing is on, otherwise it is metered at no cost."})]}):null,r.error||s.error?e.jsx("span",{className:"text-xs text-red-700",children:me(r.error??s.error)}):null]}),e.jsx(ze,{tiers:W,onChange:D})]})}function zt({primary:t,secondary:i,rowKey:r,primaryLabel:s,secondaryLabel:n,onEdit:a}){const d=(h,p)=>e.jsx("button",{type:"button","aria-label":`Edit ${p} price for ${r}`,className:"tabular-nums hover:text-[var(--otari-brand-dark)] hover:underline",onClick:v=>{v.stopPropagation(),a()},children:h==null?"—":A(h)});return e.jsxs("span",{className:"inline-flex items-center justify-end gap-1",children:[d(t,s),e.jsx("span",{className:"text-[var(--otari-muted)]",children:"/"}),d(i,n)]})}function Bt({rates:t,rowKey:i,onEdit:r}){const s=[t.cacheReadPrice==null?null:`R ${A(t.cacheReadPrice)}`,t.cacheWritePrice==null?null:`W ${A(t.cacheWritePrice)}`,t.cacheWrite1hPrice==null?null:`1h ${A(t.cacheWrite1hPrice)}`].filter(n=>n!==null);return e.jsx("button",{type:"button","aria-label":`Edit caching price for ${i}`,className:"max-w-44 text-right text-xs leading-5 text-[var(--otari-muted)] hover:text-[var(--otari-brand-dark)] hover:underline",onClick:n=>{n.stopPropagation(),r()},children:s.length>0?s.join(" · "):"Input-rate fallback"})}function Rt({row:t,onEdit:i}){const r=[...t.pricingTiers].sort((n,a)=>n.min_input_tokens-a.min_input_tokens).map(n=>ne(n.min_input_tokens)),s=r.length===0?"Base only":`${r.length} tier${r.length===1?"":"s"} · ≥ ${r.join(", ")}`;return e.jsx("button",{type:"button","aria-label":`Edit pricing policy for ${t.key}`,className:"max-w-40 text-right text-xs leading-5 text-[var(--otari-muted)] hover:text-[var(--otari-brand-dark)] hover:underline",onClick:n=>{n.stopPropagation(),i()},children:s})}function Kt({rows:t,isLoading:i,empty:r,sort:s,onSort:n,selectedKey:a,onSelect:d,pricingKey:h,onSetPricingKey:p,comparisonContextTokens:v}){const P=v==null?"Base":`at ${ne(v)}`;return e.jsxs(mt,{children:[e.jsx(ft,{children:e.jsxs(Ce,{children:[e.jsx(Le,{label:"Model",col:"model",sort:s,onSort:n}),e.jsx(re,{children:"Provider"}),e.jsx(Le,{label:`${P} in / out $ / 1M`,col:"input",sort:s,onSort:n,align:"right",info:e.jsx(Mt,{})}),e.jsxs(re,{className:"text-right",children:["Caching ",v==null?"policy":P]}),e.jsx(re,{className:"text-right",children:"Pricing policy"})]})}),e.jsx("tbody",{children:i?e.jsx(vt,{colSpan:5}):t.length>0?t.map(m=>{const S=()=>p(h===m.key?null:m.key),k=kt(m,v);return e.jsxs(c.Fragment,{children:[e.jsxs(Ce,{onClick:()=>d(m.key),selected:m.key===a,children:[e.jsxs(U,{className:"font-medium break-all",children:[m.model,e.jsx("span",{className:"sr-only",children:m.key})]}),e.jsx(U,{className:"text-[var(--otari-muted)]",children:m.provider}),e.jsx(U,{className:"text-right",children:e.jsx(zt,{primary:k.inputPrice,secondary:k.outputPrice,rowKey:m.key,primaryLabel:"input",secondaryLabel:"output",onEdit:S})}),e.jsx(U,{className:"text-right",children:e.jsx(Bt,{rates:k,rowKey:m.key,onEdit:S})}),e.jsx(U,{className:"text-right",children:e.jsx(Rt,{row:m,onEdit:S})})]}),h===m.key?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)] last:border-b-0",children:e.jsx("td",{colSpan:5,children:e.jsx(Ft,{row:m,onClose:()=>p(null)})})}):null]},m.key)}):e.jsx(bt,{colSpan:5,children:r})})]})}function Vt({providers:t}){return t.length===0?null:e.jsxs(dt,{tone:"warning",children:["Could not list ",t.map(i=>i.provider).join(", "),". Check that provider's credentials in config.yml; its models are missing from the list below."]})}function Jt(){var je,Pe,ye;const t=rt(),[i]=nt(),r=at(),s=st(),n=lt(),a=ct(),[d,h]=c.useState(""),[p,v]=c.useState(0),[P,m]=c.useState(Lt),[S,k]=c.useState(null),[W,D]=c.useState(null),[y,Y]=c.useState(Ot);c.useEffect(()=>{try{window.localStorage.setItem(Be,JSON.stringify(y))}catch{}},[y]);const[M,G]=c.useState(i.get("provider")||"all"),[F,Re]=c.useState("all"),[H,Ke]=c.useState("all"),[q,Ve]=c.useState("all"),[le,Ye]=c.useState("0"),[J,He]=c.useState(""),[Z,qe]=c.useState("all"),[ce,we]=c.useState(""),O=((je=a.data)==null?void 0:je.models)??{},Ue=((Pe=a.data)==null?void 0:Pe.available)??!1,fe=c.useMemo(()=>{var l;return new Set((((l=n.data)==null?void 0:l.providers)??[]).flatMap(f=>f.models.map(o=>o.key)))},[n.data]),Ge=l=>{h(l),v(0)},z=l=>f=>{l(f),v(0)},Je=l=>{Y(f=>f.col===l?{col:l,dir:f.dir==="asc"?"desc":"asc"}:{col:l,dir:l==="released"?"desc":"asc"}),v(0)},X=c.useMemo(()=>{var C,g,_,$,I,B,Ne;const l=new Map(gt(s.data??[]).map(u=>[u.model_key,u])),f=[],o=new Set,b=(u,he,Se,x)=>{if(o.has(u))return;o.add(u);const N=l.get(u);f.push({key:u,model:Ie(he,Se),provider:Se,isDiscovered:fe.has(u),contextWindow:(x==null?void 0:x.contextWindow)??null,inputPrice:N?N.input_price_per_million:(x==null?void 0:x.inputPrice)??null,outputPrice:N?N.output_price_per_million:(x==null?void 0:x.outputPrice)??null,cacheReadPrice:N?N.cache_read_price_per_million:(x==null?void 0:x.cacheReadPrice)??null,cacheWritePrice:N?N.cache_write_price_per_million:(x==null?void 0:x.cacheWritePrice)??null,cacheWrite1hPrice:N?N.cache_write_1h_price_per_million??null:(x==null?void 0:x.cacheWrite1hPrice)??null,pricingTiers:N?N.pricing_tiers??[]:(x==null?void 0:x.pricingTiers)??[],source:N?"configured":(x==null?void 0:x.source)??"none"})};for(const u of((C=r.data)==null?void 0:C.data)??[]){if(u.owned_by===_t)continue;const he=u.pricing_source==="default"?"default":u.pricing?"configured":"none";b(u.id,u.id,u.owned_by||We(u.id),{key:u.id,model:u.id,provider:u.owned_by,contextWindow:u.context_window,inputPrice:((g=u.pricing)==null?void 0:g.input_price_per_million)??null,outputPrice:((_=u.pricing)==null?void 0:_.output_price_per_million)??null,cacheReadPrice:(($=u.pricing)==null?void 0:$.cache_read_price_per_million)??null,cacheWritePrice:((I=u.pricing)==null?void 0:I.cache_write_price_per_million)??null,cacheWrite1hPrice:((B=u.pricing)==null?void 0:B.cache_write_1h_price_per_million)??null,pricingTiers:((Ne=u.pricing)==null?void 0:Ne.pricing_tiers)??[],source:he})}for(const u of l.keys())b(u,u,We(u));return f},[r.data,s.data,fe]),Ze=c.useMemo(()=>new Map(X.map(l=>[l.key,l])),[X]),Q=c.useMemo(()=>{var o,b,C;const l=X.map(g=>{var _,$;return{...g,contextWindow:g.contextWindow??((_=O[g.key])==null?void 0:_.context_window)??null,releaseDate:(($=O[g.key])==null?void 0:$.release_date)??null}}),f=new Set(l.map(g=>g.key));for(const g of((o=n.data)==null?void 0:o.providers)??[])for(const _ of g.models)f.has(_.key)||(f.add(_.key),l.push({key:_.key,model:Ie(_.key,g.provider),provider:g.provider,isDiscovered:!0,contextWindow:((b=O[_.key])==null?void 0:b.context_window)??null,releaseDate:((C=O[_.key])==null?void 0:C.release_date)??null,inputPrice:null,outputPrice:null,cacheReadPrice:null,cacheWritePrice:null,cacheWrite1hPrice:null,pricingTiers:[],source:"none"}));return l},[X,n.data,O]),Xe=(((ye=n.data)==null?void 0:ye.providers)??[]).filter(l=>!l.ok),ee=c.useMemo(()=>{const l=Array.from(new Set(Q.map(f=>f.provider))).sort((f,o)=>f.localeCompare(o));return[{value:"all",label:"All providers"},...l.map(f=>({value:f,label:f}))]},[Q]);c.useEffect(()=>{M==="all"||ee.length<=1||ee.some(l=>l.value===M)||G("all")},[ee,M]);const w=d.trim().toLowerCase(),oe=Number(le)||0,ue=J===""?Number.POSITIVE_INFINITY:Number(J),Qe=ce===""?null:Number(ce),de=Z==="all"?null:Date.now()-Number(Z)*Ct,pe=c.useMemo(()=>{const l=o=>{if(w&&!o.key.toLowerCase().includes(w)&&!o.provider.toLowerCase().includes(w)||M!=="all"&&o.provider!==M||F==="configured"&&o.source!=="configured"||F==="default"&&o.source!=="default"||F==="priced"&&o.inputPrice==null||F==="unpriced"&&o.inputPrice!=null||H==="discovered"&&!o.isDiscovered||H==="custom"&&o.isDiscovered)return!1;if(q!=="all"){const b=Me.find(g=>g.value===q),C=O[o.key];if(!b||!C||!b.test(C))return!1}if(oe>0&&(o.contextWindow==null||o.contextWindowue))return!1;if(de!=null){const b=o.releaseDate?Date.parse(o.releaseDate):Number.NaN;if(Number.isNaN(b)||b{const C=y.dir==="asc"?1:-1;if(y.col==="model")return o.model.localeCompare(b.model)*C;if(y.col==="released"){const I=o.releaseDate??null,B=b.releaseDate??null;return!I&&!B?o.model.localeCompare(b.model):I?B?(IB?1:0)*C||o.model.localeCompare(b.model):-1:1}const g=I=>y.col==="input"?I.inputPrice:I.outputPrice,_=g(o),$=g(b);return _==null&&$==null?o.model.localeCompare(b.model):_==null?1:$==null?-1:(_-$)*C||o.model.localeCompare(b.model)};return Q.filter(l).sort(f)},[Q,w,M,F,H,q,oe,ue,de,O,y]),ve=pe.length,be=Math.max(1,Math.ceil(ve/P)),ge=Math.min(p,be-1),_e=ge*P,et=pe.slice(_e,_e+P),tt=r.isLoading||s.isLoading||n.isLoading,it=w!==""||M!=="all"||F!=="all"||H!=="all"||q!=="all"||le!=="0"||J!==""||Z!=="all"?"No models match your filters.":"No models yet. Add a provider on the Providers page, or price a model below.",te=W?pe.find(l=>l.key===W)??Ze.get(W)??null:null;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ot,{title:"Models",description:"Every model your providers can serve. Set a price on any model so budgets and usage tracking work."}),e.jsx(ut,{error:r.error??s.error??n.error??a.error}),e.jsxs("div",{className:`grid gap-4 lg:items-start ${te?"lg:grid-cols-[minmax(0,1fr)_360px]":"grid-cols-1"}`,children:[e.jsxs("div",{className:"flex min-w-0 flex-col gap-3",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx(Et,{value:d,onChange:Ge,placeholder:"Search models…"}),e.jsx(E,{ariaLabel:"Filter by provider",value:M,onChange:z(G),options:ee}),e.jsx(E,{ariaLabel:"Filter by pricing",value:F,onChange:z(Re),options:[{value:"all",label:"Any pricing"},{value:"configured",label:"Custom price"},{value:"default",label:"Default price"},{value:"priced",label:"Priced"},{value:"unpriced",label:"Unpriced"}]}),e.jsx(E,{ariaLabel:"Filter by source",value:H,onChange:z(Ke),options:[{value:"all",label:"Any source"},{value:"discovered",label:"Discovered"},{value:"custom",label:"Custom (not discovered)"}]}),e.jsx(E,{ariaLabel:"Filter by capability",value:q,onChange:z(Ve),options:[{value:"all",label:"Any capability"},...Me.map(l=>({value:l.value,label:l.label}))]}),e.jsx(E,{ariaLabel:"Minimum context window",value:le,onChange:z(Ye),options:Pt}),e.jsx(E,{ariaLabel:"Maximum input price",value:J,onChange:z(He),options:yt}),e.jsx(E,{ariaLabel:"Compare prices at context",value:ce,onChange:we,options:Nt}),e.jsx(E,{ariaLabel:"Filter by release date",value:Z,onChange:z(qe),options:St})]}),e.jsx(Vt,{providers:Xe}),e.jsx(Kt,{rows:et,isLoading:tt,empty:it,sort:y,onSort:Je,selectedKey:W,onSelect:D,pricingKey:S,onSetPricingKey:k,comparisonContextTokens:Qe}),e.jsx("div",{className:"flex flex-wrap items-center justify-between gap-2",children:e.jsx(At,{page:ge,pageCount:be,total:ve,pageSize:P,onPage:v,onPageSize:l=>{m(l),v(0)}})})]}),te?e.jsx("aside",{className:"lg:sticky lg:top-4",children:e.jsx(It,{row:te,metadata:O[te.key],metadataAvailable:Ue,onMakeAlias:l=>t(`/aliases?target=${encodeURIComponent(l)}`),onClose:()=>D(null)})}):null]})]})}export{Jt as ModelsPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{i as rt,u as nt,r as c}from"./react-dgEcD0HR.js";import{v as at,w as st,e as lt,x as ct,P as ot,E as ut,F as E,I as dt,y as ne,z as pt,A as ht,B as xt,D as A,G as $e,H as Ee,C as Ae,h as me}from"./index-hDVMDLdX.js";import{T as mt,a as ft,d as Ce,b as re,L as vt,e as U,c as bt}from"./Table-CLdjdyTx.js";import{B as L,d as ke,f as K}from"./heroui-BX6JwHY-.js";function We(t){const i=t.indexOf(":");return i>0?t.slice(0,i):"—"}function gt(t,i=Date.now()){const r=new Map;for(const n of t){const a=r.get(n.model_key)??[];a.push(n),r.set(n.model_key,a)}const s=[];for(const n of r.values()){const a=[...n].sort((h,p)=>Date.parse(h.effective_at)-Date.parse(p.effective_at)),d=[...a].reverse().find(h=>Date.parse(h.effective_at)<=i);s.push(d??a[0])}return s.sort((n,a)=>n.model_key.localeCompare(a.model_key))}const _t="otari",Me=[{value:"vision",label:"Vision",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("image")},{value:"tool_call",label:"Tool calling",test:t=>!!t.tool_call},{value:"reasoning",label:"Reasoning",test:t=>!!t.reasoning},{value:"structured_output",label:"Structured output",test:t=>!!t.structured_output},{value:"attachment",label:"Attachments",test:t=>!!t.attachment},{value:"audio",label:"Audio",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("audio")},{value:"pdf",label:"PDF",test:t=>Array.isArray(t.input_modalities)&&t.input_modalities.includes("pdf")}],jt=[{key:"reasoning",label:"Reasoning"},{key:"tool_call",label:"Tool calling"},{key:"structured_output",label:"Structured output"},{key:"attachment",label:"Attachments"},{key:"temperature",label:"Temperature"}],Te={text:"Text",image:"Image",audio:"Audio",video:"Video",pdf:"PDF"},Pt=[{value:"0",label:"Any context"},{value:"8000",label:"≥ 8K"},{value:"32000",label:"≥ 32K"},{value:"128000",label:"≥ 128K"},{value:"200000",label:"≥ 200K"},{value:"1000000",label:"≥ 1M"}],yt=[{value:"",label:"Any price"},{value:"1",label:"≤ $1 / 1M in"},{value:"3",label:"≤ $3 / 1M in"},{value:"10",label:"≤ $10 / 1M in"},{value:"30",label:"≤ $30 / 1M in"}],Nt=[{value:"",label:"Base prices"},{value:"8000",label:"Compare at 8K"},{value:"128000",label:"Compare at 128K"},{value:"200000",label:"Compare at 200K"},{value:"500000",label:"Compare at 500K"},{value:"1000000",label:"Compare at 1M"}],St=[{value:"all",label:"Any release date"},{value:"365",label:"Past year"},{value:"730",label:"Past 2 years"},{value:"1095",label:"Past 3 years"}],Ct=1440*60*1e3;function Ie(t,i){const r=`${i}:`;return t.startsWith(r)?t.slice(r.length):t}function kt(t,i){const r={inputPrice:t.inputPrice,outputPrice:t.outputPrice,cacheReadPrice:t.cacheReadPrice,cacheWritePrice:t.cacheWritePrice,cacheWrite1hPrice:t.cacheWrite1hPrice};if(i==null)return r;const s=t.pricingTiers.filter(n=>n.min_input_tokens<=i).sort((n,a)=>a.min_input_tokens-n.min_input_tokens)[0];return s?{inputPrice:s.input_price_per_million??r.inputPrice,outputPrice:s.output_price_per_million??r.outputPrice,cacheReadPrice:s.cache_read_price_per_million??r.cacheReadPrice,cacheWritePrice:s.cache_write_price_per_million??r.cacheWritePrice,cacheWrite1hPrice:s.cache_write_1h_price_per_million??r.cacheWrite1hPrice}:r}function ae(t){const i=Number(t);return t.trim()!==""&&Number.isFinite(i)&&i>=0}function R(t){if(t.trim()==="")return!0;const i=Number(t);return Number.isFinite(i)&&i>=0}function V(t){return t.trim()===""?null:Number(t)}function De(t){return t.map((i,r)=>({id:r,minInputTokens:String(i.min_input_tokens),input:i.input_price_per_million==null?"":String(i.input_price_per_million),output:i.output_price_per_million==null?"":String(i.output_price_per_million),cacheRead:i.cache_read_price_per_million==null?"":String(i.cache_read_price_per_million),cacheWrite:i.cache_write_price_per_million==null?"":String(i.cache_write_price_per_million),cacheWrite1h:i.cache_write_1h_price_per_million==null?"":String(i.cache_write_1h_price_per_million)}))}function Oe(t){const i=new Set;return t.every(r=>{const s=Number(r.minInputTokens),n=[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].some(a=>a.trim()!=="");return!Number.isInteger(s)||s<=0||i.has(s)||!n?!1:(i.add(s),[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].every(R))})}function Fe(t){return t.map(i=>({min_input_tokens:Number(i.minInputTokens),...i.input.trim()===""?{}:{input_price_per_million:Number(i.input)},...i.output.trim()===""?{}:{output_price_per_million:Number(i.output)},...i.cacheRead.trim()===""?{}:{cache_read_price_per_million:Number(i.cacheRead)},...i.cacheWrite.trim()===""?{}:{cache_write_price_per_million:Number(i.cacheWrite)},...i.cacheWrite1h.trim()===""?{}:{cache_write_1h_price_per_million:Number(i.cacheWrite1h)}}))}function j({value:t,onChange:i,ariaLabel:r}){return e.jsx("input",{type:"number",step:"any",min:"0",inputMode:"decimal","aria-label":r,value:t,onChange:s=>i(s.target.value),className:"w-28 rounded-md border border-[var(--otari-line)] bg-white px-2 py-1 text-right text-sm tabular-nums focus:border-[var(--otari-brand)] focus:outline-none"})}function ze({tiers:t,onChange:i}){const r=(n,a,d)=>{i(t.map(h=>h.id===n?{...h,[a]:d}:h))},s=()=>{const n=t.reduce((a,d)=>Math.max(a,d.id),-1)+1;i([...t,{id:n,minInputTokens:"128000",input:"",output:"",cacheRead:"",cacheWrite:"",cacheWrite1h:""}])};return e.jsxs("div",{className:"flex flex-col gap-2 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("div",{className:"text-xs font-medium text-[var(--otari-ink)]",children:"Long-context price tiers"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"At a threshold, listed rates replace the base rate for the whole request."})]}),e.jsx(L,{size:"sm",variant:"outline",onPress:s,children:"Add tier"})]}),t.map(n=>e.jsxs("div",{className:"flex flex-wrap items-end gap-2 border-t border-[var(--otari-line)] pt-2",children:[e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Context ≥ tokens",e.jsx("input",{type:"number",min:"1",step:"1",inputMode:"numeric","aria-label":"Tier context threshold",value:n.minInputTokens,onChange:a=>r(n.id,"minInputTokens",a.target.value),className:"w-28 rounded-md border border-[var(--otari-line)] bg-white px-2 py-1 text-right text-sm tabular-nums focus:border-[var(--otari-brand)] focus:outline-none"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Input",e.jsx(j,{value:n.input,onChange:a=>r(n.id,"input",a),ariaLabel:"Tier input price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Output",e.jsx(j,{value:n.output,onChange:a=>r(n.id,"output",a),ariaLabel:"Tier output price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Cache read",e.jsx(j,{value:n.cacheRead,onChange:a=>r(n.id,"cacheRead",a),ariaLabel:"Tier cache read price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["Cache write",e.jsx(j,{value:n.cacheWrite,onChange:a=>r(n.id,"cacheWrite",a),ariaLabel:"Tier cache write price"})]}),e.jsxs("label",{className:"flex flex-col gap-1 text-xs text-[var(--otari-muted)]",children:["1h write",e.jsx(j,{value:n.cacheWrite1h,onChange:a=>r(n.id,"cacheWrite1h",a),ariaLabel:"Tier 1 hour cache write price"})]}),e.jsx(L,{size:"sm",variant:"ghost",onPress:()=>i(t.filter(a=>a.id!==n.id)),children:"Remove"})]},n.id))]})}function Wt({source:t}){return t==="configured"?e.jsx(K,{size:"sm",color:"default",children:"configured"}):t==="default"||t==="alias"?e.jsx(K,{size:"sm",color:"accent",children:t}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not priced"})}function se({label:t,tone:i="info",children:r}){const s=c.useId();return e.jsxs("span",{className:"group relative inline-flex items-center font-normal normal-case",children:[e.jsx("button",{type:"button","aria-label":t,"aria-describedby":s,className:`inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] leading-none ${i==="warning"?"border-[#c2843a] text-[#b45309]":"border-[var(--otari-line)] text-[var(--otari-muted)] hover:border-[var(--otari-brand)] hover:text-[var(--otari-brand)]"}`,children:"i"}),e.jsx("span",{id:s,role:"tooltip",className:"pointer-events-none absolute top-full right-0 z-20 mt-1.5 w-72 rounded-lg border border-[var(--otari-line)] bg-[var(--otari-surface)] px-3 py-2 text-left text-xs font-normal whitespace-normal break-words text-[var(--otari-ink)] opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100",children:r})]})}function Mt(){const t=xt();return t.data?t.data.default_pricing?e.jsx(se,{label:"How unpriced models are metered",tone:"info",children:"Default pricing is on: models without a configured price are metered using community-maintained rates (the bundled genai-prices dataset). Set a price to override the fallback."}):e.jsxs(se,{label:"How unpriced models are metered",tone:"warning",children:["Default pricing is off: only models with a configured price are metered.",t.data.require_pricing?" Requests for any other model are rejected (HTTP 402) because require_pricing is on.":" Other models are served without cost tracking."]}):null}function T({label:t,value:i}){return e.jsxs("div",{className:"flex items-baseline justify-between gap-3",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:t}),e.jsx("span",{className:"text-right text-sm text-[var(--otari-ink)] tabular-nums",children:i})]})}function ie({title:t,children:i}){return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-xs font-semibold uppercase tracking-wide text-[var(--otari-muted)]",children:t}),i]})}function Tt({row:t}){const i=$e(),r=Ee(),[s,n]=c.useState(!1),[a,d]=c.useState(""),[h,p]=c.useState(""),[v,P]=c.useState(""),[m,S]=c.useState(""),[k,W]=c.useState(""),[D,y]=c.useState([]),Y=()=>{d(t.inputPrice==null?"":String(t.inputPrice)),p(t.outputPrice==null?"":String(t.outputPrice)),P(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),S(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),W(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),y(De(t.pricingTiers)),n(!0)},M=ae(a)&&ae(h)&&R(v)&&R(m)&&R(k)&&Oe(D),G=()=>{M&&i.mutate({model_key:t.key,input_price_per_million:Number(a),output_price_per_million:Number(h),cache_read_price_per_million:V(v),cache_write_price_per_million:V(m),cache_write_1h_price_per_million:V(k),pricing_tiers:Fe(D)},{onSuccess:()=>n(!1)})};return s?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Input $ / 1M"}),e.jsx(j,{value:a,onChange:d,ariaLabel:`Input price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Output $ / 1M"}),e.jsx(j,{value:h,onChange:p,ariaLabel:`Output price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Cache read $ / 1M"}),e.jsx(j,{value:v,onChange:P,ariaLabel:`Cache read price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Cache write $ / 1M"}),e.jsx(j,{value:m,onChange:S,ariaLabel:`Cache write price for ${t.key}`})]}),e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"1h cache write $ / 1M"}),e.jsx(j,{value:k,onChange:W,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(ze,{tiers:D,onChange:y}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(L,{size:"sm",variant:"primary",isDisabled:i.isPending||!M,onPress:G,children:"Save"}),e.jsx(L,{size:"sm",variant:"ghost",isDisabled:i.isPending,onPress:()=>n(!1),children:"Cancel"})]}),i.error?e.jsx("span",{className:"text-xs text-red-700",children:me(i.error)}):null]}):e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx(T,{label:"Input",value:t.inputPrice==null?"—":`${A(t.inputPrice)} / 1M`}),e.jsx(T,{label:"Output",value:t.outputPrice==null?"—":`${A(t.outputPrice)} / 1M`}),e.jsx(T,{label:"Cache read",value:t.cacheReadPrice==null?"—":`${A(t.cacheReadPrice)} / 1M`}),e.jsx(T,{label:"Cache write",value:t.cacheWritePrice==null?"—":`${A(t.cacheWritePrice)} / 1M`}),e.jsx(T,{label:"1h cache write",value:t.cacheWrite1hPrice==null?"—":`${A(t.cacheWrite1hPrice)} / 1M`}),e.jsx(T,{label:"Context tiers",value:t.pricingTiers.length?`${t.pricingTiers.length} configured`:"—"}),e.jsxs("div",{className:"flex items-center gap-2 pt-1",children:[e.jsx(L,{size:"sm",variant:"outline",onPress:Y,children:t.source==="configured"?"Edit price":"Set price"}),t.source==="configured"?e.jsxs(e.Fragment,{children:[e.jsx(Ae,{confirmLabel:"Reset",isPending:r.isPending,onConfirm:()=>r.mutate(t.key),children:"Reset"}),e.jsx(se,{label:"What reset does",children:"Removes the custom price. The model reverts to the default rate (genai-prices) when default pricing is on, otherwise it is metered at no cost."})]}):null,r.error?e.jsx("span",{className:"text-xs text-red-700",children:me(r.error)}):null]})]})}function It({row:t,metadata:i,metadataAvailable:r,onMakeAlias:s,onClose:n}){const a=(i==null?void 0:i.input_modalities)??[],d=(i==null?void 0:i.output_modalities)??[],h=jt.filter(({key:p})=>i==null?void 0:i[p]);return e.jsx(ke,{children:e.jsxs(ke.Content,{className:"flex flex-col gap-5 p-5",children:[e.jsxs("div",{className:"flex items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("h2",{className:"text-base font-semibold break-all text-[var(--otari-ink)]",children:t.model}),i!=null&&i.deprecated?e.jsx(K,{size:"sm",color:"danger",children:"deprecated"}):null]}),e.jsxs("p",{className:"mt-1 text-xs break-all text-[var(--otari-muted)]",children:["Selector: ",e.jsx("code",{children:t.key})]}),i!=null&&i.family?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:i.family}):null]}),e.jsx("button",{type:"button","aria-label":"Close model details",onClick:n,className:"-mt-1 -mr-1 shrink-0 rounded-md px-1.5 py-0.5 text-lg leading-none text-[var(--otari-muted)] hover:bg-[var(--otari-bg)] hover:text-[var(--otari-ink)]",children:"✕"})]}),i!=null&&i.description?e.jsx("p",{className:"text-sm text-[var(--otari-ink)]",children:i.description}):null,e.jsxs(ie,{title:"Pricing",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(Wt,{source:t.source}),t.isDiscovered?null:e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not discovered"})]}),e.jsx(Tt,{row:t},t.key),e.jsx(L,{size:"sm",variant:"outline",onPress:()=>s(t.key),children:"Make an alias"})]}),e.jsxs(ie,{title:"Specs",children:[e.jsx(T,{label:"Context window",value:ne(t.contextWindow)}),e.jsx(T,{label:"Max output",value:ne((i==null?void 0:i.max_output_tokens)??null)}),e.jsx(T,{label:"Knowledge cutoff",value:(i==null?void 0:i.knowledge_cutoff)??"—"}),e.jsx(T,{label:"Released",value:ht(i==null?void 0:i.release_date)}),e.jsx(T,{label:"Open weights",value:i?i.open_weights?"Yes":"No":"—"})]}),e.jsx(ie,{title:"Modalities",children:a.length===0&&d.length===0?e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:"Unknown."}):e.jsxs("div",{className:"flex flex-col gap-1.5 text-xs text-[var(--otari-muted)]",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"In:"}),a.map(p=>e.jsx(K,{size:"sm",color:"default",children:Te[p]??p},p))]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"Out:"}),d.map(p=>e.jsx(K,{size:"sm",color:"default",children:Te[p]??p},p))]})]})}),e.jsx(ie,{title:"Capabilities",children:h.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:h.map(({key:p,label:v})=>e.jsx(K,{size:"sm",color:"default",children:v},p))}):e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:r?"None reported.":"Extended metadata unavailable (models.dev disabled or unreachable)."})})]})})}const Lt=15,$t=[{value:"15",label:"15 per page"},{value:"25",label:"25 per page"},{value:"50",label:"50 per page"}];function Et({value:t,onChange:i,placeholder:r}){return e.jsx("input",{type:"search",value:t,onChange:s=>i(s.target.value),placeholder:r,"aria-label":r,className:"w-full max-w-xs rounded-md border border-[var(--otari-line)] bg-white px-3 py-1.5 text-sm focus:border-[var(--otari-brand)] focus:outline-none"})}function At({page:t,pageCount:i,total:r,pageSize:s,onPage:n,onPageSize:a}){return e.jsxs("div",{className:"flex items-center justify-between px-1 pt-1 text-sm text-[var(--otari-muted)]",children:[e.jsxs("span",{children:[i>1?`Page ${t+1} of ${i} · `:"",pt(r)," model",r===1?"":"s"]}),e.jsxs("span",{className:"inline-flex gap-2",children:[e.jsx(E,{ariaLabel:"Rows per page",value:String(s),onChange:d=>a(Number(d)),options:$t}),i>1?e.jsxs(e.Fragment,{children:[e.jsx(L,{size:"sm",variant:"outline",isDisabled:t===0,onPress:()=>n(t-1),children:"Prev"}),e.jsx(L,{size:"sm",variant:"outline",isDisabled:t>=i-1,onPress:()=>n(t+1),children:"Next"})]}):null]})]})}const Be="otari.dashboard.modelsSort",xe={col:"model",dir:"asc"},Dt=["model","released","input","output"];function Ot(){if(typeof window>"u")return xe;try{const t=window.localStorage.getItem(Be);if(!t)return xe;const i=JSON.parse(t);if(Dt.includes(i.col)&&(i.dir==="asc"||i.dir==="desc"))return{col:i.col,dir:i.dir}}catch{}return xe}function Le({label:t,col:i,sort:r,onSort:s,align:n="left",info:a}){const d=r.col===i;return e.jsx(re,{className:n==="right"?"text-right":void 0,ariaSort:d?r.dir==="asc"?"ascending":"descending":"none",children:e.jsxs("span",{className:`inline-flex items-center gap-1.5 ${n==="right"?"flex-row-reverse":""}`,children:[e.jsxs("button",{type:"button",onClick:()=>s(i),className:`inline-flex items-center gap-1 ${n==="right"?"flex-row-reverse":""} hover:text-[var(--otari-ink)]`,children:[t,e.jsx("span",{className:"text-[10px] text-[var(--otari-muted)]",children:d?r.dir==="asc"?"▲":"▼":"↕"})]}),a]})})}function Ft({row:t,onClose:i}){const r=$e(),s=Ee(),[n,a]=c.useState(t.inputPrice==null?"":String(t.inputPrice)),[d,h]=c.useState(t.outputPrice==null?"":String(t.outputPrice)),[p,v]=c.useState(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),[P,m]=c.useState(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),[S,k]=c.useState(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),[W,D]=c.useState(De(t.pricingTiers)),y=ae(n)&&ae(d)&&R(p)&&R(P)&&R(S)&&Oe(W),Y=()=>{y&&r.mutate({model_key:t.key,input_price_per_million:Number(n),output_price_per_million:Number(d),cache_read_price_per_million:V(p),cache_write_price_per_million:V(P),cache_write_1h_price_per_million:V(S),pricing_tiers:Fe(W)},{onSuccess:i})};return e.jsxs("div",{className:"flex flex-col gap-3 px-4 py-3",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("span",{className:"text-xs font-medium break-all text-[var(--otari-muted)]",children:t.key}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Input $ / 1M",e.jsx(j,{value:n,onChange:a,ariaLabel:`Input price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Output $ / 1M",e.jsx(j,{value:d,onChange:h,ariaLabel:`Output price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Cache read $ / 1M",e.jsx(j,{value:p,onChange:v,ariaLabel:`Cache read price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["Cache write $ / 1M",e.jsx(j,{value:P,onChange:m,ariaLabel:`Cache write price for ${t.key}`})]}),e.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:["1h cache write $ / 1M",e.jsx(j,{value:S,onChange:k,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(L,{size:"sm",variant:"primary",isDisabled:r.isPending||!y,onPress:Y,children:r.isPending?"Saving…":"Save"}),e.jsx(L,{size:"sm",variant:"ghost",isDisabled:r.isPending,onPress:i,children:"Cancel"}),t.source==="configured"?e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(Ae,{confirmLabel:"Reset",isPending:s.isPending,onConfirm:()=>s.mutate(t.key,{onSuccess:i}),children:"Reset"}),e.jsx(se,{label:"What reset does",children:"Removes the custom price. The model reverts to the default rate (genai-prices) when default pricing is on, otherwise it is metered at no cost."})]}):null,r.error||s.error?e.jsx("span",{className:"text-xs text-red-700",children:me(r.error??s.error)}):null]}),e.jsx(ze,{tiers:W,onChange:D})]})}function zt({primary:t,secondary:i,rowKey:r,primaryLabel:s,secondaryLabel:n,onEdit:a}){const d=(h,p)=>e.jsx("button",{type:"button","aria-label":`Edit ${p} price for ${r}`,className:"tabular-nums hover:text-[var(--otari-brand-dark)] hover:underline",onClick:v=>{v.stopPropagation(),a()},children:h==null?"—":A(h)});return e.jsxs("span",{className:"inline-flex items-center justify-end gap-1",children:[d(t,s),e.jsx("span",{className:"text-[var(--otari-muted)]",children:"/"}),d(i,n)]})}function Bt({rates:t,rowKey:i,onEdit:r}){const s=[t.cacheReadPrice==null?null:`R ${A(t.cacheReadPrice)}`,t.cacheWritePrice==null?null:`W ${A(t.cacheWritePrice)}`,t.cacheWrite1hPrice==null?null:`1h ${A(t.cacheWrite1hPrice)}`].filter(n=>n!==null);return e.jsx("button",{type:"button","aria-label":`Edit caching price for ${i}`,className:"max-w-44 text-right text-xs leading-5 text-[var(--otari-muted)] hover:text-[var(--otari-brand-dark)] hover:underline",onClick:n=>{n.stopPropagation(),r()},children:s.length>0?s.join(" · "):"Input-rate fallback"})}function Rt({row:t,onEdit:i}){const r=[...t.pricingTiers].sort((n,a)=>n.min_input_tokens-a.min_input_tokens).map(n=>ne(n.min_input_tokens)),s=r.length===0?"Base only":`${r.length} tier${r.length===1?"":"s"} · ≥ ${r.join(", ")}`;return e.jsx("button",{type:"button","aria-label":`Edit pricing policy for ${t.key}`,className:"max-w-40 text-right text-xs leading-5 text-[var(--otari-muted)] hover:text-[var(--otari-brand-dark)] hover:underline",onClick:n=>{n.stopPropagation(),i()},children:s})}function Kt({rows:t,isLoading:i,empty:r,sort:s,onSort:n,selectedKey:a,onSelect:d,pricingKey:h,onSetPricingKey:p,comparisonContextTokens:v}){const P=v==null?"Base":`at ${ne(v)}`;return e.jsxs(mt,{children:[e.jsx(ft,{children:e.jsxs(Ce,{children:[e.jsx(Le,{label:"Model",col:"model",sort:s,onSort:n}),e.jsx(re,{children:"Provider"}),e.jsx(Le,{label:`${P} in / out $ / 1M`,col:"input",sort:s,onSort:n,align:"right",info:e.jsx(Mt,{})}),e.jsxs(re,{className:"text-right",children:["Caching ",v==null?"policy":P]}),e.jsx(re,{className:"text-right",children:"Pricing policy"})]})}),e.jsx("tbody",{children:i?e.jsx(vt,{colSpan:5}):t.length>0?t.map(m=>{const S=()=>p(h===m.key?null:m.key),k=kt(m,v);return e.jsxs(c.Fragment,{children:[e.jsxs(Ce,{onClick:()=>d(m.key),selected:m.key===a,children:[e.jsxs(U,{className:"font-medium break-all",children:[m.model,e.jsx("span",{className:"sr-only",children:m.key})]}),e.jsx(U,{className:"text-[var(--otari-muted)]",children:m.provider}),e.jsx(U,{className:"text-right",children:e.jsx(zt,{primary:k.inputPrice,secondary:k.outputPrice,rowKey:m.key,primaryLabel:"input",secondaryLabel:"output",onEdit:S})}),e.jsx(U,{className:"text-right",children:e.jsx(Bt,{rates:k,rowKey:m.key,onEdit:S})}),e.jsx(U,{className:"text-right",children:e.jsx(Rt,{row:m,onEdit:S})})]}),h===m.key?e.jsx("tr",{className:"border-b border-[var(--otari-line)] bg-[var(--otari-bg)] last:border-b-0",children:e.jsx("td",{colSpan:5,children:e.jsx(Ft,{row:m,onClose:()=>p(null)})})}):null]},m.key)}):e.jsx(bt,{colSpan:5,children:r})})]})}function Vt({providers:t}){return t.length===0?null:e.jsxs(dt,{tone:"warning",children:["Could not list ",t.map(i=>i.provider).join(", "),". Check that provider's credentials in config.yml; its models are missing from the list below."]})}function Jt(){var je,Pe,ye;const t=rt(),[i]=nt(),r=at(),s=st(),n=lt(),a=ct(),[d,h]=c.useState(""),[p,v]=c.useState(0),[P,m]=c.useState(Lt),[S,k]=c.useState(null),[W,D]=c.useState(null),[y,Y]=c.useState(Ot);c.useEffect(()=>{try{window.localStorage.setItem(Be,JSON.stringify(y))}catch{}},[y]);const[M,G]=c.useState(i.get("provider")||"all"),[F,Re]=c.useState("all"),[H,Ke]=c.useState("all"),[q,Ve]=c.useState("all"),[le,Ye]=c.useState("0"),[J,He]=c.useState(""),[Z,qe]=c.useState("all"),[ce,we]=c.useState(""),O=((je=a.data)==null?void 0:je.models)??{},Ue=((Pe=a.data)==null?void 0:Pe.available)??!1,fe=c.useMemo(()=>{var l;return new Set((((l=n.data)==null?void 0:l.providers)??[]).flatMap(f=>f.models.map(o=>o.key)))},[n.data]),Ge=l=>{h(l),v(0)},z=l=>f=>{l(f),v(0)},Je=l=>{Y(f=>f.col===l?{col:l,dir:f.dir==="asc"?"desc":"asc"}:{col:l,dir:l==="released"?"desc":"asc"}),v(0)},X=c.useMemo(()=>{var C,g,_,$,I,B,Ne;const l=new Map(gt(s.data??[]).map(u=>[u.model_key,u])),f=[],o=new Set,b=(u,he,Se,x)=>{if(o.has(u))return;o.add(u);const N=l.get(u);f.push({key:u,model:Ie(he,Se),provider:Se,isDiscovered:fe.has(u),contextWindow:(x==null?void 0:x.contextWindow)??null,inputPrice:N?N.input_price_per_million:(x==null?void 0:x.inputPrice)??null,outputPrice:N?N.output_price_per_million:(x==null?void 0:x.outputPrice)??null,cacheReadPrice:N?N.cache_read_price_per_million:(x==null?void 0:x.cacheReadPrice)??null,cacheWritePrice:N?N.cache_write_price_per_million:(x==null?void 0:x.cacheWritePrice)??null,cacheWrite1hPrice:N?N.cache_write_1h_price_per_million??null:(x==null?void 0:x.cacheWrite1hPrice)??null,pricingTiers:N?N.pricing_tiers??[]:(x==null?void 0:x.pricingTiers)??[],source:N?"configured":(x==null?void 0:x.source)??"none"})};for(const u of((C=r.data)==null?void 0:C.data)??[]){if(u.owned_by===_t)continue;const he=u.pricing_source==="default"?"default":u.pricing?"configured":"none";b(u.id,u.id,u.owned_by||We(u.id),{key:u.id,model:u.id,provider:u.owned_by,contextWindow:u.context_window,inputPrice:((g=u.pricing)==null?void 0:g.input_price_per_million)??null,outputPrice:((_=u.pricing)==null?void 0:_.output_price_per_million)??null,cacheReadPrice:(($=u.pricing)==null?void 0:$.cache_read_price_per_million)??null,cacheWritePrice:((I=u.pricing)==null?void 0:I.cache_write_price_per_million)??null,cacheWrite1hPrice:((B=u.pricing)==null?void 0:B.cache_write_1h_price_per_million)??null,pricingTiers:((Ne=u.pricing)==null?void 0:Ne.pricing_tiers)??[],source:he})}for(const u of l.keys())b(u,u,We(u));return f},[r.data,s.data,fe]),Ze=c.useMemo(()=>new Map(X.map(l=>[l.key,l])),[X]),Q=c.useMemo(()=>{var o,b,C;const l=X.map(g=>{var _,$;return{...g,contextWindow:g.contextWindow??((_=O[g.key])==null?void 0:_.context_window)??null,releaseDate:(($=O[g.key])==null?void 0:$.release_date)??null}}),f=new Set(l.map(g=>g.key));for(const g of((o=n.data)==null?void 0:o.providers)??[])for(const _ of g.models)f.has(_.key)||(f.add(_.key),l.push({key:_.key,model:Ie(_.key,g.provider),provider:g.provider,isDiscovered:!0,contextWindow:((b=O[_.key])==null?void 0:b.context_window)??null,releaseDate:((C=O[_.key])==null?void 0:C.release_date)??null,inputPrice:null,outputPrice:null,cacheReadPrice:null,cacheWritePrice:null,cacheWrite1hPrice:null,pricingTiers:[],source:"none"}));return l},[X,n.data,O]),Xe=(((ye=n.data)==null?void 0:ye.providers)??[]).filter(l=>!l.ok),ee=c.useMemo(()=>{const l=Array.from(new Set(Q.map(f=>f.provider))).sort((f,o)=>f.localeCompare(o));return[{value:"all",label:"All providers"},...l.map(f=>({value:f,label:f}))]},[Q]);c.useEffect(()=>{M==="all"||ee.length<=1||ee.some(l=>l.value===M)||G("all")},[ee,M]);const w=d.trim().toLowerCase(),oe=Number(le)||0,ue=J===""?Number.POSITIVE_INFINITY:Number(J),Qe=ce===""?null:Number(ce),de=Z==="all"?null:Date.now()-Number(Z)*Ct,pe=c.useMemo(()=>{const l=o=>{if(w&&!o.key.toLowerCase().includes(w)&&!o.provider.toLowerCase().includes(w)||M!=="all"&&o.provider!==M||F==="configured"&&o.source!=="configured"||F==="default"&&o.source!=="default"||F==="priced"&&o.inputPrice==null||F==="unpriced"&&o.inputPrice!=null||H==="discovered"&&!o.isDiscovered||H==="custom"&&o.isDiscovered)return!1;if(q!=="all"){const b=Me.find(g=>g.value===q),C=O[o.key];if(!b||!C||!b.test(C))return!1}if(oe>0&&(o.contextWindow==null||o.contextWindowue))return!1;if(de!=null){const b=o.releaseDate?Date.parse(o.releaseDate):Number.NaN;if(Number.isNaN(b)||b{const C=y.dir==="asc"?1:-1;if(y.col==="model")return o.model.localeCompare(b.model)*C;if(y.col==="released"){const I=o.releaseDate??null,B=b.releaseDate??null;return!I&&!B?o.model.localeCompare(b.model):I?B?(IB?1:0)*C||o.model.localeCompare(b.model):-1:1}const g=I=>y.col==="input"?I.inputPrice:I.outputPrice,_=g(o),$=g(b);return _==null&&$==null?o.model.localeCompare(b.model):_==null?1:$==null?-1:(_-$)*C||o.model.localeCompare(b.model)};return Q.filter(l).sort(f)},[Q,w,M,F,H,q,oe,ue,de,O,y]),ve=pe.length,be=Math.max(1,Math.ceil(ve/P)),ge=Math.min(p,be-1),_e=ge*P,et=pe.slice(_e,_e+P),tt=r.isLoading||s.isLoading||n.isLoading,it=w!==""||M!=="all"||F!=="all"||H!=="all"||q!=="all"||le!=="0"||J!==""||Z!=="all"?"No models match your filters.":"No models yet. Add a provider on the Providers page, or price a model below.",te=W?pe.find(l=>l.key===W)??Ze.get(W)??null:null;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ot,{title:"Models",description:"Every model your providers can serve. Set a price on any model so budgets and usage tracking work."}),e.jsx(ut,{error:r.error??s.error??n.error??a.error}),e.jsxs("div",{className:`grid gap-4 lg:items-start ${te?"lg:grid-cols-[minmax(0,1fr)_360px]":"grid-cols-1"}`,children:[e.jsxs("div",{className:"flex min-w-0 flex-col gap-3",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx(Et,{value:d,onChange:Ge,placeholder:"Search models…"}),e.jsx(E,{ariaLabel:"Filter by provider",value:M,onChange:z(G),options:ee}),e.jsx(E,{ariaLabel:"Filter by pricing",value:F,onChange:z(Re),options:[{value:"all",label:"Any pricing"},{value:"configured",label:"Custom price"},{value:"default",label:"Default price"},{value:"priced",label:"Priced"},{value:"unpriced",label:"Unpriced"}]}),e.jsx(E,{ariaLabel:"Filter by source",value:H,onChange:z(Ke),options:[{value:"all",label:"Any source"},{value:"discovered",label:"Discovered"},{value:"custom",label:"Custom (not discovered)"}]}),e.jsx(E,{ariaLabel:"Filter by capability",value:q,onChange:z(Ve),options:[{value:"all",label:"Any capability"},...Me.map(l=>({value:l.value,label:l.label}))]}),e.jsx(E,{ariaLabel:"Minimum context window",value:le,onChange:z(Ye),options:Pt}),e.jsx(E,{ariaLabel:"Maximum input price",value:J,onChange:z(He),options:yt}),e.jsx(E,{ariaLabel:"Compare prices at context",value:ce,onChange:we,options:Nt}),e.jsx(E,{ariaLabel:"Filter by release date",value:Z,onChange:z(qe),options:St})]}),e.jsx(Vt,{providers:Xe}),e.jsx(Kt,{rows:et,isLoading:tt,empty:it,sort:y,onSort:Je,selectedKey:W,onSelect:D,pricingKey:S,onSetPricingKey:k,comparisonContextTokens:Qe}),e.jsx("div",{className:"flex flex-wrap items-center justify-between gap-2",children:e.jsx(At,{page:ge,pageCount:be,total:ve,pageSize:P,onPage:v,onPageSize:l=>{m(l),v(0)}})})]}),te?e.jsx("aside",{className:"lg:sticky lg:top-4",children:e.jsx(It,{row:te,metadata:O[te.key],metadataAvailable:Ue,onMakeAlias:l=>t(`/aliases?target=${encodeURIComponent(l)}`),onClose:()=>D(null)})}):null]})]})}export{Jt as ModelsPage}; diff --git a/src/gateway/static/dashboard/assets/OverviewPage-_Gja1ZBt.js b/src/gateway/static/dashboard/assets/OverviewPage-CtvPFoWc.js similarity index 99% rename from src/gateway/static/dashboard/assets/OverviewPage-_Gja1ZBt.js rename to src/gateway/static/dashboard/assets/OverviewPage-CtvPFoWc.js index 1001b34a..2d51371e 100644 --- a/src/gateway/static/dashboard/assets/OverviewPage-_Gja1ZBt.js +++ b/src/gateway/static/dashboard/assets/OverviewPage-CtvPFoWc.js @@ -1 +1 @@ -import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as f,i as Z,N as G}from"./react-dgEcD0HR.js";import{J as tt,c as N,K as et,j as rt,p as at,u as st,a as nt,L as _,P as ot,E as Y,S as p,M as D,N as R,z as E,O as k,Q as it}from"./index-Dp4DdBFR.js";import{S as H}from"./charts-Cr3Dij9t.js";import{T as lt,a as dt,b,L as ct,c as ut,d as xt,e as j}from"./Table-CLdjdyTx.js";import{d as B,B as mt}from"./heroui-BX6JwHY-.js";import"./recharts-CR3TAEof.js";function U(e){return e==="neutral"?void 0:e}const vt=.02,ht=.1;function F(e){if(!e||e.request_count===0)return{rate:null,status:"neutral"};const n=e.error_count/e.request_count,a=n>=ht?"alert":n>=vt?"warn":"ok";return{rate:n,status:a}}function pt(e){return!e||e.total===0?"neutral":e.healthy>=e.total?"ok":e.healthy===0?"alert":"warn"}const ft=.8;function gt(e){if(e.length===0)return{status:"neutral",label:"No budgets configured",overCount:0,nearCount:0,cappedCount:0};const n=e.filter(s=>s.max_budget!==null&&s.user_count>0);if(n.length===0)return{status:"neutral",label:"No capped budgets",overCount:0,nearCount:0,cappedCount:0};let a=0,r=0,c,m=-1;for(const s of n){const o=s.max_budget*s.user_count,i=o>0?s.total_spend/o:0;i>=1?a+=1:i>=ft&&(r+=1),i>m&&(m=i,c={name:s.name??s.budget_id,spent:s.total_spend,allocated:o,pct:i})}const u=a>0?"alert":r>0?"warn":"ok",g=a>0?`${a} over limit`:r>0?`${r} near limit`:"All within budget";return{status:u,label:g,overCount:a,nearCount:r,cappedCount:n.length,worst:c}}const K=864e5,W=30;function I(){const e=new Date;return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`}function bt(){const[e,n]=f.useState(I);return f.useEffect(()=>{const a=()=>{if(document.visibilityState==="visible"){const r=I();n(c=>c===r?c:r)}};return document.addEventListener("visibilitychange",a),window.addEventListener("focus",a),()=>{document.removeEventListener("visibilitychange",a),window.removeEventListener("focus",a)}},[]),f.useMemo(()=>{const a=Date.now(),r=new Date(a);return{today:new Date(r.getFullYear(),r.getMonth(),r.getDate()).toISOString(),periodStart:new Date(a-W*K).toISOString(),prevStart:new Date(a-2*W*K).toISOString()}},[e])}const jt={ok:"Healthy",warn:"Elevated",alert:"High"},St={ok:"On track",warn:"Near limit",alert:"Over budget"};function Pt(){const e=tt();return e.isLoading?null:t.jsx(yt,{needsSetup:e.isSuccess&&e.data.providers.length===0})}function yt({needsSetup:e=!1}){var $,A,P,T,M,q;const n=bt(),a=f.useMemo(()=>({start_date:n.today}),[n]),r=f.useMemo(()=>({start_date:n.periodStart}),[n]),c=f.useMemo(()=>({start_date:n.prevStart,end_date:n.periodStart}),[n]),m=N(a,"hour"),u=N(r,"day"),g=N(c,"day"),s=et(),o=rt(),i=at(),S=st(),y=nt({},0,5),C=($=m.data)==null?void 0:$.totals,x=(A=u.data)==null?void 0:A.totals,v=(P=g.data)==null?void 0:P.totals,w=((T=u.data)==null?void 0:T.series)??[],O=w.length>1,l=F(x),L=F(v),z=l.rate!==null&&L.rate!==null?_(l.rate,L.rate):null,d=gt(o.data??[]),J=pt(s.data),Q=(i.data??[]).filter(h=>h.is_active).length,V=(S.data??[]).filter(h=>!h.blocked).length,X=m.error??u.error??s.error??o.error??i.error??S.error;return t.jsxs("div",{className:"flex flex-col gap-6",children:[t.jsx(ot,{title:"Overview",description:"At-a-glance spend, traffic, and health across the gateway."}),e?t.jsx(wt,{}):null,t.jsx(Y,{error:X}),t.jsx(_t,{providerHealth:J,healthy:((M=s.data)==null?void 0:M.healthy)??0,total:((q=s.data)==null?void 0:q.total)??0,budget:d,errStatus:l.status,errRate:l.rate,ready:s.isSuccess&&o.isSuccess&&u.isSuccess,failed:s.isError||o.isError||u.isError}),t.jsxs("div",{className:"grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4",children:[t.jsx(p,{label:"Spend today",value:C?D(C.cost):"—"}),t.jsx(p,{label:"Spend, last 30 days",value:x?D(x.cost):"—",hint:x?t.jsx(R,{fraction:_(x.cost,v==null?void 0:v.cost)}):null,chart:O?t.jsx(H,{values:w.map(h=>h.cost),ariaLabel:"Spend trend over the last 30 days"}):void 0}),t.jsx(p,{label:"Requests, last 30 days",value:x?E(x.request_count):"—",hint:x?t.jsx(R,{fraction:_(x.request_count,v==null?void 0:v.request_count)}):null,chart:O?t.jsx(H,{values:w.map(h=>h.requests),ariaLabel:"Request volume trend over the last 30 days"}):void 0}),t.jsx(p,{label:"Error rate, last 30 days",value:l.rate===null?"—":k(l.rate),status:U(l.status),statusLabel:l.status==="neutral"?void 0:jt[l.status],hint:l.rate!==null?t.jsx(R,{fraction:z}):null}),t.jsx(p,{label:"Budget health",value:o.data&&d.worst?k(d.worst.pct):"—",status:o.data?U(d.status):void 0,statusLabel:o.data&&d.status!=="neutral"?St[d.status]:void 0,hint:o.data?d.worst?`${d.label} · worst: ${d.worst.name}`:d.label:void 0,to:"/budgets"}),t.jsx(p,{label:"Active keys",value:i.data?E(Q):"—",to:"/keys"}),t.jsx(p,{label:"Active users",value:S.data?E(V):"—",to:"/users"})]}),t.jsx(Et,{entries:y.data??[],loading:y.isLoading,error:y.error})]})}function wt(){const e=Z();return t.jsx(B,{children:t.jsxs(B.Content,{className:"flex flex-col gap-3 p-6",children:[t.jsxs("div",{children:[t.jsx("h2",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"Get started with Otari"}),t.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"Add a provider to begin serving models. Once it is configured, this page will show your gateway’s traffic, spend, and health."})]}),t.jsx("div",{children:t.jsx(mt,{variant:"primary",onPress:()=>e("/providers"),children:"Add your first provider"})})]})})}function Nt({text:e}){return t.jsx("div",{role:"status",className:"flex items-center gap-2 rounded-xl border border-[var(--otari-line)] bg-[var(--otari-bg)] px-4 py-3 text-sm text-[var(--otari-muted)]",children:e})}function _t({providerHealth:e,healthy:n,total:a,budget:r,errStatus:c,errRate:m,ready:u,failed:g}){if(g)return t.jsx(Nt,{text:"Some status data could not be loaded."});if(!u)return null;const s=[];if((e==="warn"||e==="alert")&&a>0){const o=a-n;s.push({text:`${o} provider${o===1?"":"s"} unreachable`,to:"/providers"})}return r.overCount>0?s.push({text:`${r.overCount} budget${r.overCount===1?"":"s"} over limit`,to:"/budgets"}):r.nearCount>0&&s.push({text:`${r.nearCount} budget${r.nearCount===1?"":"s"} near limit`,to:"/budgets"}),c==="alert"&&m!==null&&s.push({text:`error rate ${k(m)}`,to:"/activity?status=error"}),s.length===0?null:t.jsxs("div",{role:"alert",className:"flex flex-col gap-2 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 sm:flex-row sm:flex-wrap sm:items-center",children:[t.jsx("span",{className:"font-medium",children:"Needs attention:"}),s.map((o,i)=>t.jsxs("span",{className:"flex items-center gap-2",children:[i>0?t.jsx("span",{"aria-hidden":!0,className:"text-amber-400",children:"·"}):null,t.jsx(G,{to:o.to,className:"underline underline-offset-2 hover:text-amber-950",children:o.text})]},o.to+o.text))]})}function Rt(e){return e==="error"?"error":"ok"}function Et({entries:e,loading:n,error:a}){return t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsxs("div",{className:"flex items-center justify-between",children:[t.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Recent activity"}),t.jsx(G,{to:"/activity",className:"text-sm text-[var(--otari-brand-dark)] hover:underline",children:"View all →"})]}),t.jsx(Y,{error:a}),t.jsxs(lt,{children:[t.jsx(dt,{children:t.jsxs("tr",{children:[t.jsx(b,{children:"Time"}),t.jsx(b,{children:"Model"}),t.jsx(b,{className:"text-right",children:"Cost"}),t.jsx(b,{children:"Status"})]})}),t.jsx("tbody",{children:n?t.jsx(ct,{colSpan:4}):e.length===0?t.jsx(ut,{colSpan:4,children:"No requests yet. Once the gateway serves traffic, it appears here."}):e.map(r=>t.jsxs(xt,{children:[t.jsx(j,{className:"text-[var(--otari-muted)]",children:t.jsx("span",{title:new Date(r.timestamp).toLocaleString(),children:it(r.timestamp)})}),t.jsx(j,{className:"text-[var(--otari-ink)]",children:r.model}),t.jsx(j,{className:"text-right tabular-nums",children:r.cost===null?"—":D(r.cost)}),t.jsx(j,{children:t.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${r.status==="error"?"border-red-200 bg-red-50 text-red-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]"}`,children:Rt(r.status)})})]},r.id))})]})]})}export{Pt as OverviewIndex,yt as OverviewPage,I as localDayKey}; +import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as f,i as Z,N as G}from"./react-dgEcD0HR.js";import{J as tt,c as N,K as et,j as rt,p as at,u as st,a as nt,L as _,P as ot,E as Y,S as p,M as D,N as R,z as E,O as k,Q as it}from"./index-hDVMDLdX.js";import{S as H}from"./charts-Cr3Dij9t.js";import{T as lt,a as dt,b,L as ct,c as ut,d as xt,e as j}from"./Table-CLdjdyTx.js";import{d as B,B as mt}from"./heroui-BX6JwHY-.js";import"./recharts-CR3TAEof.js";function U(e){return e==="neutral"?void 0:e}const vt=.02,ht=.1;function F(e){if(!e||e.request_count===0)return{rate:null,status:"neutral"};const n=e.error_count/e.request_count,a=n>=ht?"alert":n>=vt?"warn":"ok";return{rate:n,status:a}}function pt(e){return!e||e.total===0?"neutral":e.healthy>=e.total?"ok":e.healthy===0?"alert":"warn"}const ft=.8;function gt(e){if(e.length===0)return{status:"neutral",label:"No budgets configured",overCount:0,nearCount:0,cappedCount:0};const n=e.filter(s=>s.max_budget!==null&&s.user_count>0);if(n.length===0)return{status:"neutral",label:"No capped budgets",overCount:0,nearCount:0,cappedCount:0};let a=0,r=0,c,m=-1;for(const s of n){const o=s.max_budget*s.user_count,i=o>0?s.total_spend/o:0;i>=1?a+=1:i>=ft&&(r+=1),i>m&&(m=i,c={name:s.name??s.budget_id,spent:s.total_spend,allocated:o,pct:i})}const u=a>0?"alert":r>0?"warn":"ok",g=a>0?`${a} over limit`:r>0?`${r} near limit`:"All within budget";return{status:u,label:g,overCount:a,nearCount:r,cappedCount:n.length,worst:c}}const K=864e5,W=30;function I(){const e=new Date;return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`}function bt(){const[e,n]=f.useState(I);return f.useEffect(()=>{const a=()=>{if(document.visibilityState==="visible"){const r=I();n(c=>c===r?c:r)}};return document.addEventListener("visibilitychange",a),window.addEventListener("focus",a),()=>{document.removeEventListener("visibilitychange",a),window.removeEventListener("focus",a)}},[]),f.useMemo(()=>{const a=Date.now(),r=new Date(a);return{today:new Date(r.getFullYear(),r.getMonth(),r.getDate()).toISOString(),periodStart:new Date(a-W*K).toISOString(),prevStart:new Date(a-2*W*K).toISOString()}},[e])}const jt={ok:"Healthy",warn:"Elevated",alert:"High"},St={ok:"On track",warn:"Near limit",alert:"Over budget"};function Pt(){const e=tt();return e.isLoading?null:t.jsx(yt,{needsSetup:e.isSuccess&&e.data.providers.length===0})}function yt({needsSetup:e=!1}){var $,A,P,T,M,q;const n=bt(),a=f.useMemo(()=>({start_date:n.today}),[n]),r=f.useMemo(()=>({start_date:n.periodStart}),[n]),c=f.useMemo(()=>({start_date:n.prevStart,end_date:n.periodStart}),[n]),m=N(a,"hour"),u=N(r,"day"),g=N(c,"day"),s=et(),o=rt(),i=at(),S=st(),y=nt({},0,5),C=($=m.data)==null?void 0:$.totals,x=(A=u.data)==null?void 0:A.totals,v=(P=g.data)==null?void 0:P.totals,w=((T=u.data)==null?void 0:T.series)??[],O=w.length>1,l=F(x),L=F(v),z=l.rate!==null&&L.rate!==null?_(l.rate,L.rate):null,d=gt(o.data??[]),J=pt(s.data),Q=(i.data??[]).filter(h=>h.is_active).length,V=(S.data??[]).filter(h=>!h.blocked).length,X=m.error??u.error??s.error??o.error??i.error??S.error;return t.jsxs("div",{className:"flex flex-col gap-6",children:[t.jsx(ot,{title:"Overview",description:"At-a-glance spend, traffic, and health across the gateway."}),e?t.jsx(wt,{}):null,t.jsx(Y,{error:X}),t.jsx(_t,{providerHealth:J,healthy:((M=s.data)==null?void 0:M.healthy)??0,total:((q=s.data)==null?void 0:q.total)??0,budget:d,errStatus:l.status,errRate:l.rate,ready:s.isSuccess&&o.isSuccess&&u.isSuccess,failed:s.isError||o.isError||u.isError}),t.jsxs("div",{className:"grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4",children:[t.jsx(p,{label:"Spend today",value:C?D(C.cost):"—"}),t.jsx(p,{label:"Spend, last 30 days",value:x?D(x.cost):"—",hint:x?t.jsx(R,{fraction:_(x.cost,v==null?void 0:v.cost)}):null,chart:O?t.jsx(H,{values:w.map(h=>h.cost),ariaLabel:"Spend trend over the last 30 days"}):void 0}),t.jsx(p,{label:"Requests, last 30 days",value:x?E(x.request_count):"—",hint:x?t.jsx(R,{fraction:_(x.request_count,v==null?void 0:v.request_count)}):null,chart:O?t.jsx(H,{values:w.map(h=>h.requests),ariaLabel:"Request volume trend over the last 30 days"}):void 0}),t.jsx(p,{label:"Error rate, last 30 days",value:l.rate===null?"—":k(l.rate),status:U(l.status),statusLabel:l.status==="neutral"?void 0:jt[l.status],hint:l.rate!==null?t.jsx(R,{fraction:z}):null}),t.jsx(p,{label:"Budget health",value:o.data&&d.worst?k(d.worst.pct):"—",status:o.data?U(d.status):void 0,statusLabel:o.data&&d.status!=="neutral"?St[d.status]:void 0,hint:o.data?d.worst?`${d.label} · worst: ${d.worst.name}`:d.label:void 0,to:"/budgets"}),t.jsx(p,{label:"Active keys",value:i.data?E(Q):"—",to:"/keys"}),t.jsx(p,{label:"Active users",value:S.data?E(V):"—",to:"/users"})]}),t.jsx(Et,{entries:y.data??[],loading:y.isLoading,error:y.error})]})}function wt(){const e=Z();return t.jsx(B,{children:t.jsxs(B.Content,{className:"flex flex-col gap-3 p-6",children:[t.jsxs("div",{children:[t.jsx("h2",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"Get started with Otari"}),t.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"Add a provider to begin serving models. Once it is configured, this page will show your gateway’s traffic, spend, and health."})]}),t.jsx("div",{children:t.jsx(mt,{variant:"primary",onPress:()=>e("/providers"),children:"Add your first provider"})})]})})}function Nt({text:e}){return t.jsx("div",{role:"status",className:"flex items-center gap-2 rounded-xl border border-[var(--otari-line)] bg-[var(--otari-bg)] px-4 py-3 text-sm text-[var(--otari-muted)]",children:e})}function _t({providerHealth:e,healthy:n,total:a,budget:r,errStatus:c,errRate:m,ready:u,failed:g}){if(g)return t.jsx(Nt,{text:"Some status data could not be loaded."});if(!u)return null;const s=[];if((e==="warn"||e==="alert")&&a>0){const o=a-n;s.push({text:`${o} provider${o===1?"":"s"} unreachable`,to:"/providers"})}return r.overCount>0?s.push({text:`${r.overCount} budget${r.overCount===1?"":"s"} over limit`,to:"/budgets"}):r.nearCount>0&&s.push({text:`${r.nearCount} budget${r.nearCount===1?"":"s"} near limit`,to:"/budgets"}),c==="alert"&&m!==null&&s.push({text:`error rate ${k(m)}`,to:"/activity?status=error"}),s.length===0?null:t.jsxs("div",{role:"alert",className:"flex flex-col gap-2 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 sm:flex-row sm:flex-wrap sm:items-center",children:[t.jsx("span",{className:"font-medium",children:"Needs attention:"}),s.map((o,i)=>t.jsxs("span",{className:"flex items-center gap-2",children:[i>0?t.jsx("span",{"aria-hidden":!0,className:"text-amber-400",children:"·"}):null,t.jsx(G,{to:o.to,className:"underline underline-offset-2 hover:text-amber-950",children:o.text})]},o.to+o.text))]})}function Rt(e){return e==="error"?"error":"ok"}function Et({entries:e,loading:n,error:a}){return t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsxs("div",{className:"flex items-center justify-between",children:[t.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Recent activity"}),t.jsx(G,{to:"/activity",className:"text-sm text-[var(--otari-brand-dark)] hover:underline",children:"View all →"})]}),t.jsx(Y,{error:a}),t.jsxs(lt,{children:[t.jsx(dt,{children:t.jsxs("tr",{children:[t.jsx(b,{children:"Time"}),t.jsx(b,{children:"Model"}),t.jsx(b,{className:"text-right",children:"Cost"}),t.jsx(b,{children:"Status"})]})}),t.jsx("tbody",{children:n?t.jsx(ct,{colSpan:4}):e.length===0?t.jsx(ut,{colSpan:4,children:"No requests yet. Once the gateway serves traffic, it appears here."}):e.map(r=>t.jsxs(xt,{children:[t.jsx(j,{className:"text-[var(--otari-muted)]",children:t.jsx("span",{title:new Date(r.timestamp).toLocaleString(),children:it(r.timestamp)})}),t.jsx(j,{className:"text-[var(--otari-ink)]",children:r.model}),t.jsx(j,{className:"text-right tabular-nums",children:r.cost===null?"—":D(r.cost)}),t.jsx(j,{children:t.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${r.status==="error"?"border-red-200 bg-red-50 text-red-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]"}`,children:Rt(r.status)})})]},r.id))})]})]})}export{Pt as OverviewIndex,yt as OverviewPage,I as localDayKey}; diff --git a/src/gateway/static/dashboard/assets/ProvidersPage-B6Y7usRU.js b/src/gateway/static/dashboard/assets/ProvidersPage-DimvEH7H.js similarity index 99% rename from src/gateway/static/dashboard/assets/ProvidersPage-B6Y7usRU.js rename to src/gateway/static/dashboard/assets/ProvidersPage-DimvEH7H.js index 74ae54d7..21c0bbe3 100644 --- a/src/gateway/static/dashboard/assets/ProvidersPage-B6Y7usRU.js +++ b/src/gateway/static/dashboard/assets/ProvidersPage-DimvEH7H.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as x,L as J}from"./react-dgEcD0HR.js";import{J as Q,R as X,B as Z,K as ee,T as se,U as te,V as ae,P as ne,E as R,C as re,W as ie,X as oe,Q as z,h as U,Y as M,Z as le,_ as de,$ as ce}from"./index-Dp4DdBFR.js";import{F as _}from"./Field-HzRk1KDP.js";import{T as me,a as ue,b as S,L as xe,c as pe,d as he,e as C}from"./Table-CLdjdyTx.js";import{B as g,f as H,d as A,S as ve,T as ge,L as Y,I as V,D as je,C as I,a as fe,b as be}from"./heroui-BX6JwHY-.js";function K({value:t,onChange:a,label:s,placeholder:n,description:o}){return e.jsxs(ge,{value:t,onChange:a,className:"flex max-w-md flex-col gap-1",children:[e.jsx(Y,{className:"text-sm font-medium text-[var(--otari-ink)]",children:s}),e.jsx(V,{type:"password",placeholder:n??"sk-…",autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",spellCheck:!1,"data-1p-ignore":!0,"data-lpignore":"true"}),o?e.jsx(je,{className:"text-xs text-[var(--otari-muted)]",children:o}):null]})}function W({label:t,value:a,onChange:s,description:n,placeholder:o,extra:d=[],includeCatalog:m=!0}){var y;const v=de(),u=x.useMemo(()=>m?[...d,...(v.data??[]).map(i=>({id:i.id,name:i.name}))]:d,[v.data,d,m]),[p,c]=x.useState(()=>{var i;return((i=u.find(f=>f.id===a))==null?void 0:i.name)??""}),j=((y=u.find(i=>i.id===a))==null?void 0:y.name)??"",h=p.trim()===j.trim()?"":p.trim().toLowerCase(),l=u.filter(i=>!h||i.name.toLowerCase().includes(h)||i.id.toLowerCase().includes(h)).slice(0,50);return e.jsxs(I.Root,{allowsEmptyCollection:!0,menuTrigger:"focus",inputValue:p,onInputChange:c,onSelectionChange:i=>{var f;i!=null?(s(String(i)),c(((f=u.find(k=>k.id===String(i)))==null?void 0:f.name)??"")):(s(""),c(""))},className:"flex max-w-md flex-col gap-1",children:[e.jsx(Y,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsxs(I.InputGroup,{children:[e.jsx(V,{placeholder:o??"Search providers…",autoComplete:"off","data-1p-ignore":!0,"data-lpignore":"true",onFocus:i=>i.currentTarget.select()}),e.jsx(I.Trigger,{})]}),e.jsx(I.Popover,{children:e.jsx(fe,{items:l,className:"max-h-72 overflow-auto",children:i=>e.jsx(be,{id:i.id,textValue:i.name,children:i.name})})}),n?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:n}):null]})}function G({getPayload:t}){const a=ce(),s=t();return e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsx(g,{variant:"outline",isDisabled:s===null||a.isPending,onPress:()=>{s&&a.mutate(s)},children:a.isPending?"Testing…":"Test connection"}),e.jsx("span",{role:"status","aria-live":"polite",children:a.isPending?null:a.error?e.jsx("span",{className:"text-xs text-red-700",children:U(a.error)}):a.data?a.data.ok?e.jsxs("span",{className:"text-xs font-medium text-green-700",children:["Connected. ",a.data.model_count," model",a.data.model_count===1?"":"s"," available."]}):e.jsx("span",{className:"block max-w-md break-words text-xs text-red-700",children:a.data.error??"Connection failed."}):null})]})}function ye({onClose:t}){var w;const a=M(),[s,n]=x.useState(""),[o,d]=x.useState(""),[m,v]=x.useState(!1),[u,p]=x.useState(""),[c,j]=x.useState(""),h=le(s),l=((w=h.data)==null?void 0:w.id)===s?h.data:void 0;x.useEffect(()=>{l&&p(l.default_api_base??"")},[l]);const y=(l==null?void 0:l.env_key_present)??!1,i=((l==null?void 0:l.requires_api_key)??!0)&&!y,f=c.trim()!==""&&c.trim()!==s,k=/[:/]/.test(c),T=s!==""&&!k&&(!i||o.trim()!=="")&&!a.isPending,E=()=>{T&&a.mutate({instance:f?c.trim():s,provider_type:f?s:null,api_base:u.trim()||null,api_key:o.trim()||null},{onSuccess:t})};return e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(R,{error:a.error}),e.jsx(W,{label:"Provider",value:s,onChange:P=>{n(P),j(""),p("")},description:"Its endpoint is built in."}),e.jsx(K,{value:o,onChange:d,label:l&&!i?"API key (optional)":"API key",description:l?i?`${l.name}'s endpoint is built in — just add your key.`:y?`${l.env_key} is set on the server, so a key is optional here. Paste one to override it.`:`${l.name} needs no API key.`:"Stored encrypted. Requires OTARI_SECRET_KEY on the server."}),e.jsx("button",{type:"button",className:"self-start text-xs font-medium text-[var(--otari-brand-dark)]",onClick:()=>v(P=>!P),children:m?"Hide advanced":"Advanced (API base, rename)"}),m?e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"API base",value:u,onChange:p,placeholder:(l==null?void 0:l.default_api_base)??"https://…/v1",description:"Only if you route through a proxy. Blank uses the built-in default."}),e.jsx(_,{label:"Name",value:c,onChange:j,placeholder:s||"instance name",description:k?e.jsx("span",{className:"text-red-700",children:"A name cannot contain “:” or “/”."}):"Rename to run two instances of the same provider."})]}):null,e.jsxs("div",{className:"flex flex-wrap items-start gap-2",children:[e.jsx(g,{variant:"primary",isDisabled:!T,onPress:E,children:a.isPending?"Adding…":"Add provider"}),e.jsx(g,{variant:"ghost",onPress:t,children:"Cancel"}),e.jsx(G,{getPayload:()=>s===""?null:{instance:f?c.trim():s,provider_type:f?s:null,api_base:u.trim()||null,api_key:o.trim()||null}})]})]})}function ke({onClose:t}){const a=M(),[s,n]=x.useState(""),[o,d]=x.useState("openai-compatible"),[m,v]=x.useState(""),[u,p]=x.useState(""),c=/[:/]/.test(s),j=s.trim()!==""&&!c&&m.trim()!==""&&!a.isPending,h=()=>{j&&a.mutate({instance:s.trim(),provider_type:o||"openai-compatible",api_base:m.trim(),api_key:u.trim()||null},{onSuccess:t})};return e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(R,{error:a.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"Name",value:s,onChange:n,placeholder:"my-local-llm",isRequired:!0,autoFocus:!0,description:c?e.jsx("span",{className:"text-red-700",children:"A name cannot contain “:” or “/”."}):"Call it whatever you want."}),e.jsx(W,{label:"Compatible with",value:o,onChange:d,includeCatalog:!1,description:"The API this endpoint speaks.",extra:[{id:"openai-compatible",name:"OpenAI"},{id:"anthropic-compatible",name:"Anthropic"}]})]}),e.jsx(_,{label:"API base",value:m,onChange:v,placeholder:"http://localhost:8000/v1",isRequired:!0,description:"The endpoint URL of your server."}),e.jsx(K,{value:u,onChange:p,label:"API key (optional)",description:"Many local backends need none. Stored encrypted."}),e.jsxs("div",{className:"flex flex-wrap items-start gap-2",children:[e.jsx(g,{variant:"primary",isDisabled:!j,onPress:h,children:a.isPending?"Adding…":"Add provider"}),e.jsx(g,{variant:"ghost",onPress:t,children:"Cancel"}),e.jsx(G,{getPayload:()=>s.trim()===""||m.trim()===""?null:{instance:s.trim(),provider_type:o||"openai-compatible",api_base:m.trim(),api_key:u.trim()||null}})]})]})}function Pe({onClose:t}){const[a,s]=x.useState("known");return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"flex items-center justify-between",children:e.jsx("div",{className:"flex items-center gap-1 rounded-lg bg-[var(--otari-bg)] p-1",children:[["known","Known provider"],["custom","Custom endpoint"]].map(([n,o])=>e.jsx("button",{type:"button","aria-pressed":a===n,onClick:()=>s(n),className:a===n?"rounded-md bg-white px-3 py-1.5 text-sm font-medium text-[var(--otari-ink)] shadow-sm":"rounded-md px-3 py-1.5 text-sm text-[var(--otari-muted)] hover:text-[var(--otari-ink)]",children:o},n))})}),a==="known"?e.jsx(ye,{onClose:t}):e.jsx(ke,{onClose:t})]})})}function Ne({provider:t,onClose:a}){const s=ie(),[n,o]=x.useState(t.provider_type??""),[d,m]=x.useState(t.api_base??""),[v,u]=x.useState(!1),[p,c]=x.useState(""),j=()=>{if(s.isPending)return;const h={provider_type:n.trim()||null,api_base:d.trim()||null,expected_updated_at:t.updated_at};v&&p.trim()&&(h.api_key=p.trim()),s.mutate({instance:t.instance,body:h},{onSuccess:a})};return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Edit ",e.jsx("code",{children:t.instance})]}),e.jsx(R,{error:s.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"Provider type",value:n,onChange:o,placeholder:"openai"}),e.jsx(_,{label:"API base",value:d,onChange:m,placeholder:"https://api.openai.com/v1"})]}),e.jsx("div",{className:"flex flex-col gap-2",children:v?e.jsxs(e.Fragment,{children:[e.jsx(K,{value:p,onChange:c,label:"New API key",description:"Stored encrypted. The old key is replaced when you save."}),e.jsx("button",{type:"button",className:"self-start text-xs font-medium text-[var(--otari-brand-dark)]",onClick:()=>{u(!1),c("")},children:"Keep the current key"})]}):e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"text-sm text-[var(--otari-muted)]",children:["API key: ",e.jsx("code",{children:t.last4?`••••${t.last4}`:"none set"})]}),e.jsx(g,{size:"sm",variant:"outline",onPress:()=>u(!0),children:"Replace key"})]})}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(g,{variant:"primary",isDisabled:s.isPending,onPress:j,children:s.isPending?"Saving…":"Save changes"}),e.jsx(g,{variant:"ghost",onPress:a,children:"Cancel"})]})]})})}function Se(t,a){const s=new Map((a??[]).map(d=>[d.instance,d])),n=new Map((t??[]).map(d=>[d.instance,d]));return[...new Set([...s.keys(),...n.keys()])].sort().map(d=>{const m=s.get(d);return{instance:d,source:m?"stored":"config",stored:m,meta:n.get(d)}})}function Ce({state:t}){return t?t.status==="pending"?e.jsxs("span",{className:"inline-flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:[e.jsx(ve,{size:"sm"})," Testing…"]}):t.ok?e.jsxs("span",{className:"text-xs font-medium text-green-700",children:["Connected. ",t.model_count," model",t.model_count===1?"":"s"," available."]}):e.jsx("span",{className:"block max-w-xs break-words text-xs text-red-700",children:t.error??"Connection failed."}):null}function _e({health:t}){if(!t)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"—"});const a=t.ok?"border-green-200 bg-green-50 text-green-700":"border-red-200 bg-red-50 text-red-700",s=t.checked_at?`Last checked ${z(t.checked_at)}`:"Not checked yet",n=t.ok?s:`${t.error??"Unreachable"} · ${s}`;return e.jsxs("span",{title:n,className:`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${a}`,children:[e.jsx("span",{"aria-hidden":!0,className:`h-1.5 w-1.5 rounded-full ${t.ok?"bg-green-500":"bg-red-500"}`}),t.ok?"Reachable":"Unreachable"]})}function Ae({healthy:t,total:a,checkedAt:s}){const n=t===a,o=oe();return e.jsxs("div",{className:"flex flex-wrap items-center gap-3 rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] px-4 py-2.5 text-sm",children:[e.jsx("span",{"aria-hidden":!0,className:`h-2 w-2 rounded-full ${n?"bg-green-500":"bg-red-500"}`}),e.jsxs("span",{className:"font-medium text-[var(--otari-ink)]",children:[t," of ",a," provider",a===1?"":"s"," reachable"]}),s?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["Last checked ",z(s)]}):null,e.jsx(g,{size:"sm",variant:"ghost",className:"ml-auto",isDisabled:o.isPending,onPress:()=>o.mutate(),children:o.isPending?"Re-checking…":"Re-check all"})]})}function B({n:t,title:a,children:s}){return e.jsxs("li",{className:"flex gap-3",children:[e.jsx("span",{className:"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--otari-brand-tint)] text-xs font-semibold text-[var(--otari-brand-dark)]",children:t}),e.jsxs("div",{className:"text-sm",children:[e.jsx("div",{className:"font-medium text-[var(--otari-ink)]",children:a}),e.jsx("div",{className:"text-[var(--otari-muted)]",children:s})]})]})}function Te({onAddProvider:t,needsPricing:a,onEnablePricing:s,enabling:n}){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:"Welcome to Otari"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"You are signed in. Add a provider to start serving models: three quick steps."})]}),e.jsxs("ol",{className:"flex flex-col gap-3",children:[e.jsxs(B,{n:1,title:"Add a provider",children:["Enter a provider name (like ",e.jsx("code",{children:"openai"}),") and its API key. Keys are encrypted at rest."]}),e.jsxs(B,{n:2,title:"Test the connection",children:["Use ",e.jsx("strong",{children:"Test"})," on the provider row to confirm the key works and see how many models it serves."]}),e.jsxs(B,{n:3,title:"Send your first request",children:["Point your app at ",e.jsx("code",{children:"/v1"})," on this gateway with the API key printed in the server logs (",e.jsx("code",{children:"gw-…"}),"). See the"," ",e.jsx("a",{href:"/welcome",className:"font-medium text-[var(--otari-brand-dark)]",children:"quickstart"}),"."]})]}),a?e.jsxs("p",{className:"text-sm text-[var(--otari-muted)]",children:["Tip: ",e.jsx("code",{children:"require_pricing"})," is on, so requests are rejected until pricing is set."," ",e.jsx("button",{type:"button",className:"font-medium text-[var(--otari-brand-dark)] disabled:opacity-50",disabled:n,onClick:s,children:"Enable default pricing"})," ","to meter new models with public rates."]}):null,e.jsx("div",{children:e.jsx(g,{variant:"primary",onPress:t,children:"Add your first provider"})})]})})}function Le(){var w,P,L,D;const t=Q(),a=X(),s=Z(),n=ee(),o=se(),d=te(),m=ae(),[v,u]=x.useState(!1),[p,c]=x.useState(null),[j,h]=x.useState({}),l=Se((w=t.data)==null?void 0:w.providers,a.data),y=new Map((((P=n.data)==null?void 0:P.providers)??[]).map(r=>[r.instance,r])),i=t.isLoading||a.isLoading,f=((L=a.data)==null?void 0:L.find(r=>r.instance===p))??null,k=((D=s.data)==null?void 0:D.require_pricing)===!0&&s.data.default_pricing===!1,T=!i&&l.length===0&&!v,E=r=>{h(b=>({...b,[r]:{status:"pending"}})),d.mutate(r,{onSuccess:b=>h(N=>({...N,[r]:{status:"done",...b}})),onError:b=>h(N=>({...N,[r]:{status:"done",ok:!1,model_count:0,error:U(b)}}))})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ne,{title:"Providers",description:"Add provider API keys here to serve models without editing config.yml. Keys are encrypted at rest.",action:v||T?null:e.jsx(g,{variant:"primary",onPress:()=>{c(null),u(!0)},children:"Add provider"})}),e.jsx(R,{error:t.error??a.error??s.error??n.error??m.error??o.error}),T?e.jsx(Te,{onAddProvider:()=>{c(null),u(!0)},needsPricing:k,onEnablePricing:()=>m.mutate({default_pricing:!0}),enabling:m.isPending}):null,v?e.jsx(Pe,{onClose:()=>u(!1)}):null,f?e.jsx(Ne,{provider:f,onClose:()=>c(null)}):null,!i&&l.length>0&&n.data&&n.data.total>0?e.jsx(Ae,{healthy:n.data.healthy,total:n.data.total,checkedAt:n.data.checked_at}):null,e.jsxs(me,{children:[e.jsx(ue,{children:e.jsxs("tr",{children:[e.jsx(S,{children:"Provider"}),e.jsx(S,{children:"Type"}),e.jsx(S,{children:"Source"}),e.jsx(S,{children:"API key"}),e.jsx(S,{children:"Status"}),e.jsx(S,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:i?e.jsx(xe,{colSpan:6}):l.length===0?e.jsx(pe,{colSpan:6,children:"No providers yet. Add your first provider to start serving models."}):l.map(r=>{var b,N,$,q,F,O;return e.jsxs(he,{children:[e.jsx(C,{className:"font-medium",children:e.jsx(J,{to:`/models?provider=${encodeURIComponent(r.instance)}`,className:"text-[var(--otari-ink)] hover:text-[var(--otari-brand-dark)] hover:underline",children:r.instance})}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:((b=r.meta)==null?void 0:b.provider_type)??((N=r.stored)==null?void 0:N.provider_type)??r.instance}),e.jsx(C,{children:r.source==="stored"?e.jsx(H,{size:"sm",color:"accent",children:"stored"}):e.jsx(H,{size:"sm",color:"default",children:"config"})}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:r.source==="stored"?r.stored&&!r.stored.decryptable?e.jsx("span",{className:"text-amber-700",title:"This key can't be decrypted with the current OTARI_SECRET_KEY. Replace the key, or restore the original OTARI_SECRET_KEY.",children:"⚠ key unreadable"}):e.jsx("code",{children:($=r.stored)!=null&&$.last4?`••••${r.stored.last4}`:"none set"}):(q=r.meta)!=null&&q.env_key?e.jsxs("span",{children:["via ",e.jsx("code",{children:r.meta.env_key})]}):"config.yml"}),e.jsx(C,{children:e.jsx(_e,{health:y.get(r.instance)})}),e.jsx(C,{children:r.source==="stored"?e.jsxs("div",{className:"flex flex-col items-end gap-1.5",children:[e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx(g,{size:"sm",variant:"outline",isDisabled:((F=j[r.instance])==null?void 0:F.status)==="pending"||((O=r.stored)==null?void 0:O.decryptable)===!1,onPress:()=>E(r.instance),children:"Test"}),e.jsx(g,{size:"sm",variant:"ghost",onPress:()=>{u(!1),c(r.instance)},children:"Edit"}),e.jsx(re,{confirmLabel:"Delete",isPending:o.isPending,onConfirm:()=>o.mutate(r.instance),children:"Delete"})]}),e.jsx(Ce,{state:j[r.instance]})]}):e.jsx("span",{className:"block text-right text-xs text-[var(--otari-muted)]",children:"managed in config.yml"})})]},r.instance)})})]})]})}export{Le as ProvidersPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as x,L as J}from"./react-dgEcD0HR.js";import{J as Q,R as X,B as Z,K as ee,T as se,U as te,V as ae,P as ne,E as R,C as re,W as ie,X as oe,Q as z,h as U,Y as M,Z as le,_ as de,$ as ce}from"./index-hDVMDLdX.js";import{F as _}from"./Field-HzRk1KDP.js";import{T as me,a as ue,b as S,L as xe,c as pe,d as he,e as C}from"./Table-CLdjdyTx.js";import{B as g,f as H,d as A,S as ve,T as ge,L as Y,I as V,D as je,C as I,a as fe,b as be}from"./heroui-BX6JwHY-.js";function K({value:t,onChange:a,label:s,placeholder:n,description:o}){return e.jsxs(ge,{value:t,onChange:a,className:"flex max-w-md flex-col gap-1",children:[e.jsx(Y,{className:"text-sm font-medium text-[var(--otari-ink)]",children:s}),e.jsx(V,{type:"password",placeholder:n??"sk-…",autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",spellCheck:!1,"data-1p-ignore":!0,"data-lpignore":"true"}),o?e.jsx(je,{className:"text-xs text-[var(--otari-muted)]",children:o}):null]})}function W({label:t,value:a,onChange:s,description:n,placeholder:o,extra:d=[],includeCatalog:m=!0}){var y;const v=de(),u=x.useMemo(()=>m?[...d,...(v.data??[]).map(i=>({id:i.id,name:i.name}))]:d,[v.data,d,m]),[p,c]=x.useState(()=>{var i;return((i=u.find(f=>f.id===a))==null?void 0:i.name)??""}),j=((y=u.find(i=>i.id===a))==null?void 0:y.name)??"",h=p.trim()===j.trim()?"":p.trim().toLowerCase(),l=u.filter(i=>!h||i.name.toLowerCase().includes(h)||i.id.toLowerCase().includes(h)).slice(0,50);return e.jsxs(I.Root,{allowsEmptyCollection:!0,menuTrigger:"focus",inputValue:p,onInputChange:c,onSelectionChange:i=>{var f;i!=null?(s(String(i)),c(((f=u.find(k=>k.id===String(i)))==null?void 0:f.name)??"")):(s(""),c(""))},className:"flex max-w-md flex-col gap-1",children:[e.jsx(Y,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsxs(I.InputGroup,{children:[e.jsx(V,{placeholder:o??"Search providers…",autoComplete:"off","data-1p-ignore":!0,"data-lpignore":"true",onFocus:i=>i.currentTarget.select()}),e.jsx(I.Trigger,{})]}),e.jsx(I.Popover,{children:e.jsx(fe,{items:l,className:"max-h-72 overflow-auto",children:i=>e.jsx(be,{id:i.id,textValue:i.name,children:i.name})})}),n?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:n}):null]})}function G({getPayload:t}){const a=ce(),s=t();return e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsx(g,{variant:"outline",isDisabled:s===null||a.isPending,onPress:()=>{s&&a.mutate(s)},children:a.isPending?"Testing…":"Test connection"}),e.jsx("span",{role:"status","aria-live":"polite",children:a.isPending?null:a.error?e.jsx("span",{className:"text-xs text-red-700",children:U(a.error)}):a.data?a.data.ok?e.jsxs("span",{className:"text-xs font-medium text-green-700",children:["Connected. ",a.data.model_count," model",a.data.model_count===1?"":"s"," available."]}):e.jsx("span",{className:"block max-w-md break-words text-xs text-red-700",children:a.data.error??"Connection failed."}):null})]})}function ye({onClose:t}){var w;const a=M(),[s,n]=x.useState(""),[o,d]=x.useState(""),[m,v]=x.useState(!1),[u,p]=x.useState(""),[c,j]=x.useState(""),h=le(s),l=((w=h.data)==null?void 0:w.id)===s?h.data:void 0;x.useEffect(()=>{l&&p(l.default_api_base??"")},[l]);const y=(l==null?void 0:l.env_key_present)??!1,i=((l==null?void 0:l.requires_api_key)??!0)&&!y,f=c.trim()!==""&&c.trim()!==s,k=/[:/]/.test(c),T=s!==""&&!k&&(!i||o.trim()!=="")&&!a.isPending,E=()=>{T&&a.mutate({instance:f?c.trim():s,provider_type:f?s:null,api_base:u.trim()||null,api_key:o.trim()||null},{onSuccess:t})};return e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(R,{error:a.error}),e.jsx(W,{label:"Provider",value:s,onChange:P=>{n(P),j(""),p("")},description:"Its endpoint is built in."}),e.jsx(K,{value:o,onChange:d,label:l&&!i?"API key (optional)":"API key",description:l?i?`${l.name}'s endpoint is built in — just add your key.`:y?`${l.env_key} is set on the server, so a key is optional here. Paste one to override it.`:`${l.name} needs no API key.`:"Stored encrypted. Requires OTARI_SECRET_KEY on the server."}),e.jsx("button",{type:"button",className:"self-start text-xs font-medium text-[var(--otari-brand-dark)]",onClick:()=>v(P=>!P),children:m?"Hide advanced":"Advanced (API base, rename)"}),m?e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"API base",value:u,onChange:p,placeholder:(l==null?void 0:l.default_api_base)??"https://…/v1",description:"Only if you route through a proxy. Blank uses the built-in default."}),e.jsx(_,{label:"Name",value:c,onChange:j,placeholder:s||"instance name",description:k?e.jsx("span",{className:"text-red-700",children:"A name cannot contain “:” or “/”."}):"Rename to run two instances of the same provider."})]}):null,e.jsxs("div",{className:"flex flex-wrap items-start gap-2",children:[e.jsx(g,{variant:"primary",isDisabled:!T,onPress:E,children:a.isPending?"Adding…":"Add provider"}),e.jsx(g,{variant:"ghost",onPress:t,children:"Cancel"}),e.jsx(G,{getPayload:()=>s===""?null:{instance:f?c.trim():s,provider_type:f?s:null,api_base:u.trim()||null,api_key:o.trim()||null}})]})]})}function ke({onClose:t}){const a=M(),[s,n]=x.useState(""),[o,d]=x.useState("openai-compatible"),[m,v]=x.useState(""),[u,p]=x.useState(""),c=/[:/]/.test(s),j=s.trim()!==""&&!c&&m.trim()!==""&&!a.isPending,h=()=>{j&&a.mutate({instance:s.trim(),provider_type:o||"openai-compatible",api_base:m.trim(),api_key:u.trim()||null},{onSuccess:t})};return e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(R,{error:a.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"Name",value:s,onChange:n,placeholder:"my-local-llm",isRequired:!0,autoFocus:!0,description:c?e.jsx("span",{className:"text-red-700",children:"A name cannot contain “:” or “/”."}):"Call it whatever you want."}),e.jsx(W,{label:"Compatible with",value:o,onChange:d,includeCatalog:!1,description:"The API this endpoint speaks.",extra:[{id:"openai-compatible",name:"OpenAI"},{id:"anthropic-compatible",name:"Anthropic"}]})]}),e.jsx(_,{label:"API base",value:m,onChange:v,placeholder:"http://localhost:8000/v1",isRequired:!0,description:"The endpoint URL of your server."}),e.jsx(K,{value:u,onChange:p,label:"API key (optional)",description:"Many local backends need none. Stored encrypted."}),e.jsxs("div",{className:"flex flex-wrap items-start gap-2",children:[e.jsx(g,{variant:"primary",isDisabled:!j,onPress:h,children:a.isPending?"Adding…":"Add provider"}),e.jsx(g,{variant:"ghost",onPress:t,children:"Cancel"}),e.jsx(G,{getPayload:()=>s.trim()===""||m.trim()===""?null:{instance:s.trim(),provider_type:o||"openai-compatible",api_base:m.trim(),api_key:u.trim()||null}})]})]})}function Pe({onClose:t}){const[a,s]=x.useState("known");return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"flex items-center justify-between",children:e.jsx("div",{className:"flex items-center gap-1 rounded-lg bg-[var(--otari-bg)] p-1",children:[["known","Known provider"],["custom","Custom endpoint"]].map(([n,o])=>e.jsx("button",{type:"button","aria-pressed":a===n,onClick:()=>s(n),className:a===n?"rounded-md bg-white px-3 py-1.5 text-sm font-medium text-[var(--otari-ink)] shadow-sm":"rounded-md px-3 py-1.5 text-sm text-[var(--otari-muted)] hover:text-[var(--otari-ink)]",children:o},n))})}),a==="known"?e.jsx(ye,{onClose:t}):e.jsx(ke,{onClose:t})]})})}function Ne({provider:t,onClose:a}){const s=ie(),[n,o]=x.useState(t.provider_type??""),[d,m]=x.useState(t.api_base??""),[v,u]=x.useState(!1),[p,c]=x.useState(""),j=()=>{if(s.isPending)return;const h={provider_type:n.trim()||null,api_base:d.trim()||null,expected_updated_at:t.updated_at};v&&p.trim()&&(h.api_key=p.trim()),s.mutate({instance:t.instance,body:h},{onSuccess:a})};return e.jsx(A,{children:e.jsxs(A.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Edit ",e.jsx("code",{children:t.instance})]}),e.jsx(R,{error:s.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"Provider type",value:n,onChange:o,placeholder:"openai"}),e.jsx(_,{label:"API base",value:d,onChange:m,placeholder:"https://api.openai.com/v1"})]}),e.jsx("div",{className:"flex flex-col gap-2",children:v?e.jsxs(e.Fragment,{children:[e.jsx(K,{value:p,onChange:c,label:"New API key",description:"Stored encrypted. The old key is replaced when you save."}),e.jsx("button",{type:"button",className:"self-start text-xs font-medium text-[var(--otari-brand-dark)]",onClick:()=>{u(!1),c("")},children:"Keep the current key"})]}):e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"text-sm text-[var(--otari-muted)]",children:["API key: ",e.jsx("code",{children:t.last4?`••••${t.last4}`:"none set"})]}),e.jsx(g,{size:"sm",variant:"outline",onPress:()=>u(!0),children:"Replace key"})]})}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(g,{variant:"primary",isDisabled:s.isPending,onPress:j,children:s.isPending?"Saving…":"Save changes"}),e.jsx(g,{variant:"ghost",onPress:a,children:"Cancel"})]})]})})}function Se(t,a){const s=new Map((a??[]).map(d=>[d.instance,d])),n=new Map((t??[]).map(d=>[d.instance,d]));return[...new Set([...s.keys(),...n.keys()])].sort().map(d=>{const m=s.get(d);return{instance:d,source:m?"stored":"config",stored:m,meta:n.get(d)}})}function Ce({state:t}){return t?t.status==="pending"?e.jsxs("span",{className:"inline-flex items-center gap-1.5 text-xs text-[var(--otari-muted)]",children:[e.jsx(ve,{size:"sm"})," Testing…"]}):t.ok?e.jsxs("span",{className:"text-xs font-medium text-green-700",children:["Connected. ",t.model_count," model",t.model_count===1?"":"s"," available."]}):e.jsx("span",{className:"block max-w-xs break-words text-xs text-red-700",children:t.error??"Connection failed."}):null}function _e({health:t}){if(!t)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"—"});const a=t.ok?"border-green-200 bg-green-50 text-green-700":"border-red-200 bg-red-50 text-red-700",s=t.checked_at?`Last checked ${z(t.checked_at)}`:"Not checked yet",n=t.ok?s:`${t.error??"Unreachable"} · ${s}`;return e.jsxs("span",{title:n,className:`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${a}`,children:[e.jsx("span",{"aria-hidden":!0,className:`h-1.5 w-1.5 rounded-full ${t.ok?"bg-green-500":"bg-red-500"}`}),t.ok?"Reachable":"Unreachable"]})}function Ae({healthy:t,total:a,checkedAt:s}){const n=t===a,o=oe();return e.jsxs("div",{className:"flex flex-wrap items-center gap-3 rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] px-4 py-2.5 text-sm",children:[e.jsx("span",{"aria-hidden":!0,className:`h-2 w-2 rounded-full ${n?"bg-green-500":"bg-red-500"}`}),e.jsxs("span",{className:"font-medium text-[var(--otari-ink)]",children:[t," of ",a," provider",a===1?"":"s"," reachable"]}),s?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["Last checked ",z(s)]}):null,e.jsx(g,{size:"sm",variant:"ghost",className:"ml-auto",isDisabled:o.isPending,onPress:()=>o.mutate(),children:o.isPending?"Re-checking…":"Re-check all"})]})}function B({n:t,title:a,children:s}){return e.jsxs("li",{className:"flex gap-3",children:[e.jsx("span",{className:"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--otari-brand-tint)] text-xs font-semibold text-[var(--otari-brand-dark)]",children:t}),e.jsxs("div",{className:"text-sm",children:[e.jsx("div",{className:"font-medium text-[var(--otari-ink)]",children:a}),e.jsx("div",{className:"text-[var(--otari-muted)]",children:s})]})]})}function Te({onAddProvider:t,needsPricing:a,onEnablePricing:s,enabling:n}){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:"Welcome to Otari"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"You are signed in. Add a provider to start serving models: three quick steps."})]}),e.jsxs("ol",{className:"flex flex-col gap-3",children:[e.jsxs(B,{n:1,title:"Add a provider",children:["Enter a provider name (like ",e.jsx("code",{children:"openai"}),") and its API key. Keys are encrypted at rest."]}),e.jsxs(B,{n:2,title:"Test the connection",children:["Use ",e.jsx("strong",{children:"Test"})," on the provider row to confirm the key works and see how many models it serves."]}),e.jsxs(B,{n:3,title:"Send your first request",children:["Point your app at ",e.jsx("code",{children:"/v1"})," on this gateway with the API key printed in the server logs (",e.jsx("code",{children:"gw-…"}),"). See the"," ",e.jsx("a",{href:"/welcome",className:"font-medium text-[var(--otari-brand-dark)]",children:"quickstart"}),"."]})]}),a?e.jsxs("p",{className:"text-sm text-[var(--otari-muted)]",children:["Tip: ",e.jsx("code",{children:"require_pricing"})," is on, so requests are rejected until pricing is set."," ",e.jsx("button",{type:"button",className:"font-medium text-[var(--otari-brand-dark)] disabled:opacity-50",disabled:n,onClick:s,children:"Enable default pricing"})," ","to meter new models with public rates."]}):null,e.jsx("div",{children:e.jsx(g,{variant:"primary",onPress:t,children:"Add your first provider"})})]})})}function Le(){var w,P,L,D;const t=Q(),a=X(),s=Z(),n=ee(),o=se(),d=te(),m=ae(),[v,u]=x.useState(!1),[p,c]=x.useState(null),[j,h]=x.useState({}),l=Se((w=t.data)==null?void 0:w.providers,a.data),y=new Map((((P=n.data)==null?void 0:P.providers)??[]).map(r=>[r.instance,r])),i=t.isLoading||a.isLoading,f=((L=a.data)==null?void 0:L.find(r=>r.instance===p))??null,k=((D=s.data)==null?void 0:D.require_pricing)===!0&&s.data.default_pricing===!1,T=!i&&l.length===0&&!v,E=r=>{h(b=>({...b,[r]:{status:"pending"}})),d.mutate(r,{onSuccess:b=>h(N=>({...N,[r]:{status:"done",...b}})),onError:b=>h(N=>({...N,[r]:{status:"done",ok:!1,model_count:0,error:U(b)}}))})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ne,{title:"Providers",description:"Add provider API keys here to serve models without editing config.yml. Keys are encrypted at rest.",action:v||T?null:e.jsx(g,{variant:"primary",onPress:()=>{c(null),u(!0)},children:"Add provider"})}),e.jsx(R,{error:t.error??a.error??s.error??n.error??m.error??o.error}),T?e.jsx(Te,{onAddProvider:()=>{c(null),u(!0)},needsPricing:k,onEnablePricing:()=>m.mutate({default_pricing:!0}),enabling:m.isPending}):null,v?e.jsx(Pe,{onClose:()=>u(!1)}):null,f?e.jsx(Ne,{provider:f,onClose:()=>c(null)}):null,!i&&l.length>0&&n.data&&n.data.total>0?e.jsx(Ae,{healthy:n.data.healthy,total:n.data.total,checkedAt:n.data.checked_at}):null,e.jsxs(me,{children:[e.jsx(ue,{children:e.jsxs("tr",{children:[e.jsx(S,{children:"Provider"}),e.jsx(S,{children:"Type"}),e.jsx(S,{children:"Source"}),e.jsx(S,{children:"API key"}),e.jsx(S,{children:"Status"}),e.jsx(S,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:i?e.jsx(xe,{colSpan:6}):l.length===0?e.jsx(pe,{colSpan:6,children:"No providers yet. Add your first provider to start serving models."}):l.map(r=>{var b,N,$,q,F,O;return e.jsxs(he,{children:[e.jsx(C,{className:"font-medium",children:e.jsx(J,{to:`/models?provider=${encodeURIComponent(r.instance)}`,className:"text-[var(--otari-ink)] hover:text-[var(--otari-brand-dark)] hover:underline",children:r.instance})}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:((b=r.meta)==null?void 0:b.provider_type)??((N=r.stored)==null?void 0:N.provider_type)??r.instance}),e.jsx(C,{children:r.source==="stored"?e.jsx(H,{size:"sm",color:"accent",children:"stored"}):e.jsx(H,{size:"sm",color:"default",children:"config"})}),e.jsx(C,{className:"text-[var(--otari-muted)]",children:r.source==="stored"?r.stored&&!r.stored.decryptable?e.jsx("span",{className:"text-amber-700",title:"This key can't be decrypted with the current OTARI_SECRET_KEY. Replace the key, or restore the original OTARI_SECRET_KEY.",children:"⚠ key unreadable"}):e.jsx("code",{children:($=r.stored)!=null&&$.last4?`••••${r.stored.last4}`:"none set"}):(q=r.meta)!=null&&q.env_key?e.jsxs("span",{children:["via ",e.jsx("code",{children:r.meta.env_key})]}):"config.yml"}),e.jsx(C,{children:e.jsx(_e,{health:y.get(r.instance)})}),e.jsx(C,{children:r.source==="stored"?e.jsxs("div",{className:"flex flex-col items-end gap-1.5",children:[e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx(g,{size:"sm",variant:"outline",isDisabled:((F=j[r.instance])==null?void 0:F.status)==="pending"||((O=r.stored)==null?void 0:O.decryptable)===!1,onPress:()=>E(r.instance),children:"Test"}),e.jsx(g,{size:"sm",variant:"ghost",onPress:()=>{u(!1),c(r.instance)},children:"Edit"}),e.jsx(re,{confirmLabel:"Delete",isPending:o.isPending,onConfirm:()=>o.mutate(r.instance),children:"Delete"})]}),e.jsx(Ce,{state:j[r.instance]})]}):e.jsx("span",{className:"block text-right text-xs text-[var(--otari-muted)]",children:"managed in config.yml"})})]},r.instance)})})]})]})}export{Le as ProvidersPage}; diff --git a/src/gateway/static/dashboard/assets/SettingsPage-CVx7wSlF.js b/src/gateway/static/dashboard/assets/SettingsPage-CVx7wSlF.js deleted file mode 100644 index e7fa7228..00000000 --- a/src/gateway/static/dashboard/assets/SettingsPage-CVx7wSlF.js +++ /dev/null @@ -1 +0,0 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as u}from"./react-dgEcD0HR.js";import{B as R,V as C,P,E as y,a0 as E,a1 as T,a2 as _,F as D,a3 as A,a4 as O,R as F,a5 as K,I as w}from"./index-Dp4DdBFR.js";import{d as v,B as h,A as d,g as I,I as M}from"./heroui-BX6JwHY-.js";function b(t,r){return{[t]:r}}function z(t,r){let s=0;for(const a of r)if(a===t[s]&&(s+=1),s===t.length)return!0;return t.length===0}function B(t,r){const s=r.trim().toLowerCase();if(s==="")return!0;const a=`${t.key} ${t.description??""} ${t.group}`.toLowerCase(),n=t.key.toLowerCase().replace(/[^a-z0-9]/g,"");return s.split(/\s+/).every(i=>a.includes(i)||z(i,n))}function L({checked:t,onChange:r,label:s,disabled:a}){return e.jsx("button",{type:"button",role:"switch","aria-checked":t,"aria-label":s,disabled:a,onClick:()=>r(!t),className:`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-50 ${t?"bg-[var(--otari-brand)]":"bg-[var(--otari-line)]"}`,children:e.jsx("span",{className:`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${t?"translate-x-5":"translate-x-0.5"}`})})}function $({field:t,onSave:r,disabled:s}){const a=typeof t.value=="number"?t.value:0,[n,i]=u.useState(String(a)),c=t.type==="float";u.useEffect(()=>{i(String(a))},[a]);const o=Number(n),g=n.trim()!==""&&Number.isFinite(o)&&(c||Number.isInteger(o)),m=t.minimum??void 0,p=t.exclusive_minimum??void 0,x=p!==void 0?o>p:m!==void 0?o>=m:o>=0,l=g&&x&&o!==a;return e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(M,{type:"number",min:"0",step:c?"any":"1",inputMode:c?"decimal":"numeric","aria-label":t.key,value:n,disabled:s,onChange:f=>i(f.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 disabled:opacity-50"}),e.jsx(h,{size:"sm",variant:"primary","aria-label":`Save ${t.key}`,isDisabled:s||!l,onPress:()=>r(o),children:"Save"})]})}function H({field:t,onSave:r,disabled:s}){const a=typeof t.value=="string"?t.value:"",[n,i]=u.useState(a);u.useEffect(()=>{i(a)},[a]);const c=n!==a;return e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"text","aria-label":t.key,value:n,disabled:s,placeholder:"unset",onChange:o=>i(o.target.value),className:"w-56 rounded-md border border-[var(--otari-line)] bg-white px-2 py-1 text-sm focus:border-[var(--otari-brand)] focus:outline-none disabled:opacity-50"}),e.jsx(h,{size:"sm",variant:"primary","aria-label":`Save ${t.key}`,isDisabled:s||!c,onPress:()=>r(n.trim()===""?null:n),children:"Save"})]})}function Y(t){const{value:r}=t;return r==null?"unset":typeof r=="boolean"?r?"on":"off":Array.isArray(r)?r.length?r.join(", "):"none":String(r)}function q({field:t,patch:r,disabled:s}){return t.settable?t.type==="bool"?e.jsx(L,{checked:t.value===!0,onChange:a=>r(b(t.key,a)),label:t.key,disabled:s}):t.options&&t.options.length>0?e.jsx(D,{ariaLabel:t.key,value:String(t.value??""),onChange:a=>r(b(t.key,a)),options:t.options.map(a=>({value:a,label:a}))}):t.type==="int"||t.type==="float"?e.jsx($,{field:t,onSave:a=>r(b(t.key,a)),disabled:s}):e.jsx(H,{field:t,onSave:a=>r(b(t.key,a)),disabled:s}):e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"text-sm tabular-nums text-[var(--otari-ink)]",children:Y(t)}),e.jsx("span",{className:"rounded-full border border-[var(--otari-line)] px-2 py-0.5 text-xs text-[var(--otari-muted)]",children:"startup-only"})]})}function V({field:t,patch:r,disabled:s}){return e.jsxs("div",{className:"flex items-start justify-between gap-6 py-4",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:t.key}),t.description?e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:t.description}):null]}),e.jsx("div",{className:"shrink-0 pt-0.5",children:e.jsx(q,{field:t,patch:r,disabled:s})})]})}function U({value:t,fieldRef:r}){const s=u.useRef(null),a=r??s,[n,i]=u.useState(!1),[c,o]=u.useState(!1),g=async()=>{var m,p,x;(m=a.current)==null||m.focus(),(p=a.current)==null||p.select();try{if((x=navigator.clipboard)!=null&&x.writeText){await navigator.clipboard.writeText(t),i(!0),o(!1),window.setTimeout(()=>i(!1),2e3);return}}catch{}o(!0)};return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:"New master key"}),e.jsx(h,{size:"sm",variant:"outline",onPress:g,children:n?"Copied":"Copy"})]}),e.jsx("input",{ref:a,readOnly:!0,value:t,onFocus:m=>m.currentTarget.select(),autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",spellCheck:!1,"data-1p-ignore":!0,"data-lpignore":"true"}),e.jsx("span",{"aria-live":"polite",className:"text-xs text-[var(--otari-brand-dark)]",children:n?"Copied to clipboard.":""}),c?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Selected. Press Ctrl/Cmd-C to copy."}):null]})}function G({masterKey:t,error:r,isPending:s,onRegenerate:a,onClose:n}){const i=u.useRef(null);return u.useEffect(()=>{var c,o;t!==void 0&&((c=i.current)==null||c.focus(),(o=i.current)==null||o.select())},[t]),e.jsx(d.Backdrop,{children:e.jsx(d.Container,{placement:"center",size:"lg",children:e.jsxs(d.Dialog,{children:[e.jsx(d.Header,{children:e.jsx(d.Heading,{children:t!==void 0?"Master key regenerated":"Regenerate master key?"})}),e.jsx(d.Body,{className:"flex flex-col gap-4",children:t!==void 0?e.jsxs(e.Fragment,{children:[e.jsx(w,{tone:"warning",children:"Copy this key now. It is shown once and cannot be retrieved again after you close this dialog."}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:"The previous master key has stopped working. This browser tab now uses the new key."}),e.jsx(U,{value:t,fieldRef:i})]}):e.jsxs(e.Fragment,{children:[e.jsx(w,{tone:"warning",children:"This immediately invalidates the current dashboard master key. Other signed-in dashboard sessions will need the new key to continue."}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:"The replacement key will be shown once. Save it before closing the next screen."}),e.jsx(y,{error:r})]})}),e.jsx(d.Footer,{children:t!==void 0?e.jsx(h,{variant:"primary",onPress:n,children:"I’ve saved this key"}):e.jsxs(e.Fragment,{children:[e.jsx(h,{variant:"ghost",isDisabled:s,onPress:n,children:"Cancel"}),e.jsx(h,{variant:"danger",isPending:s,onPress:a,children:"Regenerate key"})]})})]})})})}function X({source:t}){const r=A(),{replaceMasterKey:s}=O(),[a,n]=u.useState(!1),[i,c]=u.useState(),o=t==="generated",g=()=>r.mutate(void 0,{onSuccess:x=>{c(x.master_key),s(x.master_key)}}),m=()=>{n(!1),c(void 0),r.reset()},p=x=>{x?(r.reset(),n(!0)):i===void 0&&m()};return e.jsx("div",{className:"flex flex-col gap-4 py-4",children:e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"master_key"}),e.jsx("p",{className:"mt-1 max-w-3xl text-sm text-[var(--otari-muted)]",children:o?"This gateway uses its first-run generated dashboard key. Regeneration invalidates the current key immediately.":"This gateway uses a key managed through OTARI_MASTER_KEY or config.yml. Rotate it in configuration, then restart the gateway."})]}),e.jsxs(d,{isOpen:a,onOpenChange:p,children:[o?e.jsx(d.Trigger,{className:I({size:"sm",variant:"danger-soft"}),children:"Regenerate"}):e.jsx(h,{size:"sm",variant:"danger-soft",isDisabled:!0,children:"Managed in configuration"}),a?e.jsx(G,{masterKey:i,error:r.error,isPending:r.isPending,onRegenerate:g,onClose:m}):null]})]})})}function J(){var c;const t=F(),r=K(),s=r.data,a=((c=t.data)==null?void 0:c.length)??0,n=(t.data??[]).filter(o=>!o.decryptable).length,i=a>0;return e.jsxs("div",{className:"flex flex-col gap-4 py-4",children:[e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"OTARI_SECRET_KEY"}),e.jsxs("p",{className:"mt-1 max-w-3xl text-sm text-[var(--otari-muted)]",children:["Generate a new key with ",e.jsx("code",{children:"uv run otari gen-secret-key"}),", then restart with"," ",e.jsx("code",{children:"OTARI_SECRET_KEY=,"}),". Re-encrypt the stored provider keys, then restart with ",e.jsx("code",{children:"OTARI_SECRET_KEY="})," once none are unreadable."]})]}),e.jsx("div",{className:"shrink-0",children:e.jsx(h,{size:"sm",variant:"outline",isDisabled:!i||r.isPending,onPress:()=>r.mutate(),children:r.isPending?"Re-encrypting…":"Re-encrypt provider keys"})})]}),e.jsx(y,{error:t.error??r.error}),n>0?e.jsxs(w,{tone:"warning",children:[n," stored provider key",n===1?"":"s"," cannot be decrypted with the current"," ",e.jsx("code",{children:"OTARI_SECRET_KEY"}),". Restore the old secret key and re-encrypt, or edit each affected provider and replace its key."]}):null,s?e.jsxs("p",{className:"text-sm text-[var(--otari-muted)]",role:"status","aria-live":"polite",children:["Re-encrypted ",s.reencrypted," provider key",s.reencrypted===1?"":"s",".",s.unreadable>0?` ${s.unreadable} still need replacement.`:" All decryptable stored keys now use the primary secret key."]}):!t.isLoading&&!i?e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:"No stored provider keys need re-encryption."}):null]})}function Q({masterKeySource:t}){return e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsxs("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Credential security ",e.jsx("span",{className:"font-normal text-[var(--otari-muted)]",children:"(2)"})]}),e.jsx(v,{children:e.jsxs(v.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:[e.jsx(X,{source:t}),e.jsx(J,{})]})})]})}function W({preview:t,error:r,isPending:s,onAccept:a,onReject:n}){return e.jsx(d.Backdrop,{children:e.jsx(d.Container,{placement:"center",size:"lg",children:e.jsxs(d.Dialog,{children:[e.jsx(d.Header,{children:e.jsx(d.Heading,{children:"Review default price updates"})}),e.jsxs(d.Body,{className:"flex flex-col gap-4",children:[e.jsxs("p",{className:"text-sm text-[var(--otari-muted)]",children:[t.added_count," added, ",t.changed_count," changed, and ",t.removed_count," removed upstream model prices. The accepted catalog is saved in the database with source ",e.jsx("code",{children:"genai-prices"})," and reloads after a restart. Your ",t.protected_model_count," custom model price",t.protected_model_count===1?"":"s"," remain unchanged."]}),t.changes.length>0?e.jsx("ul",{className:"max-h-60 list-disc overflow-auto pl-5 text-sm text-[var(--otari-ink)]",children:t.changes.map(i=>e.jsxs("li",{children:[i.model_key,": ",i.change]},i.model_key))}):null,t.changes_truncated?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Only the first 100 changes are shown."}):null,e.jsx(y,{error:r})]}),e.jsxs(d.Footer,{children:[e.jsx(h,{variant:"ghost",isDisabled:s,onPress:n,children:"Reject changes"}),e.jsx(h,{variant:"primary",isPending:s,onPress:a,children:"Accept price updates"})]})]})})})}function Z(){const t=E(),r=T(),s=_(),a=t.data,n=r.isPending||s.isPending,i=()=>{a===void 0||n||s.mutate(void 0,{onSuccess:t.reset})};return e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Default pricing catalog"}),e.jsx(v,{children:e.jsxs(v.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("div",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"genai-prices defaults"}),e.jsxs("p",{className:"mt-1 max-w-3xl text-sm text-[var(--otari-muted)]",children:["Fetch the latest upstream catalog, review the proposed change summary, then accept or reject it. Accepted data is stored as ",e.jsx("code",{children:"genai-prices"}),"; custom prices remain separate and always take precedence."]})]}),e.jsx(h,{size:"sm",variant:"outline",isDisabled:t.isPending||n,onPress:()=>t.mutate(),children:t.isPending?"Checking prices…":"Check for price updates"})]}),e.jsx(y,{error:t.error})]})}),e.jsxs(d,{isOpen:a!==void 0,onOpenChange:c=>c?void 0:i(),children:[e.jsx(d.Trigger,{className:"hidden",children:"Review price updates"}),a?e.jsx(W,{preview:a,error:r.error??s.error,isPending:n,onAccept:()=>r.mutate(void 0,{onSuccess:t.reset}),onReject:i}):null]})]})}function ee(t){const r=[],s=new Map;for(const a of t){let n=s.get(a.group);n||(n={name:a.group,fields:[]},s.set(a.group,n),r.push(n)),n.fields.push(a)}return r}function ne(){const t=R(),r=C(),s=t.data,a=r.isPending,[n,i]=u.useState(""),[c,o]=u.useState(!1),g=u.useRef(null);u.useEffect(()=>{function l(f){var N;const j=f.target,S=j&&(j.tagName==="INPUT"||j.tagName==="TEXTAREA"||j.tagName==="SELECT");f.key==="/"&&!S&&(f.preventDefault(),(N=g.current)==null||N.focus())}return window.addEventListener("keydown",l),()=>window.removeEventListener("keydown",l)},[]);const m=l=>r.mutate(l),p=(s==null?void 0:s.config)??[],x=p.filter(l=>(c?l.settable:!0)&&B(l,n)),k=ee(x);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(P,{title:"Settings",description:"Every effective gateway setting. Settable fields apply immediately and persist across restarts; startup-only fields are shown for reference and change only via config.yml or environment variables (then a restart)."}),e.jsx(y,{error:t.error??r.error}),e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("input",{ref:g,type:"search","aria-label":"Search settings",placeholder:"Search settings (press / to focus)…",value:n,onChange:l=>i(l.target.value),onKeyDown:l=>{l.key==="Escape"&&i("")},className:"min-w-0 flex-1 rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 text-sm text-[var(--otari-ink)] focus:border-[var(--otari-brand)] focus:outline-none"}),e.jsxs("label",{className:"flex items-center gap-2 text-sm text-[var(--otari-muted)]",children:[e.jsx("input",{type:"checkbox",checked:c,onChange:l=>o(l.target.checked),className:"h-4 w-4 accent-[var(--otari-brand)]"}),"Settable only"]})]}),s?e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Showing ",x.length," of ",p.length," settings"]}):null,s&&x.length===0?e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:"No settings match your search."}):null,k.map(l=>e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsxs("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:[l.name," ",e.jsxs("span",{className:"font-normal text-[var(--otari-muted)]",children:["(",l.fields.length,")"]})]}),e.jsx(v,{children:e.jsx(v.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:l.fields.map(f=>e.jsx(V,{field:f,patch:m,disabled:!s||a},f.key))})})]},l.name)),s?e.jsx(Z,{}):null,s?e.jsx(Q,{masterKeySource:s.master_key_source}):null,s?e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Mode: ",s.mode," · Version ",s.version,s.require_pricing?" · require_pricing on":""]}):null]})}export{ne as SettingsPage,B as fieldMatches}; diff --git a/src/gateway/static/dashboard/assets/SettingsPage-iRP-TsYX.js b/src/gateway/static/dashboard/assets/SettingsPage-iRP-TsYX.js new file mode 100644 index 00000000..51709cea --- /dev/null +++ b/src/gateway/static/dashboard/assets/SettingsPage-iRP-TsYX.js @@ -0,0 +1 @@ +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as u}from"./react-dgEcD0HR.js";import{B as R,V as C,P,E as y,a0 as E,a1 as T,a2 as _,F as D,a3 as A,R as O,a4 as F,I as w}from"./index-hDVMDLdX.js";import{d as v,B as h,A as d,g as I,I as K}from"./heroui-BX6JwHY-.js";function b(t,r){return{[t]:r}}function z(t,r){let s=0;for(const a of r)if(a===t[s]&&(s+=1),s===t.length)return!0;return t.length===0}function M(t,r){const s=r.trim().toLowerCase();if(s==="")return!0;const a=`${t.key} ${t.description??""} ${t.group}`.toLowerCase(),n=t.key.toLowerCase().replace(/[^a-z0-9]/g,"");return s.split(/\s+/).every(i=>a.includes(i)||z(i,n))}function B({checked:t,onChange:r,label:s,disabled:a}){return e.jsx("button",{type:"button",role:"switch","aria-checked":t,"aria-label":s,disabled:a,onClick:()=>r(!t),className:`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-50 ${t?"bg-[var(--otari-brand)]":"bg-[var(--otari-line)]"}`,children:e.jsx("span",{className:`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${t?"translate-x-5":"translate-x-0.5"}`})})}function L({field:t,onSave:r,disabled:s}){const a=typeof t.value=="number"?t.value:0,[n,i]=u.useState(String(a)),o=t.type==="float";u.useEffect(()=>{i(String(a))},[a]);const c=Number(n),p=n.trim()!==""&&Number.isFinite(c)&&(o||Number.isInteger(c)),m=t.minimum??void 0,x=t.exclusive_minimum??void 0,g=x!==void 0?c>x:m!==void 0?c>=m:c>=0,l=p&&g&&c!==a;return e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(K,{type:"number",min:"0",step:o?"any":"1",inputMode:o?"decimal":"numeric","aria-label":t.key,value:n,disabled:s,onChange:f=>i(f.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 disabled:opacity-50"}),e.jsx(h,{size:"sm",variant:"primary","aria-label":`Save ${t.key}`,isDisabled:s||!l,onPress:()=>r(c),children:"Save"})]})}function $({field:t,onSave:r,disabled:s}){const a=typeof t.value=="string"?t.value:"",[n,i]=u.useState(a);u.useEffect(()=>{i(a)},[a]);const o=n!==a;return e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"text","aria-label":t.key,value:n,disabled:s,placeholder:"unset",onChange:c=>i(c.target.value),className:"w-56 rounded-md border border-[var(--otari-line)] bg-white px-2 py-1 text-sm focus:border-[var(--otari-brand)] focus:outline-none disabled:opacity-50"}),e.jsx(h,{size:"sm",variant:"primary","aria-label":`Save ${t.key}`,isDisabled:s||!o,onPress:()=>r(n.trim()===""?null:n),children:"Save"})]})}function H(t){const{value:r}=t;return r==null?"unset":typeof r=="boolean"?r?"on":"off":Array.isArray(r)?r.length?r.join(", "):"none":String(r)}function Y({field:t,patch:r,disabled:s}){return t.settable?t.type==="bool"?e.jsx(B,{checked:t.value===!0,onChange:a=>r(b(t.key,a)),label:t.key,disabled:s}):t.options&&t.options.length>0?e.jsx(D,{ariaLabel:t.key,value:String(t.value??""),onChange:a=>r(b(t.key,a)),options:t.options.map(a=>({value:a,label:a}))}):t.type==="int"||t.type==="float"?e.jsx(L,{field:t,onSave:a=>r(b(t.key,a)),disabled:s}):e.jsx($,{field:t,onSave:a=>r(b(t.key,a)),disabled:s}):e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"text-sm tabular-nums text-[var(--otari-ink)]",children:H(t)}),e.jsx("span",{className:"rounded-full border border-[var(--otari-line)] px-2 py-0.5 text-xs text-[var(--otari-muted)]",children:"startup-only"})]})}function q({field:t,patch:r,disabled:s}){return e.jsxs("div",{className:"flex items-start justify-between gap-6 py-4",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:t.key}),t.description?e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:t.description}):null]}),e.jsx("div",{className:"shrink-0 pt-0.5",children:e.jsx(Y,{field:t,patch:r,disabled:s})})]})}function V({value:t,fieldRef:r}){const s=u.useRef(null),a=r??s,[n,i]=u.useState(!1),[o,c]=u.useState(!1),p=async()=>{var m,x,g;(m=a.current)==null||m.focus(),(x=a.current)==null||x.select();try{if((g=navigator.clipboard)!=null&&g.writeText){await navigator.clipboard.writeText(t),i(!0),c(!1),window.setTimeout(()=>i(!1),2e3);return}}catch{}c(!0)};return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:"New master key"}),e.jsx(h,{size:"sm",variant:"outline",onPress:p,children:n?"Copied":"Copy"})]}),e.jsx("input",{ref:a,readOnly:!0,value:t,onFocus:m=>m.currentTarget.select(),autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",spellCheck:!1,"data-1p-ignore":!0,"data-lpignore":"true"}),e.jsx("span",{"aria-live":"polite",className:"text-xs text-[var(--otari-brand-dark)]",children:n?"Copied to clipboard.":""}),o?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Selected. Press Ctrl/Cmd-C to copy."}):null]})}function U({masterKey:t,error:r,isPending:s,onRegenerate:a,onClose:n}){const i=u.useRef(null);return u.useEffect(()=>{var o,c;t!==void 0&&((o=i.current)==null||o.focus(),(c=i.current)==null||c.select())},[t]),e.jsx(d.Backdrop,{children:e.jsx(d.Container,{placement:"center",size:"lg",children:e.jsxs(d.Dialog,{children:[e.jsx(d.Header,{children:e.jsx(d.Heading,{children:t!==void 0?"Master key regenerated":"Regenerate master key?"})}),e.jsx(d.Body,{className:"flex flex-col gap-4",children:t!==void 0?e.jsxs(e.Fragment,{children:[e.jsx(w,{tone:"warning",children:"Copy this key now. It is shown once and cannot be retrieved again after you close this dialog."}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:"The previous master key has stopped working. This browser tab now uses the new key."}),e.jsx(V,{value:t,fieldRef:i})]}):e.jsxs(e.Fragment,{children:[e.jsx(w,{tone:"warning",children:"This immediately invalidates the current dashboard master key. Other signed-in dashboard sessions will need the new key to continue."}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:"The replacement key will be shown once. Save it before closing the next screen."}),e.jsx(y,{error:r})]})}),e.jsx(d.Footer,{children:t!==void 0?e.jsx(h,{variant:"primary",onPress:n,children:"I’ve saved this key"}):e.jsxs(e.Fragment,{children:[e.jsx(h,{variant:"ghost",isDisabled:s,onPress:n,children:"Cancel"}),e.jsx(h,{variant:"danger",isPending:s,onPress:a,children:"Regenerate key"})]})})]})})})}function G({source:t}){const r=A(),[s,a]=u.useState(!1),[n,i]=u.useState(),o=t==="generated",c=()=>r.mutate(void 0,{onSuccess:x=>{i(x.master_key)}}),p=()=>{a(!1),i(void 0),r.reset()},m=x=>{x?(r.reset(),a(!0)):n===void 0&&p()};return e.jsx("div",{className:"flex flex-col gap-4 py-4",children:e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"master_key"}),e.jsx("p",{className:"mt-1 max-w-3xl text-sm text-[var(--otari-muted)]",children:o?"This gateway uses its first-run generated dashboard key. Regeneration invalidates the current key immediately.":"This gateway uses a key managed through OTARI_MASTER_KEY or config.yml. Rotate it in configuration, then restart the gateway."})]}),e.jsxs(d,{isOpen:s,onOpenChange:m,children:[o?e.jsx(d.Trigger,{className:I({size:"sm",variant:"danger-soft"}),children:"Regenerate"}):e.jsx(h,{size:"sm",variant:"danger-soft",isDisabled:!0,children:"Managed in configuration"}),s?e.jsx(U,{masterKey:n,error:r.error,isPending:r.isPending,onRegenerate:c,onClose:p}):null]})]})})}function X(){var o;const t=O(),r=F(),s=r.data,a=((o=t.data)==null?void 0:o.length)??0,n=(t.data??[]).filter(c=>!c.decryptable).length,i=a>0;return e.jsxs("div",{className:"flex flex-col gap-4 py-4",children:[e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"OTARI_SECRET_KEY"}),e.jsxs("p",{className:"mt-1 max-w-3xl text-sm text-[var(--otari-muted)]",children:["Generate a new key with ",e.jsx("code",{children:"uv run otari gen-secret-key"}),", then restart with"," ",e.jsx("code",{children:"OTARI_SECRET_KEY=,"}),". Re-encrypt the stored provider keys, then restart with ",e.jsx("code",{children:"OTARI_SECRET_KEY="})," once none are unreadable."]})]}),e.jsx("div",{className:"shrink-0",children:e.jsx(h,{size:"sm",variant:"outline",isDisabled:!i||r.isPending,onPress:()=>r.mutate(),children:r.isPending?"Re-encrypting…":"Re-encrypt provider keys"})})]}),e.jsx(y,{error:t.error??r.error}),n>0?e.jsxs(w,{tone:"warning",children:[n," stored provider key",n===1?"":"s"," cannot be decrypted with the current"," ",e.jsx("code",{children:"OTARI_SECRET_KEY"}),". Restore the old secret key and re-encrypt, or edit each affected provider and replace its key."]}):null,s?e.jsxs("p",{className:"text-sm text-[var(--otari-muted)]",role:"status","aria-live":"polite",children:["Re-encrypted ",s.reencrypted," provider key",s.reencrypted===1?"":"s",".",s.unreadable>0?` ${s.unreadable} still need replacement.`:" All decryptable stored keys now use the primary secret key."]}):!t.isLoading&&!i?e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:"No stored provider keys need re-encryption."}):null]})}function J({masterKeySource:t}){return e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsxs("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Credential security ",e.jsx("span",{className:"font-normal text-[var(--otari-muted)]",children:"(2)"})]}),e.jsx(v,{children:e.jsxs(v.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:[e.jsx(G,{source:t}),e.jsx(X,{})]})})]})}function Q({preview:t,error:r,isPending:s,onAccept:a,onReject:n}){return e.jsx(d.Backdrop,{children:e.jsx(d.Container,{placement:"center",size:"lg",children:e.jsxs(d.Dialog,{children:[e.jsx(d.Header,{children:e.jsx(d.Heading,{children:"Review default price updates"})}),e.jsxs(d.Body,{className:"flex flex-col gap-4",children:[e.jsxs("p",{className:"text-sm text-[var(--otari-muted)]",children:[t.added_count," added, ",t.changed_count," changed, and ",t.removed_count," removed upstream model prices. The accepted catalog is saved in the database with source ",e.jsx("code",{children:"genai-prices"})," and reloads after a restart. Your ",t.protected_model_count," custom model price",t.protected_model_count===1?"":"s"," remain unchanged."]}),t.changes.length>0?e.jsx("ul",{className:"max-h-60 list-disc overflow-auto pl-5 text-sm text-[var(--otari-ink)]",children:t.changes.map(i=>e.jsxs("li",{children:[i.model_key,": ",i.change]},i.model_key))}):null,t.changes_truncated?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Only the first 100 changes are shown."}):null,e.jsx(y,{error:r})]}),e.jsxs(d.Footer,{children:[e.jsx(h,{variant:"ghost",isDisabled:s,onPress:n,children:"Reject changes"}),e.jsx(h,{variant:"primary",isPending:s,onPress:a,children:"Accept price updates"})]})]})})})}function W(){const t=E(),r=T(),s=_(),a=t.data,n=r.isPending||s.isPending,i=()=>{a===void 0||n||s.mutate(void 0,{onSuccess:t.reset})};return e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Default pricing catalog"}),e.jsx(v,{children:e.jsxs(v.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"flex flex-wrap items-start justify-between gap-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("div",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"genai-prices defaults"}),e.jsxs("p",{className:"mt-1 max-w-3xl text-sm text-[var(--otari-muted)]",children:["Fetch the latest upstream catalog, review the proposed change summary, then accept or reject it. Accepted data is stored as ",e.jsx("code",{children:"genai-prices"}),"; custom prices remain separate and always take precedence."]})]}),e.jsx(h,{size:"sm",variant:"outline",isDisabled:t.isPending||n,onPress:()=>t.mutate(),children:t.isPending?"Checking prices…":"Check for price updates"})]}),e.jsx(y,{error:t.error})]})}),e.jsxs(d,{isOpen:a!==void 0,onOpenChange:o=>o?void 0:i(),children:[e.jsx(d.Trigger,{className:"hidden",children:"Review price updates"}),a?e.jsx(Q,{preview:a,error:r.error??s.error,isPending:n,onAccept:()=>r.mutate(void 0,{onSuccess:t.reset}),onReject:i}):null]})]})}function Z(t){const r=[],s=new Map;for(const a of t){let n=s.get(a.group);n||(n={name:a.group,fields:[]},s.set(a.group,n),r.push(n)),n.fields.push(a)}return r}function ae(){const t=R(),r=C(),s=t.data,a=r.isPending,[n,i]=u.useState(""),[o,c]=u.useState(!1),p=u.useRef(null);u.useEffect(()=>{function l(f){var N;const j=f.target,S=j&&(j.tagName==="INPUT"||j.tagName==="TEXTAREA"||j.tagName==="SELECT");f.key==="/"&&!S&&(f.preventDefault(),(N=p.current)==null||N.focus())}return window.addEventListener("keydown",l),()=>window.removeEventListener("keydown",l)},[]);const m=l=>r.mutate(l),x=(s==null?void 0:s.config)??[],g=x.filter(l=>(o?l.settable:!0)&&M(l,n)),k=Z(g);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(P,{title:"Settings",description:"Every effective gateway setting. Settable fields apply immediately and persist across restarts; startup-only fields are shown for reference and change only via config.yml or environment variables (then a restart)."}),e.jsx(y,{error:t.error??r.error}),e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("input",{ref:p,type:"search","aria-label":"Search settings",placeholder:"Search settings (press / to focus)…",value:n,onChange:l=>i(l.target.value),onKeyDown:l=>{l.key==="Escape"&&i("")},className:"min-w-0 flex-1 rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 text-sm text-[var(--otari-ink)] focus:border-[var(--otari-brand)] focus:outline-none"}),e.jsxs("label",{className:"flex items-center gap-2 text-sm text-[var(--otari-muted)]",children:[e.jsx("input",{type:"checkbox",checked:o,onChange:l=>c(l.target.checked),className:"h-4 w-4 accent-[var(--otari-brand)]"}),"Settable only"]})]}),s?e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Showing ",g.length," of ",x.length," settings"]}):null,s&&g.length===0?e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:"No settings match your search."}):null,k.map(l=>e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsxs("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:[l.name," ",e.jsxs("span",{className:"font-normal text-[var(--otari-muted)]",children:["(",l.fields.length,")"]})]}),e.jsx(v,{children:e.jsx(v.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:l.fields.map(f=>e.jsx(q,{field:f,patch:m,disabled:!s||a},f.key))})})]},l.name)),s?e.jsx(W,{}):null,s?e.jsx(J,{masterKeySource:s.master_key_source}):null,s?e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Mode: ",s.mode," · Version ",s.version,s.require_pricing?" · require_pricing on":""]}):null]})}export{ae as SettingsPage,M as fieldMatches}; diff --git a/src/gateway/static/dashboard/assets/ToolsGuardrailsPage-v1VWfWQq.js b/src/gateway/static/dashboard/assets/ToolsGuardrailsPage-B3QvV7KI.js similarity index 98% rename from src/gateway/static/dashboard/assets/ToolsGuardrailsPage-v1VWfWQq.js rename to src/gateway/static/dashboard/assets/ToolsGuardrailsPage-B3QvV7KI.js index 3c73f40a..2e95bdf7 100644 --- a/src/gateway/static/dashboard/assets/ToolsGuardrailsPage-v1VWfWQq.js +++ b/src/gateway/static/dashboard/assets/ToolsGuardrailsPage-B3QvV7KI.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as d}from"./react-dgEcD0HR.js";import{a6 as C,a7 as L,P as E,E as P,h as w,a8 as R,F as D}from"./index-Dp4DdBFR.js";import{d as N,B as b}from"./heroui-BX6JwHY-.js";function B(s,t){return{[s]:t}}const M=[{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 $(){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 U({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 S="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",g="grid gap-x-4 gap-y-1.5 py-4 sm:grid-cols-[minmax(0,1fr)_16rem_10rem] sm:items-start",_=`w-full sm:col-start-2 ${S}`,k="flex items-center gap-2 sm:col-start-3",f="flex flex-col gap-1 sm:col-span-2 sm:col-start-2";function j({message:s}){return s?e.jsx("span",{className:"break-words text-xs text-red-700",children:s}):null}function y({field:s,help:t}){return e.jsxs("div",{className:"min-w-0 sm:col-start-1",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 z({field:s,onSave:t,saveError:a,disabled:n}){const r=typeof s.value=="string"?s.value:"",[i,c]=d.useState(r),o=R();d.useEffect(()=>{c(r)},[r]);const m=i.trim()!==r,x=i.trim();return e.jsxs("div",{className:g,children:[e.jsx(y,{field:s,help:"Leave blank and Save to fall back to the configured default."}),e.jsx("input",{type:"text",inputMode:"url","aria-label":s.key,value:i,disabled:n,placeholder:"unset",onChange:l=>{c(l.target.value),o.reset()},className:_}),e.jsxs("div",{className:k,children:[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===""||o.isPending,onPress:()=>o.mutate({service:s.service,url:x}),children:o.isPending?"Testing…":"Test"})]}),e.jsxs("div",{className:f,children:[e.jsx("span",{role:"status","aria-live":"polite",className:"block break-words text-xs",children:o.isPending?null:o.error?e.jsx("span",{className:"text-red-700",children:w(o.error)}):o.data?e.jsx("span",{className:o.data.ok?"font-medium text-green-700":"text-red-700",children:o.data.reason}):null}),e.jsx(j,{message:a})]})]})}function G({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 o=i!==r;return e.jsxs("div",{className:g,children:[e.jsx(y,{field:s}),e.jsx("input",{type:"text","aria-label":s.key,value:i,disabled:n,placeholder:"default",onChange:m=>c(m.target.value),className:_}),e.jsx("div",{className:k,children:e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!o,onPress:()=>t(i.trim()===""?null:i.trim()),children:"Save"})}),a?e.jsx("div",{className:f,children:e.jsx(j,{message:a})}):null]})}function I({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 o=i.trim(),m=Number(o),l=(o===""||Number.isInteger(m)&&m>=1)&&o!==r;return e.jsxs("div",{className:g,children:[e.jsx(y,{field:s,help:"Leave blank to use the backend default."}),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-full text-right tabular-nums sm:col-start-2 sm:w-28 sm:justify-self-end ${S}`}),e.jsx("div",{className:k,children:e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!l,onPress:()=>t(o===""?null:m),children:"Save"})}),a?e.jsx("div",{className:f,children:e.jsx(j,{message:a})}):null]})}function A({field:s,onSave:t,saveError:a,disabled:n}){const r=s.value===!0?"on":s.value===!1?"off":"default";return e.jsxs("div",{className:g,children:[e.jsx(y,{field:s}),e.jsx("div",{className:"sm:col-start-2 sm:justify-self-start",children:e.jsx(D,{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("div",{className:f,children:e.jsx(j,{message:a})}):null]})}function F({field:s,onSave:t,saveError:a,disabled:n}){return s.type==="url"?e.jsx(z,{field:s,onSave:t,saveError:a,disabled:n}):s.type==="int"?e.jsx(I,{field:s,onSave:t,saveError:a,disabled:n}):s.type==="bool"?e.jsx(A,{field:s,onSave:t,saveError:a,disabled:n}):e.jsx(G,{field:s,onSave:t,saveError:a,disabled:n})}function K(){const s=C(),t=L(),[a,n]=$(),[r,i]=d.useState({}),c=s.data,o=!c||t.isPending,m=new Map(((c==null?void 0:c.fields)??[]).map(l=>[l.key,l])),x=(l,p)=>{i(h=>{const{[l.key]:v,...u}=h;return u}),t.mutate(B(l.key,p),{onSuccess:()=>n(`${l.key} saved`),onError:h=>i(v=>({...v,[l.key]:w(h)}))})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(E,{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(P,{error:s.error}),M.map(l=>{const p=l.order.map(u=>m.get(u)).filter(u=>u!==void 0),h=((c==null?void 0:c.fields)??[]).filter(u=>u.service===l.key&&!l.order.includes(u.key)),v=[...p,...h];return v.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:l.label}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:l.blurb}),e.jsx(N,{children:e.jsx(N.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:v.map(u=>e.jsx(F,{field:u,onSave:T=>x(u,T),saveError:r[u.key],disabled:o},u.key))})})]},l.key)}),e.jsx(U,{message:a})]})}export{K as ToolsGuardrailsPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as d}from"./react-dgEcD0HR.js";import{a5 as C,a6 as L,P as E,E as P,h as w,a7 as R,F as D}from"./index-hDVMDLdX.js";import{d as N,B as b}from"./heroui-BX6JwHY-.js";function B(s,t){return{[s]:t}}const M=[{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 $(){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 U({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 S="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",g="grid gap-x-4 gap-y-1.5 py-4 sm:grid-cols-[minmax(0,1fr)_16rem_10rem] sm:items-start",_=`w-full sm:col-start-2 ${S}`,k="flex items-center gap-2 sm:col-start-3",f="flex flex-col gap-1 sm:col-span-2 sm:col-start-2";function j({message:s}){return s?e.jsx("span",{className:"break-words text-xs text-red-700",children:s}):null}function y({field:s,help:t}){return e.jsxs("div",{className:"min-w-0 sm:col-start-1",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 z({field:s,onSave:t,saveError:a,disabled:n}){const r=typeof s.value=="string"?s.value:"",[i,c]=d.useState(r),o=R();d.useEffect(()=>{c(r)},[r]);const m=i.trim()!==r,x=i.trim();return e.jsxs("div",{className:g,children:[e.jsx(y,{field:s,help:"Leave blank and Save to fall back to the configured default."}),e.jsx("input",{type:"text",inputMode:"url","aria-label":s.key,value:i,disabled:n,placeholder:"unset",onChange:l=>{c(l.target.value),o.reset()},className:_}),e.jsxs("div",{className:k,children:[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===""||o.isPending,onPress:()=>o.mutate({service:s.service,url:x}),children:o.isPending?"Testing…":"Test"})]}),e.jsxs("div",{className:f,children:[e.jsx("span",{role:"status","aria-live":"polite",className:"block break-words text-xs",children:o.isPending?null:o.error?e.jsx("span",{className:"text-red-700",children:w(o.error)}):o.data?e.jsx("span",{className:o.data.ok?"font-medium text-green-700":"text-red-700",children:o.data.reason}):null}),e.jsx(j,{message:a})]})]})}function G({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 o=i!==r;return e.jsxs("div",{className:g,children:[e.jsx(y,{field:s}),e.jsx("input",{type:"text","aria-label":s.key,value:i,disabled:n,placeholder:"default",onChange:m=>c(m.target.value),className:_}),e.jsx("div",{className:k,children:e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!o,onPress:()=>t(i.trim()===""?null:i.trim()),children:"Save"})}),a?e.jsx("div",{className:f,children:e.jsx(j,{message:a})}):null]})}function I({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 o=i.trim(),m=Number(o),l=(o===""||Number.isInteger(m)&&m>=1)&&o!==r;return e.jsxs("div",{className:g,children:[e.jsx(y,{field:s,help:"Leave blank to use the backend default."}),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-full text-right tabular-nums sm:col-start-2 sm:w-28 sm:justify-self-end ${S}`}),e.jsx("div",{className:k,children:e.jsx(b,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:n||!l,onPress:()=>t(o===""?null:m),children:"Save"})}),a?e.jsx("div",{className:f,children:e.jsx(j,{message:a})}):null]})}function A({field:s,onSave:t,saveError:a,disabled:n}){const r=s.value===!0?"on":s.value===!1?"off":"default";return e.jsxs("div",{className:g,children:[e.jsx(y,{field:s}),e.jsx("div",{className:"sm:col-start-2 sm:justify-self-start",children:e.jsx(D,{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("div",{className:f,children:e.jsx(j,{message:a})}):null]})}function F({field:s,onSave:t,saveError:a,disabled:n}){return s.type==="url"?e.jsx(z,{field:s,onSave:t,saveError:a,disabled:n}):s.type==="int"?e.jsx(I,{field:s,onSave:t,saveError:a,disabled:n}):s.type==="bool"?e.jsx(A,{field:s,onSave:t,saveError:a,disabled:n}):e.jsx(G,{field:s,onSave:t,saveError:a,disabled:n})}function K(){const s=C(),t=L(),[a,n]=$(),[r,i]=d.useState({}),c=s.data,o=!c||t.isPending,m=new Map(((c==null?void 0:c.fields)??[]).map(l=>[l.key,l])),x=(l,p)=>{i(h=>{const{[l.key]:v,...u}=h;return u}),t.mutate(B(l.key,p),{onSuccess:()=>n(`${l.key} saved`),onError:h=>i(v=>({...v,[l.key]:w(h)}))})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(E,{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(P,{error:s.error}),M.map(l=>{const p=l.order.map(u=>m.get(u)).filter(u=>u!==void 0),h=((c==null?void 0:c.fields)??[]).filter(u=>u.service===l.key&&!l.order.includes(u.key)),v=[...p,...h];return v.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:l.label}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:l.blurb}),e.jsx(N,{children:e.jsx(N.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:v.map(u=>e.jsx(F,{field:u,onSave:T=>x(u,T),saveError:r[u.key],disabled:o},u.key))})})]},l.key)}),e.jsx(U,{message:a})]})}export{K as ToolsGuardrailsPage}; diff --git a/src/gateway/static/dashboard/assets/UsagePage-DtQni_qk.js b/src/gateway/static/dashboard/assets/UsagePage-CsWlUWCg.js similarity index 98% rename from src/gateway/static/dashboard/assets/UsagePage-DtQni_qk.js rename to src/gateway/static/dashboard/assets/UsagePage-CsWlUWCg.js index 91abbab9..88f1eb81 100644 --- a/src/gateway/static/dashboard/assets/UsagePage-DtQni_qk.js +++ b/src/gateway/static/dashboard/assets/UsagePage-CsWlUWCg.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{i as ie,r as x}from"./react-dgEcD0HR.js";import{u as ce,c as D,P as de,E as ue,d as J,S as j,N as v,L as p,M as $,O as he,a9 as S}from"./index-Dp4DdBFR.js";import{S as E,B as me}from"./charts-Cr3Dij9t.js";import{T as xe,a as ge,b as M,L as fe,c as je,d as be,e as O}from"./Table-CLdjdyTx.js";import{B as k,S as K}from"./heroui-BX6JwHY-.js";import"./recharts-CR3TAEof.js";function z(a){return a.toLocaleString()}function ve(a){return a===null?"—":a<1e3?`${Math.round(a)} ms`:`${(a/1e3).toFixed(2)} s`}const pe=3600,R=86400,L=[{label:"Last hour",seconds:pe,bucket:"hour"},{label:"24h",seconds:R,bucket:"hour"},{label:"7d",seconds:7*R,bucket:"day"},{label:"30d",seconds:30*R,bucket:"day"},{label:"All",seconds:null,bucket:"day"}],U=L[3],A=15;function B(a){return new Date(Date.now()-a*1e3).toISOString()}function Q({title:a,rows:r,totalCost:n,emptyLabel:g,onDrill:c,loading:d}){const[u,_]=x.useState(!1),f=u?r:r.slice(0,A),N=r.length-f.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(xe,{children:[e.jsx(ge,{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:d?e.jsx(fe,{colSpan:3}):r.length===0?e.jsx(je,{colSpan:3,children:g}):f.map((o,T)=>{const m=o.is_other,y=!m&&o.key!==null,h=n>0?o.cost/n:0;return e.jsxs(be,{onClick:y?()=>c(o.key):void 0,children:[e.jsx(O,{className:"text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"truncate",children:m?`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(O,{className:"text-right tabular-nums text-[var(--otari-muted)]",children:z(o.requests)}),e.jsx(O,{className:"text-right tabular-nums text-[var(--otari-ink)]",children:$(o.cost)})]},o.key??`__null_${T}`)})})]}),!d&&N>0?e.jsxs(k,{size:"sm",variant:"ghost",onPress:()=>_(!0),children:["Show all ",r.length]}):null,!d&&u&&r.length>A?e.jsxs(k,{size:"sm",variant:"ghost",onPress:()=>_(!1),children:["Show top ",A]}):null]})}const ke=[{key:"cost",label:"Cost"},{key:"tokens",label:"Tokens"},{key:"requests",label:"Requests"}];function _e(a,r){return r==="cost"?a.cost:r==="tokens"?a.tokens:a.requests}function W(a,r){return r==="cost"?$(a):r==="tokens"?S(a):z(a)}function ye(a,r){const n=new Date(a);return Number.isNaN(n.getTime())?a:r==="hour"?n.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:"UTC"}):n.toLocaleDateString(void 0,{month:"short",day:"numeric",timeZone:"UTC"})}function Se(a,r,n){const g=a.map(d=>({label:ye(d.bucket_start,n),value:_e(d,r)})),c=g.length?Math.max(...g.map(d=>d.value)):0;return{points:g,peak:c,count:a.length}}function De(){var V,Z,Y;const a=ie(),r=ce(),[n,g]=x.useState(U),[c,d]=x.useState(()=>U.seconds===null?void 0:B(U.seconds)),[u,_]=x.useState(""),[f,N]=x.useState(""),[o,T]=x.useState("cost"),m=x.useMemo(()=>({start_date:c,model:u.trim()||void 0,user_id:f||void 0}),[c,u,f]),y=x.useMemo(()=>n.seconds===null||!c?null:{...m,start_date:new Date(new Date(c).getTime()-n.seconds*1e3).toISOString(),end_date:c},[m,n.seconds,c]),h=D(m,n.bucket),X=D(y??m,n.bucket,y!==null),i=h.data,t=i==null?void 0:i.totals,l=y!==null?(V=X.data)==null?void 0:V.totals:void 0,ee=x.useMemo(()=>({...m,model:void 0}),[m]),q=((Y=(Z=D(ee,n.bucket).data)==null?void 0:Z.by_model)==null?void 0:Y.filter(s=>!s.is_other&&s.key!==null).map(s=>s.key))??[],se=(r.data??[]).map(s=>({value:s.user_id,label:s.alias?`${s.alias} (${s.user_id})`:s.user_id})),te=(u&&!q.includes(u)?[u,...q]:q).map(s=>({value:s,label:s})),w=!!(u.trim()||f||n.seconds!==null),ae=!!(i&&t&&t.request_count===0&&!w),H=s=>{g(s),d(s.seconds===null?void 0:B(s.seconds))},ne=()=>{H(L[L.length-1]),_(""),N("")},re=()=>{n.seconds!==null&&d(B(n.seconds)),h.refetch()},I=s=>{const P=new URLSearchParams;c&&P.set("start_date",c);for(const[oe,G]of Object.entries(s))G&&P.set(oe,G);a(`/activity?${P.toString()}`)},le=t&&t.request_count>0?t.error_count/t.request_count:0,b=(i==null?void 0:i.series)??[],C=b.length>1,F=Se(b,o,n.bucket);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(de,{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:re,isDisabled:h.isFetching,children:"Refresh"})}),e.jsx(ue,{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(s=>e.jsx(k,{size:"sm",variant:n.label===s.label?"primary":"outline",onPress:()=>H(s),children:s.label},s.label))}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(J,{label:"User",value:f,onChange:N,options:se,placeholder:"All users"}),e.jsx(J,{label:"Model",value:u,onChange:_,options:te,placeholder:"All models"}),w?e.jsx(k,{size:"sm",variant:"ghost",onPress:ne,children:"Clear filters"}):null]})]}),ae?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(j,{label:"Spend",value:t?$(t.cost):"—",hint:t?e.jsx(v,{fraction:p(t.cost,l==null?void 0:l.cost)}):null,chart:C?e.jsx(E,{values:b.map(s=>s.cost),ariaLabel:"Spend trend over the selected window"}):void 0}),e.jsx(j,{label:"Requests",value:t?z(t.request_count):"—",hint:t?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:[he(le)," errors",l?e.jsxs(e.Fragment,{children:[" · ",e.jsx(v,{fraction:p(t.request_count,l.request_count)})]}):null]}):null,chart:C?e.jsx(E,{values:b.map(s=>s.requests),ariaLabel:"Request volume trend over the selected window"}):void 0}),e.jsx(j,{label:"Tokens",value:t?S(t.total_tokens):"—",hint:t?e.jsx(v,{fraction:p(t.total_tokens,l==null?void 0:l.total_tokens)}):null,chart:C?e.jsx(E,{values:b.map(s=>s.tokens),ariaLabel:"Token usage trend over the selected window"}):void 0}),e.jsx(j,{label:"Cache read",value:t?S(t.cache_read_tokens):"—",hint:t?e.jsx(v,{fraction:p(t.cache_read_tokens,l==null?void 0:l.cache_read_tokens)}):null}),e.jsx(j,{label:"Cache write",value:t?S(t.cache_write_tokens):"—",hint:t?e.jsx(v,{fraction:p(t.cache_write_tokens,l==null?void 0:l.cache_write_tokens)}):null}),e.jsx(j,{label:"1h cache write",value:t?S(t.cache_write_1h_tokens??0):"—",hint:t?e.jsx(v,{fraction:p(t.cache_write_1h_tokens??0,(l==null?void 0:l.cache_write_1h_tokens)??0)}):null}),e.jsx(j,{label:"Avg latency",value:t?ve(t.avg_latency_ms):"—"})]}),e.jsxs("div",{className:"grid gap-6 lg:grid-cols-2",children:[e.jsx(Q,{title:"Spend by model",rows:(i==null?void 0:i.by_model)??[],totalCost:(t==null?void 0:t.cost)??0,emptyLabel:w?"No usage matches these filters.":"No usage recorded yet.",onDrill:s=>I({model:s}),loading:h.isLoading}),e.jsx(Q,{title:"Spend by user",rows:(i==null?void 0:i.by_user)??[],totalCost:(t==null?void 0:t.cost)??0,emptyLabel:w?"No usage matches these filters.":"No usage recorded yet.",onDrill:s=>I({user_id:s}),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:[ke.map(s=>e.jsx(k,{size:"sm",variant:o===s.key?"primary":"outline",onPress:()=>T(s.key),children:s.label},s.key)),h.isFetching?e.jsx(K,{size:"sm"}):null]})]}),h.isLoading?e.jsx("div",{className:"flex h-48 items-center justify-center",children:e.jsx(K,{size:"sm"})}):b.length===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.jsxs("figure",{className:"flex flex-col gap-2",children:[e.jsx(me,{data:F.points,formatValue:s=>W(s,o),ariaLabel:`${o} per ${n.bucket}`}),e.jsxs("figcaption",{className:"text-xs text-[var(--otari-muted)]",children:[W(F.peak,o)," peak · ",F.count," ",n.bucket==="hour"?"hours":"days"," (times in UTC)"]})]})]})]})]})}export{De as UsagePage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{i as ie,r as x}from"./react-dgEcD0HR.js";import{u as ce,c as D,P as de,E as ue,d as J,S as j,N as v,L as p,M as $,O as he,a8 as S}from"./index-hDVMDLdX.js";import{S as E,B as me}from"./charts-Cr3Dij9t.js";import{T as xe,a as ge,b as M,L as fe,c as je,d as be,e as O}from"./Table-CLdjdyTx.js";import{B as k,S as K}from"./heroui-BX6JwHY-.js";import"./recharts-CR3TAEof.js";function z(a){return a.toLocaleString()}function ve(a){return a===null?"—":a<1e3?`${Math.round(a)} ms`:`${(a/1e3).toFixed(2)} s`}const pe=3600,R=86400,L=[{label:"Last hour",seconds:pe,bucket:"hour"},{label:"24h",seconds:R,bucket:"hour"},{label:"7d",seconds:7*R,bucket:"day"},{label:"30d",seconds:30*R,bucket:"day"},{label:"All",seconds:null,bucket:"day"}],U=L[3],A=15;function B(a){return new Date(Date.now()-a*1e3).toISOString()}function Q({title:a,rows:r,totalCost:n,emptyLabel:g,onDrill:c,loading:d}){const[u,_]=x.useState(!1),f=u?r:r.slice(0,A),N=r.length-f.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(xe,{children:[e.jsx(ge,{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:d?e.jsx(fe,{colSpan:3}):r.length===0?e.jsx(je,{colSpan:3,children:g}):f.map((o,T)=>{const m=o.is_other,y=!m&&o.key!==null,h=n>0?o.cost/n:0;return e.jsxs(be,{onClick:y?()=>c(o.key):void 0,children:[e.jsx(O,{className:"text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"truncate",children:m?`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(O,{className:"text-right tabular-nums text-[var(--otari-muted)]",children:z(o.requests)}),e.jsx(O,{className:"text-right tabular-nums text-[var(--otari-ink)]",children:$(o.cost)})]},o.key??`__null_${T}`)})})]}),!d&&N>0?e.jsxs(k,{size:"sm",variant:"ghost",onPress:()=>_(!0),children:["Show all ",r.length]}):null,!d&&u&&r.length>A?e.jsxs(k,{size:"sm",variant:"ghost",onPress:()=>_(!1),children:["Show top ",A]}):null]})}const ke=[{key:"cost",label:"Cost"},{key:"tokens",label:"Tokens"},{key:"requests",label:"Requests"}];function _e(a,r){return r==="cost"?a.cost:r==="tokens"?a.tokens:a.requests}function W(a,r){return r==="cost"?$(a):r==="tokens"?S(a):z(a)}function ye(a,r){const n=new Date(a);return Number.isNaN(n.getTime())?a:r==="hour"?n.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:"UTC"}):n.toLocaleDateString(void 0,{month:"short",day:"numeric",timeZone:"UTC"})}function Se(a,r,n){const g=a.map(d=>({label:ye(d.bucket_start,n),value:_e(d,r)})),c=g.length?Math.max(...g.map(d=>d.value)):0;return{points:g,peak:c,count:a.length}}function De(){var V,Z,Y;const a=ie(),r=ce(),[n,g]=x.useState(U),[c,d]=x.useState(()=>U.seconds===null?void 0:B(U.seconds)),[u,_]=x.useState(""),[f,N]=x.useState(""),[o,T]=x.useState("cost"),m=x.useMemo(()=>({start_date:c,model:u.trim()||void 0,user_id:f||void 0}),[c,u,f]),y=x.useMemo(()=>n.seconds===null||!c?null:{...m,start_date:new Date(new Date(c).getTime()-n.seconds*1e3).toISOString(),end_date:c},[m,n.seconds,c]),h=D(m,n.bucket),X=D(y??m,n.bucket,y!==null),i=h.data,t=i==null?void 0:i.totals,l=y!==null?(V=X.data)==null?void 0:V.totals:void 0,ee=x.useMemo(()=>({...m,model:void 0}),[m]),q=((Y=(Z=D(ee,n.bucket).data)==null?void 0:Z.by_model)==null?void 0:Y.filter(s=>!s.is_other&&s.key!==null).map(s=>s.key))??[],se=(r.data??[]).map(s=>({value:s.user_id,label:s.alias?`${s.alias} (${s.user_id})`:s.user_id})),te=(u&&!q.includes(u)?[u,...q]:q).map(s=>({value:s,label:s})),w=!!(u.trim()||f||n.seconds!==null),ae=!!(i&&t&&t.request_count===0&&!w),H=s=>{g(s),d(s.seconds===null?void 0:B(s.seconds))},ne=()=>{H(L[L.length-1]),_(""),N("")},re=()=>{n.seconds!==null&&d(B(n.seconds)),h.refetch()},I=s=>{const P=new URLSearchParams;c&&P.set("start_date",c);for(const[oe,G]of Object.entries(s))G&&P.set(oe,G);a(`/activity?${P.toString()}`)},le=t&&t.request_count>0?t.error_count/t.request_count:0,b=(i==null?void 0:i.series)??[],C=b.length>1,F=Se(b,o,n.bucket);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(de,{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:re,isDisabled:h.isFetching,children:"Refresh"})}),e.jsx(ue,{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(s=>e.jsx(k,{size:"sm",variant:n.label===s.label?"primary":"outline",onPress:()=>H(s),children:s.label},s.label))}),e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(J,{label:"User",value:f,onChange:N,options:se,placeholder:"All users"}),e.jsx(J,{label:"Model",value:u,onChange:_,options:te,placeholder:"All models"}),w?e.jsx(k,{size:"sm",variant:"ghost",onPress:ne,children:"Clear filters"}):null]})]}),ae?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(j,{label:"Spend",value:t?$(t.cost):"—",hint:t?e.jsx(v,{fraction:p(t.cost,l==null?void 0:l.cost)}):null,chart:C?e.jsx(E,{values:b.map(s=>s.cost),ariaLabel:"Spend trend over the selected window"}):void 0}),e.jsx(j,{label:"Requests",value:t?z(t.request_count):"—",hint:t?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:[he(le)," errors",l?e.jsxs(e.Fragment,{children:[" · ",e.jsx(v,{fraction:p(t.request_count,l.request_count)})]}):null]}):null,chart:C?e.jsx(E,{values:b.map(s=>s.requests),ariaLabel:"Request volume trend over the selected window"}):void 0}),e.jsx(j,{label:"Tokens",value:t?S(t.total_tokens):"—",hint:t?e.jsx(v,{fraction:p(t.total_tokens,l==null?void 0:l.total_tokens)}):null,chart:C?e.jsx(E,{values:b.map(s=>s.tokens),ariaLabel:"Token usage trend over the selected window"}):void 0}),e.jsx(j,{label:"Cache read",value:t?S(t.cache_read_tokens):"—",hint:t?e.jsx(v,{fraction:p(t.cache_read_tokens,l==null?void 0:l.cache_read_tokens)}):null}),e.jsx(j,{label:"Cache write",value:t?S(t.cache_write_tokens):"—",hint:t?e.jsx(v,{fraction:p(t.cache_write_tokens,l==null?void 0:l.cache_write_tokens)}):null}),e.jsx(j,{label:"1h cache write",value:t?S(t.cache_write_1h_tokens??0):"—",hint:t?e.jsx(v,{fraction:p(t.cache_write_1h_tokens??0,(l==null?void 0:l.cache_write_1h_tokens)??0)}):null}),e.jsx(j,{label:"Avg latency",value:t?ve(t.avg_latency_ms):"—"})]}),e.jsxs("div",{className:"grid gap-6 lg:grid-cols-2",children:[e.jsx(Q,{title:"Spend by model",rows:(i==null?void 0:i.by_model)??[],totalCost:(t==null?void 0:t.cost)??0,emptyLabel:w?"No usage matches these filters.":"No usage recorded yet.",onDrill:s=>I({model:s}),loading:h.isLoading}),e.jsx(Q,{title:"Spend by user",rows:(i==null?void 0:i.by_user)??[],totalCost:(t==null?void 0:t.cost)??0,emptyLabel:w?"No usage matches these filters.":"No usage recorded yet.",onDrill:s=>I({user_id:s}),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:[ke.map(s=>e.jsx(k,{size:"sm",variant:o===s.key?"primary":"outline",onPress:()=>T(s.key),children:s.label},s.key)),h.isFetching?e.jsx(K,{size:"sm"}):null]})]}),h.isLoading?e.jsx("div",{className:"flex h-48 items-center justify-center",children:e.jsx(K,{size:"sm"})}):b.length===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.jsxs("figure",{className:"flex flex-col gap-2",children:[e.jsx(me,{data:F.points,formatValue:s=>W(s,o),ariaLabel:`${o} per ${n.bucket}`}),e.jsxs("figcaption",{className:"text-xs text-[var(--otari-muted)]",children:[W(F.peak,o)," peak · ",F.count," ",n.bucket==="hour"?"hours":"days"," (times in UTC)"]})]})]})]})]})}export{De as UsagePage}; diff --git a/src/gateway/static/dashboard/assets/UsersPage-BDaNeswz.js b/src/gateway/static/dashboard/assets/UsersPage-ETfq5MXh.js similarity index 97% rename from src/gateway/static/dashboard/assets/UsersPage-BDaNeswz.js rename to src/gateway/static/dashboard/assets/UsersPage-ETfq5MXh.js index fbd004d1..47046858 100644 --- a/src/gateway/static/dashboard/assets/UsersPage-BDaNeswz.js +++ b/src/gateway/static/dashboard/assets/UsersPage-ETfq5MXh.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as n}from"./react-dgEcD0HR.js";import{u as E,j as w,n as I,aa as V,P as L,E as A,ab as O}from"./index-Dp4DdBFR.js";import{F as N}from"./Field-HzRk1KDP.js";import{M as B,a as R}from"./ModelScopeControl-D_p9BPKF.js";import{T as H,a as $,b as f,L as q,c as W,d as G,e as b}from"./Table-CLdjdyTx.js";import{B as o,f as P,d as v}from"./heroui-BX6JwHY-.js";const J=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function S(t){return J.format(t)}const _=t=>t.startsWith("apikey-");function D(t){return t.split("-")[0]}function M(t){return t.name??D(t.budget_id)}function T({value:t,onChange:i,budgets:r}){return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("label",{htmlFor:"user-budget",className:"text-sm font-medium text-[var(--otari-ink)]",children:"Budget"}),e.jsxs("select",{id:"user-budget",value:t??"",onChange:a=>i(a.target.value||null),className:"w-full rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 text-sm text-[var(--otari-ink)]",children:[e.jsx("option",{value:"",children:"No budget (unlimited)"}),r.map(a=>e.jsxs("option",{value:a.budget_id,children:[M(a),a.max_budget===null?" · no limit":` · ${S(a.max_budget)}`]},a.budget_id))]}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"The spending limit this user is held to. Manage budgets on the Budgets page."})]})}function K({onClose:t}){const i=O(),r=w(),[a,l]=n.useState(""),[d,c]=n.useState(""),[u,j]=n.useState(null),[y,x]=n.useState(null),[g,p]=n.useState(!0),m=()=>{if(i.isPending||!g||a.trim()==="")return;const h={user_id:a.trim(),alias:d.trim()||null,budget_id:u,allowed_models:y};i.mutate(h,{onSuccess:t})};return e.jsx(v,{children:e.jsxs(v.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Create user"}),e.jsx(A,{error:i.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(N,{label:"User ID",value:a,onChange:l,placeholder:"alice@example.com",isRequired:!0,autoFocus:!0,description:"The identifier callers send as the `user` field; spend and budgets track against it."}),e.jsx(N,{label:"Alias (optional)",value:d,onChange:c,placeholder:"Alice"})]}),e.jsx(T,{value:u,onChange:j,budgets:r.data??[]}),e.jsx(B,{title:"Model access (default for this user's keys)",description:"The models this user's keys may list and call by default. A key can narrow this, but never exceed it.",initial:null,onChange:(h,C)=>{x(h),p(C)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(o,{variant:"primary",isDisabled:i.isPending||!g||a.trim()==="",onPress:m,children:i.isPending?"Creating…":"Create user"}),e.jsx(o,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function Q({user:t,onClose:i}){const r=I(),a=w(),[l,d]=n.useState(t.alias??""),[c,u]=n.useState(t.budget_id),[j,y]=n.useState(t.allowed_models),[x,g]=n.useState(!0),p=()=>{if(r.isPending||!x)return;const m={alias:l.trim()||null,budget_id:c,allowed_models:j};r.mutate({id:t.user_id,body:m},{onSuccess:i})};return e.jsx(v,{children:e.jsxs(v.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Edit ",e.jsx("code",{children:t.user_id})]}),e.jsx(A,{error:r.error}),e.jsx(N,{label:"Alias",value:l,onChange:d,placeholder:"Alice"}),e.jsx(T,{value:c,onChange:u,budgets:a.data??[]}),e.jsx(B,{title:"Model access (default for this user's keys)",description:"The models this user's keys may list and call by default. A key can narrow this, but never exceed it.",initial:t.allowed_models,onChange:(m,h)=>{y(m),g(h)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(o,{variant:"primary",isDisabled:r.isPending||!x,onPress:p,children:r.isPending?"Saving…":"Save changes"}),e.jsx(o,{variant:"ghost",onPress:i,children:"Cancel"})]})]})})}function X({trigger:t,message:i,confirmLabel:r,isPending:a,onConfirm:l}){const[d,c]=n.useState(!1);return d?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.jsx("span",{className:"max-w-xs text-xs text-amber-800",children:i}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(o,{size:"sm",variant:"danger",isDisabled:a,onPress:l,children:r}),e.jsx(o,{size:"sm",variant:"ghost",isDisabled:a,onPress:()=>c(!1),children:"Cancel"})]})]}):e.jsx(o,{size:"sm",variant:"danger-soft",onPress:()=>c(!0),children:t})}function Y({user:t}){return t.blocked?e.jsx(P,{size:"sm",color:"warning",children:"Blocked"}):e.jsx(P,{size:"sm",color:"accent",children:"Active"})}function Z({allowed:t}){const{text:i,tone:r}=R(t),a=r==="danger"?"text-red-700 font-medium":r==="muted"?"text-[var(--otari-muted)]":"text-[var(--otari-brand-dark)] font-medium",l=t&&t.length>0?t.join(", "):void 0;return e.jsx("span",{className:`text-xs ${a}`,title:l,children:i})}function ee({onCreate:t}){return e.jsx(v,{children:e.jsxs(v.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 users yet"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"A user owns API keys and carries the budget and default model access those keys inherit. Create a user here, then issue its keys on the API keys page."})]}),e.jsx("div",{children:e.jsx(o,{variant:"primary",onPress:t,children:"Create your first user"})})]})})}function de(){const t=E(),i=w(),r=I(),a=V(),[l,d]=n.useState(!1),[c,u]=n.useState(null),[j,y]=n.useState(!1),x=t.data??[],g=t.isLoading,p=x.filter(s=>_(s.user_id)).length,m=j?x:x.filter(s=>!_(s.user_id)),h=x.find(s=>s.user_id===c)??null,C=!g&&m.length===0&&!l,z=new Map((i.data??[]).map(s=>[s.budget_id,s])),U=(s,k)=>r.mutate({id:s.user_id,body:{blocked:k}});return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(L,{title:"Users",description:"People and teams that own API keys. Set each one's budget and default model access here; issue their keys on the API keys page.",action:l?null:e.jsx(o,{variant:"primary",onPress:()=>{u(null),d(!0)},children:"Create user"})}),e.jsx(A,{error:t.error??r.error??a.error}),C?e.jsx(ee,{onCreate:()=>{u(null),d(!0)}}):null,p>0?e.jsxs("label",{className:"flex w-fit items-center gap-2 text-xs text-[var(--otari-muted)]",children:[e.jsx("input",{type:"checkbox",checked:j,onChange:s=>y(s.target.checked)}),"Show auto-created (virtual) users (",p,")"]}):null,l?e.jsx(K,{onClose:()=>d(!1)}):null,h?e.jsx(Q,{user:h,onClose:()=>u(null)},h.user_id):null,e.jsxs(H,{children:[e.jsx($,{children:e.jsxs("tr",{children:[e.jsx(f,{children:"User"}),e.jsx(f,{children:"Status"}),e.jsx(f,{children:"Budget"}),e.jsx(f,{children:"Spend"}),e.jsx(f,{children:"Model access"}),e.jsx(f,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:g?e.jsx(q,{colSpan:6}):m.length===0?e.jsx(W,{colSpan:6,children:"No users yet. Create one, or create an API key to auto-create one."}):m.map(s=>{const k=s.budget_id?z.get(s.budget_id):void 0;return e.jsxs(G,{selected:c===s.user_id,onClick:()=>{d(!1),u(s.user_id)},children:[e.jsx(b,{className:"font-medium text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsxs("span",{className:"inline-flex items-center gap-1.5",children:[e.jsx("code",{className:"text-xs",children:s.user_id}),_(s.user_id)?e.jsx(P,{size:"sm",color:"default",children:"virtual"}):null]}),s.alias?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:s.alias}):null]})}),e.jsx(b,{children:e.jsx(Y,{user:s})}),e.jsx(b,{className:"text-[var(--otari-muted)]",children:s.budget_id?e.jsx("span",{title:s.budget_id,children:k?M(k):D(s.budget_id)}):"—"}),e.jsxs(b,{className:"text-[var(--otari-muted)]",children:[S(s.spend),s.reserved>0?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:[" (+",S(s.reserved)," held)"]}):null]}),e.jsx(b,{children:e.jsx(Z,{allowed:s.allowed_models})}),e.jsx(b,{children:e.jsxs("div",{className:"flex items-center justify-end gap-1.5",onClick:F=>F.stopPropagation(),children:[s.blocked?e.jsx(o,{size:"sm",variant:"outline",isDisabled:r.isPending,onPress:()=>U(s,!1),children:"Unblock"}):e.jsx(o,{size:"sm",variant:"outline",isDisabled:r.isPending,onPress:()=>U(s,!0),children:"Block"}),e.jsx(o,{size:"sm",variant:"ghost",onPress:()=>{d(!1),u(s.user_id)},children:"Edit"}),e.jsx(X,{trigger:"Delete",confirmLabel:"Delete user",isPending:a.isPending,message:e.jsxs(e.Fragment,{children:["Delete ",e.jsx("strong",{children:s.user_id}),"? This deactivates its API keys and hides the user; usage history is preserved."]}),onConfirm:()=>a.mutate(s.user_id)})]})})]},s.user_id)})})]})]})}export{de as UsersPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as n}from"./react-dgEcD0HR.js";import{u as E,j as w,n as I,a9 as V,P as L,E as A,aa as O}from"./index-hDVMDLdX.js";import{F as N}from"./Field-HzRk1KDP.js";import{M as B,a as R}from"./ModelScopeControl-DX341Q9L.js";import{T as H,a as $,b as f,L as q,c as W,d as G,e as b}from"./Table-CLdjdyTx.js";import{B as o,f as P,d as v}from"./heroui-BX6JwHY-.js";const J=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function S(t){return J.format(t)}const _=t=>t.startsWith("apikey-");function D(t){return t.split("-")[0]}function M(t){return t.name??D(t.budget_id)}function T({value:t,onChange:i,budgets:r}){return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("label",{htmlFor:"user-budget",className:"text-sm font-medium text-[var(--otari-ink)]",children:"Budget"}),e.jsxs("select",{id:"user-budget",value:t??"",onChange:a=>i(a.target.value||null),className:"w-full rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 text-sm text-[var(--otari-ink)]",children:[e.jsx("option",{value:"",children:"No budget (unlimited)"}),r.map(a=>e.jsxs("option",{value:a.budget_id,children:[M(a),a.max_budget===null?" · no limit":` · ${S(a.max_budget)}`]},a.budget_id))]}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"The spending limit this user is held to. Manage budgets on the Budgets page."})]})}function K({onClose:t}){const i=O(),r=w(),[a,l]=n.useState(""),[d,c]=n.useState(""),[u,j]=n.useState(null),[y,x]=n.useState(null),[g,p]=n.useState(!0),m=()=>{if(i.isPending||!g||a.trim()==="")return;const h={user_id:a.trim(),alias:d.trim()||null,budget_id:u,allowed_models:y};i.mutate(h,{onSuccess:t})};return e.jsx(v,{children:e.jsxs(v.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Create user"}),e.jsx(A,{error:i.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(N,{label:"User ID",value:a,onChange:l,placeholder:"alice@example.com",isRequired:!0,autoFocus:!0,description:"The identifier callers send as the `user` field; spend and budgets track against it."}),e.jsx(N,{label:"Alias (optional)",value:d,onChange:c,placeholder:"Alice"})]}),e.jsx(T,{value:u,onChange:j,budgets:r.data??[]}),e.jsx(B,{title:"Model access (default for this user's keys)",description:"The models this user's keys may list and call by default. A key can narrow this, but never exceed it.",initial:null,onChange:(h,C)=>{x(h),p(C)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(o,{variant:"primary",isDisabled:i.isPending||!g||a.trim()==="",onPress:m,children:i.isPending?"Creating…":"Create user"}),e.jsx(o,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function Q({user:t,onClose:i}){const r=I(),a=w(),[l,d]=n.useState(t.alias??""),[c,u]=n.useState(t.budget_id),[j,y]=n.useState(t.allowed_models),[x,g]=n.useState(!0),p=()=>{if(r.isPending||!x)return;const m={alias:l.trim()||null,budget_id:c,allowed_models:j};r.mutate({id:t.user_id,body:m},{onSuccess:i})};return e.jsx(v,{children:e.jsxs(v.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsxs("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Edit ",e.jsx("code",{children:t.user_id})]}),e.jsx(A,{error:r.error}),e.jsx(N,{label:"Alias",value:l,onChange:d,placeholder:"Alice"}),e.jsx(T,{value:c,onChange:u,budgets:a.data??[]}),e.jsx(B,{title:"Model access (default for this user's keys)",description:"The models this user's keys may list and call by default. A key can narrow this, but never exceed it.",initial:t.allowed_models,onChange:(m,h)=>{y(m),g(h)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(o,{variant:"primary",isDisabled:r.isPending||!x,onPress:p,children:r.isPending?"Saving…":"Save changes"}),e.jsx(o,{variant:"ghost",onPress:i,children:"Cancel"})]})]})})}function X({trigger:t,message:i,confirmLabel:r,isPending:a,onConfirm:l}){const[d,c]=n.useState(!1);return d?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.jsx("span",{className:"max-w-xs text-xs text-amber-800",children:i}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(o,{size:"sm",variant:"danger",isDisabled:a,onPress:l,children:r}),e.jsx(o,{size:"sm",variant:"ghost",isDisabled:a,onPress:()=>c(!1),children:"Cancel"})]})]}):e.jsx(o,{size:"sm",variant:"danger-soft",onPress:()=>c(!0),children:t})}function Y({user:t}){return t.blocked?e.jsx(P,{size:"sm",color:"warning",children:"Blocked"}):e.jsx(P,{size:"sm",color:"accent",children:"Active"})}function Z({allowed:t}){const{text:i,tone:r}=R(t),a=r==="danger"?"text-red-700 font-medium":r==="muted"?"text-[var(--otari-muted)]":"text-[var(--otari-brand-dark)] font-medium",l=t&&t.length>0?t.join(", "):void 0;return e.jsx("span",{className:`text-xs ${a}`,title:l,children:i})}function ee({onCreate:t}){return e.jsx(v,{children:e.jsxs(v.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 users yet"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"A user owns API keys and carries the budget and default model access those keys inherit. Create a user here, then issue its keys on the API keys page."})]}),e.jsx("div",{children:e.jsx(o,{variant:"primary",onPress:t,children:"Create your first user"})})]})})}function de(){const t=E(),i=w(),r=I(),a=V(),[l,d]=n.useState(!1),[c,u]=n.useState(null),[j,y]=n.useState(!1),x=t.data??[],g=t.isLoading,p=x.filter(s=>_(s.user_id)).length,m=j?x:x.filter(s=>!_(s.user_id)),h=x.find(s=>s.user_id===c)??null,C=!g&&m.length===0&&!l,z=new Map((i.data??[]).map(s=>[s.budget_id,s])),U=(s,k)=>r.mutate({id:s.user_id,body:{blocked:k}});return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(L,{title:"Users",description:"People and teams that own API keys. Set each one's budget and default model access here; issue their keys on the API keys page.",action:l?null:e.jsx(o,{variant:"primary",onPress:()=>{u(null),d(!0)},children:"Create user"})}),e.jsx(A,{error:t.error??r.error??a.error}),C?e.jsx(ee,{onCreate:()=>{u(null),d(!0)}}):null,p>0?e.jsxs("label",{className:"flex w-fit items-center gap-2 text-xs text-[var(--otari-muted)]",children:[e.jsx("input",{type:"checkbox",checked:j,onChange:s=>y(s.target.checked)}),"Show auto-created (virtual) users (",p,")"]}):null,l?e.jsx(K,{onClose:()=>d(!1)}):null,h?e.jsx(Q,{user:h,onClose:()=>u(null)},h.user_id):null,e.jsxs(H,{children:[e.jsx($,{children:e.jsxs("tr",{children:[e.jsx(f,{children:"User"}),e.jsx(f,{children:"Status"}),e.jsx(f,{children:"Budget"}),e.jsx(f,{children:"Spend"}),e.jsx(f,{children:"Model access"}),e.jsx(f,{className:"text-right",children:"Actions"})]})}),e.jsx("tbody",{children:g?e.jsx(q,{colSpan:6}):m.length===0?e.jsx(W,{colSpan:6,children:"No users yet. Create one, or create an API key to auto-create one."}):m.map(s=>{const k=s.budget_id?z.get(s.budget_id):void 0;return e.jsxs(G,{selected:c===s.user_id,onClick:()=>{d(!1),u(s.user_id)},children:[e.jsx(b,{className:"font-medium text-[var(--otari-ink)]",children:e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsxs("span",{className:"inline-flex items-center gap-1.5",children:[e.jsx("code",{className:"text-xs",children:s.user_id}),_(s.user_id)?e.jsx(P,{size:"sm",color:"default",children:"virtual"}):null]}),s.alias?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:s.alias}):null]})}),e.jsx(b,{children:e.jsx(Y,{user:s})}),e.jsx(b,{className:"text-[var(--otari-muted)]",children:s.budget_id?e.jsx("span",{title:s.budget_id,children:k?M(k):D(s.budget_id)}):"—"}),e.jsxs(b,{className:"text-[var(--otari-muted)]",children:[S(s.spend),s.reserved>0?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:[" (+",S(s.reserved)," held)"]}):null]}),e.jsx(b,{children:e.jsx(Z,{allowed:s.allowed_models})}),e.jsx(b,{children:e.jsxs("div",{className:"flex items-center justify-end gap-1.5",onClick:F=>F.stopPropagation(),children:[s.blocked?e.jsx(o,{size:"sm",variant:"outline",isDisabled:r.isPending,onPress:()=>U(s,!1),children:"Unblock"}):e.jsx(o,{size:"sm",variant:"outline",isDisabled:r.isPending,onPress:()=>U(s,!0),children:"Block"}),e.jsx(o,{size:"sm",variant:"ghost",onPress:()=>{d(!1),u(s.user_id)},children:"Edit"}),e.jsx(X,{trigger:"Delete",confirmLabel:"Delete user",isPending:a.isPending,message:e.jsxs(e.Fragment,{children:["Delete ",e.jsx("strong",{children:s.user_id}),"? This deactivates its API keys and hides the user; usage history is preserved."]}),onConfirm:()=>a.mutate(s.user_id)})]})})]},s.user_id)})})]})]})}export{de as UsersPage}; diff --git a/src/gateway/static/dashboard/assets/index-Dp4DdBFR.js b/src/gateway/static/dashboard/assets/index-Dp4DdBFR.js deleted file mode 100644 index 9d575bc2..00000000 --- a/src/gateway/static/dashboard/assets/index-Dp4DdBFR.js +++ /dev/null @@ -1,2 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/ActivityPage-D3mR-Vcr.js","assets/tanstack-query-1t81HyiD.js","assets/react-dgEcD0HR.js","assets/Table-CLdjdyTx.js","assets/heroui-BX6JwHY-.js","assets/AliasesPage-DJtTC87k.js","assets/Field-HzRk1KDP.js","assets/BudgetsPage-BCAQ5J9W.js","assets/KeysPage-DIt3Gp89.js","assets/ModelScopeControl-D_p9BPKF.js","assets/ModelsPage-BR6bzhht.js","assets/OverviewPage-_Gja1ZBt.js","assets/charts-Cr3Dij9t.js","assets/recharts-CR3TAEof.js","assets/ProvidersPage-B6Y7usRU.js","assets/SettingsPage-CVx7wSlF.js","assets/ToolsGuardrailsPage-v1VWfWQq.js","assets/UsagePage-DtQni_qk.js","assets/UsersPage-BDaNeswz.js"])))=>i.map(i=>d[i]); -var we=Object.defineProperty;var Se=(e,r,s)=>r in e?we(e,r,{enumerable:!0,configurable:!0,writable:!0,value:s}):e[r]=s;var re=(e,r,s)=>Se(e,typeof r!="symbol"?r+"":r,s);import{u as y,j as t,a as v,b as x,k as H,Q as ke,c as Pe}from"./tanstack-query-1t81HyiD.js";import{d as Ee,r as o,N as le,O as Ne,H as Ce,e as Te,f as b,h as _e}from"./react-dgEcD0HR.js";import{C as L,L as ce,I as ue,a as Ae,b as Ie,B as P,d as I,c as C,T as Le,e as qe}from"./heroui-BX6JwHY-.js";(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))a(n);new MutationObserver(n=>{for(const l of n)if(l.type==="childList")for(const m of l.addedNodes)m.tagName==="LINK"&&m.rel==="modulepreload"&&a(m)}).observe(document,{childList:!0,subtree:!0});function s(n){const l={};return n.integrity&&(l.integrity=n.integrity),n.referrerPolicy&&(l.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?l.credentials="include":n.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function a(n){if(n.ep)return;n.ep=!0;const l=s(n);fetch(n.href,l)}})();var Re=Ee();const Oe="modulepreload",De=function(e){return"/"+e},se={},S=function(r,s,a){let n=Promise.resolve();if(s&&s.length>0){let m=function(u){return Promise.all(u.map(p=>Promise.resolve(p).then(g=>({status:"fulfilled",value:g}),g=>({status:"rejected",reason:g}))))};document.getElementsByTagName("link");const c=document.querySelector("meta[property=csp-nonce]"),f=(c==null?void 0:c.nonce)||(c==null?void 0:c.getAttribute("nonce"));n=m(s.map(u=>{if(u=De(u),u in se)return;se[u]=!0;const p=u.endsWith(".css"),g=p?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${g}`))return;const h=document.createElement("link");if(h.rel=p?"stylesheet":Oe,p||(h.as="script"),h.crossOrigin="",h.href=u,f&&h.setAttribute("nonce",f),document.head.appendChild(h),p)return new Promise((d,j)=>{h.addEventListener("load",d),h.addEventListener("error",()=>j(new Error(`Unable to preload CSS for ${u}`)))})}))}function l(m){const c=new Event("vite:preloadError",{cancelable:!0});if(c.payload=m,window.dispatchEvent(c),!c.defaultPrevented)throw m}return n.then(m=>{for(const c of m||[])c.status==="rejected"&&l(c.reason);return r().catch(l)})};class E extends Error{constructor(s,a){super(a);re(this,"status");this.name="ApiError",this.status=s}}let R=null;function ne(e){R=e}let Q=null;function q(e){Q=e}async function V(e){try{const r=await e.json();if(typeof r.detail=="string")return r.detail;if(r.detail!=null)return JSON.stringify(r.detail)}catch{}return e.statusText||`Request failed (${e.status})`}async function Fe(e){let r;try{r=await fetch("/v1/settings",{headers:{Accept:"application/json",Authorization:`Bearer ${e}`}})}catch{throw new E(0,"Network error: could not reach the gateway.")}if(r.status===401||r.status===403)return!1;if(!r.ok)throw new E(r.status,await V(r));return!0}async function i(e,r={}){const s=new Headers(r.headers);s.set("Accept","application/json"),r.body!=null&&!s.has("Content-Type")&&s.set("Content-Type","application/json"),Q&&s.set("Authorization",`Bearer ${Q}`);let a;try{a=await fetch(e,{...r,headers:s})}catch{throw new E(0,"Network error: could not reach the gateway.")}if(a.status===401||a.status===403)throw R==null||R(),new E(a.status,await V(a));if(!a.ok)throw new E(a.status,await V(a));if(a.status!==204)return await a.json()}const O="otari.dashboard.masterKey",de=o.createContext(null);function Ke(){try{return window.sessionStorage.getItem(O)}catch{return null}}function Me({children:e}){const r=y(),[s,a]=o.useState(()=>{const f=Ke();return q(f),f}),n=o.useCallback(()=>{q(null),a(null),r.clear();try{window.sessionStorage.removeItem(O)}catch{}},[r]),l=o.useCallback(f=>{const u=f.trim();q(u),r.clear(),a(u);try{window.sessionStorage.setItem(O,u)}catch{}},[r]),m=o.useCallback(f=>{const u=f.trim();q(u),a(u);try{window.sessionStorage.setItem(O,u)}catch{}},[]);o.useEffect(()=>(ne(n),()=>ne(null)),[n]);const c=o.useMemo(()=>({masterKey:s,isAuthenticated:s!=null,login:l,replaceMasterKey:m,logout:n}),[s,l,m,n]);return t.jsx(de.Provider,{value:c,children:e})}function W(){const e=o.useContext(de);if(!e)throw new Error("useAuth must be used within an AuthProvider");return e}function Ue(e){return e instanceof E&&e.status===0}function Be(){const e=y(),[r,s]=o.useState(!1);return o.useEffect(()=>{const a=e.getQueryCache(),n=()=>a.getAll().some(l=>l.state.status==="error"&&Ue(l.state.error));return s(n()),a.subscribe(()=>s(n()))},[e]),r}function $e(){return Be()?t.jsxs("div",{role:"alert","aria-live":"assertive",className:"fixed right-4 bottom-4 z-50 flex max-w-sm items-start gap-2.5 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-lg",children:[t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2","aria-hidden":!0,className:"mt-0.5 h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M12 9v4M12 17h.01",strokeLinecap:"round",strokeLinejoin:"round"}),t.jsx("path",{d:"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z",strokeLinejoin:"round"})]}),t.jsxs("span",{children:[t.jsx("strong",{className:"font-semibold",children:"Can’t reach the gateway."})," The backend isn’t responding; data won’t load or save until the connection is restored."]})]}):null}const N="models",F="pricing",me="settings",fe="tool-settings",G="aliases",J="discoverable",Y="providers",X="provider-health",he="stored-providers",ze="model-metadata",Qe="build",T="keys",_="budgets",xe="users",Z="usage",Ve=6e4,ae=60*6e4;function Ft(){return v({queryKey:[N],queryFn:()=>i("/v1/models"),staleTime:6e4})}function He(){return v({queryKey:[Qe],queryFn:()=>i("/dashboard-build.json"),refetchInterval:Ve,refetchOnWindowFocus:!0,staleTime:0,retry:!1})}function Kt(){return v({queryKey:[J],queryFn:()=>i("/v1/models/discoverable"),staleTime:5*6e4})}function Mt(){return v({queryKey:[Y],queryFn:()=>i("/v1/providers"),staleTime:5*6e4})}function Ut(){return v({queryKey:["provider-catalog"],queryFn:()=>i("/v1/providers/catalog"),staleTime:1/0})}function Bt(e){return v({queryKey:["provider-catalog",e],queryFn:()=>i(`/v1/providers/catalog/${encodeURIComponent(e)}`),enabled:e!=="",staleTime:1/0})}function $t(){return v({queryKey:[X],queryFn:()=>i("/v1/providers/health"),staleTime:ae,refetchInterval:ae})}function zt(){const e=y();return x({mutationFn:()=>i("/v1/providers/health?refresh=true"),onSuccess:r=>e.setQueryData([X],r)})}function Qt(){return v({queryKey:[he],queryFn:()=>i("/v1/provider-credentials"),staleTime:6e4})}function K(e){e.invalidateQueries({queryKey:[he]}),e.invalidateQueries({queryKey:[Y]}),e.invalidateQueries({queryKey:[N]}),e.invalidateQueries({queryKey:[J]}),e.invalidateQueries({queryKey:[X]})}function Vt(){const e=y();return x({mutationFn:r=>i("/v1/provider-credentials",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>K(e)})}function Ht(){const e=y();return x({mutationFn:({instance:r,body:s})=>i(`/v1/provider-credentials/${encodeURIComponent(r)}`,{method:"PATCH",body:JSON.stringify(s)}),onSuccess:()=>K(e)})}function Wt(){const e=y();return x({mutationFn:r=>i(`/v1/provider-credentials/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>K(e)})}function Gt(){const e=y();return x({mutationFn:()=>i("/v1/provider-credentials/reencrypt",{method:"POST"}),onSuccess:()=>K(e)})}function Jt(){return x({mutationFn:e=>i(`/v1/provider-credentials/${encodeURIComponent(e)}/test`,{method:"POST"})})}function Yt(){return x({mutationFn:e=>i("/v1/provider-credentials/test",{method:"POST",body:JSON.stringify(e)})})}function Xt(){return v({queryKey:[ze],queryFn:()=>i("/v1/models/metadata"),staleTime:10*6e4})}function Zt(){return v({queryKey:[G],queryFn:()=>i("/v1/aliases"),staleTime:6e4})}function er(){const e=y();return x({mutationFn:r=>i("/v1/aliases",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>{e.invalidateQueries({queryKey:[G]}),e.invalidateQueries({queryKey:[N]})}})}function tr(){const e=y();return x({mutationFn:r=>i(`/v1/aliases/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>{e.invalidateQueries({queryKey:[G]}),e.invalidateQueries({queryKey:[N]})}})}function We(){return v({queryKey:[me],queryFn:()=>i("/v1/settings"),staleTime:6e4})}function Ge(){const e=y();return x({mutationFn:r=>i("/v1/settings",{method:"PATCH",body:JSON.stringify(r)}),onSuccess:r=>{e.setQueryData([me],r),e.invalidateQueries({queryKey:[N]}),e.invalidateQueries({queryKey:[J]})}})}function rr(){return x({mutationFn:()=>i("/v1/settings/master-key/rotate",{method:"POST"})})}function sr(){return v({queryKey:[fe],queryFn:()=>i("/v1/tool-settings"),staleTime:6e4})}function nr(){const e=y();return x({mutationFn:r=>i("/v1/tool-settings",{method:"PATCH",body:JSON.stringify(r)}),onSuccess:r=>{e.setQueryData([fe],r)}})}function ar(){return x({mutationFn:({service:e,url:r})=>i(`/v1/tool-settings/${encodeURIComponent(e)}/test`,{method:"POST",body:JSON.stringify({url:r})})})}const M=1e3,Je=100;async function Ye(){const e=[];for(let r=0;ri("/v1/pricing",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>{e.invalidateQueries({queryKey:[F]}),e.invalidateQueries({queryKey:[N]})}})}function lr(){const e=y();return x({mutationFn:r=>i(`/v1/pricing/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>{e.invalidateQueries({queryKey:[F]}),e.invalidateQueries({queryKey:[N]})}})}function cr(){return x({mutationFn:()=>i("/v1/pricing/refresh",{method:"POST"})})}function ur(){const e=y();return x({mutationFn:()=>i("/v1/pricing/refresh/confirm",{method:"POST"}),onSuccess:()=>{e.invalidateQueries({queryKey:[F]}),e.invalidateQueries({queryKey:[N]}),e.invalidateQueries({queryKey:[Y]})}})}function dr(){return x({mutationFn:()=>i("/v1/pricing/refresh/reject",{method:"POST"})})}const U=1e3,Xe=100;async function Ze(){const e=[];for(let r=0;ri("/v1/keys",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>void e.invalidateQueries({queryKey:[T]})})}function hr(){const e=y();return x({mutationFn:({id:r,body:s})=>i(`/v1/keys/${encodeURIComponent(r)}`,{method:"PATCH",body:JSON.stringify(s)}),onSuccess:()=>void e.invalidateQueries({queryKey:[T]})})}function xr(){const e=y();return x({mutationFn:r=>i(`/v1/keys/${encodeURIComponent(r)}/rotate`,{method:"POST"}),onSuccess:()=>void e.invalidateQueries({queryKey:[T]})})}function yr(){const e=y();return x({mutationFn:r=>i(`/v1/keys/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>void e.invalidateQueries({queryKey:[T]})})}const B=1e3,et=100;async function tt(){const e=[];for(let r=0;ri(`/v1/budgets/${encodeURIComponent(e)}/reset-logs`),enabled:e!==null,staleTime:6e4})}function pr(){const e=y();return x({mutationFn:r=>i("/v1/budgets",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>void e.invalidateQueries({queryKey:[_]})})}function br(){const e=y();return x({mutationFn:({id:r,body:s})=>i(`/v1/budgets/${encodeURIComponent(r)}`,{method:"PATCH",body:JSON.stringify(s)}),onSuccess:()=>void e.invalidateQueries({queryKey:[_]})})}function jr(){const e=y();return x({mutationFn:r=>i(`/v1/budgets/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>void e.invalidateQueries({queryKey:[_]})})}const $=1e3,rt=100;async function st(){const e=[];for(let r=0;ri("/v1/users",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>ee(e)})}function kr(){const e=y();return x({mutationFn:({id:r,body:s})=>i(`/v1/users/${encodeURIComponent(r)}`,{method:"PATCH",body:JSON.stringify(s)}),onSuccess:()=>ee(e)})}function Pr(){const e=y();return x({mutationFn:r=>i(`/v1/users/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>{ee(e),e.invalidateQueries({queryKey:[T]})}})}function te(e){const r=new URLSearchParams;return e.start_date&&r.set("start_date",e.start_date),e.end_date&&r.set("end_date",e.end_date),e.status&&r.set("status",e.status),e.model&&r.set("model",e.model),e.endpoint&&r.set("endpoint",e.endpoint),e.user_id&&r.set("user_id",e.user_id),r}function Er(e,r,s){return v({queryKey:[Z,"list",e,r,s],queryFn:()=>{const a=te(e);return a.set("skip",String(r*s)),a.set("limit",String(s)),i(`/v1/usage?${a.toString()}`)},placeholderData:H,staleTime:1e4})}function Nr(e){return v({queryKey:[Z,"count",e],queryFn:()=>i(`/v1/usage/count?${te(e).toString()}`),placeholderData:H,staleTime:1e4})}function Cr(e,r,s=!0){return v({queryKey:[Z,"summary",e,r],queryFn:()=>{const a=te(e);return a.set("bucket",r),i(`/v1/usage/summary?${a.toString()}`)},enabled:s,placeholderData:H,staleTime:3e4})}function Tr(e){return e==null?"0":new Intl.NumberFormat("en-US").format(e)}function _r(e){if(e==null)return"$0.00";const r=e!==0&&Math.abs(e)<.01?4:2;return new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",minimumFractionDigits:2,maximumFractionDigits:r}).format(e)}function Ar(e){if(e==null)return"—";if(e>=1e6){const r=e/1e6;return`${Number.isInteger(r)?r:r.toFixed(1)}M`}if(e>=1e3){const r=Math.round(e/1e3);return r>=1e3?"1M":`${r}K`}return String(e)}const nt=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function Ir(e){if(!e)return"—";const r=/^(\d{4})-(\d{2})/.exec(e);if(!r)return e;const s=Number(r[2])-1;return s<0||s>11?r[1]:`${nt[s]} ${r[1]}`}const at=new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:2});function Lr(e){return at.format(e)}function qr(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}function it(e){return`${(e*100).toFixed(1)}%`}function Rr(e,r){return r===void 0||r===0?null:(e-r)/r}function Or(e,r=Date.now()){if(!e)return"never";const s=new Date(e);if(Number.isNaN(s.getTime()))return e;const a=Math.round((r-s.getTime())/1e3),n=a<0,l=Math.abs(a),m=[["second",60],["minute",60],["hour",24],["day",30],["month",12],["year",Number.POSITIVE_INFINITY]];let c=l,f="second";for(const[p,g]of m){if(f=p,c0?"▲":e<0?"▼":"•";return t.jsxs("span",{className:"text-[var(--otari-muted)]",children:[r," ",it(Math.abs(e))," vs prev"]})}function ot(e){return e instanceof E||e instanceof Error?e.message:"Something went wrong."}function lt({error:e}){return e?t.jsx("div",{role:"alert",className:"rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700",children:ot(e)}):null}function ct({tone:e="info",children:r}){const s=e==="warning"?"border-amber-200 bg-amber-50 text-amber-800":"border-[var(--otari-brand)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return t.jsx("div",{className:`rounded-lg border px-4 py-3 text-sm ${s}`,children:r})}function Kr({title:e,description:r,action:s}){return t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsxs("div",{children:[t.jsx("h1",{className:"text-xl font-semibold text-[var(--otari-ink)]",children:e}),r?t.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:r}):null]}),s?t.jsx("div",{className:"flex flex-wrap gap-2",children:s}):null]})}function Mr({children:e,confirmLabel:r,onConfirm:s,isPending:a}){const[n,l]=o.useState(!1);return n?t.jsxs("span",{className:"inline-flex items-center gap-1",children:[t.jsx(P,{size:"sm",variant:"danger",isDisabled:a,onPress:s,children:r}),t.jsx(P,{size:"sm",variant:"ghost",isDisabled:a,onPress:()=>l(!1),children:"Cancel"})]}):t.jsx(P,{size:"sm",variant:"danger-soft",onPress:()=>l(!0),children:e})}const ut="rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 text-sm text-[var(--otari-ink)] focus:border-[var(--otari-brand)] focus:outline-none";function Ur({id:e,label:r,ariaLabel:s,value:a,onChange:n,options:l,children:m,disabled:c}){const f=o.useId(),u=e??(r?f:void 0),p=t.jsx("select",{id:u,"aria-label":r?void 0:s,value:a,disabled:c,onChange:g=>n(g.target.value),className:ut,children:l?l.map(g=>t.jsx("option",{value:g.value,children:g.label},g.value)):m});return r?t.jsxs("div",{className:"flex flex-col gap-1",children:[t.jsx("label",{htmlFor:u,className:"text-xs font-medium text-[var(--otari-muted)]",children:r}),p]}):p}function Br({label:e,value:r,onChange:s,options:a,placeholder:n,maxVisible:l=50,allowsCustom:m=!1}){const c=h=>{var d;return((d=a.find(j=>j.value===h))==null?void 0:d.label)??h},[f,u]=o.useState(()=>c(r));o.useEffect(()=>{u(c(r))},[r]);const p=f.trim().toLowerCase(),g=a.filter(h=>!p||h.value.toLowerCase().includes(p)||h.label.toLowerCase().includes(p)).slice(0,l);return t.jsxs(L.Root,{allowsEmptyCollection:!0,allowsCustomValue:m,menuTrigger:"focus",inputValue:f,onInputChange:h=>{u(h),m?s(h.trim()):h.trim()===""&&s("")},onSelectionChange:h=>{h!=null&&s(String(h))},className:"flex flex-col gap-1",children:[t.jsx(ce,{className:"text-xs font-medium text-[var(--otari-muted)]",children:e}),t.jsxs(L.InputGroup,{children:[t.jsx(ue,{placeholder:n,autoComplete:"off",onFocus:h=>h.currentTarget.select()}),t.jsx(L.Trigger,{})]}),t.jsx(L.Popover,{children:t.jsx(Ae,{items:g,className:"max-h-72 overflow-auto",children:h=>t.jsx(Ie,{id:h.value,textValue:h.label,children:h.label})})})]})}function dt(){var l;const e=We(),r=Ge(),[s,a]=o.useState(!1);return!(((l=e.data)==null?void 0:l.require_pricing)===!0&&e.data.default_pricing===!1)||s?null:t.jsx("div",{className:"shrink-0 px-6 pt-3",children:t.jsx(ct,{tone:"warning",children:t.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("span",{children:["Requests are rejected until pricing is set (",t.jsx("code",{children:"require_pricing"})," is on). Enable default pricing to meter new models with public rates right away."]}),t.jsxs("span",{className:"flex items-center gap-2",children:[t.jsx(P,{size:"sm",variant:"primary",isDisabled:r.isPending,onPress:()=>r.mutate({default_pricing:!0}),children:r.isPending?"Enabling…":"Enable default pricing"}),t.jsx(P,{size:"sm",variant:"ghost",onPress:()=>a(!0),children:"Dismiss"})]})]})})})}function mt(){const{data:e}=He(),r=o.useRef(null);return e&&r.current===null&&(r.current=e.build),e!=null&&r.current!=null&&e.build!==r.current}function ft(){const e=mt(),[r,s]=o.useState(!1);return!e||r?null:t.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center",children:t.jsxs("div",{role:"status",className:"pointer-events-auto mt-1.5 flex items-center gap-3 rounded-full border border-[var(--otari-brand)] bg-[var(--otari-brand-tint)] py-1.5 pr-1.5 pl-4 text-sm text-[var(--otari-brand-dark)] shadow-md",children:[t.jsxs("span",{children:[t.jsx("strong",{className:"font-semibold",children:"An update is available."})," Reloading keeps you signed in."]}),t.jsx(P,{size:"sm",variant:"primary",onPress:()=>window.location.reload(),children:"Update now"}),t.jsx(P,{size:"sm",variant:"ghost",onPress:()=>s(!0),children:"Later"})]})})}const ye=200,ve=480,z=240,ht=60,ge="otari.dashboard.sidebarWidth",pe="otari.dashboard.sidebarCollapsed",oe=16,D=e=>Math.min(ve,Math.max(ye,e));function xt(){if(typeof window>"u")return z;try{const e=window.localStorage.getItem(ge),r=e?Number.parseInt(e,10):Number.NaN;return Number.isNaN(r)?z:D(r)}catch{return z}}function yt(){if(typeof window>"u")return!1;try{return window.localStorage.getItem(pe)==="1"}catch{return!1}}const vt=[{key:"home"},{key:"observability",label:"Observability"},{key:"catalog",label:"Catalog"},{key:"access",label:"Access"},{key:"system"}],gt=[{to:"/",section:"home",label:"Overview",end:!0,icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("rect",{x:"3.5",y:"3.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),t.jsx("rect",{x:"13.5",y:"3.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),t.jsx("rect",{x:"3.5",y:"13.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),t.jsx("rect",{x:"13.5",y:"13.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"})]})},{to:"/activity",section:"observability",label:"Activity",icon:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:t.jsx("path",{d:"M3 12h4l2.5-6 4 12 2.5-6H21",strokeLinecap:"round",strokeLinejoin:"round"})})},{to:"/usage",section:"observability",label:"Usage",icon:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:t.jsx("path",{d:"M4 20V10M10 20V4M16 20v-7M22 20H2",strokeLinecap:"round",strokeLinejoin:"round"})})},{to:"/providers",section:"catalog",label:"Providers",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("rect",{x:"3.5",y:"4.5",width:"17",height:"6",rx:"1.5",strokeLinejoin:"round"}),t.jsx("rect",{x:"3.5",y:"13.5",width:"17",height:"6",rx:"1.5",strokeLinejoin:"round"}),t.jsx("path",{d:"M7 7.5h.01M7 16.5h.01",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/users",section:"access",label:"Users",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("circle",{cx:"9",cy:"8",r:"3.2",strokeLinejoin:"round"}),t.jsx("path",{d:"M3.5 19a5.5 5.5 0 0 1 11 0",strokeLinecap:"round",strokeLinejoin:"round"}),t.jsx("path",{d:"M16 5.2a3.2 3.2 0 0 1 0 5.6M17.5 19a5.5 5.5 0 0 0-3-4.9",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/keys",section:"access",label:"API keys",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("circle",{cx:"7.5",cy:"15.5",r:"3.5"}),t.jsx("path",{d:"M10 13l7-7M14 5l3 3M16.5 7.5l2-2",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/budgets",section:"access",label:"Budgets",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M3 7.5A1.5 1.5 0 0 1 4.5 6H18a1.5 1.5 0 0 1 1.5 1.5V9",strokeLinejoin:"round"}),t.jsx("rect",{x:"3",y:"7.5",width:"18",height:"12",rx:"1.5",strokeLinejoin:"round"}),t.jsx("path",{d:"M16 13.5h.01",strokeLinecap:"round",strokeLinejoin:"round"}),t.jsx("path",{d:"M21 12v3h-3.5a1.5 1.5 0 0 1 0-3H21z",strokeLinejoin:"round"})]})},{to:"/models",section:"catalog",label:"Models",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z",strokeLinejoin:"round"}),t.jsx("path",{d:"M12 12l8-4.5M12 12v9M12 12L4 7.5",strokeLinejoin:"round"})]})},{to:"/aliases",section:"catalog",label:"Aliases",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M20.6 13.4L13.4 20.6a2 2 0 0 1-2.8 0l-7-7A2 2 0 0 1 3 12.2V5a2 2 0 0 1 2-2h7.2a2 2 0 0 1 1.4.6l7 7a2 2 0 0 1 0 2.8z",strokeLinejoin:"round"}),t.jsx("circle",{cx:"7.5",cy:"7.5",r:"1.5"})]})},{to:"/tools",section:"system",label:"Tools & Guardrails",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M14.7 6.3a4 4 0 0 1 5 5l-8.4 8.4a2 2 0 0 1-2.8 0l-2.2-2.2a2 2 0 0 1 0-2.8z",strokeLinejoin:"round"}),t.jsx("path",{d:"M12 9 5 16",strokeLinecap:"round"})]})},{to:"/settings",section:"system",label:"Settings",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("circle",{cx:"12",cy:"12",r:"3"}),t.jsx("path",{d:"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z",strokeLinejoin:"round"})]})}];function pt(){const{logout:e}=W(),r=o.useRef(null),[s,a]=o.useState(xt),[n,l]=o.useState(yt),[m,c]=o.useState(!1);o.useEffect(()=>{const d=window.setTimeout(()=>{try{window.localStorage.setItem(ge,String(Math.round(s)))}catch{}},200);return()=>window.clearTimeout(d)},[s]),o.useEffect(()=>{try{window.localStorage.setItem(pe,n?"1":"0")}catch{}},[n]);const f=o.useCallback(d=>{d.preventDefault(),d.currentTarget.setPointerCapture(d.pointerId),c(!0)},[]),u=o.useCallback(d=>{var A;if(!d.currentTarget.hasPointerCapture(d.pointerId))return;const j=((A=r.current)==null?void 0:A.getBoundingClientRect().left)??0;a(D(d.clientX-j))},[]),p=o.useCallback(d=>{d.currentTarget.hasPointerCapture(d.pointerId)&&d.currentTarget.releasePointerCapture(d.pointerId),c(!1)},[]),g=o.useCallback(d=>{d.key==="ArrowLeft"?(d.preventDefault(),a(j=>D(j-oe))):d.key==="ArrowRight"&&(d.preventDefault(),a(j=>D(j+oe)))},[]),h=n?ht:s;return t.jsxs("div",{className:C("relative flex h-full flex-col overflow-hidden",m&&"cursor-col-resize select-none"),children:[t.jsxs("header",{className:"flex shrink-0 items-center justify-between border-b border-[var(--otari-line)] bg-[var(--otari-surface)] px-5 py-3",children:[t.jsxs("div",{className:"flex items-center gap-2.5",children:[t.jsx("img",{src:"/favicon.svg",alt:"",className:"h-7 w-7 shrink-0"}),t.jsx("span",{className:"text-base font-semibold text-[var(--otari-ink)]",children:"Otari"})]}),t.jsx(P,{size:"sm",variant:"outline",onPress:e,"aria-label":"Sign out",children:"Sign out"})]}),t.jsx(ft,{}),t.jsx($e,{}),t.jsx(dt,{}),t.jsxs("div",{className:"flex min-h-0 flex-1",children:[t.jsxs("aside",{ref:r,style:{width:h},className:C("relative flex shrink-0 flex-col border-r border-[var(--otari-line)] bg-[var(--otari-surface)]",!m&&"transition-[width] duration-150"),children:[t.jsx("button",{type:"button",onClick:()=>l(d=>!d),"aria-label":n?"Expand sidebar":"Collapse sidebar","aria-pressed":n,title:n?"Expand sidebar":"Collapse sidebar",className:"absolute -right-3 top-4 z-30 flex h-6 w-6 items-center justify-center rounded-full border border-[var(--otari-line)] bg-[var(--otari-surface)] text-[var(--otari-muted)] shadow-sm transition-colors hover:border-[var(--otari-brand)] hover:text-[var(--otari-brand-dark)]",children:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",className:C("h-3.5 w-3.5 transition-transform",n&&"rotate-180"),children:t.jsx("path",{d:"M15 6l-6 6 6 6",strokeLinecap:"round",strokeLinejoin:"round"})})}),t.jsx("nav",{className:C("flex flex-col py-4",n?"px-2":"px-3"),children:vt.map((d,j)=>{const A=gt.filter(k=>k.section===d.key);return A.length===0?null:t.jsxs("div",{className:j>0?"mt-4":void 0,children:[!n&&d.label?t.jsx("div",{className:"px-3 pb-1 text-[11px] font-semibold tracking-wider text-[var(--otari-muted)] uppercase",children:d.label}):null,j>0&&(n||!d.label)?t.jsx("div",{className:"mx-1 mb-2 border-t border-[var(--otari-line)]"}):null,t.jsx("div",{className:"flex flex-col gap-1",children:A.map(k=>t.jsxs(le,{to:k.to,end:k.end,"aria-label":n?k.label:void 0,title:n?k.label:void 0,className:({isActive:je})=>C("flex items-center rounded-lg py-2 text-sm font-medium transition-colors",n?"justify-center px-0":"gap-3 px-3",je?"bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]":"text-[var(--otari-muted)] hover:bg-[var(--otari-bg)] hover:text-[var(--otari-ink)]"),children:[k.icon,n?null:k.label]},k.to))})]},d.key)})}),t.jsxs("a",{href:"https://otari.ai",target:"_blank",rel:"noreferrer",title:"otari.ai — the hosted Otari gateway",className:C("mt-auto mb-3 flex items-center rounded-lg py-2 text-xs font-medium text-[var(--otari-muted)] transition-colors hover:bg-[var(--otari-bg)] hover:text-[var(--otari-brand-dark)]",n?"mx-2 justify-center px-0":"mx-3 gap-2 px-3"),children:[t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-4 w-4 shrink-0",children:t.jsx("path",{d:"M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z",strokeLinejoin:"round"})}),n?null:t.jsxs("span",{className:"flex-1",children:["otari.ai ",t.jsx("span",{"aria-hidden":!0,children:"↗"})]})]}),n?null:t.jsx("div",{role:"separator","aria-orientation":"vertical","aria-label":"Resize sidebar","aria-valuenow":Math.round(s),"aria-valuemin":ye,"aria-valuemax":ve,tabIndex:0,onPointerDown:f,onPointerMove:u,onPointerUp:p,onKeyDown:g,className:C("absolute top-0 right-0 z-10 h-full w-1.5 cursor-col-resize touch-none transition-colors","hover:bg-[var(--otari-brand)] focus-visible:bg-[var(--otari-brand)] focus:outline-none",m?"bg-[var(--otari-brand)]":"bg-transparent")})]}),t.jsx("main",{className:"flex-1 overflow-y-auto",children:t.jsx("div",{className:"mx-auto flex max-w-[1800px] flex-col gap-6 px-6 py-6",children:t.jsx(Ne,{})})})]})]})}function bt(){const{login:e}=W(),[r,s]=o.useState(""),[a,n]=o.useState(null),[l,m]=o.useState(!1),c=async()=>{const f=r.trim();if(!(!f||l)){m(!0),n(null);try{await Fe(f)?e(f):n(new Error("Invalid master key."))}catch(u){n(u)}finally{m(!1)}}};return t.jsx("div",{className:"flex min-h-full items-center justify-center p-6",children:t.jsx(I,{className:"w-full max-w-md",children:t.jsxs(I.Content,{className:"flex flex-col gap-5 p-7",children:[t.jsxs("div",{className:"flex flex-col items-center gap-3 text-center",children:[t.jsx("img",{src:"/favicon.svg",alt:"Otari",className:"h-12 w-12"}),t.jsxs("div",{children:[t.jsx("h1",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"Otari Dashboard"}),t.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"Sign in with your master key to browse models, set pricing, and manage settings."})]})]}),t.jsxs("form",{className:"flex flex-col gap-4",onSubmit:f=>{f.preventDefault(),c()},children:[t.jsxs(Le,{value:r,onChange:f=>{s(f),a&&n(null)},type:"password",isRequired:!0,className:"flex flex-col gap-1",children:[t.jsx(ce,{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Master key"}),t.jsx(ue,{placeholder:"otari-mk-… or your master key",autoFocus:!0,autoComplete:"off"})]}),t.jsxs("details",{className:"text-xs text-[var(--otari-muted)]",children:[t.jsx("summary",{className:"cursor-pointer font-medium text-[var(--otari-brand-dark)]",children:"First run? Where to find your key"}),t.jsxs("p",{className:"mt-2 leading-relaxed",children:["If you did not set ",t.jsx("code",{children:"OTARI_MASTER_KEY"}),", Otari generated one and printed it to the server logs on startup. Look for the line ",t.jsx("code",{children:"Your master key:"})," (for example, run"," ",t.jsx("code",{children:"docker logs "}),") and paste it above."]})]}),t.jsx(lt,{error:a}),t.jsx(P,{type:"submit",variant:"primary",fullWidth:!0,isDisabled:!r.trim()||l,children:l?"Signing in…":"Sign in"})]}),t.jsx("p",{className:"text-center text-xs text-[var(--otari-muted)]",children:"The key is held only in this browser tab (session storage) and sent directly to this gateway."}),t.jsx("div",{className:"border-t border-[var(--otari-line)] pt-4 text-center",children:t.jsx(qe,{href:"/welcome",className:"text-sm font-medium text-[var(--otari-brand-dark)]",children:"New to Otari? Open the welcome guide"})})]})})})}const jt=o.lazy(async()=>({default:(await S(async()=>{const{ActivityPage:e}=await import("./ActivityPage-D3mR-Vcr.js");return{ActivityPage:e}},__vite__mapDeps([0,1,2,3,4]))).ActivityPage})),wt=o.lazy(async()=>({default:(await S(async()=>{const{AliasesPage:e}=await import("./AliasesPage-DJtTC87k.js");return{AliasesPage:e}},__vite__mapDeps([5,1,2,6,4,3]))).AliasesPage})),St=o.lazy(async()=>({default:(await S(async()=>{const{BudgetsPage:e}=await import("./BudgetsPage-BCAQ5J9W.js");return{BudgetsPage:e}},__vite__mapDeps([7,1,2,6,4,3]))).BudgetsPage})),kt=o.lazy(async()=>({default:(await S(async()=>{const{KeysPage:e}=await import("./KeysPage-DIt3Gp89.js");return{KeysPage:e}},__vite__mapDeps([8,1,2,6,4,9,3]))).KeysPage})),Pt=o.lazy(async()=>({default:(await S(async()=>{const{ModelsPage:e}=await import("./ModelsPage-BR6bzhht.js");return{ModelsPage:e}},__vite__mapDeps([10,1,2,3,4]))).ModelsPage})),Et=o.lazy(async()=>({default:(await S(async()=>{const{OverviewIndex:e}=await import("./OverviewPage-_Gja1ZBt.js");return{OverviewIndex:e}},__vite__mapDeps([11,1,2,12,13,4,3]))).OverviewIndex})),Nt=o.lazy(async()=>({default:(await S(async()=>{const{ProvidersPage:e}=await import("./ProvidersPage-B6Y7usRU.js");return{ProvidersPage:e}},__vite__mapDeps([14,1,2,6,4,3]))).ProvidersPage})),Ct=o.lazy(async()=>({default:(await S(async()=>{const{SettingsPage:e}=await import("./SettingsPage-CVx7wSlF.js");return{SettingsPage:e}},__vite__mapDeps([15,1,2,4]))).SettingsPage})),Tt=o.lazy(async()=>({default:(await S(async()=>{const{ToolsGuardrailsPage:e}=await import("./ToolsGuardrailsPage-v1VWfWQq.js");return{ToolsGuardrailsPage:e}},__vite__mapDeps([16,1,2,4]))).ToolsGuardrailsPage})),_t=o.lazy(async()=>({default:(await S(async()=>{const{UsagePage:e}=await import("./UsagePage-DtQni_qk.js");return{UsagePage:e}},__vite__mapDeps([17,1,2,12,13,4,3]))).UsagePage})),At=o.lazy(async()=>({default:(await S(async()=>{const{UsersPage:e}=await import("./UsersPage-BDaNeswz.js");return{UsersPage:e}},__vite__mapDeps([18,1,2,6,4,9,3]))).UsersPage}));function w(e){return t.jsx(o.Suspense,{fallback:t.jsx("div",{role:"status",children:"Loading page…"}),children:e})}function It(){const{isAuthenticated:e}=W();return e?t.jsx(Ce,{children:t.jsx(Te,{children:t.jsxs(b,{element:t.jsx(pt,{}),children:[t.jsx(b,{index:!0,element:w(t.jsx(Et,{}))}),t.jsx(b,{path:"providers",element:w(t.jsx(Nt,{}))}),t.jsx(b,{path:"keys",element:w(t.jsx(kt,{}))}),t.jsx(b,{path:"users",element:w(t.jsx(At,{}))}),t.jsx(b,{path:"budgets",element:w(t.jsx(St,{}))}),t.jsx(b,{path:"activity",element:w(t.jsx(jt,{}))}),t.jsx(b,{path:"usage",element:w(t.jsx(_t,{}))}),t.jsx(b,{path:"models",element:w(t.jsx(Pt,{}))}),t.jsx(b,{path:"aliases",element:w(t.jsx(wt,{}))}),t.jsx(b,{path:"tools",element:w(t.jsx(Tt,{}))}),t.jsx(b,{path:"settings",element:w(t.jsx(Ct,{}))}),t.jsx(b,{path:"*",element:t.jsx(_e,{to:"/",replace:!0})})]})})}):t.jsx(bt,{})}function Lt({children:e}){const[r]=o.useState(()=>new ke({defaultOptions:{queries:{refetchOnWindowFocus:!1,retry:(s,a)=>a instanceof E&&(a.status===401||a.status===403)?!1:s<2}}}));return t.jsx(Pe,{client:r,children:t.jsx(Me,{children:e})})}const be=document.getElementById("root");if(!be)throw new Error("Root element #root not found");Re.createRoot(be).render(t.jsx(o.StrictMode,{children:t.jsx(Lt,{children:t.jsx(It,{})})}));export{Yt as $,Ir as A,We as B,Mr as C,_r as D,lt as E,Ur as F,or as G,lr as H,ct as I,Mt as J,$t as K,Rr as L,Lr as M,Fr as N,it as O,Kr as P,Or as Q,Qt as R,Dr as S,Wt as T,Jt as U,Ge as V,Ht as W,zt as X,Vt as Y,Bt as Z,Ut as _,Er as a,cr as a0,ur as a1,dr as a2,rr as a3,W as a4,Gt as a5,sr as a6,nr as a7,ar as a8,qr as a9,Pr as aa,Sr as ab,Nr as b,Cr as c,Br as d,Kt as e,Zt as f,tr as g,ot as h,er as i,vr as j,pr as k,br as l,jr as m,kr as n,gr as o,mr as p,hr as q,xr as r,yr as s,fr as t,wr as u,Ft as v,ir as w,Xt as x,Ar as y,Tr as z}; diff --git a/src/gateway/static/dashboard/assets/index-hDVMDLdX.js b/src/gateway/static/dashboard/assets/index-hDVMDLdX.js new file mode 100644 index 00000000..8e5edfb3 --- /dev/null +++ b/src/gateway/static/dashboard/assets/index-hDVMDLdX.js @@ -0,0 +1,2 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/ActivityPage-Ixi7_M5Z.js","assets/tanstack-query-1t81HyiD.js","assets/react-dgEcD0HR.js","assets/Table-CLdjdyTx.js","assets/heroui-BX6JwHY-.js","assets/AliasesPage-BZiGtRPE.js","assets/Field-HzRk1KDP.js","assets/BudgetsPage-D0M-Cd5s.js","assets/KeysPage-CQj72SEP.js","assets/ModelScopeControl-DX341Q9L.js","assets/ModelsPage-BIdxI_hW.js","assets/OverviewPage-CtvPFoWc.js","assets/charts-Cr3Dij9t.js","assets/recharts-CR3TAEof.js","assets/ProvidersPage-DimvEH7H.js","assets/SettingsPage-iRP-TsYX.js","assets/ToolsGuardrailsPage-B3QvV7KI.js","assets/UsagePage-CsWlUWCg.js","assets/UsersPage-ETfq5MXh.js"])))=>i.map(i=>d[i]); +var be=Object.defineProperty;var je=(e,r,s)=>r in e?be(e,r,{enumerable:!0,configurable:!0,writable:!0,value:s}):e[r]=s;var ee=(e,r,s)=>je(e,typeof r!="symbol"?r+"":r,s);import{u as x,j as t,a as v,b as f,k as Q,Q as we,c as Se}from"./tanstack-query-1t81HyiD.js";import{d as ke,r as o,N as ie,O as Pe,H as Ee,e as Ne,f as b,h as Ce}from"./react-dgEcD0HR.js";import{C as I,L as oe,I as le,a as Te,b as _e,B as P,d as L,c as C,T as Ae,e as Le}from"./heroui-BX6JwHY-.js";(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))a(n);new MutationObserver(n=>{for(const l of n)if(l.type==="childList")for(const d of l.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&a(d)}).observe(document,{childList:!0,subtree:!0});function s(n){const l={};return n.integrity&&(l.integrity=n.integrity),n.referrerPolicy&&(l.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?l.credentials="include":n.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function a(n){if(n.ep)return;n.ep=!0;const l=s(n);fetch(n.href,l)}})();var Ie=ke();const qe="modulepreload",Re=function(e){return"/"+e},te={},S=function(r,s,a){let n=Promise.resolve();if(s&&s.length>0){let d=function(h){return Promise.all(h.map(p=>Promise.resolve(p).then(g=>({status:"fulfilled",value:g}),g=>({status:"rejected",reason:g}))))};document.getElementsByTagName("link");const u=document.querySelector("meta[property=csp-nonce]"),y=(u==null?void 0:u.nonce)||(u==null?void 0:u.getAttribute("nonce"));n=d(s.map(h=>{if(h=Re(h),h in te)return;te[h]=!0;const p=h.endsWith(".css"),g=p?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${h}"]${g}`))return;const m=document.createElement("link");if(m.rel=p?"stylesheet":qe,p||(m.as="script"),m.crossOrigin="",m.href=h,y&&m.setAttribute("nonce",y),document.head.appendChild(m),p)return new Promise((c,j)=>{m.addEventListener("load",c),m.addEventListener("error",()=>j(new Error(`Unable to preload CSS for ${h}`)))})}))}function l(d){const u=new Event("vite:preloadError",{cancelable:!0});if(u.payload=d,window.dispatchEvent(u),!u.defaultPrevented)throw d}return n.then(d=>{for(const u of d||[])u.status==="rejected"&&l(u.reason);return r().catch(l)})};class E extends Error{constructor(s,a){super(a);ee(this,"status");this.name="ApiError",this.status=s}}let q=null;function re(e){q=e}async function $(e){try{const r=await e.json();if(typeof r.detail=="string")return r.detail;if(r.detail!=null)return JSON.stringify(r.detail)}catch{}return e.statusText||`Request failed (${e.status})`}async function Oe(e){let r;try{r=await fetch("/v1/auth/session",{method:"POST",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify({master_key:e})})}catch{throw new E(0,"Network error: could not reach the gateway.")}if(r.status===401||r.status===403)return!1;if(!r.ok)throw new E(r.status,await $(r));return!0}async function De(){try{await fetch("/v1/auth/session",{method:"DELETE"})}catch{}}async function i(e,r={}){const s=new Headers(r.headers);s.set("Accept","application/json"),r.body!=null&&!s.has("Content-Type")&&s.set("Content-Type","application/json");let a;try{a=await fetch(e,{...r,headers:s})}catch{throw new E(0,"Network error: could not reach the gateway.")}if(a.status===401||a.status===403)throw q==null||q(),new E(a.status,await $(a));if(!a.ok)throw new E(a.status,await $(a));if(a.status!==204)return await a.json()}const z="otari.dashboard.hasSession",ce=o.createContext(null);function Fe(){try{return window.localStorage.getItem(z)==="1"}catch{return!1}}function Me({children:e}){const r=x(),[s,a]=o.useState(Fe),n=o.useCallback(()=>{De(),a(!1),r.clear();try{window.localStorage.removeItem(z)}catch{}},[r]),l=o.useCallback(()=>{r.clear(),a(!0);try{window.localStorage.setItem(z,"1")}catch{}},[r]);o.useEffect(()=>(re(n),()=>re(null)),[n]);const d=o.useMemo(()=>({isAuthenticated:s,login:l,logout:n}),[s,l,n]);return t.jsx(ce.Provider,{value:d,children:e})}function V(){const e=o.useContext(ce);if(!e)throw new Error("useAuth must be used within an AuthProvider");return e}function Ke(e){return e instanceof E&&e.status===0}function Ue(){const e=x(),[r,s]=o.useState(!1);return o.useEffect(()=>{const a=e.getQueryCache(),n=()=>a.getAll().some(l=>l.state.status==="error"&&Ke(l.state.error));return s(n()),a.subscribe(()=>s(n()))},[e]),r}function Be(){return Ue()?t.jsxs("div",{role:"alert","aria-live":"assertive",className:"fixed right-4 bottom-4 z-50 flex max-w-sm items-start gap-2.5 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-lg",children:[t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2","aria-hidden":!0,className:"mt-0.5 h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M12 9v4M12 17h.01",strokeLinecap:"round",strokeLinejoin:"round"}),t.jsx("path",{d:"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z",strokeLinejoin:"round"})]}),t.jsxs("span",{children:[t.jsx("strong",{className:"font-semibold",children:"Can’t reach the gateway."})," The backend isn’t responding; data won’t load or save until the connection is restored."]})]}):null}const N="models",O="pricing",ue="settings",de="tool-settings",H="aliases",W="discoverable",G="providers",J="provider-health",me="stored-providers",$e="model-metadata",ze="build",T="keys",_="budgets",fe="users",Y="usage",Qe=6e4,se=60*6e4;function Dt(){return v({queryKey:[N],queryFn:()=>i("/v1/models"),staleTime:6e4})}function Ve(){return v({queryKey:[ze],queryFn:()=>i("/dashboard-build.json"),refetchInterval:Qe,refetchOnWindowFocus:!0,staleTime:0,retry:!1})}function Ft(){return v({queryKey:[W],queryFn:()=>i("/v1/models/discoverable"),staleTime:5*6e4})}function Mt(){return v({queryKey:[G],queryFn:()=>i("/v1/providers"),staleTime:5*6e4})}function Kt(){return v({queryKey:["provider-catalog"],queryFn:()=>i("/v1/providers/catalog"),staleTime:1/0})}function Ut(e){return v({queryKey:["provider-catalog",e],queryFn:()=>i(`/v1/providers/catalog/${encodeURIComponent(e)}`),enabled:e!=="",staleTime:1/0})}function Bt(){return v({queryKey:[J],queryFn:()=>i("/v1/providers/health"),staleTime:se,refetchInterval:se})}function $t(){const e=x();return f({mutationFn:()=>i("/v1/providers/health?refresh=true"),onSuccess:r=>e.setQueryData([J],r)})}function zt(){return v({queryKey:[me],queryFn:()=>i("/v1/provider-credentials"),staleTime:6e4})}function D(e){e.invalidateQueries({queryKey:[me]}),e.invalidateQueries({queryKey:[G]}),e.invalidateQueries({queryKey:[N]}),e.invalidateQueries({queryKey:[W]}),e.invalidateQueries({queryKey:[J]})}function Qt(){const e=x();return f({mutationFn:r=>i("/v1/provider-credentials",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>D(e)})}function Vt(){const e=x();return f({mutationFn:({instance:r,body:s})=>i(`/v1/provider-credentials/${encodeURIComponent(r)}`,{method:"PATCH",body:JSON.stringify(s)}),onSuccess:()=>D(e)})}function Ht(){const e=x();return f({mutationFn:r=>i(`/v1/provider-credentials/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>D(e)})}function Wt(){const e=x();return f({mutationFn:()=>i("/v1/provider-credentials/reencrypt",{method:"POST"}),onSuccess:()=>D(e)})}function Gt(){return f({mutationFn:e=>i(`/v1/provider-credentials/${encodeURIComponent(e)}/test`,{method:"POST"})})}function Jt(){return f({mutationFn:e=>i("/v1/provider-credentials/test",{method:"POST",body:JSON.stringify(e)})})}function Yt(){return v({queryKey:[$e],queryFn:()=>i("/v1/models/metadata"),staleTime:10*6e4})}function Xt(){return v({queryKey:[H],queryFn:()=>i("/v1/aliases"),staleTime:6e4})}function Zt(){const e=x();return f({mutationFn:r=>i("/v1/aliases",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>{e.invalidateQueries({queryKey:[H]}),e.invalidateQueries({queryKey:[N]})}})}function er(){const e=x();return f({mutationFn:r=>i(`/v1/aliases/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>{e.invalidateQueries({queryKey:[H]}),e.invalidateQueries({queryKey:[N]})}})}function He(){return v({queryKey:[ue],queryFn:()=>i("/v1/settings"),staleTime:6e4})}function We(){const e=x();return f({mutationFn:r=>i("/v1/settings",{method:"PATCH",body:JSON.stringify(r)}),onSuccess:r=>{e.setQueryData([ue],r),e.invalidateQueries({queryKey:[N]}),e.invalidateQueries({queryKey:[W]})}})}function tr(){return f({mutationFn:()=>i("/v1/settings/master-key/rotate",{method:"POST"})})}function rr(){return v({queryKey:[de],queryFn:()=>i("/v1/tool-settings"),staleTime:6e4})}function sr(){const e=x();return f({mutationFn:r=>i("/v1/tool-settings",{method:"PATCH",body:JSON.stringify(r)}),onSuccess:r=>{e.setQueryData([de],r)}})}function nr(){return f({mutationFn:({service:e,url:r})=>i(`/v1/tool-settings/${encodeURIComponent(e)}/test`,{method:"POST",body:JSON.stringify({url:r})})})}const F=1e3,Ge=100;async function Je(){const e=[];for(let r=0;ri("/v1/pricing",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>{e.invalidateQueries({queryKey:[O]}),e.invalidateQueries({queryKey:[N]})}})}function or(){const e=x();return f({mutationFn:r=>i(`/v1/pricing/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>{e.invalidateQueries({queryKey:[O]}),e.invalidateQueries({queryKey:[N]})}})}function lr(){return f({mutationFn:()=>i("/v1/pricing/refresh",{method:"POST"})})}function cr(){const e=x();return f({mutationFn:()=>i("/v1/pricing/refresh/confirm",{method:"POST"}),onSuccess:()=>{e.invalidateQueries({queryKey:[O]}),e.invalidateQueries({queryKey:[N]}),e.invalidateQueries({queryKey:[G]})}})}function ur(){return f({mutationFn:()=>i("/v1/pricing/refresh/reject",{method:"POST"})})}const M=1e3,Ye=100;async function Xe(){const e=[];for(let r=0;ri("/v1/keys",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>void e.invalidateQueries({queryKey:[T]})})}function fr(){const e=x();return f({mutationFn:({id:r,body:s})=>i(`/v1/keys/${encodeURIComponent(r)}`,{method:"PATCH",body:JSON.stringify(s)}),onSuccess:()=>void e.invalidateQueries({queryKey:[T]})})}function hr(){const e=x();return f({mutationFn:r=>i(`/v1/keys/${encodeURIComponent(r)}/rotate`,{method:"POST"}),onSuccess:()=>void e.invalidateQueries({queryKey:[T]})})}function xr(){const e=x();return f({mutationFn:r=>i(`/v1/keys/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>void e.invalidateQueries({queryKey:[T]})})}const K=1e3,Ze=100;async function et(){const e=[];for(let r=0;ri(`/v1/budgets/${encodeURIComponent(e)}/reset-logs`),enabled:e!==null,staleTime:6e4})}function gr(){const e=x();return f({mutationFn:r=>i("/v1/budgets",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>void e.invalidateQueries({queryKey:[_]})})}function pr(){const e=x();return f({mutationFn:({id:r,body:s})=>i(`/v1/budgets/${encodeURIComponent(r)}`,{method:"PATCH",body:JSON.stringify(s)}),onSuccess:()=>void e.invalidateQueries({queryKey:[_]})})}function br(){const e=x();return f({mutationFn:r=>i(`/v1/budgets/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>void e.invalidateQueries({queryKey:[_]})})}const U=1e3,tt=100;async function rt(){const e=[];for(let r=0;ri("/v1/users",{method:"POST",body:JSON.stringify(r)}),onSuccess:()=>X(e)})}function Sr(){const e=x();return f({mutationFn:({id:r,body:s})=>i(`/v1/users/${encodeURIComponent(r)}`,{method:"PATCH",body:JSON.stringify(s)}),onSuccess:()=>X(e)})}function kr(){const e=x();return f({mutationFn:r=>i(`/v1/users/${encodeURIComponent(r)}`,{method:"DELETE"}),onSuccess:()=>{X(e),e.invalidateQueries({queryKey:[T]})}})}function Z(e){const r=new URLSearchParams;return e.start_date&&r.set("start_date",e.start_date),e.end_date&&r.set("end_date",e.end_date),e.status&&r.set("status",e.status),e.model&&r.set("model",e.model),e.endpoint&&r.set("endpoint",e.endpoint),e.user_id&&r.set("user_id",e.user_id),r}function Pr(e,r,s){return v({queryKey:[Y,"list",e,r,s],queryFn:()=>{const a=Z(e);return a.set("skip",String(r*s)),a.set("limit",String(s)),i(`/v1/usage?${a.toString()}`)},placeholderData:Q,staleTime:1e4})}function Er(e){return v({queryKey:[Y,"count",e],queryFn:()=>i(`/v1/usage/count?${Z(e).toString()}`),placeholderData:Q,staleTime:1e4})}function Nr(e,r,s=!0){return v({queryKey:[Y,"summary",e,r],queryFn:()=>{const a=Z(e);return a.set("bucket",r),i(`/v1/usage/summary?${a.toString()}`)},enabled:s,placeholderData:Q,staleTime:3e4})}function Cr(e){return e==null?"0":new Intl.NumberFormat("en-US").format(e)}function Tr(e){if(e==null)return"$0.00";const r=e!==0&&Math.abs(e)<.01?4:2;return new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",minimumFractionDigits:2,maximumFractionDigits:r}).format(e)}function _r(e){if(e==null)return"—";if(e>=1e6){const r=e/1e6;return`${Number.isInteger(r)?r:r.toFixed(1)}M`}if(e>=1e3){const r=Math.round(e/1e3);return r>=1e3?"1M":`${r}K`}return String(e)}const st=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function Ar(e){if(!e)return"—";const r=/^(\d{4})-(\d{2})/.exec(e);if(!r)return e;const s=Number(r[2])-1;return s<0||s>11?r[1]:`${st[s]} ${r[1]}`}const nt=new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:2});function Lr(e){return nt.format(e)}function Ir(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}function at(e){return`${(e*100).toFixed(1)}%`}function qr(e,r){return r===void 0||r===0?null:(e-r)/r}function Rr(e,r=Date.now()){if(!e)return"never";const s=new Date(e);if(Number.isNaN(s.getTime()))return e;const a=Math.round((r-s.getTime())/1e3),n=a<0,l=Math.abs(a),d=[["second",60],["minute",60],["hour",24],["day",30],["month",12],["year",Number.POSITIVE_INFINITY]];let u=l,y="second";for(const[p,g]of d){if(y=p,u0?"▲":e<0?"▼":"•";return t.jsxs("span",{className:"text-[var(--otari-muted)]",children:[r," ",at(Math.abs(e))," vs prev"]})}function it(e){return e instanceof E||e instanceof Error?e.message:"Something went wrong."}function ot({error:e}){return e?t.jsx("div",{role:"alert",className:"rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700",children:it(e)}):null}function lt({tone:e="info",children:r}){const s=e==="warning"?"border-amber-200 bg-amber-50 text-amber-800":"border-[var(--otari-brand)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return t.jsx("div",{className:`rounded-lg border px-4 py-3 text-sm ${s}`,children:r})}function Fr({title:e,description:r,action:s}){return t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsxs("div",{children:[t.jsx("h1",{className:"text-xl font-semibold text-[var(--otari-ink)]",children:e}),r?t.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:r}):null]}),s?t.jsx("div",{className:"flex flex-wrap gap-2",children:s}):null]})}function Mr({children:e,confirmLabel:r,onConfirm:s,isPending:a}){const[n,l]=o.useState(!1);return n?t.jsxs("span",{className:"inline-flex items-center gap-1",children:[t.jsx(P,{size:"sm",variant:"danger",isDisabled:a,onPress:s,children:r}),t.jsx(P,{size:"sm",variant:"ghost",isDisabled:a,onPress:()=>l(!1),children:"Cancel"})]}):t.jsx(P,{size:"sm",variant:"danger-soft",onPress:()=>l(!0),children:e})}const ct="rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 text-sm text-[var(--otari-ink)] focus:border-[var(--otari-brand)] focus:outline-none";function Kr({id:e,label:r,ariaLabel:s,value:a,onChange:n,options:l,children:d,disabled:u}){const y=o.useId(),h=e??(r?y:void 0),p=t.jsx("select",{id:h,"aria-label":r?void 0:s,value:a,disabled:u,onChange:g=>n(g.target.value),className:ct,children:l?l.map(g=>t.jsx("option",{value:g.value,children:g.label},g.value)):d});return r?t.jsxs("div",{className:"flex flex-col gap-1",children:[t.jsx("label",{htmlFor:h,className:"text-xs font-medium text-[var(--otari-muted)]",children:r}),p]}):p}function Ur({label:e,value:r,onChange:s,options:a,placeholder:n,maxVisible:l=50,allowsCustom:d=!1}){const u=m=>{var c;return((c=a.find(j=>j.value===m))==null?void 0:c.label)??m},[y,h]=o.useState(()=>u(r));o.useEffect(()=>{h(u(r))},[r]);const p=y.trim().toLowerCase(),g=a.filter(m=>!p||m.value.toLowerCase().includes(p)||m.label.toLowerCase().includes(p)).slice(0,l);return t.jsxs(I.Root,{allowsEmptyCollection:!0,allowsCustomValue:d,menuTrigger:"focus",inputValue:y,onInputChange:m=>{h(m),d?s(m.trim()):m.trim()===""&&s("")},onSelectionChange:m=>{m!=null&&s(String(m))},className:"flex flex-col gap-1",children:[t.jsx(oe,{className:"text-xs font-medium text-[var(--otari-muted)]",children:e}),t.jsxs(I.InputGroup,{children:[t.jsx(le,{placeholder:n,autoComplete:"off",onFocus:m=>m.currentTarget.select()}),t.jsx(I.Trigger,{})]}),t.jsx(I.Popover,{children:t.jsx(Te,{items:g,className:"max-h-72 overflow-auto",children:m=>t.jsx(_e,{id:m.value,textValue:m.label,children:m.label})})})]})}function ut(){var l;const e=He(),r=We(),[s,a]=o.useState(!1);return!(((l=e.data)==null?void 0:l.require_pricing)===!0&&e.data.default_pricing===!1)||s?null:t.jsx("div",{className:"shrink-0 px-6 pt-3",children:t.jsx(lt,{tone:"warning",children:t.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("span",{children:["Requests are rejected until pricing is set (",t.jsx("code",{children:"require_pricing"})," is on). Enable default pricing to meter new models with public rates right away."]}),t.jsxs("span",{className:"flex items-center gap-2",children:[t.jsx(P,{size:"sm",variant:"primary",isDisabled:r.isPending,onPress:()=>r.mutate({default_pricing:!0}),children:r.isPending?"Enabling…":"Enable default pricing"}),t.jsx(P,{size:"sm",variant:"ghost",onPress:()=>a(!0),children:"Dismiss"})]})]})})})}function dt(){const{data:e}=Ve(),r=o.useRef(null);return e&&r.current===null&&(r.current=e.build),e!=null&&r.current!=null&&e.build!==r.current}function mt(){const e=dt(),[r,s]=o.useState(!1);return!e||r?null:t.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center",children:t.jsxs("div",{role:"status",className:"pointer-events-auto mt-1.5 flex items-center gap-3 rounded-full border border-[var(--otari-brand)] bg-[var(--otari-brand-tint)] py-1.5 pr-1.5 pl-4 text-sm text-[var(--otari-brand-dark)] shadow-md",children:[t.jsxs("span",{children:[t.jsx("strong",{className:"font-semibold",children:"An update is available."})," Reloading keeps you signed in."]}),t.jsx(P,{size:"sm",variant:"primary",onPress:()=>window.location.reload(),children:"Update now"}),t.jsx(P,{size:"sm",variant:"ghost",onPress:()=>s(!0),children:"Later"})]})})}const he=200,xe=480,B=240,ft=60,ye="otari.dashboard.sidebarWidth",ve="otari.dashboard.sidebarCollapsed",ae=16,R=e=>Math.min(xe,Math.max(he,e));function ht(){if(typeof window>"u")return B;try{const e=window.localStorage.getItem(ye),r=e?Number.parseInt(e,10):Number.NaN;return Number.isNaN(r)?B:R(r)}catch{return B}}function xt(){if(typeof window>"u")return!1;try{return window.localStorage.getItem(ve)==="1"}catch{return!1}}const yt=[{key:"home"},{key:"observability",label:"Observability"},{key:"catalog",label:"Catalog"},{key:"access",label:"Access"},{key:"system"}],vt=[{to:"/",section:"home",label:"Overview",end:!0,icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("rect",{x:"3.5",y:"3.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),t.jsx("rect",{x:"13.5",y:"3.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),t.jsx("rect",{x:"3.5",y:"13.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),t.jsx("rect",{x:"13.5",y:"13.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"})]})},{to:"/activity",section:"observability",label:"Activity",icon:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:t.jsx("path",{d:"M3 12h4l2.5-6 4 12 2.5-6H21",strokeLinecap:"round",strokeLinejoin:"round"})})},{to:"/usage",section:"observability",label:"Usage",icon:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:t.jsx("path",{d:"M4 20V10M10 20V4M16 20v-7M22 20H2",strokeLinecap:"round",strokeLinejoin:"round"})})},{to:"/providers",section:"catalog",label:"Providers",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("rect",{x:"3.5",y:"4.5",width:"17",height:"6",rx:"1.5",strokeLinejoin:"round"}),t.jsx("rect",{x:"3.5",y:"13.5",width:"17",height:"6",rx:"1.5",strokeLinejoin:"round"}),t.jsx("path",{d:"M7 7.5h.01M7 16.5h.01",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/users",section:"access",label:"Users",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("circle",{cx:"9",cy:"8",r:"3.2",strokeLinejoin:"round"}),t.jsx("path",{d:"M3.5 19a5.5 5.5 0 0 1 11 0",strokeLinecap:"round",strokeLinejoin:"round"}),t.jsx("path",{d:"M16 5.2a3.2 3.2 0 0 1 0 5.6M17.5 19a5.5 5.5 0 0 0-3-4.9",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/keys",section:"access",label:"API keys",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("circle",{cx:"7.5",cy:"15.5",r:"3.5"}),t.jsx("path",{d:"M10 13l7-7M14 5l3 3M16.5 7.5l2-2",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/budgets",section:"access",label:"Budgets",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M3 7.5A1.5 1.5 0 0 1 4.5 6H18a1.5 1.5 0 0 1 1.5 1.5V9",strokeLinejoin:"round"}),t.jsx("rect",{x:"3",y:"7.5",width:"18",height:"12",rx:"1.5",strokeLinejoin:"round"}),t.jsx("path",{d:"M16 13.5h.01",strokeLinecap:"round",strokeLinejoin:"round"}),t.jsx("path",{d:"M21 12v3h-3.5a1.5 1.5 0 0 1 0-3H21z",strokeLinejoin:"round"})]})},{to:"/models",section:"catalog",label:"Models",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z",strokeLinejoin:"round"}),t.jsx("path",{d:"M12 12l8-4.5M12 12v9M12 12L4 7.5",strokeLinejoin:"round"})]})},{to:"/aliases",section:"catalog",label:"Aliases",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M20.6 13.4L13.4 20.6a2 2 0 0 1-2.8 0l-7-7A2 2 0 0 1 3 12.2V5a2 2 0 0 1 2-2h7.2a2 2 0 0 1 1.4.6l7 7a2 2 0 0 1 0 2.8z",strokeLinejoin:"round"}),t.jsx("circle",{cx:"7.5",cy:"7.5",r:"1.5"})]})},{to:"/tools",section:"system",label:"Tools & Guardrails",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("path",{d:"M14.7 6.3a4 4 0 0 1 5 5l-8.4 8.4a2 2 0 0 1-2.8 0l-2.2-2.2a2 2 0 0 1 0-2.8z",strokeLinejoin:"round"}),t.jsx("path",{d:"M12 9 5 16",strokeLinecap:"round"})]})},{to:"/settings",section:"system",label:"Settings",icon:t.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[t.jsx("circle",{cx:"12",cy:"12",r:"3"}),t.jsx("path",{d:"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z",strokeLinejoin:"round"})]})}];function gt(){const{logout:e}=V(),r=o.useRef(null),[s,a]=o.useState(ht),[n,l]=o.useState(xt),[d,u]=o.useState(!1);o.useEffect(()=>{const c=window.setTimeout(()=>{try{window.localStorage.setItem(ye,String(Math.round(s)))}catch{}},200);return()=>window.clearTimeout(c)},[s]),o.useEffect(()=>{try{window.localStorage.setItem(ve,n?"1":"0")}catch{}},[n]);const y=o.useCallback(c=>{c.preventDefault(),c.currentTarget.setPointerCapture(c.pointerId),u(!0)},[]),h=o.useCallback(c=>{var A;if(!c.currentTarget.hasPointerCapture(c.pointerId))return;const j=((A=r.current)==null?void 0:A.getBoundingClientRect().left)??0;a(R(c.clientX-j))},[]),p=o.useCallback(c=>{c.currentTarget.hasPointerCapture(c.pointerId)&&c.currentTarget.releasePointerCapture(c.pointerId),u(!1)},[]),g=o.useCallback(c=>{c.key==="ArrowLeft"?(c.preventDefault(),a(j=>R(j-ae))):c.key==="ArrowRight"&&(c.preventDefault(),a(j=>R(j+ae)))},[]),m=n?ft:s;return t.jsxs("div",{className:C("relative flex h-full flex-col overflow-hidden",d&&"cursor-col-resize select-none"),children:[t.jsxs("header",{className:"flex shrink-0 items-center justify-between border-b border-[var(--otari-line)] bg-[var(--otari-surface)] px-5 py-3",children:[t.jsxs("div",{className:"flex items-center gap-2.5",children:[t.jsx("img",{src:"/favicon.svg",alt:"",className:"h-7 w-7 shrink-0"}),t.jsx("span",{className:"text-base font-semibold text-[var(--otari-ink)]",children:"Otari"})]}),t.jsx(P,{size:"sm",variant:"outline",onPress:e,"aria-label":"Sign out",children:"Sign out"})]}),t.jsx(mt,{}),t.jsx(Be,{}),t.jsx(ut,{}),t.jsxs("div",{className:"flex min-h-0 flex-1",children:[t.jsxs("aside",{ref:r,style:{width:m},className:C("relative flex shrink-0 flex-col border-r border-[var(--otari-line)] bg-[var(--otari-surface)]",!d&&"transition-[width] duration-150"),children:[t.jsx("button",{type:"button",onClick:()=>l(c=>!c),"aria-label":n?"Expand sidebar":"Collapse sidebar","aria-pressed":n,title:n?"Expand sidebar":"Collapse sidebar",className:"absolute -right-3 top-4 z-30 flex h-6 w-6 items-center justify-center rounded-full border border-[var(--otari-line)] bg-[var(--otari-surface)] text-[var(--otari-muted)] shadow-sm transition-colors hover:border-[var(--otari-brand)] hover:text-[var(--otari-brand-dark)]",children:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",className:C("h-3.5 w-3.5 transition-transform",n&&"rotate-180"),children:t.jsx("path",{d:"M15 6l-6 6 6 6",strokeLinecap:"round",strokeLinejoin:"round"})})}),t.jsx("nav",{className:C("flex flex-col py-4",n?"px-2":"px-3"),children:yt.map((c,j)=>{const A=vt.filter(k=>k.section===c.key);return A.length===0?null:t.jsxs("div",{className:j>0?"mt-4":void 0,children:[!n&&c.label?t.jsx("div",{className:"px-3 pb-1 text-[11px] font-semibold tracking-wider text-[var(--otari-muted)] uppercase",children:c.label}):null,j>0&&(n||!c.label)?t.jsx("div",{className:"mx-1 mb-2 border-t border-[var(--otari-line)]"}):null,t.jsx("div",{className:"flex flex-col gap-1",children:A.map(k=>t.jsxs(ie,{to:k.to,end:k.end,"aria-label":n?k.label:void 0,title:n?k.label:void 0,className:({isActive:pe})=>C("flex items-center rounded-lg py-2 text-sm font-medium transition-colors",n?"justify-center px-0":"gap-3 px-3",pe?"bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]":"text-[var(--otari-muted)] hover:bg-[var(--otari-bg)] hover:text-[var(--otari-ink)]"),children:[k.icon,n?null:k.label]},k.to))})]},c.key)})}),t.jsxs("a",{href:"https://otari.ai",target:"_blank",rel:"noreferrer",title:"otari.ai — the hosted Otari gateway",className:C("mt-auto mb-3 flex items-center rounded-lg py-2 text-xs font-medium text-[var(--otari-muted)] transition-colors hover:bg-[var(--otari-bg)] hover:text-[var(--otari-brand-dark)]",n?"mx-2 justify-center px-0":"mx-3 gap-2 px-3"),children:[t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-4 w-4 shrink-0",children:t.jsx("path",{d:"M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z",strokeLinejoin:"round"})}),n?null:t.jsxs("span",{className:"flex-1",children:["otari.ai ",t.jsx("span",{"aria-hidden":!0,children:"↗"})]})]}),n?null:t.jsx("div",{role:"separator","aria-orientation":"vertical","aria-label":"Resize sidebar","aria-valuenow":Math.round(s),"aria-valuemin":he,"aria-valuemax":xe,tabIndex:0,onPointerDown:y,onPointerMove:h,onPointerUp:p,onKeyDown:g,className:C("absolute top-0 right-0 z-10 h-full w-1.5 cursor-col-resize touch-none transition-colors","hover:bg-[var(--otari-brand)] focus-visible:bg-[var(--otari-brand)] focus:outline-none",d?"bg-[var(--otari-brand)]":"bg-transparent")})]}),t.jsx("main",{className:"flex-1 overflow-y-auto",children:t.jsx("div",{className:"mx-auto flex max-w-[1800px] flex-col gap-6 px-6 py-6",children:t.jsx(Pe,{})})})]})]})}function pt(){const{login:e}=V(),[r,s]=o.useState(""),[a,n]=o.useState(null),[l,d]=o.useState(!1),u=async()=>{const y=r.trim();if(!(!y||l)){d(!0),n(null);try{await Oe(y)?e():n(new Error("Invalid master key."))}catch(h){n(h)}finally{d(!1)}}};return t.jsx("div",{className:"flex min-h-full items-center justify-center p-6",children:t.jsx(L,{className:"w-full max-w-md",children:t.jsxs(L.Content,{className:"flex flex-col gap-5 p-7",children:[t.jsxs("div",{className:"flex flex-col items-center gap-3 text-center",children:[t.jsx("img",{src:"/favicon.svg",alt:"Otari",className:"h-12 w-12"}),t.jsxs("div",{children:[t.jsx("h1",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"Otari Dashboard"}),t.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"Sign in with your master key to browse models, set pricing, and manage settings."})]})]}),t.jsxs("form",{className:"flex flex-col gap-4",onSubmit:y=>{y.preventDefault(),u()},children:[t.jsxs(Ae,{value:r,onChange:y=>{s(y),a&&n(null)},type:"password",isRequired:!0,className:"flex flex-col gap-1",children:[t.jsx(oe,{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Master key"}),t.jsx(le,{placeholder:"otari-mk-… or your master key",autoFocus:!0,autoComplete:"off"})]}),t.jsxs("details",{className:"text-xs text-[var(--otari-muted)]",children:[t.jsx("summary",{className:"cursor-pointer font-medium text-[var(--otari-brand-dark)]",children:"First run? Where to find your key"}),t.jsxs("p",{className:"mt-2 leading-relaxed",children:["If you did not set ",t.jsx("code",{children:"OTARI_MASTER_KEY"}),", Otari generated one and printed it to the server logs on startup. Look for the line ",t.jsx("code",{children:"Your master key:"})," (for example, run"," ",t.jsx("code",{children:"docker logs "}),") and paste it above."]})]}),t.jsx(ot,{error:a}),t.jsx(P,{type:"submit",variant:"primary",fullWidth:!0,isDisabled:!r.trim()||l,children:l?"Signing in…":"Sign in"})]}),t.jsx("p",{className:"text-center text-xs text-[var(--otari-muted)]",children:"The key is sent once to this gateway and exchanged for a session cookie; it is never stored in the browser."}),t.jsx("div",{className:"border-t border-[var(--otari-line)] pt-4 text-center",children:t.jsx(Le,{href:"/welcome",className:"text-sm font-medium text-[var(--otari-brand-dark)]",children:"New to Otari? Open the welcome guide"})})]})})})}const bt=o.lazy(async()=>({default:(await S(async()=>{const{ActivityPage:e}=await import("./ActivityPage-Ixi7_M5Z.js");return{ActivityPage:e}},__vite__mapDeps([0,1,2,3,4]))).ActivityPage})),jt=o.lazy(async()=>({default:(await S(async()=>{const{AliasesPage:e}=await import("./AliasesPage-BZiGtRPE.js");return{AliasesPage:e}},__vite__mapDeps([5,1,2,6,4,3]))).AliasesPage})),wt=o.lazy(async()=>({default:(await S(async()=>{const{BudgetsPage:e}=await import("./BudgetsPage-D0M-Cd5s.js");return{BudgetsPage:e}},__vite__mapDeps([7,1,2,6,4,3]))).BudgetsPage})),St=o.lazy(async()=>({default:(await S(async()=>{const{KeysPage:e}=await import("./KeysPage-CQj72SEP.js");return{KeysPage:e}},__vite__mapDeps([8,1,2,6,4,9,3]))).KeysPage})),kt=o.lazy(async()=>({default:(await S(async()=>{const{ModelsPage:e}=await import("./ModelsPage-BIdxI_hW.js");return{ModelsPage:e}},__vite__mapDeps([10,1,2,3,4]))).ModelsPage})),Pt=o.lazy(async()=>({default:(await S(async()=>{const{OverviewIndex:e}=await import("./OverviewPage-CtvPFoWc.js");return{OverviewIndex:e}},__vite__mapDeps([11,1,2,12,13,4,3]))).OverviewIndex})),Et=o.lazy(async()=>({default:(await S(async()=>{const{ProvidersPage:e}=await import("./ProvidersPage-DimvEH7H.js");return{ProvidersPage:e}},__vite__mapDeps([14,1,2,6,4,3]))).ProvidersPage})),Nt=o.lazy(async()=>({default:(await S(async()=>{const{SettingsPage:e}=await import("./SettingsPage-iRP-TsYX.js");return{SettingsPage:e}},__vite__mapDeps([15,1,2,4]))).SettingsPage})),Ct=o.lazy(async()=>({default:(await S(async()=>{const{ToolsGuardrailsPage:e}=await import("./ToolsGuardrailsPage-B3QvV7KI.js");return{ToolsGuardrailsPage:e}},__vite__mapDeps([16,1,2,4]))).ToolsGuardrailsPage})),Tt=o.lazy(async()=>({default:(await S(async()=>{const{UsagePage:e}=await import("./UsagePage-CsWlUWCg.js");return{UsagePage:e}},__vite__mapDeps([17,1,2,12,13,4,3]))).UsagePage})),_t=o.lazy(async()=>({default:(await S(async()=>{const{UsersPage:e}=await import("./UsersPage-ETfq5MXh.js");return{UsersPage:e}},__vite__mapDeps([18,1,2,6,4,9,3]))).UsersPage}));function w(e){return t.jsx(o.Suspense,{fallback:t.jsx("div",{role:"status",children:"Loading page…"}),children:e})}function At(){const{isAuthenticated:e}=V();return e?t.jsx(Ee,{children:t.jsx(Ne,{children:t.jsxs(b,{element:t.jsx(gt,{}),children:[t.jsx(b,{index:!0,element:w(t.jsx(Pt,{}))}),t.jsx(b,{path:"providers",element:w(t.jsx(Et,{}))}),t.jsx(b,{path:"keys",element:w(t.jsx(St,{}))}),t.jsx(b,{path:"users",element:w(t.jsx(_t,{}))}),t.jsx(b,{path:"budgets",element:w(t.jsx(wt,{}))}),t.jsx(b,{path:"activity",element:w(t.jsx(bt,{}))}),t.jsx(b,{path:"usage",element:w(t.jsx(Tt,{}))}),t.jsx(b,{path:"models",element:w(t.jsx(kt,{}))}),t.jsx(b,{path:"aliases",element:w(t.jsx(jt,{}))}),t.jsx(b,{path:"tools",element:w(t.jsx(Ct,{}))}),t.jsx(b,{path:"settings",element:w(t.jsx(Nt,{}))}),t.jsx(b,{path:"*",element:t.jsx(Ce,{to:"/",replace:!0})})]})})}):t.jsx(pt,{})}function Lt({children:e}){const[r]=o.useState(()=>new we({defaultOptions:{queries:{refetchOnWindowFocus:!1,retry:(s,a)=>a instanceof E&&(a.status===401||a.status===403)?!1:s<2}}}));return t.jsx(Se,{client:r,children:t.jsx(Me,{children:e})})}const ge=document.getElementById("root");if(!ge)throw new Error("Root element #root not found");Ie.createRoot(ge).render(t.jsx(o.StrictMode,{children:t.jsx(Lt,{children:t.jsx(At,{})})}));export{Jt as $,Ar as A,He as B,Mr as C,Tr as D,ot as E,Kr as F,ir as G,or as H,lt as I,Mt as J,Bt as K,qr as L,Lr as M,Dr as N,at as O,Fr as P,Rr as Q,zt as R,Or as S,Ht as T,Gt as U,We as V,Vt as W,$t as X,Qt as Y,Ut as Z,Kt as _,Pr as a,lr as a0,cr as a1,ur as a2,tr as a3,Wt as a4,rr as a5,sr as a6,nr as a7,Ir as a8,kr as a9,wr as aa,Er as b,Nr as c,Ur as d,Ft as e,Xt as f,er as g,it as h,Zt as i,yr as j,gr as k,pr as l,br as m,Sr as n,vr as o,dr as p,fr as q,hr as r,xr as s,mr as t,jr as u,Dt as v,ar as w,Yt as x,_r as y,Cr as z}; diff --git a/src/gateway/static/dashboard/index.html b/src/gateway/static/dashboard/index.html index fafd6de4..0c8c04b2 100644 --- a/src/gateway/static/dashboard/index.html +++ b/src/gateway/static/dashboard/index.html @@ -6,7 +6,7 @@ Otari Dashboard - + diff --git a/tests/unit/test_dashboard_session.py b/tests/unit/test_dashboard_session.py new file mode 100644 index 00000000..f430e8ff --- /dev/null +++ b/tests/unit/test_dashboard_session.py @@ -0,0 +1,235 @@ +"""Dashboard sign-in sessions: mint, cookie auth, revocation, and rotation. + +Covers the fix for the dashboard losing its sign-in on every tab close or +browser restart (issue #338): the master key is exchanged once for an HttpOnly +session cookie that the master-key auth dependencies accept when a request +carries no header credentials. +""" + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, update +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import sessionmaker + +from gateway.api.routes import auth_session as auth_session_route +from gateway.core.config import GatewayConfig +from gateway.main import create_app +from gateway.models.entities import DashboardSession +from gateway.services import master_key_service +from gateway.services.dashboard_session_service import SESSION_COOKIE_NAME + +MASTER_KEY = "sk-test-master" + + +def _config(tmp_path: Path) -> GatewayConfig: + return GatewayConfig( + database_url=f"sqlite:///{tmp_path / 'session-test.db'}", + master_key=MASTER_KEY, + require_pricing=False, + ) + + +def _sign_in(client: TestClient, key: str = MASTER_KEY) -> None: + response = client.post("/v1/auth/session", json={"master_key": key}) + assert response.status_code == 200, response.text + + +def test_sign_in_sets_cookie_and_cookie_authenticates_management_reads(tmp_path: Path) -> None: + config = _config(tmp_path) + with TestClient(create_app(config)) as client: + response = client.post("/v1/auth/session", json={"master_key": MASTER_KEY}) + assert response.status_code == 200, response.text + assert SESSION_COOKIE_NAME in response.cookies + # The opaque token never contains the master key. + assert MASTER_KEY not in response.cookies[SESSION_COOKIE_NAME] + assert "expires_at" in response.json() + + set_cookie = response.headers["set-cookie"] + assert "HttpOnly" in set_cookie + assert "SameSite=strict" in set_cookie.lower() or "samesite=strict" in set_cookie.lower() + # Plain-HTTP deployment (TestClient) must still receive the cookie back. + assert "secure" not in set_cookie.lower() + + # The cookie alone (no Authorization header) now opens the management API. + settings = client.get("/v1/settings") + assert settings.status_code == 200, settings.text + + +def test_https_requests_get_a_secure_cookie(tmp_path: Path) -> None: + with TestClient(create_app(_config(tmp_path)), base_url="https://testserver") as client: + response = client.post("/v1/auth/session", json={"master_key": MASTER_KEY}) + assert response.status_code == 200, response.text + assert "secure" in response.headers["set-cookie"].lower() + + +def test_forwarded_https_gets_a_secure_cookie(tmp_path: Path) -> None: + # Behind a TLS-terminating proxy the ASGI scheme often reads "http" + # (uvicorn only trusts X-Forwarded-Proto from loopback); the Secure + # decision must honor the forwarded proto so the common PaaS deployment + # is not silently downgraded. + with TestClient(create_app(_config(tmp_path))) as client: + response = client.post( + "/v1/auth/session", + json={"master_key": MASTER_KEY}, + headers={"X-Forwarded-Proto": "https"}, + ) + assert response.status_code == 200, response.text + assert "secure" in response.headers["set-cookie"].lower() + + +def test_sign_in_rejects_a_wrong_key_without_setting_a_cookie(tmp_path: Path) -> None: + with TestClient(create_app(_config(tmp_path))) as client: + response = client.post("/v1/auth/session", json={"master_key": "not-the-master-key"}) + assert response.status_code == 401 + assert SESSION_COOKIE_NAME not in response.cookies + + assert client.get("/v1/settings").status_code == 401 + + +def test_header_credentials_win_over_the_cookie(tmp_path: Path) -> None: + # An explicit-but-wrong header must fail even when a valid cookie rides along: + # API clients keep exactly the pre-cookie behavior. + with TestClient(create_app(_config(tmp_path))) as client: + _sign_in(client) + response = client.get("/v1/settings", headers={"Authorization": "Bearer wrong-key"}) + assert response.status_code == 401 + + +def test_cross_site_requests_cannot_ride_the_cookie(tmp_path: Path) -> None: + with TestClient(create_app(_config(tmp_path))) as client: + _sign_in(client) + response = client.get("/v1/settings", headers={"Sec-Fetch-Site": "cross-site"}) + assert response.status_code == 401 + # Same-origin fetches (the dashboard itself) stay accepted. + assert client.get("/v1/settings", headers={"Sec-Fetch-Site": "same-origin"}).status_code == 200 + + +def test_sign_out_revokes_the_session_server_side(tmp_path: Path) -> None: + with TestClient(create_app(_config(tmp_path))) as client: + _sign_in(client) + stolen_cookie = client.cookies[SESSION_COOKIE_NAME] + + response = client.delete("/v1/auth/session") + assert response.status_code == 204 + + # Even a kept copy of the cookie is dead after sign-out (server-side + # revocation, not just cookie deletion in the browser). + client.cookies.set(SESSION_COOKIE_NAME, stolen_cookie) + assert client.get("/v1/settings").status_code == 401 + + +def test_sign_out_without_a_session_is_a_no_op(tmp_path: Path) -> None: + with TestClient(create_app(_config(tmp_path))) as client: + assert client.delete("/v1/auth/session").status_code == 204 + + +def test_sign_out_clears_the_cookie_even_when_revocation_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A DB failure during revocation must not leave the browser holding a live + # cookie: sign-out stays best-effort (204 + cookie cleared) and the + # unrevoked session dies on its TTL. + async def _boom(db: object, token: str) -> None: + raise SQLAlchemyError("db down") + + monkeypatch.setattr(auth_session_route, "revoke_dashboard_session", _boom) + with TestClient(create_app(_config(tmp_path))) as client: + _sign_in(client) + response = client.delete("/v1/auth/session") + assert response.status_code == 204 + set_cookie = response.headers.get("set-cookie", "") + assert SESSION_COOKIE_NAME in set_cookie + assert 'expires=' in set_cookie.lower() or "max-age=0" in set_cookie.lower() + + +def test_expired_sessions_stop_authenticating(tmp_path: Path) -> None: + config = _config(tmp_path) + with TestClient(create_app(config)) as client: + _sign_in(client) + assert client.get("/v1/settings").status_code == 200 + + # Age the stored session past its TTL directly in the database. + engine = create_engine(config.database_url) + with sessionmaker(bind=engine)() as db: + db.execute(update(DashboardSession).values(expires_at=datetime.now(UTC) - timedelta(hours=1))) + db.commit() + engine.dispose() + + assert client.get("/v1/settings").status_code == 401 + + +def test_sessions_survive_a_restart_but_not_a_configured_key_change(tmp_path: Path) -> None: + db_url = f"sqlite:///{tmp_path / 'restart.db'}" + + def config_with(key: str) -> GatewayConfig: + return GatewayConfig(database_url=db_url, master_key=key, require_pricing=False) + + with TestClient(create_app(config_with(MASTER_KEY))) as client: + _sign_in(client) + cookie = client.cookies[SESSION_COOKIE_NAME] + + # Same key across a restart: the session (the whole point of #338) survives. + with TestClient(create_app(config_with(MASTER_KEY))) as client: + client.cookies.set(SESSION_COOKIE_NAME, cookie) + assert client.get("/v1/settings").status_code == 200 + + # Rotating OTARI_MASTER_KEY across a restart revokes every session: a + # session only proves possession of the old key and must die with it. + with TestClient(create_app(config_with("sk-rotated-master"))) as client: + client.cookies.set(SESSION_COOKIE_NAME, cookie) + assert client.get("/v1/settings").status_code == 401 + + +def test_rotation_then_restart_keeps_the_reminted_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + db_url = f"sqlite:///{tmp_path / 'rotate-restart.db'}" + monkeypatch.setattr(master_key_service, "generate_master_key", lambda: "otari-mk-first") + + with TestClient(create_app(GatewayConfig(database_url=db_url, require_pricing=False))) as client: + _sign_in(client, "otari-mk-first") + monkeypatch.setattr(master_key_service, "generate_master_key", lambda: "otari-mk-second") + assert client.post("/v1/settings/master-key/rotate").status_code == 200 + reminted = client.cookies[SESSION_COOKIE_NAME] + + # The startup key-change check must recognize the rotated key as current + # and keep the session the rotation re-minted. + with TestClient(create_app(GatewayConfig(database_url=db_url, require_pricing=False))) as client: + client.cookies.set(SESSION_COOKIE_NAME, reminted) + assert client.get("/v1/settings").status_code == 200 + + +def test_rotation_revokes_other_sessions_and_reminting_keeps_the_caller_signed_in( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(master_key_service, "generate_master_key", lambda: "otari-mk-first") + config = GatewayConfig( + database_url=f"sqlite:///{tmp_path / 'rotation-session.db'}", + require_pricing=False, + ) + with TestClient(create_app(config)) as client: + # Two sign-ins model two dashboard tabs; the cookie jar holds one at a + # time, so keep the first session's token aside. + _sign_in(client, "otari-mk-first") + other_tab_session = client.cookies[SESSION_COOKIE_NAME] + _sign_in(client, "otari-mk-first") + rotating_session = client.cookies[SESSION_COOKIE_NAME] + assert rotating_session != other_tab_session + + monkeypatch.setattr(master_key_service, "generate_master_key", lambda: "otari-mk-second") + # Rotate via the session cookie alone: the dashboard tab that signed in + # with a cookie has no raw key to send. + rotated = client.post("/v1/settings/master-key/rotate") + assert rotated.status_code == 200, rotated.text + assert rotated.json() == {"master_key": "otari-mk-second"} + + # The rotating tab got a fresh session on the response and stays signed in. + assert client.cookies[SESSION_COOKIE_NAME] != rotating_session + assert client.get("/v1/settings").status_code == 200 + + # Every session minted before the rotation died with the old key. + for stale in (other_tab_session, rotating_session): + client.cookies.set(SESSION_COOKIE_NAME, stale) + assert client.get("/v1/settings").status_code == 401 diff --git a/tests/unit/test_deps_db_error.py b/tests/unit/test_deps_db_error.py index aa810a03..30c73c9b 100644 --- a/tests/unit/test_deps_db_error.py +++ b/tests/unit/test_deps_db_error.py @@ -17,7 +17,7 @@ from sqlalchemy.exc import OperationalError from gateway.api import deps -from gateway.api.deps import _is_valid_master_key, _verify_and_update_api_key +from gateway.api.deps import _verify_and_update_api_key, is_valid_master_key from gateway.auth.models import generate_api_key, hash_key from gateway.core.config import GatewayConfig from gateway.services.master_key_service import generate_master_key @@ -43,7 +43,7 @@ async def test_generated_master_key_lookup_db_error_raises_503_not_500() -> None db.get.side_effect = OperationalError("SELECT", {}, Exception("database is locked")) with pytest.raises(HTTPException) as exc_info: - await _is_valid_master_key(generate_master_key(), GatewayConfig(), db) + await is_valid_master_key(generate_master_key(), GatewayConfig(), db) assert exc_info.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE assert exc_info.value.detail == "Authentication temporarily unavailable, please retry" diff --git a/tests/unit/test_master_key_service.py b/tests/unit/test_master_key_service.py index 96e260e7..95b6aecf 100644 --- a/tests/unit/test_master_key_service.py +++ b/tests/unit/test_master_key_service.py @@ -131,12 +131,12 @@ async def test_is_valid_master_key_accepts_hash_and_plaintext() -> None: generated_config = GatewayConfig() session = AsyncMock() session.get.return_value = RuntimeSetting(key=MASTER_KEY_HASH_KEY, value=hash_master_key(token)) - assert await deps._is_valid_master_key(token, generated_config, session) is True - assert await deps._is_valid_master_key("otari-mk-wrong", generated_config, session) is False + assert await deps.is_valid_master_key(token, generated_config, session) is True + assert await deps.is_valid_master_key("otari-mk-wrong", generated_config, session) is False plain = GatewayConfig(master_key="literal-key") - assert await deps._is_valid_master_key("literal-key", plain, session) is True - assert await deps._is_valid_master_key("nope", plain, session) is False + assert await deps.is_valid_master_key("literal-key", plain, session) is True + assert await deps.is_valid_master_key("nope", plain, session) is False def test_402_message_states_cause_and_both_fixes() -> None: diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 3164b2d8..de52b369 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -18,12 +18,12 @@ vi.mock("@/pages/OverviewPage", async () => { describe("App", () => { afterEach(() => { vi.restoreAllMocks(); - window.sessionStorage.clear(); + window.localStorage.clear(); window.location.hash = ""; }); it("shows a loading state while the current route loads", async () => { - window.sessionStorage.setItem("otari.dashboard.masterKey", "test-master-key"); + window.localStorage.setItem("otari.dashboard.hasSession", "1"); vi.mocked(apiFetch).mockImplementation(async (path) => { if (path === "/dashboard-build.json") { return { build: "test-build" } as never; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 24f17258..5dfd35b7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,8 @@ // Thin fetch wrapper for the gateway's management API. The dashboard is served -// from the same origin as the API, so paths are relative ("/v1/models"). Every -// management endpoint requires the master key, sent as a Bearer token. +// from the same origin as the API, so paths are relative ("/v1/models") and the +// HttpOnly session cookie minted at sign-in rides along automatically (fetch +// defaults to credentials: "same-origin"). The raw master key is sent exactly +// once, to POST /v1/auth/session, and never stored in the browser. export class ApiError extends Error { status: number; @@ -12,7 +14,7 @@ export class ApiError extends Error { } } -// AuthProvider registers a callback so a 401 anywhere can drop the stored key +// AuthProvider registers a callback so a 401 anywhere can drop the session // and bounce the operator back to the login screen. let unauthorizedHandler: (() => void) | null = null; @@ -20,12 +22,6 @@ export function setUnauthorizedHandler(handler: (() => void) | null): void { unauthorizedHandler = handler; } -let masterKey: string | null = null; - -export function setMasterKey(key: string | null): void { - masterKey = key; -} - async function extractErrorMessage(response: Response): Promise { try { const data = (await response.json()) as { detail?: unknown }; @@ -41,21 +37,22 @@ async function extractErrorMessage(response: Response): Promise { return response.statusText || `Request failed (${response.status})`; } -// Validate a candidate master key against a cheap master-key-gated endpoint -// before we treat the operator as signed in. GET /v1/settings is a small, -// always-present management read, so it works regardless of which management -// pages the dashboard ships. Returns false on 401/403 (wrong key) and throws -// ApiError for network/other failures so the UI can explain them. -export async function validateMasterKey(key: string): Promise { +// Exchange the master key for a server-issued session: the gateway verifies the +// key and answers with an HttpOnly cookie holding an opaque session token, so +// the key itself never needs to be stored (or even kept in memory) afterwards. +// Returns false on 401/403 (wrong key) and throws ApiError for network/other +// failures so the UI can explain them. +export async function createSession(key: string): Promise { let response: Response; try { - response = await fetch("/v1/settings", { - headers: { Accept: "application/json", Authorization: `Bearer ${key}` }, + response = await fetch("/v1/auth/session", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ master_key: key }), }); } catch { throw new ApiError(0, "Network error: could not reach the gateway."); } - // Both 401 and 403 mean "not an admin key" for the management endpoints. if (response.status === 401 || response.status === 403) { return false; } @@ -65,15 +62,23 @@ export async function validateMasterKey(key: string): Promise { return true; } +// Best-effort server-side sign-out: revokes the cookie's session and expires +// the cookie. Uses raw fetch (not apiFetch) and swallows failures so the +// 401-bounce path can call it without re-entering the unauthorized handler. +export async function deleteSession(): Promise { + try { + await fetch("/v1/auth/session", { method: "DELETE" }); + } catch { + // Signing out locally still proceeds; the session expires on its TTL. + } +} + export async function apiFetch(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); headers.set("Accept", "application/json"); if (init.body != null && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } - if (masterKey) { - headers.set("Authorization", `Bearer ${masterKey}`); - } let response: Response; try { @@ -82,8 +87,8 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise throw new ApiError(0, "Network error: could not reach the gateway."); } - // 401 (bad/absent key) or 403 (a valid key that isn't the master key) both mean - // this session can't use the management API: drop it and bounce to sign-in. + // 401 (expired/revoked session) or 403 both mean this session can't use the + // management API anymore: drop it and bounce to sign-in. if (response.status === 401 || response.status === 403) { unauthorizedHandler?.(); throw new ApiError(response.status, await extractErrorMessage(response)); diff --git a/web/src/auth/AuthContext.test.tsx b/web/src/auth/AuthContext.test.tsx new file mode 100644 index 00000000..2469fb28 --- /dev/null +++ b/web/src/auth/AuthContext.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useAuth } from "@/auth/AuthContext"; +import { Provider } from "@/provider"; + +function Harness() { + const { isAuthenticated, logout } = useAuth(); + return isAuthenticated ? ( + + ) : ( +
SIGNED OUT
+ ); +} + +describe("AuthProvider", () => { + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + it("restores the signed-in state on load without asking for the key again", () => { + // The session credential is an HttpOnly cookie the page cannot read; the + // persisted marker is what tells a fresh tab/restart it is signed in. + window.localStorage.setItem("otari.dashboard.hasSession", "1"); + + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); + }); + + it("starts signed out when no session marker is present", () => { + render( + + + , + ); + + expect(screen.getByText("SIGNED OUT")).toBeInTheDocument(); + }); + + it("revokes the server-side session and drops the marker on sign-out", async () => { + window.localStorage.setItem("otari.dashboard.hasSession", "1"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 })); + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Sign out" })); + + expect(screen.getByText("SIGNED OUT")).toBeInTheDocument(); + expect(window.localStorage.getItem("otari.dashboard.hasSession")).toBeNull(); + await waitFor(() => { + const call = fetchMock.mock.calls.find(([url]) => url === "/v1/auth/session"); + expect(call?.[1]?.method).toBe("DELETE"); + }); + }); +}); diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index d9e89313..8623e041 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -2,95 +2,72 @@ import { useQueryClient } from "@tanstack/react-query"; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; -import { setMasterKey, setUnauthorizedHandler } from "@/api/client"; +import { deleteSession, setUnauthorizedHandler } from "@/api/client"; -const STORAGE_KEY = "otari.dashboard.masterKey"; +// Non-secret marker that a session cookie was minted for this browser. The +// credential itself is an HttpOnly cookie the page cannot read, so this flag is +// what lets the app render signed-in synchronously on load instead of probing +// the server first. If it is ever stale (cookie expired or revoked), the first +// 401 drops it and bounces to sign-in, exactly like any mid-session revocation. +const STORAGE_KEY = "otari.dashboard.hasSession"; interface AuthContextValue { - masterKey: string | null; isAuthenticated: boolean; - login: (key: string) => void; - replaceMasterKey: (key: string) => void; + login: () => void; logout: () => void; } const AuthContext = createContext(null); -function readStoredKey(): string | null { +function readStoredMarker(): boolean { try { - return window.sessionStorage.getItem(STORAGE_KEY); + return window.localStorage.getItem(STORAGE_KEY) === "1"; } catch { - return null; + return false; } } export function AuthProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); - // Seed the api client synchronously during the first render so a restored - // session key is in place before any child query fires. Doing this in an - // effect would let the first request go out unauthenticated (effects run - // child-first, so React Query's fetch would race ahead of the sync). - const [masterKey, setKey] = useState(() => { - const stored = readStoredKey(); - setMasterKey(stored); - return stored; - }); + const [isAuthenticated, setAuthenticated] = useState(readStoredMarker); const logout = useCallback(() => { - setMasterKey(null); - setKey(null); - // Drop any admin data cached under the old key so it can't render to a + // Best-effort server-side revocation; local sign-out proceeds regardless. + void deleteSession(); + setAuthenticated(false); + // Drop any admin data cached under the old session so it can't render to a // later, possibly different, session in the same tab. queryClient.clear(); try { - window.sessionStorage.removeItem(STORAGE_KEY); + window.localStorage.removeItem(STORAGE_KEY); } catch { // Ignore storage errors (e.g. private mode); in-memory state still clears. } }, [queryClient]); - const login = useCallback( - (key: string) => { - const trimmed = key.trim(); - // Set the client key synchronously (before the re-render that mounts the - // dashboard) so the first authenticated request carries the header. - setMasterKey(trimmed); - // Clear any cache from a prior session before the new key's queries run. - queryClient.clear(); - setKey(trimmed); - try { - window.sessionStorage.setItem(STORAGE_KEY, trimmed); - } catch { - // Ignore storage errors; the key still lives in memory for this session. - } - }, - [queryClient], - ); - - const replaceMasterKey = useCallback((key: string) => { - const trimmed = key.trim(); - // A freshly rotated generated master key grants the same dashboard access - // as the key it replaces. Keep the current view stable while subsequent - // requests begin using the new credential. - setMasterKey(trimmed); - setKey(trimmed); + // Called after POST /v1/auth/session succeeded, i.e. the browser already + // holds the session cookie; this only flips the rendered state. + const login = useCallback(() => { + // Clear any cache from a prior session before the new session's queries run. + queryClient.clear(); + setAuthenticated(true); try { - window.sessionStorage.setItem(STORAGE_KEY, trimmed); + window.localStorage.setItem(STORAGE_KEY, "1"); } catch { - // Ignore storage errors; the key still lives in memory for this session. + // Ignore storage errors; the sign-in still works for this tab. } - }, []); + }, [queryClient]); - // A 401 from any request means the key is wrong or was revoked: drop it. + // A 401 from any request means the session expired or was revoked: drop it. useEffect(() => { setUnauthorizedHandler(logout); return () => setUnauthorizedHandler(null); }, [logout]); const value = useMemo( - () => ({ masterKey, isAuthenticated: masterKey != null, login, replaceMasterKey, logout }), - [masterKey, login, replaceMasterKey, logout], + () => ({ isAuthenticated, login, logout }), + [isAuthenticated, login, logout], ); return {children}; diff --git a/web/src/components/Login.test.tsx b/web/src/components/Login.test.tsx index c69566db..955718b4 100644 --- a/web/src/components/Login.test.tsx +++ b/web/src/components/Login.test.tsx @@ -21,11 +21,11 @@ function jsonResponse(body: unknown, status = 200): Response { describe("Login", () => { afterEach(() => { vi.restoreAllMocks(); - window.sessionStorage.clear(); + window.localStorage.clear(); }); - it("signs in when the master key is accepted and sends it as a Bearer token", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); + it("signs in by exchanging the master key for a session, never storing the key", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ expires_at: "2026-07-30T00:00:00Z" })); const user = userEvent.setup(); render( @@ -39,8 +39,14 @@ describe("Login", () => { expect(await screen.findByText("SIGNED IN")).toBeInTheDocument(); - const [, init] = fetchMock.mock.calls[0]; - expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer sk-correct"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/v1/auth/session"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ master_key: "sk-correct" })); + // The raw key must not land in any JS-readable storage. + expect(window.localStorage.getItem("otari.dashboard.hasSession")).toBe("1"); + expect(Object.values({ ...window.localStorage })).not.toContain("sk-correct"); + expect(Object.values({ ...window.sessionStorage })).not.toContain("sk-correct"); }); it("links to the auth-free welcome page", () => { diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx index 6b21365b..de00ccfd 100644 --- a/web/src/components/Login.tsx +++ b/web/src/components/Login.tsx @@ -1,7 +1,7 @@ import { Button, Card, Input, Label, Link, TextField } from "@heroui/react"; import { useState } from "react"; -import { validateMasterKey } from "@/api/client"; +import { createSession } from "@/api/client"; import { useAuth } from "@/auth/AuthContext"; import { ErrorBanner } from "@/components/ui"; @@ -19,9 +19,9 @@ export function Login() { setIsSubmitting(true); setError(null); try { - const valid = await validateMasterKey(trimmed); + const valid = await createSession(trimmed); if (valid) { - login(trimmed); + login(); } else { setError(new Error("Invalid master key.")); } @@ -85,7 +85,7 @@ export function Login() {

- The key is held only in this browser tab (session storage) and sent directly to this gateway. + The key is sent once to this gateway and exchanged for a session cookie; it is never stored in the browser.

diff --git a/web/src/components/PricingWarning.test.tsx b/web/src/components/PricingWarning.test.tsx index 6b8271a4..d89049e2 100644 --- a/web/src/components/PricingWarning.test.tsx +++ b/web/src/components/PricingWarning.test.tsx @@ -2,9 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { GatewaySettings } from "@/api/types"; import { PricingWarning } from "@/components/PricingWarning"; @@ -41,10 +40,8 @@ function renderPage(ui: ReactElement) { } describe("PricingWarning", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("alarms and enables default pricing when require_pricing rejects requests", async () => { diff --git a/web/src/components/UpdatePrompt.test.tsx b/web/src/components/UpdatePrompt.test.tsx index 63f13ba5..929ea44e 100644 --- a/web/src/components/UpdatePrompt.test.tsx +++ b/web/src/components/UpdatePrompt.test.tsx @@ -4,7 +4,6 @@ import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import { UpdatePrompt } from "@/components/UpdatePrompt"; // The build the fake gateway is currently serving. Tests flip it to stand in @@ -41,11 +40,9 @@ describe("UpdatePrompt", () => { beforeEach(() => { servedBuild = "build-a"; - setMasterKey("test-master-key"); }); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); if (originalLocation) { Object.defineProperty(window, "location", originalLocation); originalLocation = undefined; diff --git a/web/src/components/UpdatePrompt.tsx b/web/src/components/UpdatePrompt.tsx index b83bfdeb..5b6d1095 100644 --- a/web/src/components/UpdatePrompt.tsx +++ b/web/src/components/UpdatePrompt.tsx @@ -44,7 +44,8 @@ export function UpdatePrompt() { {/* A plain reload is enough: the gateway serves index.html with no-store, so this fetches the new bundle rather than the cached one, - and the master key lives in sessionStorage, which survives it. */} + and the sign-in lives in an HttpOnly session cookie, which + survives it. */} diff --git a/web/src/pages/ActivityPage.test.tsx b/web/src/pages/ActivityPage.test.tsx index 187eeed2..5a44fbda 100644 --- a/web/src/pages/ActivityPage.test.tsx +++ b/web/src/pages/ActivityPage.test.tsx @@ -3,9 +3,8 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { MemoryRouter } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { UsageEntry } from "@/api/types"; import { ActivityPage, copyToClipboard } from "@/pages/ActivityPage"; @@ -98,12 +97,8 @@ function listCalls(fetchMock: ReturnType): string[] { } describe("ActivityPage", () => { - beforeEach(() => { - setMasterKey("test-master-key"); - }); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("renders a request row with humanized latency, tokens, and status", async () => { diff --git a/web/src/pages/AliasesPage.test.tsx b/web/src/pages/AliasesPage.test.tsx index b6e6b2f1..56241a0b 100644 --- a/web/src/pages/AliasesPage.test.tsx +++ b/web/src/pages/AliasesPage.test.tsx @@ -3,9 +3,8 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import type { ReactElement } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { AliasResponse } from "@/api/types"; import { AliasesPage } from "@/pages/AliasesPage"; @@ -53,10 +52,8 @@ function renderPage(ui: ReactElement, route = "/aliases") { } describe("AliasesPage", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("lists aliases with provenance; config is read-only, stored is deletable", async () => { diff --git a/web/src/pages/BudgetsPage.test.tsx b/web/src/pages/BudgetsPage.test.tsx index 2165b77c..e388d75e 100644 --- a/web/src/pages/BudgetsPage.test.tsx +++ b/web/src/pages/BudgetsPage.test.tsx @@ -2,9 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { Budget, BudgetResetLog, User } from "@/api/types"; import { BudgetsPage } from "@/pages/BudgetsPage"; @@ -113,10 +112,8 @@ function renderPage(ui: ReactElement) { } describe("BudgetsPage", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("shows onboarding when there are no budgets", async () => { diff --git a/web/src/pages/KeysPage.test.tsx b/web/src/pages/KeysPage.test.tsx index 2bc4b52b..5fdb49f2 100644 --- a/web/src/pages/KeysPage.test.tsx +++ b/web/src/pages/KeysPage.test.tsx @@ -2,9 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { ApiKey, User } from "@/api/types"; import { KeysPage } from "@/pages/KeysPage"; @@ -120,10 +119,8 @@ function renderPage(ui: ReactElement) { } describe("KeysPage", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("lists keys with status and prefix, never the full secret", async () => { diff --git a/web/src/pages/ModelsPage.test.tsx b/web/src/pages/ModelsPage.test.tsx index 5cc0066e..b18cf3af 100644 --- a/web/src/pages/ModelsPage.test.tsx +++ b/web/src/pages/ModelsPage.test.tsx @@ -235,14 +235,12 @@ function modelOrder(): string[] { describe("ModelsPage", () => { beforeEach(() => { - apiClient.setMasterKey("test-master-key"); // The page persists sort choice in localStorage; start each test clean so // one test's sort does not leak into the next. window.localStorage.clear(); }); afterEach(() => { vi.restoreAllMocks(); - apiClient.setMasterKey(null); window.localStorage.clear(); }); diff --git a/web/src/pages/OverviewPage.test.tsx b/web/src/pages/OverviewPage.test.tsx index 3da96fdf..c26c9ea0 100644 --- a/web/src/pages/OverviewPage.test.tsx +++ b/web/src/pages/OverviewPage.test.tsx @@ -3,9 +3,8 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { UsageSummary } from "@/api/types"; import { localDayKey, OverviewIndex, OverviewPage } from "@/pages/OverviewPage"; @@ -93,11 +92,9 @@ function renderPage(ui: ReactElement, initial = "/overview") { } describe("OverviewPage", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); - setMasterKey(null); }); it("uses a zero-padded, one-based local calendar date as its refresh key", () => { @@ -250,10 +247,8 @@ describe("OverviewPage", () => { }); describe("OverviewIndex routing", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("renders the overview when a provider is configured", async () => { diff --git a/web/src/pages/ProvidersPage.test.tsx b/web/src/pages/ProvidersPage.test.tsx index 0615041b..f745d323 100644 --- a/web/src/pages/ProvidersPage.test.tsx +++ b/web/src/pages/ProvidersPage.test.tsx @@ -3,9 +3,8 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { MemoryRouter } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import { PROVIDER_HEALTH_REFRESH_MS } from "@/api/hooks"; import type { GatewaySettings, @@ -169,10 +168,8 @@ function healthRequestCount(fetchMock: ReturnType): number { } describe("ProvidersPage", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("lists config and stored providers with provenance and redacted keys", async () => { diff --git a/web/src/pages/SettingsPage.test.tsx b/web/src/pages/SettingsPage.test.tsx index 0aec8e2f..0342b913 100644 --- a/web/src/pages/SettingsPage.test.tsx +++ b/web/src/pages/SettingsPage.test.tsx @@ -4,7 +4,6 @@ import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { ConfigField, GatewaySettings, ReencryptProviderCredentialsResult, StoredProvider } from "@/api/types"; import { AuthProvider } from "@/auth/AuthContext"; import { SettingsPage, fieldMatches } from "@/pages/SettingsPage"; @@ -158,13 +157,11 @@ function mockApi( describe("SettingsPage", () => { beforeEach(() => { - window.sessionStorage.setItem("otari.dashboard.masterKey", "test-master-key"); - setMasterKey("test-master-key"); + window.localStorage.setItem("otari.dashboard.hasSession", "1"); }); afterEach(() => { vi.restoreAllMocks(); - window.sessionStorage.clear(); - setMasterKey(null); + window.localStorage.clear(); }); it("reflects the current settings on its switches", async () => { @@ -217,7 +214,6 @@ describe("SettingsPage", () => { expect(revealedKey).toHaveAttribute("data-lpignore", "true"); expect(screen.getByRole("alertdialog", { name: "Master key regenerated" })).toBeInTheDocument(); expect(screen.getByText("Credential security")).toBeInTheDocument(); - expect(window.sessionStorage.getItem("otari.dashboard.masterKey")).toBe("otari-mk-new"); await user.keyboard("{Escape}"); expect(screen.getByDisplayValue("otari-mk-new")).toBeInTheDocument(); @@ -225,9 +221,11 @@ describe("SettingsPage", () => { await user.click(screen.getByRole("button", { name: "I’ve saved this key" })); expect(screen.queryByDisplayValue("otari-mk-new")).not.toBeInTheDocument(); + // Auth rides the HttpOnly session cookie (re-minted server-side by the + // rotation response), so requests carry no Authorization header at all. await user.click(screen.getByRole("switch", { name: "default_pricing" })); const patch = fetchMock.mock.calls.find(([, init]) => (init?.method ?? "") === "PATCH"); - expect(new Headers(patch?.[1]?.headers).get("Authorization")).toBe("Bearer otari-mk-new"); + expect(new Headers(patch?.[1]?.headers).get("Authorization")).toBeNull(); }); it("explains when a master key is managed in configuration", async () => { diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 25eaa3c5..0990f8d4 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -12,7 +12,6 @@ import { useUpdateSettings, } from "@/api/hooks"; import type { ConfigField, PricingRefreshPreview, UpdateSettingsRequest } from "@/api/types"; -import { useAuth } from "@/auth/AuthContext"; import { ErrorBanner, FilterSelect, InfoBanner, PageHeader } from "@/components/ui"; // A single settable field maps onto one key of UpdateSettingsRequest. The keys @@ -397,16 +396,17 @@ function MasterKeyRotationDialog({ function MasterKeyRow({ source }: { source: "configured" | "generated" }) { const rotateMasterKey = useRotateMasterKey(); - const { replaceMasterKey } = useAuth(); const [dialogOpen, setDialogOpen] = useState(false); const [newKey, setNewKey] = useState(); const isGenerated = source === "generated"; + // Rotation revokes every dashboard session server-side and re-mints this + // tab's session cookie on the response, so no client-side credential swap + // is needed; the dialog only has to reveal the new key once. const rotate = () => rotateMasterKey.mutate(undefined, { onSuccess: (result) => { setNewKey(result.master_key); - replaceMasterKey(result.master_key); }, }); diff --git a/web/src/pages/ToolsGuardrailsPage.test.tsx b/web/src/pages/ToolsGuardrailsPage.test.tsx index 120055ce..f0cf2d67 100644 --- a/web/src/pages/ToolsGuardrailsPage.test.tsx +++ b/web/src/pages/ToolsGuardrailsPage.test.tsx @@ -2,9 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { ToolSettingField, ToolSettingsResponse } from "@/api/types"; import { ToolsGuardrailsPage } from "@/pages/ToolsGuardrailsPage"; @@ -61,10 +60,8 @@ function mockApi(opts: MockOpts = {}) { } describe("ToolsGuardrailsPage", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("renders the three service sections and effective values", async () => { diff --git a/web/src/pages/UsagePage.test.tsx b/web/src/pages/UsagePage.test.tsx index e8a0d761..38112316 100644 --- a/web/src/pages/UsagePage.test.tsx +++ b/web/src/pages/UsagePage.test.tsx @@ -3,9 +3,8 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { UsageSummary } from "@/api/types"; import { UsagePage } from "@/pages/UsagePage"; @@ -81,10 +80,8 @@ function renderPage(ui: ReactElement) { } describe("UsagePage", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("renders totals tiles with compact currency and error rate", async () => { diff --git a/web/src/pages/UsersPage.test.tsx b/web/src/pages/UsersPage.test.tsx index f5c4cdb0..db519be9 100644 --- a/web/src/pages/UsersPage.test.tsx +++ b/web/src/pages/UsersPage.test.tsx @@ -2,9 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMasterKey } from "@/api/client"; import type { Budget, User } from "@/api/types"; import { UsersPage } from "@/pages/UsersPage"; @@ -102,10 +101,8 @@ function renderPage(ui: ReactElement) { } describe("UsersPage", () => { - beforeEach(() => setMasterKey("test-master-key")); afterEach(() => { vi.restoreAllMocks(); - setMasterKey(null); }); it("shows onboarding when there are no users", async () => {