From 2c5f35607e34bb4ecd56e19df72d8021d1de8c36 Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Mon, 7 Sep 2026 22:17:56 +0500 Subject: [PATCH 1/4] feat(codex): add per-account auto-switch thresholds --- .../fr/reference/configuration/providers.md | 1 + .../docs/fr/reference/management-api.md | 2 +- .../ja/reference/configuration/providers.md | 1 + .../docs/ja/reference/management-api.md | 2 +- .../ko/reference/configuration/providers.md | 1 + .../docs/ko/reference/management-api.md | 2 +- .../docs/reference/configuration/providers.md | 9 +- .../content/docs/reference/management-api.md | 2 +- .../ru/reference/configuration/providers.md | 1 + .../docs/ru/reference/management-api.md | 2 +- .../tr/reference/configuration/providers.md | 1 + .../docs/tr/reference/management-api.md | 2 +- .../reference/configuration/providers.md | 1 + .../docs/zh-cn/reference/management-api.md | 2 +- .../reference/configuration/providers.md | 1 + .../docs/zh-tw/reference/management-api.md | 2 +- .../components/AccountAutoSwitchControl.tsx | 128 ++++++++++++++++++ gui/src/components/CodexAccountPool.tsx | 24 +++- .../components/codex-account-pool-cards.tsx | 44 ++++-- .../codex-account-pool-main-card.tsx | 45 ++++-- gui/src/hooks/useCodexAccountPool.ts | 54 ++++++++ gui/src/i18n/de.ts | 7 + gui/src/i18n/en.ts | 7 + gui/src/i18n/fr.ts | 7 + gui/src/i18n/ja.ts | 7 + gui/src/i18n/ko.ts | 7 + gui/src/i18n/ru.ts | 7 + gui/src/i18n/tr.ts | 7 + gui/src/i18n/zh-TW.ts | 7 + gui/src/i18n/zh.ts | 7 + gui/src/styles.css | 9 ++ .../codex-account-pool-behaviour.test.tsx | 38 ++++++ .../codex-account-pool-pinned-badge.test.tsx | 109 +++++++++++++++ .../codex-account-pool-toast-tone.test.tsx | 3 + src/codex/account-auto-switch.ts | 59 ++++++++ src/codex/account-lifecycle.ts | 2 + src/codex/auth-api.ts | 47 ++++++- src/codex/routing.ts | 11 +- src/codex/subagent-model-fallback.ts | 7 +- src/config.ts | 59 ++++++++ src/config/rebase-provenance.ts | 66 +++++++++ src/types/config.ts | 6 + structure/05_gui-and-management-api.md | 8 +- structure/08_openai-provider-tiers.md | 8 ++ .../codex-account-delete-atomicity.test.ts | 3 + .../codex-integration/codex-auth-api.test.ts | 74 ++++++++++ tests/codex-integration/codex-routing.test.ts | 44 ++++++ .../config-rebase-provenance-writers.test.ts | 7 + tests/config/config-user-edits.test.ts | 15 ++ tests/routing/subagent-model-fallback.test.ts | 14 ++ tests/server/config.test.ts | 69 ++++++++++ 51 files changed, 995 insertions(+), 53 deletions(-) create mode 100644 gui/src/components/AccountAutoSwitchControl.tsx create mode 100644 src/codex/account-auto-switch.ts 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 c7879dfa9c..d1d27cd3fb 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -39,6 +39,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `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. | | `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 tâches liées et non liées lors de leur prochaine requête ; `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | Seuils par compte remplaçant `autoSwitchThreshold` : identifiant → entier de `0` à `100`. Une entrée absente hérite du seuil global ; `0` désactive uniquement les déplacements fondés sur l’utilisation depuis ce compte. Le compte principal `__main__` est pris en charge. La carte du compte dans Codex Auth gère cette valeur. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-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 ou relier de manière proactive une tâche liée à un compte admissible moins utilisé. `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é. | | `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. | 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 0732df6dca..3a487bb95a 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -281,7 +281,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 }`, ou celui d’un compte avec `{ id, threshold }` ; `null` rétablit l’héritage 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 608e66deb2..cfcfd1181d 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -37,6 +37,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `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` は紐付け済み/未紐付けタスクの次のリクエストを再評価でき、`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | `autoSwitchThreshold` のアカウント別上書き(アカウント ID → `0`〜`100` の整数)。未設定はグローバル値を継承し、`0` はそのアカウントからの使用量ベース切り替えだけを無効にします。メインの `__main__` も指定できます。Codex Auth の各アカウントカードで管理します。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は、未紐付けリクエストまたは紐付け済みタスクの次のリクエストを usage の低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | 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 fecada7216..3ccca63c51 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -240,7 +240,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` | `{ threshold }` でグローバルしきい値、`{ 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 342441eca1..47a2523696 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -37,6 +37,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `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`는 바인딩된 작업과 바인딩 없는 작업의 다음 요청을 모두 재평가할 수 있고, `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | 계정별 `autoSwitchThreshold` 재정의입니다(계정 ID → `0`~`100` 정수). 항목이 없으면 전역 값을 상속하고, `0`은 해당 계정에서 시작하는 사용량 기반 전환만 끕니다. 메인 `__main__` 계정도 지원합니다. Codex Auth의 계정 카드에서 관리합니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | 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 086dc5aa49..0125a09c99 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -242,7 +242,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` | `{ threshold }`로 전역 임계값을, `{ 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 f32a2fdc09..15bc2e9bbc 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -37,9 +37,10 @@ After GUI registration or OAuth login, the confirmation dialog lets you open the | `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 over its effective usage threshold, 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 both bound and unbound tasks on their next request; `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | Per-account overrides for `autoSwitchThreshold`: account id → integer from `0` to `100`. An absent entry inherits the global value; `0` disables usage-driven switching only when that account is the source. Every quota check uses the source account's effective threshold, including bound-task re-evaluation, unbound selection, fill-first drain, selection-order tier drain, main-account pins, and subagent fallback. 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. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-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 proactively rebind a bound task to a lower-usage eligible account. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | | `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. | @@ -461,7 +462,9 @@ Use **Codex Auth** in the dashboard to add pool accounts and refresh quotas. `co 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, but `quota` may rebind it on its next request after the usage threshold is -crossed, while pause, cooldown, reauthentication, and failure handling can clear or move routing +crossed. `codexAccountAutoSwitchThresholds` can replace that 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 @@ -483,7 +486,7 @@ 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 or a bound task's next request can move to a lower-usage eligible account. `0` disables this usage-driven re-evaluation, not failure recovery. | +| `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 global/account threshold; after it crosses the threshold, an unbound request or a bound task's next request can move to a lower-usage eligible account. `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. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index c0dd38f1fb..9021a15e66 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -416,7 +416,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 78bca4d40d..eebf01bfb3 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -38,6 +38,7 @@ ocx models provider openrouter on | `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` может повторно оценить следующий запрос как привязанной, так и непривязанной задачи; `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | Переопределения `autoSwitchThreshold` по аккаунтам: id → целое число `0`–`100`. Без записи наследуется глобальное значение; `0` отключает только переключения по использованию с этого аккаунта. Поддерживается основной аккаунт `__main__`. Управляется в карточке аккаунта на странице Codex Auth. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | 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 aefc4e09cf..0d257a5dd9 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -270,7 +270,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, 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 36c183bf11..92d10aeddf 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -39,6 +39,7 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `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. | | `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`, bir sonraki isteklerinde hem bağlı hem de bağımsız görevleri yeniden değerlendirebilir; `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | Hesap başına `autoSwitchThreshold` geçersiz kılmaları: hesap kimliği → `0`–`100` tam sayısı. Kayıt yoksa genel değer devralınır; `0` yalnızca bu hesaptan kullanıma dayalı geçişi kapatır. Ana `__main__` hesabı desteklenir. Codex Auth hesap kartından yönetilir. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-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 veya bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. `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. | | `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. | 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 afdb835e9c..7df5001366 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -294,7 +294,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` | Genel eşiği `{ threshold }` ile veya hesap eşiğini `{ id, threshold }` ile ayarlayın; `null` genel 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 99fba1fbb4..17a53e2ee5 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 @@ -37,6 +37,7 @@ ocx models provider openrouter on | `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` 可在下一次请求中重新评估已绑定和未绑定任务;`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | 各账号对 `autoSwitchThreshold` 的覆盖:账号 ID → `0`–`100` 的整数。没有条目时继承全局值;`0` 只关闭从该账号发起的基于用量的切换。支持主账号 `__main__`。可在 Codex Auth 的账号卡片中管理。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | 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 9391afb5fd..6ccad5c0b2 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 @@ -240,7 +240,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` | 使用 `{ threshold }` 设置全局阈值,或使用 `{ 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 74ee860ff1..0d491de6a6 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -35,6 +35,7 @@ ocx models provider openrouter on | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | | `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在其下一個請求時重新評估綁定與未綁定任務;`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | +| `codexAccountAutoSwitchThresholds?` | `Record` | — | 各帳號對 `autoSwitchThreshold` 的覆寫:帳號 ID → `0`–`100` 的整數。沒有項目時繼承全域值;`0` 只停用從該帳號發起的使用量主動切換。支援主帳號 `__main__`。可在 Codex Auth 的帳號卡片中管理。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求或主動重新綁定綁定任務到較低用量的合格帳號。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | 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 ca899bc730..60ee6cc149 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 @@ -233,7 +233,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` | 使用 `{ threshold }` 設定全域閾值,或使用 `{ 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..ca8d33e8a6 --- /dev/null +++ b/gui/src/components/AccountAutoSwitchControl.tsx @@ -0,0 +1,128 @@ +import { 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 enabled = override !== null; + const [draft, setDraft] = useState(String(override ?? globalThreshold)); + const hint = t("accountPool.autoSwitchHint"); + // eslint-disable-next-line local-i18n/no-hardcoded-ui-strings -- element id suffix, not UI text + const hintId = `${inputId}-hint`; + + const commit = async () => { + const trimmed = draft.trim(); + const parsed = trimmed === "" ? Number.NaN : Number(trimmed); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + setDraft(String(override ?? globalThreshold)); + return; + } + if (parsed === override) return; + if (!await onChange(parsed)) setDraft(String(override)); + }; + + const step = (delta: -1 | 1) => { + const nextDraft = clampNumberDraft(draft, delta, 0, 100); + setDraft(nextDraft); + const next = Number(nextDraft); + if (next !== override) { + void onChange(next).then(accepted => { + if (!accepted) setDraft(String(override)); + }); + } + }; + + return ( +
+ + {enabled && ( + + setDraft(event.target.value)} + onBlur={() => { + if (togglePointerIntentRef.current) { + togglePointerIntentRef.current = false; + return; + } + void commit(); + }} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || disabled) return; + if (event.key === "Enter") { + event.preventDefault(); + void commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + setDraft(String(override)); + } + }} + /> + + step(1)} + onDecrement={() => step(-1)} + /> + + )} + + {hint} +
+ ); +} diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index c01bd8b9d2..726a71b920 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -70,7 +70,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; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [modelsNotice, setModelsNotice] = useState<{ catalogRefreshPending: boolean } | null>(null); @@ -260,6 +260,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; @@ -459,7 +474,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; @@ -523,6 +539,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} @@ -558,6 +576,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 7c112e7a72..ec32d83e1d 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; /** @@ -173,19 +178,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}
@@ -199,7 +215,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 f90756afe0..cf445420ec 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"; @@ -31,6 +32,8 @@ export function CodexAccountPoolMainCard({ pauseBusy, onPriorityChange, priorityUpdatingId, + onAutoSwitchThresholdChange, + autoSwitchDisabled, switchingId, pinnedId = null, onOpenReset, @@ -50,6 +53,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; /** @@ -72,6 +77,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 ?? { @@ -151,19 +157,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 && ( @@ -187,7 +204,7 @@ export function CodexAccountPoolMainCard({ diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 0cd82d7293..042b494351 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: { @@ -100,6 +102,7 @@ export interface CodexAccountPoolController { switchingId: string | null; pauseUpdatingId: string | null; priorityUpdatingId: string | null; + autoSwitchUpdatingId: string | null; pausingExhausted: boolean; activeNeedsReauth: boolean; /** @@ -114,6 +117,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>; @@ -143,6 +148,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 ?? []); @@ -161,6 +172,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 @@ -197,6 +209,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); @@ -250,6 +263,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, @@ -511,6 +527,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"; @@ -597,6 +649,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou switchingId, pauseUpdatingId, priorityUpdatingId, + autoSwitchUpdatingId, pausingExhausted, activeNeedsReauth, activePinnedId, @@ -604,6 +657,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 40ee6ea36f..e0fb544dcc 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1435,6 +1435,13 @@ 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.autoSwitchInherit": "Global {threshold}%", + "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 53d940d7e6..1abec201ad 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2001,6 +2001,13 @@ 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.autoSwitchInherit": "Global {threshold}%", + "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 7afb44ab2c..22ac365ece 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1924,6 +1924,13 @@ 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.autoSwitchInherit": "Seuil global : {threshold} %", + "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 75095da332..693c12b883 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1855,6 +1855,13 @@ export const ja: Record = { "accountPool.priorityCustom": "カスタム", "accountPool.priorityUpdated": "{email} の選択順序を更新しました", "accountPool.priorityUpdateFailed": "{email} の選択順序を保存できませんでした。最後に確認された値を表示しています。", + "accountPool.autoSwitchThreshold": "カスタムアカウントしきい値", + "accountPool.autoSwitchThresholdAria": "{email} の使用量しきい値", + "accountPool.autoSwitchOverrideAria": "{email} のグローバル使用量しきい値を上書き", + "accountPool.autoSwitchInherit": "グローバル {threshold}%", + "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 5ae812e9e8..6ce4f10a76 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1471,6 +1471,13 @@ export const ko: Record = { "accountPool.priorityCustom": "사용자 지정", "accountPool.priorityUpdated": "{email}의 선택 순서를 업데이트했습니다", "accountPool.priorityUpdateFailed": "{email}의 선택 순서를 저장하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", + "accountPool.autoSwitchThreshold": "사용자 지정 계정 임계값", + "accountPool.autoSwitchThresholdAria": "{email}의 사용량 임계값", + "accountPool.autoSwitchOverrideAria": "{email}의 전역 사용량 임계값 재정의", + "accountPool.autoSwitchInherit": "전역 {threshold}%", + "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 2900abb6f3..d9d060c614 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1925,6 +1925,13 @@ export const ru: Record = { "accountPool.priorityCustom": "Своё значение", "accountPool.priorityUpdated": "Порядок выбора для {email} обновлён", "accountPool.priorityUpdateFailed": "Не удалось сохранить порядок выбора для {email}. Показано последнее подтверждённое значение.", + "accountPool.autoSwitchThreshold": "Пользовательский порог аккаунта", + "accountPool.autoSwitchThresholdAria": "Порог использования для {email}", + "accountPool.autoSwitchOverrideAria": "Переопределить глобальный порог использования для {email}", + "accountPool.autoSwitchInherit": "Глобальный {threshold}%", + "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 f551a97d43..c7072ae62c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1944,6 +1944,13 @@ 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.autoSwitchInherit": "Genel {threshold}%", + "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 ae0ee0517b..66a464975d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2387,6 +2387,13 @@ export const zhTW: Record = { "accountPool.priorityCustom": "自訂", "accountPool.priorityUpdated": "已更新 {email} 的選擇順序", "accountPool.priorityUpdateFailed": "無法儲存 {email} 的選擇順序。目前顯示最後一次確認的值。", + "accountPool.autoSwitchThreshold": "自訂帳號閾值", + "accountPool.autoSwitchThresholdAria": "{email} 的使用量閾值", + "accountPool.autoSwitchOverrideAria": "覆寫 {email} 的全域使用量閾值", + "accountPool.autoSwitchInherit": "全域 {threshold}%", + "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 ca92aaeafc..aeb982112c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1452,6 +1452,13 @@ export const zh: Record = { "accountPool.priorityCustom": "自定义", "accountPool.priorityUpdated": "已更新 {email} 的选择顺序", "accountPool.priorityUpdateFailed": "无法保存 {email} 的选择顺序。当前显示最后一次确认的值。", + "accountPool.autoSwitchThreshold": "自定义账号阈值", + "accountPool.autoSwitchThresholdAria": "{email} 的使用量阈值", + "accountPool.autoSwitchOverrideAria": "覆盖 {email} 的全局使用量阈值", + "accountPool.autoSwitchInherit": "全局 {threshold}%", + "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 a5838be7e4..143628471b 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1998,9 +1998,18 @@ 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-inherit { font-size: var(--text-label); color: var(--muted); 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 2d8e42037a..d8a955b884 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; @@ -456,6 +474,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 9702d7aff9..adf0d540a9 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -31,6 +31,7 @@ const account: CodexAccountEntry = { isMain: false, paused: false, priority: 0, + autoSwitchThresholdOverride: null, hasCredential: true, quota: null, usage30d: { @@ -46,6 +47,7 @@ const mainAccount: CodexAccountEntry = { isMain: true, paused: false, priority: 0, + autoSwitchThresholdOverride: null, hasCredential: true, quota: null, }; @@ -58,6 +60,7 @@ function makeController(overrides: Partial = {}): Co switchingId: null, pauseUpdatingId: null, priorityUpdatingId: null, + autoSwitchUpdatingId: null, pausingExhausted: false, activeNeedsReauth: false, activePinnedId: null, @@ -65,6 +68,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 }), @@ -280,3 +284,108 @@ test("healthy account cards omit log-label and 30-day usage copy", async () => { expect(main.textContent).not.toContain("Log label: main"); 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("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); +}); 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/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 703e08f247..ad398ee96a 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -16,6 +16,7 @@ import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeM 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"; @@ -141,6 +142,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]; diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 2e2a775867..48b5b29ee7 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -38,6 +38,13 @@ import { setCodexAccountPin, setCodexAccountPriority, } from "./account-priority"; +import { + getCodexAccountAutoSwitchThresholdOverride, + getEffectiveCodexAutoSwitchThreshold, + isCodexAccountAutoSwitchThresholdKey, + parseCodexAutoSwitchThreshold, + setCodexAccountAutoSwitchThresholdOverride, +} from "./account-auto-switch"; import { claimDueCodexQuotaRecoveryProbes, claimManualResetCooldowns, @@ -365,6 +372,7 @@ function poolAccountDto( hasCredential: boolean, paused: boolean, priority: number, + autoSwitchThresholdOverride: number | null, ): CodexAuthAccountDto { const plan = codexPlanValue(account.plan); const quota = quotaForPlan(quotaResult.quota, plan); @@ -379,6 +387,7 @@ function poolAccountDto( isMain: false, paused, priority, + autoSwitchThresholdOverride, quota: quota ? { ...quota } : null, needsReauth, hasCredential, @@ -1146,6 +1155,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; hasCredential: boolean; @@ -1920,6 +1931,7 @@ export async function listCodexAuthAccountsSnapshot( false, isCodexAccountPaused(runtimeConfig, accountId), getCodexAccountPriority(runtimeConfig, accountId), + getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, accountId), )]; } const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration; @@ -1939,6 +1951,7 @@ export async function listCodexAuthAccountsSnapshot( true, isCodexAccountPaused(runtimeConfig, accountId), getCodexAccountPriority(runtimeConfig, accountId), + getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, accountId), )]; }); const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration(); @@ -1965,6 +1978,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, quota: mainInfo.quota ? { @@ -2345,12 +2362,36 @@ 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); + } + setCodexAccountAutoSwitchThresholdOverride(runtimeConfig, body.id, threshold); + saveRuntimeConfig(config, runtimeConfig); + 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/routing.ts b/src/codex/routing.ts index 5d8cc17d15..01f85ccbfa 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"; @@ -1305,7 +1306,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), @@ -1742,7 +1743,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( @@ -1864,7 +1865,7 @@ function previewReusableAffinityAccount( // Quota strategy only: non-quota strategies keep affinity for ongoing threads // (new-session-only rotation — docs / affinity policy A). if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId); if (threshold > 0) { const usage = computeCodexUsageScore( getAccountQuota(entry.accountId), @@ -1900,7 +1901,7 @@ function reevaluateAffinityQuota( selectionOptions?: CodexAccountUsabilityOptions, ): string | null { if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null; - const threshold = config.autoSwitchThreshold ?? 80; + const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId); const usage = threshold > 0 ? computeCodexUsageScore( getAccountQuota(entry.accountId), @@ -1994,7 +1995,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), 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 8da89cbfdf..ca91a48409 100644 --- a/src/config.ts +++ b/src/config.ts @@ -46,6 +46,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 { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health"; import { adoptCustomModelCatalogMigration, @@ -158,11 +162,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"; @@ -898,6 +905,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 @@ -1197,6 +1236,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. @@ -1854,6 +1896,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; } @@ -2632,6 +2678,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. @@ -3255,6 +3308,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); }); } @@ -3631,12 +3685,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; @@ -3704,6 +3760,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. @@ -3744,6 +3801,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); @@ -3783,6 +3841,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..d0753da8c5 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,10 +72,67 @@ 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 diff --git a/src/types/config.ts b/src/types/config.ts index 0b0a2b2b98..c6c710317f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -735,6 +735,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/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index d6e66375b7..9072c9fd12 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -124,7 +124,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 [`08_openai-provider-tiers.md`](08_openai-provider-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 [`08_openai-provider-tiers.md`](08_openai-provider-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. | | 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). | @@ -188,6 +188,12 @@ it. The matching CLI is `ocx account priority []`, r when the value is omitted. Ordering invariants live in [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md). +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; while enabled, they write an override through +`/api/codex-auth/auto-switch`, and `null` removes the map entry. Quota bars and routing both use the +effective account value so the dashboard drain marker matches runtime. + ## Sidebar stop button The dashboard sidebar includes a stop button that calls `POST /api/stop`. The button shows a diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 91627acaf0..e4317dc099 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -179,6 +179,14 @@ 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-strategy 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. + 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/codex-integration/codex-account-delete-atomicity.test.ts b/tests/codex-integration/codex-account-delete-atomicity.test.ts index 3e49033acc..4e755c4d10 100644 --- a/tests/codex-integration/codex-account-delete-atomicity.test.ts +++ b/tests/codex-integration/codex-account-delete-atomicity.test.ts @@ -48,6 +48,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); @@ -289,6 +290,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 +330,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 3b704d6809..35fa0ebdc2 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -3895,6 +3895,80 @@ 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("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([ + ["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-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 11774ef474..ce9b298000 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -1996,6 +1996,50 @@ 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("unknown active quota stays selected even when other candidates differ in health", () => { const config = makeConfig({ codexAccounts: [ diff --git a/tests/config/config-rebase-provenance-writers.test.ts b/tests/config/config-rebase-provenance-writers.test.ts index 2ad97f09aa..25a691f543 100644 --- a/tests/config/config-rebase-provenance-writers.test.ts +++ b/tests/config/config-rebase-provenance-writers.test.ts @@ -38,6 +38,13 @@ test("every enumerated top-level deletion writer records config rebase provenanc } }); +test("record child deletions use field-scoped rebase provenance", () => { + const source = readFileSync(join(import.meta.dir, "..", "src/codex/account-auto-switch.ts"), "utf8"); + expect(source).toContain( + "deleteConfigObjectChildKey(config, \"codexAccountAutoSwitchThresholds\", accountId)", + ); +}); + 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..8dd0bc9762 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, @@ -700,6 +701,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/routing/subagent-model-fallback.test.ts b/tests/routing/subagent-model-fallback.test.ts index cf255a3b93..7944c077f6 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 1b1699e0b8..e1523bbd67 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -791,6 +791,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"]) { @@ -3492,3 +3522,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")); + }); +}); From 17c86c9e289752d640da661621fd718baa7ecc39 Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Mon, 7 Sep 2026 22:41:54 +0500 Subject: [PATCH 2/4] test(config): fix rebased source path --- tests/config/config-rebase-provenance-writers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config/config-rebase-provenance-writers.test.ts b/tests/config/config-rebase-provenance-writers.test.ts index 25a691f543..f4bb94c9ec 100644 --- a/tests/config/config-rebase-provenance-writers.test.ts +++ b/tests/config/config-rebase-provenance-writers.test.ts @@ -39,7 +39,7 @@ test("every enumerated top-level deletion writer records config rebase provenanc }); test("record child deletions use field-scoped rebase provenance", () => { - const source = readFileSync(join(import.meta.dir, "..", "src/codex/account-auto-switch.ts"), "utf8"); + const source = readFileSync(join(import.meta.dir, "..", "..", "src/codex/account-auto-switch.ts"), "utf8"); expect(source).toContain( "deleteConfigObjectChildKey(config, \"codexAccountAutoSwitchThresholds\", accountId)", ); From 1312b246fb322f34b418c7c7657bf6138dc65a2c Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Tue, 8 Sep 2026 14:43:04 +0500 Subject: [PATCH 3/4] fix(codex): honor account threshold overrides --- .../components/codex-account-pool-cards.tsx | 2 +- .../codex-account-pool-main-card.tsx | 2 +- gui/src/i18n/de.ts | 1 - gui/src/i18n/en.ts | 1 - gui/src/i18n/fr.ts | 1 - gui/src/i18n/ja.ts | 1 - gui/src/i18n/ko.ts | 1 - gui/src/i18n/ru.ts | 1 - gui/src/i18n/tr.ts | 1 - gui/src/i18n/zh-TW.ts | 1 - gui/src/i18n/zh.ts | 1 - gui/src/styles.css | 1 - .../codex-account-pool-pinned-badge.test.tsx | 32 ++++++++++++++++++- src/cli/account-api.ts | 6 ++++ src/cli/account.ts | 8 +++-- src/codex/account-lifecycle.ts | 4 +++ src/codex/auth-context.ts | 3 +- src/codex/routing.ts | 2 +- tests/cli/cli-account.test.ts | 9 ++++++ .../codex-integration/codex-auth-api.test.ts | 28 ++++++++++++++++ .../codex-auth-context.test.ts | 19 +++++++++++ tests/config/config-user-edits.test.ts | 13 ++++++++ 22 files changed, 120 insertions(+), 18 deletions(-) diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index ec32d83e1d..fefb550e96 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -193,7 +193,7 @@ export function CodexAccountPoolCards({ /> )} onPriorityChange(mainSwitchEntry, priority)} /> = { "accountPool.autoSwitchThreshold": "Benutzerdefinierter Kontoschwellenwert", "accountPool.autoSwitchThresholdAria": "Nutzungsschwellenwert für {email}", "accountPool.autoSwitchOverrideAria": "Globalen Nutzungsschwellenwert für {email} überschreiben", - "accountPool.autoSwitchInherit": "Global {threshold}%", "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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1abec201ad..bbabbf222e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2004,7 +2004,6 @@ export const en = { "accountPool.autoSwitchThreshold": "Custom account threshold", "accountPool.autoSwitchThresholdAria": "Usage threshold for {email}", "accountPool.autoSwitchOverrideAria": "Override global usage threshold for {email}", - "accountPool.autoSwitchInherit": "Global {threshold}%", "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.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 22ac365ece..533d43d8c9 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1927,7 +1927,6 @@ export const fr: Record = { "accountPool.autoSwitchThreshold": "Seuil personnalisé du compte", "accountPool.autoSwitchThresholdAria": "Seuil d’utilisation pour {email}", "accountPool.autoSwitchOverrideAria": "Remplacer le seuil d’utilisation global pour {email}", - "accountPool.autoSwitchInherit": "Seuil global : {threshold} %", "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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 693c12b883..131b4e6486 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1858,7 +1858,6 @@ export const ja: Record = { "accountPool.autoSwitchThreshold": "カスタムアカウントしきい値", "accountPool.autoSwitchThresholdAria": "{email} の使用量しきい値", "accountPool.autoSwitchOverrideAria": "{email} のグローバル使用量しきい値を上書き", - "accountPool.autoSwitchInherit": "グローバル {threshold}%", "accountPool.autoSwitchHint": "このアカウントのグローバルしきい値を上書きします。0 にすると、このアカウントからの使用量ベースの事前切り替えを無効にします。", "accountPool.autoSwitchUpdated": "{email} の使用量しきい値を更新しました", "accountPool.autoSwitchUpdateFailed": "{email} の使用量しきい値を保存できませんでした。最後に確認された値を表示しています。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 6ce4f10a76..b268f87a14 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1474,7 +1474,6 @@ export const ko: Record = { "accountPool.autoSwitchThreshold": "사용자 지정 계정 임계값", "accountPool.autoSwitchThresholdAria": "{email}의 사용량 임계값", "accountPool.autoSwitchOverrideAria": "{email}의 전역 사용량 임계값 재정의", - "accountPool.autoSwitchInherit": "전역 {threshold}%", "accountPool.autoSwitchHint": "이 계정의 전역 임계값을 재정의합니다. 0은 이 계정에서 사용량 기반 사전 전환을 비활성화합니다.", "accountPool.autoSwitchUpdated": "{email}의 사용량 임계값을 업데이트했습니다", "accountPool.autoSwitchUpdateFailed": "{email}의 사용량 임계값을 저장하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d9d060c614..03edfe9751 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1928,7 +1928,6 @@ export const ru: Record = { "accountPool.autoSwitchThreshold": "Пользовательский порог аккаунта", "accountPool.autoSwitchThresholdAria": "Порог использования для {email}", "accountPool.autoSwitchOverrideAria": "Переопределить глобальный порог использования для {email}", - "accountPool.autoSwitchInherit": "Глобальный {threshold}%", "accountPool.autoSwitchHint": "Переопределяет глобальный порог для этого аккаунта. 0 отключает упреждающее переключение по использованию с этого аккаунта.", "accountPool.autoSwitchUpdated": "Порог использования для {email} обновлён", "accountPool.autoSwitchUpdateFailed": "Не удалось сохранить порог использования для {email}. Показано последнее подтверждённое значение.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c7072ae62c..1a16210af1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1947,7 +1947,6 @@ export const tr: Record = { "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.autoSwitchInherit": "Genel {threshold}%", "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.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 66a464975d..9109c50540 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2390,7 +2390,6 @@ export const zhTW: Record = { "accountPool.autoSwitchThreshold": "自訂帳號閾值", "accountPool.autoSwitchThresholdAria": "{email} 的使用量閾值", "accountPool.autoSwitchOverrideAria": "覆寫 {email} 的全域使用量閾值", - "accountPool.autoSwitchInherit": "全域 {threshold}%", "accountPool.autoSwitchHint": "覆寫此帳號的全域閾值。設為 0 會停用從此帳號發起的使用量主動切換。", "accountPool.autoSwitchUpdated": "已更新 {email} 的使用量閾值", "accountPool.autoSwitchUpdateFailed": "無法儲存 {email} 的使用量閾值。目前顯示最後確認的值。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index aeb982112c..702a30955f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1455,7 +1455,6 @@ export const zh: Record = { "accountPool.autoSwitchThreshold": "自定义账号阈值", "accountPool.autoSwitchThresholdAria": "{email} 的使用量阈值", "accountPool.autoSwitchOverrideAria": "覆盖 {email} 的全局使用量阈值", - "accountPool.autoSwitchInherit": "全局 {threshold}%", "accountPool.autoSwitchHint": "覆盖此账号的全局阈值。设为 0 会停用从此账号发起的基于使用量的主动切换。", "accountPool.autoSwitchUpdated": "已更新 {email} 的使用量阈值", "accountPool.autoSwitchUpdateFailed": "无法保存 {email} 的使用量阈值。当前显示最后确认的值。", diff --git a/gui/src/styles.css b/gui/src/styles.css index 143628471b..af266dd269 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2004,7 +2004,6 @@ dialog.modal-overlay::backdrop { .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-inherit { font-size: var(--text-label); color: var(--muted); 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; } diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index adf0d540a9..f86f2e1605 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -3,7 +3,11 @@ 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 type { + CodexAccountEntry, + CodexAccountLoadObserver, + CodexAccountPoolController, +} from "../src/hooks/useCodexAccountPool"; import { en } from "../src/i18n/en"; import { LanguageProvider } from "../src/i18n/provider"; @@ -338,6 +342,32 @@ test("custom account threshold uses only the custom number stepper", async () => 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({ diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 76c3456c9c..a2d895500b 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -24,6 +24,8 @@ export interface AccountRow { needsReauth?: 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; /** @@ -238,6 +240,7 @@ interface CodexAccountDto { isMain?: boolean; needsReauth?: boolean; priority?: number; + autoSwitchThresholdOverride?: number | null; quota?: CodexQuotaDto | null; quotaRefresh?: unknown; paused?: boolean; @@ -301,6 +304,9 @@ export async function fetchCodexRows( active: a.id === activeId, needsReauth: a.needsReauth, 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 acda3639b8..1f8fb287e9 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -321,9 +321,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-lifecycle.ts b/src/codex/account-lifecycle.ts index ad398ee96a..b76ef9f534 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -159,6 +159,10 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): saveConfigPreservingClaudeCode(runtimeConfig); } catch (error) { restoreRuntimeConfig(runtimeConfig, previousConfig); + // Child-deletion provenance is intentionally left in its WeakMap: restoring the + // threshold makes that tombstone self-suppress on the next prepare pass, and the + // next successful writer clears it. If removal is retried first, the same intent + // is still correct. No failed save can leak the deletion into persisted config. try { assertPersistedConfigUnchanged(configPath, previousPersistedConfig); } catch { diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 2f319b3144..65af701e5f 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -64,6 +64,7 @@ import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupport import type { DataPlaneAdmission } from "../server/auth-cors"; import { getMainReserveAuthorization, isMainReserveAuthorizationLive, 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); @@ -77,7 +78,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; diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 01f85ccbfa..aa224ae10b 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2108,7 +2108,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/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index f6792933f1..cfdbea8082 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -879,6 +879,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-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 35fa0ebdc2..d92ce7aef3 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -3920,6 +3920,34 @@ describe("codex-auth API", () => { 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("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, diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 73417b85f7..a6234d4f05 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1139,6 +1139,7 @@ describe("Codex auth context", () => { mainWeeklyPercent: number; poolWeeklyPercent: number; callerEntitled: boolean; + mainThresholdOverride?: number; }): Promise<{ cfg: OcxConfig; context: Awaited>; @@ -1149,6 +1150,11 @@ describe("Codex auth context", () => { cfg.autoSwitchThreshold = 90; cfg.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID; cfg.activeCodexAccountPinned = MAIN_CODEX_ACCOUNT_ID; + if (options.mainThresholdOverride !== undefined) { + cfg.codexAccountAutoSwitchThresholds = { + [MAIN_CODEX_ACCOUNT_ID]: options.mainThresholdOverride, + }; + } cfg.codexAccountPriorities = { [MAIN_CODEX_ACCOUNT_ID]: 0, "pool-a": 0, @@ -1199,6 +1205,19 @@ 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("an exhausted request-owned main pin still yields to the healthy Pool account (#3157)", async () => { const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ mainWeeklyPercent: 100, diff --git a/tests/config/config-user-edits.test.ts b/tests/config/config-user-edits.test.ts index 8dd0bc9762..bb0bbaa29b 100644 --- a/tests/config/config-user-edits.test.ts +++ b/tests/config/config-user-edits.test.ts @@ -611,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(); From fece6ddda9ab47da0ae1d2ff26c48e47c9d4e553 Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Tue, 8 Sep 2026 15:26:37 +0500 Subject: [PATCH 4/4] fix(gui): remove account threshold lint suppression --- gui/src/components/AccountAutoSwitchControl.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gui/src/components/AccountAutoSwitchControl.tsx b/gui/src/components/AccountAutoSwitchControl.tsx index ca8d33e8a6..d67f200f96 100644 --- a/gui/src/components/AccountAutoSwitchControl.tsx +++ b/gui/src/components/AccountAutoSwitchControl.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useId, useRef, useState } from "react"; import { clampNumberDraft } from "../clamp-draft"; import { useT } from "../i18n/shared"; import { NumberStepper } from "./NumberStepper"; @@ -26,8 +26,7 @@ export default function AccountAutoSwitchControl({ const enabled = override !== null; const [draft, setDraft] = useState(String(override ?? globalThreshold)); const hint = t("accountPool.autoSwitchHint"); - // eslint-disable-next-line local-i18n/no-hardcoded-ui-strings -- element id suffix, not UI text - const hintId = `${inputId}-hint`; + const hintId = useId(); const commit = async () => { const trimmed = draft.trim();