diff --git a/docs/configuration.md b/docs/configuration.md index c39f93bf..eeed8de3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,12 +87,12 @@ pricing: | `stream_missing_usage_policy` | string | `"estimate"` | How to bill a streamed response that completes with no provider usage data: `"estimate"` (charge the up-front estimate), `"fail"` (charge estimate and mark errored), or `"allow_free"` (don't bill). | | `budget_estimate_default_output_tokens` | int | `1024` | Output-token count assumed when reserving budget for a request with no declared max output; reconciled to actual usage on completion. | | `streaming_keepalive_interval_ms` | int | `15000` | Idle interval after which a streaming response emits a transport keepalive while it waits on the provider: a `ping` event on `/v1/messages`, an SSE comment line (`: keepalive`) on `/v1/chat/completions` and `/v1/responses`. Keeps an intermediary with a read timeout (Cloudflare's default Proxy Read Timeout is 125s) from severing a connection during a long time-to-first-token. Keepalives start once the provider stream is open and never touch usage accounting or extend a first-chunk/failover deadline. `0` disables. | -| `model_discovery` | bool | `true` | Auto-discover models for `GET /v1/models` from the configured providers: the `providers` block plus anything added at runtime on the Providers page. A provider that is callable through its credential environment variable alone is not discovered until it has an entry. | -| `model_cache_ttl_seconds` | int | `300` | TTL for the in-memory model-discovery cache (`0` disables caching). | +| `model_discovery` | bool | `true` | Auto-discover models for `GET /v1/models` from the configured providers: the `providers` block plus anything added at runtime on the Providers page. A provider that is callable through its credential environment variable alone is not discovered until it has an entry. Setting this to `false` also stops the background refresher, so the gateway makes no unattended `list_models` calls; the operator-facing `/v1/models/discoverable` and `/v1/providers/health` still dial when asked. | +| `model_cache_ttl_seconds` | int | `300` | TTL for the in-memory model-discovery cache, and the interval at which a background task re-dials every configured provider to refill it (floored at 30s; a round that saw a failure comes back on `model_discovery_negative_ttl_seconds` instead). While it is above `0`, `GET /v1/models`, `/v1/models/discoverable` and `/v1/providers/health` answer from that cache rather than dialing on the request path, and so does `GET /v1/models/{model_id}`, which never dials at all. The one read that still dials is the first one to ask about a provider that has never been dialed (a freshly started worker whose background refresh has not landed yet), so a cold worker reports what a provider actually serves instead of claiming it has no models. Setting this to `0` disables caching: reads then dial for themselves, and no background refresher runs. Force a live re-dial with `?refresh=true` on `/v1/models/discoverable` or `/v1/providers/health`; a health check within a few seconds of the last dial reuses it rather than starting another. | | `model_discovery_timeout_seconds` | float | `10.0` | Per-provider timeout for a live model-discovery (`list_models`) call. Bounds how long an unreachable or slow provider can stall discovery before it is treated as failed. | -| `model_discovery_negative_ttl_seconds` | float | `30.0` | How long a failed model-discovery result is remembered before the provider is dialed again, so an unreachable provider is not re-tried on every request (`0` disables negative caching). | +| `model_discovery_negative_ttl_seconds` | float | `30.0` | How long a failed model-discovery result is remembered before that provider is dialed again (`0` disables negative caching). This governs a read that dials: a cold provider, or any read while `model_cache_ttl_seconds` is `0`. Once the background refresher owns the dialing, how quickly a recovered provider reappears is bounded by `model_cache_ttl_seconds` (the refresh interval) rather than by this. | | `models_dev_metadata` | bool | `true` | Enrich the dashboard's model detail with metadata (modalities, capabilities, knowledge cutoff) fetched from the public models.dev catalog. Set `false` to disable the outbound call; the gateway then falls back to the bundled genai-prices data. | -| `models_dev_cache_ttl_seconds` | int | `86400` | TTL in seconds for the cached models.dev catalog (`0` disables caching). | +| `models_dev_cache_ttl_seconds` | int | `86400` | TTL for the cached models.dev catalog, and the interval at which a background task refetches it (floored at 5 minutes). While it is above `0`, `GET /v1/models/metadata` answers from that cache rather than waiting on the fetch. A failed fetch is held for one minute, not for the refresh interval, so a transient models.dev outage costs a minute of enrichment rather than a day. `0` disables caching, which means every read fetches instead. | | `files_enabled` | bool | `true` | Enable the `/v1/files` upload/storage endpoints (standalone mode). | | `files_backend` | string | `"local"` | Blob backend for uploaded file bytes (`"local"` filesystem for now). | | `files_local_dir` | string | `"./otari-files"` | Directory the `local` files backend writes uploaded bytes to. | diff --git a/docs/public/openapi.json b/docs/public/openapi.json index a50ca778..18d0d4db 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1536,6 +1536,18 @@ "DiscoverableProvider": { "description": "One provider instance's discovery result.", "properties": { + "checked_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When this instance was last dialed, ISO 8601. Null when it has not been checked yet, which is what the first read after a restart sees while the background refresh runs.", + "title": "Checked At" + }, "discovery_unsupported": { "default": false, "description": "True when discovery failed only because this backend serves no model-listing endpoint. The provider may still handle requests for models declared in config.", @@ -6121,6 +6133,17 @@ ], "title": "Api Key Id" }, + "api_key_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key Name" + }, "attempt_count": { "anyOf": [ { @@ -6375,6 +6398,17 @@ ], "title": "Total Tokens" }, + "user_alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Alias" + }, "user_id": { "anyOf": [ { @@ -6477,6 +6511,17 @@ ], "title": "Key" }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, "requests": { "title": "Requests", "type": "integer" @@ -9469,8 +9514,22 @@ }, "/v1/models/discoverable": { "get": { - "description": "List every model the configured provider credentials can reach.\n\nOperator-facing counterpart to GET /v1/models, which serves a curated catalog\nto API callers. This reports each provider separately and keeps its error, so\na provider with a bad key is distinguishable from one with no models. It is\nmaster-key gated because a provider error message describes the gateway's own\nconfiguration.", + "description": "List every model the configured provider credentials can reach.\n\nOperator-facing counterpart to GET /v1/models, which serves a curated catalog\nto API callers. This reports each provider separately and keeps its error, so\na provider with a bad key is distinguishable from one with no models. It is\nmaster-key gated because a provider error message describes the gateway's own\nconfiguration.\n\nAnswers from the discovery cache, which a background refresher keeps warm, so\nthe call does not wait on a slow or unreachable provider. Each provider\ncarries the ``checked_at`` its result was produced at; a null one has not been\ndialed yet. Pass ``refresh=true`` to force a live re-dial of every provider.", "operationId": "list_discoverable_models_v1_models_discoverable_get", + "parameters": [ + { + "description": "Re-dial every provider instead of answering from the discovery cache.", + "in": "query", + "name": "refresh", + "required": false, + "schema": { + "default": false, + "description": "Re-dial every provider instead of answering from the discovery cache.", + "title": "Refresh", + "type": "boolean" + } + } + ], "responses": { "200": { "content": { @@ -9481,6 +9540,16 @@ } }, "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" } }, "security": [ @@ -9499,7 +9568,7 @@ }, "/v1/models/metadata": { "get": { - "description": "Per-model metadata for the dashboard's detail view, from models.dev.\n\nCovers every model models.dev lists under a configured provider, keyed by the\n``instance:model`` selector the dashboard uses. ``available`` is false when\nenrichment is disabled (``models_dev_metadata``) or models.dev could not be\nreached; the response is then empty and the UI falls back to bundled data.\nMaster-key gated: it describes the gateway's configured providers.", + "description": "Per-model metadata for the dashboard's detail view, from models.dev.\n\nCovers every model models.dev lists under a configured provider, keyed by the\n``instance:model`` selector the dashboard uses. ``available`` is false when\nenrichment is disabled (``models_dev_metadata``) or models.dev could not be\nreached; the response is then empty and the UI falls back to bundled data.\nMaster-key gated: it describes the gateway's configured providers.\n\nAnswers from the cached catalog, kept warm by a background refresher, so the\ndashboard never waits on the models.dev fetch timeout.", "operationId": "list_model_metadata_v1_models_metadata_get", "responses": { "200": { diff --git a/docs/public/otari.postman_collection.json b/docs/public/otari.postman_collection.json index c462c7a1..fce57e2d 100644 --- a/docs/public/otari.postman_collection.json +++ b/docs/public/otari.postman_collection.json @@ -1258,7 +1258,7 @@ { "name": "List Discoverable Models", "request": { - "description": "List every model the configured provider credentials can reach.\n\nOperator-facing counterpart to GET /v1/models, which serves a curated catalog\nto API callers. This reports each provider separately and keeps its error, so\na provider with a bad key is distinguishable from one with no models. It is\nmaster-key gated because a provider error message describes the gateway's own\nconfiguration.", + "description": "List every model the configured provider credentials can reach.\n\nOperator-facing counterpart to GET /v1/models, which serves a curated catalog\nto API callers. This reports each provider separately and keeps its error, so\na provider with a bad key is distinguishable from one with no models. It is\nmaster-key gated because a provider error message describes the gateway's own\nconfiguration.\n\nAnswers from the discovery cache, which a background refresher keeps warm, so\nthe call does not wait on a slow or unreachable provider. Each provider\ncarries the ``checked_at`` its result was produced at; a null one has not been\ndialed yet. Pass ``refresh=true`` to force a live re-dial of every provider.", "header": [], "method": "GET", "url": { @@ -1270,14 +1270,22 @@ "models", "discoverable" ], - "raw": "{{baseUrl}}/v1/models/discoverable" + "query": [ + { + "description": "Re-dial every provider instead of answering from the discovery cache.", + "disabled": true, + "key": "refresh", + "value": "" + } + ], + "raw": "{{baseUrl}}/v1/models/discoverable?refresh=" } } }, { "name": "List Model Metadata", "request": { - "description": "Per-model metadata for the dashboard's detail view, from models.dev.\n\nCovers every model models.dev lists under a configured provider, keyed by the\n``instance:model`` selector the dashboard uses. ``available`` is false when\nenrichment is disabled (``models_dev_metadata``) or models.dev could not be\nreached; the response is then empty and the UI falls back to bundled data.\nMaster-key gated: it describes the gateway's configured providers.", + "description": "Per-model metadata for the dashboard's detail view, from models.dev.\n\nCovers every model models.dev lists under a configured provider, keyed by the\n``instance:model`` selector the dashboard uses. ``available`` is false when\nenrichment is disabled (``models_dev_metadata``) or models.dev could not be\nreached; the response is then empty and the UI falls back to bundled data.\nMaster-key gated: it describes the gateway's configured providers.\n\nAnswers from the cached catalog, kept warm by a background refresher, so the\ndashboard never waits on the models.dev fetch timeout.", "header": [], "method": "GET", "url": { diff --git a/src/gateway/api/routes/models.py b/src/gateway/api/routes/models.py index 2934e524..d5f1c9ef 100644 --- a/src/gateway/api/routes/models.py +++ b/src/gateway/api/routes/models.py @@ -18,10 +18,12 @@ from gateway.services.model_access import is_model_allowed, resolve_request_allowlist from gateway.services.model_catalog_service import ( ModelCatalogEntry, + background_catalog_enabled, build_metadata_map, load_models_dev_catalog, ) from gateway.services.model_discovery_service import ( + background_discovery_enabled, discover_all_models, discover_models_with_status, get_model_cache, @@ -120,6 +122,13 @@ class DiscoverableProvider(BaseModel): "The provider may still handle requests for models declared in config." ), ) + checked_at: str | None = Field( + default=None, + description=( + "When this instance was last dialed, ISO 8601. Null when it has not been checked yet, " + "which is what the first read after a restart sees while the background refresh runs." + ), + ) models: list[DiscoverableModel] @@ -432,7 +441,16 @@ async def list_models( # Phase 1: auto-discovered models from upstream providers. if config.model_discovery: try: - discovered = await discover_all_models(config, provider_filter=provider) + # Cache-only when a refresher owns the dialing. This endpoint is + # reachable with any API key, so it deliberately has no ``refresh`` + # escape hatch: forcing a fanout across every configured provider is + # an operator action, and lives on the master-key-gated + # /v1/models/discoverable and /v1/providers/health instead. + discovered = await discover_all_models( + config, + provider_filter=provider, + serve_stale=background_discovery_enabled(config), + ) except Exception: logger.exception("Model discovery failed unexpectedly") discovered = [] @@ -535,6 +553,10 @@ def _permitted(model_id: str) -> bool: @router.get("/models/discoverable", dependencies=[Depends(verify_master_key)]) async def list_discoverable_models( config: Annotated[GatewayConfig, Depends(get_config)], + refresh: Annotated[ + bool, + Query(description="Re-dial every provider instead of answering from the discovery cache."), + ] = False, ) -> DiscoverableModelsResponse: """List every model the configured provider credentials can reach. @@ -543,14 +565,22 @@ async def list_discoverable_models( a provider with a bad key is distinguishable from one with no models. It is master-key gated because a provider error message describes the gateway's own configuration. + + Answers from the discovery cache, which a background refresher keeps warm, so + the call does not wait on a slow or unreachable provider. Each provider + carries the ``checked_at`` its result was produced at; a null one has not been + dialed yet. Pass ``refresh=true`` to force a live re-dial of every provider. """ - discoveries = await discover_models_with_status(config) + serve_stale = background_discovery_enabled(config) and not refresh + discoveries = await discover_models_with_status(config, serve_stale=serve_stale, force=refresh) + cache = get_model_cache() providers = [ DiscoverableProvider( provider=discovery.provider, ok=discovery.error is None, error=discovery.error, discovery_unsupported=discovery.discovery_unsupported, + checked_at=checked.isoformat() if (checked := cache.checked_at(discovery.provider)) else None, models=sorted( ( DiscoverableModel(id=model.id, key=f"{discovery.provider}:{model.id}") @@ -577,8 +607,11 @@ async def list_model_metadata( enrichment is disabled (``models_dev_metadata``) or models.dev could not be reached; the response is then empty and the UI falls back to bundled data. Master-key gated: it describes the gateway's configured providers. + + Answers from the cached catalog, kept warm by a background refresher, so the + dashboard never waits on the models.dev fetch timeout. """ - catalog = await load_models_dev_catalog(config) + catalog = await load_models_dev_catalog(config, serve_stale=background_catalog_enabled(config)) entries = build_metadata_map(config, catalog) return ModelMetadataResponse( available=catalog is not None, @@ -626,16 +659,28 @@ async def get_model( ) pricing = (await db.execute(stmt)).scalar_one_or_none() - # Check the discovery cache for this model (respecting TTL). - # Parse provider from model_id ("provider:model_name") for a targeted lookup - # instead of scanning all cached providers. + # Check the discovery cache for this model. Parse provider from model_id + # ("provider:model_name") for a targeted lookup instead of scanning all + # cached providers. + # + # Read stale-tolerantly, matching the listing above, because nothing on the + # request path renews ``cached_at`` any more: the refresher sleeps its + # interval *after* each round finishes, so an entry stored at T is already + # expired when the next round starts and stays expired until that round's + # dials complete. A TTL-bounded peek here would 404 a model that GET + # /v1/models is listing in the same instant, for any provider model with no + # pricing row and no genai-prices fallback. This endpoint never dials, so + # serving the last known answer is the only way to agree with the listing. discovered_model = None discovered_provider = None if config.model_discovery and ":" in model_id: provider_prefix, model_name = model_id.split(":", 1) cache = get_model_cache() - ttl = config.model_cache_ttl_seconds - cached_models = cache.get(provider_prefix, ttl) + if background_discovery_enabled(config): + stale = cache.stale(provider_prefix) + cached_models = stale.models if stale is not None and stale.error is None else None + else: + cached_models = cache.get(provider_prefix, config.model_cache_ttl_seconds) if cached_models is not None: for model in cached_models: if model.id == model_name: diff --git a/src/gateway/api/routes/providers.py b/src/gateway/api/routes/providers.py index 723ba4c9..b931b33d 100644 --- a/src/gateway/api/routes/providers.py +++ b/src/gateway/api/routes/providers.py @@ -21,6 +21,7 @@ from gateway.log_config import logger from gateway.models.entities import ProviderCredential from gateway.services.model_discovery_service import ( + background_discovery_enabled, discover_provider_models, get_model_cache, test_provider_credentials, @@ -270,7 +271,11 @@ async def provider_health( ``discovery_unsupported`` and counted under ``degraded`` rather than as a reachability failure. """ - results = await check_all_provider_health(config, refresh=refresh) + results = await check_all_provider_health( + config, + refresh=refresh, + serve_stale=background_discovery_enabled(config) and not refresh, + ) checked_ats = [health.checked_at for health in results if health.checked_at is not None] return ProviderHealthResponse( providers=[_to_health_schema(health) for health in sorted(results, key=lambda item: item.instance)], diff --git a/src/gateway/api/routes/usage.py b/src/gateway/api/routes/usage.py index 32a60140..6caf7275 100644 --- a/src/gateway/api/routes/usage.py +++ b/src/gateway/api/routes/usage.py @@ -8,7 +8,7 @@ import csv import io from datetime import UTC, datetime, timedelta -from typing import Annotated, Any, Literal, cast +from typing import Annotated, Any, Literal, NamedTuple, cast from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel @@ -18,7 +18,7 @@ from gateway.api.deps import get_config, get_db, verify_api_key_or_master_key, verify_master_key from gateway.core.config import GatewayConfig from gateway.core.sql import MAX_FILTER_VALUES, match_any -from gateway.models.entities import APIKey, UsageLog +from gateway.models.entities import APIKey, UsageLog, User from gateway.services.external_usage_service import ( ExternalEventsRequest, ExternalIngestResult, @@ -78,14 +78,30 @@ # Every breakdown ``/summary`` can compute, mapped to the column it groups by and # its top-N cap. A dimension name is the ``by_`` response field it fills, so # a caller reads the selector and the payload with one vocabulary. -_SUMMARY_DIMENSIONS: dict[str, tuple[Any, int]] = { - "model": (UsageLog.model, _BREAKDOWN_TOP_N), - "user": (UsageLog.user_id, _BREAKDOWN_TOP_N), - "api_key": (UsageLog.api_key_id, _BREAKDOWN_TOP_N), - "source": (UsageLog.source, _BREAKDOWN_TOP_N), - "source_label": (UsageLog.source_label, _SESSION_BREAKDOWN_TOP_N), - "endpoint": (UsageLog.endpoint, _BREAKDOWN_TOP_N), - "provider": (UsageLog.provider, _BREAKDOWN_TOP_N), +class _LabelJoin(NamedTuple): + """How to resolve a breakdown key's display name in the same GROUP BY. + + Only the two dimensions whose key is an opaque id need this: a model, source + or endpoint already reads as its own name. Resolving it here is what lets a + client offer a user or key filter without holding those whole tables. + """ + + entity: Any + on: Any + label: Any + + +_USER_LABEL = _LabelJoin(entity=User, on=User.user_id == UsageLog.user_id, label=User.alias) +_API_KEY_LABEL = _LabelJoin(entity=APIKey, on=APIKey.id == UsageLog.api_key_id, label=APIKey.key_name) + +_SUMMARY_DIMENSIONS: dict[str, tuple[Any, int, "_LabelJoin | None"]] = { + "model": (UsageLog.model, _BREAKDOWN_TOP_N, None), + "user": (UsageLog.user_id, _BREAKDOWN_TOP_N, _USER_LABEL), + "api_key": (UsageLog.api_key_id, _BREAKDOWN_TOP_N, _API_KEY_LABEL), + "source": (UsageLog.source, _BREAKDOWN_TOP_N, None), + "source_label": (UsageLog.source_label, _SESSION_BREAKDOWN_TOP_N, None), + "endpoint": (UsageLog.endpoint, _BREAKDOWN_TOP_N, None), + "provider": (UsageLog.provider, _BREAKDOWN_TOP_N, None), } # The failure taxonomy (``errors_by_status_code``) is a GROUP BY pass like the @@ -130,7 +146,14 @@ class UsageEntry(BaseModel): id: str user_id: str | None + # Display labels resolved server-side, so a client rendering a page of rows + # does not have to hold the whole users/api_keys tables to name them. Null + # when the row has no owner, when the referenced row is gone (both foreign + # keys are ON DELETE SET NULL), or when the entity simply has no label set; + # a client falls back to the id in every one of those cases. + user_alias: str | None = None api_key_id: str | None + api_key_name: str | None = None timestamp: str model: str provider: str | None @@ -162,11 +185,19 @@ class UsageEntry(BaseModel): request_group_id: str | None = None @classmethod - def from_model(cls, log: UsageLog) -> "UsageEntry": + def from_model( + cls, + log: UsageLog, + *, + user_alias: str | None = None, + api_key_name: str | None = None, + ) -> "UsageEntry": return cls( id=log.id, user_id=log.user_id, + user_alias=user_alias, api_key_id=log.api_key_id, + api_key_name=api_key_name, timestamp=_utc_iso(log.timestamp), model=log.model, provider=log.provider, @@ -392,10 +423,24 @@ async def list_usage( counts_toward_budget=counts_toward_budget, request_group_id=request_group_id, ) - stmt = select(UsageLog).where(*conditions).order_by(UsageLog.timestamp.desc()).offset(skip).limit(limit) + # Outer-joined rather than looked up per row, and rather than left to the + # client: naming a page of rows must not cost a round trip each, nor oblige a + # dashboard to hold every user and every key in memory to label 100 rows. + # Outer so a row whose owner was deleted still comes back, with a null label. + stmt = ( + select(UsageLog, User.alias, APIKey.key_name) + .outerjoin(User, User.user_id == UsageLog.user_id) + .outerjoin(APIKey, APIKey.id == UsageLog.api_key_id) + .where(*conditions) + .order_by(UsageLog.timestamp.desc()) + .offset(skip) + .limit(limit) + ) result = await db.execute(stmt) - logs = result.scalars().all() - return [UsageEntry.from_model(log) for log in logs] + return [ + UsageEntry.from_model(log, user_alias=alias, api_key_name=key_name) + for log, alias, key_name in result.all() + ] @router.post("/external-events") @@ -563,6 +608,12 @@ class UsageGroupRow(BaseModel): """ key: str | None + # Display name for an opaque key (a user's alias, an API key's name), resolved + # in the same GROUP BY. Only ever set for the ``user`` and ``api_key`` + # dimensions, and null there too when the entity has no label or is gone; a + # client falls back to ``key``. Its purpose is to let a client build a user or + # key filter from this breakdown alone, rather than reading both whole tables. + label: str | None = None cost: float tokens: int requests: int @@ -937,6 +988,7 @@ async def _breakdown( *, limit: int | None, status_filter: str | None = None, + label_join: "_LabelJoin | None" = None, ) -> list[UsageGroupRow]: """Spend/tokens/requests grouped by ``column``, biggest spend first. @@ -950,21 +1002,31 @@ async def _breakdown( truncate). """ cost_sum = func.coalesce(func.sum(UsageLog.cost), 0.0) - stmt = ( - select( - column, - cost_sum, - _billed_input_sum() + _billed_output_sum(), - _request_count_expr(status_filter), - ) - .where(*conditions) - .group_by(column) - .order_by(cost_sum.desc()) + # The label rides along in the same pass rather than costing a second query or + # a client-side table dump. Grouped by as well as selected: it is functionally + # dependent on the joined row's primary key, but only PostgreSQL infers that, + # and this has to run on SQLite too. Outer-joined so a group whose entity was + # deleted keeps its row (with a null label) instead of vanishing from a + # breakdown that must still reconcile against the totals. + label_column = label_join.label if label_join is not None else null() + stmt = select( + column, + label_column, + cost_sum, + _billed_input_sum() + _billed_output_sum(), + _request_count_expr(status_filter), ) + if label_join is not None: + stmt = stmt.outerjoin(label_join.entity, label_join.on) + group_by = (column,) if label_join is None else (column, label_column) + stmt = stmt.where(*conditions).group_by(*group_by).order_by(cost_sum.desc()) if limit is not None: stmt = stmt.limit(limit) rows = (await db.execute(stmt)).all() - result = [UsageGroupRow(key=row[0], cost=float(row[1]), tokens=int(row[2]), requests=int(row[3])) for row in rows] + result = [ + UsageGroupRow(key=row[0], label=row[1], cost=float(row[2]), tokens=int(row[3]), requests=int(row[4])) + for row in rows + ] if limit is not None: seen_requests = sum(r.requests for r in result) # request_count is an exact integer, so a positive residual is the reliable @@ -1246,8 +1308,8 @@ async def usage_summary( # an empty selection, and it never contributes a dimension of its own. requested: set[str] = _ALL_SUMMARY_DIMENSIONS if dimensions is None else {d for d in dimensions if d != "none"} breakdowns = { - name: await _breakdown(db, column, conditions, totals, limit=cap, status_filter=status) - for name, (column, cap) in _SUMMARY_DIMENSIONS.items() + name: await _breakdown(db, column, conditions, totals, limit=cap, status_filter=status, label_join=label) + for name, (column, cap, label) in _SUMMARY_DIMENSIONS.items() if name in requested } # The failure taxonomy is a GROUP BY pass like the others, so it answers to the @@ -1311,11 +1373,11 @@ async def usage_summary( ) -_GROUP_COLUMNS: dict[str, Any] = { - "model": UsageLog.model, - "user_id": UsageLog.user_id, - "api_key_id": UsageLog.api_key_id, - "source": UsageLog.source, +_GROUP_COLUMNS: dict[str, tuple[Any, "_LabelJoin | None"]] = { + "model": (UsageLog.model, None), + "user_id": (UsageLog.user_id, _USER_LABEL), + "api_key_id": (UsageLog.api_key_id, _API_KEY_LABEL), + "source": (UsageLog.source, None), } @@ -1381,8 +1443,10 @@ async def usage_series( status_code=422, detail=f"window spans more than {_MAX_SERIES_POINTS} {bucket} buckets; use bucket=day or narrow the range", ) - column = _GROUP_COLUMNS[group_by] - groups = await _breakdown(db, column, conditions, totals, limit=_SERIES_TOP_N, status_filter=status) + column, label_join = _GROUP_COLUMNS[group_by] + groups = await _breakdown( + db, column, conditions, totals, limit=_SERIES_TOP_N, status_filter=status, label_join=label_join + ) # One grouped query for the whole grid: groups outside the top N collapse # into the fold in SQL rather than being fetched and folded here, so the row @@ -1511,7 +1575,7 @@ async def usage_summary_csv( _CSV_DIMENSION_LABELS.get(name, name), await _breakdown(db, column, conditions, totals, limit=None, status_filter=status), ) - for name, (column, _cap) in _SUMMARY_DIMENSIONS.items() + for name, (column, _cap, _label) in _SUMMARY_DIMENSIONS.items() ] buffer = io.StringIO() diff --git a/src/gateway/core/config.py b/src/gateway/core/config.py index fde2777a..87d30a00 100644 --- a/src/gateway/core/config.py +++ b/src/gateway/core/config.py @@ -491,9 +491,13 @@ class GatewayConfig(BaseSettings): default=30.0, ge=0, description=( - "How long a failed model-discovery result is remembered before the provider " + "How long a failed model-discovery result is remembered before that provider " "is dialed again, in seconds. Stops an unreachable provider from being re-tried " - "on every request (0 disables negative caching, restoring retry-every-time)." + "on every request (0 disables negative caching, restoring retry-every-time). " + "Applies to a read that dials: a provider never dialed before, or any read " + "while model_cache_ttl_seconds is 0. Otherwise the background refresher owns " + "the dialing and model_cache_ttl_seconds bounds how soon a recovered provider " + "is seen again." ), ) models_dev_metadata: bool = Field( @@ -508,7 +512,12 @@ class GatewayConfig(BaseSettings): models_dev_cache_ttl_seconds: int = Field( default=86400, ge=0, - description="TTL in seconds for the cached models.dev catalog (0 disables caching).", + description=( + "TTL in seconds for the cached models.dev catalog, and the interval at which a " + "background task refetches it (floored at 5 minutes). Above 0, GET /v1/models/metadata " + "answers from the cache instead of waiting on the fetch; a failed fetch is held for one " + "minute rather than the refresh interval. 0 disables caching, so every read fetches." + ), ) files_enabled: bool = Field( default=True, diff --git a/src/gateway/main.py b/src/gateway/main.py index f719a6cb..1df45784 100644 --- a/src/gateway/main.py +++ b/src/gateway/main.py @@ -1,6 +1,6 @@ import asyncio from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager, suppress +from contextlib import asynccontextmanager from typing import Any, Callable from fastapi import FastAPI, Request, Response @@ -15,6 +15,7 @@ from gateway.core.config import API_KEY_HEADER, X_API_KEY_HEADER, GatewayConfig from gateway.core.database import create_session, init_db from gateway.dashboard import get_dashboard_build_id, get_dashboard_dir +from gateway.log_config import logger from gateway.rate_limit import RateLimiter 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 @@ -23,6 +24,14 @@ 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 +from gateway.services.model_catalog_service import ( + clear_catalog_cache, + run_catalog_refresher, +) +from gateway.services.model_discovery_service import ( + reset_discovery_cache, + run_discovery_refresher, +) from gateway.services.policy_store import ( load_policies_at_startup, reset_policy_cache, @@ -111,6 +120,72 @@ def _validate_platform_config(config: GatewayConfig) -> None: raise ValueError(msg) +# How long shutdown waits for refreshers to acknowledge cancellation. +# +# Cancelling a task is a request, not a guarantee. The CancelledError is +# delivered at whatever the task is awaiting, and a nested cancel scope there can +# absorb it: httpx and the provider SDKs implement their own timeouts as anyio +# cancel scopes, which call ``Task.uncancel`` when they decide the cancellation +# was theirs. The refresher loop then resumes, falls through to its ``sleep``, +# and naps for a whole interval (a day, for the models.dev catalog). An +# unbounded ``await task`` never returns, so the lifespan never finishes and +# uvicorn's shutdown hangs behind a background refresh. Bounding the wait and +# moving on is the right trade: the event loop is torn down immediately after, +# and no refresher owns state that a late tick could corrupt. +_REFRESHER_STOP_TIMEOUT_SECONDS = 5.0 + + +def _log_abandoned_refresher(name: str) -> None: + logger.warning( + "%s refresher did not stop within %.0fs; abandoning it so shutdown can finish", + name, + _REFRESHER_STOP_TIMEOUT_SECONDS, + ) + + +def _log_refresher_stop(task: asyncio.Task[None], name: str) -> None: + if not task.cancelled() and (error := task.exception()) is not None: + logger.warning("%s refresher stopped with an unexpected error", name, exc_info=error) + + +async def _wait_for_refresher_stop(task: asyncio.Task[None], name: str) -> None: + """Wait for a cancelled lifespan refresher, but never indefinitely. + + ``asyncio.wait`` rather than ``await task``: it takes a timeout, and it + reports the outcome instead of re-raising it, so a refresher that died on an + unexpected error is logged here rather than aborting the rest of shutdown + (the log writer and the pooled search client still need closing). + """ + done, _pending = await asyncio.wait({task}, timeout=_REFRESHER_STOP_TIMEOUT_SECONDS) + if not done: + _log_abandoned_refresher(name) + return + _log_refresher_stop(task, name) + + +async def _stop_refresher(task: asyncio.Task[None], name: str) -> None: + """Cancel one lifespan refresher and wait for it, but never indefinitely.""" + task.cancel() + await _wait_for_refresher_stop(task, name) + + +async def _stop_refreshers(refreshers: list[tuple[asyncio.Task[None], str]]) -> None: + """Cancel all refreshers, then give the group one shared shutdown bound.""" + if not refreshers: + return + for task, _name in refreshers: + task.cancel() + done, pending = await asyncio.wait( + {task for task, _name in refreshers}, + timeout=_REFRESHER_STOP_TIMEOUT_SECONDS, + ) + for task, name in refreshers: + if task in pending: + _log_abandoned_refresher(name) + elif task in done: + _log_refresher_stop(task, name) + + def _create_lifespan(config: GatewayConfig) -> Callable[[FastAPI], Any]: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: @@ -120,6 +195,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: policy_refresher: asyncio.Task[None] | None = None provider_refresher: asyncio.Task[None] | None = None price_refresher: asyncio.Task[None] | None = None + discovery_refresher: asyncio.Task[None] | None = None + catalog_refresher: asyncio.Task[None] | None = None if config.is_hybrid_mode: log_writer = NoopLogWriter() else: @@ -171,6 +248,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # served the confirm; reload it on a TTL so sibling workers and replicas # converge, the same way aliases and provider credentials do. price_refresher = asyncio.create_task(run_price_snapshot_refresher()) + # Discovery is the one cache that used to be filled on the request + # path, which put model_discovery_timeout_seconds (10s per + # unreachable provider) on a dashboard page load and held that + # request's database session open for the whole dial. The refresher + # owns the dialing now and reads answer from the cache. Not awaited: + # priming runs on its first tick so a slow provider cannot delay boot. + # + # Started unconditionally, and each loop re-checks whether caching is + # on. Gating task creation on the setting here would strand the + # gateway in the one state that combination must never reach: + # model_cache_ttl_seconds and models_dev_cache_ttl_seconds are both + # runtime-settable from the dashboard's Settings page, and raising + # either from 0 flips every read onto the serve-from-cache path + # immediately. With no refresher running, that cache is then filled + # once per provider and never refreshed again for the life of the + # worker, which is the "cache nothing refreshes" mode these knobs + # deliberately do not offer. + discovery_refresher = asyncio.create_task(run_discovery_refresher(config)) + # Same shape for the models.dev catalog, whose fetch is bounded at 15s. + catalog_refresher = asyncio.create_task(run_catalog_refresher(config)) # Start the writer inside the try so a failure here still runs the cleanup # below; the refresher tasks are already created and would otherwise leak. @@ -181,25 +278,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.log_writer = log_writer yield finally: + refreshers = [ + (alias_refresher, "alias"), + (policy_refresher, "policy"), + (provider_refresher, "provider"), + (price_refresher, "price snapshot"), + (discovery_refresher, "model discovery"), + (catalog_refresher, "models.dev catalog"), + ] + await _stop_refreshers([(task, name) for task, name in refreshers if task is not None]) if alias_refresher is not None: - alias_refresher.cancel() - with suppress(asyncio.CancelledError): - await alias_refresher reset_alias_cache() if policy_refresher is not None: - policy_refresher.cancel() - with suppress(asyncio.CancelledError): - await policy_refresher reset_policy_cache() if provider_refresher is not None: - provider_refresher.cancel() - with suppress(asyncio.CancelledError): - await provider_refresher reset_provider_cache() - if price_refresher is not None: - price_refresher.cancel() - with suppress(asyncio.CancelledError): - await price_refresher + if discovery_refresher is not None: + reset_discovery_cache() + if catalog_refresher is not None: + clear_catalog_cache() # Only stop a writer that actually started; if start() raised there is # nothing to stop, but the refreshers above still needed cancelling. if log_writer_started: diff --git a/src/gateway/services/model_catalog_service.py b/src/gateway/services/model_catalog_service.py index 2c2893c7..01e67115 100644 --- a/src/gateway/services/model_catalog_service.py +++ b/src/gateway/services/model_catalog_service.py @@ -87,10 +87,25 @@ def clear_catalog_cache() -> None: _cache.at = 0.0 -def _read_cache(ttl: int) -> object: - """Return the cached data if still fresh, else the ``_MISS`` sentinel.""" +def _read_cache(ttl: int, *, serve_stale: bool = False, force: bool = False) -> object: + """Return the cached data if still fresh, else the ``_MISS`` sentinel. + + ``serve_stale`` accepts a cached *successful* blob at any age, so a read + never pays the 15s fetch timeout; the background refresher is what keeps it + current. A failed fetch is deliberately excluded: it falls through to + ``_NEGATIVE_TTL_SECONDS`` below, because the refresh interval is the success + cadence (a day by default) and pinning a transient models.dev outage for that + long, with no read able to retry it and no ``refresh`` flag on + ``/v1/models/metadata``, would lose enrichment far longer than the failure. + ``force`` is the refresher's own read: it treats every entry as expired so + the fetch actually happens. + """ if _cache.at == 0.0: return _MISS + if force: + return _MISS + if serve_stale and _cache.ok: + return _cache.data # A successful fetch honors the configured TTL (0 disables caching, so it is # never fresh); a failed one is held only briefly before retrying. if _cache.ok and ttl == 0: @@ -126,23 +141,30 @@ async def _fetch() -> dict[str, Any] | None: return data -async def load_models_dev_catalog(config: GatewayConfig) -> dict[str, Any] | None: +async def load_models_dev_catalog( + config: GatewayConfig, + *, + serve_stale: bool = False, + force: bool = False, +) -> dict[str, Any] | None: """Return the cached models.dev catalog, fetching it if stale. Returns ``None`` when metadata enrichment is disabled or the fetch failed. + ``serve_stale`` answers from the cache at any age so a dashboard read never + waits on models.dev; ``force`` is the background refresher's fetch. """ if not config.models_dev_metadata: return None ttl = config.models_dev_cache_ttl_seconds - cached = _read_cache(ttl) + cached = _read_cache(ttl, serve_stale=serve_stale, force=force) if cached is not _MISS: return cached # type: ignore[return-value] async with _lock: # Double-check under the lock so a burst of dashboard loads triggers one # fetch, not one per request. - cached = _read_cache(ttl) + cached = _read_cache(ttl, serve_stale=serve_stale, force=force) if cached is not _MISS: return cached # type: ignore[return-value] @@ -153,6 +175,52 @@ async def load_models_dev_catalog(config: GatewayConfig) -> dict[str, Any] | Non return data +# Floor on the refresh cadence, for the same reason as the discovery refresher's: +# a tiny configured TTL must not turn into a fetch storm against models.dev. +_MIN_REFRESH_INTERVAL_SECONDS = 300.0 + + +def background_catalog_enabled(config: GatewayConfig) -> bool: + """Whether reads may answer from the cache because a refresher fills it. + + ``models_dev_cache_ttl_seconds = 0`` is the documented way to disable + caching, so it keeps meaning "fetch on every read", and ``models_dev_metadata`` + already disables enrichment outright. + """ + return config.models_dev_metadata and config.models_dev_cache_ttl_seconds > 0 + + +def _catalog_refresh_interval(config: GatewayConfig) -> float: + return max(_MIN_REFRESH_INTERVAL_SECONDS, float(config.models_dev_cache_ttl_seconds)) + + +async def run_catalog_refresher(config: GatewayConfig, interval: float | None = None) -> None: + """Keep the models.dev catalog warm so no read waits on the 15s fetch. + + Primes once immediately, then refetches on the interval. Errors are already + swallowed inside ``_fetch`` (a failure degrades to "no enrichment"), so the + guard here only covers an unexpected one taking the refresher down. + + Re-checks ``background_catalog_enabled`` every tick rather than being started + only when it holds, for the same reason as the discovery refresher: + ``models_dev_metadata`` and ``models_dev_cache_ttl_seconds`` are both + runtime-settable, so the loop has to be able to start and stop refetching + without a restart. + """ + while True: + try: + if background_catalog_enabled(config): + await load_models_dev_catalog(config, force=True) + except asyncio.CancelledError: + raise + except Exception: + logger.warning("models.dev catalog refresh failed; retrying on the next tick", exc_info=True) + # Recomputed per tick, not captured once, so a TTL changed at runtime + # takes effect on the next round. + delay = interval if interval is not None else _catalog_refresh_interval(config) + await asyncio.sleep(delay) + + def _as_str(value: Any) -> str | None: return value if isinstance(value, str) and value else None diff --git a/src/gateway/services/model_discovery_service.py b/src/gateway/services/model_discovery_service.py index e988260f..cbd61728 100644 --- a/src/gateway/services/model_discovery_service.py +++ b/src/gateway/services/model_discovery_service.py @@ -15,6 +15,14 @@ in-flight ``list_models`` call instead of each firing their own. This is what stops ``/v1/models`` and ``/v1/models/discoverable`` (both mounted on the dashboard's Models page) from doubling every fanout. +- **Background refresh**: while ``model_cache_ttl_seconds`` is above 0 a + refresher task (``run_discovery_refresher``, wired in the lifespan) owns the + dialing, and reads answer from the cache at any age (``serve_stale``). So the + TTLs above describe when the *refresher* re-dials, not what a read waits for. + The exceptions are a provider that has never been dialed, which the arriving + read still dials so a cold worker does not claim the provider has no models, + and ``model_cache_ttl_seconds = 0``, which turns the refresher off and puts + every read back on the dialing path. """ import asyncio @@ -152,6 +160,18 @@ def _fresh(self, provider: str, positive_ttl: float, negative_ttl: float) -> Pro return None return entry.result + def stale(self, provider: str) -> ProviderDiscovery | None: + """The last known result for ``provider`` at any age, or ``None``. + + Read-only and never dials, unlike :meth:`get`, which is bounded by a TTL + and hides failures. This is what lets a read endpoint answer from the + cache while the background refresher owns the dialing: freshness is then + bounded by the refresh interval rather than paid for by whoever happens + to arrive after the TTL lapsed. + """ + entry = self._store.get(provider) + return _copy_discovery(entry.result) if entry is not None else None + async def get_or_discover( self, provider: str, @@ -159,6 +179,7 @@ async def get_or_discover( positive_ttl: float, negative_ttl: float, discover: Callable[[], Awaitable[ProviderDiscovery]], + serve_stale: bool = False, ) -> ProviderDiscovery: """Return a cached result, or run ``discover`` once (single-flight). @@ -167,7 +188,17 @@ async def get_or_discover( provider awaits that one task, so a slow provider is dialed once, not once per request. ``discover`` is expected to report failure by returning a ``ProviderDiscovery`` with ``error`` set rather than raising. + + ``serve_stale`` accepts a cached result of any age, so a read never waits + on a provider that has been dialed at least once. A provider with no + entry at all still dials (single-flighted), which keeps a cold worker + correct rather than briefly claiming the provider has no models. """ + if serve_stale: + stale = self.stale(provider) + if stale is not None: + return stale + cached = self._fresh(provider, positive_ttl, negative_ttl) if cached is not None: return _copy_discovery(cached) @@ -422,20 +453,33 @@ async def _discover_uncached(config: GatewayConfig, instance: str) -> ProviderDi return ProviderDiscovery(provider=instance, models=models) -async def discover_provider_models(config: GatewayConfig, instance: str) -> ProviderDiscovery: +async def discover_provider_models( + config: GatewayConfig, + instance: str, + *, + serve_stale: bool = False, + force: bool = False, +) -> ProviderDiscovery: """Discover one instance's models, cached (positive + negative) and single-flighted. ``discover_all_models`` drops a failing provider and logs it, which is right for a catalog served to API callers: one broken provider should not blank the listing. An operator choosing a model needs the opposite, because an empty dropdown and a provider whose key is wrong look identical. + + ``serve_stale`` answers from the cache at any age (see + :meth:`ModelCache.get_or_discover`). ``force`` treats every cached entry as + expired so the provider is dialed, without clearing the cache first: the + in-flight registration still coalesces concurrent callers, so the background + refresher and a request arriving mid-tick share one dial rather than racing. """ cache = get_model_cache() return await cache.get_or_discover( instance, - positive_ttl=config.model_cache_ttl_seconds, - negative_ttl=config.model_discovery_negative_ttl_seconds, + positive_ttl=0 if force else config.model_cache_ttl_seconds, + negative_ttl=0 if force else config.model_discovery_negative_ttl_seconds, discover=lambda: _discover_uncached(config, instance), + serve_stale=serve_stale and not force, ) @@ -509,7 +553,12 @@ async def test_provider_credentials( return ProviderDiscovery(provider=impl_name, models=list(models)) -async def discover_models_with_status(config: GatewayConfig) -> list[ProviderDiscovery]: +async def discover_models_with_status( + config: GatewayConfig, + *, + serve_stale: bool = False, + force: bool = False, +) -> list[ProviderDiscovery]: """Discover every configured instance's models concurrently, keeping errors. Deliberately not gated on ``config.model_discovery``: that flag governs what @@ -523,7 +572,7 @@ async def discover_models_with_status(config: GatewayConfig) -> list[ProviderDis # cannot 500 the whole operator listing (this route awaits with no guard); # surface it as a per-provider error instead. Real cancellation still bubbles. results = await asyncio.gather( - *(discover_provider_models(config, name) for name in instances), + *(discover_provider_models(config, name, serve_stale=serve_stale, force=force) for name in instances), return_exceptions=True, ) discoveries: list[ProviderDiscovery] = [] @@ -541,6 +590,8 @@ async def discover_models_with_status(config: GatewayConfig) -> list[ProviderDis async def discover_all_models( config: GatewayConfig, provider_filter: str | None = None, + *, + serve_stale: bool = False, ) -> list[tuple[str, Model]]: """Discover models from the configured providers with caching. @@ -570,7 +621,7 @@ async def discover_all_models( # return_exceptions so a single provider cannot abort the catalog build; real # cancellation still bubbles. results = await asyncio.gather( - *(discover_provider_models(config, name) for name in instances), + *(discover_provider_models(config, name, serve_stale=serve_stale) for name in instances), return_exceptions=True, ) @@ -585,3 +636,104 @@ async def discover_all_models( # the API catalog so one bad provider does not blank the whole listing. result_models.extend((discovery.provider, model) for model in discovery.models) return result_models + + +# --------------------------------------------------------------------------- # +# Background refresh +# --------------------------------------------------------------------------- # + +# Floor on the refresh cadence. ``model_cache_ttl_seconds`` is an operator-facing +# freshness knob, and a very small value would otherwise turn into a re-dial storm +# against every configured provider. Reads still serve the cache below this floor; +# they are simply refreshed no faster than this. +_MIN_REFRESH_INTERVAL_SECONDS = 30.0 + + +def background_discovery_enabled(config: GatewayConfig) -> bool: + """Whether reads may answer from the cache because a refresher fills it. + + Tied to ``model_cache_ttl_seconds``, which is already the switch for this: + setting it to 0 is the documented way to disable discovery caching, and that + has to keep meaning "dial on every read" rather than silently becoming "serve + a cache nothing refreshes". There is deliberately no second knob for "cache + but do not refresh": that combination only produces a cache that goes stale + forever, which is not a mode worth offering. + + Also gated on ``model_discovery``. An operator who turned that off did so to + stop the gateway dialing providers, and a background refresher would fan out + across every configured provider every interval for the life of the process, + with nobody watching, which is new unrequested traffic against a provider + that may meter or rate-limit ``list_models``. With it off the two operator + endpoints keep dialing on read, exactly as they did before this refresher + existed. + """ + return config.model_discovery and config.model_cache_ttl_seconds > 0 + + +def _refresh_interval(config: GatewayConfig, *, had_failure: bool = False) -> float: + """How long to wait before the next round of dials. + + A round that reported at least one failure comes back sooner, bounded by + ``model_discovery_negative_ttl_seconds`` rather than the success cadence. + Reads serve a cached failure at any age, so the refresh interval is what now + decides how quickly a recovered provider reappears; leaving that at the + success TTL would keep a provider that came back 30s ago looking unreachable + for the rest of a 300s window. The floor still applies, so this cannot become + a retry storm. + """ + interval = float(config.model_cache_ttl_seconds) + if had_failure: + interval = min(interval, float(config.model_discovery_negative_ttl_seconds)) + return max(_MIN_REFRESH_INTERVAL_SECONDS, interval) + + +async def refresh_discovery_cache(config: GatewayConfig) -> bool: + """Re-dial every configured provider and store the result. + + Returns whether any provider reported a failure, which sets the next tick's + delay. Failures are already per-provider (``discover_models_with_status`` + reports them rather than raising), so this only guards against an unexpected + error taking the refresher down with it. + """ + discoveries = await discover_models_with_status(config, force=True) + return any(discovery.error is not None for discovery in discoveries) + + +async def run_discovery_refresher(config: GatewayConfig, interval: float | None = None) -> None: + """Keep the discovery cache warm so no read waits on a provider dial. + + Discovery was the last cache in the gateway still filled on the request path, + which put ``model_discovery_timeout_seconds`` (10s by default, per unreachable + provider) on the critical path of a dashboard page load, and held that page's + database session open for the duration. This is the same refresher shape the + alias, policy, provider and price caches already use. + + Primes once immediately so the first read is served from cache, then re-dials + on the interval. Every error is swallowed and retried on the next tick so one + bad round cannot freeze the catalog. Cancelled at shutdown. + + Re-checks ``background_discovery_enabled`` every tick rather than being + started only when it holds: ``model_cache_ttl_seconds`` is runtime-settable, + and while it is 0 the reads dial for themselves, so refreshing here would + only add a second dialer. Ticking without dialing keeps the loop ready to + resume the moment an operator turns caching back on. + """ + while True: + had_failure = False + try: + if background_discovery_enabled(config): + had_failure = await refresh_discovery_cache(config) + except asyncio.CancelledError: + raise + except Exception: + logger.warning("Model discovery refresh failed; retrying on the next tick", exc_info=True) + # An unexpected error is a failed round too: come back on the short + # cadence rather than sleeping out the full success interval. + had_failure = True + delay = interval if interval is not None else _refresh_interval(config, had_failure=had_failure) + await asyncio.sleep(delay) + + +def reset_discovery_cache() -> None: + """Drop every cached discovery (shutdown, and tests).""" + get_model_cache().clear() diff --git a/src/gateway/services/provider_health_service.py b/src/gateway/services/provider_health_service.py index 0df2db4a..af331863 100644 --- a/src/gateway/services/provider_health_service.py +++ b/src/gateway/services/provider_health_service.py @@ -68,6 +68,7 @@ async def check_provider_health( instance: str, *, refresh: bool = False, + serve_stale: bool = False, ) -> ProviderHealth: """Report one instance's reachability via the shared model-discovery path. @@ -77,10 +78,15 @@ async def check_provider_health( lands within ``_REFRESH_DEBOUNCE_SECONDS`` of the last dial is coalesced onto that recent result instead, so a burst of re-checks keeps the discovery subsystem's single-flight coalescing rather than detaching the in-flight task. + + ``serve_stale`` answers from the cache at any age, for the polled monitor + view: the background refresher owns the dialing, so an hourly poll must not + be the thing that pays a 10s timeout for an unreachable provider. It is + ignored under ``refresh``, which exists precisely to bypass the cache. """ if refresh and _refresh_should_redial(instance): get_model_cache().clear(instance) - discovery = await discover_provider_models(config, instance) + discovery = await discover_provider_models(config, instance, serve_stale=serve_stale and not refresh) return ProviderHealth( instance=instance, ok=discovery.error is None, @@ -95,6 +101,7 @@ async def check_all_provider_health( config: GatewayConfig, *, refresh: bool = False, + serve_stale: bool = False, ) -> list[ProviderHealth]: """Check every configured instance concurrently, keeping per-provider errors. @@ -105,7 +112,7 @@ async def check_all_provider_health( """ instances = list(config.providers) results = await asyncio.gather( - *(check_provider_health(config, name, refresh=refresh) for name in instances), + *(check_provider_health(config, name, refresh=refresh, serve_stale=serve_stale) for name in instances), return_exceptions=True, ) health: list[ProviderHealth] = [] diff --git a/src/gateway/static/dashboard/assets/ActivityPage-B-JrbzNG.js b/src/gateway/static/dashboard/assets/ActivityPage-B-JrbzNG.js new file mode 100644 index 00000000..9dd9e8a1 --- /dev/null +++ b/src/gateway/static/dashboard/assets/ActivityPage-B-JrbzNG.js @@ -0,0 +1 @@ +import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as x,u as Ot}from"./react-dgEcD0HR.js";import{f as Dt,b as Ft,a as qt,r as Lt,u as Ut,c as tt,d as he,e as Z,g as Bt,h as ct,A as fe,i as Kt,j as Wt,k as zt,P as Gt,E as Yt,C as dt,l as K,R as Vt,F as xe,m as Te,n as ut,o as Zt,Y as Ht}from"./index-D6WO6K2k.js";import{C as Xt,T as Jt}from"./charts-krq1PqQO.js";import{B as O,S as Qt}from"./heroui-COmYdDDM.js";import{u as es,r as ts,B as ss}from"./tableSelection-BJDASjEj.js";import{C as rs}from"./ConfirmDialog-gmtoFRlO.js";import{D as as}from"./DataTable-DuDxGlJc.js";import{F as os}from"./FilterChips-DTdIceb1.js";import{T as ns,S as st,P as ls}from"./TablePagination-D9yR_FiC.js";import"./recharts-C3cGlHOx.js";import"./Field-CBU9MRjz.js";function rt(e,r){const a=new Date(e);return Number.isNaN(a.getTime())?e:r==="hour"?a.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:"UTC"}):a.toLocaleDateString(void 0,{month:"short",day:"numeric",timeZone:"UTC"})}const is={key:"success",label:"Succeeded",color:"var(--otari-brand)"},cs={key:"errors",label:"Failed",color:"var(--otari-danger)"},ds={key:"requests",label:"Requests",color:"var(--otari-brand)"};function us({presets:e,extentKey:r,onPreset:a,onSelectRange:d,onSelectFull:i,series:c,bucket:n,windowStart:u,windowEnd:g,loading:h=!1,ariaLabel:p="Request volume over the selected window",action:w}){const k=c.map(o=>o.bucketStart),m=c.length,oe=Dt(u,g),H=c.some(o=>(o.errors??0)>0),W=H?[is,cs]:[ds],z=c.map(o=>{const f=Math.min(o.errors??0,o.requests);return H?{x:o.bucketStart,success:o.requests-f,errors:f}:{x:o.bucketStart,requests:o.requests}}),_=m>0?Ft(k,u,g):{startIndex:0,endIndex:0},[D,ne]=x.useState(null),F=x.useRef(D),G=o=>{F.current=o,ne(o)},j=D??_,C=j.endIndex-j.startIndex+1,b=j.startIndex===0&&j.endIndex>=m-1,y=m>0&&!b,P=(o,f)=>{if(m===0)return;const S=Math.max(0,Math.min(o,f)),T=Math.min(m-1,Math.max(o,f));if(S===0&&T===m-1){i();return}const $=Lt(k,S,T,n);$&&d($.startIso,$.endIso)},q=e.findIndex(o=>o.key===r),I=q>=0?e[q].seconds:m*qt(n)/1e3,A=q>=0?e[q+1]:e.find(o=>o.seconds===null||I!==null&&o.seconds>I),le=o=>{const f=Math.max(1,Math.min(m,Math.round(o))),S=(j.startIndex+j.endIndex+1)/2;let T=Math.round(S-f/2);T=Math.max(0,Math.min(m-f,T)),P(T,T+f-1)},X=()=>{if(b){A&&a(A);return}le(C*2)},J=()=>le(C/2),Q=x.useRef(null),L=x.useRef(null),Y=o=>{const f=Math.max(0,Math.min(m-C,o));return{startIndex:f,endIndex:f+C-1}},ie=o=>{const f=o.key==="ArrowRight"||o.key==="ArrowUp"?1:o.key==="ArrowLeft"||o.key==="ArrowDown"?-1:o.key==="PageUp"?C:o.key==="PageDown"?-C:o.key==="Home"?-m:o.key==="End"?m:0;if(f===0)return;o.preventDefault();const S=Y(j.startIndex+f);S.startIndex!==j.startIndex&&P(S.startIndex,S.endIndex)},ke=o=>{if(!L.current||!Q.current)return;const f=Q.current.getBoundingClientRect().width;if(f<=0)return;const S=Math.round((o.clientX-L.current.x)/f*m);G(Y(L.current.startIndex+S))},ee=o=>{o.currentTarget.hasPointerCapture(o.pointerId)&&o.currentTarget.releasePointerCapture(o.pointerId),L.current=null;const f=F.current;G(null),f&&f.startIndex!==_.startIndex&&P(f.startIndex,f.endIndex)},te=m?j.startIndex/m*100:0,ce=m?(j.endIndex+1)/m*100:100,E=Math.max(0,m-C);return t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.map(o=>t.jsx(O,{size:"sm",variant:r===o.key?"primary":"outline",onPress:()=>a(o),children:o.label},o.key)),t.jsxs("div",{className:"ml-auto flex items-center gap-3",children:[t.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Showing ",oe," · UTC"]}),w]})]}),t.jsxs("div",{className:"rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] p-2",children:[t.jsxs("div",{className:"flex items-center justify-between gap-2 px-1 pb-1",children:[t.jsxs("span",{className:"flex items-center gap-3",children:[t.jsxs("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:["Requests / ",n==="hour"?"hour":"day"]}),t.jsx(Xt,{series:W})]}),t.jsxs("div",{className:"flex items-center gap-1.5",children:[t.jsx("span",{className:"hidden text-[11px] text-[var(--otari-muted)] sm:inline",children:"drag across the chart to zoom"}),t.jsx(O,{size:"sm",variant:"ghost",isIconOnly:!0,"aria-label":"Zoom in",isDisabled:m===0,onPress:J,children:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-4 w-4","aria-hidden":"true",children:t.jsx("path",{d:"M12 5v14M5 12h14",strokeLinecap:"round"})})}),t.jsx(O,{size:"sm",variant:"ghost",isIconOnly:!0,"aria-label":"Zoom out",isDisabled:m===0||b&&!A,onPress:X,children:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-4 w-4","aria-hidden":"true",children:t.jsx("path",{d:"M5 12h14",strokeLinecap:"round"})})}),y?t.jsx(O,{size:"sm",variant:"ghost",onPress:()=>P(0,m-1),children:"Reset"}):null]})]}),h&&m===0?t.jsx("div",{className:"flex h-[90px] items-center justify-center",children:t.jsx(Qt,{size:"sm"})}):m===0?t.jsx("div",{className:"flex h-[90px] items-center justify-center text-xs text-[var(--otari-muted)]",children:"No activity in this range."}):t.jsxs("div",{className:"flex flex-col gap-1",children:[t.jsx(Jt,{data:z,series:W,formatValue:o=>o.toLocaleString(),formatXTick:o=>rt(o,n),ariaLabel:p,height:90,onSelectRange:P,window:y||D?j:null}),y||D?t.jsx("div",{ref:Q,className:"relative h-2.5 w-full rounded-full bg-[var(--otari-bg)]",children:t.jsx("div",{role:"slider","aria-label":"Pan the selected window","aria-valuemin":0,"aria-valuemax":E,"aria-valuenow":Math.min(j.startIndex,E),"aria-valuetext":`Window starting at ${rt(k[j.startIndex]??k[0],n)}`,tabIndex:0,className:"absolute inset-y-0 cursor-grab touch-none rounded-full bg-[var(--otari-brand)]/40 outline-none hover:bg-[var(--otari-brand)]/60 focus-visible:ring-2 focus-visible:ring-[var(--otari-brand)] active:cursor-grabbing",style:{left:`${te}%`,width:`${Math.max(2,ce-te)}%`},onKeyDown:ie,onPointerDown:o=>{o.preventDefault(),L.current={x:o.clientX,startIndex:j.startIndex},G({...j}),o.currentTarget.setPointerCapture(o.pointerId)},onPointerMove:ke,onPointerUp:ee,onPointerCancel:ee})}):null]})]})]})}function ms(e){const[r,a]=Ot(),d=x.useCallback(u=>r.get(u)??e[u],[r,e]),i=x.useCallback(u=>r.has(u)?r.getAll(u).map(g=>g.trim()).filter(g=>g!==""):e[u]?[e[u]]:[],[r,e]),c=x.useCallback(u=>{const g=Number.parseInt(r.get(u)??"",10);if(!Number.isNaN(g))return g;const h=Number.parseInt(e[u],10);return Number.isNaN(h)?0:h},[r,e]),n=x.useCallback(u=>{a(g=>{const h=new URLSearchParams(g);for(const[p,w]of Object.entries(u)){if(Array.isArray(w)){h.delete(p);for(const m of w)m!==""&&h.append(p,m);continue}const k=String(w);k===""||k===e[p]?h.delete(p):h.set(p,k)}return h},{replace:!0})},[a,e]);return{get:d,getAll:i,getNumber:c,patch:n}}const Ee=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function B(e){return e===null?"—":Ee.format(e)}function R(e){return e===null?"—":e.toLocaleString()}const ps=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumSignificantDigits:3});function hs(e){return e===0?Ee.format(0):e<1e-4?ps.format(e):Ee.format(e)}function xs(e){return[...e].sort((r,a)=>+("unit_rate"in r)-+("unit_rate"in a))}function De(e){return e===null?"—":e<1e3?`${e} ms`:`${(e/1e3).toFixed(e<1e4?2:1)} s`}function gs(e){const r=new Date(e);return Number.isNaN(r.getTime())?e:r.toLocaleString()}function fs(e){const r=new Date(e).getTime();if(Number.isNaN(r))return e;const a=Math.max(0,Math.round((Date.now()-r)/1e3));if(a<60)return`${a}s ago`;const d=Math.round(a/60);if(d<60)return`${d}m ago`;const i=Math.round(d/60);return i<24?`${i}h ago`:`${Math.round(i/24)}d ago`}const bs=e=>e.id,_s=e=>{if(e.status==="error")return"bg-red-50";if(e.status==="absorbed")return"bg-amber-50"},at=[{label:"All",value:""},{label:"Success",value:"success"},{label:"Error",value:"error"},{label:"Absorbed",value:"absorbed"}],ot=[{label:"All",value:""},{label:"Priced",value:"true"},{label:"Unpriced",value:"false"}],nt=[{label:"All",value:""},{label:"Any tool",value:"any"},{label:"Web search",value:"web_search"},{label:"Code execution",value:"code_execution"}],vs=["tool"],ks=50,js=["model","source"],ys=["user","api_key"],ws=["source"],Ss={range:fe,start_date:"",end_date:"",status:"",model:"",user_id:"",api_key_id:"",priced:"",source:"",source_label:"",endpoint:"",provider:"",tool:"",page:"0",size:String(ks)};function ge(e,r,a){if(r||a)return{start:r||void 0,end:a||void 0};if(e===dt)return{};const d=Z(K,e)??Z(K,fe),i=(d==null?void 0:d.seconds)??null;return{start:i==null?void 0:ut(i),end:void 0}}function $e(e){const r=ge(e,"","");if(r.start)return r;const a=Z(K,e);return(a==null?void 0:a.seconds)==null?{start:ut(Ht)}:r}function Ns({status:e}){const r=e==="error"?"border-red-200 bg-red-50 text-red-700":e==="absorbed"?"border-amber-200 bg-amber-50 text-amber-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return t.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${r}`,children:e})}const Cs={gateway:"Gateway",claude_code:"Claude Code",codex:"Codex"};function Oe(e){return Cs[e]??e}function be(e){return typeof e=="number"&&Number.isFinite(e)&&e>0?e:0}function mt(e){const r=e.billing_meters??null,a=(h,p)=>r&&typeof r[h]=="number"?be(r[h]):be(p),d=a("total_input_tokens",e.prompt_tokens),i=a("cache_read_tokens",e.cache_read_tokens),c=a("cache_write_tokens",e.cache_write_tokens),n=a("completion_tokens",e.completion_tokens),u=Math.max(0,d-i-c),g=u+i+c+n;return g>0?{fresh:u,cacheRead:i,cacheWrite:c,output:n,total:g}:null}function _e(e){var a;const r=(a=e.billing_meters)==null?void 0:a.tools;return!r||typeof r!="object"?[]:Object.entries(r).flatMap(([d,i])=>{if(!i||typeof i!="object")return[];const c=i,n=be(c.billed),u=be(c.errors);if(!n&&!u)return[];const g=c.unit_rate;return[{tool:d,billed:n,errors:u,unitRate:typeof g=="number"?g:null}]}).sort((d,i)=>i.billed-d.billed||d.tool.localeCompare(i.tool))}function pt(e){const r=e.tool.replaceAll("_"," "),a=e.billed?[`${r} ×${e.billed}`]:[r];return e.errors&&a.push(`${e.errors} failed`),a.join(", ")}function lt(e){const r=_e(e).filter(a=>a.unitRate!==null);return r.length?r.reduce((a,d)=>a+d.billed*(d.unitRate??0),0):null}const Ps=[{key:"fresh",label:"Fresh input",fill:"var(--otari-ink)"},{key:"cacheRead",label:"Cache read",fill:"var(--otari-brand)"},{key:"cacheWrite",label:"Cache write",fill:"var(--otari-brand-soft)"},{key:"output",label:"Output",fill:"var(--otari-brand-dark)"}];function Is({entry:e}){const r=mt(e);if(r===null)return t.jsx("span",{className:"tabular-nums",children:R(e.total_tokens)});const a=Ps.map(n=>({...n,value:r[n.key]})),d=a.filter(n=>n.value>0).map(n=>`${n.label} ${n.value.toLocaleString()}`).join(", ");let i=0;const c=a.map(n=>{const u=n.value/r.total*100,g={...n,x:i,width:u};return i+=u,g});return t.jsxs("span",{className:"inline-flex flex-col items-end gap-1",title:d,children:[t.jsx("span",{className:"tabular-nums",children:r.total.toLocaleString()}),t.jsx("svg",{viewBox:"0 0 100 4",preserveAspectRatio:"none",role:"img","aria-label":`Token composition: ${d}`,className:"h-1.5 w-20 overflow-hidden rounded-full bg-[var(--otari-brand-tint)]",children:c.filter(n=>n.width>0).map(n=>t.jsx("rect",{x:n.x,y:0,width:n.width,height:4,fill:n.fill},n.key))})]})}function ht(e){if(!e)return null;if(e==="static")return"the policy's only target";if(e==="default")return"the policy's default target";if(e==="on_failure")return"a fallback candidate";if(e.startsWith("condition:")){const r=e.slice(10).split(",").filter(Boolean).join(", ");return r?`matched on ${r}`:"matched a condition"}if(e.startsWith("router:")){const r=e.slice(7);return r?`chosen by router ${r}`:"chosen by a router"}return e.replaceAll("_"," ")}function it(e){const r=new Map;for(const a of e)!a.request_group_id||a.status==="absorbed"||r.set(a.request_group_id,{servedBy:a.status==="success"?ve(a):null,servedPosition:a.status==="success"?a.attempt_position??null:null});return r}function Ms(e,r){const a=ht(e.selection_reason),d=e.attempt_position,i=e.attempt_count;if(d==null||i==null||i<=1)return a;const c=`attempt ${d} of ${i}`;return e.status==="absorbed"?r!=null&&r.servedBy?`${c} failed, served by ${r.servedBy}`:r?`${c} failed, and the request ended in an error`:`${c} failed, fell back`:e.status==="error"?d(r.attempt_position??0)-(a.attempt_position??0)||r.timestamp.localeCompare(a.timestamp))}function $s({entry:e}){const r=e.request_group_id,d=ct(r?[r]:[]),i=r?(d.data??[]).filter(p=>p.request_group_id===r):[],c=Ts(i.length?i:[e]),n=i.length>0,u=c.find(p=>p.status==="success"),g=e.attempt_count??c.length,h=n?u?`Served by attempt ${u.attempt_position??"?"} of ${g}: ${ve(u)}`:c.some(p=>p.status==="error")?"No candidate served this request.":"This request has no outcome row yet.":d.isError?"Could not load this request's other attempts.":e.request_group_id?"Loading the rest of this request's attempts…":"This row carries no request group, so its other attempts cannot be found.";return t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsxs("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:["Routing plan · ",e.policy_name]}),t.jsx("span",{className:"text-sm text-[var(--otari-ink)]",children:h}),t.jsx("div",{className:"overflow-x-auto rounded-lg border border-[var(--otari-line)]",children:t.jsxs("table",{className:"w-full text-xs","aria-label":`Routing plan for policy ${e.policy_name}`,children:[t.jsx("thead",{className:"text-[var(--otari-muted)]",children:t.jsxs("tr",{className:"border-b border-[var(--otari-line)]",children:[t.jsx("th",{scope:"col",className:"px-3 py-2 text-left font-medium",children:"#"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-left font-medium",children:"Target"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-left font-medium",children:"Selected as"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-left font-medium",children:"Outcome"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-right font-medium",children:"Total time"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-right font-medium",children:"Cost"})]})}),t.jsx("tbody",{children:c.map(p=>t.jsxs("tr",{className:`border-t border-[var(--otari-line)] first:border-t-0 ${p.status==="success"?"bg-[var(--otari-brand-tint)]":""}`,children:[t.jsx("td",{className:"px-3 py-2 tabular-nums",children:p.attempt_position??"?"}),t.jsxs("td",{className:"px-3 py-2 break-all text-[var(--otari-ink)]",children:[ve(p),p.id===e.id?t.jsx("span",{className:"ml-2 rounded-full border border-[var(--otari-line)] px-1.5 py-0.5 text-[10px] text-[var(--otari-muted)]",children:"this row"}):null]}),t.jsx("td",{className:"px-3 py-2",children:ht(p.selection_reason)??"—"}),t.jsx("td",{className:`px-3 py-2 ${p.status==="success"?"":"text-amber-700"}`,children:As(p)}),t.jsx("td",{className:"px-3 py-2 text-right tabular-nums",children:De(p.latency_ms)}),t.jsx("td",{className:"px-3 py-2 text-right tabular-nums",children:B(p.cost)})]},p.id))})]})}),t.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Cost and tool charges settle on the attempt that served, so a failed attempt carries its tokens and no charge."})]})}function v({label:e,copyValue:r,copyLabel:a,children:d}){return t.jsxs("div",{className:"flex flex-col gap-0.5",children:[t.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:e}),r?t.jsx(Zt,{value:r,label:a??e.toLowerCase(),className:"text-sm text-[var(--otari-ink)] break-all",children:d}):t.jsx("span",{className:"text-sm text-[var(--otari-ink)] break-all",children:d})]})}function ve(e){return e.provider?e.model.startsWith(`${e.provider}:`)?e.model:`${e.provider}:${e.model}`:e.model}function Es({entry:e,onPriceModel:r}){var i,c;const a=e.cost===null,d=ve(e);return t.jsxs("div",{className:"flex flex-col gap-4 px-4 py-4",children:[e.error_message?t.jsxs("div",{className:"flex flex-col gap-1.5",children:[t.jsxs("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:["Error",e.status_code!==null?` (${e.status_code})`:""]}),t.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:e.error_message})]}):null,e.policy_name!==null&&e.policy_name!==void 0?t.jsx($s,{entry:e}):null,t.jsxs("div",{className:"grid gap-4 sm:grid-cols-2 lg:grid-cols-4",children:[t.jsx(v,{label:"Provider",children:e.provider??"—"}),t.jsx(v,{label:"Endpoint",children:e.endpoint}),t.jsx(v,{label:"Source",children:Oe(e.source)}),e.source_label?t.jsx(v,{label:"Session",children:e.source_label}):null,t.jsx(v,{label:"User",copyValue:e.user_id,copyLabel:"user id",children:e.user_id??"—"}),t.jsx(v,{label:"API key",copyValue:e.api_key_id,copyLabel:"api key id",children:e.api_key_id??"—"}),t.jsx(v,{label:"Prompt tokens",children:R(e.prompt_tokens)}),t.jsx(v,{label:"Completion tokens",children:R(e.completion_tokens)}),t.jsx(v,{label:"Total tokens",children:R(e.total_tokens)}),t.jsx(v,{label:"Billed tokens",children:t.jsx("span",{title:"Fresh input, cache reads and writes, and output: the tokens this request was priced on, and the total the activity row's bar splits.",children:R(((i=mt(e))==null?void 0:i.total)??null)})}),t.jsx(v,{label:"Cost",children:B(e.cost)}),_e(e).length?t.jsxs(t.Fragment,{children:[t.jsx(v,{label:"Tools",children:_e(e).map(pt).join(" · ")}),t.jsx(v,{label:"Tool cost",children:lt(e)===null?t.jsx("span",{className:"text-[var(--otari-warning-ink,var(--otari-muted))]",title:"No per-request price is configured for this tool, so its calls were recorded at zero cost. Set one on the Tools & Guardrails screen.",children:"unpriced"}):B(lt(e))})]}):null,t.jsx(v,{label:"Cache read tokens",children:R(e.cache_read_tokens)}),t.jsx(v,{label:"Cache write tokens",children:R(e.cache_write_tokens)}),t.jsx(v,{label:"1h cache writes",children:R(e.cache_write_1h_tokens??null)}),t.jsx(v,{label:"Total time",children:De(e.latency_ms)}),t.jsx(v,{label:"Request ID",copyValue:e.id,children:e.id})]}),a?t.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[t.jsx(O,{size:"sm",variant:"outline",onPress:()=>r(d),children:"Price this model"}),t.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["This request carries no cost. Set a price for ",t.jsx("code",{className:"break-all",children:d})," so later requests are metered and count against budgets. Rows already logged keep the cost they were served with."]})]}):null,(c=e.pricing_breakdown)!=null&&c.length?t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Billed meters"}),t.jsx("div",{className:"grid gap-2 sm:grid-cols-2 lg:grid-cols-3",children:xs(e.pricing_breakdown).map(n=>t.jsx(v,{label:n.meter.replaceAll("_"," "),children:"unit_rate"in n?`${R(n.units)} at ${hs(n.unit_rate)} each, ${B(n.cost)}`:`${R(n.units)} at ${B(n.rate_per_million)} / 1M, ${B(n.cost)}`},n.meter))})]}):null]})}function Hs(){var Ge,Ye,Ve,Ze,He,Xe,Je,Qe,et;const e=ms(Ss),r=e.get("range"),a=e.get("start_date"),d=e.get("end_date"),i=e.get("status"),c=e.getAll("model"),n=e.getAll("user_id"),u=e.getAll("api_key_id"),g=e.get("priced"),h=e.get("source"),p=e.get("source_label"),w=e.get("endpoint"),k=e.get("provider"),m=e.get("tool"),oe=Math.max(0,e.getNumber("page")),H=e.getNumber("size"),W=ls.reduce((s,l)=>Math.abs(l-H)ge(r,a,d)),ne=x.useRef(z);x.useEffect(()=>{ne.current!==z&&(ne.current=z,D(ge(r,a,d)))},[z,r,a,d]);const[F,G]=x.useState(()=>$e(r)),j=x.useRef(r);x.useEffect(()=>{j.current!==r&&(j.current=r,G($e(r)))},[r]);const C=g==="true"?!0:g==="false"?!1:void 0,b=x.useMemo(()=>({start_date:_.start,end_date:_.end,status:i||void 0,model:c.length>0?c:void 0,user_id:n.length>0?n:void 0,api_key_id:u.length>0?u:void 0,source:h||void 0,source_label:p||void 0,endpoint:w||void 0,provider:k||void 0,tool:m||void 0,priced:C}),[_,m,i,c,n,u,h,p,w,k,C]),y=es(),P=JSON.stringify(b),q=x.useRef(P);x.useEffect(()=>{q.current!==P&&(q.current=P,e.patch({page:0}),y.clear())},[P,e,y]);const I=Ut(b,oe,W),A=tt(b),le=x.useMemo(()=>({start_date:_.start,end_date:_.end,status:i||void 0,user_id:n.length>0?n:void 0,api_key_id:u.length>0?u:void 0,source:h||void 0,source_label:p||void 0,endpoint:w||void 0,provider:k||void 0,tool:m||void 0}),[_,i,n,u,h,p,w,k,m]),X=he(le,"day",js),J=s=>(s??[]).filter(l=>!l.is_other&&l.key!==null),Q=J((Ge=X.data)==null?void 0:Ge.by_model).map(s=>s.key),L=x.useMemo(()=>({...b,user_id:void 0,api_key_id:void 0}),[b]),Y=he(L,"day",ys),ie=J((Ye=Y.data)==null?void 0:Ye.by_api_key).map(s=>({value:s.key,label:s.label??`${s.key.slice(0,8)}…`})),ke=x.useMemo(()=>({start_date:_.start,end_date:_.end,status:i||void 0,model:c.length>0?c:void 0,user_id:n.length>0?n:void 0,api_key_id:u.length>0?u:void 0}),[_,i,c,n,u]),ee=he(ke,"day",ws,!!h),te=(Ve=h?ee.data:X.data)==null?void 0:Ve.by_source,ce=x.useMemo(()=>{const s=(te??[]).filter(l=>!l.is_other&&l.key!==null).map(l=>l.key);return h&&!s.includes(h)?[h,...s]:s},[te,h]),E=Z(K,r)??Z(K,fe),o=!!(_.start&&F.start&&new Date(_.start).getTime()({start_date:o?_.start:F.start,end_date:o?_.end:void 0,status:i||void 0,model:c.length>0?c:void 0,user_id:n.length>0?n:void 0,api_key_id:u.length>0?u:void 0,source:h||void 0,source_label:p||void 0,endpoint:w||void 0,provider:k||void 0,tool:m||void 0,priced:C}),[o,m,_,F,i,c,n,u,h,p,w,k,C]),$=he(T,S,vs),xt=(((Ze=$.data)==null?void 0:Ze.series)??[]).map(s=>({bucketStart:s.bucket_start,requests:s.requests,errors:s.errors??0})),M=I.data??[],{pageOutcomes:je,unresolvedGroupIds:gt}=x.useMemo(()=>{const s=it(M),l=new Set;for(const N of M)N.status==="absorbed"&&N.request_group_id&&!s.has(N.request_group_id)&&l.add(N.request_group_id);return{pageOutcomes:s,unresolvedGroupIds:[...l]}},[M]),ye=ct(gt),Fe=x.useMemo(()=>{var s;return(s=ye.data)!=null&&s.length?new Map([...je,...it(ye.data)]):je},[je,ye.data]),ft=A.isSuccess&&!A.isPlaceholderData?((He=A.data)==null?void 0:He.total)??0:null,we=Z(K,r),bt=!!(a||d)||r!==fe&&(we==null?void 0:we.seconds)!=null,_t=!!(i||c.length||n.length||u.length||g||h||p||w||k||m||bt),se=(s,l)=>{var N;return((N=s.find(U=>U.value===l))==null?void 0:N.label)??l},qe=J((Xe=Y.data)==null?void 0:Xe.by_user).map(s=>({value:s.key,label:s.label?`${s.label} (${s.key})`:s.key})),vt=()=>e.patch({status:"",priced:"",model:[],user_id:[],api_key_id:[],source:"",source_label:"",endpoint:"",provider:"",tool:""}),Se=(s,l,N,U,ae)=>U.map(V=>({key:`${s}:${V}`,label:l,value:ae(V),clearLabel:`Remove ${l} filter ${ae(V)}`,onClear:()=>e.patch({[N]:U.filter(pe=>pe!==V)})})),kt=[...i?[{key:"status",label:"Status",value:se(at,i),onClear:()=>e.patch({status:""})}]:[],...g?[{key:"priced",label:"Priced",value:se(ot,g),onClear:()=>e.patch({priced:""})}]:[],...Se("user","User","user_id",n,s=>se(qe,s)),...Se("model","Model","model",c,s=>s),...Se("key","API key","api_key_id",u,s=>se(ie,s)),...h?[{key:"source",label:"Source",value:Oe(h),onClear:()=>e.patch({source:""})}]:[],...p?[{key:"session",label:"Session",value:p,onClear:()=>e.patch({source_label:""})}]:[],...w?[{key:"endpoint",label:"Endpoint",value:w,onClear:()=>e.patch({endpoint:""})}]:[],...k?[{key:"provider",label:"Provider",value:k,onClear:()=>e.patch({provider:""})}]:[],...m?[{key:"tool",label:"Tool",value:se(nt,m),onClear:()=>e.patch({tool:""})}]:[]],de=x.useMemo(()=>M.filter(s=>!s.counts_toward_budget).map(s=>s.id),[M]),jt=x.useMemo(()=>M.filter(s=>s.counts_toward_budget).map(s=>s.id),[M]),Le=ts(y.selectedKeys,de),re=Le.length,Ue=y.allMatching||re>0,yt=x.useMemo(()=>({...b,counts_toward_budget:!1}),[b]),Be=tt(yt,Ue),ue=Be.isSuccess?((Je=Be.data)==null?void 0:Je.total)??null:null,wt=de.length>0&&re===de.length&&ue!=null&&ue>re,me=y.allMatching?ue??re:re,St=de.length>0||y.allMatching,Ne=Kt(),Ce=Wt(),Pe=zt(),[Nt,Ie]=x.useState(!1),[Ct,Me]=x.useState(!1),[Re,Ae]=x.useState(null),[Pt,Ke]=x.useState(null),It=x.useCallback(s=>t.jsxs("div",{children:[t.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[t.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Request detail"}),t.jsx(O,{size:"sm",variant:"ghost",onPress:()=>Ke(null),children:"Close"})]}),t.jsx(Es,{entry:s,onPriceModel:Ae})]}),[]),We=()=>y.allMatching?{by_filter:!0,model:b.model,user_id:b.user_id,api_key_id:b.api_key_id,status:b.status,source:b.source,source_label:b.source_label,endpoint:b.endpoint,provider:b.provider,tool:b.tool,start_date:b.start_date,end_date:b.end_date,priced:b.priced}:{ids:Le},Mt=()=>{Ne.mutate(We(),{onSuccess:()=>{Ie(!1),y.clear()}})},Rt=(s,l)=>{Pe.mutate({model_key:l,input_price_per_million:s.input_price_per_million,output_price_per_million:s.output_price_per_million,cache_read_price_per_million:s.cache_read_price_per_million??null,cache_write_price_per_million:s.cache_write_price_per_million??null},{onSuccess:()=>Ae(null)})},At=s=>{Ce.mutate({...We(),...s},{onSuccess:()=>{Me(!1),y.clear()}})},Tt=()=>{I.refetch(),A.refetch(),$.refetch(),X.refetch(),Y.refetch(),h&&ee.refetch()},ze=s=>{if(s.key===r&&!a&&!d){D(ge(s.key,"","")),G($e(s.key));return}e.patch({range:s.key,start_date:"",end_date:""})},$t=(s,l)=>e.patch({start_date:s,end_date:l}),Et=x.useMemo(()=>{const s=l=>l.api_key_id===null?"—":l.api_key_name??`${l.api_key_id.slice(0,8)}…`;return[{id:"time",header:"Time",cell:l=>t.jsx("span",{title:gs(l.timestamp),className:"text-[var(--otari-muted)]",children:fs(l.timestamp)})},{id:"user",header:"User",cell:l=>l.user_id??"—"},{id:"model",header:"Model",isRowHeader:!0,cell:l=>{const N=_e(l);if(!N.length)return l.model;const U=N.reduce((V,pe)=>V+pe.billed+pe.errors,0),ae=N.map(pt).join(" · ");return t.jsxs("span",{className:"inline-flex items-center gap-1.5",children:[l.model,t.jsxs("span",{className:"inline-flex items-center rounded-full border border-[var(--otari-line)] bg-[var(--otari-brand-tint)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--otari-brand-dark)]",title:ae,"aria-label":`Gateway tools: ${ae}`,children:[U," ",U===1?"tool":"tools"]})]})}},{id:"routing",header:"Routing",cell:l=>t.jsx(Rs,{entry:l,outcome:Fe.get(l.request_group_id??"")??null})},{id:"api_key",header:"API key",cell:l=>t.jsx("span",{className:"text-[var(--otari-muted)]",children:s(l)})},{id:"tokens",header:"Tokens",align:"end",cell:l=>t.jsx(Is,{entry:l})},{id:"cost",header:"Cost",align:"end",cell:l=>B(l.cost)},{id:"latency",header:"Total time",align:"end",cell:l=>De(l.latency_ms)},{id:"status",header:"Status",cell:l=>t.jsx(Ns,{status:l.status})}]},[Fe]);return t.jsxs("div",{className:"flex flex-col gap-6",children:[t.jsx(Gt,{title:"Activity",description:"A per-request log of what the gateway served: tokens, cost, latency, and failures. No request or response content is stored."}),t.jsx(Yt,{error:I.error??A.error??$.error}),t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsx(us,{presets:K,extentKey:f,onPreset:ze,onSelectRange:$t,onSelectFull:()=>E?ze(E):void 0,series:xt,bucket:S,windowStart:_.start,windowEnd:_.end,loading:$.isLoading,ariaLabel:"Activity request volume over the selected window",action:t.jsx(Vt,{onRefresh:Tt,isFetching:I.isFetching,updatedAt:I.dataUpdatedAt})}),t.jsxs(os,{chips:kt,onClearAll:vt,children:[t.jsx(xe,{id:"filter-status",label:"Status",value:i,onChange:s=>e.patch({status:s}),children:at.map(s=>t.jsx("option",{value:s.value,children:s.label},s.value))}),t.jsx(xe,{id:"filter-priced",label:"Priced?",value:g,onChange:s=>e.patch({priced:s}),children:ot.map(s=>t.jsx("option",{value:s.value,children:s.label},s.value))}),m||(et=(Qe=$.data)==null?void 0:Qe.by_tool)!=null&&et.length?t.jsx(xe,{id:"filter-tool",label:"Tool",value:m,onChange:s=>e.patch({tool:s}),children:nt.map(s=>t.jsx("option",{value:s.value,children:s.label},s.value))}):null,ce.length>1||h?t.jsxs(xe,{id:"filter-source",label:"Source",value:h,onChange:s=>e.patch({source:s}),children:[t.jsx("option",{value:"",children:"All"}),ce.map(s=>t.jsx("option",{value:s,children:Oe(s)},s))]}):null,t.jsx(Te,{label:"API key",values:u,onChange:s=>e.patch({api_key_id:s}),allowsCustom:!0,placeholder:"All keys",options:ie}),t.jsx(Te,{label:"User",values:n,onChange:s=>e.patch({user_id:s}),allowsCustom:!0,placeholder:"All users",options:qe}),t.jsx(Te,{label:"Model",values:c,onChange:s=>e.patch({model:s}),allowsCustom:!0,placeholder:"Any model",options:Q.map(s=>({value:s,label:s}))})]})]}),Ue?t.jsxs(ss,{selectedCount:me,allMatching:y.allMatching,matchingTotal:ue,canSelectAllMatching:wt,onSelectAllMatching:y.enableAllMatching,onClear:y.clear,children:[t.jsx(O,{size:"sm",variant:"primary",onPress:()=>Me(!0),children:"Set price"}),t.jsx(O,{size:"sm",variant:"danger",onPress:()=>Ie(!0),children:"Delete"})]}):null,t.jsx(as,{ariaLabel:"Activity log",columns:Et,rows:M,getRowKey:bs,isLoading:I.isLoading,emptyContent:_t?"No requests match these filters.":"No requests recorded yet.",selectionMode:St?"multiple":"none",selectedKeys:y.selectedKeys,onSelectionChange:y.onSelectionChange,disabledKeys:jt,onRowAction:s=>Ke(l=>l===s?null:s),rowClassName:_s,detailKey:Pt,renderDetail:It}),t.jsx(ns,{page:oe,pageSize:W,total:ft,rowsOnPage:M.length,onPageChange:s=>e.patch({page:s}),onPageSizeChange:s=>e.patch({size:s,page:0}),isFetching:I.isFetching,hasNextFallback:M.length===W}),t.jsx(rs,{isOpen:Nt,onOpenChange:Ie,heading:"Delete usage rows",body:`Delete ${me.toLocaleString()} imported ${me===1?"row":"rows"}? Only imported rows are removed, and this cannot be undone.`,confirmLabel:"Delete",isPending:Ne.isPending,error:Ne.error,onConfirm:Mt}),t.jsx(st,{isOpen:Ct,onOpenChange:Me,targetCount:me,isPending:Ce.isPending,error:Ce.error,onSubmit:At}),t.jsx(st,{isOpen:Re!==null,onOpenChange:s=>Ae(s?Re??"":null),isPending:Pe.isPending,error:Pe.error,onSubmit:Rt,collectModelKey:!0,initialModelKey:Re??"",title:"Price this model",description:()=>"Set what this model costs, taken from the request you were looking at. Requests from now on are costed at these rates and counted against budgets; rows already logged keep the cost they were served with."})]})}export{Hs as ActivityPage}; diff --git a/src/gateway/static/dashboard/assets/ActivityPage-fH63Z1Im.js b/src/gateway/static/dashboard/assets/ActivityPage-fH63Z1Im.js deleted file mode 100644 index 539aa48f..00000000 --- a/src/gateway/static/dashboard/assets/ActivityPage-fH63Z1Im.js +++ /dev/null @@ -1 +0,0 @@ -import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as x,u as Et}from"./react-dgEcD0HR.js";import{f as Ot,b as Dt,a as Ft,r as qt,u as Lt,c as Ut,d as Bt,e as et,g as Ae,h as X,i as Kt,j as it,A as fe,k as zt,l as Wt,m as Gt,P as Vt,E as Yt,C as ct,n as W,R as Zt,F as he,o as $e,p as dt,q as Ht,Y as Xt}from"./index-DAnS9oY2.js";import{C as Jt,T as Qt}from"./charts-krq1PqQO.js";import{B as U,S as es}from"./heroui-COmYdDDM.js";import{u as ts,r as ss,B as rs}from"./tableSelection-BJDASjEj.js";import{C as as}from"./ConfirmDialog-lRO7CIis.js";import{D as os}from"./DataTable-DuDxGlJc.js";import{F as ns}from"./FilterChips-DTdIceb1.js";import{T as ls,S as tt,P as is}from"./TablePagination-BpT-8wzM.js";import"./recharts-C3cGlHOx.js";import"./Field-CBU9MRjz.js";function st(e,r){const o=new Date(e);return Number.isNaN(o.getTime())?e:r==="hour"?o.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:"UTC"}):o.toLocaleDateString(void 0,{month:"short",day:"numeric",timeZone:"UTC"})}const cs={key:"success",label:"Succeeded",color:"var(--otari-brand)"},ds={key:"errors",label:"Failed",color:"var(--otari-danger)"},us={key:"requests",label:"Requests",color:"var(--otari-brand)"};function ms({presets:e,extentKey:r,onPreset:o,onSelectRange:a,onSelectFull:l,series:m,bucket:c,windowStart:u,windowEnd:h,loading:f=!1,ariaLabel:d="Request volume over the selected window",action:C}){const g=m.map(n=>n.bucketStart),p=m.length,M=Ot(u,h),P=m.some(n=>(n.errors??0)>0),w=P?[cs,ds]:[us],ne=m.map(n=>{const b=Math.min(n.errors??0,n.requests);return P?{x:n.bucketStart,success:n.requests-b,errors:b}:{x:n.bucketStart,requests:n.requests}}),J=p>0?Dt(g,u,h):{startIndex:0,endIndex:0},[F,G]=x.useState(null),_=x.useRef(F),V=n=>{_.current=n,G(n)},k=F??J,I=k.endIndex-k.startIndex+1,Y=k.startIndex===0&&k.endIndex>=p-1,Z=p>0&&!Y,R=(n,b)=>{if(p===0)return;const S=Math.max(0,Math.min(n,b)),O=Math.min(p-1,Math.max(n,b));if(S===0&&O===p-1){l();return}const D=qt(g,S,O,c);D&&a(D.startIso,D.endIso)},v=e.findIndex(n=>n.key===r),y=v>=0?e[v].seconds:p*Ft(c)/1e3,q=v>=0?e[v+1]:e.find(n=>n.seconds===null||y!==null&&n.seconds>y),Q=n=>{const b=Math.max(1,Math.min(p,Math.round(n))),S=(k.startIndex+k.endIndex+1)/2;let O=Math.round(S-b/2);O=Math.max(0,Math.min(p-b,O)),R(O,O+b-1)},T=()=>{if(Y){q&&o(q);return}Q(I*2)},B=()=>Q(I/2),ee=x.useRef(null),E=x.useRef(null),le=n=>{const b=Math.max(0,Math.min(p-I,n));return{startIndex:b,endIndex:b+I-1}},ie=n=>{const b=n.key==="ArrowRight"||n.key==="ArrowUp"?1:n.key==="ArrowLeft"||n.key==="ArrowDown"?-1:n.key==="PageUp"?I:n.key==="PageDown"?-I:n.key==="Home"?-p:n.key==="End"?p:0;if(b===0)return;n.preventDefault();const S=le(k.startIndex+b);S.startIndex!==k.startIndex&&R(S.startIndex,S.endIndex)},_e=n=>{if(!E.current||!ee.current)return;const b=ee.current.getBoundingClientRect().width;if(b<=0)return;const S=Math.round((n.clientX-E.current.x)/b*p);V(le(E.current.startIndex+S))},te=n=>{n.currentTarget.hasPointerCapture(n.pointerId)&&n.currentTarget.releasePointerCapture(n.pointerId),E.current=null;const b=_.current;V(null),b&&b.startIndex!==J.startIndex&&R(b.startIndex,b.endIndex)},se=p?k.startIndex/p*100:0,ce=p?(k.endIndex+1)/p*100:100,L=Math.max(0,p-I);return t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.map(n=>t.jsx(U,{size:"sm",variant:r===n.key?"primary":"outline",onPress:()=>o(n),children:n.label},n.key)),t.jsxs("div",{className:"ml-auto flex items-center gap-3",children:[t.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Showing ",M," · UTC"]}),C]})]}),t.jsxs("div",{className:"rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] p-2",children:[t.jsxs("div",{className:"flex items-center justify-between gap-2 px-1 pb-1",children:[t.jsxs("span",{className:"flex items-center gap-3",children:[t.jsxs("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:["Requests / ",c==="hour"?"hour":"day"]}),t.jsx(Jt,{series:w})]}),t.jsxs("div",{className:"flex items-center gap-1.5",children:[t.jsx("span",{className:"hidden text-[11px] text-[var(--otari-muted)] sm:inline",children:"drag across the chart to zoom"}),t.jsx(U,{size:"sm",variant:"ghost",isIconOnly:!0,"aria-label":"Zoom in",isDisabled:p===0,onPress:B,children:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-4 w-4","aria-hidden":"true",children:t.jsx("path",{d:"M12 5v14M5 12h14",strokeLinecap:"round"})})}),t.jsx(U,{size:"sm",variant:"ghost",isIconOnly:!0,"aria-label":"Zoom out",isDisabled:p===0||Y&&!q,onPress:T,children:t.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-4 w-4","aria-hidden":"true",children:t.jsx("path",{d:"M5 12h14",strokeLinecap:"round"})})}),Z?t.jsx(U,{size:"sm",variant:"ghost",onPress:()=>R(0,p-1),children:"Reset"}):null]})]}),f&&p===0?t.jsx("div",{className:"flex h-[90px] items-center justify-center",children:t.jsx(es,{size:"sm"})}):p===0?t.jsx("div",{className:"flex h-[90px] items-center justify-center text-xs text-[var(--otari-muted)]",children:"No activity in this range."}):t.jsxs("div",{className:"flex flex-col gap-1",children:[t.jsx(Qt,{data:ne,series:w,formatValue:n=>n.toLocaleString(),formatXTick:n=>st(n,c),ariaLabel:d,height:90,onSelectRange:R,window:Z||F?k:null}),Z||F?t.jsx("div",{ref:ee,className:"relative h-2.5 w-full rounded-full bg-[var(--otari-bg)]",children:t.jsx("div",{role:"slider","aria-label":"Pan the selected window","aria-valuemin":0,"aria-valuemax":L,"aria-valuenow":Math.min(k.startIndex,L),"aria-valuetext":`Window starting at ${st(g[k.startIndex]??g[0],c)}`,tabIndex:0,className:"absolute inset-y-0 cursor-grab touch-none rounded-full bg-[var(--otari-brand)]/40 outline-none hover:bg-[var(--otari-brand)]/60 focus-visible:ring-2 focus-visible:ring-[var(--otari-brand)] active:cursor-grabbing",style:{left:`${se}%`,width:`${Math.max(2,ce-se)}%`},onKeyDown:ie,onPointerDown:n=>{n.preventDefault(),E.current={x:n.clientX,startIndex:k.startIndex},V({...k}),n.currentTarget.setPointerCapture(n.pointerId)},onPointerMove:_e,onPointerUp:te,onPointerCancel:te})}):null]})]})]})}function ps(e){const[r,o]=Et(),a=x.useCallback(u=>r.get(u)??e[u],[r,e]),l=x.useCallback(u=>r.has(u)?r.getAll(u).map(h=>h.trim()).filter(h=>h!==""):e[u]?[e[u]]:[],[r,e]),m=x.useCallback(u=>{const h=Number.parseInt(r.get(u)??"",10);if(!Number.isNaN(h))return h;const f=Number.parseInt(e[u],10);return Number.isNaN(f)?0:f},[r,e]),c=x.useCallback(u=>{o(h=>{const f=new URLSearchParams(h);for(const[d,C]of Object.entries(u)){if(Array.isArray(C)){f.delete(d);for(const p of C)p!==""&&f.append(d,p);continue}const g=String(C);g===""||g===e[d]?f.delete(d):f.set(d,g)}return f},{replace:!0})},[o,e]);return{get:a,getAll:l,getNumber:m,patch:c}}const Ee=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function z(e){return e===null?"—":Ee.format(e)}function $(e){return e===null?"—":e.toLocaleString()}const hs=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumSignificantDigits:3});function xs(e){return e===0?Ee.format(0):e<1e-4?hs.format(e):Ee.format(e)}function fs(e){return[...e].sort((r,o)=>+("unit_rate"in r)-+("unit_rate"in o))}function De(e){return e===null?"—":e<1e3?`${e} ms`:`${(e/1e3).toFixed(e<1e4?2:1)} s`}function gs(e){const r=new Date(e);return Number.isNaN(r.getTime())?e:r.toLocaleString()}function bs(e){const r=new Date(e).getTime();if(Number.isNaN(r))return e;const o=Math.max(0,Math.round((Date.now()-r)/1e3));if(o<60)return`${o}s ago`;const a=Math.round(o/60);if(a<60)return`${a}m ago`;const l=Math.round(a/60);return l<24?`${l}h ago`:`${Math.round(l/24)}d ago`}const vs=e=>e.id,_s=e=>{if(e.status==="error")return"bg-red-50";if(e.status==="absorbed")return"bg-amber-50"},rt=[{label:"All",value:""},{label:"Success",value:"success"},{label:"Error",value:"error"},{label:"Absorbed",value:"absorbed"}],at=[{label:"All",value:""},{label:"Priced",value:"true"},{label:"Unpriced",value:"false"}],ot=[{label:"All",value:""},{label:"Any tool",value:"any"},{label:"Web search",value:"web_search"},{label:"Code execution",value:"code_execution"}],js=["tool"],ks=50,ys=["model","source"],ws=["source"],Ss={range:fe,start_date:"",end_date:"",status:"",model:"",user_id:"",api_key_id:"",priced:"",source:"",source_label:"",endpoint:"",provider:"",tool:"",page:"0",size:String(ks)};function xe(e,r,o){if(r||o)return{start:r||void 0,end:o||void 0};if(e===ct)return{};const a=X(W,e)??X(W,fe),l=(a==null?void 0:a.seconds)??null;return{start:l==null?void 0:dt(l),end:void 0}}function Te(e){const r=xe(e,"","");if(r.start)return r;const o=X(W,e);return(o==null?void 0:o.seconds)==null?{start:dt(Xt)}:r}function Ns({status:e}){const r=e==="error"?"border-red-200 bg-red-50 text-red-700":e==="absorbed"?"border-amber-200 bg-amber-50 text-amber-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return t.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${r}`,children:e})}const Cs={gateway:"Gateway",claude_code:"Claude Code",codex:"Codex"};function Oe(e){return Cs[e]??e}function ge(e){return typeof e=="number"&&Number.isFinite(e)&&e>0?e:0}function ut(e){const r=e.billing_meters??null,o=(f,d)=>r&&typeof r[f]=="number"?ge(r[f]):ge(d),a=o("total_input_tokens",e.prompt_tokens),l=o("cache_read_tokens",e.cache_read_tokens),m=o("cache_write_tokens",e.cache_write_tokens),c=o("completion_tokens",e.completion_tokens),u=Math.max(0,a-l-m),h=u+l+m+c;return h>0?{fresh:u,cacheRead:l,cacheWrite:m,output:c,total:h}:null}function be(e){var o;const r=(o=e.billing_meters)==null?void 0:o.tools;return!r||typeof r!="object"?[]:Object.entries(r).flatMap(([a,l])=>{if(!l||typeof l!="object")return[];const m=l,c=ge(m.billed),u=ge(m.errors);if(!c&&!u)return[];const h=m.unit_rate;return[{tool:a,billed:c,errors:u,unitRate:typeof h=="number"?h:null}]}).sort((a,l)=>l.billed-a.billed||a.tool.localeCompare(l.tool))}function mt(e){const r=e.tool.replaceAll("_"," "),o=e.billed?[`${r} ×${e.billed}`]:[r];return e.errors&&o.push(`${e.errors} failed`),o.join(", ")}function nt(e){const r=be(e).filter(o=>o.unitRate!==null);return r.length?r.reduce((o,a)=>o+a.billed*(a.unitRate??0),0):null}const Ps=[{key:"fresh",label:"Fresh input",fill:"var(--otari-ink)"},{key:"cacheRead",label:"Cache read",fill:"var(--otari-brand)"},{key:"cacheWrite",label:"Cache write",fill:"var(--otari-brand-soft)"},{key:"output",label:"Output",fill:"var(--otari-brand-dark)"}];function Is({entry:e}){const r=ut(e);if(r===null)return t.jsx("span",{className:"tabular-nums",children:$(e.total_tokens)});const o=Ps.map(c=>({...c,value:r[c.key]})),a=o.filter(c=>c.value>0).map(c=>`${c.label} ${c.value.toLocaleString()}`).join(", ");let l=0;const m=o.map(c=>{const u=c.value/r.total*100,h={...c,x:l,width:u};return l+=u,h});return t.jsxs("span",{className:"inline-flex flex-col items-end gap-1",title:a,children:[t.jsx("span",{className:"tabular-nums",children:r.total.toLocaleString()}),t.jsx("svg",{viewBox:"0 0 100 4",preserveAspectRatio:"none",role:"img","aria-label":`Token composition: ${a}`,className:"h-1.5 w-20 overflow-hidden rounded-full bg-[var(--otari-brand-tint)]",children:m.filter(c=>c.width>0).map(c=>t.jsx("rect",{x:c.x,y:0,width:c.width,height:4,fill:c.fill},c.key))})]})}function pt(e){if(!e)return null;if(e==="static")return"the policy's only target";if(e==="default")return"the policy's default target";if(e==="on_failure")return"a fallback candidate";if(e.startsWith("condition:")){const r=e.slice(10).split(",").filter(Boolean).join(", ");return r?`matched on ${r}`:"matched a condition"}if(e.startsWith("router:")){const r=e.slice(7);return r?`chosen by router ${r}`:"chosen by a router"}return e.replaceAll("_"," ")}function lt(e){const r=new Map;for(const o of e)!o.request_group_id||o.status==="absorbed"||r.set(o.request_group_id,{servedBy:o.status==="success"?ve(o):null,servedPosition:o.status==="success"?o.attempt_position??null:null});return r}function Ms(e,r){const o=pt(e.selection_reason),a=e.attempt_position,l=e.attempt_count;if(a==null||l==null||l<=1)return o;const m=`attempt ${a} of ${l}`;return e.status==="absorbed"?r!=null&&r.servedBy?`${m} failed, served by ${r.servedBy}`:r?`${m} failed, and the request ended in an error`:`${m} failed, fell back`:e.status==="error"?a(r.attempt_position??0)-(o.attempt_position??0)||r.timestamp.localeCompare(o.timestamp))}function Ts({entry:e}){const r=e.request_group_id,a=it(r?[r]:[]),l=r?(a.data??[]).filter(d=>d.request_group_id===r):[],m=$s(l.length?l:[e]),c=l.length>0,u=m.find(d=>d.status==="success"),h=e.attempt_count??m.length,f=c?u?`Served by attempt ${u.attempt_position??"?"} of ${h}: ${ve(u)}`:m.some(d=>d.status==="error")?"No candidate served this request.":"This request has no outcome row yet.":a.isError?"Could not load this request's other attempts.":e.request_group_id?"Loading the rest of this request's attempts…":"This row carries no request group, so its other attempts cannot be found.";return t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsxs("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:["Routing plan · ",e.policy_name]}),t.jsx("span",{className:"text-sm text-[var(--otari-ink)]",children:f}),t.jsx("div",{className:"overflow-x-auto rounded-lg border border-[var(--otari-line)]",children:t.jsxs("table",{className:"w-full text-xs","aria-label":`Routing plan for policy ${e.policy_name}`,children:[t.jsx("thead",{className:"text-[var(--otari-muted)]",children:t.jsxs("tr",{className:"border-b border-[var(--otari-line)]",children:[t.jsx("th",{scope:"col",className:"px-3 py-2 text-left font-medium",children:"#"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-left font-medium",children:"Target"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-left font-medium",children:"Selected as"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-left font-medium",children:"Outcome"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-right font-medium",children:"Total time"}),t.jsx("th",{scope:"col",className:"px-3 py-2 text-right font-medium",children:"Cost"})]})}),t.jsx("tbody",{children:m.map(d=>t.jsxs("tr",{className:`border-t border-[var(--otari-line)] first:border-t-0 ${d.status==="success"?"bg-[var(--otari-brand-tint)]":""}`,children:[t.jsx("td",{className:"px-3 py-2 tabular-nums",children:d.attempt_position??"?"}),t.jsxs("td",{className:"px-3 py-2 break-all text-[var(--otari-ink)]",children:[ve(d),d.id===e.id?t.jsx("span",{className:"ml-2 rounded-full border border-[var(--otari-line)] px-1.5 py-0.5 text-[10px] text-[var(--otari-muted)]",children:"this row"}):null]}),t.jsx("td",{className:"px-3 py-2",children:pt(d.selection_reason)??"—"}),t.jsx("td",{className:`px-3 py-2 ${d.status==="success"?"":"text-amber-700"}`,children:As(d)}),t.jsx("td",{className:"px-3 py-2 text-right tabular-nums",children:De(d.latency_ms)}),t.jsx("td",{className:"px-3 py-2 text-right tabular-nums",children:z(d.cost)})]},d.id))})]})}),t.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Cost and tool charges settle on the attempt that served, so a failed attempt carries its tokens and no charge."})]})}function j({label:e,copyValue:r,copyLabel:o,children:a}){return t.jsxs("div",{className:"flex flex-col gap-0.5",children:[t.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:e}),r?t.jsx(Ht,{value:r,label:o??e.toLowerCase(),className:"text-sm text-[var(--otari-ink)] break-all",children:a}):t.jsx("span",{className:"text-sm text-[var(--otari-ink)] break-all",children:a})]})}function ve(e){return e.provider?e.model.startsWith(`${e.provider}:`)?e.model:`${e.provider}:${e.model}`:e.model}function Es({entry:e,onPriceModel:r}){var l,m;const o=e.cost===null,a=ve(e);return t.jsxs("div",{className:"flex flex-col gap-4 px-4 py-4",children:[e.error_message?t.jsxs("div",{className:"flex flex-col gap-1.5",children:[t.jsxs("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:["Error",e.status_code!==null?` (${e.status_code})`:""]}),t.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:e.error_message})]}):null,e.policy_name!==null&&e.policy_name!==void 0?t.jsx(Ts,{entry:e}):null,t.jsxs("div",{className:"grid gap-4 sm:grid-cols-2 lg:grid-cols-4",children:[t.jsx(j,{label:"Provider",children:e.provider??"—"}),t.jsx(j,{label:"Endpoint",children:e.endpoint}),t.jsx(j,{label:"Source",children:Oe(e.source)}),e.source_label?t.jsx(j,{label:"Session",children:e.source_label}):null,t.jsx(j,{label:"User",copyValue:e.user_id,copyLabel:"user id",children:e.user_id??"—"}),t.jsx(j,{label:"API key",copyValue:e.api_key_id,copyLabel:"api key id",children:e.api_key_id??"—"}),t.jsx(j,{label:"Prompt tokens",children:$(e.prompt_tokens)}),t.jsx(j,{label:"Completion tokens",children:$(e.completion_tokens)}),t.jsx(j,{label:"Total tokens",children:$(e.total_tokens)}),t.jsx(j,{label:"Billed tokens",children:t.jsx("span",{title:"Fresh input, cache reads and writes, and output: the tokens this request was priced on, and the total the activity row's bar splits.",children:$(((l=ut(e))==null?void 0:l.total)??null)})}),t.jsx(j,{label:"Cost",children:z(e.cost)}),be(e).length?t.jsxs(t.Fragment,{children:[t.jsx(j,{label:"Tools",children:be(e).map(mt).join(" · ")}),t.jsx(j,{label:"Tool cost",children:nt(e)===null?t.jsx("span",{className:"text-[var(--otari-warning-ink,var(--otari-muted))]",title:"No per-request price is configured for this tool, so its calls were recorded at zero cost. Set one on the Tools & Guardrails screen.",children:"unpriced"}):z(nt(e))})]}):null,t.jsx(j,{label:"Cache read tokens",children:$(e.cache_read_tokens)}),t.jsx(j,{label:"Cache write tokens",children:$(e.cache_write_tokens)}),t.jsx(j,{label:"1h cache writes",children:$(e.cache_write_1h_tokens??null)}),t.jsx(j,{label:"Total time",children:De(e.latency_ms)}),t.jsx(j,{label:"Request ID",copyValue:e.id,children:e.id})]}),o?t.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[t.jsx(U,{size:"sm",variant:"outline",onPress:()=>r(a),children:"Price this model"}),t.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["This request carries no cost. Set a price for ",t.jsx("code",{className:"break-all",children:a})," so later requests are metered and count against budgets. Rows already logged keep the cost they were served with."]})]}):null,(m=e.pricing_breakdown)!=null&&m.length?t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Billed meters"}),t.jsx("div",{className:"grid gap-2 sm:grid-cols-2 lg:grid-cols-3",children:fs(e.pricing_breakdown).map(c=>t.jsx(j,{label:c.meter.replaceAll("_"," "),children:"unit_rate"in c?`${$(c.units)} at ${xs(c.unit_rate)} each, ${z(c.cost)}`:`${$(c.units)} at ${z(c.rate_per_million)} / 1M, ${z(c.cost)}`},c.meter))})]}):null]})}function Hs(){var Ge,Ve,Ye,Ze,He,Xe,Je,Qe;const e=Lt(),r=Ut(),o=x.useMemo(()=>{const s=new Map;for(const i of r.data??[])s.set(i.id,i.key_name??`${i.id.slice(0,8)}…`);return s},[r.data]),a=ps(Ss),l=a.get("range"),m=a.get("start_date"),c=a.get("end_date"),u=a.get("status"),h=a.getAll("model"),f=a.getAll("user_id"),d=a.getAll("api_key_id"),C=a.get("priced"),g=a.get("source"),p=a.get("source_label"),M=a.get("endpoint"),P=a.get("provider"),w=a.get("tool"),ne=Math.max(0,a.getNumber("page")),J=a.getNumber("size"),F=is.reduce((s,i)=>Math.abs(i-J)xe(l,m,c)),k=x.useRef(G);x.useEffect(()=>{k.current!==G&&(k.current=G,V(xe(l,m,c)))},[G,l,m,c]);const[I,Y]=x.useState(()=>Te(l)),Z=x.useRef(l);x.useEffect(()=>{Z.current!==l&&(Z.current=l,Y(Te(l)))},[l]);const R=C==="true"?!0:C==="false"?!1:void 0,v=x.useMemo(()=>({start_date:_.start,end_date:_.end,status:u||void 0,model:h.length>0?h:void 0,user_id:f.length>0?f:void 0,api_key_id:d.length>0?d:void 0,source:g||void 0,source_label:p||void 0,endpoint:M||void 0,provider:P||void 0,tool:w||void 0,priced:R}),[_,w,u,h,f,d,g,p,M,P,R]),y=ts(),q=JSON.stringify(v),Q=x.useRef(q);x.useEffect(()=>{Q.current!==q&&(Q.current=q,a.patch({page:0}),y.clear())},[q,a,y]);const T=Bt(v,ne,F),B=et(v),ee=x.useMemo(()=>({start_date:_.start,end_date:_.end,status:u||void 0,user_id:f.length>0?f:void 0,api_key_id:d.length>0?d:void 0,source:g||void 0,source_label:p||void 0,endpoint:M||void 0,provider:P||void 0,tool:w||void 0}),[_,u,f,d,g,p,M,P,w]),E=Ae(ee,"day",ys),le=((Ve=(Ge=E.data)==null?void 0:Ge.by_model)==null?void 0:Ve.filter(s=>!s.is_other&&s.key!==null).map(s=>s.key))??[],ie=(r.data??[]).map(s=>({value:s.id,label:s.key_name??`${s.id.slice(0,8)}…`})),_e=x.useMemo(()=>({start_date:_.start,end_date:_.end,status:u||void 0,model:h.length>0?h:void 0,user_id:f.length>0?f:void 0,api_key_id:d.length>0?d:void 0}),[_,u,h,f,d]),te=Ae(_e,"day",ws,!!g),se=(Ye=g?te.data:E.data)==null?void 0:Ye.by_source,ce=x.useMemo(()=>{const s=(se??[]).filter(i=>!i.is_other&&i.key!==null).map(i=>i.key);return g&&!s.includes(g)?[g,...s]:s},[se,g]),L=X(W,l)??X(W,fe),n=!!(_.start&&I.start&&new Date(_.start).getTime()({start_date:n?_.start:I.start,end_date:n?_.end:void 0,status:u||void 0,model:h.length>0?h:void 0,user_id:f.length>0?f:void 0,api_key_id:d.length>0?d:void 0,source:g||void 0,source_label:p||void 0,endpoint:M||void 0,provider:P||void 0,tool:w||void 0,priced:R}),[n,w,_,I,u,h,f,d,g,p,M,P,R]),D=Ae(O,S,js),ht=(((Ze=D.data)==null?void 0:Ze.series)??[]).map(s=>({bucketStart:s.bucket_start,requests:s.requests,errors:s.errors??0})),A=T.data??[],{pageOutcomes:je,unresolvedGroupIds:xt}=x.useMemo(()=>{const s=lt(A),i=new Set;for(const N of A)N.status==="absorbed"&&N.request_group_id&&!s.has(N.request_group_id)&&i.add(N.request_group_id);return{pageOutcomes:s,unresolvedGroupIds:[...i]}},[A]),ke=it(xt),Fe=x.useMemo(()=>{var s;return(s=ke.data)!=null&&s.length?new Map([...je,...lt(ke.data)]):je},[je,ke.data]),ft=B.isSuccess&&!B.isPlaceholderData?((He=B.data)==null?void 0:He.total)??0:null,ye=X(W,l),gt=!!(m||c)||l!==fe&&(ye==null?void 0:ye.seconds)!=null,bt=!!(u||h.length||f.length||d.length||C||g||p||M||P||w||gt),re=(s,i)=>{var N;return((N=s.find(K=>K.value===i))==null?void 0:N.label)??i},qe=(e.data??[]).map(s=>({value:s.user_id,label:s.alias?`${s.alias} (${s.user_id})`:s.user_id})),vt=()=>a.patch({status:"",priced:"",model:[],user_id:[],api_key_id:[],source:"",source_label:"",endpoint:"",provider:"",tool:""}),we=(s,i,N,K,oe)=>K.map(H=>({key:`${s}:${H}`,label:i,value:oe(H),clearLabel:`Remove ${i} filter ${oe(H)}`,onClear:()=>a.patch({[N]:K.filter(pe=>pe!==H)})})),_t=[...u?[{key:"status",label:"Status",value:re(rt,u),onClear:()=>a.patch({status:""})}]:[],...C?[{key:"priced",label:"Priced",value:re(at,C),onClear:()=>a.patch({priced:""})}]:[],...we("user","User","user_id",f,s=>re(qe,s)),...we("model","Model","model",h,s=>s),...we("key","API key","api_key_id",d,s=>re(ie,s)),...g?[{key:"source",label:"Source",value:Oe(g),onClear:()=>a.patch({source:""})}]:[],...p?[{key:"session",label:"Session",value:p,onClear:()=>a.patch({source_label:""})}]:[],...M?[{key:"endpoint",label:"Endpoint",value:M,onClear:()=>a.patch({endpoint:""})}]:[],...P?[{key:"provider",label:"Provider",value:P,onClear:()=>a.patch({provider:""})}]:[],...w?[{key:"tool",label:"Tool",value:re(ot,w),onClear:()=>a.patch({tool:""})}]:[]],de=x.useMemo(()=>A.filter(s=>!s.counts_toward_budget).map(s=>s.id),[A]),jt=x.useMemo(()=>A.filter(s=>s.counts_toward_budget).map(s=>s.id),[A]),Le=ss(y.selectedKeys,de),ae=Le.length,Ue=y.allMatching||ae>0,kt=x.useMemo(()=>({...v,counts_toward_budget:!1}),[v]),Be=et(kt,Ue),ue=Be.isSuccess?((Xe=Be.data)==null?void 0:Xe.total)??null:null,yt=de.length>0&&ae===de.length&&ue!=null&&ue>ae,me=y.allMatching?ue??ae:ae,wt=de.length>0||y.allMatching,Se=zt(),Ne=Wt(),Ce=Gt(),[St,Pe]=x.useState(!1),[Nt,Ie]=x.useState(!1),[Me,Re]=x.useState(null),[Ct,Ke]=x.useState(null),Pt=x.useCallback(s=>t.jsxs("div",{children:[t.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[t.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Request detail"}),t.jsx(U,{size:"sm",variant:"ghost",onPress:()=>Ke(null),children:"Close"})]}),t.jsx(Es,{entry:s,onPriceModel:Re})]}),[]),ze=()=>y.allMatching?{by_filter:!0,model:v.model,user_id:v.user_id,api_key_id:v.api_key_id,status:v.status,source:v.source,source_label:v.source_label,endpoint:v.endpoint,provider:v.provider,tool:v.tool,start_date:v.start_date,end_date:v.end_date,priced:v.priced}:{ids:Le},It=()=>{Se.mutate(ze(),{onSuccess:()=>{Pe(!1),y.clear()}})},Mt=(s,i)=>{Ce.mutate({model_key:i,input_price_per_million:s.input_price_per_million,output_price_per_million:s.output_price_per_million,cache_read_price_per_million:s.cache_read_price_per_million??null,cache_write_price_per_million:s.cache_write_price_per_million??null},{onSuccess:()=>Re(null)})},Rt=s=>{Ne.mutate({...ze(),...s},{onSuccess:()=>{Ie(!1),y.clear()}})},At=()=>{T.refetch(),B.refetch(),D.refetch(),E.refetch(),g&&te.refetch()},We=s=>{if(s.key===l&&!m&&!c){V(xe(s.key,"","")),Y(Te(s.key));return}a.patch({range:s.key,start_date:"",end_date:""})},$t=(s,i)=>a.patch({start_date:s,end_date:i}),Tt=x.useMemo(()=>{const s=i=>i===null?"—":o.get(i)??`${i.slice(0,8)}…`;return[{id:"time",header:"Time",cell:i=>t.jsx("span",{title:gs(i.timestamp),className:"text-[var(--otari-muted)]",children:bs(i.timestamp)})},{id:"user",header:"User",cell:i=>i.user_id??"—"},{id:"model",header:"Model",isRowHeader:!0,cell:i=>{const N=be(i);if(!N.length)return i.model;const K=N.reduce((H,pe)=>H+pe.billed+pe.errors,0),oe=N.map(mt).join(" · ");return t.jsxs("span",{className:"inline-flex items-center gap-1.5",children:[i.model,t.jsxs("span",{className:"inline-flex items-center rounded-full border border-[var(--otari-line)] bg-[var(--otari-brand-tint)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--otari-brand-dark)]",title:oe,"aria-label":`Gateway tools: ${oe}`,children:[K," ",K===1?"tool":"tools"]})]})}},{id:"routing",header:"Routing",cell:i=>t.jsx(Rs,{entry:i,outcome:Fe.get(i.request_group_id??"")??null})},{id:"api_key",header:"API key",cell:i=>t.jsx("span",{className:"text-[var(--otari-muted)]",children:s(i.api_key_id)})},{id:"tokens",header:"Tokens",align:"end",cell:i=>t.jsx(Is,{entry:i})},{id:"cost",header:"Cost",align:"end",cell:i=>z(i.cost)},{id:"latency",header:"Total time",align:"end",cell:i=>De(i.latency_ms)},{id:"status",header:"Status",cell:i=>t.jsx(Ns,{status:i.status})}]},[o,Fe]);return t.jsxs("div",{className:"flex flex-col gap-6",children:[t.jsx(Vt,{title:"Activity",description:"A per-request log of what the gateway served: tokens, cost, latency, and failures. No request or response content is stored."}),t.jsx(Yt,{error:T.error??B.error??D.error}),t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsx(ms,{presets:W,extentKey:b,onPreset:We,onSelectRange:$t,onSelectFull:()=>L?We(L):void 0,series:ht,bucket:S,windowStart:_.start,windowEnd:_.end,loading:D.isLoading,ariaLabel:"Activity request volume over the selected window",action:t.jsx(Zt,{onRefresh:At,isFetching:T.isFetching,updatedAt:T.dataUpdatedAt})}),t.jsxs(ns,{chips:_t,onClearAll:vt,children:[t.jsx(he,{id:"filter-status",label:"Status",value:u,onChange:s=>a.patch({status:s}),children:rt.map(s=>t.jsx("option",{value:s.value,children:s.label},s.value))}),t.jsx(he,{id:"filter-priced",label:"Priced?",value:C,onChange:s=>a.patch({priced:s}),children:at.map(s=>t.jsx("option",{value:s.value,children:s.label},s.value))}),w||(Qe=(Je=D.data)==null?void 0:Je.by_tool)!=null&&Qe.length?t.jsx(he,{id:"filter-tool",label:"Tool",value:w,onChange:s=>a.patch({tool:s}),children:ot.map(s=>t.jsx("option",{value:s.value,children:s.label},s.value))}):null,ce.length>1||g?t.jsxs(he,{id:"filter-source",label:"Source",value:g,onChange:s=>a.patch({source:s}),children:[t.jsx("option",{value:"",children:"All"}),ce.map(s=>t.jsx("option",{value:s,children:Oe(s)},s))]}):null,t.jsx($e,{label:"API key",values:d,onChange:s=>a.patch({api_key_id:s}),placeholder:"All keys",options:ie}),t.jsx($e,{label:"User",values:f,onChange:s=>a.patch({user_id:s}),placeholder:"All users",options:qe}),t.jsx($e,{label:"Model",values:h,onChange:s=>a.patch({model:s}),allowsCustom:!0,placeholder:"Any model",options:le.map(s=>({value:s,label:s}))})]})]}),Ue?t.jsxs(rs,{selectedCount:me,allMatching:y.allMatching,matchingTotal:ue,canSelectAllMatching:yt,onSelectAllMatching:y.enableAllMatching,onClear:y.clear,children:[t.jsx(U,{size:"sm",variant:"primary",onPress:()=>Ie(!0),children:"Set price"}),t.jsx(U,{size:"sm",variant:"danger",onPress:()=>Pe(!0),children:"Delete"})]}):null,t.jsx(os,{ariaLabel:"Activity log",columns:Tt,rows:A,getRowKey:vs,isLoading:T.isLoading,emptyContent:bt?"No requests match these filters.":"No requests recorded yet.",selectionMode:wt?"multiple":"none",selectedKeys:y.selectedKeys,onSelectionChange:y.onSelectionChange,disabledKeys:jt,onRowAction:s=>Ke(i=>i===s?null:s),rowClassName:_s,detailKey:Ct,renderDetail:Pt}),t.jsx(ls,{page:ne,pageSize:F,total:ft,rowsOnPage:A.length,onPageChange:s=>a.patch({page:s}),onPageSizeChange:s=>a.patch({size:s,page:0}),isFetching:T.isFetching,hasNextFallback:A.length===F}),t.jsx(as,{isOpen:St,onOpenChange:Pe,heading:"Delete usage rows",body:`Delete ${me.toLocaleString()} imported ${me===1?"row":"rows"}? Only imported rows are removed, and this cannot be undone.`,confirmLabel:"Delete",isPending:Se.isPending,error:Se.error,onConfirm:It}),t.jsx(tt,{isOpen:Nt,onOpenChange:Ie,targetCount:me,isPending:Ne.isPending,error:Ne.error,onSubmit:Rt}),t.jsx(tt,{isOpen:Me!==null,onOpenChange:s=>Re(s?Me??"":null),isPending:Ce.isPending,error:Ce.error,onSubmit:Mt,collectModelKey:!0,initialModelKey:Me??"",title:"Price this model",description:()=>"Set what this model costs, taken from the request you were looking at. Requests from now on are costed at these rates and counted against budgets; rows already logged keep the cost they were served with."})]})}export{Hs as ActivityPage}; diff --git a/src/gateway/static/dashboard/assets/BudgetsPage-C3eMHXLY.js b/src/gateway/static/dashboard/assets/BudgetsPage-Bb-ZzB9q.js similarity index 80% rename from src/gateway/static/dashboard/assets/BudgetsPage-C3eMHXLY.js rename to src/gateway/static/dashboard/assets/BudgetsPage-Bb-ZzB9q.js index 69d3ccb4..52db3dfd 100644 --- a/src/gateway/static/dashboard/assets/BudgetsPage-C3eMHXLY.js +++ b/src/gateway/static/dashboard/assets/BudgetsPage-Bb-ZzB9q.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as i}from"./react-dgEcD0HR.js";import{I as ne,u as re,J as le,K as ie,L as oe,M as de,q as ue,P as ce,E as K,N as me,B as xe,O as ge}from"./index-DAnS9oY2.js";import{u as he,r as pe,B as fe}from"./tableSelection-BJDASjEj.js";import{C as be}from"./ConfirmDialog-lRO7CIis.js";import{D as je}from"./DataTable-DuDxGlJc.js";import{F as z}from"./Field-CBU9MRjz.js";import{C as E,I as ve,a as ye,b as Ne,B as x,d as k,S as Se}from"./heroui-COmYdDDM.js";const _e=50;function Ce({value:t,onChange:r,users:n,label:o,description:l}){const[g,h]=i.useState(""),d=i.useMemo(()=>n.filter(s=>!s.user_id.startsWith("apikey-")).map(s=>({id:s.user_id,label:s.alias?`${s.user_id} (${s.alias})`:s.user_id})),[n]),p=i.useMemo(()=>{const s=g.trim().toLowerCase();return d.filter(u=>!t.includes(u.id)).filter(u=>!s||u.id.toLowerCase().includes(s)||u.label.toLowerCase().includes(s)).slice(0,_e)},[d,t,g]),c=s=>{t.includes(s)||r([...t,s]),h("")},m=s=>r(t.filter(u=>u!==s));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:o}),l?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:l}):null]}),t.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:t.map(s=>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:[s,e.jsx("button",{type:"button","aria-label":`Remove ${s}`,onClick:()=>m(s),className:"text-[var(--otari-brand-dark)] hover:text-red-700",children:"×"})]},s))}):null,d.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(E.Root,{allowsEmptyCollection:!0,menuTrigger:"input",inputValue:g,onInputChange:h,selectedKey:null,onSelectionChange:s=>{s!=null&&c(String(s))},className:"flex flex-col gap-1",children:[e.jsxs(E.InputGroup,{children:[e.jsx(ve,{"aria-label":"Add a user",placeholder:"Search users…",autoComplete:"off"}),e.jsx(E.Trigger,{})]}),e.jsx(E.Popover,{children:e.jsx(ye,{items:p,className:"max-h-72 overflow-auto",children:s=>e.jsx(Ne,{id:s.id,textValue:s.label,children:s.label})})})]})]})}const we=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:2});function B(t){return we.format(t)}const j=86400,q=3600,O=[{label:"No reset",seconds:null},{label:"Daily",seconds:j},{label:"Weekly",seconds:7*j},{label:"Monthly",seconds:30*j}];function Be(t){if(t===null)return"No reset";const r=O.find(n=>n.seconds===t);return r?r.label:t%j===0?`Every ${t/j} days`:t%q===0?`Every ${t/q} hours`:`Every ${t}s`}function W(t){if(!t)return"—";const r=new Date(t);return Number.isNaN(r.getTime())?"—":r.toLocaleString()}function De(t){const r=t.trim();if(r==="")return{value:null,valid:!0};const n=Number(r);return!Number.isFinite(n)||n<0?{value:null,valid:!1}:{value:n,valid:!0}}function Y(t){return t!==null&&t%j===0?String(t/j):""}function Pe({value:t,onChange:r,onInvalidChange:n}){const o=O.some(s=>s.seconds===t),[l,g]=i.useState(!o),[h,d]=i.useState(()=>Y(t)),p=h.trim(),c=Number(p),m=p!==""&&(!Number.isSafeInteger(c)||c<=0);return i.useEffect(()=>{n==null||n(m)},[m,n]),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:[O.map(s=>e.jsx(x,{size:"sm",variant:!l&&t===s.seconds?"primary":"outline",onPress:()=>{g(!1),d(Y(s.seconds)),r(s.seconds)},children:s.label},s.label)),e.jsx(x,{size:"sm",variant:l?"primary":"outline",onPress:()=>g(!0),children:"Custom"})]}),l?e.jsx("div",{className:"flex items-end gap-2",children:e.jsx(z,{label:"Every N days",value:h,onChange:s=>{d(s);const u=Number(s.trim());r(s.trim()===""||!Number.isSafeInteger(u)||u<=0?null:u*j)},placeholder:"14",description:m?e.jsx("span",{className:"text-red-700",children:"Enter a whole number of days."}):"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 G({title:t,submitLabel:r,initial:n,error:o,isPending:l,onSubmit:g,onClose:h,assignUsers:d}){const[p,c]=i.useState(n.name??""),[m,s]=i.useState(n.max_budget===null?"":String(n.max_budget)),[u,b]=i.useState(n.budget_duration_sec),[S,v]=i.useState(!1),[D,P]=i.useState([]),f=De(m),A=!l&&f.valid&&!S,C=()=>{A&&g({name:p.trim()||null,max_budget:f.value,budget_duration_sec:u},D)};return e.jsx(k,{children:e.jsxs(k.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:t}),e.jsx(K,{error:o}),e.jsx(z,{label:"Name (optional)",value:p,onChange:c,autoFocus:!0,placeholder:"team-free-tier",description:"A label to recognize this budget later."}),e.jsx(z,{label:"Spending limit (USD)",value:m,onChange:s,placeholder:"100.00",description:f.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:u,onChange:b,onInvalidChange:v}),d?e.jsx(Ce,{label:"Assign to users (optional)",description:"Attach this budget to existing users now. You can also manage assignments later on the Users page.",value:D,onChange:P,users:d}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(x,{variant:"primary",isDisabled:!A,onPress:C,children:l?"Saving…":r}),e.jsx(x,{variant:"ghost",isDisabled:l,onPress:h,children:"Cancel"})]})]})})}function Ae({budget:t}){if(t.user_count===0)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users assigned"});const r=t.total_spend;if(t.max_budget===null)return e.jsxs("span",{className:"text-xs text-[var(--otari-ink)]",children:[B(r)," spent",e.jsx("span",{className:"text-[var(--otari-muted)]",children:" · no limit"})]});const n=t.max_budget*t.user_count,o=n>0?Math.min(100,r/n*100):0,l=r>n;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:B(r)}),e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["of ",B(n)]})]}),e.jsx("div",{className:"h-1.5 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",role:"progressbar","aria-valuenow":Math.round(o),"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(o,l?100:2)}%`}})})]})}function Ee({budgetId:t}){const r=ge(t);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(Se,{size:"sm"})," Loading reset history…"]});if(r.error)return e.jsx("div",{className:"px-4 py-4",children:e.jsx(K,{error:r.error})});const n=r.data??[];return n.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:n.map(o=>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:o.user_id??"—"})}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-ink)]",children:B(o.previous_spend)}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-muted)]",children:W(o.reset_at)}),e.jsx("td",{className:"py-1.5 text-[var(--otari-muted)]",children:W(o.next_reset_at)})]},o.id))})]})})}function ke({label:t,isPending:r,onConfirm:n}){const[o,l]=i.useState(!1);return o?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:t}),"? 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:n,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"})}const Ue=t=>t.budget_id;function J(t){return t.split("-")[0]}function $(t){return t.name??J(t.budget_id)}function Te(){const t=ne(),r=re(),n=le(),o=ie(),l=oe(),g=de(),[h,d]=i.useState(!1),[p,c]=i.useState(null),[m,s]=i.useState(null),[u,b]=i.useState(null),[S,v]=i.useState(null),[D,P]=i.useState(!1),f=he(),[A,C]=i.useState(!1),[Q,T]=i.useState(void 0),[X,F]=i.useState(!1),w=t.data??[],H=t.isLoading,y=w.find(a=>a.budget_id===p)??null,U=w.find(a=>a.budget_id===m)??null,L=!H&&w.length===0&&!h,Z=w.map(a=>a.budget_id),_=pe(f.selectedKeys,Z),ee=async()=>{F(!0),T(void 0);try{for(const a of _)await l.mutateAsync(a);f.clear(),C(!1)}catch(a){T(a)}finally{F(!1)}},te=i.useMemo(()=>[{id:"budget",header:"Budget",isRowHeader:!0,cell:a=>e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:a.name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsx(ue,{value:a.budget_id,label:"budget id",children:e.jsx("code",{className:"text-[11px] text-[var(--otari-muted)]",title:a.budget_id,children:J(a.budget_id)})})]})},{id:"limit",header:"Limit (per user)",cell:a=>a.max_budget===null?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"Unlimited"}):B(a.max_budget)},{id:"reset",header:"Reset",cell:a=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:Be(a.budget_duration_sec)})},{id:"users",header:"Users",cell:a=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:a.user_count})},{id:"usage",header:"Usage",cell:a=>e.jsx(Ae,{budget:a})},{id:"actions",header:"Actions",align:"end",cell:a=>e.jsxs("div",{className:"flex items-center justify-end gap-1.5",children:[e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>s(N=>N===a.budget_id?null:a.budget_id),children:m===a.budget_id?"Hide history":"History"}),e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>{d(!1),c(a.budget_id)},children:"Edit"}),e.jsx(ke,{label:$(a),isPending:l.isPending,onConfirm:()=>l.mutate(a.budget_id)})]})}],[m,l.isPending,l.mutate]),V=async(a,N)=>{P(!0),b(null);const I=await Promise.allSettled(N.map(M=>g.mutateAsync({id:M,body:{budget_id:a}})));P(!1);const R=I.flatMap((M,ae)=>M.status==="rejected"?[N[ae]]:[]);if(R.length>0){v({budgetId:a,userIds:R}),b(new Error(`Budget created, but could not assign it to: ${R.join(", ")}. Retry to try again.`));return}v(null),d(!1)},se=(a,N)=>{if(S){V(S.budgetId,S.userIds);return}b(null),n.mutate(a,{onSuccess:async I=>{if(N.length>0){await V(I.budget_id,N);return}d(!1)}})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ce,{title:"Budgets",description:"Define spending limits and reset schedules. Assign a budget to users to enforce it.",action:h||L?null:e.jsx(x,{variant:"primary",onPress:()=>{c(null),b(null),v(null),d(!0)},children:"Create budget"})}),e.jsx(K,{error:t.error??n.error??o.error??l.error??g.error}),e.jsx(me,{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."}),L?e.jsx(xe,{title:"No budgets yet",description:"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.",actionLabel:"Create your first budget",onAction:()=>{c(null),b(null),v(null),d(!0)}}):null,h?e.jsx(G,{title:"Create budget",submitLabel:S?"Retry assignments":"Create budget",initial:{name:null,max_budget:null,budget_duration_sec:null},error:n.error??u,isPending:n.isPending||D,assignUsers:r.data??[],onSubmit:se,onClose:()=>{b(null),v(null),d(!1)}}):null,y?e.jsx(G,{title:`Edit budget ${$(y)}`,submitLabel:"Save changes",initial:{name:y.name,max_budget:y.max_budget,budget_duration_sec:y.budget_duration_sec},error:o.error,isPending:o.isPending,onSubmit:a=>o.mutate({id:y.budget_id,body:a},{onSuccess:()=>c(null)}),onClose:()=>c(null)},y.budget_id):null,_.length>0?e.jsx(fe,{selectedCount:_.length,allMatching:!1,matchingTotal:null,canSelectAllMatching:!1,onSelectAllMatching:()=>{},onClear:f.clear,children:e.jsx(x,{size:"sm",variant:"danger",onPress:()=>C(!0),children:"Delete"})}):null,L?null:e.jsx(je,{ariaLabel:"Budgets",columns:te,rows:w,getRowKey:Ue,isLoading:H,emptyContent:"No budgets yet. Create one to cap spending.",selectionMode:"multiple",selectedKeys:f.selectedKeys,onSelectionChange:f.onSelectionChange}),U?e.jsx(k,{children:e.jsxs(k.Content,{className:"p-0",children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[e.jsxs("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:["Reset history — ",$(U)]}),e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>s(null),children:"Close"})]}),e.jsx(Ee,{budgetId:U.budget_id})]})}):null,e.jsx(be,{isOpen:A,onOpenChange:C,heading:"Delete budgets",body:`Delete ${_.length} ${_.length===1?"budget":"budgets"}? Users on ${_.length===1?"it":"them"} will no longer be capped.`,confirmLabel:"Delete",isPending:X,error:Q,onConfirm:ee})]})}export{Te as BudgetsPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as i}from"./react-dgEcD0HR.js";import{H as ne,p as re,I as le,J as ie,K as oe,L as de,o as ue,P as ce,E as H,M as me,z as xe,N as ge}from"./index-D6WO6K2k.js";import{u as he,r as pe,B as fe}from"./tableSelection-BJDASjEj.js";import{C as be}from"./ConfirmDialog-gmtoFRlO.js";import{D as je}from"./DataTable-DuDxGlJc.js";import{F as $}from"./Field-CBU9MRjz.js";import{C as E,I as ve,a as ye,b as Ne,B as x,d as k,S as Se}from"./heroui-COmYdDDM.js";const _e=50;function Ce({value:t,onChange:r,users:n,label:o,description:l}){const[g,h]=i.useState(""),d=i.useMemo(()=>n.filter(s=>!s.user_id.startsWith("apikey-")).map(s=>({id:s.user_id,label:s.alias?`${s.user_id} (${s.alias})`:s.user_id})),[n]),p=i.useMemo(()=>{const s=g.trim().toLowerCase();return d.filter(u=>!t.includes(u.id)).filter(u=>!s||u.id.toLowerCase().includes(s)||u.label.toLowerCase().includes(s)).slice(0,_e)},[d,t,g]),c=s=>{t.includes(s)||r([...t,s]),h("")},m=s=>r(t.filter(u=>u!==s));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:o}),l?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:l}):null]}),t.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:t.map(s=>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:[s,e.jsx("button",{type:"button","aria-label":`Remove ${s}`,onClick:()=>m(s),className:"text-[var(--otari-brand-dark)] hover:text-red-700",children:"×"})]},s))}):null,d.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(E.Root,{allowsEmptyCollection:!0,menuTrigger:"input",inputValue:g,onInputChange:h,selectedKey:null,onSelectionChange:s=>{s!=null&&c(String(s))},className:"flex flex-col gap-1",children:[e.jsxs(E.InputGroup,{children:[e.jsx(ve,{"aria-label":"Add a user",placeholder:"Search users…",autoComplete:"off"}),e.jsx(E.Trigger,{})]}),e.jsx(E.Popover,{children:e.jsx(ye,{items:p,className:"max-h-72 overflow-auto",children:s=>e.jsx(Ne,{id:s.id,textValue:s.label,children:s.label})})})]})]})}const we=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:2});function D(t){return we.format(t)}const j=86400,W=3600,O=[{label:"No reset",seconds:null},{label:"Daily",seconds:j},{label:"Weekly",seconds:7*j},{label:"Monthly",seconds:30*j}];function De(t){if(t===null)return"No reset";const r=O.find(n=>n.seconds===t);return r?r.label:t%j===0?`Every ${t/j} days`:t%W===0?`Every ${t/W} hours`:`Every ${t}s`}function q(t){if(!t)return"—";const r=new Date(t);return Number.isNaN(r.getTime())?"—":r.toLocaleString()}function Be(t){const r=t.trim();if(r==="")return{value:null,valid:!0};const n=Number(r);return!Number.isFinite(n)||n<0?{value:null,valid:!1}:{value:n,valid:!0}}function Y(t){return t!==null&&t%j===0?String(t/j):""}function Pe({value:t,onChange:r,onInvalidChange:n}){const o=O.some(s=>s.seconds===t),[l,g]=i.useState(!o),[h,d]=i.useState(()=>Y(t)),p=h.trim(),c=Number(p),m=p!==""&&(!Number.isSafeInteger(c)||c<=0);return i.useEffect(()=>{n==null||n(m)},[m,n]),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:[O.map(s=>e.jsx(x,{size:"sm",variant:!l&&t===s.seconds?"primary":"outline",onPress:()=>{g(!1),d(Y(s.seconds)),r(s.seconds)},children:s.label},s.label)),e.jsx(x,{size:"sm",variant:l?"primary":"outline",onPress:()=>g(!0),children:"Custom"})]}),l?e.jsx("div",{className:"flex items-end gap-2",children:e.jsx($,{label:"Every N days",value:h,onChange:s=>{d(s);const u=Number(s.trim());r(s.trim()===""||!Number.isSafeInteger(u)||u<=0?null:u*j)},placeholder:"14",description:m?e.jsx("span",{className:"text-red-700",children:"Enter a whole number of days."}):"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 G({title:t,submitLabel:r,initial:n,error:o,isPending:l,onSubmit:g,onClose:h,assignUsers:d}){const[p,c]=i.useState(n.name??""),[m,s]=i.useState(n.max_budget===null?"":String(n.max_budget)),[u,b]=i.useState(n.budget_duration_sec),[S,v]=i.useState(!1),[B,P]=i.useState([]),f=Be(m),A=!l&&f.valid&&!S,C=()=>{A&&g({name:p.trim()||null,max_budget:f.value,budget_duration_sec:u},B)};return e.jsx(k,{children:e.jsxs(k.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:t}),e.jsx(H,{error:o}),e.jsx($,{label:"Name (optional)",value:p,onChange:c,autoFocus:!0,placeholder:"team-free-tier",description:"A label to recognize this budget later."}),e.jsx($,{label:"Spending limit (USD)",value:m,onChange:s,placeholder:"100.00",description:f.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:u,onChange:b,onInvalidChange:v}),d?e.jsx(Ce,{label:"Assign to users (optional)",description:"Attach this budget to existing users now. You can also manage assignments later on the Users page.",value:B,onChange:P,users:d}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(x,{variant:"primary",isDisabled:!A,onPress:C,children:l?"Saving…":r}),e.jsx(x,{variant:"ghost",isDisabled:l,onPress:h,children:"Cancel"})]})]})})}function Ae({budget:t}){if(t.user_count===0)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users assigned"});const r=t.total_spend;if(t.max_budget===null)return e.jsxs("span",{className:"text-xs text-[var(--otari-ink)]",children:[D(r)," spent",e.jsx("span",{className:"text-[var(--otari-muted)]",children:" · no limit"})]});const n=t.max_budget*t.user_count,o=n>0?Math.min(100,r/n*100):0,l=r>n;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:D(r)}),e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["of ",D(n)]})]}),e.jsx("div",{className:"h-1.5 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",role:"progressbar","aria-valuenow":Math.round(o),"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(o,l?100:2)}%`}})})]})}function Ee({budgetId:t}){const r=ge(t);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(Se,{size:"sm"})," Loading reset history…"]});if(r.error)return e.jsx("div",{className:"px-4 py-4",children:e.jsx(H,{error:r.error})});const n=r.data??[];return n.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:n.map(o=>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:o.user_id??"—"})}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-ink)]",children:D(o.previous_spend)}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-muted)]",children:q(o.reset_at)}),e.jsx("td",{className:"py-1.5 text-[var(--otari-muted)]",children:q(o.next_reset_at)})]},o.id))})]})})}function ke({label:t,isPending:r,onConfirm:n}){const[o,l]=i.useState(!1);return o?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:t}),"? 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:n,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"})}const Ue=t=>t.budget_id;function J(t){return t.split("-")[0]}function z(t){return t.name??J(t.budget_id)}function Ke(){const t=ne(),r=re(),n=le(),o=ie(),l=oe(),g=de(),[h,d]=i.useState(!1),[p,c]=i.useState(null),[m,s]=i.useState(null),[u,b]=i.useState(null),[S,v]=i.useState(null),[B,P]=i.useState(!1),f=he(),[A,C]=i.useState(!1),[Q,K]=i.useState(void 0),[X,T]=i.useState(!1),w=t.data??[],F=t.isLoading,y=w.find(a=>a.budget_id===p)??null,U=w.find(a=>a.budget_id===m)??null,L=!F&&w.length===0&&!h,Z=w.map(a=>a.budget_id),_=pe(f.selectedKeys,Z),ee=async()=>{T(!0),K(void 0);try{for(const a of _)await l.mutateAsync(a);f.clear(),C(!1)}catch(a){K(a)}finally{T(!1)}},te=i.useMemo(()=>[{id:"budget",header:"Budget",isRowHeader:!0,cell:a=>e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:a.name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsx(ue,{value:a.budget_id,label:"budget id",children:e.jsx("code",{className:"text-[11px] text-[var(--otari-muted)]",title:a.budget_id,children:J(a.budget_id)})})]})},{id:"limit",header:"Limit (per user)",cell:a=>a.max_budget===null?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"Unlimited"}):D(a.max_budget)},{id:"reset",header:"Reset",cell:a=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:De(a.budget_duration_sec)})},{id:"users",header:"Users",cell:a=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:a.user_count})},{id:"usage",header:"Usage",cell:a=>e.jsx(Ae,{budget:a})},{id:"actions",header:"Actions",align:"end",cell:a=>e.jsxs("div",{className:"flex items-center justify-end gap-1.5",children:[e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>s(N=>N===a.budget_id?null:a.budget_id),children:m===a.budget_id?"Hide history":"History"}),e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>{d(!1),c(a.budget_id)},children:"Edit"}),e.jsx(ke,{label:z(a),isPending:l.isPending,onConfirm:()=>l.mutate(a.budget_id)})]})}],[m,l.isPending,l.mutate]),V=async(a,N)=>{P(!0),b(null);const I=await Promise.allSettled(N.map(M=>g.mutateAsync({id:M,body:{budget_id:a}})));P(!1);const R=I.flatMap((M,ae)=>M.status==="rejected"?[N[ae]]:[]);if(R.length>0){v({budgetId:a,userIds:R}),b(new Error(`Budget created, but could not assign it to: ${R.join(", ")}. Retry to try again.`));return}v(null),d(!1)},se=(a,N)=>{if(S){V(S.budgetId,S.userIds);return}b(null),n.mutate(a,{onSuccess:async I=>{if(N.length>0){await V(I.budget_id,N);return}d(!1)}})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ce,{title:"Budgets",description:"Define spending limits and reset schedules. Assign a budget to users to enforce it.",action:h||L?null:e.jsx(x,{variant:"primary",onPress:()=>{c(null),b(null),v(null),d(!0)},children:"Create budget"})}),e.jsx(H,{error:t.error??n.error??o.error??l.error??g.error}),e.jsx(me,{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."}),L?e.jsx(xe,{title:"No budgets yet",description:"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.",actionLabel:"Create your first budget",onAction:()=>{c(null),b(null),v(null),d(!0)}}):null,h?e.jsx(G,{title:"Create budget",submitLabel:S?"Retry assignments":"Create budget",initial:{name:null,max_budget:null,budget_duration_sec:null},error:n.error??u,isPending:n.isPending||B,assignUsers:r.data??[],onSubmit:se,onClose:()=>{b(null),v(null),d(!1)}}):null,y?e.jsx(G,{title:`Edit budget ${z(y)}`,submitLabel:"Save changes",initial:{name:y.name,max_budget:y.max_budget,budget_duration_sec:y.budget_duration_sec},error:o.error,isPending:o.isPending,onSubmit:a=>o.mutate({id:y.budget_id,body:a},{onSuccess:()=>c(null)}),onClose:()=>c(null)},y.budget_id):null,_.length>0?e.jsx(fe,{selectedCount:_.length,allMatching:!1,matchingTotal:null,canSelectAllMatching:!1,onSelectAllMatching:()=>{},onClear:f.clear,children:e.jsx(x,{size:"sm",variant:"danger",onPress:()=>C(!0),children:"Delete"})}):null,L?null:e.jsx(je,{ariaLabel:"Budgets",columns:te,rows:w,getRowKey:Ue,isLoading:F,emptyContent:"No budgets yet. Create one to cap spending.",selectionMode:"multiple",selectedKeys:f.selectedKeys,onSelectionChange:f.onSelectionChange}),U?e.jsx(k,{children:e.jsxs(k.Content,{className:"p-0",children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[e.jsxs("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:["Reset history — ",z(U)]}),e.jsx(x,{size:"sm",variant:"ghost",onPress:()=>s(null),children:"Close"})]}),e.jsx(Ee,{budgetId:U.budget_id})]})}):null,e.jsx(be,{isOpen:A,onOpenChange:C,heading:"Delete budgets",body:`Delete ${_.length} ${_.length===1?"budget":"budgets"}? Users on ${_.length===1?"it":"them"} will no longer be capped.`,confirmLabel:"Delete",isPending:X,error:Q,onConfirm:ee})]})}export{Ke as BudgetsPage}; diff --git a/src/gateway/static/dashboard/assets/ConfirmDialog-lRO7CIis.js b/src/gateway/static/dashboard/assets/ConfirmDialog-gmtoFRlO.js similarity index 92% rename from src/gateway/static/dashboard/assets/ConfirmDialog-lRO7CIis.js rename to src/gateway/static/dashboard/assets/ConfirmDialog-gmtoFRlO.js index 6492b248..81678abc 100644 --- a/src/gateway/static/dashboard/assets/ConfirmDialog-lRO7CIis.js +++ b/src/gateway/static/dashboard/assets/ConfirmDialog-gmtoFRlO.js @@ -1 +1 @@ -import{j as r}from"./tanstack-query-1t81HyiD.js";import{E as j}from"./index-DAnS9oY2.js";import{A as e,B as l}from"./heroui-COmYdDDM.js";function p({isOpen:s,onOpenChange:a,heading:n,body:o,confirmLabel:t,confirmVariant:c="danger",isPending:i,error:d,onConfirm:x}){return r.jsx(e,{isOpen:s,onOpenChange:a,children:s?r.jsx(e.Backdrop,{children:r.jsx(e.Container,{placement:"center",size:"md",children:r.jsxs(e.Dialog,{children:[r.jsx(e.Header,{children:r.jsx(e.Heading,{children:n})}),r.jsxs(e.Body,{className:"flex flex-col gap-4",children:[r.jsx("div",{className:"text-sm text-[var(--otari-muted)]",children:o}),r.jsx(j,{error:d})]}),r.jsxs(e.Footer,{children:[r.jsx(l,{variant:"ghost",isDisabled:i,onPress:()=>a(!1),children:"Cancel"}),r.jsx(l,{variant:c,isPending:i,onPress:x,children:t})]})]})})}):null})}export{p as C}; +import{j as r}from"./tanstack-query-1t81HyiD.js";import{E as j}from"./index-D6WO6K2k.js";import{A as e,B as l}from"./heroui-COmYdDDM.js";function p({isOpen:s,onOpenChange:a,heading:n,body:o,confirmLabel:t,confirmVariant:c="danger",isPending:i,error:d,onConfirm:x}){return r.jsx(e,{isOpen:s,onOpenChange:a,children:s?r.jsx(e.Backdrop,{children:r.jsx(e.Container,{placement:"center",size:"md",children:r.jsxs(e.Dialog,{children:[r.jsx(e.Header,{children:r.jsx(e.Heading,{children:n})}),r.jsxs(e.Body,{className:"flex flex-col gap-4",children:[r.jsx("div",{className:"text-sm text-[var(--otari-muted)]",children:o}),r.jsx(j,{error:d})]}),r.jsxs(e.Footer,{children:[r.jsx(l,{variant:"ghost",isDisabled:i,onPress:()=>a(!1),children:"Cancel"}),r.jsx(l,{variant:c,isPending:i,onPress:x,children:t})]})]})})}):null})}export{p as C}; diff --git a/src/gateway/static/dashboard/assets/DocsPage-omWiBiUs.js b/src/gateway/static/dashboard/assets/DocsPage-wd6etVlE.js similarity index 99% rename from src/gateway/static/dashboard/assets/DocsPage-omWiBiUs.js rename to src/gateway/static/dashboard/assets/DocsPage-wd6etVlE.js index 0d6bed9c..468e3d0c 100644 --- a/src/gateway/static/dashboard/assets/DocsPage-omWiBiUs.js +++ b/src/gateway/static/dashboard/assets/DocsPage-wd6etVlE.js @@ -1,4 +1,4 @@ -import{j as re}from"./tanstack-query-1t81HyiD.js";import{P as gi}from"./index-DAnS9oY2.js";import{d as ct}from"./heroui-COmYdDDM.js";import{g as nr}from"./react-dgEcD0HR.js";function yi(e,t){const n={};return(e[e.length-1]===""?[...e,""]:e).join((n.padRight?" ":"")+","+(n.padLeft===!1?"":" ")).trim()}const ki=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,xi=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,bi={};function ht(e,t){return(bi.jsx?xi:ki).test(e)}const wi=/[ \t\n\f\r]/g;function Si(e){return typeof e=="object"?e.type==="text"?ft(e.value):!1:ft(e)}function ft(e){return e.replace(wi,"")===""}class Ke{constructor(t,n,r){this.normal=n,this.property=t,r&&(this.space=r)}}Ke.prototype.normal={};Ke.prototype.property={};Ke.prototype.space=void 0;function tr(e,t){const n={},r={};for(const i of e)Object.assign(n,i.property),Object.assign(r,i.normal);return new Ke(n,r,t)}function _n(e){return e.toLowerCase()}class ee{constructor(t,n){this.attribute=n,this.property=t}}ee.prototype.attribute="";ee.prototype.booleanish=!1;ee.prototype.boolean=!1;ee.prototype.commaOrSpaceSeparated=!1;ee.prototype.commaSeparated=!1;ee.prototype.defined=!1;ee.prototype.mustUseProperty=!1;ee.prototype.number=!1;ee.prototype.overloadedBoolean=!1;ee.prototype.property="";ee.prototype.spaceSeparated=!1;ee.prototype.space=void 0;let Ci=0;const D=Ie(),Y=Ie(),zn=Ie(),v=Ie(),$=Ie(),Ee=Ie(),te=Ie();function Ie(){return 2**++Ci}const Dn=Object.freeze(Object.defineProperty({__proto__:null,boolean:D,booleanish:Y,commaOrSpaceSeparated:te,commaSeparated:Ee,number:v,overloadedBoolean:zn,spaceSeparated:$},Symbol.toStringTag,{value:"Module"})),pn=Object.keys(Dn);class qn extends ee{constructor(t,n,r,i){let l=-1;if(super(t,n),pt(this,"space",i),typeof r=="number")for(;++l4&&n.slice(0,4)==="data"&&Ai.test(t)){if(t.charAt(4)==="-"){const l=t.slice(5).replace(dt,zi);r="data"+l.charAt(0).toUpperCase()+l.slice(1)}else{const l=t.slice(4);if(!dt.test(l)){let o=l.replace(Ii,_i);o.charAt(0)!=="-"&&(o="-"+o),t="data"+o}}i=qn}return new i(r,t)}function _i(e){return"-"+e.toLowerCase()}function zi(e){return e.charAt(1).toUpperCase()}const Di=tr([rr,vi,lr,ar,sr],"html"),Hn=tr([rr,Ei,lr,ar,sr],"svg");function Li(e){return e.join(" ").trim()}var ze={},dn,mt;function Ri(){if(mt)return dn;mt=1;var e=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,t=/\n/g,n=/^\s*/,r=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,i=/^:\s*/,l=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,o=/^[;\s]*/,a=/^\s+|\s+$/g,s=` +import{j as re}from"./tanstack-query-1t81HyiD.js";import{P as gi}from"./index-D6WO6K2k.js";import{d as ct}from"./heroui-COmYdDDM.js";import{g as nr}from"./react-dgEcD0HR.js";function yi(e,t){const n={};return(e[e.length-1]===""?[...e,""]:e).join((n.padRight?" ":"")+","+(n.padLeft===!1?"":" ")).trim()}const ki=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,xi=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,bi={};function ht(e,t){return(bi.jsx?xi:ki).test(e)}const wi=/[ \t\n\f\r]/g;function Si(e){return typeof e=="object"?e.type==="text"?ft(e.value):!1:ft(e)}function ft(e){return e.replace(wi,"")===""}class Ke{constructor(t,n,r){this.normal=n,this.property=t,r&&(this.space=r)}}Ke.prototype.normal={};Ke.prototype.property={};Ke.prototype.space=void 0;function tr(e,t){const n={},r={};for(const i of e)Object.assign(n,i.property),Object.assign(r,i.normal);return new Ke(n,r,t)}function _n(e){return e.toLowerCase()}class ee{constructor(t,n){this.attribute=n,this.property=t}}ee.prototype.attribute="";ee.prototype.booleanish=!1;ee.prototype.boolean=!1;ee.prototype.commaOrSpaceSeparated=!1;ee.prototype.commaSeparated=!1;ee.prototype.defined=!1;ee.prototype.mustUseProperty=!1;ee.prototype.number=!1;ee.prototype.overloadedBoolean=!1;ee.prototype.property="";ee.prototype.spaceSeparated=!1;ee.prototype.space=void 0;let Ci=0;const D=Ie(),Y=Ie(),zn=Ie(),v=Ie(),$=Ie(),Ee=Ie(),te=Ie();function Ie(){return 2**++Ci}const Dn=Object.freeze(Object.defineProperty({__proto__:null,boolean:D,booleanish:Y,commaOrSpaceSeparated:te,commaSeparated:Ee,number:v,overloadedBoolean:zn,spaceSeparated:$},Symbol.toStringTag,{value:"Module"})),pn=Object.keys(Dn);class qn extends ee{constructor(t,n,r,i){let l=-1;if(super(t,n),pt(this,"space",i),typeof r=="number")for(;++l4&&n.slice(0,4)==="data"&&Ai.test(t)){if(t.charAt(4)==="-"){const l=t.slice(5).replace(dt,zi);r="data"+l.charAt(0).toUpperCase()+l.slice(1)}else{const l=t.slice(4);if(!dt.test(l)){let o=l.replace(Ii,_i);o.charAt(0)!=="-"&&(o="-"+o),t="data"+o}}i=qn}return new i(r,t)}function _i(e){return"-"+e.toLowerCase()}function zi(e){return e.charAt(1).toUpperCase()}const Di=tr([rr,vi,lr,ar,sr],"html"),Hn=tr([rr,Ei,lr,ar,sr],"svg");function Li(e){return e.join(" ").trim()}var ze={},dn,mt;function Ri(){if(mt)return dn;mt=1;var e=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,t=/\n/g,n=/^\s*/,r=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,i=/^:\s*/,l=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,o=/^[;\s]*/,a=/^\s+|\s+$/g,s=` `,u="/",h="*",c="",p="comment",f="declaration";function g(S,y){if(typeof S!="string")throw new TypeError("First argument must be a string");if(!S)return[];y=y||{};var E=1,C=1;function R(z){var I=z.match(t);I&&(E+=I.length);var U=z.lastIndexOf(s);C=~U?z.length-U:C+z.length}function F(){var z={line:E,column:C};return function(I){return I.position=new b(z),j(),I}}function b(z){this.start=z,this.end={line:E,column:C},this.source=y.source}b.prototype.content=S;function M(z){var I=new Error(y.source+":"+E+":"+C+": "+z);if(I.reason=z,I.filename=y.source,I.line=E,I.column=C,I.source=S,!y.silent)throw I}function q(z){var I=z.exec(S);if(I){var U=I[0];return R(U),S=S.slice(U.length),I}}function j(){q(n)}function k(z){var I;for(z=z||[];I=A();)I!==!1&&z.push(I);return z}function A(){var z=F();if(!(u!=S.charAt(0)||h!=S.charAt(1))){for(var I=2;c!=S.charAt(I)&&(h!=S.charAt(I)||u!=S.charAt(I+1));)++I;if(I+=2,c===S.charAt(I-1))return M("End of comment missing");var U=S.slice(2,I-2);return C+=2,R(U),S=S.slice(I),C+=2,z({type:p,comment:U})}}function P(){var z=F(),I=q(r);if(I){if(A(),!q(i))return M("property missing ':'");var U=q(l),K=z({type:f,property:w(I[0].replace(e,c)),value:U?w(U[0].replace(e,c)):c});return q(o),K}}function H(){var z=[];k(z);for(var I;I=P();)I!==!1&&(z.push(I),k(z));return z}return j(),H()}function w(S){return S?S.replace(a,c):c}return dn=g,dn}var gt;function Fi(){if(gt)return ze;gt=1;var e=ze&&ze.__importDefault||function(r){return r&&r.__esModule?r:{default:r}};Object.defineProperty(ze,"__esModule",{value:!0}),ze.default=n;const t=e(Ri());function n(r,i){let l=null;if(!r||typeof r!="string")return l;const o=(0,t.default)(r),a=typeof i=="function";return o.forEach(s=>{if(s.type!=="declaration")return;const{property:u,value:h}=s;a?i(u,h,s):h&&(l=l||{},l[u]=h)}),l}return ze}var Be={},yt;function Oi(){if(yt)return Be;yt=1,Object.defineProperty(Be,"__esModule",{value:!0}),Be.camelCase=void 0;var e=/^--[a-zA-Z0-9_-]+$/,t=/-([a-z])/g,n=/^[^-]+$/,r=/^-(webkit|moz|ms|o|khtml)-/,i=/^-(ms)-/,l=function(u){return!u||n.test(u)||e.test(u)},o=function(u,h){return h.toUpperCase()},a=function(u,h){return"".concat(h,"-")},s=function(u,h){return h===void 0&&(h={}),l(u)?u:(u=u.toLowerCase(),h.reactCompat?u=u.replace(i,a):u=u.replace(r,a),u.replace(t,o))};return Be.camelCase=s,Be}var je,kt;function Mi(){if(kt)return je;kt=1;var e=je&&je.__importDefault||function(i){return i&&i.__esModule?i:{default:i}},t=e(Fi()),n=Oi();function r(i,l){var o={};return!i||typeof i!="string"||(0,t.default)(i,function(a,s){a&&s&&(o[(0,n.camelCase)(a,l)]=s)}),o}return r.default=r,je=r,je}var Ni=Mi();const Bi=nr(Ni),ur=cr("end"),Un=cr("start");function cr(e){return t;function t(n){const r=n&&n.position&&n.position[e]||{};if(typeof r.line=="number"&&r.line>0&&typeof r.column=="number"&&r.column>0)return{line:r.line,column:r.column,offset:typeof r.offset=="number"&&r.offset>-1?r.offset:void 0}}}function ji(e){const t=Un(e),n=ur(e);if(t&&n)return{start:t,end:n}}function Ue(e){return!e||typeof e!="object"?"":"position"in e||"type"in e?xt(e.position):"start"in e||"end"in e?xt(e):"line"in e||"column"in e?Ln(e):""}function Ln(e){return bt(e&&e.line)+":"+bt(e&&e.column)}function xt(e){return Ln(e&&e.start)+"-"+Ln(e&&e.end)}function bt(e){return e&&typeof e=="number"?e:1}class G extends Error{constructor(t,n,r){super(),typeof n=="string"&&(r=n,n=void 0);let i="",l={},o=!1;if(n&&("line"in n&&"column"in n?l={place:n}:"start"in n&&"end"in n?l={place:n}:"type"in n?l={ancestors:[n],place:n.position}:l={...n}),typeof t=="string"?i=t:!l.cause&&t&&(o=!0,i=t.message,l.cause=t),!l.ruleId&&!l.source&&typeof r=="string"){const s=r.indexOf(":");s===-1?l.ruleId=r:(l.source=r.slice(0,s),l.ruleId=r.slice(s+1))}if(!l.place&&l.ancestors&&l.ancestors){const s=l.ancestors[l.ancestors.length-1];s&&(l.place=s.position)}const a=l.place&&"start"in l.place?l.place.start:l.place;this.ancestors=l.ancestors||void 0,this.cause=l.cause||void 0,this.column=a?a.column:void 0,this.fatal=void 0,this.file="",this.message=i,this.line=a?a.line:void 0,this.name=Ue(l.place)||"1:1",this.place=l.place||void 0,this.reason=this.message,this.ruleId=l.ruleId||void 0,this.source=l.source||void 0,this.stack=o&&l.cause&&typeof l.cause.stack=="string"?l.cause.stack:"",this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}}G.prototype.file="";G.prototype.name="";G.prototype.reason="";G.prototype.message="";G.prototype.stack="";G.prototype.column=void 0;G.prototype.line=void 0;G.prototype.ancestors=void 0;G.prototype.cause=void 0;G.prototype.fatal=void 0;G.prototype.place=void 0;G.prototype.ruleId=void 0;G.prototype.source=void 0;const Vn={}.hasOwnProperty,qi=new Map,Hi=/[A-Z]/g,Ui=new Set(["table","tbody","thead","tfoot","tr"]),Vi=new Set(["td","th"]),hr="https://github.com/syntax-tree/hast-util-to-jsx-runtime";function $i(e,t){if(!t||t.Fragment===void 0)throw new TypeError("Expected `Fragment` in options");const n=t.filePath||void 0;let r;if(t.development){if(typeof t.jsxDEV!="function")throw new TypeError("Expected `jsxDEV` in options when `development: true`");r=Zi(n,t.jsxDEV)}else{if(typeof t.jsx!="function")throw new TypeError("Expected `jsx` in production options");if(typeof t.jsxs!="function")throw new TypeError("Expected `jsxs` in production options");r=Ji(n,t.jsx,t.jsxs)}const i={Fragment:t.Fragment,ancestors:[],components:t.components||{},create:r,elementAttributeNameCase:t.elementAttributeNameCase||"react",evaluater:t.createEvaluater?t.createEvaluater():void 0,filePath:n,ignoreInvalidStyle:t.ignoreInvalidStyle||!1,passKeys:t.passKeys!==!1,passNode:t.passNode||!1,schema:t.space==="svg"?Hn:Di,stylePropertyNameCase:t.stylePropertyNameCase||"dom",tableCellAlignToStyle:t.tableCellAlignToStyle!==!1},l=fr(i,e,void 0);return l&&typeof l!="string"?l:i.create(e,i.Fragment,{children:l||void 0},void 0)}function fr(e,t,n){if(t.type==="element")return Wi(e,t,n);if(t.type==="mdxFlowExpression"||t.type==="mdxTextExpression")return Yi(e,t);if(t.type==="mdxJsxFlowElement"||t.type==="mdxJsxTextElement")return Qi(e,t,n);if(t.type==="mdxjsEsm")return Ki(e,t);if(t.type==="root")return Xi(e,t,n);if(t.type==="text")return Gi(e,t)}function Wi(e,t,n){const r=e.schema;let i=r;t.tagName.toLowerCase()==="svg"&&r.space==="html"&&(i=Hn,e.schema=i),e.ancestors.push(t);const l=dr(e,t.tagName,!1),o=eo(e,t);let a=Wn(e,t);return Ui.has(t.tagName)&&(a=a.filter(function(s){return typeof s=="string"?!Si(s):!0})),pr(e,o,l,t),$n(o,a),e.ancestors.pop(),e.schema=r,e.create(t,l,o,n)}function Yi(e,t){if(t.data&&t.data.estree&&e.evaluater){const r=t.data.estree.body[0];return r.type,e.evaluater.evaluateExpression(r.expression)}We(e,t.position)}function Ki(e,t){if(t.data&&t.data.estree&&e.evaluater)return e.evaluater.evaluateProgram(t.data.estree);We(e,t.position)}function Qi(e,t,n){const r=e.schema;let i=r;t.name==="svg"&&r.space==="html"&&(i=Hn,e.schema=i),e.ancestors.push(t);const l=t.name===null?e.Fragment:dr(e,t.name,!0),o=no(e,t),a=Wn(e,t);return pr(e,o,l,t),$n(o,a),e.ancestors.pop(),e.schema=r,e.create(t,l,o,n)}function Xi(e,t,n){const r={};return $n(r,Wn(e,t)),e.create(t,e.Fragment,r,n)}function Gi(e,t){return t.value}function pr(e,t,n,r){typeof n!="string"&&n!==e.Fragment&&e.passNode&&(t.node=r)}function $n(e,t){if(t.length>0){const n=t.length>1?t:t[0];n&&(e.children=n)}}function Ji(e,t,n){return r;function r(i,l,o,a){const u=Array.isArray(o.children)?n:t;return a?u(l,o,a):u(l,o)}}function Zi(e,t){return n;function n(r,i,l,o){const a=Array.isArray(l.children),s=Un(r);return t(i,l,o,a,{columnNumber:s?s.column-1:void 0,fileName:e,lineNumber:s?s.line:void 0},void 0)}}function eo(e,t){const n={};let r,i;for(i in t.properties)if(i!=="children"&&Vn.call(t.properties,i)){const l=to(e,i,t.properties[i]);if(l){const[o,a]=l;e.tableCellAlignToStyle&&o==="align"&&typeof a=="string"&&Vi.has(t.tagName)?r=a:n[o]=a}}if(r){const l=n.style||(n.style={});l[e.stylePropertyNameCase==="css"?"text-align":"textAlign"]=r}return n}function no(e,t){const n={};for(const r of t.attributes)if(r.type==="mdxJsxExpressionAttribute")if(r.data&&r.data.estree&&e.evaluater){const l=r.data.estree.body[0];l.type;const o=l.expression;o.type;const a=o.properties[0];a.type,Object.assign(n,e.evaluater.evaluateExpression(a.argument))}else We(e,t.position);else{const i=r.name;let l;if(r.value&&typeof r.value=="object")if(r.value.data&&r.value.data.estree&&e.evaluater){const a=r.value.data.estree.body[0];a.type,l=e.evaluater.evaluateExpression(a.expression)}else We(e,t.position);else l=r.value===null?!0:r.value;n[i]=l}return n}function Wn(e,t){const n=[];let r=-1;const i=e.passKeys?new Map:qi;for(;++ri?0:i+t:t=t>i?i:t,n=n>0?n:0,r.length<1e4)o=Array.from(r),o.unshift(t,n),e.splice(...o);else for(n&&e.splice(t,n);l0?(ie(e,e.length,0,t),e):t}const Ct={}.hasOwnProperty;function gr(e){const t={};let n=-1;for(;++n13&&n<32||n>126&&n<160||n>55295&&n<57344||n>64975&&n<65008||(n&65535)===65535||(n&65535)===65534||n>1114111?"�":String.fromCodePoint(n)}function ce(e){return e.replace(/[\t\n\r ]+/g," ").replace(/^ | $/g,"").toLowerCase().toUpperCase()}const J=be(/[A-Za-z]/),X=be(/[\dA-Za-z]/),ho=be(/[#-'*+\--9=?A-Z^-~]/);function rn(e){return e!==null&&(e<32||e===127)}const Rn=be(/\d/),fo=be(/[\dA-Fa-f]/),po=be(/[!-/:-@[-`{-~]/);function _(e){return e!==null&&e<-2}function W(e){return e!==null&&(e<0||e===32)}function O(e){return e===-2||e===-1||e===32}const sn=be(new RegExp("\\p{P}|\\p{S}","u")),Te=be(/\s/);function be(e){return t;function t(n){return n!==null&&n>-1&&e.test(String.fromCharCode(n))}}function Fe(e){const t=[];let n=-1,r=0,i=0;for(;++n55295&&l<57344){const a=e.charCodeAt(n+1);l<56320&&a>56319&&a<57344?(o=String.fromCharCode(l,a),i=1):o="�"}else o=String.fromCharCode(l);o&&(t.push(e.slice(r,n),encodeURIComponent(o)),r=n+i+1,o=""),i&&(n+=i,i=0)}return t.join("")+e.slice(r)}function B(e,t,n,r){const i=r?r-1:Number.POSITIVE_INFINITY;let l=0;return o;function o(s){return O(s)?(e.enter(n),a(s)):t(s)}function a(s){return O(s)&&l++o))return;const M=t.events.length;let q=M,j,k;for(;q--;)if(t.events[q][0]==="exit"&&t.events[q][1].type==="chunkFlow"){if(j){k=t.events[q][1].end;break}j=!0}for(y(r),b=M;bC;){const F=n[R];t.containerState=F[1],F[0].exit.call(t,e)}n.length=C}function E(){i.write([null]),l=void 0,i=void 0,t.containerState._closeFlow=void 0}}function xo(e,t,n){return B(e,e.attempt(this.parser.constructs.document,t,n),"linePrefix",this.parser.constructs.disable.null.includes("codeIndented")?void 0:4)}function Le(e){if(e===null||W(e)||Te(e))return 1;if(sn(e))return 2}function un(e,t,n){const r=[];let i=-1;for(;++i1&&e[n][1].end.offset-e[n][1].start.offset>1?2:1;const c={...e[r][1].end},p={...e[n][1].start};Et(c,-s),Et(p,s),o={type:s>1?"strongSequence":"emphasisSequence",start:c,end:{...e[r][1].end}},a={type:s>1?"strongSequence":"emphasisSequence",start:{...e[n][1].start},end:p},l={type:s>1?"strongText":"emphasisText",start:{...e[r][1].end},end:{...e[n][1].start}},i={type:s>1?"strong":"emphasis",start:{...o.start},end:{...a.end}},e[r][1].end={...o.start},e[n][1].start={...a.end},u=[],e[r][1].end.offset-e[r][1].start.offset&&(u=oe(u,[["enter",e[r][1],t],["exit",e[r][1],t]])),u=oe(u,[["enter",i,t],["enter",o,t],["exit",o,t],["enter",l,t]]),u=oe(u,un(t.parser.constructs.insideSpan.null,e.slice(r+1,n),t)),u=oe(u,[["exit",l,t],["enter",a,t],["exit",a,t],["exit",i,t]]),e[n][1].end.offset-e[n][1].start.offset?(h=2,u=oe(u,[["enter",e[n][1],t],["exit",e[n][1],t]])):h=0,ie(e,r-1,n-r+3,u),n=r+u.length-h-2;break}}for(n=-1;++n0&&O(b)?B(e,E,"linePrefix",l+1)(b):E(b)}function E(b){return b===null||_(b)?e.check(Tt,w,R)(b):(e.enter("codeFlowValue"),C(b))}function C(b){return b===null||_(b)?(e.exit("codeFlowValue"),E(b)):(e.consume(b),C)}function R(b){return e.exit("codeFenced"),t(b)}function F(b,M,q){let j=0;return k;function k(I){return b.enter("lineEnding"),b.consume(I),b.exit("lineEnding"),A}function A(I){return b.enter("codeFencedFence"),O(I)?B(b,P,"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(I):P(I)}function P(I){return I===a?(b.enter("codeFencedFenceSequence"),H(I)):q(I)}function H(I){return I===a?(j++,b.consume(I),H):j>=o?(b.exit("codeFencedFenceSequence"),O(I)?B(b,z,"whitespace")(I):z(I)):q(I)}function z(I){return I===null||_(I)?(b.exit("codeFencedFence"),M(I)):q(I)}}}function zo(e,t,n){const r=this;return i;function i(o){return o===null?n(o):(e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),l)}function l(o){return r.parser.lazy[r.now().line]?n(o):t(o)}}const gn={name:"codeIndented",tokenize:Lo},Do={partial:!0,tokenize:Ro};function Lo(e,t,n){const r=this;return i;function i(u){return e.enter("codeIndented"),B(e,l,"linePrefix",5)(u)}function l(u){const h=r.events[r.events.length-1];return h&&h[1].type==="linePrefix"&&h[2].sliceSerialize(h[1],!0).length>=4?o(u):n(u)}function o(u){return u===null?s(u):_(u)?e.attempt(Do,o,s)(u):(e.enter("codeFlowValue"),a(u))}function a(u){return u===null||_(u)?(e.exit("codeFlowValue"),o(u)):(e.consume(u),a)}function s(u){return e.exit("codeIndented"),t(u)}}function Ro(e,t,n){const r=this;return i;function i(o){return r.parser.lazy[r.now().line]?n(o):_(o)?(e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),i):B(e,l,"linePrefix",5)(o)}function l(o){const a=r.events[r.events.length-1];return a&&a[1].type==="linePrefix"&&a[2].sliceSerialize(a[1],!0).length>=4?t(o):_(o)?i(o):n(o)}}const Fo={name:"codeText",previous:Mo,resolve:Oo,tokenize:No};function Oo(e){let t=e.length-4,n=3,r,i;if((e[n][1].type==="lineEnding"||e[n][1].type==="space")&&(e[t][1].type==="lineEnding"||e[t][1].type==="space")){for(r=n;++r=this.left.length+this.right.length)throw new RangeError("Cannot access index `"+t+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return tthis.left.length?this.right.slice(this.right.length-r+this.left.length,this.right.length-t+this.left.length).reverse():this.left.slice(t).concat(this.right.slice(this.right.length-r+this.left.length).reverse())}splice(t,n,r){const i=n||0;this.setCursor(Math.trunc(t));const l=this.right.splice(this.right.length-i,Number.POSITIVE_INFINITY);return r&&qe(this.left,r),l.reverse()}pop(){return this.setCursor(Number.POSITIVE_INFINITY),this.left.pop()}push(t){this.setCursor(Number.POSITIVE_INFINITY),this.left.push(t)}pushMany(t){this.setCursor(Number.POSITIVE_INFINITY),qe(this.left,t)}unshift(t){this.setCursor(0),this.right.push(t)}unshiftMany(t){this.setCursor(0),qe(this.right,t.reverse())}setCursor(t){if(!(t===this.left.length||t>this.left.length&&this.right.length===0||t<0&&this.left.length===0))if(t=4?t(o):e.interrupt(r.parser.constructs.flow,n,t)(o)}}function Sr(e,t,n,r,i,l,o,a,s){const u=s||Number.POSITIVE_INFINITY;let h=0;return c;function c(y){return y===60?(e.enter(r),e.enter(i),e.enter(l),e.consume(y),e.exit(l),p):y===null||y===32||y===41||rn(y)?n(y):(e.enter(r),e.enter(o),e.enter(a),e.enter("chunkString",{contentType:"string"}),w(y))}function p(y){return y===62?(e.enter(l),e.consume(y),e.exit(l),e.exit(i),e.exit(r),t):(e.enter(a),e.enter("chunkString",{contentType:"string"}),f(y))}function f(y){return y===62?(e.exit("chunkString"),e.exit(a),p(y)):y===null||y===60||_(y)?n(y):(e.consume(y),y===92?g:f)}function g(y){return y===60||y===62||y===92?(e.consume(y),f):f(y)}function w(y){return!h&&(y===null||y===41||W(y))?(e.exit("chunkString"),e.exit(a),e.exit(o),e.exit(r),t(y)):h999||f===null||f===91||f===93&&!s||f===94&&!a&&"_hiddenFootnoteSupport"in o.parser.constructs?n(f):f===93?(e.exit(l),e.enter(i),e.consume(f),e.exit(i),e.exit(r),t):_(f)?(e.enter("lineEnding"),e.consume(f),e.exit("lineEnding"),h):(e.enter("chunkString",{contentType:"string"}),c(f))}function c(f){return f===null||f===91||f===93||_(f)||a++>999?(e.exit("chunkString"),h(f)):(e.consume(f),s||(s=!O(f)),f===92?p:c)}function p(f){return f===91||f===92||f===93?(e.consume(f),a++,c):c(f)}}function vr(e,t,n,r,i,l){let o;return a;function a(p){return p===34||p===39||p===40?(e.enter(r),e.enter(i),e.consume(p),e.exit(i),o=p===40?41:p,s):n(p)}function s(p){return p===o?(e.enter(i),e.consume(p),e.exit(i),e.exit(r),t):(e.enter(l),u(p))}function u(p){return p===o?(e.exit(l),s(o)):p===null?n(p):_(p)?(e.enter("lineEnding"),e.consume(p),e.exit("lineEnding"),B(e,u,"linePrefix")):(e.enter("chunkString",{contentType:"string"}),h(p))}function h(p){return p===o||p===null||_(p)?(e.exit("chunkString"),u(p)):(e.consume(p),p===92?c:h)}function c(p){return p===o||p===92?(e.consume(p),h):h(p)}}function Ve(e,t){let n;return r;function r(i){return _(i)?(e.enter("lineEnding"),e.consume(i),e.exit("lineEnding"),n=!0,r):O(i)?B(e,r,n?"linePrefix":"lineSuffix")(i):t(i)}}const Wo={name:"definition",tokenize:Ko},Yo={partial:!0,tokenize:Qo};function Ko(e,t,n){const r=this;let i;return l;function l(f){return e.enter("definition"),o(f)}function o(f){return Cr.call(r,e,a,n,"definitionLabel","definitionLabelMarker","definitionLabelString")(f)}function a(f){return i=ce(r.sliceSerialize(r.events[r.events.length-1][1]).slice(1,-1)),f===58?(e.enter("definitionMarker"),e.consume(f),e.exit("definitionMarker"),s):n(f)}function s(f){return W(f)?Ve(e,u)(f):u(f)}function u(f){return Sr(e,h,n,"definitionDestination","definitionDestinationLiteral","definitionDestinationLiteralMarker","definitionDestinationRaw","definitionDestinationString")(f)}function h(f){return e.attempt(Yo,c,c)(f)}function c(f){return O(f)?B(e,p,"whitespace")(f):p(f)}function p(f){return f===null||_(f)?(e.exit("definition"),r.parser.defined.push(i),t(f)):n(f)}}function Qo(e,t,n){return r;function r(a){return W(a)?Ve(e,i)(a):n(a)}function i(a){return vr(e,l,n,"definitionTitle","definitionTitleMarker","definitionTitleString")(a)}function l(a){return O(a)?B(e,o,"whitespace")(a):o(a)}function o(a){return a===null||_(a)?t(a):n(a)}}const Xo={name:"hardBreakEscape",tokenize:Go};function Go(e,t,n){return r;function r(l){return e.enter("hardBreakEscape"),e.consume(l),i}function i(l){return _(l)?(e.exit("hardBreakEscape"),t(l)):n(l)}}const Jo={name:"headingAtx",resolve:Zo,tokenize:el};function Zo(e,t){let n=e.length-2,r=3,i,l;return e[r][1].type==="whitespace"&&(r+=2),n-2>r&&e[n][1].type==="whitespace"&&(n-=2),e[n][1].type==="atxHeadingSequence"&&(r===n-1||n-4>r&&e[n-2][1].type==="whitespace")&&(n-=r+1===n?2:4),n>r&&(i={type:"atxHeadingText",start:e[r][1].start,end:e[n][1].end},l={type:"chunkText",start:e[r][1].start,end:e[n][1].end,contentType:"text"},ie(e,r,n-r+1,[["enter",i,t],["enter",l,t],["exit",l,t],["exit",i,t]])),e}function el(e,t,n){let r=0;return i;function i(h){return e.enter("atxHeading"),l(h)}function l(h){return e.enter("atxHeadingSequence"),o(h)}function o(h){return h===35&&r++<6?(e.consume(h),o):h===null||W(h)?(e.exit("atxHeadingSequence"),a(h)):n(h)}function a(h){return h===35?(e.enter("atxHeadingSequence"),s(h)):h===null||_(h)?(e.exit("atxHeading"),t(h)):O(h)?B(e,a,"whitespace")(h):(e.enter("atxHeadingText"),u(h))}function s(h){return h===35?(e.consume(h),s):(e.exit("atxHeadingSequence"),a(h))}function u(h){return h===null||h===35||W(h)?(e.exit("atxHeadingText"),a(h)):(e.consume(h),u)}}const nl=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"],At=["pre","script","style","textarea"],tl={concrete:!0,name:"htmlFlow",resolveTo:ol,tokenize:ll},rl={partial:!0,tokenize:sl},il={partial:!0,tokenize:al};function ol(e){let t=e.length;for(;t--&&!(e[t][0]==="enter"&&e[t][1].type==="htmlFlow"););return t>1&&e[t-2][1].type==="linePrefix"&&(e[t][1].start=e[t-2][1].start,e[t+1][1].start=e[t-2][1].start,e.splice(t-2,2)),e}function ll(e,t,n){const r=this;let i,l,o,a,s;return u;function u(m){return h(m)}function h(m){return e.enter("htmlFlow"),e.enter("htmlFlowData"),e.consume(m),c}function c(m){return m===33?(e.consume(m),p):m===47?(e.consume(m),l=!0,w):m===63?(e.consume(m),i=3,r.interrupt?t:d):J(m)?(e.consume(m),o=String.fromCharCode(m),S):n(m)}function p(m){return m===45?(e.consume(m),i=2,f):m===91?(e.consume(m),i=5,a=0,g):J(m)?(e.consume(m),i=4,r.interrupt?t:d):n(m)}function f(m){return m===45?(e.consume(m),r.interrupt?t:d):n(m)}function g(m){const se="CDATA[";return m===se.charCodeAt(a++)?(e.consume(m),a===se.length?r.interrupt?t:P:g):n(m)}function w(m){return J(m)?(e.consume(m),o=String.fromCharCode(m),S):n(m)}function S(m){if(m===null||m===47||m===62||W(m)){const se=m===47,we=o.toLowerCase();return!se&&!l&&At.includes(we)?(i=1,r.interrupt?t(m):P(m)):nl.includes(o.toLowerCase())?(i=6,se?(e.consume(m),y):r.interrupt?t(m):P(m)):(i=7,r.interrupt&&!r.parser.lazy[r.now().line]?n(m):l?E(m):C(m))}return m===45||X(m)?(e.consume(m),o+=String.fromCharCode(m),S):n(m)}function y(m){return m===62?(e.consume(m),r.interrupt?t:P):n(m)}function E(m){return O(m)?(e.consume(m),E):k(m)}function C(m){return m===47?(e.consume(m),k):m===58||m===95||J(m)?(e.consume(m),R):O(m)?(e.consume(m),C):k(m)}function R(m){return m===45||m===46||m===58||m===95||X(m)?(e.consume(m),R):F(m)}function F(m){return m===61?(e.consume(m),b):O(m)?(e.consume(m),F):C(m)}function b(m){return m===null||m===60||m===61||m===62||m===96?n(m):m===34||m===39?(e.consume(m),s=m,M):O(m)?(e.consume(m),b):q(m)}function M(m){return m===s?(e.consume(m),s=null,j):m===null||_(m)?n(m):(e.consume(m),M)}function q(m){return m===null||m===34||m===39||m===47||m===60||m===61||m===62||m===96||W(m)?F(m):(e.consume(m),q)}function j(m){return m===47||m===62||O(m)?C(m):n(m)}function k(m){return m===62?(e.consume(m),A):n(m)}function A(m){return m===null||_(m)?P(m):O(m)?(e.consume(m),A):n(m)}function P(m){return m===45&&i===2?(e.consume(m),U):m===60&&i===1?(e.consume(m),K):m===62&&i===4?(e.consume(m),ae):m===63&&i===3?(e.consume(m),d):m===93&&i===5?(e.consume(m),pe):_(m)&&(i===6||i===7)?(e.exit("htmlFlowData"),e.check(rl,de,H)(m)):m===null||_(m)?(e.exit("htmlFlowData"),H(m)):(e.consume(m),P)}function H(m){return e.check(il,z,de)(m)}function z(m){return e.enter("lineEnding"),e.consume(m),e.exit("lineEnding"),I}function I(m){return m===null||_(m)?H(m):(e.enter("htmlFlowData"),P(m))}function U(m){return m===45?(e.consume(m),d):P(m)}function K(m){return m===47?(e.consume(m),o="",le):P(m)}function le(m){if(m===62){const se=o.toLowerCase();return At.includes(se)?(e.consume(m),ae):P(m)}return J(m)&&o.length<8?(e.consume(m),o+=String.fromCharCode(m),le):P(m)}function pe(m){return m===93?(e.consume(m),d):P(m)}function d(m){return m===62?(e.consume(m),ae):m===45&&i===2?(e.consume(m),d):P(m)}function ae(m){return m===null||_(m)?(e.exit("htmlFlowData"),de(m)):(e.consume(m),ae)}function de(m){return e.exit("htmlFlow"),t(m)}}function al(e,t,n){const r=this;return i;function i(o){return _(o)?(e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),l):n(o)}function l(o){return r.parser.lazy[r.now().line]?n(o):t(o)}}function sl(e,t,n){return r;function r(i){return e.enter("lineEnding"),e.consume(i),e.exit("lineEnding"),e.attempt(Qe,t,n)}}const ul={name:"htmlText",tokenize:cl};function cl(e,t,n){const r=this;let i,l,o;return a;function a(d){return e.enter("htmlText"),e.enter("htmlTextData"),e.consume(d),s}function s(d){return d===33?(e.consume(d),u):d===47?(e.consume(d),F):d===63?(e.consume(d),C):J(d)?(e.consume(d),q):n(d)}function u(d){return d===45?(e.consume(d),h):d===91?(e.consume(d),l=0,g):J(d)?(e.consume(d),E):n(d)}function h(d){return d===45?(e.consume(d),f):n(d)}function c(d){return d===null?n(d):d===45?(e.consume(d),p):_(d)?(o=c,K(d)):(e.consume(d),c)}function p(d){return d===45?(e.consume(d),f):c(d)}function f(d){return d===62?U(d):d===45?p(d):c(d)}function g(d){const ae="CDATA[";return d===ae.charCodeAt(l++)?(e.consume(d),l===ae.length?w:g):n(d)}function w(d){return d===null?n(d):d===93?(e.consume(d),S):_(d)?(o=w,K(d)):(e.consume(d),w)}function S(d){return d===93?(e.consume(d),y):w(d)}function y(d){return d===62?U(d):d===93?(e.consume(d),y):w(d)}function E(d){return d===null||d===62?U(d):_(d)?(o=E,K(d)):(e.consume(d),E)}function C(d){return d===null?n(d):d===63?(e.consume(d),R):_(d)?(o=C,K(d)):(e.consume(d),C)}function R(d){return d===62?U(d):C(d)}function F(d){return J(d)?(e.consume(d),b):n(d)}function b(d){return d===45||X(d)?(e.consume(d),b):M(d)}function M(d){return _(d)?(o=M,K(d)):O(d)?(e.consume(d),M):U(d)}function q(d){return d===45||X(d)?(e.consume(d),q):d===47||d===62||W(d)?j(d):n(d)}function j(d){return d===47?(e.consume(d),U):d===58||d===95||J(d)?(e.consume(d),k):_(d)?(o=j,K(d)):O(d)?(e.consume(d),j):U(d)}function k(d){return d===45||d===46||d===58||d===95||X(d)?(e.consume(d),k):A(d)}function A(d){return d===61?(e.consume(d),P):_(d)?(o=A,K(d)):O(d)?(e.consume(d),A):j(d)}function P(d){return d===null||d===60||d===61||d===62||d===96?n(d):d===34||d===39?(e.consume(d),i=d,H):_(d)?(o=P,K(d)):O(d)?(e.consume(d),P):(e.consume(d),z)}function H(d){return d===i?(e.consume(d),i=void 0,I):d===null?n(d):_(d)?(o=H,K(d)):(e.consume(d),H)}function z(d){return d===null||d===34||d===39||d===60||d===61||d===96?n(d):d===47||d===62||W(d)?j(d):(e.consume(d),z)}function I(d){return d===47||d===62||W(d)?j(d):n(d)}function U(d){return d===62?(e.consume(d),e.exit("htmlTextData"),e.exit("htmlText"),t):n(d)}function K(d){return e.exit("htmlTextData"),e.enter("lineEnding"),e.consume(d),e.exit("lineEnding"),le}function le(d){return O(d)?B(e,pe,"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(d):pe(d)}function pe(d){return e.enter("htmlTextData"),o(d)}}const Qn={name:"labelEnd",resolveAll:dl,resolveTo:ml,tokenize:gl},hl={tokenize:yl},fl={tokenize:kl},pl={tokenize:xl};function dl(e){let t=-1;const n=[];for(;++t=3&&(u===null||_(u))?(e.exit("thematicBreak"),t(u)):n(u)}function s(u){return u===i?(e.consume(u),r++,s):(e.exit("thematicBreakSequence"),O(u)?B(e,a,"whitespace")(u):a(u))}}const Z={continuation:{tokenize:Pl},exit:zl,name:"list",tokenize:Al},Tl={partial:!0,tokenize:Dl},Il={partial:!0,tokenize:_l};function Al(e,t,n){const r=this,i=r.events[r.events.length-1];let l=i&&i[1].type==="linePrefix"?i[2].sliceSerialize(i[1],!0).length:0,o=0;return a;function a(f){const g=r.containerState.type||(f===42||f===43||f===45?"listUnordered":"listOrdered");if(g==="listUnordered"?!r.containerState.marker||f===r.containerState.marker:Rn(f)){if(r.containerState.type||(r.containerState.type=g,e.enter(g,{_container:!0})),g==="listUnordered")return e.enter("listItemPrefix"),f===42||f===45?e.check(tn,n,u)(f):u(f);if(!r.interrupt||f===49)return e.enter("listItemPrefix"),e.enter("listItemValue"),s(f)}return n(f)}function s(f){return Rn(f)&&++o<10?(e.consume(f),s):(!r.interrupt||o<2)&&(r.containerState.marker?f===r.containerState.marker:f===41||f===46)?(e.exit("listItemValue"),u(f)):n(f)}function u(f){return e.enter("listItemMarker"),e.consume(f),e.exit("listItemMarker"),r.containerState.marker=r.containerState.marker||f,e.check(Qe,r.interrupt?n:h,e.attempt(Tl,p,c))}function h(f){return r.containerState.initialBlankLine=!0,l++,p(f)}function c(f){return O(f)?(e.enter("listItemPrefixWhitespace"),e.consume(f),e.exit("listItemPrefixWhitespace"),p):n(f)}function p(f){return r.containerState.size=l+r.sliceSerialize(e.exit("listItemPrefix"),!0).length,t(f)}}function Pl(e,t,n){const r=this;return r.containerState._closeFlow=void 0,e.check(Qe,i,l);function i(a){return r.containerState.furtherBlankLines=r.containerState.furtherBlankLines||r.containerState.initialBlankLine,B(e,t,"listItemIndent",r.containerState.size+1)(a)}function l(a){return r.containerState.furtherBlankLines||!O(a)?(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,o(a)):(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,e.attempt(Il,t,o)(a))}function o(a){return r.containerState._closeFlow=!0,r.interrupt=void 0,B(e,e.attempt(Z,t,n),"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(a)}}function _l(e,t,n){const r=this;return B(e,i,"listItemIndent",r.containerState.size+1);function i(l){const o=r.events[r.events.length-1];return o&&o[1].type==="listItemIndent"&&o[2].sliceSerialize(o[1],!0).length===r.containerState.size?t(l):n(l)}}function zl(e){e.exit(this.containerState.type)}function Dl(e,t,n){const r=this;return B(e,i,"listItemPrefixWhitespace",r.parser.constructs.disable.null.includes("codeIndented")?void 0:5);function i(l){const o=r.events[r.events.length-1];return!O(l)&&o&&o[1].type==="listItemPrefixWhitespace"?t(l):n(l)}}const Pt={name:"setextUnderline",resolveTo:Ll,tokenize:Rl};function Ll(e,t){let n=e.length,r,i,l;for(;n--;)if(e[n][0]==="enter"){if(e[n][1].type==="content"){r=n;break}e[n][1].type==="paragraph"&&(i=n)}else e[n][1].type==="content"&&e.splice(n,1),!l&&e[n][1].type==="definition"&&(l=n);const o={type:"setextHeading",start:{...e[r][1].start},end:{...e[e.length-1][1].end}};return e[i][1].type="setextHeadingText",l?(e.splice(i,0,["enter",o,t]),e.splice(l+1,0,["exit",e[r][1],t]),e[r][1].end={...e[l][1].end}):e[r][1]=o,e.push(["exit",o,t]),e}function Rl(e,t,n){const r=this;let i;return l;function l(u){let h=r.events.length,c;for(;h--;)if(r.events[h][1].type!=="lineEnding"&&r.events[h][1].type!=="linePrefix"&&r.events[h][1].type!=="content"){c=r.events[h][1].type==="paragraph";break}return!r.parser.lazy[r.now().line]&&(r.interrupt||c)?(e.enter("setextHeadingLine"),i=u,o(u)):n(u)}function o(u){return e.enter("setextHeadingLineSequence"),a(u)}function a(u){return u===i?(e.consume(u),a):(e.exit("setextHeadingLineSequence"),O(u)?B(e,s,"lineSuffix")(u):s(u))}function s(u){return u===null||_(u)?(e.exit("setextHeadingLine"),t(u)):n(u)}}const Fl={tokenize:Ol};function Ol(e){const t=this,n=e.attempt(Qe,r,e.attempt(this.parser.constructs.flowInitial,i,B(e,e.attempt(this.parser.constructs.flow,i,e.attempt(qo,i)),"linePrefix")));return n;function r(l){if(l===null){e.consume(l);return}return e.enter("lineEndingBlank"),e.consume(l),e.exit("lineEndingBlank"),t.currentConstruct=void 0,n}function i(l){if(l===null){e.consume(l);return}return e.enter("lineEnding"),e.consume(l),e.exit("lineEnding"),t.currentConstruct=void 0,n}}const Ml={resolveAll:Tr()},Nl=Er("string"),Bl=Er("text");function Er(e){return{resolveAll:Tr(e==="text"?jl:void 0),tokenize:t};function t(n){const r=this,i=this.parser.constructs[e],l=n.attempt(i,o,a);return o;function o(h){return u(h)?l(h):a(h)}function a(h){if(h===null){n.consume(h);return}return n.enter("data"),n.consume(h),s}function s(h){return u(h)?(n.exit("data"),l(h)):(n.consume(h),s)}function u(h){if(h===null)return!0;const c=i[h];let p=-1;if(c)for(;++p-1){const a=o[0];typeof a=="string"?o[0]=a.slice(r):o.shift()}l>0&&o.push(e[i].slice(0,l))}return o}function Zl(e,t){let n=-1;const r=[];let i;for(;++n0){const ue=L.tokenStack[L.tokenStack.length-1];(ue[1]||zt).call(L,void 0,ue[0])}for(T.position={start:xe(x.length>0?x[0][1].start:{line:1,column:1,offset:0}),end:xe(x.length>0?x[x.length-2][1].end:{line:1,column:1,offset:0})},V=-1;++V=h)return o.format(Math.round(r/h),l);return o.format(r,"second")}function ue(t){if(!t.expires_at)return!1;const a=new Date(t.expires_at).getTime();return!Number.isNaN(a)&&aString(n).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())}T${r(a.getHours())}:${r(a.getMinutes())}`}const xe=t=>(t??"").startsWith("apikey-"),F=t=>t.key_name??t.id,he=t=>t.id;function K({label:t,value:a,multiline:r=!1,fieldRef:n}){const d=i.useRef(null),o=n??d,[l,h]=i.useState(!1),[p,g]=i.useState(!1),c=async()=>{var m,y,x;(m=o.current)==null||m.focus(),(y=o.current)==null||y.select();try{if((x=navigator.clipboard)!=null&&x.writeText){await navigator.clipboard.writeText(a),h(!0),g(!1),window.setTimeout(()=>h(!1),2e3);return}}catch{}g(!0)},u="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(f,{size:"sm",variant:"outline",onPress:c,children:l?"Copied":"Copy"})]}),r?e.jsx("textarea",{ref:o,readOnly:!0,rows:a.split(` +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as i}from"./react-dgEcD0HR.js";import{O as J,Q as H,S as X,T as Z,P as ee,E as K,z as te,U as se,p as q,M as ae}from"./index-D6WO6K2k.js";import{u as re,r as ne,B as ie}from"./tableSelection-BJDASjEj.js";import{C as le}from"./ConfirmDialog-gmtoFRlO.js";import{D as oe}from"./DataTable-DuDxGlJc.js";import{F as M}from"./Field-CBU9MRjz.js";import{a as z,M as V}from"./ModelScopeControl-W-k32mVk.js";import{U as ce}from"./UserComboBox-DoloPF6p.js";import{g as I,B as f,d as E}from"./heroui-COmYdDDM.js";function $(t){if(!t)return"—";const a=new Date(t);return Number.isNaN(a.getTime())?"—":a.toLocaleDateString()}function de(t){if(!t)return null;const a=new Date(t).getTime();if(Number.isNaN(a))return null;const r=Math.round((a-Date.now())/1e3),n=Math.abs(r),d=[["day",86400],["hour",3600],["minute",60]],o=new Intl.RelativeTimeFormat(void 0,{numeric:"auto"});for(const[l,h]of d)if(n>=h)return o.format(Math.round(r/h),l);return o.format(r,"second")}function ue(t){if(!t.expires_at)return!1;const a=new Date(t.expires_at).getTime();return!Number.isNaN(a)&&aString(n).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())}T${r(a.getHours())}:${r(a.getMinutes())}`}const xe=t=>(t??"").startsWith("apikey-"),O=t=>t.key_name??t.id,he=t=>t.id;function F({label:t,value:a,multiline:r=!1,fieldRef:n}){const d=i.useRef(null),o=n??d,[l,h]=i.useState(!1),[p,g]=i.useState(!1),c=async()=>{var m,y,x;(m=o.current)==null||m.focus(),(y=o.current)==null||y.select();try{if((x=navigator.clipboard)!=null&&x.writeText){await navigator.clipboard.writeText(a),h(!0),g(!1),window.setTimeout(()=>h(!1),2e3);return}}catch{}g(!0)},u="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(f,{size:"sm",variant:"outline",onPress:c,children:l?"Copied":"Copy"})]}),r?e.jsx("textarea",{ref:o,readOnly:!0,rows:a.split(` `).length,value:a,onFocus:m=>m.currentTarget.select(),className:`${u} resize-none whitespace-pre`}):e.jsx("input",{ref:o,readOnly:!0,value:a,onFocus:m=>m.currentTarget.select(),className:u}),e.jsx("span",{"aria-live":"polite",className:"text-xs text-green-700",children:l?"Copied to clipboard.":""}),p?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Selected. Press Ctrl/Cmd-C to copy."}):null]})}function fe({title:t,result:a,onClose:r}){const n=i.useRef(null),d=i.useRef(null),o=typeof window<"u"?window.location.origin:"",l=a.key;i.useEffect(()=>{var c,u;(c=d.current)==null||c.focus(),(u=d.current)==null||u.select()},[]);const h=c=>{var x;if(c.key!=="Tab")return;const u=(x=n.current)==null?void 0:x.querySelectorAll('button, input, textarea, a[href], [tabindex]:not([tabindex="-1"])');if(!u||u.length===0)return;const m=u[0],y=u[u.length-1];c.shiftKey&&document.activeElement===m?(c.preventDefault(),y.focus()):!c.shiftKey&&document.activeElement===y&&(c.preventDefault(),m.focus())},p=[`curl ${o}/v1/chat/completions \\`,` -H "Otari-Key: ${l}" \\`,' -H "Content-Type: application/json" \\',` -d '{"model": "your-model", "messages": [{"role": "user", "content": "Hello"}]}'`].join(` `),g=["from openai import OpenAI","",`client = OpenAI(base_url="${o}/v1", api_key="${l}")`,"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:n,role:"dialog","aria-modal":"true","aria-labelledby":"reveal-title",onKeyDown:h,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(ae,{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: ",L(a.allowed_models).text,"."]}),e.jsx(K,{label:"Secret key",value:l,fieldRef:d}),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(K,{label:"curl",value:p,multiline:!0}),e.jsx(K,{label:"Python (OpenAI SDK)",value:g,multiline:!0})]}),e.jsx("div",{className:"flex justify-end",children:e.jsx(f,{variant:"primary",onPress:r,children:"I’ve saved this key"})})]})})}function U({trigger:t,message:a,confirmLabel:r,isPending:n,onConfirm:d}){const[o,l]=i.useState(!1);return o?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:a}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(f,{size:"sm",variant:"danger",isDisabled:n,onPress:d,children:r}),e.jsx(f,{size:"sm",variant:"ghost",isDisabled:n,onPress:()=>l(!1),children:"Cancel"})]})]}):e.jsx(f,{size:"sm",variant:"danger-soft",onPress:()=>l(!0),children:t})}function W({userId:t,users:a}){const r=t.trim();if(r==="")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 n=a.find(l=>l.user_id===r);if(!n)return e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["New user ",e.jsx("code",{children:r})," starts unrestricted, so this key may allow any model."]});const{text:d}=L(n.allowed_models),o=n.allowed_models&&n.allowed_models.length>0?n.allowed_models.join(", "):null;return e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Owner ",e.jsx("code",{children:r})," allows ",e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:d.toLowerCase()}),o?e.jsxs(e.Fragment,{children:[" (",e.jsx("span",{className:"font-mono",children:o}),")"]}):null,". This key inherits that, or narrows within it."]})}function Q({checked:t,onChange:a}){return e.jsxs("label",{className:"flex items-start gap-2 rounded-lg border border-[var(--otari-line)] p-3 text-sm",children:[e.jsx("input",{type:"checkbox",checked:t,onChange:r=>a(r.target.checked),className:"mt-0.5 h-4 w-4 accent-[var(--otari-brand)]","aria-label":"Exempt this key from budget"}),e.jsxs("span",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:"Exempt from budget"}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Requests on this key are logged with their cost but never counted toward the owner's budget or spend, and never blocked by it."})]})]})}function Y({value:t,onChange:a}){const r="key-reject-user-mismatch";return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsxs("label",{htmlFor:r,className:"text-sm font-medium text-[var(--otari-ink)]",children:["Mismatched ",e.jsx("code",{children:"user"})," field"]}),e.jsxs("select",{id:r,value:t===null?"inherit":t?"reject":"accept",onChange:n=>a(n.target.value==="inherit"?null:n.target.value==="reject"),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:"inherit",children:"Use the deployment setting (default)"}),e.jsx("option",{value:"reject",children:"Always reject (403)"}),e.jsx("option",{value:"accept",children:"Always accept"})]}),e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["What happens when a request on this key names a different ",e.jsx("code",{children:"user"})," than its owner. Accept it for clients that send telemetry there rather than an identity, such as Claude Code. Spend binds to this key's owner either way."]})]})}function pe({onClose:t,onCreated:a}){const r=se(),n=q(),[d,o]=i.useState(""),[l,h]=i.useState(""),[p,g]=i.useState(!1),[c,u]=i.useState(""),[m,y]=i.useState(null),[x,N]=i.useState(!1),[v,k]=i.useState(null),[b,_]=i.useState(!0),P=l!==""&&new Date(l).getTime(){if(r.isPending||!b||A)return;const j={key_name:d.trim()||null,user_id:c.trim(),expires_at:l?new Date(l).toISOString():null,allowed_models:m,exclude_from_budget:x,reject_user_mismatch:v};r.mutate(j,{onSuccess:w=>{a(w),t()}})};return e.jsx(M,{children:e.jsxs(M.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(O,{error:r.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(E,{label:"Name",value:d,onChange:o,placeholder:"ci-bot",autoFocus:!0,description:"A label to recognize this key later."}),e.jsx(E,{label:"Expires (optional)",value:l,onChange:h,type:"datetime-local",description:P?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(ce,{value:c,onChange:u,users:n.data??[]}),e.jsx("button",{type:"button",className:"self-start text-xs font-medium text-[var(--otari-brand-dark)]",onClick:()=>g(j=>!j),children:p?"Hide advanced":"Advanced"}),p?e.jsxs("div",{className:"flex flex-col gap-4 rounded-lg border border-[var(--otari-line)] p-4",children:[e.jsx(W,{userId:c,users:n.data??[]}),e.jsx(V,{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:(j,w)=>{y(j),_(w)}}),e.jsx(Q,{checked:x,onChange:N}),e.jsx(Y,{value:v,onChange:k})]}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(f,{variant:"primary",isDisabled:r.isPending||!b||A,onPress:C,children:r.isPending?"Creating…":"Create key"}),e.jsx(f,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function ge({apiKey:t,onClose:a}){const r=H(),n=q(),[d,o]=i.useState(t.key_name??""),[l,h]=i.useState(me(t.expires_at)),[p,g]=i.useState(t.allowed_models),[c,u]=i.useState(t.exclude_from_budget),[m,y]=i.useState(t.reject_user_mismatch),[x,N]=i.useState(!0),v=()=>{r.isPending||!x||r.mutate({id:t.id,body:{key_name:d.trim()||null,expires_at:l?new Date(l).toISOString():null,allowed_models:p,exclude_from_budget:c,reject_user_mismatch:m}},{onSuccess:a})};return e.jsx(M,{children:e.jsxs(M.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(O,{error:r.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(E,{label:"Name",value:d,onChange:o,placeholder:"ci-bot"}),e.jsx(E,{label:"Expires",value:l,onChange:h,type:"datetime-local",description:"Blank clears the expiry."})]}),t.user_id?e.jsx(W,{userId:t.user_id,users:n.data??[]}):null,e.jsx(V,{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:(k,b)=>{g(k),N(b)}}),e.jsx(Q,{checked:c,onChange:u}),e.jsx(Y,{value:m,onChange:y}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(f,{variant:"primary",isDisabled:r.isPending||!x,onPress:v,children:r.isPending?"Saving…":"Save changes"}),e.jsx(f,{variant:"ghost",onPress:a,children:"Cancel"})]})]})})}function ye({apiKey:t}){return t.is_active?ue(t)?e.jsx(I,{size:"sm",color:"warning",children:"Expired"}):e.jsx(I,{size:"sm",color:"accent",children:"Active"}):e.jsx(I,{size:"sm",color:"default",children:"Disabled"})}function je({allowed:t}){const{text:a,tone:r}=L(t),n=r==="danger"?"text-red-700 font-medium":r==="muted"?"text-[var(--otari-muted)]":"text-[var(--otari-brand-dark)] font-medium",d=t&&t.length>0?t.join(", "):void 0;return e.jsx("span",{className:`text-xs ${n}`,title:d,children:a})}function De(){const t=J(),a=H(),r=X(),n=Z(),[d,o]=i.useState(!1),[l,h]=i.useState(null),[p,g]=i.useState(null),c=t.data??[],u=t.isLoading,m=c.find(s=>s.id===l)??null,y=!u&&c.length===0&&!d,x=re(),[N,v]=i.useState(!1),[k,b]=i.useState(void 0),[_,P]=i.useState(!1),A=c.map(s=>s.id),C=ne(x.selectedKeys,A),j=c.filter(s=>C.includes(s.id)),w=i.useCallback((s,S)=>a.mutate({id:s.id,body:{is_active:S}}),[a.mutate]),z=i.useCallback(s=>r.mutate(s.id,{onSuccess:S=>g({title:`New secret for ${F(s)}`,result:S})}),[r.mutate]),T=async(s,S,R)=>{P(!0),b(void 0);try{for(const B of s)await S(B);x.clear(),R==null||R()}catch(B){b(B)}finally{P(!1)}},G=i.useMemo(()=>[{id:"name",header:"Name",isRowHeader:!0,cell:s=>e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:s.key_name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx(je,{allowed:s.allowed_models}),s.exclude_from_budget?e.jsx("span",{className:"inline-flex items-center rounded-full border border-[var(--otari-line)] bg-[var(--otari-brand-tint)] px-2 py-0.5 text-xs font-medium text-[var(--otari-brand-dark)]",title:"Requests on this key are logged with cost but never counted toward budget",children:"Budget-exempt"}):null,s.reject_user_mismatch===null?null:e.jsx("span",{className:"inline-flex items-center rounded-full border border-[var(--otari-line)] bg-[var(--otari-brand-tint)] px-2 py-0.5 text-xs font-medium text-[var(--otari-brand-dark)]",title:s.reject_user_mismatch?"This key always rejects a request naming a different user, whatever the deployment setting says":"This key always accepts a request naming a different user; spend still binds to its owner",children:s.reject_user_mismatch?"Strict user":"Lenient user"})]})]})},{id:"status",header:"Status",cell:s=>e.jsx(ye,{apiKey:s})},{id:"owner",header:"Owner",cell:s=>xe(s.user_id)?e.jsx(I,{size:"sm",color:"default",children:"virtual"}):e.jsx("code",{className:"text-xs text-[var(--otari-muted)]",children:s.user_id??"—"})},{id:"key",header:"Key",cell:s=>e.jsx("code",{className:"text-xs text-[var(--otari-muted)]",children:s.key_prefix?`${s.key_prefix}…`:"—"})},{id:"created",header:"Created",cell:s=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:$(s.created_at)})},{id:"last_used",header:"Last used",cell:s=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:de(s.last_used_at)??"never"})},{id:"expires",header:"Expires",cell:s=>e.jsx("span",{className:"text-[var(--otari-muted)]",title:s.expires_at?new Date(s.expires_at).toLocaleString():void 0,children:s.expires_at?$(s.expires_at):"never"})},{id:"actions",header:"Actions",align:"end",cell:s=>e.jsxs("div",{className:"flex items-center justify-end gap-1.5",children:[e.jsx(f,{size:"sm",variant:"outline",isDisabled:a.isPending,onPress:()=>w(s,!s.is_active),children:s.is_active?"Disable":"Enable"}),e.jsx(f,{size:"sm",variant:"ghost",onPress:()=>{o(!1),h(s.id)},children:"Edit"}),e.jsx(U,{trigger:"Regenerate",confirmLabel:"Regenerate",isPending:r.isPending,message:e.jsxs(e.Fragment,{children:["Regenerate the secret for ",e.jsx("strong",{children:F(s)}),"? The current secret stops working immediately, with no grace period."]}),onConfirm:()=>z(s)}),s.is_active?null:e.jsx(U,{trigger:"Delete",confirmLabel:"Delete permanently",isPending:n.isPending,message:e.jsxs(e.Fragment,{children:["Permanently delete ",e.jsx("strong",{children:F(s)}),"? This removes the key and unlinks its usage history. Cannot be undone."]}),onConfirm:()=>n.mutate(s.id)})]})}],[a.isPending,r.isPending,n.isPending,n.mutate,w,z]),D=j.filter(s=>!s.is_active);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ee,{title:"API keys",description:"Issue and revoke the keys that authenticate callers to this gateway. Secrets are shown once at creation.",action:d?null:e.jsx(f,{variant:"primary",onPress:()=>{h(null),o(!0)},children:"Create key"})}),e.jsx(O,{error:t.error??a.error??r.error??n.error}),y?e.jsx(te,{title:"No API keys yet",description:"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.",actionLabel:"Create your first key",onAction:()=>{h(null),o(!0)}}):null,d?e.jsx(pe,{onClose:()=>o(!1),onCreated:s=>g({title:"API key created",result:s})}):null,m?e.jsx(ge,{apiKey:m,onClose:()=>h(null)},m.id):null,C.length>0?e.jsxs(ie,{selectedCount:C.length,allMatching:!1,matchingTotal:null,canSelectAllMatching:!1,onSelectAllMatching:()=>{},onClear:x.clear,children:[e.jsx(f,{size:"sm",variant:"outline",isDisabled:_,onPress:()=>void T(j,s=>a.mutateAsync({id:s.id,body:{is_active:!1}})),children:"Disable"}),e.jsx(f,{size:"sm",variant:"outline",isDisabled:_,onPress:()=>void T(j,s=>a.mutateAsync({id:s.id,body:{exclude_from_budget:!0}})),children:"Budget-exempt"}),e.jsx(f,{size:"sm",variant:"danger",isDisabled:D.length===0,onPress:()=>v(!0),children:"Delete"})]}):null,y?null:e.jsx(oe,{ariaLabel:"API keys",columns:G,rows:c,getRowKey:he,isLoading:u,emptyContent:"No API keys yet. Create one to authenticate a caller.",selectionMode:"multiple",selectedKeys:x.selectedKeys,onSelectionChange:x.onSelectionChange}),e.jsx(le,{isOpen:N,onOpenChange:v,heading:"Delete API keys",body:`Permanently delete ${D.length} disabled ${D.length===1?"key":"keys"}? This removes them and unlinks their usage history. Cannot be undone. Active keys in the selection are skipped; disable them first.`,confirmLabel:"Delete permanently",isPending:_,error:k,onConfirm:()=>void T(D,s=>n.mutateAsync(s.id),()=>v(!1))}),p?e.jsx(fe,{title:p.title,result:p.result,onClose:()=>{g(null),r.reset()}}):null]})}export{De as KeysPage}; +`);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:n,role:"dialog","aria-modal":"true","aria-labelledby":"reveal-title",onKeyDown:h,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(ae,{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: ",z(a.allowed_models).text,"."]}),e.jsx(F,{label:"Secret key",value:l,fieldRef:d}),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(F,{label:"curl",value:p,multiline:!0}),e.jsx(F,{label:"Python (OpenAI SDK)",value:g,multiline:!0})]}),e.jsx("div",{className:"flex justify-end",children:e.jsx(f,{variant:"primary",onPress:r,children:"I’ve saved this key"})})]})})}function U({trigger:t,message:a,confirmLabel:r,isPending:n,onConfirm:d}){const[o,l]=i.useState(!1);return o?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:a}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(f,{size:"sm",variant:"danger",isDisabled:n,onPress:d,children:r}),e.jsx(f,{size:"sm",variant:"ghost",isDisabled:n,onPress:()=>l(!1),children:"Cancel"})]})]}):e.jsx(f,{size:"sm",variant:"danger-soft",onPress:()=>l(!0),children:t})}function W({userId:t,users:a}){const r=t.trim();if(r==="")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 n=a.find(l=>l.user_id===r);if(!n)return e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["New user ",e.jsx("code",{children:r})," starts unrestricted, so this key may allow any model."]});const{text:d}=z(n.allowed_models),o=n.allowed_models&&n.allowed_models.length>0?n.allowed_models.join(", "):null;return e.jsxs("p",{className:"text-xs text-[var(--otari-muted)]",children:["Owner ",e.jsx("code",{children:r})," allows ",e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:d.toLowerCase()}),o?e.jsxs(e.Fragment,{children:[" (",e.jsx("span",{className:"font-mono",children:o}),")"]}):null,". This key inherits that, or narrows within it."]})}function Q({checked:t,onChange:a}){return e.jsxs("label",{className:"flex items-start gap-2 rounded-lg border border-[var(--otari-line)] p-3 text-sm",children:[e.jsx("input",{type:"checkbox",checked:t,onChange:r=>a(r.target.checked),className:"mt-0.5 h-4 w-4 accent-[var(--otari-brand)]","aria-label":"Exempt this key from budget"}),e.jsxs("span",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:"Exempt from budget"}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Requests on this key are logged with their cost but never counted toward the owner's budget or spend, and never blocked by it."})]})]})}function Y({value:t,onChange:a}){const r="key-reject-user-mismatch";return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsxs("label",{htmlFor:r,className:"text-sm font-medium text-[var(--otari-ink)]",children:["Mismatched ",e.jsx("code",{children:"user"})," field"]}),e.jsxs("select",{id:r,value:t===null?"inherit":t?"reject":"accept",onChange:n=>a(n.target.value==="inherit"?null:n.target.value==="reject"),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:"inherit",children:"Use the deployment setting (default)"}),e.jsx("option",{value:"reject",children:"Always reject (403)"}),e.jsx("option",{value:"accept",children:"Always accept"})]}),e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["What happens when a request on this key names a different ",e.jsx("code",{children:"user"})," than its owner. Accept it for clients that send telemetry there rather than an identity, such as Claude Code. Spend binds to this key's owner either way."]})]})}function pe({onClose:t,onCreated:a}){const r=se(),n=q(),[d,o]=i.useState(""),[l,h]=i.useState(""),[p,g]=i.useState(!1),[c,u]=i.useState(""),[m,y]=i.useState(null),[x,N]=i.useState(!1),[v,k]=i.useState(null),[b,_]=i.useState(!0),P=l!==""&&new Date(l).getTime(){if(r.isPending||!b||A)return;const j={key_name:d.trim()||null,user_id:c.trim(),expires_at:l?new Date(l).toISOString():null,allowed_models:m,exclude_from_budget:x,reject_user_mismatch:v};r.mutate(j,{onSuccess:w=>{a(w),t()}})};return e.jsx(E,{children:e.jsxs(E.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(K,{error:r.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(M,{label:"Name",value:d,onChange:o,placeholder:"ci-bot",autoFocus:!0,description:"A label to recognize this key later."}),e.jsx(M,{label:"Expires (optional)",value:l,onChange:h,type:"datetime-local",description:P?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(ce,{value:c,onChange:u,users:n.data??[]}),e.jsx("button",{type:"button",className:"self-start text-xs font-medium text-[var(--otari-brand-dark)]",onClick:()=>g(j=>!j),children:p?"Hide advanced":"Advanced"}),p?e.jsxs("div",{className:"flex flex-col gap-4 rounded-lg border border-[var(--otari-line)] p-4",children:[e.jsx(W,{userId:c,users:n.data??[]}),e.jsx(V,{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:(j,w)=>{y(j),_(w)}}),e.jsx(Q,{checked:x,onChange:N}),e.jsx(Y,{value:v,onChange:k})]}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(f,{variant:"primary",isDisabled:r.isPending||!b||A,onPress:C,children:r.isPending?"Creating…":"Create key"}),e.jsx(f,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function ge({apiKey:t,onClose:a}){const r=H(),n=q(),[d,o]=i.useState(t.key_name??""),[l,h]=i.useState(me(t.expires_at)),[p,g]=i.useState(t.allowed_models),[c,u]=i.useState(t.exclude_from_budget),[m,y]=i.useState(t.reject_user_mismatch),[x,N]=i.useState(!0),v=()=>{r.isPending||!x||r.mutate({id:t.id,body:{key_name:d.trim()||null,expires_at:l?new Date(l).toISOString():null,allowed_models:p,exclude_from_budget:c,reject_user_mismatch:m}},{onSuccess:a})};return e.jsx(E,{children:e.jsxs(E.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(K,{error:r.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(M,{label:"Name",value:d,onChange:o,placeholder:"ci-bot"}),e.jsx(M,{label:"Expires",value:l,onChange:h,type:"datetime-local",description:"Blank clears the expiry."})]}),t.user_id?e.jsx(W,{userId:t.user_id,users:n.data??[]}):null,e.jsx(V,{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:(k,b)=>{g(k),N(b)}}),e.jsx(Q,{checked:c,onChange:u}),e.jsx(Y,{value:m,onChange:y}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(f,{variant:"primary",isDisabled:r.isPending||!x,onPress:v,children:r.isPending?"Saving…":"Save changes"}),e.jsx(f,{variant:"ghost",onPress:a,children:"Cancel"})]})]})})}function ye({apiKey:t}){return t.is_active?ue(t)?e.jsx(I,{size:"sm",color:"warning",children:"Expired"}):e.jsx(I,{size:"sm",color:"accent",children:"Active"}):e.jsx(I,{size:"sm",color:"default",children:"Disabled"})}function je({allowed:t}){const{text:a,tone:r}=z(t),n=r==="danger"?"text-red-700 font-medium":r==="muted"?"text-[var(--otari-muted)]":"text-[var(--otari-brand-dark)] font-medium",d=t&&t.length>0?t.join(", "):void 0;return e.jsx("span",{className:`text-xs ${n}`,title:d,children:a})}function De(){const t=J(),a=H(),r=X(),n=Z(),[d,o]=i.useState(!1),[l,h]=i.useState(null),[p,g]=i.useState(null),c=t.data??[],u=t.isLoading,m=c.find(s=>s.id===l)??null,y=!u&&c.length===0&&!d,x=re(),[N,v]=i.useState(!1),[k,b]=i.useState(void 0),[_,P]=i.useState(!1),A=c.map(s=>s.id),C=ne(x.selectedKeys,A),j=c.filter(s=>C.includes(s.id)),w=i.useCallback((s,S)=>a.mutate({id:s.id,body:{is_active:S}}),[a.mutate]),L=i.useCallback(s=>r.mutate(s.id,{onSuccess:S=>g({title:`New secret for ${O(s)}`,result:S})}),[r.mutate]),T=async(s,S,R)=>{P(!0),b(void 0);try{for(const B of s)await S(B);x.clear(),R==null||R()}catch(B){b(B)}finally{P(!1)}},G=i.useMemo(()=>[{id:"name",header:"Name",isRowHeader:!0,cell:s=>e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:s.key_name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx(je,{allowed:s.allowed_models}),s.exclude_from_budget?e.jsx("span",{className:"inline-flex items-center rounded-full border border-[var(--otari-line)] bg-[var(--otari-brand-tint)] px-2 py-0.5 text-xs font-medium text-[var(--otari-brand-dark)]",title:"Requests on this key are logged with cost but never counted toward budget",children:"Budget-exempt"}):null,s.reject_user_mismatch===null?null:e.jsx("span",{className:"inline-flex items-center rounded-full border border-[var(--otari-line)] bg-[var(--otari-brand-tint)] px-2 py-0.5 text-xs font-medium text-[var(--otari-brand-dark)]",title:s.reject_user_mismatch?"This key always rejects a request naming a different user, whatever the deployment setting says":"This key always accepts a request naming a different user; spend still binds to its owner",children:s.reject_user_mismatch?"Strict user":"Lenient user"})]})]})},{id:"status",header:"Status",cell:s=>e.jsx(ye,{apiKey:s})},{id:"owner",header:"Owner",cell:s=>xe(s.user_id)?e.jsx(I,{size:"sm",color:"default",children:"virtual"}):e.jsx("code",{className:"text-xs text-[var(--otari-muted)]",children:s.user_id??"—"})},{id:"key",header:"Key",cell:s=>e.jsx("code",{className:"text-xs text-[var(--otari-muted)]",children:s.key_prefix?`${s.key_prefix}…`:"—"})},{id:"created",header:"Created",cell:s=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:$(s.created_at)})},{id:"last_used",header:"Last used",cell:s=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:de(s.last_used_at)??"never"})},{id:"expires",header:"Expires",cell:s=>e.jsx("span",{className:"text-[var(--otari-muted)]",title:s.expires_at?new Date(s.expires_at).toLocaleString():void 0,children:s.expires_at?$(s.expires_at):"never"})},{id:"actions",header:"Actions",align:"end",cell:s=>e.jsxs("div",{className:"flex items-center justify-end gap-1.5",children:[e.jsx(f,{size:"sm",variant:"outline",isDisabled:a.isPending,onPress:()=>w(s,!s.is_active),children:s.is_active?"Disable":"Enable"}),e.jsx(f,{size:"sm",variant:"ghost",onPress:()=>{o(!1),h(s.id)},children:"Edit"}),e.jsx(U,{trigger:"Regenerate",confirmLabel:"Regenerate",isPending:r.isPending,message:e.jsxs(e.Fragment,{children:["Regenerate the secret for ",e.jsx("strong",{children:O(s)}),"? The current secret stops working immediately, with no grace period."]}),onConfirm:()=>L(s)}),s.is_active?null:e.jsx(U,{trigger:"Delete",confirmLabel:"Delete permanently",isPending:n.isPending,message:e.jsxs(e.Fragment,{children:["Permanently delete ",e.jsx("strong",{children:O(s)}),"? This removes the key and unlinks its usage history. Cannot be undone."]}),onConfirm:()=>n.mutate(s.id)})]})}],[a.isPending,r.isPending,n.isPending,n.mutate,w,L]),D=j.filter(s=>!s.is_active);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ee,{title:"API keys",description:"Issue and revoke the keys that authenticate callers to this gateway. Secrets are shown once at creation.",action:d?null:e.jsx(f,{variant:"primary",onPress:()=>{h(null),o(!0)},children:"Create key"})}),e.jsx(K,{error:t.error??a.error??r.error??n.error}),y?e.jsx(te,{title:"No API keys yet",description:"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.",actionLabel:"Create your first key",onAction:()=>{h(null),o(!0)}}):null,d?e.jsx(pe,{onClose:()=>o(!1),onCreated:s=>g({title:"API key created",result:s})}):null,m?e.jsx(ge,{apiKey:m,onClose:()=>h(null)},m.id):null,C.length>0?e.jsxs(ie,{selectedCount:C.length,allMatching:!1,matchingTotal:null,canSelectAllMatching:!1,onSelectAllMatching:()=>{},onClear:x.clear,children:[e.jsx(f,{size:"sm",variant:"outline",isDisabled:_,onPress:()=>void T(j,s=>a.mutateAsync({id:s.id,body:{is_active:!1}})),children:"Disable"}),e.jsx(f,{size:"sm",variant:"outline",isDisabled:_,onPress:()=>void T(j,s=>a.mutateAsync({id:s.id,body:{exclude_from_budget:!0}})),children:"Budget-exempt"}),e.jsx(f,{size:"sm",variant:"danger",isDisabled:D.length===0,onPress:()=>v(!0),children:"Delete"})]}):null,y?null:e.jsx(oe,{ariaLabel:"API keys",columns:G,rows:c,getRowKey:he,isLoading:u,emptyContent:"No API keys yet. Create one to authenticate a caller.",selectionMode:"multiple",selectedKeys:x.selectedKeys,onSelectionChange:x.onSelectionChange}),e.jsx(le,{isOpen:N,onOpenChange:v,heading:"Delete API keys",body:`Permanently delete ${D.length} disabled ${D.length===1?"key":"keys"}? This removes them and unlinks their usage history. Cannot be undone. Active keys in the selection are skipped; disable them first.`,confirmLabel:"Delete permanently",isPending:_,error:k,onConfirm:()=>void T(D,s=>n.mutateAsync(s.id),()=>v(!1))}),p?e.jsx(fe,{title:p.title,result:p.result,onClose:()=>{g(null),r.reset()}}):null]})}export{De as KeysPage}; diff --git a/src/gateway/static/dashboard/assets/ModelScopeControl-CYPgEOWk.js b/src/gateway/static/dashboard/assets/ModelScopeControl-W-k32mVk.js similarity index 96% rename from src/gateway/static/dashboard/assets/ModelScopeControl-CYPgEOWk.js rename to src/gateway/static/dashboard/assets/ModelScopeControl-W-k32mVk.js index 8b72a566..259ddd70 100644 --- a/src/gateway/static/dashboard/assets/ModelScopeControl-CYPgEOWk.js +++ b/src/gateway/static/dashboard/assets/ModelScopeControl-W-k32mVk.js @@ -1 +1 @@ -import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as n}from"./react-dgEcD0HR.js";import{a3 as A,t as $,w as P}from"./index-DAnS9oY2.js";import{C as d,I as R,a as T,b as V}from"./heroui-COmYdDDM.js";function q(r){return r===null?"any":r.length===0?"block":"only"}const O=50;function W({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 X(r){return r===null?{text:"All models",tone:"muted"}:r.length===0?{text:"No models",tone:"danger"}:{text:"Selected models",tone:"normal"}}export{W as M,X as a}; +import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as n}from"./react-dgEcD0HR.js";import{a3 as A,s as $,v as P}from"./index-D6WO6K2k.js";import{C as d,I as R,a as T,b as V}from"./heroui-COmYdDDM.js";function q(r){return r===null?"any":r.length===0?"block":"only"}const O=50;function W({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 X(r){return r===null?{text:"All models",tone:"muted"}:r.length===0?{text:"No models",tone:"danger"}:{text:"Selected models",tone:"normal"}}export{W as M,X as a}; diff --git a/src/gateway/static/dashboard/assets/ModelsPage-SJrMcme1.js b/src/gateway/static/dashboard/assets/ModelsPage-CjADRmXg.js similarity index 95% rename from src/gateway/static/dashboard/assets/ModelsPage-SJrMcme1.js rename to src/gateway/static/dashboard/assets/ModelsPage-CjADRmXg.js index 179e1137..4beedb42 100644 --- a/src/gateway/static/dashboard/assets/ModelsPage-SJrMcme1.js +++ b/src/gateway/static/dashboard/assets/ModelsPage-CjADRmXg.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{u as kt,r as c}from"./react-dgEcD0HR.js";import{V as Mt,W as Wt,t as Tt,X as It,m as Ne,P as Lt,E as $t,F,N as At,Z as se,q as He,_ as Ge,z as Ue,$ as Pe,a0 as Et,a1 as Ot,a2 as E}from"./index-DAnS9oY2.js";import{u as Dt,r as Ft,B as Kt}from"./tableSelection-BJDASjEj.js";import{D as Bt}from"./DataTable-DuDxGlJc.js";import{i as Rt,T as wt,S as Re}from"./TablePagination-BpT-8wzM.js";import{B as I,d as ae,g as z}from"./heroui-COmYdDDM.js";import"./Field-CBU9MRjz.js";function we(t){const i=t.indexOf(":");return i>0?t.slice(0,i):"—"}function zt(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 l=[];for(const n of r.values()){const a=[...n].sort((u,f)=>Date.parse(u.effective_at)-Date.parse(f.effective_at)),d=[...a].reverse().find(u=>Date.parse(u.effective_at)<=i);l.push(d??a[0])}return l.sort((n,a)=>n.model_key.localeCompare(a.model_key))}const Vt="otari",qt="otari",ze=[{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")}],Yt=[{key:"reasoning",label:"Reasoning"},{key:"tool_call",label:"Tool calling"},{key:"structured_output",label:"Structured output"},{key:"attachment",label:"Attachments"},{key:"temperature",label:"Temperature"}],Ve={text:"Text",image:"Image",audio:"Audio",video:"Video",pdf:"PDF"},Ht=[{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"}],Gt=[{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"}],Ut=[{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"}],Jt=[{value:"all",label:"Any release date"},{value:"365",label:"Past year"},{value:"730",label:"Past 2 years"},{value:"1095",label:"Past 3 years"}],Xt=1440*60*1e3,Zt=t=>t.key;function qe(t,i){const r=`${i}:`;return t.startsWith(r)?t.slice(r.length):t}function Ye(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 l=t.pricingTiers.filter(n=>n.min_input_tokens<=i).sort((n,a)=>a.min_input_tokens-n.min_input_tokens)[0];return l?{inputPrice:l.input_price_per_million??r.inputPrice,outputPrice:l.output_price_per_million??r.outputPrice,cacheReadPrice:l.cache_read_price_per_million??r.cacheReadPrice,cacheWritePrice:l.cache_write_price_per_million??r.cacheWritePrice,cacheWrite1hPrice:l.cache_write_1h_price_per_million??r.cacheWrite1hPrice}:r}function ce(t){const i=Number(t);return t.trim()!==""&&Number.isFinite(i)&&i>=0}function w(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 Je(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 Xe(t){const i=new Set;return t.every(r=>{const l=Number(r.minInputTokens),n=[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].some(a=>a.trim()!=="");return!Number.isInteger(l)||l<=0||i.has(l)||!n?!1:(i.add(l),[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].every(w))})}function Ze(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:l=>i(l.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 Qe({tiers:t,onChange:i}){const r=(n,a,d)=>{i(t.map(u=>u.id===n?{...u,[a]:d}:u))},l=()=>{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(I,{size:"sm",variant:"outline",onPress:l,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(I,{size:"sm",variant:"ghost",onPress:()=>i(t.filter(a=>a.id!==n.id)),children:"Remove"})]},n.id))]})}function Qt({source:t}){return t==="configured"?e.jsx(z,{size:"sm",color:"default",children:"configured"}):t==="default"||t==="alias"?e.jsx(z,{size:"sm",color:"accent",children:t}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not priced"})}function oe({label:t,tone:i="info",children:r}){const l=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":l,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:l,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 ei(){const t=Ot();return t.data?t.data.default_pricing?e.jsx(oe,{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(oe,{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 le({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 ti({row:t}){const i=Ne(),r=Ge(),[l,n]=c.useState(!1),[a,d]=c.useState(""),[u,f]=c.useState(""),[b,N]=c.useState(""),[$,C]=c.useState(""),[P,m]=c.useState(""),[k,y]=c.useState([]),q=()=>{d(t.inputPrice==null?"":String(t.inputPrice)),f(t.outputPrice==null?"":String(t.outputPrice)),N(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),C(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),m(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),y(Je(t.pricingTiers)),n(!0)},M=ce(a)&&ce(u)&&w(b)&&w($)&&w(P)&&Xe(k),ue=()=>{M&&i.mutate({model_key:t.key,input_price_per_million:Number(a),output_price_per_million:Number(u),cache_read_price_per_million:V(b),cache_write_price_per_million:V($),cache_write_1h_price_per_million:V(P),pricing_tiers:Ze(k)},{onSuccess:()=>n(!1)})};return l?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:u,onChange:f,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:b,onChange:N,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:$,onChange:C,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:P,onChange:m,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(Qe,{tiers:k,onChange:y}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(I,{size:"sm",variant:"primary",isDisabled:i.isPending||!M,onPress:ue,children:"Save"}),e.jsx(I,{size:"sm",variant:"ghost",isDisabled:i.isPending,onPress:()=>n(!1),children:"Cancel"})]}),i.error?e.jsx("span",{className:"text-xs text-red-700",children:Pe(i.error)}):null]}):e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx(T,{label:"Input",value:t.inputPrice==null?"—":`${E(t.inputPrice)} / 1M`}),e.jsx(T,{label:"Output",value:t.outputPrice==null?"—":`${E(t.outputPrice)} / 1M`}),e.jsx(T,{label:"Cache read",value:t.cacheReadPrice==null?"—":`${E(t.cacheReadPrice)} / 1M`}),e.jsx(T,{label:"Cache write",value:t.cacheWritePrice==null?"—":`${E(t.cacheWritePrice)} / 1M`}),e.jsx(T,{label:"1h cache write",value:t.cacheWrite1hPrice==null?"—":`${E(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(I,{size:"sm",variant:"outline",onPress:q,children:t.source==="configured"?"Edit price":"Set price"}),t.source==="configured"?e.jsxs(e.Fragment,{children:[e.jsx(Ue,{confirmLabel:"Reset",isPending:r.isPending,onConfirm:()=>r.mutate(t.key),children:"Reset"}),e.jsx(oe,{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:Pe(r.error)}):null]})]})}function ii({row:t,metadata:i,metadataAvailable:r,onClose:l}){const n=(i==null?void 0:i.input_modalities)??[],a=(i==null?void 0:i.output_modalities)??[],d=Yt.filter(({key:u})=>i==null?void 0:i[u]);return e.jsx(ae,{children:e.jsxs(ae.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(z,{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(He,{value:t.key,label:"model id",children: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:l,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(le,{title:"Pricing",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(Qt,{source:t.source}),t.isDiscovered?null:e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not discovered"})]}),e.jsx(ti,{row:t},t.key)]}),e.jsxs(le,{title:"Specs",children:[e.jsx(T,{label:"Context window",value:se(t.contextWindow)}),e.jsx(T,{label:"Max output",value:se((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:Et(i==null?void 0:i.release_date)}),e.jsx(T,{label:"Open weights",value:i?i.open_weights?"Yes":"No":"—"})]}),e.jsx(le,{title:"Modalities",children:n.length===0&&a.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:"}),n.map(u=>e.jsx(z,{size:"sm",color:"default",children:Ve[u]??u},u))]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"Out:"}),a.map(u=>e.jsx(z,{size:"sm",color:"default",children:Ve[u]??u},u))]})]})}),e.jsx(le,{title:"Capabilities",children:d.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:d.map(({key:u,label:f})=>e.jsx(z,{size:"sm",color:"default",children:f},u))}):e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:r?"None reported.":"Extended metadata unavailable (models.dev disabled or unreachable)."})})]})})}const ri=15;function ni({value:t,onChange:i,placeholder:r}){return e.jsx("input",{type:"search",value:t,onChange:l=>i(l.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"})}const et="otari.dashboard.modelsSort",je={col:"model",dir:"asc"},li=["model","released","input","output"];function si(){if(typeof window>"u")return je;try{const t=window.localStorage.getItem(et);if(!t)return je;const i=JSON.parse(t);if(li.includes(i.col)&&(i.dir==="asc"||i.dir==="desc"))return{col:i.col,dir:i.dir}}catch{}return je}function ai({row:t,onClose:i}){const r=Ne(),l=Ge(),[n,a]=c.useState(t.inputPrice==null?"":String(t.inputPrice)),[d,u]=c.useState(t.outputPrice==null?"":String(t.outputPrice)),[f,b]=c.useState(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),[N,$]=c.useState(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),[C,P]=c.useState(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),[m,k]=c.useState(Je(t.pricingTiers)),y=ce(n)&&ce(d)&&w(f)&&w(N)&&w(C)&&Xe(m),q=()=>{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(f),cache_write_price_per_million:V(N),cache_write_1h_price_per_million:V(C),pricing_tiers:Ze(m)},{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:u,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:f,onChange:b,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:N,onChange:$,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:C,onChange:P,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(I,{size:"sm",variant:"primary",isDisabled:r.isPending||!y,onPress:q,children:r.isPending?"Saving…":"Save"}),e.jsx(I,{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(Ue,{confirmLabel:"Reset",isPending:l.isPending,onConfirm:()=>l.mutate(t.key,{onSuccess:i}),children:"Reset"}),e.jsx(oe,{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||l.error?e.jsx("span",{className:"text-xs text-red-700",children:Pe(r.error??l.error)}):null]}),e.jsx(Qe,{tiers:m,onChange:k})]})}function ci({primary:t,secondary:i,rowKey:r,primaryLabel:l,secondaryLabel:n,onEdit:a}){const d=(u,f)=>e.jsx("button",{type:"button","aria-label":`Edit ${f} price for ${r}`,className:"tabular-nums hover:text-[var(--otari-brand-dark)] hover:underline",onClick:b=>{b.stopPropagation(),a()},children:u==null?"—":E(u)});return e.jsxs("span",{className:"inline-flex items-center justify-end gap-1",children:[d(t,l),e.jsx("span",{className:"text-[var(--otari-muted)]",children:"/"}),d(i,n)]})}function oi({rates:t,rowKey:i,onEdit:r}){const l=[t.cacheReadPrice==null?null:`R ${E(t.cacheReadPrice)}`,t.cacheWritePrice==null?null:`W ${E(t.cacheWritePrice)}`,t.cacheWrite1hPrice==null?null:`1h ${E(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:l.length>0?l.join(" · "):"Input-rate fallback"})}function ui({row:t,onEdit:i}){const r=[...t.pricingTiers].sort((n,a)=>n.min_input_tokens-a.min_input_tokens).map(n=>se(n.min_input_tokens)),l=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:l})}function di({rows:t,isLoading:i,empty:r,sortDescriptor:l,onSortChange:n,selectedKey:a,onSelect:d,onEditPricing:u,comparisonContextTokens:f,selectedKeys:b,onSelectionChange:N}){const $=c.useMemo(()=>{const P=f==null?"Base":`at ${se(f)}`;return[{id:"model",header:"Model",isRowHeader:!0,allowsSorting:!0,cell:m=>e.jsxs(He,{value:m.key,label:"model id",className:"font-medium break-all",children:[m.model,e.jsx("span",{className:"sr-only select-none",children:m.key})]})},{id:"provider",header:"Provider",cell:m=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:m.provider})},{id:"input",header:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[`${P} in / out $ / 1M`,e.jsx(ei,{})]}),align:"end",allowsSorting:!0,cell:m=>{const k=Ye(m,f);return e.jsx(ci,{primary:k.inputPrice,secondary:k.outputPrice,rowKey:m.key,primaryLabel:"input",secondaryLabel:"output",onEdit:()=>u(m.key)})}},{id:"caching",header:`Caching ${f==null?"policy":P}`,align:"end",cell:m=>e.jsx(oi,{rates:Ye(m,f),rowKey:m.key,onEdit:()=>u(m.key)})},{id:"policy",header:"Pricing policy",align:"end",cell:m=>e.jsx(ui,{row:m,onEdit:()=>u(m.key)})}]},[f,u]),C=c.useCallback(P=>P.key===a?"bg-[var(--otari-brand-tint)]":void 0,[a]);return e.jsx(Bt,{ariaLabel:"Models",columns:$,rows:t,getRowKey:Zt,isLoading:i,emptyContent:r,selectionMode:"multiple",selectedKeys:b,onSelectionChange:N,sortDescriptor:l,onSortChange:n,onRowAction:d,rowClassName:C})}function pi({providers:t,onPriceModel:i}){if(t.length===0)return null;const r=t.filter(d=>!d.discovery_unsupported),l=t.filter(d=>d.discovery_unsupported),n=d=>d.map(u=>u.provider).join(", "),a=d=>d.length===1;return e.jsxs(At,{tone:"warning",children:[r.length>0?e.jsxs("span",{className:"block",children:["Could not list ",n(r),". Check ",a(r)?"that provider's":"those providers'"," ","credentials in config.yml; ",a(r)?"its":"their"," models are missing from the list below."]}):null,l.length>0?e.jsxs("span",{className:"block",children:[n(l)," ",a(l)?"does":"do"," not offer model discovery, so"," ",a(l)?"its":"their"," models are missing from the list below."," ",a(l)?"The provider":"They"," may still serve requests. Price a model by its selector to meter it here, or declare the model ids ",a(l)?"it serves":"they serve"," under the"," ",e.jsx("code",{children:"models:"})," key in config.yml to list them all.",e.jsx(I,{size:"sm",variant:"outline",className:"mt-2",onPress:()=>i(a(l)?`${l[0].provider}:`:""),children:"Price a model"})]}):null]})}function ji(){var Oe,De,Fe;const[t]=kt(),i=Mt(),r=Wt(),l=Tt(),n=It(),a=Ne(),[d,u]=c.useState(""),[f,b]=c.useState(0),[N,$]=c.useState(ri),[C,P]=c.useState(null),[m,k]=c.useState(null),[y,q]=c.useState(si),M=Dt(),[ue,de]=c.useState(!1),[tt,Se]=c.useState(!1),[it,Ce]=c.useState(void 0),[pe,Z]=c.useState(null),[rt,ke]=c.useState(!1),[nt,Me]=c.useState(void 0);c.useEffect(()=>{try{window.localStorage.setItem(et,JSON.stringify(y))}catch{}},[y]);const[O,We]=c.useState(t.get("provider")||"all"),[K,lt]=c.useState("all"),[Y,st]=c.useState("all"),[H,at]=c.useState("all"),[me,ct]=c.useState("0"),[Q,ot]=c.useState(""),[ee,ut]=c.useState("all"),[he,dt]=c.useState(""),D=((Oe=n.data)==null?void 0:Oe.models)??{},pt=((De=n.data)==null?void 0:De.available)??!1,Te=c.useMemo(()=>{var s;return new Set((((s=l.data)==null?void 0:s.providers)??[]).flatMap(h=>h.models.map(o=>o.key)))},[l.data]),mt=s=>{u(s),b(0)},B=s=>h=>{s(h),b(0)},te=c.useMemo(()=>{var W,_,v,A,L,R,Ke;const s=new Map(zt(r.data??[]).map(p=>[p.model_key,p])),h=[],o=new Set,g=(p,ye,Be,x)=>{if(o.has(p))return;o.add(p);const S=s.get(p);h.push({key:p,model:qe(ye,Be),provider:Be,isDiscovered:Te.has(p),contextWindow:(x==null?void 0:x.contextWindow)??null,inputPrice:S?S.input_price_per_million:(x==null?void 0:x.inputPrice)??null,outputPrice:S?S.output_price_per_million:(x==null?void 0:x.outputPrice)??null,cacheReadPrice:S?S.cache_read_price_per_million:(x==null?void 0:x.cacheReadPrice)??null,cacheWritePrice:S?S.cache_write_price_per_million:(x==null?void 0:x.cacheWritePrice)??null,cacheWrite1hPrice:S?S.cache_write_1h_price_per_million??null:(x==null?void 0:x.cacheWrite1hPrice)??null,pricingTiers:S?S.pricing_tiers??[]:(x==null?void 0:x.pricingTiers)??[],source:S?"configured":(x==null?void 0:x.source)??"none"})};for(const p of((W=i.data)==null?void 0:W.data)??[]){if(p.owned_by===qt)continue;const ye=p.pricing_source==="default"?"default":p.pricing?"configured":"none";g(p.id,p.id,p.owned_by||we(p.id),{key:p.id,model:p.id,provider:p.owned_by,contextWindow:p.context_window,inputPrice:((_=p.pricing)==null?void 0:_.input_price_per_million)??null,outputPrice:((v=p.pricing)==null?void 0:v.output_price_per_million)??null,cacheReadPrice:((A=p.pricing)==null?void 0:A.cache_read_price_per_million)??null,cacheWritePrice:((L=p.pricing)==null?void 0:L.cache_write_price_per_million)??null,cacheWrite1hPrice:((R=p.pricing)==null?void 0:R.cache_write_1h_price_per_million)??null,pricingTiers:((Ke=p.pricing)==null?void 0:Ke.pricing_tiers)??[],source:ye})}for(const p of s.keys())p.startsWith(`${Vt}:`)||g(p,p,we(p));return h},[i.data,r.data,Te]),Ie=c.useMemo(()=>new Map(te.map(s=>[s.key,s])),[te]),ie=c.useMemo(()=>{var o,g,W;const s=te.map(_=>{var v,A;return{..._,contextWindow:_.contextWindow??((v=D[_.key])==null?void 0:v.context_window)??null,releaseDate:((A=D[_.key])==null?void 0:A.release_date)??null}}),h=new Set(s.map(_=>_.key));for(const _ of((o=l.data)==null?void 0:o.providers)??[])for(const v of _.models)h.has(v.key)||(h.add(v.key),s.push({key:v.key,model:qe(v.key,_.provider),provider:_.provider,isDiscovered:!0,contextWindow:((g=D[v.key])==null?void 0:g.context_window)??null,releaseDate:((W=D[v.key])==null?void 0:W.release_date)??null,inputPrice:null,outputPrice:null,cacheReadPrice:null,cacheWritePrice:null,cacheWrite1hPrice:null,pricingTiers:[],source:"none"}));return s},[te,l.data,D]),ht=(((Fe=l.data)==null?void 0:Fe.providers)??[]).filter(s=>!s.ok),re=c.useMemo(()=>{const s=Array.from(new Set(ie.map(h=>h.provider))).sort((h,o)=>h.localeCompare(o));return[{value:"all",label:"All providers"},...s.map(h=>({value:h,label:h}))]},[ie]);c.useEffect(()=>{O==="all"||re.length<=1||re.some(s=>s.value===O)||We("all")},[re,O]);const G=d.trim().toLowerCase(),xe=Number(me)||0,fe=Q===""?Number.POSITIVE_INFINITY:Number(Q),xt=he===""?null:Number(he),ge=ee==="all"?null:Date.now()-Number(ee)*Xt,U=c.useMemo(()=>{const s=o=>{if(G&&!o.key.toLowerCase().includes(G)&&!o.provider.toLowerCase().includes(G)||O!=="all"&&o.provider!==O||K==="configured"&&o.source!=="configured"||K==="default"&&o.source!=="default"||K==="priced"&&o.inputPrice==null||K==="unpriced"&&o.inputPrice!=null||Y==="discovered"&&!o.isDiscovered||Y==="custom"&&o.isDiscovered)return!1;if(H!=="all"){const g=ze.find(_=>_.value===H),W=D[o.key];if(!g||!W||!g.test(W))return!1}if(xe>0&&(o.contextWindow==null||o.contextWindowfe))return!1;if(ge!=null){const g=o.releaseDate?Date.parse(o.releaseDate):Number.NaN;if(Number.isNaN(g)||g{const W=y.dir==="asc"?1:-1;if(y.col==="model")return o.model.localeCompare(g.model)*W;if(y.col==="released"){const L=o.releaseDate??null,R=g.releaseDate??null;return!L&&!R?o.model.localeCompare(g.model):L?R?(LR?1:0)*W||o.model.localeCompare(g.model):-1:1}const _=L=>y.col==="input"?L.inputPrice:L.outputPrice,v=_(o),A=_(g);return v==null&&A==null?o.model.localeCompare(g.model):v==null?1:A==null?-1:(v-A)*W||o.model.localeCompare(g.model)};return ie.filter(s).sort(h)},[ie,G,O,K,Y,H,xe,fe,ge,D,y]),J=U.length,ft=Math.max(1,Math.ceil(J/N)),Le=Math.min(f,ft-1),$e=Le*N,_e=U.slice($e,$e+N),gt={column:y.col,direction:y.dir==="asc"?"ascending":"descending"},_t=s=>{q({col:String(s.column),dir:s.direction==="ascending"?"asc":"desc"}),b(0)},vt=c.useCallback(s=>P(h=>h===s?null:s),[]),ve=_e.map(s=>s.key),X=Ft(M.selectedKeys,ve),bt=ve.length>0&&X.length===ve.length&&J>X.length,yt=M.allMatching?U.map(s=>s.key):X,Ae=M.allMatching?J:X.length,Ee=C?U.find(s=>s.key===C)??Ie.get(C)??null:null,jt=async s=>{Se(!0),Ce(void 0);try{for(const h of yt)await a.mutateAsync({model_key:h,input_price_per_million:s.input_price_per_million,output_price_per_million:s.output_price_per_million,cache_read_price_per_million:s.cache_read_price_per_million??null,cache_write_price_per_million:s.cache_write_price_per_million??null,cache_write_1h_price_per_million:null,pricing_tiers:[]});M.clear(),de(!1)}catch(h){Ce(h)}finally{Se(!1)}},Pt=async(s,h)=>{ke(!0),Me(void 0);try{const o=await a.mutateAsync({model_key:h,input_price_per_million:s.input_price_per_million,output_price_per_million:s.output_price_per_million,cache_read_price_per_million:s.cache_read_price_per_million??null,cache_write_price_per_million:s.cache_write_price_per_million??null});Z(null),k(o.model_key)}catch(o){Me(o)}finally{ke(!1)}},Nt=i.isLoading||r.isLoading||l.isLoading,St=G!==""||O!=="all"||K!=="all"||Y!=="all"||H!=="all"||me!=="0"||Q!==""||ee!=="all",be=Rt(d)?d.trim():null,Ct=e.jsxs("div",{className:"flex flex-col items-center gap-2 py-2",children:[e.jsx("span",{children:St?"No models match your filters.":"No models yet. Add a provider on the Providers page."}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"A provider that serves no model listing still answers requests, so a model you can call may not be listed here."}),e.jsx(I,{size:"sm",variant:"outline",onPress:()=>Z(be??""),children:be?`Price ${be}`:"Price a model by hand"})]}),ne=m?U.find(s=>s.key===m)??Ie.get(m)??null:null;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Lt,{title:"Models",description:"Every model your providers can serve. Set a price on any model so budgets and usage tracking work."}),e.jsx($t,{error:i.error??r.error??l.error??n.error}),e.jsxs("div",{className:`grid gap-4 lg:items-start ${ne?"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(ni,{value:d,onChange:mt,placeholder:"Search models…"}),e.jsx(F,{ariaLabel:"Filter by provider",value:O,onChange:B(We),options:re}),e.jsx(F,{ariaLabel:"Filter by pricing",value:K,onChange:B(lt),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(F,{ariaLabel:"Filter by source",value:Y,onChange:B(st),options:[{value:"all",label:"Any source"},{value:"discovered",label:"Discovered"},{value:"custom",label:"Custom (not discovered)"}]}),e.jsx(F,{ariaLabel:"Filter by capability",value:H,onChange:B(at),options:[{value:"all",label:"Any capability"},...ze.map(s=>({value:s.value,label:s.label}))]}),e.jsx(F,{ariaLabel:"Minimum context window",value:me,onChange:B(ct),options:Ht}),e.jsx(F,{ariaLabel:"Maximum input price",value:Q,onChange:B(ot),options:Gt}),e.jsx(F,{ariaLabel:"Compare prices at context",value:he,onChange:dt,options:Ut}),e.jsx(F,{ariaLabel:"Filter by release date",value:ee,onChange:B(ut),options:Jt})]}),e.jsx(pi,{providers:ht,onPriceModel:Z}),X.length>0?e.jsx(Kt,{selectedCount:Ae,allMatching:M.allMatching,matchingTotal:J,canSelectAllMatching:bt,onSelectAllMatching:M.enableAllMatching,onClear:M.clear,children:e.jsx(I,{size:"sm",variant:"primary",onPress:()=>de(!0),children:"Set pricing"})}):null,e.jsx(di,{rows:_e,isLoading:Nt,empty:Ct,sortDescriptor:gt,onSortChange:_t,selectedKey:m,onSelect:k,onEditPricing:vt,comparisonContextTokens:xt,selectedKeys:M.selectedKeys,onSelectionChange:M.onSelectionChange}),Ee?e.jsx(ae,{children:e.jsxs(ae.Content,{className:"p-0",children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Edit pricing"}),e.jsx(I,{size:"sm",variant:"ghost",onPress:()=>P(null),children:"Close"})]}),e.jsx(ai,{row:Ee,onClose:()=>P(null)})]})}):null,e.jsx(wt,{page:Le,pageSize:N,total:J,rowsOnPage:_e.length,onPageChange:b,onPageSizeChange:s=>{$(s),b(0)},pageSizeOptions:[15,25,50]})]}),ne?e.jsx("aside",{className:"lg:sticky lg:top-4",children:e.jsx(ii,{row:ne,metadata:D[ne.key],metadataAvailable:pt,onClose:()=>k(null)})}):null]}),e.jsx(Re,{isOpen:ue,onOpenChange:de,targetCount:Ae,isPending:tt,error:it,onSubmit:jt,title:"Set pricing",description:s=>`Apply these per-1M rates to ${s.toLocaleString()} selected ${s===1?"model":"models"}. This replaces each model's price; pricing tiers and the 1h cache rate are cleared. Edit a single model for tiers.`}),e.jsx(Re,{isOpen:pe!==null,onOpenChange:s=>Z(s?pe??"":null),isPending:rt,error:nt,onSubmit:Pt,collectModelKey:!0,initialModelKey:pe??"",title:"Price a model",description:()=>"Meter a model the catalogue does not list, for a provider that serves no model listing. Type the selector you send as model and its rates; the model then appears here as custom, and its usage is costed and counted against budgets."})]})}export{ji as ModelsPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{u as kt,r as c}from"./react-dgEcD0HR.js";import{V as Mt,W as Wt,s as Tt,X as It,k as Ne,P as Lt,E as $t,F,M as At,Z as se,o as He,_ as Ge,y as Ue,$ as Pe,a0 as Et,a1 as Ot,a2 as E}from"./index-D6WO6K2k.js";import{u as Dt,r as Ft,B as Kt}from"./tableSelection-BJDASjEj.js";import{D as Bt}from"./DataTable-DuDxGlJc.js";import{i as Rt,T as wt,S as Re}from"./TablePagination-D9yR_FiC.js";import{B as I,d as ae,g as z}from"./heroui-COmYdDDM.js";import"./Field-CBU9MRjz.js";function we(t){const i=t.indexOf(":");return i>0?t.slice(0,i):"—"}function zt(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 l=[];for(const n of r.values()){const a=[...n].sort((u,f)=>Date.parse(u.effective_at)-Date.parse(f.effective_at)),d=[...a].reverse().find(u=>Date.parse(u.effective_at)<=i);l.push(d??a[0])}return l.sort((n,a)=>n.model_key.localeCompare(a.model_key))}const Vt="otari",Yt="otari",ze=[{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")}],qt=[{key:"reasoning",label:"Reasoning"},{key:"tool_call",label:"Tool calling"},{key:"structured_output",label:"Structured output"},{key:"attachment",label:"Attachments"},{key:"temperature",label:"Temperature"}],Ve={text:"Text",image:"Image",audio:"Audio",video:"Video",pdf:"PDF"},Ht=[{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"}],Gt=[{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"}],Ut=[{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"}],Jt=[{value:"all",label:"Any release date"},{value:"365",label:"Past year"},{value:"730",label:"Past 2 years"},{value:"1095",label:"Past 3 years"}],Xt=1440*60*1e3,Zt=t=>t.key;function Ye(t,i){const r=`${i}:`;return t.startsWith(r)?t.slice(r.length):t}function qe(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 l=t.pricingTiers.filter(n=>n.min_input_tokens<=i).sort((n,a)=>a.min_input_tokens-n.min_input_tokens)[0];return l?{inputPrice:l.input_price_per_million??r.inputPrice,outputPrice:l.output_price_per_million??r.outputPrice,cacheReadPrice:l.cache_read_price_per_million??r.cacheReadPrice,cacheWritePrice:l.cache_write_price_per_million??r.cacheWritePrice,cacheWrite1hPrice:l.cache_write_1h_price_per_million??r.cacheWrite1hPrice}:r}function ce(t){const i=Number(t);return t.trim()!==""&&Number.isFinite(i)&&i>=0}function w(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 Je(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 Xe(t){const i=new Set;return t.every(r=>{const l=Number(r.minInputTokens),n=[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].some(a=>a.trim()!=="");return!Number.isInteger(l)||l<=0||i.has(l)||!n?!1:(i.add(l),[r.input,r.output,r.cacheRead,r.cacheWrite,r.cacheWrite1h].every(w))})}function Ze(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:l=>i(l.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 Qe({tiers:t,onChange:i}){const r=(n,a,d)=>{i(t.map(u=>u.id===n?{...u,[a]:d}:u))},l=()=>{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(I,{size:"sm",variant:"outline",onPress:l,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(I,{size:"sm",variant:"ghost",onPress:()=>i(t.filter(a=>a.id!==n.id)),children:"Remove"})]},n.id))]})}function Qt({source:t}){return t==="configured"?e.jsx(z,{size:"sm",color:"default",children:"configured"}):t==="default"||t==="alias"?e.jsx(z,{size:"sm",color:"accent",children:t}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not priced"})}function oe({label:t,tone:i="info",children:r}){const l=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":l,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:l,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 ei(){const t=Ot();return t.data?t.data.default_pricing?e.jsx(oe,{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(oe,{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 le({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 ti({row:t}){const i=Ne(),r=Ge(),[l,n]=c.useState(!1),[a,d]=c.useState(""),[u,f]=c.useState(""),[b,N]=c.useState(""),[$,C]=c.useState(""),[P,m]=c.useState(""),[k,y]=c.useState([]),Y=()=>{d(t.inputPrice==null?"":String(t.inputPrice)),f(t.outputPrice==null?"":String(t.outputPrice)),N(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),C(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),m(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),y(Je(t.pricingTiers)),n(!0)},M=ce(a)&&ce(u)&&w(b)&&w($)&&w(P)&&Xe(k),ue=()=>{M&&i.mutate({model_key:t.key,input_price_per_million:Number(a),output_price_per_million:Number(u),cache_read_price_per_million:V(b),cache_write_price_per_million:V($),cache_write_1h_price_per_million:V(P),pricing_tiers:Ze(k)},{onSuccess:()=>n(!1)})};return l?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:u,onChange:f,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:b,onChange:N,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:$,onChange:C,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:P,onChange:m,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(Qe,{tiers:k,onChange:y}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(I,{size:"sm",variant:"primary",isDisabled:i.isPending||!M,onPress:ue,children:"Save"}),e.jsx(I,{size:"sm",variant:"ghost",isDisabled:i.isPending,onPress:()=>n(!1),children:"Cancel"})]}),i.error?e.jsx("span",{className:"text-xs text-red-700",children:Pe(i.error)}):null]}):e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx(T,{label:"Input",value:t.inputPrice==null?"—":`${E(t.inputPrice)} / 1M`}),e.jsx(T,{label:"Output",value:t.outputPrice==null?"—":`${E(t.outputPrice)} / 1M`}),e.jsx(T,{label:"Cache read",value:t.cacheReadPrice==null?"—":`${E(t.cacheReadPrice)} / 1M`}),e.jsx(T,{label:"Cache write",value:t.cacheWritePrice==null?"—":`${E(t.cacheWritePrice)} / 1M`}),e.jsx(T,{label:"1h cache write",value:t.cacheWrite1hPrice==null?"—":`${E(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(I,{size:"sm",variant:"outline",onPress:Y,children:t.source==="configured"?"Edit price":"Set price"}),t.source==="configured"?e.jsxs(e.Fragment,{children:[e.jsx(Ue,{confirmLabel:"Reset",isPending:r.isPending,onConfirm:()=>r.mutate(t.key),children:"Reset"}),e.jsx(oe,{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:Pe(r.error)}):null]})]})}function ii({row:t,metadata:i,metadataAvailable:r,onClose:l}){const n=(i==null?void 0:i.input_modalities)??[],a=(i==null?void 0:i.output_modalities)??[],d=qt.filter(({key:u})=>i==null?void 0:i[u]);return e.jsx(ae,{children:e.jsxs(ae.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(z,{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(He,{value:t.key,label:"model id",children: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:l,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(le,{title:"Pricing",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(Qt,{source:t.source}),t.isDiscovered?null:e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"not discovered"})]}),e.jsx(ti,{row:t},t.key)]}),e.jsxs(le,{title:"Specs",children:[e.jsx(T,{label:"Context window",value:se(t.contextWindow)}),e.jsx(T,{label:"Max output",value:se((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:Et(i==null?void 0:i.release_date)}),e.jsx(T,{label:"Open weights",value:i?i.open_weights?"Yes":"No":"—"})]}),e.jsx(le,{title:"Modalities",children:n.length===0&&a.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:"}),n.map(u=>e.jsx(z,{size:"sm",color:"default",children:Ve[u]??u},u))]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1",children:[e.jsx("span",{children:"Out:"}),a.map(u=>e.jsx(z,{size:"sm",color:"default",children:Ve[u]??u},u))]})]})}),e.jsx(le,{title:"Capabilities",children:d.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:d.map(({key:u,label:f})=>e.jsx(z,{size:"sm",color:"default",children:f},u))}):e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:r?"None reported.":"Extended metadata unavailable (models.dev disabled or unreachable)."})})]})})}const ri=15;function ni({value:t,onChange:i,placeholder:r}){return e.jsx("input",{type:"search",value:t,onChange:l=>i(l.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"})}const et="otari.dashboard.modelsSort",je={col:"model",dir:"asc"},li=["model","released","input","output"];function si(){if(typeof window>"u")return je;try{const t=window.localStorage.getItem(et);if(!t)return je;const i=JSON.parse(t);if(li.includes(i.col)&&(i.dir==="asc"||i.dir==="desc"))return{col:i.col,dir:i.dir}}catch{}return je}function ai({row:t,onClose:i}){const r=Ne(),l=Ge(),[n,a]=c.useState(t.inputPrice==null?"":String(t.inputPrice)),[d,u]=c.useState(t.outputPrice==null?"":String(t.outputPrice)),[f,b]=c.useState(t.cacheReadPrice==null?"":String(t.cacheReadPrice)),[N,$]=c.useState(t.cacheWritePrice==null?"":String(t.cacheWritePrice)),[C,P]=c.useState(t.cacheWrite1hPrice==null?"":String(t.cacheWrite1hPrice)),[m,k]=c.useState(Je(t.pricingTiers)),y=ce(n)&&ce(d)&&w(f)&&w(N)&&w(C)&&Xe(m),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(f),cache_write_price_per_million:V(N),cache_write_1h_price_per_million:V(C),pricing_tiers:Ze(m)},{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:u,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:f,onChange:b,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:N,onChange:$,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:C,onChange:P,ariaLabel:`1 hour cache write price for ${t.key}`})]}),e.jsx(I,{size:"sm",variant:"primary",isDisabled:r.isPending||!y,onPress:Y,children:r.isPending?"Saving…":"Save"}),e.jsx(I,{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(Ue,{confirmLabel:"Reset",isPending:l.isPending,onConfirm:()=>l.mutate(t.key,{onSuccess:i}),children:"Reset"}),e.jsx(oe,{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||l.error?e.jsx("span",{className:"text-xs text-red-700",children:Pe(r.error??l.error)}):null]}),e.jsx(Qe,{tiers:m,onChange:k})]})}function ci({primary:t,secondary:i,rowKey:r,primaryLabel:l,secondaryLabel:n,onEdit:a}){const d=(u,f)=>e.jsx("button",{type:"button","aria-label":`Edit ${f} price for ${r}`,className:"tabular-nums hover:text-[var(--otari-brand-dark)] hover:underline",onClick:b=>{b.stopPropagation(),a()},children:u==null?"—":E(u)});return e.jsxs("span",{className:"inline-flex items-center justify-end gap-1",children:[d(t,l),e.jsx("span",{className:"text-[var(--otari-muted)]",children:"/"}),d(i,n)]})}function oi({rates:t,rowKey:i,onEdit:r}){const l=[t.cacheReadPrice==null?null:`R ${E(t.cacheReadPrice)}`,t.cacheWritePrice==null?null:`W ${E(t.cacheWritePrice)}`,t.cacheWrite1hPrice==null?null:`1h ${E(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:l.length>0?l.join(" · "):"Input-rate fallback"})}function ui({row:t,onEdit:i}){const r=[...t.pricingTiers].sort((n,a)=>n.min_input_tokens-a.min_input_tokens).map(n=>se(n.min_input_tokens)),l=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:l})}function di({rows:t,isLoading:i,empty:r,sortDescriptor:l,onSortChange:n,selectedKey:a,onSelect:d,onEditPricing:u,comparisonContextTokens:f,selectedKeys:b,onSelectionChange:N}){const $=c.useMemo(()=>{const P=f==null?"Base":`at ${se(f)}`;return[{id:"model",header:"Model",isRowHeader:!0,allowsSorting:!0,cell:m=>e.jsxs(He,{value:m.key,label:"model id",className:"font-medium break-all",children:[m.model,e.jsx("span",{className:"sr-only select-none",children:m.key})]})},{id:"provider",header:"Provider",cell:m=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:m.provider})},{id:"input",header:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[`${P} in / out $ / 1M`,e.jsx(ei,{})]}),align:"end",allowsSorting:!0,cell:m=>{const k=qe(m,f);return e.jsx(ci,{primary:k.inputPrice,secondary:k.outputPrice,rowKey:m.key,primaryLabel:"input",secondaryLabel:"output",onEdit:()=>u(m.key)})}},{id:"caching",header:`Caching ${f==null?"policy":P}`,align:"end",cell:m=>e.jsx(oi,{rates:qe(m,f),rowKey:m.key,onEdit:()=>u(m.key)})},{id:"policy",header:"Pricing policy",align:"end",cell:m=>e.jsx(ui,{row:m,onEdit:()=>u(m.key)})}]},[f,u]),C=c.useCallback(P=>P.key===a?"bg-[var(--otari-brand-tint)]":void 0,[a]);return e.jsx(Bt,{ariaLabel:"Models",columns:$,rows:t,getRowKey:Zt,isLoading:i,emptyContent:r,selectionMode:"multiple",selectedKeys:b,onSelectionChange:N,sortDescriptor:l,onSortChange:n,onRowAction:d,rowClassName:C})}function pi({providers:t,onPriceModel:i}){if(t.length===0)return null;const r=t.filter(d=>!d.discovery_unsupported),l=t.filter(d=>d.discovery_unsupported),n=d=>d.map(u=>u.provider).join(", "),a=d=>d.length===1;return e.jsxs(At,{tone:"warning",children:[r.length>0?e.jsxs("span",{className:"block",children:["Could not list ",n(r),". Check ",a(r)?"that provider's":"those providers'"," ","credentials in config.yml; ",a(r)?"its":"their"," models are missing from the list below."]}):null,l.length>0?e.jsxs("span",{className:"block",children:[n(l)," ",a(l)?"does":"do"," not offer model discovery, so"," ",a(l)?"its":"their"," models are missing from the list below."," ",a(l)?"The provider":"They"," may still serve requests. Price a model by its selector to meter it here, or declare the model ids ",a(l)?"it serves":"they serve"," under the"," ",e.jsx("code",{children:"models:"})," key in config.yml to list them all.",e.jsx(I,{size:"sm",variant:"outline",className:"mt-2",onPress:()=>i(a(l)?`${l[0].provider}:`:""),children:"Price a model"})]}):null]})}function ji(){var Oe,De,Fe;const[t]=kt(),i=Mt(),r=Wt(),l=Tt(),n=It(),a=Ne(),[d,u]=c.useState(""),[f,b]=c.useState(0),[N,$]=c.useState(ri),[C,P]=c.useState(null),[m,k]=c.useState(null),[y,Y]=c.useState(si),M=Dt(),[ue,de]=c.useState(!1),[tt,Se]=c.useState(!1),[it,Ce]=c.useState(void 0),[pe,Z]=c.useState(null),[rt,ke]=c.useState(!1),[nt,Me]=c.useState(void 0);c.useEffect(()=>{try{window.localStorage.setItem(et,JSON.stringify(y))}catch{}},[y]);const[O,We]=c.useState(t.get("provider")||"all"),[K,lt]=c.useState("all"),[q,st]=c.useState("all"),[H,at]=c.useState("all"),[me,ct]=c.useState("0"),[Q,ot]=c.useState(""),[ee,ut]=c.useState("all"),[he,dt]=c.useState(""),D=((Oe=n.data)==null?void 0:Oe.models)??{},pt=((De=n.data)==null?void 0:De.available)??!1,Te=c.useMemo(()=>{var s;return new Set((((s=l.data)==null?void 0:s.providers)??[]).flatMap(h=>h.models.map(o=>o.key)))},[l.data]),mt=s=>{u(s),b(0)},B=s=>h=>{s(h),b(0)},te=c.useMemo(()=>{var W,_,v,A,L,R,Ke;const s=new Map(zt(r.data??[]).map(p=>[p.model_key,p])),h=[],o=new Set,g=(p,ye,Be,x)=>{if(o.has(p))return;o.add(p);const S=s.get(p);h.push({key:p,model:Ye(ye,Be),provider:Be,isDiscovered:Te.has(p),contextWindow:(x==null?void 0:x.contextWindow)??null,inputPrice:S?S.input_price_per_million:(x==null?void 0:x.inputPrice)??null,outputPrice:S?S.output_price_per_million:(x==null?void 0:x.outputPrice)??null,cacheReadPrice:S?S.cache_read_price_per_million:(x==null?void 0:x.cacheReadPrice)??null,cacheWritePrice:S?S.cache_write_price_per_million:(x==null?void 0:x.cacheWritePrice)??null,cacheWrite1hPrice:S?S.cache_write_1h_price_per_million??null:(x==null?void 0:x.cacheWrite1hPrice)??null,pricingTiers:S?S.pricing_tiers??[]:(x==null?void 0:x.pricingTiers)??[],source:S?"configured":(x==null?void 0:x.source)??"none"})};for(const p of((W=i.data)==null?void 0:W.data)??[]){if(p.owned_by===Yt)continue;const ye=p.pricing_source==="default"?"default":p.pricing?"configured":"none";g(p.id,p.id,p.owned_by||we(p.id),{key:p.id,model:p.id,provider:p.owned_by,contextWindow:p.context_window,inputPrice:((_=p.pricing)==null?void 0:_.input_price_per_million)??null,outputPrice:((v=p.pricing)==null?void 0:v.output_price_per_million)??null,cacheReadPrice:((A=p.pricing)==null?void 0:A.cache_read_price_per_million)??null,cacheWritePrice:((L=p.pricing)==null?void 0:L.cache_write_price_per_million)??null,cacheWrite1hPrice:((R=p.pricing)==null?void 0:R.cache_write_1h_price_per_million)??null,pricingTiers:((Ke=p.pricing)==null?void 0:Ke.pricing_tiers)??[],source:ye})}for(const p of s.keys())p.startsWith(`${Vt}:`)||g(p,p,we(p));return h},[i.data,r.data,Te]),Ie=c.useMemo(()=>new Map(te.map(s=>[s.key,s])),[te]),ie=c.useMemo(()=>{var o,g,W;const s=te.map(_=>{var v,A;return{..._,contextWindow:_.contextWindow??((v=D[_.key])==null?void 0:v.context_window)??null,releaseDate:((A=D[_.key])==null?void 0:A.release_date)??null}}),h=new Set(s.map(_=>_.key));for(const _ of((o=l.data)==null?void 0:o.providers)??[])for(const v of _.models)h.has(v.key)||(h.add(v.key),s.push({key:v.key,model:Ye(v.key,_.provider),provider:_.provider,isDiscovered:!0,contextWindow:((g=D[v.key])==null?void 0:g.context_window)??null,releaseDate:((W=D[v.key])==null?void 0:W.release_date)??null,inputPrice:null,outputPrice:null,cacheReadPrice:null,cacheWritePrice:null,cacheWrite1hPrice:null,pricingTiers:[],source:"none"}));return s},[te,l.data,D]),ht=(((Fe=l.data)==null?void 0:Fe.providers)??[]).filter(s=>!s.ok),re=c.useMemo(()=>{const s=Array.from(new Set(ie.map(h=>h.provider))).sort((h,o)=>h.localeCompare(o));return[{value:"all",label:"All providers"},...s.map(h=>({value:h,label:h}))]},[ie]);c.useEffect(()=>{O==="all"||re.length<=1||re.some(s=>s.value===O)||We("all")},[re,O]);const G=d.trim().toLowerCase(),xe=Number(me)||0,fe=Q===""?Number.POSITIVE_INFINITY:Number(Q),xt=he===""?null:Number(he),ge=ee==="all"?null:Date.now()-Number(ee)*Xt,U=c.useMemo(()=>{const s=o=>{if(G&&!o.key.toLowerCase().includes(G)&&!o.provider.toLowerCase().includes(G)||O!=="all"&&o.provider!==O||K==="configured"&&o.source!=="configured"||K==="default"&&o.source!=="default"||K==="priced"&&o.inputPrice==null||K==="unpriced"&&o.inputPrice!=null||q==="discovered"&&!o.isDiscovered||q==="custom"&&o.isDiscovered)return!1;if(H!=="all"){const g=ze.find(_=>_.value===H),W=D[o.key];if(!g||!W||!g.test(W))return!1}if(xe>0&&(o.contextWindow==null||o.contextWindowfe))return!1;if(ge!=null){const g=o.releaseDate?Date.parse(o.releaseDate):Number.NaN;if(Number.isNaN(g)||g{const W=y.dir==="asc"?1:-1;if(y.col==="model")return o.model.localeCompare(g.model)*W;if(y.col==="released"){const L=o.releaseDate??null,R=g.releaseDate??null;return!L&&!R?o.model.localeCompare(g.model):L?R?(LR?1:0)*W||o.model.localeCompare(g.model):-1:1}const _=L=>y.col==="input"?L.inputPrice:L.outputPrice,v=_(o),A=_(g);return v==null&&A==null?o.model.localeCompare(g.model):v==null?1:A==null?-1:(v-A)*W||o.model.localeCompare(g.model)};return ie.filter(s).sort(h)},[ie,G,O,K,q,H,xe,fe,ge,D,y]),J=U.length,ft=Math.max(1,Math.ceil(J/N)),Le=Math.min(f,ft-1),$e=Le*N,_e=U.slice($e,$e+N),gt={column:y.col,direction:y.dir==="asc"?"ascending":"descending"},_t=s=>{Y({col:String(s.column),dir:s.direction==="ascending"?"asc":"desc"}),b(0)},vt=c.useCallback(s=>P(h=>h===s?null:s),[]),ve=_e.map(s=>s.key),X=Ft(M.selectedKeys,ve),bt=ve.length>0&&X.length===ve.length&&J>X.length,yt=M.allMatching?U.map(s=>s.key):X,Ae=M.allMatching?J:X.length,Ee=C?U.find(s=>s.key===C)??Ie.get(C)??null:null,jt=async s=>{Se(!0),Ce(void 0);try{for(const h of yt)await a.mutateAsync({model_key:h,input_price_per_million:s.input_price_per_million,output_price_per_million:s.output_price_per_million,cache_read_price_per_million:s.cache_read_price_per_million??null,cache_write_price_per_million:s.cache_write_price_per_million??null,cache_write_1h_price_per_million:null,pricing_tiers:[]});M.clear(),de(!1)}catch(h){Ce(h)}finally{Se(!1)}},Pt=async(s,h)=>{ke(!0),Me(void 0);try{const o=await a.mutateAsync({model_key:h,input_price_per_million:s.input_price_per_million,output_price_per_million:s.output_price_per_million,cache_read_price_per_million:s.cache_read_price_per_million??null,cache_write_price_per_million:s.cache_write_price_per_million??null});Z(null),k(o.model_key)}catch(o){Me(o)}finally{ke(!1)}},Nt=i.isLoading||r.isLoading||l.isLoading,St=G!==""||O!=="all"||K!=="all"||q!=="all"||H!=="all"||me!=="0"||Q!==""||ee!=="all",be=Rt(d)?d.trim():null,Ct=e.jsxs("div",{className:"flex flex-col items-center gap-2 py-2",children:[e.jsx("span",{children:St?"No models match your filters.":"No models yet. Add a provider on the Providers page."}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"A provider that serves no model listing still answers requests, so a model you can call may not be listed here."}),e.jsx(I,{size:"sm",variant:"outline",onPress:()=>Z(be??""),children:be?`Price ${be}`:"Price a model by hand"})]}),ne=m?U.find(s=>s.key===m)??Ie.get(m)??null:null;return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Lt,{title:"Models",description:"Every model your providers can serve. Set a price on any model so budgets and usage tracking work."}),e.jsx($t,{error:i.error??r.error??l.error??n.error}),e.jsxs("div",{className:`grid gap-4 lg:items-start ${ne?"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(ni,{value:d,onChange:mt,placeholder:"Search models…"}),e.jsx(F,{ariaLabel:"Filter by provider",value:O,onChange:B(We),options:re}),e.jsx(F,{ariaLabel:"Filter by pricing",value:K,onChange:B(lt),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(F,{ariaLabel:"Filter by source",value:q,onChange:B(st),options:[{value:"all",label:"Any source"},{value:"discovered",label:"Discovered"},{value:"custom",label:"Custom (not discovered)"}]}),e.jsx(F,{ariaLabel:"Filter by capability",value:H,onChange:B(at),options:[{value:"all",label:"Any capability"},...ze.map(s=>({value:s.value,label:s.label}))]}),e.jsx(F,{ariaLabel:"Minimum context window",value:me,onChange:B(ct),options:Ht}),e.jsx(F,{ariaLabel:"Maximum input price",value:Q,onChange:B(ot),options:Gt}),e.jsx(F,{ariaLabel:"Compare prices at context",value:he,onChange:dt,options:Ut}),e.jsx(F,{ariaLabel:"Filter by release date",value:ee,onChange:B(ut),options:Jt})]}),e.jsx(pi,{providers:ht,onPriceModel:Z}),X.length>0?e.jsx(Kt,{selectedCount:Ae,allMatching:M.allMatching,matchingTotal:J,canSelectAllMatching:bt,onSelectAllMatching:M.enableAllMatching,onClear:M.clear,children:e.jsx(I,{size:"sm",variant:"primary",onPress:()=>de(!0),children:"Set pricing"})}):null,e.jsx(di,{rows:_e,isLoading:Nt,empty:Ct,sortDescriptor:gt,onSortChange:_t,selectedKey:m,onSelect:k,onEditPricing:vt,comparisonContextTokens:xt,selectedKeys:M.selectedKeys,onSelectionChange:M.onSelectionChange}),Ee?e.jsx(ae,{children:e.jsxs(ae.Content,{className:"p-0",children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Edit pricing"}),e.jsx(I,{size:"sm",variant:"ghost",onPress:()=>P(null),children:"Close"})]}),e.jsx(ai,{row:Ee,onClose:()=>P(null)})]})}):null,e.jsx(wt,{page:Le,pageSize:N,total:J,rowsOnPage:_e.length,onPageChange:b,onPageSizeChange:s=>{$(s),b(0)},pageSizeOptions:[15,25,50]})]}),ne?e.jsx("aside",{className:"lg:sticky lg:top-4",children:e.jsx(ii,{row:ne,metadata:D[ne.key],metadataAvailable:pt,onClose:()=>k(null)})}):null]}),e.jsx(Re,{isOpen:ue,onOpenChange:de,targetCount:Ae,isPending:tt,error:it,onSubmit:jt,title:"Set pricing",description:s=>`Apply these per-1M rates to ${s.toLocaleString()} selected ${s===1?"model":"models"}. This replaces each model's price; pricing tiers and the 1h cache rate are cleared. Edit a single model for tiers.`}),e.jsx(Re,{isOpen:pe!==null,onOpenChange:s=>Z(s?pe??"":null),isPending:rt,error:nt,onSubmit:Pt,collectModelKey:!0,initialModelKey:pe??"",title:"Price a model",description:()=>"Meter a model the catalogue does not list, for a provider that serves no model listing. Type the selector you send as model and its rates; the model then appears here as custom, and its usage is costed and counted against budgets."})]})}export{ji as ModelsPage}; diff --git a/src/gateway/static/dashboard/assets/OverviewPage-W9tjAThu.js b/src/gateway/static/dashboard/assets/OverviewPage-CvMKYScf.js similarity index 85% rename from src/gateway/static/dashboard/assets/OverviewPage-W9tjAThu.js rename to src/gateway/static/dashboard/assets/OverviewPage-CvMKYScf.js index bccd4ccd..769b8ff4 100644 --- a/src/gateway/static/dashboard/assets/OverviewPage-W9tjAThu.js +++ b/src/gateway/static/dashboard/assets/OverviewPage-CvMKYScf.js @@ -1 +1 @@ -import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as y,i as it,N as J}from"./react-dgEcD0HR.js";import{a3 as lt,a4 as dt,g as R,a5 as ct,I as ut,c as mt,u as vt,d as ht,a6 as _,P as xt,R as ft,E as Q,a7 as g,a8 as k,a9 as E,aa as D,ab as O,ac as gt,ad as C}from"./index-DAnS9oY2.js";import{S as W}from"./charts-krq1PqQO.js";import{D as pt}from"./DataTable-DuDxGlJc.js";import{d as I,B as bt}from"./heroui-COmYdDDM.js";import"./recharts-C3cGlHOx.js";function G(e){return e==="neutral"?void 0:e}const yt=.02,jt=.1;function T(e){if(!e||e.request_count===0)return{rate:null,status:"neutral"};const l=e.error_count/e.request_count,a=l>=jt?"alert":l>=yt?"warn":"ok";return{rate:l,status:a}}function wt(e){return!e||e.total===0?"neutral":e.healthy>=e.total?"ok":e.healthy+e.degraded===0?"alert":"warn"}const St=.8;function Nt(e){if(e.length===0)return{status:"neutral",label:"No budgets configured",overCount:0,nearCount:0,cappedCount:0};const l=e.filter(i=>i.max_budget!==null&&i.user_count>0);if(l.length===0)return{status:"neutral",label:"No capped budgets",overCount:0,nearCount:0,cappedCount:0};let a=0,n=0,r,p=-1;for(const i of l){const s=i.max_budget*i.user_count,o=s>0?i.total_spend/s:0;o>=1?a+=1:o>=St&&(n+=1),o>p&&(p=o,r={name:i.name??i.budget_id,spent:i.total_spend,allocated:s,pct:o})}const b=a>0?"alert":n>0?"warn":"ok",j=a>0?`${a} over limit`:n>0?`${n} near limit`:"All within budget";return{status:b,label:j,overCount:a,nearCount:n,cappedCount:l.length,worst:r}}const Y=864e5,V=30;function z(){const e=new Date;return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`}function Rt(){const[e,l]=y.useState(z);return y.useEffect(()=>{const a=()=>{if(document.visibilityState==="visible"){const n=z();l(r=>r===n?r:n)}};return document.addEventListener("visibilitychange",a),window.addEventListener("focus",a),()=>{document.removeEventListener("visibilitychange",a),window.removeEventListener("focus",a)}},[]),y.useMemo(()=>{const a=Date.now(),n=new Date(a);return{today:new Date(n.getFullYear(),n.getMonth(),n.getDate()).toISOString(),periodStart:new Date(a-V*Y).toISOString(),prevStart:new Date(a-2*V*Y).toISOString()}},[e])}const _t={ok:"Healthy",warn:"Elevated",alert:"High"},Et={ok:"On track",warn:"Near limit",alert:"Over budget"};function Mt(){const e=lt();return e.isLoading?t.jsx(dt,{}):t.jsx(Dt,{needsSetup:e.isSuccess&&e.data.providers.length===0,setupError:e.error,refreshSetup:e.refetch,setupFetching:e.isFetching})}function Dt({needsSetup:e=!1,setupError:l,refreshSetup:a,setupFetching:n=!1}){var A,P,q,B,H,U,M,K;const r=Rt(),p=y.useMemo(()=>({start_date:r.today}),[r]),b=y.useMemo(()=>({start_date:r.periodStart}),[r]),j=y.useMemo(()=>({start_date:r.prevStart,end_date:r.periodStart}),[r]),i=R(p,"hour",C),s=R(b,"day",C),o=R(j,"day",C),d=ct(),c=ut(),w=mt(),S=vt(),h=ht({},0,5),F=(A=i.data)==null?void 0:A.totals,v=(P=s.data)==null?void 0:P.totals,x=(q=o.data)==null?void 0:q.totals,N=((B=s.data)==null?void 0:B.series)??[],L=N.length>1,u=T(v),$=T(x),X=u.rate!==null&&$.rate!==null?_(u.rate,$.rate):null,m=Nt(c.data??[]),Z=wt(d.data),tt=(w.data??[]).filter(f=>f.is_active).length,et=(S.data??[]).filter(f=>!f.blocked).length,rt=(((H=h.data)==null?void 0:H.length)??0)>0,at=e&&h.isSuccess&&!rt,st=l??i.error??s.error??d.error??c.error??w.error??S.error,nt=()=>{a==null||a(),i.refetch(),s.refetch(),o.refetch(),d.refetch(),c.refetch(),w.refetch(),S.refetch(),h.refetch()},ot=n||i.isFetching||s.isFetching||o.isFetching||d.isFetching||c.isFetching||w.isFetching||S.isFetching||h.isFetching;return t.jsxs("div",{className:"flex flex-col gap-6",children:[t.jsx(xt,{title:"Overview",description:"At-a-glance spend, traffic, and health across the gateway.",action:t.jsx(ft,{onRefresh:nt,isFetching:ot,updatedAt:s.dataUpdatedAt})}),at?t.jsx(Ct,{}):null,t.jsx(Q,{error:st}),t.jsx(Ot,{providerHealth:Z,healthy:((U=d.data)==null?void 0:U.healthy)??0,degraded:((M=d.data)==null?void 0:M.degraded)??0,total:((K=d.data)==null?void 0:K.total)??0,budget:m,errStatus:u.status,errRate:u.rate,ready:d.isSuccess&&c.isSuccess&&s.isSuccess,failed:d.isError||c.isError||s.isError}),t.jsxs("div",{className:"grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4",children:[t.jsx(g,{label:"Spend today",value:F?k(F.cost):"—"}),t.jsx(g,{label:"Spend, last 30 days",value:v?k(v.cost):"—",hint:v?t.jsx(E,{fraction:_(v.cost,x==null?void 0:x.cost)}):null,chart:L?t.jsx(W,{values:N.map(f=>f.cost),ariaLabel:"Spend trend over the last 30 days"}):void 0}),t.jsx(g,{label:"Requests, last 30 days",value:v?D(v.request_count):"—",hint:v?t.jsx(E,{fraction:_(v.request_count,x==null?void 0:x.request_count)}):null,chart:L?t.jsx(W,{values:N.map(f=>f.requests),ariaLabel:"Request volume trend over the last 30 days"}):void 0}),t.jsx(g,{label:"Error rate, last 30 days",value:u.rate===null?"—":O(u.rate),status:G(u.status),statusLabel:u.status==="neutral"?void 0:_t[u.status],hint:u.rate!==null?t.jsx(E,{fraction:X}):null}),t.jsx(g,{label:"Budget health",value:c.data&&m.worst?O(m.worst.pct):"—",status:c.data?G(m.status):void 0,statusLabel:c.data&&m.status!=="neutral"?Et[m.status]:void 0,hint:c.data?m.worst?`${m.label} · worst: ${m.worst.name}`:m.label:void 0,to:"/budgets"}),t.jsx(g,{label:"Active keys",value:w.data?D(tt):"—",to:"/keys"}),t.jsx(g,{label:"Active users",value:S.data?D(et):"—",to:"/users"})]}),t.jsx(Lt,{entries:h.data??[],loading:h.isLoading,error:h.error})]})}function Ct(){const e=it();return t.jsx(I,{children:t.jsxs(I.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(bt,{variant:"primary",onPress:()=>e("/providers"),children:"Add your first provider"})})]})})}function kt({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 Ot({providerHealth:e,healthy:l,degraded:a,total:n,budget:r,errStatus:p,errRate:b,ready:j,failed:i}){if(i)return t.jsx(kt,{text:"Some status data could not be loaded."});if(!j)return null;const s=[];if((e==="warn"||e==="alert")&&n>0){const o=n-l-a;o>0&&s.push({text:`${o} provider${o===1?"":"s"} unreachable`,to:"/providers"}),a>0&&s.push({text:`${a} provider${a===1?"":"s"} without model discovery`,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"}),p==="alert"&&b!==null&&s.push({text:`error rate ${O(b)}`,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,d)=>t.jsxs("span",{className:"flex items-center gap-2",children:[d>0?t.jsx("span",{"aria-hidden":!0,className:"text-amber-400",children:"·"}):null,t.jsx(J,{to:o.to,className:"underline underline-offset-2 hover:text-amber-950",children:o.text})]},o.to+o.text))]})}function Ft(e){return e==="error"?"error":e==="absorbed"?"absorbed":"ok"}function Lt({entries:e,loading:l,error:a}){const n=[{id:"time",header:"Time",cell:r=>t.jsx("span",{className:"text-[var(--otari-muted)]",title:new Date(r.timestamp).toLocaleString(),children:gt(r.timestamp)})},{id:"model",header:"Model",isRowHeader:!0,cell:r=>t.jsx("span",{className:"text-[var(--otari-ink)]",children:r.model})},{id:"cost",header:"Cost",align:"end",cell:r=>r.cost===null?"—":k(r.cost)},{id:"status",header:"Status",cell:r=>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":r.status==="absorbed"?"border-amber-200 bg-amber-50 text-amber-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]"}`,children:Ft(r.status)})}];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(J,{to:"/activity",className:"text-sm text-[var(--otari-brand-dark)] hover:underline",children:"View all →"})]}),t.jsx(Q,{error:a}),t.jsx(pt,{ariaLabel:"Recent activity",columns:n,rows:e,getRowKey:r=>r.id,isLoading:l,emptyContent:"No requests yet. Once the gateway serves traffic, it appears here."})]})}export{Mt as OverviewIndex,Dt as OverviewPage,z as localDayKey}; +import{j as t}from"./tanstack-query-1t81HyiD.js";import{r as y,i as it,N as J}from"./react-dgEcD0HR.js";import{a3 as lt,a4 as dt,d as R,a5 as ct,H as ut,O as mt,p as vt,u as ht,a6 as _,P as xt,R as ft,E as Q,a7 as g,a8 as O,a9 as E,aa as D,ab as k,ac as gt,ad as C}from"./index-D6WO6K2k.js";import{S as W}from"./charts-krq1PqQO.js";import{D as pt}from"./DataTable-DuDxGlJc.js";import{d as G,B as bt}from"./heroui-COmYdDDM.js";import"./recharts-C3cGlHOx.js";function I(e){return e==="neutral"?void 0:e}const yt=.02,jt=.1;function T(e){if(!e||e.request_count===0)return{rate:null,status:"neutral"};const l=e.error_count/e.request_count,a=l>=jt?"alert":l>=yt?"warn":"ok";return{rate:l,status:a}}function wt(e){return!e||e.total===0?"neutral":e.healthy>=e.total?"ok":e.healthy+e.degraded===0?"alert":"warn"}const St=.8;function Nt(e){if(e.length===0)return{status:"neutral",label:"No budgets configured",overCount:0,nearCount:0,cappedCount:0};const l=e.filter(i=>i.max_budget!==null&&i.user_count>0);if(l.length===0)return{status:"neutral",label:"No capped budgets",overCount:0,nearCount:0,cappedCount:0};let a=0,n=0,r,p=-1;for(const i of l){const s=i.max_budget*i.user_count,o=s>0?i.total_spend/s:0;o>=1?a+=1:o>=St&&(n+=1),o>p&&(p=o,r={name:i.name??i.budget_id,spent:i.total_spend,allocated:s,pct:o})}const b=a>0?"alert":n>0?"warn":"ok",j=a>0?`${a} over limit`:n>0?`${n} near limit`:"All within budget";return{status:b,label:j,overCount:a,nearCount:n,cappedCount:l.length,worst:r}}const Y=864e5,V=30;function z(){const e=new Date;return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`}function Rt(){const[e,l]=y.useState(z);return y.useEffect(()=>{const a=()=>{if(document.visibilityState==="visible"){const n=z();l(r=>r===n?r:n)}};return document.addEventListener("visibilitychange",a),window.addEventListener("focus",a),()=>{document.removeEventListener("visibilitychange",a),window.removeEventListener("focus",a)}},[]),y.useMemo(()=>{const a=Date.now(),n=new Date(a);return{today:new Date(n.getFullYear(),n.getMonth(),n.getDate()).toISOString(),periodStart:new Date(a-V*Y).toISOString(),prevStart:new Date(a-2*V*Y).toISOString()}},[e])}const _t={ok:"Healthy",warn:"Elevated",alert:"High"},Et={ok:"On track",warn:"Near limit",alert:"Over budget"};function Mt(){const e=lt();return e.isLoading?t.jsx(dt,{}):t.jsx(Dt,{needsSetup:e.isSuccess&&e.data.providers.length===0,setupError:e.error,refreshSetup:e.refetch,setupFetching:e.isFetching})}function Dt({needsSetup:e=!1,setupError:l,refreshSetup:a,setupFetching:n=!1}){var A,P,H,q,B,U,M,K;const r=Rt(),p=y.useMemo(()=>({start_date:r.today}),[r]),b=y.useMemo(()=>({start_date:r.periodStart}),[r]),j=y.useMemo(()=>({start_date:r.prevStart,end_date:r.periodStart}),[r]),i=R(p,"hour",C),s=R(b,"day",C),o=R(j,"day",C),d=ct(),c=ut(),w=mt(),S=vt(),h=ht({},0,5),F=(A=i.data)==null?void 0:A.totals,v=(P=s.data)==null?void 0:P.totals,x=(H=o.data)==null?void 0:H.totals,N=((q=s.data)==null?void 0:q.series)??[],L=N.length>1,u=T(v),$=T(x),X=u.rate!==null&&$.rate!==null?_(u.rate,$.rate):null,m=Nt(c.data??[]),Z=wt(d.data),tt=(w.data??[]).filter(f=>f.is_active).length,et=(S.data??[]).filter(f=>!f.blocked).length,rt=(((B=h.data)==null?void 0:B.length)??0)>0,at=e&&h.isSuccess&&!rt,st=l??i.error??s.error??d.error??c.error??w.error??S.error,nt=()=>{a==null||a(),i.refetch(),s.refetch(),o.refetch(),d.refetch(),c.refetch(),w.refetch(),S.refetch(),h.refetch()},ot=n||i.isFetching||s.isFetching||o.isFetching||d.isFetching||c.isFetching||w.isFetching||S.isFetching||h.isFetching;return t.jsxs("div",{className:"flex flex-col gap-6",children:[t.jsx(xt,{title:"Overview",description:"At-a-glance spend, traffic, and health across the gateway.",action:t.jsx(ft,{onRefresh:nt,isFetching:ot,updatedAt:s.dataUpdatedAt})}),at?t.jsx(Ct,{}):null,t.jsx(Q,{error:st}),t.jsx(kt,{providerHealth:Z,healthy:((U=d.data)==null?void 0:U.healthy)??0,degraded:((M=d.data)==null?void 0:M.degraded)??0,total:((K=d.data)==null?void 0:K.total)??0,budget:m,errStatus:u.status,errRate:u.rate,ready:d.isSuccess&&c.isSuccess&&s.isSuccess,failed:d.isError||c.isError||s.isError}),t.jsxs("div",{className:"grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4",children:[t.jsx(g,{label:"Spend today",value:F?O(F.cost):"—"}),t.jsx(g,{label:"Spend, last 30 days",value:v?O(v.cost):"—",hint:v?t.jsx(E,{fraction:_(v.cost,x==null?void 0:x.cost)}):null,chart:L?t.jsx(W,{values:N.map(f=>f.cost),ariaLabel:"Spend trend over the last 30 days"}):void 0}),t.jsx(g,{label:"Requests, last 30 days",value:v?D(v.request_count):"—",hint:v?t.jsx(E,{fraction:_(v.request_count,x==null?void 0:x.request_count)}):null,chart:L?t.jsx(W,{values:N.map(f=>f.requests),ariaLabel:"Request volume trend over the last 30 days"}):void 0}),t.jsx(g,{label:"Error rate, last 30 days",value:u.rate===null?"—":k(u.rate),status:I(u.status),statusLabel:u.status==="neutral"?void 0:_t[u.status],hint:u.rate!==null?t.jsx(E,{fraction:X}):null}),t.jsx(g,{label:"Budget health",value:c.data&&m.worst?k(m.worst.pct):"—",status:c.data?I(m.status):void 0,statusLabel:c.data&&m.status!=="neutral"?Et[m.status]:void 0,hint:c.data?m.worst?`${m.label} · worst: ${m.worst.name}`:m.label:void 0,to:"/budgets"}),t.jsx(g,{label:"Active keys",value:w.data?D(tt):"—",to:"/keys"}),t.jsx(g,{label:"Active users",value:S.data?D(et):"—",to:"/users"})]}),t.jsx(Lt,{entries:h.data??[],loading:h.isLoading,error:h.error})]})}function Ct(){const e=it();return t.jsx(G,{children:t.jsxs(G.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(bt,{variant:"primary",onPress:()=>e("/providers"),children:"Add your first provider"})})]})})}function Ot({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 kt({providerHealth:e,healthy:l,degraded:a,total:n,budget:r,errStatus:p,errRate:b,ready:j,failed:i}){if(i)return t.jsx(Ot,{text:"Some status data could not be loaded."});if(!j)return null;const s=[];if((e==="warn"||e==="alert")&&n>0){const o=n-l-a;o>0&&s.push({text:`${o} provider${o===1?"":"s"} unreachable`,to:"/providers"}),a>0&&s.push({text:`${a} provider${a===1?"":"s"} without model discovery`,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"}),p==="alert"&&b!==null&&s.push({text:`error rate ${k(b)}`,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,d)=>t.jsxs("span",{className:"flex items-center gap-2",children:[d>0?t.jsx("span",{"aria-hidden":!0,className:"text-amber-400",children:"·"}):null,t.jsx(J,{to:o.to,className:"underline underline-offset-2 hover:text-amber-950",children:o.text})]},o.to+o.text))]})}function Ft(e){return e==="error"?"error":e==="absorbed"?"absorbed":"ok"}function Lt({entries:e,loading:l,error:a}){const n=[{id:"time",header:"Time",cell:r=>t.jsx("span",{className:"text-[var(--otari-muted)]",title:new Date(r.timestamp).toLocaleString(),children:gt(r.timestamp)})},{id:"model",header:"Model",isRowHeader:!0,cell:r=>t.jsx("span",{className:"text-[var(--otari-ink)]",children:r.model})},{id:"cost",header:"Cost",align:"end",cell:r=>r.cost===null?"—":O(r.cost)},{id:"status",header:"Status",cell:r=>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":r.status==="absorbed"?"border-amber-200 bg-amber-50 text-amber-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]"}`,children:Ft(r.status)})}];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(J,{to:"/activity",className:"text-sm text-[var(--otari-brand-dark)] hover:underline",children:"View all →"})]}),t.jsx(Q,{error:a}),t.jsx(pt,{ariaLabel:"Recent activity",columns:n,rows:e,getRowKey:r=>r.id,isLoading:l,emptyContent:"No requests yet. Once the gateway serves traffic, it appears here."})]})}export{Mt as OverviewIndex,Dt as OverviewPage,z as localDayKey}; diff --git a/src/gateway/static/dashboard/assets/ProvidersPage-CkpZWNPU.js b/src/gateway/static/dashboard/assets/ProvidersPage-CKBJgQmn.js similarity index 98% rename from src/gateway/static/dashboard/assets/ProvidersPage-CkpZWNPU.js rename to src/gateway/static/dashboard/assets/ProvidersPage-CKBJgQmn.js index 745724fc..09f11c4f 100644 --- a/src/gateway/static/dashboard/assets/ProvidersPage-CkpZWNPU.js +++ b/src/gateway/static/dashboard/assets/ProvidersPage-CKBJgQmn.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as u,L as ee}from"./react-dgEcD0HR.js";import{a3 as te,ae as se,a1 as re,a5 as ae,af as ne,ag as ie,ah as oe,P as le,E as O,N as de,ai as ce,aj as ue,ac as U,z as me,ak as J,al as xe,$ as Y,am as pe,an as he}from"./index-DAnS9oY2.js";import{F as _}from"./Field-CBU9MRjz.js";import{D as ve}from"./DataTable-DuDxGlJc.js";import{B as j,d as A,g as ge,e as V,L as D,I as G,D as W,h as fe,S as je,C as K,a as be,b as ye}from"./heroui-COmYdDDM.js";function L({value:t,onChange:r,label:s,placeholder:n,description:o}){return e.jsxs(V,{value:t,onChange:r,className:"flex max-w-md flex-col gap-1",children:[e.jsx(D,{className:"text-sm font-medium text-[var(--otari-ink)]",children:s}),e.jsx(G,{type:"password",placeholder:n??"sk-…",autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",spellCheck:!1,"data-1p-ignore":!0,"data-lpignore":"true"}),o?e.jsx(W,{className:"text-xs text-[var(--otari-muted)]",children:o}):null]})}function q(t){const r=t.trim();if(r==="")return{ok:!0,value:null};let s;try{s=JSON.parse(r)}catch{return{ok:!1,error:"Not valid JSON."}}return s===null||typeof s!="object"||Array.isArray(s)?{ok:!1,error:'Must be a JSON object, like {"timeout": 1800}.'}:{ok:!0,value:s}}function ke(t){return t&&Object.keys(t).length>0?JSON.stringify(t,null,2):""}function $({value:t,onChange:r,error:s}){return e.jsxs(V,{value:t,onChange:r,isInvalid:s!==null,className:"flex max-w-md flex-col gap-1",children:[e.jsx(D,{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Client options (JSON)"}),e.jsx(fe,{rows:3,placeholder:'{"timeout": 1800}',spellCheck:!1,className:"font-mono text-xs"}),e.jsx(W,{className:s?"text-xs text-red-700":"text-xs text-[var(--otari-muted)]",children:s??"Passed to the provider's client, e.g. a request timeout in seconds or custom headers. Stored in plain text, so keep secrets out."})]})}function Q({label:t,value:r,onChange:s,description:n,placeholder:o,extra:c=[],includeCatalog:d=!0}){var x;const b=pe(),h=u.useMemo(()=>d?[...c,...(b.data??[]).map(i=>({id:i.id,name:i.name}))]:c,[b.data,c,d]),[f,m]=u.useState(()=>{var i;return((i=h.find(l=>l.id===r))==null?void 0:i.name)??""}),y=((x=h.find(i=>i.id===r))==null?void 0:x.name)??"",p=f.trim()===y.trim()?"":f.trim().toLowerCase(),k=h.filter(i=>!p||i.name.toLowerCase().includes(p)||i.id.toLowerCase().includes(p)).slice(0,50);return e.jsxs(K.Root,{allowsEmptyCollection:!0,menuTrigger:"focus",inputValue:f,onInputChange:m,onSelectionChange:i=>{var l;i!=null?(s(String(i)),m(((l=h.find(C=>C.id===String(i)))==null?void 0:l.name)??"")):(s(""),m(""))},className:"flex max-w-md flex-col gap-1",children:[e.jsx(D,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsxs(K.InputGroup,{children:[e.jsx(G,{placeholder:o??"Search providers…",autoComplete:"off","data-1p-ignore":!0,"data-lpignore":"true",onFocus:i=>i.currentTarget.select()}),e.jsx(K.Trigger,{})]}),e.jsx(K.Popover,{children:e.jsx(be,{items:k,className:"max-h-72 overflow-auto",children:i=>e.jsx(ye,{id:i.id,textValue:i.name,children:i.name})})}),n?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:n}):null]})}function X({getPayload:t}){const r=he(),s=t();return e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsx(j,{variant:"outline",isDisabled:s===null||r.isPending,onPress:()=>{s&&r.mutate(s)},children:r.isPending?"Testing…":"Test connection"}),e.jsx("span",{role:"status","aria-live":"polite",children:r.isPending?null:r.error?e.jsx("span",{className:"text-xs text-red-700",children:Y(r.error)}):r.data?r.data.ok?e.jsxs("span",{className:"text-xs font-medium text-green-700",children:["Connected. ",r.data.model_count," model",r.data.model_count===1?"":"s"," available."]}):r.data.discovery_unsupported?e.jsxs("span",{className:"block max-w-md break-words text-xs text-amber-800",children:["This provider does not list models, so the key could not be verified here. Save it and use the provider; declare its model ids under ",e.jsx("code",{children:"models:"})," to have them show up in the catalogue. If you did not expect this, check the provider's reply below.",r.data.error?e.jsx("span",{className:"mt-0.5 block text-[var(--otari-muted)]",children:r.data.error}):null]}):e.jsx("span",{className:"block max-w-md break-words text-xs text-red-700",children:r.data.error??"Connection failed."}):null})]})}function Ne({onClose:t}){var E;const r=J(),[s,n]=u.useState(""),[o,c]=u.useState(""),[d,b]=u.useState(!1),[h,f]=u.useState(""),[m,y]=u.useState(""),[p,k]=u.useState(""),x=q(p),i=xe(s),l=((E=i.data)==null?void 0:E.id)===s?i.data:void 0;u.useEffect(()=>{l&&f(l.default_api_base??"")},[l]);const C=(l==null?void 0:l.env_key_present)??!1,N=((l==null?void 0:l.requires_api_key)??!0)&&!C,P=m.trim()!==""&&m.trim()!==s,S=/[:/]/.test(m),w=s!==""&&!S&&(!N||o.trim()!=="")&&x.ok&&!r.isPending,T=d||!x.ok||S,R=()=>{!w||!x.ok||r.mutate({instance:P?m.trim():s,provider_type:P?s:null,api_base:h.trim()||null,api_key:o.trim()||null,client_args:x.value},{onSuccess:t})};return e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(O,{error:r.error}),e.jsx(Q,{label:"Provider",value:s,onChange:I=>{n(I),y(""),f("")},description:"Its endpoint is built in."}),e.jsx(L,{value:o,onChange:c,label:l&&!N?"API key (optional)":"API key",description:l?N?`${l.name}'s endpoint is built in — just add your key.`:C?`${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:()=>b(I=>!I),children:T?"Hide advanced":"Advanced (API base, rename, client options)"}),T?e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"API base",value:h,onChange:f,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:m,onChange:y,placeholder:s||"instance name",description:S?e.jsx("span",{className:"text-red-700",children:"A name cannot contain “:” or “/”."}):"Rename to run two instances of the same provider."})]}),e.jsx($,{value:p,onChange:k,error:x.ok?null:x.error})]}):null,e.jsxs("div",{className:"flex flex-wrap items-start gap-2",children:[e.jsx(j,{variant:"primary",isDisabled:!w,onPress:R,children:r.isPending?"Adding…":"Add provider"}),e.jsx(j,{variant:"ghost",onPress:t,children:"Cancel"}),e.jsx(X,{getPayload:()=>s===""||!x.ok?null:{instance:P?m.trim():s,provider_type:P?s:null,api_base:h.trim()||null,api_key:o.trim()||null,client_args:x.value}})]})]})}function Pe({onClose:t}){const r=J(),[s,n]=u.useState(""),[o,c]=u.useState("openai-compatible"),[d,b]=u.useState(""),[h,f]=u.useState(""),[m,y]=u.useState(""),p=q(m),k=/[:/]/.test(s),x=s.trim()!==""&&!k&&d.trim()!==""&&p.ok&&!r.isPending,i=()=>{!x||!p.ok||r.mutate({instance:s.trim(),provider_type:o||"openai-compatible",api_base:d.trim(),api_key:h.trim()||null,client_args:p.value},{onSuccess:t})};return e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(O,{error:r.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:k?e.jsx("span",{className:"text-red-700",children:"A name cannot contain “:” or “/”."}):"Call it whatever you want."}),e.jsx(Q,{label:"Compatible with",value:o,onChange:c,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:d,onChange:b,placeholder:"http://localhost:8000/v1",isRequired:!0,description:"The endpoint URL of your server."}),e.jsx(L,{value:h,onChange:f,label:"API key (optional)",description:"Many local backends need none. Stored encrypted."}),e.jsx($,{value:m,onChange:y,error:p.ok?null:p.error}),e.jsxs("div",{className:"flex flex-wrap items-start gap-2",children:[e.jsx(j,{variant:"primary",isDisabled:!x,onPress:i,children:r.isPending?"Adding…":"Add provider"}),e.jsx(j,{variant:"ghost",onPress:t,children:"Cancel"}),e.jsx(X,{getPayload:()=>s.trim()===""||d.trim()===""||!p.ok?null:{instance:s.trim(),provider_type:o||"openai-compatible",api_base:d.trim(),api_key:h.trim()||null,client_args:p.value}})]})]})}function Se({onClose:t}){const[r,s]=u.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":r===n,onClick:()=>s(n),className:r===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))})}),r==="known"?e.jsx(Ne,{onClose:t}):e.jsx(Pe,{onClose:t})]})})}function Ce({provider:t,onClose:r,onSaved:s}){const n=ce(),[o,c]=u.useState(t.provider_type??""),[d,b]=u.useState(t.api_base??""),[h,f]=u.useState(!1),[m,y]=u.useState(""),[p,k]=u.useState(()=>ke(t.client_args)),x=q(p),i=()=>{if(n.isPending||!x.ok)return;const l={provider_type:o.trim()||null,api_base:d.trim()||null,client_args:x.value,expected_updated_at:t.updated_at};h&&m.trim()&&(l.api_key=m.trim()),n.mutate({instance:t.instance,body:l},{onSuccess:()=>{s(t.instance),r()}})};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(O,{error:n.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"Provider type",value:o,onChange:c,placeholder:"openai"}),e.jsx(_,{label:"API base",value:d,onChange:b,placeholder:"https://api.openai.com/v1"})]}),e.jsx("div",{className:"flex flex-col gap-2",children:h?e.jsxs(e.Fragment,{children:[e.jsx(L,{value:m,onChange:y,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:()=>{f(!1),y("")},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(j,{size:"sm",variant:"outline",onPress:()=>f(!0),children:"Replace key"})]})}),e.jsx($,{value:p,onChange:k,error:x.ok?null:x.error}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(j,{variant:"primary",isDisabled:n.isPending||!x.ok,onPress:i,children:n.isPending?"Saving…":"Save changes"}),e.jsx(j,{variant:"ghost",onPress:r,children:"Cancel"})]})]})})}function _e(t,r){const s=new Map((r??[]).map(c=>[c.instance,c])),n=new Map((t??[]).map(c=>[c.instance,c]));return[...new Set([...s.keys(),...n.keys()])].sort().map(c=>{const d=s.get(c);return{instance:c,source:d?"stored":"config",stored:d,meta:n.get(c)}})}function Ae({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(je,{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."]}):t.discovery_unsupported?e.jsxs("span",{className:"block max-w-xs break-words text-xs text-amber-800",children:["Could not list models, so the key could not be verified. It may still work for requests.",t.error?e.jsx("span",{className:"mt-0.5 block text-[var(--otari-muted)]",children:t.error}):null]}):e.jsx("span",{className:"block max-w-xs break-words text-xs text-red-700",children:t.error??"Connection failed."}):null}function we({health:t}){if(!t)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"—"});const r=!t.ok&&t.discovery_unsupported,s=t.ok?"border-green-200 bg-green-50 text-green-700":r?"border-amber-200 bg-amber-50 text-amber-800":"border-red-200 bg-red-50 text-red-700",n=t.ok?"bg-green-500":r?"bg-amber-500":"bg-red-500",o=t.checked_at?`Last checked ${U(t.checked_at)}`:"Not checked yet",c=r?`${t.error??"This provider does not list models."} Requests to it may still work.`:t.error??"Unreachable",d=t.ok?o:`${c} · ${o}`;return e.jsxs("span",{title:d,className:`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${s}`,children:[e.jsx("span",{"aria-hidden":!0,className:`h-1.5 w-1.5 rounded-full ${n}`}),t.ok?"Reachable":r?"No model discovery":"Unreachable"]})}function Te({healthy:t,degraded:r,total:s,checkedAt:n}){const c=t===s?"bg-green-500":t+r===s?"bg-amber-500":"bg-red-500",d=ue();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 ${c}`}),e.jsxs("span",{className:"font-medium text-[var(--otari-ink)]",children:[t," of ",s," provider",s===1?"":"s"," reachable"]}),r>0?e.jsxs("span",{className:"text-amber-800",children:[r," without model discovery"]}):null,n?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["Last checked ",U(n)]}):null,e.jsx(j,{size:"sm",variant:"ghost",className:"ml-auto",isDisabled:d.isPending,onPress:()=>d.mutate(),children:d.isPending?"Re-checking…":"Re-check all"})]})}function B({n:t,title:r,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:r}),e.jsx("div",{className:"text-[var(--otari-muted)]",children:s})]})]})}function Ie({onAddProvider:t,needsPricing:r,onEnablePricing:s,enabling:n,secretKeyConfigured:o}){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",target:"_blank",rel:"noreferrer",className:"font-medium text-[var(--otari-brand-dark)]",children:"quickstart"}),"."]})]}),r?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(j,{variant:"primary",isDisabled:!o,onPress:t,children:"Add your first provider"})})]})})}function Le(){var F,H,z,M;const t=te(),r=se(),s=re(),n=ae(),o=ne(),c=ie(),d=oe(),[b,h]=u.useState(!1),[f,m]=u.useState(null),[y,p]=u.useState({}),k=_e((F=t.data)==null?void 0:F.providers,r.data),x=new Map((((H=n.data)==null?void 0:H.providers)??[]).map(a=>[a.instance,a])),i=t.isLoading||r.isLoading,l=((z=r.data)==null?void 0:z.find(a=>a.instance===f))??null,C=((M=s.data)==null?void 0:M.require_pricing)===!0&&s.data.default_pricing===!1,N=s.data?s.data.secret_key_configured!==!1:!s.isError,P=!i&&k.length===0&&!b,S=u.useRef({}),w=a=>{const v=(S.current[a]??0)+1;return S.current[a]=v,v},T=a=>{w(a),p(v=>{if(!Object.hasOwn(v,a))return v;const g={...v};return delete g[a],g})},R=(a,v,g)=>{S.current[a]===v&&p(Z=>({...Z,[a]:g}))},E=async a=>{const v=w(a);p(g=>({...g,[a]:{status:"pending"}}));try{const g=await c.mutateAsync(a);R(a,v,{status:"done",...g})}catch(g){R(a,v,{status:"done",ok:!1,model_count:0,error:Y(g),discovery_unsupported:!1})}},I=[{id:"provider",header:"Provider",isRowHeader:!0,cell:a=>e.jsx(ee,{to:`/models?provider=${encodeURIComponent(a.instance)}`,className:"font-medium text-[var(--otari-ink)] hover:text-[var(--otari-brand-dark)] hover:underline",children:a.instance})},{id:"type",header:"Type",cell:a=>{var v,g;return e.jsx("span",{className:"text-[var(--otari-muted)]",children:((v=a.meta)==null?void 0:v.provider_type)??((g=a.stored)==null?void 0:g.provider_type)??a.instance})}},{id:"source",header:"Source",cell:a=>e.jsx(ge,{size:"sm",color:a.source==="stored"?"accent":"default",children:a.source==="stored"?"stored":"config"})},{id:"api_key",header:"API key",cell:a=>{var v,g;return e.jsx("span",{className:"text-[var(--otari-muted)]",children:a.source==="stored"?a.stored&&!a.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:(v=a.stored)!=null&&v.last4?`••••${a.stored.last4}`:"none set"}):(g=a.meta)!=null&&g.env_key?e.jsxs("span",{children:["via ",e.jsx("code",{children:a.meta.env_key})]}):"config.yml"})}},{id:"status",header:"Status",cell:a=>e.jsx(we,{health:x.get(a.instance)})},{id:"actions",header:"Actions",align:"end",cell:a=>{var v,g;return a.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(j,{size:"sm",variant:"outline",isDisabled:((v=y[a.instance])==null?void 0:v.status)==="pending"||((g=a.stored)==null?void 0:g.decryptable)===!1,onPress:()=>void E(a.instance),children:"Test"}),e.jsx(j,{size:"sm",variant:"ghost",onPress:()=>{h(!1),m(a.instance)},children:"Edit"}),e.jsx(me,{confirmLabel:"Delete",isPending:o.isPending,onConfirm:()=>o.mutate(a.instance,{onSuccess:()=>T(a.instance)}),children:"Delete"})]}),e.jsx(Ae,{state:y[a.instance]})]}):e.jsx("span",{className:"block text-right text-xs text-[var(--otari-muted)]",children:"managed in config.yml"})}}];return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(le,{title:"Providers",description:"Add provider API keys here to serve models without editing config.yml. Keys are encrypted at rest.",action:b||P?null:e.jsx(j,{variant:"primary",isDisabled:!N,onPress:()=>{m(null),h(!0)},children:"Add provider"})}),e.jsx(O,{error:t.error??r.error??s.error??n.error??d.error??o.error}),N?null:e.jsxs(de,{tone:"warning",children:[e.jsx("code",{children:"OTARI_SECRET_KEY"})," is not set, so provider keys can't be encrypted at rest and adding providers from the dashboard is disabled. Set it on the server and restart to add providers here. Providers defined in"," ",e.jsx("code",{children:"config.yml"})," keep working without it."]}),P?e.jsx(Ie,{onAddProvider:()=>{m(null),h(!0)},needsPricing:C,onEnablePricing:()=>d.mutate({default_pricing:!0}),enabling:d.isPending,secretKeyConfigured:N}):null,b&&N?e.jsx(Se,{onClose:()=>h(!1)}):null,l?e.jsx(Ce,{provider:l,onClose:()=>m(null),onSaved:T},l.instance):null,!i&&k.length>0&&n.data&&n.data.total>0?e.jsx(Te,{healthy:n.data.healthy,degraded:n.data.degraded,total:n.data.total,checkedAt:n.data.checked_at}):null,P?null:e.jsx(ve,{ariaLabel:"Providers",columns:I,rows:k,getRowKey:a=>a.instance,isLoading:i,emptyContent:"No providers yet. Add your first provider to start serving models."})]})}export{Le as ProvidersPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as u,L as ee}from"./react-dgEcD0HR.js";import{a3 as te,ae as se,a1 as re,a5 as ae,af as ne,ag as ie,ah as oe,P as le,E as O,M as de,ai as ce,aj as ue,ac as U,y as me,ak as J,al as xe,$ as Y,am as pe,an as he}from"./index-D6WO6K2k.js";import{F as _}from"./Field-CBU9MRjz.js";import{D as ve}from"./DataTable-DuDxGlJc.js";import{B as j,d as A,g as ge,e as V,L as D,I as G,D as W,h as fe,S as je,C as K,a as be,b as ye}from"./heroui-COmYdDDM.js";function L({value:t,onChange:r,label:s,placeholder:n,description:o}){return e.jsxs(V,{value:t,onChange:r,className:"flex max-w-md flex-col gap-1",children:[e.jsx(D,{className:"text-sm font-medium text-[var(--otari-ink)]",children:s}),e.jsx(G,{type:"password",placeholder:n??"sk-…",autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",spellCheck:!1,"data-1p-ignore":!0,"data-lpignore":"true"}),o?e.jsx(W,{className:"text-xs text-[var(--otari-muted)]",children:o}):null]})}function q(t){const r=t.trim();if(r==="")return{ok:!0,value:null};let s;try{s=JSON.parse(r)}catch{return{ok:!1,error:"Not valid JSON."}}return s===null||typeof s!="object"||Array.isArray(s)?{ok:!1,error:'Must be a JSON object, like {"timeout": 1800}.'}:{ok:!0,value:s}}function ke(t){return t&&Object.keys(t).length>0?JSON.stringify(t,null,2):""}function $({value:t,onChange:r,error:s}){return e.jsxs(V,{value:t,onChange:r,isInvalid:s!==null,className:"flex max-w-md flex-col gap-1",children:[e.jsx(D,{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Client options (JSON)"}),e.jsx(fe,{rows:3,placeholder:'{"timeout": 1800}',spellCheck:!1,className:"font-mono text-xs"}),e.jsx(W,{className:s?"text-xs text-red-700":"text-xs text-[var(--otari-muted)]",children:s??"Passed to the provider's client, e.g. a request timeout in seconds or custom headers. Stored in plain text, so keep secrets out."})]})}function Q({label:t,value:r,onChange:s,description:n,placeholder:o,extra:c=[],includeCatalog:d=!0}){var x;const b=pe(),h=u.useMemo(()=>d?[...c,...(b.data??[]).map(i=>({id:i.id,name:i.name}))]:c,[b.data,c,d]),[f,m]=u.useState(()=>{var i;return((i=h.find(l=>l.id===r))==null?void 0:i.name)??""}),y=((x=h.find(i=>i.id===r))==null?void 0:x.name)??"",p=f.trim()===y.trim()?"":f.trim().toLowerCase(),k=h.filter(i=>!p||i.name.toLowerCase().includes(p)||i.id.toLowerCase().includes(p)).slice(0,50);return e.jsxs(K.Root,{allowsEmptyCollection:!0,menuTrigger:"focus",inputValue:f,onInputChange:m,onSelectionChange:i=>{var l;i!=null?(s(String(i)),m(((l=h.find(C=>C.id===String(i)))==null?void 0:l.name)??"")):(s(""),m(""))},className:"flex max-w-md flex-col gap-1",children:[e.jsx(D,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsxs(K.InputGroup,{children:[e.jsx(G,{placeholder:o??"Search providers…",autoComplete:"off","data-1p-ignore":!0,"data-lpignore":"true",onFocus:i=>i.currentTarget.select()}),e.jsx(K.Trigger,{})]}),e.jsx(K.Popover,{children:e.jsx(be,{items:k,className:"max-h-72 overflow-auto",children:i=>e.jsx(ye,{id:i.id,textValue:i.name,children:i.name})})}),n?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:n}):null]})}function X({getPayload:t}){const r=he(),s=t();return e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsx(j,{variant:"outline",isDisabled:s===null||r.isPending,onPress:()=>{s&&r.mutate(s)},children:r.isPending?"Testing…":"Test connection"}),e.jsx("span",{role:"status","aria-live":"polite",children:r.isPending?null:r.error?e.jsx("span",{className:"text-xs text-red-700",children:Y(r.error)}):r.data?r.data.ok?e.jsxs("span",{className:"text-xs font-medium text-green-700",children:["Connected. ",r.data.model_count," model",r.data.model_count===1?"":"s"," available."]}):r.data.discovery_unsupported?e.jsxs("span",{className:"block max-w-md break-words text-xs text-amber-800",children:["This provider does not list models, so the key could not be verified here. Save it and use the provider; declare its model ids under ",e.jsx("code",{children:"models:"})," to have them show up in the catalogue. If you did not expect this, check the provider's reply below.",r.data.error?e.jsx("span",{className:"mt-0.5 block text-[var(--otari-muted)]",children:r.data.error}):null]}):e.jsx("span",{className:"block max-w-md break-words text-xs text-red-700",children:r.data.error??"Connection failed."}):null})]})}function Ne({onClose:t}){var E;const r=J(),[s,n]=u.useState(""),[o,c]=u.useState(""),[d,b]=u.useState(!1),[h,f]=u.useState(""),[m,y]=u.useState(""),[p,k]=u.useState(""),x=q(p),i=xe(s),l=((E=i.data)==null?void 0:E.id)===s?i.data:void 0;u.useEffect(()=>{l&&f(l.default_api_base??"")},[l]);const C=(l==null?void 0:l.env_key_present)??!1,N=((l==null?void 0:l.requires_api_key)??!0)&&!C,P=m.trim()!==""&&m.trim()!==s,S=/[:/]/.test(m),w=s!==""&&!S&&(!N||o.trim()!=="")&&x.ok&&!r.isPending,T=d||!x.ok||S,R=()=>{!w||!x.ok||r.mutate({instance:P?m.trim():s,provider_type:P?s:null,api_base:h.trim()||null,api_key:o.trim()||null,client_args:x.value},{onSuccess:t})};return e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(O,{error:r.error}),e.jsx(Q,{label:"Provider",value:s,onChange:I=>{n(I),y(""),f("")},description:"Its endpoint is built in."}),e.jsx(L,{value:o,onChange:c,label:l&&!N?"API key (optional)":"API key",description:l?N?`${l.name}'s endpoint is built in — just add your key.`:C?`${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:()=>b(I=>!I),children:T?"Hide advanced":"Advanced (API base, rename, client options)"}),T?e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"API base",value:h,onChange:f,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:m,onChange:y,placeholder:s||"instance name",description:S?e.jsx("span",{className:"text-red-700",children:"A name cannot contain “:” or “/”."}):"Rename to run two instances of the same provider."})]}),e.jsx($,{value:p,onChange:k,error:x.ok?null:x.error})]}):null,e.jsxs("div",{className:"flex flex-wrap items-start gap-2",children:[e.jsx(j,{variant:"primary",isDisabled:!w,onPress:R,children:r.isPending?"Adding…":"Add provider"}),e.jsx(j,{variant:"ghost",onPress:t,children:"Cancel"}),e.jsx(X,{getPayload:()=>s===""||!x.ok?null:{instance:P?m.trim():s,provider_type:P?s:null,api_base:h.trim()||null,api_key:o.trim()||null,client_args:x.value}})]})]})}function Pe({onClose:t}){const r=J(),[s,n]=u.useState(""),[o,c]=u.useState("openai-compatible"),[d,b]=u.useState(""),[h,f]=u.useState(""),[m,y]=u.useState(""),p=q(m),k=/[:/]/.test(s),x=s.trim()!==""&&!k&&d.trim()!==""&&p.ok&&!r.isPending,i=()=>{!x||!p.ok||r.mutate({instance:s.trim(),provider_type:o||"openai-compatible",api_base:d.trim(),api_key:h.trim()||null,client_args:p.value},{onSuccess:t})};return e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(O,{error:r.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:k?e.jsx("span",{className:"text-red-700",children:"A name cannot contain “:” or “/”."}):"Call it whatever you want."}),e.jsx(Q,{label:"Compatible with",value:o,onChange:c,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:d,onChange:b,placeholder:"http://localhost:8000/v1",isRequired:!0,description:"The endpoint URL of your server."}),e.jsx(L,{value:h,onChange:f,label:"API key (optional)",description:"Many local backends need none. Stored encrypted."}),e.jsx($,{value:m,onChange:y,error:p.ok?null:p.error}),e.jsxs("div",{className:"flex flex-wrap items-start gap-2",children:[e.jsx(j,{variant:"primary",isDisabled:!x,onPress:i,children:r.isPending?"Adding…":"Add provider"}),e.jsx(j,{variant:"ghost",onPress:t,children:"Cancel"}),e.jsx(X,{getPayload:()=>s.trim()===""||d.trim()===""||!p.ok?null:{instance:s.trim(),provider_type:o||"openai-compatible",api_base:d.trim(),api_key:h.trim()||null,client_args:p.value}})]})]})}function Se({onClose:t}){const[r,s]=u.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":r===n,onClick:()=>s(n),className:r===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))})}),r==="known"?e.jsx(Ne,{onClose:t}):e.jsx(Pe,{onClose:t})]})})}function Ce({provider:t,onClose:r,onSaved:s}){const n=ce(),[o,c]=u.useState(t.provider_type??""),[d,b]=u.useState(t.api_base??""),[h,f]=u.useState(!1),[m,y]=u.useState(""),[p,k]=u.useState(()=>ke(t.client_args)),x=q(p),i=()=>{if(n.isPending||!x.ok)return;const l={provider_type:o.trim()||null,api_base:d.trim()||null,client_args:x.value,expected_updated_at:t.updated_at};h&&m.trim()&&(l.api_key=m.trim()),n.mutate({instance:t.instance,body:l},{onSuccess:()=>{s(t.instance),r()}})};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(O,{error:n.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(_,{label:"Provider type",value:o,onChange:c,placeholder:"openai"}),e.jsx(_,{label:"API base",value:d,onChange:b,placeholder:"https://api.openai.com/v1"})]}),e.jsx("div",{className:"flex flex-col gap-2",children:h?e.jsxs(e.Fragment,{children:[e.jsx(L,{value:m,onChange:y,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:()=>{f(!1),y("")},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(j,{size:"sm",variant:"outline",onPress:()=>f(!0),children:"Replace key"})]})}),e.jsx($,{value:p,onChange:k,error:x.ok?null:x.error}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(j,{variant:"primary",isDisabled:n.isPending||!x.ok,onPress:i,children:n.isPending?"Saving…":"Save changes"}),e.jsx(j,{variant:"ghost",onPress:r,children:"Cancel"})]})]})})}function _e(t,r){const s=new Map((r??[]).map(c=>[c.instance,c])),n=new Map((t??[]).map(c=>[c.instance,c]));return[...new Set([...s.keys(),...n.keys()])].sort().map(c=>{const d=s.get(c);return{instance:c,source:d?"stored":"config",stored:d,meta:n.get(c)}})}function Ae({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(je,{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."]}):t.discovery_unsupported?e.jsxs("span",{className:"block max-w-xs break-words text-xs text-amber-800",children:["Could not list models, so the key could not be verified. It may still work for requests.",t.error?e.jsx("span",{className:"mt-0.5 block text-[var(--otari-muted)]",children:t.error}):null]}):e.jsx("span",{className:"block max-w-xs break-words text-xs text-red-700",children:t.error??"Connection failed."}):null}function we({health:t}){if(!t)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"—"});const r=!t.ok&&t.discovery_unsupported,s=t.ok?"border-green-200 bg-green-50 text-green-700":r?"border-amber-200 bg-amber-50 text-amber-800":"border-red-200 bg-red-50 text-red-700",n=t.ok?"bg-green-500":r?"bg-amber-500":"bg-red-500",o=t.checked_at?`Last checked ${U(t.checked_at)}`:"Not checked yet",c=r?`${t.error??"This provider does not list models."} Requests to it may still work.`:t.error??"Unreachable",d=t.ok?o:`${c} · ${o}`;return e.jsxs("span",{title:d,className:`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${s}`,children:[e.jsx("span",{"aria-hidden":!0,className:`h-1.5 w-1.5 rounded-full ${n}`}),t.ok?"Reachable":r?"No model discovery":"Unreachable"]})}function Te({healthy:t,degraded:r,total:s,checkedAt:n}){const c=t===s?"bg-green-500":t+r===s?"bg-amber-500":"bg-red-500",d=ue();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 ${c}`}),e.jsxs("span",{className:"font-medium text-[var(--otari-ink)]",children:[t," of ",s," provider",s===1?"":"s"," reachable"]}),r>0?e.jsxs("span",{className:"text-amber-800",children:[r," without model discovery"]}):null,n?e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["Last checked ",U(n)]}):null,e.jsx(j,{size:"sm",variant:"ghost",className:"ml-auto",isDisabled:d.isPending,onPress:()=>d.mutate(),children:d.isPending?"Re-checking…":"Re-check all"})]})}function B({n:t,title:r,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:r}),e.jsx("div",{className:"text-[var(--otari-muted)]",children:s})]})]})}function Ie({onAddProvider:t,needsPricing:r,onEnablePricing:s,enabling:n,secretKeyConfigured:o}){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",target:"_blank",rel:"noreferrer",className:"font-medium text-[var(--otari-brand-dark)]",children:"quickstart"}),"."]})]}),r?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(j,{variant:"primary",isDisabled:!o,onPress:t,children:"Add your first provider"})})]})})}function Le(){var F,H,M,z;const t=te(),r=se(),s=re(),n=ae(),o=ne(),c=ie(),d=oe(),[b,h]=u.useState(!1),[f,m]=u.useState(null),[y,p]=u.useState({}),k=_e((F=t.data)==null?void 0:F.providers,r.data),x=new Map((((H=n.data)==null?void 0:H.providers)??[]).map(a=>[a.instance,a])),i=t.isLoading||r.isLoading,l=((M=r.data)==null?void 0:M.find(a=>a.instance===f))??null,C=((z=s.data)==null?void 0:z.require_pricing)===!0&&s.data.default_pricing===!1,N=s.data?s.data.secret_key_configured!==!1:!s.isError,P=!i&&k.length===0&&!b,S=u.useRef({}),w=a=>{const v=(S.current[a]??0)+1;return S.current[a]=v,v},T=a=>{w(a),p(v=>{if(!Object.hasOwn(v,a))return v;const g={...v};return delete g[a],g})},R=(a,v,g)=>{S.current[a]===v&&p(Z=>({...Z,[a]:g}))},E=async a=>{const v=w(a);p(g=>({...g,[a]:{status:"pending"}}));try{const g=await c.mutateAsync(a);R(a,v,{status:"done",...g})}catch(g){R(a,v,{status:"done",ok:!1,model_count:0,error:Y(g),discovery_unsupported:!1})}},I=[{id:"provider",header:"Provider",isRowHeader:!0,cell:a=>e.jsx(ee,{to:`/models?provider=${encodeURIComponent(a.instance)}`,className:"font-medium text-[var(--otari-ink)] hover:text-[var(--otari-brand-dark)] hover:underline",children:a.instance})},{id:"type",header:"Type",cell:a=>{var v,g;return e.jsx("span",{className:"text-[var(--otari-muted)]",children:((v=a.meta)==null?void 0:v.provider_type)??((g=a.stored)==null?void 0:g.provider_type)??a.instance})}},{id:"source",header:"Source",cell:a=>e.jsx(ge,{size:"sm",color:a.source==="stored"?"accent":"default",children:a.source==="stored"?"stored":"config"})},{id:"api_key",header:"API key",cell:a=>{var v,g;return e.jsx("span",{className:"text-[var(--otari-muted)]",children:a.source==="stored"?a.stored&&!a.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:(v=a.stored)!=null&&v.last4?`••••${a.stored.last4}`:"none set"}):(g=a.meta)!=null&&g.env_key?e.jsxs("span",{children:["via ",e.jsx("code",{children:a.meta.env_key})]}):"config.yml"})}},{id:"status",header:"Status",cell:a=>e.jsx(we,{health:x.get(a.instance)})},{id:"actions",header:"Actions",align:"end",cell:a=>{var v,g;return a.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(j,{size:"sm",variant:"outline",isDisabled:((v=y[a.instance])==null?void 0:v.status)==="pending"||((g=a.stored)==null?void 0:g.decryptable)===!1,onPress:()=>void E(a.instance),children:"Test"}),e.jsx(j,{size:"sm",variant:"ghost",onPress:()=>{h(!1),m(a.instance)},children:"Edit"}),e.jsx(me,{confirmLabel:"Delete",isPending:o.isPending,onConfirm:()=>o.mutate(a.instance,{onSuccess:()=>T(a.instance)}),children:"Delete"})]}),e.jsx(Ae,{state:y[a.instance]})]}):e.jsx("span",{className:"block text-right text-xs text-[var(--otari-muted)]",children:"managed in config.yml"})}}];return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(le,{title:"Providers",description:"Add provider API keys here to serve models without editing config.yml. Keys are encrypted at rest.",action:b||P?null:e.jsx(j,{variant:"primary",isDisabled:!N,onPress:()=>{m(null),h(!0)},children:"Add provider"})}),e.jsx(O,{error:t.error??r.error??s.error??n.error??d.error??o.error}),N?null:e.jsxs(de,{tone:"warning",children:[e.jsx("code",{children:"OTARI_SECRET_KEY"})," is not set, so provider keys can't be encrypted at rest and adding providers from the dashboard is disabled. Set it on the server and restart to add providers here. Providers defined in"," ",e.jsx("code",{children:"config.yml"})," keep working without it."]}),P?e.jsx(Ie,{onAddProvider:()=>{m(null),h(!0)},needsPricing:C,onEnablePricing:()=>d.mutate({default_pricing:!0}),enabling:d.isPending,secretKeyConfigured:N}):null,b&&N?e.jsx(Se,{onClose:()=>h(!1)}):null,l?e.jsx(Ce,{provider:l,onClose:()=>m(null),onSaved:T},l.instance):null,!i&&k.length>0&&n.data&&n.data.total>0?e.jsx(Te,{healthy:n.data.healthy,degraded:n.data.degraded,total:n.data.total,checkedAt:n.data.checked_at}):null,P?null:e.jsx(ve,{ariaLabel:"Providers",columns:I,rows:k,getRowKey:a=>a.instance,isLoading:i,emptyContent:"No providers yet. Add your first provider to start serving models."})]})}export{Le as ProvidersPage}; diff --git a/src/gateway/static/dashboard/assets/RoutingPage-DELPbpkQ.js b/src/gateway/static/dashboard/assets/RoutingPage-CeSk6-Fe.js similarity index 99% rename from src/gateway/static/dashboard/assets/RoutingPage-DELPbpkQ.js rename to src/gateway/static/dashboard/assets/RoutingPage-CeSk6-Fe.js index e46340b1..79817219 100644 --- a/src/gateway/static/dashboard/assets/RoutingPage-DELPbpkQ.js +++ b/src/gateway/static/dashboard/assets/RoutingPage-CeSk6-Fe.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as v,u as he,L as Q}from"./react-dgEcD0HR.js";import{u as ae,s as xe,E as H,t as pe,v as fe,w as ve,x as ge,y as je,q as Y,z as be,P as Ne,B as ke,D as we,G as ye,H as Ce}from"./index-DAnS9oY2.js";import{D as _e}from"./DataTable-DuDxGlJc.js";import{U as re}from"./UserComboBox-DoloPF6p.js";import{B as _,g as q,C as D,L as Se,I as Pe,a as Ae,b as Re,d as Z}from"./heroui-COmYdDDM.js";import{F as U}from"./Field-CBU9MRjz.js";function ee({records:t,seed:s,warm:r}){const n=s===0?100:Math.min(100,Math.round(t/s*100));return e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("div",{className:"h-1.5 w-32 overflow-hidden rounded-full bg-[var(--otari-bg)]",children:e.jsx("div",{className:r?"h-full bg-[var(--otari-brand)]":"h-full bg-amber-500",style:{width:`${n}%`}})}),e.jsxs("span",{className:"text-sm text-[var(--otari-ink)]",children:[t," / ",s," examples"]}),e.jsx(q,{size:"sm",color:r?"accent":"default",children:r?"routing":"warming up"})]})}function Ie({policyName:t,candidates:s,defaultTarget:r,backend:n,scopedUserId:i,onClose:h}){const g=ae(),[m,j]=v.useState(i),u=xe(m),N=m!==null&&m!=="";return e.jsxs("div",{children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[e.jsxs("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:["Examples for ",e.jsx("code",{children:t})]}),e.jsx(_,{size:"sm",variant:"ghost",onPress:h,children:"Close"})]}),e.jsxs("div",{className:"flex flex-col gap-5 px-4 py-4",children:[e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:"Candidates"}),e.jsxs("span",{className:"text-sm text-[var(--otari-ink)]",children:[e.jsx("code",{children:n})," ranks ",s.join(", ")," for each request."]}),e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:[e.jsx("code",{children:r})," serves whenever it declines: too few examples, a weakly supported pick, a request carrying tools, or ",e.jsx("code",{children:"Otari-Router: off"}),"."]})]}),e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:"Examples"}),i===null?e.jsx(re,{label:"Whose memory",value:m??"",onChange:j,users:g.data??[],placeholder:"Pick a user…",description:"Examples are one user's own prompts, so this policy warms once per caller rather than once overall.",unknownHint:e.jsx("span",{className:"text-red-700",children:"No such user. Pick an existing one."})}):e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Scoped to user ",e.jsx("code",{children:i}),", so that is the only memory it can use."]}),e.jsx(H,{error:u.error}),N?u.isLoading?e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:"Loading…"}):u.data?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Default pool"}),e.jsx(ee,{records:u.data.default_pool.records,seed:u.data.seed_count,warm:u.data.default_pool.warm})]}),u.data.tasks.map(C=>e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("code",{className:"text-sm text-[var(--otari-ink)]",children:C.task_id}),e.jsx(ee,{records:C.records,seed:u.data.seed_count,warm:C.warm})]},C.task_id)),u.data.tasks.length>0?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"The default pool counts every example this user has, including the ones filed under a task, so it can be warm while a task partition is not."}):null,e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Scoring with ",e.jsx("code",{children:u.data.embedding_model}),", ",u.data.k," nearest examples per decision, cost dial ",u.data.alpha,", deciding once per"," ",u.data.granularity==="trace_sticky"?"conversation":"call",". Change these with the ",e.jsx("code",{children:"OTARI_ROUTER_*"})," environment variables."]})]}):null:e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:"Pick a user to see how warm this policy's memory is."})]}),e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:"Adding examples"}),e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Examples are recorded over the API, with"," ",e.jsx("code",{children:"POST /v1/routing/preferences/rank"}),". Score a batch of prompts from 0 (bad) to 1 (great) per candidate; two good answers is the case that lets the cheaper model win. See"," ",e.jsx("a",{className:"text-[var(--otari-brand)] hover:underline",href:"https://mozilla-ai.github.io/otari/routing/#teach-it",target:"_blank",rel:"noreferrer",children:"Teach it"})," ","in the routing guide."]})]})]})]})}const Ee=50;function M({label:t,value:s,onChange:r,description:n,placeholder:i="provider:model",autoFocus:h,isRequired:g}){const m=pe(),{visible:j,total:u,failed:N}=v.useMemo(()=>{var x;const p=s.trim().toLowerCase(),S=((x=m.data)==null?void 0:x.providers)??[],k=S.flatMap(b=>b.models),a=p?k.filter(b=>b.key.toLowerCase().includes(p)):k;return{visible:a.slice(0,Ee),total:a.length,failed:S.filter(b=>!b.ok)}},[m.data,s]),C=m.isLoading?"Loading models from your providers…":N.length>0?`Could not list models for ${N.map(S=>S.provider).join(", ")}. Check that provider's credentials, or type the model key directly.`:u>j.length?`Showing ${j.length} of ${u} matches. Keep typing to narrow them.`:n;return e.jsxs(D.Root,{allowsCustomValue:!0,allowsEmptyCollection:!0,menuTrigger:"input",inputValue:s,onInputChange:r,onSelectionChange:p=>{p!=null&&r(String(p))},isRequired:g,className:"flex max-w-md flex-col gap-1",children:[e.jsx(Se,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsxs(D.InputGroup,{children:[e.jsx(Pe,{placeholder:i,autoFocus:h}),e.jsx(D.Trigger,{})]}),e.jsx(D.Popover,{children:e.jsx(Ae,{items:j,className:"max-h-72 overflow-auto",children:p=>e.jsx(Re,{id:p.key,textValue:p.key,children:p.key})})}),C?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:C}):null]})}const G="knn",B=5;function Le(t){return{kind:"alias",name:t.name,spec:{select:[{default:t.target}]},source:t.source,user_id:t.user_id,is_dynamic:!1,created_at:t.created_at,updated_at:t.updated_at}}const $=t=>JSON.stringify([t.kind,t.user_id,t.name]);function qe(){var n;const t=Ce(),s=(n=t.data)==null?void 0:n.fields.find(i=>i.key==="guardrails_url"),r=typeof(s==null?void 0:s.value)=="string"?s.value.trim():"";return{configured:t.isLoading||r!=="",isLoading:t.isLoading}}function Te(t){const s=t.select.findIndex(n=>n.router!==void 0),r=t.select.reduce((n,i,h)=>i.when!==void 0?h:n,-1);return s!==-1&&r!==-1&&s{var g,m;if(n.default!==void 0)return n.when===void 0;if(n.router!==void 0)return n.router===G&&(((g=n.candidates)==null?void 0:g.length)??0)>0;const i=n.when;if(i===void 0||n.target===void 0)return!1;const h=Object.keys(i);return h.length===1&&h[0]==="budget_used_pct"&&((m=i.budget_used_pct)==null?void 0:m.gte)!==void 0})}function I(t){var s;return((s=t.select.find(r=>r.default!==void 0))==null?void 0:s.default)??""}function T(t){var s;return((s=t.select.find(r=>r.router!==void 0))==null?void 0:s.candidates)??[]}function ne(t){const s=T(t);if(s.length===0)return[];const r=I(t);return s.includes(r)?s:[...s,r]}function Oe(t){const s=ne(t).indexOf(I(t));return s===-1?0:s}function De(t){var s;return(s=t.select.find(r=>r.router!==void 0))==null?void 0:s.router}function Me(t){return t.select.filter(s=>{var r,n;return((n=(r=s.when)==null?void 0:r.budget_used_pct)==null?void 0:n.gte)!==void 0&&s.target!==void 0}).map(s=>({threshold:s.when.budget_used_pct.gte,target:s.target}))}function Be(t){const s=t.spec.on_failure??[],r=T(t.spec);if(r.length>0)return`Learned · ${r.length} candidates, ${I(t.spec)} by default`;if(t.is_dynamic){const i=1+s.length;return`Chosen per request · ${i} candidate${i===1?"":"s"}`}const n=I(t.spec);return s.length>0?`${n} +${s.length} on failure`:n}function $e({userId:t,onChange:s}){const r=ae(),n=t!==null,i=(h,g)=>e.jsx("button",{type:"button","aria-pressed":n===h,onClick:()=>s(h?"":null),className:n===h?"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:g});return e.jsxs("div",{className:"flex flex-col gap-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Applies to"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"A global policy resolves for every caller. A user-scoped one resolves only for that user, and takes precedence over a global policy of the same name."})]}),e.jsxs("div",{className:"flex w-fit items-center gap-1 rounded-lg bg-[var(--otari-bg)] p-1",children:[i(!1,"Every caller"),i(!0,"One user")]}),n?e.jsx(re,{label:"User",value:t??"",onChange:s,users:r.data??[],placeholder:"Pick a user…",description:"Only this user resolves the policy.",unknownHint:e.jsx("span",{className:"text-red-700",children:"No such user. Pick an existing one."})}):null]})}const ze=["block","monitor"];function te({label:t,hint:s,value:r,onChange:n}){return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsx("div",{className:"flex w-fit items-center gap-1 rounded-lg bg-[var(--otari-bg)] p-1",children:ze.map(i=>e.jsx("button",{type:"button","aria-pressed":r===i,onClick:()=>n(i),className:r===i?"rounded-md bg-white px-3 py-1 text-sm font-medium text-[var(--otari-ink)] shadow-sm":"rounded-md px-3 py-1 text-sm text-[var(--otari-muted)] hover:text-[var(--otari-ink)]",children:i},i))}),s===void 0?null:e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:s})]})}function se({existing:t,initialTarget:s="",onClose:r}){const n=we(),i=ye(),h=t!==null,g=(t==null?void 0:t.kind)==="alias",m=qe(),[j,u]=v.useState((t==null?void 0:t.name)??""),[N,C]=v.useState((t==null?void 0:t.user_id)??null),[p,S]=v.useState(t?I(t.spec):s),[k,a]=v.useState((t==null?void 0:t.spec.on_failure)??[]),[x,b]=v.useState(t?Me(t.spec):[]),[P,E]=v.useState((t==null?void 0:t.spec.guardrails)??[]),[w,O]=v.useState(t?ne(t.spec):[]),[K,z]=v.useState(t?Oe(t.spec):0),L=w.length>0,A=L?w[K]??"":p,V=/[:/]/.test(j),le=N===null||N.trim()!=="",ie=x.every(l=>l.target.trim()!==""&&l.threshold>0&&l.threshold<100),oe=P.every(l=>l.profile.trim()!==""),de=!L||w.length>=2&&w.every(l=>l.trim()!=="")&&A.trim()!=="",W=(w.length||1)+k.length,R=W>=B,ce=W>B,X=j.trim()!==""&&A.trim()!==""&&!V&&le&&ie&&oe&&de&&!ce&&k.every(l=>l.trim()!==""),ue=v.useMemo(()=>({select:[...x.map(l=>({when:{budget_used_pct:{gte:l.threshold}},target:l.target.trim()})),...L?[{router:G,candidates:w.map(l=>l.trim())}]:[],{default:A.trim()}],...k.length>0?{on_failure:k.map(l=>l.trim())}:{},...P.length>0?{guardrails:P}:{}}),[x,w,L,A,k,P]),F=g&&(k.length>0||x.length>0||P.length>0||w.length>0),J=n.isPending||i.isPending,me=()=>{if(!X||F)return;const l=N===null?null:N.trim();if(g){i.mutate({name:j.trim(),target:A.trim(),user_id:l},{onSuccess:r});return}n.mutate({name:j.trim(),spec:ue,user_id:l},{onSuccess:r})};return e.jsx("div",{className:"flex flex-col gap-4",children:e.jsx(Z,{children:e.jsxs(Z.Content,{className:"flex flex-col gap-5 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:h?e.jsxs(e.Fragment,{children:["Edit ",t.kind==="alias"?"alias":"policy"," ",e.jsx("code",{children:t.name}),t.user_id?e.jsxs(e.Fragment,{children:[" ","for user ",e.jsx("code",{children:t.user_id})]}):null]}):"New routing policy"}),e.jsx(H,{error:n.error??i.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[h?e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Policy 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 name and who it applies to are the key and cannot be changed here. Delete and recreate to change either."})]}):e.jsx(U,{label:"Policy name",value:j,onChange:u,placeholder:"fast",isRequired:!0,autoFocus:!0,description:V?e.jsx("span",{className:"text-red-700",children:"A policy name cannot contain “:” or “/”."}):"What callers send as `model`."}),L?e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Serves"}),e.jsx("span",{className:"text-sm text-[var(--otari-ink)]",children:A.trim()===""?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"whichever model you mark below"}):e.jsx("code",{children:A})}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"A router picks per request, so this policy has no single target. The model marked below is what serves when the router does not choose."})]}):e.jsx(M,{label:"Serves",value:p,onChange:S,isRequired:!0,description:"The model that serves a normal request. Callers never see it."})]}),h?null:e.jsx($e,{userId:N,onChange:C}),x.length>0?e.jsxs("div",{className:"flex flex-col gap-3 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Instead, when the budget fills up"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Checked before the model above. A threshold must be under 100: the budget gate refuses a request before selection once the cap is reached, so a rule at 100 could never fire."})]}),x.map((l,c)=>e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(U,{label:"Budget used at least (%)",value:String(l.threshold),onChange:o=>b(f=>f.map((d,y)=>y===c?{...d,threshold:Number(o)||0}:d)),description:l.threshold>=100?e.jsx("span",{className:"text-red-700",children:"Must be under 100."}):void 0}),e.jsx("div",{className:"min-w-56 flex-1",children:e.jsx(M,{label:"Use instead",value:l.target,onChange:o=>b(f=>f.map((d,y)=>y===c?{...d,target:o}:d)),isRequired:!0})}),e.jsx(_,{variant:"ghost",onPress:()=>b(o=>o.filter((f,d)=>d!==c)),children:"Remove"})]},c))]}):null,w.length>0?e.jsxs("div",{className:"flex flex-col gap-3 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"The router chooses between"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"For each request, the cheapest of these that past scoring says is good enough. Every model here needs pricing, because the router weighs quality against cost."})]}),w.map((l,c)=>e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx("div",{className:"min-w-56 flex-1",children:e.jsx(M,{label:`Model ${c+1}`,value:l,onChange:o=>O(f=>f.map((d,y)=>y===c?o:d)),isRequired:!0})}),e.jsxs("label",{className:"flex items-center gap-2 pb-2 text-xs text-[var(--otari-ink)]",children:[e.jsx("input",{type:"radio",name:"router-safe-choice",checked:K===c,onChange:()=>z(c)}),"Serves when unsure"]}),e.jsx(_,{variant:"ghost",onPress:()=>{O(o=>o.filter((f,d)=>d!==c)),z(o=>cO(l=>[...l,""]),children:"+ Another model"}),R?e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["A policy dispatches at most ",B," models, counting the fallback chain. Remove a fallback to add another."]}):null]})]}):null,k.length>0?e.jsxs("div",{className:"flex flex-col gap-3 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"If that fails, try"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Tried in order after a retryable failure. Not tried once tokens have started streaming, or after a 400/401/403, which every provider would reject the same way."})]}),k.map((l,c)=>e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx("div",{className:"min-w-56 flex-1",children:e.jsx(M,{label:`Fallback ${c+1}`,value:l,onChange:o=>a(f=>f.map((d,y)=>y===c?o:d)),isRequired:!0})}),e.jsx(_,{variant:"ghost",onPress:()=>a(o=>o.filter((f,d)=>d!==c)),children:"Remove"})]},c)),e.jsxs("div",{className:"flex flex-wrap items-baseline gap-2",children:[e.jsx("button",{type:"button",disabled:R,className:R?"cursor-not-allowed text-sm text-[var(--otari-muted)] opacity-60":"text-sm text-[var(--otari-brand)] hover:underline",onClick:()=>a(l=>[...l,""]),children:"+ Another fallback"}),R?e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["A policy dispatches at most ",B," models in total."]}):null]})]}):null,P.length>0?e.jsxs("div",{className:"flex flex-col gap-3 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Always check"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Runs on every request through this policy. Callers can add their own guardrails but cannot weaken these."}),m.configured?null:e.jsxs("p",{className:"mt-1 text-xs text-amber-700",children:["No guardrails service is configured, so these cannot run. With `if the service is down` set to block, every request through this policy is refused until one is configured."," ",e.jsx(Q,{to:"/tools",className:"underline",children:"Set one up"}),", or remove the guardrail."]})]}),P.map((l,c)=>e.jsxs("div",{className:"flex flex-col gap-3",children:[e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(U,{label:"Profile",value:l.profile,onChange:o=>E(f=>f.map((d,y)=>y===c?{...d,profile:o}:d)),placeholder:"prompt-injection",isRequired:!0,description:"A profile configured on the guardrails service."}),e.jsx(te,{label:"Mode",value:l.mode,onChange:o=>E(f=>f.map((d,y)=>y===c?{...d,mode:o}:d)),hint:"block rejects a flagged request; monitor records it and serves anyway."}),e.jsx(te,{label:"If the service is down",value:l.on_unavailable??"block",onChange:o=>E(f=>f.map((d,y)=>y===c?{...d,on_unavailable:o}:d)),hint:"block fails closed, so a guardrails outage refuses every request through this policy."}),e.jsx(_,{variant:"ghost",onPress:()=>E(o=>o.filter((f,d)=>d!==c)),children:"Remove"})]}),l.mode==="block"&&(l.on_unavailable??"block")==="block"?e.jsx("div",{className:"text-xs text-amber-700",children:"With both set to block, a guardrails-service outage rejects every request through this policy, ahead of any fallback above."}):null]},c))]}):null,e.jsxs("div",{className:"flex flex-wrap gap-3 text-sm",children:[x.length===0?e.jsx("button",{type:"button",className:"text-[var(--otari-brand)] hover:underline",onClick:()=>b([{threshold:80,target:""}]),children:"+ Tier down when the budget fills up"}):null,k.length===0?e.jsx("button",{type:"button",className:"text-[var(--otari-brand)] hover:underline",onClick:()=>a([""]),children:"+ Add a fallback chain"}):null,w.length===0?e.jsx("button",{type:"button",className:"text-[var(--otari-brand)] hover:underline",onClick:()=>{O([p.trim()||"",""]),z(0)},children:"+ Let a router pick the cheapest good-enough model"}):null,P.length===0?e.jsxs("span",{className:"flex flex-wrap items-baseline gap-2",children:[e.jsx("button",{type:"button",disabled:!m.configured,"aria-describedby":m.configured?void 0:"guardrails-unavailable",className:m.configured?"text-[var(--otari-brand)] hover:underline":"cursor-not-allowed text-[var(--otari-muted)] opacity-60",onClick:()=>E([{profile:"",mode:"block",on_unavailable:"block"}]),children:"+ Add guardrails"}),m.configured?null:e.jsxs("span",{id:"guardrails-unavailable",className:"text-xs text-[var(--otari-muted)]",children:["No guardrails service is configured, so there would be nothing to call."," ",e.jsx(Q,{to:"/tools",className:"text-[var(--otari-brand)] hover:underline",children:"Set one up in Tools & Guardrails"}),"."]})]}):null]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx(_,{variant:"primary",isDisabled:!X||J||F,onPress:me,children:J?"Saving…":h?"Save":"Create policy"}),e.jsx(_,{variant:"ghost",onPress:r,children:"Cancel"}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"In effect for new requests within 30s."}),w.length>0?e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["A new router serves the model above until it has scored examples. Recording them is an API job for now (",e.jsx("code",{children:"POST /v1/routing/preferences/rank"}),"); open ",e.jsx("b",{children:"Examples"})," on the row afterwards to watch it warm up."]}):null,F?e.jsx("span",{className:"text-xs text-amber-700",children:"An alias holds one target. To add a fallback, a condition, or a guardrail, delete this alias and create a policy with the same name."}):null]})]})})})}function Xe(){const t=fe(),s=ve(),r=ge(),n=je(),[i]=he(),h=i.get("target")??"",[g,m]=v.useState(h!==""),[j,u]=v.useState(null),[N,C]=v.useState(null),p=[...(t.data??[]).map(a=>({...a,kind:"policy"})),...(s.data??[]).map(Le)].sort((a,x)=>a.name.localeCompare(x.name)||(a.user_id??"").localeCompare(x.user_id??"")),S=v.useCallback(a=>e.jsx(Ie,{policyName:a.name,candidates:T(a.spec),defaultTarget:I(a.spec),backend:De(a.spec)??G,scopedUserId:a.user_id,onClose:()=>C(null)}),[]),k=v.useMemo(()=>[{id:"name",header:"Policy",isRowHeader:!0,cell:a=>e.jsx(Y,{value:a.name,label:"policy name"})},{id:"serves",header:"Serves",cell:a=>e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"text-sm text-[var(--otari-ink)]",children:Be(a)}),T(a.spec).length>0?e.jsx(q,{size:"sm",color:"accent",children:"Learned"}):a.is_dynamic?e.jsx(q,{size:"sm",color:"accent",children:"Dynamic"}):null]})},{id:"guards",header:"Guards",cell:a=>{const x=a.spec.guardrails??[];return x.length===0?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"–"}):e.jsx("span",{className:"text-sm text-[var(--otari-ink)]",children:x.map(b=>`${b.profile} (${b.mode})`).join(", ")})}},{id:"scope",header:"Applies to",cell:a=>a.user_id===null?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"Every caller"}):e.jsx(Y,{value:a.user_id,label:"user id"})},{id:"source",header:"Source",cell:a=>e.jsxs("div",{className:"flex items-center gap-1",children:[e.jsx(q,{size:"sm",color:a.source==="config"?"default":"accent",children:a.source}),a.kind==="alias"?e.jsx(q,{size:"sm",color:"default",children:"alias"}):null]})},{id:"actions",header:"",cell:a=>{const x=T(a.spec).length>0&&e.jsx(_,{size:"sm",variant:"outline",onPress:()=>C(b=>b===$(a)?null:$(a)),children:N===$(a)?"Hide examples":"Examples"});return a.source==="config"?e.jsxs("div",{className:"flex items-center justify-end gap-2",children:[x,e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"set in config.yml"})]}):e.jsxs("div",{className:"flex items-center justify-end gap-2",children:[x,Te(a.spec)?e.jsx(_,{size:"sm",variant:"ghost",onPress:()=>{m(!1),u(a)},children:"Edit"}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Uses options this form cannot show yet. Edit it through the API so nothing is lost."}),e.jsx(be,{confirmLabel:"Confirm",isPending:r.isPending||n.isPending,onConfirm:()=>a.kind==="alias"?n.mutate({name:a.name,userId:a.user_id}):r.mutate({name:a.name,userId:a.user_id}),children:"Delete"})]})}}],[r,n,N]);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Ne,{title:"Routing",description:"Named models your callers send as `model`. A policy decides which real model serves each request, what is tried if that fails, and which guardrails always run. It can also let a router learn which prompts a cheaper model handles just as well.",action:g||j!==null?void 0:e.jsx(_,{variant:"primary",onPress:()=>{u(null),m(!0)},children:"New policy"})}),e.jsx(H,{error:t.error??s.error??r.error??n.error}),g?e.jsx(se,{existing:null,initialTarget:h,onClose:()=>m(!1)}):null,j!==null?e.jsx(se,{existing:j,onClose:()=>u(null)}):null,p.length===0&&!t.isLoading&&!s.isLoading&&!g?e.jsx(ke,{title:"No routing policies yet",children:e.jsxs("ol",{className:"flex list-decimal flex-col gap-1 pl-5 text-sm text-[var(--otari-muted)]",children:[e.jsx("li",{children:"Create a policy and point it at the model that should normally serve."}),e.jsx("li",{children:"Add a fallback chain so a provider outage does not become a failed request."}),e.jsx("li",{children:"Or let a router choose per request between a cheap and a strong model, then teach it with a few scored examples."}),e.jsx("li",{children:"Have your callers send the policy name as their `model`."})]})}):e.jsx(_e,{ariaLabel:"Routing policies",columns:k,rows:p,getRowKey:$,detailKey:N,renderDetail:S,isLoading:t.isLoading||s.isLoading,emptyContent:"No routing policies yet."})]})}export{Xe as RoutingPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as v,u as he,L as Q}from"./react-dgEcD0HR.js";import{p as ae,q as xe,E as H,s as pe,t as fe,v as ve,w as ge,x as je,o as Y,y as be,P as Ne,z as ke,B as we,D as ye,G as Ce}from"./index-D6WO6K2k.js";import{D as _e}from"./DataTable-DuDxGlJc.js";import{U as re}from"./UserComboBox-DoloPF6p.js";import{B as _,g as q,C as D,L as Se,I as Pe,a as Ae,b as Re,d as Z}from"./heroui-COmYdDDM.js";import{F as U}from"./Field-CBU9MRjz.js";function ee({records:t,seed:s,warm:r}){const n=s===0?100:Math.min(100,Math.round(t/s*100));return e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("div",{className:"h-1.5 w-32 overflow-hidden rounded-full bg-[var(--otari-bg)]",children:e.jsx("div",{className:r?"h-full bg-[var(--otari-brand)]":"h-full bg-amber-500",style:{width:`${n}%`}})}),e.jsxs("span",{className:"text-sm text-[var(--otari-ink)]",children:[t," / ",s," examples"]}),e.jsx(q,{size:"sm",color:r?"accent":"default",children:r?"routing":"warming up"})]})}function Ie({policyName:t,candidates:s,defaultTarget:r,backend:n,scopedUserId:i,onClose:h}){const g=ae(),[m,j]=v.useState(i),u=xe(m),N=m!==null&&m!=="";return e.jsxs("div",{children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[e.jsxs("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:["Examples for ",e.jsx("code",{children:t})]}),e.jsx(_,{size:"sm",variant:"ghost",onPress:h,children:"Close"})]}),e.jsxs("div",{className:"flex flex-col gap-5 px-4 py-4",children:[e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:"Candidates"}),e.jsxs("span",{className:"text-sm text-[var(--otari-ink)]",children:[e.jsx("code",{children:n})," ranks ",s.join(", ")," for each request."]}),e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:[e.jsx("code",{children:r})," serves whenever it declines: too few examples, a weakly supported pick, a request carrying tools, or ",e.jsx("code",{children:"Otari-Router: off"}),"."]})]}),e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:"Examples"}),i===null?e.jsx(re,{label:"Whose memory",value:m??"",onChange:j,users:g.data??[],placeholder:"Pick a user…",description:"Examples are one user's own prompts, so this policy warms once per caller rather than once overall.",unknownHint:e.jsx("span",{className:"text-red-700",children:"No such user. Pick an existing one."})}):e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Scoped to user ",e.jsx("code",{children:i}),", so that is the only memory it can use."]}),e.jsx(H,{error:u.error}),N?u.isLoading?e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:"Loading…"}):u.data?e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Default pool"}),e.jsx(ee,{records:u.data.default_pool.records,seed:u.data.seed_count,warm:u.data.default_pool.warm})]}),u.data.tasks.map(C=>e.jsxs("div",{className:"flex flex-wrap items-center gap-3",children:[e.jsx("code",{className:"text-sm text-[var(--otari-ink)]",children:C.task_id}),e.jsx(ee,{records:C.records,seed:u.data.seed_count,warm:C.warm})]},C.task_id)),u.data.tasks.length>0?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"The default pool counts every example this user has, including the ones filed under a task, so it can be warm while a task partition is not."}):null,e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Scoring with ",e.jsx("code",{children:u.data.embedding_model}),", ",u.data.k," nearest examples per decision, cost dial ",u.data.alpha,", deciding once per"," ",u.data.granularity==="trace_sticky"?"conversation":"call",". Change these with the ",e.jsx("code",{children:"OTARI_ROUTER_*"})," environment variables."]})]}):null:e.jsx("span",{className:"text-sm text-[var(--otari-muted)]",children:"Pick a user to see how warm this policy's memory is."})]}),e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-muted)]",children:"Adding examples"}),e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Examples are recorded over the API, with"," ",e.jsx("code",{children:"POST /v1/routing/preferences/rank"}),". Score a batch of prompts from 0 (bad) to 1 (great) per candidate; two good answers is the case that lets the cheaper model win. See"," ",e.jsx("a",{className:"text-[var(--otari-brand)] hover:underline",href:"https://mozilla-ai.github.io/otari/routing/#teach-it",target:"_blank",rel:"noreferrer",children:"Teach it"})," ","in the routing guide."]})]})]})]})}const Ee=50;function M({label:t,value:s,onChange:r,description:n,placeholder:i="provider:model",autoFocus:h,isRequired:g}){const m=pe(),{visible:j,total:u,failed:N}=v.useMemo(()=>{var x;const p=s.trim().toLowerCase(),S=((x=m.data)==null?void 0:x.providers)??[],k=S.flatMap(b=>b.models),a=p?k.filter(b=>b.key.toLowerCase().includes(p)):k;return{visible:a.slice(0,Ee),total:a.length,failed:S.filter(b=>!b.ok)}},[m.data,s]),C=m.isLoading?"Loading models from your providers…":N.length>0?`Could not list models for ${N.map(S=>S.provider).join(", ")}. Check that provider's credentials, or type the model key directly.`:u>j.length?`Showing ${j.length} of ${u} matches. Keep typing to narrow them.`:n;return e.jsxs(D.Root,{allowsCustomValue:!0,allowsEmptyCollection:!0,menuTrigger:"input",inputValue:s,onInputChange:r,onSelectionChange:p=>{p!=null&&r(String(p))},isRequired:g,className:"flex max-w-md flex-col gap-1",children:[e.jsx(Se,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsxs(D.InputGroup,{children:[e.jsx(Pe,{placeholder:i,autoFocus:h}),e.jsx(D.Trigger,{})]}),e.jsx(D.Popover,{children:e.jsx(Ae,{items:j,className:"max-h-72 overflow-auto",children:p=>e.jsx(Re,{id:p.key,textValue:p.key,children:p.key})})}),C?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:C}):null]})}const G="knn",B=5;function Le(t){return{kind:"alias",name:t.name,spec:{select:[{default:t.target}]},source:t.source,user_id:t.user_id,is_dynamic:!1,created_at:t.created_at,updated_at:t.updated_at}}const $=t=>JSON.stringify([t.kind,t.user_id,t.name]);function qe(){var n;const t=Ce(),s=(n=t.data)==null?void 0:n.fields.find(i=>i.key==="guardrails_url"),r=typeof(s==null?void 0:s.value)=="string"?s.value.trim():"";return{configured:t.isLoading||r!=="",isLoading:t.isLoading}}function Te(t){const s=t.select.findIndex(n=>n.router!==void 0),r=t.select.reduce((n,i,h)=>i.when!==void 0?h:n,-1);return s!==-1&&r!==-1&&s{var g,m;if(n.default!==void 0)return n.when===void 0;if(n.router!==void 0)return n.router===G&&(((g=n.candidates)==null?void 0:g.length)??0)>0;const i=n.when;if(i===void 0||n.target===void 0)return!1;const h=Object.keys(i);return h.length===1&&h[0]==="budget_used_pct"&&((m=i.budget_used_pct)==null?void 0:m.gte)!==void 0})}function I(t){var s;return((s=t.select.find(r=>r.default!==void 0))==null?void 0:s.default)??""}function T(t){var s;return((s=t.select.find(r=>r.router!==void 0))==null?void 0:s.candidates)??[]}function ne(t){const s=T(t);if(s.length===0)return[];const r=I(t);return s.includes(r)?s:[...s,r]}function Oe(t){const s=ne(t).indexOf(I(t));return s===-1?0:s}function De(t){var s;return(s=t.select.find(r=>r.router!==void 0))==null?void 0:s.router}function Me(t){return t.select.filter(s=>{var r,n;return((n=(r=s.when)==null?void 0:r.budget_used_pct)==null?void 0:n.gte)!==void 0&&s.target!==void 0}).map(s=>({threshold:s.when.budget_used_pct.gte,target:s.target}))}function Be(t){const s=t.spec.on_failure??[],r=T(t.spec);if(r.length>0)return`Learned · ${r.length} candidates, ${I(t.spec)} by default`;if(t.is_dynamic){const i=1+s.length;return`Chosen per request · ${i} candidate${i===1?"":"s"}`}const n=I(t.spec);return s.length>0?`${n} +${s.length} on failure`:n}function $e({userId:t,onChange:s}){const r=ae(),n=t!==null,i=(h,g)=>e.jsx("button",{type:"button","aria-pressed":n===h,onClick:()=>s(h?"":null),className:n===h?"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:g});return e.jsxs("div",{className:"flex flex-col gap-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Applies to"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"A global policy resolves for every caller. A user-scoped one resolves only for that user, and takes precedence over a global policy of the same name."})]}),e.jsxs("div",{className:"flex w-fit items-center gap-1 rounded-lg bg-[var(--otari-bg)] p-1",children:[i(!1,"Every caller"),i(!0,"One user")]}),n?e.jsx(re,{label:"User",value:t??"",onChange:s,users:r.data??[],placeholder:"Pick a user…",description:"Only this user resolves the policy.",unknownHint:e.jsx("span",{className:"text-red-700",children:"No such user. Pick an existing one."})}):null]})}const ze=["block","monitor"];function te({label:t,hint:s,value:r,onChange:n}){return e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsx("div",{className:"flex w-fit items-center gap-1 rounded-lg bg-[var(--otari-bg)] p-1",children:ze.map(i=>e.jsx("button",{type:"button","aria-pressed":r===i,onClick:()=>n(i),className:r===i?"rounded-md bg-white px-3 py-1 text-sm font-medium text-[var(--otari-ink)] shadow-sm":"rounded-md px-3 py-1 text-sm text-[var(--otari-muted)] hover:text-[var(--otari-ink)]",children:i},i))}),s===void 0?null:e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:s})]})}function se({existing:t,initialTarget:s="",onClose:r}){const n=we(),i=ye(),h=t!==null,g=(t==null?void 0:t.kind)==="alias",m=qe(),[j,u]=v.useState((t==null?void 0:t.name)??""),[N,C]=v.useState((t==null?void 0:t.user_id)??null),[p,S]=v.useState(t?I(t.spec):s),[k,a]=v.useState((t==null?void 0:t.spec.on_failure)??[]),[x,b]=v.useState(t?Me(t.spec):[]),[P,E]=v.useState((t==null?void 0:t.spec.guardrails)??[]),[w,O]=v.useState(t?ne(t.spec):[]),[K,z]=v.useState(t?Oe(t.spec):0),L=w.length>0,A=L?w[K]??"":p,V=/[:/]/.test(j),le=N===null||N.trim()!=="",ie=x.every(l=>l.target.trim()!==""&&l.threshold>0&&l.threshold<100),oe=P.every(l=>l.profile.trim()!==""),de=!L||w.length>=2&&w.every(l=>l.trim()!=="")&&A.trim()!=="",W=(w.length||1)+k.length,R=W>=B,ce=W>B,X=j.trim()!==""&&A.trim()!==""&&!V&&le&&ie&&oe&&de&&!ce&&k.every(l=>l.trim()!==""),ue=v.useMemo(()=>({select:[...x.map(l=>({when:{budget_used_pct:{gte:l.threshold}},target:l.target.trim()})),...L?[{router:G,candidates:w.map(l=>l.trim())}]:[],{default:A.trim()}],...k.length>0?{on_failure:k.map(l=>l.trim())}:{},...P.length>0?{guardrails:P}:{}}),[x,w,L,A,k,P]),F=g&&(k.length>0||x.length>0||P.length>0||w.length>0),J=n.isPending||i.isPending,me=()=>{if(!X||F)return;const l=N===null?null:N.trim();if(g){i.mutate({name:j.trim(),target:A.trim(),user_id:l},{onSuccess:r});return}n.mutate({name:j.trim(),spec:ue,user_id:l},{onSuccess:r})};return e.jsx("div",{className:"flex flex-col gap-4",children:e.jsx(Z,{children:e.jsxs(Z.Content,{className:"flex flex-col gap-5 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:h?e.jsxs(e.Fragment,{children:["Edit ",t.kind==="alias"?"alias":"policy"," ",e.jsx("code",{children:t.name}),t.user_id?e.jsxs(e.Fragment,{children:[" ","for user ",e.jsx("code",{children:t.user_id})]}):null]}):"New routing policy"}),e.jsx(H,{error:n.error??i.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[h?e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Policy 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 name and who it applies to are the key and cannot be changed here. Delete and recreate to change either."})]}):e.jsx(U,{label:"Policy name",value:j,onChange:u,placeholder:"fast",isRequired:!0,autoFocus:!0,description:V?e.jsx("span",{className:"text-red-700",children:"A policy name cannot contain “:” or “/”."}):"What callers send as `model`."}),L?e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Serves"}),e.jsx("span",{className:"text-sm text-[var(--otari-ink)]",children:A.trim()===""?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"whichever model you mark below"}):e.jsx("code",{children:A})}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"A router picks per request, so this policy has no single target. The model marked below is what serves when the router does not choose."})]}):e.jsx(M,{label:"Serves",value:p,onChange:S,isRequired:!0,description:"The model that serves a normal request. Callers never see it."})]}),h?null:e.jsx($e,{userId:N,onChange:C}),x.length>0?e.jsxs("div",{className:"flex flex-col gap-3 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Instead, when the budget fills up"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Checked before the model above. A threshold must be under 100: the budget gate refuses a request before selection once the cap is reached, so a rule at 100 could never fire."})]}),x.map((l,c)=>e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(U,{label:"Budget used at least (%)",value:String(l.threshold),onChange:o=>b(f=>f.map((d,y)=>y===c?{...d,threshold:Number(o)||0}:d)),description:l.threshold>=100?e.jsx("span",{className:"text-red-700",children:"Must be under 100."}):void 0}),e.jsx("div",{className:"min-w-56 flex-1",children:e.jsx(M,{label:"Use instead",value:l.target,onChange:o=>b(f=>f.map((d,y)=>y===c?{...d,target:o}:d)),isRequired:!0})}),e.jsx(_,{variant:"ghost",onPress:()=>b(o=>o.filter((f,d)=>d!==c)),children:"Remove"})]},c))]}):null,w.length>0?e.jsxs("div",{className:"flex flex-col gap-3 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"The router chooses between"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"For each request, the cheapest of these that past scoring says is good enough. Every model here needs pricing, because the router weighs quality against cost."})]}),w.map((l,c)=>e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx("div",{className:"min-w-56 flex-1",children:e.jsx(M,{label:`Model ${c+1}`,value:l,onChange:o=>O(f=>f.map((d,y)=>y===c?o:d)),isRequired:!0})}),e.jsxs("label",{className:"flex items-center gap-2 pb-2 text-xs text-[var(--otari-ink)]",children:[e.jsx("input",{type:"radio",name:"router-safe-choice",checked:K===c,onChange:()=>z(c)}),"Serves when unsure"]}),e.jsx(_,{variant:"ghost",onPress:()=>{O(o=>o.filter((f,d)=>d!==c)),z(o=>cO(l=>[...l,""]),children:"+ Another model"}),R?e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["A policy dispatches at most ",B," models, counting the fallback chain. Remove a fallback to add another."]}):null]})]}):null,k.length>0?e.jsxs("div",{className:"flex flex-col gap-3 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"If that fails, try"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Tried in order after a retryable failure. Not tried once tokens have started streaming, or after a 400/401/403, which every provider would reject the same way."})]}),k.map((l,c)=>e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx("div",{className:"min-w-56 flex-1",children:e.jsx(M,{label:`Fallback ${c+1}`,value:l,onChange:o=>a(f=>f.map((d,y)=>y===c?o:d)),isRequired:!0})}),e.jsx(_,{variant:"ghost",onPress:()=>a(o=>o.filter((f,d)=>d!==c)),children:"Remove"})]},c)),e.jsxs("div",{className:"flex flex-wrap items-baseline gap-2",children:[e.jsx("button",{type:"button",disabled:R,className:R?"cursor-not-allowed text-sm text-[var(--otari-muted)] opacity-60":"text-sm text-[var(--otari-brand)] hover:underline",onClick:()=>a(l=>[...l,""]),children:"+ Another fallback"}),R?e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["A policy dispatches at most ",B," models in total."]}):null]})]}):null,P.length>0?e.jsxs("div",{className:"flex flex-col gap-3 rounded-lg border border-[var(--otari-line)] p-3",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Always check"}),e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Runs on every request through this policy. Callers can add their own guardrails but cannot weaken these."}),m.configured?null:e.jsxs("p",{className:"mt-1 text-xs text-amber-700",children:["No guardrails service is configured, so these cannot run. With `if the service is down` set to block, every request through this policy is refused until one is configured."," ",e.jsx(Q,{to:"/tools",className:"underline",children:"Set one up"}),", or remove the guardrail."]})]}),P.map((l,c)=>e.jsxs("div",{className:"flex flex-col gap-3",children:[e.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[e.jsx(U,{label:"Profile",value:l.profile,onChange:o=>E(f=>f.map((d,y)=>y===c?{...d,profile:o}:d)),placeholder:"prompt-injection",isRequired:!0,description:"A profile configured on the guardrails service."}),e.jsx(te,{label:"Mode",value:l.mode,onChange:o=>E(f=>f.map((d,y)=>y===c?{...d,mode:o}:d)),hint:"block rejects a flagged request; monitor records it and serves anyway."}),e.jsx(te,{label:"If the service is down",value:l.on_unavailable??"block",onChange:o=>E(f=>f.map((d,y)=>y===c?{...d,on_unavailable:o}:d)),hint:"block fails closed, so a guardrails outage refuses every request through this policy."}),e.jsx(_,{variant:"ghost",onPress:()=>E(o=>o.filter((f,d)=>d!==c)),children:"Remove"})]}),l.mode==="block"&&(l.on_unavailable??"block")==="block"?e.jsx("div",{className:"text-xs text-amber-700",children:"With both set to block, a guardrails-service outage rejects every request through this policy, ahead of any fallback above."}):null]},c))]}):null,e.jsxs("div",{className:"flex flex-wrap gap-3 text-sm",children:[x.length===0?e.jsx("button",{type:"button",className:"text-[var(--otari-brand)] hover:underline",onClick:()=>b([{threshold:80,target:""}]),children:"+ Tier down when the budget fills up"}):null,k.length===0?e.jsx("button",{type:"button",className:"text-[var(--otari-brand)] hover:underline",onClick:()=>a([""]),children:"+ Add a fallback chain"}):null,w.length===0?e.jsx("button",{type:"button",className:"text-[var(--otari-brand)] hover:underline",onClick:()=>{O([p.trim()||"",""]),z(0)},children:"+ Let a router pick the cheapest good-enough model"}):null,P.length===0?e.jsxs("span",{className:"flex flex-wrap items-baseline gap-2",children:[e.jsx("button",{type:"button",disabled:!m.configured,"aria-describedby":m.configured?void 0:"guardrails-unavailable",className:m.configured?"text-[var(--otari-brand)] hover:underline":"cursor-not-allowed text-[var(--otari-muted)] opacity-60",onClick:()=>E([{profile:"",mode:"block",on_unavailable:"block"}]),children:"+ Add guardrails"}),m.configured?null:e.jsxs("span",{id:"guardrails-unavailable",className:"text-xs text-[var(--otari-muted)]",children:["No guardrails service is configured, so there would be nothing to call."," ",e.jsx(Q,{to:"/tools",className:"text-[var(--otari-brand)] hover:underline",children:"Set one up in Tools & Guardrails"}),"."]})]}):null]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx(_,{variant:"primary",isDisabled:!X||J||F,onPress:me,children:J?"Saving…":h?"Save":"Create policy"}),e.jsx(_,{variant:"ghost",onPress:r,children:"Cancel"}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"In effect for new requests within 30s."}),w.length>0?e.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["A new router serves the model above until it has scored examples. Recording them is an API job for now (",e.jsx("code",{children:"POST /v1/routing/preferences/rank"}),"); open ",e.jsx("b",{children:"Examples"})," on the row afterwards to watch it warm up."]}):null,F?e.jsx("span",{className:"text-xs text-amber-700",children:"An alias holds one target. To add a fallback, a condition, or a guardrail, delete this alias and create a policy with the same name."}):null]})]})})})}function Xe(){const t=fe(),s=ve(),r=ge(),n=je(),[i]=he(),h=i.get("target")??"",[g,m]=v.useState(h!==""),[j,u]=v.useState(null),[N,C]=v.useState(null),p=[...(t.data??[]).map(a=>({...a,kind:"policy"})),...(s.data??[]).map(Le)].sort((a,x)=>a.name.localeCompare(x.name)||(a.user_id??"").localeCompare(x.user_id??"")),S=v.useCallback(a=>e.jsx(Ie,{policyName:a.name,candidates:T(a.spec),defaultTarget:I(a.spec),backend:De(a.spec)??G,scopedUserId:a.user_id,onClose:()=>C(null)}),[]),k=v.useMemo(()=>[{id:"name",header:"Policy",isRowHeader:!0,cell:a=>e.jsx(Y,{value:a.name,label:"policy name"})},{id:"serves",header:"Serves",cell:a=>e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"text-sm text-[var(--otari-ink)]",children:Be(a)}),T(a.spec).length>0?e.jsx(q,{size:"sm",color:"accent",children:"Learned"}):a.is_dynamic?e.jsx(q,{size:"sm",color:"accent",children:"Dynamic"}):null]})},{id:"guards",header:"Guards",cell:a=>{const x=a.spec.guardrails??[];return x.length===0?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"–"}):e.jsx("span",{className:"text-sm text-[var(--otari-ink)]",children:x.map(b=>`${b.profile} (${b.mode})`).join(", ")})}},{id:"scope",header:"Applies to",cell:a=>a.user_id===null?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"Every caller"}):e.jsx(Y,{value:a.user_id,label:"user id"})},{id:"source",header:"Source",cell:a=>e.jsxs("div",{className:"flex items-center gap-1",children:[e.jsx(q,{size:"sm",color:a.source==="config"?"default":"accent",children:a.source}),a.kind==="alias"?e.jsx(q,{size:"sm",color:"default",children:"alias"}):null]})},{id:"actions",header:"",cell:a=>{const x=T(a.spec).length>0&&e.jsx(_,{size:"sm",variant:"outline",onPress:()=>C(b=>b===$(a)?null:$(a)),children:N===$(a)?"Hide examples":"Examples"});return a.source==="config"?e.jsxs("div",{className:"flex items-center justify-end gap-2",children:[x,e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"set in config.yml"})]}):e.jsxs("div",{className:"flex items-center justify-end gap-2",children:[x,Te(a.spec)?e.jsx(_,{size:"sm",variant:"ghost",onPress:()=>{m(!1),u(a)},children:"Edit"}):e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Uses options this form cannot show yet. Edit it through the API so nothing is lost."}),e.jsx(be,{confirmLabel:"Confirm",isPending:r.isPending||n.isPending,onConfirm:()=>a.kind==="alias"?n.mutate({name:a.name,userId:a.user_id}):r.mutate({name:a.name,userId:a.user_id}),children:"Delete"})]})}}],[r,n,N]);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Ne,{title:"Routing",description:"Named models your callers send as `model`. A policy decides which real model serves each request, what is tried if that fails, and which guardrails always run. It can also let a router learn which prompts a cheaper model handles just as well.",action:g||j!==null?void 0:e.jsx(_,{variant:"primary",onPress:()=>{u(null),m(!0)},children:"New policy"})}),e.jsx(H,{error:t.error??s.error??r.error??n.error}),g?e.jsx(se,{existing:null,initialTarget:h,onClose:()=>m(!1)}):null,j!==null?e.jsx(se,{existing:j,onClose:()=>u(null)}):null,p.length===0&&!t.isLoading&&!s.isLoading&&!g?e.jsx(ke,{title:"No routing policies yet",children:e.jsxs("ol",{className:"flex list-decimal flex-col gap-1 pl-5 text-sm text-[var(--otari-muted)]",children:[e.jsx("li",{children:"Create a policy and point it at the model that should normally serve."}),e.jsx("li",{children:"Add a fallback chain so a provider outage does not become a failed request."}),e.jsx("li",{children:"Or let a router choose per request between a cheap and a strong model, then teach it with a few scored examples."}),e.jsx("li",{children:"Have your callers send the policy name as their `model`."})]})}):e.jsx(_e,{ariaLabel:"Routing policies",columns:k,rows:p,getRowKey:$,detailKey:N,renderDetail:S,isLoading:t.isLoading||s.isLoading,emptyContent:"No routing policies yet."})]})}export{Xe as RoutingPage}; diff --git a/src/gateway/static/dashboard/assets/SettingsPage-CDU9M0qn.js b/src/gateway/static/dashboard/assets/SettingsPage-LbV0e7qd.js similarity index 97% rename from src/gateway/static/dashboard/assets/SettingsPage-CDU9M0qn.js rename to src/gateway/static/dashboard/assets/SettingsPage-LbV0e7qd.js index b7d313aa..ef0855fd 100644 --- a/src/gateway/static/dashboard/assets/SettingsPage-CDU9M0qn.js +++ b/src/gateway/static/dashboard/assets/SettingsPage-LbV0e7qd.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as u}from"./react-dgEcD0HR.js";import{a1 as R,ah as C,P,E as y,a4 as E,ao as T,ap as _,aq as D,F as A,ar as O,ae as F,as as K,N as w}from"./index-DAnS9oY2.js";import{d as v,B as h,A as d,i as I,I as z}from"./heroui-COmYdDDM.js";function b(t,r){return{[t]:r}}function M(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 L(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)||M(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 $({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(z,{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 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 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 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(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(A,{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 U({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 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 G({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 X({source:t}){const r=O(),[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(G,{masterKey:n,error:r.error,isPending:r.isPending,onRegenerate:c,onClose:p}):null]})]})})}function J(){var o;const t=F(),r=K(),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 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=T(),r=_(),s=D(),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(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(""),[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)&&L(l,n)),k=ee(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,t.isLoading?e.jsx(E,{}):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(U,{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,L as fieldMatches}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as u}from"./react-dgEcD0HR.js";import{a1 as R,ah as C,P,E as y,a4 as E,ao as T,ap as _,aq as D,F as A,ar as O,ae as F,as as K,M as w}from"./index-D6WO6K2k.js";import{d as v,B as h,A as d,i as I,I as M}from"./heroui-COmYdDDM.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 L(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 $({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(M,{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 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 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 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(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(A,{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 U({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 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 G({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 X({source:t}){const r=O(),[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(G,{masterKey:n,error:r.error,isPending:r.isPending,onRegenerate:c,onClose:p}):null]})]})})}function J(){var o;const t=F(),r=K(),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 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=T(),r=_(),s=D(),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(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(""),[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)&&L(l,n)),k=ee(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,t.isLoading?e.jsx(E,{}):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(U,{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,L as fieldMatches}; diff --git a/src/gateway/static/dashboard/assets/TablePagination-BpT-8wzM.js b/src/gateway/static/dashboard/assets/TablePagination-D9yR_FiC.js similarity index 93% rename from src/gateway/static/dashboard/assets/TablePagination-BpT-8wzM.js rename to src/gateway/static/dashboard/assets/TablePagination-D9yR_FiC.js index af1b75ec..79948bf6 100644 --- a/src/gateway/static/dashboard/assets/TablePagination-BpT-8wzM.js +++ b/src/gateway/static/dashboard/assets/TablePagination-D9yR_FiC.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as l}from"./react-dgEcD0HR.js";import{F as k}from"./Field-CBU9MRjz.js";import{N as y,E as C,F as T}from"./index-DAnS9oY2.js";import{A as c,B as p,e as P,L as B,I as A,S as q}from"./heroui-COmYdDDM.js";function E({label:t,value:n,onChange:a,isRequired:o,autoFocus:i}){return e.jsxs(P,{value:n,onChange:a,isRequired:o,className:"flex flex-col gap-1",children:[e.jsx(B,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsx(A,{inputMode:"decimal",placeholder:"0.00",autoFocus:i})]})}function F(t){const n=t.trim();if(n==="")return null;const a=Number(n);return Number.isFinite(a)&&a>=0?a:Number.NaN}function z(t){return/^[^\s:/]+[:/][^\s]+$/.test(t.trim())}const W=t=>`Recompute cost for ${t.toLocaleString()} imported ${t===1?"row":"rows"} from each row's own token counts at these per-1M rates. Enforced gateway rows are never affected.`;function K({isOpen:t,onOpenChange:n,targetCount:a=0,isPending:o,error:i,onSubmit:I,title:L="Set price",description:w=W,collectModelKey:h=!1,initialModelKey:d=""}){const[r,f]=l.useState(d),[b,N]=l.useState(""),[j,g]=l.useState(""),[v,u]=l.useState(""),[S,s]=l.useState("");l.useEffect(()=>{t&&(f(d),N(""),g(""),u(""),s(""))},[t,d]);const x=F(b),m=F(j),_=F(v),R=F(S),$=h&&!z(r),D=$||x===null||Number.isNaN(x)||m===null||Number.isNaN(m)||Number.isNaN(_??0)||Number.isNaN(R??0),M=()=>{D||x===null||m===null||I({input_price_per_million:x,output_price_per_million:m,..._!==null&&!Number.isNaN(_)?{cache_read_price_per_million:_}:{},...R!==null&&!Number.isNaN(R)?{cache_write_price_per_million:R}:{}},r.trim())};return e.jsx(c,{isOpen:t,onOpenChange:n,children:t?e.jsx(c.Backdrop,{children:e.jsx(c.Container,{placement:"center",size:"lg",children:e.jsxs(c.Dialog,{children:[e.jsx(c.Header,{children:e.jsx(c.Heading,{children:L})}),e.jsxs(c.Body,{className:"flex flex-col gap-4",children:[e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:w(a)}),h?e.jsx(k,{label:"Model key",value:r,onChange:f,placeholder:"provider:model",isRequired:!0,autoFocus:!0,description:r.trim()!==""&&$?"Include the provider or instance prefix, as in ollama:llama3.2.":"The selector callers send as model, prefix included (for example vllm:mistral-small)."}):null,e.jsxs("div",{className:"grid gap-3 sm:grid-cols-2",children:[e.jsx(E,{label:"Input $ / 1M",value:b,onChange:N,isRequired:!0,autoFocus:!h}),e.jsx(E,{label:"Output $ / 1M",value:j,onChange:g,isRequired:!0}),e.jsx(E,{label:"Cache read $ / 1M",value:v,onChange:u}),e.jsx(E,{label:"Cache write $ / 1M",value:S,onChange:s})]}),e.jsx(y,{tone:"info",children:"Leave a cache rate blank to bill those tokens at the input rate."}),e.jsx(C,{error:i})]}),e.jsxs(c.Footer,{children:[e.jsx(p,{variant:"ghost",isDisabled:o,onPress:()=>n(!1),children:"Cancel"}),e.jsx(p,{variant:"primary",isDisabled:D,isPending:o,onPress:M,children:"Set price"})]})]})})}):null})}const G=[25,50,100];function Q({page:t,pageSize:n,total:a,rowsOnPage:o,onPageChange:i,onPageSizeChange:I,pageSizeOptions:L=G,isFetching:w=!1,hasNextFallback:h=!1}){const d=l.useId(),r=a!=null?Math.max(1,Math.ceil(a/n)):null,f=t===0,b=r!=null?t>=r-1:!h,N=o>0?t*n+1:0,j=t*n+o,g=a!=null?a===0?"0 of 0":`${N.toLocaleString()}–${j.toLocaleString()} of ${a.toLocaleString()}`:o>0?`${N.toLocaleString()}–${j.toLocaleString()}`:"0",[v,u]=l.useState(String(t+1));l.useEffect(()=>{u(String(t+1))},[t]);const S=()=>{const s=Number.parseInt(v,10);if(Number.isNaN(s)){u(String(t+1));return}const x=r??Number.MAX_SAFE_INTEGER,m=Math.min(Math.max(s,1),x);m-1!==t?i(m-1):u(String(t+1))};return e.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("label",{htmlFor:d,className:"text-sm text-[var(--otari-muted)]",children:"Rows"}),e.jsx(T,{id:d,ariaLabel:"Rows per page",value:String(n),onChange:s=>I(Number.parseInt(s,10)),options:L.map(s=>({value:String(s),label:String(s)}))}),w?e.jsx(q,{size:"sm"}):null]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("span",{className:"text-sm text-[var(--otari-muted)] tabular-nums",children:g}),e.jsxs("div",{className:"flex items-center gap-1",children:[e.jsx(p,{size:"sm",variant:"outline","aria-label":"First page",isDisabled:f,onPress:()=>i(0),children:"«"}),e.jsx(p,{size:"sm",variant:"outline","aria-label":"Previous page",isDisabled:f,onPress:()=>i(t-1),children:"‹"}),e.jsxs("span",{className:"inline-flex items-center gap-1 text-sm text-[var(--otari-muted)]",children:[e.jsx("input",{"aria-label":"Page number",inputMode:"numeric",value:v,onChange:s=>u(s.target.value.replace(/[^0-9]/g,"")),onKeyDown:s=>{s.key==="Enter"&&s.currentTarget.blur()},onBlur:S,className:"w-12 rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-2 py-1 text-center text-sm text-[var(--otari-ink)] tabular-nums focus:border-[var(--otari-brand)] focus:outline-none"}),r!=null?e.jsxs("span",{className:"tabular-nums",children:["/ ",r.toLocaleString()]}):null]}),e.jsx(p,{size:"sm",variant:"outline","aria-label":"Next page",isDisabled:b,onPress:()=>i(t+1),children:"›"}),e.jsx(p,{size:"sm",variant:"outline","aria-label":"Last page",isDisabled:r==null||b,onPress:()=>r!=null&&i(r-1),children:"»"})]})]})]})}export{G as P,K as S,Q as T,z as i}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as l}from"./react-dgEcD0HR.js";import{F as k}from"./Field-CBU9MRjz.js";import{M as y,E as C,F as T}from"./index-D6WO6K2k.js";import{A as c,B as p,e as P,L as B,I as A,S as q}from"./heroui-COmYdDDM.js";function E({label:t,value:n,onChange:a,isRequired:o,autoFocus:i}){return e.jsxs(P,{value:n,onChange:a,isRequired:o,className:"flex flex-col gap-1",children:[e.jsx(B,{className:"text-sm font-medium text-[var(--otari-ink)]",children:t}),e.jsx(A,{inputMode:"decimal",placeholder:"0.00",autoFocus:i})]})}function F(t){const n=t.trim();if(n==="")return null;const a=Number(n);return Number.isFinite(a)&&a>=0?a:Number.NaN}function z(t){return/^[^\s:/]+[:/][^\s]+$/.test(t.trim())}const W=t=>`Recompute cost for ${t.toLocaleString()} imported ${t===1?"row":"rows"} from each row's own token counts at these per-1M rates. Enforced gateway rows are never affected.`;function K({isOpen:t,onOpenChange:n,targetCount:a=0,isPending:o,error:i,onSubmit:I,title:L="Set price",description:w=W,collectModelKey:h=!1,initialModelKey:d=""}){const[r,f]=l.useState(d),[b,N]=l.useState(""),[j,g]=l.useState(""),[v,u]=l.useState(""),[S,s]=l.useState("");l.useEffect(()=>{t&&(f(d),N(""),g(""),u(""),s(""))},[t,d]);const x=F(b),m=F(j),_=F(v),R=F(S),M=h&&!z(r),$=M||x===null||Number.isNaN(x)||m===null||Number.isNaN(m)||Number.isNaN(_??0)||Number.isNaN(R??0),D=()=>{$||x===null||m===null||I({input_price_per_million:x,output_price_per_million:m,..._!==null&&!Number.isNaN(_)?{cache_read_price_per_million:_}:{},...R!==null&&!Number.isNaN(R)?{cache_write_price_per_million:R}:{}},r.trim())};return e.jsx(c,{isOpen:t,onOpenChange:n,children:t?e.jsx(c.Backdrop,{children:e.jsx(c.Container,{placement:"center",size:"lg",children:e.jsxs(c.Dialog,{children:[e.jsx(c.Header,{children:e.jsx(c.Heading,{children:L})}),e.jsxs(c.Body,{className:"flex flex-col gap-4",children:[e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:w(a)}),h?e.jsx(k,{label:"Model key",value:r,onChange:f,placeholder:"provider:model",isRequired:!0,autoFocus:!0,description:r.trim()!==""&&M?"Include the provider or instance prefix, as in ollama:llama3.2.":"The selector callers send as model, prefix included (for example vllm:mistral-small)."}):null,e.jsxs("div",{className:"grid gap-3 sm:grid-cols-2",children:[e.jsx(E,{label:"Input $ / 1M",value:b,onChange:N,isRequired:!0,autoFocus:!h}),e.jsx(E,{label:"Output $ / 1M",value:j,onChange:g,isRequired:!0}),e.jsx(E,{label:"Cache read $ / 1M",value:v,onChange:u}),e.jsx(E,{label:"Cache write $ / 1M",value:S,onChange:s})]}),e.jsx(y,{tone:"info",children:"Leave a cache rate blank to bill those tokens at the input rate."}),e.jsx(C,{error:i})]}),e.jsxs(c.Footer,{children:[e.jsx(p,{variant:"ghost",isDisabled:o,onPress:()=>n(!1),children:"Cancel"}),e.jsx(p,{variant:"primary",isDisabled:$,isPending:o,onPress:D,children:"Set price"})]})]})})}):null})}const G=[25,50,100];function Q({page:t,pageSize:n,total:a,rowsOnPage:o,onPageChange:i,onPageSizeChange:I,pageSizeOptions:L=G,isFetching:w=!1,hasNextFallback:h=!1}){const d=l.useId(),r=a!=null?Math.max(1,Math.ceil(a/n)):null,f=t===0,b=r!=null?t>=r-1:!h,N=o>0?t*n+1:0,j=t*n+o,g=a!=null?a===0?"0 of 0":`${N.toLocaleString()}–${j.toLocaleString()} of ${a.toLocaleString()}`:o>0?`${N.toLocaleString()}–${j.toLocaleString()}`:"0",[v,u]=l.useState(String(t+1));l.useEffect(()=>{u(String(t+1))},[t]);const S=()=>{const s=Number.parseInt(v,10);if(Number.isNaN(s)){u(String(t+1));return}const x=r??Number.MAX_SAFE_INTEGER,m=Math.min(Math.max(s,1),x);m-1!==t?i(m-1):u(String(t+1))};return e.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("label",{htmlFor:d,className:"text-sm text-[var(--otari-muted)]",children:"Rows"}),e.jsx(T,{id:d,ariaLabel:"Rows per page",value:String(n),onChange:s=>I(Number.parseInt(s,10)),options:L.map(s=>({value:String(s),label:String(s)}))}),w?e.jsx(q,{size:"sm"}):null]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("span",{className:"text-sm text-[var(--otari-muted)] tabular-nums",children:g}),e.jsxs("div",{className:"flex items-center gap-1",children:[e.jsx(p,{size:"sm",variant:"outline","aria-label":"First page",isDisabled:f,onPress:()=>i(0),children:"«"}),e.jsx(p,{size:"sm",variant:"outline","aria-label":"Previous page",isDisabled:f,onPress:()=>i(t-1),children:"‹"}),e.jsxs("span",{className:"inline-flex items-center gap-1 text-sm text-[var(--otari-muted)]",children:[e.jsx("input",{"aria-label":"Page number",inputMode:"numeric",value:v,onChange:s=>u(s.target.value.replace(/[^0-9]/g,"")),onKeyDown:s=>{s.key==="Enter"&&s.currentTarget.blur()},onBlur:S,className:"w-12 rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-2 py-1 text-center text-sm text-[var(--otari-ink)] tabular-nums focus:border-[var(--otari-brand)] focus:outline-none"}),r!=null?e.jsxs("span",{className:"tabular-nums",children:["/ ",r.toLocaleString()]}):null]}),e.jsx(p,{size:"sm",variant:"outline","aria-label":"Next page",isDisabled:b,onPress:()=>i(t+1),children:"›"}),e.jsx(p,{size:"sm",variant:"outline","aria-label":"Last page",isDisabled:r==null||b,onPress:()=>r!=null&&i(r-1),children:"»"})]})]})]})}export{G as P,K as S,Q as T,z as i}; diff --git a/src/gateway/static/dashboard/assets/ToolsGuardrailsPage-oZnn27P0.js b/src/gateway/static/dashboard/assets/ToolsGuardrailsPage-DnkIwA9f.js similarity index 96% rename from src/gateway/static/dashboard/assets/ToolsGuardrailsPage-oZnn27P0.js rename to src/gateway/static/dashboard/assets/ToolsGuardrailsPage-DnkIwA9f.js index 1ef15dcd..8e86f645 100644 --- a/src/gateway/static/dashboard/assets/ToolsGuardrailsPage-oZnn27P0.js +++ b/src/gateway/static/dashboard/assets/ToolsGuardrailsPage-DnkIwA9f.js @@ -1,2 +1,2 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as m}from"./react-dgEcD0HR.js";import{H as K,at as O,W as z,m as q,au as A,P as G,E as W,a4 as H,$ as D,av as J,F as V}from"./index-DAnS9oY2.js";import{d as R,B as y}from"./heroui-COmYdDDM.js";function X(s,r){return{[s]:r}}const Q=[{key:"web_search",label:"Web search",blurb:"Backend for otari_web_search tools (a SearXNG instance or a search adapter).",pricingKey:"otari:web_search",toolId:"otari_web_search",order:["web_search_url","web_search_engines","web_search_max_results","web_search_extract","web_search_intercept","web_search_purpose_hint"]},{key:"sandbox",label:"Code execution",blurb:"Backend for otari_code_execution tools (the sandbox that runs generated code).",pricingKey:"otari:code_execution",toolId:"otari_code_execution",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 Y(){const[s,r]=m.useState(null),a=m.useRef(void 0),o=n=>{r(n),window.clearTimeout(a.current),a.current=window.setTimeout(()=>r(null),2500)};return m.useEffect(()=>()=>window.clearTimeout(a.current),[]),[s,o]}function Z({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 C="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",_="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 ${C}`,P="flex items-center gap-2 sm:col-start-3",w="flex flex-col gap-1 sm:col-span-2 sm:col-start-2";function S({message:s}){return s?e.jsx("span",{className:"break-words text-xs text-red-700",children:s}):null}function T({field:s,help:r}){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,r?e.jsx("p",{className:"mt-1 text-xs text-[var(--otari-muted)]",children:r}):null]})}function ee({field:s,onSave:r,saveError:a,disabled:o}){const n=typeof s.value=="string"?s.value:"",[l,c]=m.useState(n),[d,x]=m.useState(null),i=J();m.useEffect(()=>{c(n)},[n]);const h=l.trim()!==n,p=l.trim(),b=d!==null&&d===p;return e.jsxs("div",{className:_,children:[e.jsx(T,{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:l,disabled:o,placeholder:"unset",onChange:N=>{c(N.target.value),i.reset()},className:$}),e.jsxs("div",{className:P,children:[e.jsx(y,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:o||!h,onPress:()=>r(p===""?null:p),children:"Save"}),e.jsx(y,{size:"sm",variant:"outline","aria-label":`Test ${s.service}`,isDisabled:p===""||i.isPending,onPress:()=>{x(p),i.mutate({service:s.service,url:p})},children:i.isPending?"Testing…":"Test"})]}),e.jsxs("div",{className:w,children:[e.jsx("span",{role:"status","aria-live":"polite",className:"block break-words text-xs",children:i.isPending||!b?null:i.error?e.jsx("span",{className:"text-red-700",children:D(i.error)}):i.data?e.jsx("span",{className:i.data.ok?"font-medium text-green-700":"text-red-700",children:i.data.reason}):null}),e.jsx(S,{message:a})]})]})}function se({field:s,onSave:r,saveError:a,disabled:o}){const n=typeof s.value=="string"?s.value:"",[l,c]=m.useState(n);m.useEffect(()=>{c(n)},[n]);const d=l!==n;return e.jsxs("div",{className:_,children:[e.jsx(T,{field:s}),e.jsx("input",{type:"text","aria-label":s.key,value:l,disabled:o,placeholder:"default",onChange:x=>c(x.target.value),className:$}),e.jsx("div",{className:P,children:e.jsx(y,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:o||!d,onPress:()=>r(l.trim()===""?null:l.trim()),children:"Save"})}),a?e.jsx("div",{className:w,children:e.jsx(S,{message:a})}):null]})}function te({field:s,onSave:r,saveError:a,disabled:o}){const n=typeof s.value=="number"?String(s.value):"",[l,c]=m.useState(n);m.useEffect(()=>{c(n)},[n]);const d=l.trim(),x=Number(d),h=(d===""||Number.isInteger(x)&&x>=1)&&d!==n;return e.jsxs("div",{className:_,children:[e.jsx(T,{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:l,disabled:o,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 ${C}`}),e.jsx("div",{className:P,children:e.jsx(y,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:o||!h,onPress:()=>r(d===""?null:x),children:"Save"})}),a?e.jsx("div",{className:w,children:e.jsx(S,{message:a})}):null]})}const I=1e6;function ae({pricingKey:s,configured:r,onSave:a,saving:o,saveError:n,disabled:l}){const c=r===null?"":String(r/I),[d,x]=m.useState(c);m.useEffect(()=>{x(c)},[c]);const i=d.trim(),h=Number(i),b=i!==""&&Number.isFinite(h)&&h>=0&&i!==c;return e.jsxs("div",{className:_,children:[e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Price per call"}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:r===null?e.jsxs(e.Fragment,{children:["Not priced. Calls are recorded but billed nothing, and with"," ",e.jsx("code",{className:"font-mono",children:"require_pricing"})," on they are refused. Stored as"," ",e.jsx("code",{className:"font-mono",children:s}),"."]}):e.jsxs(e.Fragment,{children:["Charged per call and added to the request that ran it. Stored as"," ",e.jsx("code",{className:"font-mono",children:s}),"."]})})]}),e.jsxs("div",{className:"flex items-center gap-1.5 sm:col-start-2 sm:justify-self-end",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"USD"}),e.jsx("input",{type:"number",min:"0",step:"0.0001",inputMode:"decimal","aria-label":`Price per call for ${s}`,value:d,disabled:l,placeholder:"0.00",onChange:N=>x(N.target.value),className:`w-full text-right tabular-nums sm:w-28 ${C}`})]}),e.jsx("div",{className:P,children:e.jsx(y,{size:"sm",variant:"primary","aria-label":`Save price for ${s}`,isDisabled:l||!b||o,onPress:()=>a(h),children:o?"Saving…":"Save"})}),n?e.jsx("div",{className:w,children:e.jsx(S,{message:n})}):null]})}function re({field:s,onSave:r,saveError:a,disabled:o}){const n=s.value===!0?"on":s.value===!1?"off":"default";return e.jsxs("div",{className:_,children:[e.jsx(T,{field:s}),e.jsx("div",{className:"sm:col-start-2 sm:justify-self-start",children:e.jsx(V,{ariaLabel:s.key,value:n,onChange:l=>r(l==="default"?null:l==="on"),options:[{value:"default",label:"Default"},{value:"on",label:"On"},{value:"off",label:"Off"}],disabled:o})}),a?e.jsx("div",{className:w,children:e.jsx(S,{message:a})}):null]})}function ne({tool:s}){const r={model:"anthropic:claude-sonnet-4-6",messages:[{role:"user",content:"..."}],tools:[s.example]};return e.jsxs("div",{className:"flex flex-col gap-3 py-4",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:s.id}),s.available?null:e.jsx("span",{className:"rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700",children:"No backend configured"})]}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:s.description}),e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-ink)]",children:"Accepted tools[].type"}),e.jsx("div",{className:"flex flex-wrap gap-1.5",children:s.accepted_types.map(a=>e.jsx("code",{className:"rounded border border-[var(--otari-line)] bg-[var(--otari-surface)] px-1.5 py-0.5 text-xs",children:a},a))})]}),e.jsx("pre",{className:"overflow-x-auto rounded-md border border-[var(--otari-line)] bg-[var(--otari-surface)] p-3 text-xs",children:e.jsx("code",{children:`POST /v1/chat/completions -${JSON.stringify(r,null,2)}`})})]})}function oe({field:s,onSave:r,saveError:a,disabled:o}){return s.type==="url"?e.jsx(ee,{field:s,onSave:r,saveError:a,disabled:o}):s.type==="int"?e.jsx(te,{field:s,onSave:r,saveError:a,disabled:o}):s.type==="bool"?e.jsx(re,{field:s,onSave:r,saveError:a,disabled:o}):e.jsx(se,{field:s,onSave:r,saveError:a,disabled:o})}function ue(){const s=K(),r=O(),a=z(),o=q(),[n,l]=m.useState(null),[c,d]=m.useState({}),x=new Map;for(const t of a.data??[])x.get(t.model_key)===void 0&&x.set(t.model_key,t.input_price_per_million);const i=(t,j)=>{l(t),d(g=>({...g,[t]:""})),o.mutate({model_key:t,input_price_per_million:j*I,output_price_per_million:0},{onSuccess:()=>{l(null),b("Price saved")},onError:g=>{l(null),d(v=>({...v,[t]:g instanceof Error?g.message:"Could not save the price"}))}})},h=A(),[p,b]=Y(),[N,L]=m.useState({}),f=s.data,M=!f||h.isPending,U=new Map(((f==null?void 0:f.fields)??[]).map(t=>[t.key,t])),B=(t,j)=>{L(g=>{const{[t.key]:v,...k}=g;return k}),h.mutate(X(t.key,j),{onSuccess:()=>b(`${t.key} saved`),onError:g=>L(v=>({...v,[t.key]:D(g)}))})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(G,{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(W,{error:s.error}),s.isLoading?e.jsx(H,{}):null,Q.map(t=>{var E;const j=t.order.map(u=>U.get(u)).filter(u=>u!==void 0),g=((f==null?void 0:f.fields)??[]).filter(u=>u.service===t.key&&!t.order.includes(u.key)),v=[...j,...g];if(v.length===0)return null;const k=t.toolId?(((E=r.data)==null?void 0:E.data)??[]).find(u=>u.id===t.toolId):void 0;return e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:t.label}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:t.blurb}),e.jsx(R,{children:e.jsxs(R.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:[t.pricingKey?e.jsx(ae,{pricingKey:t.pricingKey,configured:x.get(t.pricingKey)??null,onSave:u=>i(t.pricingKey,u),saving:n===t.pricingKey,saveError:c[t.pricingKey]||(a.error?"Could not load the current price. Reload before editing.":void 0),disabled:a.isLoading||!!a.error}):null,v.map(u=>e.jsx(oe,{field:u,onSave:F=>B(u,F),saveError:N[u.key],disabled:M},u.key)),k?e.jsx(ne,{tool:k}):null]})})]},t.key)}),e.jsx(Z,{message:p})]})}export{ue as ToolsGuardrailsPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as m}from"./react-dgEcD0HR.js";import{G as K,at as O,W as z,k as G,au as q,P as A,E as W,a4 as H,$ as D,av as J,F as V}from"./index-D6WO6K2k.js";import{d as R,B as y}from"./heroui-COmYdDDM.js";function X(s,r){return{[s]:r}}const Q=[{key:"web_search",label:"Web search",blurb:"Backend for otari_web_search tools (a SearXNG instance or a search adapter).",pricingKey:"otari:web_search",toolId:"otari_web_search",order:["web_search_url","web_search_engines","web_search_max_results","web_search_extract","web_search_intercept","web_search_purpose_hint"]},{key:"sandbox",label:"Code execution",blurb:"Backend for otari_code_execution tools (the sandbox that runs generated code).",pricingKey:"otari:code_execution",toolId:"otari_code_execution",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 Y(){const[s,r]=m.useState(null),a=m.useRef(void 0),o=n=>{r(n),window.clearTimeout(a.current),a.current=window.setTimeout(()=>r(null),2500)};return m.useEffect(()=>()=>window.clearTimeout(a.current),[]),[s,o]}function Z({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 C="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",_="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 ${C}`,P="flex items-center gap-2 sm:col-start-3",w="flex flex-col gap-1 sm:col-span-2 sm:col-start-2";function S({message:s}){return s?e.jsx("span",{className:"break-words text-xs text-red-700",children:s}):null}function T({field:s,help:r}){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,r?e.jsx("p",{className:"mt-1 text-xs text-[var(--otari-muted)]",children:r}):null]})}function ee({field:s,onSave:r,saveError:a,disabled:o}){const n=typeof s.value=="string"?s.value:"",[l,c]=m.useState(n),[d,x]=m.useState(null),i=J();m.useEffect(()=>{c(n)},[n]);const h=l.trim()!==n,p=l.trim(),b=d!==null&&d===p;return e.jsxs("div",{className:_,children:[e.jsx(T,{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:l,disabled:o,placeholder:"unset",onChange:N=>{c(N.target.value),i.reset()},className:$}),e.jsxs("div",{className:P,children:[e.jsx(y,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:o||!h,onPress:()=>r(p===""?null:p),children:"Save"}),e.jsx(y,{size:"sm",variant:"outline","aria-label":`Test ${s.service}`,isDisabled:p===""||i.isPending,onPress:()=>{x(p),i.mutate({service:s.service,url:p})},children:i.isPending?"Testing…":"Test"})]}),e.jsxs("div",{className:w,children:[e.jsx("span",{role:"status","aria-live":"polite",className:"block break-words text-xs",children:i.isPending||!b?null:i.error?e.jsx("span",{className:"text-red-700",children:D(i.error)}):i.data?e.jsx("span",{className:i.data.ok?"font-medium text-green-700":"text-red-700",children:i.data.reason}):null}),e.jsx(S,{message:a})]})]})}function se({field:s,onSave:r,saveError:a,disabled:o}){const n=typeof s.value=="string"?s.value:"",[l,c]=m.useState(n);m.useEffect(()=>{c(n)},[n]);const d=l!==n;return e.jsxs("div",{className:_,children:[e.jsx(T,{field:s}),e.jsx("input",{type:"text","aria-label":s.key,value:l,disabled:o,placeholder:"default",onChange:x=>c(x.target.value),className:$}),e.jsx("div",{className:P,children:e.jsx(y,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:o||!d,onPress:()=>r(l.trim()===""?null:l.trim()),children:"Save"})}),a?e.jsx("div",{className:w,children:e.jsx(S,{message:a})}):null]})}function te({field:s,onSave:r,saveError:a,disabled:o}){const n=typeof s.value=="number"?String(s.value):"",[l,c]=m.useState(n);m.useEffect(()=>{c(n)},[n]);const d=l.trim(),x=Number(d),h=(d===""||Number.isInteger(x)&&x>=1)&&d!==n;return e.jsxs("div",{className:_,children:[e.jsx(T,{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:l,disabled:o,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 ${C}`}),e.jsx("div",{className:P,children:e.jsx(y,{size:"sm",variant:"primary","aria-label":`Save ${s.key}`,isDisabled:o||!h,onPress:()=>r(d===""?null:x),children:"Save"})}),a?e.jsx("div",{className:w,children:e.jsx(S,{message:a})}):null]})}const I=1e6;function ae({pricingKey:s,configured:r,onSave:a,saving:o,saveError:n,disabled:l}){const c=r===null?"":String(r/I),[d,x]=m.useState(c);m.useEffect(()=>{x(c)},[c]);const i=d.trim(),h=Number(i),b=i!==""&&Number.isFinite(h)&&h>=0&&i!==c;return e.jsxs("div",{className:_,children:[e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Price per call"}),e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:r===null?e.jsxs(e.Fragment,{children:["Not priced. Calls are recorded but billed nothing, and with"," ",e.jsx("code",{className:"font-mono",children:"require_pricing"})," on they are refused. Stored as"," ",e.jsx("code",{className:"font-mono",children:s}),"."]}):e.jsxs(e.Fragment,{children:["Charged per call and added to the request that ran it. Stored as"," ",e.jsx("code",{className:"font-mono",children:s}),"."]})})]}),e.jsxs("div",{className:"flex items-center gap-1.5 sm:col-start-2 sm:justify-self-end",children:[e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"USD"}),e.jsx("input",{type:"number",min:"0",step:"0.0001",inputMode:"decimal","aria-label":`Price per call for ${s}`,value:d,disabled:l,placeholder:"0.00",onChange:N=>x(N.target.value),className:`w-full text-right tabular-nums sm:w-28 ${C}`})]}),e.jsx("div",{className:P,children:e.jsx(y,{size:"sm",variant:"primary","aria-label":`Save price for ${s}`,isDisabled:l||!b||o,onPress:()=>a(h),children:o?"Saving…":"Save"})}),n?e.jsx("div",{className:w,children:e.jsx(S,{message:n})}):null]})}function re({field:s,onSave:r,saveError:a,disabled:o}){const n=s.value===!0?"on":s.value===!1?"off":"default";return e.jsxs("div",{className:_,children:[e.jsx(T,{field:s}),e.jsx("div",{className:"sm:col-start-2 sm:justify-self-start",children:e.jsx(V,{ariaLabel:s.key,value:n,onChange:l=>r(l==="default"?null:l==="on"),options:[{value:"default",label:"Default"},{value:"on",label:"On"},{value:"off",label:"Off"}],disabled:o})}),a?e.jsx("div",{className:w,children:e.jsx(S,{message:a})}):null]})}function ne({tool:s}){const r={model:"anthropic:claude-sonnet-4-6",messages:[{role:"user",content:"..."}],tools:[s.example]};return e.jsxs("div",{className:"flex flex-col gap-3 py-4",children:[e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx("code",{className:"text-sm font-medium text-[var(--otari-ink)]",children:s.id}),s.available?null:e.jsx("span",{className:"rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700",children:"No backend configured"})]}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:s.description}),e.jsxs("div",{className:"flex flex-col gap-1",children:[e.jsx("span",{className:"text-xs font-medium text-[var(--otari-ink)]",children:"Accepted tools[].type"}),e.jsx("div",{className:"flex flex-wrap gap-1.5",children:s.accepted_types.map(a=>e.jsx("code",{className:"rounded border border-[var(--otari-line)] bg-[var(--otari-surface)] px-1.5 py-0.5 text-xs",children:a},a))})]}),e.jsx("pre",{className:"overflow-x-auto rounded-md border border-[var(--otari-line)] bg-[var(--otari-surface)] p-3 text-xs",children:e.jsx("code",{children:`POST /v1/chat/completions +${JSON.stringify(r,null,2)}`})})]})}function oe({field:s,onSave:r,saveError:a,disabled:o}){return s.type==="url"?e.jsx(ee,{field:s,onSave:r,saveError:a,disabled:o}):s.type==="int"?e.jsx(te,{field:s,onSave:r,saveError:a,disabled:o}):s.type==="bool"?e.jsx(re,{field:s,onSave:r,saveError:a,disabled:o}):e.jsx(se,{field:s,onSave:r,saveError:a,disabled:o})}function ue(){const s=K(),r=O(),a=z(),o=G(),[n,l]=m.useState(null),[c,d]=m.useState({}),x=new Map;for(const t of a.data??[])x.get(t.model_key)===void 0&&x.set(t.model_key,t.input_price_per_million);const i=(t,j)=>{l(t),d(g=>({...g,[t]:""})),o.mutate({model_key:t,input_price_per_million:j*I,output_price_per_million:0},{onSuccess:()=>{l(null),b("Price saved")},onError:g=>{l(null),d(v=>({...v,[t]:g instanceof Error?g.message:"Could not save the price"}))}})},h=q(),[p,b]=Y(),[N,L]=m.useState({}),f=s.data,M=!f||h.isPending,U=new Map(((f==null?void 0:f.fields)??[]).map(t=>[t.key,t])),B=(t,j)=>{L(g=>{const{[t.key]:v,...k}=g;return k}),h.mutate(X(t.key,j),{onSuccess:()=>b(`${t.key} saved`),onError:g=>L(v=>({...v,[t.key]:D(g)}))})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(A,{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(W,{error:s.error}),s.isLoading?e.jsx(H,{}):null,Q.map(t=>{var E;const j=t.order.map(u=>U.get(u)).filter(u=>u!==void 0),g=((f==null?void 0:f.fields)??[]).filter(u=>u.service===t.key&&!t.order.includes(u.key)),v=[...j,...g];if(v.length===0)return null;const k=t.toolId?(((E=r.data)==null?void 0:E.data)??[]).find(u=>u.id===t.toolId):void 0;return e.jsxs("section",{className:"flex flex-col gap-2",children:[e.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:t.label}),e.jsx("p",{className:"text-sm text-[var(--otari-muted)]",children:t.blurb}),e.jsx(R,{children:e.jsxs(R.Content,{className:"flex flex-col divide-y divide-[var(--otari-line)] px-5 py-1",children:[t.pricingKey?e.jsx(ae,{pricingKey:t.pricingKey,configured:x.get(t.pricingKey)??null,onSave:u=>i(t.pricingKey,u),saving:n===t.pricingKey,saveError:c[t.pricingKey]||(a.error?"Could not load the current price. Reload before editing.":void 0),disabled:a.isLoading||!!a.error}):null,v.map(u=>e.jsx(oe,{field:u,onSave:F=>B(u,F),saveError:N[u.key],disabled:M},u.key)),k?e.jsx(ne,{tool:k}):null]})})]},t.key)}),e.jsx(Z,{message:p})]})}export{ue as ToolsGuardrailsPage}; diff --git a/src/gateway/static/dashboard/assets/UsagePage-Bt6OQ6El.js b/src/gateway/static/dashboard/assets/UsagePage-Bt6OQ6El.js deleted file mode 100644 index 84accb0e..00000000 --- a/src/gateway/static/dashboard/assets/UsagePage-Bt6OQ6El.js +++ /dev/null @@ -1 +0,0 @@ -import{j as s}from"./tanstack-query-1t81HyiD.js";import{i as ys,r as u}from"./react-dgEcD0HR.js";import{u as bs,c as _s,p as ge,i as js,g as fe,aw as ws,ax as Ss,a6 as ee,ay as We,P as Ns,E as Cs,o as ke,f as Ls,R as Ts,az as Ye,B as Es,a7 as G,a9 as se,a8 as ae,ab as Be,aA as H,F as Rs,h as Fs,ad as Os,r as As}from"./index-DAnS9oY2.js";import{S as te,C as qs,T as Ps}from"./charts-krq1PqQO.js";import{D as Ze}from"./DataTable-DuDxGlJc.js";import{F as Ds}from"./FilterChips-DTdIceb1.js";import{B as P,S as Me}from"./heroui-COmYdDDM.js";import"./recharts-C3cGlHOx.js";function D(c){return c.toLocaleString()}function $s(c){return c===null?"—":c<1e3?`${Math.round(c)} ms`:`${(c/1e3).toFixed(2)} s`}function Bs(c,x){const y=new Date(c);return Number.isNaN(y.getTime())?c:x==="hour"?y.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:"UTC"}):y.toLocaleDateString(void 0,{month:"short",day:"numeric",timeZone:"UTC"})}const Ue=Fs(Ye,We),ye=15,Ie=[{key:"cost",label:"Cost"},{key:"tokens",label:"Tokens"},{key:"requests",label:"Requests"}],Ms=[{value:"",label:"None"},{value:"model",label:"Model"},{value:"user_id",label:"User"},{value:"api_key_id",label:"API key"},{value:"source",label:"Source"}],Us=[{key:"fresh",label:"Fresh input",color:"var(--otari-ink)"},{key:"cache_read",label:"Cache read",color:"var(--otari-brand)"},{key:"cache_write",label:"Cache write",color:"var(--otari-brand-soft)"},{key:"output",label:"Output",color:"var(--otari-brand-dark)"}],Is=[{key:"success",label:"Succeeded",color:"var(--otari-brand)"},{key:"errors",label:"Failed",color:"var(--otari-danger)"}],Ke=["var(--otari-cat-1)","var(--otari-cat-2)","var(--otari-cat-3)","var(--otari-cat-4)","var(--otari-cat-5)","var(--otari-cat-6)","var(--otari-cat-7)","var(--otari-cat-8)"],Ks="var(--otari-cat-other)";function zs(c){return c==="cost"?ae:c==="tokens"?H:D}const ze="__other__",Ge="__unknown__";function He({dimensionLabel:c,rows:x,totalCost:y,emptyLabel:v,unknownLabel:$="(unknown)",onDrill:n,loading:C}){const[g,B]=u.useState(!1),W=g?x:x.slice(0,ye),Y=x.length-W.length,re=o=>o.is_other?ze:o.key??Ge,Z=[{id:"name",header:c,isRowHeader:!0,cell:o=>{const M=y>0?o.cost/y:0;return s.jsxs("div",{className:"flex flex-col gap-1",children:[s.jsx("span",{className:"truncate text-[var(--otari-ink)]",children:o.is_other?`Other (${o.requests.toLocaleString()} req)`:o.key===null?$:o.key}),s.jsx("span",{className:"h-1 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",children:s.jsx("span",{className:"block h-full rounded-full bg-[var(--otari-brand)]",style:{width:`${Math.min(100,M*100)}%`}})})]})}},{id:"requests",header:"Requests",align:"end",cell:o=>s.jsx("span",{className:"text-[var(--otari-muted)]",children:D(o.requests)})},{id:"tokens",header:"Tokens",align:"end",cell:o=>s.jsx("span",{className:"text-[var(--otari-muted)]",children:H(o.tokens)})},{id:"spend",header:"Spend",align:"end",cell:o=>s.jsx("span",{className:"text-[var(--otari-ink)]",children:ae(o.cost)})}];return s.jsxs("div",{className:"flex flex-col gap-2",children:[s.jsx(Ze,{ariaLabel:`Spend by ${c.toLowerCase()}`,columns:Z,rows:W,getRowKey:re,isLoading:C,emptyContent:v,onRowAction:o=>{o!==ze&&o!==Ge&&n(o)}}),!C&&Y>0?s.jsxs(P,{size:"sm",variant:"ghost",onPress:()=>B(!0),children:["Show all ",x.length]}):null,!C&&g&&x.length>ye?s.jsxs(P,{size:"sm",variant:"ghost",onPress:()=>B(!1),children:["Show top ",ye]}):null]})}function Gs({rows:c,totalCost:x,onDrill:y,loading:v}){const $=[{id:"tool",header:"Tool",isRowHeader:!0,cell:n=>{const C=x>0?n.cost/x:0;return s.jsxs("div",{className:"flex flex-col gap-1",children:[s.jsx("span",{className:"truncate text-[var(--otari-ink)]",children:n.tool.replaceAll("_"," ")}),s.jsx("span",{className:"h-1 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",children:s.jsx("span",{className:"block h-full rounded-full bg-[var(--otari-brand)]",style:{width:`${Math.min(100,C*100)}%`}})})]})}},{id:"calls",header:"Calls",align:"end",cell:n=>s.jsx("span",{className:"text-[var(--otari-muted)]",children:D(n.calls)})},{id:"failed",header:"Failed",align:"end",cell:n=>s.jsx("span",{className:n.errors?"text-red-700":"text-[var(--otari-muted)]",children:D(n.errors)})},{id:"requests",header:"Requests",align:"end",cell:n=>s.jsx("span",{className:"text-[var(--otari-muted)]",children:D(n.requests)})},{id:"spend",header:"Spend",align:"end",cell:n=>s.jsx("span",{className:"text-[var(--otari-ink)]",children:ae(n.cost)})}];return s.jsx(Ze,{ariaLabel:"Spend by gateway-run tool",columns:$,rows:c,getRowKey:n=>n.tool,isLoading:v,emptyContent:"No gateway-run tool calls in this range.",onRowAction:n=>y(String(n))})}const Hs=["model","user","source_label","endpoint","provider","source","tool"],Ws=["model"];function tt(){var Ae,qe,Pe,De;const c=ys(),x=bs(),y=_s(),[v,$]=u.useState(Ue),[n,C]=u.useState(()=>ge(Ue.seconds??0)),[g,B]=u.useState(!1),[W,Y]=u.useState(),[re,Z]=u.useState(),[o,M]=u.useState([]),[b,oe]=u.useState([]),[f,ne]=u.useState([]),[h,Qe]=u.useState("cost"),[F,Ve]=u.useState(""),k=g?W:n,w=g?re:void 0,L=g?k?js(k,w):"day":v.bucket,T=u.useMemo(()=>({start_date:k,end_date:w,model:o.length>0?o:void 0,user_id:b.length>0?b:void 0,api_key_id:f.length>0?f:void 0}),[k,w,o,b,f]),U=u.useMemo(()=>{if(g){if(!k||!w)return null;const e=new Date(w).getTime()-new Date(k).getTime();return e>0?{...T,start_date:new Date(new Date(k).getTime()-e).toISOString(),end_date:k}:null}return!n||v.seconds===null?null:{...T,start_date:new Date(new Date(n).getTime()-v.seconds*1e3).toISOString(),end_date:n}},[g,k,w,T,v.seconds,n]),j=fe(T,L,Hs),le=fe(U??T,L,Os,U!==null),E=ws(T,L,F||null),ie=F!==""&&E.error instanceof Ss&&E.error.status===404,O=ie?"":F,t=j.data,a=t==null?void 0:t.totals,_=U!==null?(Ae=le.data)==null?void 0:Ae.totals:void 0,be=a?ee(a.cost,_==null?void 0:_.cost):null,Xe=u.useMemo(()=>({...T,model:void 0}),[T]),_e=fe(Xe,L,Ws),Je=((Pe=(qe=_e.data)==null?void 0:qe.by_model)==null?void 0:Pe.filter(e=>!e.is_other&&e.key!==null).map(e=>e.key))??[],je=(x.data??[]).map(e=>({value:e.user_id,label:e.alias?`${e.alias} (${e.user_id})`:e.user_id})),ce=(y.data??[]).map(e=>({value:e.id,label:e.key_name??`${e.id.slice(0,8)}…`})),es=e=>{var l;return((l=ce.find(i=>i.value===e))==null?void 0:l.label)??e},ss=Je.map(e=>({value:e,label:e})),ts=g||v.key!==We,de=o.length>0||b.length>0||f.length>0||ts,we=(e,l)=>{var i;return((i=e.find(r=>r.value===l))==null?void 0:i.label)??l},as=()=>{M([]),oe([]),ne([])},ue=(e,l,i,r,m)=>i.map(S=>({key:`${e}:${S}`,label:l,value:r(S),clearLabel:`Remove ${l} filter ${r(S)}`,onClear:()=>m(i.filter(q=>q!==S))})),rs=[...ue("user","User",b,e=>we(je,e),oe),...ue("model","Model",o,e=>e,M),...ue("key","API key",f,e=>we(ce,e),ne)],os=!!(t&&a&&a.request_count===0&&!de),ns=(t==null?void 0:t.start_date)??k,ls=(t==null?void 0:t.end_date)??w,Se=e=>{B(!1),$(e),C(ge(e.seconds??0)),Y(void 0),Z(void 0)},is=(e,l)=>{B(!0),Y(e),Z(l)},cs=()=>{g||C(ge(v.seconds??0)),j.refetch(),_e.refetch(),U!==null&&le.refetch(),F&&E.refetch()},A=e=>{const l=new URLSearchParams;k&&l.set("start_date",k),w&&l.set("end_date",w);for(const[i,r]of Object.entries(e))for(const m of typeof r=="string"?[r]:r??[])m&&l.append(i,m);c(`/activity?${l.toString()}`)},ds=a&&a.request_count>0?a.error_count/a.request_count:0,Ne=((t==null?void 0:t.by_source)??[]).filter(e=>!e.is_other).length>1,us=Ne||F==="source",p=(t==null?void 0:t.series)??[],Q=p.length>1,Ce=a==null?void 0:a.billed_input_tokens,V=a===void 0?null:Ce!==void 0?Ce+(a.billed_output_tokens??a.completion_tokens):a.total_tokens,ms=_===void 0?null:_.billed_input_tokens!==void 0?_.billed_input_tokens+(_.billed_output_tokens??_.completion_tokens):_.total_tokens,Le=e=>{let l=0,i=0,r=0;for(const m of e)l+=m.input_tokens??0,i+=m.cache_read_tokens??0,r+=m.cache_write_tokens??0;return{input:l,read:i,write:r}},I=Le(p),X=I.input>0?I.read/I.input:null,me=Le(U!==null?((De=le.data)==null?void 0:De.series)??[]:[]),Te=me.input>0?me.read/me.input:void 0,Ee=e=>e.input_tokens!==void 0?e.input_tokens+(e.output_tokens??0):e.tokens,he=p.some(e=>(e.input_tokens??0)>0),Re=p.some(e=>(e.errors??0)>0),R=u.useMemo(()=>{var i;const e=p.map(r=>r.bucket_start);if(O){const r=E.data;if(!r)return{series:[],data:[]};const m=r.groups.map((d,N)=>({key:`g${N}`,label:d.is_other?"Other":d.key===null?"(unknown)":O==="api_key_id"?es(d.key):d.key,color:d.is_other?Ks:Ke[N%Ke.length]})),S=new Map(r.groups.map((d,N)=>[`${d.is_other}|${d.key}`,`g${N}`])),q=new Map(e.map(d=>[d,{x:d,...Object.fromEntries(m.map(N=>[N.key,0]))}]));for(const d of r.points){const N=S.get(`${d.is_other}|${d.key}`),$e=q.get(d.bucket_start);!N||!$e||($e[N]=h==="cost"?d.cost:h==="tokens"?d.tokens:d.requests)}return{series:m,data:[...q.values()]}}return h==="tokens"&&he?{series:Us,data:p.map(r=>{const m=r.input_tokens??0,S=r.cache_read_tokens??0,q=r.cache_write_tokens??0;return{x:r.bucket_start,fresh:Math.max(0,m-S-q),cache_read:S,cache_write:q,output:r.output_tokens??0}})}:h==="requests"&&Re?{series:Is,data:p.map(r=>{const m=Math.min(r.errors??0,r.requests);return{x:r.bucket_start,success:r.requests-m,errors:m}})}:{series:[{key:h,label:((i=Ie.find(r=>r.key===h))==null?void 0:i.label)??h,color:"var(--otari-brand)"}],data:p.map(r=>({x:r.bucket_start,[h]:h==="cost"?r.cost:h==="tokens"?Ee(r):r.requests}))}},[p,O,E.data,h,he,Re,y.data]),Fe=zs(h),hs=j.isLoading||!!O&&E.isLoading,ps=R.data.length?Math.max(...R.data.map(e=>R.series.reduce((l,i)=>l+(typeof e[i.key]=="number"?e[i.key]:0),0))):0,xs=p.map(e=>e.bucket_start),vs=(e,l)=>{const i=As(xs,e,l,L);i&&is(i.startIso,i.endIso)},pe=[{key:"model",label:"Model",rows:(t==null?void 0:t.by_model)??[],drill:e=>A({model:e,user_id:b,api_key_id:f})},{key:"user",label:"User",rows:(t==null?void 0:t.by_user)??[],drill:e=>A({user_id:e,model:o,api_key_id:f})}],gs=[{key:"source_label",label:"Session",rows:(t==null?void 0:t.by_source_label)??[],unknownLabel:"(no session)",drill:e=>A({source_label:e,model:o,user_id:b,api_key_id:f})},{key:"endpoint",label:"Endpoint",rows:(t==null?void 0:t.by_endpoint)??[],drill:e=>A({endpoint:e,model:o,user_id:b,api_key_id:f})},{key:"provider",label:"Provider",rows:(t==null?void 0:t.by_provider)??[],drill:e=>A({provider:e,model:o,user_id:b,api_key_id:f})},{key:"source",label:"Source",rows:(t==null?void 0:t.by_source)??[],drill:e=>A({source:e,model:o,user_id:b,api_key_id:f})}],Oe=(t==null?void 0:t.by_tool)??[],[xe,fs]=u.useState("model"),[J,ks]=u.useState("source_label"),K=pe.find(e=>e.key===xe)??pe[0],ve=gs.filter(e=>e.key!=="source"||Ne||J==="source"),z=ve.find(e=>e.key===J)??ve[0];return s.jsxs("div",{className:"flex flex-col gap-6",children:[s.jsx(Ns,{title:"Usage & analytics",description:"Spend, tokens, cache use, and request volume over time. Group the chart by model, user, key, or source, and click a breakdown row to drill into the request log."}),s.jsx(Cs,{error:j.error??(F!==""&&!ie?E.error:null)}),s.jsxs(Ds,{chips:rs,onClearAll:as,start:Ye.map(e=>s.jsx(P,{size:"sm",variant:!g&&v.key===e.key?"primary":"outline",onPress:()=>Se(e),children:e.label},e.key)),end:s.jsxs(s.Fragment,{children:[s.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Showing ",Ls(ns,ls)," · UTC"]}),s.jsx(Ts,{onRefresh:cs,isFetching:j.isFetching,updatedAt:j.dataUpdatedAt})]}),children:[s.jsx(ke,{label:"User",values:b,onChange:oe,options:je,placeholder:"All users"}),s.jsx(ke,{label:"Model",values:o,onChange:M,options:ss,placeholder:"All models"}),s.jsx(ke,{label:"API key",values:f,onChange:ne,options:ce,placeholder:"All keys"})]}),os?s.jsx(Es,{title:"No usage yet",description:"Once the gateway serves requests, spend and volume appear here."}):s.jsxs(s.Fragment,{children:[s.jsxs("div",{className:"grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-5",children:[s.jsx(G,{label:"Tracked cost",value:a?ae(a.cost):"—",hint:a?s.jsxs("span",{className:"text-[var(--otari-muted)]",children:[s.jsx(se,{fraction:be}),a.unpriced_requests?`${be!==null?" · ":""}${D(a.unpriced_requests)} unpriced`:null]}):null,chart:Q?s.jsx(te,{values:p.map(e=>e.cost),ariaLabel:"Spend trend over the selected window"}):void 0}),s.jsx(G,{label:"Requests",value:a?D(a.request_count):"—",hint:a?s.jsxs("span",{className:"text-[var(--otari-muted)]",children:[Be(ds)," errors",_?s.jsxs(s.Fragment,{children:[" · ",s.jsx(se,{fraction:ee(a.request_count,_.request_count)})]}):null]}):null,chart:Q?s.jsx(te,{values:p.map(e=>e.requests),ariaLabel:"Request volume trend over the selected window"}):void 0}),s.jsx(G,{label:"Tokens (billed)",value:V!==null?H(V):"—",hint:V!==null?s.jsx(se,{fraction:ee(V,ms??void 0)}):null,chart:Q?s.jsx(te,{values:p.map(Ee),ariaLabel:"Billed token trend over the selected window"}):void 0}),s.jsx(G,{label:"Cache hit rate",value:X!==null?Be(X):"—",hint:a?s.jsxs("span",{className:"text-[var(--otari-muted)]",children:[X!==null&&Te!==void 0?s.jsxs(s.Fragment,{children:[s.jsx(se,{fraction:ee(X,Te)})," · "]}):null,H(I.read)," read · ",H(I.write)," written"]}):null,chart:Q&&he?s.jsx(te,{values:p.map(e=>(e.input_tokens??0)>0?(e.cache_read_tokens??0)/(e.input_tokens??1):0),ariaLabel:"Cache hit rate trend over the selected window"}):void 0}),s.jsx(G,{label:"Avg latency",value:a?$s(a.avg_latency_ms):"—"})]}),s.jsxs("div",{className:"flex flex-col gap-3 rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] p-4",children:[s.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[s.jsx("div",{className:"inline-flex gap-1.5",children:Ie.map(e=>s.jsx(P,{size:"sm",variant:h===e.key?"primary":"outline","aria-pressed":h===e.key,onPress:()=>Qe(e.key),children:e.label},e.key))}),s.jsxs("div",{className:"flex items-center gap-2",children:[g?s.jsx(P,{size:"sm",variant:"ghost",onPress:()=>Se(v),children:"Reset zoom"}):null,j.isFetching||O&&E.isFetching?s.jsx(Me,{size:"sm"}):null,s.jsx(Rs,{ariaLabel:"Group by",value:F,onChange:e=>Ve(e),options:Ms.filter(e=>e.value!=="source"||us).map(e=>({value:e.value,label:e.value?`By ${e.label.toLowerCase()}`:"No grouping"}))})]})]}),ie?s.jsx("div",{className:"rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800",children:"The running gateway predates grouped series, so the chart shows ungrouped totals. Restart the gateway on this build to enable grouping."}):null,s.jsx(qs,{series:R.series}),hs?s.jsx("div",{className:"flex h-64 items-center justify-center",children:s.jsx(Me,{size:"sm"})}):R.data.length===0?s.jsx("div",{className:"flex h-64 items-center justify-center text-sm text-[var(--otari-muted)]",children:"No data in this range."}):s.jsxs("figure",{className:"flex flex-col gap-2",children:[s.jsx(Ps,{data:R.data,series:R.series,formatValue:Fe,formatXTick:e=>Bs(e,L),ariaLabel:`${h} per ${L}${O?`, grouped by ${O}`:""}`,height:260,showYAxis:!0,showTotal:!0,onSelectRange:vs}),s.jsxs("figcaption",{className:"text-xs text-[var(--otari-muted)]",children:[Fe(ps)," peak · ",R.data.length," ",L==="hour"?"hours":"days"," (times in UTC) · drag across the chart to zoom"]})]})]}),s.jsxs("div",{className:"grid gap-6 xl:grid-cols-2",children:[s.jsxs("div",{className:"flex flex-col gap-3",children:[s.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[s.jsxs("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Spend by ",K.label.toLowerCase()]}),s.jsx("div",{className:"inline-flex gap-1.5",children:pe.map(e=>s.jsx(P,{size:"sm",variant:xe===e.key?"primary":"outline","aria-pressed":xe===e.key,onPress:()=>fs(e.key),children:e.label},e.key))})]}),s.jsx(He,{dimensionLabel:K.label,rows:K.rows,totalCost:(a==null?void 0:a.cost)??0,emptyLabel:de?"No usage matches these filters.":"No usage recorded yet.",unknownLabel:K.unknownLabel,onDrill:K.drill,loading:j.isLoading})]}),s.jsxs("div",{className:"flex flex-col gap-3",children:[s.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[s.jsxs("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Spend by ",z.label.toLowerCase()]}),s.jsx("div",{className:"inline-flex gap-1.5",children:ve.map(e=>s.jsx(P,{size:"sm",variant:J===e.key?"primary":"outline","aria-pressed":J===e.key,onPress:()=>ks(e.key),children:e.label},e.key))})]}),s.jsx(He,{dimensionLabel:z.label,rows:z.rows,totalCost:(a==null?void 0:a.cost)??0,emptyLabel:de?"No usage matches these filters.":"No usage recorded yet.",unknownLabel:z.unknownLabel,onDrill:z.drill,loading:j.isLoading})]})]}),Oe.length?s.jsxs("div",{className:"rounded-2xl border border-[var(--otari-line)] bg-[var(--otari-surface)] p-4",children:[s.jsxs("div",{className:"mb-3 flex flex-col gap-1",children:[s.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Gateway-run tools"}),s.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Tools Otari ran itself, billed per call. MCP tools are not listed here: their names come from your own server, so they appear on each request instead."})]}),s.jsx(Gs,{rows:Oe,totalCost:(a==null?void 0:a.cost)??0,onDrill:e=>A({tool:e}),loading:j.isLoading})]}):null]})]})}export{tt as UsagePage}; diff --git a/src/gateway/static/dashboard/assets/UsagePage-Bxv4_uW3.js b/src/gateway/static/dashboard/assets/UsagePage-Bxv4_uW3.js new file mode 100644 index 00000000..172c8e1a --- /dev/null +++ b/src/gateway/static/dashboard/assets/UsagePage-Bxv4_uW3.js @@ -0,0 +1 @@ +import{j as t}from"./tanstack-query-1t81HyiD.js";import{i as bt,r as m}from"./react-dgEcD0HR.js";import{n as ve,g as _t,d as ee,aw as jt,ax as St,a6 as te,ay as Ye,P as wt,E as Nt,m as ke,f as Ct,R as Lt,az as Ze,z as Tt,a7 as G,a9 as se,a8 as re,ab as Me,aA as H,F as Et,e as Rt,ad as Ft,r as Ot}from"./index-D6WO6K2k.js";import{S as ae,C as At,T as qt}from"./charts-krq1PqQO.js";import{D as Qe}from"./DataTable-DuDxGlJc.js";import{F as Dt}from"./FilterChips-DTdIceb1.js";import{B as q,S as Ue}from"./heroui-COmYdDDM.js";import"./recharts-C3cGlHOx.js";function D(c){return c.toLocaleString()}function Pt(c){return c===null?"—":c<1e3?`${Math.round(c)} ms`:`${(c/1e3).toFixed(2)} s`}function $t(c,d){const f=new Date(c);return Number.isNaN(f.getTime())?c:d==="hour"?f.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit",timeZone:"UTC"}):f.toLocaleDateString(void 0,{month:"short",day:"numeric",timeZone:"UTC"})}const Ie=Rt(Ze,Ye),ye=15,Ke=[{key:"cost",label:"Cost"},{key:"tokens",label:"Tokens"},{key:"requests",label:"Requests"}],Bt=[{value:"",label:"None"},{value:"model",label:"Model"},{value:"user_id",label:"User"},{value:"api_key_id",label:"API key"},{value:"source",label:"Source"}],Mt=[{key:"fresh",label:"Fresh input",color:"var(--otari-ink)"},{key:"cache_read",label:"Cache read",color:"var(--otari-brand)"},{key:"cache_write",label:"Cache write",color:"var(--otari-brand-soft)"},{key:"output",label:"Output",color:"var(--otari-brand-dark)"}],Ut=[{key:"success",label:"Succeeded",color:"var(--otari-brand)"},{key:"errors",label:"Failed",color:"var(--otari-danger)"}],ze=["var(--otari-cat-1)","var(--otari-cat-2)","var(--otari-cat-3)","var(--otari-cat-4)","var(--otari-cat-5)","var(--otari-cat-6)","var(--otari-cat-7)","var(--otari-cat-8)"],It="var(--otari-cat-other)";function Kt(c){return c==="cost"?re:c==="tokens"?H:D}const Ge="__other__",He="__unknown__";function We({dimensionLabel:c,rows:d,totalCost:f,emptyLabel:j,unknownLabel:P="(unknown)",onDrill:n,loading:L}){const[W,$]=m.useState(!1),Y=W?d:d.slice(0,ye),Z=d.length-Y.length,g=o=>o.is_other?Ge:o.key??He,B=[{id:"name",header:c,isRowHeader:!0,cell:o=>{const M=f>0?o.cost/f:0;return t.jsxs("div",{className:"flex flex-col gap-1",children:[t.jsx("span",{className:"truncate text-[var(--otari-ink)]",children:o.is_other?`Other (${o.requests.toLocaleString()} req)`:o.key===null?P:o.key}),t.jsx("span",{className:"h-1 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",children:t.jsx("span",{className:"block h-full rounded-full bg-[var(--otari-brand)]",style:{width:`${Math.min(100,M*100)}%`}})})]})}},{id:"requests",header:"Requests",align:"end",cell:o=>t.jsx("span",{className:"text-[var(--otari-muted)]",children:D(o.requests)})},{id:"tokens",header:"Tokens",align:"end",cell:o=>t.jsx("span",{className:"text-[var(--otari-muted)]",children:H(o.tokens)})},{id:"spend",header:"Spend",align:"end",cell:o=>t.jsx("span",{className:"text-[var(--otari-ink)]",children:re(o.cost)})}];return t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsx(Qe,{ariaLabel:`Spend by ${c.toLowerCase()}`,columns:B,rows:Y,getRowKey:g,isLoading:L,emptyContent:j,onRowAction:o=>{o!==Ge&&o!==He&&n(o)}}),!L&&Z>0?t.jsxs(q,{size:"sm",variant:"ghost",onPress:()=>$(!0),children:["Show all ",d.length]}):null,!L&&W&&d.length>ye?t.jsxs(q,{size:"sm",variant:"ghost",onPress:()=>$(!1),children:["Show top ",ye]}):null]})}function zt({rows:c,totalCost:d,onDrill:f,loading:j}){const P=[{id:"tool",header:"Tool",isRowHeader:!0,cell:n=>{const L=d>0?n.cost/d:0;return t.jsxs("div",{className:"flex flex-col gap-1",children:[t.jsx("span",{className:"truncate text-[var(--otari-ink)]",children:n.tool.replaceAll("_"," ")}),t.jsx("span",{className:"h-1 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",children:t.jsx("span",{className:"block h-full rounded-full bg-[var(--otari-brand)]",style:{width:`${Math.min(100,L*100)}%`}})})]})}},{id:"calls",header:"Calls",align:"end",cell:n=>t.jsx("span",{className:"text-[var(--otari-muted)]",children:D(n.calls)})},{id:"failed",header:"Failed",align:"end",cell:n=>t.jsx("span",{className:n.errors?"text-red-700":"text-[var(--otari-muted)]",children:D(n.errors)})},{id:"requests",header:"Requests",align:"end",cell:n=>t.jsx("span",{className:"text-[var(--otari-muted)]",children:D(n.requests)})},{id:"spend",header:"Spend",align:"end",cell:n=>t.jsx("span",{className:"text-[var(--otari-ink)]",children:re(n.cost)})}];return t.jsx(Qe,{ariaLabel:"Spend by gateway-run tool",columns:P,rows:c,getRowKey:n=>n.tool,isLoading:j,emptyContent:"No gateway-run tool calls in this range.",onRowAction:n=>f(String(n))})}const Gt=["model","user","source_label","endpoint","provider","source","tool"],Ht=["model"],Wt=["user","api_key"];function ss(){var Ae,qe,De,Pe,$e;const c=bt(),[d,f]=m.useState(Ie),[j,P]=m.useState(()=>ve(Ie.seconds??0)),[n,L]=m.useState(!1),[W,$]=m.useState(),[Y,Z]=m.useState(),[g,B]=m.useState([]),[o,M]=m.useState([]),[v,oe]=m.useState([]),[p,Ve]=m.useState("cost"),[R,Xe]=m.useState(""),k=n?W:j,S=n?Y:void 0,w=n?k?_t(k,S):"day":d.bucket,b=m.useMemo(()=>({start_date:k,end_date:S,model:g.length>0?g:void 0,user_id:o.length>0?o:void 0,api_key_id:v.length>0?v:void 0}),[k,S,g,o,v]),U=m.useMemo(()=>{if(n){if(!k||!S)return null;const e=new Date(S).getTime()-new Date(k).getTime();return e>0?{...b,start_date:new Date(new Date(k).getTime()-e).toISOString(),end_date:k}:null}return!j||d.seconds===null?null:{...b,start_date:new Date(new Date(j).getTime()-d.seconds*1e3).toISOString(),end_date:j}},[n,k,S,b,d.seconds,j]),_=ee(b,w,Gt),ne=ee(U??b,w,Ft,U!==null),T=jt(b,w,R||null),le=R!==""&&T.error instanceof St&&T.error.status===404,F=le?"":R,s=_.data,a=s==null?void 0:s.totals,y=U!==null?(Ae=ne.data)==null?void 0:Ae.totals:void 0,fe=a?te(a.cost,y==null?void 0:y.cost):null,Je=m.useMemo(()=>({...b,model:void 0}),[b]),be=ee(Je,w,Ht),ie=e=>(e??[]).filter(l=>!l.is_other&&l.key!==null),et=ie((qe=be.data)==null?void 0:qe.by_model).map(e=>e.key),tt=m.useMemo(()=>({...b,user_id:void 0,api_key_id:void 0}),[b]),ce=ee(tt,w,Wt),_e=ie((De=ce.data)==null?void 0:De.by_user).map(e=>({value:e.key,label:e.label?`${e.label} (${e.key})`:e.key})),je=ie((Pe=ce.data)==null?void 0:Pe.by_api_key).map(e=>({value:e.key,label:e.label??`${e.key.slice(0,8)}…`})),st=et.map(e=>({value:e,label:e})),at=n||d.key!==Ye,de=g.length>0||o.length>0||v.length>0||at,Se=(e,l)=>{var i;return((i=e.find(r=>r.value===l))==null?void 0:i.label)??l},rt=()=>{B([]),M([]),oe([])},ue=(e,l,i,r,h)=>i.map(N=>({key:`${e}:${N}`,label:l,value:r(N),clearLabel:`Remove ${l} filter ${r(N)}`,onClear:()=>h(i.filter(A=>A!==N))})),ot=[...ue("user","User",o,e=>Se(_e,e),M),...ue("model","Model",g,e=>e,B),...ue("key","API key",v,e=>Se(je,e),oe)],nt=!!(s&&a&&a.request_count===0&&!de),lt=(s==null?void 0:s.start_date)??k,it=(s==null?void 0:s.end_date)??S,we=e=>{L(!1),f(e),P(ve(e.seconds??0)),$(void 0),Z(void 0)},ct=(e,l)=>{L(!0),$(e),Z(l)},dt=()=>{n||P(ve(d.seconds??0)),_.refetch(),be.refetch(),ce.refetch(),U!==null&&ne.refetch(),R&&T.refetch()},O=e=>{const l=new URLSearchParams;k&&l.set("start_date",k),S&&l.set("end_date",S);for(const[i,r]of Object.entries(e))for(const h of typeof r=="string"?[r]:r??[])h&&l.append(i,h);c(`/activity?${l.toString()}`)},ut=a&&a.request_count>0?a.error_count/a.request_count:0,Ne=((s==null?void 0:s.by_source)??[]).filter(e=>!e.is_other).length>1,mt=Ne||R==="source",x=(s==null?void 0:s.series)??[],Q=x.length>1,Ce=a==null?void 0:a.billed_input_tokens,V=a===void 0?null:Ce!==void 0?Ce+(a.billed_output_tokens??a.completion_tokens):a.total_tokens,ht=y===void 0?null:y.billed_input_tokens!==void 0?y.billed_input_tokens+(y.billed_output_tokens??y.completion_tokens):y.total_tokens,Le=e=>{let l=0,i=0,r=0;for(const h of e)l+=h.input_tokens??0,i+=h.cache_read_tokens??0,r+=h.cache_write_tokens??0;return{input:l,read:i,write:r}},I=Le(x),X=I.input>0?I.read/I.input:null,me=Le(U!==null?(($e=ne.data)==null?void 0:$e.series)??[]:[]),Te=me.input>0?me.read/me.input:void 0,Ee=e=>e.input_tokens!==void 0?e.input_tokens+(e.output_tokens??0):e.tokens,he=x.some(e=>(e.input_tokens??0)>0),Re=x.some(e=>(e.errors??0)>0),E=m.useMemo(()=>{var i;const e=x.map(r=>r.bucket_start);if(F){const r=T.data;if(!r)return{series:[],data:[]};const h=r.groups.map((u,C)=>({key:`g${C}`,label:u.is_other?"Other":u.key===null?"(unknown)":F==="api_key_id"?u.label??`${u.key.slice(0,8)}…`:u.key,color:u.is_other?It:ze[C%ze.length]})),N=new Map(r.groups.map((u,C)=>[`${u.is_other}|${u.key}`,`g${C}`])),A=new Map(e.map(u=>[u,{x:u,...Object.fromEntries(h.map(C=>[C.key,0]))}]));for(const u of r.points){const C=N.get(`${u.is_other}|${u.key}`),Be=A.get(u.bucket_start);!C||!Be||(Be[C]=p==="cost"?u.cost:p==="tokens"?u.tokens:u.requests)}return{series:h,data:[...A.values()]}}return p==="tokens"&&he?{series:Mt,data:x.map(r=>{const h=r.input_tokens??0,N=r.cache_read_tokens??0,A=r.cache_write_tokens??0;return{x:r.bucket_start,fresh:Math.max(0,h-N-A),cache_read:N,cache_write:A,output:r.output_tokens??0}})}:p==="requests"&&Re?{series:Ut,data:x.map(r=>{const h=Math.min(r.errors??0,r.requests);return{x:r.bucket_start,success:r.requests-h,errors:h}})}:{series:[{key:p,label:((i=Ke.find(r=>r.key===p))==null?void 0:i.label)??p,color:"var(--otari-brand)"}],data:x.map(r=>({x:r.bucket_start,[p]:p==="cost"?r.cost:p==="tokens"?Ee(r):r.requests}))}},[x,F,T.data,p,he,Re]),Fe=Kt(p),pt=_.isLoading||!!F&&T.isLoading,xt=E.data.length?Math.max(...E.data.map(e=>E.series.reduce((l,i)=>l+(typeof e[i.key]=="number"?e[i.key]:0),0))):0,gt=x.map(e=>e.bucket_start),vt=(e,l)=>{const i=Ot(gt,e,l,w);i&&ct(i.startIso,i.endIso)},pe=[{key:"model",label:"Model",rows:(s==null?void 0:s.by_model)??[],drill:e=>O({model:e,user_id:o,api_key_id:v})},{key:"user",label:"User",rows:(s==null?void 0:s.by_user)??[],drill:e=>O({user_id:e,model:g,api_key_id:v})}],kt=[{key:"source_label",label:"Session",rows:(s==null?void 0:s.by_source_label)??[],unknownLabel:"(no session)",drill:e=>O({source_label:e,model:g,user_id:o,api_key_id:v})},{key:"endpoint",label:"Endpoint",rows:(s==null?void 0:s.by_endpoint)??[],drill:e=>O({endpoint:e,model:g,user_id:o,api_key_id:v})},{key:"provider",label:"Provider",rows:(s==null?void 0:s.by_provider)??[],drill:e=>O({provider:e,model:g,user_id:o,api_key_id:v})},{key:"source",label:"Source",rows:(s==null?void 0:s.by_source)??[],drill:e=>O({source:e,model:g,user_id:o,api_key_id:v})}],Oe=(s==null?void 0:s.by_tool)??[],[xe,yt]=m.useState("model"),[J,ft]=m.useState("source_label"),K=pe.find(e=>e.key===xe)??pe[0],ge=kt.filter(e=>e.key!=="source"||Ne||J==="source"),z=ge.find(e=>e.key===J)??ge[0];return t.jsxs("div",{className:"flex flex-col gap-6",children:[t.jsx(wt,{title:"Usage & analytics",description:"Spend, tokens, cache use, and request volume over time. Group the chart by model, user, key, or source, and click a breakdown row to drill into the request log."}),t.jsx(Nt,{error:_.error??(R!==""&&!le?T.error:null)}),t.jsxs(Dt,{chips:ot,onClearAll:rt,start:Ze.map(e=>t.jsx(q,{size:"sm",variant:!n&&d.key===e.key?"primary":"outline",onPress:()=>we(e),children:e.label},e.key)),end:t.jsxs(t.Fragment,{children:[t.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Showing ",Ct(lt,it)," · UTC"]}),t.jsx(Lt,{onRefresh:dt,isFetching:_.isFetching,updatedAt:_.dataUpdatedAt})]}),children:[t.jsx(ke,{label:"User",values:o,onChange:M,options:_e,allowsCustom:!0,placeholder:"All users"}),t.jsx(ke,{label:"Model",values:g,onChange:B,options:st,placeholder:"All models"}),t.jsx(ke,{label:"API key",values:v,onChange:oe,options:je,allowsCustom:!0,placeholder:"All keys"})]}),nt?t.jsx(Tt,{title:"No usage yet",description:"Once the gateway serves requests, spend and volume appear here."}):t.jsxs(t.Fragment,{children:[t.jsxs("div",{className:"grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-5",children:[t.jsx(G,{label:"Tracked cost",value:a?re(a.cost):"—",hint:a?t.jsxs("span",{className:"text-[var(--otari-muted)]",children:[t.jsx(se,{fraction:fe}),a.unpriced_requests?`${fe!==null?" · ":""}${D(a.unpriced_requests)} unpriced`:null]}):null,chart:Q?t.jsx(ae,{values:x.map(e=>e.cost),ariaLabel:"Spend trend over the selected window"}):void 0}),t.jsx(G,{label:"Requests",value:a?D(a.request_count):"—",hint:a?t.jsxs("span",{className:"text-[var(--otari-muted)]",children:[Me(ut)," errors",y?t.jsxs(t.Fragment,{children:[" · ",t.jsx(se,{fraction:te(a.request_count,y.request_count)})]}):null]}):null,chart:Q?t.jsx(ae,{values:x.map(e=>e.requests),ariaLabel:"Request volume trend over the selected window"}):void 0}),t.jsx(G,{label:"Tokens (billed)",value:V!==null?H(V):"—",hint:V!==null?t.jsx(se,{fraction:te(V,ht??void 0)}):null,chart:Q?t.jsx(ae,{values:x.map(Ee),ariaLabel:"Billed token trend over the selected window"}):void 0}),t.jsx(G,{label:"Cache hit rate",value:X!==null?Me(X):"—",hint:a?t.jsxs("span",{className:"text-[var(--otari-muted)]",children:[X!==null&&Te!==void 0?t.jsxs(t.Fragment,{children:[t.jsx(se,{fraction:te(X,Te)})," · "]}):null,H(I.read)," read · ",H(I.write)," written"]}):null,chart:Q&&he?t.jsx(ae,{values:x.map(e=>(e.input_tokens??0)>0?(e.cache_read_tokens??0)/(e.input_tokens??1):0),ariaLabel:"Cache hit rate trend over the selected window"}):void 0}),t.jsx(G,{label:"Avg latency",value:a?Pt(a.avg_latency_ms):"—"})]}),t.jsxs("div",{className:"flex flex-col gap-3 rounded-xl border border-[var(--otari-line)] bg-[var(--otari-surface)] p-4",children:[t.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[t.jsx("div",{className:"inline-flex gap-1.5",children:Ke.map(e=>t.jsx(q,{size:"sm",variant:p===e.key?"primary":"outline","aria-pressed":p===e.key,onPress:()=>Ve(e.key),children:e.label},e.key))}),t.jsxs("div",{className:"flex items-center gap-2",children:[n?t.jsx(q,{size:"sm",variant:"ghost",onPress:()=>we(d),children:"Reset zoom"}):null,_.isFetching||F&&T.isFetching?t.jsx(Ue,{size:"sm"}):null,t.jsx(Et,{ariaLabel:"Group by",value:R,onChange:e=>Xe(e),options:Bt.filter(e=>e.value!=="source"||mt).map(e=>({value:e.value,label:e.value?`By ${e.label.toLowerCase()}`:"No grouping"}))})]})]}),le?t.jsx("div",{className:"rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800",children:"The running gateway predates grouped series, so the chart shows ungrouped totals. Restart the gateway on this build to enable grouping."}):null,t.jsx(At,{series:E.series}),pt?t.jsx("div",{className:"flex h-64 items-center justify-center",children:t.jsx(Ue,{size:"sm"})}):E.data.length===0?t.jsx("div",{className:"flex h-64 items-center justify-center text-sm text-[var(--otari-muted)]",children:"No data in this range."}):t.jsxs("figure",{className:"flex flex-col gap-2",children:[t.jsx(qt,{data:E.data,series:E.series,formatValue:Fe,formatXTick:e=>$t(e,w),ariaLabel:`${p} per ${w}${F?`, grouped by ${F}`:""}`,height:260,showYAxis:!0,showTotal:!0,onSelectRange:vt}),t.jsxs("figcaption",{className:"text-xs text-[var(--otari-muted)]",children:[Fe(xt)," peak · ",E.data.length," ",w==="hour"?"hours":"days"," (times in UTC) · drag across the chart to zoom"]})]})]}),t.jsxs("div",{className:"grid gap-6 xl:grid-cols-2",children:[t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Spend by ",K.label.toLowerCase()]}),t.jsx("div",{className:"inline-flex gap-1.5",children:pe.map(e=>t.jsx(q,{size:"sm",variant:xe===e.key?"primary":"outline","aria-pressed":xe===e.key,onPress:()=>yt(e.key),children:e.label},e.key))})]}),t.jsx(We,{dimensionLabel:K.label,rows:K.rows,totalCost:(a==null?void 0:a.cost)??0,emptyLabel:de?"No usage matches these filters.":"No usage recorded yet.",unknownLabel:K.unknownLabel,onDrill:K.drill,loading:_.isLoading})]}),t.jsxs("div",{className:"flex flex-col gap-3",children:[t.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:["Spend by ",z.label.toLowerCase()]}),t.jsx("div",{className:"inline-flex gap-1.5",children:ge.map(e=>t.jsx(q,{size:"sm",variant:J===e.key?"primary":"outline","aria-pressed":J===e.key,onPress:()=>ft(e.key),children:e.label},e.key))})]}),t.jsx(We,{dimensionLabel:z.label,rows:z.rows,totalCost:(a==null?void 0:a.cost)??0,emptyLabel:de?"No usage matches these filters.":"No usage recorded yet.",unknownLabel:z.unknownLabel,onDrill:z.drill,loading:_.isLoading})]})]}),Oe.length?t.jsxs("div",{className:"rounded-2xl border border-[var(--otari-line)] bg-[var(--otari-surface)] p-4",children:[t.jsxs("div",{className:"mb-3 flex flex-col gap-1",children:[t.jsx("h2",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:"Gateway-run tools"}),t.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:"Tools Otari ran itself, billed per call. MCP tools are not listed here: their names come from your own server, so they appear on each request instead."})]}),t.jsx(zt,{rows:Oe,totalCost:(a==null?void 0:a.cost)??0,onDrill:e=>O({tool:e}),loading:_.isLoading})]}):null]})]})}export{ss as UsagePage}; diff --git a/src/gateway/static/dashboard/assets/UsersPage-CWfTsLqm.js b/src/gateway/static/dashboard/assets/UsersPage-C9eMut1u.js similarity index 88% rename from src/gateway/static/dashboard/assets/UsersPage-CWfTsLqm.js rename to src/gateway/static/dashboard/assets/UsersPage-C9eMut1u.js index 6116bffe..d26718a3 100644 --- a/src/gateway/static/dashboard/assets/UsersPage-CWfTsLqm.js +++ b/src/gateway/static/dashboard/assets/UsersPage-C9eMut1u.js @@ -1 +1 @@ -import{j as e}from"./tanstack-query-1t81HyiD.js";import{r}from"./react-dgEcD0HR.js";import{u as Q,I as M,M as K,aB as X,q as Y,P as Z,E as A,B as ee,aC as se,F as te}from"./index-DAnS9oY2.js";import{u as ae,r as ne,B as re}from"./tableSelection-BJDASjEj.js";import{C as le}from"./ConfirmDialog-lRO7CIis.js";import{D as ie}from"./DataTable-DuDxGlJc.js";import{F as D}from"./Field-CBU9MRjz.js";import{a as de,M as R}from"./ModelScopeControl-CYPgEOWk.js";import{g as I,B as d,d as S,A as f}from"./heroui-COmYdDDM.js";const oe=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function U(t){return oe.format(t)}const ce=t=>t.user_id,w=t=>t.startsWith("apikey-");function H(t){return t.split("-")[0]}function E(t){return t.name??H(t.budget_id)}function $({value:t,onChange:l,budgets:n}){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=>l(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)"}),n.map(a=>e.jsxs("option",{value:a.budget_id,children:[E(a),a.max_budget===null?" · no limit":` · ${U(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 ue({onClose:t}){const l=se(),n=M(),[a,i]=r.useState(""),[o,u]=r.useState(""),[c,g]=r.useState(null),[p,m]=r.useState(null),[b,j]=r.useState(!0),x=()=>{if(l.isPending||!b||a.trim()==="")return;const h={user_id:a.trim(),alias:o.trim()||null,budget_id:c,allowed_models:p};l.mutate(h,{onSuccess:t})};return e.jsx(S,{children:e.jsxs(S.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:l.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(D,{label:"User ID",value:a,onChange:i,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(D,{label:"Alias (optional)",value:o,onChange:u,placeholder:"Alice"})]}),e.jsx($,{value:c,onChange:g,budgets:n.data??[]}),e.jsx(R,{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,k)=>{m(h),j(k)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(d,{variant:"primary",isDisabled:l.isPending||!b||a.trim()==="",onPress:x,children:l.isPending?"Creating…":"Create user"}),e.jsx(d,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function ge({user:t,onClose:l}){const n=K(),a=M(),[i,o]=r.useState(t.alias??""),[u,c]=r.useState(t.budget_id),[g,p]=r.useState(t.allowed_models),[m,b]=r.useState(!0),j=()=>{if(n.isPending||!m)return;const x={alias:i.trim()||null,budget_id:u,allowed_models:g};n.mutate({id:t.user_id,body:x},{onSuccess:l})};return e.jsx(S,{children:e.jsxs(S.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:n.error}),e.jsx(D,{label:"Alias",value:i,onChange:o,placeholder:"Alice"}),e.jsx($,{value:u,onChange:c,budgets:a.data??[]}),e.jsx(R,{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:(x,h)=>{p(x),b(h)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(d,{variant:"primary",isDisabled:n.isPending||!m,onPress:j,children:n.isPending?"Saving…":"Save changes"}),e.jsx(d,{variant:"ghost",onPress:l,children:"Cancel"})]})]})})}function me({trigger:t,message:l,confirmLabel:n,isPending:a,onConfirm:i}){const[o,u]=r.useState(!1);return o?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:l}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(d,{size:"sm",variant:"danger",isDisabled:a,onPress:i,children:n}),e.jsx(d,{size:"sm",variant:"ghost",isDisabled:a,onPress:()=>u(!1),children:"Cancel"})]})]}):e.jsx(d,{size:"sm",variant:"danger-soft",onPress:()=>u(!0),children:t})}function xe({user:t}){return t.blocked?e.jsx(I,{size:"sm",color:"warning",children:"Blocked"}):e.jsx(I,{size:"sm",color:"accent",children:"Active"})}function he({allowed:t}){const{text:l,tone:n}=de(t),a=n==="danger"?"text-red-700 font-medium":n==="muted"?"text-[var(--otari-muted)]":"text-[var(--otari-brand-dark)] font-medium",i=t&&t.length>0?t.join(", "):void 0;return e.jsx("span",{className:`text-xs ${a}`,title:i,children:l})}function pe({isOpen:t,onOpenChange:l,budgets:n,count:a,isPending:i,error:o,onAssign:u}){const[c,g]=r.useState("");return r.useEffect(()=>{t&&g("")},[t]),e.jsx(f,{isOpen:t,onOpenChange:l,children:t?e.jsx(f.Backdrop,{children:e.jsx(f.Container,{placement:"center",size:"md",children:e.jsxs(f.Dialog,{children:[e.jsx(f.Header,{children:e.jsx(f.Heading,{children:"Assign budget"})}),e.jsxs(f.Body,{className:"flex flex-col gap-4",children:[e.jsxs("p",{className:"text-sm text-[var(--otari-muted)]",children:["Assign a budget to ",a," selected ",a===1?"user":"users","."]}),e.jsx(te,{label:"Budget",value:c,onChange:g,options:[{value:"",label:"Select a budget…"},...n.map(p=>({value:p.budget_id,label:E(p)}))]}),e.jsx(A,{error:o})]}),e.jsxs(f.Footer,{children:[e.jsx(d,{variant:"ghost",isDisabled:i,onPress:()=>l(!1),children:"Cancel"}),e.jsx(d,{variant:"primary",isDisabled:!c,isPending:i,onPress:()=>u(c),children:"Assign"})]})]})})}):null})}function Pe(){const t=Q(),l=M(),n=K(),a=X(),[i,o]=r.useState(!1),[u,c]=r.useState(null),[g,p]=r.useState(!1),m=t.data??[],b=t.isLoading,j=m.filter(s=>w(s.user_id)).length,x=g?m:m.filter(s=>!w(s.user_id)),h=m.find(s=>s.user_id===u)??null,k=!b&&x.length===0&&!i,P=r.useMemo(()=>new Map((l.data??[]).map(s=>[s.budget_id,s])),[l.data]),y=ae(),[q,_]=r.useState(!1),[W,N]=r.useState(!1),[F,z]=r.useState(void 0),[T,L]=r.useState(!1),G=x.map(s=>s.user_id),v=ne(y.selectedKeys,G),V=r.useCallback((s,C)=>n.mutate({id:s.user_id,body:{blocked:C}}),[n.mutate]),O=async(s,C)=>{L(!0),z(void 0);try{for(const B of v)await s(B);y.clear(),C()}catch(B){z(B)}finally{L(!1)}},J=r.useMemo(()=>[{id:"user",header:"User",isRowHeader:!0,cell:s=>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(Y,{value:s.user_id,label:"user id",children:e.jsx("code",{className:"text-xs font-medium text-[var(--otari-ink)]",children:s.user_id})}),w(s.user_id)?e.jsx(I,{size:"sm",color:"default",children:"virtual"}):null]}),s.alias?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:s.alias}):null]})},{id:"status",header:"Status",cell:s=>e.jsx(xe,{user:s})},{id:"budget",header:"Budget",cell:s=>s.budget_id?e.jsx("span",{className:"text-[var(--otari-muted)]",title:s.budget_id,children:P.get(s.budget_id)?E(P.get(s.budget_id)):H(s.budget_id)}):e.jsx("span",{className:"text-[var(--otari-muted)]",children:"—"})},{id:"spend",header:"Spend",cell:s=>e.jsxs("span",{className:"text-[var(--otari-muted)]",children:[U(s.spend),s.reserved>0?e.jsxs("span",{children:[" (+",U(s.reserved)," held)"]}):null]})},{id:"access",header:"Model access",cell:s=>e.jsx(he,{allowed:s.allowed_models})},{id:"actions",header:"Actions",align:"end",cell:s=>e.jsxs("div",{className:"flex items-center justify-end gap-1.5",children:[e.jsx(d,{size:"sm",variant:"outline",isDisabled:n.isPending,onPress:()=>V(s,!s.blocked),children:s.blocked?"Unblock":"Block"}),e.jsx(d,{size:"sm",variant:"ghost",onPress:()=>{o(!1),c(s.user_id)},children:"Edit"}),e.jsx(me,{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)})]})}],[P,n.isPending,a.isPending,a.mutate,V]);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Z,{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:i?null:e.jsx(d,{variant:"primary",onPress:()=>{c(null),o(!0)},children:"Create user"})}),e.jsx(A,{error:t.error??n.error??a.error}),k?e.jsx(ee,{title:"No users yet",description:"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.",actionLabel:"Create your first user",onAction:()=>{c(null),o(!0)}}):null,j>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:g,onChange:s=>p(s.target.checked)}),"Show auto-created (virtual) users (",j,")"]}):null,i?e.jsx(ue,{onClose:()=>o(!1)}):null,h?e.jsx(ge,{user:h,onClose:()=>c(null)},h.user_id):null,v.length>0?e.jsxs(re,{selectedCount:v.length,allMatching:!1,matchingTotal:null,canSelectAllMatching:!1,onSelectAllMatching:()=>{},onClear:y.clear,children:[e.jsx(d,{size:"sm",variant:"primary",onPress:()=>N(!0),children:"Assign budget"}),e.jsx(d,{size:"sm",variant:"danger",onPress:()=>_(!0),children:"Delete"})]}):null,k?null:e.jsx(ie,{ariaLabel:"Users",columns:J,rows:x,getRowKey:ce,isLoading:b,emptyContent:"No users yet. Create one, or create an API key to auto-create one.",selectionMode:"multiple",selectedKeys:y.selectedKeys,onSelectionChange:y.onSelectionChange}),e.jsx(le,{isOpen:q,onOpenChange:_,heading:"Delete users",body:`Delete ${v.length} ${v.length===1?"user":"users"}? This deactivates their API keys and hides them; usage history is preserved.`,confirmLabel:"Delete",isPending:T,error:F,onConfirm:()=>O(s=>a.mutateAsync(s),()=>_(!1))}),e.jsx(pe,{isOpen:W,onOpenChange:N,budgets:l.data??[],count:v.length,isPending:T,error:F,onAssign:s=>O(C=>n.mutateAsync({id:C,body:{budget_id:s}}),()=>N(!1))})]})}export{Pe as UsersPage}; +import{j as e}from"./tanstack-query-1t81HyiD.js";import{r}from"./react-dgEcD0HR.js";import{p as Q,H as M,L as K,aB as X,o as Y,P as Z,E as A,z as ee,aC as se,F as te}from"./index-D6WO6K2k.js";import{u as ae,r as ne,B as re}from"./tableSelection-BJDASjEj.js";import{C as le}from"./ConfirmDialog-gmtoFRlO.js";import{D as ie}from"./DataTable-DuDxGlJc.js";import{F as D}from"./Field-CBU9MRjz.js";import{a as de,M as R}from"./ModelScopeControl-W-k32mVk.js";import{g as I,B as d,d as S,A as f}from"./heroui-COmYdDDM.js";const oe=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function U(t){return oe.format(t)}const ce=t=>t.user_id,B=t=>t.startsWith("apikey-");function H(t){return t.split("-")[0]}function z(t){return t.name??H(t.budget_id)}function $({value:t,onChange:l,budgets:n}){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=>l(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)"}),n.map(a=>e.jsxs("option",{value:a.budget_id,children:[z(a),a.max_budget===null?" · no limit":` · ${U(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 ue({onClose:t}){const l=se(),n=M(),[a,i]=r.useState(""),[o,u]=r.useState(""),[c,g]=r.useState(null),[p,m]=r.useState(null),[b,j]=r.useState(!0),x=()=>{if(l.isPending||!b||a.trim()==="")return;const h={user_id:a.trim(),alias:o.trim()||null,budget_id:c,allowed_models:p};l.mutate(h,{onSuccess:t})};return e.jsx(S,{children:e.jsxs(S.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:l.error}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsx(D,{label:"User ID",value:a,onChange:i,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(D,{label:"Alias (optional)",value:o,onChange:u,placeholder:"Alice"})]}),e.jsx($,{value:c,onChange:g,budgets:n.data??[]}),e.jsx(R,{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,k)=>{m(h),j(k)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(d,{variant:"primary",isDisabled:l.isPending||!b||a.trim()==="",onPress:x,children:l.isPending?"Creating…":"Create user"}),e.jsx(d,{variant:"ghost",onPress:t,children:"Cancel"})]})]})})}function ge({user:t,onClose:l}){const n=K(),a=M(),[i,o]=r.useState(t.alias??""),[u,c]=r.useState(t.budget_id),[g,p]=r.useState(t.allowed_models),[m,b]=r.useState(!0),j=()=>{if(n.isPending||!m)return;const x={alias:i.trim()||null,budget_id:u,allowed_models:g};n.mutate({id:t.user_id,body:x},{onSuccess:l})};return e.jsx(S,{children:e.jsxs(S.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:n.error}),e.jsx(D,{label:"Alias",value:i,onChange:o,placeholder:"Alice"}),e.jsx($,{value:u,onChange:c,budgets:a.data??[]}),e.jsx(R,{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:(x,h)=>{p(x),b(h)}}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(d,{variant:"primary",isDisabled:n.isPending||!m,onPress:j,children:n.isPending?"Saving…":"Save changes"}),e.jsx(d,{variant:"ghost",onPress:l,children:"Cancel"})]})]})})}function me({trigger:t,message:l,confirmLabel:n,isPending:a,onConfirm:i}){const[o,u]=r.useState(!1);return o?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:l}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(d,{size:"sm",variant:"danger",isDisabled:a,onPress:i,children:n}),e.jsx(d,{size:"sm",variant:"ghost",isDisabled:a,onPress:()=>u(!1),children:"Cancel"})]})]}):e.jsx(d,{size:"sm",variant:"danger-soft",onPress:()=>u(!0),children:t})}function xe({user:t}){return t.blocked?e.jsx(I,{size:"sm",color:"warning",children:"Blocked"}):e.jsx(I,{size:"sm",color:"accent",children:"Active"})}function he({allowed:t}){const{text:l,tone:n}=de(t),a=n==="danger"?"text-red-700 font-medium":n==="muted"?"text-[var(--otari-muted)]":"text-[var(--otari-brand-dark)] font-medium",i=t&&t.length>0?t.join(", "):void 0;return e.jsx("span",{className:`text-xs ${a}`,title:i,children:l})}function pe({isOpen:t,onOpenChange:l,budgets:n,count:a,isPending:i,error:o,onAssign:u}){const[c,g]=r.useState("");return r.useEffect(()=>{t&&g("")},[t]),e.jsx(f,{isOpen:t,onOpenChange:l,children:t?e.jsx(f.Backdrop,{children:e.jsx(f.Container,{placement:"center",size:"md",children:e.jsxs(f.Dialog,{children:[e.jsx(f.Header,{children:e.jsx(f.Heading,{children:"Assign budget"})}),e.jsxs(f.Body,{className:"flex flex-col gap-4",children:[e.jsxs("p",{className:"text-sm text-[var(--otari-muted)]",children:["Assign a budget to ",a," selected ",a===1?"user":"users","."]}),e.jsx(te,{label:"Budget",value:c,onChange:g,options:[{value:"",label:"Select a budget…"},...n.map(p=>({value:p.budget_id,label:z(p)}))]}),e.jsx(A,{error:o})]}),e.jsxs(f.Footer,{children:[e.jsx(d,{variant:"ghost",isDisabled:i,onPress:()=>l(!1),children:"Cancel"}),e.jsx(d,{variant:"primary",isDisabled:!c,isPending:i,onPress:()=>u(c),children:"Assign"})]})]})})}):null})}function Pe(){const t=Q(),l=M(),n=K(),a=X(),[i,o]=r.useState(!1),[u,c]=r.useState(null),[g,p]=r.useState(!1),m=t.data??[],b=t.isLoading,j=m.filter(s=>B(s.user_id)).length,x=g?m:m.filter(s=>!B(s.user_id)),h=m.find(s=>s.user_id===u)??null,k=!b&&x.length===0&&!i,P=r.useMemo(()=>new Map((l.data??[]).map(s=>[s.budget_id,s])),[l.data]),y=ae(),[q,_]=r.useState(!1),[W,N]=r.useState(!1),[E,F]=r.useState(void 0),[L,T]=r.useState(!1),G=x.map(s=>s.user_id),v=ne(y.selectedKeys,G),V=r.useCallback((s,C)=>n.mutate({id:s.user_id,body:{blocked:C}}),[n.mutate]),O=async(s,C)=>{T(!0),F(void 0);try{for(const w of v)await s(w);y.clear(),C()}catch(w){F(w)}finally{T(!1)}},J=r.useMemo(()=>[{id:"user",header:"User",isRowHeader:!0,cell:s=>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(Y,{value:s.user_id,label:"user id",children:e.jsx("code",{className:"text-xs font-medium text-[var(--otari-ink)]",children:s.user_id})}),B(s.user_id)?e.jsx(I,{size:"sm",color:"default",children:"virtual"}):null]}),s.alias?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:s.alias}):null]})},{id:"status",header:"Status",cell:s=>e.jsx(xe,{user:s})},{id:"budget",header:"Budget",cell:s=>s.budget_id?e.jsx("span",{className:"text-[var(--otari-muted)]",title:s.budget_id,children:P.get(s.budget_id)?z(P.get(s.budget_id)):H(s.budget_id)}):e.jsx("span",{className:"text-[var(--otari-muted)]",children:"—"})},{id:"spend",header:"Spend",cell:s=>e.jsxs("span",{className:"text-[var(--otari-muted)]",children:[U(s.spend),s.reserved>0?e.jsxs("span",{children:[" (+",U(s.reserved)," held)"]}):null]})},{id:"access",header:"Model access",cell:s=>e.jsx(he,{allowed:s.allowed_models})},{id:"actions",header:"Actions",align:"end",cell:s=>e.jsxs("div",{className:"flex items-center justify-end gap-1.5",children:[e.jsx(d,{size:"sm",variant:"outline",isDisabled:n.isPending,onPress:()=>V(s,!s.blocked),children:s.blocked?"Unblock":"Block"}),e.jsx(d,{size:"sm",variant:"ghost",onPress:()=>{o(!1),c(s.user_id)},children:"Edit"}),e.jsx(me,{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)})]})}],[P,n.isPending,a.isPending,a.mutate,V]);return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(Z,{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:i?null:e.jsx(d,{variant:"primary",onPress:()=>{c(null),o(!0)},children:"Create user"})}),e.jsx(A,{error:t.error??n.error??a.error}),k?e.jsx(ee,{title:"No users yet",description:"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.",actionLabel:"Create your first user",onAction:()=>{c(null),o(!0)}}):null,j>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:g,onChange:s=>p(s.target.checked)}),"Show auto-created (virtual) users (",j,")"]}):null,i?e.jsx(ue,{onClose:()=>o(!1)}):null,h?e.jsx(ge,{user:h,onClose:()=>c(null)},h.user_id):null,v.length>0?e.jsxs(re,{selectedCount:v.length,allMatching:!1,matchingTotal:null,canSelectAllMatching:!1,onSelectAllMatching:()=>{},onClear:y.clear,children:[e.jsx(d,{size:"sm",variant:"primary",onPress:()=>N(!0),children:"Assign budget"}),e.jsx(d,{size:"sm",variant:"danger",onPress:()=>_(!0),children:"Delete"})]}):null,k?null:e.jsx(ie,{ariaLabel:"Users",columns:J,rows:x,getRowKey:ce,isLoading:b,emptyContent:"No users yet. Create one, or create an API key to auto-create one.",selectionMode:"multiple",selectedKeys:y.selectedKeys,onSelectionChange:y.onSelectionChange}),e.jsx(le,{isOpen:q,onOpenChange:_,heading:"Delete users",body:`Delete ${v.length} ${v.length===1?"user":"users"}? This deactivates their API keys and hides them; usage history is preserved.`,confirmLabel:"Delete",isPending:L,error:E,onConfirm:()=>O(s=>a.mutateAsync(s),()=>_(!1))}),e.jsx(pe,{isOpen:W,onOpenChange:N,budgets:l.data??[],count:v.length,isPending:L,error:E,onAssign:s=>O(C=>n.mutateAsync({id:C,body:{budget_id:s}}),()=>N(!1))})]})}export{Pe as UsersPage}; diff --git a/src/gateway/static/dashboard/assets/index-D6WO6K2k.js b/src/gateway/static/dashboard/assets/index-D6WO6K2k.js new file mode 100644 index 00000000..f8a24020 --- /dev/null +++ b/src/gateway/static/dashboard/assets/index-D6WO6K2k.js @@ -0,0 +1,2 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/ActivityPage-B-JrbzNG.js","assets/tanstack-query-1t81HyiD.js","assets/react-dgEcD0HR.js","assets/charts-krq1PqQO.js","assets/recharts-C3cGlHOx.js","assets/heroui-COmYdDDM.js","assets/tableSelection-BJDASjEj.js","assets/ConfirmDialog-gmtoFRlO.js","assets/DataTable-DuDxGlJc.js","assets/FilterChips-DTdIceb1.js","assets/TablePagination-D9yR_FiC.js","assets/Field-CBU9MRjz.js","assets/RoutingPage-CeSk6-Fe.js","assets/UserComboBox-DoloPF6p.js","assets/BudgetsPage-Bb-ZzB9q.js","assets/DocsPage-wd6etVlE.js","assets/KeysPage-D1MvNEen.js","assets/ModelScopeControl-W-k32mVk.js","assets/ModelsPage-CjADRmXg.js","assets/OverviewPage-CvMKYScf.js","assets/ProvidersPage-CKBJgQmn.js","assets/SettingsPage-LbV0e7qd.js","assets/ToolsGuardrailsPage-DnkIwA9f.js","assets/UsagePage-Bxv4_uW3.js","assets/UsersPage-C9eMut1u.js"])))=>i.map(i=>d[i]); +var $e=Object.defineProperty;var Be=(e,t,r)=>t in e?$e(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var he=(e,t,r)=>Be(e,typeof t!="symbol"?t+"":t,r);import{u as x,j as n,a as p,b as f,k as K,Q as Qe,c as ze}from"./tanstack-query-1t81HyiD.js";import{d as Ve,r as c,N as se,L as Ge,O as We,H as He,e as Je,f as j,h as ye}from"./react-dgEcD0HR.js";import{B as C,C as Q,L as Se,I as ke,a as Ye,b as Xe,d as I,S as Ze,T as xe,c as L,e as et,f as tt}from"./heroui-COmYdDDM.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))a(s);new MutationObserver(s=>{for(const o of s)if(o.type==="childList")for(const l of o.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&a(l)}).observe(document,{childList:!0,subtree:!0});function r(s){const o={};return s.integrity&&(o.integrity=s.integrity),s.referrerPolicy&&(o.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?o.credentials="include":s.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function a(s){if(s.ep)return;s.ep=!0;const o=r(s);fetch(s.href,o)}})();var nt=Ve();const rt="modulepreload",st=function(e){return"/"+e},ge={},P=function(t,r,a){let s=Promise.resolve();if(r&&r.length>0){let l=function(h){return Promise.all(h.map(y=>Promise.resolve(y).then(w=>({status:"fulfilled",value:w}),w=>({status:"rejected",reason:w}))))};document.getElementsByTagName("link");const d=document.querySelector("meta[property=csp-nonce]"),m=(d==null?void 0:d.nonce)||(d==null?void 0:d.getAttribute("nonce"));s=l(r.map(h=>{if(h=st(h),h in ge)return;ge[h]=!0;const y=h.endsWith(".css"),w=y?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${h}"]${w}`))return;const v=document.createElement("link");if(v.rel=y?"stylesheet":rt,y||(v.as="script"),v.crossOrigin="",v.href=h,m&&v.setAttribute("nonce",m),document.head.appendChild(v),y)return new Promise((N,U)=>{v.addEventListener("load",N),v.addEventListener("error",()=>U(new Error(`Unable to preload CSS for ${h}`)))})}))}function o(l){const d=new Event("vite:preloadError",{cancelable:!0});if(d.payload=l,window.dispatchEvent(d),!d.defaultPrevented)throw l}return s.then(l=>{for(const d of l||[])d.status==="rejected"&&o(d.reason);return t().catch(o)})};class _ extends Error{constructor(r,a){super(a);he(this,"status");this.name="ApiError",this.status=r}}let z=null;function pe(e){z=e}async function ae(e){try{const t=await e.json();if(typeof t.detail=="string")return t.detail;if(t.detail!=null)return JSON.stringify(t.detail)}catch{}return e.statusText||`Request failed (${e.status})`}async function at(e){let t;try{t=await fetch("/v1/auth/session",{method:"POST",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify({master_key:e})})}catch{throw new _(0,"Network error: could not reach the gateway.")}if(t.status===401||t.status===403)return!1;if(!t.ok)throw new _(t.status,await ae(t));return!0}async function ot(){try{await fetch("/v1/auth/session",{method:"DELETE"})}catch{}}const Ee=3e4,it=`The gateway did not respond within ${Ee/1e3}s.`,ct=5*6e4;function G(){return AbortSignal.timeout(ct)}function ve(e){return e instanceof DOMException&&e.name==="TimeoutError"}async function i(e,t={}){const r=new Headers(t.headers);r.set("Accept","application/json"),t.body!=null&&!r.has("Content-Type")&&r.set("Content-Type","application/json");const a=t.signal??AbortSignal.timeout(Ee),s=t.signal?"The gateway did not respond in time.":it;let o;try{o=await fetch(e,{...t,headers:r,signal:a})}catch(l){throw ve(l)?new _(0,s):new _(0,"Network error: could not reach the gateway.")}if(o.status===401||o.status===403)throw z==null||z(),new _(o.status,await ae(o));if(!o.ok)throw new _(o.status,await ae(o));if(o.status!==204)try{return await o.json()}catch(l){throw ve(l)?new _(0,s):l}}const oe="otari.dashboard.hasSession",Te=c.createContext(null);function lt(){try{return window.localStorage.getItem(oe)==="1"}catch{return!1}}function ut({children:e}){const t=x(),[r,a]=c.useState(lt),s=c.useCallback(()=>{ot(),a(!1),t.clear();try{window.localStorage.removeItem(oe)}catch{}},[t]),o=c.useCallback(()=>{t.clear(),a(!0);try{window.localStorage.setItem(oe,"1")}catch{}},[t]);c.useEffect(()=>(pe(s),()=>pe(null)),[s]);const l=c.useMemo(()=>({isAuthenticated:r,login:o,logout:s}),[r,o,s]);return n.jsx(Te.Provider,{value:l,children:e})}function ie(){const e=c.useContext(Te);if(!e)throw new Error("useAuth must be used within an AuthProvider");return e}function dt(e){return e instanceof _&&e.status===0}function mt(){const e=x(),[t,r]=c.useState(!1);return c.useEffect(()=>{const a=e.getQueryCache(),s=()=>a.getAll().some(o=>o.state.status==="error"&&dt(o.state.error));return r(s()),a.subscribe(()=>r(s()))},[e]),t}function ft(){return mt()?n.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:[n.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:[n.jsx("path",{d:"M12 9v4M12 17h.01",strokeLinecap:"round",strokeLinejoin:"round"}),n.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"})]}),n.jsxs("span",{children:[n.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 W=3600,R=86400,ht=365*R,En=[{key:"1h",label:"Last hour",seconds:W,bucket:"hour"},{key:"24h",label:"24h",seconds:R,bucket:"hour"},{key:"7d",label:"7d",seconds:7*R,bucket:"day"},{key:"30d",label:"30d",seconds:30*R,bucket:"day"},{key:"90d",label:"90d",seconds:90*R,bucket:"day"},{key:"12mo",label:"12mo",seconds:ht,bucket:"day"}],Tn="30d",Cn=[{key:"1h",label:"1h",seconds:W,bucket:"hour"},{key:"24h",label:"24h",seconds:R,bucket:"hour"},{key:"7d",label:"7d",seconds:7*R,bucket:"day"},{key:"30d",label:"30d",seconds:30*R,bucket:"day"},{key:"all",label:"All",seconds:null,bucket:"day"}],Pn="24h",Nn="custom";function _n(e,t){return e.find(r=>r.key===t)}function yt(e,t=Date.now()){return new Date(t-e*1e3).toISOString()}function xt(e){return(e==="hour"?W:R)*1e3}function Ln(e,t,r=Date.now()){const a=new Date(e).getTime();return(t?new Date(t).getTime():r)-a<=R*1e3?"hour":"day"}function Rn(e,t,r,a){if(e.length===0)return null;const s=Math.max(0,Math.min(t,r)),o=Math.min(e.length-1,Math.max(t,r)),l=new Date(e[s]).getTime(),d=new Date(e[o]).getTime()+xt(a);return{startIso:new Date(l).toISOString(),endIso:new Date(d).toISOString()}}function Dn(e,t,r){const a=e.length;if(a===0)return{startIndex:0,endIndex:0};const s=e.map(d=>new Date(d).getTime());let o=0;if(t){const d=new Date(t).getTime();for(let m=0;mi("/v1/models"),staleTime:6e4})}function wt(){return p({queryKey:[vt],queryFn:()=>i("/dashboard-build.json"),refetchInterval:bt,refetchOnWindowFocus:!0,staleTime:0,retry:!1})}function In(){return p({...J,queryKey:[ue],queryFn:()=>i("/v1/models/discoverable"),staleTime:5*6e4})}function On(){return p({queryKey:[de],queryFn:()=>i("/v1/providers"),staleTime:5*6e4})}function Mn(){return p({queryKey:["provider-catalog"],queryFn:()=>i("/v1/providers/catalog"),staleTime:1/0})}function Un(e){return p({queryKey:["provider-catalog",e],queryFn:()=>i(`/v1/providers/catalog/${encodeURIComponent(e)}`),enabled:e!=="",staleTime:1/0})}function Fn(){return p({...J,queryKey:[me],queryFn:()=>i("/v1/providers/health"),staleTime:be,refetchInterval:be})}function Kn(){const e=x();return f({mutationFn:()=>i("/v1/providers/health?refresh=true"),onSuccess:t=>e.setQueryData([me],t)})}function $n(){return p({queryKey:[_e],queryFn:()=>i("/v1/provider-credentials"),staleTime:6e4})}function Y(e){e.invalidateQueries({queryKey:[_e]}),e.invalidateQueries({queryKey:[de]}),e.invalidateQueries({queryKey:[D]}),e.invalidateQueries({queryKey:[ue]}),e.invalidateQueries({queryKey:[me]})}function Bn(){const e=x();return f({mutationFn:t=>i("/v1/provider-credentials",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>Y(e)})}function Qn(){const e=x();return f({mutationFn:({instance:t,body:r})=>i(`/v1/provider-credentials/${encodeURIComponent(t)}`,{method:"PATCH",body:JSON.stringify(r)}),onSuccess:()=>Y(e)})}function zn(){const e=x();return f({mutationFn:t=>i(`/v1/provider-credentials/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>Y(e)})}function Vn(){const e=x();return f({mutationFn:()=>i("/v1/provider-credentials/reencrypt",{method:"POST",signal:G()}),onSuccess:()=>Y(e)})}function Gn(){return f({mutationFn:e=>i(`/v1/provider-credentials/${encodeURIComponent(e)}/test`,{method:"POST"})})}function Wn(){return f({mutationFn:e=>i("/v1/provider-credentials/test",{method:"POST",body:JSON.stringify(e)})})}function Hn(){return p({...J,queryKey:[pt],queryFn:()=>i("/v1/models/metadata"),staleTime:10*6e4})}function Jn(){return p({queryKey:[ce],queryFn:()=>i("/v1/aliases"),staleTime:6e4})}function Yn(){return p({queryKey:[le],queryFn:()=>i("/v1/routing/policies"),staleTime:6e4})}function Xn(){const e=x();return f({mutationFn:t=>i("/v1/routing/policies",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>{e.invalidateQueries({queryKey:[le]}),e.invalidateQueries({queryKey:[D]})}})}function Zn(){const e=x();return f({mutationFn:({name:t,userId:r})=>{const a=r==null?"":`?user_id=${encodeURIComponent(r)}`;return i(`/v1/routing/policies/${encodeURIComponent(t)}${a}`,{method:"DELETE"})},onSuccess:()=>{e.invalidateQueries({queryKey:[le]}),e.invalidateQueries({queryKey:[D]})}})}function er(e){return p({queryKey:[gt,e],queryFn:()=>i(`/v1/routing/status?user_id=${encodeURIComponent(e??"")}`),enabled:e!==null&&e!=="",staleTime:3e4})}function tr(){const e=x();return f({mutationFn:t=>i("/v1/aliases",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>{e.invalidateQueries({queryKey:[ce]}),e.invalidateQueries({queryKey:[D]})}})}function nr(){const e=x();return f({mutationFn:({name:t,userId:r})=>{const a=r==null?"":`?user_id=${encodeURIComponent(r)}`;return i(`/v1/aliases/${encodeURIComponent(t)}${a}`,{method:"DELETE"})},onSuccess:()=>{e.invalidateQueries({queryKey:[ce]}),e.invalidateQueries({queryKey:[D]})}})}function jt(){return p({queryKey:[Ce],queryFn:()=>i("/v1/settings"),staleTime:6e4})}function St(){const e=x();return f({mutationFn:t=>i("/v1/settings",{method:"PATCH",body:JSON.stringify(t)}),onSuccess:t=>{e.setQueryData([Ce],t),e.invalidateQueries({queryKey:[D]}),e.invalidateQueries({queryKey:[ue]})}})}function rr(){return f({mutationFn:()=>i("/v1/settings/master-key/rotate",{method:"POST"})})}function sr(){return p({queryKey:[Pe],queryFn:()=>i("/v1/tool-settings"),staleTime:6e4})}function ar(){return p({queryKey:[Ne],queryFn:()=>i("/v1/tools"),staleTime:6e4})}function or(){const e=x();return f({mutationFn:t=>i("/v1/tool-settings",{method:"PATCH",body:JSON.stringify(t)}),onSuccess:t=>{e.setQueryData([Pe],t),e.invalidateQueries({queryKey:[Ne]})}})}function ir(){return f({mutationFn:({service:e,url:t})=>i(`/v1/tool-settings/${encodeURIComponent(e)}/test`,{method:"POST",body:JSON.stringify({url:t})})})}const Z=1e3,kt=100;async function Et(){const e=[];for(let t=0;ti("/v1/pricing",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>{e.invalidateQueries({queryKey:[H]}),e.invalidateQueries({queryKey:[D]})}})}function ur(){const e=x();return f({mutationFn:t=>i(`/v1/pricing/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>{e.invalidateQueries({queryKey:[H]}),e.invalidateQueries({queryKey:[D]})}})}function dr(){return f({mutationFn:()=>i("/v1/pricing/refresh",{method:"POST",signal:G()})})}function mr(){const e=x();return f({mutationFn:()=>i("/v1/pricing/refresh/confirm",{method:"POST"}),onSuccess:()=>{e.invalidateQueries({queryKey:[H]}),e.invalidateQueries({queryKey:[D]}),e.invalidateQueries({queryKey:[de]})}})}function fr(){return f({mutationFn:()=>i("/v1/pricing/refresh/reject",{method:"POST"})})}const ee=1e3,Tt=100;async function Ct(){const e=[];for(let t=0;ti("/v1/keys",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>void e.invalidateQueries({queryKey:[O]})})}function xr(){const e=x();return f({mutationFn:({id:t,body:r})=>i(`/v1/keys/${encodeURIComponent(t)}`,{method:"PATCH",body:JSON.stringify(r)}),onSuccess:()=>void e.invalidateQueries({queryKey:[O]})})}function gr(){const e=x();return f({mutationFn:t=>i(`/v1/keys/${encodeURIComponent(t)}/rotate`,{method:"POST"}),onSuccess:()=>void e.invalidateQueries({queryKey:[O]})})}function pr(){const e=x();return f({mutationFn:t=>i(`/v1/keys/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>void e.invalidateQueries({queryKey:[O]})})}const te=1e3,Pt=100;async function Nt(){const e=[];for(let t=0;ti(`/v1/budgets/${encodeURIComponent(e)}/reset-logs`),enabled:e!==null,staleTime:6e4})}function wr(){const e=x();return f({mutationFn:t=>i("/v1/budgets",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>void e.invalidateQueries({queryKey:[M]})})}function jr(){const e=x();return f({mutationFn:({id:t,body:r})=>i(`/v1/budgets/${encodeURIComponent(t)}`,{method:"PATCH",body:JSON.stringify(r)}),onSuccess:()=>void e.invalidateQueries({queryKey:[M]})})}function Sr(){const e=x();return f({mutationFn:t=>i(`/v1/budgets/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>void e.invalidateQueries({queryKey:[M]})})}const ne=1e3,_t=100;async function Lt(){const e=[];for(let t=0;t<_t;t+=1){const r=await i(`/v1/users?skip=${t*ne}&limit=${ne}`);if(e.push(...r),r.lengthi("/v1/users",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>fe(e)})}function Tr(){const e=x();return f({mutationFn:({id:t,body:r})=>i(`/v1/users/${encodeURIComponent(t)}`,{method:"PATCH",body:JSON.stringify(r)}),onSuccess:()=>fe(e)})}function Cr(){const e=x();return f({mutationFn:t=>i(`/v1/users/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>{fe(e),e.invalidateQueries({queryKey:[O]})}})}function $(e){const t=new URLSearchParams,r=(a,s)=>{for(const o of typeof s=="string"?[s]:s??[])o&&t.append(a,o)};return e.start_date&&t.set("start_date",e.start_date),e.end_date&&t.set("end_date",e.end_date),e.status&&t.set("status",e.status),r("model",e.model),e.endpoint&&t.set("endpoint",e.endpoint),e.provider&&t.set("provider",e.provider),r("user_id",e.user_id),r("api_key_id",e.api_key_id),e.source&&t.set("source",e.source),e.source_label&&t.set("source_label",e.source_label),e.tool&&t.set("tool",e.tool),e.priced!==void 0&&t.set("priced",String(e.priced)),e.counts_toward_budget!==void 0&&t.set("counts_toward_budget",String(e.counts_toward_budget)),t}function Pr(e,t,r){return p({queryKey:[q,"list",e,t,r],queryFn:()=>{const a=$(e);return a.set("skip",String(t*r)),a.set("limit",String(r)),i(`/v1/usage?${a.toString()}`)},placeholderData:K,staleTime:1e4})}function Nr(e,t=!0){return p({queryKey:[q,"count",e],queryFn:()=>i(`/v1/usage/count?${$(e).toString()}`),enabled:t,placeholderData:K,staleTime:1e4})}const Rt=6e4;function Dt(e,t=!0){return p({queryKey:[q,"count","failures",e],queryFn:()=>{const r={status:"error",source:"gateway",start_date:yt(e)};return i(`/v1/usage/count?${$(r).toString()}`)},enabled:t,refetchInterval:Rt,refetchOnWindowFocus:!0,staleTime:0,retry:!1})}const qt=1e3;function _r(e){const t=[...new Set(e)].sort();return p({queryKey:[q,"groups",t],queryFn:()=>{const r=new URLSearchParams;for(const a of t)r.append("request_group_id",a);return r.set("limit",String(qt)),i(`/v1/usage?${r.toString()}`)},enabled:t.length>0,placeholderData:K,staleTime:3e4})}function Lr(){const e=x();return f({mutationFn:t=>i("/v1/usage",{method:"DELETE",body:JSON.stringify(t),signal:G()}),onSuccess:()=>{e.invalidateQueries({queryKey:[q]})}})}function Rr(){const e=x();return f({mutationFn:t=>i("/v1/usage/set-price",{method:"POST",body:JSON.stringify(t),signal:G()}),onSuccess:()=>{e.invalidateQueries({queryKey:[q]})}})}const Dr=[];function qr(e,t,r,a=!0){return p({queryKey:[q,"summary",e,t,r??"all"],queryFn:()=>{const s=$(e);if(s.set("bucket",t),r)for(const o of r.length>0?r:["none"])s.append("dimensions",o);return i(`/v1/usage/summary?${s.toString()}`)},enabled:a,placeholderData:K,staleTime:3e4})}function Ar(e,t,r,a=!0){return p({queryKey:[q,"series",e,t,r],queryFn:()=>{const s=$(e);return s.set("bucket",t),s.set("group_by",r),i(`/v1/usage/series?${s.toString()}`)},enabled:a&&r!==null,placeholderData:K,staleTime:3e4,retry:(s,o)=>!(o instanceof _&&o.status===404)&&s<3})}async function At(e,t=navigator.clipboard){if(t)try{return await t.writeText(e),!0}catch{}return It(e)}function It(e){const t=document.createElement("textarea");t.value=e,t.readOnly=!0,t.style.position="fixed",t.style.top="-1000px",t.style.opacity="0",document.body.appendChild(t);const r=document.getSelection(),a=r&&r.rangeCount>0?r.getRangeAt(0):null,s=document.activeElement instanceof HTMLElement?document.activeElement:null;t.select();let o=!1;try{o=document.execCommand("copy")}catch{o=!1}return t.remove(),r&&a&&(r.removeAllRanges(),r.addRange(a)),s==null||s.focus(),o}function Ir(e){return e==null?"0":new Intl.NumberFormat("en-US").format(e)}function Or(e){if(e==null)return"$0.00";const t=e!==0&&Math.abs(e)<.01?4:2;return new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",minimumFractionDigits:2,maximumFractionDigits:t}).format(e)}function Mr(e){if(e==null)return"—";if(e>=1e6){const t=e/1e6;return`${Number.isInteger(t)?t:t.toFixed(1)}M`}if(e>=1e3){const t=Math.round(e/1e3);return t>=1e3?"1M":`${t}K`}return String(e)}const Ot=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function Ur(e){if(!e)return"—";const t=/^(\d{4})-(\d{2})/.exec(e);if(!t)return e;const r=Number(t[2])-1;return r<0||r>11?t[1]:`${Ot[r]} ${t[1]}`}const Mt=new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:2});function Fr(e){return Mt.format(e)}function Kr(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}function Ut(e){return`${(e*100).toFixed(1)}%`}function $r(e,t){return t===void 0||t===0?null:(e-t)/t}function Ft(e,t=Date.now()){if(!e)return"never";const r=new Date(e);if(Number.isNaN(r.getTime()))return e;const a=Math.round((t-r.getTime())/1e3),s=a<0,o=Math.abs(a),l=[["second",60],["minute",60],["hour",24],["day",30],["month",12],["year",Number.POSITIVE_INFINITY]];let d=o,m="second";for(const[y,w]of l){if(m=y,d0?"▲":e<0?"▼":"•";return n.jsxs("span",{className:"text-[var(--otari-muted)]",children:[t," ",Ut(Math.abs(e))," vs prev"]})}function Kt(e){return e instanceof _||e instanceof Error?e.message:"Something went wrong."}function $t({error:e}){return e?n.jsx("div",{role:"alert",className:"rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700",children:Kt(e)}):null}function Bt({tone:e="info",children:t}){const r=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 n.jsx("div",{className:`rounded-lg border px-4 py-3 text-sm ${r}`,children:t})}function zr({title:e,description:t,action:r}){return n.jsxs("div",{className:"flex flex-col gap-3",children:[n.jsxs("div",{children:[n.jsx("h1",{className:"text-xl font-semibold text-[var(--otari-ink)]",children:e}),t?n.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:t}):null]}),r?n.jsx("div",{className:"flex flex-wrap gap-2",children:r}):null]})}function Qt(e){const[t,r]=c.useState(()=>Date.now());return c.useEffect(()=>{let a;const s=()=>{a===void 0&&(a=setInterval(()=>r(Date.now()),e))},o=()=>{a!==void 0&&(clearInterval(a),a=void 0)},l=()=>{r(Date.now()),document.visibilityState==="visible"?s():o()};return l(),document.addEventListener("visibilitychange",l),()=>{o(),document.removeEventListener("visibilitychange",l)}},[e]),t}function Vr({onRefresh:e,isFetching:t=!1,updatedAt:r,label:a="Refresh"}){const s=Qt(15e3),o=r?Ft(new Date(r).toISOString(),s):null;return n.jsxs("span",{className:"inline-flex items-center gap-2",children:[o?n.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Updated ",o]}):null,n.jsx(C,{variant:"outline",size:"sm",isIconOnly:!0,isDisabled:t,onPress:e,"aria-label":a,children:n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:`h-4 w-4 ${t?"animate-spin":""}`,"aria-hidden":"true",children:[n.jsx("path",{d:"M20 11a8 8 0 1 0-.5 4",strokeLinecap:"round",strokeLinejoin:"round"}),n.jsx("path",{d:"M20 4v5h-5",strokeLinecap:"round",strokeLinejoin:"round"})]})})]})}function Gr({value:e,label:t,className:r,children:a}){const s=o=>o.stopPropagation();return n.jsxs("span",{className:"inline-flex items-center gap-1",children:[n.jsx("span",{tabIndex:-1,className:`select-text outline-none ${r??""}`,onPointerDown:s,onMouseDown:s,children:a??e}),n.jsx(zt,{value:e,label:t})]})}function zt({value:e,label:t}){const[r,a]=c.useState("idle"),s=c.useRef(void 0);c.useEffect(()=>()=>clearTimeout(s.current),[]);const o=async()=>{const l=await At(e);a(l?"copied":"failed"),clearTimeout(s.current),s.current=setTimeout(()=>a("idle"),l?1500:5e3)};return n.jsxs(xe.Root,{isOpen:r!=="idle",children:[n.jsx(C,{size:"sm",variant:"ghost",isIconOnly:!0,"aria-label":`Copy ${t}`,onPress:o,children:n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-3.5 w-3.5","aria-hidden":"true",children:[n.jsx("rect",{x:"9",y:"9",width:"11",height:"11",rx:"2"}),n.jsx("path",{d:"M5 15V5a2 2 0 0 1 2-2h8",strokeLinecap:"round",strokeLinejoin:"round"})]})}),n.jsx(xe.Content,{placement:"top",showArrow:!0,children:r==="failed"?"Copy blocked, select the value and press Ctrl/Cmd-C":"Copied!"})]})}function Wr({title:e,description:t,actionLabel:r,onAction:a,isActionDisabled:s,children:o}){return n.jsx(I,{children:n.jsxs(I.Content,{className:"flex flex-col gap-4 p-6",children:[n.jsxs("div",{children:[n.jsx("h2",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:e}),t?n.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:t}):null]}),o,r&&a?n.jsx("div",{children:n.jsx(C,{variant:"primary",isDisabled:s,onPress:a,children:r})}):null]})})}function Hr({label:e="Loading…"}){return n.jsxs("div",{role:"status",className:"flex items-center justify-center gap-2 px-4 py-10 text-sm text-[var(--otari-muted)]",children:[n.jsx(Ze,{size:"sm"}),n.jsx("span",{children:e})]})}function Jr({children:e,confirmLabel:t,onConfirm:r,isPending:a}){const[s,o]=c.useState(!1);return s?n.jsxs("span",{className:"inline-flex items-center gap-1",children:[n.jsx(C,{size:"sm",variant:"danger",isDisabled:a,onPress:r,children:t}),n.jsx(C,{size:"sm",variant:"ghost",isDisabled:a,onPress:()=>o(!1),children:"Cancel"})]}):n.jsx(C,{size:"sm",variant:"danger-soft",onPress:()=>o(!0),children:e})}const Vt="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 Yr({id:e,label:t,ariaLabel:r,value:a,onChange:s,options:o,children:l,disabled:d}){const m=c.useId(),h=e??(t?m:void 0),y=n.jsx("select",{id:h,"aria-label":t?void 0:r,value:a,disabled:d,onChange:w=>s(w.target.value),className:Vt,children:o?o.map(w=>n.jsx("option",{value:w.value,children:w.label},w.value)):l});return t?n.jsxs("div",{className:"flex flex-col gap-1",children:[n.jsx("label",{htmlFor:h,className:"text-xs font-medium text-[var(--otari-muted)]",children:t}),y]}):y}function Xr({label:e,values:t,onChange:r,options:a,placeholder:s,maxVisible:o=50,maxValues:l=50,allowsCustom:d=!1}){const[m,h]=c.useState(""),y=t.length>=l,w=m.trim().toLowerCase(),v=a.filter(b=>!t.includes(b.value)).filter(b=>!w||b.value.toLowerCase().includes(w)||b.label.toLowerCase().includes(w)).slice(0,o),N=b=>{y||t.includes(b)||r([...t,b])},U=b=>{if(!d||b.key!=="Enter"||b.currentTarget.getAttribute("aria-activedescendant"))return;const B=m.trim();B&&(N(B),h(""))};return n.jsxs(Q.Root,{allowsEmptyCollection:!0,allowsCustomValue:d,menuTrigger:"focus",inputValue:m,onInputChange:h,selectedKey:null,disabledKeys:y?v.map(b=>b.value):[],onSelectionChange:b=>{b!=null&&(N(String(b)),h(""))},className:"flex flex-col gap-1",children:[n.jsx(Se,{className:"text-xs font-medium text-[var(--otari-muted)]",children:e}),n.jsxs(Q.InputGroup,{children:[n.jsx(ke,{placeholder:t.length===0?s:`${t.length} selected${y?" (max)":""}`,autoComplete:"off",onKeyDown:U}),n.jsx(Q.Trigger,{})]}),n.jsx(Q.Popover,{children:n.jsx(Ye,{items:v,className:"max-h-72 overflow-auto",children:b=>n.jsx(Xe,{id:b.value,textValue:b.label,children:b.label})})})]})}function Gt(){var m,h;const e=jt(),t=St(),[r,a]=c.useState(!1),o=((m=e.data)==null?void 0:m.require_pricing)===!0&&e.data.default_pricing===!1&&!r,d=((h=Dt(W,o).data)==null?void 0:h.total)??0;return o?n.jsx("div",{className:"shrink-0 px-6 pt-3",children:n.jsx(Bt,{tone:"warning",children:n.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[n.jsxs("span",{children:["Requests are rejected until pricing is set (",n.jsx("code",{children:"require_pricing"})," is on). Enable default pricing to meter new models with public rates right away.",d>0?n.jsxs(n.Fragment,{children:[" ",n.jsxs("strong",{className:"font-semibold",children:[d.toLocaleString()," ",d===1?"request":"requests"," failed in the last hour."]})," ",n.jsx(Ge,{to:"/activity?status=error&range=1h&source=gateway",className:"underline underline-offset-2",children:"View failed requests"})]}):null]}),n.jsxs("span",{className:"flex items-center gap-2",children:[n.jsx(C,{size:"sm",variant:"primary",isDisabled:t.isPending,onPress:()=>t.mutate({default_pricing:!0}),children:t.isPending?"Enabling…":"Enable default pricing"}),n.jsx(C,{size:"sm",variant:"ghost",onPress:()=>a(!0),children:"Dismiss"})]})]})})}):null}function Wt(){const{data:e}=wt(),t=c.useRef(null);return e&&t.current===null&&(t.current=e.build),e!=null&&t.current!=null&&e.build!==t.current}function Ht(){const e=Wt(),[t,r]=c.useState(!1);return!e||t?null:n.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center",children:n.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:[n.jsxs("span",{children:[n.jsx("strong",{className:"font-semibold",children:"An update is available."})," Reloading keeps you signed in."]}),n.jsx(C,{size:"sm",variant:"primary",onPress:()=>window.location.reload(),children:"Update now"}),n.jsx(C,{size:"sm",variant:"ghost",onPress:()=>r(!0),children:"Later"})]})})}const Re=200,De=480,re=240,Jt=60,qe="otari.dashboard.sidebarWidth",Ae="otari.dashboard.sidebarCollapsed",je=16,Ie="(max-width: 767px)",V=e=>Math.min(De,Math.max(Re,e));function Yt(){return typeof window>"u"||typeof window.matchMedia!="function"?!1:window.matchMedia(Ie).matches}const Xt='a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';function Zt(e){return e?Array.from(e.querySelectorAll(Xt)).filter(t=>t.offsetParent!==null||t===document.activeElement):[]}function en(){if(typeof window>"u")return re;try{const e=window.localStorage.getItem(qe),t=e?Number.parseInt(e,10):Number.NaN;return Number.isNaN(t)?re:V(t)}catch{return re}}function tn(){if(typeof window>"u")return!1;try{return window.localStorage.getItem(Ae)==="1"}catch{return!1}}const nn=[{key:"home"},{key:"observability",label:"Observability"},{key:"catalog",label:"Catalog"},{key:"access",label:"Access"},{key:"system"}],rn=[{to:"/",section:"home",label:"Overview",end:!0,icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("rect",{x:"3.5",y:"3.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),n.jsx("rect",{x:"13.5",y:"3.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),n.jsx("rect",{x:"3.5",y:"13.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),n.jsx("rect",{x:"13.5",y:"13.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"})]})},{to:"/activity",section:"observability",label:"Activity",icon:n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:n.jsx("path",{d:"M3 12h4l2.5-6 4 12 2.5-6H21",strokeLinecap:"round",strokeLinejoin:"round"})})},{to:"/usage",section:"observability",label:"Usage",icon:n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:n.jsx("path",{d:"M4 20V10M10 20V4M16 20v-7M22 20H2",strokeLinecap:"round",strokeLinejoin:"round"})})},{to:"/providers",section:"catalog",label:"Providers",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("rect",{x:"3.5",y:"4.5",width:"17",height:"6",rx:"1.5",strokeLinejoin:"round"}),n.jsx("rect",{x:"3.5",y:"13.5",width:"17",height:"6",rx:"1.5",strokeLinejoin:"round"}),n.jsx("path",{d:"M7 7.5h.01M7 16.5h.01",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/users",section:"access",label:"Users",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("circle",{cx:"9",cy:"8",r:"3.2",strokeLinejoin:"round"}),n.jsx("path",{d:"M3.5 19a5.5 5.5 0 0 1 11 0",strokeLinecap:"round",strokeLinejoin:"round"}),n.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:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("circle",{cx:"7.5",cy:"15.5",r:"3.5"}),n.jsx("path",{d:"M10 13l7-7M14 5l3 3M16.5 7.5l2-2",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/budgets",section:"access",label:"Budgets",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.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"}),n.jsx("rect",{x:"3",y:"7.5",width:"18",height:"12",rx:"1.5",strokeLinejoin:"round"}),n.jsx("path",{d:"M16 13.5h.01",strokeLinecap:"round",strokeLinejoin:"round"}),n.jsx("path",{d:"M21 12v3h-3.5a1.5 1.5 0 0 1 0-3H21z",strokeLinejoin:"round"})]})},{to:"/models",section:"catalog",label:"Models",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("path",{d:"M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z",strokeLinejoin:"round"}),n.jsx("path",{d:"M12 12l8-4.5M12 12v9M12 12L4 7.5",strokeLinejoin:"round"})]})},{to:"/routing",section:"catalog",label:"Routing",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("path",{d:"M4 5h4l4 7 4-7h4",strokeLinejoin:"round"}),n.jsx("path",{d:"M4 19h4l4-7",strokeLinejoin:"round"}),n.jsx("circle",{cx:"19",cy:"19",r:"2"}),n.jsx("circle",{cx:"19",cy:"5",r:"2"})]})},{to:"/tools",section:"system",label:"Tools & Guardrails",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.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"}),n.jsx("path",{d:"M12 9 5 16",strokeLinecap:"round"})]})},{to:"/settings",section:"system",label:"Settings",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("circle",{cx:"12",cy:"12",r:"3"}),n.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 sn(){const{logout:e}=ie(),t=c.useRef(null),r=c.useRef(null),a=c.useRef(null),[s,o]=c.useState(en),[l,d]=c.useState(tn),[m,h]=c.useState(!1),[y,w]=c.useState(Yt),[v,N]=c.useState(!1);c.useEffect(()=>{if(typeof window>"u"||typeof window.matchMedia!="function")return;const u=window.matchMedia(Ie),g=S=>{w(S.matches),S.matches||N(!1)};return typeof u.addEventListener=="function"?(u.addEventListener("change",g),()=>u.removeEventListener("change",g)):(u.addListener(g),()=>u.removeListener(g))},[]),c.useEffect(()=>{if(!v)return;const u=g=>{g.key==="Escape"&&N(!1)};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[v]),c.useEffect(()=>{var u,g,S;y&&(v?(u=t.current)==null||u.focus():(g=t.current)!=null&&g.contains(document.activeElement)&&((S=a.current)==null||S.focus()))},[y,v]);const U=c.useCallback(u=>{if(u.key!=="Tab")return;const g=Zt(t.current);if(g.length===0)return;const S=g[0],E=g[g.length-1],F=document.activeElement;u.shiftKey&&(F===S||F===t.current)?(u.preventDefault(),E.focus()):!u.shiftKey&&F===E&&(u.preventDefault(),S.focus())},[]);c.useEffect(()=>{const u=window.setTimeout(()=>{try{window.localStorage.setItem(qe,String(Math.round(s)))}catch{}},200);return()=>window.clearTimeout(u)},[s]),c.useEffect(()=>{try{window.localStorage.setItem(Ae,l?"1":"0")}catch{}},[l]);const b=c.useCallback(u=>{u.preventDefault(),u.currentTarget.setPointerCapture(u.pointerId),h(!0)},[]),B=c.useCallback(u=>{var S;if(!u.currentTarget.hasPointerCapture(u.pointerId))return;const g=((S=t.current)==null?void 0:S.getBoundingClientRect().left)??0;o(V(u.clientX-g))},[]),Me=c.useCallback(u=>{u.currentTarget.hasPointerCapture(u.pointerId)&&u.currentTarget.releasePointerCapture(u.pointerId),h(!1)},[]),Ue=c.useCallback(u=>{var g;u.preventDefault(),(g=r.current)==null||g.focus()},[]),Fe=c.useCallback(u=>{u.key==="ArrowLeft"?(u.preventDefault(),o(g=>V(g-je))):u.key==="ArrowRight"&&(u.preventDefault(),o(g=>V(g+je)))},[]),Ke=l?Jt:s,k=y?!1:l,X=y&&v?!0:void 0;return n.jsxs("div",{className:L("relative flex h-full flex-col overflow-hidden",m&&"cursor-col-resize select-none"),children:[n.jsx("button",{type:"button",inert:X,onClick:Ue,className:"sr-only focus:not-sr-only focus:absolute focus:top-3 focus:left-3 focus:z-50 focus:rounded-lg focus:border focus:border-[var(--otari-brand)] focus:bg-[var(--otari-surface)] focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-[var(--otari-brand-dark)] focus:shadow-md focus:outline-none",children:"Skip to main content"}),n.jsxs("header",{inert:X,className:"flex shrink-0 items-center justify-between border-b border-[var(--otari-line)] bg-[var(--otari-surface)] px-5 py-3",children:[n.jsxs("div",{className:"flex items-center gap-2.5",children:[n.jsx("button",{type:"button",ref:a,onClick:()=>N(u=>!u),"aria-label":v?"Close navigation":"Open navigation","aria-expanded":v,"aria-controls":"app-sidebar",className:"-ml-1 flex h-8 w-8 items-center justify-center rounded-lg text-[var(--otari-muted)] transition-colors hover:bg-[var(--otari-bg)] hover:text-[var(--otari-ink)] md:hidden",children:n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5",children:n.jsx("path",{d:"M4 6h16M4 12h16M4 18h16",strokeLinecap:"round",strokeLinejoin:"round"})})}),n.jsx("img",{src:"/favicon.svg",alt:"",className:"h-7 w-7 shrink-0"}),n.jsx("span",{className:"text-base font-semibold text-[var(--otari-ink)]",children:"Otari"})]}),n.jsx(C,{size:"sm",variant:"outline",onPress:e,"aria-label":"Sign out",children:"Sign out"})]}),n.jsx(Ht,{}),n.jsx(ft,{}),n.jsx(Gt,{}),n.jsxs("div",{className:"flex min-h-0 flex-1",children:[y&&v?n.jsx("div",{"aria-hidden":"true",onClick:()=>N(!1),className:"fixed inset-0 z-30 bg-black/40 md:hidden"}):null,n.jsxs("aside",{ref:t,id:"app-sidebar",role:y?"dialog":void 0,"aria-modal":y&&v?!0:void 0,"aria-label":y?"Navigation":void 0,tabIndex:y?-1:void 0,inert:y&&!v?!0:void 0,onKeyDown:y&&v?U:void 0,style:y?void 0:{width:Ke},className:L("flex flex-col border-r border-[var(--otari-line)] bg-[var(--otari-surface)] focus:outline-none",y?L("fixed inset-y-0 left-0 z-40 w-[17rem] shadow-xl transition-transform duration-200",v?"translate-x-0":"-translate-x-full"):L("relative shrink-0",!m&&"transition-[width] duration-150")),children:[n.jsx("button",{type:"button",onClick:()=>d(u=>!u),"aria-label":l?"Expand sidebar":"Collapse sidebar","aria-pressed":l,title:l?"Expand sidebar":"Collapse sidebar",className:"absolute -right-3 top-4 z-30 hidden 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)] md:flex",children:n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",className:L("h-3.5 w-3.5 transition-transform",l&&"rotate-180"),children:n.jsx("path",{d:"M15 6l-6 6 6 6",strokeLinecap:"round",strokeLinejoin:"round"})})}),n.jsx("nav",{className:L("flex flex-col py-4",k?"px-2":"px-3"),children:nn.map((u,g)=>{const S=rn.filter(E=>E.section===u.key);return S.length===0?null:n.jsxs("div",{className:g>0?"mt-4":void 0,children:[!k&&u.label?n.jsx("div",{className:"px-3 pb-1 text-[11px] font-semibold tracking-wider text-[var(--otari-muted)] uppercase",children:u.label}):null,g>0&&(k||!u.label)?n.jsx("div",{className:"mx-1 mb-2 border-t border-[var(--otari-line)]"}):null,n.jsx("div",{className:"flex flex-col gap-1",children:S.map(E=>n.jsxs(se,{to:E.to,end:E.end,onClick:()=>N(!1),"aria-label":k?E.label:void 0,title:k?E.label:void 0,className:({isActive:F})=>L("flex items-center rounded-lg py-2 text-sm font-medium transition-colors",k?"justify-center px-0":"gap-3 px-3",F?"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:[E.icon,k?null:E.label]},E.to))})]},u.key)})}),n.jsxs("div",{className:"mt-auto flex flex-col gap-1 pb-3",children:[n.jsxs(se,{to:"/docs",onClick:()=>N(!1),"aria-label":k?"User guide":void 0,title:k?"User guide":void 0,className:({isActive:u})=>L("flex items-center rounded-lg py-2 text-sm font-medium transition-colors",k?"mx-2 justify-center px-0":"mx-3 gap-3 px-3",u?"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:[n.jsxs("svg",{"aria-hidden":"true",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("path",{d:"M12 6.5C10.5 5 8 4.5 4 4.5V18c4 0 6.5.5 8 2 1.5-1.5 4-2 8-2V4.5c-4 0-6.5.5-8 2z",strokeLinejoin:"round"}),n.jsx("path",{d:"M12 6.5V20",strokeLinecap:"round"})]}),k?null:"User guide"]}),n.jsxs("a",{href:"https://otari.ai",target:"_blank",rel:"noreferrer",title:"otari.ai: the hosted Otari gateway",className:L("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)]",k?"mx-2 justify-center px-0":"mx-3 gap-2 px-3"),children:[n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-4 w-4 shrink-0",children:n.jsx("path",{d:"M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z",strokeLinejoin:"round"})}),k?null:n.jsxs("span",{className:"flex-1",children:["otari.ai ",n.jsx("span",{"aria-hidden":!0,children:"↗"})]})]})]}),l||y?null:n.jsx("div",{role:"separator","aria-orientation":"vertical","aria-label":"Resize sidebar","aria-valuenow":Math.round(s),"aria-valuemin":Re,"aria-valuemax":De,tabIndex:0,onPointerDown:b,onPointerMove:B,onPointerUp:Me,onKeyDown:Fe,className:L("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")})]}),n.jsx("main",{ref:r,id:"main-content",tabIndex:-1,inert:X,className:"flex-1 overflow-y-auto focus:outline-none",children:n.jsx("div",{className:"mx-auto flex max-w-[1800px] flex-col gap-6 px-4 py-5 md:px-6 md:py-6",children:n.jsx(We,{})})})]})]})}function an(){const{login:e}=ie(),[t,r]=c.useState(""),[a,s]=c.useState(null),[o,l]=c.useState(!1),d=async()=>{const m=t.trim();if(!(!m||o)){l(!0),s(null);try{await at(m)?e():s(new Error("Invalid master key."))}catch(h){s(h)}finally{l(!1)}}};return n.jsx("div",{className:"flex min-h-full items-center justify-center p-6",children:n.jsx(I,{className:"w-full max-w-md",children:n.jsxs(I.Content,{className:"flex flex-col gap-5 p-7",children:[n.jsxs("div",{className:"flex flex-col items-center gap-3 text-center",children:[n.jsx("img",{src:"/favicon.svg",alt:"Otari",className:"h-12 w-12"}),n.jsxs("div",{children:[n.jsx("h1",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"Otari Dashboard"}),n.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."})]})]}),n.jsxs("form",{className:"flex flex-col gap-4",onSubmit:m=>{m.preventDefault(),d()},children:[n.jsxs(et,{value:t,onChange:m=>{r(m),a&&s(null)},type:"password",isRequired:!0,className:"flex flex-col gap-1",children:[n.jsx(Se,{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Master key"}),n.jsx(ke,{placeholder:"otari-mk-… or your master key",autoFocus:!0,autoComplete:"off"})]}),n.jsxs("details",{className:"text-xs text-[var(--otari-muted)]",children:[n.jsx("summary",{className:"cursor-pointer font-medium text-[var(--otari-brand-dark)]",children:"First run? Where to find your key"}),n.jsxs("p",{className:"mt-2 leading-relaxed",children:["If you did not set ",n.jsx("code",{children:"OTARI_MASTER_KEY"}),", Otari generated one and printed it to the server logs on startup. Look for the line ",n.jsx("code",{children:"Your master key:"})," (for example, run"," ",n.jsx("code",{children:"docker logs "}),") and paste it above."]})]}),n.jsx($t,{error:a}),n.jsx(C,{type:"submit",variant:"primary",fullWidth:!0,isDisabled:!t.trim()||o,children:o?"Signing in…":"Sign in"})]}),n.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."}),n.jsx("div",{className:"border-t border-[var(--otari-line)] pt-4 text-center",children:n.jsx(tt,{href:"/welcome",className:"text-sm font-medium text-[var(--otari-brand-dark)]",children:"New to Otari? Open the welcome guide"})})]})})})}const on=c.lazy(async()=>({default:(await P(async()=>{const{ActivityPage:e}=await import("./ActivityPage-B-JrbzNG.js");return{ActivityPage:e}},__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11]))).ActivityPage})),cn=c.lazy(async()=>({default:(await P(async()=>{const{RoutingPage:e}=await import("./RoutingPage-CeSk6-Fe.js");return{RoutingPage:e}},__vite__mapDeps([12,1,2,8,5,13,11]))).RoutingPage})),ln=c.lazy(async()=>({default:(await P(async()=>{const{BudgetsPage:e}=await import("./BudgetsPage-Bb-ZzB9q.js");return{BudgetsPage:e}},__vite__mapDeps([14,1,2,6,5,7,8,11]))).BudgetsPage})),un=c.lazy(async()=>({default:(await P(async()=>{const{DocsPage:e}=await import("./DocsPage-wd6etVlE.js");return{DocsPage:e}},__vite__mapDeps([15,1,2,5]))).DocsPage})),dn=c.lazy(async()=>({default:(await P(async()=>{const{KeysPage:e}=await import("./KeysPage-D1MvNEen.js");return{KeysPage:e}},__vite__mapDeps([16,1,2,6,5,7,8,11,17,13]))).KeysPage})),mn=c.lazy(async()=>({default:(await P(async()=>{const{ModelsPage:e}=await import("./ModelsPage-CjADRmXg.js");return{ModelsPage:e}},__vite__mapDeps([18,1,2,6,5,8,10,11]))).ModelsPage})),fn=c.lazy(async()=>({default:(await P(async()=>{const{OverviewIndex:e}=await import("./OverviewPage-CvMKYScf.js");return{OverviewIndex:e}},__vite__mapDeps([19,1,2,3,4,5,8]))).OverviewIndex})),hn=c.lazy(async()=>({default:(await P(async()=>{const{ProvidersPage:e}=await import("./ProvidersPage-CKBJgQmn.js");return{ProvidersPage:e}},__vite__mapDeps([20,1,2,11,5,8]))).ProvidersPage})),yn=c.lazy(async()=>({default:(await P(async()=>{const{SettingsPage:e}=await import("./SettingsPage-LbV0e7qd.js");return{SettingsPage:e}},__vite__mapDeps([21,1,2,5]))).SettingsPage})),xn=c.lazy(async()=>({default:(await P(async()=>{const{ToolsGuardrailsPage:e}=await import("./ToolsGuardrailsPage-DnkIwA9f.js");return{ToolsGuardrailsPage:e}},__vite__mapDeps([22,1,2,5]))).ToolsGuardrailsPage})),gn=c.lazy(async()=>({default:(await P(async()=>{const{UsagePage:e}=await import("./UsagePage-Bxv4_uW3.js");return{UsagePage:e}},__vite__mapDeps([23,1,2,3,4,5,8,9]))).UsagePage})),pn=c.lazy(async()=>({default:(await P(async()=>{const{UsersPage:e}=await import("./UsersPage-C9eMut1u.js");return{UsersPage:e}},__vite__mapDeps([24,1,2,6,5,7,8,11,17]))).UsersPage}));function T(e){return n.jsx(c.Suspense,{fallback:n.jsx("div",{role:"status",children:"Loading page…"}),children:e})}function vn(){const{isAuthenticated:e}=ie();return e?n.jsx(He,{children:n.jsx(Je,{children:n.jsxs(j,{element:n.jsx(sn,{}),children:[n.jsx(j,{index:!0,element:T(n.jsx(fn,{}))}),n.jsx(j,{path:"providers",element:T(n.jsx(hn,{}))}),n.jsx(j,{path:"keys",element:T(n.jsx(dn,{}))}),n.jsx(j,{path:"users",element:T(n.jsx(pn,{}))}),n.jsx(j,{path:"budgets",element:T(n.jsx(ln,{}))}),n.jsx(j,{path:"activity",element:T(n.jsx(on,{}))}),n.jsx(j,{path:"usage",element:T(n.jsx(gn,{}))}),n.jsx(j,{path:"models",element:T(n.jsx(mn,{}))}),n.jsx(j,{path:"aliases",element:n.jsx(ye,{to:"/routing",replace:!0})}),n.jsx(j,{path:"routing",element:T(n.jsx(cn,{}))}),n.jsx(j,{path:"tools",element:T(n.jsx(xn,{}))}),n.jsx(j,{path:"settings",element:T(n.jsx(yn,{}))}),n.jsx(j,{path:"docs",element:T(n.jsx(un,{}))}),n.jsx(j,{path:"*",element:n.jsx(ye,{to:"/",replace:!0})})]})})}):n.jsx(an,{})}function bn({children:e}){const[t]=c.useState(()=>new Qe({defaultOptions:{queries:{refetchOnWindowFocus:!1,retry:(r,a)=>a instanceof _&&(a.status===401||a.status===403)?!1:r<2}}}));return n.jsx(ze,{client:t,children:n.jsx(ut,{children:e})})}const Oe=document.getElementById("root");if(!Oe)throw new Error("Root element #root not found");nt.createRoot(Oe).render(n.jsx(c.StrictMode,{children:n.jsx(bn,{children:n.jsx(vn,{})})}));export{Kt as $,Pn as A,Xn as B,Nn as C,tr as D,$t as E,Yr as F,sr as G,vr as H,wr as I,jr as J,Sr as K,Tr as L,Bt as M,br as N,hr as O,zr as P,xr as Q,Vr as R,gr as S,pr as T,yr as U,An as V,cr as W,Hn as X,ht as Y,Mr as Z,ur as _,xt as a,Ur as a0,jt as a1,Or as a2,On as a3,Hr as a4,Fn as a5,$r as a6,Br as a7,Fr as a8,Qr as a9,Kr as aA,Cr as aB,Er as aC,Ir as aa,Ut as ab,Ft as ac,Dr as ad,$n as ae,zn as af,Gn as ag,St as ah,Qn as ai,Kn as aj,Bn as ak,Un as al,Mn as am,Wn as an,dr as ao,mr as ap,fr as aq,rr as ar,Vn as as,ar as at,or as au,ir as av,Ar as aw,_ as ax,Tn as ay,En as az,Dn as b,Nr as c,qr as d,_n as e,qn as f,Ln as g,_r as h,Lr as i,Rr as j,lr as k,Cn as l,Xr as m,yt as n,Gr as o,kr as p,er as q,Rn as r,In as s,Yn as t,Pr as u,Jn as v,Zn as w,nr as x,Jr as y,Wr as z}; diff --git a/src/gateway/static/dashboard/assets/index-DAnS9oY2.js b/src/gateway/static/dashboard/assets/index-DAnS9oY2.js deleted file mode 100644 index 33d95d51..00000000 --- a/src/gateway/static/dashboard/assets/index-DAnS9oY2.js +++ /dev/null @@ -1,2 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/ActivityPage-fH63Z1Im.js","assets/tanstack-query-1t81HyiD.js","assets/react-dgEcD0HR.js","assets/charts-krq1PqQO.js","assets/recharts-C3cGlHOx.js","assets/heroui-COmYdDDM.js","assets/tableSelection-BJDASjEj.js","assets/ConfirmDialog-lRO7CIis.js","assets/DataTable-DuDxGlJc.js","assets/FilterChips-DTdIceb1.js","assets/TablePagination-BpT-8wzM.js","assets/Field-CBU9MRjz.js","assets/RoutingPage-DELPbpkQ.js","assets/UserComboBox-DoloPF6p.js","assets/BudgetsPage-C3eMHXLY.js","assets/DocsPage-omWiBiUs.js","assets/KeysPage-DvXgAgzE.js","assets/ModelScopeControl-CYPgEOWk.js","assets/ModelsPage-SJrMcme1.js","assets/OverviewPage-W9tjAThu.js","assets/ProvidersPage-CkpZWNPU.js","assets/SettingsPage-CDU9M0qn.js","assets/ToolsGuardrailsPage-oZnn27P0.js","assets/UsagePage-Bt6OQ6El.js","assets/UsersPage-CWfTsLqm.js"])))=>i.map(i=>d[i]); -var Me=Object.defineProperty;var Ue=(e,t,r)=>t in e?Me(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var me=(e,t,r)=>Ue(e,typeof t!="symbol"?t+"":t,r);import{u as x,j as n,a as p,b as f,k as K,Q as Fe,c as Ke}from"./tanstack-query-1t81HyiD.js";import{d as Be,r as c,N as ne,L as $e,O as Qe,H as ze,e as Ve,f as j,h as fe}from"./react-dgEcD0HR.js";import{B as P,C as Q,L as be,I as we,a as We,b as Ge,d as I,S as He,T as he,c as _,e as Je,f as Ye}from"./heroui-COmYdDDM.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))s(a);new MutationObserver(a=>{for(const o of a)if(o.type==="childList")for(const u of o.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&s(u)}).observe(document,{childList:!0,subtree:!0});function r(a){const o={};return a.integrity&&(o.integrity=a.integrity),a.referrerPolicy&&(o.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?o.credentials="include":a.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(a){if(a.ep)return;a.ep=!0;const o=r(a);fetch(a.href,o)}})();var Xe=Be();const Ze="modulepreload",et=function(e){return"/"+e},ye={},T=function(t,r,s){let a=Promise.resolve();if(r&&r.length>0){let u=function(h){return Promise.all(h.map(y=>Promise.resolve(y).then(w=>({status:"fulfilled",value:w}),w=>({status:"rejected",reason:w}))))};document.getElementsByTagName("link");const d=document.querySelector("meta[property=csp-nonce]"),m=(d==null?void 0:d.nonce)||(d==null?void 0:d.getAttribute("nonce"));a=u(r.map(h=>{if(h=et(h),h in ye)return;ye[h]=!0;const y=h.endsWith(".css"),w=y?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${h}"]${w}`))return;const v=document.createElement("link");if(v.rel=y?"stylesheet":Ze,y||(v.as="script"),v.crossOrigin="",v.href=h,m&&v.setAttribute("nonce",m),document.head.appendChild(v),y)return new Promise((N,U)=>{v.addEventListener("load",N),v.addEventListener("error",()=>U(new Error(`Unable to preload CSS for ${h}`)))})}))}function o(u){const d=new Event("vite:preloadError",{cancelable:!0});if(d.payload=u,window.dispatchEvent(d),!d.defaultPrevented)throw u}return a.then(u=>{for(const d of u||[])d.status==="rejected"&&o(d.reason);return t().catch(o)})};class R extends Error{constructor(r,s){super(s);me(this,"status");this.name="ApiError",this.status=r}}let z=null;function xe(e){z=e}async function re(e){try{const t=await e.json();if(typeof t.detail=="string")return t.detail;if(t.detail!=null)return JSON.stringify(t.detail)}catch{}return e.statusText||`Request failed (${e.status})`}async function tt(e){let t;try{t=await fetch("/v1/auth/session",{method:"POST",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify({master_key:e})})}catch{throw new R(0,"Network error: could not reach the gateway.")}if(t.status===401||t.status===403)return!1;if(!t.ok)throw new R(t.status,await re(t));return!0}async function nt(){try{await fetch("/v1/auth/session",{method:"DELETE"})}catch{}}async function i(e,t={}){const r=new Headers(t.headers);r.set("Accept","application/json"),t.body!=null&&!r.has("Content-Type")&&r.set("Content-Type","application/json");let s;try{s=await fetch(e,{...t,headers:r})}catch{throw new R(0,"Network error: could not reach the gateway.")}if(s.status===401||s.status===403)throw z==null||z(),new R(s.status,await re(s));if(!s.ok)throw new R(s.status,await re(s));if(s.status!==204)return await s.json()}const se="otari.dashboard.hasSession",je=c.createContext(null);function rt(){try{return window.localStorage.getItem(se)==="1"}catch{return!1}}function st({children:e}){const t=x(),[r,s]=c.useState(rt),a=c.useCallback(()=>{nt(),s(!1),t.clear();try{window.localStorage.removeItem(se)}catch{}},[t]),o=c.useCallback(()=>{t.clear(),s(!0);try{window.localStorage.setItem(se,"1")}catch{}},[t]);c.useEffect(()=>(xe(a),()=>xe(null)),[a]);const u=c.useMemo(()=>({isAuthenticated:r,login:o,logout:a}),[r,o,a]);return n.jsx(je.Provider,{value:u,children:e})}function ae(){const e=c.useContext(je);if(!e)throw new Error("useAuth must be used within an AuthProvider");return e}function at(e){return e instanceof R&&e.status===0}function ot(){const e=x(),[t,r]=c.useState(!1);return c.useEffect(()=>{const s=e.getQueryCache(),a=()=>s.getAll().some(o=>o.state.status==="error"&&at(o.state.error));return r(a()),s.subscribe(()=>r(a()))},[e]),t}function it(){return ot()?n.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:[n.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:[n.jsx("path",{d:"M12 9v4M12 17h.01",strokeLinecap:"round",strokeLinejoin:"round"}),n.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"})]}),n.jsxs("span",{children:[n.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 W=3600,L=86400,ct=365*L,vn=[{key:"1h",label:"Last hour",seconds:W,bucket:"hour"},{key:"24h",label:"24h",seconds:L,bucket:"hour"},{key:"7d",label:"7d",seconds:7*L,bucket:"day"},{key:"30d",label:"30d",seconds:30*L,bucket:"day"},{key:"90d",label:"90d",seconds:90*L,bucket:"day"},{key:"12mo",label:"12mo",seconds:ct,bucket:"day"}],bn="30d",wn=[{key:"1h",label:"1h",seconds:W,bucket:"hour"},{key:"24h",label:"24h",seconds:L,bucket:"hour"},{key:"7d",label:"7d",seconds:7*L,bucket:"day"},{key:"30d",label:"30d",seconds:30*L,bucket:"day"},{key:"all",label:"All",seconds:null,bucket:"day"}],jn="24h",Sn="custom";function kn(e,t){return e.find(r=>r.key===t)}function lt(e,t=Date.now()){return new Date(t-e*1e3).toISOString()}function ut(e){return(e==="hour"?W:L)*1e3}function En(e,t,r=Date.now()){const s=new Date(e).getTime();return(t?new Date(t).getTime():r)-s<=L*1e3?"hour":"day"}function Cn(e,t,r,s){if(e.length===0)return null;const a=Math.max(0,Math.min(t,r)),o=Math.min(e.length-1,Math.max(t,r)),u=new Date(e[a]).getTime(),d=new Date(e[o]).getTime()+ut(s);return{startIso:new Date(u).toISOString(),endIso:new Date(d).toISOString()}}function Pn(e,t,r){const s=e.length;if(s===0)return{startIndex:0,endIndex:0};const a=e.map(d=>new Date(d).getTime());let o=0;if(t){const d=new Date(t).getTime();for(let m=0;mi("/v1/models"),staleTime:6e4})}function yt(){return p({queryKey:[ft],queryFn:()=>i("/dashboard-build.json"),refetchInterval:ht,refetchOnWindowFocus:!0,staleTime:0,retry:!1})}function _n(){return p({queryKey:[ce],queryFn:()=>i("/v1/models/discoverable"),staleTime:5*6e4})}function Ln(){return p({queryKey:[le],queryFn:()=>i("/v1/providers"),staleTime:5*6e4})}function Dn(){return p({queryKey:["provider-catalog"],queryFn:()=>i("/v1/providers/catalog"),staleTime:1/0})}function Rn(e){return p({queryKey:["provider-catalog",e],queryFn:()=>i(`/v1/providers/catalog/${encodeURIComponent(e)}`),enabled:e!=="",staleTime:1/0})}function qn(){return p({queryKey:[ue],queryFn:()=>i("/v1/providers/health"),staleTime:ge,refetchInterval:ge})}function An(){const e=x();return f({mutationFn:()=>i("/v1/providers/health?refresh=true"),onSuccess:t=>e.setQueryData([ue],t)})}function In(){return p({queryKey:[Ce],queryFn:()=>i("/v1/provider-credentials"),staleTime:6e4})}function H(e){e.invalidateQueries({queryKey:[Ce]}),e.invalidateQueries({queryKey:[le]}),e.invalidateQueries({queryKey:[D]}),e.invalidateQueries({queryKey:[ce]}),e.invalidateQueries({queryKey:[ue]})}function On(){const e=x();return f({mutationFn:t=>i("/v1/provider-credentials",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>H(e)})}function Mn(){const e=x();return f({mutationFn:({instance:t,body:r})=>i(`/v1/provider-credentials/${encodeURIComponent(t)}`,{method:"PATCH",body:JSON.stringify(r)}),onSuccess:()=>H(e)})}function Un(){const e=x();return f({mutationFn:t=>i(`/v1/provider-credentials/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>H(e)})}function Fn(){const e=x();return f({mutationFn:()=>i("/v1/provider-credentials/reencrypt",{method:"POST"}),onSuccess:()=>H(e)})}function Kn(){return f({mutationFn:e=>i(`/v1/provider-credentials/${encodeURIComponent(e)}/test`,{method:"POST"})})}function Bn(){return f({mutationFn:e=>i("/v1/provider-credentials/test",{method:"POST",body:JSON.stringify(e)})})}function $n(){return p({queryKey:[mt],queryFn:()=>i("/v1/models/metadata"),staleTime:10*6e4})}function Qn(){return p({queryKey:[oe],queryFn:()=>i("/v1/aliases"),staleTime:6e4})}function zn(){return p({queryKey:[ie],queryFn:()=>i("/v1/routing/policies"),staleTime:6e4})}function Vn(){const e=x();return f({mutationFn:t=>i("/v1/routing/policies",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>{e.invalidateQueries({queryKey:[ie]}),e.invalidateQueries({queryKey:[D]})}})}function Wn(){const e=x();return f({mutationFn:({name:t,userId:r})=>{const s=r==null?"":`?user_id=${encodeURIComponent(r)}`;return i(`/v1/routing/policies/${encodeURIComponent(t)}${s}`,{method:"DELETE"})},onSuccess:()=>{e.invalidateQueries({queryKey:[ie]}),e.invalidateQueries({queryKey:[D]})}})}function Gn(e){return p({queryKey:[dt,e],queryFn:()=>i(`/v1/routing/status?user_id=${encodeURIComponent(e??"")}`),enabled:e!==null&&e!=="",staleTime:3e4})}function Hn(){const e=x();return f({mutationFn:t=>i("/v1/aliases",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>{e.invalidateQueries({queryKey:[oe]}),e.invalidateQueries({queryKey:[D]})}})}function Jn(){const e=x();return f({mutationFn:({name:t,userId:r})=>{const s=r==null?"":`?user_id=${encodeURIComponent(r)}`;return i(`/v1/aliases/${encodeURIComponent(t)}${s}`,{method:"DELETE"})},onSuccess:()=>{e.invalidateQueries({queryKey:[oe]}),e.invalidateQueries({queryKey:[D]})}})}function xt(){return p({queryKey:[Se],queryFn:()=>i("/v1/settings"),staleTime:6e4})}function gt(){const e=x();return f({mutationFn:t=>i("/v1/settings",{method:"PATCH",body:JSON.stringify(t)}),onSuccess:t=>{e.setQueryData([Se],t),e.invalidateQueries({queryKey:[D]}),e.invalidateQueries({queryKey:[ce]})}})}function Yn(){return f({mutationFn:()=>i("/v1/settings/master-key/rotate",{method:"POST"})})}function Xn(){return p({queryKey:[ke],queryFn:()=>i("/v1/tool-settings"),staleTime:6e4})}function Zn(){return p({queryKey:[Ee],queryFn:()=>i("/v1/tools"),staleTime:6e4})}function er(){const e=x();return f({mutationFn:t=>i("/v1/tool-settings",{method:"PATCH",body:JSON.stringify(t)}),onSuccess:t=>{e.setQueryData([ke],t),e.invalidateQueries({queryKey:[Ee]})}})}function tr(){return f({mutationFn:({service:e,url:t})=>i(`/v1/tool-settings/${encodeURIComponent(e)}/test`,{method:"POST",body:JSON.stringify({url:t})})})}const Y=1e3,pt=100;async function vt(){const e=[];for(let t=0;ti("/v1/pricing",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>{e.invalidateQueries({queryKey:[G]}),e.invalidateQueries({queryKey:[D]})}})}function sr(){const e=x();return f({mutationFn:t=>i(`/v1/pricing/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>{e.invalidateQueries({queryKey:[G]}),e.invalidateQueries({queryKey:[D]})}})}function ar(){return f({mutationFn:()=>i("/v1/pricing/refresh",{method:"POST"})})}function or(){const e=x();return f({mutationFn:()=>i("/v1/pricing/refresh/confirm",{method:"POST"}),onSuccess:()=>{e.invalidateQueries({queryKey:[G]}),e.invalidateQueries({queryKey:[D]}),e.invalidateQueries({queryKey:[le]})}})}function ir(){return f({mutationFn:()=>i("/v1/pricing/refresh/reject",{method:"POST"})})}const X=1e3,bt=100;async function wt(){const e=[];for(let t=0;ti("/v1/keys",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>void e.invalidateQueries({queryKey:[O]})})}function ur(){const e=x();return f({mutationFn:({id:t,body:r})=>i(`/v1/keys/${encodeURIComponent(t)}`,{method:"PATCH",body:JSON.stringify(r)}),onSuccess:()=>void e.invalidateQueries({queryKey:[O]})})}function dr(){const e=x();return f({mutationFn:t=>i(`/v1/keys/${encodeURIComponent(t)}/rotate`,{method:"POST"}),onSuccess:()=>void e.invalidateQueries({queryKey:[O]})})}function mr(){const e=x();return f({mutationFn:t=>i(`/v1/keys/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>void e.invalidateQueries({queryKey:[O]})})}const Z=1e3,jt=100;async function St(){const e=[];for(let t=0;ti(`/v1/budgets/${encodeURIComponent(e)}/reset-logs`),enabled:e!==null,staleTime:6e4})}function yr(){const e=x();return f({mutationFn:t=>i("/v1/budgets",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>void e.invalidateQueries({queryKey:[M]})})}function xr(){const e=x();return f({mutationFn:({id:t,body:r})=>i(`/v1/budgets/${encodeURIComponent(t)}`,{method:"PATCH",body:JSON.stringify(r)}),onSuccess:()=>void e.invalidateQueries({queryKey:[M]})})}function gr(){const e=x();return f({mutationFn:t=>i(`/v1/budgets/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>void e.invalidateQueries({queryKey:[M]})})}const ee=1e3,kt=100;async function Et(){const e=[];for(let t=0;ti("/v1/users",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>de(e)})}function br(){const e=x();return f({mutationFn:({id:t,body:r})=>i(`/v1/users/${encodeURIComponent(t)}`,{method:"PATCH",body:JSON.stringify(r)}),onSuccess:()=>de(e)})}function wr(){const e=x();return f({mutationFn:t=>i(`/v1/users/${encodeURIComponent(t)}`,{method:"DELETE"}),onSuccess:()=>{de(e),e.invalidateQueries({queryKey:[O]})}})}function B(e){const t=new URLSearchParams,r=(s,a)=>{for(const o of typeof a=="string"?[a]:a??[])o&&t.append(s,o)};return e.start_date&&t.set("start_date",e.start_date),e.end_date&&t.set("end_date",e.end_date),e.status&&t.set("status",e.status),r("model",e.model),e.endpoint&&t.set("endpoint",e.endpoint),e.provider&&t.set("provider",e.provider),r("user_id",e.user_id),r("api_key_id",e.api_key_id),e.source&&t.set("source",e.source),e.source_label&&t.set("source_label",e.source_label),e.tool&&t.set("tool",e.tool),e.priced!==void 0&&t.set("priced",String(e.priced)),e.counts_toward_budget!==void 0&&t.set("counts_toward_budget",String(e.counts_toward_budget)),t}function jr(e,t,r){return p({queryKey:[q,"list",e,t,r],queryFn:()=>{const s=B(e);return s.set("skip",String(t*r)),s.set("limit",String(r)),i(`/v1/usage?${s.toString()}`)},placeholderData:K,staleTime:1e4})}function Sr(e,t=!0){return p({queryKey:[q,"count",e],queryFn:()=>i(`/v1/usage/count?${B(e).toString()}`),enabled:t,placeholderData:K,staleTime:1e4})}const Ct=6e4;function Pt(e,t=!0){return p({queryKey:[q,"count","failures",e],queryFn:()=>{const r={status:"error",source:"gateway",start_date:lt(e)};return i(`/v1/usage/count?${B(r).toString()}`)},enabled:t,refetchInterval:Ct,refetchOnWindowFocus:!0,staleTime:0,retry:!1})}const Tt=1e3;function kr(e){const t=[...new Set(e)].sort();return p({queryKey:[q,"groups",t],queryFn:()=>{const r=new URLSearchParams;for(const s of t)r.append("request_group_id",s);return r.set("limit",String(Tt)),i(`/v1/usage?${r.toString()}`)},enabled:t.length>0,placeholderData:K,staleTime:3e4})}function Er(){const e=x();return f({mutationFn:t=>i("/v1/usage",{method:"DELETE",body:JSON.stringify(t)}),onSuccess:()=>{e.invalidateQueries({queryKey:[q]})}})}function Cr(){const e=x();return f({mutationFn:t=>i("/v1/usage/set-price",{method:"POST",body:JSON.stringify(t)}),onSuccess:()=>{e.invalidateQueries({queryKey:[q]})}})}const Pr=[];function Tr(e,t,r,s=!0){return p({queryKey:[q,"summary",e,t,r??"all"],queryFn:()=>{const a=B(e);if(a.set("bucket",t),r)for(const o of r.length>0?r:["none"])a.append("dimensions",o);return i(`/v1/usage/summary?${a.toString()}`)},enabled:s,placeholderData:K,staleTime:3e4})}function Nr(e,t,r,s=!0){return p({queryKey:[q,"series",e,t,r],queryFn:()=>{const a=B(e);return a.set("bucket",t),a.set("group_by",r),i(`/v1/usage/series?${a.toString()}`)},enabled:s&&r!==null,placeholderData:K,staleTime:3e4,retry:(a,o)=>!(o instanceof R&&o.status===404)&&a<3})}async function Nt(e,t=navigator.clipboard){if(t)try{return await t.writeText(e),!0}catch{}return _t(e)}function _t(e){const t=document.createElement("textarea");t.value=e,t.readOnly=!0,t.style.position="fixed",t.style.top="-1000px",t.style.opacity="0",document.body.appendChild(t);const r=document.getSelection(),s=r&&r.rangeCount>0?r.getRangeAt(0):null,a=document.activeElement instanceof HTMLElement?document.activeElement:null;t.select();let o=!1;try{o=document.execCommand("copy")}catch{o=!1}return t.remove(),r&&s&&(r.removeAllRanges(),r.addRange(s)),a==null||a.focus(),o}function _r(e){return e==null?"0":new Intl.NumberFormat("en-US").format(e)}function Lr(e){if(e==null)return"$0.00";const t=e!==0&&Math.abs(e)<.01?4:2;return new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",minimumFractionDigits:2,maximumFractionDigits:t}).format(e)}function Dr(e){if(e==null)return"—";if(e>=1e6){const t=e/1e6;return`${Number.isInteger(t)?t:t.toFixed(1)}M`}if(e>=1e3){const t=Math.round(e/1e3);return t>=1e3?"1M":`${t}K`}return String(e)}const Lt=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function Rr(e){if(!e)return"—";const t=/^(\d{4})-(\d{2})/.exec(e);if(!t)return e;const r=Number(t[2])-1;return r<0||r>11?t[1]:`${Lt[r]} ${t[1]}`}const Dt=new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:2});function qr(e){return Dt.format(e)}function Ar(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}function Rt(e){return`${(e*100).toFixed(1)}%`}function Ir(e,t){return t===void 0||t===0?null:(e-t)/t}function qt(e,t=Date.now()){if(!e)return"never";const r=new Date(e);if(Number.isNaN(r.getTime()))return e;const s=Math.round((t-r.getTime())/1e3),a=s<0,o=Math.abs(s),u=[["second",60],["minute",60],["hour",24],["day",30],["month",12],["year",Number.POSITIVE_INFINITY]];let d=o,m="second";for(const[y,w]of u){if(m=y,d0?"▲":e<0?"▼":"•";return n.jsxs("span",{className:"text-[var(--otari-muted)]",children:[t," ",Rt(Math.abs(e))," vs prev"]})}function At(e){return e instanceof R||e instanceof Error?e.message:"Something went wrong."}function It({error:e}){return e?n.jsx("div",{role:"alert",className:"rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700",children:At(e)}):null}function Ot({tone:e="info",children:t}){const r=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 n.jsx("div",{className:`rounded-lg border px-4 py-3 text-sm ${r}`,children:t})}function Ur({title:e,description:t,action:r}){return n.jsxs("div",{className:"flex flex-col gap-3",children:[n.jsxs("div",{children:[n.jsx("h1",{className:"text-xl font-semibold text-[var(--otari-ink)]",children:e}),t?n.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:t}):null]}),r?n.jsx("div",{className:"flex flex-wrap gap-2",children:r}):null]})}function Mt(e){const[t,r]=c.useState(()=>Date.now());return c.useEffect(()=>{let s;const a=()=>{s===void 0&&(s=setInterval(()=>r(Date.now()),e))},o=()=>{s!==void 0&&(clearInterval(s),s=void 0)},u=()=>{r(Date.now()),document.visibilityState==="visible"?a():o()};return u(),document.addEventListener("visibilitychange",u),()=>{o(),document.removeEventListener("visibilitychange",u)}},[e]),t}function Fr({onRefresh:e,isFetching:t=!1,updatedAt:r,label:s="Refresh"}){const a=Mt(15e3),o=r?qt(new Date(r).toISOString(),a):null;return n.jsxs("span",{className:"inline-flex items-center gap-2",children:[o?n.jsxs("span",{className:"text-xs text-[var(--otari-muted)]",children:["Updated ",o]}):null,n.jsx(P,{variant:"outline",size:"sm",isIconOnly:!0,isDisabled:t,onPress:e,"aria-label":s,children:n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:`h-4 w-4 ${t?"animate-spin":""}`,"aria-hidden":"true",children:[n.jsx("path",{d:"M20 11a8 8 0 1 0-.5 4",strokeLinecap:"round",strokeLinejoin:"round"}),n.jsx("path",{d:"M20 4v5h-5",strokeLinecap:"round",strokeLinejoin:"round"})]})})]})}function Kr({value:e,label:t,className:r,children:s}){const a=o=>o.stopPropagation();return n.jsxs("span",{className:"inline-flex items-center gap-1",children:[n.jsx("span",{tabIndex:-1,className:`select-text outline-none ${r??""}`,onPointerDown:a,onMouseDown:a,children:s??e}),n.jsx(Ut,{value:e,label:t})]})}function Ut({value:e,label:t}){const[r,s]=c.useState("idle"),a=c.useRef(void 0);c.useEffect(()=>()=>clearTimeout(a.current),[]);const o=async()=>{const u=await Nt(e);s(u?"copied":"failed"),clearTimeout(a.current),a.current=setTimeout(()=>s("idle"),u?1500:5e3)};return n.jsxs(he.Root,{isOpen:r!=="idle",children:[n.jsx(P,{size:"sm",variant:"ghost",isIconOnly:!0,"aria-label":`Copy ${t}`,onPress:o,children:n.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-3.5 w-3.5","aria-hidden":"true",children:[n.jsx("rect",{x:"9",y:"9",width:"11",height:"11",rx:"2"}),n.jsx("path",{d:"M5 15V5a2 2 0 0 1 2-2h8",strokeLinecap:"round",strokeLinejoin:"round"})]})}),n.jsx(he.Content,{placement:"top",showArrow:!0,children:r==="failed"?"Copy blocked, select the value and press Ctrl/Cmd-C":"Copied!"})]})}function Br({title:e,description:t,actionLabel:r,onAction:s,isActionDisabled:a,children:o}){return n.jsx(I,{children:n.jsxs(I.Content,{className:"flex flex-col gap-4 p-6",children:[n.jsxs("div",{children:[n.jsx("h2",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:e}),t?n.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:t}):null]}),o,r&&s?n.jsx("div",{children:n.jsx(P,{variant:"primary",isDisabled:a,onPress:s,children:r})}):null]})})}function $r({label:e="Loading…"}){return n.jsxs("div",{role:"status",className:"flex items-center justify-center gap-2 px-4 py-10 text-sm text-[var(--otari-muted)]",children:[n.jsx(He,{size:"sm"}),n.jsx("span",{children:e})]})}function Qr({children:e,confirmLabel:t,onConfirm:r,isPending:s}){const[a,o]=c.useState(!1);return a?n.jsxs("span",{className:"inline-flex items-center gap-1",children:[n.jsx(P,{size:"sm",variant:"danger",isDisabled:s,onPress:r,children:t}),n.jsx(P,{size:"sm",variant:"ghost",isDisabled:s,onPress:()=>o(!1),children:"Cancel"})]}):n.jsx(P,{size:"sm",variant:"danger-soft",onPress:()=>o(!0),children:e})}const Ft="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 zr({id:e,label:t,ariaLabel:r,value:s,onChange:a,options:o,children:u,disabled:d}){const m=c.useId(),h=e??(t?m:void 0),y=n.jsx("select",{id:h,"aria-label":t?void 0:r,value:s,disabled:d,onChange:w=>a(w.target.value),className:Ft,children:o?o.map(w=>n.jsx("option",{value:w.value,children:w.label},w.value)):u});return t?n.jsxs("div",{className:"flex flex-col gap-1",children:[n.jsx("label",{htmlFor:h,className:"text-xs font-medium text-[var(--otari-muted)]",children:t}),y]}):y}function Vr({label:e,values:t,onChange:r,options:s,placeholder:a,maxVisible:o=50,maxValues:u=50,allowsCustom:d=!1}){const[m,h]=c.useState(""),y=t.length>=u,w=m.trim().toLowerCase(),v=s.filter(b=>!t.includes(b.value)).filter(b=>!w||b.value.toLowerCase().includes(w)||b.label.toLowerCase().includes(w)).slice(0,o),N=b=>{y||t.includes(b)||r([...t,b])},U=b=>{if(!d||b.key!=="Enter"||b.currentTarget.getAttribute("aria-activedescendant"))return;const $=m.trim();$&&(N($),h(""))};return n.jsxs(Q.Root,{allowsEmptyCollection:!0,allowsCustomValue:d,menuTrigger:"focus",inputValue:m,onInputChange:h,selectedKey:null,disabledKeys:y?v.map(b=>b.value):[],onSelectionChange:b=>{b!=null&&(N(String(b)),h(""))},className:"flex flex-col gap-1",children:[n.jsx(be,{className:"text-xs font-medium text-[var(--otari-muted)]",children:e}),n.jsxs(Q.InputGroup,{children:[n.jsx(we,{placeholder:t.length===0?a:`${t.length} selected${y?" (max)":""}`,autoComplete:"off",onKeyDown:U}),n.jsx(Q.Trigger,{})]}),n.jsx(Q.Popover,{children:n.jsx(We,{items:v,className:"max-h-72 overflow-auto",children:b=>n.jsx(Ge,{id:b.value,textValue:b.label,children:b.label})})})]})}function Kt(){var m,h;const e=xt(),t=gt(),[r,s]=c.useState(!1),o=((m=e.data)==null?void 0:m.require_pricing)===!0&&e.data.default_pricing===!1&&!r,d=((h=Pt(W,o).data)==null?void 0:h.total)??0;return o?n.jsx("div",{className:"shrink-0 px-6 pt-3",children:n.jsx(Ot,{tone:"warning",children:n.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[n.jsxs("span",{children:["Requests are rejected until pricing is set (",n.jsx("code",{children:"require_pricing"})," is on). Enable default pricing to meter new models with public rates right away.",d>0?n.jsxs(n.Fragment,{children:[" ",n.jsxs("strong",{className:"font-semibold",children:[d.toLocaleString()," ",d===1?"request":"requests"," failed in the last hour."]})," ",n.jsx($e,{to:"/activity?status=error&range=1h&source=gateway",className:"underline underline-offset-2",children:"View failed requests"})]}):null]}),n.jsxs("span",{className:"flex items-center gap-2",children:[n.jsx(P,{size:"sm",variant:"primary",isDisabled:t.isPending,onPress:()=>t.mutate({default_pricing:!0}),children:t.isPending?"Enabling…":"Enable default pricing"}),n.jsx(P,{size:"sm",variant:"ghost",onPress:()=>s(!0),children:"Dismiss"})]})]})})}):null}function Bt(){const{data:e}=yt(),t=c.useRef(null);return e&&t.current===null&&(t.current=e.build),e!=null&&t.current!=null&&e.build!==t.current}function $t(){const e=Bt(),[t,r]=c.useState(!1);return!e||t?null:n.jsx("div",{className:"pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center",children:n.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:[n.jsxs("span",{children:[n.jsx("strong",{className:"font-semibold",children:"An update is available."})," Reloading keeps you signed in."]}),n.jsx(P,{size:"sm",variant:"primary",onPress:()=>window.location.reload(),children:"Update now"}),n.jsx(P,{size:"sm",variant:"ghost",onPress:()=>r(!0),children:"Later"})]})})}const Te=200,Ne=480,te=240,Qt=60,_e="otari.dashboard.sidebarWidth",Le="otari.dashboard.sidebarCollapsed",ve=16,De="(max-width: 767px)",V=e=>Math.min(Ne,Math.max(Te,e));function zt(){return typeof window>"u"||typeof window.matchMedia!="function"?!1:window.matchMedia(De).matches}const Vt='a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';function Wt(e){return e?Array.from(e.querySelectorAll(Vt)).filter(t=>t.offsetParent!==null||t===document.activeElement):[]}function Gt(){if(typeof window>"u")return te;try{const e=window.localStorage.getItem(_e),t=e?Number.parseInt(e,10):Number.NaN;return Number.isNaN(t)?te:V(t)}catch{return te}}function Ht(){if(typeof window>"u")return!1;try{return window.localStorage.getItem(Le)==="1"}catch{return!1}}const Jt=[{key:"home"},{key:"observability",label:"Observability"},{key:"catalog",label:"Catalog"},{key:"access",label:"Access"},{key:"system"}],Yt=[{to:"/",section:"home",label:"Overview",end:!0,icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("rect",{x:"3.5",y:"3.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),n.jsx("rect",{x:"13.5",y:"3.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),n.jsx("rect",{x:"3.5",y:"13.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"}),n.jsx("rect",{x:"13.5",y:"13.5",width:"7",height:"7",rx:"1.5",strokeLinejoin:"round"})]})},{to:"/activity",section:"observability",label:"Activity",icon:n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:n.jsx("path",{d:"M3 12h4l2.5-6 4 12 2.5-6H21",strokeLinecap:"round",strokeLinejoin:"round"})})},{to:"/usage",section:"observability",label:"Usage",icon:n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:n.jsx("path",{d:"M4 20V10M10 20V4M16 20v-7M22 20H2",strokeLinecap:"round",strokeLinejoin:"round"})})},{to:"/providers",section:"catalog",label:"Providers",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("rect",{x:"3.5",y:"4.5",width:"17",height:"6",rx:"1.5",strokeLinejoin:"round"}),n.jsx("rect",{x:"3.5",y:"13.5",width:"17",height:"6",rx:"1.5",strokeLinejoin:"round"}),n.jsx("path",{d:"M7 7.5h.01M7 16.5h.01",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/users",section:"access",label:"Users",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("circle",{cx:"9",cy:"8",r:"3.2",strokeLinejoin:"round"}),n.jsx("path",{d:"M3.5 19a5.5 5.5 0 0 1 11 0",strokeLinecap:"round",strokeLinejoin:"round"}),n.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:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("circle",{cx:"7.5",cy:"15.5",r:"3.5"}),n.jsx("path",{d:"M10 13l7-7M14 5l3 3M16.5 7.5l2-2",strokeLinecap:"round",strokeLinejoin:"round"})]})},{to:"/budgets",section:"access",label:"Budgets",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.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"}),n.jsx("rect",{x:"3",y:"7.5",width:"18",height:"12",rx:"1.5",strokeLinejoin:"round"}),n.jsx("path",{d:"M16 13.5h.01",strokeLinecap:"round",strokeLinejoin:"round"}),n.jsx("path",{d:"M21 12v3h-3.5a1.5 1.5 0 0 1 0-3H21z",strokeLinejoin:"round"})]})},{to:"/models",section:"catalog",label:"Models",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("path",{d:"M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z",strokeLinejoin:"round"}),n.jsx("path",{d:"M12 12l8-4.5M12 12v9M12 12L4 7.5",strokeLinejoin:"round"})]})},{to:"/routing",section:"catalog",label:"Routing",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("path",{d:"M4 5h4l4 7 4-7h4",strokeLinejoin:"round"}),n.jsx("path",{d:"M4 19h4l4-7",strokeLinejoin:"round"}),n.jsx("circle",{cx:"19",cy:"19",r:"2"}),n.jsx("circle",{cx:"19",cy:"5",r:"2"})]})},{to:"/tools",section:"system",label:"Tools & Guardrails",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.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"}),n.jsx("path",{d:"M12 9 5 16",strokeLinecap:"round"})]})},{to:"/settings",section:"system",label:"Settings",icon:n.jsxs("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("circle",{cx:"12",cy:"12",r:"3"}),n.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 Xt(){const{logout:e}=ae(),t=c.useRef(null),r=c.useRef(null),s=c.useRef(null),[a,o]=c.useState(Gt),[u,d]=c.useState(Ht),[m,h]=c.useState(!1),[y,w]=c.useState(zt),[v,N]=c.useState(!1);c.useEffect(()=>{if(typeof window>"u"||typeof window.matchMedia!="function")return;const l=window.matchMedia(De),g=S=>{w(S.matches),S.matches||N(!1)};return typeof l.addEventListener=="function"?(l.addEventListener("change",g),()=>l.removeEventListener("change",g)):(l.addListener(g),()=>l.removeListener(g))},[]),c.useEffect(()=>{if(!v)return;const l=g=>{g.key==="Escape"&&N(!1)};return window.addEventListener("keydown",l),()=>window.removeEventListener("keydown",l)},[v]),c.useEffect(()=>{var l,g,S;y&&(v?(l=t.current)==null||l.focus():(g=t.current)!=null&&g.contains(document.activeElement)&&((S=s.current)==null||S.focus()))},[y,v]);const U=c.useCallback(l=>{if(l.key!=="Tab")return;const g=Wt(t.current);if(g.length===0)return;const S=g[0],E=g[g.length-1],F=document.activeElement;l.shiftKey&&(F===S||F===t.current)?(l.preventDefault(),E.focus()):!l.shiftKey&&F===E&&(l.preventDefault(),S.focus())},[]);c.useEffect(()=>{const l=window.setTimeout(()=>{try{window.localStorage.setItem(_e,String(Math.round(a)))}catch{}},200);return()=>window.clearTimeout(l)},[a]),c.useEffect(()=>{try{window.localStorage.setItem(Le,u?"1":"0")}catch{}},[u]);const b=c.useCallback(l=>{l.preventDefault(),l.currentTarget.setPointerCapture(l.pointerId),h(!0)},[]),$=c.useCallback(l=>{var S;if(!l.currentTarget.hasPointerCapture(l.pointerId))return;const g=((S=t.current)==null?void 0:S.getBoundingClientRect().left)??0;o(V(l.clientX-g))},[]),qe=c.useCallback(l=>{l.currentTarget.hasPointerCapture(l.pointerId)&&l.currentTarget.releasePointerCapture(l.pointerId),h(!1)},[]),Ae=c.useCallback(l=>{var g;l.preventDefault(),(g=r.current)==null||g.focus()},[]),Ie=c.useCallback(l=>{l.key==="ArrowLeft"?(l.preventDefault(),o(g=>V(g-ve))):l.key==="ArrowRight"&&(l.preventDefault(),o(g=>V(g+ve)))},[]),Oe=u?Qt:a,k=y?!1:u,J=y&&v?!0:void 0;return n.jsxs("div",{className:_("relative flex h-full flex-col overflow-hidden",m&&"cursor-col-resize select-none"),children:[n.jsx("button",{type:"button",inert:J,onClick:Ae,className:"sr-only focus:not-sr-only focus:absolute focus:top-3 focus:left-3 focus:z-50 focus:rounded-lg focus:border focus:border-[var(--otari-brand)] focus:bg-[var(--otari-surface)] focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-[var(--otari-brand-dark)] focus:shadow-md focus:outline-none",children:"Skip to main content"}),n.jsxs("header",{inert:J,className:"flex shrink-0 items-center justify-between border-b border-[var(--otari-line)] bg-[var(--otari-surface)] px-5 py-3",children:[n.jsxs("div",{className:"flex items-center gap-2.5",children:[n.jsx("button",{type:"button",ref:s,onClick:()=>N(l=>!l),"aria-label":v?"Close navigation":"Open navigation","aria-expanded":v,"aria-controls":"app-sidebar",className:"-ml-1 flex h-8 w-8 items-center justify-center rounded-lg text-[var(--otari-muted)] transition-colors hover:bg-[var(--otari-bg)] hover:text-[var(--otari-ink)] md:hidden",children:n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5",children:n.jsx("path",{d:"M4 6h16M4 12h16M4 18h16",strokeLinecap:"round",strokeLinejoin:"round"})})}),n.jsx("img",{src:"/favicon.svg",alt:"",className:"h-7 w-7 shrink-0"}),n.jsx("span",{className:"text-base font-semibold text-[var(--otari-ink)]",children:"Otari"})]}),n.jsx(P,{size:"sm",variant:"outline",onPress:e,"aria-label":"Sign out",children:"Sign out"})]}),n.jsx($t,{}),n.jsx(it,{}),n.jsx(Kt,{}),n.jsxs("div",{className:"flex min-h-0 flex-1",children:[y&&v?n.jsx("div",{"aria-hidden":"true",onClick:()=>N(!1),className:"fixed inset-0 z-30 bg-black/40 md:hidden"}):null,n.jsxs("aside",{ref:t,id:"app-sidebar",role:y?"dialog":void 0,"aria-modal":y&&v?!0:void 0,"aria-label":y?"Navigation":void 0,tabIndex:y?-1:void 0,inert:y&&!v?!0:void 0,onKeyDown:y&&v?U:void 0,style:y?void 0:{width:Oe},className:_("flex flex-col border-r border-[var(--otari-line)] bg-[var(--otari-surface)] focus:outline-none",y?_("fixed inset-y-0 left-0 z-40 w-[17rem] shadow-xl transition-transform duration-200",v?"translate-x-0":"-translate-x-full"):_("relative shrink-0",!m&&"transition-[width] duration-150")),children:[n.jsx("button",{type:"button",onClick:()=>d(l=>!l),"aria-label":u?"Expand sidebar":"Collapse sidebar","aria-pressed":u,title:u?"Expand sidebar":"Collapse sidebar",className:"absolute -right-3 top-4 z-30 hidden 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)] md:flex",children:n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",className:_("h-3.5 w-3.5 transition-transform",u&&"rotate-180"),children:n.jsx("path",{d:"M15 6l-6 6 6 6",strokeLinecap:"round",strokeLinejoin:"round"})})}),n.jsx("nav",{className:_("flex flex-col py-4",k?"px-2":"px-3"),children:Jt.map((l,g)=>{const S=Yt.filter(E=>E.section===l.key);return S.length===0?null:n.jsxs("div",{className:g>0?"mt-4":void 0,children:[!k&&l.label?n.jsx("div",{className:"px-3 pb-1 text-[11px] font-semibold tracking-wider text-[var(--otari-muted)] uppercase",children:l.label}):null,g>0&&(k||!l.label)?n.jsx("div",{className:"mx-1 mb-2 border-t border-[var(--otari-line)]"}):null,n.jsx("div",{className:"flex flex-col gap-1",children:S.map(E=>n.jsxs(ne,{to:E.to,end:E.end,onClick:()=>N(!1),"aria-label":k?E.label:void 0,title:k?E.label:void 0,className:({isActive:F})=>_("flex items-center rounded-lg py-2 text-sm font-medium transition-colors",k?"justify-center px-0":"gap-3 px-3",F?"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:[E.icon,k?null:E.label]},E.to))})]},l.key)})}),n.jsxs("div",{className:"mt-auto flex flex-col gap-1 pb-3",children:[n.jsxs(ne,{to:"/docs",onClick:()=>N(!1),"aria-label":k?"User guide":void 0,title:k?"User guide":void 0,className:({isActive:l})=>_("flex items-center rounded-lg py-2 text-sm font-medium transition-colors",k?"mx-2 justify-center px-0":"mx-3 gap-3 px-3",l?"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:[n.jsxs("svg",{"aria-hidden":"true",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-5 w-5 shrink-0",children:[n.jsx("path",{d:"M12 6.5C10.5 5 8 4.5 4 4.5V18c4 0 6.5.5 8 2 1.5-1.5 4-2 8-2V4.5c-4 0-6.5.5-8 2z",strokeLinejoin:"round"}),n.jsx("path",{d:"M12 6.5V20",strokeLinecap:"round"})]}),k?null:"User guide"]}),n.jsxs("a",{href:"https://otari.ai",target:"_blank",rel:"noreferrer",title:"otari.ai: the hosted Otari gateway",className:_("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)]",k?"mx-2 justify-center px-0":"mx-3 gap-2 px-3"),children:[n.jsx("svg",{"aria-hidden":!0,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"h-4 w-4 shrink-0",children:n.jsx("path",{d:"M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z",strokeLinejoin:"round"})}),k?null:n.jsxs("span",{className:"flex-1",children:["otari.ai ",n.jsx("span",{"aria-hidden":!0,children:"↗"})]})]})]}),u||y?null:n.jsx("div",{role:"separator","aria-orientation":"vertical","aria-label":"Resize sidebar","aria-valuenow":Math.round(a),"aria-valuemin":Te,"aria-valuemax":Ne,tabIndex:0,onPointerDown:b,onPointerMove:$,onPointerUp:qe,onKeyDown:Ie,className:_("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")})]}),n.jsx("main",{ref:r,id:"main-content",tabIndex:-1,inert:J,className:"flex-1 overflow-y-auto focus:outline-none",children:n.jsx("div",{className:"mx-auto flex max-w-[1800px] flex-col gap-6 px-4 py-5 md:px-6 md:py-6",children:n.jsx(Qe,{})})})]})]})}function Zt(){const{login:e}=ae(),[t,r]=c.useState(""),[s,a]=c.useState(null),[o,u]=c.useState(!1),d=async()=>{const m=t.trim();if(!(!m||o)){u(!0),a(null);try{await tt(m)?e():a(new Error("Invalid master key."))}catch(h){a(h)}finally{u(!1)}}};return n.jsx("div",{className:"flex min-h-full items-center justify-center p-6",children:n.jsx(I,{className:"w-full max-w-md",children:n.jsxs(I.Content,{className:"flex flex-col gap-5 p-7",children:[n.jsxs("div",{className:"flex flex-col items-center gap-3 text-center",children:[n.jsx("img",{src:"/favicon.svg",alt:"Otari",className:"h-12 w-12"}),n.jsxs("div",{children:[n.jsx("h1",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"Otari Dashboard"}),n.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."})]})]}),n.jsxs("form",{className:"flex flex-col gap-4",onSubmit:m=>{m.preventDefault(),d()},children:[n.jsxs(Je,{value:t,onChange:m=>{r(m),s&&a(null)},type:"password",isRequired:!0,className:"flex flex-col gap-1",children:[n.jsx(be,{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Master key"}),n.jsx(we,{placeholder:"otari-mk-… or your master key",autoFocus:!0,autoComplete:"off"})]}),n.jsxs("details",{className:"text-xs text-[var(--otari-muted)]",children:[n.jsx("summary",{className:"cursor-pointer font-medium text-[var(--otari-brand-dark)]",children:"First run? Where to find your key"}),n.jsxs("p",{className:"mt-2 leading-relaxed",children:["If you did not set ",n.jsx("code",{children:"OTARI_MASTER_KEY"}),", Otari generated one and printed it to the server logs on startup. Look for the line ",n.jsx("code",{children:"Your master key:"})," (for example, run"," ",n.jsx("code",{children:"docker logs "}),") and paste it above."]})]}),n.jsx(It,{error:s}),n.jsx(P,{type:"submit",variant:"primary",fullWidth:!0,isDisabled:!t.trim()||o,children:o?"Signing in…":"Sign in"})]}),n.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."}),n.jsx("div",{className:"border-t border-[var(--otari-line)] pt-4 text-center",children:n.jsx(Ye,{href:"/welcome",className:"text-sm font-medium text-[var(--otari-brand-dark)]",children:"New to Otari? Open the welcome guide"})})]})})})}const en=c.lazy(async()=>({default:(await T(async()=>{const{ActivityPage:e}=await import("./ActivityPage-fH63Z1Im.js");return{ActivityPage:e}},__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11]))).ActivityPage})),tn=c.lazy(async()=>({default:(await T(async()=>{const{RoutingPage:e}=await import("./RoutingPage-DELPbpkQ.js");return{RoutingPage:e}},__vite__mapDeps([12,1,2,8,5,13,11]))).RoutingPage})),nn=c.lazy(async()=>({default:(await T(async()=>{const{BudgetsPage:e}=await import("./BudgetsPage-C3eMHXLY.js");return{BudgetsPage:e}},__vite__mapDeps([14,1,2,6,5,7,8,11]))).BudgetsPage})),rn=c.lazy(async()=>({default:(await T(async()=>{const{DocsPage:e}=await import("./DocsPage-omWiBiUs.js");return{DocsPage:e}},__vite__mapDeps([15,1,2,5]))).DocsPage})),sn=c.lazy(async()=>({default:(await T(async()=>{const{KeysPage:e}=await import("./KeysPage-DvXgAgzE.js");return{KeysPage:e}},__vite__mapDeps([16,1,2,6,5,7,8,11,17,13]))).KeysPage})),an=c.lazy(async()=>({default:(await T(async()=>{const{ModelsPage:e}=await import("./ModelsPage-SJrMcme1.js");return{ModelsPage:e}},__vite__mapDeps([18,1,2,6,5,8,10,11]))).ModelsPage})),on=c.lazy(async()=>({default:(await T(async()=>{const{OverviewIndex:e}=await import("./OverviewPage-W9tjAThu.js");return{OverviewIndex:e}},__vite__mapDeps([19,1,2,3,4,5,8]))).OverviewIndex})),cn=c.lazy(async()=>({default:(await T(async()=>{const{ProvidersPage:e}=await import("./ProvidersPage-CkpZWNPU.js");return{ProvidersPage:e}},__vite__mapDeps([20,1,2,11,5,8]))).ProvidersPage})),ln=c.lazy(async()=>({default:(await T(async()=>{const{SettingsPage:e}=await import("./SettingsPage-CDU9M0qn.js");return{SettingsPage:e}},__vite__mapDeps([21,1,2,5]))).SettingsPage})),un=c.lazy(async()=>({default:(await T(async()=>{const{ToolsGuardrailsPage:e}=await import("./ToolsGuardrailsPage-oZnn27P0.js");return{ToolsGuardrailsPage:e}},__vite__mapDeps([22,1,2,5]))).ToolsGuardrailsPage})),dn=c.lazy(async()=>({default:(await T(async()=>{const{UsagePage:e}=await import("./UsagePage-Bt6OQ6El.js");return{UsagePage:e}},__vite__mapDeps([23,1,2,3,4,5,8,9]))).UsagePage})),mn=c.lazy(async()=>({default:(await T(async()=>{const{UsersPage:e}=await import("./UsersPage-CWfTsLqm.js");return{UsersPage:e}},__vite__mapDeps([24,1,2,6,5,7,8,11,17]))).UsersPage}));function C(e){return n.jsx(c.Suspense,{fallback:n.jsx("div",{role:"status",children:"Loading page…"}),children:e})}function fn(){const{isAuthenticated:e}=ae();return e?n.jsx(ze,{children:n.jsx(Ve,{children:n.jsxs(j,{element:n.jsx(Xt,{}),children:[n.jsx(j,{index:!0,element:C(n.jsx(on,{}))}),n.jsx(j,{path:"providers",element:C(n.jsx(cn,{}))}),n.jsx(j,{path:"keys",element:C(n.jsx(sn,{}))}),n.jsx(j,{path:"users",element:C(n.jsx(mn,{}))}),n.jsx(j,{path:"budgets",element:C(n.jsx(nn,{}))}),n.jsx(j,{path:"activity",element:C(n.jsx(en,{}))}),n.jsx(j,{path:"usage",element:C(n.jsx(dn,{}))}),n.jsx(j,{path:"models",element:C(n.jsx(an,{}))}),n.jsx(j,{path:"aliases",element:n.jsx(fe,{to:"/routing",replace:!0})}),n.jsx(j,{path:"routing",element:C(n.jsx(tn,{}))}),n.jsx(j,{path:"tools",element:C(n.jsx(un,{}))}),n.jsx(j,{path:"settings",element:C(n.jsx(ln,{}))}),n.jsx(j,{path:"docs",element:C(n.jsx(rn,{}))}),n.jsx(j,{path:"*",element:n.jsx(fe,{to:"/",replace:!0})})]})})}):n.jsx(Zt,{})}function hn({children:e}){const[t]=c.useState(()=>new Fe({defaultOptions:{queries:{refetchOnWindowFocus:!1,retry:(r,s)=>s instanceof R&&(s.status===401||s.status===403)?!1:r<2}}}));return n.jsx(Ke,{client:t,children:n.jsx(st,{children:e})})}const Re=document.getElementById("root");if(!Re)throw new Error("Root element #root not found");Xe.createRoot(Re).render(n.jsx(c.StrictMode,{children:n.jsx(hn,{children:n.jsx(fn,{})})}));export{At as $,jn as A,Br as B,Sn as C,Vn as D,It as E,zr as F,Hn as G,Xn as H,fr as I,yr as J,xr as K,gr as L,br as M,Ot as N,hr as O,Ur as P,ur as Q,Fr as R,dr as S,mr as T,lr as U,Nn as V,nr as W,$n as X,ct as Y,Dr as Z,sr as _,ut as a,Rr as a0,xt as a1,Lr as a2,Ln as a3,$r as a4,qn as a5,Ir as a6,Or as a7,qr as a8,Mr as a9,Ar as aA,wr as aB,vr as aC,_r as aa,Rt as ab,qt as ac,Pr as ad,In as ae,Un as af,Kn as ag,gt as ah,Mn as ai,An as aj,On as ak,Rn as al,Dn as am,Bn as an,ar as ao,or as ap,ir as aq,Yn as ar,Fn as as,Zn as at,er as au,tr as av,Nr as aw,R as ax,bn as ay,vn as az,Pn as b,cr as c,jr as d,Sr as e,Tn as f,Tr as g,kn as h,En as i,kr as j,Er as k,Cr as l,rr as m,wn as n,Vr as o,lt as p,Kr as q,Cn as r,Gn as s,_n as t,pr as u,zn as v,Qn as w,Wn as x,Jn as y,Qr as z}; diff --git a/src/gateway/static/dashboard/index.html b/src/gateway/static/dashboard/index.html index 59dce7af..e33c09ee 100644 --- a/src/gateway/static/dashboard/index.html +++ b/src/gateway/static/dashboard/index.html @@ -19,7 +19,7 @@ insets to sit out of the way. --> Otari Dashboard - + diff --git a/tests/conftest.py b/tests/conftest.py index 6c418fe3..8fc8c501 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import sys from collections.abc import Generator from pathlib import Path +from typing import Any import pytest @@ -12,6 +13,37 @@ del sys.modules["gateway"] +@pytest.fixture(autouse=True) +def _no_background_refresh(monkeypatch: pytest.MonkeyPatch) -> None: + """Stop the app lifespan from dialing providers and models.dev for real. + + Every ``TestClient(app)`` runs the lifespan, which starts the discovery and + catalog refreshers; their first act is to prime the cache from a live dial. + That is right in production and wrong in a test: it makes real outbound calls + from every app boot, and it races a test that patches the dial *after* + startup, so the read then serves whatever the unpatched prime cached. + + Suppressing the refreshers leaves the cache empty, so a read takes the + cold-provider path and dials once, under whatever the test has patched. + A test that wants the warm-cache read path seeds the cache itself. + + Lives in the root conftest, not the integration one, because the unit suite + builds apps too (``tests/unit/test_gateway_root_page.py``, + ``test_tools_endpoint.py``, ``test_settings_endpoint.py`` and others all use + ``TestClient(create_app(...))``). Scoping this to ``tests/integration`` left + every one of those making live models.dev fetches on CI, which is both wrong + on its own terms and what surfaced the unbounded-shutdown bug that + ``_stop_refresher`` now guards against. A test that wants the real refresher + calls it directly rather than through the lifespan. + """ + + async def _noop(*_args: Any, **_kwargs: Any) -> None: + return None + + monkeypatch.setattr("gateway.main.run_discovery_refresher", _noop) + monkeypatch.setattr("gateway.main.run_catalog_refresher", _noop) + + @pytest.fixture(autouse=True) def _reset_default_pricing() -> Generator[None, None, None]: """Restore process-wide pricing state to its default before each test. diff --git a/tests/integration/test_model_discovery.py b/tests/integration/test_model_discovery.py index 5980c827..6d91bbd9 100644 --- a/tests/integration/test_model_discovery.py +++ b/tests/integration/test_model_discovery.py @@ -890,3 +890,235 @@ def test_provider_health_requires_master_key( """The endpoint describes gateway config, so it is master-key gated.""" resp = two_provider_client.get("/v1/providers/health") assert resp.status_code in (401, 403) + + +# --------------------------------------------------------------------------- +# Background refresh: no read dials a provider on the request path +# --------------------------------------------------------------------------- + + +@pytest.fixture +def cached_discovery_client(postgres_url: str) -> Generator[TestClient]: + """A client on the production defaults, whose tests seed the cache themselves. + + Identical to ``discovery_client``; named apart because these tests assert + what the *read* path does once the cache is warm, and each seeds it. The + refresher that fills it in production is suppressed suite-wide by the + ``_no_background_refresh`` fixture in conftest. + """ + get_model_cache().clear() + yield from _make_client( + GatewayConfig( + database_url=postgres_url, + master_key="test-master-key", + host="127.0.0.1", + port=8000, + auto_migrate=False, + model_discovery=True, + model_cache_ttl_seconds=300, + providers={"openai": {"api_key": "sk-fake-for-test"}}, + ) + ) + + +def _warm_cache_with_expired_entry(model_id: str) -> None: + """Seed the discovery cache and backdate it well past model_cache_ttl_seconds.""" + from any_llm.types.model import Model + + cache = get_model_cache() + cache.set("openai", [Model(**_make_openai_model(model_id))]) + cache._store["openai"].cached_at -= 10_000 + + +def test_models_read_serves_an_expired_cache_without_dialing( + cached_discovery_client: TestClient, + discovery_master_header: dict[str, str], +) -> None: + """The fix: an expired entry is served, not re-dialed, on GET /v1/models. + + Before the background refresher, whichever request arrived after the TTL + lapsed paid ``model_discovery_timeout_seconds`` per unreachable provider and + held its database session open for the whole dial. That is what made an + operator's next click sit behind a page load. + """ + _warm_cache_with_expired_entry("gpt-4o") + + with patch( + "gateway.services.model_discovery_service.alist_models", + new_callable=AsyncMock, + ) as mock_alist: + resp = cached_discovery_client.get("/v1/models", headers=discovery_master_header) + + assert resp.status_code == 200 + assert "openai:gpt-4o" in [model["id"] for model in resp.json()["data"]] + mock_alist.assert_not_awaited() + + +def test_discoverable_read_serves_an_expired_cache_without_dialing( + cached_discovery_client: TestClient, + discovery_master_header: dict[str, str], +) -> None: + """Same for the operator listing, and it reports when it was last dialed.""" + _warm_cache_with_expired_entry("gpt-4o") + + with patch( + "gateway.services.model_discovery_service.alist_models", + new_callable=AsyncMock, + ) as mock_alist: + resp = cached_discovery_client.get("/v1/models/discoverable", headers=discovery_master_header) + + assert resp.status_code == 200 + provider = resp.json()["providers"][0] + assert provider["provider"] == "openai" + assert [model["id"] for model in provider["models"]] == ["gpt-4o"] + # The age of the answer is reported rather than implied, so a cached verdict + # is never mistaken for a live one. + assert provider["checked_at"] is not None + mock_alist.assert_not_awaited() + + +def test_discoverable_refresh_still_dials( + cached_discovery_client: TestClient, + discovery_master_header: dict[str, str], +) -> None: + """?refresh=true is the operator's escape hatch and must reach the provider.""" + from any_llm.types.model import Model + + _warm_cache_with_expired_entry("stale-model") + + with ( + patch("gateway.services.model_discovery_service._supports_list_models", return_value=True), + patch( + "gateway.services.model_discovery_service.alist_models", + new_callable=AsyncMock, + return_value=[Model(**_make_openai_model("fresh-model"))], + ) as mock_alist, + ): + resp = cached_discovery_client.get( + "/v1/models/discoverable?refresh=true", headers=discovery_master_header + ) + + assert resp.status_code == 200 + assert [model["id"] for model in resp.json()["providers"][0]["models"]] == ["fresh-model"] + assert mock_alist.await_count == 1 + + +def test_provider_health_read_serves_an_expired_cache_without_dialing( + cached_discovery_client: TestClient, + discovery_master_header: dict[str, str], +) -> None: + """The hourly health poll fans out over every provider; it must not dial.""" + _warm_cache_with_expired_entry("gpt-4o") + + with patch( + "gateway.services.model_discovery_service.alist_models", + new_callable=AsyncMock, + ) as mock_alist: + resp = cached_discovery_client.get("/v1/providers/health", headers=discovery_master_header) + + assert resp.status_code == 200 + body = resp.json() + assert body["healthy"] == 1 + assert body["total"] == 1 + mock_alist.assert_not_awaited() + + +@pytest.fixture +def uncached_discovery_client(postgres_url: str) -> Generator[TestClient]: + """A client with discovery caching disabled (`model_cache_ttl_seconds = 0`).""" + get_model_cache().clear() + yield from _make_client( + GatewayConfig( + database_url=postgres_url, + master_key="test-master-key", + host="127.0.0.1", + port=8000, + auto_migrate=False, + model_discovery=True, + model_cache_ttl_seconds=0, + providers={"openai": {"api_key": "sk-fake-for-test"}}, + ) + ) + + +def test_models_read_dials_when_caching_is_disabled( + uncached_discovery_client: TestClient, + discovery_master_header: dict[str, str], +) -> None: + """`model_cache_ttl_seconds = 0` still means dial on every read. + + It is the only switch for this, so it must not quietly become "serve a cache + nothing refreshes": a cached entry is ignored here, not served. + """ + from any_llm.types.model import Model + + _warm_cache_with_expired_entry("stale-model") + + with ( + patch("gateway.services.model_discovery_service._supports_list_models", return_value=True), + patch( + "gateway.services.model_discovery_service.alist_models", + new_callable=AsyncMock, + return_value=[Model(**_make_openai_model("fresh-model"))], + ) as mock_alist, + ): + resp = uncached_discovery_client.get("/v1/models", headers=discovery_master_header) + + assert resp.status_code == 200 + assert "openai:fresh-model" in [model["id"] for model in resp.json()["data"]] + assert mock_alist.await_count == 1 + + +def test_model_detail_agrees_with_the_listing_on_a_stale_entry( + cached_discovery_client: TestClient, + discovery_master_header: dict[str, str], +) -> None: + """GET /v1/models/{id} must not 404 a model GET /v1/models is listing. + + Nothing on the request path renews ``cached_at`` any more, and the refresher + sleeps its interval *after* each round, so an entry is expired from the moment + the next round starts until its dials finish. A TTL-bounded peek in the detail + endpoint would disagree with the listing for that whole window, for any + provider model with no pricing row and no genai-prices fallback. + """ + _warm_cache_with_expired_entry("gpt-4o") + + with patch( + "gateway.services.model_discovery_service.alist_models", + new_callable=AsyncMock, + ) as mock_alist: + listed = cached_discovery_client.get("/v1/models", headers=discovery_master_header) + detail = cached_discovery_client.get("/v1/models/openai:gpt-4o", headers=discovery_master_header) + + assert "openai:gpt-4o" in [model["id"] for model in listed.json()["data"]] + assert detail.status_code == 200 + assert detail.json()["id"] == "openai:gpt-4o" + # Neither read dialed: the detail endpoint never dials at all, and the + # listing serves the cache. + mock_alist.assert_not_awaited() + + +def test_model_detail_does_not_serve_a_cached_failure_as_a_model( + cached_discovery_client: TestClient, + discovery_master_header: dict[str, str], +) -> None: + """A negatively cached provider has no models to report, at any age.""" + from datetime import UTC, datetime + + from gateway.services.model_discovery_service import ProviderDiscovery, _CacheEntry + + cache = get_model_cache() + cache.clear() + cache._store["openai"] = _CacheEntry( + result=ProviderDiscovery(provider="openai", models=[], error="bad key"), + cached_at=0.0, + checked_at=datetime.now(UTC), + ) + + resp = cached_discovery_client.get("/v1/models/openai:never-seen", headers=discovery_master_header) + + # Falls through to the pricing/genai-prices answer rather than inventing a + # discovered model out of a failed dial. + assert resp.status_code in (200, 404) + if resp.status_code == 200: + assert resp.json().get("pricing_source") != "discovered" diff --git a/tests/integration/test_usage_endpoint.py b/tests/integration/test_usage_endpoint.py index 563de59b..70a6b865 100644 --- a/tests/integration/test_usage_endpoint.py +++ b/tests/integration/test_usage_endpoint.py @@ -8,7 +8,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from gateway.models.entities import UsageLog, User +from gateway.models.entities import APIKey, UsageLog, User USAGE_PATH = "/v1/usage" @@ -331,7 +331,12 @@ def test_list_usage_response_shape( assert data[0] == { "id": log.id, "user_id": "shape-user", + # Resolved from the joined user row, so a client can label a page of rows + # without holding the users table. The helper sets alias == user_id. + "user_alias": "shape-user", "api_key_id": None, + # Null because this row has no key at all: the fall-back-to-the-id case. + "api_key_name": None, "timestamp": timestamp.isoformat(), "model": "gpt-4o", "provider": "openai", @@ -538,3 +543,121 @@ def test_count_usage_empty_is_zero( response = client.get(f"{USAGE_PATH}/count", headers=master_key_header, params={"user_id": "nobody"}) assert response.status_code == 200 assert response.json() == {"total": 0} + + +# --------------------------------------------------------------------------- +# Row labels: naming a page of rows must not cost the whole users/keys table +# --------------------------------------------------------------------------- + + +def test_list_usage_labels_rows_from_the_joined_entities( + client: TestClient, + master_key_header: dict[str, str], + db_session: Session, +) -> None: + """A row carries its owner's alias and its key's name. + + Without these the dashboard had to page the entire users and api_keys tables + on every visit to Usage and Activity just to turn ids into names, which grows + with the deployment rather than with the page. + """ + db_session.add(User(user_id="labelled-user", alias="Ada Lovelace", spend=0.0, blocked=False)) + db_session.flush() + key = APIKey( + id=str(uuid.uuid4()), + key_hash=f"hash-{uuid.uuid4()}", + key_prefix="sk-test", + key_name="CI pipeline", + user_id="labelled-user", + ) + db_session.add(key) + db_session.flush() + _make_log( + db_session, + user_id="labelled-user", + timestamp=datetime(2025, 7, 2, 10, 0, tzinfo=UTC), + api_key_id=key.id, + ) + db_session.commit() + + response = client.get(USAGE_PATH, headers=master_key_header, params={"user_id": "labelled-user"}) + + assert response.status_code == 200 + row = response.json()[0] + assert row["user_alias"] == "Ada Lovelace" + assert row["api_key_name"] == "CI pipeline" + + +def test_list_usage_keeps_a_row_whose_entities_are_gone( + client: TestClient, + master_key_header: dict[str, str], + db_session: Session, +) -> None: + """The joins are outer: a row with no owner still comes back, unlabelled. + + Both foreign keys are ON DELETE SET NULL, so historical usage outlives the + user and key it was billed to. An inner join would silently drop exactly the + rows an operator most wants to see. + """ + _make_log( + db_session, + user_id="soon-deleted", + timestamp=datetime(2025, 7, 3, 10, 0, tzinfo=UTC), + api_key_id=None, + ) + db_session.commit() + db_session.query(UsageLog).filter(UsageLog.user_id == "soon-deleted").update({"user_id": None}) + db_session.commit() + + response = client.get(USAGE_PATH, headers=master_key_header) + + assert response.status_code == 200 + orphans = [row for row in response.json() if row["user_id"] is None] + assert len(orphans) == 1 + assert orphans[0]["user_alias"] is None + assert orphans[0]["api_key_name"] is None + + +def test_summary_breakdowns_carry_labels_for_opaque_keys( + client: TestClient, + master_key_header: dict[str, str], + db_session: Session, +) -> None: + """by_user and by_api_key name themselves, so a filter needs no table dump. + + This is what lets the dashboard's user and key pickers be built from the + breakdown (top N by spend, in-window) the way the model picker already is. + """ + db_session.add(User(user_id="summary-user", alias="Grace Hopper", spend=0.0, blocked=False)) + db_session.flush() + key = APIKey( + id=str(uuid.uuid4()), + key_hash=f"hash-{uuid.uuid4()}", + key_prefix="sk-test", + key_name="Nightly batch", + user_id="summary-user", + ) + db_session.add(key) + db_session.flush() + _make_log( + db_session, + user_id="summary-user", + timestamp=datetime.now(UTC) - timedelta(hours=1), + api_key_id=key.id, + ) + db_session.commit() + + response = client.get( + f"{USAGE_PATH}/summary", + headers=master_key_header, + params={"user_id": "summary-user", "dimensions": ["user", "api_key", "model"]}, + ) + + assert response.status_code == 200 + body = response.json() + user_row = next(r for r in body["by_user"] if r["key"] == "summary-user") + assert user_row["label"] == "Grace Hopper" + key_row = next(r for r in body["by_api_key"] if r["key"] == key.id) + assert key_row["label"] == "Nightly batch" + # A dimension whose key already reads as its own name carries no label. + assert all(row["label"] is None for row in body["by_model"]) diff --git a/tests/unit/test_gateway_lifespan_shutdown.py b/tests/unit/test_gateway_lifespan_shutdown.py new file mode 100644 index 00000000..696d37c0 --- /dev/null +++ b/tests/unit/test_gateway_lifespan_shutdown.py @@ -0,0 +1,155 @@ +"""Shutdown must not hang on a background refresher that will not stop. + +Cancelling a task is a request, not a guarantee. The CancelledError lands at +whatever the task is awaiting, and a nested anyio cancel scope there can consume +it: ``CancelScope.__exit__`` calls ``host_task.uncancel()`` for each pending +uncancellation whenever its own scope was cancelling, then swallows the error it +sees. httpx and the provider SDKs implement their per-operation timeouts as +exactly those scopes, so a shutdown cancel that races one of their timeouts can +be absorbed. The refresher loop then resumes, falls through to its ``sleep``, and +naps out a whole interval (a day, for the models.dev catalog). + +An unbounded ``await task`` after a single ``cancel()`` turns that into an +indefinite hang: the lifespan never finishes, so uvicorn's shutdown (and +``TestClient.__exit__``) blocks forever behind a background refresh. + +The tests below model the absorption directly rather than trying to provoke it +through a real socket, because the race needs degraded upstream connectivity to +land and is not reproducible on demand. +""" + +import asyncio +from pathlib import Path + +import pytest +from fastapi import FastAPI + +from gateway.core.config import GatewayConfig +from gateway.main import _REFRESHER_STOP_TIMEOUT_SECONDS, _create_lifespan, _stop_refresher, _stop_refreshers + + +async def _absorbs_cancellation() -> None: + """A refresher whose first cancellation is consumed, as a cancel scope would. + + ``uncancel()`` is what makes this faithful: without it the task would still be + marked cancelling and the next await would re-raise. With it, the task is back + to a normal state and settles in for a full interval. + """ + absorbed = False + while True: + try: + await asyncio.sleep(3600) # stands in for the outbound fetch + except asyncio.CancelledError: + if absorbed: + raise + task = asyncio.current_task() + assert task is not None + task.uncancel() + absorbed = True + # The refresher loop's own sleep, reached the same way it is after + # `except Exception` swallows what looked like a timeout error. + await asyncio.sleep(86400) + + +@pytest.mark.asyncio +async def test_stop_refresher_returns_when_the_task_absorbs_its_cancellation() -> None: + """The regression: shutdown gives up on a refresher instead of hanging.""" + task = asyncio.create_task(_absorbs_cancellation()) + await asyncio.sleep(0) # let it reach its first await + + started = asyncio.get_running_loop().time() + await asyncio.wait_for(_stop_refresher(task, "test"), timeout=_REFRESHER_STOP_TIMEOUT_SECONDS + 2) + elapsed = asyncio.get_running_loop().time() - started + + # It waited out the grace period rather than returning instantly, and it + # returned rather than blocking on a task that will never finish. + assert _REFRESHER_STOP_TIMEOUT_SECONDS <= elapsed < _REFRESHER_STOP_TIMEOUT_SECONDS + 2 + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_stop_refreshers_bounds_multiple_stuck_tasks_together() -> None: + """Several cancellation-resistant refreshers share one shutdown bound.""" + tasks = [asyncio.create_task(_absorbs_cancellation()) for _ in range(2)] + await asyncio.sleep(0) + + started = asyncio.get_running_loop().time() + await _stop_refreshers([(task, f"test-{index}") for index, task in enumerate(tasks)]) + elapsed = asyncio.get_running_loop().time() - started + + assert _REFRESHER_STOP_TIMEOUT_SECONDS <= elapsed < _REFRESHER_STOP_TIMEOUT_SECONDS + 2 + assert all(not task.done() for task in tasks) + for task in tasks: + task.cancel() + results = await asyncio.gather(*tasks, return_exceptions=True) + assert all(isinstance(result, asyncio.CancelledError) for result in results) + + +@pytest.mark.asyncio +async def test_stop_refreshers_allows_no_tasks() -> None: + """Hybrid mode has no local refreshers to stop.""" + await _stop_refreshers([]) + + +@pytest.mark.asyncio +async def test_stop_refresher_is_prompt_for_a_well_behaved_refresher() -> None: + """The normal path must stay instant; the bound is only a backstop.""" + + async def cooperative() -> None: + await asyncio.sleep(3600) + + task = asyncio.create_task(cooperative()) + await asyncio.sleep(0) + + started = asyncio.get_running_loop().time() + await _stop_refresher(task, "test") + + assert asyncio.get_running_loop().time() - started < 1.0 + assert task.cancelled() + + +@pytest.mark.asyncio +async def test_stop_refresher_logs_an_unexpected_error_instead_of_raising() -> None: + """A refresher that died must not abort the rest of shutdown. + + The log writer and the pooled search client are closed after the refreshers, + so an exception escaping here would leak both. + """ + + async def explodes() -> None: + raise RuntimeError("refresher blew up") + + task = asyncio.create_task(explodes()) + await asyncio.sleep(0) + + await _stop_refresher(task, "test") # must not raise + + assert task.done() + + +@pytest.mark.asyncio +async def test_lifespan_shutdown_completes_despite_a_stuck_refresher( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """End to end: the app finishes shutting down even if a refresher hangs. + + This is the shape that failed on CI, where a models.dev fetch absorbed the + shutdown cancel and `TestClient.__exit__` blocked until the suite's 120s + timeout killed it. + """ + monkeypatch.setattr("gateway.main.run_catalog_refresher", lambda *_a, **_k: _absorbs_cancellation()) + config = GatewayConfig( + database_url=f"sqlite:///{tmp_path / 'lifespan.db'}", + master_key="sk-test-master", + ) + lifespan = _create_lifespan(config) + + # No asyncio.timeout wrapper: if shutdown regresses this hangs, and the + # suite-wide pytest timeout reports it. A short bound here would be + # indistinguishable from the fix under test. + async with lifespan(FastAPI()): + pass diff --git a/tests/unit/test_gateway_model_discovery.py b/tests/unit/test_gateway_model_discovery.py index 8a7d2dfa..29938aee 100644 --- a/tests/unit/test_gateway_model_discovery.py +++ b/tests/unit/test_gateway_model_discovery.py @@ -2,6 +2,8 @@ import asyncio import time +from collections.abc import Callable +from datetime import UTC, datetime from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -13,14 +15,20 @@ from gateway.core.config import GatewayConfig from gateway.services.model_discovery_service import ( _ERROR_MAX_CHARS, + _MIN_REFRESH_INTERVAL_SECONDS, ModelCache, ProviderDiscovery, + _CacheEntry, _is_missing_models_endpoint, + _refresh_interval, _short_error, _supports_list_models, + background_discovery_enabled, discover_all_models, discover_models_with_status, discover_provider_models, + refresh_discovery_cache, + run_discovery_refresher, ) from gateway.services.model_discovery_service import ( test_provider_credentials as run_credentials_test, # aliased so pytest does not collect it @@ -987,3 +995,280 @@ def test_only_missing_endpoint_statuses_classify(self) -> None: assert _is_missing_models_endpoint(self._status_error(501)) is True assert _is_missing_models_endpoint(self._status_error(500)) is False assert _is_missing_models_endpoint(ValueError("no status here")) is False + + +# --------------------------------------------------------------------------- +# Background refresh: reads serve the cache, the refresher owns the dialing +# --------------------------------------------------------------------------- + + +class TestBackgroundDiscovery: + """Discovery must not be dialed on the request path. + + ``model_discovery_timeout_seconds`` (10s per unreachable provider) used to be + paid by whoever's read happened to arrive after the TTL lapsed, which put it + on a dashboard page load. These pin the read/refresh split that removed that. + """ + + @staticmethod + def _config(ttl: int = 300) -> GatewayConfig: + return GatewayConfig(providers={"openai": {"api_key": "sk-test"}}, model_cache_ttl_seconds=ttl) + + @pytest.mark.asyncio + async def test_stale_entry_is_served_without_dialing(self) -> None: + """The regression: an expired entry is served, not re-dialed, on a read.""" + config = self._config() + cache = ModelCache() + cache.set("openai", [_make_model("gpt-4o")]) + # Age the entry well past the 300s TTL, which is what used to force the + # next reader to pay the provider timeout. + cache._store["openai"].cached_at = time.monotonic() - 10_000 + + with ( + patch("gateway.services.model_discovery_service.get_model_cache", return_value=cache), + patch("gateway.services.model_discovery_service.alist_models") as mock_alist, + ): + result = await discover_all_models(config, serve_stale=True) + + assert [model.id for _, model in result] == ["gpt-4o"] + mock_alist.assert_not_called() + + @pytest.mark.asyncio + async def test_serve_stale_still_dials_a_provider_never_checked(self) -> None: + """A cold worker must dial rather than claim the provider has no models.""" + config = self._config() + cache = ModelCache() + + with ( + patch("gateway.services.model_discovery_service.get_model_cache", return_value=cache), + patch("gateway.services.model_discovery_service._supports_list_models", return_value=True), + patch( + "gateway.services.model_discovery_service.alist_models", + new=AsyncMock(return_value=[_make_model("gpt-4o")]), + ) as mock_alist, + ): + result = await discover_all_models(config, serve_stale=True) + + assert [model.id for _, model in result] == ["gpt-4o"] + assert mock_alist.await_count == 1 + + @pytest.mark.asyncio + async def test_stale_read_does_not_hide_a_cached_failure(self) -> None: + """A negatively cached provider stays failed; it must not look healthy.""" + config = self._config() + cache = ModelCache() + cache._store["openai"] = _CacheEntry( + result=ProviderDiscovery(provider="openai", models=[], error="bad key"), + cached_at=time.monotonic() - 10_000, + checked_at=datetime.now(UTC), + ) + + with ( + patch("gateway.services.model_discovery_service.get_model_cache", return_value=cache), + patch("gateway.services.model_discovery_service.alist_models") as mock_alist, + ): + discovery = await discover_provider_models(config, "openai", serve_stale=True) + + assert discovery.error == "bad key" + mock_alist.assert_not_called() + + @pytest.mark.asyncio + async def test_force_redials_a_fresh_entry(self) -> None: + """The refresher's own read ignores freshness, or the cache never moves.""" + config = self._config() + cache = ModelCache() + cache.set("openai", [_make_model("stale-model")]) + + with ( + patch("gateway.services.model_discovery_service.get_model_cache", return_value=cache), + patch("gateway.services.model_discovery_service._supports_list_models", return_value=True), + patch( + "gateway.services.model_discovery_service.alist_models", + new=AsyncMock(return_value=[_make_model("fresh-model")]), + ) as mock_alist, + ): + discoveries = await discover_models_with_status(config, force=True) + + assert mock_alist.await_count == 1 + assert [model.id for model in discoveries[0].models] == ["fresh-model"] + # And the refreshed result is what a later stale read serves. + assert [model.id for model in (cache.stale("openai") or ProviderDiscovery("openai", [])).models] == [ + "fresh-model" + ] + + @staticmethod + async def _run_refresher_until( + config: GatewayConfig, + on_call: "Callable[[int], None]", + rounds: int, + ) -> int: + """Drive the refresher until ``rounds`` calls land, then cancel it. + + Waits on an event rather than a wall-clock sleep: the failure path logs a + full traceback, and rendering that can outlast any fixed window, which + would make a timing-based assertion flaky under load. + """ + seen = 0 + reached = asyncio.Event() + + async def refresh(cfg: GatewayConfig) -> None: + nonlocal seen + seen += 1 + on_call(seen) + if seen >= rounds: + reached.set() + + with patch("gateway.services.model_discovery_service.refresh_discovery_cache", side_effect=refresh): + task = asyncio.create_task(run_discovery_refresher(config, interval=0.001)) + try: + await asyncio.wait_for(reached.wait(), timeout=5) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + return seen + + @pytest.mark.asyncio + async def test_refresher_primes_immediately_then_ticks(self) -> None: + """Priming is the first thing the refresher does, before any sleep.""" + first_call_delay: list[float] = [] + started = time.monotonic() + + def record(_: int) -> None: + first_call_delay.append(time.monotonic() - started) + + seen = await self._run_refresher_until(self._config(), record, rounds=2) + + assert seen == 2 + # The prime lands before the first sleep, so the catalog is warm without + # waiting out an interval after boot. + assert first_call_delay[0] < 1.0 + + @pytest.mark.asyncio + async def test_refresher_survives_a_failing_round(self) -> None: + """One bad round must not kill the refresher and freeze the catalog.""" + + def blow_up_once(call: int) -> None: + if call == 1: + raise RuntimeError("provider fanout blew up") + + seen = await self._run_refresher_until(self._config(), blow_up_once, rounds=2) + + assert seen == 2 + + def test_zero_ttl_keeps_dialing_on_every_read(self) -> None: + """`model_cache_ttl_seconds = 0` documents "no caching"; honor it.""" + assert background_discovery_enabled(self._config(ttl=0)) is False + assert background_discovery_enabled(self._config(ttl=300)) is True + + def test_refresh_interval_has_a_floor(self) -> None: + """A tiny TTL must not become a re-dial storm against every provider.""" + assert _refresh_interval(self._config(ttl=1)) == _MIN_REFRESH_INTERVAL_SECONDS + assert _refresh_interval(self._config(ttl=600)) == 600.0 + + @pytest.mark.asyncio + async def test_refresher_skips_dialing_while_caching_is_off_but_keeps_ticking(self) -> None: + """The setting is read per tick, not once at startup. + + ``model_cache_ttl_seconds`` is runtime-settable from the Settings page, and + raising it from 0 flips every read onto the serve-from-cache path at once. + A refresher that had been skipped at startup would leave that cache filled + once and never refreshed for the life of the worker. So the loop always + runs and decides per tick: no dial while caching is off (the reads dial for + themselves then, and a second dialer would be pure duplication), and it + picks straight back up when the setting changes. + """ + config = self._config(ttl=0) + rounds = 0 + reached = asyncio.Event() + + async def refresh(_cfg: GatewayConfig) -> None: + nonlocal rounds + rounds += 1 + reached.set() + + with patch("gateway.services.model_discovery_service.refresh_discovery_cache", side_effect=refresh): + task = asyncio.create_task(run_discovery_refresher(config, interval=0.001)) + try: + # Several ticks' worth of time, with caching off the whole way. + await asyncio.sleep(0.05) + assert rounds == 0, "refresher dialed while model_cache_ttl_seconds was 0" + + # An operator turns caching on; the running loop must notice. + config.model_cache_ttl_seconds = 300 + await asyncio.wait_for(reached.wait(), timeout=5) + assert rounds >= 1 + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +class TestRefresherCadence: + """A failed provider must not stay failed for a whole success interval. + + Reads serve a cached failure at any age, so the refresh interval is what + decides how quickly a recovered provider reappears. Before the refresher + existed, ``model_discovery_negative_ttl_seconds`` bounded that at 30s via the + next read's re-dial; the refresher has to keep that promise. + """ + + @staticmethod + def _config(ttl: int = 300, negative_ttl: float = 30.0, discovery: bool = True) -> GatewayConfig: + return GatewayConfig( + providers={"openai": {"api_key": "sk-test"}}, + model_cache_ttl_seconds=ttl, + model_discovery_negative_ttl_seconds=negative_ttl, + model_discovery=discovery, + ) + + def test_a_failed_round_comes_back_on_the_negative_ttl(self) -> None: + config = self._config(ttl=300, negative_ttl=30.0) + assert _refresh_interval(config, had_failure=False) == 300.0 + assert _refresh_interval(config, had_failure=True) == 30.0 + + def test_the_floor_still_applies_to_a_failed_round(self) -> None: + """A tiny negative TTL must not become a re-dial storm.""" + config = self._config(ttl=300, negative_ttl=1.0) + assert _refresh_interval(config, had_failure=True) == _MIN_REFRESH_INTERVAL_SECONDS + + def test_a_failure_never_lengthens_the_interval(self) -> None: + """A negative TTL above the success TTL must not slow recovery down.""" + config = self._config(ttl=600, negative_ttl=3600.0) + assert _refresh_interval(config, had_failure=True) == 600.0 + + @pytest.mark.asyncio + async def test_refresh_reports_whether_a_provider_failed(self) -> None: + config = self._config() + cache = ModelCache() + + with ( + patch("gateway.services.model_discovery_service.get_model_cache", return_value=cache), + patch("gateway.services.model_discovery_service._supports_list_models", return_value=True), + patch( + "gateway.services.model_discovery_service.alist_models", + new=AsyncMock(side_effect=RuntimeError("provider down")), + ), + ): + assert await refresh_discovery_cache(config) is True + + cache.clear() + with ( + patch("gateway.services.model_discovery_service.get_model_cache", return_value=cache), + patch("gateway.services.model_discovery_service._supports_list_models", return_value=True), + patch( + "gateway.services.model_discovery_service.alist_models", + new=AsyncMock(return_value=[_make_model("gpt-4o")]), + ), + ): + assert await refresh_discovery_cache(config) is False + + def test_discovery_disabled_stops_the_background_dialing_too(self) -> None: + """`model_discovery: false` means "do not dial providers", unattended included. + + A refresher fanning out every interval for the life of the process is new + unrequested traffic against a provider that may meter list_models, on a + deployment that explicitly opted out. + """ + assert background_discovery_enabled(self._config(discovery=False)) is False + assert background_discovery_enabled(self._config(discovery=True)) is True diff --git a/tests/unit/test_model_catalog_service.py b/tests/unit/test_model_catalog_service.py index e48ee470..594720e2 100644 --- a/tests/unit/test_model_catalog_service.py +++ b/tests/unit/test_model_catalog_service.py @@ -1,6 +1,8 @@ """Unit tests for the models.dev catalog parsing and mapping (no network).""" +import time from typing import Any +from unittest.mock import AsyncMock, patch import pytest @@ -94,3 +96,70 @@ def test_build_metadata_map_skips_unknown_providers() -> None: def test_build_metadata_map_empty_when_catalog_missing() -> None: config = _config({"openai": {"api_key": "sk-x"}}) assert build_metadata_map(config, None) == {} + + +@pytest.mark.asyncio +async def test_stale_catalog_is_served_without_refetching() -> None: + """A dashboard read must not pay the 15s models.dev fetch timeout.""" + mcs.clear_catalog_cache() + config = GatewayConfig(models_dev_metadata=True, models_dev_cache_ttl_seconds=86400) + + with patch.object(mcs, "_fetch", new=AsyncMock(return_value={"openai": {}})) as fetch: + await mcs.load_models_dev_catalog(config, force=True) + assert fetch.await_count == 1 + # Age the entry past its TTL; a stale read still answers from cache. + mcs._cache.at = time.monotonic() - 200_000 + result = await mcs.load_models_dev_catalog(config, serve_stale=True) + assert fetch.await_count == 1 + + assert result == {"openai": {}} + + +@pytest.mark.asyncio +async def test_stale_read_without_a_cache_entry_still_fetches() -> None: + """A cold worker fetches rather than reporting metadata unavailable.""" + mcs.clear_catalog_cache() + config = GatewayConfig(models_dev_metadata=True, models_dev_cache_ttl_seconds=86400) + + with patch.object(mcs, "_fetch", new=AsyncMock(return_value={"openai": {}})) as fetch: + result = await mcs.load_models_dev_catalog(config, serve_stale=True) + + assert fetch.await_count == 1 + assert result == {"openai": {}} + + +@pytest.mark.asyncio +async def test_stale_read_retries_a_failed_fetch_on_the_negative_ttl() -> None: + """A failed fetch must not be pinned for the refresher's whole interval. + + The refresh cadence is the *success* cadence (``models_dev_cache_ttl_seconds``, + a day by default). Serving a failure at any age would leave the dashboard + without enrichment until the next tick, and ``/v1/models/metadata`` has no + ``refresh`` flag to escape it. The 60s negative TTL still governs a failure. + """ + mcs.clear_catalog_cache() + config = GatewayConfig(models_dev_metadata=True, models_dev_cache_ttl_seconds=86400) + + with patch.object(mcs, "_fetch", new=AsyncMock(return_value=None)) as failing: + assert await mcs.load_models_dev_catalog(config, force=True) is None + assert failing.await_count == 1 + + with patch.object(mcs, "_fetch", new=AsyncMock(return_value={"openai": {}})) as recovered: + # Inside the negative TTL the failure is still served, as before. + assert await mcs.load_models_dev_catalog(config, serve_stale=True) is None + assert recovered.await_count == 0 + # Past it, a read retries rather than waiting out the refresh interval. + mcs._cache.at = time.monotonic() - (mcs._NEGATIVE_TTL_SECONDS + 1) + assert await mcs.load_models_dev_catalog(config, serve_stale=True) == {"openai": {}} + assert recovered.await_count == 1 + + +def test_background_catalog_requires_caching_and_enrichment() -> None: + """Both existing knobs still mean what they say; there is no third one.""" + assert mcs.background_catalog_enabled(GatewayConfig(models_dev_metadata=False)) is False + assert ( + mcs.background_catalog_enabled(GatewayConfig(models_dev_metadata=True, models_dev_cache_ttl_seconds=0)) is False + ) + assert ( + mcs.background_catalog_enabled(GatewayConfig(models_dev_metadata=True, models_dev_cache_ttl_seconds=60)) is True + ) diff --git a/tests/unit/test_provider_health_service.py b/tests/unit/test_provider_health_service.py index a0c7142e..c97b4cf0 100644 --- a/tests/unit/test_provider_health_service.py +++ b/tests/unit/test_provider_health_service.py @@ -33,7 +33,7 @@ async def test_healthy_provider_reports_model_count_and_checked_at() -> None: get_model_cache().clear() config = _config({"openai": {"api_key": "x"}}) - async def discover(cfg: GatewayConfig, instance: str) -> ProviderDiscovery: + async def discover(cfg: GatewayConfig, instance: str, *, serve_stale: bool = False) -> ProviderDiscovery: get_model_cache().set(instance, [_model("gpt-4o"), _model("gpt-4o-mini")]) return ProviderDiscovery(provider=instance, models=[_model("gpt-4o"), _model("gpt-4o-mini")]) @@ -52,7 +52,7 @@ async def test_unreachable_provider_is_unhealthy_and_keeps_error() -> None: get_model_cache().clear() config = _config({"anthropic": {"api_key": "x"}}) - async def discover(cfg: GatewayConfig, instance: str) -> ProviderDiscovery: + async def discover(cfg: GatewayConfig, instance: str, *, serve_stale: bool = False) -> ProviderDiscovery: return ProviderDiscovery(provider=instance, models=[], error="authentication failed") with patch.object(phs, "discover_provider_models", side_effect=discover): @@ -70,7 +70,7 @@ async def test_provider_without_a_models_endpoint_is_flagged_not_just_unhealthy( get_model_cache().clear() config = _config({"otari": {"api_key": "x"}}) - async def discover(cfg: GatewayConfig, instance: str) -> ProviderDiscovery: + async def discover(cfg: GatewayConfig, instance: str, *, serve_stale: bool = False) -> ProviderDiscovery: return ProviderDiscovery( provider=instance, models=[], @@ -99,7 +99,7 @@ def spy_clear(instance: str | None = None) -> None: yield cleared -async def _noop_discover(cfg: GatewayConfig, instance: str) -> ProviderDiscovery: +async def _noop_discover(cfg: GatewayConfig, instance: str, *, serve_stale: bool = False) -> ProviderDiscovery: return ProviderDiscovery(provider=instance, models=[_model("gpt-4o")]) @@ -159,7 +159,7 @@ async def test_check_all_fans_out_and_summarizes() -> None: get_model_cache().clear() config = _config({"openai": {"api_key": "x"}, "anthropic": {"api_key": "y"}}) - async def discover(cfg: GatewayConfig, instance: str) -> ProviderDiscovery: + async def discover(cfg: GatewayConfig, instance: str, *, serve_stale: bool = False) -> ProviderDiscovery: if instance == "anthropic": return ProviderDiscovery(provider=instance, models=[], error="boom") return ProviderDiscovery(provider=instance, models=[_model("gpt-4o")]) @@ -180,7 +180,7 @@ async def test_check_all_surfaces_a_stray_exception_without_sinking_others() -> get_model_cache().clear() config = _config({"good": {"api_key": "x"}, "bad": {"api_key": "y"}}) - async def discover(cfg: GatewayConfig, instance: str) -> ProviderDiscovery: + async def discover(cfg: GatewayConfig, instance: str, *, serve_stale: bool = False) -> ProviderDiscovery: if instance == "bad": raise RuntimeError("unexpected") return ProviderDiscovery(provider=instance, models=[_model("gpt-4o")]) @@ -192,3 +192,43 @@ async def discover(cfg: GatewayConfig, instance: str) -> ProviderDiscovery: assert by_instance["good"].ok is True assert by_instance["bad"].ok is False assert by_instance["bad"].error is not None + + +@pytest.mark.asyncio +async def test_polled_health_serves_the_cache_instead_of_dialing() -> None: + """The hourly poll must not be what pays an unreachable provider's timeout. + + Health fans out over every configured instance, so a dial here costs + ``model_discovery_timeout_seconds`` per unreachable provider and holds the + request open for it. The background discovery refresher owns the dialing. + """ + get_model_cache().clear() + config = _config({"openai": {"api_key": "x"}}) + seen: list[bool] = [] + + async def discover(cfg: GatewayConfig, instance: str, *, serve_stale: bool = False) -> ProviderDiscovery: + seen.append(serve_stale) + return ProviderDiscovery(provider=instance, models=[_model("gpt-4o")]) + + with patch.object(phs, "discover_provider_models", side_effect=discover): + await phs.check_all_provider_health(config, serve_stale=True) + + assert seen == [True] + + +@pytest.mark.asyncio +async def test_explicit_recheck_still_dials() -> None: + """An explicit "re-check now" is an operator asking for a fresh probe; honor it.""" + get_model_cache().clear() + config = _config({"openai": {"api_key": "x"}}) + seen: list[bool] = [] + + async def discover(cfg: GatewayConfig, instance: str, *, serve_stale: bool = False) -> ProviderDiscovery: + seen.append(serve_stale) + return ProviderDiscovery(provider=instance, models=[_model("gpt-4o")]) + + with patch.object(phs, "discover_provider_models", side_effect=discover): + await phs.check_all_provider_health(config, refresh=True, serve_stale=True) + + # refresh wins over serve_stale, or the button would return the cached verdict. + assert seen == [False] diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 00000000..1a4d5180 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ApiError, apiFetch } from "./client"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("apiFetch", () => { + it("bounds a request that never settles", async () => { + // A hung request holds one of the browser's ~6 sockets per origin. Enough of + // them and everything an operator clicks afterwards queues behind them, which + // reads as the click doing nothing. The deadline is ours, not the server's. + vi.useFakeTimers(); + try { + vi.spyOn(globalThis, "fetch").mockImplementation( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("timed out", "TimeoutError")); + }); + }), + ); + + const pending = apiFetch("/v1/models"); + const assertion = expect(pending).rejects.toMatchObject({ + status: 0, + message: expect.stringContaining("did not respond within 30s"), + }); + await vi.advanceTimersByTimeAsync(30_000); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it("bounds a response whose body stalls after the headers arrive", async () => { + // fetch() resolves on headers, so a stalled body trips the deadline on the + // JSON read rather than on the fetch. Callers only handle ApiError. + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + status: 200, + ok: true, + json: () => Promise.reject(new DOMException("timed out", "TimeoutError")), + } as unknown as Response); + + await expect(apiFetch("/v1/models")).rejects.toBeInstanceOf(ApiError); + await expect(apiFetch("/v1/models")).rejects.toMatchObject({ + status: 0, + message: expect.stringContaining("did not respond within 30s"), + }); + }); + + it("passes a caller's signal through instead of imposing its own", async () => { + const controller = new AbortController(); + const seen: (AbortSignal | null | undefined)[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + seen.push(init?.signal); + return Promise.resolve(new Response("{}", { status: 200 })); + }); + + await apiFetch("/v1/models", { signal: controller.signal }); + + expect(seen[0]).toBe(controller.signal); + }); + + it("does not quote its own 30s deadline at a caller that set a longer one", async () => { + // The bulk usage delete and reprice run on longRequestSignal(); telling an + // operator who waited five minutes that nothing answered "within 30s" would + // point them at the wrong thing. + const controller = new AbortController(); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("timed out", "TimeoutError")); + + await expect(apiFetch("/v1/usage", { signal: controller.signal })).rejects.toMatchObject({ + status: 0, + message: "The gateway did not respond in time.", + }); + }); + + it("reports an unreachable gateway differently from a timeout", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); + + await expect(apiFetch("/v1/models")).rejects.toBeInstanceOf(ApiError); + await expect(apiFetch("/v1/models")).rejects.toMatchObject({ + message: expect.stringContaining("could not reach the gateway"), + }); + }); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5dfd35b7..5b9c4eee 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -73,17 +73,59 @@ export async function deleteSession(): Promise { } } +// Upper bound on any single management call. Nothing here should take this +// long: the gateway bounds its own provider fan-out well below it. The point is +// that a request which hangs anyway (dead socket, stalled proxy) gives its +// browser connection slot back on a deadline we control instead of holding it +// open. On HTTP/1.1 a browser allows only ~6 sockets per origin, so a handful of +// hung requests is enough to queue everything an operator clicks afterwards. +// Callers pass their own `signal` to override. +const REQUEST_TIMEOUT_MS = 30_000; +const TIMEOUT_MESSAGE = `The gateway did not respond within ${REQUEST_TIMEOUT_MS / 1000}s.`; + +// For the handful of calls whose work scales with the data rather than with one +// upstream hop: the bulk usage delete and reprice, and the pricing-snapshot +// refresh. `DELETE /v1/usage` with `by_filter` issues one unbounded DELETE and +// the reprice loops over every matched row, so on a large imported-usage table +// either can outrun the 30s bound above. Aborting them is worse than waiting: the +// server transaction commits regardless of whether the browser is still +// listening, so the operator would be told the delete failed when it succeeded, +// and the obvious next move is to run it again. Still bounded, because a socket +// held forever is what the deadline exists to prevent. +export const LONG_REQUEST_TIMEOUT_MS = 5 * 60_000; + +/** Signal for a request whose duration scales with the data, not with one hop. */ +export function longRequestSignal(): AbortSignal { + return AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS); +} + +// A TimeoutError from AbortSignal.timeout means we gave up, not that the gateway +// is unreachable; saying so points at the right thing to look at. It can surface +// from either await: fetch() resolves once headers arrive, so a body that then +// stalls trips the same deadline on the JSON read instead. +function isTimeout(error: unknown): boolean { + return error instanceof DOMException && error.name === "TimeoutError"; +} + 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"); } + const signal = init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS); + // Only name the deadline when it is ours; a caller-supplied signal has its own + // budget, and quoting 30s at an operator who waited five minutes is worse than + // saying nothing. + const timeoutMessage = init.signal ? "The gateway did not respond in time." : TIMEOUT_MESSAGE; let response: Response; try { - response = await fetch(path, { ...init, headers }); - } catch { + response = await fetch(path, { ...init, headers, signal }); + } catch (error) { + if (isTimeout(error)) { + throw new ApiError(0, timeoutMessage); + } throw new ApiError(0, "Network error: could not reach the gateway."); } @@ -102,5 +144,14 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise return undefined as T; } - return (await response.json()) as T; + try { + return (await response.json()) as T; + } catch (error) { + // Every caller expects an ApiError; a raw DOMException here would reach the + // UI as an unrecognized failure. A malformed body is still its own error. + if (isTimeout(error)) { + throw new ApiError(0, timeoutMessage); + } + throw error; + } } diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts index e47cdd61..1d951aed 100644 --- a/web/src/api/hooks.ts +++ b/web/src/api/hooks.ts @@ -1,6 +1,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ApiError, apiFetch } from "@/api/client"; +import { ApiError, apiFetch, longRequestSignal } from "@/api/client"; import { isoAgo } from "@/lib/timeRange"; import type { AliasResponse, @@ -90,8 +90,20 @@ const BUILD_POLL_MS = 60_000; // automatic probe infrequent; operators can still force an immediate re-check. export const PROVIDER_HEALTH_REFRESH_MS = 60 * 60_000; +// The four queries below are backed by provider or models.dev fan-out +// gateway-side. That is cached and refreshed in the background now, so they are +// normally fast, but they are the ones that go slow when a provider does. The +// global default retries a failed query twice (see provider.tsx), which would +// turn one slow failure into three sequential ones and hold a browser +// connection slot for the whole time; on HTTP/1.1 (6 sockets per origin) enough +// of those queue every other request behind them, including the POST an +// operator just clicked. Failing once and showing the error is the honest +// behavior, and it frees the socket. +const NO_RETRY = { retry: false } as const; + export function useModels() { return useQuery({ + ...NO_RETRY, queryKey: [MODELS], queryFn: () => apiFetch("/v1/models"), staleTime: 60_000, @@ -124,6 +136,7 @@ export function useDashboardBuild() { // reach does not move minute to minute. export function useDiscoverableModels() { return useQuery({ + ...NO_RETRY, queryKey: [DISCOVERABLE], queryFn: () => apiFetch("/v1/models/discoverable"), staleTime: 5 * 60_000, @@ -175,6 +188,7 @@ export function useProviderDetail(providerId: string) { // (issue #302). export function useProviderHealth() { return useQuery({ + ...NO_RETRY, queryKey: [PROVIDER_HEALTH], queryFn: () => apiFetch("/v1/providers/health"), staleTime: PROVIDER_HEALTH_REFRESH_MS, @@ -250,7 +264,10 @@ export function useReencryptProviderCredentials() { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => - apiFetch("/v1/provider-credentials/reencrypt", { method: "POST" }), + apiFetch("/v1/provider-credentials/reencrypt", { + method: "POST", + signal: longRequestSignal(), + }), onSuccess: () => invalidateProviderViews(queryClient), }); } @@ -280,6 +297,7 @@ export function useTestProviderCredentials() { // it, so this is cheap; kept fresh for a session since the catalog barely moves. export function useModelMetadata() { return useQuery({ + ...NO_RETRY, queryKey: [METADATA], queryFn: () => apiFetch("/v1/models/metadata"), staleTime: 10 * 60_000, @@ -540,9 +558,12 @@ export function useDeletePricing() { }); } +// Long deadline: this fetches the upstream snapshot and diffs it against every +// priced model, so it scales with the pricing table rather than with one hop. export function usePreviewPricingRefresh() { return useMutation({ - mutationFn: () => apiFetch("/v1/pricing/refresh", { method: "POST" }), + mutationFn: () => + apiFetch("/v1/pricing/refresh", { method: "POST", signal: longRequestSignal() }), }); } @@ -888,23 +909,36 @@ export function useRequestGroups(groupIds: readonly string[]) { // Delete imported usage rows by selection (ids or by_filter). Only rows the // server treats as imported (counts_toward_budget = false) are removed; every // usage view is invalidated so the list, count, and analytics refresh. +// +// Given the long deadline, not apiFetch's default: a by_filter delete is one +// unbounded DELETE server-side, so its duration tracks the number of matched +// rows. Timing out here would report failure for a delete that committed anyway. export function useDeleteUsage() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (body: UsageMutationSelection) => - apiFetch("/v1/usage", { method: "DELETE", body: JSON.stringify(body) }), + apiFetch("/v1/usage", { + method: "DELETE", + body: JSON.stringify(body), + signal: longRequestSignal(), + }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: [USAGE] }); }, }); } -// Set the cost of imported usage rows from manual per-1M rates. +// Set the cost of imported usage rows from manual per-1M rates. Long deadline +// for the same reason as the delete: the server reprices every matched row. export function useSetUsagePrice() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (body: UsageSetPriceRequest) => - apiFetch("/v1/usage/set-price", { method: "POST", body: JSON.stringify(body) }), + apiFetch("/v1/usage/set-price", { + method: "POST", + body: JSON.stringify(body), + signal: longRequestSignal(), + }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: [USAGE] }); }, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 10284604..298d5a41 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -568,7 +568,12 @@ export interface UpdateBudgetRequest { export interface UsageEntry { id: string; user_id: string | null; + // Row labels resolved server-side, so rendering a page never depends on + // holding the users/api_keys tables client-side. Null when there is no owner, + // the entity was deleted, or it simply has no label; fall back to the id. + user_alias?: string | null; api_key_id: string | null; + api_key_name?: string | null; timestamp: string; model: string; provider: string | null; @@ -734,6 +739,12 @@ export interface UsageTotals { // (`is_other: false`); `is_other` tells them apart. export interface UsageGroupRow { key: string | null; + // Display name for an opaque key, resolved server-side in the same GROUP BY: + // set only on `by_user` and `by_api_key`, and null there when the entity has + // no label or is gone. Falling back to `key` is what makes this safe to read + // unconditionally. It is why the user and key pickers no longer need every + // user and every key loaded to name a filter option. + label?: string | null; cost: number; tokens: number; requests: number; diff --git a/web/src/pages/ActivityPage.test.tsx b/web/src/pages/ActivityPage.test.tsx index 2a8b02e1..6fb7ad3f 100644 --- a/web/src/pages/ActivityPage.test.tsx +++ b/web/src/pages/ActivityPage.test.tsx @@ -49,7 +49,7 @@ interface FetchCall { // Mock fetch for the usage list/count/summary reads plus the delete and // set-price mutations. Records every call so tests can assert URLs and bodies. -function mockApi(opts: { rows?: UsageEntry[]; total?: number; groupRows?: UsageEntry[] } = {}) { +function mockApi(opts: { rows?: UsageEntry[]; total?: number; groupRows?: UsageEntry[]; users?: string[] } = {}) { const rows = opts.rows ?? []; const total = opts.total ?? rows.length; const calls: FetchCall[] = []; @@ -86,7 +86,16 @@ function mockApi(opts: { rows?: UsageEntry[]; total?: number; groupRows?: UsageE avg_latency_ms: null, }, by_model: models.map((m) => ({ key: m, cost: 0, tokens: 0, requests: 0, is_other: false })), - by_user: [], + // The user and key pickers read these breakdowns, not a full /v1/users + // or /v1/keys listing. Label-free so an option's name and a chip's label + // are the bare id, which keeps the filter assertions readable. + by_user: (opts.users ?? ["alice", "bob"]).map((u) => ({ + key: u, + cost: 0, + tokens: 0, + requests: 0, + is_other: false, + })), by_api_key: [], by_source: Array.from(new Set(rows.map((r) => r.source))).map((s) => ({ key: s, @@ -109,17 +118,8 @@ function mockApi(opts: { rows?: UsageEntry[]; total?: number; groupRows?: UsageE } return jsonResponse(rows); } - if (url.includes("/v1/users")) { - // Alias-free so an option's name and a chip's label are the bare user id, - // which keeps the filter assertions readable. - return jsonResponse([ - { user_id: "alice", alias: null }, - { user_id: "bob", alias: null }, - ]); - } - if (url.includes("/v1/keys")) { - return jsonResponse([]); - } + // The page no longer reads /v1/users or /v1/keys; both fall through to the + // empty default below, and a test asserting that is at the end of this file. return jsonResponse([]); }); @@ -531,12 +531,18 @@ describe("ActivityPage", () => { expect(await screen.findByText("101–150 of 500")).toBeInTheDocument(); const before = listCalls(calls).length; + const entitySummaryBefore = calls.filter((call) => call.url.includes("/v1/usage/summary") && call.url.includes("dimensions=user")).length; const button = screen.getByRole("button", { name: "Refresh" }); await waitFor(() => expect(button).toBeEnabled()); await user.click(button); // The list is refetched, and every fetch stays on the third page's offset. await waitFor(() => expect(listCalls(calls).length).toBeGreaterThan(before)); + await waitFor(() => + expect(calls.filter((call) => call.url.includes("/v1/usage/summary") && call.url.includes("dimensions=user")).length).toBeGreaterThan( + entitySummaryBefore, + ), + ); expect(listCalls(calls).every((url) => url.includes("skip=100"))).toBe(true); expect(screen.getByText("101–150 of 500")).toBeInTheDocument(); }); @@ -1309,3 +1315,60 @@ describe("ActivityPage filter serialization", () => { } }); }); + +describe("ActivityPage table-scan avoidance", () => { + it("never reads the whole users or api_keys table", async () => { + // Both listings are fetched by paging every row (see fetchAllUsers / + // fetchAllKeys), so a deployment with many users or keys paid a sequential + // multi-megabyte load on every visit here, just to name filter options and + // label a page of rows. Both now come off the summary breakdown and the + // usage row itself. This asserts the request is gone, not merely smaller. + const { calls } = mockApi({ rows: [entry({ api_key_id: "key-1", api_key_name: "ci-bot" })] }); + renderPage(, "/activity?range=24h"); + + await screen.findByText("gpt-4o"); + const requested = calls.map((c) => c.url); + expect(requested.some((url) => url.includes("/v1/users"))).toBe(false); + expect(requested.some((url) => url.includes("/v1/keys"))).toBe(false); + }); + + it("labels an API key column from the row, not a client-side lookup", async () => { + const { calls } = mockApi({ rows: [entry({ api_key_id: "key-1", api_key_name: "ci-bot" })] }); + renderPage(, "/activity?range=24h"); + + expect(await screen.findByText("ci-bot")).toBeInTheDocument(); + expect(calls.map((c) => c.url).some((url) => url.includes("/v1/keys"))).toBe(false); + }); + + it("falls back to a short id when the row carries no key name", async () => { + // The label is null whenever the key was deleted or never named, so the + // column must not render an empty cell for a row that does have a key. + mockApi({ rows: [entry({ api_key_id: "abcdef123456", api_key_name: null })] }); + renderPage(, "/activity?range=24h"); + + expect(await screen.findByText("abcdef12…")).toBeInTheDocument(); + }); +}); + +describe("ActivityPage suggestion scoping", () => { + it("keeps the user filter on the model typeahead but not on the user picker", async () => { + // The two pickers want opposite windows. The model typeahead must stay + // narrowed by the active user, or it offers models that user never called + // and picking one returns an empty table. The user picker must drop it, or + // it can only ever offer the user already selected. + const { calls } = mockApi({ rows: [entry()] }); + renderPage(, "/activity?model=gpt-4o&user_id=alice&range=24h"); + + await screen.findByText("gpt-4o"); + const summaries = calls.map((c) => c.url).filter((url) => url.includes("/v1/usage/summary")); + + const modelQuery = summaries.find((url) => url.includes("dimensions=model")); + expect(modelQuery, "model typeahead summary").toBeDefined(); + expect(modelQuery).toContain("user_id=alice"); + + const entityQuery = summaries.find((url) => url.includes("dimensions=user")); + expect(entityQuery, "user/key picker summary").toBeDefined(); + expect(entityQuery).not.toContain("user_id=alice"); + expect(entityQuery).toContain("model=gpt-4o"); + }); +}); diff --git a/web/src/pages/ActivityPage.tsx b/web/src/pages/ActivityPage.tsx index f4382a12..5f0dfc7c 100644 --- a/web/src/pages/ActivityPage.tsx +++ b/web/src/pages/ActivityPage.tsx @@ -4,16 +4,20 @@ import type { ReactNode } from "react"; import { useDeleteUsage, - useKeys, useSetPricing, useSetUsagePrice, useRequestGroups, useUsageCount, useUsageLogs, useUsageSummary, - useUsers, } from "@/api/hooks"; -import type { SummaryDimension, UsageEntry, UsageFilters, UsageMutationSelection } from "@/api/types"; +import type { + SummaryDimension, + UsageEntry, + UsageFilters, + UsageGroupRow, + UsageMutationSelection, +} from "@/api/types"; import { ActivityTimeline } from "@/components/ActivityTimeline"; import { BulkActionBar } from "@/components/BulkActionBar"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -165,6 +169,12 @@ const DEFAULT_PAGE_SIZE = 50; // the same query's `by_source` while no source is picked (see the source // suggestion note below), so both breakdowns ride one request. const MODEL_AND_SOURCE_BREAKDOWNS: SummaryDimension[] = ["model", "source"]; + +// The user and key pickers read these two. by_user and by_api_key carry each +// entity's display name, resolved server-side in the same GROUP BY, so naming an +// option costs nothing beyond the breakdown itself. The alternative, and what +// this replaced, was paging the whole users and api_keys tables on every visit. +const ENTITY_BREAKDOWNS: SummaryDimension[] = ["user", "api_key"]; const SOURCE_BREAKDOWN: SummaryDimension[] = ["source"]; // All filter + pagination state, with defaults, kept in the URL. @@ -756,14 +766,6 @@ function RequestDetail({ entry, onPriceModel }: { entry: UsageEntry; onPriceMode // ---------- page ---------- export function ActivityPage() { - const users = useUsers(); - const keys = useKeys(); - const keyLabels = useMemo(() => { - const map = new Map(); - for (const k of keys.data ?? []) map.set(k.id, k.key_name ?? `${k.id.slice(0, 8)}…`); - return map; - }, [keys.data]); - // Filter + pagination state lives in the URL, so a filtered view is shareable // and survives the back button. `patch` batches related changes into one entry. const url = useUrlState(URL_DEFAULTS); @@ -904,12 +906,27 @@ export function ActivityPage() { toolFilter, ], ); - // Only `by_model` and `by_source` are read (typeahead + source picker), so - // only those breakdowns are requested: no use for the other five GROUP BYs. + // Two breakdowns are read here (model typeahead, source picker); the rest are + // not requested. const modelSummary = useUsageSummary(modelSuggestFilters, "day", MODEL_AND_SOURCE_BREAKDOWNS); - const modelOptions = - modelSummary.data?.by_model?.filter((r) => !r.is_other && r.key !== null).map((r) => r.key as string) ?? []; - const keyOptions = (keys.data ?? []).map((k) => ({ value: k.id, label: k.key_name ?? `${k.id.slice(0, 8)}…` })); + const realGroups = (rows: UsageGroupRow[] | undefined) => + (rows ?? []).filter((r) => !r.is_other && r.key !== null); + const modelOptions = realGroups(modelSummary.data?.by_model).map((r) => r.key as string); + + // The user and key pickers need their own window: each must keep offering the + // *other* values of its own dimension, so both entity filters come off. That + // cannot share the model/source query above, which has to keep them applied, + // or filtering Activity to one user would make the typeahead suggest only the + // models other users called and picking one would return an empty table. + const entitySuggestFilters: UsageFilters = useMemo( + () => ({ ...filters, user_id: undefined, api_key_id: undefined }), + [filters], + ); + const entitySummary = useUsageSummary(entitySuggestFilters, "day", ENTITY_BREAKDOWNS); + const keyOptions = realGroups(entitySummary.data?.by_api_key).map((r) => ({ + value: r.key as string, + label: r.label ?? `${(r.key as string).slice(0, 8)}…`, + })); // Source options: the sources with usage in the window. Like the model // suggestions, this must ignore the source filter itself, or picking Claude Code @@ -1058,9 +1075,9 @@ export function ActivityPage() { // it is not a chip). Values show the human label where one exists. const labelFrom = (options: { value: string; label: string }[], value: string) => options.find((o) => o.value === value)?.label ?? value; - const userOptionsList = (users.data ?? []).map((u) => ({ - value: u.user_id, - label: u.alias ? `${u.alias} (${u.user_id})` : u.user_id, + const userOptionsList = realGroups(entitySummary.data?.by_user).map((r) => ({ + value: r.key as string, + label: r.label ? `${r.label} (${r.key})` : (r.key as string), })); const clearEntityFilters = () => url.patch({ @@ -1237,6 +1254,7 @@ export function ActivityPage() { void count.refetch(); void contextSummary.refetch(); void modelSummary.refetch(); + void entitySummary.refetch(); // Guarded because refetch() ignores `enabled`: without a picked source the // query is disabled by design and refetching it would fire a pointless // extra summary request. @@ -1266,8 +1284,8 @@ export function ActivityPage() { // both themselves memoized) so DataTable's per-row cache holds: a fresh array // every render would rebuild all rows per click. const columns = useMemo[]>(() => { - const apiKeyLabel = (id: string | null): string => - id === null ? "—" : (keyLabels.get(id) ?? `${id.slice(0, 8)}…`); + const apiKeyLabel = (entry: UsageEntry): string => + entry.api_key_id === null ? "—" : (entry.api_key_name ?? `${entry.api_key_id.slice(0, 8)}…`); return [ { id: "time", @@ -1315,13 +1333,13 @@ export function ActivityPage() { // additive: together they answer "what did I ask for, and what served it". cell: (e) => , }, - { id: "api_key", header: "API key", cell: (e) => {apiKeyLabel(e.api_key_id)} }, + { id: "api_key", header: "API key", cell: (e) => {apiKeyLabel(e)} }, { id: "tokens", header: "Tokens", align: "end", cell: (e) => }, { id: "cost", header: "Cost", align: "end", cell: (e) => formatUSD(e.cost) }, { id: "latency", header: "Total time", align: "end", cell: (e) => formatLatency(e.latency_ms) }, { id: "status", header: "Status", cell: (e) => }, ]; - }, [keyLabels, groupOutcomes]); + }, [groupOutcomes]); return (
@@ -1390,10 +1408,16 @@ export function ActivityPage() { ))} ) : null} + {/* allowsCustom on all three: the options are the in-window top spenders + (a breakdown capped at 100), so an entity that exists but ranks below + that, or has no traffic in the window, is not offered. Enter commits a + pasted id anyway, the way the Model box already accepts a name the + suggestions do not cover. */} url.patch({ api_key_id: values })} + allowsCustom placeholder="All keys" options={keyOptions} /> @@ -1401,6 +1425,7 @@ export function ActivityPage() { label="User" values={userFilters} onChange={(values) => url.patch({ user_id: values })} + allowsCustom placeholder="All users" options={userOptionsList} /> diff --git a/web/src/pages/UsagePage.test.tsx b/web/src/pages/UsagePage.test.tsx index d0198f0d..7e5bb445 100644 --- a/web/src/pages/UsagePage.test.tsx +++ b/web/src/pages/UsagePage.test.tsx @@ -30,10 +30,12 @@ function summary(overrides: Partial = {}): UsageSummary { { key: null, cost: 110.5, tokens: 1_400_000, requests: 14_000, is_other: true }, ], by_user: [ - { key: "alice", cost: 900.5, tokens: 8_000_000, requests: 50_000, is_other: false }, - { key: "bob", cost: 340, tokens: 4_400_000, requests: 34_000, is_other: false }, + { key: "alice", label: "Alice", cost: 900.5, tokens: 8_000_000, requests: 50_000, is_other: false }, + { key: "bob", label: "Bob", cost: 340, tokens: 4_400_000, requests: 34_000, is_other: false }, ], - by_api_key: [], + // `label` is the server-resolved key name; the picker reads it from here + // rather than from a full /v1/keys listing. + by_api_key: [{ key: "key-1", label: "ci-bot", cost: 500, tokens: 5_000_000, requests: 30_000, is_other: false }], by_source: [ { key: "gateway", cost: 1_000, tokens: 9_000_000, requests: 60_100, is_other: false }, { key: "claude_code", cost: 240.5, tokens: 3_400_000, requests: 23_900, is_other: false }, diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx index f3f3f8a4..1195ce74 100644 --- a/web/src/pages/UsagePage.tsx +++ b/web/src/pages/UsagePage.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ApiError } from "@/api/client"; -import { NO_BREAKDOWNS, useKeys, useUsageGroupedSeries, useUsageSummary, useUsers } from "@/api/hooks"; +import { NO_BREAKDOWNS, useUsageGroupedSeries, useUsageSummary } from "@/api/hooks"; import type { SummaryDimension, UsageBucket, @@ -309,9 +309,17 @@ const PAGE_BREAKDOWNS: SummaryDimension[] = [ "tool", ]; -// The typeahead's own summary drops the model filter, so it only needs by_model. +// The model typeahead drops only the model filter, so the other active filters +// still narrow what it suggests. Dropping the entity filters here too would make +// it offer models that, combined with the picked user, match nothing. const MODEL_BREAKDOWN: SummaryDimension[] = ["model"]; +// The user and key pickers drop both entity filters, so each keeps offering the +// other values of its own dimension. by_user and by_api_key carry the entity's +// display name (resolved server-side in the same GROUP BY), which is what lets +// the pickers name their options without loading the users and api_keys tables. +const ENTITY_BREAKDOWNS: SummaryDimension[] = ["user", "api_key"]; + // ---------- breakdown dimensions ---------- // One breakdown tab. Model and user are the two questions asked on every visit; @@ -330,8 +338,6 @@ interface BreakdownDimensionDef { export function UsagePage() { const navigate = useNavigate(); - const users = useUsers(); - const keys = useKeys(); const [preset, setPreset] = useState(DEFAULT_PRESET); // Anchored start of the rolling preset window, snapshotted so a re-render does @@ -413,21 +419,25 @@ export function UsagePage() { // omits the model filter, so the list stays complete when a model is selected, // and derived directly from query data rather than mirrored into state. const modelSuggestFilters: UsageFilters = useMemo(() => ({ ...filters, model: undefined }), [filters]); - // The typeahead reads only the model breakdown, so only it is requested. const modelSuggest = useUsageSummary(modelSuggestFilters, bucket, MODEL_BREAKDOWN); - const modelOptions = - modelSuggest.data?.by_model?.filter((r) => !r.is_other && r.key !== null).map((r) => r.key as string) ?? []; + const realGroups = (rows: UsageGroupRow[] | undefined) => + (rows ?? []).filter((r) => !r.is_other && r.key !== null); + const modelOptions = realGroups(modelSuggest.data?.by_model).map((r) => r.key as string); - const userOptions = (users.data ?? []).map((u) => ({ - value: u.user_id, - label: u.alias ? `${u.alias} (${u.user_id})` : u.user_id, + const entitySuggestFilters: UsageFilters = useMemo( + () => ({ ...filters, user_id: undefined, api_key_id: undefined }), + [filters], + ); + const entitySuggest = useUsageSummary(entitySuggestFilters, bucket, ENTITY_BREAKDOWNS); + const userOptions = realGroups(entitySuggest.data?.by_user).map((r) => ({ + value: r.key as string, + label: r.label ? `${r.label} (${r.key})` : (r.key as string), })); // API key options label by name (falling back to a short id), value is the id. - const keyOptions = (keys.data ?? []).map((k) => ({ - value: k.id, - label: k.key_name ?? `${k.id.slice(0, 8)}…`, + const keyOptions = realGroups(entitySuggest.data?.by_api_key).map((r) => ({ + value: r.key as string, + label: r.label ?? `${(r.key as string).slice(0, 8)}…`, })); - const keyLabel = (id: string) => keyOptions.find((o) => o.value === id)?.label ?? id; // Just the in-window models: a picked one needs no place in this list, because // the picker hides what is already selected and the chips carry the raw name. const modelOptionList = modelOptions.map((m) => ({ value: m, label: m })); @@ -505,6 +515,7 @@ export function UsagePage() { } void summary.refetch(); void modelSuggest.refetch(); + void entitySuggest.refetch(); if (previousFilters !== null) { void previous.refetch(); } @@ -599,7 +610,7 @@ export function UsagePage() { : row.key === null ? "(unknown)" : effectiveGroupBy === "api_key_id" - ? keyLabel(row.key) + ? (row.label ?? `${row.key.slice(0, 8)}…`) : row.key, color: row.is_other ? OTHER_COLOR : CAT_COLORS[index % CAT_COLORS.length], })); @@ -653,9 +664,9 @@ export function UsagePage() { [metric]: metric === "cost" ? p.cost : metric === "tokens" ? pointBilled(p) : p.requests, })), }; - // keyLabel is derived from query data; keys.data is the stable input. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [series, effectiveGroupBy, grouped.data, metric, hasComposition, hasErrors, keys.data]); + // Group labels now come from the server on `grouped.data`, so this memo has + // no input outside its dependency list and needs no exhaustive-deps escape. + }, [series, effectiveGroupBy, grouped.data, metric, hasComposition, hasErrors]); const formatValue = metricFormatter(metric); const chartLoading = summary.isLoading || (Boolean(effectiveGroupBy) && grouped.isLoading); @@ -775,11 +786,15 @@ export function UsagePage() { } > + {/* allowsCustom because the options are the in-window top spenders (a + breakdown capped at 100): an entity below that rank, or with no traffic + in the window, is not offered, so Enter has to commit a pasted id. */}