From d24ff57bc4dd53afbbb1d2c972266e6d89ea5c24 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 8 Sep 2026 17:44:26 +0900 Subject: [PATCH 1/2] release: set main channel version 2.48.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d94d23cab..6547da6552 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.47.0", + "version": "2.48.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 6f5554c709f257692452886f294a8209c8b8567b Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 14 Sep 2026 09:19:31 +0900 Subject: [PATCH 2/2] fix(oauth): honor disabled generic auto-switch threshold --- src/cli/account-extended.ts | 8 ++++---- src/oauth/generic-account-failover.ts | 1 + structure/config.md | 3 +++ structure/providers/xai-grok.md | 3 +++ structure/runtime.md | 2 +- structure/transports/inventory.md | 2 +- tests/cli/cli-account-pool-verbs.test.ts | 13 +++++++++++++ tests/oauth/generic-oauth-failover.test.ts | 10 ++++++++++ 8 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 18fca00fb7..5e90e5cb38 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -403,16 +403,16 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< // shared kernel, and absent is a server that does not speak this field at all. Collapsing // false into absent would render the live feature as an unknown capability. const inert = typeof settings.inert === "boolean" ? settings.inert : null; - // A stored threshold only steers selection once the pool consumes it, which is exactly - // what `inert: false` reports. - const enabled = inert === false && storedThreshold !== null; + // A positive stored threshold only steers selection once the pool consumes it, which is + // exactly what `inert: false` reports. Zero remains the explicit disabled value. + const enabled = inert === false && storedThreshold !== null && storedThreshold > 0; if (wantsJson) { console.log(JSON.stringify({ provider: name, autoSwitchThreshold: storedThreshold, enabled, poolEnabled, inert }, null, 2)); } else { const value = storedThreshold === null ? "unset" : `${storedThreshold}%`; const state = inert === false ? (enabled ? "on" : "off") : inert === true ? "inactive" : "unavailable"; const why = inert === false - ? (enabled ? "applied by this pool" : "no threshold stored") + ? (enabled ? "applied by this pool" : storedThreshold === 0 ? "usage-based switching disabled" : "no threshold stored") : inert === true ? "not applied by this pool" : "threshold support is unknown"; console.log(`auto-switch: ${state} (stored threshold ${value}; ${why})`); } diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index 6b444ea435..858619fd7d 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -231,6 +231,7 @@ function stableGenericRoster(providerName: string): string[] { * quota-less provider off its active account on the very first request. */ function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number): boolean { + if (threshold <= 0) return false; const headroom = accountHeadroomPercent(providerName, accountId); if (headroom === null) return false; return 100 - headroom >= threshold; diff --git a/structure/config.md b/structure/config.md index 4f532e789e..b347afb57e 100644 --- a/structure/config.md +++ b/structure/config.md @@ -52,6 +52,9 @@ matters for maintainers is which groups exist and who resolves them: | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | | Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`gui-and-management-api.md`](gui-and-management-api.md). | +The account CLI in `src/cli/account-extended.ts` persists zero as the disabled auto-switch +threshold and reports that stored value as off even when the generic pool kernel is active. + Env values are resolved through `src/config.ts`, so a config value naming an env var never persists the secret itself. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index c6e7cd48f3..93fbbc5d56 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -28,6 +28,9 @@ Grounded in the open-sourced official client (xai-org/grok-build); unit + eviden force-refresh once (singleflight, generation-checked) and replay OAuth-backed xAI requests exactly once with a re-resolved transport; API-key/BYOK paths are excluded (`src/server/responses/core.ts`). +- **Generic pool threshold:** xAI's generic OAuth fill-first selector in + `src/oauth/generic-account-failover.ts` treats a non-positive threshold as disabling proactive + usage-based account changes; reactive recovery remains independent. - **Header parity:** per-attempt `x-grok-req-id` (fresh UUID inside the transport fetch wrapper), stable session/conv affinity headers, always-set User-Agent, and a single compatibility profile const for the Grok client version (`src/providers/xai-transport.ts`); diff --git a/structure/runtime.md b/structure/runtime.md index 365fb04e07..e6bb0d1a4c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -145,7 +145,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | --- | --- | | `src/providers/registry.ts` | Canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata. | | `src/providers/derive.ts` | Enrichment from provider presets into user config. | -| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. | +| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Generic fill-first routing treats a non-positive auto-switch threshold as disabling proactive usage-based account changes. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/adapters/openai-chat.ts` | OpenAI-compatible Chat Completions bridge. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. | diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 88a26d3500..2849957090 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -19,7 +19,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | | API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A configured `apiKeyPoolStrategy` plus a cooling committed key rotates before the first send (`selectProactiveApiKeyTransport`); a 429 still rotates after the send and records a cooldown. `provider.apiKey` keeps mirroring the active entry so routing stays single-key. The pick is inert without a strategy or while the committed key is healthy. | -| OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, and a per-provider `enabled` beats the global default in either direction. | +| OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, a per-provider `enabled` beats the global default in either direction, and a non-positive fill-first threshold disables proactive usage-based rotation. | | OAuth login callback (inbound) | `src/oauth/callback-server.ts` | Every response, including non-callback 404s, closes its connection so a pooled socket cannot deliver a later login to a retired flow on the same callback port. | | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. | diff --git a/tests/cli/cli-account-pool-verbs.test.ts b/tests/cli/cli-account-pool-verbs.test.ts index afab5fea2f..2ad3e72682 100644 --- a/tests/cli/cli-account-pool-verbs.test.ts +++ b/tests/cli/cli-account-pool-verbs.test.ts @@ -437,6 +437,19 @@ describe("generic OAuth pool-settings contract (#695)", () => { expect(result.autoSwitchThreshold).toBeNull(); }); + test("a live generic pool reports a zero threshold as off", async () => { + const out = capture(); + try { + expect(await cmdAutoSwitch( + ["google-antigravity", "status", "--json"], + genericDeps(() => ({ json: { enabled: true, autoSwitchThreshold: 0, inert: false } }), []), + )).toBe(0); + } finally { out.restore(); } + expect(JSON.parse(out.lines.join("\n"))).toEqual({ + provider: "google-antigravity", autoSwitchThreshold: 0, enabled: false, poolEnabled: true, inert: false, + }); + }); + test("a successful generic write with a null body reports unknown settings", async () => { const calls: Captured[] = []; const out = capture(); diff --git a/tests/oauth/generic-oauth-failover.test.ts b/tests/oauth/generic-oauth-failover.test.ts index fbf47c776a..4e505dd18e 100644 --- a/tests/oauth/generic-oauth-failover.test.ts +++ b/tests/oauth/generic-oauth-failover.test.ts @@ -592,6 +592,16 @@ describe("#695 the generic pool consumes its persisted strategy behind pool.kern expect(preferredInitialAccount(cfg, "xai")).toBe(sorted[1]!); }); + test("fill-first treats a zero threshold as disabling proactive switching", async () => { + const ids = await seed(2); + const active = [...ids].sort((left, right) => left.localeCompare(right))[0]!; + await setActiveAccount("xai", active); + const cfg = kernelConfig("fill-first", { autoSwitchThreshold: 0 }); + + setCachedProviderAccountQuotaForTests("xai", active, { weeklyPercent: 100, updatedAt: Date.now() }); + expect(preferredInitialAccount(cfg, "xai")).toBeNull(); + }); + test("fill-first advances through the sorted roster, not the eligible subset", async () => { const ids = await seed(3); const sorted = [...ids].sort((left, right) => left.localeCompare(right));