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 442c597f0db..b53af5261af 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -119,7 +119,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 effective n'est déterminée. 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 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. | +| `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. | @@ -631,3 +631,8 @@ le nom actuel avant d'effectuer une autre modification. ### `anthropicAccountPool.routes` Les routes `anthropicAccountPool.routes` associent un modèle à des identifiants de comptes OAuth Anthropic enregistrés. La première règle correspondante (`match`, glob sensible à la casse) limite la sélection initiale et les reprises 429 lorsque le pool est activé. `fallback: true` élargit la sélection seulement si aucun compte de la règle n’est éligible. + + +### `promptLengthPricing` + +Le champ facultatif `promptLengthPricing` complète les quatre tarifs de base. `mode: "automatic"` (ou champ absent) conserve les règles de contexte du catalogue ; `mode: "flat"` les désactive. Avec `mode: "custom"`, `thresholdTokens` est un entier positif sûr, `comparison` vaut `"gt"` (>) ou `"gte"` (≥), et `rates` contient les quatre tarifs alternatifs absolus. Le seuil compte tous les jetons du prompt, y compris les lectures et écritures du cache, sans la sortie. Les tarifs sélectionnés couvrent toute la requête, sortie comprise, et remplacent la règle de contexte du catalogue des deux côtés du seuil. Les ajustements Fast/Priority s'appliquent une seule fois après la sélection des tarifs et suivent la relation Priority publiée dans le catalogue pour ce modèle ; l'estimation n'est indiquée comme borne inférieure que lorsque le catalogue déclare la relation `lower-bound` (actuellement xAI Grok) ; un modèle sans règle de catalogue applique le multiplicateur Priority une seule fois. Configurez ces options dans **Models → Price**. **Reset to automatic** supprime tous les tarifs et la règle personnalisés. Les anciennes configurations restent compatibles ; ces paramètres concernent les estimations affichées. 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 23e2efd7611..ebcae9fd4f7 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -112,7 +112,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 にした場合は、既知のゼロ料金として見積もります。自動料金に戻すにはそのモデルの設定を削除してください。カタログの全ゼロ料金は引き続きフォールバックします。各レートは 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 のオーバーライド。 | @@ -562,3 +562,8 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ ### `anthropicAccountPool.routes` `anthropicAccountPool.routes` は Anthropic OAuth の保存済みアカウント ID をモデルに割り当てます。有効なプールでは、大文字小文字を区別する `match` グロブの最初の一致が初回選択と 429 再試行を制限します。`fallback: true` はルート内に適格なアカウントがない場合だけ通常のプールを使います。 + + +### `promptLengthPricing` + +省略可能な `promptLengthPricing` は4つの基本料金に追加します。`mode: "automatic"`(または省略)はカタログのコンテキスト料金規則を維持し、`mode: "flat"` はその規則を無効にします。`mode: "custom"` では、`thresholdTokens` に正の安全な整数、`comparison` に `"gt"`(>)または `"gte"`(≥)、`rates` に4つの絶対額の代替料金を指定します。判定にはキャッシュの読み書きを含む全プロンプトトークンを使い、出力は含めません。選択された料金は出力を含むリクエスト全体に適用され、しきい値の上下どちらでもカタログの規則を置き換えます。Fast/Priority 調整は料金の選択後に1回だけ適用され、そのモデルについてカタログが公開している Priority との関係に従います。推定値が下限として表示されるのは、カタログがその関係を `lower-bound` と宣言している場合(現在は xAI Grok)だけです。カタログに規則がないモデルでは、Priority 倍率は1回だけ適用されます。**Models → Price** で設定できます。**Reset to automatic** は基本料金と規則を含む上書き全体を削除します。既存設定との互換性は維持され、表示される推定料金だけに関する設定です。 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 6ece10e3718..5dd78e39612 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -112,7 +112,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으로 추정합니다. 자동 가격으로 되돌리려면 해당 모델 항목을 삭제하세요. 카탈로그의 전부 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별 재정의입니다. | @@ -578,3 +578,8 @@ source 재정의가 0이면 꺼지고, 전역 0이어도 source에 양수 재정 ### `anthropicAccountPool.routes` `anthropicAccountPool.routes`는 모델을 저장된 Anthropic OAuth 계정 ID에 연결합니다. 풀이 활성화되면 대소문자를 구분하는 `match` 글롭의 첫 일치가 최초 선택과 429 재시도를 제한합니다. `fallback: true`는 해당 경로에 적격 계정이 없을 때만 일반 풀로 확장합니다. + + +### `promptLengthPricing` + +선택 항목 `promptLengthPricing`은 네 기본 요율을 확장합니다. `mode: "automatic"` 또는 생략은 카탈로그의 컨텍스트 규칙을 유지하고, `mode: "flat"`은 이를 비활성화합니다. `mode: "custom"`은 양의 안전한 정수 `thresholdTokens`, `"gt"` (>) 또는 `"gte"` (≥)인 `comparison`, 네 절대 대체 요율을 담은 `rates`를 사용합니다. 임계값에는 캐시 읽기와 쓰기를 포함한 모든 프롬프트 토큰이 포함되며 출력은 제외됩니다. 선택한 요율은 출력을 포함한 전체 요청에 적용되고 임계값 양쪽에서 카탈로그 규칙을 대체합니다. Fast/Priority 조정은 요율을 선택한 뒤 한 번만 적용되며, 해당 모델에 대해 카탈로그가 공개한 Priority 관계를 따릅니다. 추정치는 카탈로그가 그 관계를 `lower-bound`로 선언한 경우(현재 xAI Grok)에만 하한으로 표시됩니다. 카탈로그 규칙이 없는 모델은 Priority 배수를 한 번만 적용합니다. **Models → Price**에서 설정합니다. **Reset to automatic**은 기본 요율과 규칙을 포함한 전체 재정의를 삭제합니다. 기존 설정은 호환되며 이 설정은 표시되는 추정 비용에만 해당합니다. 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 38a1f1d4187..46b6c3110f0 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -1083,6 +1083,12 @@ verified official-price fallbacks. Unknown models return `null`; no price is inv Automatic defaults are derived on read and do not populate `modelCosts` in your config, so catalog updates remain effective. Use `set-price` to save provider-specific rates. +`price --json` also preserves a saved `promptLengthPricing` policy in `cost`; the four +`effectiveCost` rates are the base rates, before evaluating a request's prompt length or tier. +Use **Models → Price** or the management API to edit a prompt-length policy. Existing `set-price` +commands replace the four base rates and keep the model's saved Flat or Custom threshold policy; +`--auto` removes both the rates and any policy. + Discover command syntax with `ocx models --help` before live work. `list-custom` reads local configuration; `add` and `remove` save locally unless given `--live`. Local custom writes opportunistically synchronize when a proxy exists. The remaining diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 4e500ca8296..08fde51de1f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -291,7 +291,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `minMaxOutputTokens?` | `number` | Optional positive integer floor for explicit positive `max_output_tokens` budgets on `azure-openai`/`azure` Responses routes. Unset preserves the caller's cap. Setting it authorizes raising a smaller cap, which can increase output usage. Omitted or invalid request budgets are unchanged. | | `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 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. | +| `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. Requests that ride the provider outbound wrapper — model discovery, connection tests, and other proxy-originated diagnostics such as the Ollama show probe — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. | | `forwardClientHeaders?` | `string[]` | `openai-responses` only: opt in to copying named inbound client metadata headers onto inference requests when the provider has not already set that header. Credential and transport-owned names such as `Authorization`, cookies, API-key headers, `Content-Type`, `Content-Length`, `Host`, and `x-oai-attestation` are rejected and also filtered at runtime. Header names are case-insensitive and may not repeat. `headers` remains authoritative; the existing caller `User-Agent` fallback still applies when neither surface sets one. Supported names: `originator`, `x-client-request-id`, `x-codex-app-version`, `user-agent`; all other names are refused. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | @@ -1714,6 +1714,57 @@ image is absent from the list. A row declaring only `audio` or `video` therefore image-incapable, rather than being treated as a text model by one predicate and an image target by the other. +### Prompt-length pricing + +In **Models → Price**, keep four base rates and choose a prompt-length mode: + +- **Automatic** keeps the catalog context rules. It is also the behavior of existing four-rate overrides without a policy. +- **Flat rate** uses the base rates at every prompt length, disabling catalog context adjustments. +- **Custom threshold** selects four absolute alternative rates using `>` or `≥` and one token threshold. The custom policy replaces the catalog context rule on both sides of the threshold. + +All prompt tokens count, including cache reads and writes, before billable-input subtraction. +Output does not count toward the threshold. Once crossed, the alternative rates apply to the +entire request, including output, not only the excess tokens. Custom rates are standard-speed +rates: an applicable Fast/Priority premium applies once after the rates are selected and follows +the catalog's relation between Priority and long-context pricing for that model. A confirmed +Priority request shows the custom estimate as a lower bound only when the catalog declares that +relation as `lower-bound`, as for xAI Grok, which publishes no combined rate. A model with no +catalog context rule applies any Priority multiplier once. These settings control estimates; they +do not change provider billing. +**Reset to automatic** deletes the entire override and restores catalog prices and rules. Saving or +editing the config updates live estimates and historical totals. + +For example, a Haiku 5.5 override with alternative rates strictly above 100,000 prompt tokens: + +```json +{ + "modelCosts": { + "claude-haiku-5-5": { + "input": 0.10, + "output": 0.50, + "cacheRead": 0.01, + "cacheWrite": 0.125, + "promptLengthPricing": { + "mode": "custom", + "thresholdTokens": 100000, + "comparison": "gt", + "rates": { "input": 0.50, "output": 2.50, "cacheRead": 0.05, "cacheWrite": 0.625 } + } + } + } +} +``` + +This is an example of user-defined prices, not an official Haiku price list. +Any positive safe-integer threshold, including `272000`, is supported; use `"gte"` for inclusive +comparison. All four alternative rates are required and follow the base-rate limits, including zero. +The other policy is `{ "mode": "flat" }`. Omit `promptLengthPricing` to retain automatic context +rules with your base prices; an explicit `{ "mode": "automatic" }` is accepted and means the same, +and saves from the dashboard or API store it as an absent field. + +Legacy `openai-multi` model price overrides retain valid prompt-length policies when startup +migrates them to `openai`, including overrides keyed by `openai-multi/`. + ### Renamed API-key presets A provider saved under another name, such as `CommandCode`, inherits missing reasoning-effort metadata when its adapter and fixed API-key endpoint match a registry preset. Your explicit per-model lists, including `[]`, remain authoritative. An omitted provider-wide list inherits the preset default; an explicit list remains unchanged. This does not match OAuth, unrelated endpoints, or templated/custom endpoint presets. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 1449d22438d..b989d29e7b7 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -471,12 +471,29 @@ Explicit all-zero user rates mean a known-zero estimate; **Reset to automatic** 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. +four-rate entries and their optional `promptLengthPricing` policy, keyed by exact upstream model ID. +`PUT` on the same route accepts `{ modelId, cost }`, where `cost` contains +`{ input, output, cacheRead, cacheWrite }` and optionally `promptLengthPricing`, or is `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`. +The **Model price** dialog offers **Automatic**, **Flat rate**, and **Custom threshold** +prompt-length pricing. Automatic (also the default when the policy is absent) retains catalog +context rules with the saved base rates. Flat disables context adjustments. Custom selects four +absolute alternative rates using one positive safe-integer `thresholdTokens` and `comparison` +(`"gt"` for `>`, `"gte"` for `≥`). All prompt tokens count, including cache reads and writes; +output does not. The selected rates apply to the whole request, including output, not just the +excess tokens. Custom replaces the catalog context rule both below and above its threshold. +Custom rates are standard-speed rates: an applicable Fast/Priority adjustment applies once after +rate selection and follows the catalog's Priority relation for that model. Only a model whose +catalog relation is `lower-bound` reports `priorityLowerBound: true`; a model with no catalog rule +applies any Priority multiplier once. +`{ "mode": "automatic" }` is accepted on write and stored as an absent policy, so GET and PUT +receipts never contain it. +**Reset to automatic** removes the entire override, including base rates and the policy. +See the [configuration example](../configuration/providers/#prompt-length-pricing). + ```bash ocx models price ollama/custom-model --json ocx models set-price ollama/custom-model --input 0.50 --output 1.50 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 70e46f22113..f6bbcee1914 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -125,7 +125,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. | @@ -658,3 +658,8 @@ base- и Pro-id с context `1050000` и max input `922000`; OpenRouter зара ### `anthropicAccountPool.routes` `anthropicAccountPool.routes` привязывает модель к сохранённым ID учётных записей Anthropic OAuth. При включённом пуле первое совпадение полного шаблона `match` с учётом регистра ограничивает начальный выбор и повтор после 429. `fallback: true` расширяет выбор только при отсутствии доступных учётных записей маршрута. + + +### `promptLengthPricing` + +Необязательное поле `promptLengthPricing` дополняет четыре базовых тарифа. `mode: "automatic"` (или отсутствие поля) сохраняет контекстные правила каталога; `mode: "flat"` отключает их. Для `mode: "custom"` задайте положительное безопасное целое `thresholdTokens`, `comparison`: `"gt"` (>) или `"gte"` (≥), и четыре абсолютных альтернативных тарифа в `rates`. Порог учитывает все токены промпта, включая чтение и запись кэша, но исключает вывод. Выбранные тарифы применяются ко всему запросу, включая вывод, и заменяют правило каталога по обе стороны порога. Корректировки Fast/Priority применяются один раз после выбора тарифов и следуют опубликованному в каталоге соотношению Priority для этой модели; оценка помечается как нижняя граница, только если каталог объявляет соотношение `lower-bound` (сейчас это xAI Grok); для модели без правила в каталоге множитель Priority применяется один раз. Настройка доступна в **Models → Price**. **Reset to automatic** удаляет всё переопределение, включая базовые тарифы и правило. Старые конфигурации совместимы; настройки относятся к отображаемым оценкам. 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 5f3309a0f31..cd588eb2da7 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -126,7 +126,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 etkin bir bağlam penceresi çözümlenemiyorsa 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 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. | +| `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ı. | @@ -679,3 +679,8 @@ geçerli adı kontrol edin. ### `anthropicAccountPool.routes` `anthropicAccountPool.routes`, modeli kayıtlı Anthropic OAuth hesap kimliklerine bağlar. Havuz açıkken büyük/küçük harfe duyarlı `match` kalıbıyla ilk eşleşen kural ilk seçimi ve 429 yeniden denemesini sınırlar. `fallback: true` yalnızca kuralda uygun hesap kalmadığında normal havuza döner. + + +### `promptLengthPricing` + +İsteğe bağlı `promptLengthPricing` alanı dört temel ücreti genişletir. `mode: "automatic"` (veya alanın olmaması) katalog bağlam kurallarını korur; `mode: "flat"` bunları devre dışı bırakır. `mode: "custom"` için pozitif güvenli tamsayı `thresholdTokens`, `"gt"` (>) veya `"gte"` (≥) değerli `comparison` ve dört mutlak alternatif ücret içeren `rates` kullanılır. Eşik, önbellek okuma ve yazma dahil tüm istem tokenlarını sayar; çıktıyı saymaz. Seçilen ücretler çıktı dahil isteğin tamamına uygulanır ve eşiğin her iki tarafında katalog kuralının yerini alır. Fast/Priority ayarlamaları ücretler seçildikten sonra bir kez uygulanır ve katalogda bu model için yayımlanan Priority ilişkisini izler; tahmin yalnızca katalog bu ilişkiyi `lower-bound` olarak bildirdiğinde (şu anda xAI Grok) alt sınır olarak işaretlenir; katalog kuralı olmayan bir modelde Priority çarpanı bir kez uygulanır. **Models → Price** üzerinden yapılandırın. **Reset to automatic**, temel ücretler ve kural dahil tüm geçersiz kılmayı siler. Eski yapılandırmalar uyumludur; bu ayarlar gösterilen tahminleri ilgilendirir. 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 4b738b35b26..23100d6654f 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 @@ -113,7 +113,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 偏好。 | @@ -587,3 +587,8 @@ Vercel AI Gateway 可以在多个底层推理提供者之间路由一个模型 ### `anthropicAccountPool.routes` `anthropicAccountPool.routes` 将模型绑定到已保存的 Anthropic OAuth 账户 ID。启用账户池后,区分大小写的 `match` 通配模式按顺序取第一个匹配规则,限制首次选择和 429 重试。仅当该规则没有可用账户时,`fallback: true` 才回退到普通账户池。 + + +### `promptLengthPricing` + +可选字段 `promptLengthPricing` 扩展四项基础费率。`mode: "automatic"`(或省略该字段)保留目录的上下文计价规则;`mode: "flat"` 禁用这些规则。`mode: "custom"` 使用正安全整数 `thresholdTokens`、值为 `"gt"`(>)或 `"gte"`(≥)的 `comparison`,以及包含四项绝对替代费率的 `rates`。阈值统计所有提示词 token,包括缓存读取和写入,不包括输出。选中的费率适用于包括输出在内的整个请求,并在阈值两侧都取代目录上下文规则。Fast/Priority 调整在选定费率后只应用一次,并遵循目录为该模型公布的 Priority 关系;仅当目录将该关系声明为 `lower-bound`(目前为 xAI Grok)时,估算才会标记为下限;没有目录规则的模型只应用一次 Priority 倍率。可在 **Models → Price** 中配置。**Reset to automatic** 删除包括基础费率和规则在内的整个覆盖配置。旧配置保持兼容;这些设置只涉及显示的费用估算。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 4e805cf5d69..b27e1523c27 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -510,3 +510,8 @@ Vercel AI Gateway 可在多個底層推論供應商之間路由一個模型。`v ### `anthropicAccountPool.routes` `anthropicAccountPool.routes` 將模型綁定至已儲存的 Anthropic OAuth 帳戶 ID。啟用帳戶池後,區分大小寫的 `match` 萬用模式依順序採用第一個符合的規則,限制首次選擇與 429 重試。僅當該規則沒有可用帳戶時,`fallback: true` 才會回退到一般帳戶池。 + + +### `promptLengthPricing` + +選用欄位 `promptLengthPricing` 擴充四項基礎費率。`mode: "automatic"`(或省略欄位)保留目錄的上下文計價規則;`mode: "flat"` 停用這些規則。`mode: "custom"` 使用正安全整數 `thresholdTokens`、值為 `"gt"`(>)或 `"gte"`(≥)的 `comparison`,以及含四項絕對替代費率的 `rates`。門檻計算所有提示詞 token,包括快取讀取與寫入,不包括輸出。所選費率適用於包含輸出的整個請求,並在門檻兩側皆取代目錄上下文規則。Fast/Priority 調整在選定費率後只套用一次,並遵循目錄為該模型公布的 Priority 關係;僅當目錄將該關係宣告為 `lower-bound`(目前為 xAI Grok)時,估算才會標示為下限;沒有目錄規則的模型只套用一次 Priority 倍率。可在 **Models → Price** 中設定。**Reset to automatic** 刪除包含基礎費率與規則的整個覆寫設定。舊設定保持相容;這些設定僅涉及顯示的費用估算。 diff --git a/gui/src/components/ModelPriceDialog.tsx b/gui/src/components/ModelPriceDialog.tsx index 59f385a8415..67f51b14690 100644 --- a/gui/src/components/ModelPriceDialog.tsx +++ b/gui/src/components/ModelPriceDialog.tsx @@ -1,39 +1,24 @@ -import { Fragment, useCallback, useEffect, useId, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import "./model-price-dialog.css"; 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"; +import { Select } from "../ui"; +import { + CUSTOM_FIELD, EMPTY_CUSTOM, EMPTY_RATES, INVALID, RATE_FIELDS, buildDraftCost, isRecord, loadDraft, parseModelCost, receiptMatches, + type CustomDraft, type InvalidField, type ModelCost, type PricingMode, type RateDraft, type RateField, +} from "./model-price-cost"; -interface Cost4 { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; -} - -const RATE_FIELDS = ["input", "output", "cacheRead", "cacheWrite"] as const; -const RATE_LABELS: Record = { +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; @@ -44,15 +29,21 @@ interface ModelPriceDialogProps { export default function ModelPriceDialog({ model, apiBase, onRefresh, onClose }: ModelPriceDialogProps) { const t = useT(); const id = useId(); + const helpId = useId(); + const errorId = useId(); const dialogRef = useRef(null); const inputRef = useRef(null); + const customFieldRefs = useRef>>({}); const submitRef = useRef(null); const requestRef = useRef(null); const mutationPendingRef = useRef(false); const [phase, setPhase] = useState("loading"); - const [draft, setDraft] = useState(EMPTY_DRAFT); + const [draft, setDraft] = useState(EMPTY_RATES); + const [pricingMode, setPricingMode] = useState("automatic"); + const [customDraft, setCustomDraft] = useState(EMPTY_CUSTOM); const [hasOverride, setHasOverride] = useState(false); const [errorKey, setErrorKey] = useState(null); + const [invalidField, setInvalidField] = useState(null); const [recovered, setRecovered] = useState(false); const endpoint = `${apiBase}/api/providers/${encodeURIComponent(model.provider)}/model-costs`; const mutating = phase === "saving" || phase === "refreshing"; @@ -68,14 +59,17 @@ export default function ModelPriceDialog({ model, apiBase, onRefresh, onClose }: 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"); + const rawCost = Object.hasOwn(result.modelCosts, model.id) ? result.modelCosts[model.id] : undefined; + const cost = rawCost === undefined ? undefined : parseModelCost(rawCost); + if (cost === INVALID) 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), - }); + const loaded = loadDraft(cost); + setDraft(loaded.rates); + setPricingMode(loaded.mode); + setCustomDraft(loaded.custom); setHasOverride(cost !== undefined); + setErrorKey(null); + setInvalidField(null); // This read recovers an editable snapshot, not ordering against an earlier // request still running on the server or writes from another client. setRecovered(recover); @@ -88,7 +82,7 @@ export default function ModelPriceDialog({ model, apiBase, onRefresh, onClose }: bounded.clear(); if (requestRef.current === bounded) requestRef.current = null; }); - }, [endpoint, model.id, model.provider]); + }, [endpoint, model.id, model.provider, setCustomDraft, setErrorKey, setInvalidField, setPricingMode]); useEffect(() => { const dialog = dialogRef.current; @@ -108,13 +102,14 @@ export default function ModelPriceDialog({ model, apiBase, onRefresh, onClose }: }, [phase]); // undefined retries only catalog refresh after a validated persistence receipt. - const save = async (cost: Cost4 | null | undefined) => { + const save = async (cost: ModelCost | 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); + setInvalidField(null); let confirmed = cost === undefined; try { if (cost !== undefined) { @@ -126,8 +121,7 @@ export default function ModelPriceDialog({ model, apiBase, onRefresh, onClose }: 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]))) { + || result.modelId !== model.id || !receiptMatches(receiptCost, cost)) { throw new Error("invalid model-costs receipt"); } if (requestRef.current !== bounded) return; @@ -159,7 +153,7 @@ export default function ModelPriceDialog({ model, apiBase, onRefresh, onClose }: onCancel={event => { event.preventDefault(); requestClose(); }}> diff --git a/gui/src/components/model-price-cost.ts b/gui/src/components/model-price-cost.ts new file mode 100644 index 00000000000..938407cfd7e --- /dev/null +++ b/gui/src/components/model-price-cost.ts @@ -0,0 +1,102 @@ +// Contract helpers for model-cost rows and prompt-length pricing. +export const RATE_FIELDS = ["input", "output", "cacheRead", "cacheWrite"] as const; +export type RateField = (typeof RATE_FIELDS)[number]; +export type Rates = Record; +export type RateDraft = Record; +export type Comparison = "gt" | "gte"; +export type PromptLengthPricing = + | { mode: "flat" } + | { mode: "custom"; thresholdTokens: number; comparison: Comparison; rates: Rates }; +export type ModelCost = Rates & { promptLengthPricing?: PromptLengthPricing }; +export type PricingMode = "automatic" | "flat" | "custom"; +export interface CustomDraft extends RateDraft { threshold: string; comparison: Comparison } +export type InvalidField = RateField | "threshold" | "customInput" | "customOutput" | "customCacheRead" | "customCacheWrite"; +export type DraftOutcome = { cost: ModelCost } | { field: InvalidField }; + +export const MAX_RATE = 1_000_000; +export const EMPTY_RATES: RateDraft = { input: "", output: "", cacheRead: "", cacheWrite: "" }; +export const EMPTY_CUSTOM: CustomDraft = { ...EMPTY_RATES, threshold: "", comparison: "gt" }; +export const INVALID: unique symbol = Symbol("invalid model cost"); +export const CUSTOM_FIELD: Record = { + input: "customInput", output: "customOutput", cacheRead: "customCacheRead", cacheWrite: "customCacheWrite", +}; + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function hasKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value); + return keys.length === expected.length && expected.every(key => Object.hasOwn(value, key)); +} +function isRate(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_RATE; +} +function isRates(value: unknown): value is Rates { + return isRecord(value) && hasKeys(value, RATE_FIELDS) && RATE_FIELDS.every(field => isRate(value[field])); +} +function parsePromptLengthPricing(value: unknown): PromptLengthPricing | undefined | typeof INVALID { + if (value === undefined) return undefined; + if (!isRecord(value)) return INVALID; + if (value.mode === "automatic" && hasKeys(value, ["mode"])) return undefined; + if (value.mode === "flat" && hasKeys(value, ["mode"])) return { mode: "flat" }; + if (value.mode !== "custom" || !Number.isSafeInteger(value.thresholdTokens) || Number(value.thresholdTokens) <= 0 + || !hasKeys(value, ["mode", "thresholdTokens", "comparison", "rates"]) + || (value.comparison !== "gt" && value.comparison !== "gte") || !isRates(value.rates)) return INVALID; + return { mode: "custom", thresholdTokens: Number(value.thresholdTokens), comparison: value.comparison, rates: { + input: value.rates.input, output: value.rates.output, cacheRead: value.rates.cacheRead, cacheWrite: value.rates.cacheWrite, + } }; +} + +export function parseModelCost(value: unknown): ModelCost | typeof INVALID { + if (!isRecord(value) || !hasKeys(value, Object.hasOwn(value, "promptLengthPricing") + ? [...RATE_FIELDS, "promptLengthPricing"] : RATE_FIELDS) || !RATE_FIELDS.every(field => isRate(value[field]))) return INVALID; + const promptLengthPricing = parsePromptLengthPricing(value.promptLengthPricing); + if (promptLengthPricing === INVALID) return INVALID; + const rates = Object.fromEntries(RATE_FIELDS.map(field => [field, value[field] as number])) as Rates; + return promptLengthPricing === undefined ? rates : { ...rates, promptLengthPricing }; +} + +export function sameModelCost(left: ModelCost, right: ModelCost): boolean { + if (!RATE_FIELDS.every(field => left[field] === right[field])) return false; + const a = left.promptLengthPricing ?? { mode: "automatic" as const }; + const b = right.promptLengthPricing ?? { mode: "automatic" as const }; + if (a.mode !== b.mode) return false; + return a.mode !== "custom" || (b.mode === "custom" && a.thresholdTokens === b.thresholdTokens + && a.comparison === b.comparison && RATE_FIELDS.every(field => a.rates[field] === b.rates[field])); +} + +export function receiptMatches(receipt: unknown, cost: ModelCost | null): boolean { + if (cost === null) return receipt === null; + const parsed = parseModelCost(receipt); + return parsed !== INVALID && sameModelCost(parsed, cost); +} + +const toDraft = (rates: Rates): RateDraft => Object.fromEntries(RATE_FIELDS.map(field => [field, String(rates[field])])) as RateDraft; +export function loadDraft(cost: ModelCost | undefined): { rates: RateDraft; mode: PricingMode; custom: CustomDraft } { + if (!cost) return { rates: EMPTY_RATES, mode: "automatic", custom: EMPTY_CUSTOM }; + const pricing = cost.promptLengthPricing; + if (!pricing || pricing.mode === "flat") return { rates: toDraft(cost), mode: pricing?.mode ?? "automatic", custom: EMPTY_CUSTOM }; + return { rates: toDraft(cost), mode: "custom", custom: { + ...toDraft(pricing.rates), threshold: String(pricing.thresholdTokens), comparison: pricing.comparison, + } }; +} + +const numberOrZero = (value: string) => value.trim() === "" ? 0 : Number(value); +export function buildDraftCost(rates: RateDraft, mode: PricingMode, custom: CustomDraft, badField?: InvalidField): DraftOutcome { + if (badField) return { field: badField }; + if (!rates.input.trim()) return { field: "input" }; + if (!rates.output.trim()) return { field: "output" }; + const base = { input: Number(rates.input), output: Number(rates.output), cacheRead: numberOrZero(rates.cacheRead), cacheWrite: numberOrZero(rates.cacheWrite) }; + const invalidBase = RATE_FIELDS.find(field => !isRate(base[field])); + if (invalidBase) return { field: invalidBase }; + if (mode === "automatic") return { cost: base }; + if (mode === "flat") return { cost: { ...base, promptLengthPricing: { mode: "flat" } } }; + const threshold = custom.threshold.trim(); + if (!/^[1-9]\d*$/.test(threshold) || !Number.isSafeInteger(Number(threshold))) return { field: "threshold" }; + if (!custom.input.trim()) return { field: "customInput" }; + if (!custom.output.trim()) return { field: "customOutput" }; + const band = { input: Number(custom.input), output: Number(custom.output), cacheRead: numberOrZero(custom.cacheRead), cacheWrite: numberOrZero(custom.cacheWrite) }; + const invalidBand = RATE_FIELDS.find(field => !isRate(band[field])); + if (invalidBand) return { field: CUSTOM_FIELD[invalidBand] }; + return { cost: { ...base, promptLengthPricing: { mode: "custom", thresholdTokens: Number(threshold), comparison: custom.comparison, rates: band } } }; +} diff --git a/gui/src/components/model-price-dialog.css b/gui/src/components/model-price-dialog.css new file mode 100644 index 00000000000..45f366cb7ee --- /dev/null +++ b/gui/src/components/model-price-dialog.css @@ -0,0 +1,58 @@ +/* Keep the dialog's title and actions reachable while its long form scrolls. */ +.model-price-dialog { + display: flex; + flex-direction: column; + max-height: calc(100dvh - 16vh); +} +.model-price-dialog > .modal-head, +.model-price-dialog > .modal-actions { flex: none; } +.model-price-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 2px; +} +.model-price-body .input { margin-bottom: 6px; } +.model-price-body > .small { text-wrap: balance; } +.model-price-rates-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-1-5) var(--space-3); +} +.model-price-rate-field { min-width: 0; } +.model-price-rate-field .field-label { margin-bottom: var(--space-1); } +.model-price-rate-field .input { margin-bottom: 0; } +.model-price-mode { + min-width: 0; + margin: 12px 0 0; + padding: 0; + border: 0; +} +.model-price-mode > legend { padding: 0; margin-bottom: 5px; } +.model-price-mode-option { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 0; + cursor: pointer; +} +.model-price-mode-option input { margin-top: 3px; accent-color: var(--accent); } +.model-price-mode-option > span { display: grid; gap: 2px; min-width: 0; } +.model-price-band { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.model-price-band-title { font-weight: var(--weight-medium); margin-bottom: 2px; } +.model-price-custom-controls { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-2); } +.model-price-custom-controls .field-label { min-height: 2.5em; } +.model-price-dialog .model-display-name-error { margin-top: 8px; } +@media (max-width: 760px) { + .model-price-mode-option { min-height: 44px; } +} +@media (max-width: 560px) { + .model-price-rates-grid { grid-template-columns: 1fr; } + .model-price-custom-controls { grid-template-columns: 1fr; gap: 4px; } + .model-price-custom-controls .field-label { min-height: 0; } +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index eda2ab62154..4973d9e3b4c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3662,11 +3662,27 @@ export const de: Record = { "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.promptTitle": "Preise für lange Prompts", + "pricing.override.promptHelp": "Eingabe- und Cache-Tokens einer Anfrage bestimmen die Stufe; die gesamte Anfrage wird zu diesem Tarif berechnet. Ausgabe-Tokens zählen nicht. Preise gelten für Standardgeschwindigkeit.", + "pricing.override.help": "USD pro 1 Mio. Token. Ein- und Ausgaberaten eingeben; leere Cache-Raten gelten als 0. Preise von 0 sind zulässig.", "pricing.override.input": "Eingabe", "pricing.override.output": "Ausgabe", "pricing.override.cacheRead": "Cache lesen", "pricing.override.cacheWrite": "Cache schreiben", + "pricing.override.modeAutomatic": "Automatisch", + "pricing.override.modeFlat": "Einheitspreis", + "pricing.override.modeCustom": "Eigener Schwellenwert", + + "pricing.override.modeAutomaticHelp": "Verwendet die Prompt-Längenregeln des Katalogs mit den obigen Grundpreisen. Fast- und Priority-Anpassungen gelten weiterhin.", + "pricing.override.modeFlatHelp": "Verwendet die obigen Grundpreise bei jeder Prompt-Länge und ersetzt damit die Prompt-Längenregeln des Katalogs. Fast- und Priority-Anpassungen gelten weiterhin.", + "pricing.override.threshold": "Prompt-Schwellenwert (Tokens)", + "pricing.override.bandTitle": "Preise im langen Prompt-Bereich", + "pricing.override.bandHelp": "Diese Raten gelten für Standardgeschwindigkeit. Fast- und Priority-Regeln aus dem Katalog gelten weiterhin. Kosten sind Schätzungen.", + "pricing.override.comparison": "Schwellenwertvergleich", + "pricing.override.comparisonGt": "Größer als (>)", + "pricing.override.comparisonGte": "Größer oder gleich (≥)", + "pricing.override.modeCustomHelp": "Gib vier absolute Preise pro 1 Mio. Tokens ein. Die Prompt-Länge umfasst gecachte Eingaben und schließt Ausgaben aus. Diese Preise ersetzen die Prompt-Längenregel des Katalogs für die gesamte Anfrage. Fast- und Priority-Anpassungen gelten weiterhin. Kostenzahlen sind Schätzungen.", + "pricing.override.customInvalid": "Gib einen positiven sicheren Ganzzahl-Schwellenwert und vier endliche Preise von 0 bis 1.000.000 ein. Preise dürfen 0 sein.", "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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0df794fc37f..29e27e4c18a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3696,11 +3696,27 @@ export const en = { "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.promptTitle": "Long-prompt pricing", + "pricing.override.promptHelp": "Input tokens and cache reads and writes in a request determine the band; the entire request is priced at that band's rates. Output tokens never count. Rates are for standard speed.", + "pricing.override.help": "USD per 1M tokens. Enter input and output rates; blank cache rates use 0. Zero rates are allowed.", "pricing.override.input": "Input", "pricing.override.output": "Output", "pricing.override.cacheRead": "Cache read", "pricing.override.cacheWrite": "Cache write", + "pricing.override.modeAutomatic": "Automatic", + "pricing.override.modeFlat": "Flat rate", + "pricing.override.modeCustom": "Custom threshold", + + "pricing.override.modeAutomaticHelp": "Uses the catalog prompt-length rules with the base rates above. Fast and Priority adjustments still apply.", + "pricing.override.modeFlatHelp": "Uses the base rates above at every prompt length, replacing catalog prompt-length rules. Fast and Priority adjustments still apply.", + "pricing.override.threshold": "Prompt threshold (tokens)", + "pricing.override.bandTitle": "Rates in the long-prompt band", + "pricing.override.bandHelp": "These are standard-speed rates. Catalog Fast and Priority rules still apply. Cost figures are estimates.", + "pricing.override.comparison": "Threshold comparison", + "pricing.override.comparisonGt": "Greater than (>)", + "pricing.override.comparisonGte": "Greater than or equal to (≥)", + "pricing.override.modeCustomHelp": "Enter four absolute rates per 1M tokens. Prompt length includes cached input and excludes output. These rates replace the catalog prompt-length rule for the whole request. Fast and Priority adjustments still apply. Cost figures are estimates.", + "pricing.override.customInvalid": "Enter a positive safe-integer token threshold and four finite rates from 0 to 1,000,000. Rates may be 0.", "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.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2938e653da4..1f387a8bafb 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3651,11 +3651,27 @@ export const fr: Record = { "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.promptTitle": "Tarif des prompts longs", + "pricing.override.promptHelp": "Les jetons d’entrée et de cache d’une requête déterminent la tranche ; la requête entière est facturée aux tarifs de cette tranche. Les jetons de sortie ne comptent jamais. Tarifs pour la vitesse standard.", + "pricing.override.help": "USD par million de tokens. Saisissez les tarifs d’entrée et de sortie ; un tarif de cache vide vaut 0. Les tarifs à 0 sont autorisés.", "pricing.override.input": "Entrée", "pricing.override.output": "Sortie", "pricing.override.cacheRead": "Lecture du cache", "pricing.override.cacheWrite": "Écriture du cache", + "pricing.override.modeAutomatic": "Automatique", + "pricing.override.modeFlat": "Tarif fixe", + "pricing.override.modeCustom": "Seuil personnalisé", + + "pricing.override.modeAutomaticHelp": "Utilise les règles du catalogue selon la longueur du prompt avec les tarifs de base ci-dessus. Les ajustements Fast et Priority restent appliqués.", + "pricing.override.modeFlatHelp": "Utilise les tarifs de base ci-dessus quelle que soit la longueur du prompt, en remplaçant les règles du catalogue. Les ajustements Fast et Priority restent appliqués.", + "pricing.override.threshold": "Seuil du prompt (jetons)", + "pricing.override.bandTitle": "Tarifs de la tranche des prompts longs", + "pricing.override.bandHelp": "Tarifs pour la vitesse standard. Les règles Fast et Priority du catalogue restent appliquées. Les coûts sont estimés.", + "pricing.override.comparison": "Comparaison au seuil", + "pricing.override.comparisonGt": "Supérieur à (>)", + "pricing.override.comparisonGte": "Supérieur ou égal à (≥)", + "pricing.override.modeCustomHelp": "Saisissez quatre tarifs absolus par million de jetons. La longueur du prompt inclut les entrées mises en cache et exclut la sortie. Ces tarifs remplacent la règle du catalogue liée à la longueur pour toute la requête. Les ajustements Fast et Priority restent appliqués. Les coûts sont estimés.", + "pricing.override.customInvalid": "Saisissez un seuil positif en jetons, entier sûr, et quatre tarifs finis entre 0 et 1 000 000. Les tarifs peuvent être égaux à 0.", "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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index afa02d345ac..ceca93cc617 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3684,11 +3684,27 @@ export const ja: Record = { "pricing.override.badge": "手動価格", "pricing.override.title": "モデル価格", "pricing.override.modelId": "モデル ID", - "pricing.override.help": "100万トークンあたりの USD です。入力・出力単価を入力してください。空のキャッシュ単価は 0 とし、4項目すべてが 0 なら無料です。", + "pricing.override.promptTitle": "長いプロンプトの料金", + "pricing.override.promptHelp": "リクエストの入力トークンとキャッシュ読み書きトークンで区分が決まり、リクエスト全体にその区分の料金が適用されます。出力トークンは数えません。料金は標準速度向けです。", + "pricing.override.help": "100万トークンあたりの USD です。入力・出力単価を入力してください。空のキャッシュ単価は 0 とし、0 の料金も指定できます。", "pricing.override.input": "入力", "pricing.override.output": "出力", "pricing.override.cacheRead": "キャッシュ読み取り", "pricing.override.cacheWrite": "キャッシュ書き込み", + "pricing.override.modeAutomatic": "自動", + "pricing.override.modeFlat": "一律料金", + "pricing.override.modeCustom": "カスタムしきい値", + + "pricing.override.modeAutomaticHelp": "上記の基本料金を使い、カタログのプロンプト長ルールを適用します。FastとPriorityの調整は引き続き適用されます。", + "pricing.override.modeFlatHelp": "プロンプト長にかかわらず上記の基本料金を使い、カタログのプロンプト長ルールを置き換えます。FastとPriorityの調整は引き続き適用されます。", + "pricing.override.threshold": "プロンプトのしきい値(トークン)", + "pricing.override.bandTitle": "長いプロンプト区分の料金", + "pricing.override.bandHelp": "標準速度の料金です。カタログの Fast と Priority のルールは引き続き適用されます。料金は推定値です。", + "pricing.override.comparison": "しきい値の比較", + "pricing.override.comparisonGt": "より大きい (>)", + "pricing.override.comparisonGte": "以上 (≥)", + "pricing.override.modeCustomHelp": "トークン100万件あたりの絶対料金を4つ入力してください。プロンプト長にはキャッシュ済み入力を含み、出力は含みません。この料金はリクエスト全体に対してカタログのプロンプト長ルールを置き換えます。FastとPriorityの調整は引き続き適用されます。料金は推定値です。", + "pricing.override.customInvalid": "正の安全な整数のトークンしきい値と、0~1,000,000の有限な料金を4つ入力してください。料金に0を指定できます。", "pricing.override.loading": "保存済み価格を読み込み中…", "pricing.override.loadFailed": "保存済み価格を読み込めませんでした。再読み込みしてください。", "pricing.override.outcomeUnknown": "リクエストの結果を確認できませんでした。価格が変更された可能性があります。編集する前に保存済み価格を再読み込みしてください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 6369ce97bcc..9f4026aa7b2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3684,11 +3684,27 @@ export const ko: Record = { "pricing.override.badge": "수동 가격", "pricing.override.title": "모델 가격", "pricing.override.modelId": "모델 ID", - "pricing.override.help": "토큰 100만 개당 USD입니다. 입력·출력 요율을 입력하세요. 빈 캐시 요율은 0으로 처리하며, 네 요율이 모두 0이면 무료입니다.", + "pricing.override.promptTitle": "긴 프롬프트 요금", + "pricing.override.promptHelp": "요청의 입력 및 캐시 읽기·쓰기 토큰이 구간을 정하며 요청 전체에 해당 구간 요금이 적용됩니다. 출력 토큰은 계산하지 않습니다. 요금은 표준 속도 기준입니다.", + "pricing.override.help": "토큰 100만 개당 USD입니다. 입력·출력 요율을 입력하세요. 빈 캐시 요율은 0으로 처리하며, 0 요율도 허용됩니다.", "pricing.override.input": "입력", "pricing.override.output": "출력", "pricing.override.cacheRead": "캐시 읽기", "pricing.override.cacheWrite": "캐시 쓰기", + "pricing.override.modeAutomatic": "자동", + "pricing.override.modeFlat": "고정 요금", + "pricing.override.modeCustom": "사용자 지정 임계값", + + "pricing.override.modeAutomaticHelp": "위 기본 요금과 함께 카탈로그의 프롬프트 길이 규칙을 사용합니다. Fast 및 Priority 조정은 계속 적용됩니다.", + "pricing.override.modeFlatHelp": "프롬프트 길이와 관계없이 위 기본 요금을 사용하고 카탈로그의 프롬프트 길이 규칙을 대체합니다. Fast 및 Priority 조정은 계속 적용됩니다.", + "pricing.override.threshold": "프롬프트 임계값(토큰)", + "pricing.override.bandTitle": "긴 프롬프트 구간 요금", + "pricing.override.bandHelp": "표준 속도 요금입니다. 카탈로그의 Fast 및 Priority 규칙은 계속 적용됩니다. 비용은 추정치입니다.", + "pricing.override.comparison": "임계값 비교", + "pricing.override.comparisonGt": "초과 (>)", + "pricing.override.comparisonGte": "이상 (≥)", + "pricing.override.modeCustomHelp": "토큰 100만 개당 절대 요금 4개를 입력하세요. 프롬프트 길이에는 캐시된 입력이 포함되고 출력은 제외됩니다. 이 요금은 전체 요청에 대해 카탈로그의 프롬프트 길이 규칙을 대체합니다. Fast 및 Priority 조정은 계속 적용됩니다. 비용은 추정치입니다.", + "pricing.override.customInvalid": "양의 안전 정수 토큰 임계값과 0~1,000,000 사이의 유한한 요금 4개를 입력하세요. 요금은 0일 수 있습니다.", "pricing.override.loading": "저장된 가격을 불러오는 중…", "pricing.override.loadFailed": "저장된 가격을 불러오지 못했습니다. 다시 불러와 주세요.", "pricing.override.outcomeUnknown": "요청 결과를 확인하지 못했습니다. 가격이 변경되었을 수 있으니 저장된 가격을 다시 불러온 뒤 편집하세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 7339139ace6..8f9bb868fa1 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3657,11 +3657,27 @@ export const pt: Record = { "pricing.override.badge": "Preço manual", "pricing.override.title": "Preço do modelo", "pricing.override.modelId": "ID do modelo", - "pricing.override.help": "USD por 1M de tokens. Informe as taxas de entrada e saída; taxas de cache em branco usam 0. As quatro taxas em 0 significam gratuito.", + "pricing.override.promptTitle": "Preço de prompts longos", + "pricing.override.promptHelp": "Os tokens de entrada e de leitura e gravação do cache determinam a faixa; a requisição inteira é cobrada pelas taxas dessa faixa. Tokens de saída nunca contam. As taxas são de velocidade padrão.", + "pricing.override.help": "USD por 1M de tokens. Informe as taxas de entrada e saída; taxas de cache em branco usam 0. Taxas iguais a 0 são permitidas.", "pricing.override.input": "Entrada", "pricing.override.output": "Saída", "pricing.override.cacheRead": "Leitura de cache", "pricing.override.cacheWrite": "Escrita de cache", + "pricing.override.modeAutomatic": "Automático", + "pricing.override.modeFlat": "Preço fixo", + "pricing.override.modeCustom": "Limite personalizado", + + "pricing.override.modeAutomaticHelp": "Usa as regras do catálogo por tamanho do prompt com os preços básicos acima. Os ajustes Fast e Priority continuam valendo.", + "pricing.override.modeFlatHelp": "Usa os preços básicos acima para qualquer tamanho de prompt, substituindo as regras do catálogo. Os ajustes Fast e Priority continuam valendo.", + "pricing.override.threshold": "Limite do prompt (tokens)", + "pricing.override.bandTitle": "Taxas da faixa de prompts longos", + "pricing.override.bandHelp": "Estas são taxas de velocidade padrão. As regras Fast e Priority do catálogo continuam valendo. Os custos são estimativas.", + "pricing.override.comparison": "Comparação do limite", + "pricing.override.comparisonGt": "Maior que (>)", + "pricing.override.comparisonGte": "Maior ou igual a (≥)", + "pricing.override.modeCustomHelp": "Insira quatro preços absolutos por 1 milhão de tokens. O tamanho do prompt inclui a entrada em cache e exclui a saída. Esses preços substituem a regra do catálogo por tamanho de prompt para toda a solicitação. Os ajustes Fast e Priority continuam valendo. Os custos são estimativas.", + "pricing.override.customInvalid": "Insira um limite positivo de tokens que seja um inteiro seguro e quatro preços finitos de 0 a 1.000.000. Os preços podem ser 0.", "pricing.override.loading": "Carregando preço salvo…", "pricing.override.loadFailed": "Não foi possível carregar o preço salvo. Recarregue para tentar de novo.", "pricing.override.outcomeUnknown": "A requisição não terminou de forma confiável. O preço pode ter mudado. Recarregue o preço salvo antes de editar novamente.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2d08ee1946a..3d16af32b33 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3685,11 +3685,27 @@ export const ru: Record = { "pricing.override.badge": "Своя цена", "pricing.override.title": "Цена модели", "pricing.override.modelId": "ID модели", - "pricing.override.help": "USD за 1 млн токенов. Укажите входной и выходной тарифы; пустые тарифы кеша равны 0. Четыре нулевых тарифа означают бесплатное использование.", + "pricing.override.promptTitle": "Цены для длинных промптов", + "pricing.override.promptHelp": "Входные токены и токены чтения и записи кэша определяют диапазон; весь запрос тарифицируется по ценам этого диапазона. Выходные токены не учитываются. Цены указаны для стандартной скорости.", + "pricing.override.help": "USD за 1 млн токенов. Укажите входной и выходной тарифы; пустые тарифы кеша равны 0. Нулевые тарифы разрешены.", "pricing.override.input": "Вход", "pricing.override.output": "Выход", "pricing.override.cacheRead": "Чтение кеша", "pricing.override.cacheWrite": "Запись кеша", + "pricing.override.modeAutomatic": "Автоматически", + "pricing.override.modeFlat": "Фиксированная цена", + "pricing.override.modeCustom": "Свой порог", + + "pricing.override.modeAutomaticHelp": "Использует правила каталога по длине запроса с указанными выше базовыми ценами. Корректировки Fast и Priority продолжают применяться.", + "pricing.override.modeFlatHelp": "Использует указанные выше базовые цены при любой длине запроса, заменяя правила каталога. Корректировки Fast и Priority продолжают применяться.", + "pricing.override.threshold": "Порог длины запроса (токены)", + "pricing.override.bandTitle": "Цены диапазона длинных промптов", + "pricing.override.bandHelp": "Это цены для стандартной скорости. Правила Fast и Priority из каталога продолжают действовать. Стоимость является оценочной.", + "pricing.override.comparison": "Сравнение с порогом", + "pricing.override.comparisonGt": "Больше (>)", + "pricing.override.comparisonGte": "Больше или равно (≥)", + "pricing.override.modeCustomHelp": "Укажите четыре абсолютные цены за миллион токенов. Длина запроса включает кэшированный ввод, но не включает вывод. Эти цены заменяют правило каталога по длине запроса для всего запроса. Корректировки Fast и Priority продолжают применяться. Стоимость указана приблизительно.", + "pricing.override.customInvalid": "Укажите положительный порог в токенах, являющийся безопасным целым числом, и четыре конечные цены от 0 до 1 000 000. Цена может быть равна 0.", "pricing.override.loading": "Загрузка сохранённой цены…", "pricing.override.loadFailed": "Не удалось загрузить сохранённую цену. Повторите загрузку.", "pricing.override.outcomeUnknown": "Результат запроса неизвестен. Цена могла измениться. Загрузите сохранённую цену перед следующим изменением.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 06b1b16cb66..3bf06e2157f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3685,11 +3685,27 @@ export const tr: Record = { "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.promptTitle": "Uzun istem fiyatlandırması", + "pricing.override.promptHelp": "İsteğin girdi ve önbellek okuma ve yazma token'ları aralığı belirler; isteğin tamamı o aralığın fiyatıyla hesaplanır. Çıktı token'ları sayılmaz. Fiyatlar standart hız içindir.", + "pricing.override.help": "1 milyon token başına USD. Giriş ve çıkış ücretlerini girin; boş önbellek ücretleri 0 sayılır. 0 ücretlere izin verilir.", "pricing.override.input": "Giriş", "pricing.override.output": "Çıkış", "pricing.override.cacheRead": "Önbellek okuma", "pricing.override.cacheWrite": "Önbellek yazma", + "pricing.override.modeAutomatic": "Otomatik", + "pricing.override.modeFlat": "Sabit ücret", + "pricing.override.modeCustom": "Özel eşik", + + "pricing.override.modeAutomaticHelp": "Yukarıdaki temel ücretlerle katalogdaki istem uzunluğu kurallarını kullanır. Fast ve Priority ayarlamaları uygulanmaya devam eder.", + "pricing.override.modeFlatHelp": "İstem uzunluğundan bağımsız olarak yukarıdaki temel ücretleri kullanır ve katalogdaki istem uzunluğu kurallarını devre dışı bırakır. Fast ve Priority ayarlamaları uygulanmaya devam eder.", + "pricing.override.threshold": "İstem eşiği (token)", + "pricing.override.bandTitle": "Uzun istem aralığındaki fiyatlar", + "pricing.override.bandHelp": "Bunlar standart hız fiyatlarıdır. Katalogdaki Fast ve Priority kuralları geçerliliğini korur. Maliyetler tahminidir.", + "pricing.override.comparison": "Eşik karşılaştırması", + "pricing.override.comparisonGt": "Büyüktür (>)", + "pricing.override.comparisonGte": "Büyük veya eşittir (≥)", + "pricing.override.modeCustomHelp": "1 milyon token başına dört mutlak ücret girin. İstem uzunluğu önbelleğe alınmış girdiyi içerir ve çıktıyı içermez. Bu ücretler, tüm istek için katalogdaki istem uzunluğu kuralının yerini alır. Fast ve Priority ayarlamaları uygulanmaya devam eder. Maliyetler tahminidir.", + "pricing.override.customInvalid": "Pozitif ve güvenli bir tam sayı token eşiği ile 0–1.000.000 arasında dört sonlu ücret girin. Ücretler 0 olabilir.", "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.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a5b09444fb7..92b5561346f 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3613,11 +3613,27 @@ export const vi: Record = { "pricing.override.badge": "Giá thủ công", "pricing.override.title": "Giá model", "pricing.override.modelId": "Model ID", - "pricing.override.help": "USD cho mỗi 1M token. Nhập mức giá đầu vào (input) và đầu ra (output); mức giá cache nếu để trống sẽ tính là 0. Đặt cả bốn mức giá bằng 0 nghĩa là miễn phí.", + "pricing.override.promptTitle": "Giá prompt dài", + "pricing.override.promptHelp": "Token đầu vào và token đọc, ghi cache của yêu cầu quyết định mức giá; toàn bộ yêu cầu được tính theo mức đó. Token đầu ra không được tính. Giá áp dụng cho tốc độ tiêu chuẩn.", + "pricing.override.help": "USD cho mỗi 1M token. Nhập mức giá đầu vào (input) và đầu ra (output); mức giá cache nếu để trống sẽ tính là 0. Có thể đặt mức giá bằng 0.", "pricing.override.input": "Đầu vào (Input)", "pricing.override.output": "Đầu ra (Output)", "pricing.override.cacheRead": "Đọc bộ nhớ đệm (Cache read)", "pricing.override.cacheWrite": "Ghi bộ nhớ đệm (Cache write)", + "pricing.override.modeAutomatic": "Tự động", + "pricing.override.modeFlat": "Giá cố định", + "pricing.override.modeCustom": "Ngưỡng tùy chỉnh", + + "pricing.override.modeAutomaticHelp": "Dùng quy tắc độ dài prompt trong danh mục cùng các mức giá cơ bản ở trên. Điều chỉnh Fast và Priority vẫn được áp dụng.", + "pricing.override.modeFlatHelp": "Dùng các mức giá cơ bản ở trên cho mọi độ dài prompt, thay thế quy tắc độ dài prompt trong danh mục. Điều chỉnh Fast và Priority vẫn được áp dụng.", + "pricing.override.threshold": "Ngưỡng prompt (token)", + "pricing.override.bandTitle": "Giá trong mức prompt dài", + "pricing.override.bandHelp": "Đây là giá ở tốc độ tiêu chuẩn. Quy tắc Fast và Priority trong danh mục vẫn áp dụng. Chi phí chỉ là ước tính.", + "pricing.override.comparison": "So sánh ngưỡng", + "pricing.override.comparisonGt": "Lớn hơn (>)", + "pricing.override.comparisonGte": "Lớn hơn hoặc bằng (≥)", + "pricing.override.modeCustomHelp": "Nhập bốn mức giá tuyệt đối cho mỗi 1 triệu token. Độ dài prompt bao gồm đầu vào đã lưu trong bộ nhớ đệm và không bao gồm đầu ra. Các mức giá này thay thế quy tắc độ dài prompt trong danh mục cho toàn bộ yêu cầu. Điều chỉnh Fast và Priority vẫn được áp dụng. Chi phí chỉ là ước tính.", + "pricing.override.customInvalid": "Nhập ngưỡng token là số nguyên an toàn dương và bốn mức giá hữu hạn từ 0 đến 1.000.000. Mức giá có thể là 0.", "pricing.override.loading": "Đang tải giá đã lưu…", "pricing.override.loadFailed": "Không thể tải giá đã lưu. Hãy tải lại để thử lại.", "pricing.override.outcomeUnknown": "Yêu cầu không hoàn tất một cách đáng tin cậy. Giá có thể đã thay đổi. Hãy tải lại giá đã lưu trước khi chỉnh sửa tiếp.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b74384c84a2..fff221d842b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3648,11 +3648,27 @@ export const zhTW: Record = { "pricing.override.badge": "手動價格", "pricing.override.title": "模型價格", "pricing.override.modelId": "模型 ID", - "pricing.override.help": "單位為每百萬 token 的美元價格。請輸入輸入與輸出費率;空白快取費率以 0 計算。四項皆為 0 表示免費。", + "pricing.override.promptTitle": "長提示詞計價", + "pricing.override.promptHelp": "請求中的輸入與快取讀寫 token 決定所屬區間,整個請求都依該區間計價。輸出 token 不計入。費率適用於標準速度。", + "pricing.override.help": "單位為每百萬 token 的美元價格。請輸入輸入與輸出費率;空白快取費率以 0 計算。允許費率為 0。", "pricing.override.input": "輸入", "pricing.override.output": "輸出", "pricing.override.cacheRead": "快取讀取", "pricing.override.cacheWrite": "快取寫入", + "pricing.override.modeAutomatic": "自動", + "pricing.override.modeFlat": "固定費率", + "pricing.override.modeCustom": "自訂門檻", + + "pricing.override.modeAutomaticHelp": "使用上方基本費率,並套用目錄中的提示長度規則。Fast 和 Priority 調整仍會套用。", + "pricing.override.modeFlatHelp": "無論提示長度為何都使用上方基本費率,並取代目錄中的提示長度規則。Fast 和 Priority 調整仍會套用。", + "pricing.override.threshold": "提示門檻(Token)", + "pricing.override.bandTitle": "長提示詞區間費率", + "pricing.override.bandHelp": "這些是標準速度費率。目錄中的 Fast 與 Priority 規則仍會套用。費用為估算值。", + "pricing.override.comparison": "門檻比較", + "pricing.override.comparisonGt": "大於 (>)", + "pricing.override.comparisonGte": "大於或等於 (≥)", + "pricing.override.modeCustomHelp": "請輸入每百萬個 Token 的四項絕對費率。提示長度包含快取輸入,不包含輸出。這些費率會針對整個要求取代目錄中的提示長度規則。Fast 和 Priority 調整仍會套用。費用為估算值。", + "pricing.override.customInvalid": "請輸入正的安全整數 Token 門檻,以及四項介於 0 到 1,000,000 的有限費率。費率可以為 0。", "pricing.override.loading": "正在載入已儲存的價格…", "pricing.override.loadFailed": "無法載入已儲存的價格,請重新載入。", "pricing.override.outcomeUnknown": "無法確認請求結果,價格可能已變更。再次編輯前請重新載入已儲存的價格。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8108ee65951..81bc809e20e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3683,11 +3683,27 @@ export const zh: Record = { "pricing.override.badge": "手动价格", "pricing.override.title": "模型价格", "pricing.override.modelId": "模型 ID", - "pricing.override.help": "单位为每百万 token 的美元价格。请输入输入和输出费率;空白缓存费率按 0 计算。四项均为 0 表示免费。", + "pricing.override.promptTitle": "长提示词计价", + "pricing.override.promptHelp": "请求中的输入和缓存读写 token 决定所属区间,整个请求都按该区间计价。输出 token 不计入。费率适用于标准速度。", + "pricing.override.help": "单位为每百万 token 的美元价格。请输入输入和输出费率;空白缓存费率按 0 计算。允许费率为 0。", "pricing.override.input": "输入", "pricing.override.output": "输出", "pricing.override.cacheRead": "缓存读取", "pricing.override.cacheWrite": "缓存写入", + "pricing.override.modeAutomatic": "自动", + "pricing.override.modeFlat": "固定费率", + "pricing.override.modeCustom": "自定义阈值", + + "pricing.override.modeAutomaticHelp": "使用上方基本费率,并应用目录中的提示长度规则。Fast 和 Priority 调整仍会应用。", + "pricing.override.modeFlatHelp": "无论提示长度如何都使用上方基本费率,并替代目录中的提示长度规则。Fast 和 Priority 调整仍会应用。", + "pricing.override.threshold": "提示阈值(令牌)", + "pricing.override.bandTitle": "长提示词区间费率", + "pricing.override.bandHelp": "这些是标准速度费率。目录中的 Fast 和 Priority 规则仍会应用。费用为估算值。", + "pricing.override.comparison": "阈值比较", + "pricing.override.comparisonGt": "大于 (>)", + "pricing.override.comparisonGte": "大于或等于 (≥)", + "pricing.override.modeCustomHelp": "请输入每百万令牌的四项绝对费率。提示长度包含缓存输入,不包含输出。这些费率会针对整个请求替代目录中的提示长度规则。Fast 和 Priority 调整仍会应用。费用为估算值。", + "pricing.override.customInvalid": "请输入一个正的安全整数令牌阈值,以及四项介于 0 到 1,000,000 的有限费率。费率可以为 0。", "pricing.override.loading": "正在加载已保存的价格…", "pricing.override.loadFailed": "无法加载已保存的价格,请重新加载。", "pricing.override.outcomeUnknown": "无法确认请求结果,价格可能已更改。再次编辑前请重新加载已保存的价格。", diff --git a/gui/tests/model-price-cost.test.ts b/gui/tests/model-price-cost.test.ts new file mode 100644 index 00000000000..791067f44a8 --- /dev/null +++ b/gui/tests/model-price-cost.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { + EMPTY_CUSTOM, INVALID, buildDraftCost, loadDraft, parseModelCost, receiptMatches, + type CustomDraft, type ModelCost, type RateDraft, +} from "../src/components/model-price-cost"; + +const base: RateDraft = { input: "1.25", output: "9.5", cacheRead: "", cacheWrite: "" }; +const custom: CustomDraft = { input: "2.5", output: "19", cacheRead: "", cacheWrite: "", threshold: "200000", comparison: "gte" }; +const rates = { input: 1.25, output: 9.5, cacheRead: 0, cacheWrite: 0 }; +const saved: ModelCost = { ...rates, promptLengthPricing: { mode: "custom", thresholdTokens: 200000, comparison: "gte", rates: { input: 2.5, output: 19, cacheRead: 0, cacheWrite: 0 } } }; + +describe("model price cost contract", () => { + test("normalizes an explicit automatic mode to absent", () => { + expect(parseModelCost({ ...rates, promptLengthPricing: { mode: "automatic" } })).toEqual(rates); + expect(parseModelCost({ ...rates, promptLengthPricing: { mode: "flat" } })).toEqual({ ...rates, promptLengthPricing: { mode: "flat" } }); + expect(parseModelCost({ ...rates, promptLengthPricing: { mode: "automatic", ignored: true } })).toBe(INVALID); + }); + + test("builds automatic without a key and defaults blank cache rates to zero", () => { + expect(buildDraftCost(base, "automatic", EMPTY_CUSTOM)).toEqual({ cost: rates }); + expect(buildDraftCost(base, "flat", EMPTY_CUSTOM)).toEqual({ cost: { ...rates, promptLengthPricing: { mode: "flat" } } }); + expect(buildDraftCost(base, "custom", custom)).toEqual({ cost: saved }); + }); + + test("loads saved custom values and validates positive safe-integer thresholds", () => { + expect(loadDraft(saved)).toEqual({ rates: { input: "1.25", output: "9.5", cacheRead: "0", cacheWrite: "0" }, mode: "custom", custom: { ...custom, cacheRead: "0", cacheWrite: "0" } }); + for (const threshold of ["", "0", "1.5", "1e3", "007", "9007199254740993"]) { + expect(buildDraftCost(base, "custom", { ...custom, threshold })).toEqual({ field: "threshold" }); + } + }); + + test("receipt comparison accepts absent and automatic as equal but checks custom data exactly", () => { + expect(receiptMatches(rates, { ...rates, promptLengthPricing: { mode: "automatic" } })).toBe(true); + expect(receiptMatches({ ...saved, promptLengthPricing: { ...saved.promptLengthPricing!, comparison: "gt" } }, saved)).toBe(false); + expect(receiptMatches(null, null)).toBe(true); + expect(receiptMatches(rates, null)).toBe(false); + }); +}); diff --git a/gui/tests/models-price-editor.test.tsx b/gui/tests/models-price-editor.test.tsx index a80180b8930..ef0a07c042c 100644 --- a/gui/tests/models-price-editor.test.tsx +++ b/gui/tests/models-price-editor.test.tsx @@ -8,7 +8,10 @@ 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 }; +type PricingPolicy = { mode: "automatic" } | { mode: "flat" } + | { mode: "custom"; thresholdTokens: number; comparison: "gt" | "gte"; rates: Rates }; +type Cost = Rates & { promptLengthPricing?: PricingPolicy }; +type Mutation = { modelId: string; cost: Cost | 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 }; @@ -28,7 +31,7 @@ describe("Models manual price editor", () => { let container: HTMLElement; let root: Root | null; let rows: ModelRow[]; - let modelCosts: Record; + let modelCosts: Record; let mutations: Mutation[]; let reads: Array<{ url: string; init?: RequestInit }>; let catalogReads: number; @@ -150,7 +153,7 @@ describe("Models manual price editor", () => { } function inputs(): HTMLInputElement[] { - return [...container.querySelectorAll("dialog input")]; + return [...container.querySelectorAll('dialog input[type="number"]')]; } function button(label: string): HTMLButtonElement { @@ -167,16 +170,50 @@ describe("Models manual price editor", () => { await flush(); } - async function fill(values: string[]) { - for (const [index, value] of values.entries()) { + async function fill(values: string[], offset = 0) { + for (const [relativeIndex, value] of values.entries()) { await act(async () => { - const input = inputs()[index]!; + const input = inputs()[offset + relativeIndex]!; Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value); input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); }); } } + async function chooseMode(mode: "automatic" | "flat" | "custom") { + await act(async () => { + container.querySelector(`dialog input[type="radio"][value="${mode}"]`)!.click(); + }); + await flush(); + } + + async function chooseComparison(value: "gt" | "gte") { + await act(async () => container.querySelector('dialog [role="combobox"]')!.click()); + await flush(); + const label = value === "gt" ? "Greater than (>)" : "Greater than or equal to"; + await act(async () => [...testWindow.document.querySelectorAll('[role="option"]')] + .find(option => option.textContent?.includes(label))!.click()); + await flush(); + } + + test("custom comparison options stay inside the modal and selection restores trigger focus", async () => { + await mount(); + await open(); + await chooseMode("custom"); + const comparison = container.querySelector('dialog [role="combobox"]')!; + await act(async () => comparison.click()); + await flush(); + const menu = testWindow.document.querySelector('[role="listbox"]')!; + expect(menu.closest("dialog") === container.querySelector("dialog")).toBe(true); + const inclusive = [...menu.querySelectorAll('[role="option"]')] + .find(option => option.textContent?.includes("Greater than or equal to"))!; + await act(async () => inclusive.click()); + await flush(); + expect(comparison.textContent).toContain("Greater than or equal to"); + expect(testWindow.document.querySelector('[role="listbox"]')).toBeNull(); + expect(testWindow.document.activeElement === comparison).toBe(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); @@ -260,7 +297,170 @@ describe("Models manual price editor", () => { } 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 } }]); + expect(mutations).toEqual([{ modelId: "grok-4.6", cost: { + input: 1000000, output: 0, cacheRead: 0.000001, cacheWrite: 0.5, + } }]); + }); + + test("unparseable cache text is rejected instead of saving as a blank zero rate", async () => { + await mount(); + await open(); + await fill(["2", "3", "", "0"]); + const cacheRead = inputs()[2]!; + // Browsers report value "" plus validity.badInput for text like "1e" in a number input. + Object.defineProperty(cacheRead, "validity", { configurable: true, value: { badInput: true } }); + await click("Save"); + expect(mutations).toHaveLength(0); + expect(cacheRead.getAttribute("aria-invalid")).toBe("true"); + expect(container.querySelector('dialog [role="alert"]')).not.toBeNull(); + }); + + test("flat mode persists and reopens; automatic mode keeps the manually entered base rates", async () => { + await mount(); + await open(); + await fill(["2", "3", "0", "0"]); + await chooseMode("flat"); + await click("Save"); + expect(mutations[0]).toEqual({ modelId: "grok-4.6", cost: { + input: 2, output: 3, cacheRead: 0, cacheWrite: 0, promptLengthPricing: { mode: "flat" }, + } }); + + await open(); + expect(container.querySelector('dialog input[type="radio"][value="flat"]')!.checked).toBe(true); + expect(inputs().slice(0, 4).map(input => input.value)).toEqual(["2", "3", "0", "0"]); + await chooseMode("automatic"); + await click("Save"); + expect(mutations[1]!.cost).toEqual({ + input: 2, output: 3, cacheRead: 0, cacheWrite: 0, + }); + await open(); + expect(container.querySelector('dialog input[type="radio"][value="automatic"]')!.checked).toBe(true); + }); + + test("custom threshold pricing preserves draft across unsaved mode toggles and reopens exact saved policy", async () => { + await mount(); + await open(); + await fill(["1", "2", "3", "4"]); + await chooseMode("custom"); + expect(container.textContent).toContain("Uses the catalog prompt-length rules with the base rates above."); + expect(container.textContent).toContain("Prompt length includes cached input and excludes output."); + await fill(["1000", "0", "5", "0", "7"], 4); + await chooseComparison("gte"); + await chooseMode("flat"); + await chooseMode("custom"); + expect(inputs().slice(4).map(input => input.value)).toEqual(["1000", "0", "5", "0", "7"]); + await click("Save"); + expect(mutations[0]!.cost).toEqual({ + input: 1, output: 2, cacheRead: 3, cacheWrite: 4, + promptLengthPricing: { + mode: "custom", thresholdTokens: 1000, comparison: "gte", + rates: { input: 0, output: 5, cacheRead: 0, cacheWrite: 7 }, + }, + }); + await open(); + expect(container.querySelector('dialog input[type="radio"][value="custom"]')!.checked).toBe(true); + expect(container.textContent).toContain("Uses the catalog prompt-length rules with the base rates above."); + expect(inputs().slice(4).map(input => input.value)).toEqual(["1000", "0", "5", "0", "7"]); + expect(container.querySelector('dialog [role="combobox"]')!.textContent).toContain("Greater than or equal to"); + await click("Reset to automatic"); + expect(mutations[1]).toEqual({ modelId: "grok-4.6", cost: null }); + await open(); + expect(container.querySelector('dialog input[type="radio"][value="automatic"]')!.checked).toBe(true); + expect(inputs().map(input => input.value)).toEqual(["", "", "", ""]); + }); + + test("custom mode rejects nonpositive, unsafe, fractional thresholds and incomplete or out-of-range rates", async () => { + await mount(); + await open(); + await chooseMode("custom"); + await fill(["1", "2", "3", "4", "0", "1", "2", "3", "4"]); + await click("Save"); + expect(mutations).toHaveLength(0); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("positive safe-integer"); + expect(inputs()[4]!.getAttribute("aria-invalid")).toBe("true"); + + for (const threshold of ["1.5", "9007199254740992"]) { + await fill(["1", "2", "3", "4", threshold, "1", "2", "3", "4"]); + await click("Save"); + expect(mutations).toHaveLength(0); + } + await fill(["1", "2", "3", "4", "10", "1", "1000001", "3", "4"]); + await click("Save"); + expect(mutations).toHaveLength(0); + await fill(["1", "2", "3", "4", "10", "0", "0", "0", "0"]); + await click("Save"); + expect(mutations[0]!.cost).toMatchObject({ promptLengthPricing: { + mode: "custom", thresholdTokens: 10, rates: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + } }); + }); + + test("a receipt with matching base rates but a different prompt-length policy requires recovery", async () => { + await mount(); + await open(); + await fill(["1", "2", "3", "4"]); + await chooseMode("custom"); + await fill(["100", "5", "6", "7", "8"], 4); + putResponse = body => Response.json({ ok: true, provider: "xai-demo", modelId: body.modelId, + cost: { ...body.cost, promptLengthPricing: { mode: "flat" } }, + }); + await click("Save"); + expect(inputs().every(input => input.disabled)).toBe(true); + expect(container.querySelector('dialog [role="alert"]')!.textContent).toContain("may have changed"); + putResponse = null; + await click("Reload price"); + expect(container.querySelector('dialog input[type="radio"][value="custom"]')!.checked).toBe(true); + expect(inputs().slice(4).map(input => input.value)).toEqual(["100", "5", "6", "7", "8"]); + expect(mutations).toHaveLength(1); + }); + + test("GET and PUT receipts reject extra keys in cost and every prompt-length pricing shape", async () => { + await mount(); + const malformedPolicies = [ + { mode: "flat", thresholdTokens: 100, rates: SAVED }, + { mode: "automatic", extra: true }, + { mode: "custom", thresholdTokens: 100, comparison: "gt", rates: SAVED, extra: true }, + { mode: "custom", thresholdTokens: 100, comparison: "gt", rates: { ...SAVED, extra: 1 } }, + ]; + for (const policy of malformedPolicies) { + getResponse = () => Response.json({ provider: "xai-demo", modelCosts: { + "grok-4.6": { ...SAVED, promptLengthPricing: policy }, + } }); + await open(); + expect(inputs().every(input => input.disabled)).toBe(true); + await click("Cancel"); + } + getResponse = () => Response.json({ provider: "xai-demo", modelCosts: { + "grok-4.6": { ...SAVED, unexpected: true }, + } }); + await open(); + expect(inputs().every(input => input.disabled)).toBe(true); + await click("Cancel"); + getResponse = null; + + const malformedReceipts: Array<{ mode: "flat" | "custom"; receipt: (cost: Cost) => unknown }> = [ + { mode: "flat", receipt: cost => ({ ...cost, promptLengthPricing: { mode: "flat", thresholdTokens: 100, rates: SAVED } }) }, + { mode: "flat", receipt: cost => ({ ...cost, promptLengthPricing: { mode: "flat", extra: true } }) }, + { mode: "custom", receipt: cost => ({ ...cost, promptLengthPricing: { ...cost.promptLengthPricing, extra: true } }) }, + { mode: "custom", receipt: cost => ({ ...cost, promptLengthPricing: { + ...cost.promptLengthPricing, + rates: { ...(cost.promptLengthPricing?.mode === "custom" ? cost.promptLengthPricing.rates : SAVED), extra: 1 }, + } }) }, + { mode: "flat", receipt: cost => ({ ...cost, unexpected: true }) }, + ]; + for (const malformed of malformedReceipts) { + await open(); + await fill(["2", "3", "0", "0"]); + await chooseMode(malformed.mode); + if (malformed.mode === "custom") await fill(["100", "5", "6", "7", "8"], 4); + putResponse = body => Response.json({ ok: true, provider: "xai-demo", modelId: body.modelId, + cost: body.cost && malformed.receipt(body.cost), + }); + await click("Save"); + expect(inputs().every(input => input.disabled)).toBe(true); + await click("Reload price"); + expect(container.querySelector(`dialog input[type="radio"][value="${malformed.mode}"]`)!.checked).toBe(true); + await click("Cancel"); + } }); test("failed initial reads keep editing locked until a successful reload", async () => { @@ -313,7 +513,9 @@ describe("Models manual price editor", () => { 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(mutations[1]).toEqual({ modelId: "grok-4.6", cost: { + input: 2, output: 3, cacheRead: 0, cacheWrite: 0, + } }); expect(container.querySelector("dialog")).toBeNull(); }); } @@ -324,6 +526,7 @@ describe("Models manual price editor", () => { { 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: { "grok-4.6": { ...SAVED, promptLengthPricing: { mode: "custom", thresholdTokens: 0, comparison: "gt", rates: SAVED } } } }, { provider: "xai-demo", modelCosts: [] }, ]) { getResponse = () => Response.json(payload); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 626893f97cb..ffd84bbd044 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -22,6 +22,7 @@ "cli-anthropic2-account.test.ts": "cli", "anthropic2-catalog.test.ts": "codex-integration", "provider-anthropic-instance-quota.test.ts": "providers", "anthropic2-management.test.ts": "server", "management-sidecar-anthropic-instance.test.ts": "server", "anthropic2-usage-attribution.test.ts": "usage", "vision-anthropic-instance-sidecar.test.ts": "vision", "web-search-anthropic-instance.test.ts": "web-search", "provider-anthropic-instance.test.ts": "providers", "config-anthropic-instance-pool.test.ts": "config", "oauth-anthropic-instance-registration.test.ts": "oauth", "oauth-anthropic-instance-refresh.test.ts": "oauth", + "model-costs-pricing-cache.test.ts": "server", "model-costs-prompt-length-pricing.test.ts": "server", "cli-models-prompt-length-pricing.test.ts": "cli", "usage-prompt-length-pricing.test.ts": "usage", "claude-picker-upload.test.ts": "claude-integration", "claude-picker-linger.test.ts": "claude-integration", "messages-native-scope.test.ts": "claude-integration", "catalog-auto-review-row.test.ts": "codex-integration", "codex-use-remaining-quota.test.ts": "codex-integration", "cli-log-view-filter.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", "cli-usage-top.test.ts": "cli", "cli-companion-usage.test.ts": "cli", "cli-account-key-quota.test.ts": "cli", diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index 86b9cdd25aa..1797d92e414 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -21,7 +21,7 @@ import { redactSecretString } from "../lib/redact"; import type { ProviderCostOverlay } from "../types"; import { resolveMatchedPrice } from "../usage/cost"; import { MAX_COST4_RATE } from "../usage/expected-prices"; -import { isValidCost4Rate } from "../usage/user-cost-overlays"; +import { isValidCost4Rate, validCost4 } from "../usage/user-cost-overlays"; const USAGE = `Usage: ocx models display-name (--set | --clear) [--json] @@ -89,8 +89,8 @@ function priceRecord(value: unknown): value is Record { 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])); + return priceRecord(value) && validCost4(value) + && Object.keys(value).every(key => key === "promptLengthPricing" || (PRICE_RATE_KEYS as readonly string[]).includes(key)); } async function price(write: boolean, argv: string[], deps: RuntimeApiDeps): Promise { @@ -135,7 +135,9 @@ async function priceRequest(write: boolean, argv: string[], deps: RuntimeApiDeps } // The API map owns manual overrides; bundled defaults remain derived rather // than being persisted as overrides that would mask later catalog updates. - const effectiveCost = cost ?? resolveMatchedPrice(provider, modelId, undefined, [])?.cost4 ?? null; + const effectiveCost = cost + ? { input: cost.input, output: cost.output, cacheRead: cost.cacheRead, cacheWrite: cost.cacheWrite } + : resolveMatchedPrice(provider, modelId, undefined, [])?.cost4 ?? null; printData({ provider, modelId, cost, effectiveCost }, wantsJson, [ effectiveCost === null ? `${selector}: automatic pricing (unknown)` : `${selector}: ${JSON.stringify(effectiveCost)} USD per 1M tokens${cost === null ? " (automatic estimate)" : ""}`, @@ -161,17 +163,42 @@ async function priceRequest(write: boolean, argv: string[], deps: RuntimeApiDeps } return value; }; - const cost: ProviderCostOverlay | null = auto ? null : { + // Validate every rate before any request, so a bad flag never reaches the management plane. + const rates: ProviderCostOverlay | undefined = auto ? undefined : { input: rate(input!, "--input"), output: rate(output!, "--output"), cacheRead: rate(cacheRead ?? "0", "--cache-read"), cacheWrite: rate(cacheWrite ?? "0", "--cache-write"), }; + // A base-rate write replaces the whole row, so read the current row first and carry the + // stored prompt-length policy for exactly this model forward instead of silently deleting it. + let carriedPromptLengthPricing: ProviderCostOverlay["promptLengthPricing"]; + if (rates) { + const current = await runtimeRequest(path, {}, deps); + if (!priceRecord(current) || current.provider !== provider || !priceRecord(current.modelCosts) + || !Object.values(current.modelCosts).every(validPriceCost)) { + throw new Error("Invalid model price response"); + } + if (Object.hasOwn(current.modelCosts, modelId)) { + carriedPromptLengthPricing = (current.modelCosts[modelId] as ProviderCostOverlay).promptLengthPricing; + } + } + const cost: ProviderCostOverlay | null = rates + ? { ...rates, ...(carriedPromptLengthPricing ? { promptLengthPricing: carriedPromptLengthPricing } : {}) } + : null; const result = await runtimeRequest(path, { method: "PUT", body: JSON.stringify({ modelId, cost }) }, deps); + // Absent and { mode: "automatic" } are the same policy (the server stores automatic as + // absent), and key order carries no meaning in a JSON receipt, so compare canonically. + const canonicalPolicy = (policy: ProviderCostOverlay["promptLengthPricing"]): string => + policy === undefined || policy.mode === "automatic" ? `{"mode":"automatic"}` + : JSON.stringify(policy, (_key, item: unknown) => (item && typeof item === "object" && !Array.isArray(item) + ? Object.fromEntries(Object.entries(item as Record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : item)); 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]))) { + || !PRICE_RATE_KEYS.every(key => receivedCost[key] === cost[key]) + || canonicalPolicy(receivedCost.promptLengthPricing) !== canonicalPolicy(cost.promptLengthPricing))) { throw new Error("Invalid model price persistence receipt"); } // Project the acknowledged fields only; unrelated response fields are not CLI output. diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 9b4dbdea91c..2cc9f00fe0e 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -1,4 +1,4 @@ -import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; import { configReasoningPinsConfigError } from "./provider-validation"; import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration"; import { refreshPreservedProviderOwner } from "../usage/user-cost-overlays"; @@ -16,7 +16,7 @@ import { } from "./rebase-provenance"; import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./mutation-lock"; import { ConfigWritePublishedError, persistConfigUnlocked, readRawConfigJson } from "./persist-unlocked"; -import { configDiagnosticsFromRaw, readConfigDiagnostics } from "./diagnostics"; +import { configDiagnosticsFromRaw, readConfigDiagnostics, validateConfigCandidate } from "./diagnostics"; import { normalizePersistedClaudeCode } from "./load-degrade"; import { reconcileApiKeys } from "./api-key-reconcile"; import { captureLiveTopologyBaseline, LIVE_TOPOLOGY_KEYS, projectLiveTopology } from "./live-topology"; @@ -61,6 +61,35 @@ export function adoptPersistedCodexAccountSelection( if (baseline) clearConfigTopLevelDeletionIntent(baseline, keys); } +type ModelCostReplacement = { provider: string; modelId: string; cost: ProviderCostOverlay | null }; +const modelCostReplacements = new WeakMap(); + +/** Explicit pricing PUT owns one whole row, even when its base tuple equals the baseline. */ +export function withModelCostRowReplacement( + config: OcxConfig, provider: string, modelId: string, cost: ProviderCostOverlay | null, save: () => void, +): void { + const previous = modelCostReplacements.get(config); + const replacementCost = structuredClone(cost); + if (replacementCost?.promptLengthPricing?.mode === "automatic") delete replacementCost.promptLengthPricing; + modelCostReplacements.set(config, { provider, modelId, cost: replacementCost }); + try { save(); } + finally { + if (previous) modelCostReplacements.set(config, previous); + else modelCostReplacements.delete(config); + } +} + +function applyModelCostRowReplacement(config: OcxConfig): void { + const replacement = modelCostReplacements.get(config); + if (!replacement) return; + if (!Object.hasOwn(config.providers, replacement.provider)) throw new Error("pricing provider was removed before persistence"); + const provider = config.providers[replacement.provider]!; + const costs = Object.assign(Object.create(null) as Record, provider.modelCosts ?? {}); + if (replacement.cost === null) delete costs[replacement.modelId]; + else costs[replacement.modelId] = structuredClone(replacement.cost); + provider.modelCosts = costs; +} + /** * Adopt a committed discovery decision and its merge baseline as one synchronous step. * Visibility writers assign and save synchronously; any future writer yielding between @@ -483,6 +512,10 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { if (pinError) throw new Error(pinError); let published = false; const persist = (candidate: OcxConfig): void => { + if (modelCostReplacements.has(config)) { + const validation = validateConfigCandidate(candidate); + if (!validation.ok) throw new Error(validation.error); + } const changed = persistConfigUnlocked(candidate); published = true; if (changed) bumpGenerationForCooperatingConfigWrite(); @@ -544,6 +577,9 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { } } applyConfigObjectChildDeletions(config, childDeletions); + // Recursive three-way merging preserves sibling rows; explicit PUT/reset + // replaces only its model after merging, including absent policy fields. + applyModelCostRowReplacement(config); if (claudeCodeBaseline.has(config)) { if (onDisk !== undefined) { const baseline = claudeCodeBaseline.get(config); diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 4e0a7d20ce2..dcbabdf148b 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -34,7 +34,7 @@ import { import { fastWireDeclarationError } from "../../providers/fastwire"; import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelWireDefault } from "../../providers/registry"; import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models"; -import { COST4_RATE_KEYS, isValidCost4Rate } from "../../usage/user-cost-overlays"; +import { COST4_RATE_KEYS, isValidCost4Rate, isValidPromptLengthPricing, copyPromptLengthPricing } from "../../usage/user-cost-overlays"; import { MAX_COST4_RATE } from "../../usage/expected-prices"; import { DECLARABLE_HOSTED_TOOL_TYPES, @@ -423,7 +423,7 @@ export { providerRelativeSendPathConfigError } from "../provider-relative-send-p /** * Validate `providers..modelCosts`: a plain object keyed by exact model - * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates. + * id, each value four USD-per-1M-token rates and an optional prompt-length policy. * Returns null when valid/absent, else a human-readable error. */ export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null { @@ -443,25 +443,28 @@ export function providerModelCostsConfigError(value: unknown, field = "modelCost const rates = entry as Record; for (const key of COST4_RATE_KEYS) { const rate = rates[key]; - if (!isValidCost4Rate(rate)) { + if (!Object.hasOwn(rates, key) || !isValidCost4Rate(rate)) { return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`; } } + if (Object.hasOwn(rates, "promptLengthPricing") && !isValidPromptLengthPricing(rates.promptLengthPricing)) { + return `${field}.${safeModelId}.promptLengthPricing must be automatic, flat, or a complete custom policy with a positive safe-integer thresholdTokens, gt/gte comparison, and four valid rates`; + } // Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row // would otherwise be persisted and echoed verbatim by display paths that // mask only top-level provider secrets. const extraKeys = Object.keys(rates) - .filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key)); + .filter((key) => key !== "promptLengthPricing" && !(COST4_RATE_KEYS as readonly string[]).includes(key)); if (extraKeys.length > 0) { - return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`; + return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, cacheWrite, and promptLengthPricing are allowed (USD per 1M tokens)`; } } return null; } /** - * Serialize `providers..modelCosts` for display: copy ONLY the four - * numeric rate fields per model and DROP secret-shaped model ids, so a pasted + * Serialize `providers..modelCosts` for display: copy the four validated + * rates and complete validated policy and DROP secret-shaped model ids, so a pasted * API key in a key position cannot be echoed back by CLI/DTO display paths. * The result uses a null prototype so "__proto__" remains an own row. */ @@ -471,6 +474,7 @@ export function sanitizeModelCostsForDisplay(costs: unknown): Record; + if (Object.hasOwn(rates, "promptLengthPricing") && !isValidPromptLengthPricing(rates.promptLengthPricing)) continue; const input = rates.input; const output = rates.output; const cacheRead = rates.cacheRead; @@ -484,7 +488,10 @@ export function sanitizeModelCostsForDisplay(costs: unknown): Record 0 ? out : undefined; diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 9b33d6e573d..247d264197d 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -1,7 +1,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; import { OPENAI_PROVIDER_TIER_VERSION } from "../types"; import { openaiResponsesUrl } from "../adapters/openai-responses-url"; -import { MAX_COST4_RATE } from "../usage/expected-prices"; +import { COST4_RATE_KEYS, validCost4 } from "../usage/user-cost-validation"; import { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "./openai-tiers-destination"; export { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint, isOpenAiOperatedResponsesDestination, destinationDecodesNativeCompactionBlob } from "./openai-tiers-destination"; @@ -54,19 +54,12 @@ function managedLegacyMultiOverlay( }; } -/** Shape check for a legacy overlay: a plain record of complete non-negative finite Cost4 rows. */ +/** Use the shared rate/policy contract while rejecting unknown legacy row fields. */ function validLegacyOverlayCosts(value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const fields = ["input", "output", "cacheRead", "cacheWrite"] as const; + const allowed = new Set([...COST4_RATE_KEYS, "promptLengthPricing"]); return Object.values(value as Record).every(entry => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; - const rates = entry as Record; - return Object.keys(rates).length === fields.length - && fields.every(key => Object.hasOwn(rates, key) - && typeof rates[key] === "number" - && Number.isFinite(rates[key]) - && (rates[key] as number) >= 0 - && (rates[key] as number) <= MAX_COST4_RATE); + return validCost4(entry) && Object.keys(entry).every(key => allowed.has(key)); }); } diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index b8928c39bd7..2b6586c9a94 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -109,6 +109,7 @@ import { import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; +import { withModelCostRowReplacement } from "../../config/live-reconcile"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; @@ -408,7 +409,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise, - previousModelCosts ?? {}, + structuredClone(previousModelCosts ?? {}), ); if (cost === null) delete nextModelCosts[modelId]; - else nextModelCosts[modelId] = cost; + else nextModelCosts[modelId] = structuredClone(cost); const mergedError = providerModelCostsConfigError(nextModelCosts); if (mergedError) return jsonResponse({ error: mergedError }, 400, req, config); // Keep even an empty map until persistence reconciles individual model keys. @@ -435,10 +436,14 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise persistConfig(config)); } catch (error) { - if (hadModelCosts) provider.modelCosts = previousModelCosts; - else delete provider.modelCosts; + // A published write is durable even if bookkeeping failed: recovery GET + // must not serve old prices or let a later save undo the committed policy. + if (!(error instanceof ConfigWritePublishedError)) { + if (hadModelCosts) provider.modelCosts = previousModelCosts; + else delete provider.modelCosts; + } throw error; } return jsonResponse({ ok: true, provider: name, modelId, cost }, 200, req, config); diff --git a/src/types.ts b/src/types.ts index 63425b6c6ba..2abde60e2af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,6 +122,7 @@ export type { ProviderWebSearchBridgeBackend, ProviderWebSearchBridgeConfig, ProviderCostOverlay, + PromptLengthPricing, RequestPacingRule, ProviderRequestPacingConfig, FastWire, diff --git a/src/types/provider.ts b/src/types/provider.ts index a06e907a420..9feac95f48e 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -164,16 +164,25 @@ export interface ProviderWebSearchBridgeConfig { endpoint?: string; } -/** - * User-configured display price for one model (USD per 1M tokens). - * Mirrors the `Cost4` shape used by the usage cost estimator; structurally - * compatible so config rows can be lifted directly into price overlays. - */ +/** Optional user-owned prompt-length policy; rates are USD per 1M tokens. */ +export type PromptLengthPricing = + | { mode: "automatic" } + | { mode: "flat" } + | { + mode: "custom"; + thresholdTokens: number; + comparison: "gt" | "gte"; + rates: { input: number; output: number; cacheRead: number; cacheWrite: number }; + }; + +/** Complete base rates for one model, with automatic pricing when policy is absent. */ export interface ProviderCostOverlay { input: number; output: number; cacheRead: number; cacheWrite: number; + /** Absent means automatic vendor prompt-length pricing. */ + promptLengthPricing?: PromptLengthPricing; } export interface RequestPacingRule { diff --git a/src/usage/cost.ts b/src/usage/cost.ts index c0bf94785e8..d3e7f6a668a 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -15,6 +15,7 @@ import { resolveMetadataProvider, } from "../generated/model-metadata"; import type { AttemptTierOutcome, OcxUsage } from "../types"; +import type { PromptLengthPricing } from "../types/provider"; import { canonicalFastTierMarker } from "../providers/fastwire"; import { baseProviderLabel } from "../providers/label"; import type { PersistedUsageAttempt, UsageStatus } from "./log"; @@ -79,6 +80,7 @@ export interface MatchedPrice { modelId: string; jawcodeProvider?: string; cost4: Cost4; + promptLengthPricing?: PromptLengthPricing; source: "jawcode" | "expected" | "user"; sourceRef?: string; verifiedAt?: string; @@ -97,6 +99,8 @@ export interface AttemptCostEstimate { priorityMultiplier?: number; /** Set when the published long-context rate was applied (#908). */ contextTier?: ContextTierName; + /** User absolute rates selected for this attempt, separate from catalog tiers. */ + customThresholdApplied?: true; /** The numeric estimate is a known floor because the exact Priority price is unavailable. */ priorityLowerBound?: boolean; } @@ -111,6 +115,8 @@ export interface CostEstimate { priorityMultiplier?: number; /** Set when any priced attempt used the published long-context rate (#908). */ contextTier?: ContextTierName; + /** At least one request/attempt selected the user threshold rates. */ + customThresholdApplied?: true; /** The aggregate is a known floor because every priced attempt is a lower bound. */ priorityLowerBound?: boolean; } @@ -343,6 +349,7 @@ function userOverlayMatch( modelId, cost4: overlay.cost4, source: "user", + ...(overlay.promptLengthPricing ? { promptLengthPricing: overlay.promptLengthPricing } : {}), sourceRef: overlay.source, verifiedAt: overlay.verifiedAt, status: "verified", @@ -475,8 +482,8 @@ function isConfirmedFast(tier?: ServiceTierInput): boolean { } /** - * Apply the published long-context rate when raw prompt size crosses the vendor - * threshold. Returns [effectiveCost4, tierName]. + * Select user prompt-length rates, or apply the published context rule in automatic mode. + * Returns [effectiveCost4, catalogTier, priorityLowerBound, customThresholdApplied]. * * `rawInputTokens` MUST be `usage.inputTokens` (total prompt size), never the * normalized billable input — normalization subtracts cache read/write, so a @@ -486,13 +493,27 @@ function isConfirmedFast(tier?: ServiceTierInput): boolean { * API rows can stack the bands. xAI publishes neither a combined rate nor an exclusion, * so its long-context rate remains the known lower bound instead of inventing a stacked multiplier. */ -function applyContextTier( +function applyPromptLengthPricing( cost4: Cost4, provider: string, modelId: string, rawInputTokens: number | undefined, tier?: ServiceTierInput, -): [Cost4, ContextTierName | undefined, boolean] { + policy?: PromptLengthPricing, +): [Cost4, ContextTierName | undefined, boolean, true?] { + // A user policy replaces the catalog rule on BOTH sides of its threshold. + // Keep custom provenance separate while honoring the published Priority relation. + if (policy?.mode === "flat") return [cost4, undefined, false]; + if (policy?.mode === "custom") { + const crossed = rawInputTokens !== undefined && Number.isFinite(rawInputTokens) + && (policy.comparison === "gte" + ? rawInputTokens >= policy.thresholdTokens : rawInputTokens > policy.thresholdTokens); + if (!crossed) return [cost4, undefined, false]; + const relation = findContextTier(provider, modelId)?.confirmedPriorityRelation; + const confirmedFast = isConfirmedFast(tier); + if (confirmedFast && relation === "exclusive") return [cost4, undefined, false]; + return [policy.rates, undefined, confirmedFast && relation === "lower-bound", true]; + } if (rawInputTokens === undefined) return [cost4, undefined, false]; const rule = findContextTier(provider, modelId); if (!rule || !isLongContext(rule, rawInputTokens)) return [cost4, undefined, false]; @@ -520,9 +541,12 @@ function applyPriorityMultiplier( modelId: string, serviceTier?: ServiceTierInput, contextTier?: ContextTierName, + customThresholdApplied?: true, ): [Cost4, number] { if (canonicalFastTierMarker(tierScalar(serviceTier)) !== "priority") return [cost4, 1]; if (contextTier && findContextTier(provider, modelId)?.confirmedPriorityRelation !== "stack") return [cost4, 1]; + if (customThresholdApplied && isConfirmedFast(serviceTier) + && findContextTier(provider, modelId)?.confirmedPriorityRelation === "lower-bound") return [cost4, 1]; const rule = findPriorityPricingRule(provider, modelId); if (rule?.requiresResponseConfirmation && !isConfirmedFast(serviceTier)) return [cost4, 1]; const multiplier = rule?.multiplier ?? 1; @@ -576,11 +600,11 @@ export function estimateAttemptCost( const attemptServiceTier = attempt.tierOutcome ? serviceTierContextFromOutcome(attempt.tierOutcome) : serviceTier; - const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier( - price.cost4, price.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier, + const [tieredCost4, contextTier, contextPriorityLowerBound, customThresholdApplied] = applyPromptLengthPricing( + price.cost4, price.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier, price.promptLengthPricing, ); const [effectiveCost4, multiplier] = applyPriorityMultiplier( - tieredCost4, price.provider, attempt.model, attemptServiceTier, contextTier, + tieredCost4, price.provider, attempt.model, attemptServiceTier, contextTier, customThresholdApplied, ); const priorityLowerBound = contextPriorityLowerBound || isOpenRouterPriorityLowerBound(price.provider, attempt.tierOutcome); @@ -594,6 +618,7 @@ export function estimateAttemptCost( estimated: isEstimated(attempt.usage, attempt.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), ...(contextTier ? { contextTier } : {}), + ...(customThresholdApplied ? { customThresholdApplied } : {}), ...(priorityLowerBound ? { priorityLowerBound: true } : {}), }; } @@ -637,6 +662,7 @@ export function estimateComboCost( ? { priorityMultiplier: estimates.find(est => est.priorityMultiplier)?.priorityMultiplier } : {}), ...(estimates.some(est => est.contextTier) ? { contextTier: "long" as const } : {}), + ...(estimates.some(est => est.customThresholdApplied) ? { customThresholdApplied: true as const } : {}), ...(estimates.every(est => est.priorityLowerBound === true) ? { priorityLowerBound: true as const } : {}), @@ -661,11 +687,11 @@ export function estimateRequestCost( if (!tokens) return null; const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays, input); if (!price) return null; - const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier( - price.cost4, price.provider, input.model, input.usage.inputTokens, input.serviceTier, + const [tieredCost4, contextTier, contextPriorityLowerBound, customThresholdApplied] = applyPromptLengthPricing( + price.cost4, price.provider, input.model, input.usage.inputTokens, input.serviceTier, price.promptLengthPricing, ); const [effectiveCost4, multiplier] = applyPriorityMultiplier( - tieredCost4, price.provider, input.model, input.serviceTier, contextTier, + tieredCost4, price.provider, input.model, input.serviceTier, contextTier, customThresholdApplied, ); const priorityLowerBound = contextPriorityLowerBound || isOpenRouterPriorityLowerBound( price.provider, @@ -678,6 +704,7 @@ export function estimateRequestCost( estimated: isEstimated(input.usage, input.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), ...(contextTier ? { contextTier } : {}), + ...(customThresholdApplied ? { customThresholdApplied } : {}), ...(priorityLowerBound ? { priorityLowerBound: true } : {}), }; } diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index da72ecc1c9c..0a4796f4c67 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -10,6 +10,7 @@ import { isAnthropicInstanceId } from "../providers/anthropic-instance-id"; export function pricingFamilyProvider(provider: string): string { return isAnthropicInstanceId(provider) ? "anthropic" : provider; } +import type { PromptLengthPricing } from "../types/provider"; /** * Expected-price overlay for models whose jawcode cost rows are missing or all-zero @@ -46,6 +47,8 @@ export interface ExpectedPriceOverlay { provider: string; modelId: string; cost4: Cost4; + /** User-owned context policy; catalog rows leave this absent. */ + promptLengthPricing?: PromptLengthPricing; source: string; verifiedAt: string; status: ExpectedPriceStatus; diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index 6024e175966..058857858d9 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -18,8 +18,10 @@ * * Display-time estimation only — these rows never affect billing. */ -import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; -import { MAX_COST4_RATE, type ExpectedPriceOverlay } from "./expected-prices"; +import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay, PromptLengthPricing } from "../types"; +import type { ExpectedPriceOverlay } from "./expected-prices"; +import { validCost4 } from "./user-cost-validation"; +export { COST4_RATE_KEYS, isValidCost4Rate, validCost4, isValidPromptLengthPricing } from "./user-cost-validation"; import { redactSecretString } from "../lib/redact"; import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id"; import { codexAccountLogLabel } from "../codex/account-label"; @@ -236,22 +238,13 @@ export function resetPreservedDiskOnlyProvidersForTests(): void { preservationOwnerStates.clear(); } -/** Exact four fields accepted for a user-configured cost tuple. */ -export const COST4_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const; - -/** Shared per-rate predicate used by config validation, display sanitization, and runtime lifting. */ -export function isValidCost4Rate(rate: unknown): rate is number { - return typeof rate === "number" - && Number.isFinite(rate) - && rate >= 0 - && rate <= MAX_COST4_RATE; -} - -/** True when `value` is a complete cost entry: all four rates are non-negative finite numbers. */ -function validCost4(value: unknown): value is ProviderCostOverlay { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const entry = value as Record; - return COST4_RATE_KEYS.every(key => isValidCost4Rate(entry[key])); +/** Copy a validated override; automatic is represented by absence. */ +export function copyPromptLengthPricing(policy: PromptLengthPricing): PromptLengthPricing | undefined { + if (policy.mode === "automatic") return undefined; + if (policy.mode !== "custom") return { mode: policy.mode }; + const { input, output, cacheRead, cacheWrite } = policy.rates; + return { mode: "custom", thresholdTokens: policy.thresholdTokens, comparison: policy.comparison, + rates: { input, output, cacheRead, cacheWrite } }; } /** @@ -273,10 +266,11 @@ export function refreshUserCostOverlays(config: OcxConfig): void { if (!costs || typeof costs !== "object" || Array.isArray(costs)) continue; for (const [modelId, cost4] of Object.entries(costs)) { if (!modelId.trim() || !validCost4(cost4)) continue; + const policy = cost4.promptLengthPricing && copyPromptLengthPricing(cost4.promptLengthPricing); rows.push({ provider: providerName, modelId, - // Copy ONLY the four validated rate fields: a hand-edited row may + // Copy only validated rates and policy: a hand-edited row may // carry extra properties (e.g. a misplaced apiKey) that must never // reach display estimates or /api/logs through the registry. cost4: { @@ -285,6 +279,7 @@ export function refreshUserCostOverlays(config: OcxConfig): void { cacheRead: cost4.cacheRead, cacheWrite: cost4.cacheWrite, }, + ...(policy ? { promptLengthPricing: policy } : {}), // Display provenance only: redact token-shaped provider/model ids so // the source string can never echo a pasted key. Matching still uses // the raw fields, and the change-detection signature below MUST keep diff --git a/src/usage/user-cost-validation.ts b/src/usage/user-cost-validation.ts new file mode 100644 index 00000000000..d2767d50601 --- /dev/null +++ b/src/usage/user-cost-validation.ts @@ -0,0 +1,38 @@ +/** Pure model-cost validation shared by config, migration, and display lifting. */ +import type { ProviderCostOverlay, PromptLengthPricing } from "../types"; +import { MAX_COST4_RATE } from "./expected-prices"; + +/** Exact four fields accepted for a user-configured cost tuple. */ +export const COST4_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const; + +/** Shared per-rate predicate used by config validation, display sanitization, and runtime lifting. */ +export function isValidCost4Rate(rate: unknown): rate is number { + return typeof rate === "number" + && Number.isFinite(rate) + && rate >= 0 + && rate <= MAX_COST4_RATE; +} + +/** Complete base rates with a valid optional policy; extra top-level fields are never copied. */ +export function validCost4(value: unknown): value is ProviderCostOverlay { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const entry = value as Record; + return COST4_RATE_KEYS.every(key => Object.hasOwn(entry, key) && isValidCost4Rate(entry[key])) + && (!Object.hasOwn(entry, "promptLengthPricing") || isValidPromptLengthPricing(entry.promptLengthPricing)); +} + +/** Strict nested policy boundary; diagnostics never echo untrusted policy fields. */ +export function isValidPromptLengthPricing(value: unknown): value is PromptLengthPricing { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const policy = value as Record; + if (!Object.hasOwn(policy, "mode")) return false; + if (policy.mode === "automatic" || policy.mode === "flat") return Object.keys(policy).length === 1; + if (policy.mode !== "custom" || Object.keys(policy).length !== 4 + || !["mode", "thresholdTokens", "comparison", "rates"].every(key => Object.hasOwn(policy, key)) + || typeof policy.thresholdTokens !== "number" || !Number.isSafeInteger(policy.thresholdTokens) + || policy.thresholdTokens <= 0 || (policy.comparison !== "gt" && policy.comparison !== "gte")) return false; + const rates = policy.rates; + return !!rates && typeof rates === "object" && !Array.isArray(rates) + && Object.keys(rates).length === COST4_RATE_KEYS.length + && COST4_RATE_KEYS.every(key => Object.hasOwn(rates, key) && isValidCost4Rate((rates as Record)[key])); +} diff --git a/structure/INDEX.md b/structure/INDEX.md index 7acac40929c..3ff8fd65633 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -88,6 +88,7 @@ The dashboard, the management API, and third-party client config ownership. | [`cli-management.md`](cli-management.md) | Terminal help and capability metadata, management-client output and the generated operating reference. | | [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and startup safety. | | [`dashboard-and-usage.md`](dashboard-and-usage.md) | Dashboard page contracts, usage accounting and request metrics, and per-surface management settings. | +| [`pricing.md`](pricing.md) | User price policies, prompt-length rate selection, persistence and estimate cache invalidation. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | | [`clients/codex-desktop.md`](clients/codex-desktop.md) | Windows package activation, restart context, and protected compatibility authority persistence. | | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | @@ -138,6 +139,7 @@ A source area can be described by more than one doc, because these docs are orga | `desktop/ui/cli.js` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `gui/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`design-methodology.md`](design-methodology.md)
[`companion.md`](companion.md) | +| `gui/src/components/ModelPriceDialog.tsx` | [`pricing.md`](pricing.md) | | `scripts/` | [`overview.md`](overview.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `scripts/generate-ocx-skill-surface.ts` | [`cli-management.md`](cli-management.md) | | `skills/ocx/` | [`cli-management.md`](cli-management.md) | @@ -150,6 +152,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/claude/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md) | | `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | @@ -164,7 +167,9 @@ A source area can be described by more than one doc, because these docs are orga | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | | `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md) | | `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | +| `src/config/live-reconcile.ts` | [`pricing.md`](pricing.md) | | `src/config/persisted-mutation.ts` | [`runtime.md`](runtime.md) | +| `src/config/schema/leaf-validators.ts` | [`pricing.md`](pricing.md) | | `src/generated/` | [`runtime.md`](runtime.md) | | `src/github/` | [`runtime.md`](runtime.md) | | `src/grok/` | [`runtime.md`](runtime.md) | @@ -200,6 +205,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | +| `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | | `src/server/responses/advisor-plan-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/advisor-slot.ts` | [`advisor.md`](advisor.md) | | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | @@ -219,12 +225,16 @@ A source area can be described by more than one doc, because these docs are orga | `src/tray/` | [`runtime.md`](runtime.md) | | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/types/` | [`providers-and-adapters.md`](providers-and-adapters.md) | +| `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/update/job-supervised-restart.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/job.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/supervised-recovery.mjs` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/usage/` | [`runtime.md`](runtime.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`companion.md`](companion.md) | +| `src/usage/cost.ts` | [`pricing.md`](pricing.md) | | `src/usage/timeline.ts` | [`gui-and-management-api.md`](gui-and-management-api.md) | +| `src/usage/user-cost-overlays.ts` | [`pricing.md`](pricing.md) | +| `src/usage/user-cost-validation.ts` | [`pricing.md`](pricing.md) | | `src/vision/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | | `src/web-search/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | | `tests/` | [`ops/test-sandbox-cleanup.md`](ops/test-sandbox-cleanup.md) | diff --git a/structure/config.md b/structure/config.md index 6d0b2b44836..2ddd9cc3719 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,6 +1,6 @@ # Config Surface -`src/config/schema/leaf-validators.ts` accepts only boolean `apiKeys[].allowMeteredComboTargets`; malformed entries are dropped rather than promoted. `src/types/config.ts` and combo validation define optional boolean target `metered`, normalized false when omitted, and optional per-combo `allowMeteredTargets` (default false, literal boolean validation shared by config schema and management API). Explicit consent is required under the [combo runtime contract](runtime.md#request-local-target-compatibility). Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). `src/types/config.ts` defines `anthropicAccountPool.routes` as ordered model rules. `src/oauth/anthropic-model-routes.ts` validates bounded names, full case-sensitive globs and stored account IDs; `src/config/diagnostics.ts` rejects malformed candidate writes. Load retains a malformed hand edit so unrelated providers survive, while the enabled Anthropic selector refuses it locally before dispatch. Saved rules remain inert when the pool is disabled; affinity is process-local. Request logs use only the rule’s 1-based `route:#` position, never its configured name. `src/cli/hub-disable.ts` removes interdependent hub/loopback/role/hostname settings through `mutatePersistedConfig` and `validateConfigCandidate` in one commit. Top-level deletion intent uses `src/config/rebase-provenance.ts` and authoritative disk provenance in `src/config/live-reconcile.ts` to preserve removed hub bindings through later live saves, `src/config/live-topology.ts` keeps an independent active-topology baseline and merges desired next-start settings into a separate persistence projection, retaining actual socket identity and live hub/client topology during guarded saves and OAuth disk adoption. Client connections and runtimeRole remain one topology unit through connect/disconnect, including fresh disk-only connections. Disk additions/value updates and deletions all survive repeated saves without changing active topology. `src/config/api-key-reconcile.ts` merges key rows by stable ID under the existing save lock; either side's revocation defeats stale edits while disjoint names and rotations merge; unchanged live key membership/order adopts persisted ordering. `tests/config/hub-disable-live-save.test.ts` exercises disable followed by live key creation, rename, rotation, unrelated settings and repeated saves. Saved API keys have no immutable enrollment marker; explicit exact-ID revocation preserves all unselected keys. +`src/config/schema/leaf-validators.ts` accepts only boolean `apiKeys[].allowMeteredComboTargets`; malformed entries are dropped rather than promoted. `src/types/config.ts` and combo validation define optional boolean target `metered`, normalized false when omitted, and optional per-combo `allowMeteredTargets` (default false, literal boolean validation shared by config schema and management API). Explicit consent is required under the [combo runtime contract](runtime.md#request-local-target-compatibility). Model-price overrides follow the [pricing contract](pricing.md). Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). `src/types/config.ts` defines `anthropicAccountPool.routes` as ordered model rules. `src/oauth/anthropic-model-routes.ts` validates bounded names, full case-sensitive globs and stored account IDs; `src/config/diagnostics.ts` rejects malformed candidate writes. Load retains a malformed hand edit so unrelated providers survive, while the enabled Anthropic selector refuses it locally before dispatch. Saved rules remain inert when the pool is disabled; affinity is process-local. Request logs use only the rule’s 1-based `route:#` position, never its configured name. `src/cli/hub-disable.ts` removes interdependent hub/loopback/role/hostname settings through `mutatePersistedConfig` and `validateConfigCandidate` in one commit. Top-level deletion intent uses `src/config/rebase-provenance.ts` and authoritative disk provenance in `src/config/live-reconcile.ts` to preserve removed hub bindings through later live saves, `src/config/live-topology.ts` keeps an independent active-topology baseline and merges desired next-start settings into a separate persistence projection, retaining actual socket identity and live hub/client topology during guarded saves and OAuth disk adoption. Client connections and runtimeRole remain one topology unit through connect/disconnect, including fresh disk-only connections. Disk additions/value updates and deletions all survive repeated saves without changing active topology. `src/config/api-key-reconcile.ts` merges key rows by stable ID under the existing save lock; either side's revocation defeats stale edits while disjoint names and rotations merge; unchanged live key membership/order adopts persisted ordering. `tests/config/hub-disable-live-save.test.ts` exercises disable followed by live key creation, rename, rotation, unrelated settings and repeated saves. Saved API keys have no immutable enrollment marker; explicit exact-ID revocation preserves all unselected keys. Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index a6171f8ddb2..b49f40a4c78 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -2,7 +2,7 @@ Dashboard page contracts, usage accounting and request metrics, and the management settings that back individual dashboard surfaces. Serving, authentication boundaries, and `/api/*` ownership are -in [GUI and management API](gui-and-management-api.md). +in [GUI and management API](gui-and-management-api.md). Model-price editing and estimate selection follow [pricing](pricing.md). ## UX boundary diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5b82cdb7ff6..782cf24163b 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,6 +1,6 @@ # GUI And Management API -`src/server/management/combo-routes.ts` retains omitted `metered: true` metadata by trimmed provider/model identity during dashboard-shaped saves. Explicit false clears the flag; false defaults are omitted from persisted targets. Omitted combo `allowMeteredTargets` survives saves and renames; explicit false clears it and stays sparse. The CLI exposes `--allow-metered-targets on|off`, and receipts preserve the boolean. Key and loopback operator consent follow the [runtime contract](runtime.md#request-local-target-compatibility). Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null), +`src/server/management/combo-routes.ts` retains omitted `metered: true` metadata by trimmed provider/model identity during dashboard-shaped saves. Explicit false clears the flag; false defaults are omitted from persisted targets. Omitted combo `allowMeteredTargets` survives saves and renames; explicit false clears it and stays sparse. The CLI exposes `--allow-metered-targets on|off`, and receipts preserve the boolean. Key and loopback operator consent follow the [runtime contract](runtime.md#request-local-target-compatibility). Model-price APIs and receipt validation follow the [pricing contract](pricing.md). Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null), `autoSwitchThreshold` (pool default) and `effectiveAutoSwitchThreshold`. The dedicated `PUT /api/oauth/accounts/auto-switch` accepts `{ provider: "anthropic", accountId, threshold }`; explicit null restores inheritance and missing/invalid values fail. Other providers are rejected. diff --git a/structure/manifest.json b/structure/manifest.json index 692ec387344..c115fcca7cf 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -534,6 +534,23 @@ "src/usage/" ] }, + { + "path": "pricing.md", + "tier": 5, + "title": "Model Price Overrides", + "scope": "User price policies, prompt-length rate selection, persistence and estimate cache invalidation.", + "documents": [ + "src/config/live-reconcile.ts", + "src/cli/models-runtime.ts", + "src/types/provider.ts", + "src/config/schema/leaf-validators.ts", + "src/server/management/model-routes.ts", + "src/usage/cost.ts", + "src/usage/user-cost-overlays.ts", + "src/usage/user-cost-validation.ts", + "gui/src/components/ModelPriceDialog.tsx" + ] + }, { "path": "clients/integrations.md", "tier": 5, diff --git a/structure/pricing.md b/structure/pricing.md new file mode 100644 index 00000000000..c8b76acf5c0 --- /dev/null +++ b/structure/pricing.md @@ -0,0 +1,64 @@ +# Model Price Overrides + +## Configuration and API + +`ProviderCostOverlay` in `src/types/provider.ts` keeps four required base rates and an optional +`promptLengthPricing` discriminated policy: `automatic`, `flat`, or `custom` with a positive +safe-integer `thresholdTokens`, `gt`/`gte` comparison and four absolute alternative `rates`. +Absent policy retains legacy automatic context behavior. `src/config/schema/leaf-validators.ts` +rejects malformed/unknown nested fields on writes and copies only validated policy fields for +display, dropping an explicit `automatic` policy so absence is the single stored form of +Automatic; load degradation drops an invalid model row without losing the provider. + +`src/providers/openai-tiers.ts` accepts these same rate and policy shapes through the shared +`validCost4` predicate in the pure `src/usage/user-cost-validation.ts` module when migrating +legacy `openai-multi` overlays. It rejects unknown row +fields and malformed policies, and preserves valid policies while rewriting model keys to `openai`. +`tests/adapters/openai/openai-provider-option-migration.test.ts` covers preservation and rejection. + +`src/server/management/model-routes.ts` serves `GET/PUT /api/providers/{provider}/model-costs`. +GET and successful PUT receipts retain all four rates and the optional validated policy. +A `cost: null` write removes the entire model override. Writes retain atomic persistence, +rollback and sibling-model preservation. `tests/server/model-costs-prompt-length-pricing.test.ts` +covers persistence, receipts, reset, rollback and external edits. + +`src/config/live-reconcile.ts` scopes explicit pricing PUT intent to one complete model row. +After recursive reconciliation under the config mutation lock, it reapplies that row (or its +deletion) and validates the final candidate before publication. This prevents concurrent mode +changes from mixing Custom fields into Flat/Automatic policies, and honors replacement even +when the submitted base rates equal the old baseline. Unrelated saves still adopt disk edits. +`src/cli/models-runtime.ts` reads the complete policy in `cost` and projects only base rates in +`effectiveCost`. `set-price` reads the current row first and carries that model's policy into +the replacement row, so a base-rate edit never silently drops a Flat or Custom policy; its receipt +must echo the same policy, with absent and `automatic` treated as equal. + +## Dialog and estimates + +`gui/src/components/ModelPriceDialog.tsx` edits base rates plus Automatic, Flat rate or Custom +threshold pricing. Automatic retains catalog context rules with the saved base tuple. Flat skips +context adjustments; Custom replaces the catalog rule on both sides of its single threshold. +Parsing, draft building, per-field validation and receipt comparison live in the pure +`gui/src/components/model-price-cost.ts`; the dialog sends no policy for Automatic, validates +complete policy receipts before refreshing the model list and retains its uncertain-outcome/reload +flow. `gui/tests/models-price-editor.test.tsx` covers this behavior. +The comparison Select renders its menu inside the modal dialog so native modal inertness does +not block pointer or keyboard interaction with its options. + +`src/usage/cost.ts` selects rates against inclusive `usage.inputTokens`, before subtracting cache +reads/writes for cost estimates; output is excluded from threshold selection. Alternative rates +apply to every token bucket of the request. Custom rates are standard-speed rates that stand in +for the catalog long-context rate, so a crossed threshold follows the catalog's +`confirmedPriorityRelation` for that model: `stack` (or no catalog rule) applies the Priority +multiplier once, `lower-bound` keeps the custom rates and sets `priorityLowerBound`, and +`exclusive` prices a confirmed Priority request from the base rates. Confirmation requirements are +unchanged. `customThresholdApplied` stays separate from the catalog `contextTier` marker and is +set only when the custom rates priced the request. Each Combo attempt selects its own tuple. +`tests/usage/usage-prompt-length-pricing.test.ts` pins boundaries, cached prompts, whole-request +rates, compatibility, Combo and Fast/Priority behavior. +It also verifies Automatic Haiku overrides at 99,999/100,000/100,001 inclusive prompt tokens, +including cache reads/writes and the inherited catalog band for both Anthropic OAuth pools. + +`src/usage/user-cost-overlays.ts` copies the complete policy into its versioned registry: +policy-only edits invalidate price memoization and usage caches; identical reloads remain stable. +`tests/server/model-costs-pricing-cache.test.ts` exercises warmed cache invalidation and reset. +These settings affect estimates, not upstream billing or official catalog price lists. diff --git a/tests/adapters/openai/openai-provider-option-migration.test.ts b/tests/adapters/openai/openai-provider-option-migration.test.ts index 379ba10bf64..1fcad055157 100644 --- a/tests/adapters/openai/openai-provider-option-migration.test.ts +++ b/tests/adapters/openai/openai-provider-option-migration.test.ts @@ -5,6 +5,7 @@ import { projectOpenAiTierMigration, } from "../../../src/providers/openai-tiers"; import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../../../src/types"; +import { repoRoot } from "../../helpers/repo-root"; const forward: OcxProviderConfig = { adapter: "openai-responses", @@ -30,6 +31,46 @@ function expectCanonical(result: ReturnType, } describe("OpenAI provider option migration matrix", () => { + test("CLI startup initializes the migration validators without an import cycle", () => { + const result = Bun.spawnSync([process.execPath, "run", "src/cli/index.ts", "--help"], { + cwd: repoRoot(), env: process.env, stdout: "pipe", stderr: "pipe", timeout: 10_000, + }); + expect(result.stderr.toString()).not.toContain("ReferenceError"); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toContain("Usage:"); + }); + + test.each([ + { mode: "automatic" }, + { mode: "flat" }, + { mode: "custom", thresholdTokens: 100_000, comparison: "gt", rates: { input: 2, output: 4, cacheRead: 0, cacheWrite: 0 } }, + { mode: "custom", thresholdTokens: 100_000, comparison: "gte", rates: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, + ] as const)("legacy Multi preserves prompt-length policy %j when rewriting model keys", promptLengthPricing => { + const cost = { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0, promptLengthPricing }; + const input = cfg({ providers: { "openai-multi": { ...forward, modelCosts: { "openai-multi/gpt-5.6": cost } } } }); + const before = structuredClone(input); + const result = projectOpenAiTierMigration(input); + expectCanonical(result, "pool"); + expect(result.config.providers.openai.modelCosts).toEqual({ "gpt-5.6": cost }); + expect(input).toEqual(before); + expect(projectOpenAiTierMigration(result.config).changed).toBe(false); + }); + + test.each([ + null, + { mode: "flat", extra: true }, + { mode: "automatic", rates: {} }, + { mode: "custom", thresholdTokens: 0, comparison: "gt", rates: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }, + { mode: "custom", thresholdTokens: 100, comparison: "gte", rates: { input: 1, output: 2, cacheRead: 0 } }, + { mode: "custom", thresholdTokens: 100, comparison: "gte", rates: { input: 1_000_001, output: 2, cacheRead: 0, cacheWrite: 0 } }, + { mode: "custom", thresholdTokens: 100, comparison: "gte", rates: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, extra: 1 } }, + ])("legacy Multi rejects malformed prompt-length policy %j", promptLengthPricing => { + const input = cfg({ providers: { "openai-multi": { ...forward, modelCosts: { + "gpt-5.6": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, promptLengthPricing }, + } } } } as unknown as Partial); + expect(() => projectOpenAiTierMigration(input)).toThrow(OpenAiTierMigrationCollisionError); + }); + test("unmarked minimal openai becomes one pool-default row", () => { const input = cfg(); const before = structuredClone(input); diff --git a/tests/cli/cli-models-price.test.ts b/tests/cli/cli-models-price.test.ts index 50aa79c07a0..b24fe7968c2 100644 --- a/tests/cli/cli-models-price.test.ts +++ b/tests/cli/cli-models-price.test.ts @@ -18,12 +18,17 @@ async function invoke(sub: string, args: string[], response?: unknown, status = baseUrl: "http://127.0.0.1:1", fetchImpl: async (url, init) => { const path = new URL(String(url)).pathname; + const method = init?.method ?? "GET"; const body = init?.body ? JSON.parse(String(init.body)) : undefined; - calls.push({ - path, - method: init?.method ?? "GET", - body, - }); + calls.push({ path, method, body }); + // A rate write now reads the current row first to carry its prompt-length policy forward. + // That read is unrelated to the provided response, which describes the later PUT. + if (method === "GET") { + if (sub === "price" && response !== undefined) { + return response instanceof Response ? response : Response.json(response, { status }); + } + return Response.json({ provider: path.split("/")[3], modelCosts: {} }); + } if (response instanceof Response) return response; return Response.json(response === undefined ? { ok: true, provider: path.split("/")[3], modelId: body?.modelId, cost: body?.cost } @@ -84,22 +89,25 @@ describe("models manual price commands", () => { 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 } }, - }]); + expect(result.calls).toEqual([ + { path: "/api/providers/custom-price/model-costs", method: "GET", body: undefined }, + { + 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 }); + expect(explicit.calls.find(call => call.method === "PUT")!.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 } }); + expect(zero.calls.find(call => call.method === "PUT")!.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 } }); + expect(max.calls.find(call => call.method === "PUT")!.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 () => { diff --git a/tests/cli/cli-models-prompt-length-pricing.test.ts b/tests/cli/cli-models-prompt-length-pricing.test.ts new file mode 100644 index 00000000000..be1f9b167bb --- /dev/null +++ b/tests/cli/cli-models-prompt-length-pricing.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test"; +import { handleModelsRuntimeCommand } from "../../src/cli/models-runtime"; + +const BASE = { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }; +const CUSTOM = { + mode: "custom", thresholdTokens: 1000, comparison: "gt", + rates: { input: 3, output: 9, cacheRead: 0.3, cacheWrite: 3.75 }, +}; +const STORED = { input: 1, output: 4, cacheRead: 0.1, cacheWrite: 1.25, promptLengthPricing: CUSTOM }; + +type RunOptions = { + getProvider?: string; + reorderPut?: boolean; + receiptCost?: (cost: Record) => unknown; +}; + +async function setPrice(args: string[], modelCosts: Record, options: RunOptions = {}) { + const calls: Array<{ method: string; body: any }> = []; + const log = console.log; + console.log = () => {}; + try { + const code = await handleModelsRuntimeCommand("set-price", args, { + baseUrl: "http://127.0.0.1:1", + fetchImpl: async (_url, init) => { + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ method, body }); + if (method === "GET") return Response.json({ provider: options.getProvider ?? "custom-price", modelCosts }); + const reordered = options.reorderPut && body.cost?.promptLengthPricing + ? { ...body.cost, promptLengthPricing: Object.fromEntries(Object.entries(body.cost.promptLengthPricing).reverse()) } + : body.cost; + const cost = options.receiptCost ? options.receiptCost(reordered) : reordered; + return Response.json({ ok: true, provider: "custom-price", modelId: body.modelId, cost }); + }, + }); + return { code, calls, put: calls.find(call => call.method === "PUT") }; + } finally { + console.log = log; + } +} + +test("price accepts complete stored prompt-length policies and rejects malformed API policy rows", async () => { + const base = { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }; + const policies = [{ mode: "automatic" }, { mode: "flat" }, { + mode: "custom", thresholdTokens: 1, comparison: "gt", rates: base, + }, { mode: "custom", thresholdTokens: 0, comparison: "gt", rates: base }, + { mode: "custom", thresholdTokens: 1, comparison: "gt", rates: { input: 1 } }]; + for (const [index, promptLengthPricing] of policies.entries()) { + const cost = { ...base, promptLengthPricing }; + const output: string[] = []; + const log = console.log; + const error = console.error; + console.log = (...args) => { output.push(args.join(" ")); }; + console.error = () => {}; + try { + const code = await handleModelsRuntimeCommand("price", ["acme/org/model", "--json"], { + baseUrl: "http://127.0.0.1:1", fetchImpl: async () => Response.json({ provider: "acme", modelCosts: { "org/model": cost } }), + }); + expect(code).toBe(index < 3 ? 0 : 1); + if (index < 3) { + const result = JSON.parse(output.join("\n")); + expect(result.cost).toEqual(cost); + expect(result.effectiveCost).toEqual(base); + expect(Object.keys(result.effectiveCost).sort()).toEqual(["cacheRead", "cacheWrite", "input", "output"]); + } + } finally { console.log = log; console.error = error; } + } +}); + +test("legacy set-price accepts absent/automatic receipts and rejects policies it did not send", async () => { + const policies = [undefined, { mode: "automatic" }, { mode: "flat" }, { + mode: "custom", thresholdTokens: 1, comparison: "gte", rates: BASE, + }]; + for (const [index, promptLengthPricing] of policies.entries()) { + const cost = { ...BASE, ...(promptLengthPricing ? { promptLengthPricing } : {}) }; + const output: string[] = []; + const errors: string[] = []; + const log = console.log; + const error = console.error; + console.log = (...args) => { output.push(args.join(" ")); }; + console.error = (...args) => { errors.push(args.join(" ")); }; + let writes = 0; + try { + const code = await handleModelsRuntimeCommand("set-price", ["acme/org/model", "--input", "1", "--output", "2", "--json"], { + baseUrl: "http://127.0.0.1:1", fetchImpl: async (_url, init) => { + if ((init?.method ?? "GET") === "GET") return Response.json({ provider: "acme", modelCosts: {} }); + writes += 1; + expect(JSON.parse(String(init?.body))).toEqual({ modelId: "org/model", cost: BASE }); + return Response.json({ ok: true, provider: "acme", modelId: "org/model", cost }); + }, + }); + expect(code).toBe(index < 2 ? 0 : 1); + expect(writes).toBe(1); + if (index < 2) expect(JSON.parse(output.join("\n")).cost).toEqual(BASE); + else { + expect(output).toEqual([]); + expect(errors.join("\n")).toContain("Invalid model price persistence receipt"); + } + } finally { console.log = log; console.error = error; } + } +}); + +describe("models set-price keeps the nested promptLengthPricing policy", () => { + test("a custom policy survives a rate change on the same row", async () => { + const result = await setPrice(["custom-price/org/model", "--input", "2", "--output", "6", "--json"], { "org/model": STORED }); + expect(result.code).toBe(0); + expect(result.put!.body).toEqual({ + modelId: "org/model", + cost: { input: 2, output: 6, cacheRead: 0, cacheWrite: 0, promptLengthPricing: CUSTOM }, + }); + }); + + test("a flat policy is carried forward as well", async () => { + const flat = { input: 1, output: 4, cacheRead: 0.1, cacheWrite: 1.25, promptLengthPricing: { mode: "flat" } }; + const result = await setPrice(["custom-price/org/model", "--input", "2", "--output", "6", "--json"], { "org/model": flat }); + expect(result.put!.body.cost.promptLengthPricing).toEqual({ mode: "flat" }); + }); + + test("a policy on a sibling model is never carried onto a new row", async () => { + const result = await setPrice(["custom-price/org/model", "--input", "2", "--output", "6", "--json"], { other: STORED }); + expect(result.code).toBe(0); + expect(result.put!.body.cost).not.toHaveProperty("promptLengthPricing"); + }); + + test("--auto reset sends a null cost without reading a policy", async () => { + const result = await setPrice(["custom-price/org/model", "--auto", "--json"], { "org/model": STORED }); + expect(result.code).toBe(0); + expect(result.calls.map(call => call.method)).toEqual(["PUT"]); + expect(result.put!.body).toEqual({ modelId: "org/model", cost: null }); + }); + + test("a current-row read for another provider is rejected before any write", async () => { + const result = await setPrice(["custom-price/org/model", "--input", "2", "--output", "6", "--json"], + { "org/model": STORED }, { getProvider: "other-price" }); + expect(result.code).toBe(1); + expect(result.put).toBeUndefined(); + }); + + test("a receipt whose promptLengthPricing keys arrive reordered is accepted", async () => { + const result = await setPrice(["custom-price/org/model", "--input", "2", "--output", "6", "--json"], + { "org/model": STORED }, { reorderPut: true }); + expect(result.code).toBe(0); + expect(result.put!.body.cost.promptLengthPricing).toEqual(CUSTOM); + }); + + test("an absent receipt policy equals the automatic one that was sent", async () => { + const stored = { ...STORED, promptLengthPricing: { mode: "automatic" } }; + const result = await setPrice(["custom-price/org/model", "--input", "2", "--output", "6", "--json"], + { "org/model": stored }, { receiptCost: (cost) => { const copy = { ...cost }; delete copy.promptLengthPricing; return copy; } }); + expect(result.code).toBe(0); + expect(result.put!.body.cost.promptLengthPricing).toEqual({ mode: "automatic" }); + }); + + test("an automatic receipt policy equals the absent policy that was sent", async () => { + const result = await setPrice(["custom-price/org/model", "--input", "2", "--output", "6", "--json"], + {}, { receiptCost: (cost) => ({ ...cost, promptLengthPricing: { mode: "automatic" } }) }); + expect(result.code).toBe(0); + expect(result.put!.body.cost).not.toHaveProperty("promptLengthPricing"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a1b26b134c2..5e817d539f2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -38,6 +38,10 @@ "management-sidecar-anthropic-instance.test.ts": "server", "anthropic2-usage-attribution.test.ts": "usage", "vision-anthropic-instance-sidecar.test.ts": "vision", "web-search-anthropic-instance.test.ts": "web-search", "provider-anthropic-instance.test.ts": "providers", "config-anthropic-instance-pool.test.ts": "config", "oauth-anthropic-instance-registration.test.ts": "oauth", "oauth-anthropic-instance-refresh.test.ts": "oauth", "claude-picker-upload.test.ts": "claude-integration", "claude-picker-linger.test.ts": "claude-integration", + "model-costs-pricing-cache.test.ts": "server", + "model-costs-prompt-length-pricing.test.ts": "server", + "cli-models-prompt-length-pricing.test.ts": "cli", + "usage-prompt-length-pricing.test.ts": "usage", "messages-native-scope.test.ts": "claude-integration", "catalog-auto-review-row.test.ts": "codex-integration", "codex-use-remaining-quota.test.ts": "codex-integration", "cli-log-view-filter.test.ts": "cli", diff --git a/tests/server/model-costs-pricing-cache.test.ts b/tests/server/model-costs-pricing-cache.test.ts new file mode 100644 index 00000000000..0a7b2095b2d --- /dev/null +++ b/tests/server/model-costs-pricing-cache.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { getFilteredUsageAggregate, getUsageAggregate, resetUsageAggregateCacheForTests } from "../../src/server/management/usage-aggregate-cache"; +import { getUsageSummaryCacheEntry, resetUsageSummaryCacheForTests } from "../../src/server/management/usage-summary-cache"; +import type { OcxConfig, PromptLengthPricing } from "../../src/types"; +import { estimateRequestCost } from "../../src/usage/cost"; +import { resetUsageReadCacheForTests, type PersistedUsageEntry } from "../../src/usage/log"; +import { refreshUserCostOverlays, resetPreservedDiskOnlyProvidersForTests } from "../../src/usage/user-cost-overlays"; +import { stopUserCostOverlayReconciler } from "../../src/usage/user-cost-overlay-reconciler"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { managementFetch } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const MODEL = "gpt-6-astra"; +const BASE = { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.5 }; +const ABOVE = { input: 3, output: 6, cacheRead: 0.3, cacheWrite: 1.5 }; +const INPUT = { provider: "openai", model: MODEL, usageStatus: "reported" as const, + usage: { inputTokens: 280_000, outputTokens: 1_000, cacheReadInputTokens: 20_000, cacheCreationInputTokens: 10_000 } }; +let home: string; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-pricing-cache-")); + process.env.OPENCODEX_HOME = home; + isolatedCodexHome = installIsolatedCodexHome("ocx-pricing-cache-codex-"); + resetUsageReadCacheForTests(); + resetUsageSummaryCacheForTests(); + resetUsageAggregateCacheForTests(); + resetPreservedDiskOnlyProvidersForTests(); + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "openai", providers: { + openai: { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "forward" }, + } } satisfies OcxConfig); +}); + +afterEach(() => { + stopUserCostOverlayReconciler(); + resetUsageReadCacheForTests(); + resetUsageSummaryCacheForTests(); + resetUsageAggregateCacheForTests(); + resetPreservedDiskOnlyProvidersForTests(); + refreshUserCostOverlays({ providers: {} } as OcxConfig); + isolatedCodexHome.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +test("policy-only API writes refresh warm estimate, summary and aggregate caches; reset restores catalog", async () => { + const now = Date.now(); + const entry: PersistedUsageEntry = { requestId: "fixed-pricing-cache-row", timestamp: now - 1000, + ...INPUT, status: 200, durationMs: 10, totalTokens: 281_000 }; + const ledgerPath = join(home, "usage.jsonl"); + const ledger = `${JSON.stringify(entry)}\n`; + writeFileSync(ledgerPath, ledger); + const server = startServer(0); + try { + const summaryUrl = new URL("/api/usage?range=all", server.url); + const priceUrl = new URL("/api/providers/openai/model-costs", server.url); + const summarize = async () => { + const response = await managementFetch(summaryUrl); + expect(response.status).toBe(200); + return response.json(); + }; + const writePolicy = async (policy: PromptLengthPricing | null) => { + const cost = policy === null ? null : { ...BASE, promptLengthPricing: policy }; + const response = await managementFetch(priceUrl, { method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ modelId: MODEL, cost }) }); + expect(response.status).toBe(200); + expect((await response.json()).cost).toEqual(policy?.mode === "automatic" ? BASE : cost); + }; + + // Defaults use the real memo path: no injected overlay arrays or mocked cache reads. + const catalog = estimateRequestCost(INPUT)!; + expect(catalog.cost.total).toBeCloseTo(5.365, 12); + expect(catalog.price?.source).not.toBe("user"); + expect(catalog.contextTier).toBe("long"); + expect(estimateRequestCost(INPUT)?.price).toBe(catalog.price); + expect((await summarize()).summary.estimatedCostUsd).toBeCloseTo(catalog.cost.total, 12); + + const custom = (thresholdTokens: number, comparison: "gt" | "gte", rates = ABOVE): PromptLengthPricing => + ({ mode: "custom", thresholdTokens, comparison, rates }); + await writePolicy(custom(300_000, "gt")); + + // Each step changes just one policy dimension; every base rate stays fixed. + const stages: Array<{ policy: PromptLengthPricing | null; total: number }> = [ + { policy: custom(279_999, "gt"), total: 0.777 }, + { policy: custom(280_000, "gt"), total: 0.259 }, + { policy: custom(280_000, "gte"), total: 0.777 }, + { policy: custom(280_000, "gte", { ...ABOVE, input: 4 }), total: 1.027 }, + { policy: custom(280_000, "gte", { ...ABOVE, input: 4, output: 8 }), total: 1.029 }, + { policy: custom(280_000, "gte", { ...ABOVE, input: 4, output: 8, cacheRead: 0.4 }), total: 1.031 }, + { policy: custom(280_000, "gte", { input: 4, output: 8, cacheRead: 0.4, cacheWrite: 2 }), total: 1.036 }, + { policy: { mode: "flat" }, total: 0.259 }, + { policy: { mode: "automatic" }, total: 0.517 }, + { policy: null, total: catalog.cost.total }, + ]; + let previousTotal = 0.259; + for (const { policy, total } of stages) { + const estimateBefore = estimateRequestCost(INPUT)!; + expect(estimateBefore.cost.total).toBeCloseTo(previousTotal, 12); + expect(estimateRequestCost(INPUT)?.price).toBe(estimateBefore.price); + const before = await summarize(); + const summaryCache = getUsageSummaryCacheEntry("all:all")!; + expect(before.summary.estimatedCostUsd).toBeCloseTo(previousTotal, 12); + expect((await summarize()).summary).toEqual(before.summary); + expect(getUsageSummaryCacheEntry("all:all")).toBe(summaryCache); + const aggregate = await getUsageAggregate({ now }); + expect(aggregate.update).toBe("unchanged"); + expect((await getUsageAggregate({ now })).accumulator).toBe(aggregate.accumulator); + const filtered = await getFilteredUsageAggregate({ provider: INPUT.provider }); + expect((await getFilteredUsageAggregate({ provider: INPUT.provider })).update).toBe("unchanged"); + expect(filtered.accumulator.summarize("all", now).summary.estimatedCostUsd).toBeCloseTo(previousTotal, 12); + + await writePolicy(policy); + + const estimateAfter = estimateRequestCost(INPUT)!; + expect(estimateAfter.price).not.toBe(estimateBefore.price); + expect(estimateAfter.cost.total).toBeCloseTo(total, 12); + const changed = await summarize(); + expect(changed.summary.requests).toBe(1); + expect(changed.summary.estimatedCostUsd).toBeCloseTo(total, 12); + expect(changed.models[0].estimatedCostUsd).toBeCloseTo(total, 12); + expect(getUsageSummaryCacheEntry("all:all")).not.toBe(summaryCache); + const refreshedAggregate = await getUsageAggregate({ now }); + expect(refreshedAggregate.accumulator).not.toBe(aggregate.accumulator); + expect(refreshedAggregate.accumulator.summarize("all", now).summary.estimatedCostUsd).toBeCloseTo(total, 12); + const refreshedFiltered = await getFilteredUsageAggregate({ provider: INPUT.provider }); + expect(refreshedFiltered.update).toBe("rebuild"); + expect(refreshedFiltered.accumulator).not.toBe(filtered.accumulator); + expect(refreshedFiltered.accumulator.summarize("all", now).summary.estimatedCostUsd).toBeCloseTo(total, 12); + expect(readFileSync(ledgerPath, "utf8")).toBe(ledger); + previousTotal = total; + } + const reset = estimateRequestCost(INPUT)!; + expect(reset.price?.source).toBe(catalog.price?.source); + expect(reset.price?.cost4).toEqual(catalog.price?.cost4); + expect(reset.cost).toEqual(catalog.cost); + expect(reset.contextTier).toBe("long"); + expect((await (await managementFetch(priceUrl)).json()).modelCosts).toEqual({}); + } finally { await server.stop(true); } +}, 15_000); diff --git a/tests/server/model-costs-prompt-length-pricing.test.ts b/tests/server/model-costs-prompt-length-pricing.test.ts new file mode 100644 index 00000000000..ddeb865899f --- /dev/null +++ b/tests/server/model-costs-prompt-length-pricing.test.ts @@ -0,0 +1,339 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getConfigPath, loadConfig, saveConfig, saveConfigPreservingClaudeCode, armClaudeCodeBaseline, + providerModelCostsConfigError, validateConfigCandidate } from "../../src/config"; +import { sanitizeModelCostsForDisplay } from "../../src/config/schema/leaf-validators"; +import { ConfigWritePublishedError } from "../../src/config/persist-unlocked"; +import { withModelCostRowReplacement } from "../../src/config/live-reconcile"; +import { handleModelRoutes } from "../../src/server/management/model-routes"; +import { handleModelsRuntimeCommand } from "../../src/cli/models-runtime"; +import { activeUserCostOverlays, refreshUserCostOverlays, userCostOverlayVersion } from "../../src/usage/user-cost-overlays"; +import { reconcileUserCostOverlaysFromDisk, resetUserCostOverlayReconcilerForTests, startUserCostOverlayReconciler } from "../../src/usage/user-cost-overlay-reconciler"; +import type { OcxConfig, PromptLengthPricing } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const BASE = { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }; +const POLICY: PromptLengthPricing = { mode: "custom", thresholdTokens: 100, comparison: "gte", + rates: { input: 2, output: 4, cacheRead: 0.2, cacheWrite: 0 } }; +const MODEL = "org/model--fast"; +let home: string; +let previousHome: string | undefined; +let config: OcxConfig; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-prompt-pricing-")); + process.env.OPENCODEX_HOME = home; + config = { port: 10100, defaultProvider: "acme", providers: { acme: { + adapter: "openai-chat", baseUrl: "https://example.invalid/v1", liveModels: false, models: [MODEL], + } } }; + saveConfig(config); +}); + +afterEach(() => { + resetUserCostOverlayReconcilerForTests(); + refreshUserCostOverlays({ providers: {} } as OcxConfig); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +async function call(method: "GET" | "PUT", cost?: unknown, persist = saveConfigPreservingClaudeCode) { + const url = new URL("http://127.0.0.1:10100/api/providers/acme/model-costs"); + const response = await handleModelRoutes({ version: "test", config, url, + req: new Request(url, { method, headers: { "Content-Type": "application/json" }, + ...(method === "PUT" ? { body: JSON.stringify({ modelId: MODEL, cost }) } : {}) }), + deps: { saveConfigPreservingClaudeCode: persist }, + convergeCodexCatalog: async () => { throw new Error("unexpected catalog convergence"); }, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + if (!response) throw new Error("pricing route missing"); + return response; +} + +test("policy API receipts survive reopening; legacy replacement and null reset the whole row", async () => { + for (const promptLengthPricing of [{ mode: "automatic" }, { mode: "flat" }, POLICY]) { + const cost = { ...BASE, promptLengthPricing }; + const expected = promptLengthPricing.mode === "automatic" ? BASE : cost; + const response = await call("PUT", cost); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, provider: "acme", modelId: MODEL, cost: expected }); + config = loadConfig(); + expect((await (await call("GET")).json()).modelCosts[MODEL]).toEqual(expected); + expect(config.providers.acme!.modelCosts![MODEL]).toEqual(expected); + expect(activeUserCostOverlays()[0]?.promptLengthPricing).toEqual(promptLengthPricing.mode === "automatic" ? undefined : promptLengthPricing); + } + await call("PUT", BASE); + config = loadConfig(); + expect(config.providers.acme!.modelCosts![MODEL]).toEqual(BASE); + await call("PUT", { ...BASE, promptLengthPricing: POLICY }); + const response = await call("PUT", null); + expect((await response.json()).cost).toBeNull(); + config = loadConfig(); + expect((await (await call("GET")).json()).modelCosts).toEqual({}); + expect(activeUserCostOverlays()).toEqual([]); +}); + +test("hand-edited automatic validates, disappears from GET and overlays, and does not churn caches", async () => { + await call("PUT", BASE); + const version = userCostOverlayVersion(); + const disk = JSON.parse(readFileSync(getConfigPath(), "utf8")); + disk.providers.acme.modelCosts[MODEL].promptLengthPricing = { mode: "automatic" }; + expect(providerModelCostsConfigError(disk.providers.acme.modelCosts)).toBeNull(); + writeFileSync(getConfigPath(), JSON.stringify(disk)); + config = loadConfig(); + expect((await (await call("GET")).json()).modelCosts[MODEL]).toEqual(BASE); + expect(activeUserCostOverlays()[0]?.promptLengthPricing).toBeUndefined(); + expect(Object.hasOwn(activeUserCostOverlays()[0]!, "promptLengthPricing")).toBe(false); + expect(userCostOverlayVersion()).toBe(version); + expect(sanitizeModelCostsForDisplay(config.providers.acme!.modelCosts)?.[MODEL]).toEqual(BASE); +}); + +test("live-reconcile explicit automatic row replacement clears an external custom policy", () => { + config.providers.acme!.modelCosts = { [MODEL]: BASE }; + saveConfig(config); + armClaudeCodeBaseline(config); + const disk = loadConfig(); + disk.providers.acme!.modelCosts = { [MODEL]: { ...BASE, promptLengthPricing: POLICY }, + sibling: { ...BASE, promptLengthPricing: POLICY } }; + saveConfig(disk); + const automatic = { ...BASE, promptLengthPricing: { mode: "automatic" as const } }; + config.providers.acme!.modelCosts![MODEL] = automatic; + withModelCostRowReplacement(config, "acme", MODEL, automatic, () => saveConfigPreservingClaudeCode(config)); + expect(config.providers.acme!.modelCosts![MODEL]).toEqual(BASE); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).providers.acme.modelCosts).toEqual({ + [MODEL]: BASE, sibling: { ...BASE, promptLengthPricing: POLICY }, + }); + expect(activeUserCostOverlays().find(row => row.modelId === MODEL)?.promptLengthPricing).toBeUndefined(); +}); + +test("custom boundaries persist safe integers, both comparisons, zero and maximum rates", async () => { + for (const thresholdTokens of [1, Number.MAX_SAFE_INTEGER]) { + for (const comparison of ["gt", "gte"]) { + const promptLengthPricing = { mode: "custom", thresholdTokens, comparison, + rates: { input: 0, output: 1e6, cacheRead: 0, cacheWrite: 1e6 } }; + expect(providerModelCostsConfigError({ m: { ...BASE, promptLengthPricing } })).toBeNull(); + const cost = { ...BASE, promptLengthPricing }; + expect((await call("PUT", cost)).status).toBe(200); + config = loadConfig(); + expect((await (await call("GET")).json()).modelCosts[MODEL]).toEqual(cost); + } + } +}); + +test("invalid policies fail writes without mutation or secret echo", async () => { + await call("PUT", { ...BASE, promptLengthPricing: POLICY }); + const before = readFileSync(getConfigPath(), "utf8"); + const invalid: unknown[] = [null, [], "flat", {}, { mode: "unknown" }, + { mode: "flat", apiKey: "private-policy-value" }, { mode: "automatic", rates: BASE }, + ...[0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, "100"].map(thresholdTokens => ({ ...POLICY, thresholdTokens })), + { ...POLICY, comparison: "ge" }, { mode: "custom", thresholdTokens: 100, comparison: "gt" }, + { ...POLICY, rates: { input: 1, output: 2, cacheRead: 0 } }, + { ...POLICY, rates: { ...BASE, apiKey: "private-policy-value" } }, + ...[-1, 1e6 + 1, "2", null].map(input => ({ ...POLICY, rates: { ...BASE, input } })), + ]; + for (const promptLengthPricing of invalid) { + const candidate = { ...config, providers: { acme: { ...config.providers.acme, + modelCosts: { [MODEL]: { ...BASE, promptLengthPricing } }, + } } }; + expect(validateConfigCandidate(candidate).ok).toBe(false); + const response = await call("PUT", { ...BASE, promptLengthPricing }); + expect(response.status).toBe(400); + expect(await response.text()).not.toContain("private-policy-value"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + } + for (const key of ["input", "output", "cacheRead", "cacheWrite"]) { + for (const invalidRate of [-1, 1e6 + 1, Infinity, NaN, "2", null]) { + expect(providerModelCostsConfigError({ m: { ...BASE, promptLengthPricing: { + ...POLICY, rates: { ...BASE, [key]: invalidRate }, + } } })).not.toBeNull(); + } + } +}); + +test("display copies complete policies and discards invalid nested content", () => { + const source = { m: { ...BASE, promptLengthPricing: structuredClone(POLICY), apiKey: "private-policy-value" } }; + const output = sanitizeModelCostsForDisplay(source)!; + expect(output.m).toEqual({ ...BASE, promptLengthPricing: POLICY }); + expect(output.m!.promptLengthPricing).not.toBe(source.m.promptLengthPricing); + if (source.m.promptLengthPricing.mode === "custom") source.m.promptLengthPricing.rates.input = 99; + expect(output.m!.promptLengthPricing).toEqual(POLICY); + expect(sanitizeModelCostsForDisplay({ m: { ...BASE, promptLengthPricing: { mode: "flat", secret: "value" } } })).toBeUndefined(); +}); + +test("policy-only external edits invalidate once and survive unrelated live saves; malformed rows degrade", () => { + config.providers.acme!.modelCosts = { [MODEL]: { ...BASE, promptLengthPricing: POLICY } }; + saveConfig(config); + config = loadConfig(); + const version = userCostOverlayVersion(); + const disk = JSON.parse(readFileSync(getConfigPath(), "utf8")); + disk.providers.acme.modelCosts[MODEL].promptLengthPricing = { ...POLICY, comparison: "gt" }; + disk.providers.acme.modelCosts.sibling = { ...BASE, promptLengthPricing: { mode: "flat" } }; + writeFileSync(getConfigPath(), JSON.stringify(disk)); + expect(reconcileUserCostOverlaysFromDisk(config)).toBe(true); + expect(userCostOverlayVersion()).toBe(version + 1); + expect(activeUserCostOverlays().find(row => row.modelId === MODEL)?.promptLengthPricing).toEqual({ ...POLICY, comparison: "gt" }); + reconcileUserCostOverlaysFromDisk(config); + expect(userCostOverlayVersion()).toBe(version + 1); + config.port = 10101; + saveConfig(config); + expect(userCostOverlayVersion()).toBe(version + 1); + expect(loadConfig().providers.acme!.modelCosts!.sibling?.promptLengthPricing).toEqual({ mode: "flat" }); + disk.providers.acme.modelCosts[MODEL].promptLengthPricing.rates = { input: 2 }; + writeFileSync(getConfigPath(), JSON.stringify(disk)); + const degraded = loadConfig(); + expect(degraded.providers.acme!.baseUrl).toBe("https://example.invalid/v1"); + expect(degraded.providers.acme!.modelCosts![MODEL]).toBeUndefined(); + expect(degraded.providers.acme!.modelCosts!.sibling?.promptLengthPricing).toEqual({ mode: "flat" }); +}); + +test("failed policy persistence rolls back the live row and leaves registry version stable", async () => { + await call("PUT", BASE); + const version = userCostOverlayVersion(); + await expect(call("PUT", { ...BASE, promptLengthPricing: POLICY }, () => { throw new Error("write failed"); })).rejects.toThrow("write failed"); + expect(config.providers.acme!.modelCosts![MODEL]).toEqual(BASE); + expect(userCostOverlayVersion()).toBe(version); +}); + +test("post-publication errors retain committed pricing for recovery GET and subsequent saves", async () => { + await call("PUT", BASE); + armClaudeCodeBaseline(config); + for (const cost of [{ ...BASE, promptLengthPricing: { mode: "flat" as const } }, null]) { + await expect(call("PUT", cost, candidate => { + saveConfigPreservingClaudeCode(candidate); + throw new ConfigWritePublishedError(new Error("bookkeeping failed")); + })).rejects.toBeInstanceOf(ConfigWritePublishedError); + const expected = cost === null ? {} : { [MODEL]: cost }; + expect((await (await call("GET")).json()).modelCosts).toEqual(expected); + expect(config.providers.acme!.modelCosts).toEqual(expected); + config.modelCacheTtlMs = (config.modelCacheTtlMs ?? 60_000) + 1; + saveConfigPreservingClaudeCode(config); + expect(loadConfig().providers.acme!.modelCosts ?? {}).toEqual(expected); + expect(activeUserCostOverlays().find(row => row.modelId === MODEL)?.promptLengthPricing) + .toEqual(cost?.promptLengthPricing); + } +}); + +test("policy writes retain sibling policies concurrently added by a disk writer", async () => { + config = loadConfig(); + armClaudeCodeBaseline(config); + const diskWriter = loadConfig(); + diskWriter.providers.acme!.modelCosts = { sibling: { ...BASE, promptLengthPricing: { mode: "flat" } } }; + saveConfig(diskWriter); + await call("PUT", { ...BASE, promptLengthPricing: POLICY }); + const persisted = loadConfig(); + expect(persisted.providers.acme!.modelCosts).toEqual({ + sibling: { ...BASE, promptLengthPricing: { mode: "flat" } }, + [MODEL]: { ...BASE, promptLengthPricing: POLICY }, + }); +}); + +test("running poller adopts a policy-only edit and leaves identical refreshes stable", async () => { + await call("PUT", { ...BASE, promptLengthPricing: POLICY }); + const version = userCostOverlayVersion(); + const lease = startUserCostOverlayReconciler({ intervalMs: 10, liveConfig: config }); + try { + const disk = JSON.parse(readFileSync(getConfigPath(), "utf8")); + disk.providers.acme.modelCosts[MODEL].promptLengthPricing = { mode: "flat" }; + writeFileSync(getConfigPath(), JSON.stringify(disk)); + const deadline = Date.now() + 3000; + while (userCostOverlayVersion() === version && Date.now() < deadline) await Bun.sleep(10); + expect(userCostOverlayVersion()).toBe(version + 1); + expect(config.providers.acme!.modelCosts![MODEL]?.promptLengthPricing).toEqual({ mode: "flat" }); + expect(activeUserCostOverlays()[0]?.promptLengthPricing).toEqual({ mode: "flat" }); + expect(activeUserCostOverlays()[0]?.cost4).toEqual(BASE); + reconcileUserCostOverlaysFromDisk(config); + expect(userCostOverlayVersion()).toBe(version + 1); + } finally { lease.stop(); } +}); + +test("same-model automatic baseline, external custom policy, and explicit flat PUT replace atomically", async () => { + config.providers.acme!.modelCosts = { [MODEL]: { ...BASE, promptLengthPricing: { mode: "automatic" } } }; + saveConfig(config); + armClaudeCodeBaseline(config); + const disk = loadConfig(); + disk.providers.acme!.modelCosts = { + [MODEL]: { ...BASE, promptLengthPricing: POLICY }, + sibling: { ...BASE, promptLengthPricing: POLICY }, + }; + saveConfig(disk); + const expected = { ...BASE, promptLengthPricing: { mode: "flat" } }; + expect((await (await call("PUT", expected)).json()).cost).toEqual(expected); + expect(config.providers.acme!.modelCosts![MODEL]).toEqual(expected); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).providers.acme.modelCosts).toEqual({ + [MODEL]: expected, sibling: { ...BASE, promptLengthPricing: POLICY }, + }); + expect(providerModelCostsConfigError(config.providers.acme!.modelCosts)).toBeNull(); + expect(activeUserCostOverlays().find(row => row.modelId === MODEL)?.promptLengthPricing).toEqual({ mode: "flat" }); +}); + +test("same-model CLI write clears an external custom policy not yet adopted by live config even when base rates equal baseline", async () => { + config.providers.acme!.modelCosts = { [MODEL]: BASE }; + saveConfig(config); + armClaudeCodeBaseline(config); + const disk = loadConfig(); + disk.providers.acme!.modelCosts = { [MODEL]: { ...BASE, promptLengthPricing: POLICY } }; + saveConfig(disk); + const output: string[] = []; + const log = console.log; + console.log = (...values) => { output.push(values.join(" ")); }; + try { + const result = await handleModelsRuntimeCommand("set-price", [ + `acme/${MODEL}`, "--input", "1", "--output", "2", "--cache-read", "0.1", "--cache-write", "0", "--json", + ], { baseUrl: "http://127.0.0.1:1", fetchImpl: async (_url, init) => { + if (!init?.method || init.method === "GET") return call("GET"); + const body = JSON.parse(String(init?.body)); + expect(body).toEqual({ modelId: MODEL, cost: BASE }); + return call("PUT", body.cost); + } }); + expect(result).toBe(0); + expect(JSON.parse(output.join("\n")).cost).toEqual(BASE); + } finally { console.log = log; } + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).providers.acme.modelCosts[MODEL]).toEqual(BASE); + expect(config.providers.acme!.modelCosts![MODEL]).toEqual(BASE); + expect(activeUserCostOverlays().find(row => row.modelId === MODEL)?.promptLengthPricing).toBeUndefined(); + // Replacement intent must not leak into a later unrelated save. + const later = loadConfig(); + later.providers.acme!.modelCosts![MODEL] = { ...BASE, promptLengthPricing: POLICY }; + saveConfig(later); + config.modelCacheTtlMs = 60_001; + saveConfigPreservingClaudeCode(config); + expect(loadConfig().providers.acme!.modelCosts![MODEL]?.promptLengthPricing).toEqual(POLICY); +}); + +test("same-model reset wins over an external custom addition and retains sibling policies", async () => { + armClaudeCodeBaseline(config); + const disk = loadConfig(); + disk.providers.acme!.modelCosts = { + [MODEL]: { ...BASE, promptLengthPricing: POLICY }, sibling: { ...BASE, promptLengthPricing: POLICY }, + }; + saveConfig(disk); + expect((await (await call("PUT", null)).json()).cost).toBeNull(); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).providers.acme.modelCosts).toEqual({ + sibling: { ...BASE, promptLengthPricing: POLICY }, + }); + expect(activeUserCostOverlays().some(row => row.modelId === MODEL)).toBe(false); +}); + +test("final merged candidate validation rejects an invalid sibling merge before publishing and rolls back", async () => { + config.providers.acme!.modelCosts = { sibling: { ...BASE, promptLengthPricing: { mode: "automatic" } } }; + saveConfig(config); + armClaudeCodeBaseline(config); + const disk = loadConfig(); + disk.providers.acme!.modelCosts = { sibling: { ...BASE, promptLengthPricing: POLICY } }; + saveConfig(disk); + config.providers.acme!.modelCosts.sibling = { ...BASE, promptLengthPricing: { mode: "flat" } }; + const before = readFileSync(getConfigPath(), "utf8"); + const previous = structuredClone(config.providers.acme!.modelCosts); + const version = userCostOverlayVersion(); + await expect(call("PUT", { ...BASE, promptLengthPricing: POLICY })).rejects.toThrow(); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + expect(config.providers.acme!.modelCosts).toEqual(previous); + expect(userCostOverlayVersion()).toBe(version); + // A failed write clears its replacement intent too. + config.providers.acme!.modelCosts = { sibling: { ...BASE, promptLengthPricing: POLICY } }; + saveConfigPreservingClaudeCode(config); + expect(loadConfig().providers.acme!.modelCosts![MODEL]).toBeUndefined(); +}); diff --git a/tests/usage/usage-prompt-length-pricing.test.ts b/tests/usage/usage-prompt-length-pricing.test.ts new file mode 100644 index 00000000000..bb0910e1b14 --- /dev/null +++ b/tests/usage/usage-prompt-length-pricing.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from "bun:test"; +import type { AttemptTierOutcome, OcxUsage } from "../../src/types"; +import type { PromptLengthPricing } from "../../src/types/provider"; +import { estimateAttemptCost, estimateComboCost, estimateRequestCost, type ServiceTierInput } from "../../src/usage/cost"; +import { CONTEXT_TIERS, type Cost4, type ExpectedPriceOverlay } from "../../src/usage/expected-prices"; + +const BASE: Cost4 = { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }; +const ABOVE: Cost4 = { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }; +const custom = (thresholdTokens = 100_000, comparison: "gt" | "gte" = "gt"): PromptLengthPricing => ({ + mode: "custom", thresholdTokens, comparison, rates: ABOVE, +}); +function rows(policy?: PromptLengthPricing, provider = "anthropic", modelId = "claude-haiku-5-5", cost4 = BASE): ExpectedPriceOverlay[] { + return [{ provider, modelId, cost4, promptLengthPricing: policy, source: "user test", verifiedAt: "user-configured", status: "verified" }]; +} +function estimate(usage: OcxUsage, policy?: PromptLengthPricing, provider = "anthropic", model = "claude-haiku-5-5", serviceTier?: ServiceTierInput) { + return estimateRequestCost({ provider, model, usage, usageStatus: "reported", serviceTier }, undefined, rows(policy, provider, model)); +} +function expectCharges(result: ReturnType, usage: OcxUsage, rates: Cost4, multiplier = 1) { + expect(result).not.toBeNull(); + const read = usage.cacheReadInputTokens ?? usage.cachedInputTokens ?? 0; + const write = usage.cacheCreationInputTokens ?? 0; + const expected = { + input: (usage.inputTokens! - read - write) * rates.input * multiplier / 1e6, + output: usage.outputTokens! * rates.output * multiplier / 1e6, + cacheRead: read * rates.cacheRead * multiplier / 1e6, + cacheWrite: write * rates.cacheWrite * multiplier / 1e6, + }; + for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) { + expect(result!.cost[key]).toBeCloseTo(expected[key], 12); + } + expect(result!.cost.total).toBeCloseTo(Object.values(expected).reduce((a, b) => a + b, 0), 12); +} + +describe("user prompt-length pricing", () => { + test("Automatic Haiku overrides preserve the catalog boundary and all four cache-inclusive rates", () => { + const base = { input: 0.2, output: 0.8, cacheRead: 0.02, cacheWrite: 0.25 }; + for (const provider of ["anthropic", "anthropic2", "anthropic-apikey", "claude-cli"]) { + for (const policy of [undefined, { mode: "automatic" } as const]) { + for (const inputTokens of [99_999, 100_000, 100_001]) { + const usage = { inputTokens, outputTokens: 1_000, cacheReadInputTokens: 60_000, cacheCreationInputTokens: 20_000 }; + const long = inputTokens > 100_000; + const rates = Object.fromEntries(Object.entries(base).map(([key, value]) => [key, value * (long ? 5 : 1)])) as Cost4; + const request = { provider, model: "claude-haiku-5-5", usageStatus: "reported" as const, usage }; + const overlays = rows(policy, provider, request.model, base); + for (const result of [estimateRequestCost(request, undefined, overlays), estimateAttemptCost({ ...request, ordinal: 1 }, undefined, undefined, overlays)]) { + expectCharges(result, usage, rates); + expect(result?.contextTier).toBe(long ? "long" : undefined); + expect(result?.customThresholdApplied).toBeUndefined(); + } + } + } + } + }); + + for (const threshold of [100_000, 272_000]) { + for (const comparison of ["gt", "gte"] as const) { + for (const offset of [-1, 0, 1]) { + test(`${comparison} ${threshold}: prompt at boundary ${offset >= 0 ? "+" : ""}${offset}`, () => { + const usage = { inputTokens: threshold + offset, outputTokens: 500 }; + const crossed = offset > 0 || (comparison === "gte" && offset === 0); + const result = estimate(usage, custom(threshold, comparison)); + expectCharges(result, usage, crossed ? ABOVE : BASE); + expect(result?.customThresholdApplied).toBe(crossed ? true : undefined); + expect(result?.contextTier).toBeUndefined(); + }); + } + } + } + + test("cached reads and writes count toward the threshold once; all four rates change", () => { + const usage = { inputTokens: 100_001, outputTokens: 10_000, cacheReadInputTokens: 90_000, cacheCreationInputTokens: 10_000 }; + expectCharges(estimate(usage, custom()), usage, ABOVE); + const below = { ...usage, inputTokens: 100_000 }; + expectCharges(estimate(below, custom()), below, BASE); + const fullyCached = { inputTokens: 280_000, outputTokens: 1, cachedInputTokens: 280_000 }; + expectCharges(estimate(fullyCached, custom(272_000)), fullyCached, ABOVE); + }); + + test("output tokens never push a short prompt over its threshold", () => { + const usage = { inputTokens: 1, outputTokens: 1_000_000 }; + expectCharges(estimate(usage, custom()), usage, BASE); + }); + + test("flat and custom replace the automatic rule below and above the custom boundary", () => { + const provider = "openai-apikey", model = "gpt-6-astra"; + for (const count of [280_000, 400_001]) { + const usage = { inputTokens: count, outputTokens: 1_000 }; + expectCharges(estimate(usage, { mode: "flat" }, provider, model), usage, BASE); + const result = estimate(usage, custom(400_000), provider, model); + expectCharges(result, usage, count > 400_000 ? ABOVE : BASE); + expect(result?.contextTier).toBeUndefined(); + } + const usage = { inputTokens: 100_001, outputTokens: 1_000 }; + expectCharges(estimate(usage, custom(), provider, model), usage, ABOVE); + }); + + test("legacy and explicit automatic overrides retain catalog multipliers", () => { + const usage = { inputTokens: 280_000, outputTokens: 100, cacheReadInputTokens: 200_000, cacheCreationInputTokens: 10_000 }; + const expected = { input: BASE.input * 2, output: BASE.output * 1.5, cacheRead: BASE.cacheRead * 2, cacheWrite: BASE.cacheWrite * 2 }; + for (const policy of [undefined, { mode: "automatic" } as const]) { + const result = estimate(usage, policy, "openai-apikey", "gpt-6-astra"); + expectCharges(result, usage, expected); + expect(result?.contextTier).toBe("long"); + expect(result?.customThresholdApplied).toBeUndefined(); + } + }); + + test("zero base and zero alternative tuples remain authoritative", () => { + const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const input = { provider: "custom", model: "private-model", usageStatus: "reported" as const, usage: { inputTokens: 100_001, outputTokens: 100 } }; + expectCharges(estimateRequestCost(input, undefined, rows(custom(), input.provider, input.model, zero)), input.usage, ABOVE); + const freePolicy: PromptLengthPricing = { ...custom(), mode: "custom", thresholdTokens: 100_000, comparison: "gt", rates: zero }; + expectCharges(estimateRequestCost(input, undefined, rows(freePolicy, input.provider, input.model)), input.usage, zero); + }); + + test("OpenAI priority applies once after custom or flat selection, automatic still stacks", () => { + const usage = { inputTokens: 280_000, outputTokens: 100, cacheReadInputTokens: 200_000 }; + for (const tier of ["priority", { requestedServiceTier: "priority" }, { configuredServiceTier: "priority" }, { responseServiceTier: "priority" }]) { + expectCharges(estimate(usage, custom(), "openai-apikey", "gpt-6-astra", tier), usage, ABOVE, 2); + expectCharges(estimate(usage, { mode: "flat" }, "openai-apikey", "gpt-6-astra", tier), usage, BASE, 2); + const auto = estimate(usage, undefined, "openai-apikey", "gpt-6-astra", tier); + expect(auto?.cost.input).toBeCloseTo(80_000 * BASE.input * 2 * 2 / 1e6, 12); + } + }); + + test("Anthropic and xAI confirmation and downgrade rules remain independent", () => { + const usage = { inputTokens: 280_000, outputTokens: 100 }; + for (const [provider, model] of [["anthropic", "claude-opus-5-5"], ["xai", "grok-4.7"]]) { + for (const tier of ["priority", { requestedServiceTier: "priority" }, { configuredServiceTier: "priority" }, { requestedServiceTier: "priority", responseServiceTier: "standard" }]) { + expectCharges(estimate(usage, custom(), provider, model, tier), usage, ABOVE); + } + const result = estimate(usage, custom(), provider, model, { responseServiceTier: "priority" }); + expectCharges(result, usage, ABOVE, provider === "xai" ? 1 : 2); + expect(result?.priorityLowerBound).toBe(provider === "xai" ? true : undefined); + } + const legacyXai = estimate(usage, undefined, "xai", "grok-4.7", { responseServiceTier: "priority" }); + expect(legacyXai?.priorityLowerBound).toBe(true); + expect(legacyXai?.priorityMultiplier).toBeUndefined(); + }); + + test("custom policy inherits a catalog exclusive Priority relation only when confirmed", () => { + const rule = CONTEXT_TIERS.find(row => row.provider === "openai-apikey" && row.modelId === "gpt-6-astra")!; + const before = rule.confirmedPriorityRelation; + try { + rule.confirmedPriorityRelation = "exclusive"; + const usage = { inputTokens: 280_000, outputTokens: 100 }; + const tier = { responseServiceTier: "priority" }; + expectCharges(estimate(usage, undefined, rule.provider, rule.modelId, tier), usage, BASE, 2); + const result = estimate(usage, custom(), rule.provider, rule.modelId, tier); + expectCharges(result, usage, BASE, 2); + expect(result?.customThresholdApplied).toBeUndefined(); + expect(result?.priorityLowerBound).toBeUndefined(); + expectCharges(estimate(usage, custom(), rule.provider, rule.modelId, { requestedServiceTier: "priority" }), usage, ABOVE, 2); + expectCharges(estimate(usage, { mode: "flat" }, rule.provider, rule.modelId, tier), usage, BASE, 2); + } finally { rule.confirmedPriorityRelation = before; } + }); + + test("xAI custom confirmed Priority uses the custom rates as a lower bound", () => { + const usage = { inputTokens: 300_000, outputTokens: 1_000 }; + const base = { input: 3, output: 15, cacheRead: 0.5, cacheWrite: 0 }; + const rates = { input: 6, output: 30, cacheRead: 1, cacheWrite: 0 }; + const policy: PromptLengthPricing = { mode: "custom", thresholdTokens: 200_000, comparison: "gte", rates }; + const overlays = rows(policy, "xai", "grok-4.7", base); + const serviceTier = { requestedServiceTier: "priority", responseServiceTier: "priority" }; + const request = { provider: "xai", model: "grok-4.7", usage, usageStatus: "reported" as const }; + for (const result of [estimateRequestCost({ ...request, serviceTier }, undefined, overlays), + estimateAttemptCost({ ...request, ordinal: 1 }, undefined, serviceTier, overlays)]) { + expectCharges(result, usage, rates); + expect(result?.cost.total).toBeCloseTo(1.83, 12); + expect(result?.priorityLowerBound).toBe(true); + expect(result?.priorityMultiplier).toBeUndefined(); + expect(result?.customThresholdApplied).toBe(true); + expect(result?.contextTier).toBeUndefined(); + } + expectCharges(estimate(usage, { mode: "flat" }, "xai", "grok-4.7", serviceTier), usage, BASE, 2); + }); + + test("custom Priority stacks once with a catalog stack relation or no context rule", () => { + const usage = { inputTokens: 300_000, outputTokens: 1_000 }; + for (const [provider, model] of [["openai-apikey", "gpt-6-astra"], ["anthropic", "claude-opus-5-5"]]) { + const result = estimate(usage, custom(), provider, model, { responseServiceTier: "priority" }); + expectCharges(result, usage, ABOVE, 2); + expect(result?.priorityMultiplier).toBe(2); + expect(result?.priorityLowerBound).toBeUndefined(); + expect(result?.customThresholdApplied).toBe(true); + } + }); + + test("Cursor explicit Fast IDs retain user prices; variant outcomes apply one premium", () => { + const usage = { inputTokens: 100_001, outputTokens: 100 }; + expectCharges(estimate(usage, custom(), "cursor", "claude-opus-5-5-high-fast", { requestedServiceTier: "priority" }), usage, ABOVE); + expectCharges(estimate(usage, custom(), "cursor", "claude-opus-5-5", { requestedServiceTier: "priority" }), usage, ABOVE, 2); + }); + + test("OpenRouter unknown Priority prices keep their lower-bound provenance", () => { + const outcome: AttemptTierOutcome = { canonical: "priority", wireKind: "service-tier", wireValue: "priority", fastOutcome: "applied", confirmation: "assumed" }; + const usage = { inputTokens: 100_001, outputTokens: 100 }; + const result = estimateAttemptCost({ ordinal: 1, provider: "openrouter", model: "anthropic/test", usageStatus: "reported", usage, tierOutcome: outcome }, undefined, undefined, rows(custom(), "openrouter", "anthropic/test")); + expectCharges(result, usage, ABOVE); + expect(result?.priorityLowerBound).toBe(true); + }); + + test("each Combo attempt evaluates its own prompt; sums do not select a shared band", () => { + const common = { provider: "anthropic", model: "claude-haiku-5-5", usageStatus: "reported" as const }; + const attempts = [ + { ...common, ordinal: 1, usage: { inputTokens: 90_000, outputTokens: 100 } }, + { ...common, ordinal: 2, usage: { inputTokens: 100_001, outputTokens: 100 } }, + ]; + const result = estimateComboCost(attempts, undefined, undefined, rows(custom())); + expectCharges(result?.attempts?.[0] ?? null, attempts[0]!.usage, BASE); + expectCharges(result?.attempts?.[1] ?? null, attempts[1]!.usage, ABOVE); + expect(result?.cost.total).toBeCloseTo(0.00905 + 0.0502505, 12); + expect(result?.customThresholdApplied).toBe(true); + expect(result?.contextTier).toBeUndefined(); + expect(estimateComboCost([...attempts, { ...common, ordinal: 3 }], undefined, undefined, rows(custom()))).toBeNull(); + }); + + test("invalid token evidence remains unavailable instead of falling through to a custom price", () => { + for (const usage of [{ outputTokens: 100 }, { inputTokens: -1, outputTokens: 100 }, { inputTokens: 1, outputTokens: 1, cacheReadInputTokens: 2 }]) { + expect(estimate(usage, custom())).toBeNull(); + } + }); +});