diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 46b6c3110f0..1b5575e889d 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -302,7 +302,7 @@ Only fields named by `supported` may be written; null is not false, and an inert policy is not active pooling. Threshold zero and `enabled:false` are independent. Use `--enabled on|off`, `--threshold 0..100`, `--strategy`, `--sticky 1..100`, and `--quota-window` only where supported. OpenAI does not support pool enabled/window -writes; `reset-first` is OpenAI-only, `least-loaded` Kiro-only, and Anthropic windows +writes; `reset-first` is for OpenAI and generic OAuth pools (never Anthropic), `least-loaded` Kiro-only, and Anthropic windows are `five-hour`, `weekly`, `max-utilization`. Omitted fields stay unchanged. Read DTO fields such as routes, per-account concurrency or Anthropic `nativeMessages` do not imply a setter in this command; use only the listed CLI options. A save that diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 08fde51de1f..8060f45db64 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1149,7 +1149,7 @@ second account. | --- | --- | --- | --- | | `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..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..oauthAccountFailover.strategy?` | `"quota" \| "round-robin" \| "fill-first" \| "least-loaded"` (Kiro only) | — | Kiro's `least-loaded` picks the eligible account with the fewest in-flight requests, using account order for ties. It requires `pool.kernel` and proactive preference; other strategies retain their existing rules. | +| `providers..oauthAccountFailover.strategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first" \| "least-loaded"` (Kiro only) | — | Kiro's `least-loaded` picks the eligible account with the fewest in-flight requests, using account order for ties. It requires `pool.kernel` and proactive preference; other strategies retain their existing rules. `reset-first` also requires `pool.kernel`: among eligible accounts that are not exhausted, the first attempt (while the proactive preference is on) and a 429 rotation both prefer the account whose quota resets soonest (the weekly window when one is reported, otherwise the 5-hour window), with more headroom breaking ties. Exhausted accounts go last. Without quota evidence, or when a passive provider has evidence for only some accounts, it falls back to headroom order. The Anthropic pool does not accept it. | | `providers.kiro.oauthAccountFailover.maxConcurrentPerAccount?` | integer 1–100 | unlimited | A process-local per-account queue holds a slot through response completion or cancellation. A full selected account waits at most 250 ms, then returns 503 `account_capacity` with `Retry-After: 1`. The cap does not move the request to another account. | | `providers..oauthAccountFailover.autoSwitchThreshold?` | `number` | `80` | 0–100 usage percent at which `fill-first` advances off the active account (#695). Set with `ocx account auto-switch threshold `. Read only under `pool.kernel` with `strategy: "fill-first"`; an account with no measured usage counts as under the threshold. | | `providers..oauthAccountFailover.stickyLimit?` | `number` | `1` | Successful dispatches retained on one `round-robin` selection, 1–100 (#695). Read only under `pool.kernel` with `strategy: "round-robin"`. | diff --git a/skills/ocx/references/01_surface_accounts.md b/skills/ocx/references/01_surface_accounts.md index 82b2af8d37e..de86c7746cc 100644 --- a/skills/ocx/references/01_surface_accounts.md +++ b/skills/ocx/references/01_surface_accounts.md @@ -858,7 +858,7 @@ State-changing: yes. |---|---|---| | `--enabled` | string | Stored pool preference; unsupported for Codex. Not equivalent to threshold0. | | `--threshold` | number | Auto-switch threshold0–100. | -| `--strategy` | string | Use a strategy supported by this provider kind; reset-first is Codex, least-loaded is Kiro. | +| `--strategy` | string | Use a strategy supported by this provider kind; reset-first is Codex or generic OAuth, least-loaded is Kiro. | | `--sticky` | number | Sticky limit1–100. | | `--quota-window` | string | Anthropic only: five-hour, weekly or max-utilization. | | `--json` | boolean | Emit the validated task result as one JSON document. | diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 7b0c7b84b0c..30ce49ec610 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -697,7 +697,7 @@ The target's `supported` list decides which fields can be written. Preserve null `enabledEffective`, and optional `inert`: null is not false, stored policy is not proof of an available account, and threshold zero is not the pool enable switch. OpenAI has no pool `--enabled` or `--quota-window` writer. `reset-first` strategy -is OpenAI-only; `least-loaded` is Kiro-only. Anthropic pool windows are +is for OpenAI and generic OAuth pools, never Anthropic; `least-loaded` is Kiro-only. Anthropic pool windows are `five-hour`, `weekly`, `max-utilization`. For a requested supported policy edit: ```bash diff --git a/src/cli/account-policy.ts b/src/cli/account-policy.ts index c5de7d5239f..07e42bd6cc5 100644 --- a/src/cli/account-policy.ts +++ b/src/cli/account-policy.ts @@ -90,7 +90,8 @@ async function pool(args: string[], json: boolean, deps: RuntimeApiDeps): Promis if (limit !== undefined) patch.autoSwitchThreshold = integer(limit, 0); if (sticky !== undefined) patch.stickyLimit = integer(sticky, 1); if (strategy !== undefined) { - if (!["quota", "round-robin", "fill-first", ...(provider === "openai" ? ["reset-first"] : []), + // reset-first is a Codex and generic-pool strategy; the Anthropic pool does not run it. + if (!["quota", "round-robin", "fill-first", ...(isAnthropicInstanceId(provider) ? [] : ["reset-first"]), ...(provider === "kiro" ? ["least-loaded"] : [])].includes(strategy)) usage(); patch.strategy = strategy; } diff --git a/src/cli/capabilities-accounts.ts b/src/cli/capabilities-accounts.ts index 8a6d14e1494..8f86d4fef19 100644 --- a/src/cli/capabilities-accounts.ts +++ b/src/cli/capabilities-accounts.ts @@ -157,7 +157,7 @@ export const ACCOUNT_CAPABILITIES: readonly Capability[] = [ usage: "ocx account pool [--enabled ] [--threshold <0-100>] [--strategy ] [--sticky <1-100>] [--quota-window ] [--json]", summary: "Read or edit the selected runtime's supported pool policy fields.", routes: [{"method":"GET","path":"/api/pool/settings"},{"method":"PUT","path":"/api/pool/settings"}], - flags: [{"name":"--enabled","value":"string","summary":"Stored pool preference; unsupported for Codex. Not equivalent to threshold0."},{"name":"--threshold","value":"number","summary":"Auto-switch threshold0–100."},{"name":"--strategy","value":"string","summary":"Use a strategy supported by this provider kind; reset-first is Codex, least-loaded is Kiro."},{"name":"--sticky","value":"number","summary":"Sticky limit1–100."},{"name":"--quota-window","value":"string","summary":"Anthropic only: five-hour, weekly or max-utilization."},{"name":"--json","value":"boolean","summary":"Emit the validated task result as one JSON document."}], + flags: [{"name":"--enabled","value":"string","summary":"Stored pool preference; unsupported for Codex. Not equivalent to threshold0."},{"name":"--threshold","value":"number","summary":"Auto-switch threshold0–100."},{"name":"--strategy","value":"string","summary":"Use a strategy supported by this provider kind; reset-first is Codex or generic OAuth, least-loaded is Kiro."},{"name":"--sticky","value":"number","summary":"Sticky limit1–100."},{"name":"--quota-window","value":"string","summary":"Anthropic only: five-hour, weekly or max-utilization."},{"name":"--json","value":"boolean","summary":"Emit the validated task result as one JSON document."}], mutates: true, json: "envelope", details: ["Reads the target supported-field list before a narrow PUT. Null/unsupported, stored/effective and generic inert policy stay distinct; disabling does not promise that reactive429 rotation stops.","No local classification/defaults are round-tripped into the live target; supported-field reads are not CAS."], diff --git a/src/oauth/account-quota-rank.ts b/src/oauth/account-quota-rank.ts index d3eb0c68845..0f73c58a2b0 100644 --- a/src/oauth/account-quota-rank.ts +++ b/src/oauth/account-quota-rank.ts @@ -140,6 +140,73 @@ export function isAccountQuotaExhausted( return headroom !== null && headroom <= 0; } +/** + * Earliest future reset timestamp (in ms) for the relevant quota window, or null if unknown. + * Prefers weekly reset when multiple windows exist so expiring allowance is consumed first. + */ +export function accountResetTimestamp( + provider: string, + accountId: string, + requestedModelId?: string | null, + now = Date.now(), +): number | null { + const quota = getCachedProviderAccountQuota(provider, accountId); + if (!quota) return null; + const family = classifyModelFamilyForQuota(provider, requestedModelId); + const windows = (quota.customWindows ?? []).filter(w => !family || windowMatchesFamily(w.label, family)); + const weekly = windows.find(w => /week/i.test(w.label)); + let rawReset = weekly?.resetAt ?? quota.weeklyResetAt; + if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") { + // Providers without a weekly cadence: prefer an explicitly 5h-labeled window before + // falling back to first-window order. + const fiveHour = windows.find(w => /5h|five/i.test(w.label)) ?? windows[0]; + rawReset = fiveHour?.resetAt ?? quota.fiveHourResetAt; + } else if (rawReset === undefined) { + rawReset = windows[0]?.resetAt ?? quota.fiveHourResetAt; + } + if (typeof rawReset !== "number" || !Number.isFinite(rawReset)) return null; + const ms = rawReset < 1e11 ? rawReset * 1000 : rawReset; + return ms > now ? ms : null; +} + +/** + * Rank candidates by soonest reset timestamp first among healthy accounts. + * Accounts whose quota will reset earliest get priority so allowances do not expire unused. + * + * When a passive provider has incomplete quota evidence (some ring members with no + * cached record), reset timestamps are unreliable: an unknown account converts to + * Infinity and always loses to a known near-exhausted one. Fall back to headroom + * order instead of inventing a reset ordering. + */ +export function rankAccountsByResetFirst( + provider: string, + ring: readonly string[], + requestedModelId?: string | null, + now = Date.now(), + accounts?: ReadonlyMap, +): string[] { + if (ring.length < 2) return [...ring]; + const ranked = rankAccountsByHeadroom(provider, ring, requestedModelId, accounts); + const healthy = ranked.filter(id => !isAccountQuotaExhausted(provider, id, requestedModelId, accounts?.get(id))); + if (healthy.length < 2) return ranked; + if ( + hasPassiveAccountQuota(provider) + && !ring.every(id => headroomOf(provider, id, requestedModelId, accounts?.get(id)) !== null) + ) return ranked; + + const withReset = healthy.map((id, index) => ({ + id, + resetAt: accountResetTimestamp(provider, id, requestedModelId, now) ?? Number.POSITIVE_INFINITY, + headroom: headroomOf(provider, id, requestedModelId, accounts?.get(id)) ?? 0, + index, + })); + + withReset.sort((a, b) => (a.resetAt - b.resetAt) || (b.headroom - a.headroom) || (a.index - b.index)); + const sortedHealthy = withReset.map(r => r.id); + const exhausted = ranked.filter(id => isAccountQuotaExhausted(provider, id, requestedModelId, accounts?.get(id))); + return [...sortedHealthy, ...exhausted]; +} + /** * Order candidates best-first. * diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index e1a1884037b..d38f2dfea6e 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -25,6 +25,7 @@ import { hasHeadroomEvidence, isAccountQuotaExhausted, rankAccountsByHeadroom, + rankAccountsByResetFirst, classifyModelFamilyForQuota, type QuotaModelFamily, } from "./account-quota-rank"; @@ -351,7 +352,7 @@ export function rotateAntigravityAccountOnAuthRefusal( } /** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */ -type ActiveGenericStrategy = "round-robin" | "fill-first" | "least-loaded"; +type ActiveGenericStrategy = "round-robin" | "fill-first" | "reset-first" | "least-loaded"; /** Matches the Codex and Anthropic pools; the DTO still reports `null` for "not stored". */ const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80; @@ -365,10 +366,13 @@ const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80; * branch instead of a four-way one. */ function activeGenericStrategy(config: OcxConfig, providerName: string): ActiveGenericStrategy | null { + // All pool strategies run behind the kernel flag; `quota` is the pre-kernel path. + // Letting `reset-first` bypass it would change 429 selection for pools that never + // opted into kernel-gated strategies. if (config.pool?.kernel !== true) return null; const raw = config.providers?.[providerName]?.oauthAccountFailover?.strategy; if (providerName === "kiro" && raw === "least-loaded") return raw; - return raw === "round-robin" || raw === "fill-first" ? raw : null; + return raw === "round-robin" || raw === "fill-first" || raw === "reset-first" ? raw : null; } function genericStickyLimit(config: OcxConfig, providerName: string): number { @@ -574,6 +578,10 @@ export function rotateGenericOAuthAccountOnRefusal( } return null; } + if (strategy === "reset-first") { + return rankAccountsByResetFirst(providerName, candidates, requestedModelId, now, + new Map(set.accounts.map(account => [account.id, account])))[0] ?? null; + } // With no quota evidence this returns the ring untouched, so providers without // per-account quota keep exactly the traversal they have today. return rankAccountsByHeadroom(providerName, candidates, requestedModelId, @@ -697,6 +705,17 @@ export function preferredInitialAccount( const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows); return picked && picked !== active ? picked : null; } + if (strategy === "reset-first") { + // Same candidate set as round-robin: paused, reauth-needed and cooled accounts are out, + // and an account whose model listing omits the requested model loses to one that has it. + // Reset ordering needs quota evidence; without it the ordinary active-account path runs. + const family = classifyModelFamilyForQuota(providerName, requestedModelId); + const eligibleNow = preferKiroModelSupport(providerName, + eligibleFailoverAccounts(providerName, now, family), requestedModelId, config); + if (eligibleNow.length === 0 || !hasHeadroomEvidence(providerName, eligibleNow, requestedModelId, accountRows)) return null; + const best = rankAccountsByResetFirst(providerName, eligibleNow, requestedModelId, now, accountRows)[0] ?? null; + return best && best !== active ? best : null; + } const activeRow = selected.accounts.find(account => account.id === active); if (activeRow && activeRow.paused !== true && activeRow.needsReauth !== true diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index 60adab46d32..b334a9db930 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -22,7 +22,7 @@ import { readAnthropicModelRoutes } from "./anthropic-model-routes"; */ export type PoolSettingsKind = "codex" | "anthropic" | "generic"; -export const GENERIC_POOL_STRATEGIES = ["quota", "round-robin", "fill-first", "least-loaded"] as const; +export const GENERIC_POOL_STRATEGIES = ["quota", "round-robin", "fill-first", "reset-first", "least-loaded"] as const; export type GenericPoolStrategy = typeof GENERIC_POOL_STRATEGIES[number]; export function poolSettingsCapability(name: string, provider: OcxProviderConfig | undefined): PoolSettingsKind | null { @@ -33,7 +33,9 @@ export function poolSettingsCapability(name: string, provider: OcxProviderConfig } export function parseGenericPoolStrategy(value: unknown, providerName?: string): GenericPoolStrategy | null { - // Keep the common three names in the shared parser; the fourth is Kiro-specific. + // reset-first is shared with the Codex pool, so it is named here rather than added to the + // three-name shared parser that the Anthropic kind also uses. least-loaded is Kiro-specific. + if (value === "reset-first") return value; if (value === "least-loaded") return providerName === "kiro" ? value : null; return parseAccountPoolStrategy(value); } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 50c2cabb3c2..3de178867c4 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -597,10 +597,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // sticky limit is refused identically whichever pool is addressed. let strategy: string | undefined; if (fields.strategy !== undefined) { - const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) : parseGenericPoolStrategy(fields.strategy, provider); - if (parsed === null) return jsonResponse({ error: kind === "codex" - ? "strategy must be one of: quota, round-robin, fill-first, reset-first" - : `strategy must be one of: quota, round-robin, fill-first${provider === "kiro" ? ", least-loaded" : ""}` }, 400); + // reset-first is a Codex and generic strategy. The Anthropic runtime implements only + // quota, round-robin and fill-first, so that kind keeps refusing it rather than storing + // a strategy it would silently ignore. + const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) + : kind === "generic" ? parseGenericPoolStrategy(fields.strategy, provider) + : parseAccountPoolStrategy(fields.strategy); + if (parsed === null) return jsonResponse({ error: kind === "anthropic" + ? "strategy must be one of: quota, round-robin, fill-first" + : `strategy must be one of: quota, round-robin, fill-first, reset-first${kind === "generic" && provider === "kiro" ? ", least-loaded" : ""}` }, 400); strategy = parsed; } let stickyLimit: number | undefined; @@ -754,7 +759,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (body.strategy === null) delete next.strategy; else { const parsed = parseGenericPoolStrategy(body.strategy, provider); - if (parsed === null) return jsonResponse({ error: `strategy must be one of: quota, round-robin, fill-first${provider === "kiro" ? ", least-loaded" : ""}` }, 400); + if (parsed === null) return jsonResponse({ error: `strategy must be one of: quota, round-robin, fill-first, reset-first${provider === "kiro" ? ", least-loaded" : ""}` }, 400); next.strategy = parsed; } } diff --git a/src/types/provider.ts b/src/types/provider.ts index 9feac95f48e..4ef8e456291 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -726,7 +726,7 @@ export interface OcxProviderConfig { * contract. Consumed by the selector only while `pool.kernel` is on; with the flag off * it is still merely persisted, so omitted and set behave the same. */ - strategy?: "quota" | "round-robin" | "fill-first" | "least-loaded"; + strategy?: "quota" | "round-robin" | "fill-first" | "reset-first" | "least-loaded"; /** * 0-100 usage percent at which fill-first advances off the active account (#695). * Read only under `pool.kernel` with `strategy: "fill-first"`; 80 when unset, matching diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 923984e43a4..5403b3ed2d5 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -307,6 +307,17 @@ the same routed model during account rotation, without bypassing their send-budg admission or account-snapshot pairing. The forwarding contract is covered in `tests/oauth/oauth-account-quota-rank.test.ts`; the core facade remains orchestration-only. +Generic OAuth pools accept `reset-first` under `pool.kernel` (`activeGenericStrategy` in +`src/oauth/generic-account-failover.ts`). `rankAccountsByResetFirst` orders non-exhausted +accounts by the soonest future reset of the requested family's weekly window, falling back to +the 5-hour window, then by headroom; exhausted accounts go last. A passive provider with +evidence for only part of the ring keeps headroom order. The first-attempt pick uses the same +eligible set as round-robin, including per-account model listings, and needs headroom evidence. +Kiro passes account-bound quota evidence through both selection paths, preserving headroom +ordering and explicit exhaustion when reset times are unknown. The Anthropic kind refuses +the strategy. Coverage: `tests/oauth/oauth-account-quota-rank.test.ts` +and `tests/server/account-pool-management-api.test.ts`. + ## SOCKS5 dispatch boundary `src/lib/socks5-handshake.ts` shares RFC1928/RFC1929 framing between the fetch tunnel and diff --git a/tests/cli/cli-account-policy.test.ts b/tests/cli/cli-account-policy.test.ts index 0e0a2caabe9..51c72ac5208 100644 --- a/tests/cli/cli-account-policy.test.ts +++ b/tests/cli/cli-account-policy.test.ts @@ -218,6 +218,14 @@ describe("pool field masks and actual policy projection", () => { expect(f.calls[1]!.body).toEqual({ provider: "anthropic", enabled: false, autoSwitchThreshold: 0 }); expect(result()).toMatchObject({ enabled: false, enabledEffective: false, autoSwitchThreshold: 0 }); }); + test("a generic pool accepts reset-first and sends it unchanged", async () => { + const config: OcxConfig = { port: 10100, defaultProvider: "google-antigravity", providers: { "google-antigravity": { adapter: "google", baseUrl: "https://example.test" } } }; + const dto = unifiedPoolSettingsDto(config, "google-antigravity", "generic"); + const f = fixture(c => c.method === "GET" ? dto : { ...dto, ...c.body }); + expect(await policy("pool", ["google-antigravity", "--strategy", "reset-first", "--json"], f.deps)).toBe(0); + expect(f.calls[1]!.body).toEqual({ provider: "google-antigravity", strategy: "reset-first" }); + expect(result()).toMatchObject({ kind: "generic", strategy: "reset-first" }); + }); test("Codex enabled rejects even dishonest supported field list", async () => { const dto = unifiedPoolSettingsDto({ port: 10100, providers: {}, defaultProvider: "openai" }, "openai", "codex"); const f = fixture(() => ({ ...dto, supported: [...dto.supported, "enabled"] })); diff --git a/tests/oauth/oauth-account-quota-rank.test.ts b/tests/oauth/oauth-account-quota-rank.test.ts index 5ce440418af..2d7ed9ab541 100644 --- a/tests/oauth/oauth-account-quota-rank.test.ts +++ b/tests/oauth/oauth-account-quota-rank.test.ts @@ -7,6 +7,7 @@ import { hasHeadroomEvidence, isAccountQuotaExhausted, rankAccountsByHeadroom, + rankAccountsByResetFirst, } from "../../src/oauth/account-quota-rank"; import { clearPoolRotationState, @@ -28,6 +29,7 @@ import { saveCredential, setActiveAccount, } from "../../src/oauth/store"; +import { clearAntigravityAccountRosters, recordAntigravityAccountRoster } from "../../src/providers/antigravity-account-roster"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -378,3 +380,98 @@ describe("modular Responses model-family forwarding", () => { } }); +describe("Antigravity reset-first weekly prioritization", () => { + test("prefers account whose weekly allowance resets earliest, even with less headroom", () => { + const now = Date.now(); + setCachedProviderAccountQuotaForTests("google-antigravity", "a", { + updatedAt: now, + customWindows: [ + { label: "Gem", percent: 45, resetAt: now + 2 * 3600_000 }, + { label: "Gem (Weekly)", percent: 99, resetAt: now + 2 * 86400_000 }, + ], + }); + setCachedProviderAccountQuotaForTests("google-antigravity", "b", { + updatedAt: now, + customWindows: [ + { label: "Gem", percent: 0, resetAt: now + 5 * 3600_000 }, + { label: "Gem (Weekly)", percent: 5, resetAt: now + 7 * 86400_000 }, + ], + }); + expect(rankAccountsByHeadroom("google-antigravity", ["a", "b"], "gemini-3.8-flash")[0]).toBe("b"); + expect(rankAccountsByResetFirst("google-antigravity", ["b", "a"], "gemini-3.8-flash", now)[0]).toBe("a"); + }); + + async function seedResetFirstPair(now: number): Promise { + for (const accountId of ["acct-soon", "acct-later"]) { + await saveCredential("google-antigravity", { + access: "access-" + accountId, + refresh: "refresh-" + accountId, + expires: now + 3_600_000, + accountId, + } as never); + } + const ids = getAccountSet("google-antigravity")?.accounts.map(a => a.id) ?? []; + expect(ids.length).toBe(2); + setCachedProviderAccountQuotaForTests("google-antigravity", ids[0]!, { + updatedAt: now, + customWindows: [ + { label: "Gem", percent: 20 }, + { label: "Gem (Weekly)", percent: 80, resetAt: now + 2 * 86400_000 }, + ], + }); + setCachedProviderAccountQuotaForTests("google-antigravity", ids[1]!, { + updatedAt: now, + customWindows: [ + { label: "Gem", percent: 0 }, + { label: "Gem (Weekly)", percent: 10, resetAt: now + 7 * 86400_000 }, + ], + }); + return ids; + } + + function resetFirstConfig(kernel: boolean): OcxConfig { + return { + providers: { + "google-antigravity": { + ...PROVIDER, + oauthAccountFailover: { enabled: true, strategy: "reset-first" }, + }, + }, + oauthAccountFailover: { enabled: true }, + pool: { kernel }, + } as unknown as OcxConfig; + } + + test("preferredInitialAccount selects soonest reset and sticks until failure", async () => { + const now = Date.now(); + const ids = await seedResetFirstPair(now); + const cfg = resetFirstConfig(true); + await setActiveAccount("google-antigravity", ids[1]!); + expect(preferredInitialAccount(cfg, "google-antigravity", now, "gemini-3.8-flash")).toBe(ids[0]); + await setActiveAccount("google-antigravity", ids[0]!); + expect(preferredInitialAccount(cfg, "google-antigravity", now, "gemini-3.8-flash")).toBeNull(); + expect(rotateGenericOAuthAccountOn429(cfg, "google-antigravity", ids[0]!, null, now, "gemini-3.8-flash")).toBe(ids[1]); + }); + + test("a stored reset-first is inert while pool.kernel is off", async () => { + const now = Date.now(); + const ids = await seedResetFirstPair(now); + // The healthy active account keeps the request on the quota path; reset-first would move it. + await setActiveAccount("google-antigravity", ids[1]!); + expect(preferredInitialAccount(resetFirstConfig(false), "google-antigravity", now, "gemini-3.8-flash")).toBeNull(); + }); + + test("the first-attempt pick skips an account whose model roster omits the requested model", async () => { + const now = Date.now(); + const ids = await seedResetFirstPair(now); + try { + // The soonest-reset account lists only another model; the later one lists the request's. + recordAntigravityAccountRoster("google-antigravity", ids[0]!, [{ id: "gemini-other", wireModelId: "gemini-other" }] as never, now); + recordAntigravityAccountRoster("google-antigravity", ids[1]!, [{ id: "gemini-3.8-flash", wireModelId: "gemini-3.8-flash" }] as never, now); + await setActiveAccount("google-antigravity", ids[1]!); + expect(preferredInitialAccount(resetFirstConfig(true), "google-antigravity", now, "gemini-3.8-flash")).toBeNull(); + } finally { + clearAntigravityAccountRosters("google-antigravity"); + } + }); +}); diff --git a/tests/providers/kiro/kiro-pool-rank.test.ts b/tests/providers/kiro/kiro-pool-rank.test.ts index cbd5edc2463..72532843d1f 100644 --- a/tests/providers/kiro/kiro-pool-rank.test.ts +++ b/tests/providers/kiro/kiro-pool-rank.test.ts @@ -488,3 +488,12 @@ describe("pre-dispatch account preference", () => { } }); }); + +test("reset-first retains account-bound Kiro headroom and exhaustion when resets are unknown", async () => { + const { rankAccountsByResetFirst } = await import("../../../src/oauth/account-quota-rank"); + seedPercent("kiro", "a", 90); + seedPercent("kiro", "b", 10); + seedExhausted("c"); + expect(rankAccountsByResetFirst("kiro", ["a", "c", "b"], undefined, Date.now(), roster("a", "b", "c"))) + .toEqual(["b", "a", "c"]); +}); diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index ad6ccaccc47..0b04e62fb2d 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -761,7 +761,7 @@ describe("unified pool-settings contract (#695 wp5c)", () => { if (dir) removeTreeWithRetry(dir); }); - test("reset-first round-trips through canonical and legacy Codex settings only", async () => { + test("reset-first round-trips through Codex settings and generic pools, never Anthropic", async () => { const server = startServer(0); try { const write = async (provider: string, strategy: string) => fetch(new URL("/api/pool/settings", server.url), { @@ -776,11 +776,18 @@ describe("unified pool-settings contract (#695 wp5c)", () => { const legacy = new Request("http://localhost/api/codex-auth/active"); const legacyRead = await handleCodexAuthAPI(legacy, new URL(legacy.url), loadConfig()); expect(await legacyRead!.json()).toMatchObject({ accountPoolStrategy: "reset-first" }); - for (const provider of ["anthropic", "google-antigravity"]) { - const rejected = await write(provider, "reset-first"); - expect(rejected.status).toBe(400); - await rejected.text(); - } + // The Anthropic runtime implements quota/round-robin/fill-first only, so that kind + // refuses reset-first instead of storing a strategy it would ignore. + const rejected = await write("anthropic", "reset-first"); + expect(rejected.status).toBe(400); + expect((await rejected.json() as { error: string }).error).toBe("strategy must be one of: quota, round-robin, fill-first"); + // A generic OAuth pool accepts and round-trips it. + const antigravityWrite = await write("google-antigravity", "reset-first"); + expect(antigravityWrite.status).toBe(200); + expect(await antigravityWrite.json()).toMatchObject({ kind: "generic", strategy: "reset-first" }); + const antigravityRead = await fetch(new URL("/api/pool/settings?provider=google-antigravity", server.url)); + expect(await antigravityRead.json()).toMatchObject({ kind: "generic", strategy: "reset-first" }); + expect(loadConfig().providers["google-antigravity"]?.oauthAccountFailover?.strategy).toBe("reset-first"); const compatibility = new Request("http://localhost/api/codex-auth/pool-strategy", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ strategy: "reset-first" }), });