diff --git a/devlog/_plan/260907_lane_d/040_price_editor.md b/devlog/_plan/260907_lane_d/040_price_editor.md index 4628710d0d..8d79b2160e 100644 --- a/devlog/_plan/260907_lane_d/040_price_editor.md +++ b/devlog/_plan/260907_lane_d/040_price_editor.md @@ -21,3 +21,24 @@ A fold-back: add GET/PUT entries in src/server/management/route-registry.ts. Reuse providerModelCostsConfigError. GET returns sanitized per-provider modelCosts map; Models owns a typed map loaded with catalog or dedicated GET, so manual badges survive reload. CLI omitted cache-read/cache-write rates default to zero, explicitly documented. + +P revalidation/API contract: GET /api/providers/{provider}/model-costs returns +{provider,modelCosts}; PUT accepts {modelId,cost:Cost4|null}, returns +{ok:true,provider,modelId,cost}. Null deletes only that model key. Models API adds +manualPricing boolean on applicable rows so badges survive reload, while the dialog +GET owns editable rates. CLI models price reads; models set-price writes/resets. +Same C2 phase splits disjoint workers: backend API/CLI/model-row/tests; frontend dialog/ +Models/types/i18n/tests; main owns explicit-zero cost semantics, docs and manifests. +No worker commits/pushes/runs local checks. Main integrates once both return. +Main granted D exactly the zero sentence in all seven translated providers config +reference pages; leave all other sections to E/M. New i18n keys are append-only. + +Implementation checkpoint: GET/PUT editor and two CLI verbs share the four-rate store; +manualPricing is emitted only for exact stored overrides. All-zero user prices are +known-zero estimates while catalog zero fallbacks remain unchanged. API/CLI and dialog +regressions cover persistence, reset, sibling isolation, invalid input and unknown receipts. +All 9 locale catalogs gained matching append-only keys. Seven existing configuration +rows (English plus six translations) had only the zero sentence updated; zh-tw has no +modelCosts row on this baseline and was left untouched. CLI surface regenerated by its +own generator, not a build or test. New backend test names appended to both manifests. +Local suites/typecheck/build/install NOT RUN; final top CI and screenshot remain open. diff --git a/devlog/_plan/260907_lane_d/050_usage_ranges.md b/devlog/_plan/260907_lane_d/050_usage_ranges.md index 35b6758a76..e08d758e5b 100644 --- a/devlog/_plan/260907_lane_d/050_usage_ranges.md +++ b/devlog/_plan/260907_lane_d/050_usage_ranges.md @@ -28,3 +28,22 @@ integer epoch milliseconds or full ISO-8601 with timezone only; require both bou reject negative/unsafe/date-invalid/reversed, never normalize overflow dates. MODIFY src/cli/usage-report.ts heading prints since/until for customWindow responses. GUI datetime values become epoch ms locally; end selected minute includes 59.999s. + +P revalidation: custom windows always filter rows before aggregation. Introduce exported +UsageTimeWindow {since:number,until:number} and immutable optional accumulator window; +snapshot timestamps update first, clone retains the window, summary returns customWindow:true +and exact since/until while actual generatedAt stays now. Partition/day filtering must not +drop the partial first day. Grid uses local calendar day boundaries and caps at 366 days. +getFilteredUsageAggregate accepts window, keys both bounds, passes window to factory and +reuses existing revision/timezone/overlay guards. Only-window queries preserve account rows. +GUI skips held/session report caching for custom windows (arbitrary keys must not grow the +preset cache); useDataSurface key still includes bounds and unsubscribed stores already evict. +Workers split backend/API/CLI/tests and GUI/i18n/tests; main owns docs/manifests/generated map. + +Implementation checkpoint: shared strict ISO/epoch-ms parser, immutable per-entry window, +window-keyed filtered cache, API and CLI inclusive bounds, exact interval heading, and +localized Usage date/time controls are implemented. Custom GUI reports bypass held caches; +calendar grid stays within the server's bounded days. Tests cover partial/inclusive bounds, +filters/accounts, cache invalidation, clone/snapshot behavior, empty/error responses and UI +apply/clear/stale-response paths. New parser test registered in both manifests. ISO fractions +beyond millisecond precision reject instead of truncating. Product execution NOT RUN locally. diff --git a/devlog/_plan/260907_lane_d/060_delivery.md b/devlog/_plan/260907_lane_d/060_delivery.md new file mode 100644 index 0000000000..38bcb1c80a --- /dev/null +++ b/devlog/_plan/260907_lane_d/060_delivery.md @@ -0,0 +1,24 @@ +# 060 Remote verification and delivery + +Consume D5's implementation checkpoint. Resolve remaining independent review feedback on +its owning layer, cascade all dependent refs, and preserve contributor trailers. Detailed +unpublished security-review notes stay in scratch. Reconcile A's added reasoning-envelope +budget arguments with D1 ordering when A reaches dev; preserve both changes. + +Fetch fresh dev before final dispatch. Run only the top branch's ci.yml workflow with +lane=all; require successful actual platform jobs including Windows on the exact head. +Download its dashboard-preview artifact and verify build-commit/build-gui-tree markers. +Capture the changed dialogs and custom Usage range with synthetic data through the existing +browser capability; publish proof images separately so evidence does not change tested code. + +Create D2-D5 PRs with the required template, screenshot, manual chain table and native +stack:null proof. Attach independent implementation/security verdicts and top CI URL to +each PR. Leave all merges and original issue/PR closure actions to the main task. +Local product tests/typecheck/build/install remain NOT RUN. D closes only when exact-head +remote evidence and the requested handoff table are complete. + +Calendar audit fold-back: custom heatmaps iterate the server's returned civil dates, +using UTC only for weekday/month layout; they do not step a local midnight cursor. +The server's backward calendar walk resets midnight after decrement and explicitly +advances to the prior existing local day if a whole-day timezone jump prevented progress. +Regressions pin America/Santiago (2026-09-05..07) and Pacific/Apia (2011-12-29..31). diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 96d00e5668..1d043fc7fd 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -104,7 +104,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `modelAutoCompactTokenLimits?` | `Record` | Budgets souples de compactage automatique par modèle, sous forme d'entiers sûrs positifs. Ils peuvent uniquement abaisser l'enveloppe effective de 90 % du contexte ou de l'entrée maximale et sont omis lorsqu'aucune fenêtre de contexte faisant autorité n'est connue. Pour le fournisseur canonique `openai`, les clés doivent être les identifiants exacts de modèles natifs pris en charge, sans préfixe de fournisseur ni de sélecteur de compte. PATCH fusionne les entrées ; `null` supprime une clé, tandis que `null` pour le champ entier efface la table. Ces marqueurs `null` sont réservés à PATCH. | | `defaultMaxOutputTokens?` | `number` | Solution de secours `openai-chat` à l’échelle du fournisseur lorsque le client omet `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Budgets de repli `openai-chat` positifs par modèle ; les correspondances exactes ou par motif priment sur la valeur par défaut du fournisseur. | -| `modelCosts?` | `Record` | Prix affichés par modèle (USD par 1M de jetons), indexés par l'identifiant exact du modèle en amont de ce fournisseur — et non par un identifiant de fournisseur ni par une étiquette routée `provider/model`, par exemple `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Tout identifiant de modèle constitue une clé valide : les fournisseurs personnalisés peuvent cibler n'importe quel point de terminaison compatible avec OpenAI au moyen de l'adaptateur `openai-chat`, et les identifiants de fournisseur locaux ou internes fonctionnent même s'ils sont absents des catalogues intégrés. Les prix configurés par l'utilisateur priment sur les catalogues intégrés dans les estimations des pages Journaux (`~$`) et Utilisation. Les entrées historiques sont recalculées à partir de la surcharge actuelle ; modifier un prix peut donc changer les totaux antérieurs. L'ordre de repli est le suivant : `modelCosts` défini par l'utilisateur → catalogue jawcode → surcharge des prix attendus → repli propre au fournisseur au niveau du modèle. Une entrée entièrement nulle passe à la source suivante. Chaque tarif doit être un nombre fini positif ou nul, inférieur ou égal à 1 000 000 (USD par 1M de jetons) ; les lignes hors plage sont rejetées par l'interface de gestion et ignorées au chargement. Ces valeurs servent uniquement à l'estimation lors de l'affichage : les surcharges n'affectent jamais le routage, la sélection des comptes, les quotas ni la facturation. | +| `modelCosts?` | `Record` | Prix affichés par modèle (USD par 1M de jetons), indexés par l'identifiant exact du modèle en amont de ce fournisseur — et non par un identifiant de fournisseur ni par une étiquette routée `provider/model`, par exemple `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Tout identifiant de modèle constitue une clé valide : les fournisseurs personnalisés peuvent cibler n'importe quel point de terminaison compatible avec OpenAI au moyen de l'adaptateur `openai-chat`, et les identifiants de fournisseur locaux ou internes fonctionnent même s'ils sont absents des catalogues intégrés. Les prix configurés par l'utilisateur priment sur les catalogues intégrés dans les estimations des pages Journaux (`~$`) et Utilisation. Les entrées historiques sont recalculées à partir de la surcharge actuelle ; modifier un prix peut donc changer les totaux antérieurs. L'ordre de repli est le suivant : `modelCosts` défini par l'utilisateur → catalogue jawcode → surcharge des prix attendus → repli propre au fournisseur au niveau du modèle. Une surcharge utilisateur explicitement définie à zéro produit une estimation nulle connue ; supprimez cette entrée pour rétablir la tarification automatique. Les prix de catalogue entièrement nuls restent soumis au repli. Chaque tarif doit être un nombre fini positif ou nul, inférieur ou égal à 1 000 000 (USD par 1M de jetons) ; les lignes hors plage sont rejetées par l'interface de gestion et ignorées au chargement. Ces valeurs servent uniquement à l'estimation lors de l'affichage : les surcharges n'affectent jamais le routage, la sélection des comptes, les quotas ni la facturation. | | `headers?` | `Record` | En-têtes supplémentaires en amont. L'autorisation, les cookies, les en-têtes de clé API, les nouvelles lignes intégrées et les noms invalides sont rejetés. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Préférences OpenRouter `order`, `only` et `allowFallbacks` par défaut ; valable uniquement pour les OpenRouter canoniques avec `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Remplacements exacts de l'ID de modèle qui remplacent la préférence OpenRouter à l'échelle du fournisseur. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 8938073e6c..e298d36559 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -93,7 +93,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelAutoCompactTokenLimits?` | `Record` | モデルごとの正の安全な整数によるソフト自動圧縮予算。実効値であるコンテキストまたは最大入力の 90% の上限を下げることだけができ、信頼できるコンテキストウィンドウが不明な場合は出力されません。canonical `openai` では、キーは provider や account-selector の接頭辞を含まない、サポート対象の正確なネイティブモデル ID でなければなりません。provider PATCH はエントリをマージし、キーを `null` にするとそのキーを削除し、フィールド全体を `null` にするとマップを消去します。これらの `null` tombstone は PATCH 専用です。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | -| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。そのプロバイダーの正確なアップストリーム モデル ID をキーにします(プロバイダー識別子やルーティングされた `provider/model` ラベルではありません)。値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないモデル ID も、任意の OpenAI 互換エンドポイントを対象とするカスタムプロバイダーや、ローカル・内部プロバイダーで有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。各レートは 0 以上の有限数で、最大 1,000,000(100万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・クォータ・請求には影響しません。 | +| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。そのプロバイダーの正確なアップストリーム モデル ID をキーにします(プロバイダー識別子やルーティングされた `provider/model` ラベルではありません)。値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないモデル ID も、任意の OpenAI 互換エンドポイントを対象とするカスタムプロバイダーや、ローカル・内部プロバイダーで有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。ユーザーが明示的に全レートを 0 にした場合は、既知のゼロ料金として見積もります。自動料金に戻すにはそのモデルの設定を削除してください。カタログの全ゼロ料金は引き続きフォールバックします。各レートは 0 以上の有限数で、最大 1,000,000(100万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・クォータ・請求には影響しません。 | | `headers?` | `Record` |追加の上流ヘッダー。認証、Cookie、API キー ヘッダー、埋め込まれた改行、および無効な名前は拒否されます。 | | `openRouterRouting?` | `OpenRouterProviderRouting` |デフォルトの OpenRouter `order`、`only`、および `allowFallbacks` 設定。 `openai-chat` を持つ正規 OpenRouter に対してのみ有効です。 | | `modelOpenRouterRouting?` | `Record` |プロバイダー全体の OpenRouter 設定を置き換える正確なモデル ID のオーバーライド。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 2dda27e7bf..c37191745d 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -93,7 +93,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `modelAutoCompactTokenLimits?` | `Record` | 모델별 양의 안전 정수형 소프트 자동 압축 예산입니다. 유효한 컨텍스트 또는 최대 입력의 90% 한도를 낮출 수만 있으며, 신뢰할 수 있는 컨텍스트 창을 알 수 없으면 내보내지 않습니다. canonical `openai`에서는 키가 공급자나 계정 선택자 접두사가 없는 정확한 지원 네이티브 모델 ID여야 합니다. 공급자 PATCH는 항목을 병합하며, 키를 `null`로 지정하면 해당 키를 삭제하고 필드 전체를 `null`로 지정하면 맵을 지웁니다. 이 `null` tombstone은 PATCH에서만 사용할 수 있습니다. | | `defaultMaxOutputTokens?` | `number` | 클라이언트가 `max_output_tokens`를 생략했을 때 쓰는 공급자 전반의 `openai-chat` 폴백입니다. | | `modelMaxOutputTokens?` | `Record` | 양수 모델별 `openai-chat` 폴백 예산입니다. 정확한 일치와 패턴 일치가 공급자 기본값보다 우선합니다. | -| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 해당 공급자의 정확한 업스트림 모델 ID를 키로 사용하며(공급자 식별자나 라우팅된 `provider/model` 레이블이 아님) 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 커스텀 공급자는 `openai-chat` 어댑터로 임의의 OpenAI 호환 엔드포인트를 대상으로 할 수 있으며, 내장 카탈로그에 없는 로컬·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 각 요율은 0 이상의 유한한 숫자이며 최대 1,000,000(100만 토큰당 USD)입니다. 범위를 벗어난 행은 관리 경계에서 거부되고 로드 시 삭제됩니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | +| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 해당 공급자의 정확한 업스트림 모델 ID를 키로 사용하며(공급자 식별자나 라우팅된 `provider/model` 레이블이 아님) 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 커스텀 공급자는 `openai-chat` 어댑터로 임의의 OpenAI 호환 엔드포인트를 대상으로 할 수 있으며, 내장 카탈로그에 없는 로컬·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 사용자가 모든 요율을 명시적으로 0으로 설정하면 비용을 0으로 추정합니다. 자동 가격으로 되돌리려면 해당 모델 항목을 삭제하세요. 카탈로그의 전부 0인 요율은 계속 다음 소스로 폴백합니다. 각 요율은 0 이상의 유한한 숫자이며 최대 1,000,000(100만 토큰당 USD)입니다. 범위를 벗어난 행은 관리 경계에서 거부되고 로드 시 삭제됩니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | | `headers?` | `Record` | 추가 상위 헤더입니다. Authorization, cookies, API-key 헤더, 내장 개행, 잘못된 이름은 허용하지 않습니다. | | `openRouterRouting?` | `OpenRouterProviderRouting` | 기본 OpenRouter `order`, `only`, `allowFallbacks` 선호도입니다. 정식 OpenRouter와 `openai-chat`에서만 유효합니다. | | `modelOpenRouterRouting?` | `Record` | 공급자 전반의 OpenRouter 선호도를 덮어쓰는 정확한 모델 id별 재정의입니다. | diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 300bb7d5e2..4b95d1bcd2 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -111,14 +111,21 @@ Inspect proxy requests, usage, storage, memory, and debug data. The direct alias | Alias | Equivalent resource | | --- | --- | | `ocx logs [filters] [--follow] [--json|--jsonl]` | `ocx observe logs` | -| `ocx usage [--range ] [--surface ] [--provider ] [--model ] [--json]` | `ocx observe usage` | +| `ocx usage [--range ] [--since --until ] [--surface ] [--provider ] [--model ] [--json]` | `ocx observe usage` | | `ocx storage [--json]` | `ocx observe storage` | | `ocx memory [--json]` | `ocx observe memory` | ```bash ocx observe usage --range 30d --json +ocx usage --since 2026-09-01T09:00:00Z --until 2026-09-01T10:59:59.999Z --json ``` +`--since` and `--until` must be supplied together. They accept integer epoch milliseconds or +full ISO datetimes with an explicit timezone, include both endpoints, and override `--range`. +Invalid or reversed bounds fail before the request. Human output prints the requested interval; +`--json` includes `customWindow`, `since`, and `until`. Existing surface/provider/model filters +still apply. These commands query the running proxy; they do not provide offline reports. + `--range today` (alias `1d`) reports the current local day. `--provider` and `--model` narrow the report to one upstream target — distinct from `--surface`, which selects the calling client (Codex, Claude Code, Grok) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 3d602799bb..e92bf67874 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -508,6 +508,8 @@ proxy to be running (`ocx start`, or an installed service). | --- | --- | --- | | `list` (default) | `--provider `, `--json` | List models seeded in configured providers. | | `live` | `--provider `, `--json` | Read the running catalog, including models discovered at runtime. Rows are flagged `native`/`routed`, `custom`, and `enabled`/`disabled`. | +| `price ` | `--json` | Read the model's saved manual price override; no override means automatic pricing. | +| `set-price ` | `--input `, `--output `, `--cache-read `, `--cache-write `, `--auto`, `--json` | Set display prices in USD per 1M tokens. Input/output are required when setting; omitted cache rates become zero. `--auto` removes only this model's override. | | `add ` | `--display-name `, `--context-window `, `--modalities ` | Register a model the provider catalog does not advertise. | | `edit ` | `--model-id `, `--display-name `, `--context-window `, `--modalities `, `--json` | Edit a custom model. `-` clears a field; `0` clears the context window. | | `remove ` | `--yes` | Delete a custom model. Requires `--yes` when stdin is not an interactive terminal. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b0792b7367..0deb12616f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -149,7 +149,7 @@ predictions. Explicit provider/model price overrides still take precedence. | `modelAutoCompactTokenLimits?` | `Record` | Positive safe-integer per-model soft auto-compaction budgets. Values can only lower the effective 90%-of-context/max-input envelope and are omitted when no authoritative context window is known. For canonical `openai`, keys must be exact supported native model IDs without provider or account-selector prefixes. Provider PATCH merges entries; set a key to `null` to delete it or the whole field to `null` to clear the map. These `null` tombstones are PATCH-only. | | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | -| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → exact official correction → jawcode catalog → expected-price overlay → model-level vendor fallback, and an all-zero entry falls through to the next source in that sequence. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | +| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → exact official correction → jawcode catalog → expected-price overlay → model-level vendor fallback, and an explicit all-zero user entry means a known-zero estimate; delete that model entry to restore automatic pricing. All-zero catalog metadata still falls through. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | | `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index a4ea3ad3d9..238d23b336 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -182,7 +182,7 @@ by the current window size. | `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — | | `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — | | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | -| `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by range and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | Returns an `error: "read_failed"` summary if storage cannot be read | +| `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | @@ -210,6 +210,23 @@ an earlier file prefix from 7-day, 30-day, or all-history totals. `managementUsa accepted for compatibility with bounded legacy readers, but changing it no longer expands or reduces the history summarized by this endpoint. +Pass both `since` and `until` to select an inclusive custom interval. Each accepts integer Unix +epoch **milliseconds**, or a full ISO datetime with an explicit timezone. Invalid dates, negative +or out-of-range values, reversed bounds, and a single bound are rejected. Custom bounds override +`range`; the response keeps the preset `range` field for compatibility and adds `customWindow: true`, +the exact `since`, and `until`. `generatedAt` remains the time the report was produced. + +Custom windows filter individual ledger entries before daily aggregation, including partial first +and last days. They preserve `surface`, `provider`, `model`, and `apiKeyId` filtering and never reuse +or overwrite unfiltered preset summaries. The daily chart remains capped at 366 local calendar days; +totals cover the full requested interval. Snapshot-window fields describe the scanned ledger before +the time filter, so they can extend beyond the requested bounds. + +The Usage page accepts local date/time inputs. Its selected ending minute includes the entire +minute through `:59.999`. Choosing a preset or clearing the custom window restores preset behavior. +This adds exact range selection and existing cost estimates; it does not add hourly chart buckets +or offline reporting. + The runtime ledger is append-only. Replacing or truncating it, or changing local pricing/time-zone inputs, triggers a complete rebuild. If you manually edit an older row in place while the proxy is running, restart the proxy (or replace the file) before relying on the new total; incremental refreshes @@ -228,6 +245,29 @@ re-estimated from the pricing active when the summary is read. This is an API-eq not a subscription charge. New main-pool requests use the reserved `main` label; legacy bare `openai` rows remain in an ambiguous bucket instead of being reassigned from current configuration. +Manual model prices can also be edited from **Models → Price**. A manual-pricing badge survives +catalog reloads. Prices are stored in `providers..modelCosts` and survive catalog sync. +Explicit all-zero user rates mean a known-zero estimate; **Reset to automatic** removes the +override and restores the usual catalog fallback. These remain display estimates, not bills. + +`GET /api/providers/{provider}/model-costs` returns `{ provider, modelCosts }`, with sanitized +four-rate entries keyed by exact upstream model ID. `PUT` on the same route accepts +`{ modelId, cost }`, where `cost` is `{ input, output, cacheRead, cacheWrite }` or `null` to reset. +All four rates must be finite numbers from 0 through 1,000,000, in USD per 1M tokens. +Unknown fields and malformed rates are rejected. A write preserves other models' overrides +and returns `{ ok: true, provider, modelId, cost }`; reset returns `cost: null`. + +```bash +ocx models price ollama/custom-model --json +ocx models set-price ollama/custom-model --input 0.50 --output 1.50 +ocx models set-price ollama/custom-model --input 0 --output 0 +ocx models set-price ollama/custom-model --auto +``` + +Omitted CLI cache-read/cache-write rates default to zero. Use `--cache-read` and `--cache-write` +to set them explicitly. A provider name remains an exact configuration identity; account display +labels are not editable provider names. + Rows in `models`, `providers`, and `days[].models` also carry `cacheHitRate`: the share of input tokens served from the provider's prompt cache, clamped to `[0, 1]`. It is `null` — never `0` — when the provider reported no cache telemetry or the row has no input tokens, because "no cache diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 14a8b0a3ce..7704a9cbda 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -106,7 +106,7 @@ cross-route credential fallback не существует. Строки API GPT- | `modelAutoCompactTokenLimits?` | `Record` | Мягкие бюджеты автосжатия по моделям в виде положительных безопасных целых чисел. Они могут только уменьшать эффективную границу в 90 % контекста или максимального ввода и не выдаются, если авторитетное окно контекста неизвестно. Для канонического `openai` ключами могут быть только точные поддерживаемые ID нативных моделей без префиксов провайдера или селектора аккаунта. PATCH провайдера объединяет записи: `null` для ключа удаляет его, а `null` для всего поля очищает карту. Такие маркеры `null` допустимы только в PATCH. | | `defaultMaxOutputTokens?` | `number` | Provider-wide fallback для `openai-chat`, когда клиент не передал `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Положительные fallback-budget'ы `openai-chat` по моделям; exact/pattern-match имеет приоритет над provider-default. | -| `modelCosts?` | `Record` | Отображаемые цены по моделям (USD за 1M токенов), ключ — точный upstream id модели этого провайдера (не идентификатор провайдера и не маршрутизируемая метка `provider/model`), значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомный провайдер может указывать на любой OpenAI-совместимый endpoint через адаптер `openai-chat`, а локальные и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в Logs и Usage; исторические записи пересчитываются по текущему оверлею, поэтому изменение цены может сдвинуть прошлые суммы (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Каждая ставка должна быть неотрицательным конечным числом не более 1 000 000 (USD за 1M токенов); строки вне диапазона отклоняются на управляющей границе и отбрасываются при загрузке. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | +| `modelCosts?` | `Record` | Отображаемые цены по моделям (USD за 1M токенов), ключ — точный upstream id модели этого провайдера (не идентификатор провайдера и не маршрутизируемая метка `provider/model`), значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомный провайдер может указывать на любой OpenAI-совместимый endpoint через адаптер `openai-chat`, а локальные и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в Logs и Usage; исторические записи пересчитываются по текущему оверлею, поэтому изменение цены может сдвинуть прошлые суммы (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); явно заданный пользователем набор нулевых ставок означает известную нулевую оценку; удалите запись модели, чтобы восстановить автоматическую цену. Нулевые цены каталога по-прежнему переходят к следующему источнику. Каждая ставка должна быть неотрицательным конечным числом не более 1 000 000 (USD за 1M токенов); строки вне диапазона отклоняются на управляющей границе и отбрасываются при загрузке. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | | `headers?` | `Record` | Дополнительные upstream-header'ы. Заголовки авторизации, cookie, API-key-header'ы, встроенные переводы строк и невалидные имена отклоняются. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Предпочтения по умолчанию для OpenRouter (`order`, `only`, `allowFallbacks`); валидно только для канонического OpenRouter с `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact override по model id, которые полностью заменяют provider-wide preference для OpenRouter. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 3e01051010..65d0d7eca7 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -111,7 +111,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `modelAutoCompactTokenLimits?` | `Record` | Model başına pozitif güvenli tamsayı biçiminde yumuşak otomatik sıkıştırma bütçeleri. Değerler yalnızca bağlamın veya maksimum girdinin etkin %90 zarfını düşürebilir ve yetkili bir bağlam penceresi bilinmiyorsa yayımlanmaz. Canonical `openai` için anahtarlar, sağlayıcı veya hesap seçici öneki olmadan desteklenen tam yerel model kimlikleri olmalıdır. Sağlayıcı PATCH girdileri birleştirir; bir anahtarı `null` yapmak o anahtarı siler, alanın tamamını `null` yapmak haritayı temizler. Bu `null` silme işaretleri yalnızca PATCH içindir. | | `defaultMaxOutputTokens?` | `number` | İstemci `max_output_tokens` değerini atladığında sağlayıcı genelinde `openai-chat` geri dönüşü. | | `modelMaxOutputTokens?` | `Record` | Pozitif model başına `openai-chat` geri dönüş bütçeleri; tam/kalıp eşleşmeleri sağlayıcı varsayılanını yener. | -| `modelCosts?` | `Record` | Sağlayıcının tam yukarı akış model kimliğine göre anahtarlanan model başına görüntüleme fiyatları (1M token başına USD) — bir sağlayıcı tanımlayıcısı veya yönlendirilen `provider/model` etiketi değil, örn. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Herhangi bir model kimliği geçerli bir anahtardır — özel sağlayıcılar `openai-chat` adaptörü aracılığıyla herhangi bir OpenAI uyumlu uç noktayı hedefleyebilir ve yerel veya dahili sağlayıcı kimlikleri yerleşik kataloglarda bulunmasalar bile çalışır. Kullanıcı tarafından yapılandırılan fiyatlar Günlükler `~$` ve Kullanım tahminlerinde yerleşik katalogları yener; geçmiş girdiler geçerli katmandan yeniden fiyatlandırılır, bu nedenle bir fiyatı düzenlemek geçmiş toplamları değiştirebilir. Geri dönüş sırası: kullanıcı `modelCosts` → jawcode kataloğu → beklenen fiyat katmanı → model düzeyinde satıcı geri dönüşü ve tamamen sıfır bir girdi bu dizideki bir sonraki kaynağa düşer. Her oran en fazla 1.000.000 (1M token başına USD) olan negatif olmayan sonlu bir sayı olmalıdır; aralık dışı satırlar yönetim sınırı tarafından reddedilir ve yükleme sırasında bırakılır. Yalnızca görüntüleme zamanı tahmini: katmanlar yönlendirmeyi, hesap seçimini, kotaları veya faturalandırmayı asla etkilemez. | +| `modelCosts?` | `Record` | Sağlayıcının tam yukarı akış model kimliğine göre anahtarlanan model başına görüntüleme fiyatları (1M token başına USD) — bir sağlayıcı tanımlayıcısı veya yönlendirilen `provider/model` etiketi değil, örn. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Herhangi bir model kimliği geçerli bir anahtardır — özel sağlayıcılar `openai-chat` adaptörü aracılığıyla herhangi bir OpenAI uyumlu uç noktayı hedefleyebilir ve yerel veya dahili sağlayıcı kimlikleri yerleşik kataloglarda bulunmasalar bile çalışır. Kullanıcı tarafından yapılandırılan fiyatlar Günlükler `~$` ve Kullanım tahminlerinde yerleşik katalogları yener; geçmiş girdiler geçerli katmandan yeniden fiyatlandırılır, bu nedenle bir fiyatı düzenlemek geçmiş toplamları değiştirebilir. Geri dönüş sırası: kullanıcı `modelCosts` → jawcode kataloğu → beklenen fiyat katmanı → model düzeyinde satıcı geri dönüşü ve kullanıcının açıkça sıfır olarak belirlediği oranlar bilinen sıfır maliyetli bir tahmin üretir; otomatik fiyatlandırmaya dönmek için model girdisini silin. Tamamen sıfır katalog fiyatları bir sonraki kaynağa geçmeye devam eder. Her oran en fazla 1.000.000 (1M token başına USD) olan negatif olmayan sonlu bir sayı olmalıdır; aralık dışı satırlar yönetim sınırı tarafından reddedilir ve yükleme sırasında bırakılır. Yalnızca görüntüleme zamanı tahmini: katmanlar yönlendirmeyi, hesap seçimini, kotaları veya faturalandırmayı asla etkilemez. | | `headers?` | `Record` | Ek yukarı akış başlıkları. Yetkilendirme, çerezler, API anahtarı başlıkları, gömülü yeni satırlar ve geçersiz adlar reddedilir. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Varsayılan OpenRouter `order`, `only` ve `allowFallbacks` tercihleri; yalnızca `openai-chat` ile kurallı OpenRouter için geçerlidir. | | `modelOpenRouterRouting?` | `Record` | Sağlayıcı genelindeki OpenRouter tercihinin yerini alan tam model kimliği geçersiz kılmaları. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 32f2b52a69..e01ab6f51a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -93,7 +93,7 @@ selector,而不是分配一个新名称。 | `modelAutoCompactTokenLimits?` | `Record` | 按模型设置的正安全整数软自动压缩预算。该值只能降低“上下文或最大输入的 90%”这一有效上限;没有已知的权威上下文窗口时不会输出。对于规范 `openai`,键必须是受支持的精确原生模型 ID,且不得包含提供者或账户选择器前缀。提供者 PATCH 会合并条目;将某个键设为 `null` 会删除该键,将整个字段设为 `null` 会清空映射。这些 `null` 删除标记仅适用于 PATCH。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | | `modelMaxOutputTokens?` | `Record` | 正数型、按模型设置的 `openai-chat` 回退预算;精确/模式匹配优先于提供者默认值。 | -| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以该提供者的精确上游模型 ID 为键(不是提供者标识符或路由后的 `provider/model` 标签),值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——自定义提供者可以通过 `openai-chat` 适配器指向任意 OpenAI 兼容端点,即使不存在于内置目录中,本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 和 Usage 估算中优先于内置目录;历史条目也会按当前覆盖项重新计价,因此修改价格可能改变过去的总额(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。每个费率必须是大于等于 0 的有限数字,且不超过 1,000,000(每 100 万 token 的美元数);超出范围的条目会在管理边界被拒绝,并在加载时被丢弃。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | +| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以该提供者的精确上游模型 ID 为键(不是提供者标识符或路由后的 `provider/model` 标签),值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——自定义提供者可以通过 `openai-chat` 适配器指向任意 OpenAI 兼容端点,即使不存在于内置目录中,本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 和 Usage 估算中优先于内置目录;历史条目也会按当前覆盖项重新计价,因此修改价格可能改变过去的总额(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);用户明确将所有费率设为零时,会得到已知的零费用估算;删除该模型的覆盖项即可恢复自动定价。目录中的全零价格仍会回退到下一个来源。每个费率必须是大于等于 0 的有限数字,且不超过 1,000,000(每 100 万 token 的美元数);超出范围的条目会在管理边界被拒绝,并在加载时被丢弃。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | | `headers?` | `Record` | 额外的上游请求头。会拒绝 Authorization、cookie、API key 头、嵌入换行符以及无效名称。 | | `openRouterRouting?` | `OpenRouterProviderRouting` | 默认的 OpenRouter `order`、`only` 和 `allowFallbacks` 偏好;仅对使用 `openai-chat` 的规范 OpenRouter 有效。 | | `modelOpenRouterRouting?` | `Record` | 精确模型 id 级别的覆盖项,会替换提供者级 OpenRouter 偏好。 | diff --git a/gui/src/components/ModelPriceDialog.tsx b/gui/src/components/ModelPriceDialog.tsx new file mode 100644 index 0000000000..59f385a841 --- /dev/null +++ b/gui/src/components/ModelPriceDialog.tsx @@ -0,0 +1,237 @@ +import { Fragment, useCallback, useEffect, useId, useRef, useState } from "react"; +import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; +import { readJsonOrThrow } from "../fetch-json"; +import { useT, type TKey } from "../i18n/shared"; +import type { ModelRow } from "../pages/models-shared"; + +interface Cost4 { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +const RATE_FIELDS = ["input", "output", "cacheRead", "cacheWrite"] as const; +const RATE_LABELS: Record = { + input: "pricing.override.input", + output: "pricing.override.output", + cacheRead: "pricing.override.cacheRead", + cacheWrite: "pricing.override.cacheWrite", +}; +const MAX_RATE = 1_000_000; +const REQUEST_TIMEOUT_MS = 60_000; +const EMPTY_DRAFT = { input: "", output: "", cacheRead: "", cacheWrite: "" }; +type Phase = "loading" | "loadFailed" | "ready" | "saving" | "unknown" | "refreshing" | "refreshFailed"; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isCost(value: unknown): value is Cost4 { + return isRecord(value) && RATE_FIELDS.every(field => ( + typeof value[field] === "number" && Number.isFinite(value[field]) + && value[field] >= 0 && value[field] <= MAX_RATE + )); +} + +interface ModelPriceDialogProps { + model: ModelRow; + apiBase: string; + onRefresh: (signal: AbortSignal) => Promise; + onClose: () => void; +} + +export default function ModelPriceDialog({ model, apiBase, onRefresh, onClose }: ModelPriceDialogProps) { + const t = useT(); + const id = useId(); + const dialogRef = useRef(null); + const inputRef = useRef(null); + const submitRef = useRef(null); + const requestRef = useRef(null); + const mutationPendingRef = useRef(false); + const [phase, setPhase] = useState("loading"); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [hasOverride, setHasOverride] = useState(false); + const [errorKey, setErrorKey] = useState(null); + const [recovered, setRecovered] = useState(false); + const endpoint = `${apiBase}/api/providers/${encodeURIComponent(model.provider)}/model-costs`; + const mutating = phase === "saving" || phase === "refreshing"; + const locked = phase !== "ready"; + + const readOverride = useCallback((recover = false) => { + if (requestRef.current) return; + const bounded = createBoundedFetch(REQUEST_TIMEOUT_MS); + requestRef.current = bounded; + void fetch(endpoint, { signal: bounded.signal, cache: "no-store" }).then(async response => { + const result = await readJsonOrThrow(response); + bounded.signal.throwIfAborted(); + if (!isRecord(result) || result.provider !== model.provider || !isRecord(result.modelCosts)) { + throw new Error("invalid model-costs response"); + } + const cost = Object.hasOwn(result.modelCosts, model.id) ? result.modelCosts[model.id] : undefined; + if (cost !== undefined && !isCost(cost)) throw new Error("invalid model cost"); + if (requestRef.current !== bounded) return; + setDraft(cost === undefined ? EMPTY_DRAFT : { + input: String(cost.input), output: String(cost.output), + cacheRead: String(cost.cacheRead), cacheWrite: String(cost.cacheWrite), + }); + setHasOverride(cost !== undefined); + // This read recovers an editable snapshot, not ordering against an earlier + // request still running on the server or writes from another client. + setRecovered(recover); + setPhase("ready"); + }).catch(() => { + if (requestRef.current !== bounded) return; + setPhase(recover ? "unknown" : "loadFailed"); + setErrorKey(recover ? "pricing.override.recoveryFailed" : "pricing.override.loadFailed"); + }).finally(() => { + bounded.clear(); + if (requestRef.current === bounded) requestRef.current = null; + }); + }, [endpoint, model.id, model.provider]); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + void readOverride(); + return () => { + requestRef.current?.controller.abort(); + requestRef.current?.clear(); + requestRef.current = null; + if (dialog?.open) dialog.close(); + }; + }, [readOverride]); + + useEffect(() => { + if (phase === "ready") inputRef.current?.focus(); + else if (phase === "unknown" || phase === "loadFailed" || phase === "refreshFailed") submitRef.current?.focus(); + }, [phase]); + + // undefined retries only catalog refresh after a validated persistence receipt. + const save = async (cost: Cost4 | null | undefined) => { + if (requestRef.current || (cost === undefined ? phase !== "refreshFailed" : phase !== "ready")) return; + const bounded = createBoundedFetch(REQUEST_TIMEOUT_MS); + requestRef.current = bounded; + setPhase(cost === undefined ? "refreshing" : "saving"); + mutationPendingRef.current = true; + setErrorKey(null); + let confirmed = cost === undefined; + try { + if (cost !== undefined) { + const response = await fetch(endpoint, { + method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelId: model.id, cost }), signal: bounded.signal, + }); + const result = await readJsonOrThrow(response); + bounded.signal.throwIfAborted(); + const receiptCost = isRecord(result) ? result.cost : undefined; + if (!isRecord(result) || result.ok !== true || result.provider !== model.provider + || result.modelId !== model.id || (cost === null ? receiptCost !== null + : !isCost(receiptCost) || !RATE_FIELDS.every(field => receiptCost[field] === cost[field]))) { + throw new Error("invalid model-costs receipt"); + } + if (requestRef.current !== bounded) return; + confirmed = true; + setPhase("refreshing"); + } + if (!await onRefresh(bounded.signal)) throw new Error("catalog refresh failed"); + bounded.signal.throwIfAborted(); + if (requestRef.current === bounded) onClose(); + } catch { + if (requestRef.current !== bounded) return; + setPhase(confirmed ? "refreshFailed" : "unknown"); + setErrorKey(confirmed ? "pricing.override.refreshFailed" : "pricing.override.outcomeUnknown"); + } finally { + bounded.clear(); + if (requestRef.current === bounded) { + requestRef.current = null; + mutationPendingRef.current = false; + } + } + }; + + const requestClose = () => { + if (!mutationPendingRef.current) onClose(); + }; + + return ( + { event.preventDefault(); requestClose(); }}> + + +
+ {t("pricing.override.modelId")} + {model.namespaced} +
+

{t("pricing.override.help")}

+ {phase === "loading" &&

{t("pricing.override.loading")}

} + {RATE_FIELDS.map(field => ( + + + { + if (locked || requestRef.current) return; + const value = event.target.value; + setDraft(current => ({ + ...current, + cacheRead: current.cacheRead || "0", cacheWrite: current.cacheWrite || "0", + [field]: value, + })); + setErrorKey(null); + }} /> + + ))} + {recovered &&

{t("pricing.override.recovered")}

} + {errorKey && } +
+ + + +
+ +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4145cb8032..79d1591728 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2591,4 +2591,38 @@ export const de: Record = { "models.displayNameTooLong": "Der Anzeigename darf höchstens 128 Zeichen lang sein.", "models.displayNameNoSlash": "Der Anzeigename darf kein / enthalten.", "models.displayNameNoControl": "Der Anzeigename darf keine Steuerzeichen enthalten.", + "pricing.override.action": "Preis", + "pricing.override.actionLabel": "Preis für {model} bearbeiten", + "pricing.override.badge": "Manueller Preis", + "pricing.override.title": "Modellpreis", + "pricing.override.modelId": "Modell-ID", + "pricing.override.help": "USD pro 1 Mio. Token. Ein- und Ausgaberaten eingeben; leere Cache-Raten gelten als 0. Vier Raten von 0 bedeuten kostenlos.", + "pricing.override.input": "Eingabe", + "pricing.override.output": "Ausgabe", + "pricing.override.cacheRead": "Cache lesen", + "pricing.override.cacheWrite": "Cache schreiben", + "pricing.override.loading": "Gespeicherten Preis laden…", + "pricing.override.loadFailed": "Der gespeicherte Preis konnte nicht geladen werden. Erneut laden.", + "pricing.override.outcomeUnknown": "Das Ergebnis der Anfrage ist unklar. Der Preis könnte geändert worden sein. Vor weiteren Änderungen den gespeicherten Preis neu laden.", + "pricing.override.recoveryFailed": "Der gespeicherte Preis konnte nicht ermittelt werden. Die Bearbeitung bleibt gesperrt; erneut laden.", + "pricing.override.recovered": "Aktueller gespeicherter Preis geladen. Die frühere Anfrage oder ein anderer Client kann ihn noch ändern.", + "pricing.override.refreshFailed": "Der Preis wurde gespeichert, aber die Modellliste konnte nicht aktualisiert werden. Die Liste erneut aktualisieren.", + "pricing.override.invalid": "Ein- und Ausgaberaten eingeben. Jede Rate muss eine endliche Zahl zwischen 0 und 1.000.000 sein.", + "pricing.override.reset": "Automatischen Preis verwenden", + "pricing.override.save": "Speichern", + "pricing.override.saving": "Speichern…", + "pricing.override.reload": "Preis neu laden", + "pricing.override.refresh": "Liste aktualisieren", + "pricing.override.cancel": "Abbrechen", + "pricing.override.close": "Schließen", + "usage.range.custom": "Eigener Zeitraum", + "usage.range.start": "Beginn (Ortszeit)", + "usage.range.end": "Ende (Ortszeit)", + "usage.range.apply": "Anwenden", + "usage.range.clear": "Zurücksetzen", + "usage.range.help": "Ortszeit. Die gesamte Endminute ist enthalten.", + "usage.range.required": "Geben Sie Datum und Uhrzeit für Beginn und Ende ein.", + "usage.range.invalid": "Geben Sie gültige lokale Daten und Uhrzeiten ab 1970-01-01 UTC ein.", + "usage.range.reversed": "Das Ende muss auf oder nach dem Beginn liegen.", + "usage.range.applied": "Ausgewählter Zeitraum: {start} – {end} (beide Grenzen eingeschlossen).", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2a31ec7947..cceef53d12 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2625,6 +2625,40 @@ export const en = { "models.displayNameTooLong": "Friendly name must be 128 characters or fewer.", "models.displayNameNoSlash": "Friendly name cannot contain /.", "models.displayNameNoControl": "Friendly name cannot contain control characters.", + "pricing.override.action": "Price", + "pricing.override.actionLabel": "Edit price for {model}", + "pricing.override.badge": "Manual price", + "pricing.override.title": "Model price", + "pricing.override.modelId": "Model ID", + "pricing.override.help": "USD per 1M tokens. Enter input and output rates; blank cache rates use 0. All four rates set to 0 mean free.", + "pricing.override.input": "Input", + "pricing.override.output": "Output", + "pricing.override.cacheRead": "Cache read", + "pricing.override.cacheWrite": "Cache write", + "pricing.override.loading": "Loading saved price…", + "pricing.override.loadFailed": "Could not load the saved price. Reload to try again.", + "pricing.override.outcomeUnknown": "The request did not finish reliably. The price may have changed. Reload the saved price before editing again.", + "pricing.override.recoveryFailed": "Could not recover the saved price. Editing stays locked; reload to try again.", + "pricing.override.recovered": "Latest saved price loaded. The earlier request or another client may still change it.", + "pricing.override.refreshFailed": "The price was saved, but the model list could not be refreshed. Retry the list refresh.", + "pricing.override.invalid": "Enter input and output rates. Every rate must be a finite number from 0 to 1,000,000.", + "pricing.override.reset": "Reset to automatic", + "pricing.override.save": "Save", + "pricing.override.saving": "Saving…", + "pricing.override.reload": "Reload price", + "pricing.override.refresh": "Refresh list", + "pricing.override.cancel": "Cancel", + "pricing.override.close": "Close", + "usage.range.custom": "Custom date range", + "usage.range.start": "Start (local time)", + "usage.range.end": "End (local time)", + "usage.range.apply": "Apply", + "usage.range.clear": "Clear", + "usage.range.help": "Local time. Includes the entire end minute.", + "usage.range.required": "Enter both a start and an end date and time.", + "usage.range.invalid": "Enter valid local dates and times, on or after 1970-01-01 UTC.", + "usage.range.reversed": "The end must be at or after the start.", + "usage.range.applied": "Selected interval: {start} – {end} (both inclusive).", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 753982c1bf..67b2586fe4 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2578,4 +2578,38 @@ export const fr: Record = { "models.displayNameTooLong": "Le nom d’affichage doit contenir au maximum 128 caractères.", "models.displayNameNoSlash": "Le nom d’affichage ne peut pas contenir /.", "models.displayNameNoControl": "Le nom d’affichage ne peut pas contenir de caractères de contrôle.", + "pricing.override.action": "Prix", + "pricing.override.actionLabel": "Modifier le prix de {model}", + "pricing.override.badge": "Prix manuel", + "pricing.override.title": "Prix du modèle", + "pricing.override.modelId": "ID du modèle", + "pricing.override.help": "USD par million de tokens. Saisissez les tarifs d’entrée et de sortie ; un tarif de cache vide vaut 0. Quatre tarifs à 0 signifient gratuit.", + "pricing.override.input": "Entrée", + "pricing.override.output": "Sortie", + "pricing.override.cacheRead": "Lecture du cache", + "pricing.override.cacheWrite": "Écriture du cache", + "pricing.override.loading": "Chargement du prix enregistré…", + "pricing.override.loadFailed": "Impossible de charger le prix enregistré. Rechargez pour réessayer.", + "pricing.override.outcomeUnknown": "Le résultat de la requête est incertain. Le prix a peut-être changé. Rechargez le prix enregistré avant toute autre modification.", + "pricing.override.recoveryFailed": "Impossible de récupérer le prix enregistré. La modification reste verrouillée ; rechargez pour réessayer.", + "pricing.override.recovered": "Le prix actuellement enregistré est chargé. La requête précédente ou un autre client peut encore le modifier.", + "pricing.override.refreshFailed": "Le prix est enregistré, mais la liste des modèles n’a pas pu être actualisée. Réessayez l’actualisation.", + "pricing.override.invalid": "Saisissez les tarifs d’entrée et de sortie. Chaque tarif doit être un nombre fini entre 0 et 1 000 000.", + "pricing.override.reset": "Revenir au prix automatique", + "pricing.override.save": "Enregistrer", + "pricing.override.saving": "Enregistrement…", + "pricing.override.reload": "Recharger le prix", + "pricing.override.refresh": "Actualiser la liste", + "pricing.override.cancel": "Annuler", + "pricing.override.close": "Fermer", + "usage.range.custom": "Période personnalisée", + "usage.range.start": "Début (heure locale)", + "usage.range.end": "Fin (heure locale)", + "usage.range.apply": "Appliquer", + "usage.range.clear": "Effacer", + "usage.range.help": "Heure locale. La dernière minute est entièrement incluse.", + "usage.range.required": "Saisissez la date et l’heure de début et de fin.", + "usage.range.invalid": "Saisissez des dates et heures locales valides à partir du 1970-01-01 UTC.", + "usage.range.reversed": "La fin doit être égale ou postérieure au début.", + "usage.range.applied": "Période sélectionnée : {start} – {end} (bornes incluses).", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 300d1f8c51..92bb516827 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2612,4 +2612,38 @@ export const ja: Record = { "models.displayNameTooLong": "表示名は 128 文字以内にしてください。", "models.displayNameNoSlash": "表示名に / は使用できません。", "models.displayNameNoControl": "表示名に制御文字は使用できません。", + "pricing.override.action": "価格", + "pricing.override.actionLabel": "{model} の価格を編集", + "pricing.override.badge": "手動価格", + "pricing.override.title": "モデル価格", + "pricing.override.modelId": "モデル ID", + "pricing.override.help": "100万トークンあたりの USD です。入力・出力単価を入力してください。空のキャッシュ単価は 0 とし、4項目すべてが 0 なら無料です。", + "pricing.override.input": "入力", + "pricing.override.output": "出力", + "pricing.override.cacheRead": "キャッシュ読み取り", + "pricing.override.cacheWrite": "キャッシュ書き込み", + "pricing.override.loading": "保存済み価格を読み込み中…", + "pricing.override.loadFailed": "保存済み価格を読み込めませんでした。再読み込みしてください。", + "pricing.override.outcomeUnknown": "リクエストの結果を確認できませんでした。価格が変更された可能性があります。編集する前に保存済み価格を再読み込みしてください。", + "pricing.override.recoveryFailed": "保存済み価格を確認できないため、編集はロックされています。再読み込みしてください。", + "pricing.override.recovered": "現在の保存済み価格を読み込みました。先ほどのリクエストや別のクライアントが後から変更する可能性があります。", + "pricing.override.refreshFailed": "価格は保存されましたが、モデル一覧を更新できませんでした。一覧の更新を再試行してください。", + "pricing.override.invalid": "入力・出力単価を入力してください。各単価は 0 以上 1,000,000 以下の有限の数値にしてください。", + "pricing.override.reset": "自動価格に戻す", + "pricing.override.save": "保存", + "pricing.override.saving": "保存中…", + "pricing.override.reload": "価格を再読み込み", + "pricing.override.refresh": "一覧を更新", + "pricing.override.cancel": "キャンセル", + "pricing.override.close": "閉じる", + "usage.range.custom": "期間を指定", + "usage.range.start": "開始(現地時間)", + "usage.range.end": "終了(現地時間)", + "usage.range.apply": "適用", + "usage.range.clear": "解除", + "usage.range.help": "現地時間です。終了時刻の分全体を含みます。", + "usage.range.required": "開始と終了の日時を両方入力してください。", + "usage.range.invalid": "1970-01-01 UTC以降の有効な現地日時を入力してください。", + "usage.range.reversed": "終了日時は開始日時と同じか、それ以降にしてください。", + "usage.range.applied": "選択した期間:{start} – {end}(両端を含む)。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 42c000c6bb..7f02d3792f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2613,4 +2613,38 @@ export const ko: Record = { "models.displayNameTooLong": "표시 이름은 128자 이하여야 합니다.", "models.displayNameNoSlash": "표시 이름에 /를 사용할 수 없습니다.", "models.displayNameNoControl": "표시 이름에 제어 문자를 사용할 수 없습니다.", + "pricing.override.action": "가격", + "pricing.override.actionLabel": "{model} 가격 편집", + "pricing.override.badge": "수동 가격", + "pricing.override.title": "모델 가격", + "pricing.override.modelId": "모델 ID", + "pricing.override.help": "토큰 100만 개당 USD입니다. 입력·출력 요율을 입력하세요. 빈 캐시 요율은 0으로 처리하며, 네 요율이 모두 0이면 무료입니다.", + "pricing.override.input": "입력", + "pricing.override.output": "출력", + "pricing.override.cacheRead": "캐시 읽기", + "pricing.override.cacheWrite": "캐시 쓰기", + "pricing.override.loading": "저장된 가격을 불러오는 중…", + "pricing.override.loadFailed": "저장된 가격을 불러오지 못했습니다. 다시 불러와 주세요.", + "pricing.override.outcomeUnknown": "요청 결과를 확인하지 못했습니다. 가격이 변경되었을 수 있으니 저장된 가격을 다시 불러온 뒤 편집하세요.", + "pricing.override.recoveryFailed": "저장된 가격을 확인하지 못해 편집이 잠겨 있습니다. 다시 불러와 주세요.", + "pricing.override.recovered": "현재 저장된 가격을 불러왔습니다. 이전 요청이나 다른 클라이언트가 이후에 가격을 변경할 수 있습니다.", + "pricing.override.refreshFailed": "가격은 저장했지만 모델 목록을 갱신하지 못했습니다. 목록 갱신을 다시 시도하세요.", + "pricing.override.invalid": "입력·출력 요율을 입력하세요. 모든 요율은 0 이상 1,000,000 이하의 유한한 숫자여야 합니다.", + "pricing.override.reset": "자동 가격으로 복원", + "pricing.override.save": "저장", + "pricing.override.saving": "저장 중…", + "pricing.override.reload": "가격 다시 불러오기", + "pricing.override.refresh": "목록 갱신", + "pricing.override.cancel": "취소", + "pricing.override.close": "닫기", + "usage.range.custom": "기간 직접 지정", + "usage.range.start": "시작 (현지 시간)", + "usage.range.end": "종료 (현지 시간)", + "usage.range.apply": "적용", + "usage.range.clear": "해제", + "usage.range.help": "현지 시간 기준이며, 종료 시각의 마지막 분 전체를 포함합니다.", + "usage.range.required": "시작과 종료 날짜 및 시간을 모두 입력하세요.", + "usage.range.invalid": "1970-01-01 UTC 이후의 유효한 현지 날짜와 시간을 입력하세요.", + "usage.range.reversed": "종료 시각은 시작 시각과 같거나 이후여야 합니다.", + "usage.range.applied": "선택한 기간: {start} – {end} (양 끝 시각 포함).", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 51fcaef210..9548be7d64 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2614,4 +2614,38 @@ export const ru: Record = { "models.displayNameTooLong": "Понятное имя должно содержать не более 128 символов.", "models.displayNameNoSlash": "Понятное имя не может содержать /.", "models.displayNameNoControl": "Понятное имя не может содержать управляющие символы.", + "pricing.override.action": "Цена", + "pricing.override.actionLabel": "Изменить цену для {model}", + "pricing.override.badge": "Своя цена", + "pricing.override.title": "Цена модели", + "pricing.override.modelId": "ID модели", + "pricing.override.help": "USD за 1 млн токенов. Укажите входной и выходной тарифы; пустые тарифы кеша равны 0. Четыре нулевых тарифа означают бесплатное использование.", + "pricing.override.input": "Вход", + "pricing.override.output": "Выход", + "pricing.override.cacheRead": "Чтение кеша", + "pricing.override.cacheWrite": "Запись кеша", + "pricing.override.loading": "Загрузка сохранённой цены…", + "pricing.override.loadFailed": "Не удалось загрузить сохранённую цену. Повторите загрузку.", + "pricing.override.outcomeUnknown": "Результат запроса неизвестен. Цена могла измениться. Загрузите сохранённую цену перед следующим изменением.", + "pricing.override.recoveryFailed": "Не удалось получить сохранённую цену. Редактирование заблокировано; повторите загрузку.", + "pricing.override.recovered": "Текущая сохранённая цена загружена. Предыдущий запрос или другой клиент ещё может изменить её.", + "pricing.override.refreshFailed": "Цена сохранена, но список моделей не обновлён. Повторите обновление списка.", + "pricing.override.invalid": "Укажите входной и выходной тарифы. Каждый тариф должен быть конечным числом от 0 до 1 000 000.", + "pricing.override.reset": "Вернуть автоматическую цену", + "pricing.override.save": "Сохранить", + "pricing.override.saving": "Сохранение…", + "pricing.override.reload": "Загрузить цену", + "pricing.override.refresh": "Обновить список", + "pricing.override.cancel": "Отмена", + "pricing.override.close": "Закрыть", + "usage.range.custom": "Произвольный период", + "usage.range.start": "Начало (местное время)", + "usage.range.end": "Конец (местное время)", + "usage.range.apply": "Применить", + "usage.range.clear": "Сбросить", + "usage.range.help": "Местное время. Последняя минута включена целиком.", + "usage.range.required": "Введите дату и время начала и конца.", + "usage.range.invalid": "Введите допустимые местные дату и время не ранее 1970-01-01 UTC.", + "usage.range.reversed": "Конец не может быть раньше начала.", + "usage.range.applied": "Выбранный период: {start} – {end} (обе границы включены).", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c764bf11ca..7b835eb03e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2614,4 +2614,38 @@ export const tr: Record = { "models.displayNameTooLong": "Görünen ad en fazla 128 karakter olabilir.", "models.displayNameNoSlash": "Görünen ad / içeremez.", "models.displayNameNoControl": "Görünen ad denetim karakterleri içeremez.", + "pricing.override.action": "Fiyat", + "pricing.override.actionLabel": "{model} fiyatını düzenle", + "pricing.override.badge": "Elle belirlenen fiyat", + "pricing.override.title": "Model fiyatı", + "pricing.override.modelId": "Model kimliği", + "pricing.override.help": "1 milyon token başına USD. Giriş ve çıkış ücretlerini girin; boş önbellek ücretleri 0 sayılır. Dört ücret de 0 ise ücretsizdir.", + "pricing.override.input": "Giriş", + "pricing.override.output": "Çıkış", + "pricing.override.cacheRead": "Önbellek okuma", + "pricing.override.cacheWrite": "Önbellek yazma", + "pricing.override.loading": "Kayıtlı fiyat yükleniyor…", + "pricing.override.loadFailed": "Kayıtlı fiyat yüklenemedi. Yeniden yükleyin.", + "pricing.override.outcomeUnknown": "İsteğin sonucu doğrulanamadı. Fiyat değişmiş olabilir. Yeniden düzenlemeden önce kayıtlı fiyatı yükleyin.", + "pricing.override.recoveryFailed": "Kayıtlı fiyat alınamadı. Düzenleme kilitli kalır; yeniden yükleyin.", + "pricing.override.recovered": "Güncel kayıtlı fiyat yüklendi. Önceki istek veya başka bir istemci fiyatı hâlâ değiştirebilir.", + "pricing.override.refreshFailed": "Fiyat kaydedildi ancak model listesi yenilenemedi. Listeyi yeniden yenileyin.", + "pricing.override.invalid": "Giriş ve çıkış ücretlerini girin. Her ücret 0 ile 1.000.000 arasında sonlu bir sayı olmalıdır.", + "pricing.override.reset": "Otomatik fiyata dön", + "pricing.override.save": "Kaydet", + "pricing.override.saving": "Kaydediliyor…", + "pricing.override.reload": "Fiyatı yeniden yükle", + "pricing.override.refresh": "Listeyi yenile", + "pricing.override.cancel": "İptal", + "pricing.override.close": "Kapat", + "usage.range.custom": "Özel tarih aralığı", + "usage.range.start": "Başlangıç (yerel saat)", + "usage.range.end": "Bitiş (yerel saat)", + "usage.range.apply": "Uygula", + "usage.range.clear": "Temizle", + "usage.range.help": "Yerel saat. Bitiş dakikasının tamamı dahildir.", + "usage.range.required": "Başlangıç ve bitiş için tarih ve saat girin.", + "usage.range.invalid": "1970-01-01 UTC veya sonrasına ait geçerli yerel tarih ve saat girin.", + "usage.range.reversed": "Bitiş, başlangıçla aynı veya daha sonra olmalıdır.", + "usage.range.applied": "Seçilen aralık: {start} – {end} (iki sınır da dahil).", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ab6aedd67a..cd0b7e17d3 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2576,4 +2576,38 @@ export const zhTW: Record = { "models.displayNameTooLong": "友善名稱不能超過 128 個字元。", "models.displayNameNoSlash": "友善名稱不能包含 /。", "models.displayNameNoControl": "友善名稱不能包含控制字元。", + "pricing.override.action": "價格", + "pricing.override.actionLabel": "編輯 {model} 的價格", + "pricing.override.badge": "手動價格", + "pricing.override.title": "模型價格", + "pricing.override.modelId": "模型 ID", + "pricing.override.help": "單位為每百萬 token 的美元價格。請輸入輸入與輸出費率;空白快取費率以 0 計算。四項皆為 0 表示免費。", + "pricing.override.input": "輸入", + "pricing.override.output": "輸出", + "pricing.override.cacheRead": "快取讀取", + "pricing.override.cacheWrite": "快取寫入", + "pricing.override.loading": "正在載入已儲存的價格…", + "pricing.override.loadFailed": "無法載入已儲存的價格,請重新載入。", + "pricing.override.outcomeUnknown": "無法確認請求結果,價格可能已變更。再次編輯前請重新載入已儲存的價格。", + "pricing.override.recoveryFailed": "無法取得已儲存的價格,編輯仍被鎖定。請重新載入。", + "pricing.override.recovered": "已載入目前儲存的價格。先前的請求或其他用戶端仍可能變更該價格。", + "pricing.override.refreshFailed": "價格已儲存,但無法重新整理模型清單。請重試重新整理清單。", + "pricing.override.invalid": "請輸入輸入與輸出費率。每項費率必須是 0 到 1,000,000 之間的有限數字。", + "pricing.override.reset": "恢復自動價格", + "pricing.override.save": "儲存", + "pricing.override.saving": "正在儲存…", + "pricing.override.reload": "重新載入價格", + "pricing.override.refresh": "重新整理清單", + "pricing.override.cancel": "取消", + "pricing.override.close": "關閉", + "usage.range.custom": "自訂時間範圍", + "usage.range.start": "開始(本地時間)", + "usage.range.end": "結束(本地時間)", + "usage.range.apply": "套用", + "usage.range.clear": "清除", + "usage.range.help": "使用本地時間,包含結束時刻的整分鐘。", + "usage.range.required": "請輸入開始和結束的日期及時間。", + "usage.range.invalid": "請輸入不早於 1970-01-01 UTC 的有效本地日期和時間。", + "usage.range.reversed": "結束時間必須等於或晚於開始時間。", + "usage.range.applied": "所選範圍:{start} – {end}(包含兩端)。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 052f449855..d9f93f20c7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2612,4 +2612,38 @@ export const zh: Record = { "models.displayNameTooLong": "友好名称不能超过 128 个字符。", "models.displayNameNoSlash": "友好名称不能包含 /。", "models.displayNameNoControl": "友好名称不能包含控制字符。", + "pricing.override.action": "价格", + "pricing.override.actionLabel": "编辑 {model} 的价格", + "pricing.override.badge": "手动价格", + "pricing.override.title": "模型价格", + "pricing.override.modelId": "模型 ID", + "pricing.override.help": "单位为每百万 token 的美元价格。请输入输入和输出费率;空白缓存费率按 0 计算。四项均为 0 表示免费。", + "pricing.override.input": "输入", + "pricing.override.output": "输出", + "pricing.override.cacheRead": "缓存读取", + "pricing.override.cacheWrite": "缓存写入", + "pricing.override.loading": "正在加载已保存的价格…", + "pricing.override.loadFailed": "无法加载已保存的价格,请重新加载。", + "pricing.override.outcomeUnknown": "无法确认请求结果,价格可能已更改。再次编辑前请重新加载已保存的价格。", + "pricing.override.recoveryFailed": "无法获取已保存的价格,编辑仍被锁定。请重新加载。", + "pricing.override.recovered": "已加载当前保存的价格。之前的请求或其他客户端仍可能更改该价格。", + "pricing.override.refreshFailed": "价格已保存,但无法刷新模型列表。请重试刷新列表。", + "pricing.override.invalid": "请输入输入和输出费率。每项费率必须是 0 到 1,000,000 之间的有限数字。", + "pricing.override.reset": "恢复自动价格", + "pricing.override.save": "保存", + "pricing.override.saving": "正在保存…", + "pricing.override.reload": "重新加载价格", + "pricing.override.refresh": "刷新列表", + "pricing.override.cancel": "取消", + "pricing.override.close": "关闭", + "usage.range.custom": "自定义时间范围", + "usage.range.start": "开始(本地时间)", + "usage.range.end": "结束(本地时间)", + "usage.range.apply": "应用", + "usage.range.clear": "清除", + "usage.range.help": "使用本地时间,包含结束时刻的整分钟。", + "usage.range.required": "请输入开始和结束的日期及时间。", + "usage.range.invalid": "请输入不早于 1970-01-01 UTC 的有效本地日期和时间。", + "usage.range.reversed": "结束时间必须等于或晚于开始时间。", + "usage.range.applied": "所选范围:{start} – {end}(包含两端)。", }; diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index fc8db5626e..bc010c4abc 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1,5 +1,6 @@ import { CodexStaleBanner } from "../components/codex-stale-banner"; import ModelDisplayNameDialog from "../components/ModelDisplayNameDialog"; +import ModelPriceDialog from "../components/ModelPriceDialog"; import { fetchCodexAppServerState } from "../codex-app-server-state"; import type { AppServerStateOutcome } from "../codex-app-server-state"; import { useCodexRestart } from "../use-codex-restart"; @@ -330,6 +331,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [v2HelpOpen, setV2HelpOpen] = useState(false); const [customModalOpen, setCustomModalOpen] = useState(false); const [displayNameModel, setDisplayNameModel] = useState(null); + const [priceModel, setPriceModel] = useState(null); + const priceTriggerRef = useRef(null); const [displayNameSaving, setDisplayNameSaving] = useState(false); const [displayNameRequestError, setDisplayNameRequestError] = useState(null); const [displayNameRecovery, setDisplayNameRecovery] = useState<{ @@ -1696,6 +1699,23 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; {t("models.customBadge")} )} + {!m.native && m.provider !== "combo" && ( + <> + {m.manualPricing === true && {t("pricing.override.badge")}} + + + )} {!m.custom && recentIds.has(m.id) && {t("models.newBadge")}} {m.contextCapped && {t("models.contextCappedValue", { value: fmtK(m.contextCap ?? contextCapValue) })}} @@ -2651,6 +2671,21 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; onClose={closeDisplayNameEdit} /> )} + {priceModel && ( + load(true, signal)} + onClose={() => { + const trigger = priceTriggerRef.current; + setPriceModel(null); + window.setTimeout(() => { + if (trigger?.isConnected) trigger.focus(); + }, 0); + }} + /> + )} ); diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index bd7537073b..7895ce9370 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -10,6 +10,7 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { SectionTabs } from "../components/section-tabs"; import { sectionAnchorId } from "../section-anchors"; +import { parseUsageTimeRange, type UsageRangeError, type UsageTimeWindow } from "../usage-time-range"; type Range = "all" | "30d" | "7d"; type UsageSurface = "all" | "codex" | "claude" | "grok"; @@ -79,6 +80,8 @@ interface UsageResponse { range: Range; surface: UsageSurface; since: number | null; + until?: number; + customWindow?: boolean; generatedAt: number; summary: UsageSummaryTotals; days: UsageDay[]; @@ -156,20 +159,54 @@ interface HeatmapCell { dayOfWeek: number; } -function buildHeatmap(days: UsageDay[]): { weeks: HeatmapCell[][]; months: { label: string; col: number }[]; buckets: number[] } { +function buildHeatmap(days: UsageDay[], customWindow = false): { weeks: HeatmapCell[][]; months: { label: string; col: number }[]; buckets: number[] } { const buckets = quantileBuckets(days.map(d => d.totalTokens)); + const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + if (customWindow) { + const weeks: HeatmapCell[][] = []; + const months: { label: string; col: number }[] = []; + let week: HeatmapCell[] = []; + let weekStart: number | undefined; + let previousMonth = -1; + let lastMonthCol = -4; + const pad = (length: number) => { + while (week.length < length) week.push({ date: "", requests: 0, totalTokens: 0, level: 0, dayOfWeek: week.length }); + }; + // The server already supplied the bounded civil dates. Local midnight stepping + // can retain a shifted hour across DST and omit the final day of the report. + for (const day of days) { + const [year, month, date] = day.date.split("-").map(Number); + const calendar = new Date(Date.UTC(year, month - 1, date)); + const weekday = calendar.getUTCDay(); + const nextWeekStart = calendar.getTime() - weekday * 86_400_000; + if (weekStart !== nextWeekStart) { + if (week.length > 0) { pad(7); weeks.push(week); } + week = []; + weekStart = nextWeekStart; + } + const monthIndex = calendar.getUTCMonth(); + if (monthIndex !== previousMonth && weeks.length - lastMonthCol >= 4) { + months.push({ label: monthNames[monthIndex], col: weeks.length }); + previousMonth = monthIndex; + lastMonthCol = weeks.length; + } + pad(weekday); + week.push({ date: day.date, requests: day.requests, totalTokens: day.totalTokens, + level: bucketLevel(day.totalTokens, buckets), dayOfWeek: weekday }); + } + if (week.length > 0) { pad(7); weeks.push(week); } + return { weeks, months, buckets }; + } const dayMap = new Map(days.map(d => [d.date, d])); const today = new Date(); today.setHours(0, 0, 0, 0); const start = new Date(today); start.setDate(start.getDate() - 364); - // Align to Sunday start.setDate(start.getDate() - start.getDay()); const weeks: HeatmapCell[][] = []; const months: { label: string; col: number }[] = []; - const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; let lastMonthCol = -4; let prevMonthIdx = -1; let week: HeatmapCell[] = []; @@ -214,7 +251,7 @@ function UsageFilters({ t, }: { surface: UsageSurface; - range: Range; + range: Range | null; onSurface: (surface: UsageSurface) => void; onRange: (range: Range) => void; t: TFn; @@ -378,7 +415,7 @@ function UsageHeatmapPanel({ locale, t, }: { - range: Range; + range: Range | null; heatmap: ReturnType; weekBars: UsageDay[]; locale: Locale; @@ -671,7 +708,7 @@ function UsageWorkspaceBody({ modelQuery: string; onModelQuery: (query: string) => void; sortedProviders: UsageProvider[]; - range: Range; + range: Range | null; locale: Locale; t: TFn; }) { @@ -762,31 +799,52 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas const [surface, setSurface] = useState("all"); const [scope, setScope] = useState("machine"); const [modelQuery, setModelQuery] = useState(""); + const [draftWindow, setDraftWindow] = useState({ since: "", until: "" }); + const [customWindow, setCustomWindow] = useState(null); + const [rangeError, setRangeError] = useState(null); + const since = customWindow?.since; + const until = customWindow?.until; + + const clearCustomWindow = () => { + setCustomWindow(null); + setDraftWindow({ since: "", until: "" }); + setRangeError(null); + }; + const selectRange = (next: Range) => { + setRange(next); + clearCustomWindow(); + }; const loadUsage = useCallback(async (signal: AbortSignal): Promise => { const query = new URLSearchParams({ range, surface }); if (connected && scope === "machine" && apiKeyId) query.set("apiKeyId", apiKeyId); + if (since !== undefined && until !== undefined) { + query.set("since", String(since)); + query.set("until", String(until)); + } const response = await fetch(`${apiBase}/api/usage?${query}`, { signal }); if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim()); const next = await response.json() as UsageResponse; - writeHeldUsage(apiBase, range, surface, connected, scope, apiKeyId, next); + if (since === undefined) writeHeldUsage(apiBase, range, surface, connected, scope, apiKeyId, next); return next; - }, [apiBase, apiKeyId, connected, range, scope, surface]); + }, [apiBase, apiKeyId, connected, range, scope, surface, since, until]); - const resourceKey = usageCacheKey(apiBase, range, surface, connected, scope, apiKeyId); - const cached = readHeldUsage(apiBase, range, surface, connected, scope, apiKeyId); + const presetKey = usageCacheKey(apiBase, range, surface, connected, scope, apiKeyId); + const resourceKey = customWindow ? JSON.stringify([presetKey, since, until]) : presetKey; + // Arbitrary custom windows belong only to the subscription-scoped resource store. + const cached = customWindow ? null : readHeldUsage(apiBase, range, surface, connected, scope, apiKeyId); // Range and surface identify different reports, so the key changes with both. That prevents // a force-loading dependency revalidation from ever showing a previous report as this one. const resource = useDataSurface( resourceKey, - [apiBase, apiKeyId, connected, range, scope, surface], + [apiBase, apiKeyId, connected, range, scope, surface, since, until], loadUsage, { isEmpty: () => false, initialData: cached ?? undefined }, ); const { state } = resource; const data = state.data ?? cached ?? null; - const heatmap = useMemo(() => buildHeatmap(data?.days ?? []), [data?.days]); + const heatmap = useMemo(() => buildHeatmap(data?.days ?? [], !!customWindow), [data?.days, customWindow]); const weekBars = useMemo(() => lastSevenDays(data?.days ?? []), [data?.days]); const activeDays = useMemo(() => (data?.days ?? []).filter(d => d.requests > 0).length, [data?.days]); const filteredModels = useMemo(() => { @@ -810,9 +868,57 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas <>

{t("usage.title")}

- +

{t("usage.subtitle")}

+
{ + event.preventDefault(); + const result = parseUsageTimeRange(draftWindow.since, draftWindow.until); + if (result.ok === false) { + setRangeError(result.error); + return; + } + setRangeError(null); + setCustomWindow(result.window); + }}> +
+ + + + +
+

{t("usage.range.help")}

+ {rangeError && } + {customWindow &&

{(() => { + const formatter = new Intl.DateTimeFormat(locale, { + year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", + second: "2-digit", fractionalSecondDigits: 3, timeZoneName: "short", + }); + return t("usage.range.applied", { start: formatter.format(customWindow.since), end: formatter.format(customWindow.until) }); + })()}

} +
{/* Only shown when connected. Naming the source is a two-plane concept: it answers "which store served these numbers", and that question only exists once there are @@ -869,7 +975,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas modelQuery={modelQuery} onModelQuery={setModelQuery} sortedProviders={sortedProviders} - range={range} + range={customWindow ? null : range} locale={locale} t={t} /> diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 1f5ef7786b..19d9bb67f7 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -37,6 +37,7 @@ export interface ModelRow { displayName?: string; displayNameOverride?: string; displayNameSource?: "operator" | "provider" | "fallback"; + manualPricing?: boolean; inputModalities?: string[]; contextWindow?: number; contextCap?: number; diff --git a/gui/src/usage-time-range.ts b/gui/src/usage-time-range.ts new file mode 100644 index 0000000000..eebc445ad2 --- /dev/null +++ b/gui/src/usage-time-range.ts @@ -0,0 +1,31 @@ +export interface UsageTimeWindow { + since: number; + until: number; +} + +export type UsageRangeError = "required" | "invalid" | "reversed"; + +function localMinute(value: string): number | null { + const parts = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); + if (!parts) return null; + const [year, month, day, hour, minute] = parts.slice(1).map(Number); + const date = new Date(`${value}:00`); + const timestamp = date.getTime(); + // Reject calendar overflow and nonexistent local times (including DST gaps). + if (!Number.isSafeInteger(timestamp) || timestamp < 0 + || date.getFullYear() !== year || date.getMonth() !== month - 1 + || date.getDate() !== day || date.getHours() !== hour || date.getMinutes() !== minute) return null; + return timestamp; +} + +export function parseUsageTimeRange(start: string, end: string): + | { ok: true; window: UsageTimeWindow } + | { ok: false; error: UsageRangeError } { + if (!start || !end) return { ok: false, error: "required" }; + const since = localMinute(start); + const endMinute = localMinute(end); + if (since === null || endMinute === null) return { ok: false, error: "invalid" }; + if (since > endMinute) return { ok: false, error: "reversed" }; + // Both bounds are inclusive: the selected end minute includes its final millisecond. + return { ok: true, window: { since, until: endMinute + 59_999 } }; +} diff --git a/gui/tests/models-price-editor.test.tsx b/gui/tests/models-price-editor.test.tsx new file mode 100644 index 0000000000..a80180b893 --- /dev/null +++ b/gui/tests/models-price-editor.test.tsx @@ -0,0 +1,427 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import Models from "../src/pages/Models"; +import type { ModelRow } from "../src/pages/models-shared"; + +type Rates = { input: number; output: number; cacheRead: number; cacheWrite: number }; +type Mutation = { modelId: string; cost: Rates | null }; +const FREE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; +const SAVED = { input: 1.25, output: 9.5, cacheRead: 0.125, cacheWrite: 2.75 }; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +describe("Models manual price editor", () => { + const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", + "IS_REACT_ACT_ENVIRONMENT", "fetch", "setInterval", "clearInterval", + ] as const; + let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; + let testWindow: Window; + let container: HTMLElement; + let root: Root | null; + let rows: ModelRow[]; + let modelCosts: Record; + let mutations: Mutation[]; + let reads: Array<{ url: string; init?: RequestInit }>; + let catalogReads: number; + let getFailure: boolean; + let catalogFailure: boolean; + let getGate: ReturnType | null; + let putGate: ReturnType | null; + let catalogGate: ReturnType | null; + let getResponse: (() => Response) | null; + let putResponse: ((body: Mutation) => Response) | null; + + beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#models" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + setInterval: { configurable: true, value: () => 1 }, + clearInterval: { configurable: true, value: () => {} }, + }); + rows = [ + { provider: "xai-demo", id: "grok-4.6", namespaced: "xai-demo/grok-4.6", disabled: false, manualPricing: true }, + { provider: "xai-demo", id: "vendor/custom", namespaced: "xai-demo/vendor/custom", disabled: false, custom: true, customId: "custom-1" }, + { provider: "openai", id: "gpt-5.5", namespaced: "openai/gpt-5.5", disabled: false, native: true, manualPricing: true }, + { provider: "combo", id: "balanced", namespaced: "combo/balanced", disabled: false, manualPricing: true }, + ]; + const providers = [ + { name: "xai-demo", liveModels: false, models: ["grok-4.6", "vendor/custom"] }, + { name: "openai", liveModels: false, models: ["gpt-5.5"] }, + ]; + modelCosts = { "grok-4.6": { ...SAVED }, sibling: { ...FREE } }; + mutations = []; + reads = []; + catalogReads = 0; + getFailure = false; + catalogFailure = false; + getGate = null; + putGate = null; + catalogGate = null; + getResponse = null; + putResponse = null; + testWindow.localStorage.setItem("ocx-lang", "en"); + testWindow.localStorage.setItem("ocx-models-collapsed:v2", JSON.stringify([])); + testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ + models: rows, providers, selectedModels: {}, disabled: [], contextCaps: {}, contextCapValue: 350_000, + })); + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/providers/xai-demo/model-costs")) { + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Mutation; + mutations.push(body); + if (putGate) await putGate.promise; + if (body.cost === null) delete modelCosts[body.modelId]; + else modelCosts[body.modelId] = body.cost; + rows = rows.map(row => row.provider === "xai-demo" && row.id === body.modelId + ? { ...row, manualPricing: body.cost !== null } : row); + return putResponse ? putResponse(body) : Response.json({ ok: true, provider: "xai-demo", ...body }); + } + reads.push({ url, init }); + if (getGate) await getGate.promise; + if (getFailure) return Response.json({ error: "unavailable" }, { status: 503 }); + return getResponse ? getResponse() : Response.json({ provider: "xai-demo", modelCosts }); + } + if (url.endsWith("/api/models")) { + catalogReads++; + if (catalogGate) await catalogGate.promise; + if (catalogFailure) return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json(rows); + } + if (url.endsWith("/api/providers")) return Response.json(providers); + if (url.endsWith("/api/selected-models")) return Response.json({ selected: {} }); + if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); + if (url.endsWith("/api/aliases")) return Response.json({ providers: {}, models: {}, defaults: { global: false, providers: {} } }); + if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); + if (url.endsWith("/api/shadow-call-settings")) return Response.json({ enabled: false, model: "" }); + if (url.endsWith("/api/v2")) return Response.json({ enabled: false, agentsMaxThreadsConflict: false, multiAgentMode: "default" }); + return new Response(null, { status: 404 }); + }) as typeof fetch; + container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container as never); + root = null; + }); + + afterEach(async () => { + clearClientResourceStoresForTests(); + if (root) await act(async () => root!.unmount()); + getGate?.resolve(); + putGate?.resolve(); + catalogGate?.resolve(); + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + async function flush() { + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + } + + async function mount() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await flush(); + } + + function trigger(model = "xai-demo/grok-4.6"): HTMLButtonElement { + return container.querySelector(`[aria-label="Edit price for ${model}"]`)!; + } + + function inputs(): HTMLInputElement[] { + return [...container.querySelectorAll("dialog input")]; + } + + function button(label: string): HTMLButtonElement { + return [...container.querySelectorAll("dialog button")].find(node => node.textContent === label)!; + } + + async function click(label: string) { + await act(async () => button(label).click()); + await flush(); + } + + async function open(model?: string) { + await act(async () => trigger(model).click()); + await flush(); + } + + async function fill(values: string[]) { + for (const [index, value] of values.entries()) { + await act(async () => { + const input = inputs()[index]!; + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + } + } + + test("real routed and custom rows expose Price; badges use manualPricing and exclude native/combo aliases", async () => { + await mount(); + expect(container.querySelectorAll('[aria-label^="Edit price for "]')).toHaveLength(2); + expect(trigger("openai/gpt-5.5")).toBeNull(); + expect(trigger("combo/balanced")).toBeNull(); + expect(trigger().closest(".models-model-row")!.textContent).toContain("Manual price"); + expect(trigger("xai-demo/vendor/custom").closest(".models-model-row")!.textContent).not.toContain("Manual price"); + expect(reads).toHaveLength(0); + }); + + test("opening loads exact fresh rates, focuses input, and closing aborts a pending read", async () => { + await mount(); + await open(); + expect(inputs().map(input => input.value)).toEqual(["1.25", "9.5", "0.125", "2.75"]); + expect(testWindow.document.activeElement).toBe(inputs()[0]); + expect(reads[0]!.init?.cache).toBe("no-store"); + await click("Cancel"); + expect(testWindow.document.activeElement).toBe(trigger()); + + modelCosts["grok-4.6"] = { input: 3, output: 7, cacheRead: 2, cacheWrite: 4 }; + await open(); + expect(inputs().map(input => input.value)).toEqual(["3", "7", "2", "4"]); + await click("Cancel"); + getGate = deferred(); + await open(); + expect(inputs().every(input => input.disabled)).toBe(true); + expect(button("Save").disabled).toBe(true); + const signal = reads.at(-1)!.init!.signal!; + await act(async () => container.querySelector("dialog")!.dispatchEvent(new testWindow.Event("cancel", { cancelable: true }))); + expect(signal.aborted).toBe(true); + expect(container.querySelector("dialog")).toBeNull(); + await act(async () => getGate!.resolve()); + expect(container.querySelector("dialog")).toBeNull(); + }); + + test("missing override starts empty; explicit free saves exact slash-containing ID, refreshes, then closes", async () => { + await mount(); + await open("xai-demo/vendor/custom"); + expect(inputs().map(input => input.value)).toEqual(["", "", "", ""]); + expect(button("Reset to automatic").disabled).toBe(true); + await click("Save"); + expect(mutations).toHaveLength(0); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("Enter input and output rates"); + await fill(["0", "0"]); + expect(inputs().map(input => input.value)).toEqual(["0", "0", "0", "0"]); + const before = catalogReads; + catalogGate = deferred(); + await click("Save"); + expect(mutations).toEqual([{ modelId: "vendor/custom", cost: FREE }]); + expect(catalogReads).toBeGreaterThan(before); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(button("Cancel").disabled).toBe(true); + await act(async () => catalogGate!.resolve()); + await flush(); + expect(container.querySelector("dialog")).toBeNull(); + expect(trigger("xai-demo/vendor/custom").closest(".models-model-row")!.textContent).toContain("Manual price"); + await open("xai-demo/vendor/custom"); + expect(inputs().map(input => input.value)).toEqual(["0", "0", "0", "0"]); + expect(button("Reset to automatic").disabled).toBe(false); + }); + + test("reset sends null and refresh removes the badge without changing sibling rates", async () => { + await mount(); + await open(); + await click("Reset to automatic"); + expect(mutations).toEqual([{ modelId: "grok-4.6", cost: null }]); + expect(modelCosts.sibling).toEqual(FREE); + expect(trigger().closest(".models-model-row")!.textContent).not.toContain("Manual price"); + await open(); + expect(inputs().map(input => input.value)).toEqual(["", "", "", ""]); + }); + + test("finite bounds are enforced and the maximum with fractional cache rates is accepted", async () => { + await mount(); + await open(); + for (const invalid of ["-1", "1000001", ""]) { + await fill([invalid]); + await click("Save"); + expect(mutations).toHaveLength(0); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("finite number"); + } + await fill(["1000000", "0", "0.000001", "0.5"]); + await click("Save"); + expect(mutations).toEqual([{ modelId: "grok-4.6", cost: { input: 1000000, output: 0, cacheRead: 0.000001, cacheWrite: 0.5 } }]); + }); + + test("failed initial reads keep editing locked until a successful reload", async () => { + getFailure = true; + await mount(); + await open(); + expect(inputs().every(input => input.disabled)).toBe(true); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("Could not load"); + expect(testWindow.document.activeElement).toBe(button("Reload price")); + await click("Reload price"); + expect(mutations).toHaveLength(0); + expect(inputs()[0]!.disabled).toBe(true); + getFailure = false; + await click("Reload price"); + expect(inputs()[0]!.value).toBe("1.25"); + expect(inputs()[0]!.disabled).toBe(false); + }); + + for (const failure of ["transport", "malformed", "wrong identity", "wrong cost", "http"] as const) { + test(`${failure} mutation outcome requires read recovery before new edits`, async () => { + await mount(); + await open(); + putResponse = body => { + if (failure === "transport") throw new TypeError("connection dropped"); + if (failure === "malformed") return new Response("{", { status: 200 }); + if (failure === "http") return Response.json({ error: "failed" }, { status: 503 }); + return Response.json({ ok: true, provider: "xai-demo", ...body, + ...(failure === "wrong identity" ? { modelId: "other" } : { cost: SAVED }), + }); + }; + await fill(["0", "0", "0", "0"]); + await click("Save"); + expect(mutations).toHaveLength(1); + expect(inputs().every(input => input.disabled)).toBe(true); + expect(button("Reset to automatic").disabled).toBe(true); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("may have changed"); + await act(async () => button("Reset to automatic").dispatchEvent(new testWindow.MouseEvent("click", { bubbles: true }))); + expect(mutations).toHaveLength(1); + getFailure = true; + await click("Reload price"); + expect(mutations).toHaveLength(1); + expect(inputs()[0]!.disabled).toBe(true); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("Editing stays locked"); + getFailure = false; + await click("Reload price"); + expect(inputs().map(input => input.value)).toEqual(["0", "0", "0", "0"]); + expect(inputs()[0]!.disabled).toBe(false); + expect(container.textContent).toContain("may still change it"); + expect(mutations).toHaveLength(1); + putResponse = null; + await fill(["2", "3"]); + await click("Save"); + expect(mutations[1]).toEqual({ modelId: "grok-4.6", cost: { input: 2, output: 3, cacheRead: 0, cacheWrite: 0 } }); + expect(container.querySelector("dialog")).toBeNull(); + }); + } + + test("malformed GET cost or provider is never treated as an empty override", async () => { + await mount(); + for (const payload of [ + { provider: "other", modelCosts }, + { provider: "xai-demo", modelCosts: { "grok-4.6": { ...SAVED, input: -1 } } }, + { provider: "xai-demo", modelCosts: { "grok-4.6": { input: 1, output: 2 } } }, + { provider: "xai-demo", modelCosts: [] }, + ]) { + getResponse = () => Response.json(payload); + await open(); + expect(inputs().every(input => input.disabled)).toBe(true); + expect(button("Reset to automatic").disabled).toBe(true); + await click("Cancel"); + } + expect(mutations).toHaveLength(0); + }); + + test("a reset with a lost receipt recovers empty rates without replaying the reset", async () => { + await mount(); + await open(); + putResponse = () => { throw new TypeError("receipt lost"); }; + await click("Reset to automatic"); + expect(inputs()[0]!.disabled).toBe(true); + await click("Reload price"); + expect(inputs().map(input => input.value)).toEqual(["", "", "", ""]); + expect(inputs()[0]!.disabled).toBe(false); + expect(button("Reset to automatic").disabled).toBe(true); + expect(mutations).toEqual([{ modelId: "grok-4.6", cost: null }]); + }); + + test("the mutation deadline unlocks cancellation but requires a fresh read before editing", async () => { + await mount(); + await open(); + const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout"); + const deadline = new AbortController(); + const timeoutBudgets: number[] = []; + const transport = globalThis.fetch; + let pendingSignal: AbortSignal | null | undefined; + try { + Object.defineProperty(AbortSignal, "timeout", { configurable: true, value: (ms: number) => { + timeoutBudgets.push(ms); + return deadline.signal; + } }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT" && String(input).endsWith("/model-costs")) { + pendingSignal = init.signal; + return new Promise((_resolve, reject) => { + init.signal!.addEventListener("abort", () => reject(new Error("request deadline")), { once: true }); + }); + } + return transport(input, init); + }) as typeof fetch; + await click("Save"); + expect(button("Cancel").disabled).toBe(true); + expect(timeoutBudgets).toEqual([60_000]); + await act(async () => deadline.abort()); + await flush(); + expect(pendingSignal?.aborted).toBe(true); + expect(button("Cancel").disabled).toBe(false); + expect(inputs().every(input => input.disabled)).toBe(true); + expect(button("Reload price").disabled).toBe(false); + } finally { + globalThis.fetch = transport; + if (descriptor) Object.defineProperty(AbortSignal, "timeout", descriptor); + else Reflect.deleteProperty(AbortSignal, "timeout"); + } + await click("Reload price"); + expect(inputs()[0]!.disabled).toBe(false); + expect(reads).toHaveLength(2); + }); + + test("confirmed receipt survives repeated failed catalog refreshes and retries never PUT again", async () => { + await mount(); + await open(); + catalogFailure = true; + await click("Reset to automatic"); + expect(mutations).toHaveLength(1); + expect(inputs().every(input => input.disabled)).toBe(true); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("price was saved"); + await click("Refresh list"); + expect(mutations).toHaveLength(1); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("price was saved"); + expect(reads).toHaveLength(1); + catalogFailure = false; + await click("Refresh list"); + expect(mutations).toEqual([{ modelId: "grok-4.6", cost: null }]); + expect(container.querySelector("dialog")).toBeNull(); + }); + + test("pending mutations reject duplicate submit and dismissal", async () => { + await mount(); + await open(); + putGate = deferred(); + await click("Save"); + await act(async () => { + container.querySelector("dialog form")!.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + container.querySelector("dialog")!.dispatchEvent(new testWindow.Event("cancel", { cancelable: true })); + button("Cancel").dispatchEvent(new testWindow.MouseEvent("click", { bubbles: true })); + }); + expect(mutations).toHaveLength(1); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(inputs().every(input => input.disabled)).toBe(true); + await act(async () => putGate!.resolve()); + await flush(); + expect(container.querySelector("dialog")).toBeNull(); + }); +}); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx new file mode 100644 index 0000000000..db441dbb83 --- /dev/null +++ b/gui/tests/usage-custom-range.test.tsx @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import Usage from "../src/pages/Usage"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "ResizeObserver", "IS_REACT_ACT_ENVIRONMENT"] as const; +const originalFetch = globalThis.fetch; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let root: Root | undefined; +let container: HTMLElement; +let apiBase: string; +let sequence = 0; +type RequestGate = { url: string; resolve: (response: Response) => void }; +let requests: RequestGate[]; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + clearClientResourceStoresForTests(); + testWindow = new Window({ url: "http://localhost/" }); + testWindow.localStorage.setItem("ocx-lang", "en"); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + ResizeObserver: { configurable: true, value: testWindow.ResizeObserver }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + // The page also has a held memory cache: each test gets a distinct report identity. + apiBase = `http://usage-custom-${++sequence}`; + requests = []; + globalThis.fetch = ((input: RequestInfo | URL) => new Promise(resolve => { + requests.push({ url: String(input), resolve }); + })) as typeof fetch; +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + root = undefined; + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +}); + +async function mount(connected = false) { + const previousRequests = requests.length; + container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render(); + }); + expect(requests).toHaveLength(previousRequests + 1); +} + +function report(gate: RequestGate, marker: string, date = "2020-09-15") { + const query = new URL(gate.url).searchParams; + const custom = query.has("since"); + return { + range: query.get("range"), surface: query.get("surface"), + since: custom ? Number(query.get("since")) : null, + ...(custom ? { customWindow: true, until: Number(query.get("until")) } : {}), + generatedAt: Date.now(), + summary: { + requests: 1, measuredRequests: 1, reportedRequests: 1, unreportedRequests: 0, + unsupportedRequests: 0, estimatedRequests: 0, inputTokens: 10, outputTokens: 20, + cachedInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 30, coverageRatio: 1, + }, + days: [{ date, requests: 1, measuredRequests: 1, reportedRequests: 1, totalTokens: 30, models: [] }], + models: [{ model: marker, provider: "openai", requests: 1, measuredRequests: 1, reportedRequests: 1, + estimatedRequests: 0, totalTokens: 30, inputTokens: 10, outputTokens: 20, shareRatio: 1 }], + providers: [], historyTruncated: false, truncatedPrefixBytes: 0, entriesTruncated: false, entriesDropped: 0, + }; +} + +async function respond(index: number, marker: string, date?: string) { + await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); }); +} + +const form = () => container.querySelector('form[aria-label="Custom date range"]')!; +const startInput = () => form().querySelectorAll('input[type="datetime-local"]')[0]; +const endInput = () => form().querySelectorAll('input[type="datetime-local"]')[1]; +const interval = () => form().querySelector('[role="status"]')?.textContent; +const error = () => form().querySelector('[role="alert"]')?.textContent; +const preset = (name: string) => container.querySelector(`button.usage-segmented-btn[aria-label="${name}"]`)!; + +async function click(button: HTMLButtonElement) { + expect(button).toBeTruthy(); + await act(async () => { button.click(); }); +} + +async function enter(start: string, end: string) { + await act(async () => { + for (const [input, value] of [[startInput(), start], [endInput(), end]] as const) { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + input.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + } + }); +} + +const apply = () => click(form().querySelector('button[type="submit"]')!); +const clear = () => click(form().querySelector('button[type="button"]')!); +const since = new Date(2020, 8, 15, 10, 20, 0, 0).getTime(); +const until = new Date(2020, 8, 15, 10, 21, 59, 999).getTime(); +const boundsQuery = `since=${since}&until=${until}`; + +function sessionEntries() { + return Array.from({ length: sessionStorage.length }, (_, index) => { + const key = sessionStorage.key(index)!; + return [key, sessionStorage.getItem(key)]; + }); +} + +test("America/Santiago midnight DST retains final-day activity and tooltip", async () => { + const previous = process.env.TZ; + process.env.TZ = "America/Santiago"; + try { + expect(new Date(2026, 8, 6, 0).getHours()).toBe(1); + await mount(); + await respond(0, "preset-marker"); + await enter("2026-09-05T00:00", "2026-09-07T23:59"); + await apply(); + const gate = requests.at(-1)!; + const data = report(gate, "santiago-marker", "2026-09-07"); + data.days = ["2026-09-05", "2026-09-06", "2026-09-07"].map(date => ({ + date, requests: date === "2026-09-07" ? 7 : 0, measuredRequests: 0, reportedRequests: 0, + totalTokens: date === "2026-09-07" ? 700 : 0, models: [], + })); + await act(async () => gate.resolve(Response.json(data))); + const active = container.querySelector('.heatmap-grid .heatmap-cell:not(.heatmap-cell-0)'); + expect(active).not.toBeNull(); + await act(async () => active!.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true }))); + expect(container.querySelector(".heatmap-tip-date")?.textContent).toBe("2026-09-07"); + expect(container.querySelector(".heatmap-tip")?.textContent).toContain("700"); + } finally { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } +}); + +test("Apply submits inclusive bounds once; Clear restores the held preset without custom cache entries", async () => { + await mount(); + expect(requests[0].url).toBe(`${apiBase}/api/usage?range=30d&surface=all`); + await respond(0, "preset-report-marker"); + const held = sessionEntries(); + expect(held).toHaveLength(1); + await enter("2020-09-15T10:20", "2020-09-15T10:21"); + expect(requests).toHaveLength(1); + expect(container.textContent).toContain("preset-report-marker"); + await apply(); + expect(requests).toHaveLength(2); + expect(requests[1].url).toBe(`${apiBase}/api/usage?range=30d&surface=all&${boundsQuery}`); + for (const name of ["Available history", "30d", "7d"]) expect(preset(name).getAttribute("aria-pressed")).toBe("false"); + expect(container.textContent).not.toContain("preset-report-marker"); + expect(container.textContent).toContain("Loading usage data"); + expect(interval()).toContain("both inclusive"); + expect(interval()).toContain(".999"); + const appliedInterval = interval(); + await respond(1, "custom-report-marker"); + expect(container.textContent).toContain("custom-report-marker"); + expect(sessionEntries()).toEqual(held); + // Resource eviction is scheduled on a zero-delay timer. Drain that turn before Clear + // so this explicitly covers restoring a held preset after its resource store was evicted. + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + // A one-day historical window must not produce a year grid anchored to today's date. + expect(container.querySelectorAll(".heatmap-grid .heatmap-cell")).toHaveLength(7); + const activeCell = container.querySelector(".heatmap-grid .heatmap-cell-1")!; + await act(async () => { activeCell.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true })); }); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("2020-09-15"); + await enter("2020-09-16T10:20", "2020-09-16T10:21"); + expect(interval()).toBe(appliedInterval); + expect(requests).toHaveLength(2); + await clear(); + expect(startInput().value).toBe(""); + expect(endInput().value).toBe(""); + expect(interval()).toBeUndefined(); + expect(preset("30d").getAttribute("aria-pressed")).toBe("true"); + expect(container.textContent).toContain("preset-report-marker"); + expect(container.textContent).not.toContain("custom-report-marker"); + expect(requests.at(-1)!.url).toBe(`${apiBase}/api/usage?range=30d&surface=all`); + await act(async () => { root!.unmount(); }); + root = undefined; + container.remove(); + clearClientResourceStoresForTests(); + await mount(); + expect(container.textContent).toContain("preset-report-marker"); + await enter("2020-09-15T10:20", "2020-09-15T10:21"); + await apply(); + // Reopening that exact custom window must not resurrect a module/session-held report. + expect(container.textContent).not.toContain("custom-report-marker"); + expect(container.textContent).not.toContain("preset-report-marker"); + expect(container.textContent).toContain("Loading usage data"); + expect(requests.at(-1)!.url).toBe(`${apiBase}/api/usage?range=30d&surface=all&${boundsQuery}`); +}); + +test("missing, partial, invalid and reversed drafts make no request or applied-state change", async () => { + await mount(); + await respond(0, "held-valid-report"); + for (const [start, end, expected] of [ + ["", "", "Enter both"], + ["2020-09-15T10:20", "", "Enter both"], + ["", "2020-09-15T10:20", "Enter both"], + ["1969-01-01T12:00", "2020-09-15T10:20", "Enter valid"], + ["2020-09-16T10:20", "2020-09-15T10:20", "The end must"], + ]) { + await enter(start, end); + await apply(); + expect(error()).toContain(expected); + expect(startInput().getAttribute("aria-invalid")).toBe("true"); + expect(requests).toHaveLength(1); + expect(container.textContent).toContain("held-valid-report"); + expect(interval()).toBeUndefined(); + } + await enter("2020-09-15T10:20", "2020-09-15T10:21"); + await apply(); + await respond(1, "applied-valid-report"); + const previousInterval = interval(); + await enter("2020-09-16T10:20", "2020-09-15T10:20"); + await apply(); + expect(requests).toHaveLength(2); + expect(interval()).toBe(previousInterval); + expect(container.textContent).toContain("applied-valid-report"); + await clear(); + expect(error()).toBeUndefined(); +}); + +test("new bounds never show a held report or a superseded request that settles late", async () => { + await mount(); + await respond(0, "preset-stale-marker"); + await enter("2020-09-15T10:20", "2020-09-15T10:21"); + await apply(); + await respond(1, "first-custom-marker"); + // Change only until, then only since: each bound independently owns a new request. + await enter("2020-09-15T10:20", "2020-09-15T10:22"); + await apply(); + expect(requests[2].url).toBe(`${apiBase}/api/usage?range=30d&surface=all&since=${since}&until=${until + 60_000}`); + expect(container.textContent).not.toContain("first-custom-marker"); + await enter("2020-09-15T10:21", "2020-09-15T10:22"); + await apply(); + expect(requests[3].url).toBe(`${apiBase}/api/usage?range=30d&surface=all&since=${since + 60_000}&until=${until + 60_000}`); + await respond(2, "late-superseded-marker"); + expect(container.textContent).not.toContain("late-superseded-marker"); + expect(container.textContent).not.toContain("preset-stale-marker"); + expect(container.textContent).toContain("Loading usage data"); + await respond(3, "latest-custom-marker"); + expect(container.textContent).toContain("latest-custom-marker"); + expect(sessionEntries()).toHaveLength(1); +}); + +test("Apply preserves machine key, surface and hub scope; choosing a preset clears custom", async () => { + await mount(true); + await respond(0, "machine-report"); + await click(preset("Grok")); + await respond(1, "machine-grok-report"); + await enter("2020-09-15T10:20", "2020-09-15T10:21"); + await apply(); + expect(requests[2].url).toBe(`${apiBase}/api/usage?range=30d&surface=grok&apiKeyId=machine%2Fkey+%2B+one&${boundsQuery}`); + await respond(2, "machine-custom-report"); + const hub = [...container.querySelectorAll(".usage-scope-control button")].find(button => button.textContent === "Hub-wide")!; + await click(hub); + expect(requests[3].url).toBe(`${apiBase}/api/usage?range=30d&surface=grok&${boundsQuery}`); + await respond(3, "hub-custom-report"); + await enter("2020-09-15T10:20", "2020-09-15T10:22"); + await apply(); + expect(requests[4].url).toBe(`${apiBase}/api/usage?range=30d&surface=grok&since=${since}&until=${until + 60_000}`); + await respond(4, "hub-new-custom-report"); + await click(preset("7d")); + expect(requests.at(-1)!.url).toBe(`${apiBase}/api/usage?range=7d&surface=grok`); + expect(interval()).toBeUndefined(); + expect(startInput().value).toBe(""); + expect(endInput().value).toBe(""); + expect(preset("7d").getAttribute("aria-pressed")).toBe("true"); + expect(hub.getAttribute("aria-pressed")).toBe("true"); +}); + +test("each preset clears custom, including the retained preset; 7d never replaces custom days with this week", async () => { + await mount(); + await respond(0, "preset-marker"); + for (const [index, name] of ["30d", "Available history", "7d"].entries()) { + await enter("2020-09-15T10:20", `2020-09-15T10:${21 + index}`); + const previousRequests = requests.length; + await apply(); + expect(requests).toHaveLength(previousRequests + 1); + await respond(requests.length - 1, "custom-marker"); + await click(preset(name)); + expect(preset(name).getAttribute("aria-pressed")).toBe("true"); + expect(interval()).toBeUndefined(); + expect(startInput().value).toBe(""); + expect(endInput().value).toBe(""); + } + await enter("2020-09-15T10:20", "2020-09-15T10:21"); + await apply(); + expect(requests.at(-1)!.url).toBe(`${apiBase}/api/usage?range=7d&surface=all&${boundsQuery}`); + await respond(requests.length - 1, "custom-from-7d-marker"); + expect(container.querySelector(".daybars")).toBeNull(); + expect(container.querySelectorAll(".heatmap-grid .heatmap-cell")).toHaveLength(7); + expect(preset("7d").getAttribute("aria-pressed")).toBe("false"); +}); diff --git a/gui/tests/usage-time-range.test.ts b/gui/tests/usage-time-range.test.ts new file mode 100644 index 0000000000..8ef289ef31 --- /dev/null +++ b/gui/tests/usage-time-range.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { parseUsageTimeRange } from "../src/usage-time-range"; + +test("local minutes become inclusive epoch-ms bounds, including a single minute", () => { + expect(parseUsageTimeRange("2024-02-29T12:34", "2024-02-29T12:34")).toEqual({ + ok: true, + window: { + since: new Date(2024, 1, 29, 12, 34, 0, 0).getTime(), + until: new Date(2024, 1, 29, 12, 34, 59, 999).getTime(), + }, + }); +}); + +test("both local datetime bounds are required", () => { + for (const [start, end] of [["", ""], ["2024-02-29T12:34", ""], ["", "2024-02-29T12:34"]]) { + expect(parseUsageTimeRange(start, end)).toEqual({ ok: false, error: "required" }); + } +}); + +test("malformed, overflowing and negative dates are rejected rather than normalized", () => { + for (const invalid of [ + "not-a-date", "2023-02-29T12:34", "2024-02-30T12:34", "2024-13-01T12:34", + "2024-02-29T24:00", "2024-02-29T12:60", "1969-01-01T12:00", + "2024-02-29", "2024-02-29T12:34Z", "2024-02-29T12:34:30", "2024-02-29T12:34+09:00", + ]) { + expect(parseUsageTimeRange(invalid, "2024-03-01T12:34")).toEqual({ ok: false, error: "invalid" }); + expect(parseUsageTimeRange("2024-02-01T12:34", invalid)).toEqual({ ok: false, error: "invalid" }); + } +}); + +test("reversed dates are rejected before extending the end minute", () => { + expect(parseUsageTimeRange("2024-03-01T12:35", "2024-03-01T12:34")) + .toEqual({ ok: false, error: "reversed" }); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 60e0f22a0d..d46f547867 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1298,7 +1298,10 @@ "zhipu-bigmodel-provider.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", - "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" + "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", + "cli-models-price.test.ts": "cli", + "model-costs-management-api.test.ts": "server", + "usage-time-range.test.ts": "usage" }, "migrated": [ "adapters", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 10b0cd9e89..512aa3a7e2 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -28,6 +28,22 @@ These answer in the CLI head and never reach the proxy, so they work with nothin Safe to run at any time; none of these change state. +### `ocx models price` + +Read the saved manual price for an exact provider/model selector. + +| Method | Route | +|---|---| +| GET | `/api/providers/{provider}/model-costs` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit provider, modelId, and cost (null for automatic pricing). | + +JSON mode: `envelope`. + +- The provider must be configured; everything after the first slash is the exact upstream model ID. + ### `ocx status` Proxy status, injection state, and version skew between this CLI and the running proxy. @@ -116,6 +132,8 @@ Token and estimated-cost report over a time range. | Flag | Value | Meaning | |---|---|---| | `--range` | string | today | 1d | 7d | 30d | all | +| `--since` | string | Inclusive start: epoch milliseconds or full ISO datetime with timezone; requires --until and overrides --range. | +| `--until` | string | Inclusive end: epoch milliseconds or full ISO datetime with timezone; requires --since. | | `--provider` | string | Restrict to one provider. | | `--model` | string | Restrict to one model id. | | `--json` | boolean | Emit the usage report as JSON. | @@ -353,6 +371,27 @@ JSON mode: `payload`. Each of these writes. Check the flags column before running one unattended. +### `ocx models set-price` + +Save four manual USD-per-1M-token rates, or restore automatic pricing for one model. + +| Method | Route | +|---|---| +| PUT | `/api/providers/{provider}/model-costs` | + +| Flag | Value | Meaning | +|---|---|---| +| `--input` | number | Input rate; required unless --auto is used. | +| `--output` | number | Output rate; required unless --auto is used. | +| `--cache-read` | number | Cache read rate; defaults to 0. | +| `--cache-write` | number | Cache write rate; defaults to 0. | +| `--auto` | boolean | Remove this model's override; cannot be combined with rates. | +| `--json` | boolean | Emit the saved price or reset result as JSON. | + +JSON mode: `payload`. + +- Uses the exact upstream model ID after the first slash. Omitted cache rates default to zero; sibling model prices are preserved. + ### `ocx connect rotate` Rotate the connected client's data key against the hub, with commit and abort. @@ -648,6 +687,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 35 -- of those, state-changing: 15 +- declared capabilities: 37 +- of those, state-changing: 16 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index ff1f5fb9a2..86aa5438df 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -95,6 +95,31 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [ * A capability must not name a route the command does not actually fetch. */ export const CAPABILITIES: readonly Capability[] = [ + { + command: ["models", "price"], + summary: "Read the saved manual price for an exact provider/model selector.", + routes: [{ method: "GET", path: "/api/providers/{provider}/model-costs" }], + flags: [{ name: "--json", value: "boolean", summary: "Emit provider, modelId, and cost (null for automatic pricing)." }], + mutates: false, + json: "envelope", + details: ["The provider must be configured; everything after the first slash is the exact upstream model ID."], + }, + { + command: ["models", "set-price"], + summary: "Save four manual USD-per-1M-token rates, or restore automatic pricing for one model.", + routes: [{ method: "PUT", path: "/api/providers/{provider}/model-costs" }], + flags: [ + { name: "--input", value: "number", summary: "Input rate; required unless --auto is used." }, + { name: "--output", value: "number", summary: "Output rate; required unless --auto is used." }, + { name: "--cache-read", value: "number", summary: "Cache read rate; defaults to 0." }, + { name: "--cache-write", value: "number", summary: "Cache write rate; defaults to 0." }, + { name: "--auto", value: "boolean", summary: "Remove this model's override; cannot be combined with rates." }, + { name: "--json", value: "boolean", summary: "Emit the saved price or reset result as JSON." }, + ], + mutates: true, + json: "payload", + details: ["Uses the exact upstream model ID after the first slash. Omitted cache rates default to zero; sibling model prices are preserved."], + }, { command: ["status"], summary: "Proxy status, injection state, and version skew between this CLI and the running proxy.", @@ -200,6 +225,8 @@ export const CAPABILITIES: readonly Capability[] = [ routes: [{ method: "GET", path: "/api/usage" }], flags: [ { name: "--range", value: "string", summary: "today | 1d | 7d | 30d | all" }, + { name: "--since", value: "string", summary: "Inclusive start: epoch milliseconds or full ISO datetime with timezone; requires --until and overrides --range." }, + { name: "--until", value: "string", summary: "Inclusive end: epoch milliseconds or full ISO datetime with timezone; requires --since." }, { name: "--provider", value: "string", summary: "Restrict to one provider." }, { name: "--model", value: "string", summary: "Restrict to one model id." }, { name: "--json", value: "boolean", summary: "Emit the usage report as JSON." }, diff --git a/src/cli/models-runtime-subcommands.ts b/src/cli/models-runtime-subcommands.ts index a49828d203..4aa6d7b77a 100644 --- a/src/cli/models-runtime-subcommands.ts +++ b/src/cli/models-runtime-subcommands.ts @@ -15,6 +15,8 @@ */ export const MODELS_RUNTIME_SUBCOMMANDS = [ "live", + "price", + "set-price", "edit", "enable", "disable", diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index e21fa25d9e..129f2fb53b 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -13,9 +13,18 @@ import { type RuntimeApiDeps, } from "./runtime-api"; import { isModelsRuntimeSubcommand } from "./models-runtime-subcommands"; +import { isValidProviderName } from "../config/provider-name"; +import { isValidModelDiscoveryModelId } from "../providers/model-discovery-limits"; +import { redactSecretString } from "../lib/redact"; +import type { ProviderCostOverlay } from "../types"; +import { MAX_COST4_RATE } from "../usage/expected-prices"; +import { isValidCost4Rate } from "../usage/user-cost-overlays"; const USAGE = `Usage: ocx models live [--provider ] [--json] + ocx models price [--json] + ocx models set-price --input N --output N [--cache-read N] [--cache-write N] [--json] + ocx models set-price --auto [--json] ocx models edit [--model-id ] [--display-name ] [--context-window ] [--modalities ] [--reasoning-efforts ] @@ -28,7 +37,10 @@ const USAGE = `Usage: ocx models new-policy [on|off] [--provider ] [--json] ocx models new-arrivals [--json] ocx models context [--set-all]|provider on [--value ]|provider off|all > [--json] - ocx models shadow [model|-] [--enabled ] [--json]`; + ocx models shadow [model|-] [--enabled ] [--json] + +Prices are USD per 1M tokens. Omitted cache rates default to 0. +Price selectors use the exact upstream model ID after the first slash.`; type ModelRow = { provider?: string; @@ -55,6 +67,99 @@ async function live(argv: string[], deps: RuntimeApiDeps): Promise { })); } +function priceRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +const PRICE_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const; + +function validPriceCost(value: unknown): value is ProviderCostOverlay { + return priceRecord(value) && Object.keys(value).length === PRICE_RATE_KEYS.length + && PRICE_RATE_KEYS.every(key => Object.hasOwn(value, key) && isValidCost4Rate(value[key])); +} + +async function price(write: boolean, argv: string[], deps: RuntimeApiDeps): Promise { + try { + await priceRequest(write, argv, deps); + } catch (error) { + // Duplicated, inline and stray options also reach parser diagnostics. + // Keep HTTP-specific RuntimeApiError exits while masking usage errors. + if (error instanceof CliUsageError) { + throw new CliUsageError(redactSecretString(error.message), error.usage); + } + throw error; + } +} + +async function priceRequest(write: boolean, argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const selector = args.shift() ?? ""; + const slash = selector.indexOf("/"); + const provider = selector.slice(0, slash); + const modelId = selector.slice(slash + 1); + if (slash < 1 || !isValidProviderName(provider) || !isValidModelDiscoveryModelId(modelId)) { + throw new CliUsageError("model selector must be provider/model with an exact upstream model id", USAGE); + } + if (redactSecretString(modelId) !== modelId) { + throw new CliUsageError("modelId cannot be displayed safely", USAGE); + } + const wantsJson = takeFlag(args, "--json"); + const path = `/api/providers/${encodeURIComponent(provider)}/model-costs`; + if (!write) { + rejectArgs(args, USAGE); + const result = await runtimeRequest(path, {}, deps); + if (!priceRecord(result) || result.provider !== provider || !priceRecord(result.modelCosts) + || !Object.values(result.modelCosts).every(validPriceCost)) { + throw new Error("Invalid model price response"); + } + let cost: ProviderCostOverlay | null = null; + if (Object.hasOwn(result.modelCosts, modelId)) { + const stored = result.modelCosts[modelId]; + if (!validPriceCost(stored)) throw new Error("Invalid model price response"); + cost = { ...stored }; + } + printData({ provider, modelId, cost }, wantsJson, [ + cost === null ? `${selector}: automatic pricing` : `${selector}: ${JSON.stringify(cost)} USD per 1M tokens`, + ]); + return; + } + const auto = takeFlag(args, "--auto"); + const input = takeOption(args, "--input"); + const output = takeOption(args, "--output"); + const cacheRead = takeOption(args, "--cache-read"); + const cacheWrite = takeOption(args, "--cache-write"); + rejectArgs(args, USAGE); + if (auto && [input, output, cacheRead, cacheWrite].some(value => value !== undefined)) { + throw new CliUsageError("--auto cannot be combined with price rates", USAGE); + } + if (!auto && (input === undefined || output === undefined)) { + throw new CliUsageError("--input and --output are required unless --auto is used", USAGE); + } + const rate = (raw: string, flag: string): number => { + const value = Number(raw); + if (!raw.trim() || !isValidCost4Rate(value)) { + throw new CliUsageError(`${flag} must be a finite number between 0 and ${MAX_COST4_RATE}`, USAGE); + } + return value; + }; + const cost: ProviderCostOverlay | null = auto ? null : { + input: rate(input!, "--input"), + output: rate(output!, "--output"), + cacheRead: rate(cacheRead ?? "0", "--cache-read"), + cacheWrite: rate(cacheWrite ?? "0", "--cache-write"), + }; + const result = await runtimeRequest(path, { method: "PUT", body: JSON.stringify({ modelId, cost }) }, deps); + const receivedCost = priceRecord(result) ? result.cost : undefined; + if (!priceRecord(result) || result.ok !== true || result.provider !== provider || result.modelId !== modelId + || (cost === null ? receivedCost !== null : !validPriceCost(receivedCost) + || !PRICE_RATE_KEYS.every(key => receivedCost[key] === cost[key]))) { + throw new Error("Invalid model price persistence receipt"); + } + // Project the acknowledged fields only; unrelated response fields are not CLI output. + printData({ ok: true, provider, modelId, cost }, wantsJson, + [auto ? `${selector}: automatic pricing restored.` : `${selector}: manual pricing saved.`]); +} + async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const id = args.shift()?.trim(); @@ -328,6 +433,8 @@ export async function handleModelsRuntimeCommand(sub: string, argv: string[], de if (!isModelsRuntimeSubcommand(sub)) return null; let action: (() => Promise) | undefined; if (sub === "live") action = () => live(argv, deps); + else if (sub === "price") action = () => price(false, argv, deps); + else if (sub === "set-price") action = () => price(true, argv, deps); else if (sub === "edit") action = () => edit(argv, deps); else if (sub === "enable") action = () => visibility(true, argv, deps); else if (sub === "disable") action = () => visibility(false, argv, deps); diff --git a/src/cli/observe.ts b/src/cli/observe.ts index 46e264d2a8..10a77254d0 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -12,6 +12,8 @@ import { } from "./runtime-api"; import { formatUsageReport } from "./usage-report"; import { USAGE_RANGES, USAGE_SURFACES } from "../usage/summary"; +import { parseUsageTimeWindow, type UsageTimeWindow } from "../usage/time-range"; +import { redactSecretString } from "../lib/redact"; const USAGE = `Usage: ocx observe logs [--provider ] [--model ] [--status ] @@ -20,6 +22,7 @@ const USAGE = `Usage: ocx logs rebuild-index ocx logs index-status ocx observe usage [--range ] [--surface ] + [--since ] [--until ] [--provider ] [--model ] [--json] ocx observe storage [codex-logs [status|protect|unprotect|repair|compact] [--mode ]] [--json] ocx observe memory [--json] @@ -146,6 +149,14 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise { const surface = takeOption(args, "--surface") ?? "all"; const provider = takeOption(args, "--provider"); const model = takeOption(args, "--model"); + const since = takeOption(args, "--since"); + const until = takeOption(args, "--until"); + let window: UsageTimeWindow | undefined; + try { + window = parseUsageTimeWindow(since, until); + } catch (error) { + throw new CliUsageError(error instanceof Error ? error.message : "invalid usage time window", USAGE); + } // `1d` is accepted here as well as server-side so the CLI does not reject an // alias the API would have understood. const ranges = [...USAGE_RANGES, "1d"]; @@ -153,8 +164,8 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise { if (!USAGE_SURFACES.includes(surface as (typeof USAGE_SURFACES)[number])) { throw new CliUsageError(`--surface must be one of ${USAGE_SURFACES.join(", ")}`, USAGE); } - rejectArgs(args, USAGE); - const result = await runtimeRequest(`/api/usage${query({ range, surface, provider, model })}`, {}, deps); + rejectArgs(args.map(redactSecretString), USAGE); + const result = await runtimeRequest(`/api/usage${query({ range, surface, provider, model, since: window?.since, until: window?.until })}`, {}, deps); // Built only when it will be printed: JavaScript evaluates arguments before // the call, so passing formatUsageReport(...) inline would run the human // renderer during --json and let its assumptions affect a path that is meant diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts index e9f92f442d..6b684277c0 100644 --- a/src/cli/usage-report.ts +++ b/src/cli/usage-report.ts @@ -24,6 +24,8 @@ interface UsageReportInput { range?: string; surface?: string; since?: number | null; + until?: number; + customWindow?: boolean; summary?: { requests?: number; totalTokens?: number; @@ -90,7 +92,10 @@ function table(header: string[], rows: string[][]): string[] { } function describeScope(data: UsageReportInput): string { - const parts = [`Usage — ${data.range ?? "?"}`]; + const interval = data.customWindow && typeof data.since === "number" && typeof data.until === "number" + ? `custom ${new Date(data.since).toISOString()} to ${new Date(data.until).toISOString()} (inclusive)` + : data.range ?? "?"; + const parts = [`Usage — ${interval}`]; if (data.surface && data.surface !== "all") parts.push(`surface=${data.surface}`); if (data.filter?.provider) parts.push(`provider=${data.filter.provider}`); if (data.filter?.model) parts.push(`model=${data.filter.model}`); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index ecbd6f655d..0177e56776 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -51,6 +51,7 @@ import { usageLogRevisionKey, } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; +import { parseUsageTimeWindow, type UsageTimeWindow } from "../../usage/time-range"; import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface, rangeWindow, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; @@ -177,6 +178,12 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise typeof value === "string" && value.trim() !== ""); const now = Date.now(); try { @@ -211,7 +218,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise key !== "modelId" && key !== "cost")) { + return jsonResponse({ error: "only a valid modelId and cost object or null are allowed" }, 400, req, config); + } + const modelId = body.modelId; + if (redactSecretString(modelId) !== modelId) { + return jsonResponse({ error: "modelId cannot be displayed safely" }, 400, req, config); + } + const submitted = { [modelId]: body.cost }; + const validationError = body.cost === null ? null : providerModelCostsConfigError(submitted); + if (validationError) return jsonResponse({ error: validationError }, 400, req, config); + // Copy only validated rate fields; never echo a secret-shaped model key that the + // shared display boundary suppresses. Model IDs remain exact, including slashes. + const cost = body.cost === null ? null : sanitizeModelCostsForDisplay(submitted)?.[modelId]; + if (cost === undefined) return jsonResponse({ error: "modelId cannot be displayed safely" }, 400, req, config); + + const hadModelCosts = Object.hasOwn(provider, "modelCosts"); + const previousModelCosts = provider.modelCosts; + const nextModelCosts = Object.assign( + Object.create(null) as Record, + previousModelCosts ?? {}, + ); + if (cost === null) delete nextModelCosts[modelId]; + else nextModelCosts[modelId] = cost; + const mergedError = providerModelCostsConfigError(nextModelCosts); + if (mergedError) return jsonResponse({ error: mergedError }, 400, req, config); + if (Object.keys(nextModelCosts).length > 0) provider.modelCosts = nextModelCosts; + else delete provider.modelCosts; + try { + // The persistence owner refreshes usage overlays after its atomic write. + // Price-only edits do not change routing or require catalog convergence. + persistConfig(config); + } catch (error) { + if (hadModelCosts) provider.modelCosts = previousModelCosts; + else delete provider.modelCosts; + throw error; + } + return jsonResponse({ ok: true, provider: name, modelId, cost }, 200, req, config); + } + const displayNameMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/model-display-names$/); if (displayNameMatch && req.method === "PUT") { let name: string; diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 6d9ec08853..07405c4362 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -46,6 +46,7 @@ export type ManagementModelRow = Partial & { native?: boolean; custom?: boolean; customId?: string; + manualPricing?: boolean; fastRowAvailable?: boolean; displayNameOverride?: string; displayNameSource?: "operator" | "provider" | "fallback"; @@ -181,8 +182,12 @@ export async function listManagementModelRows( for (const row of rows) knownIds.add(row.namespaced); return rows.map(row => { const pending = initialModelSelectionPending(config.providers[row.provider]); + const modelCosts = Object.hasOwn(config.providers, row.provider) + ? config.providers[row.provider]?.modelCosts : undefined; return { ...row, + ...(!row.native && modelCosts !== undefined && Object.hasOwn(modelCosts, row.id) + ? { manualPricing: true } : {}), ...(pending ? { disabled: true, initialSelectionPending: true } : {}), fastRowAvailable: !row.disabled && !pending && !knownIds.has(fastRowId(row.namespaced)) && catalogFastRowEligible(config, row), diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 421ead31e0..6c7d57547b 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -334,6 +334,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/request-history/{id}/route-decision", module: "server/management/request-history-routes", mutates: false, mechanism: "ends-with" }, { method: "PUT", path: "/api/providers/{provider}/alias", module: "server/management/model-routes", mutates: true, mechanism: "regex" }, { method: "PUT", path: "/api/providers/{provider}/model-aliases", module: "server/management/model-routes", mutates: true, mechanism: "regex" }, + { method: "GET", path: "/api/providers/{provider}/model-costs", module: "server/management/model-routes", mutates: false, mechanism: "regex" }, + { method: "PUT", path: "/api/providers/{provider}/model-costs", module: "server/management/model-routes", mutates: true, mechanism: "regex" }, { method: "PUT", path: "/api/custom-models/{id}", module: "server/management/model-routes", mutates: true, mechanism: "regex" }, { method: "DELETE", path: "/api/custom-models/{id}", module: "server/management/model-routes", mutates: true, mechanism: "regex" }, { method: "GET", path: "/api/lab/subjects/{id}", module: "server/management/lab-routes", mutates: false, mechanism: "regex", exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, diff --git a/src/server/management/usage-aggregate-cache.ts b/src/server/management/usage-aggregate-cache.ts index 5e65c26ad7..51e02781f1 100644 --- a/src/server/management/usage-aggregate-cache.ts +++ b/src/server/management/usage-aggregate-cache.ts @@ -14,6 +14,7 @@ import { type UsageSummaryAccumulator, } from "../../usage/summary"; import { userCostOverlayVersion } from "../../usage/user-cost-overlays"; +import type { UsageTimeWindow } from "../../usage/time-range"; import { cacheApiKeyUsageFromRollup, @@ -263,7 +264,8 @@ export async function getFilteredUsageAggregate(filter: { provider?: string | null; model?: string | null; apiKeyId?: string | null; -}): Promise { +}, window?: UsageTimeWindow): Promise { + const fixedWindow = window ? Object.freeze({ ...window }) : undefined; const normalizedFilter = { provider: normalizeFilterValue(filter.provider), model: normalizeFilterValue(filter.model), @@ -273,11 +275,13 @@ export async function getFilteredUsageAggregate(filter: { normalizedFilter.provider, normalizedFilter.model, normalizedFilter.apiKeyId, + fixedWindow?.since ?? null, + fixedWindow?.until ?? null, ]); const existing = filteredFlights.get(key); if (existing) return existing; - const flight = refreshFilteredAggregate(key, normalizedFilter); + const flight = refreshFilteredAggregate(key, normalizedFilter, fixedWindow); filteredFlights.set(key, flight); try { return await flight; @@ -316,12 +320,13 @@ function publishFilteredAggregate( async function rebuildFilteredAggregate( key: string, filter: NormalizedUsageFilter, + window?: UsageTimeWindow, ): Promise { let lastError: unknown; for (let attempt = 0; attempt < MAX_REBUILD_ATTEMPTS; attempt += 1) { const overlayVersion = userCostOverlayVersion(); const timeZone = currentTimeZone(); - const accumulator = createUsageSummaryAccumulator({ filter, mode: "row-unique" }); + const accumulator = createUsageSummaryAccumulator({ filter, mode: "row-unique", window }); try { const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); @@ -345,6 +350,7 @@ async function appendFilteredAggregate( key: string, state: RetainedUsageAggregate, filter: NormalizedUsageFilter, + window?: UsageTimeWindow, ): Promise { pinnedAggregates.add(state); let rebuildAfterUnpin = false; @@ -384,28 +390,29 @@ async function appendFilteredAggregate( pinnedAggregates.delete(state); trimRetainedFilteredAggregates(); } - if (rebuildAfterUnpin) return rebuildFilteredAggregate(key, filter); + if (rebuildAfterUnpin) return rebuildFilteredAggregate(key, filter, window); throw new Error("filtered usage append did not settle"); } async function refreshFilteredAggregate( key: string, filter: NormalizedUsageFilter, + window?: UsageTimeWindow, ): Promise { const state = retainedFilteredAggregates.get(key); - if (!state) return rebuildFilteredAggregate(key, filter); + if (!state) return rebuildFilteredAggregate(key, filter, window); const observed = currentUsageLogRevision(); const overlayVersion = userCostOverlayVersion(); const timeZone = currentTimeZone(); if (requiresRebuild(state, observed, overlayVersion, timeZone)) { retainedFilteredAggregates.delete(key); - return rebuildFilteredAggregate(key, filter); + return rebuildFilteredAggregate(key, filter, window); } if (state.revisionKey === usageLogRevisionKey(observed)) { state.retainedAt = Date.now(); return resultFrom(state, "unchanged"); } - return appendFilteredAggregate(key, state, filter); + return appendFilteredAggregate(key, state, filter, window); } export function usageAggregateRetainedStats(): UsageAggregateRetainedStats { diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 2a3b2ae6d3..deb7f6f20b 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -177,8 +177,8 @@ export function calculateCost(tokens: CostTokens, cost4: Cost4): CostBreakdown { * bundle) nonzero -> overlay verified -> overlay verified-derived -> jawcode * model-level vendor price (cross-provider fallback: a model follows its official * vendor price — WP5 policy, e.g. kiro/claude-opus-4-6 uses the anthropic price) - * -> null. All-zero rows are overlay candidates (zero is "not billable here", - * not "free"). + * -> null. An explicit all-zero user override means free; all-zero catalog + * rows remain overlay candidates rather than evidence of free pricing. */ export function resolveMatchedPrice( provider: string, @@ -244,7 +244,7 @@ function resolveMatchedPriceInner( /** * Exact provider/model price lookup: user-configured `modelCosts` first, then * an exact official correction, the jawcode provider bundle, the expected-price overlay, then the - * model-level vendor fallback. All-zero rows fall through ("not billable"). + * model-level vendor fallback. All-zero catalog rows fall through; user zeros win. */ function resolveMatchedPriceExact( provider: string, @@ -302,14 +302,14 @@ function resolveMatchedPriceExact( }; } -/** User-configured overlay match (all-zero rows fall through like any other source). */ +/** User-configured overlay match; explicit zero rates are authoritative too. */ function userOverlayMatch( provider: string, modelId: string, userOverlays: readonly ExpectedPriceOverlay[], ): MatchedPrice | null { const overlay = findExpectedPriceOverlay(provider, modelId, userOverlays); - if (!overlay || !validCost4(overlay.cost4) || !hasNonZeroCost(overlay.cost4)) return null; + if (!overlay || !validCost4(overlay.cost4)) return null; return { provider, modelId, diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 6390db38c1..2e731a2900 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -1,6 +1,7 @@ import { baseProviderLabel } from "../providers/label"; import { canonicalAntigravityUsageModel } from "../providers/antigravity-models"; import { usageDisplayTotalTokens } from "./totals"; +import type { UsageTimeWindow } from "./time-range"; import { isUnresolvedRequestedModel, usageModelPriceOptions } from "./model-identity"; import { isCodexUsageAccountLogLabel, type PersistedUsageEntry, type UsageStatus } from "./log"; import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext } from "./cost"; @@ -145,6 +146,8 @@ export interface UsageSummary { range: UsageRange; surface: UsageSurface; since: number | null; + customWindow?: true; + until?: number; generatedAt: number; summary: UsageSummaryTotals; days: UsageDay[]; @@ -297,6 +300,25 @@ function dayCountForAllRange(oldest: number | null, now: number): number { return Math.min(MAX_USAGE_DAY_BUCKETS, Math.max(1, days)); } +function customWindowDates(window: UsageTimeWindow): string[] { + const start = startOfLocalDay(window.since); + const date = new Date(startOfLocalDay(window.until)); + const dates: string[] = []; + while (date.getTime() >= start && dates.length < MAX_USAGE_DAY_BUCKETS) { + dates.push(localDateKey(date.getTime())); + const previous = date.getTime(); + date.setDate(date.getDate() - 1); + date.setHours(0, 0, 0, 0); + // A skipped civil day can normalize back to this same midnight (Apia, 2011). + // Move through the preceding instant to find the prior existing local day. + if (date.getTime() >= previous) { + date.setTime(previous - 1); + date.setHours(0, 0, 0, 0); + } + } + return dates.reverse(); +} + function blankTotals(): UsageSummaryTotals { return { requests: 0, @@ -1015,6 +1037,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { private readonly requestIds: Map | null; private readonly filter: NormalizedUsageFilter | null; private readonly mode: UsageAccumulatorMode; + private readonly window: UsageTimeWindow | undefined; private nextRequestId = 0; private nextOrdinal = 0; private snapshotStart: number | null = null; @@ -1025,6 +1048,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { constructor(options?: { filter?: { provider?: string | null; model?: string | null; apiKeyId?: string | null }; mode?: UsageAccumulatorMode; + window?: UsageTimeWindow; }) { const provider = normalizeFilterValue(options?.filter?.provider); const model = normalizeFilterValue(options?.filter?.model); @@ -1033,6 +1057,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { ? null : { provider, model, apiKeyId }; this.mode = options?.mode ?? "exact"; + this.window = options?.window ? Object.freeze({ ...options.window }) : undefined; this.requestIds = this.mode === "exact" ? new Map() : null; } @@ -1048,6 +1073,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { const cloned = new StreamingUsageSummaryAccumulator({ ...(this.filter ? { filter: this.filter } : {}), mode: this.mode, + window: this.window, }); cloned.nextRequestId = this.nextRequestId; cloned.nextOrdinal = this.nextOrdinal; @@ -1276,6 +1302,8 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { ? sourceEntry.timestamp : Math.max(this.snapshotEnd, sourceEntry.timestamp); } + if (this.window && (!Number.isFinite(sourceEntry.timestamp) + || sourceEntry.timestamp < this.window.since || sourceEntry.timestamp > this.window.until)) return; const projected = this.filter ? projectedEntryForFilter(sourceEntry, this.filter) : { entry: sourceEntry, comboOverlap: false }; if (!projected) return; this.comboOverlap ||= projected.comboOverlap; @@ -1340,7 +1368,9 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { now: number, surface: UsageSurface = "all", ): UsageSummary & { filter?: UsageFilterEcho } { - const { since, days: fixedDays } = rangeWindow(range, now); + const preset = rangeWindow(range, now); + const since = this.window?.since ?? preset.since; + const fixedDays = preset.days; const totals = blankTotals(); const models = new Map(); const providers = new Map(); @@ -1351,7 +1381,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { for (const partition of this.partitions.values()) { if (!usageSurfaceMatches(partition.surface, surface)) continue; - if (since !== null && partition.dayStart < since) continue; + if (!this.window && since !== null && partition.dayStart < since) continue; mergeTotals(totals, partition.totals); mergeModelMaps(models, partition.models); if (partition.providers) mergeModelMaps(providers, partition.providers); @@ -1377,27 +1407,34 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { } finalizeCoverage(totals); - const dayCount = range === "all" ? dayCountForAllRange(oldestTimestamp, now) : fixedDays; - const startOfToday = startOfLocalDay(now); + const customDates = this.window ? new Set(customWindowDates(this.window)) : null; + const dayCount = customDates?.size ?? (range === "all" ? dayCountForAllRange(oldestTimestamp, now) : fixedDays); + const startOfToday = startOfLocalDay(this.window?.until ?? now); const firstVisibleDay = new Date(startOfToday); firstVisibleDay.setDate(firstVisibleDay.getDate() - dayCount + 1); const firstVisibleDate = localDateKey(firstVisibleDay.getTime()); const lastVisibleDate = localDateKey(startOfToday); - for (let offset = dayCount - 1; offset >= 0; offset--) { + const visibleDates = customDates ?? new Set(); + for (let offset = dayCount - 1; !customDates && offset >= 0; offset--) { const date = new Date(startOfToday); date.setDate(date.getDate() - offset); - const key = localDateKey(date.getTime()); + visibleDates.add(localDateKey(date.getTime())); + } + for (const key of visibleDates) { if (!dayAccumulators.has(key)) { dayAccumulators.set(key, { totals: blankTotals(), models: new Map(), modelOverlaps: [] }); } } - const days = [...dayAccumulators] + const visibleDays = customDates + ? [...customDates].map(date => [date, dayAccumulators.get(date)!] as const) + : [...dayAccumulators] // All-history totals, models, providers, and accounts still cover every // retained row. Only the chart buckets are bounded so one malformed or // ancient timestamp cannot synthesize an enormous JSON response. .filter(([date]) => range !== "all" || (date >= firstVisibleDate && date <= lastVisibleDate)) - .sort(([a], [b]) => a.localeCompare(b)) + .sort(([a], [b]) => a.localeCompare(b)); + const days = visibleDays .map(([date, day]): UsageDay => ({ date, requests: day.totals.requests, @@ -1412,6 +1449,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { range, surface, since, + ...(this.window ? { customWindow: true as const, until: this.window.until } : {}), generatedAt: now, summary: totals, days, @@ -1449,6 +1487,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { export function createUsageSummaryAccumulator(options?: { filter?: { provider?: string | null; model?: string | null; apiKeyId?: string | null }; mode?: UsageAccumulatorMode; + window?: UsageTimeWindow; }): UsageSummaryAccumulator { return new StreamingUsageSummaryAccumulator(options); } @@ -1501,7 +1540,11 @@ export function projectUsageSummary( const model = normalizeFilterValue(filter.model); const apiKeyId = normalizeExactFilterValue(filter.apiKeyId); if (provider === null && model === null && apiKeyId === null) return summary; - const accumulator = createUsageSummaryAccumulator({ filter: { provider, model, apiKeyId } }); + const accumulator = createUsageSummaryAccumulator({ + filter: { provider, model, apiKeyId }, + ...(summary.customWindow && summary.since !== null && summary.until !== undefined + ? { window: { since: summary.since, until: summary.until } } : {}), + }); for (const entry of entries ?? []) accumulator.add(entry); const projected = accumulator.summarize(summary.range, summary.generatedAt, summary.surface); return { diff --git a/src/usage/time-range.ts b/src/usage/time-range.ts new file mode 100644 index 0000000000..01b1beec83 --- /dev/null +++ b/src/usage/time-range.ts @@ -0,0 +1,48 @@ +/** Inclusive epoch-millisecond bounds, independent of the selected preset. */ +export interface UsageTimeWindow { + readonly since: number; + readonly until: number; +} + +const MAX_DATE_MS = 8_640_000_000_000_000; +const ISO_DATETIME = /^(\d{4}|\+\d{6})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|([+-])(\d{2}):(\d{2}))$/; + +function parseTimestamp(input: string | number, name: "since" | "until"): number { + const invalid = (): never => { + throw new Error(`${name} must be nonnegative integer epoch milliseconds or a valid full ISO datetime with timezone`); + }; + let timestamp: number; + if (typeof input === "number") timestamp = input; + else if (/^\d+$/.test(input)) timestamp = Number(input); + else { + const parts = ISO_DATETIME.exec(input); + if (!parts) return invalid(); + const year = Number(parts[1]); + const month = Number(parts[2]); + const day = Number(parts[3]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const monthDays = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + // Date.parse normalizes some impossible dates (e.g. February 30). + // Validate the written calendar fields before applying its timezone offset. + if (month < 1 || month > 12 || day < 1 || day > monthDays[month - 1]! + || Number(parts[4]) > 23 || Number(parts[5]) > 59 || Number(parts[6]) > 59 + || (parts[7] !== "Z" && (Number(parts[9]) > 23 || Number(parts[10]) > 59))) { + return invalid(); + } + timestamp = Date.parse(input); + } + if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > MAX_DATE_MS) return invalid(); + return timestamp; +} + +/** No bounds selects the preset; supplying either bound requires both. */ +export function parseUsageTimeWindow( + since: string | number | null | undefined, + until: string | number | null | undefined, +): UsageTimeWindow | undefined { + if (since == null && until == null) return undefined; + if (since == null || until == null) throw new Error("since and until must be supplied together"); + const window = { since: parseTimestamp(since, "since"), until: parseTimestamp(until, "until") }; + if (window.since > window.until) throw new Error("since must be less than or equal to until"); + return Object.freeze(window); +} diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index a151a34d42..4a697671a4 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -357,6 +357,14 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou ## Usage accounting +Custom usage windows are immutable bounds on the streaming accumulator, applied to each +ledger entry before attribution and daily aggregation. The filtered aggregate cache includes +both inclusive millisecond bounds in its identity and retains the existing ledger revision, +overlay-version and timezone checks. Preset warming never consumes custom summaries. +The response retains its preset range discriminator for compatibility and explicitly marks +`customWindow`, `since`, and `until`; the chart uses the window's local calendar days with +the existing 366-day cap. GUI custom reports bypass the held preset/session cache. + Account quota discovery is capability-based. Cheap OAuth and provider-key lists include `quotaMode` (`probe`, `passive`, or `unsupported`) without contacting upstream quota APIs. `GET /api/oauth/accounts?provider=..."a=1` and diff --git a/tests/cli/cli-models-price.test.ts b/tests/cli/cli-models-price.test.ts new file mode 100644 index 0000000000..9adda79977 --- /dev/null +++ b/tests/cli/cli-models-price.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; +import { handleModelsRuntimeCommand } from "../../src/cli/models-runtime"; +import { CAPABILITIES } from "../../src/cli/capabilities"; +import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; + +const COST = { input: 1.25, output: 5, cacheRead: 0.125, cacheWrite: 2 }; + +async function invoke(sub: string, args: string[], response?: unknown, status = 200) { + const calls: Array<{ path: string; method: string; body: unknown }> = []; + const stdout: string[] = []; + const stderr: string[] = []; + const log = console.log; + const error = console.error; + console.log = (...values: unknown[]) => { stdout.push(values.map(String).join(" ")); }; + console.error = (...values: unknown[]) => { stderr.push(values.map(String).join(" ")); }; + try { + const code = await handleModelsRuntimeCommand(sub, args, { + baseUrl: "http://127.0.0.1:1", + fetchImpl: async (url, init) => { + const path = new URL(String(url)).pathname; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ + path, + method: init?.method ?? "GET", + body, + }); + if (response instanceof Response) return response; + return Response.json(response === undefined + ? { ok: true, provider: path.split("/")[3], modelId: body?.modelId, cost: body?.cost } + : response, { status }); + }, + }); + return { code, calls, stdout: stdout.join("\n"), stderr: stderr.join("\n") }; + } finally { + console.log = log; + console.error = error; + } +} + +describe("models manual price commands", () => { + test("price reads the map and selects the exact ID after the first slash", async () => { + const result = await invoke("price", ["custom-price/org/model--fast", "--json"], { + provider: "custom-price", + modelCosts: { "org/model--fast": COST, "org--model--fast": { input: 9, output: 9, cacheRead: 9, cacheWrite: 9 } }, + }); + expect(result.code).toBe(0); + expect(result.calls).toEqual([{ path: "/api/providers/custom-price/model-costs", method: "GET", body: undefined }]); + expect(JSON.parse(result.stdout)).toEqual({ provider: "custom-price", modelId: "org/model--fast", cost: COST }); + }); + + test("missing own keys read as automatic, including prototype-shaped selectors", async () => { + for (const modelId of ["missing", "__proto__", "constructor", "toString"]) { + const result = await invoke("price", [`custom-price/${modelId}`, "--json"], { provider: "custom-price", modelCosts: {} }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ provider: "custom-price", modelId, cost: null }); + } + const automatic = await invoke("price", ["custom-price/missing"], { provider: "custom-price", modelCosts: {} }); + expect(automatic.stdout).toContain("automatic pricing"); + }); + + test("set-price sends four numeric rates with omitted cache rates defaulted to zero", async () => { + const result = await invoke("set-price", ["custom-price/org/model", "--input", "1.25", "--output", "5", "--json"]); + expect(result.code).toBe(0); + expect(result.calls).toEqual([{ + path: "/api/providers/custom-price/model-costs", method: "PUT", + body: { modelId: "org/model", cost: { input: 1.25, output: 5, cacheRead: 0, cacheWrite: 0 } }, + }]); + }); + + test("explicit cache rates, all-zero pricing, and the maximum rate are transmitted unchanged", async () => { + const explicit = await invoke("set-price", ["custom-price/org/model", "--input", "1.25", "--output", "5", "--cache-read", "0.125", "--cache-write", "2"]); + expect(explicit.code).toBe(0); + expect(explicit.calls[0]!.body).toEqual({ modelId: "org/model", cost: COST }); + const zero = await invoke("set-price", ["custom-price/model", "--input", "0", "--output", "0"]); + expect(zero.code).toBe(0); + expect(zero.calls[0]!.body).toEqual({ modelId: "model", cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }); + const max = await invoke("set-price", ["custom-price/model", "--input", "1000000", "--output", "1e6"]); + expect(max.code).toBe(0); + expect(max.calls[0]!.body).toEqual({ modelId: "model", cost: { input: 1_000_000, output: 1_000_000, cacheRead: 0, cacheWrite: 0 } }); + }); + + test("--auto sends null and preserves the exact upstream ID", async () => { + const payload = { ok: true, provider: "custom-price", modelId: "org/model", cost: null }; + const result = await invoke("set-price", ["custom-price/org/model", "--auto", "--json"], payload); + expect(result.code).toBe(0); + expect(result.calls).toEqual([{ + path: "/api/providers/custom-price/model-costs", method: "PUT", body: { modelId: "org/model", cost: null }, + }]); + expect(JSON.parse(result.stdout)).toEqual(payload); + }); + + test("invalid selectors and read options fail before any request", async () => { + for (const selector of ["", "native-model", "/model", "provider/", " provider/model", "provider/ model", "provider/model ", "provider/bad\nmodel", "provider/" + "x".repeat(1025), "__proto__/model"]) { + for (const sub of ["price", "set-price"]) { + const result = await invoke(sub, [selector, ...(sub === "set-price" ? ["--auto"] : [])]); + expect(result.code).toBe(2); + expect(result.calls).toHaveLength(0); + } + } + for (const args of [["--auto"], ["--input", "1"], ["extra"], ["--json", "--json"]]) { + const result = await invoke("price", ["custom-price/model", ...args]); + expect(result.code).toBe(2); + expect(result.calls).toHaveLength(0); + } + }); + + test("missing, conflicting, repeated, unknown and invalid rate arguments make no requests", async () => { + const cases = [ + [], ["--input", "1"], ["--output", "2"], ["--input"], ["--input", "--output", "2"], + ["--auto", "--input", "0"], ["--auto", "--cache-read", "0"], ["--auto", "--cache-write", "0"], + ["--auto", "--auto"], ["--auto", "--unknown"], ["--auto", "extra"], + ["--input", "1", "--input", "2", "--output", "3"], + ...["", " ", "NaN", "Infinity", "1e309", "-1", "1000001", "1x", "1,2"].map(rate => ["--input", rate, "--output", "1"]), + ...["--output", "--cache-read", "--cache-write"].map(flag => flag === "--output" + ? ["--input", "1", flag, "-1"] : ["--input", "1", "--output", "2", flag, "-1"]), + ]; + for (const args of cases) { + const result = await invoke("set-price", ["custom-price/model", ...args]); + expect(result.code).toBe(2); + expect(result.calls).toHaveLength(0); + expect(result.stderr.length).toBeGreaterThan(0); + } + }); + + test("API rejection is reported with a nonzero exit and no success message", async () => { + const result = await invoke("set-price", ["custom-price/model", "--auto"], { error: "provider not found" }, 404); + expect(result.code).toBe(4); + expect(result.stderr).toContain("provider not found"); + expect(result.stdout).toBe(""); + }); + + test("duplicate, inline and stray price arguments never echo credential-shaped values", async () => { + const secret = "sk-" + "a".repeat(40); + for (const extra of [["--input", secret], [`--input=${secret}`], [secret]]) { + const result = await invoke("set-price", ["custom-price/model", "--input", "1", "--output", "2", ...extra]); + expect(result.code).toBe(2); + expect(result.calls).toHaveLength(0); + expect(result.stderr).not.toContain(secret); + expect(result.stderr).toContain("Unexpected argument(s)"); + expect(result.stdout).toBe(""); + } + }); + + test("malformed or mismatched success receipts fail without printing response contents", async () => { + const secret = "sk-" + "a".repeat(40); + const cost = { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }; + const receipt = { ok: true, provider: "custom-price", modelId: "model", cost }; + for (const response of [ + null, {}, "malformed", new Response("{"), new Response(null, { status: 204 }), + { ...receipt, ok: false }, { ...receipt, provider: "other" }, { ...receipt, modelId: "other" }, + { ...receipt, cost: null }, { ...receipt, cost: { input: 1, output: 2 } }, + { ...receipt, cost: { ...cost, output: 3 } }, { ...receipt, cost: { ...cost, apiKey: secret } }, + ]) { + const result = await invoke("set-price", ["custom-price/model", "--input", "1", "--output", "2"], response); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Invalid model price persistence receipt"); + expect(result.stderr).not.toContain(secret); + } + const badReset = await invoke("set-price", ["custom-price/model", "--auto"], receipt); + expect(badReset.code).toBe(1); + expect(badReset.stdout).toBe(""); + const projected = await invoke("set-price", ["custom-price/model", "--input", "1", "--output", "2", "--json"], { ...receipt, apiKey: secret }); + expect(projected.code).toBe(0); + expect(JSON.parse(projected.stdout)).toEqual(receipt); + expect(projected.stdout).not.toContain(secret); + }); + + test("invalid GET maps fail rather than appearing automatic or leaking extra rate fields", async () => { + for (const response of [ + null, {}, new Response("{"), { provider: "other", modelCosts: {} }, + { provider: "custom-price", modelCosts: [] }, + { provider: "custom-price", modelCosts: { model: null } }, + { provider: "custom-price", modelCosts: { model: { ...COST, input: -1 } } }, + { provider: "custom-price", modelCosts: { model: { ...COST, extra: "unexpected" } } }, + ]) { + const result = await invoke("price", ["custom-price/model", "--json"], response); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Invalid model price response"); + } + }); + + test("secret-shaped model selectors fail before request or output for read, set and reset", async () => { + const modelId = "sk-" + "a".repeat(40); + for (const [sub, flags] of [ + ["price", []], + ["set-price", ["--input", "1", "--output", "2"]], + ["set-price", ["--auto"]], + ] as const) { + const result = await invoke(sub, [`custom-price/${modelId}`, ...flags, "--json"]); + expect(result.code).toBe(2); + expect(result.calls).toHaveLength(0); + expect(result.stdout).toBe(""); + expect(result.stderr).not.toContain(modelId); + expect(result.stderr).toContain("modelId cannot be displayed safely"); + } + }); + + test("capabilities map both CLI verbs onto the registered route methods", () => { + for (const [sub, method, mutates] of [["price", "GET", false], ["set-price", "PUT", true]] as const) { + const capability = CAPABILITIES.find(entry => entry.command.join(" ") === `models ${sub}`); + expect(capability?.routes).toEqual([{ method, path: "/api/providers/{provider}/model-costs" }]); + expect(capability?.mutates).toBe(mutates); + expect(MANAGEMENT_ROUTES.find(route => route.method === method && route.path === "/api/providers/{provider}/model-costs")).toMatchObject({ + module: "server/management/model-routes", mutates, mechanism: "regex", + }); + } + }); +}); diff --git a/tests/cli/cli-models-runtime-dispatch.test.ts b/tests/cli/cli-models-runtime-dispatch.test.ts index 3608fe9457..06bc8f43b1 100644 --- a/tests/cli/cli-models-runtime-dispatch.test.ts +++ b/tests/cli/cli-models-runtime-dispatch.test.ts @@ -37,6 +37,24 @@ describe("models runtime subcommand dispatch (#3094)", () => { expect(isModelsRuntimeSubcommand("new-arrivals")).toBe(true); }); + test("price and set-price are routed through the runtime dispatcher", async () => { + expect(isModelsRuntimeSubcommand("price")).toBe(true); + expect(isModelsRuntimeSubcommand("set-price")).toBe(true); + const methods: string[] = []; + const deps = { + baseUrl: "http://127.0.0.1:1", + fetchImpl: async (_url: string | URL | Request, init?: RequestInit) => { + methods.push(init?.method ?? "GET"); + return Response.json(init?.method === "PUT" + ? { provider: "dispatch-test", modelId: "model", cost: null, ok: true } + : { provider: "dispatch-test", modelCosts: {} }); + }, + }; + expect(await handleModelsRuntimeCommand("price", ["dispatch-test/model"], deps)).toBe(0); + expect(await handleModelsRuntimeCommand("set-price", ["dispatch-test/model", "--auto"], deps)).toBe(0); + expect(methods).toEqual(["GET", "PUT"]); + }); + test("handleModels routes exactly the shared set to the runtime module", () => { // Reading the source keeps this honest without booting the CLI: the dispatch must // consult the shared predicate rather than re-listing names inline. @@ -54,4 +72,3 @@ describe("models runtime subcommand dispatch (#3094)", () => { expect(new Set(MODELS_RUNTIME_SUBCOMMANDS).size).toBe(MODELS_RUNTIME_SUBCOMMANDS.length); }); }); - diff --git a/tests/cli/cli-usage-report.test.ts b/tests/cli/cli-usage-report.test.ts index b20112ee12..d1fdd7b4b5 100644 --- a/tests/cli/cli-usage-report.test.ts +++ b/tests/cli/cli-usage-report.test.ts @@ -136,6 +136,64 @@ describe("formatUsageReport", () => { }); describe("ocx usage command", () => { + test("duplicate, inline and stray custom-bound arguments do not echo credential-shaped values", async () => { + const secret = "sk-" + "a".repeat(40); + const errors: string[] = []; + const errorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => { errors.push(args.map(String).join(" ")); }); + try { + for (const extra of [["--since", secret], [`--since=${secret}`], [secret]]) { + const result = await run(["usage", "--since", "0", "--until", "1", ...extra], payload()); + expect(result.code).toBe(2); + expect(result.urls).toEqual([]); + } + expect(errors.join("\n")).not.toContain(secret); + expect(errors.join("\n")).toContain("Unexpected argument(s)"); + } finally { errorSpy.mockRestore(); } + }); + + test("normalizes custom ISO bounds and preserves the selected preset and filters", async () => { + const body = payload({ customWindow: true, since: 1709164800123, until: 1709164800123 }); + const { code, urls, out } = await run([ + "usage", "--range", "7d", "--surface", "codex", "--provider", "openai", "--model", "gpt-5.5", + "--since", "2024-02-29T09:00:00.123+09:00", "--until", "1709164800123", + ], body); + expect(code).toBe(0); + expect(urls).toHaveLength(1); + const query = new URL(urls[0]!).searchParams; + expect(Object.fromEntries(query)).toEqual({ + range: "7d", surface: "codex", provider: "openai", model: "gpt-5.5", + since: "1709164800123", until: "1709164800123", + }); + expect(out.split("\n")[0]).toContain("custom 2024-02-29T00:00:00.123Z to 2024-02-29T00:00:00.123Z (inclusive)"); + expect((await run(["usage", "--since", "0", "--until", "0", "--json"], body)).out) + .toBe(JSON.stringify(body, null, 2)); + }); + + test("rejects malformed or unpaired windows as usage errors without an API request", async () => { + const errors: string[] = []; + const errorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + try { + for (const args of [ + ["--since", "0"], ["--until", "0"], ["--since", "2", "--until", "1"], + ["--since", "-1", "--until", "0"], ["--since", "1.5", "--until", "2"], + ["--since", "0", "--until", "8640000000000001"], + ["--since", "0", "--until", "2026-02-30T00:00:00Z"], + ["--since", "0", "--until", "2026-09-01T00:00:00"], + ["--since", "0", "--until", "2026-09-01T00:00:00.0001Z"], + ]) { + const result = await run(["usage", ...args], payload()); + expect(result.code).toBe(2); + expect(result.urls).toEqual([]); + } + expect(errors.join("\n")).toContain("since and until must be supplied together"); + expect(errors.join("\n")).toContain("timezone"); + } finally { + errorSpy.mockRestore(); + } + }); + test("forwards range and provider to the API", async () => { const { code, urls } = await run(["usage", "--range", "today", "--provider", "xai"], payload()); expect(code).toBe(0); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fe613ac71f..05ce7e87ff 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1133,5 +1133,8 @@ "zhipu-bigmodel-provider.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", - "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" + "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", + "cli-models-price.test.ts": "cli", + "model-costs-management-api.test.ts": "server", + "usage-time-range.test.ts": "usage" } diff --git a/tests/server/api-usage.test.ts b/tests/server/api-usage.test.ts index a86836a0de..fa5c0ee2e2 100644 --- a/tests/server/api-usage.test.ts +++ b/tests/server/api-usage.test.ts @@ -109,6 +109,111 @@ afterEach(() => { }); describe("GET /api/usage", () => { + test("custom bounds override presets while preserving surface, filters and accounts", async () => { + const since = new Date(2026, 1, 10, 12).getTime(); + const until = since + 3_600_000; + const rows = [ + { timestamp: since - 1, apiKeyId: "Key-A" }, + { timestamp: since, apiKeyId: "Key-A" }, + { timestamp: until, apiKeyId: "key-a" }, + { timestamp: since + 1, apiKeyId: "Key-A", surface: "claude" }, + { timestamp: until + 1, apiKeyId: "Key-A" }, + ].map((row, index) => ({ + requestId: `custom-${index}`, provider: "openai", model: "gpt-5.5", accountLogLabel: "main", + status: 200, durationMs: 1, usageStatus: "reported", usage: { inputTokens: 10, outputTokens: 5 }, + totalTokens: 15, ...row, + })); + writeFileSync(join(testDir, "usage.jsonl"), rows.map(row => JSON.stringify(row)).join("\n") + "\n"); + const server = startServer(0); + try { + const preset = await (await fetch(new URL("/api/usage?range=all", server.url))).json(); + const params = new URLSearchParams({ range: "today", since: new Date(since).toISOString(), until: String(until), surface: "codex" }); + const before = Date.now(); + const response = await fetch(new URL(`/api/usage?${params}`, server.url)); + expect(response.status).toBe(200); + const custom = await response.json(); + expect(custom).toMatchObject({ range: "today", surface: "codex", customWindow: true, since, until }); + expect(custom.generatedAt).toBeGreaterThanOrEqual(before); + expect(custom.generatedAt).toBeLessThanOrEqual(Date.now()); + expect(custom.summary.requests).toBe(2); + expect(custom.days).toHaveLength(1); + expect(custom.days[0].requests).toBe(2); + expect(custom.accounts[0]).toMatchObject({ accountLogLabel: "main", requests: 2 }); + expect(custom.filter).toBeUndefined(); + expect(custom.snapshotWindowStart).toBe(since - 1); + expect(custom.snapshotWindowEnd).toBe(until + 1); + params.set("apiKeyId", "Key-A"); + const byKey = await (await fetch(new URL(`/api/usage?${params}`, server.url))).json(); + expect(byKey.summary.requests).toBe(1); + expect(byKey.accounts[0].requests).toBe(1); + expect(byKey.filter).toMatchObject({ apiKeyId: "Key-A", matched: true }); + params.set("provider", "OpenAI"); + params.set("model", "GPT-5.5"); + const combined = await (await fetch(new URL(`/api/usage?${params}`, server.url))).json(); + expect(combined.filter).toMatchObject({ provider: "openai", model: "gpt-5.5", apiKeyId: "Key-A", matched: true }); + expect(combined.summary.requests).toBe(1); + expect(combined.accounts).toEqual([]); + params.set("since", String(until)); + const noMatch = await (await fetch(new URL(`/api/usage?${params}`, server.url))).json(); + expect(noMatch.summary.requests).toBe(0); + expect(noMatch.filter.matched).toBe(false); + const after = await (await fetch(new URL("/api/usage?range=all", server.url))).json(); + expect(after.summary).toEqual(preset.summary); + expect(after.summary.requests).toBe(5); + expect(after.customWindow).toBeUndefined(); + expect(after.until).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + + test("rejects invalid custom bounds with 400 before scanning", async () => { + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively"); + const server = startServer(0); + try { + for (const query of [ + "since=0", "until=0", "since=&until=1", "since=2&until=1", "since=-1&until=1", + "since=0&until=8640000000000001", "since=0&until=9007199254740992", + "since=0&until=2026-02-30T12:00:00Z", "since=0&until=2026-09-01T12:00:00", + "since=0&until=2026-09-01T12:00:00.0001Z", + ]) { + const response = await fetch(new URL(`/api/usage?${query}`, server.url)); + expect(response.status).toBe(400); + expect((await response.json()).error).toBeTruthy(); + } + expect(scanSpy).not.toHaveBeenCalled(); + } finally { + scanSpy.mockRestore(); + await server.stop(true); + } + }); + + test("empty custom history and read failures retain the requested interval", async () => { + const server = startServer(0); + const url = new URL("/api/usage?range=today&since=0&until=0", server.url); + try { + const empty = await (await fetch(url)).json(); + expect(empty).toMatchObject({ customWindow: true, since: 0, until: 0, summary: { requests: 0 } }); + expect(empty.days).toHaveLength(1); + expect(empty.error).toBeUndefined(); + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockRejectedValue(new Error("fixture scan failure")); + try { + // A distinct key forces a fresh custom scan. + url.searchParams.set("until", "1"); + const response = await fetch(url); + expect(response.status).toBe(200); // existing Usage UI reads the error field + expect(await response.json()).toMatchObject({ + range: "today", customWindow: true, since: 0, until: 1, error: "read_failed", + }); + } finally { + scanSpy.mockRestore(); + } + } finally { + await server.stop(true); + } + }); + test("concurrent cold requests share one base-ledger scan", async () => { writeFixture(Date.now()); const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; diff --git a/tests/server/model-costs-management-api.test.ts b/tests/server/model-costs-management-api.test.ts new file mode 100644 index 0000000000..856bf9ee79 --- /dev/null +++ b/tests/server/model-costs-management-api.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; +import { saveConfigPreservingClaudeCode } from "../../src/config"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { handleModelRoutes } from "../../src/server/management/model-routes"; +import { listManagementModelRows } from "../../src/server/management/model-rows"; +import type { OcxConfig, ProviderCostOverlay } from "../../src/types"; +import { activeUserCostOverlays, refreshUserCostOverlays } from "../../src/usage/user-cost-overlays"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const PROVIDER = "manual-price-test"; +const COST: ProviderCostOverlay = { input: 1.25, output: 5, cacheRead: 0.125, cacheWrite: 2 }; +const SIBLING: ProviderCostOverlay = { input: 3, output: 7, cacheRead: 0.5, cacheWrite: 4 }; +const ZERO: ProviderCostOverlay = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; +let home: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; + +function fixture(costs?: Record): OcxConfig { + return { + port: 10100, + defaultProvider: PROVIDER, + modelCacheTtlMs: 60_000, + providers: { + [PROVIDER]: { + adapter: "openai-chat", + baseUrl: "https://price.example.invalid/v1", + alias: "price-alias", + liveModels: false, + models: ["org/model", "org/other", "sibling", "custom"], + ...(costs ? { modelCosts: costs } : {}), + }, + }, + }; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-model-prices-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); +}); + +afterEach(() => { + clearModelCache(); + resetCodexModelEntitlementCacheForTests(); + refreshUserCostOverlays(fixture()); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); +}); + +function harness(config = fixture(), persist?: (saved: OcxConfig) => void) { + const persisted: OcxConfig[] = []; + let convergeCalls = 0; + async function call(method: "GET" | "PUT", body?: unknown, provider = PROVIDER, rawBody?: string, rawProvider?: string) { + const url = new URL(`http://127.0.0.1:10100/api/providers/${rawProvider ?? encodeURIComponent(provider)}/model-costs`); + const response = await handleModelRoutes({ + version: "test", + req: new Request(url, { + method, + headers: { "Content-Type": "application/json" }, + ...(method === "PUT" ? { body: rawBody ?? JSON.stringify(body) } : {}), + }), + url, + config, + deps: { + saveConfigPreservingClaudeCode: saved => { + persist?.(saved); + persisted.push(structuredClone(saved)); + }, + }, + convergeCodexCatalog: async () => { + convergeCalls += 1; + throw new Error("price writes must not converge catalogs"); + }, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + if (!response) throw new Error("model-costs route was not dispatched"); + return response; + } + return { call, config, persisted, get convergeCalls() { return convergeCalls; } }; +} + +describe("provider model costs API", () => { + test("GET returns the exact configured provider's sanitized map or an empty map", async () => { + const h = harness(); + expect(await (await h.call("GET")).json()).toEqual({ provider: PROVIDER, modelCosts: {} }); + const costs = JSON.parse(JSON.stringify({ + "org/model": { ...COST, apiKey: "not-for-display" }, + bad: { ...COST, input: -1 }, + ["sk-" + "a".repeat(40)]: COST, + })); + h.config.providers[PROVIDER]!.modelCosts = costs; + expect(await (await h.call("GET")).json()).toEqual({ provider: PROVIDER, modelCosts: { "org/model": COST } }); + expect(h.persisted).toHaveLength(0); + }); + + test("set, replace with explicit zero, and reset persist only the exact model key", async () => { + const h = harness(fixture({ sibling: SIBLING, "org--model": SIBLING })); + for (const cost of [COST, ZERO, null]) { + const response = await h.call("PUT", { modelId: "org/model", cost }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, provider: PROVIDER, modelId: "org/model", cost }); + const expected = { sibling: SIBLING, "org--model": SIBLING, ...(cost ? { "org/model": cost } : {}) }; + expect(h.config.providers[PROVIDER]!.modelCosts).toEqual(expected); + expect(h.persisted.at(-1)!.providers[PROVIDER]!.modelCosts).toEqual(expected); + } + expect(h.persisted).toHaveLength(3); + expect(h.convergeCalls).toBe(0); + }); + + test("reset of the last entry removes the map and repeated reset remains successful", async () => { + const h = harness(fixture({ "org/model": COST })); + for (let attempt = 0; attempt < 2; attempt++) { + expect((await h.call("PUT", { modelId: "org/model", cost: null })).status).toBe(200); + expect(Object.hasOwn(h.config.providers[PROVIDER]!, "modelCosts")).toBe(false); + expect(await (await h.call("GET")).json()).toEqual({ provider: PROVIDER, modelCosts: {} }); + } + }); + + test("the normal persistence owner writes disk and refreshes the overlay registry", async () => { + const config = fixture({ sibling: SIBLING }); + writeFileSync(join(home, "config.json"), JSON.stringify(config)); + const h = harness(config, saveConfigPreservingClaudeCode); + await h.call("PUT", { modelId: "org/model", cost: COST }); + const disk = JSON.parse(readFileSync(join(home, "config.json"), "utf8")) as OcxConfig; + expect(disk.providers[PROVIDER]!.modelCosts).toEqual({ sibling: SIBLING, "org/model": COST }); + expect(activeUserCostOverlays().find(row => row.provider === PROVIDER && row.modelId === "org/model")?.cost4).toEqual(COST); + expect(await (await harness(disk).call("GET")).json()).toEqual({ provider: PROVIDER, modelCosts: { sibling: SIBLING, "org/model": COST } }); + await h.call("PUT", { modelId: "org/model", cost: null }); + expect(JSON.parse(readFileSync(join(home, "config.json"), "utf8")).providers[PROVIDER].modelCosts).toEqual({ sibling: SIBLING }); + expect(activeUserCostOverlays().some(row => row.provider === PROVIDER && row.modelId === "org/model")).toBe(false); + }); + + test("persist failure restores map identity and own-property absence for set and reset", async () => { + for (const costs of [undefined, {}, { "org/model": COST, sibling: SIBLING }]) { + for (const cost of [SIBLING, null]) { + const config = fixture(costs); + const provider = config.providers[PROVIDER]!; + const previous = provider.modelCosts; + const snapshot = structuredClone(previous); + const hadMap = Object.hasOwn(provider, "modelCosts"); + const h = harness(config, () => { throw new Error("disk full"); }); + await expect(h.call("PUT", { modelId: "org/model", cost })).rejects.toThrow("disk full"); + expect(provider.modelCosts).toBe(previous); + expect(provider.modelCosts).toEqual(snapshot); + expect(Object.hasOwn(provider, "modelCosts")).toBe(hadMap); + expect(h.persisted).toHaveLength(0); + expect(h.convergeCalls).toBe(0); + } + } + }); + + test("missing, alias, case-folded and inherited provider names are not resolved", async () => { + const h = harness(); + for (const method of ["GET", "PUT"] as const) { + for (const provider of ["missing", "price-alias", PROVIDER.toUpperCase(), "__proto__", "constructor", "toString"]) { + expect((await h.call(method, { modelId: "org/model", cost: COST }, provider)).status).toBe(404); + } + expect((await h.call(method, { modelId: "org/model", cost: COST }, PROVIDER, undefined, "%E0%A4%A")).status).toBe(400); + } + expect(h.persisted).toHaveLength(0); + }); + + test("malformed bodies, model IDs, rates and extra fields fail before mutation", async () => { + const h = harness(fixture({ sibling: SIBLING })); + const original = h.config.providers[PROVIDER]!.modelCosts; + const invalid: unknown[] = [null, [], 4, {}, { modelId: "org/model" }, { cost: COST }, + ...["", " ", " model", "model ", "bad\nmodel", "x".repeat(1025), 42].map(modelId => ({ modelId, cost: null })), + ...[null, [], "1", true, -1, 1_000_001].map(input => ({ modelId: "org/model", cost: { ...COST, input } })), + ...[[], "auto", 0, { input: 1, output: 2 }, { ...COST, apiKey: "extra" }].map(cost => ({ modelId: "org/model", cost })), + { modelId: "org/model", cost: COST, extra: true }, + JSON.parse('{"modelId":"org/model","cost":null,"__proto__":{"polluted":true}}'), + JSON.parse('{"modelId":"org/model","cost":{"input":1,"output":2,"cacheRead":0,"cacheWrite":0,"constructor":{}}}'), + JSON.parse('{"modelId":"org/model","cost":{"input":1,"output":2,"cacheRead":0,"cacheWrite":0,"__proto__":{}}}'), + ]; + for (const body of invalid) expect((await h.call("PUT", body)).status).toBe(400); + for (const raw of ["{", "", '{"modelId":"org/model","cost":{"input":1e309,"output":1,"cacheRead":0,"cacheWrite":0}}']) { + expect((await h.call("PUT", undefined, PROVIDER, raw)).status).toBe(400); + } + expect(h.config.providers[PROVIDER]!.modelCosts).toBe(original); + expect(h.persisted).toHaveLength(0); + }); + + test("prototype-shaped model keys are stored and reset as own data without touching prototypes", async () => { + const h = harness(fixture({ sibling: SIBLING })); + for (const modelId of ["__proto__", "constructor", "toString"]) { + expect((await h.call("PUT", { modelId, cost: COST })).status).toBe(200); + const map = h.config.providers[PROVIDER]!.modelCosts!; + expect(Object.getPrototypeOf(map)).toBeNull(); + expect(Object.hasOwn(map, modelId)).toBe(true); + expect(map[modelId]).toEqual(COST); + const body = await (await h.call("GET")).json() as { modelCosts: Record }; + expect(Object.hasOwn(body.modelCosts, modelId)).toBe(true); + expect(body.modelCosts[modelId]).toEqual(COST); + await h.call("PUT", { modelId, cost: null }); + expect(Object.hasOwn(h.config.providers[PROVIDER]!.modelCosts!, modelId)).toBe(false); + } + expect(h.config.providers[PROVIDER]!.modelCosts).toEqual({ sibling: SIBLING }); + expect(Object.hasOwn(Object.prototype, "input")).toBe(false); + }); + + test("secret-shaped model IDs are rejected without echo on both set and reset", async () => { + const modelId = "sk-" + "a".repeat(40); + const h = harness(fixture({ [modelId]: COST, sibling: SIBLING })); + const original = h.config.providers[PROVIDER]!.modelCosts; + for (const cost of [COST, null]) { + const response = await h.call("PUT", { modelId, cost }); + expect(response.status).toBe(400); + expect(await response.text()).not.toContain(modelId); + } + expect(h.config.providers[PROVIDER]!.modelCosts).toBe(original); + expect(h.persisted).toHaveLength(0); + }); + + test("management dispatch reaches GET/PUT and still rejects cross-origin writes", async () => { + const config = fixture(); + const url = new URL(`http://127.0.0.1:10100/api/providers/${PROVIDER}/model-costs`); + let writes = 0; + for (const method of ["PUT", "GET"] as const) { + const response = await handleManagementAPI(new Request(url, { + method, headers: { Host: url.host, "Content-Type": "application/json" }, + ...(method === "PUT" ? { body: JSON.stringify({ modelId: "org/model", cost: COST }) } : {}), + }), url, config, { saveConfigPreservingClaudeCode: () => { writes++; } }); + expect(response?.status).toBe(200); + } + const blocked = await handleManagementAPI(new Request(url, { + method: "PUT", headers: { Host: url.host, Origin: "https://other.example.invalid" }, + body: JSON.stringify({ modelId: "org/model", cost: null }), + }), url, config, { saveConfigPreservingClaudeCode: () => { writes++; } }); + expect(blocked?.status).toBe(403); + expect(writes).toBe(1); + expect(config.providers[PROVIDER]!.modelCosts).toEqual({ "org/model": COST }); + }); + + test("set survives reload as manualPricing true and reset omits the badge field", async () => { + const config = fixture({ "org--other": SIBLING }); + config.customModels = [{ id: "custom-row", provider: PROVIDER, modelId: "custom" }]; + const h = harness(config); + expect((await h.call("PUT", { modelId: "org/model", cost: ZERO })).status).toBe(200); + expect((await h.call("PUT", { modelId: "custom", cost: COST })).status).toBe(200); + const reloaded = JSON.parse(JSON.stringify(config)) as OcxConfig; + const rows = await listManagementModelRows(reloaded, { entitlementWaitMs: 0 }); + expect(rows.find(row => row.provider === PROVIDER && row.id === "org/model")?.manualPricing).toBe(true); + for (const modelId of ["org/other", "sibling"]) { + const row = rows.find(row => row.provider === PROVIDER && row.id === modelId); + expect(row).toBeDefined(); + expect(Object.hasOwn(row!, "manualPricing")).toBe(false); + } + expect(rows.find(row => row.customId === "custom-row")?.manualPricing).toBe(true); + expect(rows.filter(row => row.native).every(row => !Object.hasOwn(row, "manualPricing"))).toBe(true); + for (const modelId of ["org/model", "custom"]) { + expect((await harness(reloaded).call("PUT", { modelId, cost: null })).status).toBe(200); + } + const resetRows = await listManagementModelRows(reloaded, { entitlementWaitMs: 0 }); + for (const modelId of ["org/model", "custom"]) { + const row = resetRows.find(row => row.provider === PROVIDER && row.id === modelId); + expect(row).toBeDefined(); + expect(Object.hasOwn(row!, "manualPricing")).toBe(false); + } + }); +}); diff --git a/tests/usage/usage-aggregate-cache.test.ts b/tests/usage/usage-aggregate-cache.test.ts index c739d0546d..3efb5615e4 100644 --- a/tests/usage/usage-aggregate-cache.test.ts +++ b/tests/usage/usage-aggregate-cache.test.ts @@ -72,6 +72,55 @@ afterEach(() => { }); describe("retained usage aggregate cache", () => { + test("custom cache keys isolate both endpoints and never poison preset aggregates", async () => { + const path = join(testDir, "usage.jsonl"); + const rows = [NOW - 2_000, NOW - 1_000, NOW].map((timestamp, index) => ({ ...entry(String(index)), timestamp })); + writeFileSync(path, rows.map(row => JSON.stringify(row)).join("\n") + "\n"); + const base = await getUsageAggregate(); + const firstWindow = { since: NOW - 2_000, until: NOW - 1_000 }; + const first = await getFilteredUsageAggregate({}, firstWindow); + const same = await getFilteredUsageAggregate({}, { ...firstWindow }); + const differentStart = await getFilteredUsageAggregate({}, { since: NOW - 1_000, until: NOW - 1_000 }); + const differentEnd = await getFilteredUsageAggregate({}, { since: NOW - 2_000, until: NOW }); + expect(same.accumulator).toBe(first.accumulator); + expect(same.update).toBe("unchanged"); + expect(requests(first)).toBe(2); + expect(requests(differentStart)).toBe(1); + expect(requests(differentEnd)).toBe(3); + expect((await getUsageAggregate()).accumulator).toBe(base.accumulator); + expect(requests(base)).toBe(3); + expect(base.accumulator.summarize("all", NOW).customWindow).toBeUndefined(); + for (let index = 1; index <= 7; index++) { + await getFilteredUsageAggregate({}, { since: NOW, until: NOW + index }); + } + expect(usageAggregateRetainedStats().count).toBe(5); // base plus four filtered windows + }); + + test("custom incremental clones filter appended rows and rebuild with changed prices", async () => { + const path = join(testDir, "usage.jsonl"); + const window = { since: NOW - 1_000, until: NOW }; + writeFileSync(path, line("one")); + const original = await getFilteredUsageAggregate({}, window); + appendFileSync(path, [ + { ...entry("inside"), timestamp: NOW }, + { ...entry("outside"), timestamp: NOW + 1 }, + ].map(row => JSON.stringify(row)).join("\n") + "\n"); + const appended = await getFilteredUsageAggregate({}, window); + expect(appended.update).toBe("append"); + expect(requests(original)).toBe(1); + expect(requests(appended)).toBe(2); + expect(appended.accumulator.snapshotWindow.end).toBe(NOW + 1); + refreshUserCostOverlays({ providers: { openai: { modelCosts: { + "gpt-5.5": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, + } } } } as unknown as OcxConfig); + const rebuilt = await getFilteredUsageAggregate({}, window); + expect(rebuilt.update).toBe("rebuild"); + expect(rebuilt.accumulator.summarize("today", NOW)).toMatchObject({ + customWindow: true, ...window, summary: { requests: 2 }, + }); + expect(rebuilt.accumulator.summarize("all", NOW).summary.estimatedCostUsd).toBeCloseTo(0.000006, 10); + }); + test("append and rebuild preserve unresolved attribution and restricted pricing without ledger changes", async () => { const path = join(testDir, "usage.jsonl"); writeFileSync(path, line("ordinary")); diff --git a/tests/usage/usage-cost.test.ts b/tests/usage/usage-cost.test.ts index f234622e07..aa5711534e 100644 --- a/tests/usage/usage-cost.test.ts +++ b/tests/usage/usage-cost.test.ts @@ -1140,7 +1140,7 @@ describe("provider cost overlay (user-configured)", () => { }); }); - test("an all-zero overlay on a suffix-shaped configured provider falls through to compiled pricing, not the base provider's overlay", () => { + test("an explicit zero overlay on a suffix-shaped configured provider wins over every fallback", () => { refreshUserCostOverlays({ providers: { acme: { modelCosts: { "claude-opus-4-6": USER_PRICE } }, @@ -1150,16 +1150,12 @@ describe("provider cost overlay (user-configured)", () => { }, } as unknown as OcxConfig); const price = resolveMatchedPrice("acme-pabcdef", "claude-opus-4-6"); - // The all-zero row falls through to compiled/catalog pricing — the - // documented fallback order — and never to acme's user-configured price. + // Operator zero is an explicit free estimate, not missing catalog metadata. expect(price).not.toBeNull(); expect(price?.provider).toBe("acme-pabcdef"); - expect(price?.source).toBe("jawcode"); + expect(price?.source).toBe("user"); expect(price?.cost4).not.toEqual(USER_PRICE); - // A real positive catalog price, without pinning the vendor's current - // rate (the catalog lives outside this PR and may change independently). - expect(price?.cost4?.input).toBeGreaterThan(0); - expect(price?.cost4?.output).toBeGreaterThan(0); + expect(price?.cost4).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); }); test("a generated account label (not a configured provider) still collapses to the base provider's overlay", () => { @@ -1200,7 +1196,7 @@ describe("provider cost overlay (user-configured)", () => { expect(resolveMatchedPrice("acme-pabcdef", "acme-custom-model")).toBeNull(); }); - test("all-zero user overlay falls through to the expected overlay price", () => { + test("all-zero user overlay gives known-zero request and combo estimates until reset", () => { const zero: ExpectedPriceOverlay[] = [{ provider: "deepseek", modelId: "deepseek-chat", @@ -1210,10 +1206,13 @@ describe("provider cost overlay (user-configured)", () => { status: "verified", }]; const price = resolveMatchedPrice("deepseek", "deepseek-chat", undefined, zero); - expect(price?.source).toBe("expected"); - // A real positive expected-overlay price, without pinning the current - // rate (the overlay table may change independently of this feature). - expect(price?.cost4.input).toBeGreaterThan(0); + expect(price?.source).toBe("user"); + expect(price?.cost4).toEqual(zero[0]!.cost4); + const input = { provider: "deepseek", model: "deepseek-chat", usageStatus: "reported" as const, + usage: { inputTokens: 1_000_000, outputTokens: 100_000 } }; + expect(estimateRequestCost(input, undefined, zero)?.cost.total).toBe(0); + expect(estimateComboCost([{ ...input, ordinal: 1 }], undefined, undefined, zero)?.cost.total).toBe(0); + expect(resolveMatchedPrice("deepseek", "deepseek-chat", undefined, [])?.cost4.input).toBeGreaterThan(0); }); test("combo fails closed when a user-priced attempt shares a combo with an unpriced one", () => { diff --git a/tests/usage/usage-summary.test.ts b/tests/usage/usage-summary.test.ts index 6e26fb2e66..094e9e5a44 100644 --- a/tests/usage/usage-summary.test.ts +++ b/tests/usage/usage-summary.test.ts @@ -16,6 +16,122 @@ import { isUnresolvedRequestedModel } from "../../src/usage/model-identity"; const FIXED_NOW = Date.UTC(2026, 5, 28, 12, 0, 0); +describe("custom usage windows", () => { + test("Pacific/Apia skipped day still reaches the preceding existing calendar date", () => { + const previous = process.env.TZ; + process.env.TZ = "Pacific/Apia"; + try { + const start = new Date(2011, 11, 29, 12).getTime(); + const end = new Date(2011, 11, 31, 12).getTime(); + expect(new Date(2011, 11, 30, 0).getDate()).toBe(31); + const accumulator = createUsageSummaryAccumulator({ window: { since: start, until: end } }); + accumulator.add(entry({ ts: start, requestId: "before-skip" })); + accumulator.add(entry({ ts: end, requestId: "after-skip" })); + const summary = accumulator.summarize("all", end); + expect(summary.days.map(day => day.date)).toEqual(["2011-12-29", "2011-12-31"]); + expect(summary.days.map(day => day.requests)).toEqual([1, 1]); + expect(summary.summary.requests).toBe(2); + } finally { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } + }); + + const since = new Date(2026, 1, 10, 12, 0, 0, 123).getTime(); + const until = since + 3_600_000; + + test("includes both intraday endpoints before attribution and retains whole-log snapshot", () => { + for (const mode of ["exact", "row-unique"] as const) { + const accumulator = createUsageSummaryAccumulator({ mode, window: { since, until } }); + for (const ts of [since - 1, since, until, until + 1]) { + accumulator.add(entry({ ts, usageStatus: "reported", usage: { inputTokens: 10, outputTokens: 5 }, accountLogLabel: "main" })); + } + const result = accumulator.summarize("today", FIXED_NOW, "codex"); + expect(result).toMatchObject({ range: "today", customWindow: true, since, until, generatedAt: FIXED_NOW }); + expect(result.summary).toMatchObject({ requests: 2, inputTokens: 20, outputTokens: 10 }); + expect(result.days).toHaveLength(1); + expect(result.days[0]).toMatchObject({ date: "2026-02-10", requests: 2 }); + expect(result.accounts[0]).toMatchObject({ accountLogLabel: "main", requests: 2 }); + expect(result.filter).toBeUndefined(); + expect(accumulator.snapshotWindow).toEqual({ start: since - 1, end: until + 1 }); + } + }); + + test("same-instant windows survive caller mutation and independent incremental clones", () => { + const window = { since, until: since }; + const accumulator = createUsageSummaryAccumulator({ window, mode: "row-unique" }); + window.since = 0; + window.until = FIXED_NOW; + accumulator.add(entry({ ts: since })); + const clone = accumulator.clone(); + clone.add(entry({ ts: since, requestId: "second" })); + clone.add(entry({ ts: since + 1 })); + expect(accumulator.summarize("all", FIXED_NOW).summary.requests).toBe(1); + expect(clone.summarize("7d", FIXED_NOW)).toMatchObject({ + customWindow: true, since, until: since, summary: { requests: 2 }, + }); + expect(accumulator.snapshotWindow.end).toBe(since); + expect(clone.snapshotWindow.end).toBe(since + 1); + }); + + test("empty grids use exact local calendar days across DST and include endpoint midnight", () => { + for (const [year, month, day, expected] of [ + [2026, 2, 7, ["2026-03-07", "2026-03-08", "2026-03-09"]], + [2026, 9, 31, ["2026-10-31", "2026-11-01", "2026-11-02"]], + ] as const) { + const window = { + since: new Date(year, month, day, 23, 59).getTime(), + until: new Date(year, month, day + 2, 0, 0).getTime(), + }; + const accumulator = createUsageSummaryAccumulator({ window }); + const result = accumulator.summarize("30d", FIXED_NOW); + expect(result.days.map(row => row.date)).toEqual([...expected]); + expect(result.days.every(row => row.requests === 0)).toBe(true); + expect(result.summary.requests).toBe(0); + expect(result.since).toBe(window.since); + expect(result.until).toBe(window.until); + expect(accumulator.snapshotWindow).toEqual({ start: null, end: null }); + } + }); + + test("caps only the chart at 366 calendar days ending at until", () => { + const window = { since: new Date(2020, 0, 1, 12).getTime(), until: new Date(2026, 0, 1, 12).getTime() }; + const accumulator = createUsageSummaryAccumulator({ window }); + accumulator.add(entry({ ts: window.since })); + accumulator.add(entry({ ts: window.until })); + const result = accumulator.summarize("today", FIXED_NOW); + expect(result.summary.requests).toBe(2); + expect(result.days).toHaveLength(366); + expect(result.days[0]?.date).toBe("2025-01-01"); + expect(result.days.at(-1)?.date).toBe("2026-01-01"); + expect(result.days.reduce((sum, row) => sum + row.requests, 0)).toBe(1); + }); + + test("custom calendar order remains chronological across expanded ISO years", () => { + const accumulator = createUsageSummaryAccumulator({ window: { + since: new Date(9999, 11, 31, 12).getTime(), until: new Date(10000, 0, 1, 12).getTime(), + } }); + expect(accumulator.summarize("all", FIXED_NOW).days.map(day => day.date)) + .toEqual(["9999-12-31", "10000-01-01"]); + }); + + test("window filtering preserves preset cost attribution for the same retained rows", () => { + const rows = [since - 1, since, until, until + 1].map(ts => entry({ + ts, provider: "anthropic", model: "claude-3-haiku-20240307", usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 50 }, + })); + const accumulator = createUsageSummaryAccumulator({ window: { since, until }, mode: "row-unique" }); + rows.forEach(row => accumulator.add(row)); + const result = accumulator.summarize("today", FIXED_NOW); + const baseline = summarizeUsage(rows.slice(1, 3), "all", until); + expect(result.summary.estimatedCostUsd).toBeGreaterThan(0); + expect(result.summary).toEqual(baseline.summary); + expect(result.models).toEqual(baseline.models); + expect(result.providers).toEqual(baseline.providers); + expect(result.days[0]?.estimatedCostUsd).toBeCloseTo(result.summary.estimatedCostUsd, 10); + }); +}); + function entry(overrides: Partial & { ts: number }): PersistedUsageEntry { const { ts, ...rest } = overrides; return { diff --git a/tests/usage/usage-time-range.test.ts b/tests/usage/usage-time-range.test.ts new file mode 100644 index 0000000000..3a36def2bc --- /dev/null +++ b/tests/usage/usage-time-range.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { parseUsageTimeWindow } from "../../src/usage/time-range"; + +describe("usage time window parsing", () => { + test("accepts epoch milliseconds and normalizes explicit ISO offsets", () => { + expect(parseUsageTimeWindow(undefined, null)).toBeUndefined(); + expect(parseUsageTimeWindow("0", 0)).toEqual({ since: 0, until: 0 }); + expect(parseUsageTimeWindow("1970-01-01T00:00:00.1Z", "1970-01-01T00:00:00.12Z")) + .toEqual({ since: 100, until: 120 }); + expect(parseUsageTimeWindow("2024-02-29T09:00:00.123+09:00", "2024-02-28T19:00:00.123-05:00")) + .toEqual({ since: 1709164800123, until: 1709164800123 }); + expect(parseUsageTimeWindow("1970-01-01T00:00:00Z", "8640000000000000")) + .toEqual({ since: 0, until: 8_640_000_000_000_000 }); + expect(parseUsageTimeWindow("+275760-09-13T00:00:00Z", 8_640_000_000_000_000)?.since) + .toBe(8_640_000_000_000_000); + }); + + test("rejects absent peers, reversed bounds and non-integer or invalid dates", () => { + for (const [since, until] of [[0, undefined], [null, 0], [2, 1]] as const) { + expect(() => parseUsageTimeWindow(since, until)).toThrow(); + } + for (const value of [ + "", " ", " 0", "1.5", "1e3", "0x10", "-1", -1, 0.5, NaN, Infinity, + "9007199254740992", "8640000000000001", "2026-09-01", "2026-09-01T12:00:00", + "2026-09-01T12:00Z", "2026-02-29T00:00:00Z", "2024-02-30T00:00:00Z", + "2100-02-29T00:00:00Z", "2026-04-31T00:00:00+09:00", "2026-13-01T00:00:00Z", + "2026-01-00T00:00:00Z", "2026-01-01T24:00:00Z", "2026-01-01T00:60:00Z", + "2026-01-01T00:00:60Z", "2026-01-01T00:00:00+24:00", "2026-01-01T00:00:00+01:60", + "1970-01-01T00:00:00+00:01", "+275760-09-13T00:00:00.001Z", + "2026-09-01T00:00:00.0001Z", + ]) { + expect(() => parseUsageTimeWindow(value, 8_640_000_000_000_000)).toThrow(); + expect(() => parseUsageTimeWindow(0, value)).toThrow(); + } + }); +});