diff --git a/.github/pr-assets/260923-account-thresholds.png b/.github/pr-assets/260923-account-thresholds.png new file mode 100644 index 00000000000..4f7bb7cebd6 Binary files /dev/null and b/.github/pr-assets/260923-account-thresholds.png differ 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 c4fd2dc57de..5900b846b41 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -36,9 +36,10 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `codexAccountNamespaces?` | `Record` | — | Mappage facultatif d’un sélecteur de modèle public arbitraire vers une cible de compte Codex stockée. Lorsque les lignes du sélecteur qualifié par compte sont activées, chaque sélecteur dont la cible est présente ajoute des lignes `/` distinctes au sélecteur Codex ; chaque ligne utilise uniquement ce compte. Dès qu'un sélecteur est actif, les lignes natives non qualifiées sont masquées dans le sélecteur, mais leurs identifiants restent routables et figurent toujours dans la réponse brute de `/v1/models`, sauf désactivation explicite. | | `codexAccountPickerEnabled?` | `boolean` | désactivé lorsque la carte est vide | Contrôle si les mappages `codexAccountNamespaces` éligibles génèrent des lignes de sélecteur Codex qualifiées pour le compte. `true` permet aux lignes mappées d'apparaître. Si elle est omise avec une carte non vide, elle est traitée comme activée pour des raisons de compatibilité ascendante ; si la carte est vide, elle est éteinte. `false` masque les lignes générées et restaure les lignes nues du sélecteur natif sans supprimer les mappages ni désactiver le routage exact `/`. | | `activeCodexAccountId?` | `string` | — | Compte de pool sélectionné manuellement pour la prochaine demande. La sélection efface l'affinité des threads ; les demandes en cours conservent les informations d’identification capturées. | -| `codexAccountPriorities?` | `Record` | — | Ordre de sélection par compte pour le pool Codex : identifiant de compte → entier de `-100` à `100`, **les valeurs élevées sont prioritaires**, une valeur absente équivaut à `0`. Cette limite porte sur le classement, et non sur l'admissibilité : la sélection retient, parmi les comptes déjà admissibles, le niveau prioritaire le plus élevé qui dispose encore d'une marge de quota, puis `accountPoolStrategy` choisit un compte dans ce niveau. Un niveau est ignoré uniquement lorsque chacun de ses membres dépasse `autoSwitchThreshold`, est en temporisation, est temporairement évité, est suspendu ou doit être réauthentifié ; un quota inconnu ne suffit jamais à considérer un niveau comme épuisé. L'ordre ne rend jamais admissible un compte qui ne l'est pas et ne réaffecte jamais une tâche déjà liée à un compte. Le compte principal `__main__` participe selon les mêmes règles ; la connexion Codex Desktop peut ainsi être configurée pour être utilisée en dernier. Sans entrée, le pool se comporte exactement comme auparavant. Un mappage mal formé est ignoré avec un avertissement dans la console : l'ordre est désactivé et la configuration n'est pas réparée. Ce champ est géré par `ocx account priority` et la page Codex Auth. | +| `codexAccountPriorities?` | `Record` | — | Ordre de sélection par compte pour le pool Codex : identifiant de compte → entier de `-100` à `100`, **les valeurs élevées sont prioritaires**, une valeur absente équivaut à `0`. Cette limite porte sur le classement, et non sur l'admissibilité : la sélection retient, parmi les comptes déjà admissibles, le niveau prioritaire le plus élevé qui dispose encore d'une marge de quota, puis `accountPoolStrategy` choisit un compte dans ce niveau. Un niveau est ignoré uniquement lorsque chacun de ses membres atteint son propre seuil effectif non nul (valeur spécifique au compte ou seuil global), est en temporisation, est temporairement évité, est suspendu ou doit être réauthentifié ; un quota inconnu ne suffit jamais à considérer un niveau comme épuisé. L'ordre ne rend jamais admissible un compte qui ne l'est pas et ne réaffecte jamais une tâche déjà liée à un compte. Le compte principal `__main__` participe selon les mêmes règles ; la connexion Codex Desktop peut ainsi être configurée pour être utilisée en dernier. Sans entrée, le pool se comporte exactement comme auparavant. Un mappage mal formé est ignoré avec un avertissement dans la console : l'ordre est désactivé et la configuration n'est pas réparée. Ce champ est géré par `ocx account priority` et la page Codex Auth. | | `activeCodexAccountPinned?` | `string` | — | Identifiant du compte du dernier opérateur sélectionné manuellement. Lorsqu'il est défini, un niveau `codexAccountPriorities` supérieur ne peut pas le préempter jusqu'à ce que la broche soit libérée par drainage, exclusion, suppression ou un failover/promotion explicite. Un mouvement circulaire ordinaire à l’intérieur du niveau plafonné ne le libère pas. L'écriture d'une entrée `codexAccountPriorities` libère également le pin, donc un pin créé avant qu'un ordre n'existe ne peut pas surpasser un ensemble par la suite. `GET /api/codex-auth/active` indique à la fois si le compte effectif est épinglé (`pinned`) et le compte portant le plafond (`pinnedAccountId`). | | `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête. Les tâches liées conservent leur compte au-delà du seuil par défaut (`pool.cacheAffinity`) jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir, et ne basculent alors que vers un compte dont l'utilisation est strictement inférieure et qui dispose d'une véritable marge de quota. Définissez `pool.cacheAffinity: false` pour réévaluer aussi les tâches liées à ce seuil. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | Seuils par compte remplaçant `autoSwitchThreshold` : identifiant → entier de `0` à `100`. Une entrée absente hérite du seuil global ; `0` désactive uniquement les déplacements fondés sur l’utilisation depuis ce compte. Le compte principal `__main__` est pris en charge. La carte du compte dans Codex Auth gère cette valeur. Activer la valeur spécifique au compte copie le seuil global actuel dans une valeur fixe. Cette valeur, y compris `0`, reste prioritaire après toute modification du seuil global. La désactiver envoie `threshold: null`, supprime l'entrée et rétablit l'héritage du seuil global actuel et de ses modifications futures. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Les tâches liées suivent `pool.cacheAffinity` (activé par défaut) : elles restent jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir, et ne se relient alors qu'à un compte dont l'utilisation est strictement inférieure et qui dispose d'une véritable marge de quota. Définissez le drapeau à `false` pour relier de manière proactive une tâche liée à un compte admissible moins utilisé au seuil, toujours sous la même contrainte de destination. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. `reset-first`: Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation. Les resets mensuels ne déterminent pas cet ordre. | | `pool.cacheAffinity?` | `boolean` | `true` | Ordre d'affinité de cache pour les threads Codex liés, indépendant de `pool.kernel`. Activé par défaut ; omettre la clé ou la définir à `true` conserve la liaison, et une valeur autre que `false` est lue comme activée. Une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`, car déplacer une conversation liée jette le cache d'invites isolé par compte. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, exclu du plan, identifiants invalides, génération remplacée, TTL expiré, refus de quota 429/402, ou réellement épuisé (utilisation connue à 100 %) — et seulement vers un compte dont l'utilisation est strictement inférieure et qui dispose d'une véritable marge de quota. Un compte dont l'utilisation est inconnue n'est jamais choisi comme destination d'une tâche liée. Si tous les comptes dépassent le seuil, la tâche liée reste, car aucune destination n'est meilleure. Définissez `false` pour rétablir la réaffectation au seuil, toujours sous la même contrainte de destination. L'affinité est un réordonnancement, pas un verrouillage. | | `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. | @@ -48,6 +49,8 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Politique Anthropic de mise en cache des invites : désactivée, éphémère pendant 5 minutes ou étendue à 1 heure. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | désactivé | Politique facultative d'actualisation proactive OAuth et de préchauffage des comptes Codex. | +Pour ces stratégies, le seuil effectif de chaque compte est son entrée `codexAccountAutoSwitchThresholds` si elle existe, sinon le seuil global `autoSwitchThreshold`. Une valeur de zéro désactive uniquement le basculement proactif selon l'utilisation ; la liaison au démarrage, le verrouillage strict, le cooldown, les droits d'accès au modèle et la reprise après échec restent appliqués. + Les noms des sélecteurs sont des étiquettes publiques choisies par l'utilisateur ; opencodex ne leur attribue aucune sémantique de rôle de compte. Les clés `codexAccountNamespaces` comportent de 1 à 64 caractères. Elles commencent et se terminent par une lettre ou un chiffre ASCII et ne contiennent que des lettres, des chiffres, `.`, `_` ou `-`. Les noms réservés @@ -208,8 +211,8 @@ et suspend uniquement ceux dont l'utilisation vient d'être confirmée à 100 % | Stratégie | Comportement | | --- | --- | | `quota` (par défaut) | S'il n'existe aucun compte actif, choisir le compte admissible le moins utilisé selon les fenêtres de 5 heures, d'une semaine et de 30 jours. Sinon, conserver un compte actif admissible sous `autoSwitchThreshold` ; une fois le seuil franchi, une requête non liée peut être déplacée vers un compte admissible moins utilisé. Les tâches liées conservent l'affinité de cache par défaut et restent jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir (suspendu, inutilisable) ; un déplacement exige alors une véritable marge de quota et une utilisation strictement inférieure sur la destination. Définissez `pool.cacheAffinity: false` pour laisser la requête suivante d'une tâche liée bouger au seuil, toujours vers un compte admissible moins utilisé disposant d'une véritable marge de quota. `0` désactive cette réévaluation fondée sur l'utilisation, mais pas la récupération après incident. | -| `round-robin` | Répartit uniformément les requêtes non liées entre les comptes admissibles. `autoSwitchThreshold` ne modifie pas la sélection circulaire normale. `accountPoolStickyLimit` (1–100) compte les affectations effectuées avec une même sélection, et non les réponses réussies en amont. | -| `fill-first` | Attribue les requêtes non liées au compte actif jusqu'à sa temporisation, sa réauthentification ou le seuil d'évacuation configuré ; une utilisation inconnue n'impose pas de changement. Les tâches liées et saines conservent leur affinité. | +| `round-robin` | Répartit uniformément les requêtes non liées entre les comptes admissibles. La rotation repose sur un compteur et ignore les seuils, mais le filtre commun des niveaux de priorité évalue toujours la marge de chaque compte selon son seuil effectif (valeur spécifique au compte ou seuil global). `accountPoolStickyLimit` (1–100) compte les affectations effectuées avec une même sélection, et non les réponses réussies en amont. | +| `fill-first` | Attribue les requêtes non liées au compte actif jusqu'à sa temporisation, sa réauthentification ou le seuil d'évacuation effectif de ce compte (valeur spécifique au compte ou seuil global) ; une utilisation inconnue n'impose pas de changement. Les tâches liées et saines conservent leur affinité. | La rotation ne protège pas contre l’application des règles par les fournisseurs ; l'utilisation de plusieurs comptes peut enfreindre les conditions du fournisseur. diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 0ce8ea8003b..fbd25126534 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -341,7 +341,7 @@ Codex. Ses routes sont les suivantes : | `PUT /api/codex-auth/accounts/pause-exhausted` | Suspendre les comptes dont le quota est épuisé | Les échecs de verrouillage de mutation deviennent 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | Effacer le temps de recharge d'exécution pour un compte ou tous les comptes | 400 identifiant invalide | | `GET, PUT /api/codex-auth/active` | Lire ou sélectionner le compte actif | 400 compte invalide ou manquant ; 409 conflit avec un compte suspendu ou une ancienne ligne | -| `PUT /api/codex-auth/auto-switch` | Définir le seuil de quota pour le changement automatique de compte | 400 seuil invalide | +| `PUT /api/codex-auth/auto-switch` | Définir le seuil global avec `{ threshold }` sans `id`, ou la valeur spécifique à un compte avec `{ id, threshold }` ; `id: '__main__'` désigne le compte Codex Desktop. Avec un `id`, `threshold: null` supprime la valeur spécifique et rétablit l'héritage du seuil global | 400 id/seuil invalide ; 404 compte absent | | `PUT, PATCH /api/codex-auth/pool-strategy` | Mettre à jour la stratégie de sélection du groupe de comptes Codex | 400 stratégie ou configuration invalide | | `PUT /api/codex-auth/failover` | Définir le seuil de basculement du compte | 400 seuil invalide | | `GET /api/codex-auth/quota` | Lire l'état du quota mis en cache par compte | — | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e8cba6c928d..d796c31d981 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -350,6 +350,23 @@ across proxy restarts). On credential failures (`401` / `403`) the account is qu reauth and affinities for that account are cleared. On `429`, the account enters cooldown, affinities are cleared, and pool selection may rotate — threads are not pinned through a rate-limit response. +**Return to a recovered account.** To resume using the highest-priority account during an +ongoing task, enable `ocx config set codexAccountPriorityFailback true` and keep the Pool strategy +at `quota` with automatic switching enabled. For example, a task can move from Plus to another +account when its five-hour window fills, then return to Plus after the quota refresh confirms +recovery. While requests are flowing, background refresh attempts run at most once every five +minutes. The request that triggers a refresh may still use the previous account; the next request +after the refresh completes can move back. Already-running requests finish on their captured +account. Manual account pins and model-access restrictions still apply. This feature is off by +default; use `ocx config set codexAccountPriorityFailback false` to restore stable bindings. + +The bound source account's effective threshold controls this preference: override `0` disables it, +and a positive override works even when the global threshold is `0`. A candidate must have known, +non-exhausted usage below its own positive effective threshold. Candidate `0` removes only that +threshold preference; eligibility, cooldown and hard locks still apply. Each window contributing +to its score needs a recent live observation in this process. Credits-only updates, retained +windows and hydrated display bars do not by themselves prove recovery. + **Codex client metadata.** The ChatGPT forward path passes through the curated `FORWARD_HEADERS` allowlist (authorization, `chatgpt-account-id`, originator, session/thread ids, and related Codex headers — see [Adapters](/reference/adapters/)). Pool mode overwrites only auth and 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 7dacef8a955..0c1133b8849 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -35,8 +35,9 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。account-qualified picker row が有効な場合、target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | | `codexAccountPickerEnabled?` | `boolean` | map が空なら off | 有効な `codexAccountNamespaces` mapping から account-qualified Codex picker row を生成するかを制御します。`true` は mapping された行の表示を許可します。空でない map で省略した場合は後方互換性のため有効として扱われ、map が空なら off です。`false` は mapping を削除せず、明示的な `/` routing も無効にせずに、生成行を非表示にして picker の bare native 行を復元します。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | -| `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が `autoSwitchThreshold` 超過、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 | +| `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が自身の 0 以外の実効しきい値(アカウント別上書き、未設定ならグローバル値)以上、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価できます。紐付け済みタスクは既定(`pool.cacheAffinity`)ではしきい値を超えても同じアカウントを維持し、アカウントが使い切られるか処理できなくなったときだけ離れ、その場合も実際に quota 余裕があり usage がより低いアカウントへだけ移ります。`pool.cacheAffinity: false` にするとしきい値で紐付け済みタスクも再評価します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | `autoSwitchThreshold` のアカウント別上書き(アカウント ID → `0`〜`100` の整数)。未設定はグローバル値を継承し、`0` はそのアカウントからの使用量ベース切り替えだけを無効にします。メインの `__main__` も指定できます。Codex Auth の各アカウントカードで管理します。 上書きを有効にすると、現在のグローバルしきい値が固定のアカウント別値としてコピーされます。`0` を含む上書き値は、その後グローバル値が変更されても優先されます。無効にすると `threshold: null` を送信してエントリを削除し、現在のグローバルしきい値と今後の変更を継承する状態に戻ります。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。紐付け済みタスクは既定ではアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持され、離れるときは実際に quota 余裕があり usage がより低いアカウントへだけ移ります。フラグをオフにすると、しきい値で紐付け済みタスクの次のリクエストも実際に quota 余裕があり usage がより低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | | `pool.cacheAffinity?` | `boolean` | `true` | 紐付け済み Codex スレッド向けの cache-affinity 順序。`pool.kernel` とは独立で、既定はオンです。不正な値はオンとして読みます。live な紐付けが quota 余裕より優先され、`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れますが、実際に quota 余裕があり usage がより低いアカウントへだけ移ります。`false` にするとしきい値での再紐付けに戻ります。affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | @@ -46,6 +47,8 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic プロンプト キャッシュ ポリシー: 無効、5 分間の一時的、または 1 時間の延長。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` |オフ |オプションのプロアクティブな OAuth 更新および Codex アカウントのウォームアップ ポリシー。 | +各戦略で使う実効しきい値は、アカウントの `codexAccountAutoSwitchThresholds` があればその値、なければグローバルの `autoSwitchThreshold` です。0 は使用量による先行切り替えだけを無効にし、起動時のバインド、ハードロック、クールダウン、モデル利用資格の確認、障害復旧は引き続き適用されます。 + selector 名はユーザーが選ぶ公開 label であり、opencodex はアカウント role の意味を付与しません。 `codexAccountNamespaces` のキーは長さ 1〜64 文字、先頭と末尾は ASCII 英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 @@ -189,7 +192,7 @@ pause、cooldown、再認証、障害処理も独立して routing を消去ま **割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は未紐付けリクエストを移せます。既定では cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持され、離れるときは実際に quota 余裕があり usage がより低いアカウントへだけ移ります。usage が不明なアカウントは紐付け済みタスクの移動先にはならず、すべてのアカウントがしきい値を超えていればそのまま残ります。フラグをオフにすると紐付け済みタスクの次のリクエストもしきい値で再紐付けできますが、その場合も実際に quota 余裕があり usage がより低いアカウントへだけ移ります。`round-robin` は 未紐付けリクエストを均等分散し、しきい値は通常の rotation を変えません。`accountPoolStickyLimit` (既定 `1`、1–100)は成功応答ではなく割り当て/紐付け数を数えます。`fill-first` は未紐付けリクエストを -cooldown、再認証、または drain threshold までアクティブアカウントへ割り当て、正常な紐付け済みタスクは +cooldown、再認証、またはそのアカウントの実効 drain threshold(アカウント別上書き、未設定ならグローバル値)までアクティブアカウントへ割り当て、正常な紐付け済みタスクは affinity を維持します。これらの戦略は provider enforcement を回避しません。 ### `anthropicAccountPool` (実験的) diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 3aab78aa88c..7e8596c82ea 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -290,7 +290,7 @@ Aside プロファイルの変更はこの場合でも一つだけ保存しま | `PUT /api/codex-auth/accounts/pause-exhausted` |クォータを使い果たしたアカウントを一時停止する |ミューテーションロックの失敗は 503 になります | | `POST /api/codex-auth/accounts/clear-cooldown` | 1 つのアカウントまたはすべてのアカウントのランタイム クールダウンをクリアする | 400 無効な ID | | `GET, PUT /api/codex-auth/active` |アクティブなアカウントを読み取るか選択します | 400 アカウントが無効または欠落しています。 409 一時停止/レガシー行の競合 | -| `PUT /api/codex-auth/auto-switch` |自動アカウント切り替えのクォータしきい値を設定する | 400 無効なしきい値 | +| `PUT /api/codex-auth/auto-switch` | `id` を省略した `{ threshold }` でグローバルしきい値、`{ id, threshold }` でアカウント別の上書き値を設定する。`id: '__main__'` は Codex Desktop アカウントを指定する。`id` を指定した場合、`threshold: null` は上書き値を削除してグローバル値の継承に戻す | 400 無効な ID/しきい値、404 アカウントなし | | `PUT, PATCH /api/codex-auth/pool-strategy` | Codex アカウントプールの選択戦略を更新 | 400 無効な戦略/構成 | | `PUT /api/codex-auth/failover` |アカウントのフェイルオーバーしきい値を設定する | 400 無効なしきい値 | | `GET /api/codex-auth/quota` |キャッシュされたクォータ状態をアカウントごとに読み取る | — | 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 fdbf9c697a8..8e26b199ea5 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -35,8 +35,9 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. 계정 한정 선택기 행이 활성화되어 있으면 target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | | `codexAccountPickerEnabled?` | `boolean` | map이 비어 있으면 꺼짐 | 유효한 `codexAccountNamespaces` 매핑에서 account-qualified Codex 선택기 행을 생성할지 제어합니다. `true`는 매핑된 행의 표시를 허용합니다. 비어 있지 않은 map에서 생략하면 이전 버전과의 호환성을 위해 활성화된 것으로 취급되며, map이 비어 있으면 꺼집니다. `false`는 매핑을 삭제하거나 명시적 `/` 라우팅을 비활성화하지 않은 채 생성 행을 숨기고 선택기에 bare native 행을 복원합니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | -| `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 `autoSwitchThreshold` 초과, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. | +| `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 자신의 0이 아닌 유효 임계값(계정별 재정의 또는 전역 기본값) 이상, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있습니다. 바인딩된 작업은 기본값(`pool.cacheAffinity`)에서 이 임계값을 넘어도 계정을 유지하며, 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때에만 떠나고, 그때도 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 옮깁니다. `pool.cacheAffinity: false`로 두면 임계값에서 바인딩된 작업도 재평가합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | 계정별 `autoSwitchThreshold` 재정의입니다(계정 ID → `0`~`100` 정수). 항목이 없으면 전역 값을 상속하고, `0`은 해당 계정에서 시작하는 사용량 기반 전환만 끕니다. 메인 `__main__` 계정도 지원합니다. Codex Auth의 계정 카드에서 관리합니다. 재정의를 켜면 현재 전역 임계값을 고정된 계정별 값으로 복사합니다. `0`을 포함한 재정의 값은 이후 전역 값이 바뀌어도 우선합니다. 끄면 `threshold: null`을 보내 항목을 삭제하고 현재 전역 임계값과 향후 변경 사항을 상속하는 상태로 돌아갑니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있습니다. 바인딩된 작업은 기본값에서 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지되며, 떠날 때는 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 옮깁니다. 플래그를 끄면 임계값에서 바인딩된 작업의 다음 요청도 실제 quota 여유가 있고 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | | `pool.cacheAffinity?` | `boolean` | `true` | 바인딩된 Codex 스레드의 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 켜짐입니다. 잘못된 값은 켜진 것으로 읽습니다. live 바인딩이 quota 여유보다 우선하므로 `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 떠나되, 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 옮깁니다. `false`로 두면 임계값 재바인딩이 복원됩니다. affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | @@ -46,6 +47,8 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 프롬프트 캐시 정책입니다. 비활성, 5분짜리 임시, 1시간짜리 확장 중 하나입니다. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 꺼짐 | 선택적 선제 OAuth 갱신과 Codex 계정 워밍업 정책입니다. | +각 전략의 계정별 유효 임계값은 `codexAccountAutoSwitchThresholds` 항목이 있으면 그 값이며, 없으면 전역 `autoSwitchThreshold`입니다. 0은 사용량 기반 사전 전환만 비활성화합니다. 시작 바인딩, 하드 잠금, 쿨다운, 모델 사용 권한 검사와 장애 복구는 계속 적용됩니다. + selector 이름은 사용자가 정하는 공개 label이며, opencodex는 여기에 계정 역할 의미를 부여하지 않습니다. `codexAccountNamespaces` 키는 길이가 1~64자이고 시작과 끝은 ASCII 영숫자여야 하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. @@ -190,7 +193,7 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있습니다. 기본값에서 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지되고, 떠날 때는 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 옮깁니다. usage를 모르는 계정은 바인딩된 작업의 목적지가 되지 않으며, 모든 계정이 임계값 위이면 그대로 둡니다. 플래그를 끄면 바인딩된 작업의 다음 요청도 임계값에서 옮길 수 있지만, 그때도 실제 quota 여유가 있고 usage가 더 낮은 계정으로만 갑니다. `round-robin`은 바인딩 없는 요청을 균등 분배하며 임계값은 기본 순환에 영향을 주지 않습니다. `accountPoolStickyLimit`(기본 `1`, 1–100)은 성공 응답이 아니라 배정/바인딩 횟수를 셉니다. -`fill-first`는 바인딩 없는 요청을 cooldown, 재인증 또는 drain threshold까지 활성 계정에 배정하고, +`fill-first`는 바인딩 없는 요청을 cooldown, 재인증 또는 해당 계정의 유효 drain threshold(계정별 재정의 또는 전역 기본값)까지 활성 계정에 배정하고, 정상적인 바인딩 작업은 affinity를 유지합니다. 이 전략들은 provider enforcement를 우회하지 않으며 다계정 사용은 ToS 위반일 수 있습니다. @@ -464,3 +467,16 @@ Vercel AI Gateway는 하나의 모델을 여러 기반 추론 공급자에 걸 "visionSidecar": { "enabled": true } } ``` + +## 진행 중 작업의 우선순위 계정 복귀 + +최상위 `codexAccountPriorityFailback: true`는 `quota` 전략과 현재 source 계정의 양수 유효 임계값에서만 +진행 중인 작업을 복구된 상위 우선순위 계정으로 옮기도록 명시적으로 허용합니다. 기본값은 꺼짐이며, +활성화하면 현재 계정이 정상이어도 따뜻한 프롬프트 캐시를 포기할 수 있습니다. 최근 관측된 쿼터 여유가 +있어야 하며 오래된 관측, 알 수 없는 쿼터, 수동 고정, 모델 제한, 쿨다운·재인증 제한은 우회하지 않습니다. +해당 복귀용 쿼터 확인은 요청이 있을 때 최대 5분에 한 번 시작하고 실패한 시도도 간격에 포함합니다. +진행 중인 요청의 계정은 바꾸지 않으며, 정확한 계정 지정 경로와 Direct 모드에는 적용하지 않습니다. +source 재정의가 0이면 꺼지고, 전역 0이어도 source에 양수 재정의가 있으면 적용합니다. +후보의 양수 유효 임계값은 사용량 상한이며, 후보 0은 그 선호만 끕니다. 후보 0도 알 수 없거나 +소진된 사용량을 허용하지 않습니다. 판단에 쓰는 각 quota window는 이 프로세스에서 최근 관측되어야 +하며, credit-only 갱신이나 다른 window의 부분 갱신은 오래된 사용량을 새 관측으로 만들지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index eb153c9caeb..6bce6490e77 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -296,7 +296,7 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | `PUT /api/codex-auth/accounts/pause-exhausted` | quota가 소진된 account를 일시 중지합니다 | mutation-lock 실패는 503이 됩니다 | | `POST /api/codex-auth/accounts/clear-cooldown` | account 하나 또는 모든 account의 runtime cooldown을 지웁니다 | 400 잘못된 id | | `GET, PUT /api/codex-auth/active` | 활성 account를 읽거나 선택합니다 | 400 잘못되었거나 누락된 account; 409 paused/legacy-row 충돌 | -| `PUT /api/codex-auth/auto-switch` | 자동 account 전환을 위한 quota threshold를 설정합니다 | 400 잘못된 threshold | +| `PUT /api/codex-auth/auto-switch` | `id`를 생략한 `{ threshold }`로 전역 임계값을, `{ id, threshold }`로 계정별 재정의 값을 설정합니다. `id: '__main__'`은 Codex Desktop 계정을 지정합니다. `id`가 지정된 경우 `threshold: null`은 재정의 값을 삭제하고 전역 임계값 상속을 복원합니다 | 400 잘못된 ID/임계값, 404 계정 없음 | | `PUT, PATCH /api/codex-auth/pool-strategy` | Codex account-pool 선택 전략을 업데이트합니다 | 400 잘못된 전략/구성 | | `PUT /api/codex-auth/failover` | account failover threshold를 설정합니다 | 400 잘못된 threshold | | `GET /api/codex-auth/quota` | 계정별 캐시된 quota 상태를 읽습니다 | — | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c7535203297..96841ec7aa1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -49,9 +49,11 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. When account-qualified picker rows are enabled, each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | | `codexAccountPickerEnabled?` | `boolean` | off when the map is empty | Controls whether eligible `codexAccountNamespaces` mappings generate account-qualified Codex picker rows. `true` allows mapped rows to appear. If omitted with a non-empty map, it is treated as enabled for backward compatibility; if the map is empty, it is off. `false` hides generated rows and restores bare native picker rows without deleting mappings or disabling exact `/` routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | -| `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is over `autoSwitchThreshold`, cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | +| `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is at or above its own nonzero effective usage threshold (account override or global default), cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and by default, never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | | `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). | +| `codexAccountPriorityFailback?` | `boolean` | `false` | Opt-in return of ongoing Codex Pool tasks to a usable higher-priority account under the `quota` strategy. Requests trigger a background quota refresh at most once every five minutes, including failed refresh attempts. When recent quota observations show headroom, the next request may rebind even if its current account is below the threshold. This explicit preference can discard a warm prompt cache; it does not change default cache affinity. No requests means no polling. Manual pins, model access, cooldowns, pauses and reauthentication exclusions still apply; in-flight requests keep their captured account. Requires the current source account's effective threshold to be positive (an override of 0 disables it; a positive override works with global 0); exact account routes and Direct mode are unchanged. | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for placing new/unbound work. `quota` can re-evaluate unbound tasks on their next request once usage crosses this threshold. Bound tasks keep their account past the threshold by default (`pool.cacheAffinity`); they leave only when that account is exhausted or otherwise cannot serve, and then only for an account with genuine quota headroom and strictly lower usage. Set `pool.cacheAffinity: false` to re-evaluate bound tasks at this threshold, still only onto such a destination. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | Per-account overrides for `autoSwitchThreshold`: account id → integer from `0` to `100`. An absent entry inherits the global value; `0` disables usage-driven switching only when that account is the source. `quota` uses the current source account's effective threshold for bound-task re-evaluation and unbound switching. Fill-first drain and selection-order tier headroom are evaluated against each account's own effective threshold; main-account pins and subagent fallback also use the relevant account's effective threshold. Supports the main `__main__` account. A malformed map is ignored with a warning. Managed by each account card on the Codex Auth page; disabling an override removes its entry. Enabling the override copies the current global threshold into a fixed account value. The override, including `0`, takes precedence over later global changes. Disabling it sends `threshold: null`, removes the entry, and restores inheritance of the current global threshold and future global changes. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request. Bound tasks follow `pool.cacheAffinity` (on by default): they stay until the account is exhausted (known usage at 100%) or otherwise cannot serve, and then may rebind only to an account with genuine quota headroom and strictly lower usage. Set the flag `false` to proactively rebind a bound task at the threshold, still only onto such a destination. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. | | `pool.cacheAffinity?` | `boolean` | `true` | Cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. On by default; omitting the key or setting `true` keeps a bound task on its account until that account genuinely cannot serve. Only an explicit `false` restores threshold-based rebinding of bound tasks. A live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — and then only to an account with genuine quota headroom and strictly lower usage. Under either setting, an account with unknown usage is never chosen as a destination for a bound task, so when every account sits above the threshold the task stays put. Affinity is a reordering, not a pin. | | `pool.credentialGroups?` | `Array<{ id: string; credentials: string[]; note?: string }>` | `[]` | Accepted and validated, but not yet consumed by routing: declaring a group changes no routing decision until a consuming layer lands. Operator-declared quota domains: groups of credentials that demonstrably share one upstream usage limit. Members of one group count once toward available capacity, and a quota refusal inside a group is not answered by rotating to another member — the limit is the same, so the move would pay a cold prefix for zero new capacity. Declared groups speak only to quota; sharing a limit says nothing about prompt-cache compatibility, which is classified separately. Each member is written provider-qualified as `":"`, because a credential id means something only inside its provider; the provider segment accepts the usual aliases (`chatgpt:` and `codex:` both mean OpenAI). Group ids must be unique, `credentials` must be non-empty, and a credential may appear in at most one group — an ambiguous declaration is rejected on write and dropped with a warning on load rather than resolved by whichever group is listed first, since that would merge two unrelated quota domains. A malformed list costs only the grouping: `pool.kernel` and `pool.cacheAffinity` are preserved. Absent or empty means no declared grouping, so an unconfigured install behaves exactly as before. | @@ -64,6 +66,14 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. | +Across these strategies, each account's effective threshold is its `codexAccountAutoSwitchThresholds` entry when present, otherwise global `autoSwitchThreshold`. A zero override disables only proactive usage switching; startup binding, hard-lock, cooldown, entitlement checks, and failure recovery remain enforced. + +Setting an account's switching threshold to `0` does not disable main-account hard-lock, startup +protection, cooldowns, or model eligibility checks. In Pool mode, a pinned caller credential that +matches the observed main account cannot bypass that account's applicable cooldown: an eligible +Pool alternative is used, or the request is rejected. Unrelated caller credentials do not inherit +the main account's cooldown; explicit Direct behavior is unchanged. + Selector names are user-chosen public labels; opencodex assigns no account-role semantics to them. `codexAccountNamespaces` keys are 1–64 characters, starting and ending with an ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside. Reserved JavaScript object @@ -645,8 +655,8 @@ and pauses only accounts freshly confirmed at 100%; unknown or failed refreshes | Strategy | Behaviour | | --- | --- | | `quota` (default) | If no active account exists, choose the lowest-usage eligible account across 5-hour, weekly, and 30-day windows. Otherwise retain an eligible active account below `autoSwitchThreshold`; after it crosses the threshold, an unbound request can move to a lower-usage eligible account. Bound tasks keep cache affinity by default and stay until the account is exhausted (known usage at 100%) or cannot serve (paused, unusable); a move then requires genuine quota headroom and strictly lower usage on the destination, so the task stays put when every account is over the threshold or the only cooler account has unknown usage. Set `pool.cacheAffinity: false` to let a bound task's next request move at the threshold, still only onto such a destination. `0` disables this usage-driven re-evaluation, not failure recovery. | -| `round-robin` | Evenly assign unbound requests across eligible accounts. `autoSwitchThreshold` does not change normal round-robin selection. `accountPoolStickyLimit` (1–100) counts assignments on one pick, not successful upstream responses. | -| `fill-first` | Assign unbound requests to the active account until cooldown, reauthentication, or the configured drain threshold; unknown usage does not force a switch. Healthy bound tasks keep affinity. | +| `round-robin` | Evenly assign unbound requests across eligible accounts. Rotation is counter-based and ignores usage thresholds, but the shared priority-tier filter still evaluates each account's headroom using its effective threshold (account override or global default). `accountPoolStickyLimit` (1–100) counts assignments on one pick, not successful upstream responses. | +| `fill-first` | Assign unbound requests to the active account until cooldown, reauthentication, or that account's effective drain threshold (account override or global default); unknown usage does not force a switch. Healthy bound tasks keep affinity. | Rotation does not protect against provider enforcement; multi-account use may violate provider terms. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 52c0c2cdb9c..c44954400b1 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -553,7 +553,7 @@ manager. Its routes are: | `PUT /api/settings` with `codexQuotaAutoRefresh: { id, window, enabled }` | Enable or disable 5-hour or weekly automatic window activation for one account | 400 invalid id/window/state; 404 missing account; 409 unavailable window | | `POST /api/codex-auth/accounts/clear-cooldown` | Clear runtime cooldown for one account or all accounts | 400 invalid id | | `GET, PUT /api/codex-auth/active` | Read or select the active account | 400 invalid or missing account; 409 paused/legacy-row conflict | -| `PUT /api/codex-auth/auto-switch` | Set the quota threshold for automatic account switching | 400 invalid threshold | +| `PUT /api/codex-auth/auto-switch` | Set the global quota threshold with `{ threshold }`, or an account override with `{ id, threshold }`; `null` restores global inheritance, and `__main__` selects the Desktop login | 400 invalid id/threshold; 404 missing account | | `PUT, PATCH /api/codex-auth/pool-strategy` | Update Codex account-pool selection strategy | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | | `GET /api/codex-auth/quota` | Read cached quota state by account | — | 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 35de1cf8541..62fe92c5fb6 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -36,8 +36,9 @@ ocx models provider openrouter on | `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Когда строки picker'а с указанием аккаунта включены, каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | | `codexAccountPickerEnabled?` | `boolean` | выкл. при пустой map | Управляет созданием account-qualified строк picker'а Codex из подходящих сопоставлений `codexAccountNamespaces`. `true` разрешает показывать сопоставленные строки. Если поле не задано при непустой map, функция считается включённой для обратной совместимости; при пустой map она выключена. `false` скрывает созданные строки и возвращает bare native-строки в picker, не удаляя сопоставления и не отключая точную маршрутизацию `/`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | -| `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты превысили `autoSwitchThreshold`, находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. | +| `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты достигли собственного ненулевого эффективного порога (переопределение аккаунта или глобальное значение), находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос. Привязанная задача по умолчанию (`pool.cacheAffinity`) сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос, и переносится только на аккаунт со строго меньшим usage и реальным запасом квоты. Установите `pool.cacheAffinity: false`, чтобы повторно оценивать привязанные задачи на этом пороге. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | Переопределения `autoSwitchThreshold` по аккаунтам: id → целое число `0`–`100`. Без записи наследуется глобальное значение; `0` отключает только переключения по использованию с этого аккаунта. Поддерживается основной аккаунт `__main__`. Управляется в карточке аккаунта на странице Codex Auth. Включение переопределения копирует текущий глобальный порог в фиксированное значение аккаунта. Переопределение, включая `0`, имеет приоритет и после изменения глобального порога. Отключение отправляет `threshold: null`, удаляет запись и восстанавливает наследование текущего глобального порога и его будущих изменений. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Привязанные задачи следуют `pool.cacheAffinity` (по умолчанию включён): они остаются, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос, и тогда переносятся только на аккаунт со строго меньшим usage и реальным запасом квоты. Установите флаг в `false`, чтобы на пороге проактивно перепривязать задачу к подходящему аккаунту с меньшим usage, всё равно только при той же проверке назначения. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | | `pool.cacheAffinity?` | `boolean` | `true` | Порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию включён; пропуск ключа или `true` сохраняет привязку, а любое значение кроме `false` читается как включённое. Живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold` — перенос живого разговора сбрасывает изолированный по аккаунту prompt cache. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден, исключён планом, с недействительными credential'ами, со сменённой generation, с истёкшим TTL, при отказе квоты 429/402 или реально исчерпан (известный usage 100%) — и только на аккаунт со строго меньшим usage и реальным запасом квоты. Аккаунт с неизвестным usage никогда не выбирается как назначение для привязанной задачи. Если все аккаунты выше порога, привязанная задача остаётся: лучшего назначения нет. `false` возвращает перепривязку по порогу, всё равно только при той же проверке назначения. Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | @@ -47,6 +48,8 @@ ocx models provider openrouter on | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Политика prompt-cache Anthropic: отключено, 5-минутный ephemeral или 1-часовой extended. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Необязательная политика proactive OAuth refresh и warmup'а аккаунтов Codex. | +Во всех этих стратегиях эффективный порог аккаунта берётся из `codexAccountAutoSwitchThresholds`, если запись есть, иначе из глобального `autoSwitchThreshold`. Нулевое переопределение отключает только проактивное переключение по использованию; привязка при запуске, hard-lock, cooldown, проверка доступа к модели и восстановление после сбоев остаются обязательными. + Имена селекторов — выбранные пользователем публичные метки; opencodex не придаёт им семантики ролей аккаунтов. Ключи `codexAccountNamespaces` имеют длину 1–64 символа. Они должны начинаться и заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные @@ -221,7 +224,7 @@ redirect'ов для обычных provider-request'ов реализована **Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Привязанные задачи по умолчанию держат cache affinity и остаются, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос; перенос тогда требует реального запаса квоты и строго меньшего usage на назначении. Установите `pool.cacheAffinity: false`, чтобы следующий запрос привязанной задачи мог уйти на пороге, всё равно только на подходящий аккаунт с меньшим usage и реальным запасом квоты. `round-robin` равномерно распределяет непривязанные запросы, а порог не меняет обычную ротацию. `accountPoolStickyLimit` (по умолчанию `1`, 1–100) считает назначения/bind, а не успешные ответы. `fill-first` назначает непривязанные запросы активному аккаунту до cooldown, -reauth или порога исчерпания; здоровые привязанные задачи сохраняют affinity. Эти стратегии не +reauth или эффективного порога исчерпания этого аккаунта (переопределение аккаунта или глобальное значение); здоровые привязанные задачи сохраняют affinity. Эти стратегии не защищают от enforcement провайдера. ### `anthropicAccountPool` (experimental) diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 793b6e9a612..2879e43ccd4 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -329,7 +329,7 @@ picker изменилась. `catalogRefreshPending: true` в успешном | `PUT /api/codex-auth/accounts/pause-exhausted` | Поставить на паузу аккаунты с исчерпанной квотой | Сбои mutation-lock превращаются в 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | Очистить runtime cooldown для одного аккаунта или для всех | 400 invalid id | | `GET, PUT /api/codex-auth/active` | Прочитать или выбрать активный аккаунт | 400 invalid or missing account; 409 paused/legacy-row conflict | -| `PUT /api/codex-auth/auto-switch` | Задать порог квоты для автоматического переключения аккаунтов | 400 invalid threshold | +| `PUT /api/codex-auth/auto-switch` | Задать глобальный порог через `{ threshold }` без `id` или переопределение аккаунта через `{ id, threshold }`; `id: '__main__'` выбирает аккаунт Codex Desktop. При указанном `id` значение `threshold: null` удаляет переопределение и восстанавливает наследование глобального порога | 400 invalid id/threshold; 404 missing account | | `PUT, PATCH /api/codex-auth/pool-strategy` | Обновить стратегию выбора в пуле аккаунтов Codex | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Задать порог failover аккаунтов | 400 invalid threshold | | `GET /api/codex-auth/quota` | Прочитать кэшированное состояние квоты по аккаунтам | — | 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 1fbe43c91d8..225c98827aa 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -36,9 +36,10 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `codexAccountNamespaces?` | `Record` | — | İsteğe bağlı olarak rastgele bir genel model seçiciden saklanan bir Codex hesap hedefine eşleme. Hesap nitelikli seçici satırları etkinleştirildiğinde, hedefi mevcut olan her seçici, Codex seçicisine ayrı `/` satırları ekler; her satır yalnızca o hesabı kullanır. Herhangi bir seçici etkinken, yalın yerel satırlar seçicide gizlenir, ancak açıkça devre dışı bırakılmadıkça kimlikleri yönlendirilebilir kalır ve ham `/v1/models` tarafından listelenir. | | `codexAccountPickerEnabled?` | `boolean` | harita boşken kapalı | Uygun `codexAccountNamespaces` eşlemelerinin hesap nitelikli Codex seçici satırları oluşturup oluşturmayacağını denetler. `true`, eşlenen satırların görünmesine izin verir. Boş olmayan bir haritayla atlanırsa, geriye dönük uyumluluk için etkin olarak değerlendirilir; harita boşsa kapalıdır. `false`, eşlemeleri silmeden veya tam `/` yönlendirmesini devre dışı bırakmadan oluşturulan satırları gizler ve yalın yerel seçici satırlarını geri yükler. | | `activeCodexAccountId?` | `string` | — | Sonraki istek için manuel olarak seçilen Havuz hesabı. Seçim iş parçacığı bağlılığını temizler; devam eden istekler yakalanan kimlik bilgilerini korur. | -| `codexAccountPriorities?` | `Record` | — | Codex havuzu için hesap başına seçim sırası: hesap kimliği → `-100` ile `100` arası tam sayı, **daha yüksek olan daha önce kullanılır**, yoksa `0` anlamına gelir. Bu bir öncelik sırası sınırıdır, bir uygunluk sınırı değildir: seçim, zaten uygun olan hesapları hala kota payı bulunan en yüksek katmana daraltır ve `accountPoolStrategy` daha sonra bu katman içinde seçim yapar. Bir katman, yalnızca her üye `autoSwitchThreshold` üzerinde olduğunda, soğumada olduğunda, yumuşak kaçınıldığında, duraklatıldığında veya yeniden kimlik doğrulama gerektiğinde atlanır — bilinmeyen kota asla bir katmanı boşaltmaz. Sıralama asla uygun olmayan bir hesabı seçilebilir yapmaz ve zaten bir hesabı olan bir iş parçacığını asla yeniden bağlamaz. Ana `__main__` hesap eşit şartlarda katılır, bu sayede Codex Desktop girişi en son tükenecek şekilde ayarlanabilir. Hiçbir girdi olmadığında havuz tam olarak eskisi gibi davranır. Hatalı biçimlendirilmiş bir harita bir konsol uyarısıyla yok sayılır (sıralama kapalı, yapılandırma onarımı yok). `ocx account priority` ve Codex Auth sayfası tarafından yönetilir. | +| `codexAccountPriorities?` | `Record` | — | Codex havuzu için hesap başına seçim sırası: hesap kimliği → `-100` ile `100` arası tam sayı, **daha yüksek olan daha önce kullanılır**, yoksa `0` anlamına gelir. Bu bir öncelik sırası sınırıdır, bir uygunluk sınırı değildir: seçim, zaten uygun olan hesapları hala kota payı bulunan en yüksek katmana daraltır ve `accountPoolStrategy` daha sonra bu katman içinde seçim yapar. Bir katman, yalnızca her üye sıfırdan farklı kendi etkin eşiğine (hesaba özel değer veya genel varsayılan) ulaştığında, soğumada olduğunda, yumuşak kaçınıldığında, duraklatıldığında veya yeniden kimlik doğrulama gerektiğinde atlanır — bilinmeyen kota asla bir katmanı boşaltmaz. Sıralama asla uygun olmayan bir hesabı seçilebilir yapmaz ve zaten bir hesabı olan bir iş parçacığını asla yeniden bağlamaz. Ana `__main__` hesap eşit şartlarda katılır, bu sayede Codex Desktop girişi en son tükenecek şekilde ayarlanabilir. Hiçbir girdi olmadığında havuz tam olarak eskisi gibi davranır. Hatalı biçimlendirilmiş bir harita bir konsol uyarısıyla yok sayılır (sıralama kapalı, yapılandırma onarımı yok). `ocx account priority` ve Codex Auth sayfası tarafından yönetilir. | | `activeCodexAccountPinned?` | `string` | — | Operatörün en son elle seçtiği hesap kimliği. Ayarlandığı sürece, pin tükenme, hariç tutma, silme veya açık bir yük devretme/yükseltme ile serbest bırakılana kadar daha yüksek bir `codexAccountPriorities` katmanı onu öncelikleyemez. Sınırlı katman içindeki sıradan round-robin hareketi onu serbest bırakmaz. Herhangi bir `codexAccountPriorities` girdisi yazmak da pini serbest bırakır, böylece bir sıra var olmadan önce yapılan bir pin daha sonra ayarlanan bir pinin önüne geçemez. `GET /api/codex-auth/active`, hem geçerli hesabın sabitlenip sabitlenmediğini (`pinned`) hem de tavanı taşıyan hesabı (`pinnedAccountId`) bildirir. | | `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir. Bağlı görevler varsayılan olarak (`pool.cacheAffinity`) eşiğin ötesinde hesabını korur; hesap tükenene veya hizmet veremez hale gelene kadar kalır ve o zaman yalnızca kullanımı kesin olarak daha düşük ve gerçek kota payı olan bir hesaba geçer. `pool.cacheAffinity: false` ile bağlı görevler de bu eşikte yeniden değerlendirilir. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | Hesap başına `autoSwitchThreshold` geçersiz kılmaları: hesap kimliği → `0`–`100` tam sayısı. Kayıt yoksa genel değer devralınır; `0` yalnızca bu hesaptan kullanıma dayalı geçişi kapatır. Ana `__main__` hesabı desteklenir. Codex Auth hesap kartından yönetilir. Hesaba özel değeri etkinleştirmek, mevcut genel eşiği sabit bir hesap değerine kopyalar. `0` dahil bu değer, genel eşik daha sonra değişse de önceliğini korur. Devre dışı bırakmak `threshold: null` gönderir, kaydı siler ve mevcut genel eşikle gelecekteki değişikliklerini devralmayı yeniden etkinleştirir. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. Bağlı görevler `pool.cacheAffinity`'yi izler (varsayılan açık): hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır ve o zaman yalnızca kullanımı kesin olarak daha düşük ve gerçek kota payı olan bir hesaba geçer. Bayrağı `false` yapınca bağlı bir görev eşiğinde proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlanabilir; hedef kısıtı yine geçerlidir. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. `reset-first`: Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır. Aylık sıfırlamalar bu sıralamayı belirlemez. | | `pool.cacheAffinity?` | `boolean` | `true` | Bağlı Codex iş parçacıkları için önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak açıktır; anahtarı atlamak veya `true` vermek bağlamayı korur ve `false` dışındaki bir değer açık okunur. Canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz, çünkü canlı bir konuşmayı taşımak hesaba özel istem önbelleğini atar. Hesap duraklatılmış, kullanılamaz, plandan dışlanmış, kimlik bilgisi geçersiz, generation değişmiş, TTL dolmuş, 429/402 kota reddi almış veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır ve yalnızca kullanımı kesin olarak daha düşük ve gerçek kota payı olan bir hesaba geçer. Kullanımı bilinmeyen bir hesap bağlı bir görev için hedef olarak asla seçilmez. Tüm hesaplar eşiğin üzerindeyse bağlı görev yerinde kalır; daha iyi bir hedef yoktur. `false` eşiğe göre yeniden bağlamayı geri getirir; hedef kısıtı yine geçerlidir. Bağlılık bir sabitleme değil yeniden sıralamadır. | | `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. | @@ -48,6 +49,8 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic istem önbelleği politikası: devre dışı, 5 dakikalık kısa ömürlü veya 1 saatlik uzatılmış. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | kapalı | İsteğe bağlı proaktif OAuth yenileme ve Codex hesabı ısınma politikası. | +Bu stratejilerde her hesabın etkin eşiği, varsa `codexAccountAutoSwitchThresholds` girdisi, yoksa genel `autoSwitchThreshold` değeridir. Sıfır yalnızca kullanıma dayalı proaktif geçişi kapatır; başlangıç bağlaması, kesin kilit, bekleme süresi, model erişim denetimleri ve hata kurtarma uygulanmaya devam eder. + Seçici adları kullanıcı tarafından seçilen genel etiketlerdir; opencodex bunlara hiçbir hesap rolü anlambilimi atamaz. `codexAccountNamespaces` anahtarları 1–64 karakterdir, başında ve sonunda bir ASCII harf veya rakam bulunur, içinde @@ -236,8 +239,8 @@ kalır. | Strateji | Davranış | | --- | --- | | `quota` (varsayılan) | Aktif bir hesap yoksa 5 saatlik, haftalık ve 30 günlük pencerelerde en düşük kullanımlı uygun hesabı seçin. Aksi takdirde `autoSwitchThreshold` altında uygun bir aktif hesabı tutun; eşiği aştıktan sonra bağımsız bir istek daha düşük kullanımlı uygun bir hesaba geçebilir. Bağlı görevler varsayılan olarak önbellek bağlılığını korur ve hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene (duraklatılmış, kullanılamaz) kadar kalır; bir geçiş o zaman hedefte gerçek kota payı ve kesin olarak daha düşük kullanım ister. `pool.cacheAffinity: false` ile bağlı bir görevin bir sonraki isteği eşikte hareket edebilir, yine yalnızca gerçek kota payı olan daha düşük kullanımlı uygun bir hesaba. `0`, bu kullanım odaklı yeniden değerlendirmeyi devre dışı bırakır, arıza kurtarmayı devre dışı bırakmaz. | -| `round-robin` | Bağımsız istekleri uygun hesaplar arasında eşit olarak atayın. `autoSwitchThreshold` normal round-robin seçimini değiştirmez. `accountPoolStickyLimit` (1–100), başarılı yukarı akış yanıtlarını değil, bir seçimdeki atamaları sayar. | -| `fill-first` | Bağımsız istekleri soğuma, yeniden kimlik doğrulama veya yapılandırılmış tükenme eşiğine kadar aktif hesaba atayın; bilinmeyen kullanım geçişe zorlamaz. Sağlıklı bağlı görevler bağlılığı korur. | +| `round-robin` | Bağımsız istekleri uygun hesaplar arasında eşit olarak atayın. Dönüş sayaç tabanlıdır ve kullanım eşiklerini dikkate almaz; ancak ortak öncelik katmanı filtresi her hesabın kota payını kendi etkin eşiğine (hesaba özel değer veya genel varsayılan) göre değerlendirir. `accountPoolStickyLimit` (1–100), başarılı yukarı akış yanıtlarını değil, bir seçimdeki atamaları sayar. | +| `fill-first` | Bağımsız istekleri soğuma, yeniden kimlik doğrulama veya bu hesabın etkin tükenme eşiğine (hesaba özel değer veya genel varsayılan) kadar aktif hesaba atayın; bilinmeyen kullanım geçişe zorlamaz. Sağlıklı bağlı görevler bağlılığı korur. | Rotasyon, sağlayıcı yaptırımlarına karşı koruma sağlamaz; çoklu hesap kullanımı sağlayıcı şartlarını ihlal edebilir. diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index 63beca196dc..7e48c55dda5 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -351,7 +351,7 @@ devreder. Rotaları şunlardır: | `PUT /api/codex-auth/accounts/pause-exhausted` | Kotası tükenen hesapları duraklatın | Mutasyon kilidi arızaları 503 olur | | `POST /api/codex-auth/accounts/clear-cooldown` | Bir hesap veya tüm hesaplar için çalışma zamanı soğuma süresini temizleyin | 400 geçersiz kimlik | | `GET, PUT /api/codex-auth/active` | Aktif hesabı okuyun veya seçin | 400 geçersiz veya eksik hesap; 409 duraklatılmış/eski satır çakışması | -| `PUT /api/codex-auth/auto-switch` | Otomatik hesap geçişi için kota eşiğini ayarlayın | 400 geçersiz eşik | +| `PUT /api/codex-auth/auto-switch` | `id` olmadan `{ threshold }` ile genel eşiği, `{ id, threshold }` ile hesaba özel eşiği ayarlayın; `id: '__main__'` Codex Desktop hesabını seçer. `id` belirtilmişken `threshold: null` hesaba özel değeri kaldırır ve genel eşikten kalıtımı geri yükler | 400 geçersiz kimlik/eşik; 404 eksik hesap | | `PUT, PATCH /api/codex-auth/pool-strategy` | Codex hesap havuzu seçim stratejisini güncelleyin | 400 geçersiz strateji/yapılandırma | | `PUT /api/codex-auth/failover` | Hesap yük devretme eşiğini ayarlayın | 400 geçersiz eşik | | `GET /api/codex-auth/quota` | Hesaba göre önbelleğe alınmış kota durumunu okuyun | — | 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 85a12fa08ba..79b342887c8 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 @@ -35,8 +35,9 @@ ocx models provider openrouter on | `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。启用账户限定的选择器行后,target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | | `codexAccountPickerEnabled?` | `boolean` | 映射为空时关闭 | 控制是否根据有效的 `codexAccountNamespaces` 映射生成账户限定的 Codex 选择器行。`true` 允许显示映射行。在非空映射中省略此字段时,为保持向后兼容会视为已启用;映射为空时则关闭。`false` 会隐藏生成行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | -| `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都超过 `autoSwitchThreshold`、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 | +| `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都达到各自非零的有效阈值(账号覆盖值,未设置则使用全局值)、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务。已绑定任务默认(`pool.cacheAffinity`)在越过阈值后仍保留账号,直到该账号耗尽或无法继续服务,并且只改绑到确有额度余量且 usage 严格更低的账号。将 `pool.cacheAffinity` 设为 `false` 才会在该阈值重新评估已绑定任务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | 各账号对 `autoSwitchThreshold` 的覆盖:账号 ID → `0`–`100` 的整数。没有条目时继承全局值;`0` 只关闭从该账号发起的基于用量的切换。支持主账号 `__main__`。可在 Codex Auth 的账号卡片中管理。 启用覆盖时,会将当前全局阈值复制为固定的账号值。覆盖值(包括 `0`)在之后修改全局阈值时仍优先。禁用时发送 `threshold: null`,删除条目,并恢复继承当前全局阈值及其未来的更改。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号。已绑定任务默认会保留到账号耗尽(已知 usage 为 100%)或无法继续服务,改绑时只前往确有额度余量且 usage 严格更低的账号。关闭该标志后,也可在该阈值把已绑定任务的下一次请求改绑到确有额度余量且 usage 严格更低的账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | | `pool.cacheAffinity?` | `boolean` | `true` | 已绑定 Codex 线程的 cache-affinity 排序,独立于 `pool.kernel`。默认开启;省略该键或设为 `true` 即为开启,非法值视为开启。live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,且只改绑到确有额度余量且 usage 严格更低的账号。用量未知的账号不会作为已绑定任务的改绑目标。设为 `false` 可恢复按阈值改绑。affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | @@ -46,6 +47,8 @@ ocx models provider openrouter on | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 提示缓存策略:禁用、5 分钟临时缓存,或 1 小时扩展缓存。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的主动 OAuth 刷新与 Codex 账户预热策略。 | +这些策略均使用各账户的有效阈值:存在 `codexAccountAutoSwitchThresholds` 条目时使用该值,否则继承全局 `autoSwitchThreshold`。覆盖值为 0 仅关闭基于用量的主动切换;启动绑定、硬锁、冷却、模型使用资格检查和故障恢复仍然生效。 + selector 名称是用户自定的公开 label;opencodex 不会为其赋予账户角色语义。 `codexAccountNamespaces` 的 key 长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, 中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index cc4396173f6..ec092ddafb6 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -288,7 +288,7 @@ OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上 | `PUT /api/codex-auth/accounts/pause-exhausted` | 暂停配额已耗尽的账户 | 变更锁失败会变成 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | 清除一个账户或所有账户的运行时冷却 | 400 id 无效 | | `GET, PUT /api/codex-auth/active` | 读取或选择当前活跃账户 | 400 账户无效或缺失;409 暂停/旧行冲突 | -| `PUT /api/codex-auth/auto-switch` | 设置自动切换账户的配额阈值 | 400 阈值无效 | +| `PUT /api/codex-auth/auto-switch` | 使用不含 `id` 的 `{ threshold }` 设置全局阈值,或使用 `{ id, threshold }` 设置账号覆盖值;`id: '__main__'` 选择 Codex Desktop 账号。指定 `id` 时,`threshold: null` 删除该账号的覆盖值并恢复继承全局阈值 | 400 ID/阈值无效;404 账号不存在 | | `PUT, PATCH /api/codex-auth/pool-strategy` | 更新 Codex 账户池选择策略 | 400 策略/配置无效 | | `PUT /api/codex-auth/failover` | 设置账户故障转移阈值 | 400 阈值无效 | | `GET /api/codex-auth/quota` | 按账户读取缓存的配额状态 | — | 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 cdbd005d00f..8dceb76fbd5 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 @@ -35,6 +35,7 @@ ocx models provider openrouter on | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | | `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務。綁定任務預設(`pool.cacheAffinity`)在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務,並且只改綁到確有額度餘裕且用量嚴格更低的帳號。將 `pool.cacheAffinity` 設為 `false` 才會在此閾值重新評估綁定任務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | 各帳號對 `autoSwitchThreshold` 的覆寫:帳號 ID → `0`–`100` 的整數。沒有項目時繼承全域值;`0` 只停用從該帳號發起的使用量主動切換。支援主帳號 `__main__`。可在 Codex Auth 的帳號卡片中管理。 啟用覆寫時,會將目前全域閾值複製為固定的帳號值。覆寫值(包括 `0`)在之後修改全域閾值時仍優先。停用時傳送 `threshold: null`,刪除項目,並恢復繼承目前全域閾值及其未來的變更。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號。綁定任務預設會保留到帳號耗盡(已知用量 100%)或無法繼續服務,改綁時只前往確有額度餘裕且用量嚴格更低的帳號。關閉該設定後,也可在閾值將綁定任務的下一個請求改綁到確有額度餘裕且用量嚴格更低的帳號。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | | `pool.cacheAffinity?` | `boolean` | `true` | 綁定 Codex 執行緒的 cache-affinity 排序,獨立於 `pool.kernel`。預設開啟;省略該鍵或設為 `true` 即為開啟,格式錯誤視為開啟。即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,且只改綁到確有額度餘裕且用量嚴格更低的帳號。用量未知的帳號不會作為綁定任務的改綁目標。設為 `false` 可恢復依閾值重新綁定。親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | @@ -43,6 +44,8 @@ ocx models provider openrouter on | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache 政策:停用、5 分鐘臨時或 1 小時延長。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 可選的主動 OAuth refresh 與 Codex 帳號暖機政策。 | +這些策略均使用各帳戶的有效閾值:存在 `codexAccountAutoSwitchThresholds` 項目時使用該值,否則繼承全域 `autoSwitchThreshold`。覆寫值為 0 僅關閉基於用量的主動切換;啟動綁定、硬鎖、冷卻、模型使用資格檢查和故障復原仍然生效。 + `codexAccountNamespaces` key 是公開選擇器:1–64 字元,以 ASCII 字母或數字開頭與結尾,中間為字母、數字、`.`、`_` 或 `-`。保留的 JavaScript 物件名稱被拒絕。每個值是有效的池帳號 id(絕非內部 `__main__`)或代表 Codex Desktop 帳號的 `"@main"`。供應商與保留的 `openai` / `combo` 衝突以不區分大小寫方式檢查。保持原始帳號 id 與電子郵件私密;選擇器是公開名稱。 ## 保留的 OpenAI 供應商 @@ -146,8 +149,8 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | 策略 | 行為 | | --- | --- | | `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求可移至較低用量的合格帳號。預設下 cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務,改綁時只前往確有額度餘裕且用量嚴格更低的帳號。關閉該設定後,也可在閾值將綁定任務的下一個請求改綁到確有額度餘裕且用量嚴格更低的帳號。`0` 停用此用量驅動的重新評估,而非失敗復原。 | -| `round-robin` | 在合格帳號間均勻指派未綁定請求。`autoSwitchThreshold` 不變更一般 round-robin 選擇。`accountPoolStickyLimit`(1–100)計數一次選擇上的指派,而非成功的上游回應。 | -| `fill-first` | 將未綁定請求指派到現用帳號直到冷卻、重新認證或設定的排空閾值;未知用量不強制切換。健康的綁定任務保留親和性。 | +| `round-robin` | 在合格帳號間均勻指派未綁定請求。輪替本身以計數器為基礎,不使用用量閾值,但共用的優先順序層級篩選仍依各帳號自身的有效閾值(帳號覆寫值,未設定則使用全域值)檢查餘裕。`accountPoolStickyLimit`(1–100)計數一次選擇上的指派,而非成功的上游回應。 | +| `fill-first` | 將未綁定請求指派到現用帳號直到冷卻、重新認證或該帳號的有效排空閾值(帳號覆寫值,未設定則使用全域值);未知用量不強制切換。健康的綁定任務保留親和性。 | 輪換不保護免於供應商強制執行;多帳號使用可能違反供應商條款。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 10094e71816..3ee8223f192 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -277,7 +277,7 @@ OpenAI 也遵循此規則:開關不會選擇特殊的 922k 模式。生效中 | `PUT /api/codex-auth/accounts/pause-exhausted` | 暫停配額耗盡的帳號 | 變更鎖失敗變為 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | 清除一個或所有帳號的 runtime 冷卻 | 400 無效 id | | `GET, PUT /api/codex-auth/active` | 讀取或選擇現用帳號 | 400 無效或缺失帳號;409 暫停/舊列衝突 | -| `PUT /api/codex-auth/auto-switch` | 設定自動帳號切換的配額閾值 | 400 無效閾值 | +| `PUT /api/codex-auth/auto-switch` | 使用不含 `id` 的 `{ threshold }` 設定全域閾值,或使用 `{ id, threshold }` 設定帳號覆寫值;`id: '__main__'` 指定 Codex Desktop 帳號。指定 `id` 時,`threshold: null` 刪除該帳號的覆寫值並恢復繼承全域閾值 | 400 ID/閾值無效;404 帳號不存在 | | `PUT, PATCH /api/codex-auth/pool-strategy` | 更新 Codex 帳號池選擇策略 | 400 無效策略/設定 | | `PUT /api/codex-auth/failover` | 設定帳號容錯移轉閾值 | 400 無效閾值 | | `GET /api/codex-auth/quota` | 依帳號讀取快取配額狀態 | — | diff --git a/gui/src/components/AccountAutoSwitchControl.tsx b/gui/src/components/AccountAutoSwitchControl.tsx new file mode 100644 index 00000000000..6f0343bdb44 --- /dev/null +++ b/gui/src/components/AccountAutoSwitchControl.tsx @@ -0,0 +1,165 @@ +import { useId, useRef, useState } from "react"; +import { clampNumberDraft } from "../clamp-draft"; +import { useT } from "../i18n/shared"; +import { NumberStepper } from "./NumberStepper"; +import "./account-auto-switch-control.css"; + +export interface AccountAutoSwitchControlProps { + accountLabel: string; + globalThreshold: number; + override: number | null; + disabled?: boolean; + inputId: string; + onChange(threshold: number | null): Promise; +} + +/** Compact account-card override for global usage-driven switching threshold. */ +export default function AccountAutoSwitchControl({ + accountLabel, + globalThreshold, + override, + disabled = false, + inputId, + onChange, +}: AccountAutoSwitchControlProps) { + const t = useT(); + const togglePointerIntentRef = useRef(false); + const pendingRef = useRef(false); + const [saving, setSaving] = useState(false); + const enabled = override !== null; + const [editor, setEditor] = useState({ override, draft: String(override ?? globalThreshold) }); + // Only an account override change replaces the draft. Global refreshes must not + // erase an unfinished custom edit, and neither update should replace focused DOM. + if (editor.override !== override) { + setEditor({ override, draft: String(override ?? globalThreshold) }); + } + const { draft } = editor; + const setDraft = (value: string) => setEditor(current => ({ ...current, draft: value })); + const resetDraft = () => setEditor(current => ({ + ...current, + draft: String(current.override ?? globalThreshold), + })); + const blocked = disabled || saving; + const hint = t("accountPool.autoSwitchHint"); + const hintId = useId(); + + const write = async (next: number | null) => { + if (disabled || pendingRef.current) return; + pendingRef.current = true; + setSaving(true); + try { + await onChange(next); + } finally { + // The controller owns acceptance. Reconcile even when a rejected toggle + // leaves the override unchanged (including zero), without falling back to global. + resetDraft(); + pendingRef.current = false; + setSaving(false); + } + }; + + const commit = async () => { + if (!enabled || disabled || pendingRef.current) return; + const trimmed = draft.trim(); + const parsed = trimmed === "" ? Number.NaN : Number(trimmed); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + resetDraft(); + return; + } + if (parsed === override) return; + await write(parsed); + }; + + const step = (delta: -1 | 1) => { + if (disabled || pendingRef.current) return; + const nextDraft = clampNumberDraft(draft, delta, 0, 100); + setDraft(nextDraft); + const next = Number(nextDraft); + if (next !== override) { + void write(next); + } + }; + + return ( +
{ + if (event.currentTarget.contains(event.relatedTarget)) return; + if (togglePointerIntentRef.current) { + togglePointerIntentRef.current = false; + return; + } + void commit(); + }} + > + + {enabled && ( + + { if (!blocked) setDraft(event.target.value); }} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || blocked) return; + if (event.key === "Enter") { + event.preventDefault(); + void commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + resetDraft(); + } + }} + /> + + step(1)} + onDecrement={() => step(-1)} + /> + + )} + + {hint} +
+ ); +} diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 2b30f20194f..73f7b4f356c 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -74,7 +74,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // but stays inert (no load, no polling) whenever a shared controller was injected. const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; - const { accounts, activeId, loadState, refreshFailed, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; + const { accounts, activeId, loadState, refreshFailed, switchingId, pauseUpdatingId, priorityUpdatingId, autoSwitchUpdatingId, pausingExhausted, activePinnedId, load } = controller; // #3898: the native-main device reauth drives the dedicated namespace; a // completed flow refreshes the account list so the card leaves reauth state. const mainReauth = useMainDeviceReauth(apiBase, () => { void load(); }); @@ -273,6 +273,21 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban }), result.ok ? "ok" : "err"); }; + const changeAccountAutoSwitchThreshold = async ( + account: CodexAccountEntry, + threshold: number | null, + ) => { + if (threshold === account.autoSwitchThresholdOverride) return true; + const result = await controller.setAccountAutoSwitchThreshold(account.id, threshold); + if (!result.ok && result.reason === "busy") return false; + showActionFeedback(t(result.ok + ? "accountPool.autoSwitchUpdated" + : "accountPool.autoSwitchUpdateFailed", { + email: account.alias ?? account.email, + }), result.ok ? "ok" : "err"); + return result.ok; + }; + const remove = async (id: string) => { const label = accounts.find(account => account.id === id)?.email ?? t("pws.accountOrdinal", { count: "1" }); if (!(await confirmAction({ message: t("codexAuth.removeConfirm", { id: label }), confirmLabel: t("common.remove"), tone: "danger" }))) return; @@ -442,7 +457,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const isMainActive = !main?.paused && (!activeId || activeId === "__main__"); const switchActionLabel = t(accountModeState === "direct" ? "codexAuth.prepareForPool" : "codexAuth.setAsNext"); const pauseBusy = pauseUpdatingId !== null || pausingExhausted; - const autoSwitchThreshold = autoSwitch.threshold ?? 0; + const autoSwitchThreshold = autoSwitch.threshold; + const accountAutoSwitchDisabled = !autoSwitch.hydrated || autoSwitchUpdatingId !== null; // The standalone Codex Auth page keeps the doctor-copy affordance; the embedded // Providers workspace account surface does not. const showDoctorCopy = !embedded; @@ -503,6 +519,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban pauseBusy={pauseBusy} onPriorityChange={(entry, priority) => { void changePriority(entry, priority); }} priorityUpdatingId={priorityUpdatingId} + onAutoSwitchThresholdChange={changeAccountAutoSwitchThreshold} + autoSwitchDisabled={accountAutoSwitchDisabled} switchingId={switchingId} pinnedId={activePinnedId} onOpenReset={openResetPopup} @@ -545,6 +563,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban pauseBusy={pauseBusy} onPriorityChange={(entry, priority) => { void changePriority(entry, priority); }} priorityUpdatingId={priorityUpdatingId} + onAutoSwitchThresholdChange={changeAccountAutoSwitchThreshold} + autoSwitchDisabled={accountAutoSwitchDisabled} switchingId={switchingId} pinnedId={activePinnedId} onReauth={openReauth} diff --git a/gui/src/components/account-auto-switch-control.css b/gui/src/components/account-auto-switch-control.css new file mode 100644 index 00000000000..df2f64f85db --- /dev/null +++ b/gui/src/components/account-auto-switch-control.css @@ -0,0 +1,8 @@ +.codex-account-controls { display: flex; align-items: center; justify-content: flex-end; gap: 12px; flex-wrap: wrap; min-width: 0; } +.codex-account-auto-switch { display: flex; align-items: center; gap: 7px; min-width: 0; flex: 0 0 auto; } +.codex-account-auto-switch-label { font-size: var(--text-label); color: var(--muted); font-weight: var(--weight-medium); white-space: nowrap; } +.codex-account-auto-switch-input-wrap { display: inline-flex; align-items: stretch; position: relative; } +.codex-account-auto-switch-input { width: 76px; height: 30px; padding: 4px 38px 4px 8px; font-size: var(--text-label); text-align: right; } +.codex-account-auto-switch-unit { position: absolute; right: 25px; top: 50%; transform: translateY(-50%); color: var(--muted); font-size: var(--text-label); pointer-events: none; } +.codex-account-auto-switch-input-wrap .ocx-stepper { width: 22px; } +.codex-account-auto-switch-toggle { transform: scale(.8); transform-origin: center; margin-inline: -3px; } diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 72da4b774c7..9231d62094c 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -5,6 +5,7 @@ import { IconAlert, IconPause, IconPlay, IconX } from "../icons"; import { displayAccountId } from "../lib/privacy"; import AccountPriorityControl, { AccountPriorityBadge } from "./AccountPriorityControl"; import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "../account-priority"; +import AccountAutoSwitchControl from "./AccountAutoSwitchControl"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import QuotaBars from "./QuotaBars"; @@ -32,6 +33,8 @@ export function CodexAccountPoolCards({ pauseBusy, onPriorityChange, priorityUpdatingId, + onAutoSwitchThresholdChange, + autoSwitchDisabled, switchingId, pinnedId = null, onReauth, @@ -52,6 +55,8 @@ export function CodexAccountPoolCards({ pauseBusy: boolean; onPriorityChange: (account: CodexAccountEntry, priority: number) => void; priorityUpdatingId: string | null; + onAutoSwitchThresholdChange: (account: CodexAccountEntry, threshold: number | null) => Promise; + autoSwitchDisabled: boolean; /** In-flight manual switch, which writes the same pin an order write clears. */ switchingId: string | null; /** @@ -180,19 +185,30 @@ export function CodexAccountPoolCards({
{a.email}{a.plan ? ` · ${a.plan}` : ""}
- {(normalizeAccountPriority(a.priority) !== DEFAULT_ACCOUNT_PRIORITY || moreOpen.has(a.id)) && ( - onPriorityChange(a, priority)} - /> - )} +
+ {(normalizeAccountPriority(a.priority) !== DEFAULT_ACCOUNT_PRIORITY || moreOpen.has(a.id)) && ( + onPriorityChange(a, priority)} + /> + )} + onAutoSwitchThresholdChange(a, next)} + /> +
{healthSummary && (
{healthSummary}
@@ -206,7 +222,7 @@ export function CodexAccountPoolCards({ diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 051424184f5..487c2f910f9 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { IconLock, IconPause, IconPlay, IconPlus, IconRefresh, IconTicket } from "../icons"; import AccountPriorityControl, { AccountPriorityBadge } from "./AccountPriorityControl"; +import AccountAutoSwitchControl from "./AccountAutoSwitchControl"; import QuotaBars from "./QuotaBars"; import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-helpers"; import type { CodexAccountEntry, CodexAccountLoadState } from "./codex-account-pool-types"; @@ -32,6 +33,8 @@ export function CodexAccountPoolMainCard({ pauseBusy, onPriorityChange, priorityUpdatingId, + onAutoSwitchThresholdChange, + autoSwitchDisabled, switchingId, pinnedId = null, onOpenReset, @@ -52,6 +55,8 @@ export function CodexAccountPoolMainCard({ pauseBusy: boolean; onPriorityChange: (entry: CodexAccountEntry, priority: number) => void; priorityUpdatingId: string | null; + onAutoSwitchThresholdChange: (entry: CodexAccountEntry, threshold: number | null) => Promise; + autoSwitchDisabled: boolean; /** In-flight manual switch, which writes the same pin an order write clears. */ switchingId: string | null; /** @@ -80,6 +85,7 @@ export function CodexAccountPoolMainCard({ isMain: true, paused: main?.paused ?? false, priority: main?.priority ?? 0, + autoSwitchThresholdOverride: main?.autoSwitchThresholdOverride ?? null, hasCredential: true, quota: main?.quota ?? null, quotaAutoRefresh: main?.quotaAutoRefresh ?? { @@ -159,19 +165,30 @@ export function CodexAccountPoolMainCard({ {/* The main card keeps its order select inline: it is one control, not one per pool row, and the main card has no ⋯ disclosure to fold it into. */} {main && ( - onPriorityChange(mainSwitchEntry, priority)} - /> +
+ onPriorityChange(mainSwitchEntry, priority)} + /> + onAutoSwitchThresholdChange(mainSwitchEntry, next)} + /> +
)} {policy?.enabled && ( @@ -238,7 +255,7 @@ export function CodexAccountPoolMainCard({ diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index fe4cdaa155b..4599a5b4a61 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -44,6 +44,8 @@ export interface CodexAccountEntry { paused: boolean; /** Selection order; higher is used earlier. Always present, 0 when unset. */ priority: number; + /** Null inherits global threshold; 0 disables usage-driven switching for this account. */ + autoSwitchThresholdOverride: number | null; hasCredential: boolean; quota: AccountQuota | null; quotaAutoRefresh: { @@ -107,6 +109,7 @@ export interface CodexAccountPoolController { switchingId: string | null; pauseUpdatingId: string | null; priorityUpdatingId: string | null; + autoSwitchUpdatingId: string | null; pausingExhausted: boolean; activeNeedsReauth: boolean; /** @@ -121,6 +124,8 @@ export interface CodexAccountPoolController { setAccountPaused(id: string, paused: boolean): Promise; /** `null` resets the account to the default order. Accepts the `__main__` sentinel. */ setAccountPriority(id: string, priority: number | null): Promise; + /** `null` restores global inheritance. Accepts the `__main__` sentinel. */ + setAccountAutoSwitchThreshold(id: string, threshold: number | null): Promise; pauseExhaustedAccounts(): Promise>; saveAlias(id: string, alias: string): Promise; removeAccount(id: string): Promise>; @@ -150,6 +155,12 @@ interface CodexAccountUsageSummary { /** In-memory last-good snapshot (not sessionStorage — accounts carry emails/ids). */ const lastGoodByBase = new Map(); +function normalizeAccountAutoSwitchThreshold(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100 + ? value + : null; +} + export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccountPoolController { const seed = lastGoodByBase.get(apiBase); const [accounts, setAccounts] = useState(() => seed?.accounts ?? []); @@ -175,6 +186,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const [switchingId, setSwitchingId] = useState(null); const [pauseUpdatingId, setPauseUpdatingId] = useState(null); const [priorityUpdatingId, setPriorityUpdatingId] = useState(null); + const [autoSwitchUpdatingId, setAutoSwitchUpdatingId] = useState(null); const [pausingExhausted, setPausingExhausted] = useState(false); const [activePinnedId, setActivePinnedId] = useState(null); // A counter, not a boolean: the initial load, the 30s poll, quota-fill retries and explicit @@ -211,6 +223,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou // Its own gate, deliberately not the pause one: re-ordering one account and pausing // another are independent writes, and a shared ref would make either reject the other. const priorityMutationRef = useRef<{ accountId: string } | null>(null); + const autoSwitchMutationRef = useRef<{ accountId: string } | null>(null); const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => { observersRef.current!.add(observer); @@ -277,6 +290,9 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou ...account, ...(logLabel ? { logLabel } : {}), priority: normalizeAccountPriority(account.priority), + autoSwitchThresholdOverride: normalizeAccountAutoSwitchThreshold( + account.autoSwitchThresholdOverride, + ), quotaAutoRefresh: account.quotaAutoRefresh ?? { ...available, fiveHourEnabled: false, @@ -556,6 +572,42 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } }, [apiBase, load]); + const setAccountAutoSwitchThreshold = useCallback(async ( + id: string, + threshold: number | null, + ) => { + if (autoSwitchMutationRef.current) return { ok: false, reason: "busy" } as const; + autoSwitchMutationRef.current = { accountId: id }; + setAutoSwitchUpdatingId(id); + try { + const response = await fetch(`${apiBase}/api/codex-auth/auto-switch`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, threshold }), + }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const raw = await response.json().catch(() => ({})); + const result = (raw && typeof raw === "object" ? raw : {}) as { + autoSwitchThresholdOverride?: unknown; + }; + const stored = Object.prototype.hasOwnProperty.call(result, "autoSwitchThresholdOverride") + ? normalizeAccountAutoSwitchThreshold(result.autoSwitchThresholdOverride) + : threshold; + setAccounts(current => current.map(account => ( + account.id === id || (id === "__main__" && account.isMain) + ? { ...account, autoSwitchThresholdOverride: stored } + : account + ))); + void load(); + return { ok: true } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + autoSwitchMutationRef.current = null; + setAutoSwitchUpdatingId(null); + } + }, [apiBase, load]); + const pauseExhaustedAccounts = useCallback(async () => { if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; pauseMutationRef.current = "bulk"; @@ -646,6 +698,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou switchingId, pauseUpdatingId, priorityUpdatingId, + autoSwitchUpdatingId, pausingExhausted, activeNeedsReauth, activePinnedId, @@ -653,6 +706,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou switchAccount, setAccountPaused, setAccountPriority, + setAccountAutoSwitchThreshold, pauseExhaustedAccounts, saveAlias, removeAccount, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b28662b265a..d013e0a54a4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1711,6 +1711,12 @@ export const de: Record = { "accountPool.priorityCustom": "Benutzerdefiniert", "accountPool.priorityUpdated": "Auswahlreihenfolge für {email} aktualisiert", "accountPool.priorityUpdateFailed": "Die Auswahlreihenfolge für {email} konnte nicht gespeichert werden. Der zuletzt bestätigte Wert wird angezeigt.", + "accountPool.autoSwitchThreshold": "Benutzerdefinierter Kontoschwellenwert", + "accountPool.autoSwitchThresholdAria": "Nutzungsschwellenwert für {email}", + "accountPool.autoSwitchOverrideAria": "Globalen Nutzungsschwellenwert für {email} überschreiben", + "accountPool.autoSwitchHint": "Überschreibt den globalen Schwellenwert für dieses Konto. 0 deaktiviert nutzungsbasiertes proaktives Wechseln von diesem Konto.", + "accountPool.autoSwitchUpdated": "Nutzungsschwellenwert für {email} aktualisiert", + "accountPool.autoSwitchUpdateFailed": "Der Nutzungsschwellenwert für {email} konnte nicht gespeichert werden. Der zuletzt bestätigte Wert wird angezeigt.", "codexAuth.switched": "{email} ist für die nächste Anfrage ausgewählt", "codexAuth.loadFailed": "Die Codex-Kontoeinstellungen konnten nicht geladen werden.", "codexAuth.accountsRefreshFailed": "Die letzte Kontoaktualisierung ist fehlgeschlagen. Unten stehen die zuletzt bestätigten Konten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index f3f03e21c79..4ba7b5ef61b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2304,6 +2304,12 @@ export const en = { "accountPool.priorityCustom": "Custom", "accountPool.priorityUpdated": "Selection order updated for {email}", "accountPool.priorityUpdateFailed": "Selection order for {email} could not be saved. The last confirmed value is shown.", + "accountPool.autoSwitchThreshold": "Custom account threshold", + "accountPool.autoSwitchThresholdAria": "Usage threshold for {email}", + "accountPool.autoSwitchOverrideAria": "Override global usage threshold for {email}", + "accountPool.autoSwitchHint": "Overrides the global threshold for this account. 0 disables usage-based proactive switching from this account.", + "accountPool.autoSwitchUpdated": "Usage threshold updated for {email}", + "accountPool.autoSwitchUpdateFailed": "Usage threshold for {email} could not be saved. The last confirmed value is shown.", "codexAuth.switched": "{email} is selected for the next request", "codexAuth.loadFailed": "Codex account settings could not be loaded.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 34525f7e4a1..39e81b39fd4 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2227,6 +2227,12 @@ export const fr: Record = { "accountPool.priorityCustom": "Personnalisé", "accountPool.priorityUpdated": "Ordre de sélection mis à jour pour {email}", "accountPool.priorityUpdateFailed": "Impossible d’enregistrer l’ordre de sélection de {email}. La dernière valeur confirmée est affichée.", + "accountPool.autoSwitchThreshold": "Seuil personnalisé du compte", + "accountPool.autoSwitchThresholdAria": "Seuil d’utilisation pour {email}", + "accountPool.autoSwitchOverrideAria": "Remplacer le seuil d’utilisation global pour {email}", + "accountPool.autoSwitchHint": "Remplace le seuil global pour ce compte. 0 désactive le changement proactif basé sur l’utilisation depuis ce compte.", + "accountPool.autoSwitchUpdated": "Seuil d’utilisation mis à jour pour {email}", + "accountPool.autoSwitchUpdateFailed": "Le seuil d’utilisation de {email} n’a pas pu être enregistré. La dernière valeur confirmée est affichée.", "codexAuth.switched": "{email} est sélectionné pour la prochaine requête", "codexAuth.loadFailed": "Impossible de charger les paramètres des comptes Codex.", "codexAuth.accountsRefreshFailed": "La dernière actualisation des comptes a échoué. Les comptes ci-dessous sont les derniers confirmés.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index dcf3ce28623..aa994f03892 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2154,6 +2154,12 @@ export const ja: Record = { "accountPool.priorityCustom": "カスタム", "accountPool.priorityUpdated": "{email} の選択順序を更新しました", "accountPool.priorityUpdateFailed": "{email} の選択順序を保存できませんでした。最後に確認された値を表示しています。", + "accountPool.autoSwitchThreshold": "カスタムアカウントしきい値", + "accountPool.autoSwitchThresholdAria": "{email} の使用量しきい値", + "accountPool.autoSwitchOverrideAria": "{email} のグローバル使用量しきい値を上書き", + "accountPool.autoSwitchHint": "このアカウントのグローバルしきい値を上書きします。0 にすると、このアカウントからの使用量ベースの事前切り替えを無効にします。", + "accountPool.autoSwitchUpdated": "{email} の使用量しきい値を更新しました", + "accountPool.autoSwitchUpdateFailed": "{email} の使用量しきい値を保存できませんでした。最後に確認された値を表示しています。", "codexAuth.switched": "次のリクエストでは {email} を使用します", "codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。", "codexAuth.accountsRefreshFailed": "最新のアカウント更新に失敗しました。以下は最後に確認されたアカウントです。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 68c11214149..620ef41bf15 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1747,6 +1747,12 @@ export const ko: Record = { "accountPool.priorityCustom": "사용자 지정", "accountPool.priorityUpdated": "{email}의 선택 순서를 업데이트했습니다", "accountPool.priorityUpdateFailed": "{email}의 선택 순서를 저장하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", + "accountPool.autoSwitchThreshold": "사용자 지정 계정 임계값", + "accountPool.autoSwitchThresholdAria": "{email}의 사용량 임계값", + "accountPool.autoSwitchOverrideAria": "{email}의 전역 사용량 임계값 재정의", + "accountPool.autoSwitchHint": "이 계정의 전역 임계값을 재정의합니다. 0은 이 계정에서 사용량 기반 사전 전환을 비활성화합니다.", + "accountPool.autoSwitchUpdated": "{email}의 사용량 임계값을 업데이트했습니다", + "accountPool.autoSwitchUpdateFailed": "{email}의 사용량 임계값을 저장하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", "codexAuth.switched": "다음 요청에 {email}을(를) 사용합니다", "codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.", "codexAuth.accountsRefreshFailed": "최신 계정 새로고침에 실패했습니다. 아래 목록은 마지막으로 확인된 계정입니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 966c173fe0a..41b850cdbdf 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2226,6 +2226,12 @@ export const ru: Record = { "accountPool.priorityCustom": "Своё значение", "accountPool.priorityUpdated": "Порядок выбора для {email} обновлён", "accountPool.priorityUpdateFailed": "Не удалось сохранить порядок выбора для {email}. Показано последнее подтверждённое значение.", + "accountPool.autoSwitchThreshold": "Пользовательский порог аккаунта", + "accountPool.autoSwitchThresholdAria": "Порог использования для {email}", + "accountPool.autoSwitchOverrideAria": "Переопределить глобальный порог использования для {email}", + "accountPool.autoSwitchHint": "Переопределяет глобальный порог для этого аккаунта. 0 отключает упреждающее переключение по использованию с этого аккаунта.", + "accountPool.autoSwitchUpdated": "Порог использования для {email} обновлён", + "accountPool.autoSwitchUpdateFailed": "Не удалось сохранить порог использования для {email}. Показано последнее подтверждённое значение.", "codexAuth.switched": "{email} выбран для следующего запроса", "codexAuth.loadFailed": "Не удалось загрузить настройки аккаунтов Codex.", "codexAuth.accountsRefreshFailed": "Последнее обновление аккаунтов не удалось. Ниже показаны последние подтверждённые аккаунты.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4f1053581f2..b973e44efe4 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2245,6 +2245,12 @@ export const tr: Record = { "accountPool.priorityCustom": "Özel", "accountPool.priorityUpdated": "{email} için seçim sırası güncellendi", "accountPool.priorityUpdateFailed": "{email} için öncelik güncellenemedi", + "accountPool.autoSwitchThreshold": "Özel hesap eşiği", + "accountPool.autoSwitchThresholdAria": "{email} için kullanım eşiği", + "accountPool.autoSwitchOverrideAria": "{email} için genel kullanım eşiğini geçersiz kıl", + "accountPool.autoSwitchHint": "Bu hesap için genel eşiği geçersiz kılar. 0, bu hesaptan kullanıma dayalı proaktif geçişi devre dışı bırakır.", + "accountPool.autoSwitchUpdated": "{email} için kullanım eşiği güncellendi", + "accountPool.autoSwitchUpdateFailed": "{email} için kullanım eşiği kaydedilemedi. Son onaylanan değer gösteriliyor.", "codexAuth.switched": "Sonraki istek için {email} seçildi", "codexAuth.loadFailed": "Codex hesap ayarları yüklenemedi.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 8cb83da7079..9ba8912c716 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -13,6 +13,12 @@ export const vi: Record = { "tray.cost": "Phí ước tính", "tray.cached": "Đệm {percent}", + "accountPool.autoSwitchThreshold": "Ngưỡng riêng cho tài khoản", + "accountPool.autoSwitchThresholdAria": "Ngưỡng sử dụng cho {email}", + "accountPool.autoSwitchOverrideAria": "Ghi đè ngưỡng sử dụng chung cho {email}", + "accountPool.autoSwitchHint": "Ghi đè ngưỡng chung cho tài khoản này. 0 chỉ tắt chuyển đổi chủ động dựa trên mức sử dụng từ tài khoản này.", + "accountPool.autoSwitchUpdated": "Đã cập nhật ngưỡng sử dụng cho {email}", + "accountPool.autoSwitchUpdateFailed": "Không thể lưu ngưỡng sử dụng cho {email}. Giá trị được xác nhận gần nhất đang được hiển thị.", "usage.incomplete": "Không thể đưa một số bản ghi sử dụng vào. Số lượng, ngày tháng và thứ hạng chỉ phản ánh các bản ghi có thể đọc được.", "models.pickerOrder.usageIncomplete": "Không thể lưu thứ tự dùng nhiều nhất vì lịch sử sử dụng không đầy đủ. Hãy chọn thứ tự khác hoặc sửa lịch sử trước.", "api.attribution.noRecordedUse": "Không có lượt sử dụng trong các bản ghi có thể đọc được", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c3482290e58..85ff5cd78e6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2736,6 +2736,12 @@ export const zhTW: Record = { "accountPool.priorityCustom": "自訂", "accountPool.priorityUpdated": "已更新 {email} 的選擇順序", "accountPool.priorityUpdateFailed": "無法儲存 {email} 的選擇順序。目前顯示最後一次確認的值。", + "accountPool.autoSwitchThreshold": "自訂帳號閾值", + "accountPool.autoSwitchThresholdAria": "{email} 的使用量閾值", + "accountPool.autoSwitchOverrideAria": "覆寫 {email} 的全域使用量閾值", + "accountPool.autoSwitchHint": "覆寫此帳號的全域閾值。設為 0 會停用從此帳號發起的使用量主動切換。", + "accountPool.autoSwitchUpdated": "已更新 {email} 的使用量閾值", + "accountPool.autoSwitchUpdateFailed": "無法儲存 {email} 的使用量閾值。目前顯示最後確認的值。", "api.clientConfig.clientOmp": "OMP", "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7655f8b09e0..27942bc8ab0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1728,6 +1728,12 @@ export const zh: Record = { "accountPool.priorityCustom": "自定义", "accountPool.priorityUpdated": "已更新 {email} 的选择顺序", "accountPool.priorityUpdateFailed": "无法保存 {email} 的选择顺序。当前显示最后一次确认的值。", + "accountPool.autoSwitchThreshold": "自定义账号阈值", + "accountPool.autoSwitchThresholdAria": "{email} 的使用量阈值", + "accountPool.autoSwitchOverrideAria": "覆盖 {email} 的全局使用量阈值", + "accountPool.autoSwitchHint": "覆盖此账号的全局阈值。设为 0 会停用从此账号发起的基于使用量的主动切换。", + "accountPool.autoSwitchUpdated": "已更新 {email} 的使用量阈值", + "accountPool.autoSwitchUpdateFailed": "无法保存 {email} 的使用量阈值。当前显示最后确认的值。", "codexAuth.switched": "下一次请求将使用 {email}", "codexAuth.loadFailed": "无法加载 Codex 账号设置。", "codexAuth.accountsRefreshFailed": "最近一次账号刷新失败。以下是最后一次确认的账号。", diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 9b378537153..6e76ce6750d 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -92,6 +92,24 @@ beforeEach(() => { activePinnedAccountId = null; return { ok: true, json: async () => ({ ok: true, id: body.id, priority: stored }) } as unknown as Response; } + if (path === "codex-auth/auto-switch") { + const body = JSON.parse(String(init?.body)) as { id: string; threshold: number | null }; + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (account.id === body.id || (body.id === "__main__" && "isMain" in account && account.isMain === true)) + ? { ...account, autoSwitchThresholdOverride: body.threshold } + : account + )); + return { + ok: true, + json: async () => ({ + ok: true, + id: body.id, + autoSwitchThresholdOverride: body.threshold, + autoSwitchThreshold: body.threshold ?? threshold, + }), + } as unknown as Response; + } if (path === "codex-auth/accounts/pause") { const gate = nextPauseResponseGate; nextPauseResponseGate = null; @@ -485,6 +503,26 @@ test("a confirmed selection-order save updates the row before the reload lands", expect(seen.current!.accounts.find(account => account.id === "a2")?.priority).toBe(2); }); +test("an account usage-threshold save updates the row and null restores inheritance", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, priority: 0, autoSwitchThresholdOverride: null, hasCredential: true, quota: null }, + { id: "a2", email: "pool", isMain: false, paused: false, priority: 0, autoSwitchThresholdOverride: null, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountAutoSwitchThreshold("a2", 60)).toEqual({ ok: true }); + }); + expect(calls).toContain("PUT codex-auth/auto-switch"); + expect(seen.current!.accounts.find(account => account.id === "a2")?.autoSwitchThresholdOverride).toBe(60); + expect(seen.current!.autoSwitchUpdatingId).toBeNull(); + + await act(async () => { + expect(await seen.current!.setAccountAutoSwitchThreshold("a2", null)).toEqual({ ok: true }); + }); + expect(seen.current!.accounts.find(account => account.id === "a2")?.autoSwitchThresholdOverride).toBeNull(); +}); + test("an accepted selection-order write clears the pin before reconciliation lands", async () => { activePinnedAccountId = "a1"; const seen = await mountController(); diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index c622a5f7930..4cae15ebfd5 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -3,7 +3,12 @@ import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import CodexAccountPool from "../src/components/CodexAccountPool"; -import type { CodexAccountEntry, CodexAccountPoolController } from "../src/hooks/useCodexAccountPool"; +import { useCodexAccountPool } from "../src/hooks/useCodexAccountPool"; +import type { + CodexAccountEntry, + CodexAccountLoadObserver, + CodexAccountPoolController, +} from "../src/hooks/useCodexAccountPool"; import { en } from "../src/i18n/en"; import { LanguageProvider } from "../src/i18n/provider"; @@ -31,6 +36,7 @@ const account: CodexAccountEntry = { isMain: false, paused: false, priority: 0, + autoSwitchThresholdOverride: null, hasCredential: true, quota: null, usage30d: { @@ -46,6 +52,7 @@ const mainAccount: CodexAccountEntry = { isMain: true, paused: false, priority: 0, + autoSwitchThresholdOverride: null, hasCredential: true, quota: null, }; @@ -58,6 +65,7 @@ function makeController(overrides: Partial = {}): Co switchingId: null, pauseUpdatingId: null, priorityUpdatingId: null, + autoSwitchUpdatingId: null, pausingExhausted: false, activeNeedsReauth: false, activePinnedId: null, @@ -65,6 +73,7 @@ function makeController(overrides: Partial = {}): Co switchAccount: async () => ({ ok: true, activeId: null }), setAccountPaused: async () => ({ ok: true }), setAccountPriority: async () => ({ ok: true }), + setAccountAutoSwitchThreshold: async () => ({ ok: true }), pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), saveAlias: async () => ({ ok: true }), removeAccount: async () => ({ ok: true }), @@ -281,6 +290,522 @@ test("healthy account cards omit log-label and 30-day usage copy", async () => { expect(hasPinnedHint(main)).toBe(false); }); +test("account cards show custom threshold controls only when enabled", async () => { + const inherited = { + ...account, + autoSwitchThresholdOverride: null, + }; + const overridden = { + ...account, + id: "pool-2", + email: "override@example.test", + autoSwitchThresholdOverride: 70, + }; + await mountPool(makeController({ + accounts: [ + { ...mainAccount, autoSwitchThresholdOverride: null }, + inherited, + overridden, + ], + readLastThreshold: () => 95, + })); + + const inheritedCard = cardFor("pool@example.test"); + expect(inheritedCard.textContent).toContain("Custom account threshold"); + expect(inheritedCard.textContent).not.toContain("Global 95%"); + expect(inheritedCard.querySelector('input[type="number"]')).toBeNull(); + const inheritedToggle = inheritedCard.querySelector('button[aria-pressed="false"]'); + expect(inheritedToggle).not.toBeNull(); + expect(inheritedToggle!.disabled).toBe(false); + + const overrideCard = cardFor("override@example.test"); + expect(overrideCard.textContent).toContain("Custom account threshold"); + const input = overrideCard.querySelector( + 'input[aria-label="Usage threshold for override@example.test"]', + ); + expect(input?.value).toBe("70"); + expect(overrideCard.querySelector('button[aria-pressed="true"]')).not.toBeNull(); +}); + +test("custom account threshold uses only the custom number stepper", async () => { + const style = win.document.createElement("style"); + style.textContent = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + win.document.head.appendChild(style); + await mountPool(makeController({ + accounts: [{ ...account, autoSwitchThresholdOverride: 70 }], + readLastThreshold: () => 95, + })); + + const card = cardFor("pool@example.test"); + const input = card.querySelector('input[type="number"]'); + expect(input).not.toBeNull(); + expect(win.getComputedStyle(input!).appearance).toBe("textfield"); + expect(card.querySelectorAll(".ocx-stepper__btn")).toHaveLength(2); +}); + +test("a global threshold refresh preserves an in-progress custom account draft", async () => { + const overridden = { ...account, autoSwitchThresholdOverride: 70 }; + let observer: CodexAccountLoadObserver | null = null; + await mountPool(makeController({ + accounts: [overridden], + readLastThreshold: () => 95, + subscribeLoadObserver: (next) => { + observer = next; + return () => {}; + }, + })); + const input = cardFor("pool@example.test").querySelector('input[type="number"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")! + .set!.call(input, "75"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + + const startedRevision = observer!.beginActiveRead(); + await act(async () => { + observer!.acceptActiveRead({ autoSwitchThreshold: 80 }, startedRevision); + }); + + expect(cardFor("pool@example.test").querySelector('input[type="number"]')!.value).toBe("75"); +}); + +test("toggle-off wins over a pending edited-threshold blur", async () => { + const writes: Array = []; + await mountPool(makeController({ + accounts: [{ ...account, autoSwitchThresholdOverride: 70 }], + readLastThreshold: () => 95, + setAccountAutoSwitchThreshold: async (_id, threshold) => { + if (writes.length > 0) return { ok: false, reason: "busy" }; + writes.push(threshold); + return await new Promise(() => {}); + }, + })); + + const card = cardFor("pool@example.test"); + const input = card.querySelector('input[type="number"]')!; + const toggle = card.querySelector('button[aria-pressed="true"]')!; + + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")! + .set!.call(input, "75"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + toggle.dispatchEvent(new win.Event("pointerdown", { bubbles: true })); + input.dispatchEvent(new win.FocusEvent("focusout", { bubbles: true, relatedTarget: null })); + toggle.dispatchEvent(new win.Event("pointerup", { bubbles: true })); + toggle.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(writes).toEqual([null]); +}); + +test("account threshold override cannot persist the seed before global threshold hydration", async () => { + let writes = 0; + await mountPool(makeController({ + readLastThreshold: () => undefined, + setAccountAutoSwitchThreshold: async () => { + writes += 1; + return { ok: true }; + }, + })); + + const inheritedCard = cardFor("pool@example.test"); + const toggle = inheritedCard.querySelector('button[aria-pressed="false"]'); + expect(toggle).not.toBeNull(); + expect(toggle!.disabled).toBe(true); + + await act(async () => { + toggle!.click(); + await Promise.resolve(); + }); + expect(writes).toBe(0); +}); + +// Keep the cards, pool and controller real. Only the HTTP boundary is replaced: writes +// remain pending until the test answers them, and subsequent reads return persisted data. +let thresholdHarnessId = 0; +async function mountThresholdPool(entry = account, initial: number | null = 50) { + const apiBase = `/threshold-focus-${++thresholdHarnessId}`; + let persisted = initial; + let globalThreshold = 95; + let controller: CodexAccountPoolController; + const writes: Array<{ id: string; threshold: number | null }> = []; + let respond: ((response: Response) => void) | undefined; + let nextAccountsGate: Promise | undefined; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + const path = String(url).split("/api/")[1]; + if (path === "codex-auth/auto-switch" && init?.method === "PUT") { + writes.push(JSON.parse(String(init.body))); + return new Promise((resolve) => { respond = resolve; }); + } + if (path?.startsWith("codex-auth/accounts")) { + // Capture when the request starts, not when its delayed response is released. + const response = Response.json({ accounts: [{ ...entry, autoSwitchThresholdOverride: persisted }] }); + const gate = nextAccountsGate; + nextAccountsGate = undefined; + if (gate) await gate; + return response; + } + if (path === "codex-auth/active") { + return Response.json({ activeCodexAccountId: null, autoSwitchThreshold: globalThreshold, pinnedAccountId: null }); + } + if (path?.startsWith("usage?")) return Response.json({ accounts: [] }); + if (path === "settings") return Response.json({ showCodexSparkQuota: false, codexQuotaAutoRefresh: {} }); + throw new Error(`Unexpected threshold test request: ${url}`); + }, + }); + function Pool() { + controller = useCodexAccountPool(apiBase); + return ; + } + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + const control = () => cardFor(entry.email).querySelector(".codex-account-auto-switch")!; + return { + writes, + control, + input: () => control().querySelector("input")!, + toggle: () => control().querySelector(".toggle")!, + steppers: () => [...control().querySelectorAll(".ocx-stepper__btn")], + storedOverride: () => controller!.accounts.find(row => row.id === entry.id)!.autoSwitchThresholdOverride, + async startSlowRefresh() { + let release!: () => void; + nextAccountsGate = new Promise(resolve => { release = resolve; }); + let refresh!: Promise; + await act(async () => { refresh = controller!.load(); }); + expect(nextAccountsGate).toBeUndefined(); + return async () => { + await act(async () => { release(); await refresh; }); + }; + }, + async settle(ok = true, stored = writes.at(-1)!.threshold) { + expect(respond).toBeDefined(); + await act(async () => { + if (ok) persisted = stored; + respond!(ok + ? Response.json({ ok: true, autoSwitchThresholdOverride: stored }) + : new Response(null, { status: 500 })); + respond = undefined; + }); + }, + async refresh(nextGlobal: number, nextOverride = persisted) { + globalThreshold = nextGlobal; + persisted = nextOverride; + await act(async () => { await controller!.load(); }); + }, + }; +} + +async function editThreshold(input: HTMLInputElement, draft: string) { + await act(async () => { + input.focus(); + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, draft); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); +} + +async function pressEnter(element: HTMLElement) { + await act(async () => { + element.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + // happy-dom does not synthesize native keyboard button activation. + if (element.tagName === "BUTTON") element.click(); + }); +} + +async function tabWithinThreshold(from: HTMLElement, to: HTMLElement) { + await act(async () => { + from.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true })); + to.focus(); // Native focus()/focusout, including relatedTarget; no fabricated blur. + }); + expect(win.document.activeElement === to).toBe(true); +} + +for (const entry of [account, mainAccount]) { + test(`${entry.id}: Enter save preserves input identity and focus after controller acceptance`, async () => { + const pool = await mountThresholdPool(entry); + const input = pool.input(); + await editThreshold(input, "60"); + await pressEnter(input); + expect(pool.writes).toEqual([{ id: entry.isMain ? "__main__" : entry.id, threshold: 60 }]); + await pool.settle(); + expect(pool.input() === input).toBe(true); + expect(win.document.activeElement === input).toBe(true); + expect(input.value).toBe("60"); + }); + + test(`${entry.id}: pending Enter write keeps focusable read-only input and blocks duplicate writes`, async () => { + const pool = await mountThresholdPool(entry); + const input = pool.input(); + await editThreshold(input, "60"); + await pressEnter(input); + expect(input.disabled).toBe(false); + expect(input.readOnly).toBe(true); + expect(win.document.activeElement === input).toBe(true); + await pressEnter(input); + await act(async () => { pool.steppers()[0]!.click(); }); + expect(input.value).toBe("60"); + expect(pool.writes).toHaveLength(1); + await pool.settle(); + expect(input.readOnly).toBe(false); + expect(win.document.activeElement === input).toBe(true); + await editThreshold(input, "61"); + await pressEnter(input); + await pool.settle(); + expect(pool.writes.map(write => write.threshold)).toEqual([60, 61]); + }); +} + +test("dirty input can Tab through both steppers to toggle off without blur-saving", async () => { + const pool = await mountThresholdPool(); + const input = pool.input(); + const [up, down] = pool.steppers(); + const toggle = pool.toggle(); + await editThreshold(input, "60"); + for (const button of [up!, down!, toggle]) expect(button.tabIndex).toBe(0); + await tabWithinThreshold(input, up!); + expect(pool.writes).toEqual([]); + await tabWithinThreshold(up!, down!); + await tabWithinThreshold(down!, toggle); + expect(pool.writes).toEqual([]); + await pressEnter(toggle); + expect(pool.writes.map(write => write.threshold)).toEqual([null]); + expect(toggle.disabled).toBe(false); + expect(win.document.activeElement === toggle).toBe(true); + await pool.settle(); + expect(pool.input()).toBeNull(); + expect(pool.toggle() === toggle).toBe(true); + expect(win.document.activeElement === toggle).toBe(true); +}); + +test("keyboard stepper retains focus across pending and repeated accepted writes", async () => { + const pool = await mountThresholdPool(); + const input = pool.input(); + const up = pool.steppers()[0]!; + await editThreshold(input, "60"); + await tabWithinThreshold(input, up); + await pressEnter(up); + expect(pool.writes.map(write => write.threshold)).toEqual([61]); + expect(up.disabled).toBe(false); + expect(win.document.activeElement === up).toBe(true); + await pressEnter(up); + expect(pool.writes).toHaveLength(1); + await pool.settle(); + expect(pool.steppers()[0] === up).toBe(true); + expect(win.document.activeElement === up).toBe(true); + await pressEnter(up); + await pool.settle(); + expect(pool.writes.map(write => write.threshold)).toEqual([61, 62]); + expect(pool.input() === input).toBe(true); + expect(input.value).toBe("62"); + expect(win.document.activeElement === up).toBe(true); +}); + +test("leaving the group from a stepper commits the unsaved draft once", async () => { + const pool = await mountThresholdPool(); + const input = pool.input(); + const up = pool.steppers()[0]!; + await editThreshold(input, "60"); + await tabWithinThreshold(input, up); + expect(pool.writes).toEqual([]); + const outside = win.document.createElement("button"); + win.document.body.appendChild(outside); + await act(async () => { outside.focus(); }); + expect(pool.writes.map(write => write.threshold)).toEqual([60]); + await pool.settle(); + expect(win.document.activeElement === outside).toBe(true); + expect(pool.writes).toHaveLength(1); +}); + +for (const initial of [50, 0]) { + test(`failed pointer toggle-off restores persisted ${initial}, not dirty draft or global 95`, async () => { + const pool = await mountThresholdPool(account, initial); + const input = pool.input(); + const toggle = pool.toggle(); + await editThreshold(input, "60"); + await act(async () => { + toggle.dispatchEvent(new win.Event("pointerdown", { bubbles: true })); + toggle.focus(); + toggle.dispatchEvent(new win.Event("pointerup", { bubbles: true })); + toggle.click(); + }); + expect(pool.writes.map(write => write.threshold)).toEqual([null]); + await pool.settle(false); + expect(pool.toggle().getAttribute("aria-pressed")).toBe("true"); + expect(pool.input().value).toBe(String(initial)); + expect(pool.input() === input).toBe(true); + expect(win.document.activeElement === toggle).toBe(true); + expect(host.textContent).toContain(en["accountPool.autoSwitchUpdateFailed"].split("{")[0]!); + }); +} + +test("global refresh preserves dirty draft; changed override syncs without replacing focused input", async () => { + const pool = await mountThresholdPool(); + const input = pool.input(); + await editThreshold(input, "60"); + await pool.refresh(80); + expect(pool.input() === input).toBe(true); + expect(input.value).toBe("60"); + await pool.refresh(80, 0); + expect(pool.input() === input).toBe(true); + expect(input.value).toBe("0"); + expect(win.document.activeElement === input).toBe(true); + expect(pool.writes).toEqual([]); +}); + +test("accepted server-normalized override replaces the draft without remounting", async () => { + const pool = await mountThresholdPool(); + const input = pool.input(); + await editThreshold(input, "60"); + await pressEnter(input); + await pool.settle(true, 55); + expect(pool.input() === input).toBe(true); + expect(input.value).toBe("55"); + expect(win.document.activeElement === input).toBe(true); +}); + +test("pointer steppers keep input focus and commit each accepted step once", async () => { + const pool = await mountThresholdPool(); + const input = pool.input(); + await editThreshold(input, "60"); + for (const index of [0, 0, 1]) { + const button = pool.steppers()[index]!; + await act(async () => { + button.dispatchEvent(new win.Event("pointerdown", { bubbles: true })); + const mouseDown = new win.MouseEvent("mousedown", { bubbles: true, cancelable: true }); + if (button.dispatchEvent(mouseDown)) button.focus(); + button.dispatchEvent(new win.Event("pointerup", { bubbles: true })); + button.click(); + }); + expect(input.disabled).toBe(false); + expect(input.readOnly).toBe(true); + expect(win.document.activeElement === input).toBe(true); + await pool.settle(); + expect(pool.input() === input).toBe(true); + expect(win.document.activeElement === input).toBe(true); + } + expect(pool.writes.map(write => write.threshold)).toEqual([61, 62, 61]); + expect(input.value).toBe("61"); +}); + +test("failed Enter restores persisted value and permits another edit without duplicate blur write", async () => { + const pool = await mountThresholdPool(); + const input = pool.input(); + await editThreshold(input, "60"); + await pressEnter(input); + await pool.settle(false); + expect(pool.input() === input).toBe(true); + expect(win.document.activeElement === input).toBe(true); + expect(input.value).toBe("50"); + expect(input.readOnly).toBe(false); + await editThreshold(input, "65"); + await pressEnter(input); + await act(async () => { input.blur(); }); + await pool.settle(); + expect(pool.writes.map(write => write.threshold)).toEqual([60, 65]); + expect(input.value).toBe("65"); + expect(win.document.activeElement === input).toBe(false); +}); + +test("external input blur commits zero and leaves override enabled", async () => { + const pool = await mountThresholdPool(); + await editThreshold(pool.input(), "0"); + await act(async () => { pool.input().blur(); }); + expect(pool.writes.map(write => write.threshold)).toEqual([0]); + await pool.settle(); + expect(pool.input().value).toBe("0"); + expect(pool.toggle().getAttribute("aria-pressed")).toBe("true"); +}); + +test("inherited override follows latest global seed including zero and preserves toggle identity", async () => { + const pool = await mountThresholdPool(account, null); + const toggle = pool.toggle(); + expect(pool.input()).toBeNull(); + await pool.refresh(0); + await act(async () => { toggle.focus(); }); + await pressEnter(toggle); + expect(pool.writes.map(write => write.threshold)).toEqual([0]); + await pool.settle(); + expect(pool.input().value).toBe("0"); + expect(pool.toggle() === toggle).toBe(true); + expect(win.document.activeElement === toggle).toBe(true); + await pressEnter(toggle); + await pool.settle(); + expect(pool.writes.map(write => write.threshold)).toEqual([0, null]); + expect(pool.input()).toBeNull(); + expect(pool.toggle().getAttribute("aria-pressed")).toBe("false"); + expect(win.document.activeElement === toggle).toBe(true); +}); + +test("invalid draft and Escape restore persisted override without a write", async () => { + const pool = await mountThresholdPool(account, 0); + const input = pool.input(); + for (const invalid of ["", "-1", "101", "50.5"]) { + await editThreshold(input, invalid); + await pressEnter(input); + expect(input.value).toBe("0"); + } + await editThreshold(input, "60"); + await act(async () => { + input.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + expect(input.value).toBe("0"); + expect(pool.writes).toEqual([]); +}); + +for (const timing of ["during", "after"] as const) { + for (const outcome of ["accepted Enter", "rejected Enter", "rejected toggle"] as const) { + test(`pre-write accounts snapshot resolving ${timing} ${outcome} preserves draft, stored override and focus`, async () => { + const pool = await mountThresholdPool(); + const input = pool.input(); + const toggle = pool.toggle(); + const finishRefresh = await pool.startSlowRefresh(); // Snapshot contains persisted 50. + await editThreshold(input, "60"); + const toggling = outcome === "rejected toggle"; + if (toggling) { + await tabWithinThreshold(input, pool.steppers()[0]!); + await tabWithinThreshold(pool.steppers()[0]!, pool.steppers()[1]!); + await tabWithinThreshold(pool.steppers()[1]!, toggle); + await pressEnter(toggle); + } else { + await pressEnter(input); + } + const focused = toggling ? toggle : input; + expect(pool.writes.map(write => write.threshold)).toEqual([toggling ? null : 60]); + if (timing === "during") await finishRefresh(); + expect(pool.input() === input).toBe(true); + expect(input.value).toBe("60"); + expect(input.readOnly).toBe(true); + expect(pool.storedOverride()).toBe(50); + expect(win.document.activeElement === focused).toBe(true); + + const accepted = outcome === "accepted Enter"; + await pool.settle(accepted); + expect(pool.input() === input).toBe(true); + expect(input.value).toBe(accepted ? "60" : "50"); + expect(input.readOnly).toBe(false); + expect(pool.storedOverride()).toBe(accepted ? 60 : 50); + expect(pool.toggle().getAttribute("aria-pressed")).toBe("true"); + expect(win.document.activeElement === focused).toBe(true); + + if (timing === "after") { + // A late pre-write snapshot must neither roll back acceptance nor erase a + // fresh unsaved edit made after the success/error response was handled. + await editThreshold(input, "65"); + await finishRefresh(); + expect(pool.input() === input).toBe(true); + expect(input.value).toBe("65"); + expect(pool.storedOverride()).toBe(accepted ? 60 : 50); + expect(win.document.activeElement === input).toBe(true); + } + expect(pool.writes).toHaveLength(1); + }); + } +} test("plan exclusion is visible without presenting the account as the next automatic selection", async () => { await mountPool(makeController({ diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index 7410d9561d7..58e550e0801 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -40,6 +40,7 @@ const legacyAccount: LegacyCodexAccountEntry = { isMain: false, paused: false, priority: 0, + autoSwitchThresholdOverride: null, hasCredential: true, quota: { resetCredits: 2, updatedAt: 1 }, }; @@ -78,6 +79,7 @@ function makeController(overrides: Partial = {}): Co switchingId: null, pauseUpdatingId: null, priorityUpdatingId: null, + autoSwitchUpdatingId: null, pausingExhausted: false, activeNeedsReauth: false, activePinnedId: null, @@ -88,6 +90,7 @@ function makeController(overrides: Partial = {}): Co switchAccount: async () => ({ ok: true, activeId: null }), setAccountPaused: async () => ({ ok: true }), setAccountPriority: async () => ({ ok: true }), + setAccountAutoSwitchThreshold: async () => ({ ok: true }), pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), saveAlias: async () => ({ ok: true }), removeAccount: async () => ({ ok: false, reason: "request" }), diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 577abfb22ab..10b87639c59 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,6 +167,12 @@ } }, "explicit": { + "codex-priority-failback.test.ts": "codex-integration", + "codex-account-threshold-routing.test.ts": "codex-integration", + "codex-account-threshold-api.test.ts": "codex-integration", + "codex-account-threshold-auth.test.ts": "codex-integration", + "config-account-thresholds.test.ts": "config", + "cli-account-threshold.test.ts": "cli", "release-desktop-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", "gui-desktop-sidecar-script.test.ts": "gui", diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 9b8cf7108a7..f5567add09b 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -33,6 +33,8 @@ export interface AccountRow { validationPending?: boolean; /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ priority?: number; + /** Null means the account inherits the global usage-switch threshold. */ + autoSwitchThresholdOverride?: number | null; quota?: CodexQuotaDto | null; quotaRefresh?: CodexQuotaRefreshOutcome; quotaUnavailable?: boolean; @@ -252,6 +254,7 @@ interface CodexAccountDto { selectionExcludedPlan?: string; health?: { reason?: string }; priority?: number; + autoSwitchThresholdOverride?: number | null; quota?: CodexQuotaDto | null; quotaRefresh?: unknown; paused?: boolean; @@ -322,6 +325,9 @@ export async function fetchCodexRows( } : {}), ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}), priority: typeof a.priority === "number" ? a.priority : 0, + autoSwitchThresholdOverride: typeof a.autoSwitchThresholdOverride === "number" + ? a.autoSwitchThresholdOverride + : null, paused: a.paused === true, ...(includeQuota ? { quota: projectQuota(a.quota), diff --git a/src/cli/account.ts b/src/cli/account.ts index 84edce909fc..f1b33666328 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -340,10 +340,9 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise { } if (c.type === "codex") { console.error("Takes effect immediately; running threads move on their next request, and in-flight requests keep the account they captured."); - const active = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active"); - const threshold = active.status === 200 && typeof active.json.autoSwitchThreshold === "number" - ? active.json.autoSwitchThreshold - : undefined; + const state = await fetchRows(deps, baseUrl, name, "codex"); + const selected = state.rows.find(row => row.id === activeId); + const threshold = selected?.autoSwitchThresholdOverride ?? state.autoSwitchThreshold; if (pinDrainReason !== undefined) { // "may override" is the right caveat for a pin that is currently fine and could be // overtaken later. It is the wrong sentence for one the next request will discard, and @@ -352,7 +351,7 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise { ? `is at or above the auto-switch threshold${threshold !== undefined ? ` (${threshold}%)` : ""}` : `cannot currently be selected (${pinDrainReason})`; console.error(`Note: ${displayId(activeId)} ${because}, so routing releases this pin on its next request.`); - } else if (threshold !== undefined && threshold > 0) { + } else if (state.status === 200 && typeof threshold === "number" && threshold > 0) { console.error(`Note: auto-switch (threshold ${threshold}%) may override this pin.`); } } diff --git a/src/codex/account-auto-switch.ts b/src/codex/account-auto-switch.ts new file mode 100644 index 00000000000..b9a4b0d9e88 --- /dev/null +++ b/src/codex/account-auto-switch.ts @@ -0,0 +1,59 @@ +import type { OcxConfig } from "../types"; +import { deleteConfigObjectChildKey } from "../config/rebase-provenance"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; + +export const DEFAULT_CODEX_AUTO_SWITCH_THRESHOLD = 80; +export const MIN_CODEX_AUTO_SWITCH_THRESHOLD = 0; +export const MAX_CODEX_AUTO_SWITCH_THRESHOLD = 100; + +export function parseCodexAutoSwitchThreshold(value: unknown): number | null { + return typeof value === "number" + && Number.isInteger(value) + && value >= MIN_CODEX_AUTO_SWITCH_THRESHOLD + && value <= MAX_CODEX_AUTO_SWITCH_THRESHOLD + ? value + : null; +} + +export function isCodexAccountAutoSwitchThresholdKey(key: unknown): key is string { + return key === MAIN_CODEX_ACCOUNT_ID || isValidCodexAccountId(key); +} + +/** Null means this account inherits the global threshold. */ +export function getCodexAccountAutoSwitchThresholdOverride( + config: OcxConfig, + accountId: string, +): number | null { + const thresholds = config.codexAccountAutoSwitchThresholds; + if (!thresholds || !Object.hasOwn(thresholds, accountId)) return null; + return parseCodexAutoSwitchThreshold(thresholds[accountId]); +} + +/** Source-account threshold used by every usage-driven routing decision. */ +export function getEffectiveCodexAutoSwitchThreshold( + config: OcxConfig, + accountId: string, +): number { + const override = getCodexAccountAutoSwitchThresholdOverride(config, accountId); + if (override !== null) return override; + return config.autoSwitchThreshold ?? DEFAULT_CODEX_AUTO_SWITCH_THRESHOLD; +} + +/** Store a concrete override, or null to restore global inheritance. */ +export function setCodexAccountAutoSwitchThresholdOverride( + config: OcxConfig, + accountId: string, + threshold: number | null, +): void { + if (threshold === null) { + deleteConfigObjectChildKey(config, "codexAccountAutoSwitchThresholds", accountId); + return; + } + const entries = new Map(Object.entries(config.codexAccountAutoSwitchThresholds ?? {})); + entries.set(accountId, threshold); + config.codexAccountAutoSwitchThresholds = Object.fromEntries(entries); +} + +export function forgetCodexAccountAutoSwitchThreshold(config: OcxConfig, accountId: string): void { + setCodexAccountAutoSwitchThresholdOverride(config, accountId, null); +} diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index c274f49085b..6994f9f0ca5 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -5,6 +5,7 @@ import { saveConfigPreservingClaudeCode, withConfigMutationLockSync, } from "../config"; +import { captureConfigTopLevelRollback } from "../config/rebase-provenance"; import { removeCodexAccountCredential } from "./account-store"; import { clearAccountNeedsReauth } from "./account-runtime-state"; import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision"; @@ -19,6 +20,7 @@ import { extractAccountIdClaims } from "../oauth/chatgpt"; import { forgetCodexAccountPause } from "./account-pause"; import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority"; import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state"; +import { forgetCodexAccountAutoSwitchThreshold } from "./account-auto-switch"; import { codexAccountNamespaceEntries, codexAccountPickerEnabled } from "./account-namespaces"; import type { OcxConfig } from "../types"; @@ -163,6 +165,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): let cleanupFailed = false; const pickerVisibilityChanged = withConfigMutationLockSync(() => { const previousConfig = structuredClone(runtimeConfig); + const restoreDeletionProvenance = captureConfigTopLevelRollback(runtimeConfig, []); const configPath = getConfigPath(); const hasPersistedConfig = existsSync(configPath); const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath) : undefined; @@ -177,6 +180,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): .filter(account => account.isMain || account.id !== accountId); forgetCodexAccountPause(runtimeConfig, accountId); forgetCodexAccountPriority(runtimeConfig, accountId); + forgetCodexAccountAutoSwitchThreshold(runtimeConfig, accountId); if (runtimeConfig.codexQuotaAutoRefresh?.[accountId]) { const retained = { ...runtimeConfig.codexQuotaAutoRefresh }; delete retained[accountId]; @@ -193,6 +197,9 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): saveConfigPreservingClaudeCode(runtimeConfig); } catch (error) { restoreRuntimeConfig(runtimeConfig, previousConfig); + // The value snapshot cannot restore WeakMap-backed deletion intent. Retaining a + // rejected reset would erase a later disk override when this account inherited. + restoreDeletionProvenance(); try { assertPersistedConfigUnchanged(configPath, previousPersistedConfig); } catch { diff --git a/src/codex/account-priority.ts b/src/codex/account-priority.ts index afb39c4df25..2e848245248 100644 --- a/src/codex/account-priority.ts +++ b/src/codex/account-priority.ts @@ -2,6 +2,16 @@ import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation"; import type { OcxConfig } from "../types"; import { deleteConfigTopLevelKey } from "../config/rebase-provenance"; +import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch"; + +/** Shared cadence for the opt-in live priority recheck and its observation freshness. */ +export const CODEX_PRIORITY_FAILBACK_REFRESH_MS = 5 * 60_000; + +export function codexAccountPriorityFailbackEnabled(config: OcxConfig, accountId: string): boolean { + return config.codexAccountPriorityFailback === true + && (config.accountPoolStrategy ?? "quota") === "quota" + && getEffectiveCodexAutoSwitchThreshold(config, accountId) > 0; +} /** * Which ids may carry a selection order: any pool account, plus the synthetic diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index b19e487264a..5776dbd8721 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -6,6 +6,7 @@ import { ConfigMutationLockError, mutatePersistedConfig } from "../../config"; import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause"; import { getCodexAccountPriority } from "../account-priority"; +import { getCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch"; import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing"; import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; @@ -134,6 +135,7 @@ export function poolAccountDto( isMain: false, paused, priority, + autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(config, account.id), quota: quota ? { ...quota } : null, needsReauth: needsReauth || health.status === "reauth_required", ...(reauthReason !== undefined ? { reauthReason } : {}), @@ -157,6 +159,8 @@ export interface CodexAuthAccountDto { paused: boolean; /** Selection order; higher is used earlier. Always present, 0 when unset. */ priority: number; + /** Null inherits the global usage-switch threshold; 0 disables it for this account. */ + autoSwitchThresholdOverride: number | null; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; /** @@ -353,6 +357,7 @@ export async function listCodexAuthAccountsSnapshot( paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig), priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), + autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}), diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts index 804750be0dc..b11e9bca99d 100644 --- a/src/codex/auth-api/pool-mode-gate.ts +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -1,10 +1,12 @@ +import { CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "../account-priority"; +import { codexQuotaHasFreshUsage } from "../quota-observation-freshness"; import { getCodexAccountCredential, getValidCodexToken, readCodexAccountRecord } from "../account-store"; import { getAccountQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota"; import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing"; import { readCodexTokens } from "../auth-collision"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; -import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account"; import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper"; import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache"; import { getMainAccountHardLockStatus } from "../main-account-hard-lock"; @@ -16,11 +18,12 @@ import type { AdmissionLease } from "../../lib/admission"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; import type { PoolQuotaResult } from "./pool-quota-probe"; -import { fetchMainAccountInfoAttempt, fetchMainAccountInfo } from "./main-account-probe"; +import { fetchMainAccountInfoAttempt, fetchMainAccountInfo, MAIN_CACHE_TTL } from "./main-account-probe"; import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_CACHE_TTL, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; let primeInFlight: Promise | null = null; +let lastPriorityFailbackPrimeAt: number | undefined; /** * Last prime attempt per pool account. A failed WHAM lookup stores no quota, so * without this the account stays "unknown" and every later prime trigger re-selects @@ -176,11 +179,20 @@ export async function primeCodexPoolQuotas( || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool" ) return; if (primeInFlight) return primeInFlight; + // Count attempted passes, including failed reads; incoming traffic must not flood WHAM. + if (reason === "priority-failback") { + const now = Date.now(); + if (lastPriorityFailbackPrimeAt !== undefined + && now - lastPriorityFailbackPrimeAt < CODEX_PRIORITY_FAILBACK_REFRESH_MS) return; + lastPriorityFailbackPrimeAt = now; + } primeInFlight = (async () => { const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const stale = pool.filter(a => { const q = getAccountQuota(a.id); - if (q) return Date.now() - q.updatedAt >= POOL_CACHE_TTL; + const observationStale = reason === "priority-failback" && q + && !codexQuotaHasFreshUsage(q, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS); + if (q && !observationStale) return Date.now() - q.updatedAt >= POOL_CACHE_TTL; // No stored quota: either never primed, or the last attempt failed. Retry only // once per TTL window so an unreachable or rejecting account cannot turn every // prime trigger into another upstream request. @@ -199,10 +211,16 @@ export async function primeCodexPoolQuotas( // Keep one local owner and one cross-process reader from physical // identity reconciliation through WHAM and all quota publication. (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)(); - if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return; + const quota = getAccountQuota(MAIN_CODEX_ACCOUNT_ID); + const observationStale = reason === "priority-failback" && quota + && !codexQuotaHasFreshUsage(quota, getMainAccountPlan(), Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS); + if (quota && (reason !== "priority-failback" + || (!observationStale && Date.now() - quota.updatedAt < MAIN_CACHE_TTL))) return; if (!(options.readMainTokens ?? readCodexTokens)()) return; - if (options.fetchMainInfo) await options.fetchMainInfo(false); - else await fetchMainAccountInfoAttempt(false, 1, mainLease, true); + const bypassCachedQuota = !!observationStale; + if (options.fetchMainInfo) await options.fetchMainInfo(bypassCachedQuota); + // Cache bypass is passive observation, never an explicit reauthentication recovery. + else await fetchMainAccountInfoAttempt(bypassCachedQuota, 1, mainLease, true, false); }); } catch (error) { if (!isNativeMainClaimUnavailable(error)) throw error; @@ -218,7 +236,10 @@ export async function primeCodexPoolQuotas( if (!getCodexAccountCredential(a.id)) return; let result: PoolQuotaResult; try { - result = await fetchPoolAccountQuota(a.id, false, a.plan, getValidPoolTokenForPrime); + const quota = getAccountQuota(a.id); + const bypassCachedQuota = reason === "priority-failback" && quota !== null + && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS); + result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime); } catch (error) { // Local quota-flight saturation proves no WHAM request existed for this account. // Consume it per item so sibling workers remain inside the shared prime lifetime. @@ -257,6 +278,7 @@ export async function primeCodexPoolQuotas( * from another suite cannot coalesce into the next prime. */ export function clearCodexQuotaPrimeState(): void { primeInFlight = null; + lastPriorityFailbackPrimeAt = undefined; poolQuotaPrimeAttemptedAt.clear(); getValidPoolTokenForPrime = getValidCodexToken; } @@ -266,6 +288,7 @@ export function clearCodexQuotaPrimeState(): void { * the throttle a production caller would see. */ export function clearCodexQuotaPrimeSingleFlightForTests(): void { primeInFlight = null; + lastPriorityFailbackPrimeAt = undefined; } /** Test-only reset for the worker-level single-flight. */ diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index ed15ff56330..5ad42d82f61 100644 --- a/src/codex/auth-api/routes.ts +++ b/src/codex/auth-api/routes.ts @@ -21,6 +21,8 @@ import type { CodexAuthCatalogConvergence } from "./login-flow"; import { PoolQuotaProbeBusyError } from "./pool-quota-probe"; import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service"; import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config"; +import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; +import { getEffectiveCodexAutoSwitchThreshold, isCodexAccountAutoSwitchThresholdKey, parseCodexAutoSwitchThreshold, setCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch"; export async function handleCodexAuthAPI( req: Request, @@ -272,12 +274,46 @@ export async function handleCodexAuthAPI( } if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") { - let body: { threshold: number }; - try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + let parsedBody: unknown; + try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) { + return jsonResponse({ error: "body must be an object" }, 400); + } + const body = parsedBody as { id?: unknown; threshold?: unknown }; + const runtimeConfig = getRuntimeConfig(config); + if (Object.hasOwn(body, "id")) { + if (!isCodexAccountAutoSwitchThresholdKey(body.id)) { + return jsonResponse({ error: "id must be a Codex account id" }, 400); + } + const threshold = body.threshold === null ? null : parseCodexAutoSwitchThreshold(body.threshold); + if (body.threshold !== null && threshold === null) { + return jsonResponse({ error: "threshold must be null or an integer 0-100" }, 400); + } + if (body.id !== MAIN_CODEX_ACCOUNT_ID && !configuredPoolAccount(runtimeConfig, body.id)) { + return jsonResponse({ error: "Codex account not found" }, 404); + } + const rollback = captureConfigTopLevelRollback(runtimeConfig, ["codexAccountAutoSwitchThresholds"]); + try { + // Inheritance resets delete children in place; keep the previous map intact for rollback. + if (runtimeConfig.codexAccountAutoSwitchThresholds) { + runtimeConfig.codexAccountAutoSwitchThresholds = { ...runtimeConfig.codexAccountAutoSwitchThresholds }; + } + setCodexAccountAutoSwitchThresholdOverride(runtimeConfig, body.id, threshold); + saveRuntimeConfig(config, runtimeConfig); + } catch (error) { + rollback(); + throw error; + } + return jsonResponse({ + ok: true, + id: body.id, + autoSwitchThresholdOverride: threshold, + autoSwitchThreshold: getEffectiveCodexAutoSwitchThreshold(runtimeConfig, body.id), + }); + } if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) { return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400); } - const runtimeConfig = getRuntimeConfig(config); runtimeConfig.autoSwitchThreshold = body.threshold; saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true }); diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index db100fd6c26..948982fdde3 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,3 +1,4 @@ +import { codexAccountPriorityFailbackEnabled } from "./account-priority"; import type { PoolQuotaWriter } from "./quota-types"; import { createHash, timingSafeEqual } from "node:crypto"; import { @@ -80,6 +81,7 @@ import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupport import type { DataPlaneAdmission } from "../server/auth-cors"; import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability"; import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; +import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch"; /** * A request-owned bearer cannot inspect the physical main credential for its plan, but cached @@ -90,7 +92,7 @@ import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; * request that already brought its own credential (#3157). */ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean { - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, MAIN_CODEX_ACCOUNT_ID); if (threshold <= 0) return true; const usage = computeCodexUsageScore(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)); return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold; @@ -121,6 +123,7 @@ export function requestOwnedMainPinState( policy: CodexAuthPolicyConfig, requestScopedMainCredential: boolean, fixedAccountId: string | undefined, + quotaScope?: CodexQuotaScope, ): { candidate: boolean; preserve: boolean } { const candidate = requestScopedMainCredential && fixedAccountId === undefined @@ -130,7 +133,9 @@ export function requestOwnedMainPinState( && requestOwnedMainPinHasQuotaHeadroom(config); return { candidate, - preserve: candidate && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)), + preserve: candidate && !(callerMatchesObservedMain(headers) + && (isMainAccountHardLocked(policy) + || getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope)?.cooldownUntil)), }; } @@ -856,15 +861,31 @@ export async function resolveCodexAuthContext( throw new CodexReserveUnavailableError(); } const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId; - const { - candidate: requestOwnedMainPinCandidate, - preserve: preserveRequestOwnedMainPin, - } = requestOwnedMainPinState(headers, config, policy, requestScopedMainCredential, fixedAccountId); + const quotaScope = codexQuotaScopeForModel(options.modelId); + // Pool pins and fallback must not resurrect an observed main credential that is cooling + // down. Unrelated caller-owned credentials and explicit Direct keep their own policy. + // The identity match and scoped health read are memory-only; never probe the auth file. + const callerOwnedMainPoolCooldown = () => mode === "pool" && callerMatchesObservedMain(headers) + ? getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope) + : null; + const assertCallerOwnedMainPoolNotCooled = () => { + const cooldown = callerOwnedMainPoolCooldown(); + if (cooldown?.cooldownUntil) { + throw new CodexAccountCooldownError( + MAIN_CODEX_ACCOUNT_ID, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope, + ); + } + }; + const mainPinState = () => requestOwnedMainPinState( + headers, config, policy, requestScopedMainCredential, fixedAccountId, quotaScope, + ); + const requestOwnedMainPinCandidate = mainPinState().candidate; // During an owned startup, equality cannot be established until recovery and the // memory-only policy binding finish. This read-only fence never probes a foreign home. if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) { throw new CodexMainProfileDrainingError(); } + const preserveRequestOwnedMainPin = () => mainPinState().preserve; if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } @@ -878,6 +899,7 @@ export async function resolveCodexAuthContext( throw new CodexMainProfileDrainingError(); } if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); + assertCallerOwnedMainPoolNotCooled(); if (reserve) { const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy }); const token = selectedCodexToken(selected); @@ -897,6 +919,7 @@ export async function resolveCodexAuthContext( } } if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); + assertCallerOwnedMainPoolNotCooled(); return { kind: "main", accountId: null }; } @@ -946,13 +969,13 @@ export async function resolveCodexAuthContext( // is the one exception where that exclusion is selection evidence in the opposite direction. // Validate the caller's own gated-model roster before using it, and fall through to a Pool model // detour when it lacks the grant. This branch performs no physical-main credential read. - if (preserveRequestOwnedMainPin) { + if (preserveRequestOwnedMainPin()) { const callerEntitled = !options.modelId || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) || await ( options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel )(headers, options.modelId); - if (callerEntitled && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))) { + if (callerEntitled && preserveRequestOwnedMainPin()) { return { kind: "main", accountId: null }; } } @@ -1001,7 +1024,6 @@ export async function resolveCodexAuthContext( const nativeMainSelectionOnly = !nativeMainTrafficBlocked && selectionAdmission?.mainProfileDraining === true; let accountId: string; - const quotaScope = codexQuotaScopeForModel(options.modelId); try { const excludeAccountIds = nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) @@ -1045,7 +1067,7 @@ export async function resolveCodexAuthContext( // Main stays excluded from this request's model roster below. This synthetic liveness is // consulted only by shared-state preservation, so a caller-owned pin survives a model // detour without reading or selecting the physical main credential. - ? () => preserveRequestOwnedMainPin + ? preserveRequestOwnedMainPin : options.isMainAccountTokenLive, modelEligibleAccountIds, deniedModelAccountIds, @@ -1202,13 +1224,17 @@ export async function resolveCodexAuthContext( // unprimed (dashboard never opened, or startup prime was blocked). Kick a // best-effort prime so the NEXT routing decision has real scores. This never // blocks the current request, and the helper's single-flight guard collapses - // repeated triggers into one pass. - if (fixedAccountId === undefined && !nativeMainReadsForbidden && !getAccountQuota(accountId)) { + // repeated triggers into one pass. Opt-in priority failback also refreshes inactive + // accounts; that path has a five-minute attempt limit inside the prime helper. + const priorityFailback = codexAccountPriorityFailbackEnabled(config, accountId); + if (fixedAccountId === undefined && !nativeMainReadsForbidden + && (!getAccountQuota(accountId) || priorityFailback)) { + const reason = priorityFailback ? "priority-failback" : "pre-route"; if (options.primeCodexPoolQuotas) { - void options.primeCodexPoolQuotas(config, "pre-route").catch(() => {}); + void options.primeCodexPoolQuotas(config, reason).catch(() => {}); } else { import("./auth-api") - .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route")) + .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, reason)) .catch(() => {}); } } diff --git a/src/codex/quota-observation-freshness.ts b/src/codex/quota-observation-freshness.ts new file mode 100644 index 00000000000..e75b6b04161 --- /dev/null +++ b/src/codex/quota-observation-freshness.ts @@ -0,0 +1,34 @@ +import { isThirtyDayOnlyCodexPlan } from "./plan"; +import type { StoredAccountQuota } from "./quota-types"; + +type Window = "weeklyPercent" | "monthlyPercent" | "shortPercent"; +// Failback needs observations, not a recent cache write. Keep this request-policy evidence +// process-local: hydrated display bars do not prove a fresh observation in this process. +const observed = new WeakMap>>(); +const windows: Window[] = ["weeklyPercent", "monthlyPercent", "shortPercent"]; +const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); + +export function stampCodexQuotaUsageObservation( + next: StoredAccountQuota, + incoming: Pick, + existing?: StoredAccountQuota, +): StoredAccountQuota { + const previous = existing && observed.get(existing); + const timestamps: Partial> = {}; + for (const window of windows) { + if (!finite(next[window])) continue; + if (finite(incoming[window])) timestamps[window] = next.updatedAt; + else if (previous?.[window] !== undefined) timestamps[window] = previous[window]; + } + observed.set(next, timestamps); + return next; +} + +export function codexQuotaHasFreshUsage(quota: StoredAccountQuota, plan: unknown, now: number, maxAgeMs: number): boolean { + const long: Window[] = isThirtyDayOnlyCodexPlan(plan) ? ["monthlyPercent"] : ["weeklyPercent", "monthlyPercent"]; + const relevant = long.filter(window => finite(quota[window])); + if (relevant.length === 0) return false; + if (finite(quota.shortPercent)) relevant.push("shortPercent"); + const timestamps = observed.get(quota); + return relevant.every(window => finite(timestamps?.[window]) && now - timestamps![window]! < maxAgeMs); +} diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 56ba7abdd36..d5420ac7e72 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; import { isThirtyDayOnlyCodexPlan } from "./plan"; +import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness"; import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache"; @@ -333,7 +334,7 @@ function mergeAccountQuota( assignCarriedShort(next, existing, updatedAt, policyEvidence); if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows; next.resetCredits = quota.resetCredits; - return next; + return stampCodexQuotaUsageObservation(next, quota, existing); } if (snapshotHasWeekly(quota)) { @@ -393,7 +394,7 @@ function mergeAccountQuota( if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits; else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits; - return next; + return stampCodexQuotaUsageObservation(next, quota, existing); } /** @@ -612,6 +613,7 @@ export function updateAccountQuota( } if (resetCredits !== undefined) quota.resetCredits = resetCredits; + stampCodexQuotaUsageObservation(quota, { weeklyPercent: nextWeekly, monthlyPercent: nextMonthly }, existing); accountQuota.set(accountId, quota); // This legacy writer has no physical credential provenance. if (accountId === MAIN_CODEX_ACCOUNT_ID) mainPolicyQuota = null; diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 5a092186d32..7ee760bed45 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1,9 +1,11 @@ +import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch"; +import { codexQuotaHasFreshUsage } from "./quota-observation-freshness"; import { saveConfigPreservingClaudeCode } from "../config"; import { isCodexAccountGenerationLive, registerCodexRefreshGenerationHandoff } from "./account-store"; import { handOffThreadAffinityGeneration } from "./routing/thread-affinity"; import { codexAccountLogLabel } from "./account-label"; import { isCodexAccountPaused } from "./account-pause"; -import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority"; +import { clearCodexAccountPin, pinnedCodexAccountId, codexAccountPriorityFailbackEnabled, CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "./account-priority"; import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability"; import { markAccountNeedsReauth } from "./account-runtime-state"; import { codexAccountPinDrainReason } from "./routing/pin-drain"; @@ -492,6 +494,32 @@ export function resolveCodexAccountForThread( return resolution.status === "selected" ? resolution.accountId : null; } +/** The opt-in may preempt priority, never eligibility, a quota refusal or a manual pin. */ +function pickAffinityPriorityFailback( + config: OcxConfig, + accountId: string, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + if (!codexAccountPriorityFailbackEnabled(config, accountId) + || accountPoolStrategyForScope(config, quotaScope) !== "quota") return null; + const candidate = pickPriorityPreemption(config, accountId, now, quotaScope, selectionOptions); + if (!candidate || hasUnrecoveredCodexQuotaRefusal(candidate, quotaScope) + || shouldFailover(config, candidate, now)) return null; + const quota = getAccountQuota(candidate); + const plan = getPoolAccountPlanForSelection(config, candidate, selectionOptions); + // Retained bars alone are not a reason to discard a healthy conversation's cache. + if (!quota || !codexQuotaHasFreshUsage(quota, plan, now, CODEX_PRIORITY_FAILBACK_REFRESH_MS) + || !Number.isFinite(quota.updatedAt) + || now - quota.updatedAt >= CODEX_PRIORITY_FAILBACK_REFRESH_MS + || (quota.shortObservedAt !== undefined + && now - quota.shortObservedAt >= CODEX_PRIORITY_FAILBACK_REFRESH_MS)) return null; + const usage = computeCodexUsageScore(quota, plan, now); + const threshold = getEffectiveCodexAutoSwitchThreshold(config, candidate); + return !isUnknownUsage(usage) && usage < 100 && (threshold <= 0 || usage < threshold) ? candidate : null; +} + function previewReusableAffinityAccount( entry: ThreadAffinityEntry | undefined, config: OcxConfig, @@ -528,10 +556,12 @@ function previewReusableAffinityAccount( if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") { return resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions) ?? entry.accountId; } + const recovered = pickAffinityPriorityFailback(config, entry.accountId, now, quotaScope, selectionOptions); + if (recovered) return recovered; // Quota strategy only: non-quota strategies keep affinity for ongoing threads // (new-session-only rotation — docs / affinity policy A). if (accountPoolStrategyForScope(config, quotaScope) === "quota") { - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId); if (threshold > 0) { const usage = computeCodexUsageScore( getAccountQuota(entry.accountId), @@ -564,7 +594,7 @@ function resetFirstAffinityReplacement( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId); if (threshold <= 0) return null; const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now); if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null; @@ -652,7 +682,9 @@ function reevaluateAffinityQuota( return replacement; } if (strategy !== "quota") return null; - const threshold = config.autoSwitchThreshold ?? 80; + const recovered = pickAffinityPriorityFailback(config, entry.accountId, now, quotaScope, selectionOptions); + if (recovered) { entry.lastReevalAt = now; return recovered; } + const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId); const usage = threshold > 0 ? computeCodexUsageScore( getAccountQuota(entry.accountId), @@ -773,7 +805,7 @@ export function previewCodexAccountForRequest( } active = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions) ?? active; - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, active); if (threshold > 0) { const usage = computeCodexUsageScore( getAccountQuota(active), @@ -932,7 +964,7 @@ export function resolveCodexAccountForThreadDetailed( if (entry.transientHoldSince !== undefined) delete entry.transientHoldSince; if (entry.transientDetourAccountId !== undefined) delete entry.transientDetourAccountId; // Periodic quota re-eval: a long-lived bound thread must still switch when - // it crosses autoSwitchThreshold, but only onto an account that has genuine + // it crosses its effective threshold, but only onto an account that has genuine // quota headroom AND is strictly cooler — moving to a destination still over // the threshold just trades the warmed prompt-cache prefix for an equally hot // account, which is the #4546 ping-pong. diff --git a/src/codex/routing/selection.ts b/src/codex/routing/selection.ts index 9ac5538a000..4c6a7889b19 100644 --- a/src/codex/routing/selection.ts +++ b/src/codex/routing/selection.ts @@ -1,3 +1,4 @@ +import { getEffectiveCodexAutoSwitchThreshold } from "../account-auto-switch"; import { isCodexAccountPaused } from "../account-pause"; import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority"; import { isSelectableCodexPoolAccount } from "../account-id"; @@ -250,7 +251,7 @@ export function hasCodexQuotaHeadroom( selectionOptions?: CodexAccountUsabilityOptions, now: number = Date.now(), ): boolean { - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, accountId); if (threshold <= 0) return true; const usage = computeCodexUsageScore( getAccountQuota(accountId), @@ -681,7 +682,7 @@ export function preferModelEntitledAccount( * * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only * fires when the tier filter has already excluded `active`, and only toward a - * tier that strictly outranks it. Threads bound by affinity never reach here. + * tier that strictly outranks it. Bound threads reach it only through explicit priority failback. */ export function pickPriorityPreemption( config: OcxConfig, @@ -721,7 +722,7 @@ export function applyQuotaAutoSwitch( selectionOptions?: CodexAccountUsabilityOptions, commitSharedSelection = true, ): string { - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, active); if (threshold <= 0) return active; const quota = getAccountQuota(active); const activeUsage = computeCodexUsageScore( diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 42d289c7e00..86e8be0b143 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -27,6 +27,7 @@ import { type CodexAccountUsabilityOptions, } from "./account-usability"; import { isCodexAccountPaused } from "./account-pause"; +import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch"; import { slugEquals } from "../providers/slug-codec"; import { isThreadSpawnRequest } from "../server/effort-policy"; import { PROVIDER_REGISTRY } from "../providers/registry"; @@ -157,8 +158,8 @@ export function buildSubagentModelChain( return normalizedChain(primary, config, extraFallback); } -function quotaThreshold(config: OcxConfig): number { - const threshold = config.autoSwitchThreshold ?? 80; +function quotaThreshold(config: OcxConfig, accountId: string): number { + const threshold = getEffectiveCodexAutoSwitchThreshold(config, accountId); return threshold > 0 ? threshold : Number.POSITIVE_INFINITY; } @@ -233,7 +234,7 @@ export function isNativeModelQuotaExhausted( // rather than letting the scorer read wall time - the two would silently diverge. const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId), now); if (usage >= CODEX_UNKNOWN_USAGE_SCORE) return false; - return usage >= quotaThreshold(config); + return usage >= quotaThreshold(config, resolvedAccountId); } export function isModelHealthBlocked( diff --git a/src/config.ts b/src/config.ts index 0c63a168bad..8b4dc757c3c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,7 +10,7 @@ import { } from "./codex/custom-model-catalog-migration"; import { refreshUserCostOverlays } from "./usage/user-cost-overlays"; import { - clearPendingConfigTopLevelDeletions, + clearPendingConfigDeletions, projectConfigRebaseProvenance, } from "./config/rebase-provenance"; import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; @@ -343,7 +343,7 @@ export function initializePersistedConfigIfMissing( adoptCustomModelCatalogMigration(config, persisted); if (persisted.configRebaseProvenance === undefined) delete config.configRebaseProvenance; else config.configRebaseProvenance = structuredClone(persisted.configRebaseProvenance); - clearPendingConfigTopLevelDeletions(config); + clearPendingConfigDeletions(config); refreshUserCostOverlays(persisted); return "created"; } catch (cause) { @@ -367,7 +367,7 @@ export function saveConfig(config: OcxConfig): void { adoptCustomModelCatalogMigration(config, withProvenance); if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance; else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance); - clearPendingConfigTopLevelDeletions(config); + clearPendingConfigDeletions(config); }); } diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index a3893b98af9..b5019b95ff7 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -52,6 +52,7 @@ import { clientConnectionSchema, CODEX_ACCOUNT_PIN_PATTERN, codexAccountPrioritiesSchema, + codexAccountAutoSwitchThresholdsSchema, codexPoolSchema, codexQuotaAutoRefreshSchema, credentialGroupsSchema, @@ -347,6 +348,13 @@ function codexAccountPrioritiesError(value: unknown): string | null { return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities."); } } + if (raw.codexAccountAutoSwitchThresholds !== undefined) { + const parsed = codexAccountAutoSwitchThresholdsSchema.safeParse(raw.codexAccountAutoSwitchThresholds); + if (!parsed.success) { + return schemaDiagnosticsError(parsed.error) + .replace("schema_invalid: ", "schema_invalid: codexAccountAutoSwitchThresholds."); + } + } // Tested as a string rather than coerced: `String(123)` matches the id pattern, so a // coercing guard waves a non-string pin through to the schema, where `.catch(undefined)` // drops it and reports the write as a success — the exact silent-degrade this guards. diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 8715b1f146b..80492fc20f8 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -3,6 +3,9 @@ import { configReasoningPinsConfigError } from "./provider-validation"; import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration"; import { refreshPreservedProviderOwner, refreshUserCostOverlays } from "../usage/user-cost-overlays"; import { + applyConfigObjectChildDeletions, + clearPendingConfigObjectChildDeletions, + prepareConfigObjectChildDeletionRebase, clearPendingConfigTopLevelDeletions, configHasRebaseProvenance, configRebaseDeletionKeys, @@ -261,12 +264,14 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}), }); + const childDeletions = prepareConfigObjectChildDeletionRebase(config); reconcileConfigRecord( config as unknown as Record, persistedBaseline as unknown as Record, persisted as unknown as Record, new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]), ); + applyConfigObjectChildDeletions(config, childDeletions); if (claudeGuardArmed && !pendingLiveClaudeMutation) { if (persisted.claudeCode === undefined) delete config.claudeCode; @@ -319,6 +324,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { const pinError = configReasoningPinsConfigError(config); if (pinError) throw new Error(pinError); withConfigMutationLockSync(() => { + const childDeletions = prepareConfigObjectChildDeletionRebase(config); const bindingBaseline = persistedLiveServerBinding.get(config); // One authoritative pre-write read feeds both the live-config reconciliation and // custom-model deletion migration. A second read could observe different bytes. @@ -359,6 +365,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { for (const key of deletedKeys) delete (config as unknown as Record)[key]; } } + applyConfigObjectChildDeletions(config, childDeletions); if (claudeCodeBaseline.has(config)) { if (onDisk !== undefined) { const baseline = claudeCodeBaseline.get(config); @@ -398,6 +405,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance); liveConfigBaseline.set(config, structuredClone(projectedConfig)); } + clearPendingConfigObjectChildDeletions(config); clearPendingConfigTopLevelDeletions(config); }); } diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index daf16aba298..72aa94ad063 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -389,6 +389,10 @@ export function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validat if (raw !== undefined && validated.codexAccountPriorities === undefined) { warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled"); } + const rawThresholds = record?.codexAccountAutoSwitchThresholds; + if (rawThresholds !== undefined && validated.codexAccountAutoSwitchThresholds === undefined) { + warnings.push("codexAccountAutoSwitchThresholds is invalid (expected account ids mapped to integers between 0 and 100) — per-account usage thresholds are disabled"); + } return warnings; } diff --git a/src/config/rebase-provenance.ts b/src/config/rebase-provenance.ts index a799725d25a..b9a28eca0ce 100644 --- a/src/config/rebase-provenance.ts +++ b/src/config/rebase-provenance.ts @@ -1,8 +1,17 @@ import type { OcxConfig } from "../types"; const pendingTopLevelDeletions = new WeakMap>(); +const pendingObjectChildDeletions = new WeakMap>>(); export const CONFIG_REBASE_PROVENANCE_KEY = "configRebaseProvenance"; +export type ConfigObjectChildDeletions = Map>; + +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + export function parsedConfigRebaseDeletionKeys(config: OcxConfig): Set | null { const value = config.configRebaseProvenance; if (!value || typeof value !== "object" || Array.isArray(value)) return null; @@ -63,15 +72,79 @@ export function deleteConfigTopLevelKey(config: OcxCo pendingTopLevelDeletions.set(config, deleted); } +/** Delete one child from a record-valued field without tombstoning concurrent sibling keys. */ +export function deleteConfigObjectChildKey( + config: OcxConfig, + key: K, + childKey: string, +): void { + const record = config as unknown as Record; + const value = record[key as string]; + if (isPlainRecord(value)) { + delete value[childKey]; + if (Object.keys(value).length === 0) delete record[key as string]; + } + const byParent = pendingObjectChildDeletions.get(config) ?? new Map>(); + const deleted = byParent.get(key as string) ?? new Set(); + deleted.add(childKey); + byParent.set(key as string, deleted); + pendingObjectChildDeletions.set(config, byParent); +} + +/** + * Materialize record containers so the normal recursive three-way merge can adopt + * concurrent sibling keys. Returned child tombstones must be applied after that merge. + */ +export function prepareConfigObjectChildDeletionRebase(config: OcxConfig): ConfigObjectChildDeletions { + const pending = pendingObjectChildDeletions.get(config); + const active: ConfigObjectChildDeletions = new Map(); + if (!pending) return active; + const record = config as unknown as Record; + for (const [key, children] of pending) { + const current = record[key]; + const deleted = new Set([...children].filter(child => + !isPlainRecord(current) || !Object.hasOwn(current, child) || current[child] === undefined)); + if (deleted.size === 0) continue; + active.set(key, deleted); + if (!isPlainRecord(current)) record[key] = {}; + } + return active; +} + +/** Reassert explicit child deletions after rebasing, then omit an empty parent record. */ +export function applyConfigObjectChildDeletions( + config: OcxConfig, + deletions: ConfigObjectChildDeletions, +): void { + const record = config as unknown as Record; + for (const [key, children] of deletions) { + const current = record[key]; + if (!isPlainRecord(current)) continue; + for (const child of children) delete current[child]; + if (Object.keys(current).length === 0) delete record[key]; + } +} + export function clearPendingConfigTopLevelDeletions(config: OcxConfig): void { pendingTopLevelDeletions.delete(config); } +export function clearPendingConfigObjectChildDeletions(config: OcxConfig): void { + pendingObjectChildDeletions.delete(config); +} + +/** Consume both kinds of pending deletion only after a successful config publication. */ +export function clearPendingConfigDeletions(config: OcxConfig): void { + clearPendingConfigObjectChildDeletions(config); + clearPendingConfigTopLevelDeletions(config); +} + /** * Capture field replacements and deletion intent for a synchronous live-config save. * Restore before yielding on failure: an asynchronous rollback could overwrite a newer * mutation. Descriptors preserve absent versus explicitly undefined properties; the - * private pending set must also retain its original presence, even when it was empty. + * private pending deletion collections must also retain their original presence, even when empty. + * Nested values are not cloned: callers must replace containers before mutating their children. * Unrelated fields and the live object's identity/baselines are left in place. */ export function captureConfigTopLevelRollback( @@ -82,6 +155,9 @@ export function captureConfigTopLevelRollback( .map(key => [key, Object.getOwnPropertyDescriptor(config, key)] as const)); const pending = pendingTopLevelDeletions.get(config); const pendingBefore = pending === undefined ? undefined : new Set(pending); + const pendingChildren = pendingObjectChildDeletions.get(config); + const childrenBefore = pendingChildren === undefined ? undefined + : new Map([...pendingChildren].map(([key, children]) => [key, new Set(children)])); return () => { for (const [key, descriptor] of descriptors) { if (descriptor) Object.defineProperty(config, key, descriptor); @@ -90,5 +166,8 @@ export function captureConfigTopLevelRollback( // The absent fields above are restoration, not new user deletion commands. if (pendingBefore === undefined) pendingTopLevelDeletions.delete(config); else pendingTopLevelDeletions.set(config, new Set(pendingBefore)); + if (childrenBefore === undefined) pendingObjectChildDeletions.delete(config); + else pendingObjectChildDeletions.set(config, + new Map([...childrenBefore].map(([key, children]) => [key, new Set(children)]))); }; } diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 6d1e936670c..199a7641fc6 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -5,6 +5,7 @@ import { clientConnectionSchema, CODEX_ACCOUNT_PIN_PATTERN, codexAccountPrioritiesSchema, + codexAccountAutoSwitchThresholdsSchema, codexPoolSchema, codexQuotaAutoRefreshSchema, credentialGroupsSchema, @@ -217,6 +218,10 @@ export const configSchema = z.object({ // typo cannot trip the backup-and-defaults repair path and wipe providers or // pool accounts. Warning emitted in loadConfig. codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined), + // Malformed hand edits disable only account-local overrides, preserving the rest of config. + codexAccountAutoSwitchThresholds: codexAccountAutoSwitchThresholdsSchema.optional().catch(undefined), + // An invalid optional preference must not discard providers or credential rows. + codexAccountPriorityFailback: z.boolean().optional().catch(false), activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined), // A malformed hand edit must degrade to false without discarding providers, accounts, // or the exact selector map. Live writes remain strict. diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 0210a9d0c3d..13b2442e24b 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -14,6 +14,7 @@ import { } from "../provider-validation"; import { isValidCodexAccountNamespaceTarget } from "../../codex/account-namespace-match"; import { isCodexAccountPriorityKey } from "../../codex/account-priority"; +import { isCodexAccountAutoSwitchThresholdKey, parseCodexAutoSwitchThreshold } from "../../codex/account-auto-switch"; import { parseAccountPriority } from "../../codex/pool-rotation"; import { credentialGroupIssues } from "../../routing/identity-domains"; import { providerDestinationConfigError } from "../../lib/destination-policy"; @@ -633,6 +634,38 @@ const codexQuotaAutoRefreshEntrySchema = z.object({ const CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR = "quota auto-refresh keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_AUTO_SWITCH_THRESHOLDS_RECORD_ERROR = + "codexAccountAutoSwitchThresholds must be a plain object mapping Codex account ids to usage thresholds"; +const CODEX_ACCOUNT_AUTO_SWITCH_THRESHOLD_KEY_ERROR = + "usage-threshold keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_AUTO_SWITCH_THRESHOLD_VALUE_ERROR = + "account usage threshold must be an integer between 0 and 100"; + +export const codexAccountAutoSwitchThresholdsSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: CODEX_ACCOUNT_AUTO_SWITCH_THRESHOLDS_RECORD_ERROR }, +).superRefine((thresholds, ctx) => { + for (const [accountId, threshold] of Object.entries(thresholds)) { + if (!isCodexAccountAutoSwitchThresholdKey(accountId)) { + ctx.addIssue({ + code: "custom", + path: [accountId], + message: CODEX_ACCOUNT_AUTO_SWITCH_THRESHOLD_KEY_ERROR, + }); + } + if (parseCodexAutoSwitchThreshold(threshold) === null) { + ctx.addIssue({ + code: "custom", + path: [accountId], + message: CODEX_ACCOUNT_AUTO_SWITCH_THRESHOLD_VALUE_ERROR, + }); + } + } +}).pipe(z.record(z.string(), z.number().int())); + export const codexQuotaAutoRefreshSchema = z.custom>( (value): value is Record => !!value && typeof value === "object" diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 867f816414d..ff95de0f170 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -568,6 +568,7 @@ export async function prepareResponsesRequest( options.codexAuthPolicy ?? config, previewRequestScopedMainCredential, route.codexAccountId, + codexQuotaScopeForModel(route.modelId), ).preserve; // Deliberately NOT fenced on ownership: final auth derives `nativeMainSelectionOnly` from the // drain alone, and adding a term here would diverge from it in the other direction. @@ -824,6 +825,7 @@ export async function prepareResponsesRequest( options.codexAuthPolicy ?? config, recoveryRequestScopedMainCredential, route.codexAccountId, + codexQuotaScopeForModel(route.modelId), ).preserve; const recoverySelectionOptions = { nativeMainSelectionOnly: !recoveryNativeMainBlocked diff --git a/src/types/config.ts b/src/types/config.ts index 6f73b99cd42..6fc6939cce8 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -924,6 +924,12 @@ export interface OcxConfig { * which has no row, can be ordered too. Range -100..100. */ codexAccountPriorities?: Record; + /** + * Per-account proactive-switch threshold overrides. Missing account entry inherits + * `autoSwitchThreshold`; 0 disables usage-driven switching only for that account. + * Includes the synthetic `__main__` Desktop account. Range 0..100. + */ + codexAccountAutoSwitchThresholds?: Record; /** * Account id the operator last selected by hand. Suppresses upward priority * preemption until that account crosses the auto-switch threshold. Stores the @@ -1017,6 +1023,8 @@ export interface OcxConfig { activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ autoSwitchThreshold?: number; + /** Opt-in: return bound quota-strategy tasks to recovered higher-priority accounts. */ + codexAccountPriorityFailback?: boolean; /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */ accountPoolStrategy?: OcxAccountPoolRotationStrategy | "reset-first"; /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ diff --git a/structure/INDEX.md b/structure/INDEX.md index 129e4c392d4..56cbecc4143 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -69,6 +69,7 @@ The dashboard, the management API, and third-party client config ownership. | Doc | Scope | | --- | --- | | [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and usage accounting. | +| [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | @@ -96,16 +97,16 @@ A source area can be described by more than one doc, because these docs are orga | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `desktop/` | [`desktop-shell.md`](desktop-shell.md)
[`companion.md`](companion.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)
[`design-methodology.md`](design-methodology.md)
[`companion.md`](companion.md) | +| `gui/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`codex-account-controls.md`](codex-account-controls.md)
[`design-methodology.md`](design-methodology.md)
[`companion.md`](companion.md) | | `scripts/` | [`overview.md`](overview.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/adapters/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/cursor.md`](providers/cursor.md)
[`providers/chat-compat.md`](providers/chat-compat.md)
[`adapters/registry.md`](adapters/registry.md) | | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | | `src/claude/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.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)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-account-controls.md`](codex-account-controls.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md) | -| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`codex-account-controls.md`](codex-account-controls.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | diff --git a/structure/catalog.md b/structure/catalog.md index a3f8e24df18..7529184821f 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -530,3 +530,4 @@ Dashboard Fast-row persistence and client refresh follow the [Fast selector rows Compaction routing selects its configured model at Responses ingress under the [compaction routing contract](transports/responses.md#compaction-routing-overrides). Catalog selection remains conversation-owned. +Subagent account previews and live routing share the [priority failback](providers/openai-tiers.md#ongoing-priority-failback) decision; model eligibility and fixed catalog selectors retain their existing meaning. diff --git a/structure/codex-account-controls.md b/structure/codex-account-controls.md new file mode 100644 index 00000000000..9a40d802c3a --- /dev/null +++ b/structure/codex-account-controls.md @@ -0,0 +1,31 @@ +# Codex Account Controls + +Account-card controls in `gui/src/components/codex-account-pool-cards.tsx` and +`gui/src/components/codex-account-pool-main-card.tsx` project the account metadata owned by +`src/codex/auth-api.ts`. Management authentication and endpoint ownership remain in +[GUI and management API](gui-and-management-api.md). + +## Selection order + +Selection order must not be folded into the alias route. `codexAccountPriorities` is routing +metadata that Pool selection consults. It lives in config rather than on `CodexAccount` so the +`__main__` Desktop login can carry one; the alias route's rejection of `__main__` would be wrong here. +The matching CLI in `src/cli/account.ts` is `ocx account priority []`, +reading the current order when the value is omitted. Ordering invariants live in +[OpenAI account modes](providers/openai-tiers.md). + +## Custom usage thresholds + +Per-account usage thresholds follow the same sidecar shape: `codexAccountAutoSwitchThresholds` maps +added account ids or `__main__` to 0..100. Account cards expose a custom-threshold toggle without +showing an inherited percentage. Enabling it copies the current global threshold into a fixed +account override through `/api/codex-auth/auto-switch`; that override, including `0`, takes precedence +over later global changes. Disabling it sends `null`, removes the map entry, and restores inheritance +of the current global threshold and subsequent global changes. Quota bars and routing both use the +effective account value so the dashboard drain marker matches runtime. + +`gui/src/components/AccountAutoSwitchControl.tsx` keeps account-stable identity across saves, +preserves focus while a write is pending, and reconciles the draft to the persisted override after +acceptance or rejection. Internal keyboard focus movement does not commit a dirty draft; leaving +the control group does. An unrelated global refresh does not overwrite a dirty custom draft. +Mounted coverage lives in `gui/tests/codex-account-pool-pinned-badge.test.tsx`. diff --git a/structure/codex-home.md b/structure/codex-home.md index 47177b946ae..6a16055f74b 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -352,3 +352,5 @@ Codex pool settings and their consumers follow the [reset-first ordering contrac Upstream API-key usage follows the [physical-attempt account attribution contract](gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations. Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. + +Native-main owner claims and credential-generation backoff remain authoritative during [priority failback priming](providers/openai-tiers.md#ongoing-priority-failback); the preference grants no access through a fenced main profile. diff --git a/structure/config.md b/structure/config.md index 12cf9acbd10..131e00c3d69 100644 --- a/structure/config.md +++ b/structure/config.md @@ -552,3 +552,5 @@ exclusive `--socks5-off`. The start owner persists only an explicitly requested change; the off flag refuses to erase a non-SOCKS proxy. Invalid-address errors never echo user-supplied credentials, and status messages redact proxy URLs. The parser regression cases live in `tests/cli/start-args.test.ts`. + +`src/config/schema/config-schema.ts` accepts the opt-in `codexAccountPriorityFailback` preference and degrades malformed values to false without discarding providers. Its [routing contract](providers/openai-tiers.md#ongoing-priority-failback) requires quota strategy and a positive threshold. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5be71c0bfbc..0c5c3427749 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -11,7 +11,7 @@ Explicit Codex CLI installation observation is a local CLI surface, not a manage Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. The shared server request path follows the Responses -[core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. +[core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [priority failback](providers/openai-tiers.md#ongoing-priority-failback) preference adds no new dashboard control or account-eligibility override. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Management provider-validation calls use the [initialization-independent relative send-path validation](config.md#provider-relative-send-paths) before persistence. Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). @@ -163,7 +163,7 @@ this document owns is which module holds which area and what invariant that area | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | -| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | +| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Account usage thresholds share `PUT /api/codex-auth/auto-switch`: `{ threshold }` updates the global value, while `{ id, threshold }` updates an account override; account `threshold` is an integer 0..100 or `null` to inherit the global value, and account-list DTOs always expose `autoSwitchThresholdOverride` as that integer or `null`. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | @@ -225,12 +225,9 @@ by an event. One screen-owned stream has disconnect cleanup and bounded server s reconnection and the existing shared scheduler provide recovery. Codex retains its own established selection controller. These events cannot change credentials or select an account. -Selection order is the opposite case and must not be folded into the alias route. `codexAccountPriorities` -is routing metadata that Pool selection consults, it lives in config rather than on `CodexAccount` so the -`__main__` Desktop login can carry one, and the alias route's rejection of `__main__` would be wrong for -it. The matching CLI is `ocx account priority []`, reading the current order -when the value is omitted. Ordering invariants live in -[`openai-tiers.md`](providers/openai-tiers.md). +Selection order and custom usage thresholds are routing metadata, not aliases. Their account-card, +CLI and fixed-copy/null-inheritance contracts live in [Codex account controls](codex-account-controls.md); +runtime ordering and safety invariants remain in [OpenAI account modes](providers/openai-tiers.md). ## The client role owns no management plane diff --git a/structure/manifest.json b/structure/manifest.json index 702372d40b5..0c1c5191f70 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -337,6 +337,17 @@ "src/vision/" ] }, + { + "path": "codex-account-controls.md", + "tier": 5, + "title": "Codex Account Controls", + "scope": "Account selection order, custom usage thresholds, and stable account-card editing.", + "documents": [ + "gui/", + "src/cli/", + "src/codex/" + ] + }, { "path": "clients/integrations.md", "tier": 5, diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index dcd15fe1bac..64d26d661e5 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -537,3 +537,4 @@ manual-runtime update still prints the existing restart hint. Linux release bundling enables Tauri verbosity on the primary attempt so linuxdeploy diagnostics remain visible. macOS signing verbosity and publication/signature gates are unchanged. Universal macOS release builds install both aarch64-apple-darwin and x86_64-apple-darwin Rust targets. Windows builds consume the private JSON override generated by `desktop/scripts/windows-installer-config.ts`: only WiX ProductVersion uses the validated numeric public version core. Public package/application versions, tags, asset names and updater manifests retain full SemVer. The pinned Tauri MSI template permits equal-core replacement; manual MSI installation does not enforce same-core preview/stable downgrade prevention. +The existing `codex-routing`, `codex-auth-context` and `codex-quota-prime` tests cover [priority failback](../providers/openai-tiers.md#ongoing-priority-failback), including cache-default retention, stale evidence, main fencing and failed-attempt cadence. diff --git a/structure/overview.md b/structure/overview.md index e75cf6430ee..275099157ad 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -262,3 +262,4 @@ Dashboard Fast-row persistence and client refresh follow the [Fast selector rows Codex compaction can select a request-local model through the [existing Responses handlers](transports/responses.md#compaction-routing-overrides) for the configured manual and automatic triggers, while subsequent turns keep their conversation settings. +The optional [ongoing priority failback](providers/openai-tiers.md#ongoing-priority-failback) is distinct from default cache affinity and changes no credential-eligibility boundary. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 0fde5683acd..18514688f79 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -332,7 +332,7 @@ and lets the configured strategy pick within that tier. A tier drains only when the auto-switch threshold, cooling down, soft-avoided, paused, or needs reauth; unknown quota never drains a tier, and every tier drained leaves the eligible list untouched. Ordering never admits an account that pause, cooldown, health, or reauth already excluded, and never overrides those -exclusions. It adds no new rebind cause for a bound thread, which still moves only for the reasons it +exclusions. By default it adds no new rebind cause for a bound thread, which moves for the reasons it already had: a quota-strategy re-evaluation when `pool.cacheAffinity` is off (threshold) or the bound account cannot serve (the default), an account that stopped being selectable, or affinity expiry. A conversation carrying live uploaded-file references raises that bar to the default one regardless of @@ -342,6 +342,21 @@ headroom and strictly lower usage on the destination. The stable `__main__` alia equal terms with added accounts, which is what lets the Desktop login be ordered last. An absent or empty map reproduces the prior selection sequence exactly. +`codexAccountAutoSwitchThresholds` is persisted per-account routing metadata. Each 0..100 value +overrides global `autoSwitchThreshold` for that source account; absence inherits global, and 0 disables +only usage-driven switching from that account. Runtime must resolve this effective value anywhere it +asks whether an account is drained: unbound selection, quota/reset-first bound-task re-evaluation, +fill-first, priority-tier headroom, main-account pin reuse, previews, and subagent quota fallback. +Failure recovery remains separate. The stable `__main__` alias participates, deletion removes an added +account's sidecar entry, and malformed maps degrade as a unit rather than invalidating the config. +Zero never disables main-account hard-lock, startup policy binding, quota cooldowns, or model +entitlement checks. Pool pin reuse and caller-owned fallback enforce the relevant cooldown when +the bearer matches the already-observed main credential, including after an awaited entitlement +read. This uses memory-only identity evidence; unrelated callers and explicit Direct retain their +existing policy, and an independent model's cooldown does not block another quota scope. +Cache-affinity preservation across model detours still retires shared state at genuine 100% +exhaustion, including with a zero account override; below exhaustion the threshold remains disabled. + Preemption moves unbound requests back up when a higher tier regains headroom, and it holds the runtime cursor only. Under an independent quota scope it must never touch the shared active cursor, because the scopes track separate native quota groups and a scoped request has no standing to move @@ -679,7 +694,7 @@ extend that observation. ## Bound-thread rebind destination -A quota-strategy re-evaluation may move a LIVE thread binding only to an account that has genuine +An ordinary quota-strategy re-evaluation may move a LIVE thread only to an account that has genuine quota headroom and is also strictly cooler than the bound account. Both bars are load-bearing. Without the headroom bar, "strictly cooler" has no floor, so a pool whose every member sits in the 80-100% band hands a long conversation from account to account on consecutive turns; Codex prompt @@ -731,3 +746,26 @@ often that refusal fires and can never replace it. Upstream API-key usage follows the [physical-attempt account attribution contract](../gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations. `src/codex/auth-api/login-flow.ts` distinguishes HTTP 429 from an attempted warmup as `codex_warmup_rate_limited` and preserves that code in OAuth status. Failed attempted warmup does not persist replacement credentials; quota-confirmed deferred registration and HTTP 401/403 handling remain separate. `src/codex/warmup.ts` retains a known 429 when bounded error-body draining times out. + +## Ongoing priority failback + +`codexAccountPriorityFailback: true` explicitly permits bound quota-strategy tasks to return to a +strictly higher priority with known non-exhausted headroom. It defaults off and requires the bound +source account's effective threshold to be positive: source override 0 disables it, while a positive +source override enables it even with global 0. A candidate's positive effective threshold requires +usage below that value; candidate 0 removes only this preference, never unknown/exhausted, health, +entitlement or hard-lock exclusions. This separate preference can lose a warm cache; ordinary +rebinding stays strictly cooler. +The shared `routing.ts` helper gives preview and resolve the same result after generation, refusal, +health, pin and model checks; independent/model lanes retain their shared-cursor isolation. Stale quota +and short-window observation timestamps do not authorize this optional move. +`src/codex/quota-observation-freshness.ts` keeps process-local observation times for the quota windows +that contribute to the candidate's score. Credits and partial updates preserve carried timestamps; +hydrated bars alone cannot authorize failback until live observations cover those windows. This +evidence changes no persisted quota shape, scoring, recovery or hard-lock policy. `account-priority.ts` owns +the five-minute cadence; `auth-api/pool-mode-gate.ts` bounds request-triggered attempts, including failures, +while preserving main-owner claims and per-credential WHAM dispatch backoff. No requests means no new polling. +For this reason only, stale observation proof bypasses aggregate quota-cache freshness after the +existing attempt backoff. Main refresh keeps its owned lease and passive intent: cache bypass does +not clear an inference reauthentication mark. Other prime reasons retain their existing cache rules. +The split config schema degrades malformed optional values to false. Exact-account and Direct routes are unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index 26f5b2a43f8..b4e343923bd 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -596,3 +596,4 @@ registration succeeds. Bun updater lease and recovery behavior follows the [update transaction contract](ops/docs-and-release.md#bun-updater-ownership-transaction). Companion timeline and filtered totals follow the [companion usage contract](companion.md). +[Ongoing priority failback](providers/openai-tiers.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. diff --git a/structure/subagents.md b/structure/subagents.md index cf52d556baf..fd79c701d7a 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -414,3 +414,4 @@ Startup provider-id migration preserves the account binding between configuratio Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](gui-and-management-api.md#fast-selector-rows-setting). The [compaction routing override](transports/responses.md#compaction-routing-overrides) uses explicit request-kind and trigger metadata, independently of spawned-child markers. +[Ongoing priority failback](providers/openai-tiers.md#ongoing-priority-failback) keeps model-detour and independent-quota affinity isolated; preview remains read-only and no child changes an unrelated shared cursor. diff --git a/tests/cli/cli-account-pin-drain.test.ts b/tests/cli/cli-account-pin-drain.test.ts index baf486d932c..0459650b07f 100644 --- a/tests/cli/cli-account-pin-drain.test.ts +++ b/tests/cli/cli-account-pin-drain.test.ts @@ -9,7 +9,11 @@ import type { AccountDeps } from "../../src/cli/account-api"; */ const BASE_URL = "http://127.0.0.1:10100"; -function deps(putJson: Record, thresholdJson: Record): AccountDeps { +function deps( + putJson: Record, + thresholdJson: Record, + accounts = [{ id: putJson.activeCodexAccountId, autoSwitchThresholdOverride: null as number | null }], +): AccountDeps { return { baseUrl: BASE_URL, loadConfigImpl: () => ({ providers: { openai: { adapter: "codex" } } }) as never, @@ -21,6 +25,9 @@ function deps(putJson: Record, thresholdJson: Record { expect(result.err).not.toContain("auto-switch threshold"); }); + test("a reported quota drain names the selected account's override", async () => { + const result = await run(["use", "openai", "pool_hot"], deps({ + ok: true, + activeCodexAccountId: "pool_hot", + pinDrained: true, + pinDrainReason: "quota_threshold", + }, { autoSwitchThreshold: 80 }, [ + { id: "pool_other", autoSwitchThresholdOverride: 95 }, + { id: "pool_hot", autoSwitchThresholdOverride: 60 }, + ])); + + expect(result.code).toBe(0); + expect(result.err).toContain("is at or above the auto-switch threshold (60%)"); + expect(result.err).toContain("routing releases this pin on its next request"); + expect(result.err).not.toContain("80%"); + expect(result.err).not.toContain("95%"); + expect(result.err).not.toContain("may override this pin"); + }); + test("no reported drain keeps the generic caveat", async () => { const result = await run(["use", "openai", "pool_cool"], deps({ ok: true, diff --git a/tests/cli/cli-account-threshold.test.ts b/tests/cli/cli-account-threshold.test.ts new file mode 100644 index 00000000000..9a4167548a9 --- /dev/null +++ b/tests/cli/cli-account-threshold.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import { cmdAccount } from "../../src/cli/account"; +import type { OcxConfig } from "../../src/types"; + +const codexAccounts: Array> = [{ id: "__main__", isMain: true }]; +async function run(args: string[]): Promise<{ code: number; stderr: string }> { + const errors: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = () => {}; + console.error = (...values: unknown[]) => { errors.push(values.map(String).join(" ")); }; + try { + const code = await cmdAccount(args, { + baseUrl: "http://localhost:10100", + loadConfigImpl: () => ({ + port: 10100, defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + } as OcxConfig), + fetchImpl: (async (input, init) => { + const path = new URL(String(input)).pathname; + if (path === "/api/codex-auth/active") return Response.json( + init?.method === "PUT" ? { ok: true } : { activeId: "__main__", autoSwitchThreshold: 80 }, + ); + if (path === "/api/codex-auth/accounts") return Response.json({ accounts: codexAccounts }); + throw new Error("Unexpected account API path: " + path); + }) as typeof fetch, + }); + return { code, stderr: errors.join("\n") }; + } finally { + console.log = originalLog; + console.error = originalError; + } +} + + test("use openai omits the pin warning when the selected account disables usage switching", async () => { + codexAccounts[0]!.autoSwitchThresholdOverride = 0; + + const result = await run(["use", "openai", "main"]); + + expect(result.code).toBe(0); + expect(result.stderr).not.toContain("may override this pin"); + }); diff --git a/tests/codex-integration/codex-account-delete-atomicity.test.ts b/tests/codex-integration/codex-account-delete-atomicity.test.ts index 3e49033acc1..dea566c182a 100644 --- a/tests/codex-integration/codex-account-delete-atomicity.test.ts +++ b/tests/codex-integration/codex-account-delete-atomicity.test.ts @@ -30,6 +30,8 @@ import { } from "../../src/codex/quota"; import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; import * as configModule from "../../src/config"; +import { setCodexAccountAutoSwitchThresholdOverride } from "../../src/codex/account-auto-switch"; +import { prepareConfigObjectChildDeletionRebase } from "../../src/config/rebase-provenance"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -48,6 +50,7 @@ function seededConfig(): OcxConfig { config.codexAccountPickerEnabled = true; config.pausedCodexAccountIds = [ACCOUNT_ID]; config.codexAccountPriorities = { [ACCOUNT_ID]: 7 }; + config.codexAccountAutoSwitchThresholds = { [ACCOUNT_ID]: 65 }; config.activeCodexAccountPinned = ACCOUNT_ID; config.activeCodexAccountId = ACCOUNT_ID; saveConfig(config); @@ -76,6 +79,47 @@ afterEach(() => { }); describe("Codex account delete persistence ordering", () => { + test.each([ + [undefined, false], [undefined, true], [65, false], [65, true], + ] as const)("failed deletion preserves later threshold edits (override=%s, prior reset=%s)", (threshold, priorReset) => { + const seeded = seededConfig(); + seeded.codexAccountAutoSwitchThresholds = { + other: 30, ...(threshold === undefined ? {} : { [ACCOUNT_ID]: threshold }), + }; + saveConfig(seeded); + const config = loadConfig(); + configModule.armClaudeCodeBaseline(config); + configModule.deleteConfigTopLevelKey(config, "injectionPrompt"); + if (priorReset) setCodexAccountAutoSwitchThresholdOverride(config, "other", null); + const before = structuredClone(config); + const diskBefore = readFileSync(getConfigPath(), "utf8"); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(candidate => { + prepareConfigObjectChildDeletionRebase(candidate); + throw new Error("forced pre-write failure"); + }); + try { + expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow("forced pre-write failure"); + expect(config).toEqual(before); + expect(readFileSync(getConfigPath(), "utf8")).toBe(diskBefore); + expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); + } finally { + saveSpy.mockRestore(); + } + writeFileSync(getConfigPath(), JSON.stringify({ ...JSON.parse(diskBefore), + codexAccountAutoSwitchThresholds: { [ACCOUNT_ID]: 70, other: 85, sibling: 25 }, + })); + config.upstreamFailoverThreshold = 4; + configModule.saveConfigPreservingClaudeCode(config); + const expectedThresholds = { [ACCOUNT_ID]: 70, sibling: 25, ...(priorReset ? {} : { other: 85 }) }; + expect(config.codexAccountAutoSwitchThresholds).toEqual(expectedThresholds); + const persisted = loadConfig(); + expect(persisted.codexAccountAutoSwitchThresholds).toEqual(expectedThresholds); + expect(persisted.codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(true); + expect(persisted.upstreamFailoverThreshold).toBe(4); + expect(persisted.configRebaseProvenance).toEqual({ version: 1, deletedTopLevelKeys: ["injectionPrompt"] }); + }); + test("a config persistence failure leaves the account and destructive state intact", () => { const config = seededConfig(); const before = structuredClone(config); @@ -289,6 +333,7 @@ describe("Codex account delete persistence ordering", () => { expect(config.codexAccountNamespaces).toEqual({ stable: ACCOUNT_ID }); expect(config.pausedCodexAccountIds).toBeUndefined(); expect(config.codexAccountPriorities).toBeUndefined(); + expect(config.codexAccountAutoSwitchThresholds).toBeUndefined(); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(config.activeCodexAccountId).toBeUndefined(); expect(getCodexAccountCredential(ACCOUNT_ID)).toBeNull(); @@ -328,6 +373,7 @@ describe("Codex account delete persistence ordering", () => { expect(config.codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false); expect(config.pausedCodexAccountIds).toBeUndefined(); expect(config.codexAccountPriorities).toBeUndefined(); + expect(config.codexAccountAutoSwitchThresholds).toBeUndefined(); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(config.activeCodexAccountId).toBeUndefined(); expect(getCodexAccountCredential(ACCOUNT_ID)).toBeNull(); diff --git a/tests/codex-integration/codex-account-threshold-api.test.ts b/tests/codex-integration/codex-account-threshold-api.test.ts new file mode 100644 index 00000000000..06a4ff714bf --- /dev/null +++ b/tests/codex-integration/codex-account-threshold-api.test.ts @@ -0,0 +1,413 @@ +import { registerWarmupRateLimitCases } from "../helpers/codex-warmup-rate-limit"; +import { registerResetCreditConsumeValidationTests } from "../helpers/reset-credit-consume-validation"; +import * as usageHistoryModule from "../../src/usage/log"; +import { getAccountQuotaHistory } from "../../src/codex/quota"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import type { ServerWebSocket } from "bun"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + acquireNativeMainProfileDrain, + getNativeMainProfileRequestCount, + resetLifecycleDrainStateForTests, +} from "../../src/server/lifecycle"; +import { fallbackCodexAccountLogLabel } from "../../src/codex/account-label"; +import { + handleCodexAuthAPI, updateAccountQuota, getAccountQuota, + checkAccountIdCollision, getMainChatgptAccountId, + markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, + clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, fetchMainAccountInfoSnapshot, + clearCodexQuotaPrimeState, primeCodexPoolQuotas, seedCodexAuthAdmissionForTests, + type CodexAuthAccountDto, + listCodexAuthAccounts, + setAccountQuotaFromParsed, +} from "../../src/codex/auth-api"; +import { + getCodexAccountCredential, + listCodexAccountIds, + readCodexAccountRecord, + removeCodexAccountCredential, + saveCodexAccountCredential, +} from "../../src/codex/account-store"; +import * as accountStoreModule from "../../src/codex/account-store"; +import { isCodexAccountUsable } from "../../src/codex/account-usability"; +import * as reserveAvailabilityModule from "../../src/codex/reserve-availability"; +import { getMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { openManualResetCreditOperation } from "../../src/codex/reset-credit-operation-ledger"; +import { quotaRecoveryRecordForTests, resetQuotaRecoveryForTests } from "../../src/codex/quota-401-recovery"; +import { watchdogMs } from "../helpers/ci-watchdog"; +import { + clearCodexUpstreamHealth, + clearCodexUpstreamHealthForAccount, + getCodexQuotaHealthSnapshot, + claimManualResetCooldowns, + settleManualResetCooldown, + clearThreadAccountMap, + getCodexUpstreamHealth, + recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, + resolveCodexAccountForThread, +} from "../../src/codex/routing"; +import { pinnedCodexAccountId, setCodexAccountPin } from "../../src/codex/account-priority"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { + clearCodexWebSocketRegistry, + getTrackedCodexWebSocketCountForAccount, + registerCodexWebSocket, +} from "../../src/codex/websocket-registry"; +import type { OcxConfig } from "../../src/types"; +import type { WsData } from "../../src/server/ws-bridge"; +import { handleNativeProfileAPI } from "../../src/codex/native-profile-api"; +import type { NativeProfileManager } from "../../src/codex/native-profile-manager"; +import { getMainPolicyQuota } from "../../src/codex/quota"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; +import { reconcileCodexPlansFromTokens, resetJwtPlanNotesForTests } from "../../src/codex/plan-from-token"; +import { + deleteCodexAccount, + reconcileMainCodexAccountRuntimeState, + resetMainCodexAccountIdentityTrackingForTests, +} from "../../src/codex/account-lifecycle"; +import { + ConfigMutationLockError, + armClaudeCodeBaseline, + getConfigPath, + loadConfig, + saveConfig, + setPersistedConfigMutationBeforeCommitForTests, +} from "../../src/config"; +import * as configModule from "../../src/config"; +import { setCodexAccountAutoSwitchThresholdOverride } from "../../src/codex/account-auto-switch"; +import { prepareConfigObjectChildDeletionRebase } from "../../src/config/rebase-provenance"; +import type { CatalogDisposition } from "../../src/codex/convergence-types"; +import { captureConfigGeneration, registerStateStore } from "../../src/lib/state-store-sweeper"; +import { + reconcileLiveStateStores, + setLiveStateStoreConfig, + STATE_STORE_REGISTRATIONS, +} from "../../src/lib/state-store-registrations"; +import { + listOpenAiForwardSidecarCandidates, + resolveFirstUsableOpenAiSidecar, +} from "../../src/providers/openai-sidecar"; +import { BOUNDED_BODY_MAX_BYTES } from "../../src/lib/bounded-body"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let TEST_DIR = ""; +let TEST_CODEX_HOME = ""; +const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; +let previousManualImportEnv: string | undefined; +let previousFetch: typeof fetch; + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + providers: {}, + defaultProvider: "openai", + codexAccounts: [], + ...overrides, + }; +} + +function seedPoolAccount( + config: OcxConfig, + account: { + id: string; + email: string; + plan?: string; + accessToken?: string; + refreshToken?: string; + chatgptAccountId?: string; + expiresAt?: number; + }, +): void { + config.codexAccounts = [ + ...(config.codexAccounts ?? []), + { id: account.id, email: account.email, plan: account.plan, isMain: false }, + ]; + saveCodexAccountCredential(account.id, { + accessToken: account.accessToken ?? `access-${account.id}`, + refreshToken: account.refreshToken ?? `refresh-${account.id}`, + expiresAt: account.expiresAt ?? Date.now() + 5 * 60_000, + chatgptAccountId: account.chatgptAccountId ?? `acct-${account.id}`, + }); +} + +beforeEach(() => { + resetLifecycleDrainStateForTests(); + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + previousManualImportEnv = process.env[MANUAL_IMPORT_ENV]; + previousFetch = globalThis.fetch; + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-codex-auth-api-")); + TEST_CODEX_HOME = join(TEST_DIR, "codex"); + mkdirSync(TEST_CODEX_HOME, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.CODEX_HOME = TEST_CODEX_HOME; + delete process.env[MANUAL_IMPORT_ENV]; + clearAccountNeedsReauth("__main__"); + clearAccountQuota(); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + clearCodexWebSocketRegistry(); + resetMainCodexAccountIdentityTrackingForTests(); + resetJwtPlanNotesForTests(); + resetQuotaRecoveryForTests(); +}); + +afterEach(async () => { + resetLifecycleDrainStateForTests(); + setPersistedConfigMutationBeforeCommitForTests(null); + clearAccountNeedsReauth("__main__"); + clearAccountQuota(); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + clearCodexWebSocketRegistry(); + globalThis.fetch = previousFetch; + resetQuotaRecoveryForTests(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousManualImportEnv === undefined) delete process.env[MANUAL_IMPORT_ENV]; + else process.env[MANUAL_IMPORT_ENV] = previousManualImportEnv; + await flushConfigDirHardeningForTests(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (TEST_DIR) removeTreeWithRetry(TEST_DIR); + TEST_DIR = ""; + TEST_CODEX_HOME = ""; +}); + +describe("Codex per-account threshold API", () => { + async function putAccountAutoSwitch(config: OcxConfig, body: unknown): Promise { + const req = new Request("http://localhost/api/codex-auth/auto-switch", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + return (await handleCodexAuthAPI(req, new URL(req.url), config))!; + } + + test("PUT /api/codex-auth/auto-switch persists a pool account override", async () => { + const config = makeConfig({ autoSwitchThreshold: 95 }); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + + const resp = await putAccountAutoSwitch(config, { id: "work", threshold: 60 }); + + expect(resp.status).toBe(200); + expect(await resp.json()).toMatchObject({ + ok: true, + id: "work", + autoSwitchThresholdOverride: 60, + autoSwitchThreshold: 60, + }); + expect(config.codexAccountAutoSwitchThresholds).toEqual({ work: 60 }); + }); + + test("PUT /api/codex-auth/auto-switch persists a main-account override", async () => { + const config = makeConfig({ autoSwitchThreshold: 95 }); + + const resp = await putAccountAutoSwitch(config, { + id: MAIN_CODEX_ACCOUNT_ID, + threshold: 0, + }); + + expect(resp.status).toBe(200); + expect(await resp.json()).toMatchObject({ + id: MAIN_CODEX_ACCOUNT_ID, + autoSwitchThresholdOverride: 0, + autoSwitchThreshold: 0, + }); + expect(config.codexAccountAutoSwitchThresholds).toEqual({ + [MAIN_CODEX_ACCOUNT_ID]: 0, + }); + }); + + test.each([ + ["new override", undefined, 0], + ["replacement override", { __main__: 60, side: 35 }, 0], + ["last override reset", { __main__: 60 }, null], + ["sibling-preserving reset", { __main__: 60, side: 35 }, null], + ] as const)("account threshold rollback preserves live and disk state after lock contention: %s", async (_label, thresholds, threshold) => { + saveConfig(makeConfig({ providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + autoSwitchThreshold: 95, ...(thresholds ? { codexAccountAutoSwitchThresholds: { ...thresholds } } : {}) })); + const config = loadConfig(); + armClaudeCodeBaseline(config); + // Established deletion provenance allows rebasing newly added disk-only fields. + configModule.deleteConfigTopLevelKey(config, "injectionPrompt"); + const previousMap = config.codexAccountAutoSwitchThresholds; + const previousDescriptor = Object.getOwnPropertyDescriptor(config, "codexAccountAutoSwitchThresholds"); + const diskBefore = readFileSync(getConfigPath(), "utf8"); + const lockDatabase = new Database(join(TEST_DIR, "config-mutation.sqlite"), { create: true }); + lockDatabase.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + await expect(putAccountAutoSwitch(config, { id: MAIN_CODEX_ACCOUNT_ID, threshold })) + .rejects.toBeInstanceOf(ConfigMutationLockError); + expect(readFileSync(getConfigPath(), "utf8")).toBe(diskBefore); + expect(config.codexAccountAutoSwitchThresholds).toBe(previousMap); + expect(config.codexAccountAutoSwitchThresholds).toEqual(thresholds); + expect(Object.getOwnPropertyDescriptor(config, "codexAccountAutoSwitchThresholds")).toEqual(previousDescriptor); + } finally { + lockDatabase.exec("ROLLBACK"); + lockDatabase.close(); + } + // A later unrelated save must not publish the rejected override/reset or erase a disk sibling. + writeFileSync(getConfigPath(), JSON.stringify({ ...JSON.parse(diskBefore), + codexAccountAutoSwitchThresholds: { ...thresholds, concurrent: 25 }, autoSwitchThreshold: 90 })); + config.upstreamFailoverThreshold = 4; + configModule.saveConfigPreservingClaudeCode(config); + expect(loadConfig()).toMatchObject({ autoSwitchThreshold: 90, upstreamFailoverThreshold: 4, + codexAccountAutoSwitchThresholds: { ...thresholds, concurrent: 25 } }); + expect(config.codexAccountAutoSwitchThresholds).toEqual({ ...thresholds, concurrent: 25 }); + expect(loadConfig().configRebaseProvenance).toEqual({ version: 1, deletedTopLevelKeys: ["injectionPrompt"] }); + }); + + test.each(["lock contention", "save boundary failure"] as const)( + "account threshold rollback restores pending child deletions after %s", async failure => { + saveConfig(makeConfig({ providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + autoSwitchThreshold: 95, codexAccountAutoSwitchThresholds: { work: 60 } })); + const config = loadConfig(); + armClaudeCodeBaseline(config); + // This pending, previously accepted reset must survive rollback of the next request. + setCodexAccountAutoSwitchThresholdOverride(config, "work", null); + const previousDescriptor = Object.getOwnPropertyDescriptor(config, "codexAccountAutoSwitchThresholds"); + const diskBefore = readFileSync(getConfigPath(), "utf8"); + const lockDatabase = failure === "lock contention" + ? new Database(join(TEST_DIR, "config-mutation.sqlite"), { create: true }) : undefined; + lockDatabase?.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + const saveSpy = failure === "save boundary failure" + ? spyOn(configModule, "saveConfigPreservingClaudeCode").mockImplementation(candidate => { + // Real pre-save preparation can recreate the absent parent before a later failure. + prepareConfigObjectChildDeletionRebase(candidate); + throw new ConfigMutationLockError("synthetic config commit failure"); + }) : undefined; + try { + await expect(putAccountAutoSwitch(config, { id: MAIN_CODEX_ACCOUNT_ID, threshold: null })) + .rejects.toBeInstanceOf(ConfigMutationLockError); + expect(readFileSync(getConfigPath(), "utf8")).toBe(diskBefore); + expect(Object.getOwnPropertyDescriptor(config, "codexAccountAutoSwitchThresholds")).toEqual(previousDescriptor); + } finally { + saveSpy?.mockRestore(); + lockDatabase?.exec("ROLLBACK"); + lockDatabase?.close(); + } + writeFileSync(getConfigPath(), JSON.stringify({ ...JSON.parse(diskBefore), + codexAccountAutoSwitchThresholds: { work: 85, __main__: 70, concurrent: 25 }, autoSwitchThreshold: 90 })); + config.upstreamFailoverThreshold = 4; + configModule.saveConfigPreservingClaudeCode(config); + // Keep old work deletion, discard rejected main deletion, adopt concurrent additions. + expect(config.codexAccountAutoSwitchThresholds).toEqual({ __main__: 70, concurrent: 25 }); + expect(loadConfig()).toMatchObject({ autoSwitchThreshold: 90, upstreamFailoverThreshold: 4, + codexAccountAutoSwitchThresholds: { __main__: 70, concurrent: 25 } }); + expect(loadConfig().codexAccountAutoSwitchThresholds).not.toHaveProperty("work"); + }, + ); + + test("PUT /api/codex-auth/auto-switch rejects an unknown pool account", async () => { + const config = makeConfig({ autoSwitchThreshold: 95 }); + + const resp = await putAccountAutoSwitch(config, { id: "missing", threshold: 60 }); + + expect(resp.status).toBe(404); + expect(config.codexAccountAutoSwitchThresholds).toBeUndefined(); + }); + + test("a null account threshold restores global inheritance and drops an empty map", async () => { + const config = makeConfig({ + autoSwitchThreshold: 95, + codexAccountAutoSwitchThresholds: { work: 60 }, + }); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + + const resp = await putAccountAutoSwitch(config, { id: "work", threshold: null }); + + expect(resp.status).toBe(200); + expect(await resp.json()).toMatchObject({ + id: "work", + autoSwitchThresholdOverride: null, + autoSwitchThreshold: 95, + }); + expect(config.codexAccountAutoSwitchThresholds).toBeUndefined(); + }); + + test("account threshold overrides include main and are reported by the account list", async () => { + const config = makeConfig({ + autoSwitchThreshold: 95, + codexAccountAutoSwitchThresholds: { work: 60, [MAIN_CODEX_ACCOUNT_ID]: 0 }, + }); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + seedPoolAccount(config, { id: "side", email: "side@example.test" }); + + const accounts = await listCodexAuthAccounts(config); + + expect(accounts.find(a => a.id === "work")?.autoSwitchThresholdOverride).toBe(60); + expect(accounts.find(a => a.id === "side")?.autoSwitchThresholdOverride).toBeNull(); + expect(accounts.find(a => a.isMain)?.autoSwitchThresholdOverride).toBe(0); + }); + + test.each([ + [true, "p***n@example.test"], + [false, "person@example.test"], + ] as const)("account threshold DTOs preserve email masking=%s", async (maskEmails, expectedEmail) => { + const config = makeConfig({ + autoSwitchThreshold: 95, + codexAccountAutoSwitchThresholds: { work: 0, missing: 60 }, + privacy: { maskEmails }, + }); + seedPoolAccount(config, { id: "work", email: "person@example.test" }); + seedPoolAccount(config, { id: "missing", email: "person@example.test" }); + updateAccountQuota("work", 99); + removeCodexAccountCredential("missing"); + + const accounts = await listCodexAuthAccounts(config); + + expect(accounts.find(account => account.id === "work")).toMatchObject({ + email: expectedEmail, + autoSwitchThresholdOverride: 0, + hasCredential: true, + }); + expect(accounts.find(account => account.id === "missing")).toMatchObject({ + email: expectedEmail, + autoSwitchThresholdOverride: 60, + hasCredential: false, + needsReauth: true, + }); + expect(JSON.stringify(accounts)).not.toContain("access-work"); + expect(JSON.stringify(accounts)).not.toContain("refresh-work"); + }); + + test.each([ + ["a negative threshold", -1], + ["a threshold above 100", 101], + ["a fractional threshold", 1.5], + ["a numeric string", "80"], + ["a missing threshold", undefined], + ] as const)("rejects %s as an account threshold override", async (_label, threshold) => { + const config = makeConfig(); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + + const resp = await putAccountAutoSwitch(config, { id: "work", threshold }); + + expect(resp.status).toBe(400); + expect(config.codexAccountAutoSwitchThresholds).toBeUndefined(); + }); + +}); diff --git a/tests/codex-integration/codex-account-threshold-auth.test.ts b/tests/codex-integration/codex-account-threshold-auth.test.ts new file mode 100644 index 00000000000..362c72c51d9 --- /dev/null +++ b/tests/codex-integration/codex-account-threshold-auth.test.ts @@ -0,0 +1,608 @@ +import { registerStoredDirectIdentityTests } from "../helpers/stored-direct-identity"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyCodexAuthContextToProvider, + assertCodexAuthContextNotCooled, + CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE, + CodexAccountCooldownError, + CodexAuthContextError, + CodexDirectAuthenticationError, + CodexMainProfileDrainingError, + CodexModelAvailabilityError, + CodexPoolAuthenticationError, + CodexThreadAffinityExpiredError, + codexMainProfileDrainingResponse, + __resetNativeMainFenceReasonLog, + cooldownErrorMessage, + cooldownErrorResponse, + headersForCodexAuthContext, + materializeCodexUpstreamAuth, + CodexMainSubstitutionUnavailableError, + isCodexAuthContextUsable, + codexPoolAffinityKey, + resolveCodexAuthContext, + requestOwnedMainPinState, + releaseCodexAuthContextProbeLease, + shouldMarkAccountNeedsReauthForCodexAuthFailure, + stripCodexRuntimeProviderFields, +} from "../../src/codex/auth-context"; +import { + CodexCredentialGenerationConflictError, + CodexCredentialRefreshLockTimeoutError, + CodexCredentialRefreshBusyError, + CodexCredentialRefreshStaleError, + getCodexAccountCredential, + getValidCodexToken, + readCodexAccountRecord, + removeCodexAccountCredential, + saveCodexAccountCredential, +} from "../../src/codex/account-store"; +import { ConfigMutationLockError, getConfigPath } from "../../src/config"; +import { + getMainAccountPlan, + MAIN_CODEX_ACCOUNT_ID, + setMainAccountPlan, +} from "../../src/codex/main-account"; +import { + clearMainAccountInfoCache, + observeMainQuotaCredential, + observeMainQuotaIdentity, +} from "../../src/codex/main-account-cache"; +import { + clearAccountNeedsReauth, + clearAccountQuota, + handleCodexAuthAPI, + isAccountNeedsReauth, + markAccountNeedsReauth, + setAccountQuotaFromParsed, +} from "../../src/codex/auth-api"; +import { __resetGuardianState, guardianSweep } from "../../src/oauth/token-guardian"; +import { + CODEX_THREAD_AFFINITY_IDLE_TTL_MS, + CODEX_QUOTA_PROBE_INTERVAL_MS, + clearCodexUpstreamHealth, + clearThreadAccountMap, + getCodexQuotaHealthSnapshot, + recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, +} from "../../src/codex/routing"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { + blockNativeMainStartupForUnownedServiceHome, + completeNativeMainRecovery, + initializeNativeMainStartupGate, +} from "../../src/codex/native-profile-startup"; +import type { NativeProfileManager } from "../../src/codex/native-profile-manager"; +import { + acquireNativeMainProfileDrain, + codexAccountSelectionForTurn, + tryAdmitTurn, +} from "../../src/server/lifecycle"; +import type { CodexModelEntitlementSnapshot } from "../../src/codex/model-entitlements"; +import { recordContextSessionOwner, clearContextSessionOwnersForTests } from "../../src/codex/context-owner"; +import { handleContextHistory } from "../../src/server/context-history"; +import { resetContextRelayActivationForTests } from "../../src/codex/context-compat"; +import { hasForwardableCodexBearer } from "../../src/server/auth-cors"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir: string; +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +beforeEach(() => { + // This suite validates refresh admission and auth-context outcomes. Real icacls + // processes are covered elsewhere and can retain temp-dir handles long enough + // to obscure those assertions under Windows isolated-test load. + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + testDir = mkdtempSync(join(tmpdir(), "ocx-auth-ctx-")); + previousOpencodexHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = testDir; + // Isolate the main-account credential source: testDir has no auth.json, so the main + // account is deterministically absent (these cases test pool-only fail-closed behavior). + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = testDir; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + setMainAccountPlan(null); + __resetGuardianState(); + clearAccountNeedsReauth("pool-a"); + clearAccountNeedsReauth("pool-b"); +}); + +afterEach(() => { + resetContextRelayActivationForTests(); + setIcaclsRunnerForTests(null); + removeTreeWithRetry(testDir); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + setMainAccountPlan(null); + __resetGuardianState(); + clearAccountNeedsReauth("pool-a"); + clearAccountNeedsReauth("pool-b"); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; +}); + +function config(): OcxConfig { + return { + port: 10100, + defaultProvider: "routed", + activeCodexAccountId: "pool-a", + providers: { + routed: { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "routed-key" }, + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.test/backend-api/codex", authMode: "forward" }, + }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "pool@example.test", isMain: false, chatgptAccountId: "pool_acc" }, + ], + }; +} + +describe("Codex account thresholds preserve auth fences", () => { + test.each([99, 100])("a zero account threshold preserves entitlement and exhaustion policy at %s usage", async usage => { + const cfg = config(); + cfg.autoSwitchThreshold = 50; + cfg.codexAccountAutoSwitchThresholds = { "pool-a": 0 }; + cfg.activeCodexAccountPinned = "pool-a"; + cfg.codexAccounts?.push({ id: "pool-b", email: "b@example.test", isMain: false }); + for (const id of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(id, { + accessToken: `${id}-token`, + refreshToken: `${id}-refresh`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `${id}-account`, + }); + } + setAccountQuotaFromParsed("pool-a", { weeklyPercent: usage }); + setAccountQuotaFromParsed("pool-b", { weeklyPercent: 1 }); + resetCodexRoutingForManualSelection("pool-a"); + const headers = new Headers({ "x-codex-parent-thread-id": "zero-threshold-auth-detour" }); + const ordinaryOptions = { modelId: "gpt-5.5", primeCodexPoolQuotas: async () => {} }; + await expect(resolveCodexAuthContext(headers, cfg, "pool", ordinaryOptions)) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-a", accessToken: "pool-a-token" }); + + const entitlementSnapshot: CodexModelEntitlementSnapshot = { + modelsByAccount: new Map([ + ["pool-a", new Set(["gpt-5.5"])], + ["pool-b", new Set(["gpt-daybreak-blue-latest"])], + ]), + confirmedAccountIds: new Set(["pool-a", "pool-b"]), + credentialIdentities: new Map(), + }; + const gatedOptions = { + modelId: "gpt-daybreak-blue-latest", + resolveCodexModelEntitlements: async () => entitlementSnapshot, + primeCodexPoolQuotas: async () => {}, + }; + await expect(resolveCodexAuthContext(headers, cfg, "pool", { + ...gatedOptions, + accountId: "pool-a", + })).rejects.toThrow("Selected Codex account does not support this model"); + await expect(resolveCodexAuthContext(headers, cfg, "pool", gatedOptions)) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b", accessToken: "pool-b-token" }); + const expectedShared = usage < 100 ? "pool-a" : "pool-b"; + expect(cfg.activeCodexAccountId).toBe(expectedShared); + expect(cfg.activeCodexAccountPinned).toBe(usage < 100 ? "pool-a" : undefined); + await expect(resolveCodexAuthContext(headers, cfg, "pool", ordinaryOptions)) + .resolves.toMatchObject({ kind: "pool", accountId: expectedShared }); + }); + + test("a zero account threshold rejects a gated model when no stored account is entitled", async () => { + const cfg = config(); + cfg.codexAccounts = cfg.codexAccounts?.filter(account => !account.isMain); + cfg.autoSwitchThreshold = 50; + cfg.codexAccountAutoSwitchThresholds = { "pool-a": 0 }; + saveCodexAccountCredential("pool-a", { + accessToken: "pool-token", + refreshToken: "pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool-account", + }); + setAccountQuotaFromParsed("pool-a", { weeklyPercent: 100 }); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: "gpt-daybreak-blue-latest", + resolveCodexModelEntitlements: async () => ({ + modelsByAccount: new Map([["pool-a", new Set(["gpt-5.5"])]]), + confirmedAccountIds: new Set(["pool-a"]), + credentialIdentities: new Map(), + }), + })).rejects.toThrow("No eligible Codex account supports this model"); + }); + + async function resolveRequestOwnedMainPinCase(options: { + mainWeeklyPercent: number; + poolWeeklyPercent: number; + callerEntitled: boolean; + mainThresholdOverride?: number; + poolEntitled?: boolean; + mainRetryAfter?: string; + poolUsable?: boolean; + duringCallerEntitlement?: (cfg: OcxConfig) => Promise; + mode?: "pool" | "direct"; + }): Promise<{ + cfg: OcxConfig; + context: Awaited>; + directEntitlementChecks: number; + }> { + const cfg = config(); + cfg.accountPoolStrategy = "quota"; + cfg.autoSwitchThreshold = 90; + cfg.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID; + cfg.activeCodexAccountPinned = MAIN_CODEX_ACCOUNT_ID; + if (options.poolUsable === false) cfg.pausedCodexAccountIds = ["pool-a"]; + if (options.mainThresholdOverride !== undefined) { + cfg.codexAccountAutoSwitchThresholds = { + [MAIN_CODEX_ACCOUNT_ID]: options.mainThresholdOverride, + }; + } + cfg.codexAccountPriorities = { + [MAIN_CODEX_ACCOUNT_ID]: 0, + "pool-a": 0, + }; + resetCodexRoutingForManualSelection(MAIN_CODEX_ACCOUNT_ID); + saveCodexAccountCredential("pool-a", { + accessToken: "pool-token", + refreshToken: "pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool-account", + }); + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { weeklyPercent: options.mainWeeklyPercent }); + setAccountQuotaFromParsed("pool-a", { weeklyPercent: options.poolWeeklyPercent }); + if (options.mainRetryAfter !== undefined) { + recordCodexUpstreamOutcome(cfg, MAIN_CODEX_ACCOUNT_ID, 429, { + retryAfter: options.mainRetryAfter, + fixedAccount: true, + now: Date.now(), + }); + } + let directEntitlementChecks = 0; + const context = await resolveCodexAuthContext(new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }), cfg, options.mode ?? "pool", { + requestScopedMainCredential: true, + // Uses the one model still account-gated. These #3157 cases are about how a caller + // entitlement MISS interacts with the main pin, so they need a model whose entitlement is + // actually consulted; the flagships stopped being gated on 2026-09-04 and now skip the + // check entirely, which would leave directEntitlementChecks at 0 and prove nothing. + modelId: "gpt-daybreak-blue-latest", + isDirectCallerEntitledToCodexModel: async () => { + directEntitlementChecks += 1; + await options.duringCallerEntitlement?.(cfg); + return options.callerEntitled; + }, + resolveCodexModelEntitlements: async () => ({ + modelsByAccount: new Map([["pool-a", new Set( + options.poolEntitled === false ? [] : ["gpt-daybreak-blue-latest"], + )]]), + clientVersionByAccount: new Map([["pool-a", "0.150.1"]]), + confirmedAccountIds: new Set(["pool-a"]), + credentialIdentities: new Map([["pool-a", "pool:1:pool-account"]]), + }), + }); + return { cfg, context, directEntitlementChecks }; + } + + test("a healthy manual main pin keeps the validated caller bearer ahead of an exhausted pool account (#3157)", async () => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 16, + poolWeeklyPercent: 100, + callerEntitled: true, + }); + expect(context).toMatchObject({ kind: "main", accountId: null }); + expect(directEntitlementChecks).toBe(1); + expect(cfg.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("a zero main-account threshold override preserves a request-owned main pin at full usage", async () => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + mainThresholdOverride: 0, + }); + expect(context).toMatchObject({ kind: "main", accountId: null }); + expect(directEntitlementChecks).toBe(1); + expect(cfg.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("a zero main-account threshold does not let a request-owned main pin bypass Retry-After", async () => { + // Model a previously observed physical-main identity matching this caller. + // An unrelated caller must not inherit stored main's cooldown. + observeMainQuotaIdentity("caller-keyring-account"); + observeMainQuotaCredential("caller-keyring-token", "caller-keyring-account"); + try { + const { cfg, context } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + mainThresholdOverride: 0, + mainRetryAfter: "600", + }); + const cooldown = getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared"); + expect(cooldown).toMatchObject({ cooldownSource: "retry-after" }); + expect(cooldown!.cooldownUntil).toBeGreaterThan(Date.now()); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a", accessToken: "pool-token" }); + // Request preview shares the final-auth predicate and must honor the same cooldown. + cfg.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID; + cfg.activeCodexAccountPinned = MAIN_CODEX_ACCOUNT_ID; + expect(requestOwnedMainPinState(new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }), cfg, cfg, true, undefined, "shared")).toEqual({ candidate: true, preserve: false }); + } finally { + clearMainAccountInfoCache(); + } + }); + + test.each(["caller-keyring-account", "other-main-account"])( + "a zero main-account threshold does not impose main cooldown on an unrelated caller credential in workspace %s", + async (observedAccountId) => { + observeMainQuotaIdentity(observedAccountId); + observeMainQuotaCredential("other-main-token", observedAccountId); + try { + const { cfg, context } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + mainThresholdOverride: 0, + mainRetryAfter: "600", + }); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared")) + .toMatchObject({ cooldownSource: "retry-after" }); + expect(context).toMatchObject({ kind: "main", accountId: null }); + const forwarded = headersForCodexAuthContext(new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }), context); + expect(forwarded.get("authorization")).toBe("Bearer caller-keyring-token"); + expect(forwarded.get("chatgpt-account-id")).toBe("caller-keyring-account"); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + } finally { + clearMainAccountInfoCache(); + } + }, + ); + + test.each(["unentitled", "paused"] as const)( + "a zero main-account threshold rejects cooled matching caller when Pool fallback is %s", + async (unavailableReason) => { + observeMainQuotaIdentity("caller-keyring-account"); + observeMainQuotaCredential("caller-keyring-token", "caller-keyring-account"); + try { + await expect(resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + mainThresholdOverride: 0, + mainRetryAfter: "600", + poolEntitled: unavailableReason !== "unentitled", + poolUsable: unavailableReason !== "paused", + })).rejects.toBeInstanceOf(CodexAccountCooldownError); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared")) + .toMatchObject({ cooldownSource: "retry-after" }); + } finally { + clearMainAccountInfoCache(); + } + }, + ); + + test.each([true, false])( + "a zero main-account threshold rechecks cooldown after caller entitlement resolves with Pool entitled %s", + async (poolEntitled) => { + observeMainQuotaIdentity("caller-keyring-account"); + observeMainQuotaCredential("caller-keyring-token", "caller-keyring-account"); + let entered!: (cfg: OcxConfig) => void; + const entitlementStarted = new Promise(resolve => { entered = resolve; }); + let release!: () => void; + const entitlementGate = new Promise(resolve => { release = resolve; }); + const pending = resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + mainThresholdOverride: 0, + poolEntitled, + duringCallerEntitlement: async cfg => { + entered(cfg); + await entitlementGate; + }, + }); + // Attach rejection handling before releasing the asynchronous dependency. + const outcome = pending.then( + result => ({ status: "resolved" as const, result }), + error => ({ status: "rejected" as const, error }), + ); + try { + const cfg = await entitlementStarted; + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared")).toBeNull(); + const now = Date.now(); + recordCodexUpstreamOutcome(cfg, MAIN_CODEX_ACCOUNT_ID, 429, { + retryAfter: "600", fixedAccount: true, now, + }); + release(); + const settled = await outcome; + if (poolEntitled) { + expect(settled.status).toBe("resolved"); + if (settled.status === "resolved") { + expect(settled.result.context).toMatchObject({ kind: "pool", accountId: "pool-a" }); + } + } else { + expect(settled.status).toBe("rejected"); + if (settled.status === "rejected") expect(settled.error).toBeInstanceOf(CodexAccountCooldownError); + } + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared")).toMatchObject({ + cooldownSource: "retry-after", cooldownUntil: now + 600_000, + }); + } finally { + release(); + await outcome; + clearMainAccountInfoCache(); + } + }, + ); + + test.each([ + ["reserve", "gpt-reserve", "shared", "main", null], + ["shared", "gpt-5.6-sol", "reserve", "pool", "pool-a"], + ] as const)( + "a zero main-account threshold respects %s cooldown when resolving shared caller auth", + async (cooledScope, cooledModel, healthyScope, expectedKind, expectedAccountId) => { + observeMainQuotaIdentity("caller-keyring-account"); + observeMainQuotaCredential("caller-keyring-token", "caller-keyring-account"); + try { + const { cfg } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + mainThresholdOverride: 0, + }); + const now = Date.now(); + recordCodexUpstreamOutcome(cfg, MAIN_CODEX_ACCOUNT_ID, 429, { + now, + resetAt: Math.floor((now + 600_000) / 1_000), + modelId: cooledModel, + fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, cooledScope); + expect(cooldown).toMatchObject({ cooldownSource: "reset-derived", quotaScope: cooledScope }); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, healthyScope)).toBeNull(); + const headers = new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }); + // Reserve evidence is state-only: shared requests must not inherit its cooldown, + // and this test must not turn Reserve into an ordinary Pool-selectable model. + await expect(resolveCodexAuthContext(headers, cfg, "pool", { + requestScopedMainCredential: true, modelId: "gpt-5.6-sol", + })).resolves.toMatchObject({ kind: expectedKind, accountId: expectedAccountId }); + expect(cfg.activeCodexAccountPinned).toBe(expectedKind === "main" ? MAIN_CODEX_ACCOUNT_ID : undefined); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, cooledScope)).toEqual(cooldown); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, healthyScope)).toBeNull(); + } finally { + clearMainAccountInfoCache(); + } + }, + ); + + test("a zero main-account threshold leaves explicit Direct caller auth unchanged during matching main cooldown", async () => { + observeMainQuotaIdentity("caller-keyring-account"); + observeMainQuotaCredential("caller-keyring-token", "caller-keyring-account"); + try { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + mainThresholdOverride: 0, + mainRetryAfter: "600", + mode: "direct", + }); + expect(context).toMatchObject({ kind: "main", accountId: null }); + expect(directEntitlementChecks).toBe(1); + const forwarded = headersForCodexAuthContext(new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }), context); + expect(forwarded.get("authorization")).toBe("Bearer caller-keyring-token"); + expect(forwarded.get("chatgpt-account-id")).toBe("caller-keyring-account"); + expect(cfg.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "shared")) + .toMatchObject({ cooldownSource: "retry-after" }); + } finally { + clearMainAccountInfoCache(); + } + }); + + test("an exhausted request-owned main pin still yields to the healthy Pool account (#3157)", async () => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + }); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(directEntitlementChecks).toBe(0); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(cfg.activeCodexAccountPinned).toBeUndefined(); + }); + + test.each([99, 100])("a zero main-account threshold respects model-detour exhaustion policy at %s usage", async usage => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: usage, + poolWeeklyPercent: 16, + callerEntitled: false, + mainThresholdOverride: 0, + }); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a", accessToken: "pool-token" }); + expect(directEntitlementChecks).toBe(1); + expect(cfg.activeCodexAccountId).toBe(usage < 100 ? MAIN_CODEX_ACCOUNT_ID : "pool-a"); + expect(cfg.activeCodexAccountPinned).toBe(usage < 100 ? MAIN_CODEX_ACCOUNT_ID : undefined); + }); + + test("a zero main-account threshold rejects an unentitled caller when no Pool detour supports the model", async () => { + await expect(resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: false, + poolEntitled: false, + mainThresholdOverride: 0, + })).rejects.toThrow(CodexPoolAuthenticationError); + }); + + test("a caller entitlement miss uses a Pool model detour without clearing the healthy main pin (#3157)", async () => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 16, + poolWeeklyPercent: 20, + callerEntitled: false, + }); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(directEntitlementChecks).toBe(1); + expect(cfg.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test.each([undefined, "pool-a"])( + "a zero account threshold still enforces Retry-After with account selector %s", + async (accountId) => { + const cfg = config(); + cfg.autoSwitchThreshold = 50; + cfg.codexAccountAutoSwitchThresholds = { "pool-a": 0 }; + saveCodexAccountCredential("pool-a", { + accessToken: "pool-token", + refreshToken: "pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool-account", + }); + setAccountQuotaFromParsed("pool-a", { weeklyPercent: 100 }); + const headers = new Headers({ authorization: "Bearer inbound-main-token" }); + const options = { accountId, modelId: "gpt-5.5", primeCodexPoolQuotas: async () => {} }; + const context = await resolveCodexAuthContext(headers, cfg, "pool", options); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a", accessToken: "pool-token" }); + + const now = Date.now(); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + retryAfter: "600", + fixedAccount: true, + now, + }); + await expect(resolveCodexAuthContext(headers, cfg, "pool", options)) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + expect(() => assertCodexAuthContextNotCooled(context)).toThrow(CodexAccountCooldownError); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toMatchObject({ + cooldownUntil: now + 600_000, + cooldownSource: "retry-after", + }); + }, + ); + +}); diff --git a/tests/codex-integration/codex-account-threshold-routing.test.ts b/tests/codex-integration/codex-account-threshold-routing.test.ts new file mode 100644 index 00000000000..8d70782c212 --- /dev/null +++ b/tests/codex-integration/codex-account-threshold-routing.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { STORE_BUDGET_MS } from "../helpers/test-budget"; +import { + CODEX_FAILURE_WINDOW_MS, + CODEX_QUOTA_PROBE_INTERVAL_MS, + CODEX_TRANSIENT_SOFT_AVOID_MS, + CODEX_THREAD_AFFINITY_IDLE_TTL_MS, + CODEX_THREAD_AFFINITY_MAX_ENTRIES, + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS, + classifyCodexUpstreamOutcome, + clearCodexAccountCooldown, + clearCodexUpstreamHealth, + clearCodexUpstreamHealthForAccount, + clearThreadAccountMap, + clearThreadAccountMapForAccount, + computeCodexUsageScore, + getCodexAccountCooldownUntil, + getEffectiveActiveCodexAccountId, + getCodexQuotaHealthSnapshot, + getCodexAccountSoftAvoidUntil, + getCodexUpstreamHealth, + isCodexAccountInCooldown, + isCodexAccountSoftAvoided, + pickLowestUsageCodexAccount, + parseRetryAfterMs, + previewCodexAccountForRequest, + reconcileCodexActiveAfterExclusion, + recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, + resolveCodexAccountForThread, + resolveCodexAccountForThreadDetailed, + tryAcquireCodexQuotaProbeLease, +} from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { readCodexAccountRecord, removeCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { + clearAccountNeedsReauth, + clearAccountQuota, + getAccountQuota, + handleCodexAuthAPI, + isAccountNeedsReauth, + parseUsageQuota, + setAccountQuotaFromParsed, + updateAccountQuota, +} from "../../src/codex/auth-api"; +import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../../src/codex/quota"; +import { setCodexAccountPriority } from "../../src/codex/account-priority"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; +import { routeModel } from "../../src/router"; +import { consumeForInspection } from "../../src/server/relay"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +import { flushConfigDirHardeningForTests, hardenConfigDir } from "../../src/config/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; + +let TEST_DIR = ""; +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + +function installRoutingScratchHome(): void { + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-routing-")); + // Routing cases exercise account state, not the operating system ACL implementation. + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.CODEX_HOME = TEST_DIR; +} + +async function removeRoutingScratchHome(): Promise { + const ownedDirectory = TEST_DIR; + TEST_DIR = ""; + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (ownedDirectory) removeTreeWithRetry(ownedDirectory); + } +} + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + ], + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + upstreamFailoverThreshold: 3, + ...overrides, + } as OcxConfig; +} + +function saveTestCredential(id: string): void { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); +} + +describe("codex routing", () => { + beforeEach(() => { + installRoutingScratchHome(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + clearAccountNeedsReauth("c"); + saveTestCredential("a"); + saveTestCredential("b"); + }); + + afterEach(async () => { + try { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + clearAccountNeedsReauth("c"); + } finally { + await removeRoutingScratchHome(); + } + }); + + test("an inherited fractional global threshold keeps its configured value", () => { + const config = makeConfig({ autoSwitchThreshold: 95.5 }); + updateAccountQuota("a", 90); + updateAccountQuota("b", 5); + + expect(resolveCodexAccountForThread("fractional-global-threshold", config)).toBe("a"); + }); + + test("an account threshold override switches below the global threshold", () => { + const config = makeConfig({ + autoSwitchThreshold: 95, + codexAccountAutoSwitchThresholds: { a: 50 }, + } as Partial & { codexAccountAutoSwitchThresholds: Record }); + updateAccountQuota("a", 60); + updateAccountQuota("b", 5); + + expect(resolveCodexAccountForThread("account-threshold", config)).toBe("b"); + }); + + test("a zero account override disables proactive switching only for that account", () => { + const config = makeConfig({ + autoSwitchThreshold: 50, + codexAccountAutoSwitchThresholds: { a: 0 }, + } as Partial & { codexAccountAutoSwitchThresholds: Record }); + updateAccountQuota("a", 99); + updateAccountQuota("b", 1); + + expect(resolveCodexAccountForThread("account-threshold-off", config)).toBe("a"); + }); + + test("a bound task uses its account threshold override for immediate re-evaluation", () => { + const config = makeConfig({ + autoSwitchThreshold: 95, + pool: { cacheAffinity: false }, + codexAccountAutoSwitchThresholds: { a: 50 }, + } as Partial & { codexAccountAutoSwitchThresholds: Record }); + const now = 1_800_000_000_000; + updateAccountQuota("a", 10); + updateAccountQuota("b", 5); + expect(resolveCodexAccountForThread("account-threshold-bound", config, now)).toBe("a"); + + updateAccountQuota("a", 60); + expect(resolveCodexAccountForThread("account-threshold-bound", config, now + 1)).toBe("b"); + }); + + test.each(["quota", "fill-first", "round-robin"] as const)( + "%s zero account threshold preserves full-usage affinity but still avoids a cooled account", + (strategy) => { + const now = Date.now(); + const threadId = `zero-threshold-cooldown-${strategy}`; + const config = makeConfig({ + accountPoolStrategy: strategy, + accountPoolStickyLimit: 1, + activeCodexAccountPinned: "a", + autoSwitchThreshold: 50, + codexAccountAutoSwitchThresholds: { a: 0 }, + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 1); + resetCodexRoutingForManualSelection("a"); + expect(resolveCodexAccountForThread(threadId, config, now)).toBe("a"); + + updateAccountQuota("a", 100); + const reevalAt = now + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(previewCodexAccountForRequest(threadId, config, reevalAt)).toBe("a"); + expect(resolveCodexAccountForThread(threadId, config, reevalAt)).toBe("a"); + expect(resolveCodexAccountForThread(null, config, reevalAt)).toBe("a"); + expect(config.activeCodexAccountPinned).toBe("a"); + + // Record health without rotating on the outcome: the selector itself must + // reject the cooled account even though proactive switching is disabled. + recordCodexUpstreamOutcome(config, "a", 429, { + fixedAccount: true, + retryAfter: "600", + now: reevalAt, + }); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(previewCodexAccountForRequest(threadId, config, reevalAt + 1)).toBe("b"); + expect(resolveCodexAccountForThread(threadId, config, reevalAt + 1)).toBe("b"); + expect(getCodexAccountCooldownUntil("a", reevalAt + 1)).toBe(reevalAt + 600_000); + }, + ); + + test.each((["quota", "fill-first", "round-robin"] as const) + .flatMap(strategy => [99, 100].map(usage => [strategy, usage] as const)))( + "%s zero account threshold preserves model-detour state unless cache affinity is exhausted at %s", + (strategy, usage) => { + const now = Date.now(); + const threadId = `zero-threshold-model-detour-${strategy}`; + const modelId = "gpt-daybreak-blue-latest"; + const config = makeConfig({ + accountPoolStrategy: strategy, + accountPoolStickyLimit: 1, + activeCodexAccountPinned: "a", + autoSwitchThreshold: 50, + codexAccountAutoSwitchThresholds: { a: 0 }, + }); + updateAccountQuota("a", usage); + updateAccountQuota("b", 1); + resetCodexRoutingForManualSelection("a"); + expect(resolveCodexAccountForThread(threadId, config, now, "shared")).toBe("a"); + + const selectionOptions = { modelEligibleAccountIds: new Set(["b"]) }; + expect(previewCodexAccountForRequest( + threadId, config, now + 1, "shared", selectionOptions, modelId, + )).toBe("b"); + const preserve = strategy !== "quota" || usage < 100; + const expectedShared = preserve ? "a" : "b"; + expect(resolveCodexAccountForThreadDetailed( + threadId, config, now + 1, "shared", selectionOptions, modelId, + )).toEqual({ status: "selected", accountId: "b", affinity: preserve + ? { move: "new_bind", reason: "healthy" } : { move: "rebound", reason: "unusable" } }); + expect(config.activeCodexAccountId).toBe(expectedShared); + expect(config.activeCodexAccountPinned).toBe(preserve ? "a" : undefined); + expect(getEffectiveActiveCodexAccountId(config)).toBe(expectedShared); + expect(resolveCodexAccountForThread(threadId, config, now + 2, "shared")).toBe(expectedShared); + expect(resolveCodexAccountForThread(null, config, now + 2, "shared")).toBe(expectedShared); + }, + ); + +}); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index c17088cb15e..724ef91629f 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -73,12 +73,15 @@ import { } from "../../src/codex/account-lifecycle"; import { ConfigMutationLockError, + armClaudeCodeBaseline, getConfigPath, loadConfig, saveConfig, setPersistedConfigMutationBeforeCommitForTests, } from "../../src/config"; import * as configModule from "../../src/config"; +import { setCodexAccountAutoSwitchThresholdOverride } from "../../src/codex/account-auto-switch"; +import { prepareConfigObjectChildDeletionRebase } from "../../src/config/rebase-provenance"; import type { CatalogDisposition } from "../../src/codex/convergence-types"; import { captureConfigGeneration, registerStateStore } from "../../src/lib/state-store-sweeper"; import { @@ -4012,6 +4015,7 @@ describe("codex-auth API", () => { expect(accounts.find(a => a.isMain)?.priority).toBe(0); }); + test("GET /api/codex-auth/active reports an operator pin but not an automatic pick", async () => { const config = makeConfig({ activeCodexAccountId: "work" }); seedPoolAccount(config, { id: "work", email: "work@example.test" }); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 430e78b2f33..77c9be839bd 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -45,6 +45,11 @@ import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan, } from "../../src/codex/main-account"; +import { + clearMainAccountInfoCache, + observeMainQuotaCredential, + observeMainQuotaIdentity, +} from "../../src/codex/main-account-cache"; import { clearAccountNeedsReauth, clearAccountQuota, @@ -243,6 +248,28 @@ describe("Codex auth context", () => { expect(() => materializeCodexUpstreamAuth(new Headers(), ctx)).toThrow("validation is pending"); expect(() => applyCodexAuthContextToProvider(cfg.providers.chatgpt!, ctx, "pool")).toThrow("validation is pending"); }); + test.each([ + { enabled: true, global: 80, override: undefined, prime: true }, + { enabled: false, global: 80, override: undefined, prime: false }, + { enabled: true, global: 80, override: 0, prime: false }, + { enabled: true, global: 0, override: 40, prime: true }, + ])("priority failback primes known quota using the selected source threshold (%j)", async ({ enabled, global, override, prime }) => { + const cfg = config(); + cfg.codexAccountPriorityFailback = enabled; + cfg.autoSwitchThreshold = global; + if (override !== undefined) cfg.codexAccountAutoSwitchThresholds = { "pool-a": override }; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", refreshToken: "pool_refresh", + expiresAt: Date.now() + 3_600_000, chatgptAccountId: "pool_acc", + }); + setAccountQuotaFromParsed("pool-a", { weeklyPercent: 10 }); + const reasons: string[] = []; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + primeCodexPoolQuotas: async (_config, reason) => { reasons.push(reason); }, + })).resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(reasons).toEqual(prime ? ["priority-failback"] : []); + }); + test("main-profile drain routes a non-main pool account without native reads or quota priming", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", @@ -805,6 +832,7 @@ describe("Codex auth context", () => { } satisfies Partial); }); + test("ordinary native models do not pay the entitlement discovery path", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool-token", @@ -1191,93 +1219,6 @@ describe("Codex auth context", () => { } }); - async function resolveRequestOwnedMainPinCase(options: { - mainWeeklyPercent: number; - poolWeeklyPercent: number; - callerEntitled: boolean; - }): Promise<{ - cfg: OcxConfig; - context: Awaited>; - directEntitlementChecks: number; - }> { - const cfg = config(); - cfg.accountPoolStrategy = "quota"; - cfg.autoSwitchThreshold = 90; - cfg.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID; - cfg.activeCodexAccountPinned = MAIN_CODEX_ACCOUNT_ID; - cfg.codexAccountPriorities = { - [MAIN_CODEX_ACCOUNT_ID]: 0, - "pool-a": 0, - }; - resetCodexRoutingForManualSelection(MAIN_CODEX_ACCOUNT_ID); - saveCodexAccountCredential("pool-a", { - accessToken: "pool-token", - refreshToken: "pool-refresh", - expiresAt: Date.now() + 5 * 60_000, - chatgptAccountId: "pool-account", - }); - setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { weeklyPercent: options.mainWeeklyPercent }); - setAccountQuotaFromParsed("pool-a", { weeklyPercent: options.poolWeeklyPercent }); - let directEntitlementChecks = 0; - const context = await resolveCodexAuthContext(new Headers({ - authorization: "Bearer caller-keyring-token", - "chatgpt-account-id": "caller-keyring-account", - }), cfg, "pool", { - requestScopedMainCredential: true, - // Uses the one model still account-gated. These #3157 cases are about how a caller - // entitlement MISS interacts with the main pin, so they need a model whose entitlement is - // actually consulted; the flagships stopped being gated on 2026-09-04 and now skip the - // check entirely, which would leave directEntitlementChecks at 0 and prove nothing. - modelId: "gpt-daybreak-blue-latest", - isDirectCallerEntitledToCodexModel: async () => { - directEntitlementChecks += 1; - return options.callerEntitled; - }, - resolveCodexModelEntitlements: async () => ({ - modelsByAccount: new Map([["pool-a", new Set(["gpt-daybreak-blue-latest"])]]), - clientVersionByAccount: new Map([["pool-a", "0.150.1"]]), - confirmedAccountIds: new Set(["pool-a"]), - credentialIdentities: new Map([["pool-a", "pool:1:pool-account"]]), - }), - }); - return { cfg, context, directEntitlementChecks }; - } - - test("a healthy manual main pin keeps the validated caller bearer ahead of an exhausted pool account (#3157)", async () => { - const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ - mainWeeklyPercent: 16, - poolWeeklyPercent: 100, - callerEntitled: true, - }); - expect(context).toMatchObject({ kind: "main", accountId: null }); - expect(directEntitlementChecks).toBe(1); - expect(cfg.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); - expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); - }); - - test("an exhausted request-owned main pin still yields to the healthy Pool account (#3157)", async () => { - const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ - mainWeeklyPercent: 100, - poolWeeklyPercent: 16, - callerEntitled: true, - }); - expect(context).toMatchObject({ kind: "pool", accountId: "pool-a" }); - expect(directEntitlementChecks).toBe(0); - expect(cfg.activeCodexAccountId).toBe("pool-a"); - expect(cfg.activeCodexAccountPinned).toBeUndefined(); - }); - - test("a caller entitlement miss uses a Pool model detour without clearing the healthy main pin (#3157)", async () => { - const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ - mainWeeklyPercent: 16, - poolWeeklyPercent: 20, - callerEntitled: false, - }); - expect(context).toMatchObject({ kind: "pool", accountId: "pool-a" }); - expect(directEntitlementChecks).toBe(1); - expect(cfg.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); - expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); - }); test("a failed Pool account may fall back once to the validated caller-owned main credential", async () => { const cfg = config(); @@ -1726,6 +1667,7 @@ describe("Codex auth context", () => { .rejects.toBeInstanceOf(CodexAccountCooldownError); }); + test("reset-derived cooldown admits one probe and clears on its success (#433)", async () => { const originalNow = Date.now; const now = 1_800_000_000_000; diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 5a1045b7434..5f777c0cd7b 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -457,6 +457,51 @@ describe("accountPoolStrategy new-session routing", () => { expect(resolveCodexAccountForThread("bound", config, now)).toBe("c"); }); + test.each([false, true])("reset-first account override zero preserves affinity but not cooldown eligibility with cacheAffinity=%s", cacheAffinity => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "reset-first", + autoSwitchThreshold: 80, + codexAccountAutoSwitchThresholds: { a: 0 }, + pool: { cacheAffinity }, + }); + const now = Date.now(); + for (const [index, id] of THREE_ACCOUNT_IDS.entries()) { + setAccountQuotaFromParsed(id, { weeklyPercent: 10 + index * 10, weeklyResetAt: now / 1000 + 300 * (index + 1) }); + } + expect(resolveCodexAccountForThread("account-zero-reset", config, now)).toBe("a"); + setAccountQuotaFromParsed("a", { weeklyPercent: 100 }); + for (const later of [now + 1, now + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1]) { + expect(previewCodexAccountForRequest("account-zero-reset", config, later)).toBe("a"); + expect(resolveCodexAccountForThread("account-zero-reset", config, later)).toBe("a"); + } + const failedAt = now + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 2; + recordCodexUpstreamOutcome(config, "a", 429, { now: failedAt, retryAfter: "60" }); + expect(previewCodexAccountForRequest("account-zero-reset", config, failedAt + 1)).toBe("b"); + expect(resolveCodexAccountForThread("account-zero-reset", config, failedAt + 1)).toBe("b"); + }); + + test.each([ + { global: 0, scope: undefined }, + { global: 95, scope: undefined }, + { global: 0, scope: "reserve" as const }, + { global: 95, scope: "reserve" as const }, + ])("reset-first account override controls affinity with global=$global scope=$scope", ({ global, scope }) => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "reset-first", + pool: { cacheAffinity: false }, + autoSwitchThreshold: global, + codexAccountAutoSwitchThresholds: { a: 60 }, + }); + const now = Date.now(); + for (const [index, id] of THREE_ACCOUNT_IDS.entries()) { + setAccountQuotaFromParsed(id, { weeklyPercent: 10 + index * 10, weeklyResetAt: now / 1000 + 300 * (index + 1) }); + } + expect(resolveCodexAccountForThread("account-override-reset", config, now, scope)).toBe("a"); + setAccountQuotaFromParsed("a", { weeklyPercent: 70 }); + expect(previewCodexAccountForRequest("account-override-reset", config, now + 1, scope)).toBe("b"); + expect(resolveCodexAccountForThread("account-override-reset", config, now + 1, scope)).toBe("b"); + }); + test("reset-first ignores past/missing resets and breaks ties by usage", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); const now = Date.now(); diff --git a/tests/codex-integration/codex-priority-failback.test.ts b/tests/codex-integration/codex-priority-failback.test.ts new file mode 100644 index 00000000000..43247a55b78 --- /dev/null +++ b/tests/codex-integration/codex-priority-failback.test.ts @@ -0,0 +1,348 @@ +import { codexAccountPriorityFailbackEnabled, CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "../../src/codex/account-priority"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { getDefaultConfig } from "../../src/config/proxy-env"; +import { codexQuotaHasFreshUsage } from "../../src/codex/quota-observation-freshness"; +import { rememberActiveCodexAccount } from "../../src/codex/routing/active-account"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { STORE_BUDGET_MS } from "../helpers/test-budget"; +import { + CODEX_FAILURE_WINDOW_MS, + CODEX_QUOTA_PROBE_INTERVAL_MS, + CODEX_TRANSIENT_SOFT_AVOID_MS, + CODEX_THREAD_AFFINITY_IDLE_TTL_MS, + CODEX_THREAD_AFFINITY_MAX_ENTRIES, + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS, + classifyCodexUpstreamOutcome, + clearCodexAccountCooldown, + clearCodexUpstreamHealth, + clearCodexUpstreamHealthForAccount, + clearThreadAccountMap, + clearThreadAccountMapForAccount, + computeCodexUsageScore, + getCodexAccountCooldownUntil, + getEffectiveActiveCodexAccountId, + getCodexQuotaHealthSnapshot, + getCodexAccountSoftAvoidUntil, + getCodexUpstreamHealth, + isCodexAccountInCooldown, + isCodexAccountSoftAvoided, + pickLowestUsageCodexAccount, + parseRetryAfterMs, + previewCodexAccountForRequest, + reconcileCodexActiveAfterExclusion, + recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, + resolveCodexAccountForThread, + resolveCodexAccountForThreadDetailed, + tryAcquireCodexQuotaProbeLease, +} from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { readCodexAccountRecord, removeCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { + clearAccountNeedsReauth, + clearAccountQuota, + getAccountQuota, + handleCodexAuthAPI, + isAccountNeedsReauth, + parseUsageQuota, + setAccountQuotaFromParsed, + updateAccountQuota, +} from "../../src/codex/auth-api"; +import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../../src/codex/quota"; +import { setCodexAccountPriority } from "../../src/codex/account-priority"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; +import { routeModel } from "../../src/router"; +import { consumeForInspection } from "../../src/server/relay"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +import { flushConfigDirHardeningForTests, hardenConfigDir } from "../../src/config/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; + +let TEST_DIR = ""; +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + +function installRoutingScratchHome(): void { + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-routing-")); + // Routing cases exercise account state, not the operating system ACL implementation. + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.CODEX_HOME = TEST_DIR; +} + +async function removeRoutingScratchHome(): Promise { + const ownedDirectory = TEST_DIR; + TEST_DIR = ""; + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (ownedDirectory) removeTreeWithRetry(ownedDirectory); + } +} + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + ], + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + upstreamFailoverThreshold: 3, + ...overrides, + } as OcxConfig; +} + +function saveTestCredential(id: string): void { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); +} + +describe("codex priority failback boundaries", () => { + beforeEach(() => { + installRoutingScratchHome(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearPoolRotationState(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + saveTestCredential("a"); + saveTestCredential("b"); + }); + + afterEach(async () => { + try { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + } finally { + await removeRoutingScratchHome(); + } + }); + + /** `a` is ordered above `b`; the persisted operator selection is the lower tier. */ + function orderedConfig(overrides: Partial = {}): OcxConfig { + return makeConfig({ + activeCodexAccountId: "b", + codexAccountPriorities: { a: 1 }, + ...overrides, + } as Partial); + } + + test("opt-in moves the same task back after a five-hour quota recovery", () => { + const config = orderedConfig({ codexAccountPriorityFailback: true, autoSwitchThreshold: 100 }); + const now = Date.now(); + updateAccountQuota("a", 4); + updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("ongoing", config, now)).toBe("a"); + setAccountQuotaFromParsed("a", { weeklyPercent: 4, shortPercent: 100, shortResetAt: now / 1000 + 18000 }); + expect(resolveCodexAccountForThread("ongoing", config, now + 1)).toBe("b"); + setAccountQuotaFromParsed("a", { weeklyPercent: 4, shortPercent: 0, shortResetAt: now / 1000 + 36000 }); + expect(previewCodexAccountForRequest("ongoing", config, now + 2)).toBe("a"); + // Preview does not change the task or the active account. + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread("ongoing", config, now + 2)).toBe("a"); + expect(resolveCodexAccountForThread("ongoing", config, now + 3)).toBe("a"); + }); + + test.each([undefined, false])("recovered priority does not move bound tasks by default (%s)", enabled => { + const config = orderedConfig({ codexAccountPriorityFailback: enabled }); + updateAccountQuota("a", 100); + updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("sticky", config)).toBe("b"); + updateAccountQuota("a", 0); + expect(previewCodexAccountForRequest("sticky", config)).toBe("b"); + expect(resolveCodexAccountForThread("sticky", config)).toBe("b"); + }); + + test("live failback in an independent quota scope leaves shared selection untouched", () => { + const config = orderedConfig({ codexAccountPriorityFailback: true }); + updateAccountQuota("a", 100); + updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("scoped-failback", config, Date.now(), "spark")).toBe("b"); + updateAccountQuota("a", 10); + expect(previewCodexAccountForRequest("scoped-failback", config, Date.now(), "spark")).toBe("a"); + expect(resolveCodexAccountForThread("scoped-failback", config, Date.now(), "spark")).toBe("a"); + expect(config.activeCodexAccountId).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + }); + + test("live failback respects pins, model eligibility, cooldown and unknown quota", () => { + const config = orderedConfig({ codexAccountPriorityFailback: true }); + const now = Date.now(); + updateAccountQuota("a", 100); + updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("protected", config, now)).toBe("b"); + clearAccountQuota(); + updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("protected", config, now + 1)).toBe("b"); + updateAccountQuota("a", 0); + config.activeCodexAccountPinned = "b"; + expect(resolveCodexAccountForThread("protected", config, now + 2)).toBe("b"); + delete config.activeCodexAccountPinned; + expect(resolveCodexAccountForThreadDetailed("protected", config, now + 3, "shared", + { modelEligibleAccountIds: new Set(["b"]) })).toMatchObject({ status: "selected", accountId: "b" }); + recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "600", now: now + 4, fixedAccount: true }); + expect(resolveCodexAccountForThread("protected", config, now + 5)).toBe("b"); + }); + + test.each(["round-robin", "fill-first", "reset-first"] as const)("live failback leaves %s affinity unchanged", strategy => { + const config = orderedConfig({ codexAccountPriorityFailback: true, accountPoolStrategy: strategy }); + updateAccountQuota("a", 100); + updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("rotation", config)).toBe("b"); + updateAccountQuota("a", 0); + expect(resolveCodexAccountForThread("rotation", config)).toBe("b"); + }); + + test("stale priority evidence retains the warm task until a fresh observation arrives", () => { + const config = orderedConfig({ codexAccountPriorityFailback: true }); + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + for (const id of ["a", "b"]) { + saveCodexAccountCredential(id, { + ...readCodexAccountRecord(id)!.credential!, expiresAt: now + 3_600_000, + }); + } + updateAccountQuota("a", 100); + updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("stale-priority", config, now)).toBe("b"); + updateAccountQuota("a", 0); + now += CODEX_PRIORITY_FAILBACK_REFRESH_MS + 1; + expect(previewCodexAccountForRequest("stale-priority", config, now)).toBe("b"); + expect(resolveCodexAccountForThread("stale-priority", config, now)).toBe("b"); + updateAccountQuota("a", 0); + expect(previewCodexAccountForRequest("stale-priority", config, now)).toBe("a"); + expect(config.activeCodexAccountId).toBe("b"); + expect(resolveCodexAccountForThread("stale-priority", config, now)).toBe("a"); + // Shared quota rebinding uses the existing persisted promotion path; preview does not. + expect(config.activeCodexAccountId).toBe("a"); + } finally { clock.mockRestore(); } + }); + + test("threshold zero disables only the explicit live failback preference", () => { + const config = orderedConfig({ codexAccountPriorityFailback: true }); + updateAccountQuota("a", 100); + updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("zero-failback", config)).toBe("b"); + config.autoSwitchThreshold = 0; + updateAccountQuota("a", 0); + expect(codexAccountPriorityFailbackEnabled(config, "b")).toBe(false); + expect(previewCodexAccountForRequest("zero-failback", config)).toBe("b"); + expect(resolveCodexAccountForThread("zero-failback", config)).toBe("b"); + }); + + test.each([undefined, false, "true", 1])("invalid or absent failback does not enable it (%s)", value => { + const parsed = configSchema.safeParse({ ...getDefaultConfig(), codexAccountPriorityFailback: value }); + expect(parsed.success).toBe(true); + if (!parsed.success) throw new Error("optional preference discarded the configuration"); + expect(parsed.data.providers.openai).toEqual(getDefaultConfig().providers.openai); + expect(codexAccountPriorityFailbackEnabled(parsed.data as OcxConfig, "b")).toBe(false); + }); + + test.each([ + { global: 80, source: 0, candidate: 80, usage: 1, expected: "b" }, + { global: 0, source: 40, candidate: undefined, usage: 20, expected: "a" }, + { global: 0, source: undefined, candidate: 80, usage: 1, expected: "b" }, + { global: 80, source: 80, candidate: 20, usage: 19, expected: "a" }, + { global: 80, source: 80, candidate: 20, usage: 20, expected: "b" }, + { global: 80, source: 30, candidate: 0, usage: 0, expected: "a" }, + { global: 80, source: 30, candidate: 0, usage: 99, expected: "a" }, + { global: 80, source: 30, candidate: 0, usage: 100, expected: "b" }, + { global: 80, source: undefined, candidate: undefined, usage: 80, expected: "b" }, + ])("effective source/candidate thresholds agree in preview and resolve: %j", scenario => { + const config = orderedConfig({ codexAccountPriorityFailback: true }); + updateAccountQuota("a", 100); updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("effective", config)).toBe("b"); + config.autoSwitchThreshold = scenario.global; + config.codexAccountAutoSwitchThresholds = { + ...(scenario.source === undefined ? {} : { b: scenario.source }), + ...(scenario.candidate === undefined ? {} : { a: scenario.candidate }), + }; + updateAccountQuota("a", scenario.usage); + const snapshot = JSON.stringify(config); + expect(previewCodexAccountForRequest("effective", config)).toBe(scenario.expected); + expect(JSON.stringify(config)).toBe(snapshot); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread("effective", config)).toBe(scenario.expected); + }); + + test("fresh credits or a partial short update cannot rejuvenate retained long-window evidence", () => { + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + for (const id of ["a", "b"]) saveCodexAccountCredential(id, { + ...readCodexAccountRecord(id)!.credential!, expiresAt: now + 3_600_000, + }); + const config = orderedConfig({ codexAccountPriorityFailback: true }); + updateAccountQuota("a", 100); updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("retained", config, now)).toBe("b"); + config.codexAccountAutoSwitchThresholds = { a: 0 }; + setAccountQuotaFromParsed("a", { weeklyPercent: 10, shortPercent: 0 }); + now += CODEX_PRIORITY_FAILBACK_REFRESH_MS + 1; + for (const partial of [{ resetCredits: 1 }, { shortPercent: 0 }]) { + setAccountQuotaFromParsed("a", partial); + expect(getAccountQuota("a")!.updatedAt).toBe(now); + expect(previewCodexAccountForRequest("retained", config, now)).toBe("b"); + expect(resolveCodexAccountForThread("retained", config, now)).toBe("b"); + } + setAccountQuotaFromParsed("a", { weeklyPercent: 10, shortPercent: 0 }); + expect(previewCodexAccountForRequest("retained", config, now)).toBe("a"); + expect(resolveCodexAccountForThread("retained", config, now)).toBe("a"); + } finally { clock.mockRestore(); } + }); + + test("hydrated-looking quota without live observation proof cannot trigger optional failback", () => { + expect(codexQuotaHasFreshUsage({ weeklyPercent: 0, updatedAt: Date.now() }, "plus", Date.now(), 300_000)).toBe(false); + }); + + test("the bound source threshold wins over an unrelated runtime cursor", () => { + const config = orderedConfig({ codexAccountPriorityFailback: true }); + updateAccountQuota("a", 100); updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("bound-source", config)).toBe("b"); + config.codexAccountAutoSwitchThresholds = { a: 80, b: 0 }; + updateAccountQuota("a", 1); + rememberActiveCodexAccount(config, "a"); + expect(previewCodexAccountForRequest("bound-source", config)).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread("bound-source", config)).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }); + + test("candidate zero cannot bypass an observed exhausted short window", () => { + const config = orderedConfig({ codexAccountPriorityFailback: true }); + updateAccountQuota("a", 100); updateAccountQuota("b", 2); + expect(resolveCodexAccountForThread("short-full", config)).toBe("b"); + config.codexAccountAutoSwitchThresholds = { a: 0 }; + setAccountQuotaFromParsed("a", { weeklyPercent: 1, shortPercent: 100, shortResetAt: Date.now() / 1000 + 3600 }); + expect(previewCodexAccountForRequest("short-full", config)).toBe("b"); + expect(resolveCodexAccountForThread("short-full", config)).toBe("b"); + }); +}); diff --git a/tests/codex-integration/codex-quota-prime.test.ts b/tests/codex-integration/codex-quota-prime.test.ts index aeb67e33707..b353b24dfe1 100644 --- a/tests/codex-integration/codex-quota-prime.test.ts +++ b/tests/codex-integration/codex-quota-prime.test.ts @@ -1,10 +1,13 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { primeCodexPoolQuotas, + type PrimeCodexPoolQuotasOptions, getAccountQuota, updateAccountQuota, + setAccountQuotaFromParsed, clearAccountQuota, clearCodexQuotaPrimeState, clearCodexQuotaPrimeSingleFlightForTests, @@ -22,11 +25,13 @@ import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/a import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; import { completeNativeMainRecovery, + flushNativeMainStartupReleases, initializeNativeMainStartupGate, } from "../../src/codex/native-profile-startup"; import { handleNativeProfileAPI } from "../../src/codex/native-profile-api"; import type { NativeProfileManager } from "../../src/codex/native-profile-manager"; -import { resolveCodexAccountForThread, clearThreadAccountMap } from "../../src/codex/routing"; +import { resolveCodexAccountForThread, previewCodexAccountForRequest, clearThreadAccountMap } from "../../src/codex/routing"; +import { markAccountNeedsReauth, clearAccountNeedsReauth, isAccountNeedsReauth } from "../../src/codex/account-runtime-state"; import { acquireNativeMainProfileDrain, getNativeMainProfileRequestCount, @@ -34,11 +39,13 @@ import { } from "../../src/server/lifecycle"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { flushWindowsSecretAclReapsBeforeRemoval, setIcaclsRunnerForTests, setAsyncIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; // Phase 20 (260630_wsl-account-autoswitch): startup/lazy quota priming. -const TEST_DIR = join(import.meta.dir, ".tmp-codex-quota-prime-test"); -const TEST_CODEX_HOME = join(TEST_DIR, "codex"); +let TEST_DIR = ""; +let TEST_CODEX_HOME = ""; let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; @@ -97,7 +104,12 @@ describe("primeCodexPoolQuotas", () => { beforeEach(() => { previousOpencodexHome = process.env.OPENCODEX_HOME; previousCodexHome = process.env.CODEX_HOME; - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-quota-prime-")); + TEST_CODEX_HOME = join(TEST_DIR, "codex"); + // This suite asserts WHAM/selection ownership, not OS permission mutation. + const aclSuccess = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclSuccess); + setAsyncIcaclsRunnerForTests(async () => aclSuccess); mkdirSync(TEST_CODEX_HOME, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; // Isolate the main-account source: TEST_CODEX_HOME has no auth.json, so the @@ -111,13 +123,18 @@ describe("primeCodexPoolQuotas", () => { resetLifecycleDrainStateForTests(); }); - afterEach(() => { + afterEach(async () => { clearAccountQuota(); clearThreadAccountMap(); clearCodexQuotaPrimeState(); clearMainAccountInfoCache(); resetMainCodexAccountIdentityTrackingForTests(); resetLifecycleDrainStateForTests(); + await flushNativeMainStartupReleases(); + await flushConfigDirHardeningForTests(); + await flushWindowsSecretAclReapsBeforeRemoval(TEST_DIR); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -152,6 +169,127 @@ describe("primeCodexPoolQuotas", () => { } }); + test("live failback refreshes inactive quota every five minutes, including after failures", async () => { + const config = makeConfig({ codexAccountPriorityFailback: true }); + seedPoolAccount(config, "p1"); + saveCodexAccountCredential("p1", { + accessToken: "access-p1", refreshToken: "refresh-p1", + expiresAt: Date.now() + 86_400_000, chatgptAccountId: "acct-p1", + }); + const originalFetch = globalThis.fetch; + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + let calls = 0; + let fail = false; + try { + globalThis.fetch = (async () => { + calls += 1; + return fail ? new Response("unavailable", { status: 503 }) : whamResponse(0); + }) as typeof fetch; + await primeCodexPoolQuotas(config, "priority-failback"); + expect(calls).toBe(1); + now += 299_999; + await primeCodexPoolQuotas(config, "priority-failback"); + expect(calls).toBe(1); + now += 1; + fail = true; + await primeCodexPoolQuotas(config, "priority-failback"); + expect(calls).toBe(2); + now += 1; + await primeCodexPoolQuotas(config, "priority-failback"); + expect(calls).toBe(2); + now += 300_000; + fail = false; + await primeCodexPoolQuotas(config, "priority-failback"); + expect(calls).toBe(3); + expect(getAccountQuota("p1")?.weeklyPercent).toBe(0); + } finally { + globalThis.fetch = originalFetch; + clock.mockRestore(); + } + }); + + test("priority failback also refreshes stale native main quota", async () => { + const config = makeConfig({ codexAccountPriorityFailback: true }); + seedMainAccount(); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 100); + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + let reads = 0; + const options: PrimeCodexPoolQuotasOptions = { + reconcileMainAccount: () => false, + readMainTokens: () => ({ access_token: "main-access", account_id: "main-account" }), + fetchMainInfo: async force => { expect(force).toBe(true); reads += 1; return { email: null, plan: null, quota: null }; }, + }; + try { + await primeCodexPoolQuotas(config, "priority-failback", options); + expect(reads).toBe(0); + now += 300_001; + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { resetCredits: 1 }); + await primeCodexPoolQuotas(config, "priority-failback", options); + expect(reads).toBe(1); + } finally { + clock.mockRestore(); + } + }); + + test("priority failback refreshes retained stale windows despite a recent credits cache write", async () => { + const config = makeConfig({ codexAccountPriorityFailback: true, activeCodexAccountId: "p2", codexAccountPriorities: { p1: 1 } }); + seedPoolAccount(config, "p1"); seedPoolAccount(config, "p2"); + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + const originalFetch = globalThis.fetch; + const probed: string[] = []; + try { + for (const id of ["p1", "p2"]) saveCodexAccountCredential(id, { + ...readCodexAccountRecord(id)!.credential!, expiresAt: now + 3_600_000, + }); + updateAccountQuota("p1", 100); updateAccountQuota("p2", 2); + expect(resolveCodexAccountForThread("partial-prime", config, now)).toBe("p2"); + config.codexAccountAutoSwitchThresholds = { p1: 0 }; + updateAccountQuota("p1", 10); + now += 300_001; + setAccountQuotaFromParsed("p1", { resetCredits: 1 }); + updateAccountQuota("p2", 2); + globalThis.fetch = (async (_input, init) => { + probed.push(new Headers(init?.headers).get("chatgpt-account-id") ?? "missing"); + return whamResponse(0); + }) as typeof fetch; + await primeCodexPoolQuotas(config, "pre-route"); + expect(probed).toEqual([]); + expect(previewCodexAccountForRequest("partial-prime", config, now)).toBe("p2"); + await primeCodexPoolQuotas(config, "priority-failback"); + expect(probed).toEqual(["acct-p1"]); + expect(previewCodexAccountForRequest("partial-prime", config, now)).toBe("p1"); + expect(resolveCodexAccountForThread("partial-prime", config, now)).toBe("p1"); + await primeCodexPoolQuotas(config, "priority-failback"); + expect(probed).toHaveLength(1); + } finally { globalThis.fetch = originalFetch; clock.mockRestore(); } + }); + + test("passive main failback refresh never clears an inference reauth mark", async () => { + const config = makeConfig({ codexAccountPriorityFailback: true }); + seedMainAccount(); + const originalFetch = globalThis.fetch; + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + let calls = 0; + try { + globalThis.fetch = (async () => { calls++; return whamResponse(10); }) as typeof fetch; + await primeCodexPoolQuotas(config, "startup"); + expect(calls).toBe(1); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + now += 300_001; + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { resetCredits: 1 }); + await primeCodexPoolQuotas(config, "priority-failback"); + expect(calls).toBe(2); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + } finally { + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + globalThis.fetch = originalFetch; clock.mockRestore(); + } + }); + test("prime populates stale/unknown pool accounts", async () => { const config = makeConfig(); seedPoolAccount(config, "p1"); diff --git a/tests/codex-integration/main-account-hard-lock-auth.test.ts b/tests/codex-integration/main-account-hard-lock-auth.test.ts index 3e0bb6a7994..1519d49c68e 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -71,6 +71,10 @@ function config(): OcxConfig { }; } +function accountZeroConfig(): OcxConfig { + return { ...config(), autoSwitchThreshold: 95, codexAccountAutoSwitchThresholds: { [MAIN]: 0 } }; +} + function writeMain(token = bearer()): void { writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: token, refresh_token: "fixture-refresh", account_id: accountId }, @@ -201,11 +205,15 @@ describe("main quota policy at native admission", () => { }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); - test.each(["owned-99", "owned-98", "foreign", "unknown", "recovery", "second-listener", + test.each([...(["owned-99", "owned-98", "foreign", "unknown", "recovery", "second-listener", "invalid-access-token", "invalid-account-id", "invalid-id-token", "mismatched-identity", "renewed-listener", "stage-retry", "manual-recovery", "stale-sweep", "retained-unknown-binding", - "conflicting-token-identities", "conflicting-claims", "owned-opaque-99"] as const)( - "fresh startup restores durable main policy only after owned recovery (%s)", scenario => { + "conflicting-token-identities", "conflicting-claims", "owned-opaque-99"] as const) + .map(scenario => [scenario, "global-zero"] as const), + ["owned-99", "account-zero"] as const, + ["owned-98", "account-zero"] as const, + ["recovery", "account-zero"] as const])( + "fresh startup restores durable main policy only after owned recovery (%s, %s)", (scenario, thresholdMode) => { const restoredId = scenario === "recovery" ? "hard-lock-recovered-main" : accountId; const restoredBearer = scenario === "owned-opaque-99" ? "opaque-owned-startup-bearer" : `header.${Buffer.from(JSON.stringify({ exp: tokenExpiry, ...(["renewed-listener", "manual-recovery", "stale-sweep"].includes(scenario) ? { startupTokenRevision: 1 } : {}), @@ -230,7 +238,8 @@ describe("main quota policy at native admission", () => { } })); } writeFileSync(join(home, "config.json"), JSON.stringify({ - ...config(), port: 0, hostname: "127.0.0.1", codexMainAccountHardLock: scenario !== "second-listener", + ...(thresholdMode === "account-zero" ? accountZeroConfig() : config()), + port: 0, hostname: "127.0.0.1", codexMainAccountHardLock: scenario !== "second-listener", providers: { openai: { ...config().providers.openai, codexAccountMode: "direct" } }, })); writeFileSync(join(home, "config.toml"), 'model = "gpt-5.6-sol"\n'); @@ -250,6 +259,8 @@ describe("main quota policy at native admission", () => { const line = child.stdout.toString().split(/\r?\n/).find(value => value.startsWith("POLICY_STARTUP_RESULT=")); expect(line).toBeDefined(); const result = JSON.parse(line!.slice("POLICY_STARTUP_RESULT=".length)); + expect(result.thresholds).toEqual(thresholdMode === "account-zero" + ? { global: 95, mainOverride: 0 } : { global: 0, mainOverride: null }); expect(result.before).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); expect(result.listeners[0].tokenReads).toBe(0); expect(result.unexpectedNetwork).toEqual([]); @@ -282,8 +293,8 @@ describe("main quota policy at native admission", () => { expect(result.heldRecovery.observation).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); expect(result.heldRecovery.poolFallback).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); expect(result.heldRecovery.mainPin).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); - expect(result.heldRecovery.storedAlternative).toMatchObject({ admitted: true, kind: "pool" }); - expect(result.heldRecovery.automaticAlternative).toMatchObject({ admitted: true, kind: "pool" }); + expect(result.heldRecovery.storedAlternative).toMatchObject({ admitted: true, kind: "pool", accountId: "startup-pool" }); + expect(result.heldRecovery.automaticAlternative).toMatchObject({ admitted: true, kind: "pool", accountId: "startup-pool" }); expect(result.originalResponse.status).toBe(200); } if (scenario === "second-listener") { @@ -327,6 +338,71 @@ describe("main quota policy at native admission", () => { }, SPAWN_BUDGET_MS, ); + test("per-account zero cannot bypass exact-main or main-only Pool hard-lock", async () => { + const cfg = accountZeroConfig(); + quota(99); + const refresh = spyOn(mainAccount, "getValidMainAccountToken"); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN })) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(isCodexAccountUsable(cfg, MAIN, { nativeMainSelectionOnly: true })).toBe(false); + expect(refresh).not.toHaveBeenCalled(); + expect(getCodexUpstreamHealth(MAIN)).toBeNull(); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + }); + + test("per-account zero keeps main below hard-lock but detours to healthy Pool at 99", async () => { + const cfg = accountZeroConfig(); + addAlternative(cfg); + setAccountQuotaFromParsed("hard-lock-pool", { weeklyPercent: 1, shortPercent: 1 }); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 98.99, shortPercent: 98.99 }, undefined, + captureMainQuotaWriter(accountId)); + // Above global 95: ignoring the explicit zero would proactively leave main here. + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "main-pool", accountId: MAIN }); + quota(99); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "hard-lock-pool" }); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + }); + + test("per-account zero cannot bypass caller-owned Direct or exact-main hard-lock", async () => { + const cfg = accountZeroConfig(); + observeMainQuotaCredential(bearer(), accountId); + quota(99); + forbidPhysicalReads(); + await expect(resolveCodexAuthContext(caller(), cfg, "direct")) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + await expect(resolveCodexAuthContext(caller(), cfg, "pool", { + requestScopedMainCredential: true, accountId: MAIN, + })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + await expect(resolveCodexAuthContext(caller(), cfg, "pool", { requestScopedMainCredential: true })) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + }); + + test("per-account zero main pin detours to healthy Pool without physical main reads", async () => { + const cfg = accountZeroConfig(); + addAlternative(cfg); + cfg.activeCodexAccountPinned = MAIN; + observeMainQuotaCredential(bearer(), accountId); + quota(99); + forbidPhysicalReads(); + await expect(resolveCodexAuthContext(caller(), cfg, "pool", { requestScopedMainCredential: true })) + .resolves.toMatchObject({ kind: "pool", accountId: "hard-lock-pool" }); + }); + + test("per-account zero cannot bypass hard-lock when selected main headers materialize", async () => { + const cfg = accountZeroConfig(); + quota(98.99); + const context = await resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN }); + expect(context.kind).toBe("main-pool"); + quota(99); + expect(() => headersForCodexAuthContext(new Headers(), context, cfg)).toThrow(CodexMainAccountHardLockError); + cfg.codexMainAccountHardLock = false; + expect(headersForCodexAuthContext(new Headers(), context, cfg).get("authorization")).toBe(`Bearer ${bearer()}`); + }); + test("short-only 99 blocks exact main and main-only Pool without probe or reauth", async () => { quota(99); const cfg = config(); diff --git a/tests/config/config-account-thresholds.test.ts b/tests/config/config-account-thresholds.test.ts new file mode 100644 index 00000000000..5d83e51cec1 --- /dev/null +++ b/tests/config/config-account-thresholds.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getConfigPath, getDefaultConfig, readConfigDiagnostics, validateConfigCandidate } from "../../src/config"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +let testDir = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-config-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + delete process.env.OPENCODEX_HOME; + if (testDir && existsSync(testDir)) removeTreeWithRetry(testDir); + testDir = ""; +}); + +function backupNames(): string[] { + return readdirSync(testDir).filter(name => name.startsWith("config.json.invalid-")); +} + +function writeConfig(content: unknown): void { + writeFileSync( + getConfigPath(), + typeof content === "string" ? content : JSON.stringify(content), + "utf-8", + ); +} + + test("config candidates preserve valid account thresholds and reject malformed maps", () => { + const base = getDefaultConfig(); + + expect(validateConfigCandidate({ + ...base, + codexAccountAutoSwitchThresholds: { work: 0, __main__: 100 }, + })).toMatchObject({ + ok: true, + config: expect.objectContaining({ + codexAccountAutoSwitchThresholds: { work: 0, __main__: 100 }, + }), + }); + for (const thresholds of [ + { work: -1 }, + { work: 101 }, + { work: 1.5 }, + { work: "80" }, + { "bad id!": 80 }, + [], + ]) { + expect(validateConfigCandidate({ + ...base, + codexAccountAutoSwitchThresholds: thresholds, + })).toMatchObject({ + ok: false, + error: expect.stringContaining("codexAccountAutoSwitchThresholds"), + }); + } + }); + + +describe("codex account usage-threshold overrides", () => { + function writeThresholdConfig(codexAccountAutoSwitchThresholds: unknown): void { + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccountAutoSwitchThresholds, + }); + } + + test("round-trips pool and main-account thresholds including zero", () => { + const thresholds = { work: 0, __main__: 100 }; + writeThresholdConfig(thresholds); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.config.codexAccountAutoSwitchThresholds).toEqual(thresholds); + }); + + test("degrades a malformed map without discarding providers", () => { + writeThresholdConfig({ work: 101 }); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.codexAccountAutoSwitchThresholds).toBeUndefined(); + expect(Object.keys(diagnostics.config.providers)).toContain("openai"); + expect(backupNames()).toHaveLength(0); + expect(diagnostics.warnings).toContainEqual(expect.stringContaining("per-account usage thresholds are disabled")); + }); +}); diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index f874ce66de1..728f991a2a0 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { ConfigMutationLockError, deleteConfigTopLevelKey, getConfigPath, initializePersistedConfigIfMissing, loadConfig, observeInitialConfigState, readConfigGeneration, saveConfig, withConfigMutationLockSync } from "../../src/config"; +import { armClaudeCodeBaseline, ConfigMutationLockError, deleteConfigTopLevelKey, getConfigPath, initializePersistedConfigIfMissing, loadConfig, observeInitialConfigState, readConfigGeneration, saveConfig, saveConfigPreservingClaudeCode, withConfigMutationLockSync } from "../../src/config"; +import { setCodexAccountAutoSwitchThresholdOverride } from "../../src/codex/account-auto-switch"; import { InitialConfigPublicationError, publishInitialConfigNoReplace } from "../../src/config/initialize"; import { nextAtomicTempSequence } from "../../src/config/atomic-write"; import { CodexCredentialRefreshLockTimeoutError, getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -205,6 +206,43 @@ test("initial creation keeps candidate values and existing bytes; the explicit s expect(initTemps()).toEqual([]); }); +test.each([0, 60])("initial publication consumes a deleted %i account override before a later disk recreation", (threshold) => { + const candidate = { ...config(), codexAccountAutoSwitchThresholds: { work: threshold, side: 70 } }; + setCodexAccountAutoSwitchThresholdOverride(candidate, "work", null); + expect(initializePersistedConfigIfMissing(candidate)).toBe("created"); + expect(loadConfig().codexAccountAutoSwitchThresholds).toEqual({ side: 70 }); + armClaudeCodeBaseline(candidate); + + const newer = JSON.parse(readFileSync(getConfigPath(), "utf8")); + newer.codexAccountAutoSwitchThresholds = { work: 80, side: 70, added: 90 }; + writeFileSync(getConfigPath(), JSON.stringify(newer)); + candidate.port = 21001; + saveConfigPreservingClaudeCode(candidate); + + expect(candidate.codexAccountAutoSwitchThresholds).toEqual({ work: 80, side: 70, added: 90 }); + expect(loadConfig().codexAccountAutoSwitchThresholds).toEqual({ work: 80, side: 70, added: 90 }); + expect(loadConfig().port).toBe(21001); +}); + +test("failed initial publication retains child deletion intent for the next guarded save", () => { + const candidate = { ...config(), codexAccountAutoSwitchThresholds: { work: 60, side: 70 } }; + armClaudeCodeBaseline(candidate); + setCodexAccountAutoSwitchThresholdOverride(candidate, "work", null); + expect(() => initializePersistedConfigIfMissing(candidate, { + link() { throw new Error("publication refused"); }, + })).toThrow(); + expect(existsSync(getConfigPath())).toBe(false); + + writeFileSync(getConfigPath(), JSON.stringify({ + ...config(), + codexAccountAutoSwitchThresholds: { work: 80, side: 70, added: 90 }, + })); + saveConfigPreservingClaudeCode(candidate); + + expect(candidate.codexAccountAutoSwitchThresholds).toEqual({ side: 70, added: 90 }); + expect(loadConfig().codexAccountAutoSwitchThresholds).toEqual({ side: 70, added: 90 }); +}); + test.each(["", "not-json\n", '{"port":"broken"}', '\uFEFF{ "port":21002, "providers":{}, "defaultProvider":"openai", "unknown":42 }\n'])( "init preserves occupied bytes without lock or backup creation: %j", bytes => { writeFileSync(getConfigPath(), bytes); diff --git a/tests/config/config-rebase-provenance-writers.test.ts b/tests/config/config-rebase-provenance-writers.test.ts index 2ad97f09aa3..1fb3e30d2a2 100644 --- a/tests/config/config-rebase-provenance-writers.test.ts +++ b/tests/config/config-rebase-provenance-writers.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { repoPath } from "../helpers/repo-root"; const writerContracts: Record = { @@ -23,6 +22,28 @@ const writerContracts: Record = { "src/cli/v2.ts": ["multiAgentMode", "keepNativeChatGptOnV1"], }; +const childWriterContracts: Record = { + "src/codex/account-auto-switch.ts": ["codexAccountAutoSwitchThresholds"], +}; + +function childDeletionFields(source: string): Set { + const fields = new Set(); + // Keep string literals whole and discard comments, so examples are not mistaken + // for calls. The contract checks a helper call with a config identifier and a + // literal field name, independently of formatting and local identifier names. + const tokens = (source.match(/\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|[\w$]+|[^\s]/g) ?? []) + .filter(token => !token.startsWith("//") && !token.startsWith("/*")); + for (let index = 0; index < tokens.length; index++) { + if (tokens[index] === "deleteConfigObjectChildKey" && tokens[index + 1] === "(" + && /^[\w$]+$/.test(tokens[index + 2] ?? "") && tokens[index + 3] === "," + && tokens[index + 5] === ",") { + const field = tokens[index + 4]; + if (field && /^(["'])[\w$]+\1$/.test(field)) fields.add(field.slice(1, -1)); + } + } + return fields; +} + test("every enumerated top-level deletion writer records config rebase provenance", () => { for (const [path, keys] of Object.entries(writerContracts)) { const source = readFileSync(repoPath(path), "utf8"); @@ -38,6 +59,29 @@ test("every enumerated top-level deletion writer records config rebase provenanc } }); +test("every enumerated child deletion writer records field-scoped rebase provenance", () => { + for (const [path, keys] of Object.entries(childWriterContracts)) { + const fields = childDeletionFields(readFileSync(repoPath(path), "utf8")); + for (const key of keys) { + expect(fields.has(key), `${path} must record child deletion provenance for ${key}`).toBe(true); + } + } +}); + +test("child writer contracts tolerate formatting and ignore comments, strings, and top-level deletions", () => { + const source = ` + // deleteConfigObjectChildKey(config, "comment", id); + const example = 'deleteConfigObjectChildKey(config, "string", id)'; + deleteConfigTopLevelKey(renamedConfig, "topLevel"); + deleteConfigObjectChildKey( + renamedConfig, + 'codexAccountAutoSwitchThresholds', + renamedAccountId, + ); + `; + expect([...childDeletionFields(source)]).toEqual(["codexAccountAutoSwitchThresholds"]); +}); + test("live-config writers contain no untracked direct top-level deletion", () => { for (const path of Object.keys(writerContracts)) { const source = readFileSync(repoPath(path), "utf8"); diff --git a/tests/config/config-user-edits.test.ts b/tests/config/config-user-edits.test.ts index 38ac41aa378..bb0bbaa29b8 100644 --- a/tests/config/config-user-edits.test.ts +++ b/tests/config/config-user-edits.test.ts @@ -17,6 +17,7 @@ import { validateConfigCandidate, } from "../../src/config"; import { legacyCustomModelCatalogSlugs } from "../../src/codex/custom-model-catalog-migration"; +import { setCodexAccountAutoSwitchThresholdOverride } from "../../src/codex/account-auto-switch"; import { rateLimitRetryPolicyFor } from "../../src/providers/key-failover"; import { activeUserCostOverlays, @@ -610,6 +611,19 @@ test("OAuth reconciliation adopts a guarded Claude edit that predates its disk s expect(diskConfig().claudeCode).toEqual({ authMode: "proxy" }); }); +test("OAuth reconciliation preserves a cleared account threshold and adopts a disk sibling", () => { + const live = loadConfig(); + live.codexAccountAutoSwitchThresholds = { work: 60 }; + saveConfig(live); + const persistedBaseline = loadConfig(); + + writeDiskConfig({ codexAccountAutoSwitchThresholds: { work: 60, side: 70 } }); + setCodexAccountAutoSwitchThresholdOverride(live, "work", null); + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.codexAccountAutoSwitchThresholds).toEqual({ side: 70 }); +}); + test("OAuth reconciliation adopts a modelCosts edit and refreshes the overlay registry", () => { const live = loadConfig(); const persistedBaseline = loadConfig(); @@ -700,6 +714,20 @@ test("a live deletion of a key that only ever existed on disk is not undone by t expect(live.grokExcludedModels).toBeUndefined(); }); +test("clearing an account threshold preserves a sibling override added on disk", () => { + const live = loadConfig(); + live.codexAccountAutoSwitchThresholds = { work: 60 }; + saveConfig(live); + armClaudeCodeBaseline(live); + + writeDiskConfig({ codexAccountAutoSwitchThresholds: { work: 60, side: 70 } }); + setCodexAccountAutoSwitchThresholdOverride(live, "work", null); + saveConfigPreservingClaudeCode(live); + + expect(live.codexAccountAutoSwitchThresholds).toEqual({ side: 70 }); + expect(diskConfig().codexAccountAutoSwitchThresholds).toEqual({ side: 70 }); +}); + test("provenance distinguishes an unseen disk key from an explicit deletion", () => { const live = loadConfig(); armClaudeCodeBaseline(live); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 46e7472102d..716f5fe4dd0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,10 @@ { + "codex-priority-failback.test.ts": "codex-integration", + "codex-account-threshold-routing.test.ts": "codex-integration", + "codex-account-threshold-api.test.ts": "codex-integration", + "codex-account-threshold-auth.test.ts": "codex-integration", + "config-account-thresholds.test.ts": "config", + "cli-account-threshold.test.ts": "cli", "release-desktop-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", "gui-desktop-sidecar-script.test.ts": "gui", diff --git a/tests/helpers/main-account-policy-startup-child.ts b/tests/helpers/main-account-policy-startup-child.ts index 387a12cfcda..d393fbc5a86 100644 --- a/tests/helpers/main-account-policy-startup-child.ts +++ b/tests/helpers/main-account-policy-startup-child.ts @@ -1,6 +1,7 @@ import { spyOn } from "bun:test"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; interface Fixture { scenario: "owned-99" | "owned-98" | "foreign" | "unknown" | "recovery" | "second-listener" @@ -153,7 +154,7 @@ const admit = async ( options: Parameters[3] = {}, policy = config, ) => { - try { const context = await resolveCodexAuthContext(headers(), policy, mode, options); return { admitted: true, kind: context.kind }; } + try { const context = await resolveCodexAuthContext(headers(), policy, mode, options); return { admitted: true, kind: context.kind, accountId: context.accountId }; } catch (error) { return { admitted: false, error: (error as Error).name }; } }; const wire = async (token = fixture.bearer, id = fixture.accountId) => { @@ -276,6 +277,7 @@ try { } console.log("POLICY_STARTUP_RESULT=" + JSON.stringify({ scenario: fixture.scenario, before, listeners, firstServerSettled, firstAdmission, heldRecovery, laterRecovery, + thresholds: { global: config.autoSwitchThreshold, mainOverride: config.codexAccountAutoSwitchThresholds?.[MAIN_CODEX_ACCOUNT_ID] ?? null }, retainedUnknown, validReplacement, settled, after, settledAdmission, response, beforePrimaryUpstreamCalls, primaryUpstreamCalls, originalResponse, unexpectedNetwork, diff --git a/tests/routing/subagent-model-fallback.test.ts b/tests/routing/subagent-model-fallback.test.ts index d68330dc83c..b5374feff2b 100644 --- a/tests/routing/subagent-model-fallback.test.ts +++ b/tests/routing/subagent-model-fallback.test.ts @@ -799,6 +799,20 @@ test("the native-main drain sentinel covers the flagships without widening to gp expect(isNativeModelQuotaExhausted("gpt-5.6-sol", config, "pool-a", now)).toBe(false); }); + test("native subagent quota checks use the resolved account threshold override", () => { + resetSubagentModelFallbackStateForTests(); + updateAccountQuota("pool-a", 60); + const config = cfg({ + autoSwitchThreshold: 95, + codexAccountAutoSwitchThresholds: { "pool-a": 50 }, + }); + + expect(isNativeModelQuotaExhausted("gpt-5.6-sol", config, "pool-a")).toBe(true); + + config.codexAccountAutoSwitchThresholds = { "pool-a": 0 }; + expect(isNativeModelQuotaExhausted("gpt-5.6-sol", config, "pool-a")).toBe(false); + }); + test("openai-direct/gpt-5.5 is accepted as encrypted-task fallback when canonical", () => { resetSubagentModelFallbackStateForTests(); updateAccountQuota("pool-a", 95, undefined, 20);