diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index 4a8824336de..3c43a780a24 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -237,7 +237,11 @@ renvoient 1 ; une sonde de quota en amont qui échoue ou expire produit plutôt ### `ocx account auto-switch > [--json]` -Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold ` accepte 0–100. Un seuil générique n’oriente la sélection que si `pool.kernel` est activé avec `strategy: "fill-first"` ; le drapeau désactivé, sa sauvegarde n’active pas le basculement par seuil. Dans les deux cas, elle ne change ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Pour les pools génériques, les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique un seuil enregistré mais non appliqué, `inert: false` un seuil que le pool applique réellement. L’absence d’`inert` signale une capacité inconnue, qui ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés. +Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold ` accepte 0–100. Un seuil générique n’oriente la sélection que si `pool.kernel` est activé avec `strategy: "fill-first"` ; le drapeau désactivé, sa sauvegarde n’active pas le basculement par seuil. Dans les deux cas, elle ne change ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Pour les pools génériques, les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique un seuil enregistré mais non appliqué, `inert: false` un seuil que le pool applique réellement. L’absence d’`inert` signale une capacité inconnue, qui ne produit jamais `enabled: true`. Les fournisseurs à clé API et les valeurs invalides sont refusés. + +### `ocx account auto-switch anthropic … --account ` + +Pour Anthropic OAuth, `ocx account auto-switch anthropic threshold 90 --account ` enregistre un entier de 0 à 100. `off --account ` vaut 0, `on --account ` vaut 80, `inherit --account ` rétablit l’héritage et `status --account ` lit sans écrire ; `--json` est disponible. La carte propose le même réglage. Une valeur absente/null hérite de `anthropicAccountPool.autoSwitchThreshold` (80 par défaut) ; 0 désactive seulement le basculement selon l’utilisation de ce compte. Le réglage survit au redémarrage et à la reconnexion, et disparaît avec le compte. Les priorités manuelle/affinité, les replis en cas de quota inconnu ou de comptes épuisés et les routes de modèles restent inchangés. Les seuils sont inactifs si le pool est désactivé ; pause et reprise après 429 restent actives. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } 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 207e6a4c965..8f0e9a10215 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -412,3 +412,13 @@ L'accès HTTP direct est surtout utile aux intégrations qui exigent les contrat ## Sessions distantes et rotation des clés de données `POST /api/keys/rotate {id}` démarre un chevauchement de dix minutes et renvoie le nouveau secret une seule fois. `POST /api/keys/rotate/commit {id,rotationId}` valide; `DELETE /api/keys/rotate {id,rotationId}` annule. L'authentification de gestion est obligatoire et une clé de données ne suffit pas. `POST /api/session/logout` exige la `gui-session` courante, l'Origin correspondante et CSRF. Un jeton admin reçoit 403 et ne peut jamais créer une session de consentement. + +## Seuil d’utilisation par compte Anthropic + +`PUT /api/oauth/accounts/auto-switch` + +Anthropic OAuth uniquement. `{ provider: "anthropic", accountId, threshold }` : entier 0–100 ou null pour hériter ; champ absent invalide. Conservé au redémarrage, supprimé avec le compte. + +Le DTO inclut `autoSwitchThresholdOverride` (entier/null), `autoSwitchThreshold` (défaut du pool) et `effectiveAutoSwitchThreshold`. 0 désactive seulement le basculement selon l’utilisation ; pause et reprise après 429 restent actives. + +HTTP: 400 invalid/unsupported; 404 missing account; `oauth_mutation_busy` on lock contention. diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 2d4b72755a6..a97def7a7c6 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -175,7 +175,11 @@ OAuth プロバイダーと API キー プロバイダーの場合、これに ### `ocx account auto-switch > [--json]` -`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold ` は 0–100 を保存します。汎用プールのしきい値は `pool.kernel` が有効で `strategy: "fill-first"` の場合にのみ選択へ反映されます。フラグが無効なら、保存してもしきい値による切り替えは有効になりません。いずれの場合もプロバイダーの有効化設定と 429 エラー時のローテーションは変更されません。汎用プールの照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は保存済みで未適用、`inert: false` はプールが適用中であることを示します。`inert` が無い場合は機能が不明であり、その場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。 +`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold ` は 0–100 を保存します。汎用プールのしきい値は `pool.kernel` が有効で `strategy: "fill-first"` の場合にのみ選択へ反映されます。フラグが無効なら、保存してもしきい値による切り替えは有効になりません。いずれの場合もプロバイダーの有効化設定と 429 エラー時のローテーションは変更されません。汎用プールの照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は保存済みで未適用、`inert: false` はプールが適用中であることを示します。`inert` が無い場合は機能が不明であり、その場合も `enabled: true` とは表示しません。API キープロバイダー、不正な値は拒否されます。 + +### `ocx account auto-switch anthropic … --account ` + +Anthropic OAuth では `ocx account auto-switch anthropic threshold 90 --account ` でアカウント別の整数 0–100 を保存します。`off --account ` は 0、`on --account ` は 80、`inherit --account ` は継承へ戻し、`status --account ` は読み取り専用です。`--json` も使えます。カードにも同じカスタム設定があります。未設定/null は `anthropicAccountPool.autoSwitchThreshold`(既定 80)を継承し、0 はそのアカウントの使用量による切り替えのみ無効にします。再起動・再ログインで保持され、削除時に消えます。手動選択、affinity、使用量不明・全候補消耗時のフォールバック、モデルルート制限は維持されます。プール無効時は適用されず、一時停止と 429 復旧は引き続き有効です。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } 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 3fee2a3a801..cfdb48f8e77 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -354,3 +354,13 @@ account の selector binding は残るため、欠落中の exact route は fail ## リモートセッションとデータキー更新 `POST /api/keys/rotate {id}` は10分間の移行を開始し、新しい秘密値を一度だけ返します。`POST /api/keys/rotate/commit {id,rotationId}` で確定し、`DELETE /api/keys/rotate {id,rotationId}` で中止します。管理認証が必須で、データキーからは呼べません。`POST /api/session/logout` には現在の `gui-session`、一致する Origin、CSRF が必要です。管理トークンは 403 となり、同意セッションを作成できません。 + +## Anthropic アカウント使用量しきい値 + +`PUT /api/oauth/accounts/auto-switch` + +Anthropic OAuth のみ。`{ provider: "anthropic", accountId, threshold }`: 整数 0–100、null は継承、欠落はエラー。再起動後も保持され、アカウント削除時に消えます。 + +DTO は `autoSwitchThresholdOverride`(整数/null)、`autoSwitchThreshold`(プール既定値)、`effectiveAutoSwitchThreshold` を含みます。0 は使用量による切り替えのみ無効にし、一時停止と 429 復旧は維持します。 + +HTTP: 400 invalid/unsupported; 404 missing account; `oauth_mutation_busy` on lock contention. diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 856390db88c..b87fb2dd9c6 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -270,7 +270,11 @@ OAuth 및 API 키 제공자에는 제공자의 할당량 보고 엔드포인트 ### `ocx account auto-switch > [--json]` -`openai` Codex 풀의 임계값을 제어하거나 일반 OAuth 풀의 임계값을 저장합니다. `on`은 80%, `off`는 0%, `threshold `은 0–100을 저장합니다. 일반 풀의 임계값은 `pool.kernel`이 켜져 있고 `strategy: "fill-first"`일 때만 선택에 반영됩니다. 플래그가 꺼져 있으면 저장해도 임계값 기반 전환이 켜지지 않습니다. 어느 쪽이든 제공자 활성화 설정은 바뀌지 않고, 429 오류에 따른 회전도 비활성화되지 않습니다. 일반 풀의 조회와 변경 결과는 서버가 확인한 값을 사용합니다. 일반 풀의 `poolEnabled`는 저장된 제공자별 설정이며 `null`은 미지정입니다. 전역 설정을 상속한 실제 상태를 뜻하지 않습니다. `inert: true`는 임계값이 저장만 되고 적용되지 않는 상태, `inert: false`는 풀이 실제로 적용하고 있는 상태를 뜻합니다. `inert`가 아예 없으면 기능 지원을 알 수 없는 경우이며, 이때도 `enabled: true`로 표시하지 않습니다. API 키 제공자, Anthropic 및 잘못된 값은 거부합니다. +`openai` Codex 풀의 임계값을 제어하거나 일반 OAuth 풀의 임계값을 저장합니다. `on`은 80%, `off`는 0%, `threshold `은 0–100을 저장합니다. 일반 풀의 임계값은 `pool.kernel`이 켜져 있고 `strategy: "fill-first"`일 때만 선택에 반영됩니다. 플래그가 꺼져 있으면 저장해도 임계값 기반 전환이 켜지지 않습니다. 어느 쪽이든 제공자 활성화 설정은 바뀌지 않고, 429 오류에 따른 회전도 비활성화되지 않습니다. 일반 풀의 조회와 변경 결과는 서버가 확인한 값을 사용합니다. 일반 풀의 `poolEnabled`는 저장된 제공자별 설정이며 `null`은 미지정입니다. 전역 설정을 상속한 실제 상태를 뜻하지 않습니다. `inert: true`는 임계값이 저장만 되고 적용되지 않는 상태, `inert: false`는 풀이 실제로 적용하고 있는 상태를 뜻합니다. `inert`가 아예 없으면 기능 지원을 알 수 없는 경우이며, 이때도 `enabled: true`로 표시하지 않습니다. API 키 제공자 및 잘못된 값은 거부합니다. + +### `ocx account auto-switch anthropic … --account ` + +Anthropic OAuth는 `ocx account auto-switch anthropic threshold 90 --account `로 계정별 정수 0–100을 저장합니다. `off --account `는 0, `on --account `는 80, `inherit --account `는 상속 복원, `status --account `는 조회입니다. `--json`도 지원합니다. 계정 카드에서 같은 사용자 지정 임계값을 편집합니다. 미설정/null은 풀 기본값 `anthropicAccountPool.autoSwitchThreshold`(기본 80)를 상속하고, 0은 해당 계정의 사용량 기반 전환만 끕니다. 재시작·재로그인 후에도 유지되고 계정 삭제 시 제거됩니다. 수동 선택, 세션 affinity, 사용량 미확인·전체 소진 시 fallback, 모델 경로 제한은 유지됩니다. 풀이 꺼져 있으면 임계값은 적용되지 않으며 pause와 429 복구는 계속 동작합니다. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } 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 1a884ba64d4..a38309cb2cc 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -380,3 +380,13 @@ account의 selector binding은 남아 있어 계정이 없을 때 exact route가 ## 원격 세션과 데이터 키 교체 `POST /api/keys/rotate {id}`는 최대 10분의 전환을 시작하며 새 데이터 키를 한 번만 반환합니다. `POST /api/keys/rotate/commit {id,rotationId}`는 확정하고, `DELETE /api/keys/rotate {id,rotationId}`는 취소합니다. 모두 관리 인증이 필요하며 데이터 키로 호출할 수 없습니다. `POST /api/session/logout`은 현재 `gui-session`, 일치하는 Origin, CSRF가 필요합니다. 관리자 토큰은 403을 받고 동의 세션을 만들거나 교환할 수 없습니다. + +## Anthropic 계정 사용량 임계값 + +`PUT /api/oauth/accounts/auto-switch` + +Anthropic OAuth 전용. `{ provider: "anthropic", accountId, threshold }`: 정수 0–100, null은 상속, 누락은 오류. 재시작 후 유지되고 계정 삭제 시 제거됩니다. + +계정 DTO는 `autoSwitchThresholdOverride`(정수/null), `autoSwitchThreshold`(풀 기본값), `effectiveAutoSwitchThreshold`를 포함합니다. 0은 사용량 전환만 끄며 pause·429 복구는 유지합니다. + +HTTP: 400 invalid/unsupported; 404 missing account; `oauth_mutation_busy` on lock contention. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 02edd827e13..738fec91484 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -504,7 +504,11 @@ instead (exit 0), matching the dashboard's quota bars. ### `ocx account auto-switch > [--json]` -Controls the `openai` Codex pool threshold, or stores a threshold for a generic OAuth pool. `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0–100. A generic pool threshold steers selection only while `pool.kernel` is on with `strategy: "fill-first"`; with the flag off, saving one does not enable threshold-based switching. It never changes the provider enablement override or disables reactive 429 rotation. `status` and mutation output for generic pools use the confirmed server response. For generic pools, `poolEnabled` is the stored provider override (`null` means unspecified), not inherited effective state; `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability, which never reports `enabled: true`. API-key providers, Anthropic and invalid values are rejected. +Controls the `openai` Codex pool threshold, or stores a threshold for a generic OAuth pool. `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0–100. A generic pool threshold steers selection only while `pool.kernel` is on with `strategy: "fill-first"`; with the flag off, saving one does not enable threshold-based switching. It never changes the provider enablement override or disables reactive 429 rotation. `status` and mutation output for generic pools use the confirmed server response. For generic pools, `poolEnabled` is the stored provider override (`null` means unspecified), not inherited effective state; `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability, which never reports `enabled: true`. API-key providers and invalid values are rejected. + +### `ocx account auto-switch anthropic … --account ` + +For Anthropic OAuth, use `ocx account auto-switch anthropic threshold 90 --account ` (integer 0–100), `off --account ` (0), `on --account ` (80), `inherit --account ` (reset), or `status --account ` (read-only); append `--json` for structured output. The account card offers the same custom-threshold toggle. Missing/null inherits `anthropicAccountPool.autoSwitchThreshold` (default 80); 0 disables usage-driven switching for that account, not pause or reactive 429 recovery. Overrides survive restart and re-login and are removed with the account. With pooling enabled, quota and fill-first compare each source/candidate against its own threshold in the selected quota window. Manual/affinity precedence, identity-less round-robin/fill-first behavior, unknown-quota fallback and all-drained fallback remain unchanged. Round-robin is not usage-driven; disabled pools ignore these thresholds. Model-route allowlists still constrain every candidate. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 3c60b3505db..41e40d28302 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -744,3 +744,13 @@ Direct HTTP is most useful for integrations that need the exact endpoint contrac ## Remote sessions and data-key rotation `POST /api/keys/rotate {id}` starts a ten-minute overlap and returns the new data secret once. `POST /api/keys/rotate/commit {id,rotationId}` commits it; `DELETE /api/keys/rotate {id,rotationId}` aborts it. All require management authentication; data keys cannot call them. `POST /api/session/logout` requires the current `gui-session`, matching Origin, and CSRF. An admin token receives 403 and can never mint or exchange into a consent session. + +## Anthropic account usage threshold + +`PUT /api/oauth/accounts/auto-switch` + +Anthropic OAuth only; `{ provider: "anthropic", accountId, threshold }` accepts integer 0–100 or null to inherit. Missing threshold is invalid. Stored override survives restart and is removed with the account. + +Account-list DTOs include `autoSwitchThresholdOverride` (integer/null), `autoSwitchThreshold` (pool default), and `effectiveAutoSwitchThreshold`. 0 disables usage-driven switching only; it never disables pause or 429 recovery. + +HTTP: 400 invalid/unsupported; 404 missing account; `oauth_mutation_busy` on lock contention. diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index bd3daa00013..899eb314a8e 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -215,7 +215,11 @@ quota-bar'ов дашборда. ### `ocx account auto-switch > [--json]` -Управляет порогом пула Codex `openai` или сохраняет порог общего пула OAuth. `on` сохраняет 80 %, `off` — 0 %, а `threshold ` принимает 0–100. Порог общего пула влияет на выбор только при включённом `pool.kernel` и `strategy: "fill-first"`; при выключенном флаге сохранение не включает переключение по порогу. В обоих случаях оно не меняет настройку включения провайдера и не отключает ротацию после ошибки 429. Для общего пула результат чтения и изменения берётся из подтверждённого ответа сервера. Для общего пула `poolEnabled` — сохранённая настройка провайдера (`null` означает отсутствие настройки), а не итоговое унаследованное состояние. `inert: true` означает, что порог сохранён, но не применяется, а `inert: false` — что пул его применяет. Отсутствие `inert` означает неизвестную возможность, которая также не даёт `enabled: true`. Провайдеры с ключом API, Anthropic и неверные значения отклоняются. +Управляет порогом пула Codex `openai` или сохраняет порог общего пула OAuth. `on` сохраняет 80 %, `off` — 0 %, а `threshold ` принимает 0–100. Порог общего пула влияет на выбор только при включённом `pool.kernel` и `strategy: "fill-first"`; при выключенном флаге сохранение не включает переключение по порогу. В обоих случаях оно не меняет настройку включения провайдера и не отключает ротацию после ошибки 429. Для общего пула результат чтения и изменения берётся из подтверждённого ответа сервера. Для общего пула `poolEnabled` — сохранённая настройка провайдера (`null` означает отсутствие настройки), а не итоговое унаследованное состояние. `inert: true` означает, что порог сохранён, но не применяется, а `inert: false` — что пул его применяет. Отсутствие `inert` означает неизвестную возможность, которая также не даёт `enabled: true`. Провайдеры с ключом API и неверные значения отклоняются. + +### `ocx account auto-switch anthropic … --account ` + +Для Anthropic OAuth команда `ocx account auto-switch anthropic threshold 90 --account ` сохраняет целое число 0–100. `off --account ` задаёт 0, `on --account ` — 80, `inherit --account ` восстанавливает наследование, а `status --account ` только читает; доступен `--json`. Карточка аккаунта предлагает тот же контроль. Отсутствующее/null значение наследует `anthropicAccountPool.autoSwitchThreshold` (по умолчанию 80); 0 отключает только переключение по использованию этого аккаунта. Настройка переживает перезапуск и повторный вход, удаляется вместе с аккаунтом. Ручной выбор, affinity, резервный выбор при неизвестных или исчерпанных квотах и ограничения маршрутов не меняются. При выключенном пуле пороги не действуют; пауза и восстановление после 429 сохраняются. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } 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 62b74ab5452..b7f827b36b6 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -402,3 +402,13 @@ fail closed, пока аккаунт отсутствует, а при повт ## Удалённые сессии и ротация ключей данных `POST /api/keys/rotate {id}` начинает десятиминутный overlap и один раз возвращает новый секрет. `POST /api/keys/rotate/commit {id,rotationId}` подтверждает, `DELETE /api/keys/rotate {id,rotationId}` отменяет. Требуется management auth; ключ данных не подходит. `POST /api/session/logout` требует текущую `gui-session`, совпадающий Origin и CSRF. Admin token получает 403 и не может создать consent session. + +## Порог использования аккаунта Anthropic + +`PUT /api/oauth/accounts/auto-switch` + +Только Anthropic OAuth. `{ provider: "anthropic", accountId, threshold }`: целое 0–100 или null для наследования; отсутствие поля — ошибка. Сохраняется при перезапуске и удаляется вместе с аккаунтом. + +DTO содержит `autoSwitchThresholdOverride` (целое/null), `autoSwitchThreshold` (порог пула) и `effectiveAutoSwitchThreshold`. 0 отключает только переключение по использованию; пауза и восстановление после 429 сохраняются. + +HTTP: 400 invalid/unsupported; 404 missing account; `oauth_mutation_busy` on lock contention. diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index 6c98e625a2d..df4f49ab949 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -262,7 +262,11 @@ eşleşen null veya eski bir rapora düşer (çıkış 0). ### `ocx account auto-switch > [--json]` -`openai` Codex havuzunun eşiğini yönetir veya genel OAuth havuzunun eşiğini kaydeder. `on` %80, `off` %0 kaydeder; `threshold ` 0–100 kabul eder. Genel havuz eşiği yalnızca `pool.kernel` açıkken ve `strategy: "fill-first"` seçiliyken seçimi yönlendirir; bayrak kapalıyken kayıt işlemi eşik tabanlı geçişi etkinleştirmez. Her iki durumda da sağlayıcının etkinlik ayarını veya 429 hatasından sonraki otomatik hesap değişimini etkilemez. Genel havuz çıktısı sunucunun doğruladığı değerleri kullanır. Genel havuzlarda `poolEnabled`, kaydedilmiş sağlayıcı ayarıdır (`null` belirtilmemiş demektir); devralınmış etkin durumu göstermez. `inert: true` eşiğin kaydedildiğini ama uygulanmadığını, `inert: false` ise havuzun onu uyguladığını belirtir. `inert` yoksa yetenek bilinmiyordur ve bu durumda da `enabled: true` bildirilmez. API anahtarlı sağlayıcılar, Anthropic ve geçersiz değerler reddedilir. +`openai` Codex havuzunun eşiğini yönetir veya genel OAuth havuzunun eşiğini kaydeder. `on` %80, `off` %0 kaydeder; `threshold ` 0–100 kabul eder. Genel havuz eşiği yalnızca `pool.kernel` açıkken ve `strategy: "fill-first"` seçiliyken seçimi yönlendirir; bayrak kapalıyken kayıt işlemi eşik tabanlı geçişi etkinleştirmez. Her iki durumda da sağlayıcının etkinlik ayarını veya 429 hatasından sonraki otomatik hesap değişimini etkilemez. Genel havuz çıktısı sunucunun doğruladığı değerleri kullanır. Genel havuzlarda `poolEnabled`, kaydedilmiş sağlayıcı ayarıdır (`null` belirtilmemiş demektir); devralınmış etkin durumu göstermez. `inert: true` eşiğin kaydedildiğini ama uygulanmadığını, `inert: false` ise havuzun onu uyguladığını belirtir. `inert` yoksa yetenek bilinmiyordur ve bu durumda da `enabled: true` bildirilmez. API anahtarlı sağlayıcılar ve geçersiz değerler reddedilir. + +### `ocx account auto-switch anthropic … --account ` + +Anthropic OAuth için `ocx account auto-switch anthropic threshold 90 --account ` komutu 0–100 arasında tam sayı kaydeder. `off --account ` 0, `on --account ` 80 kaydeder; `inherit --account ` devralmayı geri getirir, `status --account ` yalnızca okur. `--json` desteklenir. Hesap kartı aynı ayarı sunar. Eksik/null değer `anthropicAccountPool.autoSwitchThreshold` varsayılanını (80) devralır; 0 yalnızca bu hesabın kullanıma dayalı geçişini kapatır. Yeniden başlatma ve girişte korunur, hesap silinince kaldırılır. Manuel seçim, affinity, bilinmeyen/tükenmiş kota yedek davranışı ve model rotaları değişmez. Havuz kapalıyken eşikler uygulanmaz; duraklatma ve 429 kurtarması sürer. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } 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 0a19213029c..1666fb0a8de 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -438,3 +438,13 @@ entegrasyonlar için en yararlıdır. ## Uzak oturumlar ve veri anahtarı döndürme `POST /api/keys/rotate {id}` on dakikalık geçişi başlatır ve yeni sırrı yalnızca bir kez döndürür. `POST /api/keys/rotate/commit {id,rotationId}` onaylar, `DELETE /api/keys/rotate {id,rotationId}` iptal eder. Yönetim kimlik doğrulaması gerekir; veri anahtarı bunları çağıramaz. `POST /api/session/logout` mevcut `gui-session`, eşleşen Origin ve CSRF ister. Admin token 403 alır ve onay oturumu oluşturamaz. + +## Anthropic hesap kullanım eşiği + +`PUT /api/oauth/accounts/auto-switch` + +Yalnızca Anthropic OAuth. `{ provider: "anthropic", accountId, threshold }`: 0–100 tam sayı veya devralmak için null; eksik alan hatadır. Yeniden başlatmada korunur, hesapla birlikte silinir. + +DTO: `autoSwitchThresholdOverride` (tam sayı/null), `autoSwitchThreshold` (havuz varsayılanı), `effectiveAutoSwitchThreshold`. 0 yalnızca kullanıma dayalı geçişi kapatır; duraklatma ve 429 kurtarması sürer. + +HTTP: 400 invalid/unsupported; 404 missing account; `oauth_mutation_busy` on lock contention. diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 1806ae38843..4c1cc0afeda 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -196,7 +196,11 @@ token,也不是简单重读账号列表。`--json` 返回 ### `ocx account auto-switch > [--json]` -控制 `openai` Codex 账户池阈值,或保存通用 OAuth 账户池阈值。`on` 保存 80%,`off` 保存 0%,`threshold ` 接受 0–100。通用池的阈值只有在 `pool.kernel` 打开且 `strategy: "fill-first"` 时才参与选择;标志关闭时,保存阈值不会启用阈值切换。两种情况下都不会改变提供方启用设置或禁用 429 错误后的轮换。通用池的查询和修改结果使用服务器确认值。通用池的 `poolEnabled` 是已保存的提供方设置,`null` 表示未指定,并不代表继承后的实际状态。`inert: true` 表示阈值已保存但未应用,`inert: false` 表示账户池正在应用它。没有 `inert` 字段表示能力未知,此时同样不会报告 `enabled: true`。API 密钥提供方、Anthropic 和无效值会被拒绝。 +控制 `openai` Codex 账户池阈值,或保存通用 OAuth 账户池阈值。`on` 保存 80%,`off` 保存 0%,`threshold ` 接受 0–100。通用池的阈值只有在 `pool.kernel` 打开且 `strategy: "fill-first"` 时才参与选择;标志关闭时,保存阈值不会启用阈值切换。两种情况下都不会改变提供方启用设置或禁用 429 错误后的轮换。通用池的查询和修改结果使用服务器确认值。通用池的 `poolEnabled` 是已保存的提供方设置,`null` 表示未指定,并不代表继承后的实际状态。`inert: true` 表示阈值已保存但未应用,`inert: false` 表示账户池正在应用它。没有 `inert` 字段表示能力未知,此时同样不会报告 `enabled: true`。API 密钥提供方和无效值会被拒绝。 + +### `ocx account auto-switch anthropic … --account ` + +Anthropic OAuth 使用 `ocx account auto-switch anthropic threshold 90 --account ` 保存账户专属整数 0–100。`off --account ` 设为 0,`on --account ` 设为 80,`inherit --account ` 恢复继承,`status --account ` 只读查询;可加 `--json`。账户卡片提供相同控制。未设置/null 继承 `anthropicAccountPool.autoSwitchThreshold`(默认 80);0 只禁用该账户按用量切换。设置在重启和重新登录后保留,删除账户时移除。手动选择、affinity、未知或全部耗尽时的后备行为与模型路由限制不变。池禁用时不应用阈值;暂停与 429 恢复仍有效。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } 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 4abf420ad36..a9809b951f2 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 @@ -348,3 +348,13 @@ OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上 ## 远程会话与数据密钥轮换 `POST /api/keys/rotate {id}` 开始十分钟重叠期,并只返回一次新密钥。`POST /api/keys/rotate/commit {id,rotationId}` 提交,`DELETE /api/keys/rotate {id,rotationId}` 中止。它们都需要管理认证,数据密钥不能调用。`POST /api/session/logout` 需要当前 `gui-session`、匹配的 Origin 和 CSRF。Admin token 会收到 403,永远不能创建用户同意会话。 + +## Anthropic 账户用量阈值 + +`PUT /api/oauth/accounts/auto-switch` + +仅 Anthropic OAuth。`{ provider: "anthropic", accountId, threshold }`:整数 0–100 或 null 继承;缺少字段无效。重启后保留,随账户删除。 + +DTO 包含 `autoSwitchThresholdOverride`(整数/null)、`autoSwitchThreshold`(池默认值)、`effectiveAutoSwitchThreshold`。0 只禁用按用量切换;暂停和 429 恢复不变。 + +HTTP: 400 invalid/unsupported; 404 missing account; `oauth_mutation_busy` on lock contention. diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 6abdcc7e7bc..09ead299f34 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -172,7 +172,11 @@ ocx account resume google-antigravity ### `ocx account auto-switch > [--json]` -控制 `openai` Codex 帳戶池閾值,或儲存通用 OAuth 帳戶池閾值。`on` 儲存 80%,`off` 儲存 0%,`threshold ` 接受 0–100。通用池的閾值只有在 `pool.kernel` 開啟且 `strategy: "fill-first"` 時才參與選擇;旗標關閉時,儲存閾值不會啟用閾值切換。兩種情況下都不會改變供應商啟用設定或停用 429 錯誤後的輪替。通用池的查詢與修改結果使用伺服器確認值。通用池的 `poolEnabled` 是已儲存的供應商設定,`null` 表示未指定,並不代表繼承後的實際狀態。`inert: true` 表示閾值已儲存但未套用,`inert: false` 表示帳戶池正在套用它。沒有 `inert` 欄位表示能力未知,此時同樣不會回報 `enabled: true`。API 金鑰供應商、Anthropic 與無效值會被拒絕。 +控制 `openai` Codex 帳戶池閾值,或儲存通用 OAuth 帳戶池閾值。`on` 儲存 80%,`off` 儲存 0%,`threshold ` 接受 0–100。通用池的閾值只有在 `pool.kernel` 開啟且 `strategy: "fill-first"` 時才參與選擇;旗標關閉時,儲存閾值不會啟用閾值切換。兩種情況下都不會改變供應商啟用設定或停用 429 錯誤後的輪替。通用池的查詢與修改結果使用伺服器確認值。通用池的 `poolEnabled` 是已儲存的供應商設定,`null` 表示未指定,並不代表繼承後的實際狀態。`inert: true` 表示閾值已儲存但未套用,`inert: false` 表示帳戶池正在套用它。沒有 `inert` 欄位表示能力未知,此時同樣不會回報 `enabled: true`。API 金鑰供應商與無效值會被拒絕。 + +### `ocx account auto-switch anthropic … --account ` + +Anthropic OAuth 使用 `ocx account auto-switch anthropic threshold 90 --account ` 儲存帳戶專屬整數 0–100。`off --account ` 設為 0,`on --account ` 設為 80,`inherit --account ` 恢復繼承,`status --account ` 唯讀查詢;可加 `--json`。帳戶卡片提供相同控制。未設定/null 繼承 `anthropicAccountPool.autoSwitchThreshold`(預設 80);0 只停用該帳戶依用量切換。設定在重啟和重新登入後保留,刪除帳戶時移除。手動選擇、affinity、未知或全部耗盡時的後備行為與模型路由限制不變。集區停用時不套用門檻;暫停與 429 復原仍有效。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } 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 e987e1f8a72..2ef37691a42 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 @@ -327,3 +327,13 @@ OpenAI 也遵循此規則:開關不會選擇特殊的 922k 模式。生效中 ## 遠端工作階段與資料金鑰輪替 `POST /api/keys/rotate {id}` 開始十分鐘重疊期,且只回傳一次新金鑰。`POST /api/keys/rotate/commit {id,rotationId}` 提交,`DELETE /api/keys/rotate {id,rotationId}` 中止。全部都需要管理驗證,資料金鑰不能呼叫。`POST /api/session/logout` 需要目前的 `gui-session`、相符的 Origin 與 CSRF。Admin token 會收到 403,永遠不能建立使用者同意工作階段。 + +## Anthropic 帳戶用量門檻 + +`PUT /api/oauth/accounts/auto-switch` + +僅 Anthropic OAuth。`{ provider: "anthropic", accountId, threshold }`:整數 0–100 或 null 繼承;缺少欄位無效。重啟後保留,隨帳戶刪除。 + +DTO 包含 `autoSwitchThresholdOverride`(整數/null)、`autoSwitchThreshold`(集區預設值)、`effectiveAutoSwitchThreshold`。0 只停用依用量切換;暫停和 429 復原不變。 + +HTTP: 400 invalid/unsupported; 404 missing account; `oauth_mutation_busy` on lock contention. diff --git a/gui/src/components/AccountAutoSwitchControl.tsx b/gui/src/components/AccountAutoSwitchControl.tsx index 6f0343bdb44..078b65dec46 100644 --- a/gui/src/components/AccountAutoSwitchControl.tsx +++ b/gui/src/components/AccountAutoSwitchControl.tsx @@ -10,6 +10,7 @@ export interface AccountAutoSwitchControlProps { override: number | null; disabled?: boolean; inputId: string; + hintText?: string; onChange(threshold: number | null): Promise; } @@ -20,6 +21,7 @@ export default function AccountAutoSwitchControl({ override, disabled = false, inputId, + hintText, onChange, }: AccountAutoSwitchControlProps) { const t = useT(); @@ -40,7 +42,7 @@ export default function AccountAutoSwitchControl({ draft: String(current.override ?? globalThreshold), })); const blocked = disabled || saving; - const hint = t("accountPool.autoSwitchHint"); + const hint = hintText ?? t("accountPool.autoSwitchHint"); const hintId = useId(); const write = async (next: number | null) => { diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index 84120b54c41..15b5e20b9e1 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -2,7 +2,7 @@ * Opt-in Anthropic OAuth account pool controls (#294). * Experimental — shows a strong warning because the feature is not battle-tested. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { getPoolSettings, putPoolSettings } from "../../pool-settings"; import { @@ -37,9 +37,11 @@ type PoolState = { export default function AnthropicAccountPoolSettings({ apiBase, accountCount, + onThresholdChange, }: { apiBase: string; accountCount: number; + onThresholdChange?: (threshold: number) => void; }) { const t = useT(); const [state, setState] = useState(null); @@ -48,6 +50,24 @@ export default function AnthropicAccountPoolSettings({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [loadError, setLoadError] = useState(false); + const onThresholdChangeRef = useRef(onThresholdChange); + const mountedRef = useRef(true); + const apiBaseRef = useRef(apiBase); + const saveAbortRef = useRef(null); + + useLayoutEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + saveAbortRef.current?.abort(); + saveAbortRef.current = null; + }; + }, []); + + useLayoutEffect(() => { + onThresholdChangeRef.current = onThresholdChange; + apiBaseRef.current = apiBase; + }, [apiBase, onThresholdChange]); useEffect(() => { let cancelled = false; @@ -79,6 +99,7 @@ export default function AnthropicAccountPoolSettings({ quotaWindow: normalizeAccountPoolQuotaWindow(json.quotaWindow), }); setDraft(String(nextThreshold)); + onThresholdChangeRef.current?.(nextThreshold); setStickyDraft(String(nextSticky)); setLoadError(false); }) @@ -99,6 +120,12 @@ export default function AnthropicAccountPoolSettings({ stickyLimit: number; quotaWindow: AccountPoolQuotaWindow; }) => { + const requestApiBase = apiBase; + saveAbortRef.current?.abort(); + const controller = new AbortController(); + saveAbortRef.current = controller; + const currentRequest = () => mountedRef.current && apiBaseRef.current === requestApiBase + && saveAbortRef.current === controller && !controller.signal.aborted; const previousState = state; setState({ enabled: next.enabled, @@ -112,27 +139,31 @@ export default function AnthropicAccountPoolSettings({ try { // The client owns the field mapping: `threshold` becomes `autoSwitchThreshold` and the // provider is always sent, so no call site can forget either. - const json = await putPoolSettings(apiBase, "anthropic", { + const json = await putPoolSettings(requestApiBase, "anthropic", { enabled: next.enabled, threshold: next.threshold, strategy: next.strategy, stickyLimit: next.stickyLimit, quotaWindow: next.quotaWindow, - }); + }, (input, init) => fetch(input, init), { signal: controller.signal }); + if (!currentRequest()) return; if (!json) throw new Error("save"); + const savedThreshold = typeof json.autoSwitchThreshold === "number" ? json.autoSwitchThreshold : next.threshold; const savedStrategy = normalizeAccountPoolStrategy(json?.strategy ?? next.strategy); const savedSticky = normalizeAccountPoolStickyLimit(json?.stickyLimit ?? next.stickyLimit); const savedWindow = normalizeAccountPoolQuotaWindow(json?.quotaWindow ?? next.quotaWindow); setState({ enabled: next.enabled, - threshold: next.threshold, + threshold: savedThreshold, strategy: savedStrategy, stickyLimit: savedSticky, quotaWindow: savedWindow, }); - setDraft(String(next.threshold)); + setDraft(String(savedThreshold)); + onThresholdChangeRef.current?.(savedThreshold); setStickyDraft(String(savedSticky)); } catch { + if (!currentRequest()) return; setError(t("anthropicPool.saveFailed")); if (previousState) { setState(previousState); @@ -140,7 +171,9 @@ export default function AnthropicAccountPoolSettings({ setStickyDraft(String(previousState.stickyLimit)); } } finally { - setSaving(false); + const ownsSave = saveAbortRef.current === controller; + if (ownsSave) saveAbortRef.current = null; + if (ownsSave && mountedRef.current && apiBaseRef.current === requestApiBase) setSaving(false); } }, [apiBase, state, t]); diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 58b7eb214e8..440d30653f0 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -9,6 +9,7 @@ import { IconLock, IconRefresh, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; import { displayAccountId } from "../../lib/privacy"; +import AccountAutoSwitchControl from "../AccountAutoSwitchControl"; import { formatOAuthHealthLabel, formatOAuthHealthSummary, @@ -428,7 +429,12 @@ export default function ProviderAuthPanel({ {isOauth && ( <> {item.name === "anthropic" && ( - + { void authHandlers?.onAccountPoolThreshold?.(item.name, threshold); }} + /> )} {item.name === "google-antigravity" && (
@@ -621,6 +627,16 @@ export default function ProviderAuthPanel({
+ {item.name === "anthropic" && account.autoSwitchThresholdOverride !== undefined + && account.autoSwitchThreshold !== undefined && authHandlers.onAccountThreshold && ( + authHandlers.onAccountThreshold!(item.name, account, threshold)} + /> + )}
diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 0fbca409b7c..697e43114ea 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -61,6 +61,9 @@ export type OAuthAccountRow = AccountQuotaReading & { autoSelectable?: boolean; skipReason?: "needs_reauth" | "paused" | "suspended" | "cooldown" | "quota_exhausted"; paused?: boolean; + autoSwitchThresholdOverride?: number | null; + autoSwitchThreshold?: number; + effectiveAutoSwitchThreshold?: number; health?: { status: OAuthAccountHealthStatus; reason?: string; until?: string }; healthLabel?: string; healthSummary?: string; @@ -91,6 +94,8 @@ export interface ProviderAuthHandlers { onReauth: (provider: string, accountId?: string) => void | Promise; onSwitchAccount: (provider: string, account: OAuthAccountRow) => void | Promise; onPauseAccount: (provider: string, account: OAuthAccountRow, paused: boolean) => void | Promise; + onAccountThreshold?: (provider: string, account: OAuthAccountRow, threshold: number | null) => Promise; + onAccountPoolThreshold?: (provider: string, threshold: number) => void | Promise; onRemoveAccount: (provider: string, account: OAuthAccountRow) => void | Promise; onRetryAccounts?: (provider: string) => void | Promise; onAddApiKey: (provider: string, key: string) => Promise; diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index a901a0ae133..d9a04b3e453 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -23,6 +23,9 @@ export interface OAuthAccount extends AccountQuotaReading { autoSelectable?: boolean; skipReason?: "needs_reauth" | "paused" | "suspended" | "cooldown" | "quota_exhausted"; paused?: boolean; + autoSwitchThresholdOverride?: number | null; + autoSwitchThreshold?: number; + effectiveAutoSwitchThreshold?: number; expiresAt?: number; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; healthLabel?: string; @@ -362,8 +365,28 @@ export function useProviderAccountPools(deps: { return key; }; + const setAccountPoolThreshold = async (provider: string, threshold: number): Promise => { + if (!aliveRef.current || !mountedRef.current || serverRef.current !== apiBase) return false; + // Pool settings and roster reads describe one server value. Invalidate older reads before + // publishing the confirmed save, then refresh so a concurrent external write can still win. + invalidateSelectionReads(provider, "oauth"); + setAccountSets(current => { + const existing = current[provider]; + return !existing ? current : { ...current, [provider]: { ...existing, + accounts: existing.accounts.map(row => ({ ...row, + autoSwitchThreshold: threshold, + effectiveAutoSwitchThreshold: typeof row.autoSwitchThresholdOverride === "number" + ? row.autoSwitchThresholdOverride : threshold, + })) } }; + }); + // Restart the full roster path, not only the cheap membership read. The settings card can + // resolve before the initial account load; cancelling that load without replacing its quota + // enrichment would leave usage bars empty until a manual refresh or remount. + return fetchAccountSets([provider]); + }; + const switchAccount = async (provider: string, account: OAuthAccount) => { - if (account.active || account.needsReauth || account.paused || switchingAccountRef.current || pausingAccountRef.current) return; + if (account.active || account.needsReauth || account.paused || switchingAccountRef.current || pausingAccountRef.current || selectionMutationsRef.current.has(`oauth:${provider}`)) return; const target = { provider, accountId: account.id }; switchingAccountRef.current = target; setSwitchingAccount(target); @@ -403,8 +426,58 @@ export function useProviderAccountPools(deps: { } }; + const setAccountThreshold = async (provider: string, account: OAuthAccount, threshold: number | null): Promise => { + const key = `oauth:${provider}`; + if (switchingAccountRef.current || pausingAccountRef.current || selectionMutationsRef.current.has(key)) return false; + const mutationKey = invalidateSelectionReads(provider, "oauth"); + const mutation = Symbol(); + selectionMutationsRef.current.set(mutationKey, mutation); + const currentMutation = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase + && selectionMutationsRef.current.get(mutationKey) === mutation; + const label = oauthAccountDisplayLabel(accountSets[provider]?.accounts ?? [account], account, t); + try { + const bounded = createBoundedFetch(20_000); + requestsRef.current.add(bounded.controller); + let result: Pick; + try { + const res = await fetch(`${apiBase}/api/oauth/accounts/auto-switch`, { + method: "PUT", headers: { "Content-Type": "application/json" }, signal: bounded.signal, + body: JSON.stringify({ provider, accountId: account.id, threshold }), + }); + if (!res.ok) throw new Error("account threshold write failed"); + result = await res.json() as typeof result; + if (bounded.signal.aborted) throw new Error("account threshold deadline exceeded"); + } finally { + bounded.clear(); + requestsRef.current.delete(bounded.controller); + } + const validPercent = (value: unknown) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100; + if (!result || (result.autoSwitchThresholdOverride !== null && !validPercent(result.autoSwitchThresholdOverride)) + || !validPercent(result.autoSwitchThreshold) || !validPercent(result.effectiveAutoSwitchThreshold)) throw new Error("invalid threshold response"); + if (!currentMutation()) return false; + invalidateSelectionReads(provider, "oauth"); + setAccountSets(current => { + const existing = current[provider]; + return !existing ? current : { ...current, [provider]: { ...existing, + accounts: existing.accounts.map(row => row.id === account.id ? { ...row, + autoSwitchThresholdOverride: result.autoSwitchThresholdOverride, + autoSwitchThreshold: result.autoSwitchThreshold, effectiveAutoSwitchThreshold: result.effectiveAutoSwitchThreshold } : row) } }; + }); + return true; + } catch { + if (currentMutation()) notify(t("accountPool.autoSwitchUpdateFailed", { email: label }), false); + return false; + } finally { + if (currentMutation()) { + invalidateSelectionReads(provider, "oauth"); + selectionMutationsRef.current.delete(mutationKey); + void refreshAccountRosters({ provider, kind: "oauth" }); + } + } + }; + const pauseAccount = async (provider: string, account: OAuthAccount, paused: boolean) => { - if (switchingAccountRef.current || pausingAccountRef.current) return; + if (switchingAccountRef.current || pausingAccountRef.current || selectionMutationsRef.current.has(`oauth:${provider}`)) return; const target = { provider, accountId: account.id }; pausingAccountRef.current = target; setPausingAccount({ ...target, paused }); @@ -623,7 +696,7 @@ export function useProviderAccountPools(deps: { return { accountSets, accountLoadStates, switchingAccount, pausingAccount, openAccounts, keyPools, addingKeyFor, newKeyValue, setAccountSets, setAccountLoadStates, setSwitchingAccount, setOpenAccounts, setKeyPools, setAddingKeyFor, setNewKeyValue, - fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, pauseAccount, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount, + fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount, oauthCardProviders, keyCardProviders, activeAccountNeedsReauth, }; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 42ef7f400fc..6ddb8ba1d59 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,7 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "pws.anthropicAccountThresholdHint": "Überschreibt den Standard des Claude-Pools. 0 deaktiviert den nutzungsbasierten Wechsel nur für dieses Konto; Pause und Wiederherstellung bei Ratenlimits gelten weiterhin.", "kiroLogin.title": "Bei Kiro anmelden", "kiroLogin.chooseMethod": "Anmeldemethode wählen", "kiroLogin.cli": "Mit Kiro CLI anmelden", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 11db36231fa..cd0bf7c4953 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,7 @@ * `{var}` are plain interpolations. */ export const en = { + "pws.anthropicAccountThresholdHint": "Overrides the Claude pool default. 0 disables usage-based switching only for this account; pause and rate-limit recovery still apply.", "kiroLogin.title": "Sign in to Kiro", "kiroLogin.chooseMethod": "Choose a sign-in method", "kiroLogin.cli": "Kiro CLI", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index fdc2b1be7de..b146c51151c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,7 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "pws.anthropicAccountThresholdHint": "Remplace le seuil par défaut du pool Claude. 0 désactive le basculement selon l’utilisation uniquement pour ce compte ; la pause et la reprise après limitation restent actives.", "kiroLogin.title": "Se connecter à Kiro", "kiroLogin.chooseMethod": "Choisir une méthode de connexion", "kiroLogin.cli": "Importer avec Kiro CLI", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 256be8bae52..479576d4484 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,7 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "pws.anthropicAccountThresholdHint": "Claude プールの既定値を上書きします。0 はこのアカウントだけで使用量による切り替えを無効にします。一時停止とレート制限からの復旧は引き続き適用されます。", "kiroLogin.title": "Kiro にログイン", "kiroLogin.chooseMethod": "ログイン方法を選択", "kiroLogin.cli": "Kiro CLI から取り込む", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 843d22de50e..55ad082da30 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,7 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "pws.anthropicAccountThresholdHint": "Claude 풀 기본값을 재정의합니다. 0은 이 계정의 사용량 기반 전환만 끄며, 일시정지와 요청 제한 복구는 계속 적용됩니다.", "kiroLogin.title": "Kiro에 로그인", "kiroLogin.chooseMethod": "로그인 방법 선택", "kiroLogin.cli": "Kiro CLI에서 가져오기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f9e134bb6aa..545641e5a71 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,7 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "pws.anthropicAccountThresholdHint": "Переопределяет порог пула Claude для этого аккаунта. 0 отключает переключение по использованию только для этого аккаунта; пауза и восстановление после 429 продолжают работать.", "kiroLogin.title": "Войти в Kiro", "kiroLogin.chooseMethod": "Выберите способ входа", "kiroLogin.cli": "Импортировать через Kiro CLI", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 70f22b95bee..6de30bf8766 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,7 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "pws.anthropicAccountThresholdHint": "Claude havuzunun varsayılan eşiğini geçersiz kılar. 0, yalnızca bu hesap için kullanıma dayalı geçişi kapatır; duraklatma ve hız sınırı kurtarması geçerliliğini korur.", "kiroLogin.title": "Kiro oturumu aç", "kiroLogin.chooseMethod": "Oturum açma yöntemi seç", "kiroLogin.cli": "Kiro CLI ile içe aktar", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 59ec009d37d..23a799cd258 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -6,6 +6,7 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const vi: Record = { + "pws.anthropicAccountThresholdHint": "Ghi đè ngưỡng mặc định của nhóm Claude. 0 chỉ tắt chuyển đổi dựa trên mức sử dụng của tài khoản này; tạm dừng và khôi phục khi bị giới hạn vẫn áp dụng.", "kiroLogin.title": "Đăng nhập Kiro", "kiroLogin.chooseMethod": "Chọn cách đăng nhập", "kiroLogin.cli": "Nhập từ Kiro CLI", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 6be095cb4a4..b8b8786e243 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,7 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "pws.anthropicAccountThresholdHint": "覆寫 Claude 集區預設門檻。0 僅停用此帳戶的依用量切換;暫停和速率限制復原仍然適用。", "kiroLogin.title": "登入 Kiro", "kiroLogin.chooseMethod": "選擇登入方式", "kiroLogin.cli": "從 Kiro CLI 匯入", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 446b16bb0d0..c93b513d65a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,7 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "pws.anthropicAccountThresholdHint": "覆盖 Claude 池默认阈值。0 仅禁用此账户的按用量切换;暂停和速率限制恢复仍然生效。", "kiroLogin.title": "登录 Kiro", "kiroLogin.chooseMethod": "选择登录方式", "kiroLogin.cli": "从 Kiro CLI 导入", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index da2c7787f9e..f45544e0376 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -386,7 +386,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const { accountSets, setAccountSets, accountLoadStates, switchingAccount, pausingAccount, keyPools, fetchAccountSets, fetchKeyPools, refreshAccountRosters, oauthCardProviders, keyCardProviders, - switchAccount, pauseAccount, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias, + switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias, removeAccount, activeAccountNeedsReauth, } = pools; const refreshSelection = useCallback((target?: AccountSelectionTarget) => { @@ -659,6 +659,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { onReauth: (provider, accountId) => requestLoginOAuth(provider, true, accountId), onSwitchAccount: switchAccount, onPauseAccount: pauseAccount, + onAccountPoolThreshold: setAccountPoolThreshold, + onAccountThreshold: setAccountThreshold, onRemoveAccount: removeAccount, onRetryAccounts: async provider => { await fetchAccountSets([provider]); }, onAddApiKey: addApiKeyValue, diff --git a/gui/tests/anthropic-pool-quota-window.test.tsx b/gui/tests/anthropic-pool-quota-window.test.tsx index c8b2a86c8fc..c50ae3506de 100644 --- a/gui/tests/anthropic-pool-quota-window.test.tsx +++ b/gui/tests/anthropic-pool-quota-window.test.tsx @@ -79,7 +79,7 @@ function stubPool(initial: PoolPayload): Record[] { return puts; } -async function mountPool(): Promise { +async function mountPool(onThresholdChange?: (threshold: number) => void): Promise { const host = testWindow.document.createElement("div"); testWindow.document.body.appendChild(host as never); const { createRoot } = await import("react-dom/client"); @@ -88,7 +88,7 @@ async function mountPool(): Promise { mountedRoots.push(root); root.render( - + , ); }); @@ -120,6 +120,43 @@ afterEach(async () => { }); describe("Anthropic account pool quota window", () => { + test("only confirmed pool defaults seed account override controls", async () => { + let fail = false; + globalThis.fetch = (async (_input, init) => init?.method === "PUT" + ? fail ? new Response(null, { status: 500 }) : Response.json({ enabled: false, autoSwitchThreshold: 73 }) + : Response.json({ enabled: true, autoSwitchThreshold: 64, strategy: "quota", stickyLimit: 1, quotaWindow: "five-hour" })) as typeof fetch; + const values: number[] = []; + const host = await mountPool(value => { values.push(value); }); + expect(values).toEqual([64]); + const toggle = host.querySelector('button[aria-pressed]') as HTMLButtonElement; + await act(async () => { toggle.click(); await flush(); }); + expect(values).toEqual([64, 73]); + fail = true; + await act(async () => { toggle.click(); await flush(); }); + expect(values).toEqual([64, 73]); + }); + + test("an unmounted settings card aborts its save without publishing the old server value", async () => { + let aborted = false; + globalThis.fetch = (async (_input, init) => { + if (init?.method !== "PUT") return Response.json({ enabled: true, autoSwitchThreshold: 64, strategy: "quota", stickyLimit: 1, quotaWindow: "five-hour" }); + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + aborted = true; + reject(new Error("aborted")); + }, { once: true }); + }); + }) as typeof fetch; + const values: number[] = []; + const host = await mountPool(value => { values.push(value); }); + const toggle = host.querySelector('button[aria-pressed]') as HTMLButtonElement; + await act(async () => { toggle.click(); await Promise.resolve(); }); + const root = mountedRoots.pop(); + await act(async () => { root?.unmount(); await Promise.resolve(); }); + expect(aborted).toBe(true); + expect(values).toEqual([64]); + }); + test("quota window selector renders for quota and fill-first strategies", async () => { stubPool({ enabled: true, diff --git a/gui/tests/provider-account-pause-refresh.test.tsx b/gui/tests/provider-account-pause-refresh.test.tsx index 3bcdf9cee69..9580be1e6d0 100644 --- a/gui/tests/provider-account-pause-refresh.test.tsx +++ b/gui/tests/provider-account-pause-refresh.test.tsx @@ -54,6 +54,92 @@ afterEach(async () => { } }); +test("confirmed threshold persists in UI when follow-up read fails; failed writes preserve prior state", async () => { + let fail = false; const bodies: unknown[] = []; + respond = async (_url, init) => { + if (init?.method !== "PUT") return new Response(null, { status: 503 }); + bodies.push(JSON.parse(String(init.body))); + return fail ? new Response(null, { status: 500 }) : Response.json({ autoSwitchThresholdOverride: 0, autoSwitchThreshold: 70, effectiveAutoSwitchThreshold: 0 }); + }; + await act(async () => { expect(await pools.setAccountThreshold("fixture", row("b", false), 0)).toBe(true); }); + expect(pools.accountSets.fixture.accounts[1]?.autoSwitchThresholdOverride).toBe(0); + expect(bodies[0]).toEqual({ provider: "fixture", accountId: "b", threshold: 0 }); + fail = true; + await act(async () => { expect(await pools.setAccountThreshold("fixture", row("b", false), null)).toBe(false); }); + expect(pools.accountSets.fixture.accounts[1]?.autoSwitchThresholdOverride).toBe(0); + expect(notices.some(notice => notice.key === "accountPool.autoSwitchUpdateFailed")).toBe(true); +}); + +test("pending threshold owns its roster generation and blocks conflicting pause", async () => { + let settle!: (response: Response) => void; let writes = 0; + respond = async (_url, init) => { + if (init?.method !== "PUT") return new Response(null, { status: 503 }); + writes++; return new Promise(resolve => { settle = resolve; }); + }; + let pending!: Promise; + await act(async () => { pending = pools.setAccountThreshold("fixture", row("b", false), 40); }); + await act(async () => { await pools.pauseAccount("fixture", row("b", false), true); }); + expect(writes).toBe(1); + await act(async () => { settle(Response.json({ autoSwitchThresholdOverride: 40, autoSwitchThreshold: 70, effectiveAutoSwitchThreshold: 40 })); await pending; }); + expect(pools.accountSets.fixture.accounts[1]?.autoSwitchThresholdOverride).toBe(40); +}); + +test("a confirmed pool threshold invalidates an older roster while later external changes still win", async () => { + let settleStale!: (response: Response) => void; + let reads = 0; + const urls: string[] = []; + respond = async (url, init) => { + if (init?.method === "PUT") return new Response(null, { status: 500 }); + urls.push(url); + reads++; + if (reads === 1) return new Promise(resolve => { settleStale = resolve; }); + const threshold = reads <= 3 ? 70 : 55; + return Response.json({ activeAccountId: "a", accounts: [ + { ...row("a", true), quotaMode: "probe", autoSwitchThresholdOverride: null, autoSwitchThreshold: threshold, effectiveAutoSwitchThreshold: threshold }, + { ...row("b", false), quotaMode: "probe", autoSwitchThresholdOverride: 40, autoSwitchThreshold: threshold, effectiveAutoSwitchThreshold: 40 }, + ] }); + }; + + let stale!: Promise; + await act(async () => { stale = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + await act(async () => { expect(await pools.setAccountPoolThreshold("fixture", 70)).toBe(true); }); + await act(async () => { await Promise.resolve(); }); + expect(urls.some(url => url.includes("quota=1"))).toBe(true); + expect(pools.accountSets.fixture.accounts[0]?.autoSwitchThreshold).toBe(70); + expect(pools.accountSets.fixture.accounts[1]?.effectiveAutoSwitchThreshold).toBe(40); + + await act(async () => { + settleStale(Response.json({ activeAccountId: "a", accounts: [ + { ...row("a", true), quotaMode: "probe", autoSwitchThresholdOverride: null, autoSwitchThreshold: 65, effectiveAutoSwitchThreshold: 65 }, + { ...row("b", false), quotaMode: "probe", autoSwitchThresholdOverride: 40, autoSwitchThreshold: 65, effectiveAutoSwitchThreshold: 40 }, + ] })); + await stale; + }); + expect(pools.accountSets.fixture.accounts[0]?.autoSwitchThreshold).toBe(70); + + await act(async () => { expect(await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" })).toBe(true); }); + expect(pools.accountSets.fixture.accounts[0]?.autoSwitchThreshold).toBe(55); +}); + +test("a stalled account threshold write is aborted when the hook unmounts", async () => { + let aborted = false; + respond = async (_url, init) => new Promise((_resolve, reject) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + init?.signal?.addEventListener("abort", () => { + aborted = true; + reject(new Error("aborted")); + }, { once: true }); + }); + let pending!: Promise; + await act(async () => { + pending = pools.setAccountThreshold("fixture", row("b", false), 40); + await Promise.resolve(); + }); + await act(async () => { root?.unmount(); root = null; }); + expect(await pending).toBe(false); + expect(aborted).toBe(true); +}); + test("a saved pause stays visible and only the failed roster refresh is reported", async () => { respond = async (_url, init) => init?.method === "PUT" ? Response.json({ ok: true, activeAccountId: "a", activeAccountChanged: false }) @@ -86,4 +172,3 @@ test("a rejected save reports the pause failure and leaves the row unpaused", as expect(pools.accountSets.fixture.accounts.find(account => account.id === "b")?.paused).toBe(false); expect(notices).toEqual([{ key: "codexAuth.pauseFailed", ok: false }]); }); - diff --git a/gui/tests/provider-quota-refresh-controls.test.tsx b/gui/tests/provider-quota-refresh-controls.test.tsx index 2c1d868550a..0bab2820572 100644 --- a/gui/tests/provider-quota-refresh-controls.test.tsx +++ b/gui/tests/provider-quota-refresh-controls.test.tsx @@ -92,6 +92,23 @@ test("the usage tab reports the real outcome, not the click", async () => { expect(host.textContent).toContain("Quota check completed"); }); +test("Anthropic account threshold editor uses its pool default, preserves zero, and resets with null", async () => { + const calls: Array = []; + const item = { ...oauthItem, name: "anthropic", adapter: "anthropic" }; + const handlers = authHandlers({ onAccountThreshold: async (_provider, _account, value) => { calls.push(value); return true; } }); + const row = { id: "threshold-account", active: true, autoSwitchThresholdOverride: null, autoSwitchThreshold: 65 }; + await render(); + const toggle = () => host.querySelector('[aria-label^="Override global usage threshold"]') as HTMLButtonElement; + expect(toggle()).not.toBeNull(); + await act(async () => { toggle().click(); }); expect(calls).toEqual([65]); + await render(); + const input = host.querySelector('#anthropic-threshold-threshold-account') as HTMLInputElement; + expect(input.value).toBe("0"); + await act(async () => { toggle().click(); }); expect(calls).toEqual([65, null]); + await render(); + expect(toggle()).toBeNull(); // Old servers do not acquire a synthetic capability. +}); + test("a failed read is reported as a failure", async () => { const { handler, settle } = deferredHandler(); await render(); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c0898f6e213..b955f7fffe4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -102,7 +102,9 @@ "anthropic-account-pool.test.ts": "adapters/anthropic", "anthropic-account-pause-outbound.test.ts": "adapters/anthropic", "anthropic-account-pause.test.ts": "adapters/anthropic", + "anthropic-account-threshold.test.ts": "adapters/anthropic", "anthropic-combo-account-cooldown.test.ts": "adapters/anthropic", + "cli-anthropic-account-threshold.test.ts": "cli", "anthropic-model-routes.test.ts": "adapters/anthropic", "anthropic-agentrouter-language-framing.test.ts": "adapters/anthropic", "anthropic-baseurl-override.test.ts": "adapters/anthropic", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 110b56be1e9..15c52cd6c71 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -919,15 +919,19 @@ Show or set the usage percentage at which a pool moves to another account. | PUT | `/api/codex-auth/auto-switch` | | GET | `/api/oauth/accounts/pool` | | PUT | `/api/oauth/accounts/pool` | +| GET | `/api/oauth/accounts` | +| PUT | `/api/oauth/accounts/auto-switch` | | Flag | Value | Meaning | |---|---|---| | `--json` | boolean | Emit the stored threshold and whether it is applied. | +| `--account` | string | Anthropic account ID; inherit restores the pool default, off stores zero. | JSON mode: `envelope`. - A bare invocation reads and never writes. - `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0-100. +- Anthropic requires --account ; inherit sends null to restore its pool default. Manual/affinity precedence and pool-off recovery are unchanged. - For a generic OAuth pool, `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability. ### `ocx storage cleanup` diff --git a/src/cli/account-anthropic-threshold.ts b/src/cli/account-anthropic-threshold.ts new file mode 100644 index 00000000000..31b7176ac67 --- /dev/null +++ b/src/cli/account-anthropic-threshold.ts @@ -0,0 +1,41 @@ +import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api"; + +/** A separate account selector prevents accidentally changing the whole Anthropic pool. */ +export async function cmdAnthropicAccountThreshold(args: string[], action: string, wantsJson: boolean, deps: AccountDeps): Promise { + const selector = args.indexOf("--account"); + const accountId = selector >= 0 ? args[selector + 1] : undefined; + if (selector >= 0) args.splice(selector, 2); + let threshold: number | null | undefined; + if (action === "inherit" && args.length === 0) threshold = null; + else if (action === "off" && args.length === 0) threshold = 0; + else if (action === "on" && args.length === 0) threshold = 80; + else if (action === "threshold" && args.length === 1 && /^\d+$/.test(args[0]!)) threshold = Number(args[0]); + else if (action !== "status" || args.length !== 0) return invalid(); + if (!accountId?.trim() || accountId.startsWith("--") || (threshold !== undefined && threshold !== null && threshold > 100)) return invalid(); + const base = await resolveBaseUrl(deps); + if (!base) return proxyUnreachable(); + const response = action === "status" + ? await apiJson(deps, base, "GET", "/api/oauth/accounts?provider=anthropic") + : await apiJson(deps, base, "PUT", "/api/oauth/accounts/auto-switch", { provider: "anthropic", accountId, threshold }); + if (response.status === 0) return proxyUnreachable(response.transportError); + if (response.status !== 200) return apiError(response.json, "failed to update account threshold", response.status); + if (!response.json || typeof response.json !== "object" || Array.isArray(response.json)) return apiError({}, "invalid account threshold response", 400); + const result = action === "status" + ? (Array.isArray(response.json.accounts) ? response.json.accounts : []).find((row: { id?: string } | null) => row?.id === accountId) + : response.json; + if (!result || typeof result !== "object") return apiError({}, "account not found", 404); + if (!Object.hasOwn(result, "autoSwitchThresholdOverride")) return apiError({}, "proxy does not support Anthropic account thresholds; upgrade and restart it", 400); + const validPercent = (value: unknown) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100; + if ((result.autoSwitchThresholdOverride !== null && !validPercent(result.autoSwitchThresholdOverride)) + || !validPercent(result.effectiveAutoSwitchThreshold)) return apiError({}, "invalid account threshold response", 400); + const payload = { provider: "anthropic", accountId, autoSwitchThresholdOverride: result.autoSwitchThresholdOverride, + effectiveAutoSwitchThreshold: result.effectiveAutoSwitchThreshold }; + if (wantsJson) console.log(JSON.stringify(payload, null, 2)); + else console.log(`auto-switch: ${payload.autoSwitchThresholdOverride === null ? "inherited" : "custom"} (${payload.effectiveAutoSwitchThreshold === 0 ? "usage-based switching disabled" : `${payload.effectiveAutoSwitchThreshold}%`})`); + return 0; +} + +function invalid(): number { + console.error("Usage: ocx account auto-switch anthropic > --account [--json]"); + return 2; +} diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 43199c43a23..6ad19b3105a 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -347,6 +347,7 @@ interface OAuthAccountDto { needsReauth?: boolean; /** Present only for providers that support operator pause (generic OAuth pools). */ paused?: boolean; + autoSwitchThresholdOverride?: number | null; autoSelectable?: boolean; skipReason?: unknown; /** Always sent by the management route; explicitly `null` when the tier is unknown. */ @@ -388,6 +389,7 @@ async function fetchOAuthRows( active: a.active ?? a.id === activeId, needsReauth: a.needsReauth, ...(a.paused === true ? { paused: true } : {}), + ...(name === "anthropic" && Object.hasOwn(a, "autoSwitchThresholdOverride") ? { autoSwitchThresholdOverride: a.autoSwitchThresholdOverride } : {}), ...(name === "kiro" && typeof a.autoSelectable === "boolean" ? { autoSelectable: a.autoSelectable } : {}), ...(name === "kiro" && a.autoSelectable === false && isKiroSkipReason(a.skipReason) diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 5260b979a99..bc61bfb3483 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -1,4 +1,5 @@ import { loadConfig } from "../config"; +import { cmdAnthropicAccountThreshold } from "./account-anthropic-threshold"; import { isReservedCodexAccountWord, reportCodexAccountTargetError, resolveCodexAccountTarget } from "./account-target"; import { hasPassiveAccountQuota } from "../providers/quota"; import { closeSync, openSync, readSync, readFileSync, statSync } from "node:fs"; @@ -40,6 +41,7 @@ const AUTO_NOTE = "auto (no pin — lowest-usage account is selected per request const EXTENDED_USAGE = `Usage: ocx account refresh [--json] ocx account auto-switch > [--json] + ocx account auto-switch anthropic > --account [--json] ocx account alias [--json] ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json] ocx account pause [--json] @@ -358,6 +360,7 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< const classified = configAndType(deps, name); // Anthropic keeps its threshold on its own pool contract; generic OAuth providers (#695) // and the Codex pool are accepted here. + if (!("error" in classified) && classified.type === "oauth" && name === "anthropic") return cmdAnthropicAccountThreshold(args, action, wantsJson, deps); if ("error" in classified || classified.type === "api-key" || name === "anthropic") { return usage("Error: auto-switch only applies to the openai Codex account pool or a generic OAuth provider pool"); } diff --git a/src/cli/account.ts b/src/cli/account.ts index ab9a2089f7d..c702bd48fbb 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -49,6 +49,7 @@ const ACCOUNT_USAGE = `Usage: ocx account clear [--json] ocx account refresh [--json] ocx account auto-switch > [--json] + ocx account auto-switch anthropic > --account [--json] ocx account alias [--json] ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json] ocx account pause [--json] diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index c64edc31d30..484454a802f 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -636,13 +636,17 @@ export const CAPABILITIES: readonly Capability[] = [ { method: "PUT", path: "/api/codex-auth/auto-switch" }, { method: "GET", path: "/api/oauth/accounts/pool" }, { method: "PUT", path: "/api/oauth/accounts/pool" }, + { method: "GET", path: "/api/oauth/accounts" }, + { method: "PUT", path: "/api/oauth/accounts/auto-switch" }, ], - flags: [{ name: "--json", value: "boolean", summary: "Emit the stored threshold and whether it is applied." }], + flags: [{ name: "--json", value: "boolean", summary: "Emit the stored threshold and whether it is applied." }, + { name: "--account", value: "string", summary: "Anthropic account ID; inherit restores the pool default, off stores zero." }], mutates: true, json: "envelope", details: [ "A bare invocation reads and never writes.", "`on` stores 80%, `off` stores 0%, and `threshold ` accepts 0-100.", + "Anthropic requires --account ; inherit sends null to restore its pool default. Manual/affinity precedence and pool-off recovery are unchanged.", "For a generic OAuth pool, `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability.", ], }, diff --git a/src/lib/account-selection-events.ts b/src/lib/account-selection-events.ts index c3381b91060..0272fb3ebe4 100644 --- a/src/lib/account-selection-events.ts +++ b/src/lib/account-selection-events.ts @@ -5,8 +5,20 @@ export type AccountSelectionEvent = { revision: number; }; +export type OAuthAccountSelectionSnapshot = Readonly<{ + accountId: string; + revision?: string; +}>; + +export type OAuthAccountRoutingPolicyChange = Readonly<{ + provider: string; + before: OAuthAccountSelectionSnapshot; + after: OAuthAccountSelectionSnapshot; +}>; + const listeners = new Set<(event: AccountSelectionEvent) => void>(); const oauthPauseListeners = new Set<(provider: string) => void>(); +const oauthRoutingPolicyListeners = new Set<(event: OAuthAccountRoutingPolicyChange) => void>(); let revision = 0; /** Call only after the authoritative selection has been persisted. */ @@ -41,6 +53,22 @@ export function subscribeOAuthAccountPauseChanges(listener: (provider: string) = return () => { oauthPauseListeners.delete(subscription); }; } +/** + * Internal post-persistence signal for policy-only mutations that advance the + * selection generation without changing the operator's selected account. + */ +export function publishOAuthAccountRoutingPolicyChange(event: OAuthAccountRoutingPolicyChange): void { + for (const listener of [...oauthRoutingPolicyListeners]) { + try { listener(event); } catch { /* A process-local policy observer cannot undo persistence. */ } + } +} + +export function subscribeOAuthAccountRoutingPolicyChanges(listener: (event: OAuthAccountRoutingPolicyChange) => void): () => void { + const subscription = (event: OAuthAccountRoutingPolicyChange) => listener(event); + oauthRoutingPolicyListeners.add(subscription); + return () => { oauthRoutingPolicyListeners.delete(subscription); }; +} + export function currentAccountSelectionRevision(): number { return revision; } diff --git a/src/oauth/anthropic-account-threshold.ts b/src/oauth/anthropic-account-threshold.ts new file mode 100644 index 00000000000..5aaf5c05078 --- /dev/null +++ b/src/oauth/anthropic-account-threshold.ts @@ -0,0 +1,13 @@ +import type { OcxConfig } from "../types"; +import type { ProviderAccount } from "./types"; + +/** Shared strict boundary for persisted rows and management writes. Zero is not exhaustion. */ +export function parseAnthropicAccountThreshold(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100 ? value : null; +} + +/** Missing or malformed legacy metadata inherits; concrete zero must survive nullish fallback. */ +export function effectiveAnthropicAccountThreshold(config: OcxConfig, account?: Pick): number { + return parseAnthropicAccountThreshold(account?.autoSwitchThresholdOverride) + ?? parseAnthropicAccountThreshold(config.anthropicAccountPool?.autoSwitchThreshold) ?? 80; +} diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index e0a0db6970a..050d51a6097 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -34,7 +34,8 @@ import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConf import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { retainedUtf8Bytes } from "../lib/admission"; import { routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes"; -import { subscribeOAuthAccountPauseChanges } from "../lib/account-selection-events"; +import { subscribeAccountSelections, subscribeOAuthAccountPauseChanges, subscribeOAuthAccountRoutingPolicyChanges } from "../lib/account-selection-events"; +import { effectiveAnthropicAccountThreshold } from "./anthropic-account-threshold"; /** * The read side of a `Headers` object, so a caller can pass the live upstream response's @@ -116,6 +117,11 @@ export function anthropicAutoSwitchThreshold(config: OcxConfig): number { return DEFAULT_AUTO_SWITCH_THRESHOLD; } +/** Read live policy at selection, not a credential snapshot captured before an await. */ +export function anthropicAccountAutoSwitchThreshold(config: OcxConfig, accountId: string): number { + return effectiveAnthropicAccountThreshold(config, getAccountSet(PROVIDER)?.accounts.find(row => row.id === accountId)); +} + /** Strict parse for management APIs — returns null instead of defaulting. */ export function parseAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow | null { if (typeof raw === "string" && VALID_QUOTA_WINDOWS.has(raw as OcxAccountPoolQuotaWindow)) { @@ -314,6 +320,23 @@ const QUORUM_CACHE_TTL_MS = 2_000; let quorumCache: { value: boolean; readAt: number } | null = null; // Pause changes eligibility, not health. Do not reset cooldowns or cancel sent turns. subscribeOAuthAccountPauseChanges(provider => { if (provider === PROVIDER) quorumCache = null; }); +// A threshold write must fence in-flight automatic proposals, but it does not +// revoke an operator's one-dispatch choice. Rebase only that still-owned choice; +// an intervening account change clears it, so an ABA selection is not resurrected. +subscribeOAuthAccountRoutingPolicyChanges(event => { + if (event.provider !== PROVIDER || !manualPreference) return; + if (manualPreference.accountId !== event.before.accountId + || manualPreference.revision !== event.before.revision) return; + manualPreference = event.after.accountId === manualPreference.accountId ? { ...event.after } : null; +}); +// Any non-policy selection generation supersedes the pending one-shot choice. +// Threshold mutations rebase it first, before this generic notification runs. +subscribeAccountSelections(event => { + if (event.provider !== PROVIDER || event.kind !== "oauth" || !manualPreference) return; + const current = captureOAuthAccountSelection(PROVIDER); + if (current?.accountId !== manualPreference.accountId + || current.revision !== manualPreference.revision) manualPreference = null; +}); /** * Whether a 429 has somewhere to go: two or more accounts that could serve traffic if asked. @@ -401,8 +424,15 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number { function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null): string | null { const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId); - const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)) : unfiltered; - const eligible = available.length > 0 ? available : unfiltered; + const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id) + || isAnthropicAccountPoolEnabled(config) && anthropicAccountAutoSwitchThreshold(config, id) === 0) : unfiltered; + const availableOrFallback = available.length > 0 ? available : unfiltered; + // Thresholds are preferences, never eligibility. Keep the old lowest-usage fallback + // when every candidate is drained, and keep pool-off reactive recovery policy inert. + const hasKnownUnderThreshold = isAnthropicAccountPoolEnabled(config) + && availableOrFallback.some(id => hasKnownUsage(config, id) && isActiveUnderFillFirstThreshold(config, id)); + const eligible = hasKnownUnderThreshold + ? availableOrFallback.filter(id => isActiveUnderFillFirstThreshold(config, id)) : availableOrFallback; if (eligible.length === 0) return null; const scored: ScoredAccount[] = eligible.map(accountId => ({ accountId, @@ -435,7 +465,8 @@ function pickNextFillFirstAnthropicAccount( decision: AnthropicRouteDecision | null, ): string | null { const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); - const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)) : eligible; + const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id) + || anthropicAccountAutoSwitchThreshold(config, id) === 0) : eligible; const candidates = available.length > 0 ? available : eligible; if (candidates.length === 0) return null; const routeOrder = usesDeclaredRouteOrder(eligible, decision); @@ -517,7 +548,7 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg } function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean { - const threshold = anthropicAutoSwitchThreshold(config); + const threshold = anthropicAccountAutoSwitchThreshold(config, accountId); if (threshold <= 0) return true; const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); if (window === "weekly" && exhausted5h(accountId)) return false; @@ -676,7 +707,7 @@ export function resolveAnthropicAccountForSession( return { accountId: strategyPick.accountId, reason: strategyPick.reason, routePosition: decision?.position }; } - const threshold = anthropicAutoSwitchThreshold(config); + const threshold = anthropicAccountAutoSwitchThreshold(config, set.activeAccountId); const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true) && !isCooled(set.activeAccountId, now) && eligible.includes(set.activeAccountId); diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 7d085c96034..0d5233f9124 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -27,13 +27,14 @@ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget"; -import { publishAccountSelection, publishOAuthAccountPauseChange } from "../lib/account-selection-events"; +import { publishAccountSelection, publishOAuthAccountPauseChange, publishOAuthAccountRoutingPolicyChange } from "../lib/account-selection-events"; import { captureConfigGeneration, type GenerationContext, } from "../lib/state-store-sweeper"; import { validateCopilotApiBaseUrl } from "./github-copilot"; import { validateDevinApiBaseUrl } from "./devin/api-base"; +import { parseAnthropicAccountThreshold } from "./anthropic-account-threshold"; import type { OAuthAccountSelection, OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types"; export type AuthStore = Record; @@ -626,6 +627,8 @@ function normalizeAccount(value: unknown): ProviderAccount | null { if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim(); if (candidate.needsReauth === true) account.needsReauth = true; if (candidate.paused === true) account.paused = true; + const threshold = parseAnthropicAccountThreshold(candidate.autoSwitchThresholdOverride); + if (threshold !== null) account.autoSwitchThresholdOverride = threshold; if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt; if (typeof candidate.loginId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.loginId)) { @@ -783,7 +786,7 @@ function serializeMutation(work: () => Promise, retainedValues: readonly u drainOAuthMutations(); return result; } -export function mutateStore(fn:(store:AuthStore)=>T|Promise, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void }):Promise{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{ +export function mutateStore(fn:(store:AuthStore)=>T|Promise, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void }):Promise{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{ const { store, hadLegacy } = loadAuthStoreInternal(); if (hadLegacy) backupLegacyOnce(); const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, { @@ -821,6 +824,9 @@ export function mutateStore(fn:(store:AuthStore)=>T|Promise, retainedValue options?.finalizeResult?.(result, store); persist(store); if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders); + // A committed observer may establish ordering before the generic selection + // publication, but its failure can never turn a durable write into a reported failure. + try { options?.afterPersist?.(result); } catch { /* The authoritative write already committed. */ } for (const provider of changedProviders) publishAccountSelection(provider, "oauth"); return result; }finally{guard.release();}}, retainedValues, options?.waitMs); @@ -1224,6 +1230,39 @@ export type SetAccountPausedResult = | { status: "unchanged"; activeAccountId: string; activeAccountChanged: boolean } | { status: "not-found" }; +/** Serialize policy with refresh/removal; stale pre-wait selection proposals must retry. */ +export async function setAnthropicAccountThreshold( + accountId: string, + threshold: number | null, + options: { assertBeforePersist?: () => void } = {}, +): Promise { + const normalizedThreshold = threshold === null ? null : parseAnthropicAccountThreshold(threshold); + if (threshold !== null && normalizedThreshold === null) { + throw new Error("threshold must be an integer 0-100 or null"); + } + const result = await mutateStore(store => { + const set = store.anthropic; + const account = set?.accounts.find(row => row.id === accountId); + if (!set || !account) return { status: "not-found" as const }; + if ((account.autoSwitchThresholdOverride ?? null) === normalizedThreshold) return { status: "unchanged" as const }; + const before = accountSelection(set); + if (normalizedThreshold === null) delete account.autoSwitchThresholdOverride; + else account.autoSwitchThresholdOverride = normalizedThreshold; + set.selectionRevision = randomUUID(); + return { status: "updated" as const, before, after: accountSelection(set) }; + }, [accountId, normalizedThreshold], { assertBeforePersist: options.assertBeforePersist, afterPersist: result => { + if (result.status !== "updated") return; + // Publish the policy-owned transition before the generic selection event. This + // preserves an exact previous-revision manual intent without opening an ABA gap. + publishOAuthAccountRoutingPolicyChange(Object.freeze({ + provider: "anthropic", + before: Object.freeze(result.before), + after: Object.freeze(result.after), + })); + } }); + return result.status !== "not-found"; +} + /** Persist an operator pause and move an active account to the next usable unpaused slot when available. */ export async function setAccountPaused( provider: string, @@ -1323,6 +1362,7 @@ export async function replaceProviderAccountSet( ...(account.alias ? { alias: account.alias } : {}), ...(account.needsReauth ? { needsReauth: true } : {}), ...(account.paused ? { paused: true } : {}), + ...(account.autoSwitchThresholdOverride !== undefined ? { autoSwitchThresholdOverride: account.autoSwitchThresholdOverride } : {}), ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}), ...(account.loginId ? { loginId: account.loginId } : {}), })), diff --git a/src/oauth/types.ts b/src/oauth/types.ts index 73af11f0a63..f53139bd595 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -90,6 +90,8 @@ export interface ProviderAccount { needsReauth?: boolean; /** Operator exclusion from generic OAuth account selection until explicitly resumed. */ paused?: boolean; + /** Anthropic-only usage-switch override; absent inherits its pool default, zero disables it. */ + autoSwitchThresholdOverride?: number; addedAt?: number; } diff --git a/src/server/management/anthropic-account-threshold.ts b/src/server/management/anthropic-account-threshold.ts new file mode 100644 index 00000000000..4c0e5a368c8 --- /dev/null +++ b/src/server/management/anthropic-account-threshold.ts @@ -0,0 +1,27 @@ +import type { OcxConfig } from "../../types"; +import { effectiveAnthropicAccountThreshold, parseAnthropicAccountThreshold } from "../../oauth/anthropic-account-threshold"; +import { OAUTH_PROVIDERS } from "../../oauth"; +import { setAnthropicAccountThreshold } from "../../oauth/store"; +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBodyOr } from "./body"; + +/** Account-owned policy writes share the auth-store lock, not a config/auth split transaction. */ +export async function handleAnthropicAccountThreshold(req: Request, config: OcxConfig): Promise { + const body = await readManagementJsonBodyOr(req, {}); + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "body must be an object" }, 400); + const fields = body as Record; + const definition = OAUTH_PROVIDERS.anthropic; + const effectiveProvider = config.providers.anthropic + ?? definition?.resolveProviderConfig?.(config) ?? definition?.providerConfig; + if (fields.provider !== "anthropic" || effectiveProvider?.authMode !== "oauth") { + return jsonResponse({ error: "account threshold requires Anthropic OAuth" }, 400); + } + if (typeof fields.accountId !== "string" || !fields.accountId.trim()) return jsonResponse({ error: "missing accountId" }, 400); + const threshold = parseAnthropicAccountThreshold(fields.threshold); + if (fields.threshold !== null && threshold === null) return jsonResponse({ error: "threshold must be an integer 0-100 or null" }, 400); + if (!await setAnthropicAccountThreshold(fields.accountId, threshold)) return jsonResponse({ error: "account not found" }, 404); + return jsonResponse({ ok: true, provider: "anthropic", accountId: fields.accountId, + autoSwitchThresholdOverride: threshold, + effectiveAutoSwitchThreshold: effectiveAnthropicAccountThreshold(config, { autoSwitchThresholdOverride: threshold ?? undefined }), + autoSwitchThreshold: effectiveAnthropicAccountThreshold(config) }); +} diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 836105e1817..f2ae4aef429 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -1,4 +1,6 @@ import { parseAnthropicModelRoutes, readAnthropicModelRoutes } from "../../oauth/anthropic-model-routes"; +import { effectiveAnthropicAccountThreshold } from "../../oauth/anthropic-account-threshold"; +import { handleAnthropicAccountThreshold } from "./anthropic-account-threshold"; import { randomBytes, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; @@ -418,6 +420,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< }); return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode, ...(supportsPause ? { paused: full?.paused === true } : {}), + ...(provider === "anthropic" && supportsPause ? { autoSwitchThresholdOverride: full?.autoSwitchThresholdOverride ?? null, + effectiveAutoSwitchThreshold: effectiveAnthropicAccountThreshold(config, full), + autoSwitchThreshold: effectiveAnthropicAccountThreshold(config) } : {}), ...(provider === "kiro" && full ? kiroAutoSelection(full) : {}) }; }), }; @@ -493,6 +498,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); } + if (url.pathname === "/api/oauth/accounts/auto-switch" && req.method === "PUT") return handleAnthropicAccountThreshold(req, config); if (url.pathname === "/api/oauth/accounts/pause" && req.method === "PUT") { const body = await readManagementJsonBodyOr(req, {}); if (!isPlainRecord(body)) return jsonResponse({ error: "body must be an object" }, 400); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index a59319dd1bb..f41692503fe 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -323,6 +323,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PUT", path: "/api/oauth/accounts/active", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/oauth/accounts/alias", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/oauth/accounts/pause", module: "server/management/oauth-account-routes", mutates: true }, + { method: "PUT", path: "/api/oauth/accounts/auto-switch", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/providers/keys/active", module: "server/management/oauth-account-routes", mutates: true }, { method: "PUT", path: "/api/providers/keys/alias", module: "server/management/oauth-account-routes", mutates: true }, diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index c340c0612a9..ebabd087625 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -193,9 +193,13 @@ export async function prepareResponsesTransport( // Resolve that choice, not the rejected candidate, before trying admission again. oauthSelection = captureOAuthAccountSelection(route.providerName); if (!oauthSelection) return null; - const revisedAnthropicId = route.providerName === "anthropic" && anthropicRouteDecision - ? resolveAnthropicAccountForSession(anthropicSessionKey, config, Date.now(), anthropicRouteDecision).accountId : null; - if (route.providerName === "anthropic" && anthropicRouteDecision && !revisedAnthropicId) return null; + // A revision also changes on per-account policy edits. Re-evaluate the selector + // after credential waits even without a model route, rather than reusing stale active. + const revisedAnthropic = route.providerName === "anthropic" + ? resolveAnthropicAccountForSession(anthropicSessionKey, config, Date.now(), anthropicRouteDecision) : null; + const revisedAnthropicId = revisedAnthropic?.accountId; + if (route.providerName === "anthropic" && !revisedAnthropicId) return null; + if (revisedAnthropic) anthropicReason = revisedAnthropic.reason; candidate = route.providerName === "anthropic" ? await getAnthropicPoolAccessSnapshot(revisedAnthropicId ?? oauthSelection.accountId) : await getValidAccessSnapshotForAccount(route.providerName, oauthSelection.accountId, { requireUsableAccount: true }); diff --git a/structure/INDEX.md b/structure/INDEX.md index 2f5e29efc0a..53f9e34cebd 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -56,6 +56,7 @@ Per-vendor contracts and the adapter authority that constructs them. | Doc | Scope | | --- | --- | +| [`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md) | Account-owned usage thresholds, inheritance, routing boundaries and durable policy changes. | | [`providers-and-adapters.md`](providers-and-adapters.md) | Provider and adapter selection, the adapter inventory, live model discovery, and the hosted-search continuation bridge. | | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | Anthropic OAuth account pause, model routes, and quota labels. | | [`providers/openai-tiers.md`](providers/openai-tiers.md) | Pool/Direct account modes, API-key separation, and the public provider and quota contract. | @@ -135,7 +136,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/lab/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-spend.md`](transports/responses-spend.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/link/` | [`remote-link.md`](remote-link.md) | -| `src/oauth/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | +| `src/oauth/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | | `src/plugins/` | [`ops/plugins.md`](ops/plugins.md) | | `src/protocols/` | [`data-planes/protocol-paths.md`](data-planes/protocol-paths.md) | | `src/providers/` | [`runtime.md`](runtime.md)
[`subagents.md`](subagents.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | diff --git a/structure/decisions/ADR-6014-anthropic-account-threshold.md b/structure/decisions/ADR-6014-anthropic-account-threshold.md new file mode 100644 index 00000000000..072e8d00374 --- /dev/null +++ b/structure/decisions/ADR-6014-anthropic-account-threshold.md @@ -0,0 +1,49 @@ +# ADR-6014 — decision recorded under "Anthropic account thresholds" + +- Contract owner: [Anthropic account thresholds](../providers/anthropic-account-thresholds.md) + +## Decision Log + +- Purpose and intent: Give individual Claude subscriptions an optional usage-switch policy, + completing the second vertical slice of issue #6013 without changing manual pause semantics. +- Existing implementation and constraints: Anthropic owns its active/manual/affinity selector, + three strategies and three quota windows. Model routes constrain the roster. The first slice + stores pause on the OAuth account; credentials, policy changes and deletion must serialize. +- Alternatives considered: A config-side account-id map mirrors Codex but creates an auth/config + split write and orphan cleanup problem. Hard eligibility would strand all-drained or unknown + quota requests. Reusing the generic pool threshold would change every account. +- Selected approach: Store optional `autoSwitchThresholdOverride` on the protected OAuth row. + Missing/null inherits the current Anthropic pool default; validate integer 0..100, retaining + concrete zero as usage-driven switching disabled. The auth mutation lock bumps selection + revision on changes, rejecting old admission proposals without touching credentials or health. + A post-persistence policy event carries the exact previous/current selection revisions and runs + before the generic selection publication. It rebases only a one-shot manual preference that + still owns the previous revision; every ordinary selection generation clears stale ownership, + including A→B→A. Re-login and refresh preserve metadata; account/provider deletion owns cleanup. +- Why this approach: One account-owned record provides atomic lifetime and restart persistence. + The existing cache remains the freshness/unknown authority. Known below-threshold candidates + are preferred using their own policy, but usage never makes an account ineligible. + Manual/affinity and identity-less strategy fast paths keep their existing priority. Quota and + fill-first use policy with pooling enabled; round-robin and pool-off recovery stay unchanged. +- Benefits, tradeoffs, and impact: No config migration or additional secret store. Account DTOs + expose override/default/effective values without credentials. The dedicated PUT, explicit CLI + `--account` and reused compact card control share the contract. Same-provider mutation ownership + and roster generations protect GUI reads. GET and PUT both use the built-in Anthropic OAuth + definition when the explicit provider row is absent, and each PUT response projects the value + committed by that request instead of re-reading a later concurrent write. Focus/draft behavior + uses existing React components with a localized Anthropic hint. The confirmed pool default lives + in the shared account-roster state: settings reads/saves invalidate older roster generations and + start a fresh read, so late responses cannot overwrite a save while later external changes remain + observable. Per-account writes use the roster client's bounded, abortable request lifecycle, so an + unmount or API-base change cannot leave mutation ownership locked. Additional auth-store reads occur + at selection boundaries. + Thresholds are soft preferences, not spend caps; admitted/sent requests are not cancelled. + +## Verification + +Focused tests cover every strategy/window, zero/inheritance, unknown/reset-expired quotas, +route scope, pool-off recovery, generation invalidation, active and non-active policy edits after +a manual selection, observer ordering, A→B→A and same-id generations, rejected persistence, +successive policy edits, refresh/pause races, restart and deletion in +`tests/adapters/anthropic/anthropic-account-threshold.test.ts`. CLI and mounted GUI tests verify +surface parity. Validation uses isolated homes, not the live proxy. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 384fa6fa2cb..fb6136f84d3 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,17 @@ # GUI And Management API +Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null), +`autoSwitchThreshold` (pool default) and `effectiveAutoSwitchThreshold`. The dedicated +`PUT /api/oauth/accounts/auto-switch` accepts `{ provider: "anthropic", accountId, threshold }`; +explicit null restores inheritance and missing/invalid values fail. Other providers are rejected. +`src/server/management/anthropic-account-threshold.ts` validates; the auth store serializes writes. +CLI `ocx account auto-switch anthropic` requires `--account ` with status, inherit, on, off or +threshold. The dashboard reuses `AccountAutoSwitchControl` below account actions, retaining +focus/draft semantics and translated copy. The hook protects same-provider selection mutations +and stale roster reads; confirmed pool-setting changes seed new overrides immediately, without +overwriting an existing custom draft. Old servers do not show a synthetic control. See +[Anthropic threshold semantics](providers/anthropic-account-thresholds.md). + Anthropic account rows now expose the shared boolean `paused` DTO and use the existing `PUT /api/oauth/accounts/pause` body `{ provider, accountId, paused }`. The dashboard's `ProviderAuthPanel` and `useProviderAccountPools` reuse the translated pause/resume actions, diff --git a/structure/manifest.json b/structure/manifest.json index d200b1c1e29..533183c97bd 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -267,6 +267,13 @@ "src/protocols/" ] }, + { + "path": "providers/anthropic-account-thresholds.md", + "tier": 4, + "title": "Anthropic Account Thresholds", + "scope": "Account-owned usage thresholds, inheritance, routing boundaries and durable policy changes.", + "documents": ["src/oauth/"] + }, { "path": "providers-and-adapters.md", "tier": 4, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 3fee43be69d..a96f7c56e5e 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -3,6 +3,7 @@ Anthropic account pause, model routes, and quota labels follow the [Anthropic account-pool contract](providers/anthropic-account-pool.md). +Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). An Anthropic 429 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling is skipped only after the matching account cooldown is present. GitHub Copilot `modelContextTiers` is selected per upstream model. The Chat and Responses diff --git a/structure/providers/anthropic-account-thresholds.md b/structure/providers/anthropic-account-thresholds.md new file mode 100644 index 00000000000..813aaf55dc9 --- /dev/null +++ b/structure/providers/anthropic-account-thresholds.md @@ -0,0 +1,31 @@ +# Anthropic Account Thresholds + +`src/oauth/anthropic-account-threshold.ts` resolves `ProviderAccount.autoSwitchThresholdOverride` +against `anthropicAccountPool.autoSwitchThreshold` (default 80). Stored integers 0..100 survive +refresh, re-login and restart. Missing/null inherits; malformed disk values normalize to absent. +`setAnthropicAccountThreshold` in `src/oauth/store.ts` serializes writes with pause, refresh and +deletion and advances selection revision when policy changes. Account deletion removes it. +`src/server/responses/request-transport.ts` re-evaluates Anthropic selection after a revision +conflict during credential resolution, with or without a model route, before physical dispatch. +The policy-only post-persistence signal runs before the generic selection event and carries exact +previous/current revisions. A pending one-shot manual choice may adopt the new revision only while +it still owns the previous one; this fences old automatic proposals without silently discarding +operator intent. Ordinary selection publications clear stale ownership, including A→B→A and +same-account revision replacement. + +Management reads and writes resolve Anthropic OAuth eligibility through the same configured-or- +built-in provider definition, so a separately persisted account store remains editable when the +explicit provider row is absent. A successful write returns its validated committed override and +derived effective value directly; it never re-reads a newer concurrent mutation into the response. + +`src/oauth/anthropic-routing.ts` compares quota and fill-first source/candidates against each +account's effective threshold. Zero disables usage-driven switching for that account, including +the weekly five-hour exhaustion guard. Unknown source usage does not force a switch. Existing +reset-aware last-good normalization remains the freshness authority. Known below-threshold +candidates are preferred; unknown and all-drained sets retain the legacy ranking/fallback. +Thresholds are preferences, not exclusions: model routes do not widen merely because candidates +are drained. Manual/affinity and identity-less RR/fill-first priorities remain unchanged; +round-robin is not usage-driven. With pooling disabled, reactive recovery ignores stored policy. +Pause, reauthentication, cooldown and credential admission continue to take precedence over zero. + +> Decision record: [ADR-6014](../decisions/ADR-6014-anthropic-account-threshold.md) diff --git a/tests/adapters/anthropic/anthropic-account-threshold.test.ts b/tests/adapters/anthropic/anthropic-account-threshold.test.ts new file mode 100644 index 00000000000..cfe603ca8d0 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-account-threshold.test.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearPoolRotationState } from "../../../src/codex/pool-rotation"; +import { subscribeAccountSelections } from "../../../src/lib/account-selection-events"; +import { effectiveAnthropicAccountThreshold, parseAnthropicAccountThreshold } from "../../../src/oauth/anthropic-account-threshold"; +import { bindAnthropicSessionAffinity, clearAnthropicAccountPoolState, promoteAnthropicActiveAccount, + resetAnthropicRoutingForManualSelection, resolveAnthropicAccountForSession, rotateAnthropicAccountOn429 } from "../../../src/oauth/anthropic-routing"; +import { captureOAuthAccountSelection, getAccountCredential, getAccountSet, removeAccount, replaceProviderAccountSet, + saveAccountCredential, saveCredential, setAccountPaused, setActiveAccount, setAnthropicAccountThreshold } from "../../../src/oauth/store"; +import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../../../src/providers/quota"; +import type { OcxConfig, OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy } from "../../../src/types"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; +import { handleAnthropicAccountThreshold } from "../../../src/server/management/anthropic-account-threshold"; +import { handleOauthAccountRoutes } from "../../../src/server/management/oauth-account-routes"; +import type { ManagementContext } from "../../../src/server/management/context"; + +const oldHome = process.env.OPENCODEX_HOME; +let home: string; +let ids: [string, string, string]; +function config(strategy: OcxAccountPoolRotationStrategy = "quota", quotaWindow: OcxAccountPoolQuotaWindow = "five-hour", enabled = true): OcxConfig { + return { port: 0, defaultProvider: "anthropic", providers: { + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }, + }, anthropicAccountPool: { enabled, strategy, quotaWindow, autoSwitchThreshold: 80 } }; +} +function quota(id: string, percent: number, resetAt = Date.now() + 3600_000) { + setCachedProviderAccountQuotaForTests("anthropic", id, { fiveHourPercent: percent, weeklyPercent: percent, + fiveHourResetAt: resetAt, weeklyResetAt: resetAt, updatedAt: Date.now() }); +} +beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "ocx-anthropic-threshold-")); process.env.OPENCODEX_HOME = home; + clearAnthropicAccountPoolState(); clearPoolRotationState(); clearAccountQuotaCache(); + for (let i = 0; i < 3; i++) await saveCredential("anthropic", { access: `synthetic-${i}`, refresh: `synthetic-refresh-${i}`, + expires: Date.now() + 3600_000, accountId: `threshold-${i}` }); + ids = getAccountSet("anthropic")!.accounts.map(row => row.id).sort() as typeof ids; + await setActiveAccount("anthropic", ids[0]); + const pick = resolveAnthropicAccountForSession("setup", config()); + await promoteAnthropicActiveAccount(pick.accountId!, captureOAuthAccountSelection("anthropic"), { config: config(), reason: pick.reason }); +}); +afterEach(() => { + clearAnthropicAccountPoolState(); clearPoolRotationState(); clearAccountQuotaCache(); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); +}); + +test("inheritance, null reset, zero and strict integer range", async () => { + for (const invalid of [undefined, null, "80", true, [], {}, -1, 101, 2.5, NaN, Infinity]) expect(parseAnthropicAccountThreshold(invalid)).toBeNull(); + for (const valid of [0, 1, 80, 100]) expect(parseAnthropicAccountThreshold(valid)).toBe(valid); + const cfg = config(); + expect(effectiveAnthropicAccountThreshold({ ...cfg, anthropicAccountPool: {} })).toBe(80); + await setAnthropicAccountThreshold(ids[0], 0); + expect(effectiveAnthropicAccountThreshold(cfg, getAccountSet("anthropic")!.accounts.find(row => row.id === ids[0]))).toBe(0); + await setAnthropicAccountThreshold(ids[0], null); cfg.anthropicAccountPool!.autoSwitchThreshold = 63; + expect(effectiveAnthropicAccountThreshold(cfg, getAccountSet("anthropic")!.accounts.find(row => row.id === ids[0]))).toBe(63); + expect(await setAnthropicAccountThreshold("missing", 50)).toBe(false); +}); + +for (const strategy of ["quota", "fill-first"] as const) for (const window of ["five-hour", "weekly", "max-utilization"] as const) { + test(`${strategy}/${window}: source and successor use their own thresholds`, async () => { + const [a, b, c] = ids; const cfg = config(strategy, window); + quota(a, 60); quota(b, 30); quota(c, 70); + await setAnthropicAccountThreshold(a, 50); await setAnthropicAccountThreshold(b, 20); await setAnthropicAccountThreshold(c, 90); + expect(resolveAnthropicAccountForSession("new", cfg).accountId).toBe(c); + await setAnthropicAccountThreshold(a, 0); + expect(resolveAnthropicAccountForSession("new", cfg).accountId).toBe(a); + await setAnthropicAccountThreshold(a, 100); quota(a, 99); + expect(resolveAnthropicAccountForSession("new", cfg).accountId).toBe(a); + quota(a, 100); + expect(resolveAnthropicAccountForSession("new", cfg).accountId).toBe(c); + }); + test(`${strategy}/${window}: unknown and reset-expired source does not force switching`, async () => { + await setAnthropicAccountThreshold(ids[0], 1); quota(ids[1], 0); quota(ids[2], 0); + expect(resolveAnthropicAccountForSession("unknown", config(strategy, window)).accountId).toBe(ids[0]); + quota(ids[0], 100, Date.now() - 1000); + expect(resolveAnthropicAccountForSession("expired", config(strategy, window)).accountId).toBe(ids[0]); + }); +} + +for (const window of ["five-hour", "weekly", "max-utilization"] as const) test(`round-robin/${window} is not usage-driven`, async () => { + const cfg = config("round-robin", window); + const before = resolveAnthropicAccountForSession("unbound", cfg); + for (const id of ids) { quota(id, 99); await setAnthropicAccountThreshold(id, 1); } + expect(resolveAnthropicAccountForSession("unbound", cfg)).toEqual(before); +}); + +test("manual, affinity and identity-less strategy priorities remain unchanged", async () => { + const [a, b] = ids; quota(a, 70); quota(b, 10); await setAnthropicAccountThreshold(a, 20); + bindAnthropicSessionAffinity("bound", a); + expect(resolveAnthropicAccountForSession("bound", config()).reason).toBe("affinity"); + for (const strategy of ["round-robin", "fill-first"] as const) expect(resolveAnthropicAccountForSession(null, config(strategy)).accountId).toBe(a); + await setActiveAccount("anthropic", a); resetAnthropicRoutingForManualSelection(a); + expect(resolveAnthropicAccountForSession("new", config())).toMatchObject({ accountId: a, reason: "manual" }); +}); + +test.each(["active", "non-active"] as const)("%s threshold edits preserve the pending manual dispatch", async target => { + const [a, b, c] = ids; + quota(a, 90); quota(b, 10); quota(c, 70); + await setActiveAccount("anthropic", a); + resetAnthropicRoutingForManualSelection(a); + + await setAnthropicAccountThreshold(target === "active" ? a : b, target === "active" ? 20 : 50); + const first = resolveAnthropicAccountForSession("manual-after-policy", config()); + expect(first).toMatchObject({ accountId: a, reason: "manual" }); + expect(await promoteAnthropicActiveAccount(a, captureOAuthAccountSelection("anthropic"), { + config: config(), sessionKey: "manual-after-policy", reason: first.reason, + })).not.toBeNull(); + + // The operator's one-shot intent is now consumed; the edited quota policy owns + // the next unbound session and moves traffic to the lower-usage account. + expect(resolveAnthropicAccountForSession("policy-after-manual", config())).toMatchObject({ accountId: b, reason: "lowest-usage" }); +}); + +test("policy ownership is visible before the generic selection event", async () => { + const [a, b, c] = ids; + quota(a, 90); quota(b, 10); quota(c, 70); + await setActiveAccount("anthropic", a); + resetAnthropicRoutingForManualSelection(a); + const observed: ReturnType[] = []; + const unsubscribe = subscribeAccountSelections(event => { + if (event.provider === "anthropic" && event.kind === "oauth") { + observed.push(resolveAnthropicAccountForSession("inside-selection-event", config())); + } + }); + try { + await setAnthropicAccountThreshold(b, 50); + } finally { + unsubscribe(); + } + expect(observed).toEqual([expect.objectContaining({ accountId: a, reason: "manual" })]); + expect(resolveAnthropicAccountForSession("after-selection-event", config())).toMatchObject({ accountId: a, reason: "manual" }); +}); + +test.each(["aba", "same-id"] as const)("ordinary %s revisions cannot be adopted by a later policy event", async transition => { + const [a, b, c] = ids; + quota(a, 90); quota(b, 10); quota(c, 70); + await setActiveAccount("anthropic", a); + resetAnthropicRoutingForManualSelection(a); + if (transition === "aba") await setActiveAccount("anthropic", b); + await setActiveAccount("anthropic", a); + await setAnthropicAccountThreshold(b, 50); + expect(resolveAnthropicAccountForSession(`after-${transition}`, config())).toMatchObject({ accountId: b, reason: "lowest-usage" }); +}); + +test("a consumed manual choice stays consumed across successive policy edits", async () => { + const [a, b, c] = ids; + quota(a, 90); quota(b, 10); quota(c, 70); + await setActiveAccount("anthropic", a); + resetAnthropicRoutingForManualSelection(a); + const manual = resolveAnthropicAccountForSession("consume-before-policy", config()); + expect(manual).toMatchObject({ accountId: a, reason: "manual" }); + expect(await promoteAnthropicActiveAccount(a, captureOAuthAccountSelection("anthropic"), { + config: config(), sessionKey: "consume-before-policy", reason: manual.reason, + })).not.toBeNull(); + await setAnthropicAccountThreshold(b, 50); + await setAnthropicAccountThreshold(c, 60); + expect(resolveAnthropicAccountForSession("after-consumed-policy", config())).toMatchObject({ accountId: b, reason: "lowest-usage" }); +}); + +test("a rejected policy persistence neither advances selection nor consumes manual intent", async () => { + const [a, b, c] = ids; + quota(a, 90); quota(b, 10); quota(c, 70); + await setActiveAccount("anthropic", a); + resetAnthropicRoutingForManualSelection(a); + const before = captureOAuthAccountSelection("anthropic"); + await expect(setAnthropicAccountThreshold(b, 50, { + assertBeforePersist: () => { throw new Error("synthetic threshold persist refusal"); }, + })).rejects.toThrow("synthetic threshold persist refusal"); + expect(captureOAuthAccountSelection("anthropic")).toEqual(before); + expect(resolveAnthropicAccountForSession("after-rejected-policy", config())).toMatchObject({ accountId: a, reason: "manual" }); +}); + +test("successive policy revisions preserve the current manual choice exactly once", async () => { + const [a, b, c] = ids; + quota(a, 90); quota(b, 10); quota(c, 70); + await setActiveAccount("anthropic", a); + resetAnthropicRoutingForManualSelection(a); + await setAnthropicAccountThreshold(b, 50); + await setAnthropicAccountThreshold(c, 60); + const manual = resolveAnthropicAccountForSession("after-two-policies", config()); + expect(manual).toMatchObject({ accountId: a, reason: "manual" }); + expect(await promoteAnthropicActiveAccount(a, captureOAuthAccountSelection("anthropic"), { + config: config(), sessionKey: "after-two-policies", reason: manual.reason, + })).not.toBeNull(); + expect(resolveAnthropicAccountForSession("after-two-policies-consumed", config())).toMatchObject({ accountId: b, reason: "lowest-usage" }); +}); + +test("all-drained fallback remains available; zero candidate stays usable", async () => { + const [a, b, c] = ids; quota(a, 90); quota(b, 20); quota(c, 50); + for (const id of ids) await setAnthropicAccountThreshold(id, 10); + expect(resolveAnthropicAccountForSession("new", config()).accountId).toBe(b); + await setAnthropicAccountThreshold(c, 0); + expect(resolveAnthropicAccountForSession("new", config()).accountId).toBe(c); +}); + +test("unknown successors do not displace the legacy measured fallback without a known under-threshold candidate", async () => { + quota(ids[0], 90); quota(ids[1], 95); + expect(resolveAnthropicAccountForSession("unknown-successor", config()).accountId).toBe(ids[0]); +}); + +test("zero remains available even with a measured exhausted five-hour window under weekly routing", async () => { + quota(ids[0], 90); quota(ids[1], 100); quota(ids[2], 95); + await setAnthropicAccountThreshold(ids[1], 0); + for (const strategy of ["quota", "fill-first"] as const) expect(resolveAnthropicAccountForSession("new", config(strategy, "weekly")).accountId).toBe(ids[1]); +}); + +test("strict routes stay closed even when drained; fallback widens only empty eligibility", async () => { + const [a, b, c] = ids; for (const id of ids) { quota(id, 90); await setAnthropicAccountThreshold(id, 10); } + quota(c, 0); + const route = { position: 1, accounts: [b, a], fallback: true }; + expect(resolveAnthropicAccountForSession("new", config(), Date.now(), route).accountId).toBe(b); + await setAccountPaused("anthropic", a, true); await setAccountPaused("anthropic", b, true); + expect(resolveAnthropicAccountForSession("new", config(), Date.now(), { ...route, fallback: false }).accountId).toBeNull(); + expect(resolveAnthropicAccountForSession("new", config(), Date.now(), route).accountId).toBe(c); +}); + +test("pool-off proactive and reactive recovery ignore stored per-account thresholds", async () => { + const [a, b, c] = ids; quota(a, 90); quota(b, 20); quota(c, 50); + await setAnthropicAccountThreshold(a, 1); await setAnthropicAccountThreshold(b, 1); await setAnthropicAccountThreshold(c, 0); + expect(resolveAnthropicAccountForSession("new", config("fill-first", "five-hour", false)).accountId).toBe(a); + expect(rotateAnthropicAccountOn429(config("fill-first", "five-hour", false), a, "60")).toBe(b); +}); + +test("pool-on fill-first 429 successors use candidate thresholds without escaping route", async () => { + const [a, b, c] = ids; quota(b, 40); quota(c, 70); + await setAnthropicAccountThreshold(b, 30); await setAnthropicAccountThreshold(c, 80); + expect(rotateAnthropicAccountOn429(config("fill-first"), a, "60", null, Date.now(), null, + { position: 1, accounts: [a, b, c], fallback: false })).toBe(c); +}); + +test("threshold generation rejects a pre-wait proposal, including non-active candidate edits", async () => { + const captured = captureOAuthAccountSelection("anthropic"); + await setAnthropicAccountThreshold(ids[1], 25); + expect(await promoteAnthropicActiveAccount(ids[1], captured, { config: config() })).toBeNull(); + expect(getAccountSet("anthropic")!.activeAccountId).toBe(ids[0]); +}); + +test("idempotence and ABA generations; queued deletion cannot recreate an account", async () => { + const a = ids[0]; + await setAnthropicAccountThreshold(a, 0); + const before = captureOAuthAccountSelection("anthropic"); + await setAnthropicAccountThreshold(a, 0); + expect(captureOAuthAccountSelection("anthropic")).toEqual(before); + await setAnthropicAccountThreshold(a, null); await setAnthropicAccountThreshold(a, 0); + expect(captureOAuthAccountSelection("anthropic")?.revision).not.toBe(before?.revision); + expect(await promoteAnthropicActiveAccount(a, before, { config: config() })).toBeNull(); + const result = await Promise.all([setAnthropicAccountThreshold(a, 30), removeAccount("anthropic", a), setAnthropicAccountThreshold(a, 90)]); + expect(result).toEqual([true, true, false]); + expect(getAccountSet("anthropic")!.accounts.some(row => row.id === a)).toBe(false); +}); + +test("refresh, pause, replacement and fresh-process reads retain threshold; deletion cleans it", async () => { + const a = ids[0]; const credential = getAccountCredential("anthropic", a)!; + await Promise.all([setAnthropicAccountThreshold(a, 0), saveAccountCredential("anthropic", a, { ...credential, access: "synthetic-rotated" }), setAccountPaused("anthropic", a, true)]); + let account = getAccountSet("anthropic")!.accounts.find(row => row.id === a)!; + expect(account).toMatchObject({ autoSwitchThresholdOverride: 0, paused: true, credential: { access: "synthetic-rotated" } }); + await replaceProviderAccountSet("anthropic", getAccountSet("anthropic")); + if (process.platform !== "win32") expect(statSync(join(home, "auth.json")).mode & 0o777).toBe(0o600); + const child = Bun.spawnSync([process.execPath, "-e", `import { getAccountSet } from './src/oauth/store.ts'; console.log(getAccountSet('anthropic').accounts.find(a => a.id === ${JSON.stringify(a)}).autoSwitchThresholdOverride);`], { cwd: process.cwd(), env: process.env }); + expect(child.exitCode).toBe(0); expect(child.stdout.toString().trim()).toBe("0"); + await removeAccount("anthropic", a); + expect(await setAnthropicAccountThreshold(a, 60)).toBe(false); + expect(getAccountSet("anthropic")!.accounts.some(row => row.id === a)).toBe(false); +}); + +test("invalid persisted metadata normalizes to inherited without migrating other account state", async () => { + const path = join(home, "auth.json"); const store = JSON.parse(readFileSync(path, "utf8")); + store.anthropic.accounts[0].autoSwitchThresholdOverride = "0"; + writeFileSync(path, JSON.stringify(store)); + expect(getAccountSet("anthropic")!.accounts[0]!.autoSwitchThresholdOverride).toBeUndefined(); +}); + +test("API validates provider, account, integer, missing and null; reads durable policy", async () => { + const put = (body: unknown, cfg = config()) => handleAnthropicAccountThreshold(new Request("http://localhost/api/oauth/accounts/auto-switch", { + method: "PUT", body: JSON.stringify(body), headers: { "content-type": "application/json" }, + }), cfg); + for (const threshold of [undefined, "50", -1, 101, 0.5, true, {}, []]) expect((await put({ provider: "anthropic", accountId: ids[0], threshold })).status).toBe(400); + for (const threshold of [0, 100, null]) { + const response = await put({ provider: "anthropic", accountId: ids[0], threshold }); + expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ autoSwitchThresholdOverride: threshold, effectiveAutoSwitchThreshold: threshold ?? 80 }); + } + expect((await put({ provider: "kiro", accountId: ids[0], threshold: 30 })).status).toBe(400); + expect((await put({ provider: "anthropic", accountId: "missing", threshold: 30 })).status).toBe(404); + const cfg = config(); cfg.providers.anthropic!.authMode = "api-key"; + expect((await put({ provider: "anthropic", accountId: ids[0], threshold: 30 }, cfg)).status).toBe(400); + const fallback = config(); delete fallback.providers.anthropic; + expect((await put({ provider: "anthropic", accountId: ids[0], threshold: 30 }, fallback)).status).toBe(200); +}); + +test("concurrent API writes each report its commit without assuming queue order", async () => { + const put = (threshold: number) => handleAnthropicAccountThreshold(new Request("http://localhost/api/oauth/accounts/auto-switch", { + method: "PUT", body: JSON.stringify({ provider: "anthropic", accountId: ids[0], threshold }), + headers: { "content-type": "application/json" }, + }), config()); + const [first, second] = await Promise.all([put(30), put(70)]); + expect(await first.json()).toMatchObject({ autoSwitchThresholdOverride: 30, effectiveAutoSwitchThreshold: 30 }); + expect(await second.json()).toMatchObject({ autoSwitchThresholdOverride: 70, effectiveAutoSwitchThreshold: 70 }); + expect([30, 70]).toContain(getAccountSet("anthropic")!.accounts.find(row => row.id === ids[0])?.autoSwitchThresholdOverride); +}); + +test("management dispatcher exposes the saved override/default/effective DTO without credentials", async () => { + const cfg = config(); + const call = (req: Request) => handleOauthAccountRoutes({ req, url: new URL(req.url), config: cfg, deps: {} } as ManagementContext); + const put = new Request("http://localhost/api/oauth/accounts/auto-switch", { method: "PUT", body: JSON.stringify({ provider: "anthropic", accountId: ids[0], threshold: 0 }) }); + expect((await call(put))?.status).toBe(200); + cfg.anthropicAccountPool!.autoSwitchThreshold = 60; + const response = await call(new Request("http://localhost/api/oauth/accounts?provider=anthropic")); + const dto = await response!.json(); + expect(dto.accounts.find((row: { id: string }) => row.id === ids[0])).toMatchObject({ autoSwitchThresholdOverride: 0, effectiveAutoSwitchThreshold: 0, autoSwitchThreshold: 60 }); + expect(dto.accounts.find((row: { id: string }) => row.id === ids[1])).toMatchObject({ autoSwitchThresholdOverride: null, effectiveAutoSwitchThreshold: 60, autoSwitchThreshold: 60 }); + expect(JSON.stringify(dto)).not.toContain("synthetic"); +}); diff --git a/tests/adapters/anthropic/anthropic-model-routes.test.ts b/tests/adapters/anthropic/anthropic-model-routes.test.ts index faaae0d123f..3eb71e9050b 100644 --- a/tests/adapters/anthropic/anthropic-model-routes.test.ts +++ b/tests/adapters/anthropic/anthropic-model-routes.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { OAUTH_PROVIDERS } from "../../../src/oauth"; +import { getAccountCredential, setAnthropicAccountThreshold } from "../../../src/oauth/store"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -92,6 +94,25 @@ test("bounded first-match globs and invalid rules", () => { expect(parseAnthropicModelRoutes([{ name: "a", match: "[bad]", accounts: ["a"] }]).ok).toBe(false); }); +test.each([false, true])("threshold edit during credential refresh reselects before send (route=%s)", async routed => { + const ids = await seed(); const [a, b, c] = ids as [string, string, string]; + const cfg = config(ids, () => answer()); + cfg.anthropicAccountPool = { enabled: true, strategy: "quota", ...(routed ? { routes: [{ name: "all", match: "claude-*", accounts: ids }] } : {}) }; + const initial = resolveAnthropicAccountForSession(null, cfg); + await promoteAnthropicActiveAccount(initial.accountId!, captureOAuthAccountSelection("anthropic"), { config: cfg, reason: initial.reason }); + for (const [id, percent] of [[a, 60], [b, 70], [c, 90]] as const) setCachedProviderAccountQuotaForTests("anthropic", id, { fiveHourPercent: percent, updatedAt: Date.now() }); + const credential = getAccountCredential("anthropic", a)!; + await saveAccountCredential("anthropic", a, { ...credential, expires: Date.now() - 1 }); + const refresh = spyOn(OAUTH_PROVIDERS.anthropic!, "refresh").mockImplementation(async () => { + await setAnthropicAccountThreshold(a, 50); + return { ...credential, expires: Date.now() + 3600_000 }; + }); + try { + const response = await post(cfg); expect(response.status).toBe(200); + expect(sends).toEqual(["Bearer synthetic-access-1"]); + } finally { refresh.mockRestore(); } +}); + test.each([true, false])("all-paused pool returns 403 without any send (enabled=%s)", async enabled => { const ids = await seed(); for (const id of ids) await setAccountPaused("anthropic", id, true); diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 7772accc49d..211aac44d29 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -1089,13 +1089,13 @@ describe("ocx account CLI (issue #180 matrix)", () => { }); }); - test("21: auto-switch rejects wrong providers, invalid thresholds and missing providers", async () => { - const wrongProvider = await run(["auto-switch", "anthropic", "on"]); + test("21: auto-switch rejects missing Anthropic account selectors, invalid thresholds and missing providers", async () => { + const missingAnthropicAccount = await run(["auto-switch", "anthropic", "on"]); const invalidThreshold = await run(["auto-switch", "openai", "threshold", "101"]); const missingProvider = await run(["auto-switch"]); - expect(wrongProvider.code).toBe(1); - expect(wrongProvider.stderr).toContain("auto-switch only applies to the openai Codex account pool or a generic OAuth provider pool"); + expect(missingAnthropicAccount.code).toBe(2); + expect(missingAnthropicAccount.stderr).toContain("--account "); expect(invalidThreshold.code).toBe(1); expect(invalidThreshold.stderr).toContain("integer 0-100"); expect(missingProvider.code).toBe(1); diff --git a/tests/cli/cli-anthropic-account-threshold.test.ts b/tests/cli/cli-anthropic-account-threshold.test.ts new file mode 100644 index 00000000000..5b42b53e0ce --- /dev/null +++ b/tests/cli/cli-anthropic-account-threshold.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { cmdAutoSwitch } from "../../src/cli/account-extended"; +import type { AccountDeps } from "../../src/cli/account-api"; + +test("Anthropic CLI writes explicit account policy and resets inheritance", async () => { + const calls: { path: string; body: unknown }[] = []; + const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", + loadConfigImpl: () => ({ providers: { anthropic: { adapter: "anthropic", authMode: "oauth" } } }) as never, + fetchImpl: (async (url, init) => { + const body = JSON.parse(String(init?.body)); calls.push({ path: new URL(String(url)).pathname, body }); + return Response.json({ autoSwitchThresholdOverride: body.threshold, effectiveAutoSwitchThreshold: body.threshold ?? 80 }); + }) as typeof fetch }; + const log = console.log; const error = console.error; const output: string[] = []; + console.log = value => { output.push(String(value)); }; console.error = () => {}; + try { + for (const [action, value] of [["off", 0], ["on", 80], ["inherit", null]] as const) { + expect(await cmdAutoSwitch(["anthropic", action, "--account", "a", "--json"], deps)).toBe(0); + expect(JSON.parse(output.at(-1)!)).toMatchObject({ accountId: "a", autoSwitchThresholdOverride: value }); + expect(calls.at(-1)).toEqual({ path: "/api/oauth/accounts/auto-switch", body: { provider: "anthropic", accountId: "a", threshold: value } }); + } + expect(await cmdAutoSwitch(["anthropic", "threshold", "100", "--account", "a"], deps)).toBe(0); + const count = calls.length; + for (const args of [["threshold", "101"], ["threshold", "1.5"], ["threshold", "-1"], ["inherit", "extra"]]) { + expect(await cmdAutoSwitch(["anthropic", ...args, "--account", "a"], deps)).toBe(2); + } + expect(await cmdAutoSwitch(["anthropic", "off"], deps)).toBe(2); + expect(calls.length).toBe(count); + } finally { console.log = log; console.error = error; } +}); + +test("status is read-only and reports inheritance instead of guessing on an old proxy", async () => { + const calls: string[] = []; let supported = true; + const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", + loadConfigImpl: () => ({ providers: { anthropic: { adapter: "anthropic", authMode: "oauth" } } }) as never, + fetchImpl: (async (_url, init) => { calls.push(init?.method ?? "GET"); return Response.json({ accounts: [{ id: "a", + ...(supported ? { autoSwitchThresholdOverride: null, effectiveAutoSwitchThreshold: 65 } : {}) }] }); }) as typeof fetch }; + const log = console.log; const error = console.error; const output: string[] = []; + console.log = value => { output.push(String(value)); }; console.error = () => {}; + try { + expect(await cmdAutoSwitch(["anthropic", "status", "--account", "a", "--json"], deps)).toBe(0); + expect(JSON.parse(output[0]!)).toMatchObject({ autoSwitchThresholdOverride: null, effectiveAutoSwitchThreshold: 65 }); + supported = false; + expect(await cmdAutoSwitch(["anthropic", "status", "--account", "a"], deps)).not.toBe(0); + expect(calls).toEqual(["GET", "GET"]); + } finally { console.log = log; console.error = error; } +}); + +test("malformed status responses fail without throwing or displaying an invented threshold", async () => { + const log = console.log; const error = console.error; console.log = () => {}; console.error = () => {}; + try { + for (const body of [null, [], {}, { accounts: [{ id: "a", autoSwitchThresholdOverride: null }] }]) { + const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", + loadConfigImpl: () => ({ providers: { anthropic: { adapter: "anthropic", authMode: "oauth" } } }) as never, + fetchImpl: (async () => Response.json(body)) as typeof fetch }; + expect(await cmdAutoSwitch(["anthropic", "status", "--account", "a"], deps)).not.toBe(0); + } + } finally { console.log = log; console.error = error; } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5c52800bcbc..96dc540b2a4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -109,7 +109,9 @@ "anthropic-account-pool.test.ts": "adapters/anthropic", "anthropic-account-pause-outbound.test.ts": "adapters/anthropic", "anthropic-account-pause.test.ts": "adapters/anthropic", + "anthropic-account-threshold.test.ts": "adapters/anthropic", "anthropic-combo-account-cooldown.test.ts": "adapters/anthropic", + "cli-anthropic-account-threshold.test.ts": "cli", "anthropic-model-routes.test.ts": "adapters/anthropic", "anthropic-agentrouter-language-framing.test.ts": "adapters/anthropic", "anthropic-baseurl-override.test.ts": "adapters/anthropic",