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 e642700298..89f445c620 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -36,11 +36,12 @@ 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 et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `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. | -| `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. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `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` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc un réordonnancement, pas un verrouillage. | +| `autoSwitchThreshold?` | `number` | `80` | Seuil global par défaut pour la commutation proactive ; `codexAccountAutoSwitchThresholds` le remplace par compte. Par défaut, `quota` réévalue les tâches liées et non liées selon le seuil effectif du compte source actuel ; `fill-first` évalue la marge de chaque compte selon son propre seuil effectif pour les affectations non liées. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. La rotation `round-robin` repose sur un compteur et ignore les seuils, mais le filtre commun des niveaux de priorité utilise toujours le seuil effectif de chaque compte. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. Une valeur effective de `0` désactive uniquement la commutation fondée sur l'utilisation depuis ce compte, pas les affectations 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 son seuil effectif (valeur spécifique au compte ou seuil global) et, une fois ce seuil atteint, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées dans les limites du filtre commun des niveaux de priorité ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation effectif de ce compte (valeur spécifique au compte ou seuil global). `reset-first`: Parmi les comptes ayant une marge selon leur propre seuil effectif, 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` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a atteint le seuil effectif de ce compte. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc 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. | | `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Seuil facultatif du coupe-circuit pour les échecs DNS/TCP avérés avant connexion sur les requêtes Responses OpenAI natives en mode transfert et les envois compacts. `0` le désactive ; `1`–`20` ouvre, après ce nombre de requêtes logiques arrivées à leur terme, une temporisation de 30 secondes propre à l'origine du fournisseur. Tant que le circuit est ouvert, les requêtes reçoivent `503` avec `Retry-After` avant la sélection du compte ou l'envoi en amont ; après la temporisation, une requête est admise en état semi-ouvert. Les délais d'attente et les réponses HTTP ne sont jamais comptabilisés, et toute réponse HTTP ferme le circuit. Ce mécanisme s'applique uniquement au routage du pool Codex sans compte épinglé ; il reste inactif pour `codexAccountMode: "direct"` et les sélecteurs qualifiés par compte. | @@ -204,9 +205,9 @@ 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é, et la requête suivante d'une tâche liée aussi sauf si `pool.cacheAffinity` est activé. Avec ce drapeau, l'affinité de cache prime sur la marge de quota et la tâche liée reste jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir (suspendu, inutilisable). `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é. | +| `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 son seuil effectif (valeur spécifique au compte ou seuil global) ; une fois ce seuil atteint, une requête non liée peut être déplacée vers un compte admissible moins utilisé, et la requête suivante d'une tâche liée aussi sauf si `pool.cacheAffinity` est activé. Avec ce drapeau, l'affinité de cache prime sur la marge de quota et la tâche liée reste jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir (suspendu, inutilisable). `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. 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 e7da3b7bfb..4ab34f576d 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -293,7 +293,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/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 31451fa18c..f967c283ef 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -35,10 +35,11 @@ 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 ページで管理します。 | -| `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | -| `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 | +| `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` | プロアクティブ切り替えのグローバル既定しきい値。`codexAccountAutoSwitchThresholds` でアカウント別に上書きできます。既定では `quota` は現在の切り替え元アカウントの実効しきい値で紐付け済み/未紐付けタスクを再評価し、`fill-first` は未紐付け割り当ての余裕を各アカウント自身の実効しきい値で判定します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`round-robin` の rotation 自体はカウンターに基づきしきい値を使いませんが、共通の優先度 tier フィルターは各アカウントの実効しきい値を使います。既知の 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 最小の適格アカウントを選び、適格なアクティブアカウントが自身の実効しきい値(アカウント別上書き、未設定ならグローバル値)未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は共通の優先度 tier フィルター内で未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、またはそのアカウントの実効 drain threshold(アカウント別上書き、未設定ならグローバル値)までアクティブアカウントへ割り当てます。 `reset-first`: 各アカウント自身の実効しきい値に基づいて余裕があるアカウントから、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が そのアカウントの実効しきい値に達したという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | ネイティブOpenAI forwardのResponses送信とcompact送信で、実証済みの接続前DNS/TCP障害に適用するオプトインのサーキットしきい値です。`0`で無効、`1`〜`20`ではその回数の終端論理リクエストが失敗するとprovider-originを30秒間遮断します。遮断中はアカウント選択やupstream送信の前に`Retry-After`付き`503`を返し、時間経過後はhalf-openリクエストを1件だけ許可します。タイムアウトとHTTP応答は数えず、HTTP応答が1件でもあれば回路を閉じます。 Codex Pool ルーティングでアカウントが固定されていない場合にのみ適用され、`codexAccountMode: "direct"` とアカウント修飾セレクターでは動作しません。 | @@ -178,10 +179,10 @@ pause、cooldown、再認証、障害処理も独立して routing を消去ま 別の適格な Pool アカウントへリクエストを切り替えることがあります。これらの障害回復は `autoSwitchThreshold: 0` でも有効であり、`0` が無効にするのは使用量に基づく予防的な切り替えだけです。 -**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも再紐付けできます。オンなら cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は -未紐付けリクエストを均等分散し、しきい値は通常の rotation を変えません。`accountPoolStickyLimit` +**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが自身の実効しきい値(アカウント別上書き、未設定ならグローバル値)未満なら維持します。その実効しきい値に達した後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも再紐付けできます。オンなら cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は +未紐付けリクエストを均等分散し、rotation 自体はカウンターに基づきしきい値を使いません。ただし、共通の優先度 tier フィルターは各アカウント自身の実効しきい値で余裕を判定します。`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 4398ff8118..534642d728 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -246,7 +246,7 @@ Authorization: Bearer | `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 dae4836490..41ee2ec43d 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -35,10 +35,11 @@ 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 페이지에서 관리합니다. | -| `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | -| `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. | +| `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` | 선제 전환의 전역 기본 임계값이며 `codexAccountAutoSwitchThresholds`로 계정별 재정의할 수 있습니다. 기본적으로 `quota`는 현재 전환 출발 계정의 유효 임계값으로 바인딩된 작업과 바인딩 없는 작업을 재평가하고, `fill-first`는 바인딩 없는 작업 배정을 위해 각 계정 자신의 유효 임계값으로 여유를 판단합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `round-robin` 순환 자체는 카운터 기반으로 임계값을 사용하지 않지만, 공통 우선순위 tier 필터는 각 계정의 유효 임계값을 계속 적용합니다. 알려진 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가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 자신의 유효 임계값(계정별 재정의 또는 전역 기본값) 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 공통 우선순위 tier 필터 안에서 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 해당 계정의 유효 drain threshold(계정별 재정의 또는 전역 기본값)까지 활성 계정에 배정합니다. `reset-first`: 각 계정 자신의 유효 임계값에 따라 여유가 있는 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | +| `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 해당 계정의 유효 임계값에 도달했다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | | `upstreamHostCircuitThreshold?` | `number` | `0` | 네이티브 OpenAI forward Responses와 compact 전송에서 입증된 연결 전 DNS/TCP 실패에 적용하는 선택적 회로 차단 임계값입니다. `0`은 비활성화하며, `1`~`20`은 이 횟수만큼 최종 논리 요청이 실패하면 provider-origin을 30초 동안 차단합니다. 차단 중에는 계정 선택이나 업스트림 전송 전에 `Retry-After`가 포함된 `503`을 반환하고, 시간이 지나면 반개방 요청 하나만 허용합니다. 타임아웃과 HTTP 응답은 집계하지 않으며, HTTP 응답이 하나라도 오면 회로를 닫습니다. Codex Pool 라우팅에서 계정이 고정되지 않은 경우에만 적용되며, `codexAccountMode: "direct"` 및 계정 한정 선택자에서는 동작하지 않습니다. | @@ -181,10 +182,10 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 `autoSwitchThreshold: 0`에서도 계속 작동하며, `0`은 사용량 기반 선제 전환만 비활성화합니다. **배정 및 선제 전환 전략:** `quota`(기본)는 활성 계정이 없을 때 최저 usage의 적격 계정을 선택하고, -적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. 플래그가 켜져 있으면 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지됩니다. -`round-robin`은 바인딩 없는 요청을 균등 분배하며 임계값은 기본 순환에 영향을 주지 않습니다. +적격 활성 계정이 자신의 유효 임계값(계정별 재정의 또는 전역 기본값) 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. 플래그가 켜져 있으면 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지됩니다. +`round-robin`은 바인딩 없는 요청을 균등 분배하며 순환 자체는 카운터 기반으로 임계값을 사용하지 않습니다. 다만 공통 우선순위 tier 필터는 각 계정 자신의 유효 임계값으로 여유를 판단합니다. `accountPoolStickyLimit`(기본 `1`, 1–100)은 성공 응답이 아니라 배정/바인딩 횟수를 셉니다. -`fill-first`는 바인딩 없는 요청을 cooldown, 재인증 또는 drain threshold까지 활성 계정에 배정하고, +`fill-first`는 바인딩 없는 요청을 cooldown, 재인증 또는 해당 계정의 유효 drain threshold(계정별 재정의 또는 전역 기본값)까지 활성 계정에 배정하고, 정상적인 바인딩 작업은 affinity를 유지합니다. 이 전략들은 provider enforcement를 우회하지 않으며 다계정 사용은 ToS 위반일 수 있습니다. 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 b2c7951e89..17f772eb28 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -253,7 +253,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 752c13b395..da8c1ce40c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -49,11 +49,12 @@ 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 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`). | -| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `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. | -| `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 or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `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` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, 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%) — so affinity is a reordering, not a pin. | +| `autoSwitchThreshold?` | `number` | `80` | Global default usage threshold for proactive switching; `codexAccountAutoSwitchThresholds` overrides it per account. By default, `quota` re-evaluates bound and unbound tasks using the current source account's effective threshold; `fill-first` uses each account's effective threshold to check headroom for unbound assignment. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `round-robin` rotation is counter-based and does not use the threshold, but the shared priority-tier filter still checks each account's effective threshold. The score uses the hottest known 5h, weekly, or 30d quota window. An effective value of `0` disables usage-based proactive switching from that account 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 its effective threshold (account override or global default), and once that threshold is reached may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly within the shared priority-tier filter; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or that account's effective drain threshold (account override or global default). `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts with headroom under their own effective 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` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, a live binding outranks quota headroom: `quota` does not move the thread merely because usage reached that account's effective threshold. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — so affinity is a reordering, not a pin. | | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Opt-in circuit threshold for proven pre-connection DNS/TCP failures on native OpenAI forward Responses and compact sends. `0` disables it; `1`–`20` opens a 30-second provider-origin cooldown after that many terminal logical requests. While open, requests receive `503` with `Retry-After` before account selection or upstream send; after cooldown, one half-open request is admitted. Timeouts and HTTP responses never count, and any HTTP response closes the circuit. Applies only to Codex Pool routing with no pinned account; it is inert for `codexAccountMode: "direct"` and account-qualified selectors. | @@ -63,6 +64,12 @@ 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. | +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 @@ -538,10 +545,12 @@ validation never applies the IPv6 accommodation. Use **Codex Auth** in the dashboard to add pool accounts and refresh quotas. `config.json` stores non-secret metadata; access and refresh tokens use the hardened credential store. Pool routing separates new/unbound assignment, usage-based proactive switching, and failure recovery. A bound task -normally keeps affinity. By default `quota` may rebind it on its next request after the usage -threshold is crossed; with `pool.cacheAffinity` on, that rebind waits until the bound account is -exhausted or otherwise cannot serve. Pause, cooldown, reauthentication, and failure handling can -clear or move routing independently. An unbound request has no live account binding; this can include an existing visible +normally keeps affinity. By default `quota` may rebind it on its next request once the source +account's effective usage threshold is reached; with `pool.cacheAffinity` on, that rebind waits until +the bound account is exhausted or otherwise cannot serve. `codexAccountAutoSwitchThresholds` can +replace the global threshold for the source account; an absent entry inherits the global value and +`0` disables only that account's usage-driven moves. Pause, cooldown, reauthentication, and failure +handling can clear or move routing independently. An unbound request has no live account binding; this can include an existing visible task after proxy restart or affinity reset. A pre-stream 429 or 402, or a 5xx response whose bounded body explicitly reports quota exhaustion, retries once on an eligible alternate account in the same request, even when usage-based proactive switching is off. The ordinary transient-5xx policy runs @@ -562,9 +571,9 @@ 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, and a bound task's next request can too unless `pool.cacheAffinity` is on. With that flag on, cache affinity outranks quota headroom and the bound task stays until the account is exhausted (known usage at 100%) or cannot serve (paused, unusable). `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. | +| `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 its effective threshold (account override or global default); once it reaches that threshold, an unbound request can move to a lower-usage eligible account, and a bound task's next request can too unless `pool.cacheAffinity` is on. With that flag on, cache affinity outranks quota headroom and the bound task stays until the account is exhausted (known usage at 100%) or cannot serve (paused, unusable). `0` disables this usage-driven re-evaluation, not failure recovery. | +| `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 47509393f4..853597ba61 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -462,7 +462,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 d471f31c25..f37011b8bd 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -36,10 +36,11 @@ 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. | -| `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | -| `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold`. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. | +| `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` | Глобальный порог проактивного переключения по умолчанию; `codexAccountAutoSwitchThresholds` переопределяет его для отдельных аккаунтов. По умолчанию `quota` повторно оценивает привязанные и непривязанные задачи по эффективному порогу текущего исходного аккаунта; `fill-first` проверяет запас каждого аккаунта по его собственному эффективному порогу для непривязанных назначений. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. Ротация `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, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже его эффективного порога (переопределение аккаунта или глобальное значение), а при достижении этого порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся на аккаунте до исчерпания его квоты (известный usage 100%) или потери возможности обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы в рамках общего фильтра уровней приоритета; `fill-first` назначает их активному аккаунту до cooldown, недоступности или эффективного порога исчерпания этого аккаунта (переопределение аккаунта или глобальное значение). `reset-first`: Среди аккаунтов с запасом квоты по собственному эффективному порогу выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | +| `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage достиг эффективного порога этого аккаунта. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Опциональный порог circuit breaker для доказанных DNS/TCP-сбоев до соединения в нативных OpenAI forward Responses- и compact-отправках. `0` отключает его; `1`–`20` открывает 30-секундный cooldown для provider-origin после такого числа завершившихся логических запросов. Пока circuit открыт, до выбора аккаунта и upstream-отправки возвращается `503` с `Retry-After`; после cooldown допускается один half-open запрос. Таймауты и HTTP-ответы не учитываются, а любой HTTP-ответ закрывает circuit. Применяется только к маршрутизации Codex Pool без закреплённого аккаунта; при `codexAccountMode: "direct"` и для селекторов с указанием аккаунта схема не активна. | @@ -211,10 +212,10 @@ redirect'ов для обычных provider-request'ов реализована после чего запрос может перейти на другой подходящий аккаунт Pool. Эти переходы восстановления остаются активными при `autoSwitchThreshold: 0`; значение `0` отключает только проактивное переключение по использованию. -**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, cache affinity важнее запаса квоты, и привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы, а порог не -меняет обычную ротацию. `accountPoolStickyLimit` (по умолчанию `1`, 1–100) считает назначения/bind, +**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже его эффективного порога (переопределение аккаунта или глобальное значение), а при достижении этого порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, cache affinity важнее запаса квоты, и привязанная задача остаётся на аккаунте до исчерпания его квоты (известный usage 100%) или потери возможности обслуживать запрос. `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 978744bbb0..f9de5594e0 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -283,7 +283,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 05cb47c56e..2caa813c18 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -36,11 +36,12 @@ 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 ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `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. | -| `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. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `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` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; bağlılık bir sabitleme değil yeniden sıralamadır. | +| `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için genel varsayılan kullanım eşiği; `codexAccountAutoSwitchThresholds` bunu hesap bazında geçersiz kılar. Varsayılan olarak `quota`, bağlı ve bağımsız görevleri mevcut kaynak hesabın etkin eşiğine göre yeniden değerlendirir; `fill-first`, bağımsız atama için her hesabın kota payını kendi etkin eşiğine göre kontrol eder. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `round-robin` dönüşü sayaç tabanlıdır ve eşiği kullanmaz, ancak ortak öncelik katmanı filtresi her hesabın etkin eşiğini kullanmaya devam eder. Puan, bilinen 5 saatlik, haftalık veya 30 günlük kota pencerelerindeki en yüksek kullanımı alır. Etkin değer `0` yalnızca bu hesaptan 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, hesabın etkin eşiği (hesaba özel değer veya genel varsayılan) altında uygun bir aktif hesabı tutar ve bu eşiğe ulaşıldığında bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri ortak öncelik katmanı filtresi içinde eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya bu hesabın etkin tükenme eşiğine (hesaba özel değer veya genel varsayılan) kadar bağımsız istekleri aktif hesaba atamaya devam eder. `reset-first`: Kendi etkin eşiğine göre kota payı olan 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` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın hesabın etkin eşiğine ulaşması nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; 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. | | `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Yerel OpenAI iletme Responses ve sıkıştırma gönderimlerinde kanıtlanmış bağlantı öncesi DNS/TCP arızaları için isteğe bağlı devre eşiği. `0` devre dışı bırakır; `1`–`20`, bu kadar terminal mantıksal istekten sonra 30 saniyelik bir sağlayıcı-kaynak soğuma süresi açar. Açıkken istekler, hesap seçiminden veya yukarı akış gönderiminden önce `Retry-After` ile `503` alır; soğuma süresinden sonra bir yarı açık isteğe izin verilir. Zaman aşımları ve HTTP yanıtları asla sayılmaz ve herhangi bir HTTP yanıtı devreyi kapatır. Yalnızca sabitlenmiş hesabı olmayan Codex Havuz yönlendirmesi için geçerlidir; `codexAccountMode: "direct"` ve hesap nitelikli seçiciler için etkisizdir. | @@ -230,9 +231,9 @@ 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 ve `pool.cacheAffinity` kapalıysa bağlı bir görevin bir sonraki isteği de geçebilir. Bayrak açıkken önbellek bağlılığı kota payından öndedir ve bağlı görev, hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene (duraklatılmış, kullanılamaz) kadar kalır. `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. | +| `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 hesabın etkin eşiği (hesaba özel değer veya genel varsayılan) altında uygun bir aktif hesabı tutun; bu eşiğe ulaşıldığında bağımsız bir istek daha düşük kullanımlı uygun bir hesaba geçebilir ve `pool.cacheAffinity` kapalıysa bağlı bir görevin bir sonraki isteği de geçebilir. Bayrak açıkken önbellek bağlılığı kota payından öndedir ve bağlı görev, hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene (duraklatılmış, kullanılamaz) kadar kalır. `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. 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 354310f01e..e87d12d22c 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -307,7 +307,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 62e8d4447c..ec1038bc7b 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,10 +35,11 @@ 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 页面管理。 | -| `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | -| `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 | +| `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` | 主动切换的全局默认用量阈值,可通过 `codexAccountAutoSwitchThresholds` 按账号覆盖。默认情况下,`quota` 使用当前来源账号的有效阈值重新评估已绑定和未绑定任务;`fill-first` 按各账号自身的有效阈值检查未绑定分配所需的余量。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`round-robin` 轮换本身基于计数器,不使用阈值,但共用的优先级 tier 筛选仍使用各账号的有效阈值。分数取已知 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 最低的合格账号;活跃账号合格且低于其有效阈值(账号覆盖值,未设置则使用全局值)时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 在共用的优先级 tier 筛选范围内均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或该账号的有效耗尽阈值(账号覆盖值,未设置则使用全局值)前持续分配给活跃账号。 `reset-first`: 在按各自有效阈值判断仍有用量余量的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量达到该账号的有效阈值就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | 原生 OpenAI forward Responses 与 compact 发送的可选断路器阈值,仅统计已证明的连接前 DNS/TCP 故障。`0` 表示禁用;`1`–`20` 表示在这么多个终止逻辑请求失败后,对 provider-origin 冷却 30 秒。断路期间会在账户选择和上游发送之前返回带 `Retry-After` 的 `503`;冷却结束后只允许一个半开请求。超时和 HTTP 响应不计数,任意 HTTP 响应都会关闭断路器。 仅适用于未固定账户的 Codex Pool 路由;在 `codexAccountMode: "direct"` 或使用账户限定选择器时不会启用。 | @@ -177,9 +178,9 @@ routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 并可将请求切换到另一个符合条件的 Pool 账户。即使 `autoSwitchThreshold: 0`, 这些故障恢复流程仍然有效;`0` 只会禁用基于用量的主动切换。 -**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,cache affinity 优先于 quota 余量,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求,用量 -阈值不会改变正常轮换。`accountPoolStickyLimit`(默认 `1`,1–100)统计分配/绑定,而不是成功响应。 -`fill-first` 在 cooldown、重新认证或耗尽阈值前把未绑定请求分配给活跃账号;健康的已绑定任务保持 +**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于其有效阈值(账号覆盖值,未设置则使用全局值)时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,cache affinity 优先于 quota 余量,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求,轮换本身基于计数器,不使用用量阈值。 +但共用的优先级 tier 筛选仍按各账号自身的有效阈值检查余量。`accountPoolStickyLimit`(默认 `1`,1–100)统计分配/绑定,而不是成功响应。 +`fill-first` 在 cooldown、重新认证或该账号的有效耗尽阈值(账号覆盖值,未设置则使用全局值)前把未绑定请求分配给活跃账号;健康的已绑定任务保持 affinity。这些策略不能规避 provider enforcement。 ### `anthropicAccountPool`(实验性) 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 480accdf33..e7d4d597b0 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 @@ -250,7 +250,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 a5ca056c18..b0964d117b 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 @@ -34,9 +34,10 @@ ocx models provider openrouter on | `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | -| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | -| `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 | +| `autoSwitchThreshold?` | `number` | `80` | 主動切換的全域預設用量閾值,可透過 `codexAccountAutoSwitchThresholds` 依帳號覆寫。預設情況下,`quota` 使用目前來源帳號的有效閾值重新評估綁定與未綁定任務;`fill-first` 依各帳號自身的有效閾值檢查未綁定指派所需的餘裕。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`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 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,在合格現用帳號低於其有效閾值(帳號覆寫值,未設定則使用全域值)時繼續使用,且達到該閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 在共用的優先順序層級篩選範圍內均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或該帳號的有效排空閾值(帳號覆寫值,未設定則使用全域值)。 `reset-first`: 在依各自有效閾值判斷仍有用量餘裕的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量達到該帳號的有效閾值就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | | `modelCacheTtlMs?` | `number` | `300000` | Per-供應商 `/models` 快取的新鮮度視窗。 | @@ -139,9 +140,9 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | 策略 | 行為 | | --- | --- | -| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求可移至較低用量的合格帳號,未開啟 `pool.cacheAffinity` 時綁定任務的下一個請求也可。開啟後,cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`0` 停用此用量驅動的重新評估,而非失敗復原。 | -| `round-robin` | 在合格帳號間均勻指派未綁定請求。`autoSwitchThreshold` 不變更一般 round-robin 選擇。`accountPoolStickyLimit`(1–100)計數一次選擇上的指派,而非成功的上游回應。 | -| `fill-first` | 將未綁定請求指派到現用帳號直到冷卻、重新認證或設定的排空閾值;未知用量不強制切換。健康的綁定任務保留親和性。 | +| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則在合格現用帳號低於其有效閾值(帳號覆寫值,未設定則使用全域值)時繼續使用;達到該閾值後,未綁定請求可移至較低用量的合格帳號,未開啟 `pool.cacheAffinity` 時綁定任務的下一個請求也可。開啟後,cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`0` 停用此用量驅動的重新評估,而非失敗復原。 | +| `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 4342a698e0..1a753189f4 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 @@ -239,7 +239,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 0000000000..d7aa96918f --- /dev/null +++ b/gui/src/components/AccountAutoSwitchControl.tsx @@ -0,0 +1,164 @@ +import { useId, useRef, useState } from "react"; +import { clampNumberDraft } from "../clamp-draft"; +import { useT } from "../i18n/shared"; +import { NumberStepper } from "./NumberStepper"; + +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 c7e006be6a..ecd34a0c10 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -71,7 +71,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, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; + const { accounts, activeId, loadState, 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(); }); @@ -265,6 +265,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 (!window.confirm(t("codexAuth.removeConfirm", { id: label }))) return; @@ -434,7 +449,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; @@ -494,6 +510,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} @@ -530,6 +548,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/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 72da4b774c..9231d62094 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 e1439c1439..9004219e2b 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 } 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 && ( @@ -235,7 +252,7 @@ export function CodexAccountPoolMainCard({ diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 87aca93743..7b32c31ee2 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: { @@ -102,6 +104,7 @@ export interface CodexAccountPoolController { switchingId: string | null; pauseUpdatingId: string | null; priorityUpdatingId: string | null; + autoSwitchUpdatingId: string | null; pausingExhausted: boolean; activeNeedsReauth: boolean; /** @@ -116,6 +119,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>; @@ -145,6 +150,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 ?? []); @@ -163,6 +174,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 @@ -199,6 +211,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); @@ -265,6 +278,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, @@ -530,6 +546,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"; @@ -616,6 +668,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou switchingId, pauseUpdatingId, priorityUpdatingId, + autoSwitchUpdatingId, pausingExhausted, activeNeedsReauth, activePinnedId, @@ -623,6 +676,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 992396cd49..015a311773 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1486,6 +1486,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.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c208f9cd29..f30ccf37a1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2077,6 +2077,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 f6f30b11ba..423dbd2b83 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2000,6 +2000,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.switchFailed": "Impossible de changer de compte. Votre sélection précédente reste inchangée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2014a28342..c1291874df 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1929,6 +1929,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.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8cd711d927..6e239fb75b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1522,6 +1522,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.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0c6034ae6e..78b8c75fc2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1999,6 +1999,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.switchFailed": "Не удалось переключить аккаунт. Ваш предыдущий выбор не изменён.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5e624434ac..3f85b702f5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2018,6 +2018,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/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 919318c809..e2e0928004 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2508,6 +2508,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 93b43a4ee2..c2ba2af13e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1503,6 +1503,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.switchFailed": "无法切换账户。之前的选择保持不变。", diff --git a/gui/src/styles.css b/gui/src/styles.css index 352d1c53a2..bf425d791d 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1980,9 +1980,17 @@ dialog.modal-overlay::backdrop { .codex-account-more-body { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; flex-basis: 100%; padding-top: 6px; } .codex-account-identity { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 16px 6px; min-width: 0; } .codex-account-identity-copy { font-size: var(--text-label); line-height: var(--leading-body); color: var(--muted); min-width: 0; overflow-wrap: anywhere; } +.codex-account-controls { display: flex; align-items: center; justify-content: flex-end; gap: 12px; flex-wrap: wrap; min-width: 0; } .codex-account-priority { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0; min-width: 0; flex: 0 0 auto; } .codex-account-priority-label { font-size: var(--text-label); color: var(--muted); font-weight: var(--weight-medium); white-space: nowrap; } .codex-account-priority .select-trigger { max-width: 100%; padding: 4px 9px; font-size: var(--text-label); } +.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; } .startup-page-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .startup-runtime-notice-slot { diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 42cec3818c..1b29160b7f 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 c622a5f793..4cae15ebfd 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 5e64bed40a..eecdda9ddf 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -28,6 +28,7 @@ const legacyAccount: LegacyCodexAccountEntry = { isMain: false, paused: false, priority: 0, + autoSwitchThresholdOverride: null, hasCredential: true, quota: { resetCredits: 2, updatedAt: 1 }, }; @@ -66,6 +67,7 @@ function makeController(overrides: Partial = {}): Co switchingId: null, pauseUpdatingId: null, priorityUpdatingId: null, + autoSwitchUpdatingId: null, pausingExhausted: false, activeNeedsReauth: false, activePinnedId: null, @@ -75,6 +77,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/src/cli/account-api.ts b/src/cli/account-api.ts index 7e7cda8e3b..e37eab8a9f 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 f834024f28..f4d4392713 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -327,9 +327,11 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise { else console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`); 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"); - if (active.status === 200 && typeof active.json.autoSwitchThreshold === "number" && active.json.autoSwitchThreshold > 0) { - console.error(`Note: auto-switch (threshold ${active.json.autoSwitchThreshold}%) may override this pin.`); + const state = await fetchRows(deps, baseUrl, name, "codex"); + const selected = state.rows.find(row => row.id === activeId); + const threshold = selected?.autoSwitchThresholdOverride ?? state.autoSwitchThreshold; + if (state.status === 200 && typeof threshold === "number" && threshold > 0) { + console.error(`Note: auto-switch (threshold ${threshold}%) may override this pin.`); } } return 0; diff --git a/src/codex/account-auto-switch.ts b/src/codex/account-auto-switch.ts new file mode 100644 index 0000000000..b9a4b0d9e8 --- /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 88208bfb1b..d3e1728413 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"; @@ -17,6 +18,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"; @@ -160,6 +162,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; @@ -174,6 +177,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]; @@ -190,6 +194,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/auth-api.ts b/src/codex/auth-api.ts index 8f92f2c8c8..039330254b 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -12,6 +12,7 @@ import { saveConfigPreservingClaudeCode, withConfigMutationLockSync, } from "../config"; +import { captureConfigTopLevelRollback } from "../config/rebase-provenance"; import { codexAccountLogLabel, withCodexAccountLogLabel } from "./account-label"; import { getCodexAccountCredential, @@ -46,6 +47,13 @@ import { setCodexAccountPin, setCodexAccountPriority, } from "./account-priority"; +import { + getCodexAccountAutoSwitchThresholdOverride, + getEffectiveCodexAutoSwitchThreshold, + isCodexAccountAutoSwitchThresholdKey, + parseCodexAutoSwitchThreshold, + setCodexAccountAutoSwitchThresholdOverride, +} from "./account-auto-switch"; import { claimDueCodexQuotaRecoveryProbes, codexQuotaScopeForModel, @@ -357,6 +365,7 @@ function poolAccountDto( hasCredential: boolean, paused: boolean, priority: number, + autoSwitchThresholdOverride: number | null, maskEmails: boolean, ): CodexAuthAccountDto { const plan = codexPlanValue(account.plan); @@ -383,6 +392,7 @@ function poolAccountDto( isMain: false, paused, priority, + autoSwitchThresholdOverride, quota: quota ? { ...quota } : null, needsReauth: needsReauth || health.status === "reauth_required", ...(reauthReason !== undefined ? { reauthReason } : {}), @@ -1162,6 +1172,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; /** @@ -1997,6 +2009,7 @@ export async function listCodexAuthAccountsSnapshot( false, isCodexAccountPaused(runtimeConfig, accountId), getCodexAccountPriority(runtimeConfig, accountId), + getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, accountId), maskEmails, )]; } @@ -2018,6 +2031,7 @@ export async function listCodexAuthAccountsSnapshot( true, isCodexAccountPaused(runtimeConfig, accountId), getCodexAccountPriority(runtimeConfig, accountId), + getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, accountId), maskEmails, )]; }); @@ -2053,6 +2067,10 @@ 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 } : {}), @@ -2450,12 +2468,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 32d124b7fd..e3eb2edd00 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -68,6 +68,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"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; const CODEX_APP_AFFINITY_KEY = randomBytes(32); @@ -81,7 +82,7 @@ const CODEX_APP_AFFINITY_KEY = randomBytes(32); * 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; @@ -678,6 +679,21 @@ export async function resolveCodexAuthContext( throw new CodexReserveUnavailableError(); } const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId; + 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 requestOwnedMainPinCandidate = requestScopedMainCredential && fixedAccountId === undefined && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID @@ -689,8 +705,9 @@ export async function resolveCodexAuthContext( if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) { throw new CodexMainProfileDrainingError(); } - const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate - && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)); + const preserveRequestOwnedMainPin = () => requestOwnedMainPinCandidate + && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)) + && !callerOwnedMainPoolCooldown()?.cooldownUntil; if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } @@ -704,6 +721,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); @@ -723,6 +741,7 @@ export async function resolveCodexAuthContext( } } if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); + assertCallerOwnedMainPoolNotCooled(); return { kind: "main", accountId: null }; } @@ -772,13 +791,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 }; } } @@ -809,7 +828,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]) @@ -836,7 +854,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, }; diff --git a/src/codex/routing.ts b/src/codex/routing.ts index ae6e5d792a..076d1469a1 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -4,6 +4,7 @@ import { isCodexAccountGenerationLive, readCodexAccountRecord, type CodexRefresh import { codexAccountLogLabel } from "./account-label"; import { NATIVE_RESERVE_MODEL } from "./catalog/native-models"; import { isCodexAccountPaused } from "./account-pause"; +import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch"; import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority"; import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -1505,7 +1506,7 @@ 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), @@ -2030,7 +2031,7 @@ 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( @@ -2195,7 +2196,7 @@ function previewReusableAffinityAccount( // 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), @@ -2255,7 +2256,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; @@ -2282,7 +2283,7 @@ function reevaluateAffinityQuota( return replacement; } if (strategy !== "quota") return null; - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId); const usage = threshold > 0 ? computeCodexUsageScore( getAccountQuota(entry.accountId), @@ -2380,7 +2381,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), @@ -2499,7 +2500,7 @@ export function resolveCodexAccountForThreadDetailed( ) { entry.lastUsedAt = now; // Periodic quota re-eval: a long-lived bound thread must still switch when - // it crosses autoSwitchThreshold and a strictly-cooler account exists. + // it crosses its effective account threshold and a strictly-cooler account exists. // Without this the reuse branch returns before applyQuotaAutoSwitch and the // thread stays pinned for the full idle TTL (the WSL "never switches" report). // Over-threshold pins re-eval immediately so a depleted primary does not keep diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index c0bcdda15c..7df8b29949 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 19642652c8..37cacc4ac1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,10 @@ import { MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, } from "./codex/account-namespace-match"; import { isCodexAccountPriorityKey } from "./codex/account-priority"; +import { + isCodexAccountAutoSwitchThresholdKey, + parseCodexAutoSwitchThreshold, +} from "./codex/account-auto-switch"; import { loopbackCompanionAllowed } from "./codex/loopback-target"; import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health"; import { @@ -165,11 +169,14 @@ export { type RuntimePortState, } from "./config/process-state"; import { + applyConfigObjectChildDeletions, + clearPendingConfigObjectChildDeletions, clearPendingConfigTopLevelDeletions, configHasRebaseProvenance, configRebaseDeletionKeys, CONFIG_REBASE_PROVENANCE_KEY, deleteConfigTopLevelKey, + prepareConfigObjectChildDeletionRebase, projectConfigRebaseProvenance, } from "./config/rebase-provenance"; export { deleteConfigTopLevelKey } from "./config/rebase-provenance"; @@ -988,6 +995,38 @@ const codexQuotaAutoRefreshSchema = z.custom>( } }).pipe(z.record(z.string(), codexQuotaAutoRefreshEntrySchema)); +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"; + +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())); + /** * Deliberately permissive. A user's config is not ours to invalidate: a strict * entry fails the whole parse, and loadConfig's fallback then backs the file up @@ -1338,6 +1377,9 @@ 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), + // Same preference boundary as selection order: malformed hand edits disable only + // account-local overrides instead of resetting providers or pool accounts. + codexAccountAutoSwitchThresholds: codexAccountAutoSwitchThresholdsSchema.optional().catch(undefined), 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. @@ -2086,6 +2128,10 @@ function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: Ocx 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; } @@ -2963,6 +3009,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. @@ -3614,6 +3667,7 @@ export function initializePersistedConfigIfMissing( adoptCustomModelCatalogMigration(config, persisted); if (persisted.configRebaseProvenance === undefined) delete config.configRebaseProvenance; else config.configRebaseProvenance = structuredClone(persisted.configRebaseProvenance); + clearPendingConfigObjectChildDeletions(config); clearPendingConfigTopLevelDeletions(config); refreshUserCostOverlays(persisted); return "created"; @@ -3638,6 +3692,7 @@ export function saveConfig(config: OcxConfig): void { adoptCustomModelCatalogMigration(config, withProvenance); if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance; else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance); + clearPendingConfigObjectChildDeletions(config); clearPendingConfigTopLevelDeletions(config); }); } @@ -4014,12 +4069,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; @@ -4087,6 +4144,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. @@ -4127,6 +4185,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); @@ -4166,6 +4225,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/rebase-provenance.ts b/src/config/rebase-provenance.ts index a799725d25..69bb34772e 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,73 @@ 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); +} + /** * 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 +149,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 +160,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/types/config.ts b/src/types/config.ts index 8f87281eb8..45b33fddb7 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -814,6 +814,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 diff --git a/structure/INDEX.md b/structure/INDEX.md index a6d335ccb2..10b8bdae07 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -68,6 +68,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. | @@ -92,16 +93,16 @@ for it; see [`AGENTS.md`](AGENTS.md). | `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.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) | +| `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) | | `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/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) | | `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) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | | `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md) | diff --git a/structure/codex-account-controls.md b/structure/codex-account-controls.md new file mode 100644 index 0000000000..9a40d802c3 --- /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/gui-and-management-api.md b/structure/gui-and-management-api.md index c445858e6b..7c4215ae61 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -121,7 +121,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). | @@ -173,12 +173,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). ## Sidebar stop button diff --git a/structure/manifest.json b/structure/manifest.json index 408be95cde..855ba95364 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -316,6 +316,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/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 527aacba6b..e67141ba89 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -255,6 +255,19 @@ being selectable, or affinity expiry. The stable `__main__` alias carries an ord 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. + 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 diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 7772accc49..c1e50eac4f 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -962,6 +962,15 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(result.stderr).toContain("auto-switch (threshold 80%) may override this pin"); }); + 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"); + }); + test("WP2 regression: classifyAccount routes a key-overridden OAuth provider to api-key", () => { const config = fixtureConfig(); (config.providers as Record).xai = { authMode: "key" }; diff --git a/tests/codex-integration/codex-account-delete-atomicity.test.ts b/tests/codex-integration/codex-account-delete-atomicity.test.ts index 3e49033acc..dea566c182 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-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 34e29bf334..a06fd60662 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -70,12 +70,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 { @@ -4016,6 +4019,219 @@ describe("codex-auth API", () => { expect(accounts.find(a => a.isMain)?.priority).toBe(0); }); + 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(); + }); + 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 c23a1cda2c..b98d6e908e 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -42,6 +42,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, @@ -802,6 +807,75 @@ describe("Codex auth context", () => { } satisfies Partial); }); + test("a zero account threshold permits a model detour but never bypasses exact entitlement rejection", async () => { + 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: 100 }); + 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" }); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(cfg.activeCodexAccountPinned).toBe("pool-a"); + await expect(resolveCodexAuthContext(headers, cfg, "pool", ordinaryOptions)) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + }); + + 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"); + }); + test("ordinary native models do not pay the entitlement discovery path", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool-token", @@ -1175,6 +1249,12 @@ describe("Codex auth context", () => { 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>; @@ -1185,6 +1265,12 @@ describe("Codex auth context", () => { 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, @@ -1198,11 +1284,18 @@ describe("Codex auth context", () => { }); 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, "pool", { + }), 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 @@ -1211,10 +1304,13 @@ describe("Codex auth context", () => { 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(["gpt-daybreak-blue-latest"])]]), + 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"]]), @@ -1235,6 +1331,219 @@ describe("Codex auth context", () => { 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 { 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" }); + } 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, @@ -1247,6 +1556,29 @@ describe("Codex auth context", () => { expect(cfg.activeCodexAccountPinned).toBeUndefined(); }); + test("a zero main-account threshold still detours an unentitled caller without clearing the main pin", async () => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + 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(MAIN_CODEX_ACCOUNT_ID); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + 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, @@ -1726,6 +2058,40 @@ describe("Codex auth context", () => { .rejects.toBeInstanceOf(CodexAccountCooldownError); }); + 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", + }); + }, + ); + 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 19816dfcf7..1c5584b419 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -456,6 +456,50 @@ 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", + 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-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index c11be73f2e..b6c14cb76a 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -2166,6 +2166,121 @@ describe("codex routing", () => { expect(config.activeCodexAccountId).toBe("a"); }); + 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, + 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)( + "%s zero account threshold allows a model-only detour without spending the pin or ordinary affinity", + (strategy) => { + 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", 100); + 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"); + expect(resolveCodexAccountForThreadDetailed( + threadId, config, now + 1, "shared", selectionOptions, modelId, + )).toEqual({ status: "selected", accountId: "b" }); + expect(config.activeCodexAccountId).toBe("a"); + expect(config.activeCodexAccountPinned).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread(threadId, config, now + 2, "shared")).toBe("a"); + expect(resolveCodexAccountForThread(null, config, now + 2, "shared")).toBe("a"); + }, + ); + test("unknown active quota stays selected even when other candidates differ in health", () => { const config = makeConfig({ codexAccounts: [ 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 c3caaabe2c..9f62ae07af 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -69,6 +69,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 }, @@ -170,11 +174,15 @@ describe("startup policy binding read is bounded", () => { }); describe("main quota policy at native admission", () => { - 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 } : {}), @@ -199,7 +207,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'); @@ -219,6 +228,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([]); @@ -251,8 +262,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") { @@ -296,6 +307,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-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index f874ce66de..728f991a2a 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 2ad97f09aa..1fb3e30d2a 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 38ac41aa37..bb0bbaa29b 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/helpers/main-account-policy-startup-child.ts b/tests/helpers/main-account-policy-startup-child.ts index 387a12cfcd..d393fbc5a8 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 095e95dabd..a54bed157b 100644 --- a/tests/routing/subagent-model-fallback.test.ts +++ b/tests/routing/subagent-model-fallback.test.ts @@ -798,6 +798,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); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index e69e932bbb..493e85eff8 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -1046,6 +1046,36 @@ describe("opencodex config defaults", () => { }); }); + 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"), + }); + } + }); + test("config candidates validate Claude Code subagent effort levels", () => { const base = getDefaultConfig(); for (const subagentEffort of ["low", "medium", "high", "xhigh", "max"]) { @@ -3826,3 +3856,42 @@ describe("codex account selection order", () => { expect(degraded.warnings).toContainEqual(expect.stringContaining("no longer pinned")); }); }); + +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")); + }); +});