From 67539603bca17b8bdef69578dcafd00ba632f054 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 12:36:31 +0900 Subject: [PATCH 1/6] docs(devlog): roadmap for L2 pool routing prompt-cache and routing-adoption unit Locks the two fixes for #4546 and #4550 before implementation. --- .../260914_l2_pool_routing_cache/000_unit.md | 36 +++++++++ .../010_cache_safe_rebind.md | 63 +++++++++++++++ .../020_routing_adoption.md | 78 +++++++++++++++++++ .../030_delivery.md | 13 ++++ 4 files changed, 190 insertions(+) create mode 100644 devlog/_plan/260914_l2_pool_routing_cache/000_unit.md create mode 100644 devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md create mode 100644 devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md create mode 100644 devlog/_plan/260914_l2_pool_routing_cache/030_delivery.md diff --git a/devlog/_plan/260914_l2_pool_routing_cache/000_unit.md b/devlog/_plan/260914_l2_pool_routing_cache/000_unit.md new file mode 100644 index 0000000000..65a67ccd47 --- /dev/null +++ b/devlog/_plan/260914_l2_pool_routing_cache/000_unit.md @@ -0,0 +1,36 @@ +# 260914 L2 — pool routing prompt-cache preservation and honest routing status + +Lane R1-L2 of the 260914 delivery round. One pull request against `dev` from +`codex/260914-l2-pool-routing-cache`, closing two issues that both come down to the +same thing: the pool tells the operator one story and does another. + +- #4546 — quota-strategy account rotation moves a **bound** conversation mid-thread, so the + account-isolated prompt-cache prefix is discarded on every turn once the pool is hot. +- #4550 — `ocx status` prints `routing=opencodex-local` read from config on disk, which is the + *configured* route, not the route an already-running Codex client actually adopted. + +## Write scope + +Permitted: `src/codex/routing.ts`, the account-pool / session-affinity code, a new +`src/codex/routing-adoption.ts` leaf, `src/codex/autostart-health.ts` wiring, their tests, +the docs-site configuration reference, and this unit. + +Excluded, owned by concurrent lanes: `src/providers/devin*`, `src/providers/antigravity*`, +`src/server/responses/*`, `src/codex/catalog/*`, `src/adapters/cursor/*`, `gui/`. + +## Verification posture + +Local suite, typecheck, install and GUI build are **not run** for this unit by explicit +instruction. Proof is hosted CI at the exact final head SHA and nothing else. The pull +request states that posture in its Verification section rather than implying a local green. + +## Roadmap + +| Doc | Work phase | Outcome | +| --- | --- | --- | +| `010_cache_safe_rebind.md` | wp1 | A live binding only moves to an account with real headroom (#4546) | +| `020_routing_adoption.md` | wp2 | Status separates configured routing from adopted routing (#4550) | +| `030_delivery.md` | wp3 | One template-filled PR, hosted CI green at the exact final head | + +Implementation is delegated to subagents on `devin/swe-2` and `xai/grok-4.6` at a 2:3 ratio, +each with a disjoint write scope so two writers never hold the same file. diff --git a/devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md b/devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md new file mode 100644 index 0000000000..ffe040f4e8 --- /dev/null +++ b/devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md @@ -0,0 +1,63 @@ +# wp1 — a bound thread may only move to an account that has headroom (#4546) + +## What the code does today + +`resolveCodexAccountForThreadDetailed` reuses a live thread binding and then calls +`reevaluateAffinityQuota`. Under the `quota` strategy that helper computes the bound +account's usage score and asks `mayRebindAffinityForQuota`, whose default answer is +`usage >= autoSwitchThreshold` (80). When that is true it calls `pickLowerUsageAccount`, +which returns whichever eligible account is **strictly cooler** — by any margin at all. + +Two consequences, both reported: + +1. Because `mayRebind` is also what short-circuits the 60 s re-score interval, a thread in the + 80–100 % band is re-scored on **every request**, not once a minute. +2. "Strictly cooler" has no floor. Once every account sits at 95–99 %, the coolest is still + over the threshold, so the thread is handed from account to account on consecutive turns. + +Codex prompt caches are tenant-isolated, so each hop starts from a cold prefix. The reporter +measured a 7k-token turn becoming a 150k-token turn, 1.9 B tokens across 15,607 requests in +about 13 hours on five accounts. + +## The rule to add + +A live binding may only be moved to an account that has **genuine quota headroom** — the same +bar `resetFirstAffinityReplacement` already applies for the `reset-first` strategy via +`hasCodexQuotaHeadroom`. Quota strategy is the outlier, and that asymmetry is the defect. + +Consequences of the new rule, which are what the regression test pins: + +- Every account over the threshold ⇒ no candidate has headroom ⇒ the thread stays put and keeps + its cache. There is nothing to win by moving: the destination is as hot as the origin. +- A cool account exists ⇒ the thread still moves, exactly once, and lands somewhere it can stay. + Movement is now bounded by the number of accounts rather than by the number of turns. +- Nothing changes for an **unbound** request: cascading fresh single-turn work onto the coolest + account is correct, because there is no warm prefix to lose. +- Release paths are untouched. `hasUnrecoveredCodexQuotaRefusal` (429/402) still outranks every + affinity preference, `shouldFailover` still applies, and an exhausted or unusable account still + loses the binding. The rule narrows a *preference*, never a refusal. + +## Where it goes + +The rule exists in two places that the suite asserts answer identically, so both change together: + +- `reevaluateAffinityQuota` — the live resolve path. +- `previewReusableAffinityAccount` — the side-effect-free preview used for subagent fallback. + +Headroom alone is not sufficient, because `hasCodexQuotaHeadroom` deliberately answers **true** +for an account whose usage is unknown — unknown-means-selectable is the right default for an +unbound request. It is the wrong bet for a bound one: trading a warm prefix for an unmeasured +account is a guess, not an improvement. So the candidate must clear both bars, headroom **and** +strictly lower usage than the bound account. `CODEX_UNKNOWN_USAGE_SCORE` is 101, so an +unobserved account can never be strictly cooler than a known over-threshold score and the second +bar excludes it without a special case. An unknown-usage *bound* account never rebinds today +either, because `mayRebindAffinityForQuota` requires a known score. + +## Regression test + +Next to the existing pool-rotation tests in `tests/codex-integration/`. Three cases: + +1. All accounts over the threshold: the bound thread's account is unchanged across repeated + resolves — the ping-pong case, which fails before the fix. +2. One account below the threshold: the bound thread moves to it once, then stays. +3. Preview agrees with resolve in both situations. diff --git a/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md b/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md new file mode 100644 index 0000000000..d6ce0e999f --- /dev/null +++ b/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md @@ -0,0 +1,78 @@ +# wp2 — configured routing is not adopted routing (#4550) + +## What the report establishes + +A Codex CLI thread kept returning `usage_limit_exceeded` while a healthy secondary account sat +in the pool. Thread-correlated diagnostics show those turns dialing +`wss://chatgpt.com/backend-api/codex/responses` directly, with no matching proxy usage record, +while `ocx status` reported `routing=opencodex-local`. The reporter's own leading hypothesis is +retained pre-injection configuration: the process started before the route was written and has +been holding the old one ever since. + +The report asks for either transport interception or an honest status. Interception is not +available to us — a client process that already resolved its endpoint is beyond the proxy's +reach, and restarting it is the operator's call. So the defect we can actually fix is the +status: it presents a fact about **config on disk** as a fact about **live traffic**. + +## What status knows today + +`getCodexRoutingKind()` classifies `~/.codex/config.toml` and `deriveStartupHealth` turns +`opencodex-local` into `routingInjected: true`. `formatStartupRoutingDetail` prints +`routing=, service=…, shim=…`. Every input is a file read. No part of that chain can +distinguish a client that adopted the route from one that predates it. + +## The evidence we do have + +Both halves already exist in this repository: + +- **When the route was written.** `src/codex/journal.ts` records our injection and stamps it, + and the journal file is rewritten on every injection, so the newer of the recorded timestamp + and the journal's mtime bounds when the current route became effective. +- **When each client started.** `src/codex/app-server-processes.ts` already enumerates + processes cross-platform and reads start times (`readProcessStartMs`, + `readProcessStartMsBatch`, `/proc//stat` on Linux, `ps -o lstart` on macOS, + `Win32_Process.CreationDate` on Windows), and its `ProcessSnapshot` already carries an + optional `startedAtMs`. +- **Which processes are Codex clients.** `src/codex/native-profile-processes.ts` carries the + matching rules — direct `codex` basenames plus interpreter-wrapped `node|bun codex.js` + entrypoints — but they are private and reachable only through a *count*. The count is enough to + answer "is Codex busy" and not enough to name a stale PID, so those rules are extracted into an + exported predicate and the existing counter is rewired through it. Copying them into a second + module is how `#2457` happened; one predicate, two callers. + +A Codex client whose start time precedes the injection cannot have read the injected route. +That is a sound inference, and it is the one the operator needed. + +## Shape + +A new leaf module `src/codex/routing-adoption.ts`, so the derivation is pure and testable and +the wiring into shared files stays small: + +```ts +type RoutingAdoption = "not-applicable" | "adopted" | "pending-client-restart" | "unknown"; +deriveRoutingAdoption({ routingKind, injectedAtMs, clients }): RoutingAdoptionEvidence +collectRoutingAdoption(...): RoutingAdoptionEvidence // journal + process enumeration +``` + +- `not-applicable` — routing is not ours to speak for (native or custom). +- `adopted` — routing is `opencodex-local` and every running Codex client started after the + injection. This is still an inference about *opportunity*, not a traffic observation, and the + wording must not overclaim. +- `pending-client-restart` — at least one running Codex client predates the injection. +- `unknown` — no injection time, or process start times unreadable. Enumeration failure reports + `unknown`; it never invents a clean bill of health, matching the `#476` restart contract. + +Clock coarseness matters: `ps lstart` is second-granularity, and `app-server-processes.ts` +already documents why its equivalent comparison uses `<=`. A client started in the same second +as the injection is treated as **not** stale, so a rounding artifact cannot produce a false +warning. + +`formatStartupRoutingDetail` gains an adoption token, and the summary names the concrete +action — restart the affected client — rather than only the routing kind. + +## Regression test + +A pure-derivation test: a pre-injection client yields `pending-client-restart` with that PID +listed; a post-injection client yields `adopted`; a missing injection time or an unreadable +start time yields `unknown`; a same-second start is not stale; a non-opencodex routing kind is +`not-applicable`; and the formatted detail string differs between configured and adopted. diff --git a/devlog/_plan/260914_l2_pool_routing_cache/030_delivery.md b/devlog/_plan/260914_l2_pool_routing_cache/030_delivery.md new file mode 100644 index 0000000000..882b065fda --- /dev/null +++ b/devlog/_plan/260914_l2_pool_routing_cache/030_delivery.md @@ -0,0 +1,13 @@ +# wp3 — delivery and proof + +One pull request against `dev`, filled to `.github/PULL_REQUEST_TEMPLATE.md`, with +`Closes #4546` and `Closes #4550`. Pushed with `git push --no-verify`. No merge: the parent +session performs the admin squash merge. + +Proof is hosted CI at the exact final head SHA. The Verification section states that the local +suite, typecheck, install and GUI build were **NOT RUN** for this unit, names the hosted run id, +and reports its conclusion at that SHA. A green run at an earlier head is not proof for a later +one, so any follow-up commit resets the evidence and the new head's run is what gets reported. + +Because `enforce-target` resets the contributor readiness checklist on every push, the head +SHA is captured after the final commit, not before. From f7b16edf05701db3db69615caf62fcfffd37af86 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 12:54:25 +0900 Subject: [PATCH 2/6] fix(codex): keep a bound thread's prompt cache when the pool is hot Under the quota account-pool strategy a live thread binding could be moved to any strictly-cooler eligible account once its account crossed autoSwitchThreshold. "Strictly cooler" had no floor, so once every account sat in the 80-100% band the coolest was still over the threshold and a long-running conversation was handed from account to account on consecutive turns. Codex prompt caches are account-isolated, so each hop restarted from a cold prefix. A live binding may now only move to an account that clears the same bar reset-first already applied through hasCodexQuotaHeadroom, and that is also strictly cooler than the bound account. Headroom alone is not enough, because that predicate answers true for unknown usage, which is the right default for an unbound pick and a guess when a warm prefix is at stake. Movement is now bounded by the number of accounts rather than the number of turns, and every release path is untouched: a 429/402 refusal, failover, exhaustion, pause and generation checks all still drop the binding before the preference rule is consulted. Refs #4546 --- .../fr/reference/configuration/providers.md | 8 +-- .../ja/reference/configuration/providers.md | 8 +-- .../ko/reference/configuration/providers.md | 8 +-- .../docs/reference/configuration/providers.md | 8 +-- .../ru/reference/configuration/providers.md | 8 +-- .../tr/reference/configuration/providers.md | 8 +-- .../reference/configuration/providers.md | 8 +-- .../reference/configuration/providers.md | 8 +-- src/codex/routing.ts | 67 ++++++++++++++++--- 9 files changed, 90 insertions(+), 41 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index e642700298..ec4613a3be 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -38,8 +38,8 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `activeCodexAccountId?` | `string` | — | Compte de pool sélectionné manuellement pour la prochaine demande. La sélection efface l'affinité des threads ; les demandes en cours conservent les informations d’identification capturées. | | `codexAccountPriorities?` | `Record` | — | Ordre de sélection par compte pour le pool Codex : identifiant de compte → entier de `-100` à `100`, **les valeurs élevées sont prioritaires**, une valeur absente équivaut à `0`. Cette limite porte sur le classement, et non sur l'admissibilité : la sélection retient, parmi les comptes déjà admissibles, le niveau prioritaire le plus élevé qui dispose encore d'une marge de quota, puis `accountPoolStrategy` choisit un compte dans ce niveau. Un niveau est ignoré uniquement lorsque chacun de ses membres dépasse `autoSwitchThreshold`, est en temporisation, est temporairement évité, est suspendu ou doit être réauthentifié ; un quota inconnu ne suffit jamais à considérer un niveau comme épuisé. L'ordre ne rend jamais admissible un compte qui ne l'est pas et ne réaffecte jamais une tâche déjà liée à un compte. Le compte principal `__main__` participe selon les mêmes règles ; la connexion Codex Desktop peut ainsi être configurée pour être utilisée en dernier. Sans entrée, le pool se comporte exactement comme auparavant. Un mappage mal formé est ignoré avec un avertissement dans la console : l'ordre est désactivé et la configuration n'est pas réparée. Ce champ est géré par `ocx account priority` et la page Codex Auth. | | `activeCodexAccountPinned?` | `string` | — | Identifiant du compte du dernier opérateur sélectionné manuellement. Lorsqu'il est défini, un niveau `codexAccountPriorities` supérieur ne peut pas le préempter jusqu'à ce que la broche soit libérée par drainage, exclusion, suppression ou un failover/promotion explicite. Un mouvement circulaire ordinaire à l’intérieur du niveau plafonné ne le libère pas. L'écriture d'une entrée `codexAccountPriorities` libère également le pin, donc un pin créé avant qu'un ordre n'existe ne peut pas surpasser un ensemble par la suite. `GET /api/codex-auth/active` indique à la fois si le compte effectif est épinglé (`pinned`) et le compte portant le plafond (`pinnedAccountId`). | -| `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. `reset-first`: Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation. Les resets mensuels ne déterminent pas cet ordre. | +| `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi, en les déplaçant uniquement vers un compte admissible qui dispose encore d'une marge sous le seuil. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible qui dispose encore d'une marge sous le seuil. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. `reset-first`: Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation. Les resets mensuels ne déterminent pas cet ordre. | | `pool.cacheAffinity?` | `boolean` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc un réordonnancement, pas un verrouillage. | | `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. | @@ -184,7 +184,7 @@ Utilisez **Codex Auth** dans le tableau de bord pour ajouter des comptes au grou métadonnées non secrètes ; les jetons d'accès et d'actualisation utilisent le magasin d'identifiants renforcé. Le routage du pool distingue l'affectation des requêtes nouvelles ou non liées, la commutation proactive fondée sur l'utilisation et la récupération après incident. Une tâche liée conserve normalement son affinité. Par défaut, `quota` peut la relier lors de sa requête suivante une fois le seuil d'utilisation -franchi ; avec `pool.cacheAffinity` activé, cette réaffectation attend que le compte lié soit épuisé ou ne puisse plus servir. La suspension, la temporisation, la réauthentification et la gestion des échecs peuvent, indépendamment, effacer ou déplacer son routage. +franchi, et uniquement vers un compte admissible qui dispose encore d'une marge sous le seuil ; avec `pool.cacheAffinity` activé, cette réaffectation attend que le compte lié soit épuisé ou ne puisse plus servir. La suspension, la temporisation, la réauthentification et la gestion des échecs peuvent, indépendamment, effacer ou déplacer son routage. Une requête non liée ne possède aucune liaison active à un compte ; il peut s'agir d'une tâche existante visible après le redémarrage du proxy ou la réinitialisation de l'affinité. Un 429 ou un 402 reçu avant le début de la diffusion déclenche une nouvelle tentative unique sur un autre compte admissible au sein de la même requête, même lorsque la commutation proactive fondée sur l'utilisation est désactivée. Les changements de @@ -204,7 +204,7 @@ et suspend uniquement ceux dont l'utilisation vient d'être confirmée à 100 % | Stratégie | Comportement | | --- | --- | -| `quota` (par défaut) | S'il n'existe aucun compte actif, choisir le compte admissible le moins utilisé selon les fenêtres de 5 heures, d'une semaine et de 30 jours. Sinon, conserver un compte actif admissible sous `autoSwitchThreshold` ; une fois le seuil franchi, une requête non liée peut être déplacée vers un compte admissible moins utilisé, et la requête suivante d'une tâche liée aussi sauf si `pool.cacheAffinity` est activé. Avec ce drapeau, l'affinité de cache prime sur la marge de quota et la tâche liée reste jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir (suspendu, inutilisable). `0` désactive cette réévaluation fondée sur l'utilisation, mais pas la récupération après incident. | +| `quota` (par défaut) | S'il n'existe aucun compte actif, choisir le compte admissible le moins utilisé selon les fenêtres de 5 heures, d'une semaine et de 30 jours. Sinon, conserver un compte actif admissible sous `autoSwitchThreshold` ; une fois le seuil franchi, une requête non liée peut être déplacée vers un compte admissible moins utilisé, et la requête suivante d'une tâche liée vers un compte admissible qui dispose encore d'une marge sous le seuil sauf si `pool.cacheAffinity` est activé. Avec ce drapeau, l'affinité de cache prime sur la marge de quota et la tâche liée reste jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir (suspendu, inutilisable). `0` désactive cette réévaluation fondée sur l'utilisation, mais pas la récupération après incident. | | `round-robin` | Répartit uniformément les requêtes non liées entre les comptes admissibles. `autoSwitchThreshold` ne modifie pas la sélection circulaire normale. `accountPoolStickyLimit` (1–100) compte les affectations effectuées avec une même sélection, et non les réponses réussies en amont. | | `fill-first` | Attribue les requêtes non liées au compte actif jusqu'à sa temporisation, sa réauthentification ou le seuil d'évacuation configuré ; une utilisation inconnue n'impose pas de changement. Les tâches liées et saines conservent leur affinité. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 31451fa18c..221980eb23 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -36,8 +36,8 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `codexAccountPickerEnabled?` | `boolean` | map が空なら off | 有効な `codexAccountNamespaces` mapping から account-qualified Codex picker row を生成するかを制御します。`true` は mapping された行の表示を許可します。空でない map で省略した場合は後方互換性のため有効として扱われ、map が空なら off です。`false` は mapping を削除せず、明示的な `/` routing も無効にせずに、生成行を非表示にして picker の bare native 行を復元します。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が `autoSwitchThreshold` 超過、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 | -| `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | +| `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価し、しきい値未満の余裕が残っている適格アカウントへだけ移します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも、しきい値未満の余裕が残っている適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | | `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | @@ -163,7 +163,7 @@ Clash / Surge / Mihomo 利用者向けの fake-IP DNS 例外は 2 種類あり pool アカウントの追加と quota 更新はダッシュボードの **Codex Auth** ページで処理してください。設定には secret で ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。Pool routing は新規/未紐付け割り当て、使用量ベースのプロアクティブ切り替え、障害回復に分かれます。 -紐付け済みタスクは通常 affinity を維持します。既定では `quota` はしきい値超過後の次のリクエストで再紐付けでき、 +紐付け済みタスクは通常 affinity を維持します。既定では `quota` はしきい値超過後の次のリクエストで、しきい値未満の余裕が残っている適格アカウントへだけ再紐付けでき、 `pool.cacheAffinity` がオンなら、紐付け先アカウントが使い切られるか処理できなくなるまでその再紐付けを延期します。 pause、cooldown、再認証、障害処理も独立して routing を消去または変更できます。未紐付けリクエストには プロキシ再起動や affinity リセット後の既存タスクも含まれます。出力前の **429/402** は使用量ベースの @@ -178,7 +178,7 @@ pause、cooldown、再認証、障害処理も独立して routing を消去ま 別の適格な Pool アカウントへリクエストを切り替えることがあります。これらの障害回復は `autoSwitchThreshold: 0` でも有効であり、`0` が無効にするのは使用量に基づく予防的な切り替えだけです。 -**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも再紐付けできます。オンなら cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は +**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも、しきい値未満の余裕が残っている適格アカウントへ再紐付けできます。オンなら cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は 未紐付けリクエストを均等分散し、しきい値は通常の rotation を変えません。`accountPoolStickyLimit` (既定 `1`、1–100)は成功応答ではなく割り当て/紐付け数を数えます。`fill-first` は未紐付けリクエストを cooldown、再認証、または drain threshold までアクティブアカウントへ割り当て、正常な紐付け済みタスクは diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index dae4836490..f8a4660b71 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -36,8 +36,8 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `codexAccountPickerEnabled?` | `boolean` | map이 비어 있으면 꺼짐 | 유효한 `codexAccountNamespaces` 매핑에서 account-qualified Codex 선택기 행을 생성할지 제어합니다. `true`는 매핑된 행의 표시를 허용합니다. 비어 있지 않은 map에서 생략하면 이전 버전과의 호환성을 위해 활성화된 것으로 취급되며, map이 비어 있으면 꺼집니다. `false`는 매핑을 삭제하거나 명시적 `/` 라우팅을 비활성화하지 않은 채 생성 행을 숨기고 선택기에 bare native 행을 복원합니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 `autoSwitchThreshold` 초과, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. | -| `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | +| `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가하며, 임계값 미만의 여유가 남은 적격 계정으로만 옮깁니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 임계값 미만의 여유가 남은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | | `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | @@ -164,7 +164,7 @@ pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. Pool 라우팅은 새 작업/바인딩 없는 작업 배정, 사용량 기반 선제 전환, 실패 복구로 구분됩니다. 바인딩된 작업은 보통 affinity를 유지합니다. 기본값에서 `quota`는 사용량 임계값을 넘은 뒤 -다음 요청에서 재바인딩할 수 있고, `pool.cacheAffinity`가 켜져 있으면 바인딩된 계정이 소진되었거나 +다음 요청에서, 임계값 미만의 여유가 남은 적격 계정으로만 재바인딩할 수 있고, `pool.cacheAffinity`가 켜져 있으면 바인딩된 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 그 재바인딩을 미룹니다. 일시 중지, cooldown, 재인증, 실패 처리도 독립적으로 라우팅을 지우거나 바꿀 수 있습니다. 바인딩 없는 요청은 live 계정 바인딩이 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 전 **429/402**는 사용량 기반 선제 @@ -181,7 +181,7 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 `autoSwitchThreshold: 0`에서도 계속 작동하며, `0`은 사용량 기반 선제 전환만 비활성화합니다. **배정 및 선제 전환 전략:** `quota`(기본)는 활성 계정이 없을 때 최저 usage의 적격 계정을 선택하고, -적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. 플래그가 켜져 있으면 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지됩니다. +적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 임계값 미만의 여유가 남은 적격 계정으로 옮길 수 있습니다. 플래그가 켜져 있으면 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하며 임계값은 기본 순환에 영향을 주지 않습니다. `accountPoolStickyLimit`(기본 `1`, 1–100)은 성공 응답이 아니라 배정/바인딩 횟수를 셉니다. `fill-first`는 바인딩 없는 요청을 cooldown, 재인증 또는 drain threshold까지 활성 계정에 배정하고, diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a1f69c8a4c..705c28a6f4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -51,8 +51,8 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is over `autoSwitchThreshold`, cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | | `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). | -| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. | +| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold, moving them only to an eligible account that still has headroom below the threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to an eligible account that still has headroom below the threshold. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. | | `pool.cacheAffinity?` | `boolean` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, a live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — so affinity is a reordering, not a pin. | | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | @@ -539,7 +539,7 @@ Use **Codex Auth** in the dashboard to add pool accounts and refresh quotas. `co non-secret metadata; access and refresh tokens use the hardened credential store. Pool routing separates new/unbound assignment, usage-based proactive switching, and failure recovery. A bound task normally keeps affinity. By default `quota` may rebind it on its next request after the usage -threshold is crossed; with `pool.cacheAffinity` on, that rebind waits until the bound account is +threshold is crossed, and only to an eligible account that still has headroom below the threshold; with `pool.cacheAffinity` on, that rebind waits until the bound account is exhausted or otherwise cannot serve. Pause, cooldown, reauthentication, and failure handling can clear or move routing independently. An unbound request has no live account binding; this can include an existing visible task after proxy restart or affinity reset. A pre-stream 429 or 402, or a 5xx response whose bounded @@ -562,7 +562,7 @@ and pauses only accounts freshly confirmed at 100%; unknown or failed refreshes | Strategy | Behaviour | | --- | --- | -| `quota` (default) | If no active account exists, choose the lowest-usage eligible account across 5-hour, weekly, and 30-day windows. Otherwise retain an eligible active account below `autoSwitchThreshold`; after it crosses the threshold, an unbound request can move to a lower-usage eligible account, and a bound task's next request can too unless `pool.cacheAffinity` is on. With that flag on, cache affinity outranks quota headroom and the bound task stays until the account is exhausted (known usage at 100%) or cannot serve (paused, unusable). `0` disables this usage-driven re-evaluation, not failure recovery. | +| `quota` (default) | If no active account exists, choose the lowest-usage eligible account across 5-hour, weekly, and 30-day windows. Otherwise retain an eligible active account below `autoSwitchThreshold`; after it crosses the threshold, an unbound request can move to a lower-usage eligible account, and a bound task's next request can move to an eligible account that still has headroom below the threshold unless `pool.cacheAffinity` is on. With that flag on, cache affinity outranks quota headroom and the bound task stays until the account is exhausted (known usage at 100%) or cannot serve (paused, unusable). `0` disables this usage-driven re-evaluation, not failure recovery. | | `round-robin` | Evenly assign unbound requests across eligible accounts. `autoSwitchThreshold` does not change normal round-robin selection. `accountPoolStickyLimit` (1–100) counts assignments on one pick, not successful upstream responses. | | `fill-first` | Assign unbound requests to the active account until cooldown, reauthentication, or the configured drain threshold; unknown usage does not force a switch. Healthy bound tasks keep affinity. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index d471f31c25..ff4833b339 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -37,8 +37,8 @@ ocx models provider openrouter on | `codexAccountPickerEnabled?` | `boolean` | выкл. при пустой map | Управляет созданием account-qualified строк picker'а Codex из подходящих сопоставлений `codexAccountNamespaces`. `true` разрешает показывать сопоставленные строки. Если поле не задано при непустой map, функция считается включённой для обратной совместимости; при пустой map она выключена. `false` скрывает созданные строки и возвращает bare native-строки в picker, не удаляя сопоставления и не отключая точную маршрутизацию `/`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты превысили `autoSwitchThreshold`, находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. | -| `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | +| `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог, перенося её только на подходящий аккаунт, у которого ещё есть запас ниже порога. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт, у которого ещё есть запас ниже порога. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | | `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold`. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | @@ -193,7 +193,7 @@ redirect'ов для обычных provider-request'ов реализована защищённом хранилище учётных данных аккаунтов Codex. Pool routing разделяет назначение новых/непривязанных задач, проактивное переключение по использованию и восстановление после сбоев. Привязанная задача обычно сохраняет affinity. По умолчанию `quota` может перепривязать её при следующем -запросе после превышения порога; при включённом `pool.cacheAffinity` эта перепривязка ждёт, пока +запросе после превышения порога, и только на подходящий аккаунт, у которого ещё есть запас ниже порога; при включённом `pool.cacheAffinity` эта перепривязка ждёт, пока привязанный аккаунт не будет исчерпан или не сможет обслуживать запрос. Pause, cooldown, повторная аутентификация и обработка сбоев также могут независимо очистить или изменить routing. Непривязанным может стать и существующая задача после перезапуска прокси или сброса affinity. Отказ **429/402** до вывода допускает одну попытку @@ -211,7 +211,7 @@ redirect'ов для обычных provider-request'ов реализована после чего запрос может перейти на другой подходящий аккаунт Pool. Эти переходы восстановления остаются активными при `autoSwitchThreshold: 0`; значение `0` отключает только проактивное переключение по использованию. -**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, cache affinity важнее запаса квоты, и привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы, а порог не +**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт, у которого ещё есть запас ниже порога. Если флаг включён, cache affinity важнее запаса квоты, и привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы, а порог не меняет обычную ротацию. `accountPoolStickyLimit` (по умолчанию `1`, 1–100) считает назначения/bind, а не успешные ответы. `fill-first` назначает непривязанные запросы активному аккаунту до cooldown, reauth или порога исчерпания; здоровые привязанные задачи сохраняют affinity. Эти стратегии не diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 05cb47c56e..2306e9318e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -38,8 +38,8 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `activeCodexAccountId?` | `string` | — | Sonraki istek için manuel olarak seçilen Havuz hesabı. Seçim iş parçacığı bağlılığını temizler; devam eden istekler yakalanan kimlik bilgilerini korur. | | `codexAccountPriorities?` | `Record` | — | Codex havuzu için hesap başına seçim sırası: hesap kimliği → `-100` ile `100` arası tam sayı, **daha yüksek olan daha önce kullanılır**, yoksa `0` anlamına gelir. Bu bir öncelik sırası sınırıdır, bir uygunluk sınırı değildir: seçim, zaten uygun olan hesapları hala kota payı bulunan en yüksek katmana daraltır ve `accountPoolStrategy` daha sonra bu katman içinde seçim yapar. Bir katman, yalnızca her üye `autoSwitchThreshold` üzerinde olduğunda, soğumada olduğunda, yumuşak kaçınıldığında, duraklatıldığında veya yeniden kimlik doğrulama gerektiğinde atlanır — bilinmeyen kota asla bir katmanı boşaltmaz. Sıralama asla uygun olmayan bir hesabı seçilebilir yapmaz ve zaten bir hesabı olan bir iş parçacığını asla yeniden bağlamaz. Ana `__main__` hesap eşit şartlarda katılır, bu sayede Codex Desktop girişi en son tükenecek şekilde ayarlanabilir. Hiçbir girdi olmadığında havuz tam olarak eskisi gibi davranır. Hatalı biçimlendirilmiş bir harita bir konsol uyarısıyla yok sayılır (sıralama kapalı, yapılandırma onarımı yok). `ocx account priority` ve Codex Auth sayfası tarafından yönetilir. | | `activeCodexAccountPinned?` | `string` | — | Operatörün en son elle seçtiği hesap kimliği. Ayarlandığı sürece, pin tükenme, hariç tutma, silme veya açık bir yük devretme/yükseltme ile serbest bırakılana kadar daha yüksek bir `codexAccountPriorities` katmanı onu öncelikleyemez. Sınırlı katman içindeki sıradan round-robin hareketi onu serbest bırakmaz. Herhangi bir `codexAccountPriorities` girdisi yazmak da pini serbest bırakır, böylece bir sıra var olmadan önce yapılan bir pin daha sonra ayarlanan bir pinin önüne geçemez. `GET /api/codex-auth/active`, hem geçerli hesabın sabitlenip sabitlenmediğini (`pinned`) hem de tavanı taşıyan hesabı (`pinnedAccountId`) bildirir. | -| `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. `reset-first`: Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır. Aylık sıfırlamalar bu sıralamayı belirlemez. | +| `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir; yalnızca eşiğin altında hâlâ kota payı olan uygun bir hesaba taşır. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak eşiğin altında hâlâ kota payı olan uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. `reset-first`: Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır. Aylık sıfırlamalar bu sıralamayı belirlemez. | | `pool.cacheAffinity?` | `boolean` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; bağlılık bir sabitleme değil yeniden sıralamadır. | | `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. | @@ -198,7 +198,7 @@ Auth** kullanın. `config.json` gizli olmayan meta verileri saklar; erişim ve yenileme belirteçleri güçlendirilmiş kimlik bilgisi deposunu kullanır. Havuz yönlendirmesi yeni/bağımsız atamayı, kullanıma dayalı proaktif geçişi ve arıza kurtarmayı ayırır. Bağlı bir görev normalde bağlılığı korur. Varsayılan olarak -`quota`, kullanım eşiği aşıldıktan sonraki isteğinde onu yeniden bağlayabilir; +`quota`, kullanım eşiği aşıldıktan sonraki isteğinde onu yalnızca eşiğin altında hâlâ kota payı olan uygun bir hesaba yeniden bağlayabilir; `pool.cacheAffinity` açıkken bu yeniden bağlama, bağlı hesap tükenene veya hizmet veremez hale gelene kadar bekler. Duraklatma, soğuma, yeniden kimlik doğrulama ve arıza işleme ise yönlendirmeyi bağımsız olarak temizleyebilir veya @@ -230,7 +230,7 @@ kalır. | Strateji | Davranış | | --- | --- | -| `quota` (varsayılan) | Aktif bir hesap yoksa 5 saatlik, haftalık ve 30 günlük pencerelerde en düşük kullanımlı uygun hesabı seçin. Aksi takdirde `autoSwitchThreshold` altında uygun bir aktif hesabı tutun; eşiği aştıktan sonra bağımsız bir istek daha düşük kullanımlı uygun bir hesaba geçebilir ve `pool.cacheAffinity` kapalıysa bağlı bir görevin bir sonraki isteği de geçebilir. Bayrak açıkken önbellek bağlılığı kota payından öndedir ve bağlı görev, hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene (duraklatılmış, kullanılamaz) kadar kalır. `0`, bu kullanım odaklı yeniden değerlendirmeyi devre dışı bırakır, arıza kurtarmayı devre dışı bırakmaz. | +| `quota` (varsayılan) | Aktif bir hesap yoksa 5 saatlik, haftalık ve 30 günlük pencerelerde en düşük kullanımlı uygun hesabı seçin. Aksi takdirde `autoSwitchThreshold` altında uygun bir aktif hesabı tutun; eşiği aştıktan sonra bağımsız bir istek daha düşük kullanımlı uygun bir hesaba geçebilir ve `pool.cacheAffinity` kapalıysa bağlı bir görevin bir sonraki isteği eşiğin altında hâlâ kota payı olan uygun bir hesaba geçebilir. Bayrak açıkken önbellek bağlılığı kota payından öndedir ve bağlı görev, hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene (duraklatılmış, kullanılamaz) kadar kalır. `0`, bu kullanım odaklı yeniden değerlendirmeyi devre dışı bırakır, arıza kurtarmayı devre dışı bırakmaz. | | `round-robin` | Bağımsız istekleri uygun hesaplar arasında eşit olarak atayın. `autoSwitchThreshold` normal round-robin seçimini değiştirmez. `accountPoolStickyLimit` (1–100), başarılı yukarı akış yanıtlarını değil, bir seçimdeki atamaları sayar. | | `fill-first` | Bağımsız istekleri soğuma, yeniden kimlik doğrulama veya yapılandırılmış tükenme eşiğine kadar aktif hesaba atayın; bilinmeyen kullanım geçişe zorlamaz. Sağlıklı bağlı görevler bağlılığı korur. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 62e8d4447c..cd8fcacd71 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 @@ -36,8 +36,8 @@ ocx models provider openrouter on | `codexAccountPickerEnabled?` | `boolean` | 映射为空时关闭 | 控制是否根据有效的 `codexAccountNamespaces` 映射生成账户限定的 Codex 选择器行。`true` 允许显示映射行。在非空映射中省略此字段时,为保持向后兼容会视为已启用;映射为空时则关闭。`false` 会隐藏生成行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都超过 `autoSwitchThreshold`、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 | -| `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | +| `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务,且仅切到仍有低于阈值余量的合格账号。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切到仍有低于阈值余量的合格账号。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | | `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | @@ -163,7 +163,7 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。Pool routing 分为新建/未绑定任务分配、基于用量的主动切换和故障恢复。已绑定任务通常保持 affinity。默认情况下 -`quota` 可在超过阈值后的下一次请求中重新绑定;开启 `pool.cacheAffinity` 后,该重新绑定会等到 +`quota` 可在超过阈值后的下一次请求中重新绑定到仍有低于阈值余量的合格账号;开启 `pool.cacheAffinity` 后,该重新绑定会等到 绑定账号耗尽或无法继续服务。暂停、cooldown、重新认证和故障处理也能独立清除或改变 routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 affinity 重置后的已有任务。输出前的 **429/402** 即使在关闭基于用量的主动切换时,也可在同一请求中对合格替代账号重试一次。 @@ -177,7 +177,7 @@ routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 并可将请求切换到另一个符合条件的 Pool 账户。即使 `autoSwitchThreshold: 0`, 这些故障恢复流程仍然有效;`0` 只会禁用基于用量的主动切换。 -**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,cache affinity 优先于 quota 余量,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求,用量 +**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切到仍有低于阈值余量的合格账号。开启后,cache affinity 优先于 quota 余量,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求,用量 阈值不会改变正常轮换。`accountPoolStickyLimit`(默认 `1`,1–100)统计分配/绑定,而不是成功响应。 `fill-first` 在 cooldown、重新认证或耗尽阈值前把未绑定请求分配给活跃账号;健康的已绑定任务保持 affinity。这些策略不能规避 provider enforcement。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index a5ca056c18..1f52d93a7b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -34,8 +34,8 @@ ocx models provider openrouter on | `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | -| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | +| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務,並僅移至仍有低於閾值餘裕的合格帳號。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動將綁定任務重新綁定到仍有低於閾值餘裕的合格帳號。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | | `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | @@ -130,7 +130,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 ## Codex 帳號池 -在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性。預設下 `quota` 可在超過用量閾值後的下一個請求時重新綁定它;開啟 `pool.cacheAffinity` 後,該重新綁定會等到綁定帳號耗盡或無法繼續服務。暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 +在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性。預設下 `quota` 可在超過用量閾值後的下一個請求時,將它重新綁定到仍有低於閾值餘裕的合格帳號;開啟 `pool.cacheAffinity` 後,該重新綁定會等到綁定帳號耗盡或無法繼續服務。暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 在 **401/403** 時,App 登入清除該帳號的行程本地親和性並要求重新認證。 在 **429** 時,opencodex 遵循 `Retry-After`、啟動帳號冷卻、清除親和性,並可能將請求輪換到另一個合格的池帳號。這些失敗轉換在 `autoSwitchThreshold: 0` 時仍然活躍;該設定僅停用基於用量的主動切換。 @@ -139,7 +139,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | 策略 | 行為 | | --- | --- | -| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求可移至較低用量的合格帳號,未開啟 `pool.cacheAffinity` 時綁定任務的下一個請求也可。開啟後,cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`0` 停用此用量驅動的重新評估,而非失敗復原。 | +| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求可移至較低用量的合格帳號,未開啟 `pool.cacheAffinity` 時綁定任務的下一個請求也可移至仍有低於閾值餘裕的合格帳號。開啟後,cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`0` 停用此用量驅動的重新評估,而非失敗復原。 | | `round-robin` | 在合格帳號間均勻指派未綁定請求。`autoSwitchThreshold` 不變更一般 round-robin 選擇。`accountPoolStickyLimit`(1–100)計數一次選擇上的指派,而非成功的上游回應。 | | `fill-first` | 將未綁定請求指派到現用帳號直到冷卻、重新認證或設定的排空閾值;未知用量不強制切換。健康的綁定任務保留親和性。 | diff --git a/src/codex/routing.ts b/src/codex/routing.ts index ae6e5d792a..7a07ccbabd 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2205,16 +2205,15 @@ function previewReusableAffinityAccount( // Preview must agree with resolve: this is the second copy of the same rule, and the // suite asserts the two answer identically. if (mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) { - const best = pickLowerUsageAccount( + const best = pickCacheSafeQuotaReplacement( config, entry.accountId, usage, now, quotaScope, selectionOptions, - true, ); - if (best !== entry.accountId) return best; + if (best) return best; } } } @@ -2265,8 +2264,57 @@ function resetFirstAffinityReplacement( } /** - * Re-evaluate an affined account under the quota strategy. Returns a strictly - * cooler replacement, or null when the current binding should remain. + * Quota-strategy replacement for a LIVE binding (#4546). + * + * "Strictly cooler by any margin" — what {@link pickLowerUsageAccount} answers — is the + * right rule for an unbound request and the wrong one for a bound thread. Once every + * account sits in the threshold band the coolest is still over it, so a long-running + * conversation was handed from account to account on consecutive turns. Codex prompt + * caches are account-isolated, so each hop restarted from a cold prefix; the reporter + * measured 7k-token turns becoming 150k-token turns. + * + * The destination must clear the same bar {@link resetFirstAffinityReplacement} already + * applies — genuine headroom via {@link hasCodexQuotaHeadroom} — AND be strictly cooler + * than the bound account. Headroom alone is not sufficient: that predicate deliberately + * answers true for unknown usage, which is the right default for an unbound pick but a + * guess when a warm prefix is at stake. `CODEX_UNKNOWN_USAGE_SCORE` is 101, so an + * unobserved account can never be strictly cooler than a known over-threshold score and + * the second bar excludes it without a special case. + * + * This narrows a preference, never a refusal: callers release the binding on a 429/402, + * failover, or exhaustion before this helper is consulted, so a thread cannot be wedged + * on an account that cannot serve. + */ +function pickCacheSafeQuotaReplacement( + config: OcxConfig, + boundAccountId: string, + boundUsage: number, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const candidates = getEligiblePoolAccounts( + config, + boundAccountId, + now, + quotaScope, + selectionOptions, + true, + ).filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); + const best = pickLowestUsageAmong(config, candidates, selectionOptions, now); + if (best === null || best === boundAccountId) return null; + const bestUsage = computeCodexUsageScore( + getAccountQuota(best), + getPoolAccountPlanForSelection(config, best, selectionOptions), + now, + ); + return bestUsage < boundUsage ? best : null; +} + +/** + * Re-evaluate an affined account under the quota strategy. Returns a replacement + * that has genuine quota headroom and is strictly cooler than the bound account, + * or null when the current binding should remain (#4546). */ function reevaluateAffinityQuota( entry: ThreadAffinityEntry, @@ -2302,16 +2350,14 @@ function reevaluateAffinityQuota( } entry.lastReevalAt = now; if (!mayRebind) return null; - const best = pickLowerUsageAccount( + return pickCacheSafeQuotaReplacement( config, entry.accountId, usage, now, quotaScope, selectionOptions, - true, ); - return best === entry.accountId ? null : best; } /** @@ -2499,7 +2545,10 @@ export function resolveCodexAccountForThreadDetailed( ) { entry.lastUsedAt = now; // Periodic quota re-eval: a long-lived bound thread must still switch when - // it crosses autoSwitchThreshold and a strictly-cooler account exists. + // it crosses autoSwitchThreshold, but only onto an account that has genuine + // quota headroom AND is strictly cooler — moving to a destination still over + // the threshold just trades the warmed prompt-cache prefix for an equally hot + // account, which is the #4546 ping-pong. // Without this the reuse branch returns before applyQuotaAutoSwitch and the // thread stays pinned for the full idle TTL (the WSL "never switches" report). // Over-threshold pins re-eval immediately so a depleted primary does not keep From da39ff94b474476fad72cf5d92e2d13303151b5e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 13:01:46 +0900 Subject: [PATCH 3/6] docs(structure): record the bound-thread rebind destination invariant structure/manifest.json lists providers/openai-tiers.md as a doc for src/codex/, so narrowing the quota-strategy live-rebind rule obliges the same change here. Records why both bars are load-bearing, what the rule deliberately does not change, and that the rule is written twice on purpose. Also folds an independent roadmap review into devlog/_plan: the Codex-client process set must not be the app-server lister, adopted stays an opportunity inference, and tests extend existing subsystem files rather than adding new ones. Refs #4546 --- .../260914_l2_pool_routing_cache/000_unit.md | 21 ++++++++++++-- .../010_cache_safe_rebind.md | 10 +++++++ .../020_routing_adoption.md | 28 +++++++++++++++++-- structure/providers/openai-tiers.md | 27 ++++++++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/devlog/_plan/260914_l2_pool_routing_cache/000_unit.md b/devlog/_plan/260914_l2_pool_routing_cache/000_unit.md index 65a67ccd47..c47abbbaab 100644 --- a/devlog/_plan/260914_l2_pool_routing_cache/000_unit.md +++ b/devlog/_plan/260914_l2_pool_routing_cache/000_unit.md @@ -12,8 +12,18 @@ same thing: the pool tells the operator one story and does another. ## Write scope Permitted: `src/codex/routing.ts`, the account-pool / session-affinity code, a new -`src/codex/routing-adoption.ts` leaf, `src/codex/autostart-health.ts` wiring, their tests, -the docs-site configuration reference, and this unit. +`src/codex/routing-adoption.ts` leaf, `src/codex/native-profile-processes.ts`, +`src/codex/autostart-health.ts` wiring, their tests, the docs-site configuration reference, +`structure/providers/openai-tiers.md`, and this unit. + +`structure/providers/openai-tiers.md` is not optional: `structure/manifest.json` lists it as a +doc for `src/codex/`, and `structure/AGENTS.md` makes changing an owned source area oblige the +same change to update its doc. `bun run structure:check` is wired into the suite by +`tests/ci-workflows/structure-ssot.test.ts`, so ownership here is enforced, not advisory. + +Tests EXTEND existing subsystem files rather than adding new ones. A new test file would also +require entries in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`, and `tests/test-layout.test.ts` enforces that. Excluded, owned by concurrent lanes: `src/providers/devin*`, `src/providers/antigravity*`, `src/server/responses/*`, `src/codex/catalog/*`, `src/adapters/cursor/*`, `gui/`. @@ -34,3 +44,10 @@ request states that posture in its Verification section rather than implying a l Implementation is delegated to subagents on `devin/swe-2` and `xai/grok-4.6` at a 2:3 ratio, each with a disjoint write scope so two writers never hold the same file. + +## Review record + +An independent reviewer audited this roadmap before implementation and returned FAIL with three +blocking findings, all folded in: the planned Codex-client process set could not see a CLI process +at all, `adopted` was not sound as written, and the write scope omitted the `structure/` doc that +owns `src/codex/`. The `010` diagnosis was confirmed correct on every point. diff --git a/devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md b/devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md index ffe040f4e8..58a86a41c4 100644 --- a/devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md +++ b/devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md @@ -36,6 +36,11 @@ Consequences of the new rule, which are what the regression test pins: - Release paths are untouched. `hasUnrecoveredCodexQuotaRefusal` (429/402) still outranks every affinity preference, `shouldFailover` still applies, and an exhausted or unusable account still loses the binding. The rule narrows a *preference*, never a refusal. +- One correction from review: a known score of 100 with **no** recorded refusal is not by itself a + release path today, and this change does not make it one. Such a thread stays while its account is + still selectable, and surrenders the binding as soon as a sibling with headroom exists. Stickiness + until the account actually refuses is intended, so the regression test asserts that and not the + stronger claim. ## Where it goes @@ -59,5 +64,10 @@ Next to the existing pool-rotation tests in `tests/codex-integration/`. Three ca 1. All accounts over the threshold: the bound thread's account is unchanged across repeated resolves — the ping-pong case, which fails before the fix. + The scores must be UNEQUAL (95 / 90 / 97). Equal scores would not move even before the fix, so an + equal-score fixture would pass for the wrong reason and prove nothing. 2. One account below the threshold: the bound thread moves to it once, then stays. 3. Preview agrees with resolve in both situations. + +`pickLowerUsageAccount` itself must not change: it is shared with `applyQuotaAutoSwitch` and the +unbound selection path, so the new bar belongs at the two bound-thread call sites only. diff --git a/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md b/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md index d6ce0e999f..0d6d790b8b 100644 --- a/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md +++ b/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md @@ -25,14 +25,21 @@ distinguish a client that adopted the route from one that predates it. Both halves already exist in this repository: -- **When the route was written.** `src/codex/journal.ts` records our injection and stamps it, - and the journal file is rewritten on every injection, so the newer of the recorded timestamp - and the journal's mtime bounds when the current route became effective. +- **When the route was written.** `src/codex/journal.ts` records our injection and stamps it. + Review pinned down why the newer of two readings is required: `Journal.timestamp` is the + *native snapshot* time and is not refreshed on re-injection, `writeJournal` no-ops when config is + already injected, and `markJournalInjectedState` rewrites the file — moving its mtime — without + touching `timestamp`. So the bound is `max(journal mtime, recorded timestamp)`. The new leaf + parses `JOURNAL_PATH` itself rather than calling the private `readJournal`, because that helper + can delete a corrupt journal and a status read must never mutate state. - **When each client started.** `src/codex/app-server-processes.ts` already enumerates processes cross-platform and reads start times (`readProcessStartMs`, `readProcessStartMsBatch`, `/proc//stat` on Linux, `ps -o lstart` on macOS, `Win32_Process.CreationDate` on Windows), and its `ProcessSnapshot` already carries an optional `startedAtMs`. + Correction from review: that field is declared on the type but the enumerators never populate it. + Start times come from `readProcessStartMsBatch`, which is how `collectCodexAppServerCatalogState` + already does it. - **Which processes are Codex clients.** `src/codex/native-profile-processes.ts` carries the matching rules — direct `codex` basenames plus interpreter-wrapped `node|bun codex.js` entrypoints — but they are private and reachable only through a *count*. The count is enough to @@ -40,6 +47,16 @@ Both halves already exist in this repository: exported predicate and the existing counter is rewired through it. Copying them into a second module is how `#2457` happened; one predicate, two callers. + Review corrected the lister, and this was the roadmap's worst error: + `listCodexAppServerProcesses` must **not** be the client set. It matches `app-server` and + `codex-code-mode-host` command lines only, and #4550 is a **CLI** process, so using it would make + `adopted` vacuously true — the same false reassurance the issue reports. + `probeNativeCodexProcesses` cannot stand in either: it is async and returns a count, while + `collectStartupHealth` is synchronous. So the extracted predicate comes with a **synchronous** + CLI lister returning `{ pid, commandLine }`. On Windows the existing PowerShell path emits only + `@($items).Count` and yields no PIDs, so that platform reports "cannot enumerate" — a distinct + outcome from an empty list, because empty means "none running" and would resolve to `adopted`. + A Codex client whose start time precedes the injection cannot have read the injected route. That is a sound inference, and it is the one the operator needed. @@ -58,6 +75,11 @@ collectRoutingAdoption(...): RoutingAdoptionEvidence // journal + process enum - `adopted` — routing is `opencodex-local` and every running Codex client started after the injection. This is still an inference about *opportunity*, not a traffic observation, and the wording must not overclaim. + Review named the false-`adopted` sources this design knowingly does not cover, and the doc comment + must name them too: a Codex client the matcher fails to recognise, a restored or resumed thread + that keeps an already-open direct WebSocket even though its process started after injection, and an + `OPENAI_BASE_URL` or profile override in the client's own environment. Anything unverifiable is + `unknown`. - `pending-client-restart` — at least one running Codex client predates the injection. - `unknown` — no injection time, or process start times unreadable. Enumeration failure reports `unknown`; it never invents a clean bill of health, matching the `#476` restart contract. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 527aacba6b..0207aee733 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -518,3 +518,30 @@ The history read API reports a median effective token estimate and interval samp Live bindings obey the existing cache-affinity release policy: with `pool.cacheAffinity`, threshold crossing alone retains a healthy account. Manual preference, scoped health and shared-cursor guards remain authoritative. Independent `spark`/`reserve` quota scopes resolve reset-first to existing quota selection because shared reset timestamps do not describe those windows. The configured value stays unchanged. The Codex parser in `src/oauth/pool-kernel.ts` is reexported by the compatibility facade and used by both `/api/pool/settings` and the legacy Codex settings route. Generic and Anthropic parsers reject reset-first. The dashboard offers it only for Codex; API, CLI and translated guides preserve the same contract. + +## Bound-thread rebind destination + +A quota-strategy re-evaluation may move a LIVE thread binding only to an account that has genuine +quota headroom and is also strictly cooler than the bound account. Both bars are load-bearing. +Without the headroom bar, "strictly cooler" has no floor, so a pool whose every member sits in the +80-100% band hands a long conversation from account to account on consecutive turns; Codex prompt +caches are account-isolated, so each hop restarts from a cold prefix and a 7k-token turn becomes a +150k-token one (#4546). Without the strictly-cooler bar, `hasCodexQuotaHeadroom` — which answers +true for unknown usage, correctly for an unbound pick — would trade a warm prefix for an unmeasured +account. `CODEX_UNKNOWN_USAGE_SCORE` is 101, so the second bar excludes an unobserved destination +without a special case. + +Movement is therefore bounded by the number of accounts rather than the number of turns. The rule +narrows a preference and never a refusal: a 429/402 with no success since, a failover streak, pause, +cooldown, lost generation and an unusable account all still release the binding before this rule is +consulted, and they run in `resolveCodexAccountForThreadDetailed` ahead of it. A known score of 100 +with no recorded refusal is deliberately not a release path on its own — stickiness until the +account actually refuses is intended — but it does surrender the binding as soon as a sibling with +headroom exists. Unbound assignment is untouched and still takes the coolest eligible account, +because a fresh request has no warm prefix to lose. `pool.cacheAffinity` remains the stronger +opt-in, raising the bar from the threshold to genuine exhaustion. + +The rule is written twice on purpose — the live path in `reevaluateAffinityQuota` and the +side-effect-free `previewReusableAffinityAccount` that subagent fallback reads — and the suite +asserts the two answer identically. A preview that disagreed would hand fallback a different +account than the request actually uses. From b45988d786a26ca503cc6da4599cafae42305a13 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 13:03:27 +0900 Subject: [PATCH 4/6] test(codex): pin the bound-thread rebind destination rule Four cases next to the existing cache-affinity tests. The first is the #4546 death spiral and fails without the fix: with a=95, b=90, c=97 the pre-fix rule handed the thread to whichever account was one point cooler on every resolve. The scores are deliberately unequal, since equal scores would not move even before the fix and would pass for the wrong reason. The other three keep the change a narrowing rather than a pin: a thread still moves onto an account with genuine headroom, a 429 still releases a binding the preference rule would have kept, and a fully spent account still yields to a sibling with headroom. Preview is asserted beside every resolve because the rule is written twice. Refs #4546 --- .../codex-pool-rotation.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 19816dfcf7..522ea4a8e3 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -1250,6 +1250,125 @@ describe("selection order across rotation strategies", () => { expect(resolveCodexAccountForThread(threadId, config, later)).toBe("a"); }); + // #4546: under quota strategy with no cacheAffinity, a live binding may only + // move to an account that has genuine headroom AND is strictly cooler. These + // cases share the bind-then-re-eval harness with the cache-affinity tests + // above; they pin the narrowed preference, not a pin. + test("a bound thread does not ping-pong among over-threshold accounts", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + }); + const threadId = "cache-safe-death-spiral"; + // Bind the thread while "a" is the natural quota pick, which is how a real conversation + // acquires its affinity in the first place. + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + // Every account is now in the 80–100% band, and the scores are unequal on + // purpose: before the fix, each of these resolves handed the thread to + // whichever account was one point cooler, discarding the account-isolated + // prompt cache. Equal scores would not move even before the fix, so the + // case would pass for the wrong reason. + updateAccountQuota("a", 95); + updateAccountQuota("b", 90); + updateAccountQuota("c", 97); + + const now = Date.now(); + for (const later of [ + now, + now + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1, + now + 2 * CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 2, + ]) { + // Two copies of the same rule live in this file; a preview that disagreed with the + // final answer would hand subagent fallback a different account than the request uses. + expect(previewCodexAccountForRequest(threadId, config, later)).toBe("a"); + expect(resolveCodexAccountForThread(threadId, config, later)).toBe("a"); + } + }); + + test("a bound thread still moves once onto an account with genuine headroom", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + }); + const threadId = "cache-safe-real-improvement"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + // "a" crossed the threshold; "b" still has headroom. The fix narrowed the + // replacement rule, it did not pin the thread. + updateAccountQuota("a", 95); + updateAccountQuota("b", 5); + updateAccountQuota("c", 50); + + const movedAt = Date.now(); + expect(previewCodexAccountForRequest(threadId, config, movedAt)).toBe("b"); + expect(resolveCodexAccountForThread(threadId, config, movedAt)).toBe("b"); + + // "b" is under the threshold, so a later re-eval has nothing to move toward. + const later = movedAt + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(previewCodexAccountForRequest(threadId, config, later)).toBe("b"); + expect(resolveCodexAccountForThread(threadId, config, later)).toBe("b"); + }); + + test("a 429 still releases a binding the preference rule would have kept", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + }); + const threadId = "cache-safe-429-release"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + // Same all-hot band as the ping-pong case: the preference rule has no legal + // destination, so without the refusal the thread would stay on "a". The 429 + // is the stronger signal and must still win. Resolve at the refusal instant + // so "a" is still in its default cooldown and is not a selectable destination; + // "b" is then the only remaining account that is both selectable and + // unambiguously coolest. + updateAccountQuota("a", 95); + updateAccountQuota("b", 90); + updateAccountQuota("c", 97); + const now = Date.now(); + recordCodexUpstreamOutcome(config, "a", 429, { now }); + expect(previewCodexAccountForRequest(threadId, config, now)).toBe("b"); + expect(resolveCodexAccountForThread(threadId, config, now)).toBe("b"); + }); + + test("a fully spent bound account still moves to a sibling with headroom", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + }); + const threadId = "cache-safe-exhausted-with-headroom"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + // 100% is exhaustion, not a pin. With cacheAffinity off, a 100 score without a + // 429/402 does not drop the binding by itself — stickiness-until-refusal is + // intended — but a sibling with genuine headroom is a real improvement and + // must still be taken. (An all-hot pool would keep the thread on "a".) + updateAccountQuota("a", 100); + updateAccountQuota("b", 5); + updateAccountQuota("c", 50); + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(previewCodexAccountForRequest(threadId, config, later)).toBe("b"); + expect(resolveCodexAccountForThread(threadId, config, later)).toBe("b"); + }); + test("the pool moves, then a manual pick wins the next unbound dispatch", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin", From 4a572234bf1e56609ad607bda0c9d496873462f9 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 13:22:54 +0900 Subject: [PATCH 5/6] fix(codex): tell configured Codex routing apart from adopted routing ocx status printed routing=opencodex-local straight from config on disk, so an operator read it as proof that their running Codex CLI was pooled. A client that started before the route was injected keeps dialing chatgpt.com directly, which is what #4550 reports: repeated usage_limit_exceeded while a healthy secondary account sat in the pool, and no proxy usage record for those turns. Status now carries adoption evidence. A running Codex client whose start time precedes the recorded injection cannot have read the injected route, so it is named as pending-client-restart with its pid, and the summary says to restart it. Anything unverifiable stays unknown rather than claiming a clean bill of health: a failed process walk is distinct from an empty one, because an empty walk means "no clients running" and would read as adopted. adopted is deliberately an inference about opportunity, not an observation of live traffic, and the module comment names every false-adopted source this evidence does not cover, including same-second starts, an empty match set, a different CODEX_HOME, and Codex surfaces the CLI predicate never matches. The Codex CLI matching rules were private and reachable only through a count, so they are now one exported predicate with two callers rather than a second copy. The adoption field is optional and the routing detail string keeps its old shape as a prefix, so the GUI and server consumers of StartupHealth are untouched, and a stale client never changes status, protection, rebootSafe or recommendedCommand. Refs #4550 --- .../020_routing_adoption.md | 26 ++- src/codex/app-server-processes.ts | 25 +++ src/codex/autostart-health.ts | 38 +++- src/codex/native-profile-processes.ts | 129 ++++++++++-- src/codex/routing-adoption.ts | 189 ++++++++++++++++++ structure/providers/openai-tiers.md | 8 +- 6 files changed, 391 insertions(+), 24 deletions(-) create mode 100644 src/codex/routing-adoption.ts diff --git a/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md b/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md index 0d6d790b8b..032d56e44c 100644 --- a/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md +++ b/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md @@ -53,9 +53,22 @@ Both halves already exist in this repository: `adopted` vacuously true — the same false reassurance the issue reports. `probeNativeCodexProcesses` cannot stand in either: it is async and returns a count, while `collectStartupHealth` is synchronous. So the extracted predicate comes with a **synchronous** - CLI lister returning `{ pid, commandLine }`. On Windows the existing PowerShell path emits only - `@($items).Count` and yields no PIDs, so that platform reports "cannot enumerate" — a distinct - outcome from an empty list, because empty means "none running" and would resolve to `adopted`. + CLI lister returning `{ pid, commandLine }`. + + Round 2 narrowed that further: "Windows cannot enumerate" is true only of + `windowsProcessCount`'s `@($items).Count`, not of the platform. All three snapshot listers in + `app-server-processes.ts` are already synchronous and already return `{ pid, commandLine }` — + `listUnixProcSnapshots` reads `/proc`, `listDarwinSnapshots` and `listWindowsSnapshots` use + `execFileSync` — and the Windows pre-filter `WINDOWS_CODEX_BASENAME_CANDIDATE_RE` already admits + CLI `codex.exe`/`codex.cmd` lines, with `isCodexAppServerCommandLine` applied only afterwards. + So the lister filters those snapshots with the extracted predicate instead of shelling out to + `ps` a second time, and Windows yields PIDs like the others. + + What must NOT be reused is `listCodexAppServerProcesses` itself: it deliberately maps + enumeration failure to an empty array for the #476 kill contract, and for adoption an empty array + means "no clients running" and resolves to `adopted`. "Could not enumerate" has to stay a + distinct outcome that resolves to `unknown`. Where that distinction cannot be preserved the + conservative answer is `unknown`; a platform that cannot enumerate must never report `adopted`. A Codex client whose start time precedes the injection cannot have read the injected route. That is a sound inference, and it is the one the operator needed. @@ -80,6 +93,13 @@ collectRoutingAdoption(...): RoutingAdoptionEvidence // journal + process enum that keeps an already-open direct WebSocket even though its process started after injection, and an `OPENAI_BASE_URL` or profile override in the client's own environment. Anything unverifiable is `unknown`. + Round 2 added four more the comment must name: a start time in the SAME second as the injection, + which we deliberately treat as not stale; an empty match set, which is vacuously `adopted`; a + client running against a different `CODEX_HOME` or config path than the journal we read; and + Codex surfaces the CLI predicate does not match at all — `codex-code-mode-host`, Electron + helpers, VS Code extension hosts. None of these restores the original "config on disk implies + live traffic" overclaim, but a status line that sounds more certain than its evidence is the whole + defect in #4550, so the limits belong in the code. - `pending-client-restart` — at least one running Codex client predates the injection. - `unknown` — no injection time, or process start times unreadable. Enumeration failure reports `unknown`; it never invents a clean bill of health, matching the `#476` restart contract. diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 4962aa79c0..355beaa9e5 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -524,6 +524,31 @@ function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | return listUnixProcSnapshots(getuid()); } +export interface ListProcessSnapshotsOptions { + platform?: NodeJS.Platform; + getuid?: () => number | undefined; +} + +/** + * Raw process snapshots for callers that need to match their own predicate. + * + * Throws on enumeration failure. That is the contract routing-adoption needs: + * a thrown read is "could not enumerate" and must never collapse to an empty + * list. listCodexAppServerProcesses maps the same failure to [] for the #476 + * kill path, which would otherwise print a false adopted for #4550. + */ +export function listProcessSnapshots(options: ListProcessSnapshotsOptions = {}): ProcessSnapshot[] { + const platform = options.platform ?? process.platform; + const getuid = options.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + return defaultListSnapshots(platform, getuid); +} + export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcess[] { const platform = io.platform ?? process.platform; const getuid = io.getuid ?? (() => { diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index a87b332e0e..5fc990a5f6 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -2,6 +2,7 @@ import { codexAutoStartEnabled } from "../config"; import { diagnoseService, type ServiceDiagnostic } from "../service"; import type { OcxConfig } from "../types"; import { getCodexRoutingKind, type CodexRoutingKind } from "./inject"; +import { collectRoutingAdoption, type RoutingAdoptionEvidence } from "./routing-adoption"; import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim"; export type StartupProtection = "service" | "shim" | "none"; @@ -22,6 +23,7 @@ export interface StartupHealthInputs { shimHealthy: boolean; platform: NodeJS.Platform; diagnosticStale?: boolean; + routingAdoption?: RoutingAdoptionEvidence; } export interface StartupHealth { @@ -51,6 +53,7 @@ export interface StartupHealth { installShim: string; restoreNative: string; }; + routingAdoption?: RoutingAdoptionEvidence; } const COMMANDS = { @@ -115,6 +118,7 @@ export interface StartupHealthDiagnostics { routingKind?: CodexRoutingKind; service?: ServiceDiagnostic; shim?: CodexShimDiagnostic; + routingAdoption?: RoutingAdoptionEvidence; } /** Collect current machine state without mutating config, services, or shims. */ @@ -124,8 +128,11 @@ export function collectStartupHealth( ): StartupHealth { const shim = diagnostics.shim ?? diagnoseCodexShim(); const service = diagnostics.service ?? diagnoseService(); + const routingKind = diagnostics.routingKind ?? getCodexRoutingKind(); + const routingAdoption = diagnostics.routingAdoption + ?? (routingKind === "opencodex-local" ? collectRoutingAdoption({ routingKind }) : undefined); return deriveStartupHealth({ - routingKind: diagnostics.routingKind ?? getCodexRoutingKind(), + routingKind, autostartEnabled: codexAutoStartEnabled(config), serviceInstalled: service.installed, serviceViable: service.viable, @@ -137,10 +144,17 @@ export function collectStartupHealth( shimInstalled: shim.installed, shimHealthy: shim.healthy, platform: process.platform, + ...(routingAdoption ? { routingAdoption } : {}), }); } export function startupHealthSummary(health: StartupHealth): string { + const summary = classifyStartupHealthSummary(health); + const action = pendingClientRestartAction(health); + return action ? `${summary}; ${action}` : summary; +} + +function classifyStartupHealthSummary(health: StartupHealth): string { if (health.status === "native") return health.routingKind === "custom-remote" ? "custom remote Codex routing (no local restart dependency)" : "native Codex routing (no opencodex restart dependency)"; @@ -155,6 +169,24 @@ export function startupHealthSummary(health: StartupHealth): string { return `AT RISK after restart (no viable background service; run '${command}')`; } +function pendingClientRestartAction(health: StartupHealth): string | null { + const adoption = health.routingAdoption; + if (adoption?.adoption !== "pending-client-restart") return null; + const pids = adoption.staleClients.map(client => client.pid); + if (pids.length === 0) return null; + const pidList = pids.join(", "); + return pids.length === 1 + ? `restart Codex client pid ${pidList} so it adopts the injected proxy route` + : `restart Codex clients pid ${pidList} so they adopt the injected proxy route`; +} + +function pendingClientRestartDetail(adoption: RoutingAdoptionEvidence | undefined): string | null { + if (adoption?.adoption !== "pending-client-restart") return null; + const pids = adoption.staleClients.map(client => client.pid); + if (pids.length === 0) return null; + return `clients=pending-restart(pid ${pids.join(", ")})`; +} + /** * The routing/service/shim token `ocx doctor` prints under restart safety. * Extracted so `ocx status` can show the same string rather than growing a @@ -168,5 +200,7 @@ export function formatStartupRoutingDetail(health: StartupHealth): string { const shim = health.shimHealthy ? "healthy" : health.shimInstalled ? "stale" : "absent"; - return `routing=${health.routingKind}, service=${service}, shim=${shim}`; + const base = `routing=${health.routingKind}, service=${service}, shim=${shim}`; + const token = pendingClientRestartDetail(health.routingAdoption); + return token ? `${base}, ${token}` : base; } diff --git a/src/codex/native-profile-processes.ts b/src/codex/native-profile-processes.ts index 8eb27f8715..634e791096 100644 --- a/src/codex/native-profile-processes.ts +++ b/src/codex/native-profile-processes.ts @@ -1,6 +1,11 @@ import { execFile } from "node:child_process"; import { basename } from "node:path"; import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation"; +import { + listProcessSnapshots, + tokenizeCommandLine, + type ProcessSnapshot, +} from "./app-server-processes"; const PROCESS_LIST_MAX_BUFFER = 16 * 1024 * 1024; const DIRECT_CODEX_BASENAMES = new Set(["codex", "codex.exe"]); @@ -39,6 +44,114 @@ export type NativeCodexProcessProbe = | { status: "busy"; count: number } | { status: "unknown"; count: 0 }; +export interface CodexClientProcess { + pid: number; + commandLine: string; +} + +/** + * Synchronous Codex-CLI process list for routing-adoption (#4550). + * + * "enumerated" is a successful read, including the empty list, which means + * no matching client is running. "unavailable" means we could not name PIDs: + * a failed snapshot walk. The two must not collapse. An empty array is + * "none running" and would otherwise produce a false adopted. Windows is + * enumerable here: listProcessSnapshots already returns CLI command lines. + * Do not route through listCodexAppServerProcesses; that maps a failed + * walk to [] for the #476 kill contract. + */ +export type CodexClientProcessList = + | { status: "enumerated"; processes: CodexClientProcess[] } + | { status: "unavailable" }; + +export interface ListCodexClientProcessesOptions { + platform?: NodeJS.Platform; + pid?: number; + getuid?: () => number | undefined; + /** Test seam: raw snapshots. Enumeration failure is thrown, not []. */ + listSnapshots?: () => ProcessSnapshot[]; +} + +/** + * True when a ps comm field plus args string is a Codex CLI client. + * + * Direct "codex" / "codex.exe" basenames, or a known interpreter whose + * immediate entrypoint is a Codex CLI script. The busy-count probe and the + * routing-adoption lister both call this so the rules cannot drift (#2457). + */ +export function isCodexClientProcess(command: string, args: string): boolean { + const comm = basename(command).toLowerCase(); + const [rawArgv0 = "", rawEntrypoint = ""] = args.trim().split(/\s+/, 2); + const argv0 = basename(rawArgv0).toLowerCase(); + const entrypoint = basename(rawEntrypoint).toLowerCase(); + const isDirectCodex = DIRECT_CODEX_BASENAMES.has(comm) + || DIRECT_CODEX_BASENAMES.has(argv0); + const isInterpreterWrappedCodex = CODEX_INTERPRETER_BASENAMES.has(argv0) + && CODEX_ENTRYPOINT_BASENAMES.has(entrypoint); + return isDirectCodex || isInterpreterWrappedCodex; +} + +function parseUnixPsLine(line: string): { pid: number; command: string; args: string } | null { + const match = line.trim().match(/^(\d+)\s+(\S+)\s*(.*)$/); + if (!match) return null; + const pid = Number(match[1]); + if (!Number.isSafeInteger(pid)) return null; + return { pid, command: match[2]!, args: match[3]! }; +} + +function unixCodexClientsFromPs(output: string, selfPid: number): CodexClientProcess[] { + const processes: CodexClientProcess[] = []; + for (const line of output.split("\n")) { + const parsed = parseUnixPsLine(line); + if (!parsed || parsed.pid === selfPid) continue; + if (!isCodexClientProcess(parsed.command, parsed.args)) continue; + processes.push({ + pid: parsed.pid, + commandLine: parsed.args.trim() || parsed.command, + }); + } + return processes; +} + +/** + * Name running Codex CLI processes so status can tell a pre-injection client + * from one that had a chance to read the injected route (#4550). + * + * probeNativeCodexProcesses is async and count-only; collectStartupHealth is + * synchronous, so this path cannot reuse it. Snapshots come from + * listProcessSnapshots so Windows CLI PIDs are named too; a thrown walk + * stays unavailable rather than an empty adopted list. + */ +export function listCodexClientProcesses({ + platform = process.platform, + pid = process.pid, + getuid, + listSnapshots, +}: ListCodexClientProcessesOptions = {}): CodexClientProcessList { + try { + const snapshots = listSnapshots + ? listSnapshots() + : listProcessSnapshots({ platform, getuid }); + const seen = new Set(); + const processes: CodexClientProcess[] = []; + for (const snapshot of snapshots) { + if (snapshot.pid === pid || seen.has(snapshot.pid)) continue; + if (!snapshotIsCodexClient(snapshot)) continue; + seen.add(snapshot.pid); + processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); + } + return { status: "enumerated", processes }; + } catch { + return { status: "unavailable" }; + } +} + +function snapshotIsCodexClient(snapshot: ProcessSnapshot): boolean { + if (isCodexClientProcess(snapshot.executable ?? "", snapshot.commandLine)) return true; + const argv0 = tokenizeCommandLine(snapshot.commandLine)[0] ?? ""; + return argv0 !== "" && isCodexClientProcess(argv0, snapshot.commandLine); +} + /** Async, shell-free child execution with runtime-enforced timeout and output bounds. */ export const executeNativeProcess: NativeProcessExecutor = (file, args, options) => new Promise((resolve, reject) => { execFile(file, args, { @@ -87,21 +200,7 @@ async function unixProcessCount(run: NativeProcessExecutor, pid: number): Promis shell: false, killSignal: "SIGKILL", }); - let count = 0; - for (const line of output.split("\n")) { - const match = line.trim().match(/^(\d+)\s+(\S+)\s*(.*)$/); - if (!match || Number(match[1]) === pid) continue; - const command = basename(match[2]!).toLowerCase(); - const [rawArgv0 = "", rawEntrypoint = ""] = match[3]!.trim().split(/\s+/, 2); - const argv0 = basename(rawArgv0).toLowerCase(); - const entrypoint = basename(rawEntrypoint).toLowerCase(); - const isDirectCodex = DIRECT_CODEX_BASENAMES.has(command) - || DIRECT_CODEX_BASENAMES.has(argv0); - const isInterpreterWrappedCodex = CODEX_INTERPRETER_BASENAMES.has(argv0) - && CODEX_ENTRYPOINT_BASENAMES.has(entrypoint); - if (isDirectCodex || isInterpreterWrappedCodex) count += 1; - } - return count; + return unixCodexClientsFromPs(output, pid).length; } /** Best-effort, read-only process probe. It never terminates a user process. */ diff --git a/src/codex/routing-adoption.ts b/src/codex/routing-adoption.ts new file mode 100644 index 0000000000..b8ae1f352b --- /dev/null +++ b/src/codex/routing-adoption.ts @@ -0,0 +1,189 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { readProcessStartMsBatch } from "./app-server-processes"; +import type { CodexRoutingKind } from "./inject"; +import { JOURNAL_PATH } from "./journal"; +import { + listCodexClientProcesses, + type CodexClientProcessList, +} from "./native-profile-processes"; + +export type RoutingAdoption = "not-applicable" | "adopted" | "pending-client-restart" | "unknown"; + +export interface RoutingAdoptionEvidence { + adoption: RoutingAdoption; + injectedAtMs: number | null; + staleClients: Array<{ pid: number; startedAtMs: number }>; + observedClients: number; +} + +export interface CollectRoutingAdoptionOptions { + routingKind: CodexRoutingKind; + platform?: NodeJS.Platform; + listClients?: () => CodexClientProcessList; + readStartMsBatch?: (pids: readonly number[], platform: NodeJS.Platform) => Map; + injectedAtMs?: number | null; +} + +/** + * Infer whether running Codex clients had an opportunity to read the + * injected opencodex-local route (#4550). + * + * "adopted" is not an observation of live traffic. It means every matched + * running Codex client started after the route was written. That is the + * inference the operator needed, and it is also the overclaim we must not + * make. Known sources of a false adopted this evidence does not cover: + * a client the matcher does not recognise; a restored or resumed thread + * that keeps an already-open direct WebSocket even though the process + * started after injection; OPENAI_BASE_URL / profile overrides in the + * client's own environment; a start time in the SAME second as the + * injection, which we deliberately treat as not stale; an empty match + * set, which is vacuously adopted; a client running against a different + * CODEX_HOME or config path than the journal we read; and Codex surfaces + * the CLI predicate does not match at all, such as codex-code-mode-host, + * Electron helpers, and VS Code extension hosts. Anything that cannot + * be verified is "unknown", never a clean bill of health — matching the + * #476 restart contract where enumeration failure means no verified + * targets. + */ +export function deriveRoutingAdoption(inputs: { + routingKind: CodexRoutingKind; + injectedAtMs: number | null; + clients: ReadonlyArray<{ pid: number; startedAtMs: number | null }>; + enumerationFailed?: boolean; +}): RoutingAdoptionEvidence { + const observedClients = inputs.clients.length; + if (inputs.routingKind !== "opencodex-local") { + return { adoption: "not-applicable", injectedAtMs: null, staleClients: [], observedClients: 0 }; + } + if (inputs.enumerationFailed) { + return { adoption: "unknown", injectedAtMs: inputs.injectedAtMs, staleClients: [], observedClients }; + } + if (inputs.injectedAtMs === null) { + return { adoption: "unknown", injectedAtMs: null, staleClients: [], observedClients }; + } + const injectedAtMs = inputs.injectedAtMs; + const staleClients: Array<{ pid: number; startedAtMs: number }> = []; + let unreadableStart = false; + for (const client of inputs.clients) { + if (client.startedAtMs === null) { + unreadableStart = true; + continue; + } + if (startedBeforeInjection(client.startedAtMs, injectedAtMs)) { + staleClients.push({ pid: client.pid, startedAtMs: client.startedAtMs }); + } + } + staleClients.sort((left, right) => left.pid - right.pid); + if (staleClients.length > 0) { + return { adoption: "pending-client-restart", injectedAtMs, staleClients, observedClients }; + } + if (unreadableStart) { + return { adoption: "unknown", injectedAtMs, staleClients: [], observedClients }; + } + return { adoption: "adopted", injectedAtMs, staleClients: [], observedClients }; +} + +/** + * ps lstart is second-granularity; app-server-processes.ts uses <= for catalog + * staleness for the opposite reason (a rewrite in the same second may be unseen). + * Here a start in the same wall-clock second as the injection cannot be proven + * to predate it, so a rounding artifact must not produce a false pending-restart + * warning (#4550). Truncate both sides to seconds; strictly earlier seconds are + * stale. + */ +function startedBeforeInjection(startedAtMs: number, injectedAtMs: number): boolean { + return Math.floor(startedAtMs / 1000) < Math.floor(injectedAtMs / 1000); +} + +/** + * Gather journal + process evidence for deriveRoutingAdoption. + * + * Injection time is the newer of the journal timestamp and JOURNAL_PATH mtime: + * writeJournal records the native snapshot time and then no-ops, while + * markJournalInjectedState rewrites the file (mtime moves) without touching + * timestamp. The journal is parsed here instead of through readJournal, which + * can delete a corrupt file; a status read must never mutate state. Ownership + * matches journaledInjectedOpenaiBaseUrl plus journalOwner: we only bound a + * route we recorded writing. + */ +export function collectRoutingAdoption(options: CollectRoutingAdoptionOptions): RoutingAdoptionEvidence { + const { routingKind } = options; + if (routingKind !== "opencodex-local") { + return deriveRoutingAdoption({ routingKind, injectedAtMs: null, clients: [] }); + } + const injectedAtMs = options.injectedAtMs !== undefined + ? options.injectedAtMs + : readOwnedInjectionBoundMs(); + let listed: CodexClientProcessList; + try { + listed = (options.listClients ?? (() => listCodexClientProcesses({ platform: options.platform })))(); + } catch { + listed = { status: "unavailable" }; + } + if (listed.status === "unavailable") { + return deriveRoutingAdoption({ + routingKind, + injectedAtMs, + clients: [], + enumerationFailed: true, + }); + } + const platform = options.platform ?? process.platform; + const pids = listed.processes.map(proc => proc.pid); + let starts: Map; + try { + starts = pids.length === 0 + ? new Map() + : (options.readStartMsBatch ?? readProcessStartMsBatch)(pids, platform); + } catch { + starts = new Map(pids.map(pid => [pid, null])); + } + const clients = listed.processes.map(proc => ({ + pid: proc.pid, + startedAtMs: starts.get(proc.pid) ?? null, + })); + return deriveRoutingAdoption({ routingKind, injectedAtMs, clients }); +} + +function readOwnedInjectionBoundMs(): number | null { + try { + if (!existsSync(JOURNAL_PATH)) return null; + const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8")) as { + version?: unknown; + timestamp?: unknown; + injectedOpenaiBaseUrl?: unknown; + owner?: { kind?: unknown; pid?: unknown; apiKeyId?: unknown }; + pid?: unknown; + }; + if (journal === null || typeof journal !== "object" || journal.version !== 1) return null; + const injectedUrl = typeof journal.injectedOpenaiBaseUrl === "string" + ? journal.injectedOpenaiBaseUrl + : ""; + if (!injectedUrl) return null; + if (!journalHasOwner(journal)) return null; + const recordedMs = typeof journal.timestamp === "string" ? Date.parse(journal.timestamp) : Number.NaN; + let mtimeMs = Number.NaN; + try { + mtimeMs = statSync(JOURNAL_PATH).mtimeMs; + } catch { + mtimeMs = Number.NaN; + } + const bound = Math.max( + Number.isFinite(recordedMs) ? recordedMs : Number.NEGATIVE_INFINITY, + Number.isFinite(mtimeMs) ? mtimeMs : Number.NEGATIVE_INFINITY, + ); + return Number.isFinite(bound) ? bound : null; + } catch { + return null; + } +} + +function journalHasOwner(journal: { + owner?: { kind?: unknown; pid?: unknown; apiKeyId?: unknown }; + pid?: unknown; +}): boolean { + const owner = journal.owner; + if (owner?.kind === "client" && typeof owner.apiKeyId === "string" && owner.apiKeyId) return true; + if (owner?.kind === "process" && Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0) return true; + return Number.isSafeInteger(journal.pid) && Number(journal.pid) > 0; +} diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 0207aee733..47d5488834 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -541,7 +541,7 @@ headroom exists. Unbound assignment is untouched and still takes the coolest eli because a fresh request has no warm prefix to lose. `pool.cacheAffinity` remains the stronger opt-in, raising the bar from the threshold to genuine exhaustion. -The rule is written twice on purpose — the live path in `reevaluateAffinityQuota` and the -side-effect-free `previewReusableAffinityAccount` that subagent fallback reads — and the suite -asserts the two answer identically. A preview that disagreed would hand fallback a different -account than the request actually uses. +Two call sites need the rule — the live path in `reevaluateAffinityQuota` and the side-effect-free +`previewReusableAffinityAccount` that subagent fallback reads — and they share one helper rather +than restating it, because the suite asserts the two answer identically and a preview that +disagreed would hand fallback a different account than the request actually uses. From 4b22b25f61c254cd29a823ed125ece5233b9289a Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 13:38:41 +0900 Subject: [PATCH 6/6] test(codex): pin configured-versus-adopted routing evidence Twelve cases through the pure derivation and the injected lister and start-time seams, so nothing reads a real process table or the journal. A pre-injection client is named with its pid, a post-injection client is adopted, a start in the same wall-clock second is not stale, and enumeration failure, a missing injection time and an unreadable start time each resolve to unknown rather than to a clean bill of health. A stale client outranks an unreadable one. The detail string is asserted byte-identical to the old routing=..., service=..., shim=... form when nothing is pending, which is why the existing assertions in this file still hold, and a stale client is asserted not to move status, protection, rebootSafe or recommendedCommand. Refs #4550 --- tests/service/autostart-health.test.ts | 218 ++++++++++++++++++++++++- 1 file changed, 217 insertions(+), 1 deletion(-) diff --git a/tests/service/autostart-health.test.ts b/tests/service/autostart-health.test.ts index 213a118e2b..293f21f4ce 100644 --- a/tests/service/autostart-health.test.ts +++ b/tests/service/autostart-health.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { deriveStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../../src/codex/autostart-health"; +import { collectStartupHealth, deriveStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../../src/codex/autostart-health"; import { unusedProxyWarningLines } from "../../src/cli/status"; import { classifyCodexRouting, hasInjectedCodexRouting } from "../../src/codex/inject"; +import { isCodexClientProcess, listCodexClientProcesses } from "../../src/codex/native-profile-processes"; +import { collectRoutingAdoption, deriveRoutingAdoption } from "../../src/codex/routing-adoption"; import { handleManagementAPI } from "../../src/server/management-api"; import { getCachedStartupHealth, getStartupHealthSnapshot, invalidateStartupHealthCache, markStartupHealthDiagnosticStale } from "../../src/server/startup-health-cache"; import type { OcxConfig } from "../../src/types"; @@ -395,3 +397,217 @@ describe("routing visibility (#2411)", () => { expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "unknown" })).toEqual([]); }); }); + +// #4550: configured routing is not adopted routing. A Codex client that started +// before the route was injected cannot have read it, so status must name the +// stale pid instead of presenting config on disk as live traffic. Everything +// here runs through the pure derivation and the injected lister/start-time +// seams — no real process table or journal is touched. +describe("routing adoption (#4550)", () => { + const injectedAtMs = 1_700_000_000_000; + + const staleClientEvidence = ( + clients: ReadonlyArray<{ pid: number; startedAtMs: number | null }> = [ + { pid: 4242, startedAtMs: injectedAtMs - 60_000 }, + ], + ) => deriveRoutingAdoption({ routingKind: "opencodex-local", injectedAtMs, clients }); + + test("a client started before the injection is pending-client-restart with its pid named", () => { + const evidence = staleClientEvidence(); + expect(evidence.adoption).toBe("pending-client-restart"); + expect(evidence.staleClients).toEqual([{ pid: 4242, startedAtMs: injectedAtMs - 60_000 }]); + expect(evidence.observedClients).toBe(1); + }); + + test("a client started after the injection is adopted", () => { + const evidence = deriveRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs, + clients: [{ pid: 4242, startedAtMs: injectedAtMs + 60_000 }], + }); + expect(evidence).toMatchObject({ adoption: "adopted", staleClients: [], observedClients: 1 }); + }); + + test("a start in the same wall-clock second as the injection is not stale", () => { + // ps -o lstart is second-granularity, so a millisecond lead inside the same + // second is a rounding artifact, not proof the client predates the route. + // Both values sit inside second 1700000000; the comparison must truncate. + const evidence = deriveRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs: injectedAtMs + 900, + clients: [{ pid: 4242, startedAtMs: injectedAtMs + 100 }], + }); + expect(evidence.adoption).toBe("adopted"); + expect(evidence.staleClients).toEqual([]); + }); + + test("enumeration failure, a missing injection time, and an unreadable start all resolve to unknown", () => { + // "Could not tell" must never collapse into a clean bill of health. + expect(deriveRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs, + clients: [{ pid: 4242, startedAtMs: injectedAtMs + 60_000 }], + enumerationFailed: true, + }).adoption).toBe("unknown"); + expect(deriveRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs: null, + clients: [{ pid: 4242, startedAtMs: injectedAtMs - 60_000 }], + }).adoption).toBe("unknown"); + expect(deriveRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs, + clients: [{ pid: 4242, startedAtMs: null }], + }).adoption).toBe("unknown"); + }); + + test("a stale client outranks an unreadable one", () => { + const evidence = staleClientEvidence([ + { pid: 4242, startedAtMs: injectedAtMs - 60_000 }, + { pid: 4343, startedAtMs: null }, + ]); + expect(evidence.adoption).toBe("pending-client-restart"); + expect(evidence.staleClients).toEqual([{ pid: 4242, startedAtMs: injectedAtMs - 60_000 }]); + }); + + test.each(["native", "custom-local"] as const)("routing kind %s is not-applicable without enumerating clients", (routingKind) => { + // We do not speak for routing we do not own — the collector must not even + // walk the process table for a kind that is not opencodex-local. + let listCalls = 0; + const evidence = collectRoutingAdoption({ + routingKind, + listClients: () => { + listCalls += 1; + return { status: "enumerated", processes: [] }; + }, + readStartMsBatch: () => new Map(), + }); + expect(evidence.adoption).toBe("not-applicable"); + expect(listCalls).toBe(0); + }); + + test("collectRoutingAdoption reads start times through its seams and names the stale pid", () => { + const evidence = collectRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs, + platform: "linux", + listClients: () => ({ + status: "enumerated" as const, + processes: [{ pid: 4242, commandLine: "codex chat" }], + }), + readStartMsBatch: pids => new Map(pids.map(pid => [pid, injectedAtMs - 60_000])), + }); + expect(evidence.adoption).toBe("pending-client-restart"); + expect(evidence.staleClients).toEqual([{ pid: 4242, startedAtMs: injectedAtMs - 60_000 }]); + }); + + test("collectRoutingAdoption maps an unavailable walk and a start-time failure to unknown", () => { + expect(collectRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs, + listClients: () => ({ status: "unavailable" as const }), + }).adoption).toBe("unknown"); + expect(collectRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs, + listClients: () => ({ + status: "enumerated" as const, + processes: [{ pid: 4242, commandLine: "codex chat" }], + }), + readStartMsBatch: () => { throw new Error("start times unavailable"); }, + }).adoption).toBe("unknown"); + }); + + test("formatStartupRoutingDetail keeps the routing/service/shim prefix and appends stale clients", () => { + const plain = formatStartupRoutingDetail(deriveStartupHealth(base)); + expect(plain).toBe("routing=opencodex-local, service=absent, shim=absent"); + + // adopted evidence adds nothing — the string stays byte-identical, which is + // what keeps the pre-#4550 assertions above valid. + const adopted = deriveRoutingAdoption({ + routingKind: "opencodex-local", + injectedAtMs, + clients: [{ pid: 4242, startedAtMs: injectedAtMs + 60_000 }], + }); + expect(formatStartupRoutingDetail(deriveStartupHealth({ ...base, routingAdoption: adopted }))).toBe(plain); + + const stale = staleClientEvidence(); + expect(formatStartupRoutingDetail(deriveStartupHealth({ ...base, routingAdoption: stale }))) + .toBe(`${plain}, clients=pending-restart(pid 4242)`); + }); + + test("a stale client adds a restart action to the summary without changing restart-safety classification", () => { + const without = deriveStartupHealth(base); + const withStale = deriveStartupHealth({ ...base, routingAdoption: staleClientEvidence() }); + // Adoption evidence describes client opportunity, not restart safety — + // conflating them would silently change unrelated behaviour. + expect(withStale).toMatchObject({ + status: without.status, + protection: without.protection, + rebootSafe: without.rebootSafe, + recommendedCommand: without.recommendedCommand, + }); + expect(startupHealthSummary(withStale)).toBe( + `${startupHealthSummary(without)}; restart Codex client pid 4242 so it adopts the injected proxy route`, + ); + }); + + test("the summary names every stale client when more than one predates the injection", () => { + const stale = staleClientEvidence([ + { pid: 4242, startedAtMs: injectedAtMs - 60_000 }, + { pid: 4000, startedAtMs: injectedAtMs - 120_000 }, + ]); + expect(startupHealthSummary(deriveStartupHealth({ ...base, routingAdoption: stale }))) + .toContain("restart Codex clients pid 4000, 4242 so they adopt the injected proxy route"); + }); + + test("collectStartupHealth carries injected routingAdoption evidence into the health summary", () => { + const health = collectStartupHealth({ codexAutoStart: true }, { + routingKind: "opencodex-local", + service: { + supported: true, + installed: false, + enabled: false, + running: false, + viable: false, + startable: false, + stale: false, + conflict: false, + backend: null, + summary: "test service diagnostic", + }, + shim: { installed: false, healthy: false, summary: "test shim diagnostic" }, + routingAdoption: staleClientEvidence(), + }); + expect(health.routingAdoption?.adoption).toBe("pending-client-restart"); + expect(startupHealthSummary(health)).toContain("restart Codex client pid 4242"); + }); + + test("isCodexClientProcess matches direct and interpreter-wrapped Codex clients only", () => { + expect(isCodexClientProcess("codex", "codex chat")).toBe(true); + expect(isCodexClientProcess("/usr/local/bin/codex", "/usr/local/bin/codex --profile work")).toBe(true); + expect(isCodexClientProcess("node", "node /home/user/.codex/codex.js chat")).toBe(true); + expect(isCodexClientProcess("vim", "vim note.txt")).toBe(false); + expect(isCodexClientProcess("codex-helper", "codex-helper run")).toBe(false); + expect(isCodexClientProcess("node", "node server.js")).toBe(false); + }); + + test("listCodexClientProcesses keeps a failed walk distinct from an empty match set", () => { + // A throw means "could not tell"; an empty array means "none running". + // Collapsing them would turn a failed enumeration into a false adopted. + expect(listCodexClientProcesses({ + listSnapshots: () => { throw new Error("walk failed"); }, + })).toEqual({ status: "unavailable" }); + expect(listCodexClientProcesses({ + pid: -1, + listSnapshots: () => [{ pid: 4321, commandLine: "vim note.txt", executable: "vim" }], + })).toEqual({ status: "enumerated", processes: [] }); + expect(listCodexClientProcesses({ + pid: -1, + listSnapshots: () => [ + { pid: 4242, commandLine: "codex chat", executable: "/usr/local/bin/codex" }, + { pid: 4321, commandLine: "vim note.txt", executable: "vim" }, + ], + })).toEqual({ status: "enumerated", processes: [{ pid: 4242, commandLine: "codex chat" }] }); + }); +});