Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ second account.
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `oauthAccountFailover.enabled?` | `boolean` | presence-driven | Global override for the **pre-dispatch account preference** only. `false` stops a healthy request being steered toward the account with more known headroom. It does **not** disable 429 rotation. |
| `providers.<name>.oauthAccountFailover.enabled?` | `boolean` | inherits | Per-provider override for the same preference; beats the global setting. Only `false` is meaningful — `true` adds nothing over account presence. |
| `providers.<name>.oauthAccountFailover.enabled?` | `boolean` | inherits | Per-provider override for the same preference; beats the global setting in either direction. `false` declines the preference for this provider even when the global setting is `true`, and `true` opts this provider in even when the global setting is `false`. Reactive 429 rotation is unaffected either way. |
| `providers.<name>.oauthAccountFailover.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | — | Declared pool strategy for a generic OAuth provider (#695). Persisted through `ocx account strategy <provider> <name>` or `PUT /api/oauth/accounts/pool`; the generic selector does not act on it yet, so omitted and set behave the same today. |
| `providers.<name>.oauthAccountFailover.autoSwitchThreshold?` | `number` | — | Declared 0–100 usage percent for a proactive switch on a generic OAuth provider (#695). Set with `ocx account auto-switch <provider> threshold <n>`; inert until the selector consumes it. |

Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@
"anthropic-pool-toggle-copy.test.ts": "adapters/anthropic",
"anthropic-quorum-cache.test.ts": "routing",
"anthropic-reasoning.test.ts": "adapters/anthropic",
"anthropic-sidecar-account-failover.test.ts": "adapters/anthropic",
"anthropic-stream-hardening.test.ts": "adapters/anthropic",
"anthropic-tail-guard.test.ts": "adapters/anthropic",
"anthropic-thinking-signature.test.ts": "adapters/anthropic",
Expand Down Expand Up @@ -683,6 +684,7 @@
"kimi-oauth-identity.test.ts": "providers",
"kiro-account-quota.test.ts": "providers/kiro",
"kiro-adapter.test.ts": "providers/kiro",
"kiro-auth-context-continuation.test.ts": "providers/kiro",
"kiro-builder-id-profile.test.ts": "providers/kiro",
"kiro-calibration.test.ts": "providers/kiro",
"kiro-images.test.ts": "providers/kiro",
Expand Down
8 changes: 7 additions & 1 deletion src/oauth/anthropic-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,13 @@ export function rotateAnthropicAccountOn429(
// from a count read taken before the failure.
quorumCache = null;

const next = pickAlternateAnthropicAccount(config, failedAccountId, now);
// The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive
// presence-only recovery must not silently reactivate round-robin/fill-first merely
// because those dormant values remain in config. The quota picker is the neutral
// recovery policy already used by the default strategy.
const next = isAnthropicAccountPoolEnabled(config)
? pickAlternateAnthropicAccount(config, failedAccountId, now)
: pickLowestUsage(config, failedAccountId, now);
if (!next) {
console.warn("[anthropic-pool] all eligible Anthropic OAuth accounts are in cooldown; returning 429");
return null;
Expand Down
15 changes: 10 additions & 5 deletions src/oauth/generic-account-failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,15 +178,20 @@ export function isGenericOAuthFailoverEnabled(
* Whether the pre-dispatch account PREFERENCE may run for this provider.
*
* Unlike reactive rotation, this moves a request that upstream has not refused, so it stays
* refusable: an explicit `false` — per provider first, then global — turns it off. `true` adds
* nothing over presence, so only `false` is honoured; that keeps the predicate identical to the
* old behaviour for every operator who never wrote the key, and a malformed value falls through
* rather than taking a provider out of service.
* refusable: an explicit provider value wins over the global default, and a global `false`
* turns it off only when the provider has no override. A malformed value falls through rather
* than taking a provider out of service.
*/
function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, now: number): boolean {
const provider = config.providers?.[providerName];
if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
if (provider.oauthAccountFailover?.enabled === false) return false;
const perProvider = provider.oauthAccountFailover?.enabled;
// Preserve the published narrow-over-broad precedence. A provider-specific true may
// opt this provider into proactive preference even when the global default is false;
// a provider-specific false refuses it even when the global setting is true.
if (typeof perProvider === "boolean") {
return perProvider && hasFailoverAccountQuorum(providerName, now);
}
if (config.oauthAccountFailover?.enabled === false) return false;
return hasFailoverAccountQuorum(providerName, now);
}
Expand Down
16 changes: 13 additions & 3 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3364,7 +3364,10 @@ async function handleResponsesInner(
* tolerates project discovery failing, so a stored account can legitimately have no project;
* sending that account's bearer with the FAILED account's project is worse than not rotating.
*/
const applyFailoverSnapshot = (snapshot: OAuthAccessSnapshot): boolean => {
const applyFailoverSnapshot = (
snapshot: OAuthAccessSnapshot,
retryParsed: OcxParsedRequest = parsed,
): boolean => {
if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return false;
let rotatedProvider: OcxProviderConfig = { ...route.provider, apiKey: snapshot.accessToken };
if (route.providerName === "github-copilot") {
Expand All @@ -3377,7 +3380,14 @@ async function handleResponsesInner(
}
if (snapshot.projectId) rotatedProvider = { ...rotatedProvider, project: snapshot.projectId };
route.provider = rotatedProvider;
if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) };
if (route.providerName === "kiro") {
const kiroContext = { ...(snapshot.kiro ?? {}) };
// Terminal-guard continuations are rebuilt from a shallow clone. Updating only the
// outer request pairs the new bearer with the failed account's region/profile on
// the retry. Keep both owners synchronized; for ordinary paths they are identical.
parsed._kiroAuthContext = kiroContext;
if (retryParsed !== parsed) retryParsed._kiroAuthContext = { ...kiroContext };
}
// Re-stamp: a request that rotated accounts must be attributed to the account that actually
// served it. All three rotation sites funnel through here, so this is the only re-stamp
// needed -- and putting it anywhere else would let one of the three drift.
Expand Down Expand Up @@ -6686,7 +6696,7 @@ async function handleResponsesInner(
const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
genericFailoverAccountId = nextAccountId;
genericFailovers += 1;
if (applyFailoverSnapshot(snapshot)) {
if (applyFailoverSnapshot(snapshot, nextParsed)) {
invalidateSameTargetRequest();
activeAdapter = resolveAdapter(
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
Expand Down
2 changes: 1 addition & 1 deletion src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ export interface OcxConfig {
* What `enabled: false` still refuses is the PRE-DISPATCH preference: steering a request
* upstream has not refused toward the account with more known headroom. That moves a healthy
* request, so it stays a real choice. `providers.<name>.oauthAccountFailover` overrides this
* per provider, and only `false` is meaningful — `true` adds nothing over presence.
* per provider in either direction; reactive 429 rotation remains presence-driven.
*/
oauthAccountFailover?: {
enabled?: boolean;
Expand Down
2 changes: 1 addition & 1 deletion src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ export interface OcxProviderConfig {
* activate it, and a 429 with an idle second account is a defect rather than a preference.
* What an explicit `false` still refuses is the pre-dispatch preference that steers a HEALTHY
* request toward the account with more known headroom. It beats the global
* `oauthAccountFailover`; only `false` is meaningful, since `true` adds nothing over presence.
* `oauthAccountFailover` in either direction; reactive 429 rotation remains presence-driven.
*/
oauthAccountFailover?: {
enabled?: boolean;
Expand Down
9 changes: 9 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -1412,9 +1412,18 @@ 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/key-failover.ts` | A 429 rotates the active key and records a cooldown; `provider.apiKey` keeps mirroring the active entry so routing stays single-key. |
| 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. |
| 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. |

[Decision Log]
- 목적과 의도: Keep reactive OAuth 429 recovery available without silently enabling proactive account-routing policy the operator switched off.
- 기존 구현 및 제약 조건: #3495 made reactive recovery presence-driven, but a disabled Anthropic pool still consulted its dormant strategy on the reactive path, and a per-provider `oauthAccountFailover.enabled: true` could no longer beat a global `false`.
- 검토한 주요 대안: Restore the old all-or-nothing enable flag; leave the merged behavior and document the gaps; or keep the reactive/proactive split and repair the exact policy boundaries.
- 선택한 방식: Keep presence-driven reactive recovery, apply proactive precedence only before dispatch, and use quota ordering for disabled-pool Anthropic recovery.
- 다른 대안 대신 이 방식을 선택한 이유: This preserves the merged product decision without letting disabled proactive settings influence a retry, and it restores the published narrow-over-broad precedence in both directions.
- 장점, 단점 및 영향: 429 recovery stays automatic for operators with multiple eligible accounts; operators who require no automatic account switch must keep one eligible account, which the GUI and public docs state explicitly.

## Sidecars

Web search and vision sidecars run only when the main request needs that capability and a usable
Expand Down
149 changes: 149 additions & 0 deletions tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* The web-search sidecar is a rotation site of its own, and it reaches Anthropic through the
* shared 429 hook rather than the main response loop. A pool that is switched off must still
* recover there: `anthropicAccountPool.enabled: false` declines PROACTIVE routing, not the
* reactive retry that runs only after upstream has already refused the request.
*/
import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ProviderAdapter } from "../../../src/adapters/base";
import { clearAnthropicAccountPoolState } from "../../../src/oauth/anthropic-routing";
import { clearGenericFailoverHealth } from "../../../src/oauth/generic-account-failover";
import { getAccountSet, saveCredential, setActiveAccount } from "../../../src/oauth/store";
import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../../src/types";
import { removeTreeWithRetry } from "../../helpers/remove-tree";

const previousHome = process.env.OPENCODEX_HOME;
let testHome = "";
let handleResponses: typeof import("../../../src/server/responses")["handleResponses"];
let observedKeys: string[] = [];
let sidecarMode = false;

function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter {
return {
name: "anthropic",
buildRequest() {
return {
url: provider.baseUrl,
method: "POST",
headers: { authorization: `Bearer ${provider.apiKey ?? ""}` },
body: "{}",
};
},
async *parseStream() {
yield { type: "done" as const };
},
};
}

beforeAll(async () => {
const actualResolver = await import("../../../src/server/adapter-resolve");
const actualResolveAdapter = actualResolver.resolveAdapter;
mock.module("../../../src/server/adapter-resolve", () => ({
...actualResolver,
resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") {
if (provider.adapter === "test-anthropic-sidecar") return fixtureAdapter(provider);
return actualResolveAdapter(provider, cacheRetention);
},
}));

mock.module("../../../src/web-search", () => ({
buildWebSearchTool: () => ({
name: "web_search",
parameters: { type: "object", properties: {} },
}),
planWebSearch: () => sidecarMode
? {
backend: "anthropic",
hostedTool: { type: "web_search" },
settings: { model: "claude-haiku-4-5", reasoning: "low", timeoutMs: 1_000 },
maxSearches: 1,
}
: undefined,
shouldResolveOpenAiWebSearchSidecar: () => false,
runWithWebSearch: async (args: {
parsed: OcxParsedRequest;
adapter: ProviderAdapter;
on429?: (retryAfter: string | null) => Promise<ProviderAdapter | null>;
}) => {
const first = await args.adapter.buildRequest(args.parsed);
observedKeys.push(new Headers(first.headers).get("authorization") ?? "");
const rotated = await args.on429?.("30");
if (!rotated) throw new Error("Anthropic sidecar did not rotate after 429");
const second = await rotated.buildRequest(args.parsed);
observedKeys.push(new Headers(second.headers).get("authorization") ?? "");
return new Response("sidecar-ok", { status: 200 });
},
}));

({ handleResponses } = await import("../../../src/server/responses"));
});

beforeEach(() => {
testHome = mkdtempSync(join(tmpdir(), "ocx-oauth-429-boundaries-"));
process.env.OPENCODEX_HOME = testHome;
observedKeys = [];
sidecarMode = false;
clearAnthropicAccountPoolState();
clearGenericFailoverHealth();
});

afterEach(() => {
clearAnthropicAccountPoolState();
clearGenericFailoverHealth();
removeTreeWithRetry(testHome);
});

afterAll(() => {
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousHome;
mock.restore();
});

test("Anthropic web-search sidecar rotates on 429 when proactive pooling is disabled", async () => {
sidecarMode = true;
for (let index = 0; index < 2; index += 1) {
await saveCredential("anthropic", {
access: `anthropic-access-${index}`,
refresh: `anthropic-refresh-${index}`,
expires: Date.now() + 3_600_000,
accountId: `anthropic-account-${index}`,
} as never, { addAccount: true });
}
const ids = getAccountSet("anthropic")!.accounts.map(account => account.id);
await setActiveAccount("anthropic", ids[0]!);

const config = {
port: 0,
defaultProvider: "anthropic",
anthropicAccountPool: { enabled: false, strategy: "round-robin" },
providers: {
anthropic: {
adapter: "test-anthropic-sidecar",
baseUrl: "https://anthropic-sidecar.test/v1",
authMode: "oauth",
models: ["model"],
},
},
} as unknown as OcxConfig;

const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "anthropic/model",
input: "search",
stream: true,
tools: [{ type: "web_search" }],
}),
}), config, { model: "", provider: "" });

expect(response.status).toBe(200);
expect(await response.text()).toBe("sidecar-ok");
expect(observedKeys).toEqual([
"Bearer anthropic-access-0",
"Bearer anthropic-access-1",
]);
});
2 changes: 2 additions & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"anthropic-pool-toggle-copy.test.ts": "adapters/anthropic",
"anthropic-quorum-cache.test.ts": "routing",
"anthropic-reasoning.test.ts": "adapters/anthropic",
"anthropic-sidecar-account-failover.test.ts": "adapters/anthropic",
"anthropic-stream-hardening.test.ts": "adapters/anthropic",
"anthropic-tail-guard.test.ts": "adapters/anthropic",
"anthropic-thinking-signature.test.ts": "adapters/anthropic",
Expand Down Expand Up @@ -520,6 +521,7 @@
"kimi-oauth-identity.test.ts": "providers",
"kiro-account-quota.test.ts": "providers/kiro",
"kiro-adapter.test.ts": "providers/kiro",
"kiro-auth-context-continuation.test.ts": "providers/kiro",
"kiro-builder-id-profile.test.ts": "providers/kiro",
"kiro-calibration.test.ts": "providers/kiro",
"kiro-images.test.ts": "providers/kiro",
Expand Down
5 changes: 4 additions & 1 deletion tests/oauth/adapter-event-oauth-failover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,12 @@ describe("#2568 adapter-event OAuth failover", () => {
[{ type: "text", text: "ok" }],
];

const body = await (await handleResponses(request(true), config(false), { model: "", provider: "" })).text();
const response = await handleResponses(request(true), config(false), { model: "", provider: "" });
const body = await response.text();

expect(response.status).toBe(200);
expect(attemptKeys).toEqual(["cursor-access-1", "cursor-access-0"]);
expect(body).toContain("ok");
expect(body).not.toContain("rate_limit_exceeded");
});

Expand Down
Loading
Loading