diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 2bdf799069b..32adbfb434b 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -32,7 +32,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `providerContextCapValues?` | `Record` | `{}` | Dernières limites sélectionnées par fournisseur, conservées après désactivation. Ces valeurs n’activent aucun plafond. Une valeur active est prioritaire sur une valeur mémorisée. | | `contextCapValue?` | `number` | `350000` | Valeur par défaut lors de la première activation. Les activations suivantes restaurent la sélection du fournisseur. Modifier la valeur globale avec `setAll: true` ne modifie que les plafonds actifs ; `setAll: true` sans valeur active tous les fournisseurs configurés à la valeur globale actuelle. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Métadonnées du compte pool ChatGPT/Codex gérées par Codex Auth. Les secrets vivent séparément dans `codex-accounts.json`. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | Comptes exclus de la sélection du pool jusqu'à la reprise, y compris le compte principal `__main__` lorsqu'il est mis en pause. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Comptes exclus de la sélection du pool jusqu'à la reprise, y compris le compte principal `__main__` lorsqu'il est mis en pause. La pause et la reprise manuelles mettent aussi à jour les entrées principales et du pool existantes du même compte et espace de travail. | | `codexAccountNamespaces?` | `Record` | — | Mappage facultatif d’un sélecteur de modèle public arbitraire vers une cible de compte Codex stockée. Lorsque les lignes du sélecteur qualifié par compte sont activées, chaque sélecteur dont la cible est présente ajoute des lignes `/` distinctes au sélecteur Codex ; chaque ligne utilise uniquement ce compte. Dès qu'un sélecteur est actif, les lignes natives non qualifiées sont masquées dans le sélecteur, mais leurs identifiants restent routables et figurent toujours dans la réponse brute de `/v1/models`, sauf désactivation explicite. | | `codexAccountPickerEnabled?` | `boolean` | désactivé lorsque la carte est vide | Contrôle si les mappages `codexAccountNamespaces` éligibles génèrent des lignes de sélecteur Codex qualifiées pour le compte. `true` permet aux lignes mappées d'apparaître. Si elle est omise avec une carte non vide, elle est traitée comme activée pour des raisons de compatibilité ascendante ; si la carte est vide, elle est éteinte. `false` masque les lignes générées et restaure les lignes nues du sélecteur natif sans supprimer les mappages ni désactiver le routage exact `/`. | | `activeCodexAccountId?` | `string` | — | Compte de pool sélectionné manuellement pour la prochaine demande. La sélection efface l'affinité des threads ; les demandes en cours conservent les informations d’identification capturées. | 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 eba9d89fd46..9e7a6ec106f 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -371,7 +371,7 @@ Codex. Ses routes sont les suivantes : | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Répertorier, actualiser ou supprimer des comptes Codex. POST est conservé comme point de terminaison de compatibilité désactivé ; les réponses DELETE réussies incluent `catalogRefreshPending`. | POST renvoie toujours 403 `manual_import_disabled` ; 400 entrée DELETE invalide | | `PUT /api/codex-auth/accounts/alias` | Définir ou supprimer un alias de compte | 400 invalide account/alias | -| `PUT /api/codex-auth/accounts/pause` | Suspendre ou reprendre un compte | 400 invalide account/state ; 404 compte manquant | +| `PUT /api/codex-auth/accounts/pause` | Suspendre ou reprendre manuellement un compte et ses entrées principales ou du pool existantes de même identité ; renvoie `affectedAccountIds` | 400 compte/état invalide ; 404 compte introuvable ; 503 identité du compte principal occupée ou illisible | | `PUT /api/codex-auth/accounts/pause-exhausted` | Suspendre les comptes dont le quota est épuisé | Les échecs de verrouillage de mutation deviennent 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | Effacer le temps de recharge d'exécution pour un compte ou tous les comptes | 400 identifiant invalide | | `GET, PUT /api/codex-auth/active` | Lire ou sélectionner le compte actif | 400 compte invalide ou manquant ; 409 conflit avec un compte suspendu ou une ancienne ligne | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 04f1868680c..4ae7448950a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -31,7 +31,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `providerContextCapValues?` | `Record` | `{}` | プロバイダーごとに最後に選択した上限。無効にしても保持され、この値だけで上限が有効になることはありません。有効な値が保存済みの値より優先されます。 | | `contextCapValue?` | `number` | `350000` | 初回の有効化で使う既定値。再び有効にすると、そのプロバイダーの選択値を復元します。`setAll: true` とともにグローバル値を変更すると、有効な上限だけを更新します。値を指定せずに `setAll: true` を送ると、設定済みの全プロバイダーの上限を現在のグローバル値で有効にします。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | -| `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 手動の一時停止と再開は、同じアカウント・ワークスペースのメインログインとプール内の既存エントリにも反映されます。 | | `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。account-qualified picker row が有効な場合、target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | | `codexAccountPickerEnabled?` | `boolean` | map が空なら off | 有効な `codexAccountNamespaces` mapping から account-qualified Codex picker row を生成するかを制御します。`true` は mapping された行の表示を許可します。空でない map で省略した場合は後方互換性のため有効として扱われ、map が空なら off です。`false` は mapping を削除せず、明示的な `/` routing も無効にせずに、生成行を非表示にして picker の bare native 行を復元します。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | 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 3d8018ae3dc..25eb7807eab 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -320,7 +320,7 @@ CLI コマンドは Anthropic OAuth アカウントを id または一意の別 | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Codex アカウントの一覧表示/更新または削除。POST は無効化された互換エンドポイントとしてのみ残り、成功した DELETE は `catalogRefreshPending` を返します。 | POST は常に 403 `manual_import_disabled`。DELETE の入力が無効な場合は 400。 | | `PUT /api/codex-auth/accounts/alias` |アカウント エイリアスの設定またはクリア | 400 無効なアカウント/エイリアス | -| `PUT /api/codex-auth/accounts/pause` | 1 つのアカウントを一時停止または再開する | 400 無効なアカウント/状態。 404 アカウントが見つかりません | +| `PUT /api/codex-auth/accounts/pause` | アカウントと、同じ ID 情報を持つ既存のメイン/プールのエントリを手動で一時停止または再開する。`affectedAccountIds` を返す | 400 無効なアカウント/状態、404 アカウントが見つからない、503 メインの ID 情報が使用中または読み取り不能 | | `PUT /api/codex-auth/accounts/pause-exhausted` |クォータを使い果たしたアカウントを一時停止する |ミューテーションロックの失敗は 503 になります | | `POST /api/codex-auth/accounts/clear-cooldown` | 1 つのアカウントまたはすべてのアカウントのランタイム クールダウンをクリアする | 400 無効な ID | | `GET, PUT /api/codex-auth/active` |アクティブなアカウントを読み取るか選択します | 400 アカウントが無効または欠落しています。 409 一時停止/レガシー行の競合 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index f78de15e39f..0c3494ff3ac 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -31,7 +31,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `providerContextCapValues?` | `Record` | `{}` | 공급자별로 마지막에 선택한 상한입니다. 꺼도 선택값이 남으며, 저장된 값만으로는 상한이 활성화되지 않습니다. 활성 값이 저장된 선택값보다 우선합니다. | | `contextCapValue?` | `number` | `350000` | 처음 켤 때 쓰는 기본값입니다. 다시 켜면 공급자별 선택값을 복원합니다. `setAll: true`와 함께 전역 값을 바꾸면 활성 상한만 갱신합니다. 값 없이 `setAll: true`를 보내면 설정된 모든 공급자의 상한을 현재 전역 값으로 켭니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. 수동 일시 중지 및 재개는 같은 계정과 워크스페이스의 기존 기본 로그인 및 풀 항목에도 적용됩니다. | | `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. 계정 한정 선택기 행이 활성화되어 있으면 target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | | `codexAccountPickerEnabled?` | `boolean` | map이 비어 있으면 꺼짐 | 유효한 `codexAccountNamespaces` 매핑에서 account-qualified Codex 선택기 행을 생성할지 제어합니다. `true`는 매핑된 행의 표시를 허용합니다. 비어 있지 않은 map에서 생략하면 이전 버전과의 호환성을 위해 활성화된 것으로 취급되며, map이 비어 있으면 꺼집니다. `false`는 매핑을 삭제하거나 명시적 `/` 라우팅을 비활성화하지 않은 채 생성 행을 숨기고 선택기에 bare native 행을 복원합니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | 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 bdabc85256a..ed7d0292fa8 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -332,7 +332,7 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Codex account를 나열/갱신하거나 삭제합니다. POST는 비활성화된 호환성 endpoint로만 유지되며, 성공한 DELETE는 `catalogRefreshPending`를 포함합니다. | POST는 항상 403 `manual_import_disabled`; DELETE 입력이 잘못되면 400 | | `PUT /api/codex-auth/accounts/alias` | 계정 alias를 설정하거나 지웁니다 | 400 잘못된 account/alias | -| `PUT /api/codex-auth/accounts/pause` | 계정 하나를 일시 중지하거나 재개합니다 | 400 잘못된 account/state; 404 누락된 account | +| `PUT /api/codex-auth/accounts/pause` | 계정과 동일한 로그인 정보를 가진 기존 메인/풀 항목을 수동으로 일시 중지하거나 재개합니다. `affectedAccountIds`를 반환합니다 | 400 잘못된 계정/상태; 404 계정 없음; 503 메인 계정의 로그인 정보가 사용 중이거나 읽을 수 없음 | | `PUT /api/codex-auth/accounts/pause-exhausted` | quota가 소진된 account를 일시 중지합니다 | mutation-lock 실패는 503이 됩니다 | | `POST /api/codex-auth/accounts/clear-cooldown` | account 하나 또는 모든 account의 runtime cooldown을 지웁니다 | 400 잘못된 id | | `GET, PUT /api/codex-auth/active` | 활성 account를 읽거나 선택합니다 | 400 잘못되었거나 누락된 account; 409 paused/legacy-row 충돌 | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d23c45edb22..ce28f9ffaa3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -44,7 +44,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `providerContextCapValues?` | `Record` | `{}` | Last selected provider limits, retained while disabled. These values do not activate a cap. An enabled value takes precedence over a remembered value. | | `contextCapValue?` | `number` | `350000` | Default used on first enable. A later enable restores the selected provider value. Updating the global value with `setAll: true` changes enabled caps only; `setAll: true` without a value enables all configured providers at the current global value. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. Manual pause and resume also update existing main and pool entries for the same account and workspace. | | `creditCodexAccountIds?` | `string[]` | `[]` | Accounts allowed to keep serving from ChatGPT credits after a usage limit, including the main `__main__` account. Upstream does not refuse an account that holds credits at 100%; it serves the request and draws the balance. Spending is opt-in: an account not listed here is skipped by selection while one of its usage windows (weekly or monthly, only monthly on 30-day plans, or the 5-hour window) reads 100% with its reset still ahead, and returns once that reset passes. A weekly or monthly reading without a reset time does not hold the account; a 5-hour reading at 100% without a reset holds it only while that reading is still fresh. When no other account is available, automatic selection finds none rather than spending credits. An unlisted `__main__` is also refused, like a hard-lock refusal, when a request names it or carries its credential. Listing `__main__` does not lift the main-account hard lock (on by default at 98%), which still stops the main login first; turn the lock off to let the main account spend credits (a lock at 100% still stops it at 100%). New accounts start unlisted. Managed by the **Use credits** switch in the Codex Auth header and the **Use credits after limit** switch in each account card's **⋯** menu. | | `codexQuotaAutoRefresh?` | `Record` | `{}` | Per-Codex-login-account opt-in for automatic `fiveHour` and `weekly` window activation in Pool mode; Direct mode does not run this worker. In Providers/Codex Auth **Advanced settings**, one control enables or disables both supported windows across all current main and added accounts. New accounts are not opted in automatically. Enable skips windows absent from live WHAM data; disable also clears stale enabled windows. The UI reuses granular `/api/settings` writes, reconciles partial failures, and retries the original ON/OFF intent without replacing unrelated settings or completed reset markers. The API still rejects enabling an unavailable window with HTTP 409. At a reported reset time, opencodex sends one minimal non-stored Codex message using that account's quota and persists the activated timestamp. This does not apply to API-key providers. | | `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. When account-qualified picker rows are enabled, each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 1378a13f20e..4664d425d26 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -677,7 +677,7 @@ manager. Its routes are: | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | List/refresh or delete Codex accounts. POST is retained as a disabled compatibility endpoint; successful DELETE responses include `catalogRefreshPending`. | POST always returns 403 `manual_import_disabled`; 400 invalid DELETE input | | `PUT /api/codex-auth/accounts/alias` | Set or clear an account alias | 400 invalid account/alias | -| `PUT /api/codex-auth/accounts/pause` | Pause or resume one account | 400 invalid account/state; 404 missing account | +| `PUT /api/codex-auth/accounts/pause` | Manually pause or resume an account and its existing matching main/pool entries; returns `affectedAccountIds` | 400 invalid account/state; 404 missing account; 503 main identity busy or unreadable | | `PUT /api/codex-auth/accounts/pause-exhausted` | Pause accounts whose quota is exhausted | Mutation-lock failures become 503 | | `PUT /api/codex-auth/accounts/credits` | Allow or stop spending ChatGPT credits after the usage limit. Body `{ id, creditsAfterLimit }` for one account, including `__main__`: true adds the id to `creditCodexAccountIds`, false removes it. Body `{ all }` for the global switch: true lists `__main__` and every pool account, false clears the list. Applies to the next selection. | 400 invalid id or non-boolean value; 404 missing account | | `PUT /api/settings` with `codexQuotaAutoRefresh: { id, window, enabled }` | Enable or disable 5-hour or weekly automatic window activation for one account | 400 invalid id/window/state; 404 missing account; 409 unavailable window | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 7070027d795..35f25ae4f52 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -32,7 +32,7 @@ ocx models provider openrouter on | `providerContextCapValues?` | `Record` | `{}` | Последние выбранные лимиты по провайдерам, сохраняемые после отключения. Эти значения сами по себе не включают ограничение. Активное значение имеет приоритет над сохранённым. | | `contextCapValue?` | `number` | `350000` | Значение по умолчанию при первом включении. При повторном включении восстанавливается выбранное значение провайдера. Изменение глобального значения с `setAll: true` обновляет только активные лимиты; `setAll: true` без значения включает лимиты всех настроенных провайдеров с текущим глобальным значением. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. Ручная приостановка и возобновление также обновляют существующие записи основного входа и пула для той же учётной записи и рабочего пространства. | | `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Когда строки picker'а с указанием аккаунта включены, каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | | `codexAccountPickerEnabled?` | `boolean` | выкл. при пустой map | Управляет созданием account-qualified строк picker'а Codex из подходящих сопоставлений `codexAccountNamespaces`. `true` разрешает показывать сопоставленные строки. Если поле не задано при непустой map, функция считается включённой для обратной совместимости; при пустой map она выключена. `false` скрывает созданные строки и возвращает bare native-строки в picker, не удаляя сопоставления и не отключая точную маршрутизацию `/`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | 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 a3f4c294220..77b456ab70c 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -359,7 +359,7 @@ picker изменилась. `catalogRefreshPending: true` в успешном | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Показать/обновить список либо удалить аккаунты Codex. POST сохранён только как отключённый endpoint совместимости; успешный DELETE включает `catalogRefreshPending`. | POST всегда возвращает 403 `manual_import_disabled`; 400 при неверных данных DELETE | | `PUT /api/codex-auth/accounts/alias` | Задать или очистить alias аккаунта | 400 invalid account/alias | -| `PUT /api/codex-auth/accounts/pause` | Поставить один аккаунт на паузу или снять её | 400 invalid account/state; 404 missing account | +| `PUT /api/codex-auth/accounts/pause` | Вручную приостановить или возобновить аккаунт и существующие записи основного аккаунта и пула с той же идентичностью; возвращает `affectedAccountIds` | 400 неверный аккаунт/состояние; 404 аккаунт не найден; 503 данные идентичности основного аккаунта заняты или недоступны для чтения | | `PUT /api/codex-auth/accounts/pause-exhausted` | Поставить на паузу аккаунты с исчерпанной квотой | Сбои mutation-lock превращаются в 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | Очистить runtime cooldown для одного аккаунта или для всех | 400 invalid id | | `GET, PUT /api/codex-auth/active` | Прочитать или выбрать активный аккаунт | 400 invalid or missing account; 409 paused/legacy-row conflict | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index c54cf2a12bb..e351ffe2c3d 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -32,7 +32,7 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `providerContextCapValues?` | `Record` | `{}` | Sağlayıcı başına son seçilen sınırlar; devre dışı bırakıldığında da saklanır. Bu değerler tek başına sınırı etkinleştirmez. Etkin değer, saklanan değerden önceliklidir. | | `contextCapValue?` | `number` | `350000` | İlk etkinleştirmede kullanılan varsayılan değer. Sonraki etkinleştirmelerde sağlayıcının seçimi geri yüklenir. Genel değeri `setAll: true` ile güncellemek yalnızca etkin sınırları değiştirir; değer olmadan `setAll: true`, yapılandırılmış tüm sağlayıcıların sınırlarını geçerli genel değerle etkinleştirir. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth tarafından yönetilen ChatGPT/Codex havuz hesabı meta verileri. Sırlar ayrı olarak `codex-accounts.json` içinde yer alır. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | Duraklatıldığında ana `__main__` hesabı da dahil olmak üzere, devam ettirilene kadar Havuz seçiminden hariç tutulan hesaplar. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Duraklatıldığında ana `__main__` hesabı da dahil olmak üzere, devam ettirilene kadar Havuz seçiminden hariç tutulan hesaplar. Elle duraklatma ve devam ettirme, aynı hesap ve çalışma alanının mevcut ana giriş ve havuz kayıtlarını da günceller. | | `codexAccountNamespaces?` | `Record` | — | İsteğe bağlı olarak rastgele bir genel model seçiciden saklanan bir Codex hesap hedefine eşleme. Hesap nitelikli seçici satırları etkinleştirildiğinde, hedefi mevcut olan her seçici, Codex seçicisine ayrı `/` satırları ekler; her satır yalnızca o hesabı kullanır. Herhangi bir seçici etkinken, yalın yerel satırlar seçicide gizlenir, ancak açıkça devre dışı bırakılmadıkça kimlikleri yönlendirilebilir kalır ve ham `/v1/models` tarafından listelenir. | | `codexAccountPickerEnabled?` | `boolean` | harita boşken kapalı | Uygun `codexAccountNamespaces` eşlemelerinin hesap nitelikli Codex seçici satırları oluşturup oluşturmayacağını denetler. `true`, eşlenen satırların görünmesine izin verir. Boş olmayan bir haritayla atlanırsa, geriye dönük uyumluluk için etkin olarak değerlendirilir; harita boşsa kapalıdır. `false`, eşlemeleri silmeden veya tam `/` yönlendirmesini devre dışı bırakmadan oluşturulan satırları gizler ve yalın yerel seçici satırlarını geri yükler. | | `activeCodexAccountId?` | `string` | — | Sonraki istek için manuel olarak seçilen Havuz hesabı. Seçim iş parçacığı bağlılığını temizler; devam eden istekler yakalanan kimlik bilgilerini korur. | 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 a2e1ad81366..2199bce119f 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -390,7 +390,7 @@ devreder. Rotaları şunlardır: | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Codex hesaplarını listeleyin/yenileyin veya silin. POST devre dışı bırakılmış bir uyumluluk uç noktası olarak tutulur; başarılı DELETE yanıtları `catalogRefreshPending` içerir. | POST her zaman 403 `manual_import_disabled` döndürür; 400 geçersiz DELETE girdisi | | `PUT /api/codex-auth/accounts/alias` | Bir hesap takma adını ayarlayın veya temizleyin | 400 geçersiz hesap/takma ad | -| `PUT /api/codex-auth/accounts/pause` | Bir hesabı duraklatın veya devam ettirin | 400 geçersiz hesap/durum; 404 eksik hesap | +| `PUT /api/codex-auth/accounts/pause` | Bir hesabı ve aynı kimliğe sahip mevcut ana hesap/havuz kayıtlarını elle duraklatın veya devam ettirin; `affectedAccountIds` döndürür | 400 geçersiz hesap/durum; 404 hesap bulunamadı; 503 ana hesap kimliği kullanımda veya okunamıyor | | `PUT /api/codex-auth/accounts/pause-exhausted` | Kotası tükenen hesapları duraklatın | Mutasyon kilidi arızaları 503 olur | | `POST /api/codex-auth/accounts/clear-cooldown` | Bir hesap veya tüm hesaplar için çalışma zamanı soğuma süresini temizleyin | 400 geçersiz kimlik | | `GET, PUT /api/codex-auth/active` | Aktif hesabı okuyun veya seçin | 400 geçersiz veya eksik hesap; 409 duraklatılmış/eski satır çakışması | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index fa9e46f2fc2..39e62f9de1a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -31,7 +31,7 @@ ocx models provider openrouter on | `providerContextCapValues?` | `Record` | `{}` | 各提供商最后选择的上限,关闭后仍保留。仅保存这些值不会启用上限。有效值优先于保存的选择值。 | | `contextCapValue?` | `number` | `350000` | 首次开启时使用的默认值。再次开启时恢复该提供商的选择值。修改全局值时附带 `setAll: true` 只会更新已开启的上限;不带值的 `setAll: true` 会按当前全局值开启所有已配置提供商的上限。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | -| `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 手动暂停或恢复会同步更新同一账号、同一工作区的主登录与池内已有入口。 | | `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。启用账户限定的选择器行后,target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | | `codexAccountPickerEnabled?` | `boolean` | 映射为空时关闭 | 控制是否根据有效的 `codexAccountNamespaces` 映射生成账户限定的 Codex 选择器行。`true` 允许显示映射行。在非空映射中省略此字段时,为保持向后兼容会视为已启用;映射为空时则关闭。`false` 会隐藏生成行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | 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 f02cf6e6d5d..abcdb26b4f9 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 @@ -313,7 +313,7 @@ OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上 | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | 列出/刷新或删除 Codex 账户。POST 仅作为已禁用的兼容端点保留;成功的 DELETE 响应包含 `catalogRefreshPending`。 | POST 始终返回 403 `manual_import_disabled`;DELETE 输入无效时返回 400 | | `PUT /api/codex-auth/accounts/alias` | 设置或清除账户别名 | 400 账户/别名无效 | -| `PUT /api/codex-auth/accounts/pause` | 暂停或恢复一个账户 | 400 账户/状态无效;404 缺少账户 | +| `PUT /api/codex-auth/accounts/pause` | 手动暂停或恢复账户及同身份的已有主登录/池内入口;返回 `affectedAccountIds` | 400 账户/状态无效;404 缺少账户;503 主登录身份忙碌或无法读取 | | `PUT /api/codex-auth/accounts/pause-exhausted` | 暂停配额已耗尽的账户 | 变更锁失败会变成 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | 清除一个账户或所有账户的运行时冷却 | 400 id 无效 | | `GET, PUT /api/codex-auth/active` | 读取或选择当前活跃账户 | 400 账户无效或缺失;409 暂停/旧行冲突 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index f3d3a121179..78d0118dd20 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -31,7 +31,7 @@ ocx models provider openrouter on | `providerContextCapValues?` | `Record` | `{}` | 各供應商最後選擇的上限,停用後仍保留。僅儲存這些值不會啟用上限。生效中的值優先於儲存的選擇值。 | | `contextCapValue?` | `number` | `350000` | 首次啟用時使用的預設值。再次啟用時恢復該供應商的選擇值。修改全域值時附帶 `setAll: true` 只會更新已啟用的上限;不帶值的 `setAll: true` 會以目前全域值啟用所有已設定供應商的上限。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池帳號中繼資料。秘密分別存在 `codex-accounts.json`。 | -| `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 手動暫停或恢復會同步更新同一帳號、同一工作區的主登入與池內既有入口。 | | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | | `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務。綁定任務預設(`pool.cacheAffinity`)在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務,並且只改綁到確有額度餘裕且用量嚴格更低的帳號。將 `pool.cacheAffinity` 設為 `false` 才會在此閾值重新評估綁定任務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 02aa615fa60..8bbfae3db3c 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 @@ -303,7 +303,7 @@ OpenAI 也遵循此規則:開關不會選擇特殊的 922k 模式。生效中 | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | 列出/重新整理或刪除 Codex 帳號。POST 僅保留為已停用的相容 endpoint;成功的 DELETE 回應包含 `catalogRefreshPending`。 | POST 一律回傳 403 `manual_import_disabled`;DELETE 輸入無效時回傳 400 | | `PUT /api/codex-auth/accounts/alias` | 設定或清除帳號別名 | 400 無效帳號/別名 | -| `PUT /api/codex-auth/accounts/pause` | 暫停或恢復一個帳號 | 400 無效帳號/狀態;404 缺失帳號 | +| `PUT /api/codex-auth/accounts/pause` | 手動暫停或恢復帳號及身分相符的既有主帳號/帳號池項目;回傳 `affectedAccountIds` | 400 無效帳號/狀態;404 找不到帳號;503 主帳號身分資料使用中或無法讀取 | | `PUT /api/codex-auth/accounts/pause-exhausted` | 暫停配額耗盡的帳號 | 變更鎖失敗變為 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | 清除一個或所有帳號的 runtime 冷卻 | 400 無效 id | | `GET, PUT /api/codex-auth/active` | 讀取或選擇現用帳號 | 400 無效或缺失帳號;409 暫停/舊列衝突 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1f0d2d4ccaa..94dc2610b14 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -529,6 +529,7 @@ "codex-app-server-processes.test.ts": "codex-integration", "codex-app-server-restart-service.test.ts": "codex-integration", "codex-auth-api.test.ts": "codex-integration", + "codex-linked-account-pause.test.ts": "codex-integration", "codex-auth-cancel-ownership.test.ts": "codex-integration", "codex-auth-collision.test.ts": "codex-integration", "codex-auth-context.test.ts": "codex-integration", diff --git a/src/codex/auth-api/account-pause-group.ts b/src/codex/auth-api/account-pause-group.ts new file mode 100644 index 00000000000..878f009dc72 --- /dev/null +++ b/src/codex/auth-api/account-pause-group.ts @@ -0,0 +1,113 @@ +import { lstatSync } from "node:fs"; +import type { OcxConfig } from "../../types"; +import { inspectChatGptDomainClaim, extractEmail } from "../../oauth/chatgpt"; +import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../account-id"; +import { readCodexTokensResult } from "../auth-collision"; +import type { CodexTokenReadResult } from "../auth-collision"; +import { getCodexAccountCredential } from "../account-store"; +import { resolveCodexHomeDir } from "../home"; +import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; +import { withNativeMainSharedClaim } from "../native-main-claim"; +import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../native-profile-store"; +import { NativeProfileError } from "../native-profile-types"; +import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http"; + +type AccountIdentity = { accountId: string; email: string }; +type MainAuth = CodexTokenReadResult | { status: "api-key-only" }; + +/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */ +function readMainAuth(authPath: string): MainAuth { + const main = readCodexTokensResult(authPath, { bounded: true }); + if (main.status !== "invalid") return main; + try { + const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8")); + if (j === null || typeof j !== "object" || Array.isArray(j)) return main; + const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown }; + const apiKeyOnly = envelope.tokens == null + && (envelope.auth_mode === undefined || envelope.auth_mode === "api_key") + && typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== ""; + return apiKeyOnly ? { status: "api-key-only" } : main; + } catch { + return main; + } +} + +function identity(accountId: unknown, email: unknown): AccountIdentity | undefined { + const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : undefined; + return typeof accountId === "string" && accountId.trim() && normalizedEmail + ? { accountId: accountId.trim(), email: normalizedEmail } + : undefined; +} + +/** Resolve only existing entries, under native-main ownership, never on the request path. */ +function linkedAccountIds(config: OcxConfig, selectedId: string, main: MainAuth): string[] | undefined { + // Unknown main identity must not produce a successful but incomplete pause. + if (main.status === "unreadable" || main.status === "invalid") return undefined; + const identities = new Map(); + if (main.status === "ok") { + const tokens = main.tokens; + if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" + || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return undefined; + const claims = [tokens.id_token, tokens.access_token] + .filter((token): token is string => typeof token === "string") + .map(inspectChatGptDomainClaim); + if (claims.some(claim => claim.kind === "invalid")) return undefined; + const claimIds = claims.flatMap(claim => claim.kind === "valid" ? [claim.accountId] : []); + // organizations[] describes memberships, not the selected workspace. Never use its first row. + const accountId = tokens.account_id.trim() || claimIds[0]; + if (claimIds.some(claim => claim !== accountId)) return undefined; + // Both tokens must name the same member; otherwise the bearer's identity is unknown. + const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] + .flatMap(email => email?.trim() ? [email.trim().toLowerCase()] : [])); + if (emails.size > 1) return undefined; + identities.set(MAIN_CODEX_ACCOUNT_ID, identity(accountId, [...emails][0])); + } + for (const account of config.codexAccounts ?? []) { + if (!isSelectableCodexPoolAccount(account)) continue; + const credential = getCodexAccountCredential(account.id); + identities.set(account.id, identity(credential?.chatgptAccountId, account.email)); + } + const selected = identities.get(selectedId); + // Missing identity evidence never links unrelated logins, including members of one workspace. + if (!selected) return [selectedId]; + return [selectedId, ...[...identities].flatMap(([id, candidate]) => ( + id !== selectedId && candidate?.accountId === selected.accountId && candidate.email === selected.email + ? [id] : [] + ))]; +} + +/** Hold admission throughout; claim the physical main whenever its home exists. */ +export async function withCodexAccountPauseGroup( + config: OcxConfig, + selectedId: string, + publish: (accountIds: string[]) => Response, +): Promise { + const lease = tryAcquireNativeMainProfileClaim(); + if (!lease) return nativeMainProfileBusyResponse(); + try { + const codexHome = resolveCodexHomeDir(); + try { + lstatSync(codexHome); + } catch (error) { + // Only positive absence permits Pool-only publication. A dangling symlink or access + // failure is not absence; do not create a home just to coordinate an unused main login. + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") return nativeMainProfileBusyResponse(); + const ids = linkedAccountIds(config, selectedId, { status: "missing" }); + return ids ? publish(ids) : nativeMainProfileBusyResponse(); + } + const context = resolveNativeProfileContext({ codexHome }); + return await withNativeMainSharedClaim(context, async () => { + const main = readMainAuth(context.authPath); + const ids = linkedAccountIds(config, selectedId, main); + return ids ? publish(ids) : nativeMainProfileBusyResponse(); + }); + } catch (error) { + if (isNativeMainClaimUnavailable(error) + || (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) { + return nativeMainProfileBusyResponse(); + } + throw error; + } finally { + lease.release(); + } +} diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index f52b289c7f8..f2546328249 100644 --- a/src/codex/auth-api/routes.ts +++ b/src/codex/auth-api/routes.ts @@ -24,6 +24,7 @@ import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config"; import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; import { getEffectiveCodexAutoSwitchThreshold, isCodexAccountAutoSwitchThresholdKey, parseCodexAutoSwitchThreshold, setCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch"; +import { withCodexAccountPauseGroup } from "./account-pause-group"; export async function handleCodexAuthAPI( req: Request, @@ -100,18 +101,23 @@ export async function handleCodexAuthAPI( || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); if (!exists) return jsonResponse({ error: "Account not found" }, 404); - setCodexAccountPaused(runtimeConfig, id, body.paused); - if (body.paused) { - clearThreadAccountMapForAccount(id); - selectFallbackAfterPause(runtimeConfig, id); - } - saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ - ok: true, - id, - paused: body.paused, - activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, - appliesImmediately: true, + const paused = body.paused; + return withCodexAccountPauseGroup(runtimeConfig, id, accountIds => { + // Publish every exclusion before reconciling: a duplicate must never become the fallback. + for (const accountId of accountIds) setCodexAccountPaused(runtimeConfig, accountId, paused); + if (paused) { + for (const accountId of accountIds) clearThreadAccountMapForAccount(accountId); + for (const accountId of accountIds) selectFallbackAfterPause(runtimeConfig, accountId); + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + paused, + affectedAccountIds: accountIds, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); }); } diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 333ab103d98..dd71d8f2d7d 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -219,6 +219,12 @@ be treated as implemented: ## API ownership +`PUT /api/codex-auth/accounts/pause` manually pauses or resumes all existing entries whose account +and workspace identity is confirmed to match, including native main and a pool duplicate. Its +`affectedAccountIds` response contains only the existing opaque entry handles; emails and physical +account ids are not serialized. Absent homes and API-key-only main logins allow Pool-only grouping; +busy/unreadable main identity returns 503. See [OpenAI account operations](providers/openai-accounts.md#manual-account-pause-and-resume). + Model rows keep stored custom overrides separate from their effective `exportMetadata` projection. `src/server/management/model-row-export-metadata.ts` resolves inherited limits and capabilities; the [client export contract](clients/integrations.md#owned-catalog-convergence) prevents picker-only diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 4ed82b37bca..d8872d97542 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -256,6 +256,22 @@ API-key and custom forward destinations preserve their metadata. See [Responses Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). +## Manual account pause and resume + +Manual pause/resume in `src/codex/auth-api/account-pause-group.ts` resolves existing native-main +and pool entries by the full ChatGPT account/workspace id and normalized email. Matching entries +share the operation; equal emails in different workspaces and different members of one workspace +remain independent. Missing identity evidence never links entries. Main's ID and access tokens must +agree on both workspace and member email; a disagreement returns 503. Main discovery, group publication +and config persistence hold native-main admission and, when the home exists, the cross-process +shared claim. Main reads use the claim's pinned auth path and bounded regular-file reader. A positively +absent home or valid API-key-only envelope has no ChatGPT main identity: Pool-only grouping proceeds, +and main remains independently addressable by id. An inaccessible existing home, busy claim, malformed +credentials, or unreadable/nonregular/oversized auth file returns 503 before any group publication. +All matching exclusions are set before active-account reconciliation, so a duplicate cannot be the fallback. +The persisted format remains `pausedCodexAccountIds`; routing performs no extra identity reads. +Automatic quota-protection and bulk-exhaustion policies retain their existing per-entry decisions. + ## Automatic pool plan exclusions `src/codex/routing/selection.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 7342ea65071..99202c57eee 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -178,6 +178,9 @@ alone for the rotation gate and fails closed, so only positive evidence changes stable `__main__` alias remains visible for maintenance and quota reads, but is excluded from new affinity, quota rotation, cooldown probes, transient failover, and manual activation. In-flight requests keep their captured credential. An all-paused pool fails closed. +Manual pause/resume synchronizes existing entries with a confirmed matching account and workspace, +including a duplicated main login. [Account operations](openai-accounts.md#manual-account-pause-and-resume) +owns identity matching and publication; automatic quota policies retain their per-entry decisions. The dashboard's bulk pause action refreshes all account quotas and mutates only accounts whose plan-relevant window is freshly confirmed at exactly 100%; unknown and failed refreshes are skipped. diff --git a/tests/codex-integration/codex-linked-account-pause.test.ts b/tests/codex-integration/codex-linked-account-pause.test.ts new file mode 100644 index 00000000000..9996a224ae6 --- /dev/null +++ b/tests/codex-integration/codex-linked-account-pause.test.ts @@ -0,0 +1,327 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { loadConfig } from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { handleCodexAuthAPI, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, resolveCodexAccountForThread } from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { pinnedCodexAccountId, setCodexAccountPin } from "../../src/codex/account-priority"; +import { acquireNativeMainProfileDrain, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { withNativeMainExclusiveClaim } from "../../src/codex/native-main-claim"; +import { MAX_AUTH_BYTES, resolveNativeProfileContext } from "../../src/codex/native-profile-store"; + +let TEST_DIR: string; +let TEST_CODEX_HOME: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai", codexAccounts: [], ...overrides }; +} + +function seedPoolAccount(config: OcxConfig, account: { id: string; email: string; chatgptAccountId?: string }): void { + config.codexAccounts!.push({ id: account.id, email: account.email, isMain: false }); + saveCodexAccountCredential(account.id, { + accessToken: `access-${account.id}`, refreshToken: `refresh-${account.id}`, + expiresAt: Date.now() + 300_000, chatgptAccountId: account.chatgptAccountId ?? `acct-${account.id}`, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-linked-pause-")); + TEST_CODEX_HOME = join(TEST_DIR, "codex"); + mkdirSync(TEST_CODEX_HOME); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.CODEX_HOME = TEST_CODEX_HOME; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + resetLifecycleDrainStateForTests(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearPoolRotationState(); + clearAccountQuota(); + setMainAccountPlan(null); +}); + +afterEach(async () => { + resetLifecycleDrainStateForTests(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearPoolRotationState(); + clearAccountQuota(); + setMainAccountPlan(null); + await flushConfigDirHardeningForTests(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(TEST_DIR); +}); + +for (const selectedId of [MAIN_CODEX_ACCOUNT_ID, "same-login"]) { + test(`manual pause links main and pool identity from ${selectedId}`, async () => { + const config = makeConfig({ activeCodexAccountId: "same-login" }); + const idToken = `header.${Buffer.from(JSON.stringify({ + email: "SAME@example.test", chatgpt_account_id: "personal-scope", + })).toString("base64url")}.signature`; + const auth = JSON.stringify({ tokens: { + access_token: "main-linked-access", account_id: "personal-scope", id_token: idToken, + } }); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), auth); + seedPoolAccount(config, { id: "same-login", email: " same@example.test ", chatgptAccountId: "personal-scope" }); + seedPoolAccount(config, { id: "other-workspace", email: "same@example.test", chatgptAccountId: "team-scope" }); + seedPoolAccount(config, { id: "other-member", email: "other@example.test", chatgptAccountId: "personal-scope" }); + updateAccountQuota("same-login", 1); + updateAccountQuota("other-workspace", 10); + updateAccountQuota("other-member", 20); + setCodexAccountPin(config, "same-login"); + expect(resolveCodexAccountForThread("linked-existing-thread", config)).toBe("same-login"); + + const changePause = async (id: string, paused: boolean) => { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, paused }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + return response!.json(); + }; + const result = await changePause(selectedId, true); + expect(resolveCodexAccountForThread("linked-existing-thread", config)).toBe("other-workspace"); + expect(resolveCodexAccountForThread("linked-new-thread", config)).toBe("other-workspace"); + expect(new Set(config.pausedCodexAccountIds)).toEqual(new Set([MAIN_CODEX_ACCOUNT_ID, "same-login"])); + expect(new Set(result.affectedAccountIds)).toEqual(new Set([MAIN_CODEX_ACCOUNT_ID, "same-login"])); + expect(new Set(loadConfig().pausedCodexAccountIds)).toEqual(new Set(config.pausedCodexAccountIds)); + expect(pinnedCodexAccountId(config)).toBeUndefined(); + expect(readFileSync(join(TEST_CODEX_HOME, "auth.json"), "utf8")).toBe(auth); + expect(getCodexAccountCredential("same-login")?.accessToken).toBe("access-same-login"); + + // Resume through the other card, preserving pauses belonging to unrelated identities. + config.pausedCodexAccountIds!.push("other-member"); + await changePause(selectedId === MAIN_CODEX_ACCOUNT_ID ? "same-login" : MAIN_CODEX_ACCOUNT_ID, false); + expect(config.pausedCodexAccountIds).toEqual(["other-member"]); + expect(loadConfig().pausedCodexAccountIds).toEqual(["other-member"]); + }); +} + +test("manual pause cannot publish a partial identity group while native main is draining", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pause-drain", email: "drain@example.test" }); + const drain = acquireNativeMainProfileDrain("linked-pause-test"); + try { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-drain", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(existsSync(join(TEST_DIR, "config.json"))).toBe(false); + } finally { + drain.release(); + } +}); + +test("manual pause does not link a main login whose email identity is unknown", async () => { + const config = makeConfig(); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-main", account_id: "shared-workspace" }, + })); + seedPoolAccount(config, { id: "known-member", email: "member@example.test", chatgptAccountId: "shared-workspace" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "known-member", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + expect(config.pausedCodexAccountIds).toEqual(["known-member"]); +}); + +test("manual pause refuses an unreadable main identity without changing pause state", async () => { + const config = makeConfig(); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), "invalid-json"); + seedPoolAccount(config, { id: "pause-invalid", email: "invalid@example.test" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-invalid", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); +}); + +test("manual pause respects the cross-process native-main switch claim", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pause-exclusive", email: "exclusive@example.test" }); + await withNativeMainExclusiveClaim(resolveNativeProfileContext(), async () => { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-exclusive", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(existsSync(join(TEST_DIR, "config.json"))).toBe(false); + }); +}); + +test("manual pause refuses conflicting native-main workspace claims", async () => { + const config = makeConfig(); + const idToken = `header.${Buffer.from(JSON.stringify({ + email: "same@example.test", chatgpt_account_id: "different-scope", + })).toString("base64url")}.signature`; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-main", account_id: "wire-scope", id_token: idToken }, + })); + seedPoolAccount(config, { id: "pause-conflict", email: "same@example.test", chatgptAccountId: "different-scope" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-conflict", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); +}); + +test("manual pause refuses main tokens that name different members of one workspace", async () => { + const config = makeConfig(); + const token = (email: string) => `header.${Buffer.from(JSON.stringify({ + email, chatgpt_account_id: "shared-scope", + })).toString("base64url")}.signature`; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: token("bob@example.test"), account_id: "shared-scope", id_token: token("alice@example.test") }, + })); + seedPoolAccount(config, { id: "alice-login", email: "alice@example.test", chatgptAccountId: "shared-scope" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "alice-login", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); +}); + +test("manual pause matches the selected workspace instead of the first organization membership", async () => { + const config = makeConfig(); + const idToken = `header.${Buffer.from(JSON.stringify({ + email: "same@example.test", organizations: [{ id: "other-membership" }], + })).toString("base64url")}.signature`; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-main", account_id: "selected-workspace", id_token: idToken }, + })); + seedPoolAccount(config, { id: "selected-login", email: "same@example.test", chatgptAccountId: "selected-workspace" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: MAIN_CODEX_ACCOUNT_ID, paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + expect(new Set(config.pausedCodexAccountIds)).toEqual(new Set([MAIN_CODEX_ACCOUNT_ID, "selected-login"])); +}); + +for (const mainState of ["missing-home", "api-key-only", "api-key-null-tokens"] as const) { + test(`manual pause and resume group Pool-only identities with ${mainState}`, async () => { + const config = makeConfig(); + if (mainState === "missing-home") { + process.env.CODEX_HOME = join(TEST_DIR, "absent-codex-home"); + } else { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + OPENAI_API_KEY: "test-only-api-key", + ...(mainState === "api-key-null-tokens" ? { auth_mode: "api_key", tokens: null } : {}), + })); + } + seedPoolAccount(config, { id: "pool-only", email: "same@example.test", chatgptAccountId: "pool-scope" }); + seedPoolAccount(config, { id: "pool-copy", email: "SAME@example.test", chatgptAccountId: "pool-scope" }); + seedPoolAccount(config, { id: "pool-other", email: "same@example.test", chatgptAccountId: "other-scope" }); + for (const paused of [true, false]) { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-only", paused }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + expect(new Set((await response!.json()).affectedAccountIds)).toEqual(new Set(["pool-only", "pool-copy"])); + expect(new Set(config.pausedCodexAccountIds)).toEqual(new Set(paused ? ["pool-only", "pool-copy"] : [])); + expect(loadConfig().pausedCodexAccountIds).toEqual(config.pausedCodexAccountIds); + } + // Main remains addressable by id but has no identity to link to the Pool. + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: MAIN_CODEX_ACCOUNT_ID, paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + expect((await response!.json()).affectedAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + if (mainState === "missing-home") expect(existsSync(process.env.CODEX_HOME!)).toBe(false); + }); +} + +for (const mainState of ["oversized", "directory", "invalid-home", "api-key-malformed-tokens", "api-key-chatgpt-mode"] as const) { + test(`manual pause rejects ${mainState} before publication`, async () => { + const config = makeConfig({ activeCodexAccountId: "pause-unsafe" }); + seedPoolAccount(config, { id: "pause-unsafe", email: "unsafe@example.test" }); + setCodexAccountPin(config, "pause-unsafe"); + const before = JSON.stringify(config); + const authPath = join(TEST_CODEX_HOME, "auth.json"); + if (mainState === "directory") mkdirSync(authPath); + else if (mainState === "invalid-home") { + const homePath = join(TEST_DIR, "non-directory-home"); + writeFileSync(homePath, "not-a-directory"); + process.env.CODEX_HOME = homePath; + } else if (mainState === "oversized") { + // Valid ChatGPT JSON would succeed with the old unbounded reader. + writeFileSync(authPath, JSON.stringify({ + tokens: { access_token: "opaque-main", account_id: "main-scope" }, + padding: " ".repeat(MAX_AUTH_BYTES), + })); + } else { + writeFileSync(authPath, JSON.stringify({ + OPENAI_API_KEY: "test-only-api-key", + ...(mainState === "api-key-malformed-tokens" ? { tokens: {} } : { auth_mode: "chatgpt" }), + })); + } + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-unsafe", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(response!.headers.get("Retry-After")).toBe("1"); + expect(JSON.stringify(config)).toBe(before); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(pinnedCodexAccountId(config)).toBe("pause-unsafe"); + expect(existsSync(join(TEST_DIR, "config.json"))).toBe(false); + }); +} + +test("Pool-only pause with an absent home still respects native-main admission", async () => { + process.env.CODEX_HOME = join(TEST_DIR, "absent-codex-home"); + const config = makeConfig(); + seedPoolAccount(config, { id: "absent-drain", email: "drain@example.test" }); + const drain = acquireNativeMainProfileDrain("absent-home-pause-test"); + try { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "absent-drain", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(existsSync(join(TEST_DIR, "config.json"))).toBe(false); + } finally { + drain.release(); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5e648d1b623..5ffa75989e2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -334,6 +334,7 @@ "codex-affinity-debug.test.ts": "codex-integration", "codex-app-server-path-spaces.test.ts": "codex-integration", "codex-app-server-processes.test.ts": "codex-integration", "codex-app-server-restart-service.test.ts": "codex-integration", "codex-auth-api.test.ts": "codex-integration", + "codex-linked-account-pause.test.ts": "codex-integration", "codex-auth-cancel-ownership.test.ts": "codex-integration", "codex-auth-collision.test.ts": "codex-integration", "codex-auth-context.test.ts": "codex-integration", "codex-auth-modal-status.test.ts": "gui", "codex-catalog-admission.test.ts": "codex-integration", "codex-catalog-exclusions.test.ts": "codex-integration",