From cf7f5345a0616fc60958091febef55096828ad20 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 15:32:08 +0900 Subject: [PATCH 01/14] feat(anthropic): add account pool quotaWindow config type and normalization --- src/oauth/anthropic-routing.ts | 22 +++++++++++++++- src/types.ts | 1 + src/types/config.ts | 4 +++ tests/anthropic-account-pool.test.ts | 39 ++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 69dcd021a1..aed5b863c3 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -27,7 +27,7 @@ import { POOL_KEY_ANTHROPIC, seedPoolRotationAccount, } from "../codex/pool-rotation"; -import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types"; +import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../types"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { retainedUtf8Bytes } from "../lib/admission"; @@ -39,6 +39,8 @@ const MAX_AFFINITY_ENTRIES = 2_000; const MAX_AFFINITY_COMPONENT_BYTES = 512; const UNKNOWN_USAGE_SCORE = 100; const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; +const DEFAULT_QUOTA_WINDOW: OcxAccountPoolQuotaWindow = "five-hour"; +const VALID_QUOTA_WINDOWS = new Set(["five-hour", "weekly", "max-utilization"]); /** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */ export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3; @@ -50,6 +52,8 @@ export interface AnthropicAccountPoolConfig { strategy?: OcxAccountPoolRotationStrategy; /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ stickyLimit?: number; + /** Usage window for quota-based scoring. Default "five-hour" (today's behaviour). */ + quotaWindow?: OcxAccountPoolQuotaWindow; } interface AccountHealth { @@ -86,6 +90,22 @@ export function anthropicAutoSwitchThreshold(config: OcxConfig): number { return DEFAULT_AUTO_SWITCH_THRESHOLD; } +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow | null { + if (typeof raw === "string" && VALID_QUOTA_WINDOWS.has(raw as OcxAccountPoolQuotaWindow)) { + return raw as OcxAccountPoolQuotaWindow; + } + return null; +} + +export function normalizeAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow { + return parseAccountPoolQuotaWindow(raw) ?? DEFAULT_QUOTA_WINDOW; +} + +export function anthropicQuotaWindow(config: AnthropicAccountPoolConfig): OcxAccountPoolQuotaWindow { + return normalizeAccountPoolQuotaWindow(config.quotaWindow); +} + function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined { const text = value?.trim(); if (!text) return undefined; diff --git a/src/types.ts b/src/types.ts index 22a083098c..0c8cc85eb0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,7 @@ export type { OcxClientIntegrationsConfig, OcxConfig, OcxAccountPoolRotationStrategy, + OcxAccountPoolQuotaWindow, OcxComboStrategy, OcxComboDefaultEffort, OcxComboTarget, diff --git a/src/types/config.ts b/src/types/config.ts index 6dd7f4e86e..3d3f61b98a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -582,6 +582,8 @@ export interface OcxConfig { strategy?: OcxAccountPoolRotationStrategy; /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ stickyLimit?: number; + /** Usage window for quota-based scoring. Default "five-hour" (today's behaviour). */ + quotaWindow?: OcxAccountPoolQuotaWindow; }; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; @@ -600,6 +602,8 @@ export interface OcxConfig { export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first"; +export type OcxAccountPoolQuotaWindow = "five-hour" | "weekly" | "max-utilization"; + export type OcxComboStrategy = "failover" | "round-robin"; export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index c13f59bd10..7650a8f2dd 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -4,12 +4,15 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearPoolRotationState, notePoolRotationFailure, POOL_KEY_ANTHROPIC } from "../src/codex/pool-rotation"; import { + anthropicQuotaWindow, anthropicSessionKeyFromParts, bindAnthropicSessionAffinity, clearAnthropicAccountPoolState, formatAnthropicProviderForLog, getEligibleAnthropicAccounts, isAnthropicAccountPoolEnabled, + normalizeAccountPoolQuotaWindow, + parseAccountPoolQuotaWindow, resolveAnthropicAccountForSession, resetAnthropicRoutingForManualSelection, rotateAnthropicAccountOn429, @@ -380,3 +383,39 @@ describe("anthropic account pool", () => { expect(resolveAnthropicAccountForSession("seed-3", config).accountId).toBe(cId); }); }); + +describe("anthropic account pool quota window", () => { + test("every valid window normalizes and strict-parses to itself", () => { + for (const window of ["five-hour", "weekly", "max-utilization"]) { + expect(normalizeAccountPoolQuotaWindow(window)).toBe(window); + expect(parseAccountPoolQuotaWindow(window)).toBe(window); + } + }); + + test("an unknown window string defaults to five-hour and fails strict parse", () => { + expect(normalizeAccountPoolQuotaWindow("daily")).toBe("five-hour"); + expect(parseAccountPoolQuotaWindow("daily")).toBeNull(); + }); + + test("undefined defaults to five-hour and fails strict parse", () => { + expect(normalizeAccountPoolQuotaWindow(undefined)).toBe("five-hour"); + expect(parseAccountPoolQuotaWindow(undefined)).toBeNull(); + }); + + test("non-string input never satisfies strict parse", () => { + expect(parseAccountPoolQuotaWindow(5)).toBeNull(); + expect(parseAccountPoolQuotaWindow(null)).toBeNull(); + expect(parseAccountPoolQuotaWindow({})).toBeNull(); + expect(parseAccountPoolQuotaWindow(["weekly"])).toBeNull(); + expect(normalizeAccountPoolQuotaWindow(5)).toBe("five-hour"); + }); + + test("accessor reads the pool config field and defaults when absent or invalid", () => { + expect(anthropicQuotaWindow({ quotaWindow: "five-hour" })).toBe("five-hour"); + expect(anthropicQuotaWindow({ quotaWindow: "weekly" })).toBe("weekly"); + expect(anthropicQuotaWindow({ quotaWindow: "max-utilization" })).toBe("max-utilization"); + expect(anthropicQuotaWindow({})).toBe("five-hour"); + expect(anthropicQuotaWindow({ enabled: true })).toBe("five-hour"); + expect(anthropicQuotaWindow({ quotaWindow: "daily" })).toBe("five-hour"); + }); +}); From fc2a35306117a19d9128aac26ed52ec704d4e176 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 15:42:00 +0900 Subject: [PATCH 02/14] refactor(anthropic): thread config through account pool usage scoring --- src/oauth/anthropic-routing.ts | 51 +++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index aed5b863c3..c5eeb71649 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -162,12 +162,16 @@ function isCooled(accountId: string, now: number): boolean { return getAnthropicAccountHealthSnapshot(accountId, now) !== null; } -function hasKnownUsage(accountId: string): boolean { +/** + * Usage scoring takes `config` so which quota window is read stays a + * configuration decision. `five-hour` is the only window scored today. + */ +function hasKnownUsage(config: OcxConfig, accountId: string): boolean { const quota = getCachedProviderAccountQuota(PROVIDER, accountId); return typeof quota?.fiveHourPercent === "number" && Number.isFinite(quota.fiveHourPercent); } -function usageScore(accountId: string): number { +function usageScore(config: OcxConfig, accountId: string): number { const quota = getCachedProviderAccountQuota(PROVIDER, accountId); if (!quota || typeof quota.fiveHourPercent !== "number" || !Number.isFinite(quota.fiveHourPercent)) { return UNKNOWN_USAGE_SCORE; @@ -211,20 +215,29 @@ export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | nu return Math.max(1, Math.ceil((earliest - now) / 1000)); } -function pickLowestUsage(excludeId: string | undefined, now: number): string | null { +interface ScoredAccount { + accountId: string; + score: number; +} + +function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number { + return a.score - b.score; +} + +function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number): string | null { const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId); if (eligible.length === 0) return null; - let best = eligible[0]!; - let bestScore = usageScore(best); - for (let i = 1; i < eligible.length; i++) { - const id = eligible[i]!; - const score = usageScore(id); - if (score < bestScore) { - best = id; - bestScore = score; - } + const scored: ScoredAccount[] = eligible.map(accountId => ({ + accountId, + score: usageScore(config, accountId), + })); + let best = scored[0]!; + for (let i = 1; i < scored.length; i++) { + const candidate = scored[i]!; + // Strict `< 0` keeps the earliest eligible account on an exact tie. + if (compareScoredAccounts(candidate, best) < 0) best = candidate; } - return best; + return best.accountId; } /** Next eligible Anthropic account in stable order after `afterId` (wrapping). */ @@ -270,7 +283,7 @@ function pickAlternateAnthropicAccount( if (strategy === "fill-first") { return pickNextFillFirstAnthropicAccount(config, excludeId, eligible); } - return pickLowestUsage(excludeId, now); + return pickLowestUsage(config, excludeId, now); } function pruneExpiredAffinity(now: number): void { @@ -311,8 +324,8 @@ function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): const threshold = anthropicAutoSwitchThreshold(config); if (threshold <= 0) return true; // Unknown usage must not force fill-first to abandon the active account. - if (!hasKnownUsage(accountId)) return true; - return usageScore(accountId) < threshold; + if (!hasKnownUsage(config, accountId)) return true; + return usageScore(config, accountId) < threshold; } /** @@ -433,11 +446,11 @@ export function resolveAnthropicAccountForSession( if (threshold > 0) { // Unknown usage must NOT force a switch away from the healthy active account. - if (activeOk && (!hasKnownUsage(set.activeAccountId) || usageScore(set.activeAccountId) < threshold)) { + if (activeOk && (!hasKnownUsage(config, set.activeAccountId) || usageScore(config, set.activeAccountId) < threshold)) { accountId = set.activeAccountId; reason = "active"; } else { - const picked = pickLowestUsage(undefined, now); + const picked = pickLowestUsage(config, undefined, now); if (picked) { accountId = picked; reason = activeOk && picked === set.activeAccountId ? "active" : "lowest-usage"; @@ -450,7 +463,7 @@ export function resolveAnthropicAccountForSession( accountId = set.activeAccountId; reason = "active"; } else { - const picked = pickLowestUsage(set.activeAccountId, now); + const picked = pickLowestUsage(config, set.activeAccountId, now); if (picked) { accountId = picked; reason = "only-eligible"; From 44b69f53cf2ccd3fe78e2df4d3cfda61fb96d54c Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 15:42:57 +0900 Subject: [PATCH 03/14] feat(management): expose quotaWindow on the anthropic account-pool API --- src/server/management/oauth-account-routes.ts | 13 +++ tests/account-pool-management-api.test.ts | 79 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index ee57515b41..5e441ea013 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -39,6 +39,7 @@ import { parseAccountPoolStickyLimit, parseAccountPoolStrategy, } from "../../codex/pool-rotation"; +import { normalizeAccountPoolQuotaWindow, parseAccountPoolQuotaWindow } from "../../oauth/anthropic-routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; @@ -326,6 +327,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80, strategy: normalizeAccountPoolStrategy(pool.strategy), stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit), + quotaWindow: normalizeAccountPoolQuotaWindow(pool.quotaWindow), experimental: true, }); } @@ -340,6 +342,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< autoSwitchThreshold?: unknown; strategy?: unknown; stickyLimit?: unknown; + quotaWindow?: unknown; }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400); @@ -376,11 +379,20 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } stickyLimit = parsed; } + let quotaWindow = config.anthropicAccountPool?.quotaWindow; + if (body.quotaWindow !== undefined) { + const parsed = parseAccountPoolQuotaWindow(body.quotaWindow); + if (parsed === null) { + return jsonResponse({ error: "quotaWindow must be one of: five-hour, weekly, max-utilization" }, 400); + } + quotaWindow = parsed; + } config.anthropicAccountPool = { enabled, autoSwitchThreshold: threshold, ...(strategy !== undefined ? { strategy } : {}), ...(stickyLimit !== undefined ? { stickyLimit } : {}), + ...(quotaWindow !== undefined ? { quotaWindow } : {}), }; saveConfigPreservingClaudeCode(config); reconcileLiveStateStores(); @@ -391,6 +403,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< autoSwitchThreshold: threshold, strategy: normalizeAccountPoolStrategy(strategy), stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit), + quotaWindow: normalizeAccountPoolQuotaWindow(quotaWindow), experimental: true, }); } diff --git a/tests/account-pool-management-api.test.ts b/tests/account-pool-management-api.test.ts index 4dbe399269..0a8c993215 100644 --- a/tests/account-pool-management-api.test.ts +++ b/tests/account-pool-management-api.test.ts @@ -352,4 +352,83 @@ describe("Anthropic account pool strategy management API", () => { await server.stop(true); } }); + + test("GET returns quotaWindow five-hour by default", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ quotaWindow: "five-hour" }); + } finally { + await server.stop(true); + } + }); + + test("PUT persists each valid quotaWindow value", async () => { + const server = startServer(0); + try { + for (const quotaWindow of ["weekly", "max-utilization", "five-hour"]) { + const put = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "anthropic", enabled: true, quotaWindow }), + }); + expect(put.status).toBe(200); + expect(await put.json()).toMatchObject({ ok: true, quotaWindow }); + + const get = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(await get.json()).toMatchObject({ quotaWindow }); + } + } finally { + await server.stop(true); + } + }); + + test("PUT rejects invalid quotaWindow with 400", async () => { + const server = startServer(0); + try { + for (const bad of ["monthly", "", "Weekly", 1, null]) { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "anthropic", enabled: true, quotaWindow: bad }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "quotaWindow must be one of: five-hour, weekly, max-utilization", + }); + } + } finally { + await server.stop(true); + } + }); + + test("PUT without quotaWindow preserves the existing value", async () => { + const server = startServer(0); + try { + const first = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "anthropic", enabled: true, quotaWindow: "weekly" }), + }); + expect(first.status).toBe(200); + + const second = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "anthropic", enabled: false, autoSwitchThreshold: 55 }), + }); + expect(second.status).toBe(200); + expect(await second.json()).toMatchObject({ quotaWindow: "weekly" }); + + const get = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(await get.json()).toMatchObject({ + enabled: false, + autoSwitchThreshold: 55, + quotaWindow: "weekly", + }); + } finally { + await server.stop(true); + } + }); }); From 557136b914f347ee3a5344bf90b4e9bffb619b3b Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 15:43:38 +0900 Subject: [PATCH 04/14] docs(anthropic): document account pool quotaWindow across locales --- .../src/content/docs/fr/reference/configuration/providers.md | 2 +- docs-site/src/content/docs/guides/claude-code.md | 3 ++- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- .../src/content/docs/reference/configuration/providers.md | 5 +++-- .../src/content/docs/ru/reference/configuration/providers.md | 2 +- .../src/content/docs/tr/reference/configuration/providers.md | 2 +- .../content/docs/zh-cn/reference/configuration/providers.md | 2 +- .../content/docs/zh-tw/reference/configuration/providers.md | 2 +- 9 files changed, 12 insertions(+), 10 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 822c7ba47c..01a6de6da2 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -194,7 +194,7 @@ rotation automatique peut déclencher des restrictions du fournisseur. | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Active l'affinité persistante et le basculement après une temporisation 429. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Pour les nouvelles sessions, choisir la plus faible utilisation connue et mise en cache sur 5 heures qui atteint ou dépasse ce seuil. `0` désactive la sélection selon le quota. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie des nouvelles sessions ; quota utilise uniquement les barres sur 5 heures. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie des nouvelles sessions ; quota lit la fenêtre définie par `quotaWindow`, par défaut les barres sur 5 heures. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Liaisons de nouvelle session réussies conservées sur une sélection à tour de rôle. Portée 1–100. | Lorsque cette option est activée, un 429 enregistre une temporisation bornée à partir de `Retry-After` ou d'un délai de repli, puis peut diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 81d0e0eaea..6686fa8a1d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -15,7 +15,8 @@ add-account). By default every request uses the **active** account only. An **experimental, opt-in** Claude account pool (`anthropicAccountPool.enabled`) adds sticky session affinity and 429 cooldown failover across those OAuth accounts. For **new** sessions only, `anthropicAccountPool.strategy` selects among eligible accounts: `quota` (default) picks -lowest known 5-hour usage when above `autoSwitchThreshold`; `round-robin` spreads evenly +lowest known usage in the window set by `quotaWindow` (default the 5-hour bar) when above +`autoSwitchThreshold`; `round-robin` spreads evenly (`stickyLimit`, default `1`); `fill-first` drains the active account until cooldown, reauthentication, or threshold, then advances. It is **off by default**, shows a GUI warning, and is not battle-tested — Anthropic may restrict accounts that look like automated rotation; 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 f1210892a5..c1b126051d 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -160,7 +160,7 @@ affinity を維持します。これらの戦略は provider enforcement を回 | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` |スティッキー アフィニティと 429 クールダウン フェイルオーバーを有効にします。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` |新しいセッションの場合は、このしきい値以上の、既知の最も低いキャッシュされた 5 時間の使用量を選択します。 `0` はクォータの選択を無効にします。 | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` |新しいセッション戦略。クォータでは 5 時間足のみを使用します。 | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` |新しいセッション戦略。quota は `quotaWindow` で指定した期間を参照し、既定は 5 時間足です。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` |成功した新しいセッションのバインドは 1 つのラウンドロビン選択で保持されます。範囲は 1 ~ 100。 | 有効にすると、429 レコードは `Retry-After` またはデフォルトのバックオフからの制限されたクールダウンを記録し、リクエスト内でローテーションする可能性があります。アフィニティはプロセスローカルであり、サイズ制限があります。資格情報 401/403 は、アカウントに再認証が必要であることをマークします。すべての対象となるアカウントが冷却されている場合、クライアントは、既知の場合、認証エラーではなく、`Retry-After` を含む 429 を受け取ります。 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 ccacb0a94f..3c8c01f2ec 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -164,7 +164,7 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | sticky 결속과 429 쿨다운 failover를 켭니다. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 새 세션에서는 이 임계값 이상에서 알려진 캐시 5시간 사용량이 가장 낮은 계정을 고릅니다. `0`이면 quota 선택을 끕니다. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 세션 전략입니다. quota는 5시간 막대만 사용합니다. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 세션 전략입니다. quota는 `quotaWindow`로 지정한 창을 읽으며, 기본값은 5시간 막대입니다. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 성공한 새 세션 결속이 한 번의 라운드로빈 선택에 유지되는 횟수입니다. 범위는 1–100입니다. | 활성화되면 429 레코드가 `Retry-After` 또는 기본 backoff에서 제한된 쿨다운을 기록하고, 요청 안에서 회전할 수 있습니다. 결속은 프로세스 로컬이며 크기가 제한됩니다. 자격 증명 401/403은 해당 계정이 재인증이 필요함을 표시합니다. 적격한 계정이 모두 쿨다운 중이면, 클라이언트는 인증 오류가 아니라 알려진 경우 `Retry-After`가 포함된 429를 받습니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 85cef14e1e..a39f250a09 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -265,8 +265,9 @@ rotation may trigger provider restrictions. | Key | Type | Default | Description | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Enable sticky affinity and 429 cooldown failover. | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, choose the lowest known cached 5-hour usage at or above this threshold. `0` disables quota picking. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; quota uses 5-hour bars only. | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, choose the lowest known cached usage in the configured window at or above this threshold. `0` disables quota picking. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; quota reads the window set by `quotaWindow`. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Which cached usage bar the `quota` strategy scores. `five-hour` keeps the original behavior. `weekly` scores the weekly bar but still skips accounts whose 5-hour bar is exhausted, and breaks weekly ties by lower 5-hour usage. `max-utilization` scores whichever of the two bars is higher. This affects new-session picking only, not affinity re-evaluation, and per-account weekly bars are only known once the dashboard Providers page has polled them. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection. Range 1–100. | When enabled, 429 records bounded cooldown from `Retry-After` or a default backoff and may rotate 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 c415517074..77e4f378c9 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -195,7 +195,7 @@ reauth или порога исчерпания; здоровые привяза | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Включить sticky affinity и cooldown failover на 429. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Для новых сессий выбирать аккаунт с наименьшим известным cached 5-hour usage, если активный аккаунт достиг порога. `0` отключает выбор по quota. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия для новых сессий; quota смотрит только на 5-hour bar'ы. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия для новых сессий; quota смотрит на окно, заданное в `quotaWindow`; по умолчанию это 5-hour bar'ы. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Сколько успешных bind'ов новых сессий удерживать на одном выборе round-robin. Диапазон 1–100. | Если функция включена, 429 записывает ограниченный cooldown из `Retry-After` или из default 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 4db3211bc5..c4df28fbc8 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -220,7 +220,7 @@ ve otomatik rotasyon sağlayıcı kısıtlamalarını tetikleyebilir. | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Yapışkan bağlılığı ve 429 soğuma yük devretmesini etkinleştirin. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Yeni oturumlar için bu eşikte veya üzerinde bilinen en düşük önbelleğe alınmış 5 saatlik kullanımı seçin. `0` kota seçimini devre dışı bırakır. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni oturum stratejisi; kota yalnızca 5 saatlik çubukları kullanır. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni oturum stratejisi; kota, `quotaWindow` ile belirlenen pencereyi okur; varsayılan 5 saatlik çubuklardır. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Bir round-robin seçiminde tutulan başarılı yeni oturum bağlamaları. Aralık 1–100. | Etkinleştirildiğinde 429, `Retry-After`'dan veya varsayılan bir geri çekilmeden 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 3630a9ba6c..6561b56d91 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 @@ -158,7 +158,7 @@ affinity。这些策略不能规避 provider enforcement。 | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | 启用粘性亲和性和 429 冷却故障转移。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 对于新会话,选择已知缓存的、5 小时使用率最低且达到或超过此阈值的账户。`0` 会禁用配额选择。 | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新会话策略;quota 只使用 5 小时条形数据。 | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新会话策略;quota 读取 `quotaWindow` 指定的窗口,默认是 5 小时条形数据。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 在一次轮询选择中保留的成功新会话绑定次数。范围 1–100。 | 启用后,429 会根据 `Retry-After` 记录有界冷却,或者使用默认退避,并且可能在同一请求内轮换。亲和性是进程本地的,并且有大小上限。凭据 401/403 会将账户标记为需要重新认证。如果所有合格账户都在冷却,客户端会在已知时收到带 `Retry-After` 的 429,而不是身份验证错误。 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 b0a46f49ec..f065c1054a 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 @@ -127,7 +127,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | 啟用 sticky 親和性與 429 冷卻容錯移轉。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 對於新 session,選擇在此閾值或以上的最低已知快取 5 小時用量。`0` 停用量量挑選。 | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新 session 策略;quota 僅使用 5 小時列。 | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新 session 策略;quota 讀取 `quotaWindow` 指定的視窗,預設為 5 小時列。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 在一次 round-robin 選擇上保留的成功新 session 綁定。範圍 1–100。 | 啟用時,429 記錄來自 `Retry-After` 或預設 backoff 的有界冷卻,並可能在請求內輪換。親和性為行程本地且有界。憑證 401/403 將帳號標記為需要重新認證。若所有合格帳號都在冷卻,客戶端收到附帶已知 `Retry-After` 的 429,而非認證錯誤。 From be9189475fe3f2eb8b66607849926db9e6b1aa94 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 16:05:46 +0900 Subject: [PATCH 05/14] feat(anthropic): add window-aware account pool usage scoring --- src/oauth/anthropic-routing.ts | 71 ++++++++-- tests/anthropic-account-pool.test.ts | 185 ++++++++++++++++++++++++++- 2 files changed, 241 insertions(+), 15 deletions(-) diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index c5eeb71649..44ec7d22c7 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -162,21 +162,56 @@ function isCooled(accountId: string, now: number): boolean { return getAnthropicAccountHealthSnapshot(accountId, now) !== null; } -/** - * Usage scoring takes `config` so which quota window is read stays a - * configuration decision. `five-hour` is the only window scored today. - */ +function fiveHourKnown(accountId: string): boolean { + const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.fiveHourPercent; + return typeof percent === "number" && Number.isFinite(percent); +} + +function weeklyKnown(accountId: string): boolean { + const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.weeklyPercent; + return typeof percent === "number" && Number.isFinite(percent); +} + +function fiveHourScore(accountId: string): number { + const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.fiveHourPercent; + return typeof percent === "number" && Number.isFinite(percent) + ? Math.max(0, Math.min(100, percent)) + : UNKNOWN_USAGE_SCORE; +} + +function weeklyScore(accountId: string): number { + const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.weeklyPercent; + return typeof percent === "number" && Number.isFinite(percent) + ? Math.max(0, Math.min(100, percent)) + : UNKNOWN_USAGE_SCORE; +} + +function exhausted5h(accountId: string): boolean { + return fiveHourKnown(accountId) && fiveHourScore(accountId) >= 100; +} + function hasKnownUsage(config: OcxConfig, accountId: string): boolean { - const quota = getCachedProviderAccountQuota(PROVIDER, accountId); - return typeof quota?.fiveHourPercent === "number" && Number.isFinite(quota.fiveHourPercent); + const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); + switch (window) { + case "five-hour": return fiveHourKnown(accountId); + case "weekly": return weeklyKnown(accountId); + case "max-utilization": return fiveHourKnown(accountId) || weeklyKnown(accountId); + } } function usageScore(config: OcxConfig, accountId: string): number { - const quota = getCachedProviderAccountQuota(PROVIDER, accountId); - if (!quota || typeof quota.fiveHourPercent !== "number" || !Number.isFinite(quota.fiveHourPercent)) { - return UNKNOWN_USAGE_SCORE; + const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); + switch (window) { + case "five-hour": return fiveHourScore(accountId); + case "weekly": return weeklyScore(accountId); + case "max-utilization": { + const scores = [ + ...(fiveHourKnown(accountId) ? [fiveHourScore(accountId)] : []), + ...(weeklyKnown(accountId) ? [weeklyScore(accountId)] : []), + ]; + return scores.length > 0 ? Math.max(...scores) : UNKNOWN_USAGE_SCORE; + } } - return Math.max(0, Math.min(100, quota.fiveHourPercent)); } const TOKEN_SKEW_MS = 60_000; @@ -218,18 +253,23 @@ export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | nu interface ScoredAccount { accountId: string; score: number; + fiveHourTieBreak: number; } function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number { - return a.score - b.score; + return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak; } function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number): string | null { - const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId); + const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); + const unfiltered = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId); + const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)) : unfiltered; + const eligible = available.length > 0 ? available : unfiltered; if (eligible.length === 0) return null; const scored: ScoredAccount[] = eligible.map(accountId => ({ accountId, score: usageScore(config, accountId), + fiveHourTieBreak: window === "weekly" ? fiveHourScore(accountId) : 0, })); let best = scored[0]!; for (let i = 1; i < scored.length; i++) { @@ -323,6 +363,8 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean { const threshold = anthropicAutoSwitchThreshold(config); if (threshold <= 0) return true; + const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); + if (window === "weekly" && exhausted5h(accountId)) return false; // Unknown usage must not force fill-first to abandon the active account. if (!hasKnownUsage(config, accountId)) return true; return usageScore(config, accountId) < threshold; @@ -445,8 +487,11 @@ export function resolveAnthropicAccountForSession( let reason: AnthropicAccountSelectionReason = "none"; if (threshold > 0) { + const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); // Unknown usage must NOT force a switch away from the healthy active account. - if (activeOk && (!hasKnownUsage(config, set.activeAccountId) || usageScore(config, set.activeAccountId) < threshold)) { + if (activeOk + && !(window === "weekly" && exhausted5h(set.activeAccountId)) + && (!hasKnownUsage(config, set.activeAccountId) || usageScore(config, set.activeAccountId) < threshold)) { accountId = set.activeAccountId; reason = "active"; } else { diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index 7650a8f2dd..c2ca5055b6 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -19,7 +19,7 @@ import { } from "../src/oauth/anthropic-routing"; import { getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../src/providers/quota"; -import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../src/types"; +import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../src/types"; const originalHome = process.env.OPENCODEX_HOME; let home: string; @@ -68,7 +68,11 @@ async function seedTwoAccounts() { function cfg( enabled: boolean, threshold = 80, - pool: { strategy?: OcxAccountPoolRotationStrategy; stickyLimit?: number } = {}, + pool: { + strategy?: OcxAccountPoolRotationStrategy; + stickyLimit?: number; + quotaWindow?: OcxAccountPoolQuotaWindow; + } = {}, ): OcxConfig { return { port: 0, @@ -419,3 +423,180 @@ describe("anthropic account pool quota window", () => { expect(anthropicQuotaWindow({ quotaWindow: "daily" })).toBe("five-hour"); }); }); + +describe("anthropic account pool quota window scoring", () => { + test("weekly mode picks lowest weekly usage", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10, weeklyPercent: 90, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 90, weeklyPercent: 10, updatedAt }); + + expect(resolveAnthropicAccountForSession("weekly-lowest", cfg(true, 80, { quotaWindow: "weekly" })).accountId).toBe(bId); + }); + + test("weekly mode excludes 5h-exhausted candidate", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 30, weeklyPercent: 90, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 100, weeklyPercent: 5, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 40, weeklyPercent: 50, updatedAt }); + + expect(resolveAnthropicAccountForSession("weekly-exhausted-candidate", cfg(true, 80, { quotaWindow: "weekly" })).accountId).toBe(cId); + }); + + test("weekly mode keeps 5h-exhausted candidate when it is the only alternative", async () => { + const { aId, bId } = await seedTwoAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", bId, { + fiveHourPercent: 100, + weeklyPercent: 80, + updatedAt: Date.now(), + }); + + expect(rotateAnthropicAccountOn429(cfg(true, 80, { quotaWindow: "weekly" }), aId, "30", "weekly-only-exhausted")).toBe(bId); + }); + + test("weekly mode does not exclude a candidate whose 5h is merely unknown", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 20, weeklyPercent: 90, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { weeklyPercent: 60, updatedAt }); + + expect(resolveAnthropicAccountForSession("weekly-unknown-five-hour", cfg(true, 80, { quotaWindow: "weekly" })).accountId).toBe(bId); + }); + + test("weekly mode ranks unknown weekly last", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 90, weeklyPercent: 85, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10, updatedAt }); + + expect(resolveAnthropicAccountForSession("weekly-unknown-last", cfg(true, 80, { quotaWindow: "weekly" })).accountId).toBe(aId); + }); + + test("weekly tie breaks by lower five-hour usage", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 50, weeklyPercent: 85, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 20, weeklyPercent: 85, updatedAt }); + + expect(resolveAnthropicAccountForSession("weekly-five-hour-tie", cfg(true, 80, { quotaWindow: "weekly" })).accountId).toBe(bId); + }); + + test("weekly mode treats 5h-exhausted active as over threshold", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 100, weeklyPercent: 10, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 30, weeklyPercent: 60, updatedAt }); + + expect(resolveAnthropicAccountForSession("weekly-exhausted-active", cfg(true, 80, { quotaWindow: "weekly" })).accountId).toBe(bId); + }); + + test("max-utilization scores by the hotter window", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 20, weeklyPercent: 95, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 60, weeklyPercent: 30, updatedAt }); + + expect(resolveAnthropicAccountForSession("max-hotter", cfg(true, 80, { quotaWindow: "max-utilization" })).accountId).toBe(bId); + }); + + test("omitted quotaWindow equals explicit five-hour", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10, weeklyPercent: 90, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 90, weeklyPercent: 10, updatedAt }); + + expect(resolveAnthropicAccountForSession("window-omitted", cfg(true)).accountId).toBe(aId); + expect(resolveAnthropicAccountForSession("window-five-hour", cfg(true, 80, { quotaWindow: "five-hour" })).accountId).toBe(aId); + expect(resolveAnthropicAccountForSession("window-weekly-control", cfg(true, 80, { quotaWindow: "weekly" })).accountId).toBe(bId); + }); + + test("threshold 0 keeps the active account regardless of quotaWindow", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 100, weeklyPercent: 100, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 0, weeklyPercent: 0, updatedAt }); + + for (const quotaWindow of ["five-hour", "weekly", "max-utilization"] as const) { + const sessionKey = `threshold-zero-${quotaWindow}`; + expect(resolveAnthropicAccountForSession(sessionKey, cfg(true, 0, { quotaWindow })).accountId).toBe(aId); + } + }); + + test("threshold 0 still applies quotaWindow on 429 failover", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10, weeklyPercent: 90, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 90, weeklyPercent: 10, updatedAt }); + + expect(rotateAnthropicAccountOn429(cfg(true, 0, { quotaWindow: "weekly" }), aId, "30", "threshold-zero-weekly")).toBe(cId); + + clearAnthropicAccountPoolState(); + expect(rotateAnthropicAccountOn429(cfg(true, 0, { quotaWindow: "five-hour" }), aId, "30", "threshold-zero-five-hour")).toBe(bId); + }); + + test("429 failover never returns null while any eligible account remains", async () => { + const { aId, bId } = await seedTwoAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", bId, { + fiveHourPercent: 100, + weeklyPercent: 100, + updatedAt: Date.now(), + }); + + expect(rotateAnthropicAccountOn429(cfg(true, 80, { quotaWindow: "weekly" }), aId, "30", "weekly-never-null")).toBe(bId); + }); + + test("fill-first threshold uses configured window", async () => { + const { aId } = await seedTwoAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { + fiveHourPercent: 90, + weeklyPercent: 10, + updatedAt: Date.now(), + }); + + expect(resolveAnthropicAccountForSession( + "fill-first-weekly-threshold", + cfg(true, 80, { strategy: "fill-first", quotaWindow: "weekly" }), + ).accountId).toBe(aId); + expect(resolveAnthropicAccountForSession( + "fill-first-five-hour-threshold", + cfg(true, 80, { strategy: "fill-first", quotaWindow: "five-hour" }), + ).accountId).not.toBe(aId); + }); + + test("fill-first at threshold 0 keeps a 5h-exhausted active under weekly", async () => { + const { aId } = await seedTwoAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { + fiveHourPercent: 100, + weeklyPercent: 100, + updatedAt: Date.now(), + }); + + expect(resolveAnthropicAccountForSession( + "fill-first-zero-weekly", + cfg(true, 0, { strategy: "fill-first", quotaWindow: "weekly" }), + ).accountId).toBe(aId); + }); + + test("weekly mode skips a 5h-exhausted successor under fill-first", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + expect([aId, bId, cId].sort((a, b) => a.localeCompare(b))).toEqual([aId, bId, cId]); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10, weeklyPercent: 90, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 100, weeklyPercent: 10, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 20, weeklyPercent: 30, updatedAt }); + + expect(resolveAnthropicAccountForSession( + "fill-first-skip-exhausted", + cfg(true, 80, { strategy: "fill-first", quotaWindow: "weekly" }), + ).accountId).toBe(cId); + }); + + test("weekly mode with empty quota cache falls back to stable order", async () => { + const { aId, bId } = await seedTwoAccounts(); + const config = cfg(true, 80, { quotaWindow: "weekly" }); + + expect(resolveAnthropicAccountForSession("weekly-empty-keep", config).accountId).toBe(aId); + expect(rotateAnthropicAccountOn429(config, aId, "30", "weekly-empty-failover")).toBe(bId); + }); +}); From f0aad33558b4c9b9b1a3f6b933a06ad952e77a86 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 16:07:08 +0900 Subject: [PATCH 06/14] feat(gui): add a quota window selector to the Claude account pool --- gui/src/account-pool-strategy.ts | 17 ++ .../AnthropicAccountPoolSettings.tsx | 62 ++++- gui/src/i18n/de.ts | 10 +- gui/src/i18n/en.ts | 13 +- gui/src/i18n/fr.ts | 9 +- gui/src/i18n/ja.ts | 10 +- gui/src/i18n/ko.ts | 10 +- gui/src/i18n/ru.ts | 10 +- gui/src/i18n/tr.ts | 10 +- gui/src/i18n/zh-TW.ts | 9 +- gui/src/i18n/zh.ts | 10 +- .../anthropic-pool-quota-window.test.tsx | 212 ++++++++++++++++++ 12 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 gui/tests/anthropic-pool-quota-window.test.tsx diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts index 4ff6b860c2..4dbc7b9e2b 100644 --- a/gui/src/account-pool-strategy.ts +++ b/gui/src/account-pool-strategy.ts @@ -6,12 +6,23 @@ export const ACCOUNT_POOL_STRATEGIES: readonly AccountPoolStrategy[] = [ "fill-first", ] as const; +/** Which cached usage bar the `quota` strategy scores. Mirrors `OcxAccountPoolQuotaWindow`. */ +export type AccountPoolQuotaWindow = "five-hour" | "weekly" | "max-utilization"; + +export const ACCOUNT_POOL_QUOTA_WINDOWS: readonly AccountPoolQuotaWindow[] = [ + "five-hour", + "weekly", + "max-utilization", +] as const; + export const DEFAULT_ACCOUNT_POOL_STRATEGY: AccountPoolStrategy = "quota"; +export const DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW: AccountPoolQuotaWindow = "five-hour"; export const DEFAULT_ACCOUNT_POOL_STICKY_LIMIT = 1; export const MIN_ACCOUNT_POOL_STICKY_LIMIT = 1; export const MAX_ACCOUNT_POOL_STICKY_LIMIT = 100; const STRATEGY_SET = new Set(ACCOUNT_POOL_STRATEGIES); +const QUOTA_WINDOW_SET = new Set(ACCOUNT_POOL_QUOTA_WINDOWS); export function normalizeAccountPoolStrategy(value: unknown): AccountPoolStrategy { return typeof value === "string" && STRATEGY_SET.has(value) @@ -19,6 +30,12 @@ export function normalizeAccountPoolStrategy(value: unknown): AccountPoolStrateg : DEFAULT_ACCOUNT_POOL_STRATEGY; } +export function normalizeAccountPoolQuotaWindow(value: unknown): AccountPoolQuotaWindow { + return typeof value === "string" && QUOTA_WINDOW_SET.has(value) + ? value as AccountPoolQuotaWindow + : DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW; +} + export function normalizeAccountPoolStickyLimit(value: unknown): number { return typeof value === "number" && Number.isInteger(value) diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index d0ef91fab5..d9208cf920 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -5,20 +5,32 @@ import { useCallback, useEffect, useState } from "react"; import { useT } from "../../i18n/shared"; import { + ACCOUNT_POOL_QUOTA_WINDOWS, + DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW, DEFAULT_ACCOUNT_POOL_STICKY_LIMIT, DEFAULT_ACCOUNT_POOL_STRATEGY, + normalizeAccountPoolQuotaWindow, normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimitDraft, + type AccountPoolQuotaWindow, type AccountPoolStrategy, } from "../../account-pool-strategy"; import AccountPoolStrategyControls from "../AccountPoolStrategyControls"; +import { Select } from "../../ui"; + +const QUOTA_WINDOW_LABEL_KEYS = { + "five-hour": "accountPool.quotaWindowFiveHour", + weekly: "accountPool.quotaWindowWeekly", + "max-utilization": "accountPool.quotaWindowMaxUtilization", +} as const; type PoolState = { enabled: boolean; threshold: number; strategy: AccountPoolStrategy; stickyLimit: number; + quotaWindow: AccountPoolQuotaWindow; }; export default function AnthropicAccountPoolSettings({ @@ -56,6 +68,7 @@ export default function AnthropicAccountPoolSettings({ autoSwitchThreshold?: number; strategy?: unknown; stickyLimit?: unknown; + quotaWindow?: unknown; }>; }) .then(json => { @@ -67,6 +80,7 @@ export default function AnthropicAccountPoolSettings({ threshold: nextThreshold, strategy: normalizeAccountPoolStrategy(json.strategy), stickyLimit: nextSticky, + quotaWindow: normalizeAccountPoolQuotaWindow(json.quotaWindow), }); setDraft(String(nextThreshold)); setStickyDraft(String(nextSticky)); @@ -87,6 +101,7 @@ export default function AnthropicAccountPoolSettings({ threshold: number; strategy: AccountPoolStrategy; stickyLimit: number; + quotaWindow: AccountPoolQuotaWindow; }) => { const previousState = state; setState({ @@ -94,6 +109,7 @@ export default function AnthropicAccountPoolSettings({ threshold: next.threshold, strategy: next.strategy, stickyLimit: next.stickyLimit, + quotaWindow: next.quotaWindow, }); setSaving(true); setError(null); @@ -107,20 +123,24 @@ export default function AnthropicAccountPoolSettings({ autoSwitchThreshold: next.threshold, strategy: next.strategy, stickyLimit: next.stickyLimit, + quotaWindow: next.quotaWindow, }), }); if (!res.ok) throw new Error("save"); const json = await res.json().catch(() => null) as { strategy?: unknown; stickyLimit?: unknown; + quotaWindow?: unknown; } | null; const savedStrategy = normalizeAccountPoolStrategy(json?.strategy ?? next.strategy); const savedSticky = normalizeAccountPoolStickyLimit(json?.stickyLimit ?? next.stickyLimit); + const savedWindow = normalizeAccountPoolQuotaWindow(json?.quotaWindow ?? next.quotaWindow); setState({ enabled: next.enabled, threshold: next.threshold, strategy: savedStrategy, stickyLimit: savedSticky, + quotaWindow: savedWindow, }); setDraft(String(next.threshold)); setStickyDraft(String(savedSticky)); @@ -140,6 +160,10 @@ export default function AnthropicAccountPoolSettings({ const threshold = state?.threshold ?? 80; const strategy = state?.strategy ?? DEFAULT_ACCOUNT_POOL_STRATEGY; const stickyLimit = state?.stickyLimit ?? DEFAULT_ACCOUNT_POOL_STICKY_LIMIT; + const quotaWindow = state?.quotaWindow ?? DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW; + // Only quota scores a usage bar; fill-first scores one too, but a 0 threshold turns its + // drain point off. Neither reads a bar under round-robin, so the window is inert there. + const quotaWindowInert = strategy !== "quota" && !(strategy === "fill-first" && threshold > 0); const loading = state === null && !loadError; // Always allow turning the pool off; only block enabling when fewer than 2 accounts. const toggleDisabled = loading || saving || loadError || (!enabled && accountCount < 2); @@ -155,7 +179,10 @@ export default function AnthropicAccountPoolSettings({ : loading ? t("common.loading") : enabled - ? t("anthropicPool.enabledDesc", { threshold }) + ? t("anthropicPool.enabledDesc", { + threshold, + window: t(QUOTA_WINDOW_LABEL_KEYS[quotaWindow]), + }) : t("anthropicPool.disabledDesc")} @@ -172,6 +199,7 @@ export default function AnthropicAccountPoolSettings({ threshold, strategy, stickyLimit, + quotaWindow, }); }} > @@ -214,6 +242,7 @@ export default function AnthropicAccountPoolSettings({ threshold: parsed, strategy, stickyLimit, + quotaWindow, }); } }} @@ -234,6 +263,7 @@ export default function AnthropicAccountPoolSettings({ threshold, strategy: next, stickyLimit, + quotaWindow, }); }} onStickyDraftChange={setStickyDraft} @@ -253,9 +283,39 @@ export default function AnthropicAccountPoolSettings({ threshold, strategy, stickyLimit: parsed, + quotaWindow, }); }} /> + + + )} diff --git a/gui/src/styles.css b/gui/src/styles.css index bbf6147218..3bfbdaf9a5 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1780,6 +1780,7 @@ dialog.modal-overlay::backdrop { margin-top: 12px; padding: 0 16px; } +.anthropic-pool-card__field--quota-window { padding-bottom: 16px; } .api-active-keys-skeleton { min-height: 96px; border: 1px solid var(--border-soft); diff --git a/gui/tests/anthropic-pool-quota-window.test.tsx b/gui/tests/anthropic-pool-quota-window.test.tsx index 6892b13eee..289463dc42 100644 --- a/gui/tests/anthropic-pool-quota-window.test.tsx +++ b/gui/tests/anthropic-pool-quota-window.test.tsx @@ -176,6 +176,27 @@ describe("Anthropic account pool quota window", () => { expect(drainedHost.textContent).toContain("scores a usage bar"); }); + test("quota window help text does not open the selector", async () => { + stubPool({ + enabled: true, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + quotaWindow: "five-hour", + }); + const host = await mountPool(); + const field = windowTrigger(host).closest(".anthropic-pool-card__field"); + const help = field?.querySelector(".card-sub"); + if (!help) throw new Error("quota window help missing"); + + await act(async () => { + help.click(); + await flush(); + }); + + expect(testWindow.document.querySelector('[role="listbox"]')).toBeNull(); + }); + test("save sends quotaWindow in the PUT body", async () => { const puts = stubPool({ enabled: true, From 8de1995e4f06f2a757f3629bc5f8f904d6792e59 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 21:24:12 +0900 Subject: [PATCH 08/14] fix(gui): use spacing token for quota window padding --- gui/src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/styles.css b/gui/src/styles.css index 3bfbdaf9a5..0bb8f5440b 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1780,7 +1780,7 @@ dialog.modal-overlay::backdrop { margin-top: 12px; padding: 0 16px; } -.anthropic-pool-card__field--quota-window { padding-bottom: 16px; } +.anthropic-pool-card__field--quota-window { padding-bottom: var(--space-4); } .api-active-keys-skeleton { min-height: 96px; border: 1px solid var(--border-soft); From cafda191014a9f1c59bf2ca3d72ccd8036176532 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 21:24:12 +0900 Subject: [PATCH 09/14] docs(anthropic): complete quota window behavior across locales --- .../docs/fr/reference/configuration/providers.md | 3 ++- docs-site/src/content/docs/guides/claude-code.md | 10 ++++++---- .../docs/ja/reference/configuration/providers.md | 3 ++- .../docs/ko/reference/configuration/providers.md | 3 ++- .../content/docs/reference/configuration/providers.md | 2 +- .../docs/ru/reference/configuration/providers.md | 3 ++- .../docs/tr/reference/configuration/providers.md | 4 ++-- .../docs/zh-cn/reference/configuration/providers.md | 3 ++- .../docs/zh-tw/reference/configuration/providers.md | 3 ++- 9 files changed, 21 insertions(+), 13 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 01a6de6da2..5bfe881848 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -193,8 +193,9 @@ rotation automatique peut déclencher des restrictions du fournisseur. | Clé | Type | Par défaut | Description | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Active l'affinité persistante et le basculement après une temporisation 429. | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Pour les nouvelles sessions, choisir la plus faible utilisation connue et mise en cache sur 5 heures qui atteint ou dépasse ce seuil. `0` désactive la sélection selon le quota. | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Pour les nouvelles sessions, lorsque le compte actif atteint ce seuil, choisir la plus faible utilisation connue et mise en cache dans la fenêtre configurée. `0` désactive la sélection selon le quota. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie des nouvelles sessions ; quota lit la fenêtre définie par `quotaWindow`, par défaut les barres sur 5 heures. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Barre d'utilisation mise en cache utilisée pour classer les comptes. `five-hour` conserve le comportement actuel. `weekly` utilise la barre hebdomadaire, ignore les comptes dont la barre sur 5 heures est épuisée et départage les égalités par la plus faible utilisation sur 5 heures. `max-utilization` utilise la plus élevée des deux barres. `round-robin` ignore ce réglage. Une session saine avec affinité n'est pas rééquilibrée de manière proactive ; la récupération après une erreur terminale, y compris le basculement 429, classe néanmoins les remplaçants avec cette fenêtre sans modifier les limites de temporisation ou de basculement. Les barres hebdomadaires ne sont connues qu'après leur interrogation dans la page Fournisseurs du tableau de bord. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Liaisons de nouvelle session réussies conservées sur une sélection à tour de rôle. Portée 1–100. | Lorsque cette option est activée, un 429 enregistre une temporisation bornée à partir de `Retry-After` ou d'un délai de repli, puis peut diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 6686fa8a1d..85d49c5cc9 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -13,10 +13,10 @@ You can log in multiple Claude accounts via the Providers dashboard (`ocx login add-account). By default every request uses the **active** account only. An **experimental, opt-in** Claude account pool (`anthropicAccountPool.enabled`) adds sticky -session affinity and 429 cooldown failover across those OAuth accounts. For **new** sessions -only, `anthropicAccountPool.strategy` selects among eligible accounts: `quota` (default) picks -lowest known usage in the window set by `quotaWindow` (default the 5-hour bar) when above -`autoSwitchThreshold`; `round-robin` spreads evenly +session affinity and 429 cooldown failover across those OAuth accounts. For **new** sessions, +`anthropicAccountPool.strategy` selects among eligible accounts: `quota` (default) picks the +lowest known usage in the window set by `quotaWindow` (`five-hour` by default, or `weekly` / +`max-utilization`) when above `autoSwitchThreshold`; `round-robin` spreads evenly (`stickyLimit`, default `1`); `fill-first` drains the active account until cooldown, reauthentication, or threshold, then advances. It is **off by default**, shows a GUI warning, and is not battle-tested — Anthropic may restrict accounts that look like automated rotation; @@ -32,6 +32,8 @@ Operational contract when enabled: selection until re-authenticated. - If every eligible account is cooling, the proxy returns **429** (not 401) with `Retry-After` when known. +- Recovery, including 429 failover, uses `quotaWindow` to rank eligible replacements without + changing the existing cooldown or failover limits; `round-robin` ignores `quotaWindow`. See [Configuration](/reference/configuration/#anthropicaccountpool-experimental). 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 c1b126051d..c841c7ac9f 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -159,8 +159,9 @@ affinity を維持します。これらの戦略は provider enforcement を回 |キー |タイプ |デフォルト |説明 | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` |スティッキー アフィニティと 429 クールダウン フェイルオーバーを有効にします。 | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` |新しいセッションの場合は、このしきい値以上の、既知の最も低いキャッシュされた 5 時間の使用量を選択します。 `0` はクォータの選択を無効にします。 | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` |新しいセッションでは、アクティブなアカウントがこのしきい値に達すると、設定した期間で既知のキャッシュ使用量が最も低いアカウントを選択します。 `0` はクォータ選択を無効にします。 | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` |新しいセッション戦略。quota は `quotaWindow` で指定した期間を参照し、既定は 5 時間足です。 | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` |使用量ベースのアカウント選択で評価するキャッシュ済み使用量です。`five-hour` は従来の動作を維持します。`weekly` は週次使用量を使いますが、5 時間使用量が上限に達したアカウントを除外し、週次使用量が同じ場合は 5 時間使用量が低い方を優先します。`max-utilization` は 2 つの値のうち高い方を使います。`round-robin` はこの設定を無視します。正常な affinity セッションを先回りして再配置することはありませんが、429 フェイルオーバーを含む終端エラーからの復旧では、既存のクールダウンとフェイルオーバー上限を変えずに、この期間で代替アカウントを順位付けします。アカウント別の週次使用量は、ダッシュボードのプロバイダーページで取得した後にのみ利用できます。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` |成功した新しいセッションのバインドは 1 つのラウンドロビン選択で保持されます。範囲は 1 ~ 100。 | 有効にすると、429 レコードは `Retry-After` またはデフォルトのバックオフからの制限されたクールダウンを記録し、リクエスト内でローテーションする可能性があります。アフィニティはプロセスローカルであり、サイズ制限があります。資格情報 401/403 は、アカウントに再認証が必要であることをマークします。すべての対象となるアカウントが冷却されている場合、クライアントは、既知の場合、認証エラーではなく、`Retry-After` を含む 429 を受け取ります。 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 3c8c01f2ec..6f3e523651 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -163,8 +163,9 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 | 키 | 타입 | 기본값 | 설명 | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | sticky 결속과 429 쿨다운 failover를 켭니다. | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 새 세션에서는 이 임계값 이상에서 알려진 캐시 5시간 사용량이 가장 낮은 계정을 고릅니다. `0`이면 quota 선택을 끕니다. | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 새 세션에서는 활성 계정이 이 임계값에 도달하면 설정된 창의 알려진 캐시 사용량이 가장 낮은 계정을 고릅니다. `0`이면 quota 선택을 끕니다. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 세션 전략입니다. quota는 `quotaWindow`로 지정한 창을 읽으며, 기본값은 5시간 막대입니다. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 사용량 기반 계정 선택이 점수화할 캐시 사용량 막대입니다. `five-hour`는 기존 동작을 유지합니다. `weekly`는 주간 막대를 사용하되 5시간 막대가 소진된 계정을 건너뛰고, 주간 동점이면 5시간 사용량이 낮은 계정을 우선합니다. `max-utilization`은 두 막대 중 더 높은 값을 사용합니다. `round-robin`은 이 설정을 무시합니다. 정상 affinity 세션을 선제적으로 재배치하지 않지만, 429 failover를 포함한 종료 오류 복구에서는 기존 쿨다운과 failover 한도를 바꾸지 않은 채 이 창으로 대체 계정의 순위를 매깁니다. 계정별 주간 막대는 대시보드의 프로바이더 페이지에서 조회한 뒤에만 알 수 있습니다. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 성공한 새 세션 결속이 한 번의 라운드로빈 선택에 유지되는 횟수입니다. 범위는 1–100입니다. | 활성화되면 429 레코드가 `Retry-After` 또는 기본 backoff에서 제한된 쿨다운을 기록하고, 요청 안에서 회전할 수 있습니다. 결속은 프로세스 로컬이며 크기가 제한됩니다. 자격 증명 401/403은 해당 계정이 재인증이 필요함을 표시합니다. 적격한 계정이 모두 쿨다운 중이면, 클라이언트는 인증 오류가 아니라 알려진 경우 `Retry-After`가 포함된 429를 받습니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a39f250a09..d31987e325 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -267,7 +267,7 @@ rotation may trigger provider restrictions. | `anthropicAccountPool.enabled?` | `boolean` | `false` | Enable sticky affinity and 429 cooldown failover. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, choose the lowest known cached usage in the configured window at or above this threshold. `0` disables quota picking. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; quota reads the window set by `quotaWindow`. | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Which cached usage bar the `quota` strategy scores. `five-hour` keeps the original behavior. `weekly` scores the weekly bar but still skips accounts whose 5-hour bar is exhausted, and breaks weekly ties by lower 5-hour usage. `max-utilization` scores whichever of the two bars is higher. This affects new-session picking only, not affinity re-evaluation, and per-account weekly bars are only known once the dashboard Providers page has polled them. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Which cached usage bar usage-aware account selection scores. `five-hour` keeps the original behavior. `weekly` scores the weekly bar but still skips accounts whose 5-hour bar is exhausted, and breaks weekly ties by lower 5-hour usage. `max-utilization` scores whichever of the two bars is higher. `round-robin` ignores this setting. A healthy affinity-bound session is not proactively rebalanced; terminal recovery, including 429 failover, still ranks eligible replacement accounts with this window without changing cooldown or failover limits. Per-account weekly bars are only known once the dashboard Providers page has polled them. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection. Range 1–100. | When enabled, 429 records bounded cooldown from `Retry-After` or a default backoff and may rotate 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 77e4f378c9..32f3b875b0 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -194,8 +194,9 @@ reauth или порога исчерпания; здоровые привяза | Ключ | Тип | По умолчанию | Описание | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Включить sticky affinity и cooldown failover на 429. | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Для новых сессий выбирать аккаунт с наименьшим известным cached 5-hour usage, если активный аккаунт достиг порога. `0` отключает выбор по quota. | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Для новых сессий выбирать аккаунт с наименьшим известным cached usage в настроенном окне, если активный аккаунт достиг порога. `0` отключает выбор по quota. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия для новых сессий; quota смотрит на окно, заданное в `quotaWindow`; по умолчанию это 5-hour bar'ы. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Cached usage bar для оценки при выборе аккаунта по использованию. `five-hour` сохраняет прежнее поведение. `weekly` использует недельный bar, но пропускает аккаунты с исчерпанным 5-hour bar и при равенстве недельного usage выбирает меньший 5-hour usage. `max-utilization` использует большее из двух значений. `round-robin` игнорирует настройку. Здоровая сессия с affinity не перебалансируется заранее; восстановление после терминальной ошибки, включая 429 failover, всё равно ранжирует доступные замены по этому окну, не меняя лимиты cooldown и failover. Недельные bar'ы аккаунтов известны только после опроса на странице Providers в dashboard. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Сколько успешных bind'ов новых сессий удерживать на одном выборе round-robin. Диапазон 1–100. | Если функция включена, 429 записывает ограниченный cooldown из `Retry-After` или из default 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 c4df28fbc8..5f9ea914dd 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -219,8 +219,9 @@ ve otomatik rotasyon sağlayıcı kısıtlamalarını tetikleyebilir. | Anahtar | Tip | Varsayılan | Açıklama | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Yapışkan bağlılığı ve 429 soğuma yük devretmesini etkinleştirin. | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Yeni oturumlar için bu eşikte veya üzerinde bilinen en düşük önbelleğe alınmış 5 saatlik kullanımı seçin. `0` kota seçimini devre dışı bırakır. | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Yeni oturumlarda etkin hesap bu eşiğe ulaştığında, yapılandırılan penceredeki bilinen en düşük önbelleğe alınmış kullanımı seçin. `0` kota seçimini devre dışı bırakır. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni oturum stratejisi; kota, `quotaWindow` ile belirlenen pencereyi okur; varsayılan 5 saatlik çubuklardır. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Kullanıma dayalı hesap seçiminin puanladığı önbelleğe alınmış kullanım çubuğu. `five-hour` mevcut davranışı korur. `weekly` haftalık çubuğu kullanır, ancak 5 saatlik çubuğu tükenmiş hesapları atlar ve haftalık eşitliklerde daha düşük 5 saatlik kullanımı tercih eder. `max-utilization` iki çubuktan yüksek olanı kullanır. `round-robin` bu ayarı yok sayar. Sağlıklı affinity oturumları önceden yeniden dengelenmez; 429 yük devretmesi dahil terminal hata kurtarması, mevcut soğuma ve yük devretme sınırlarını değiştirmeden uygun yedek hesapları bu pencereye göre sıralar. Hesap başına haftalık çubuklar ancak dashboard Sağlayıcılar sayfasında sorgulandıktan sonra bilinir. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Bir round-robin seçiminde tutulan başarılı yeni oturum bağlamaları. Aralık 1–100. | Etkinleştirildiğinde 429, `Retry-After`'dan veya varsayılan bir geri çekilmeden @@ -480,4 +481,3 @@ bildirir; senkronize edilen katalog `xhigh`'ı ayrı tutarken `max` bildirir. "visionSidecar": { "enabled": true } } ``` - 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 6561b56d91..6e4cf2fb0e 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 @@ -157,8 +157,9 @@ affinity。这些策略不能规避 provider enforcement。 | 键 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | 启用粘性亲和性和 429 冷却故障转移。 | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 对于新会话,选择已知缓存的、5 小时使用率最低且达到或超过此阈值的账户。`0` 会禁用配额选择。 | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 对于新会话,当活动账户达到此阈值时,选择配置窗口中已知缓存使用率最低的账户。`0` 会禁用配额选择。 | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新会话策略;quota 读取 `quotaWindow` 指定的窗口,默认是 5 小时条形数据。 | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 基于用量选择账户时用于评分的缓存用量条。`five-hour` 保持原有行为。`weekly` 使用每周用量条,但仍会跳过 5 小时用量已耗尽的账户;每周用量相同时,优先选择 5 小时用量较低的账户。`max-utilization` 使用两条用量中较高的值。`round-robin` 会忽略此设置。不会主动重新平衡健康且已建立亲和性的会话;遇到终止性错误进行恢复时(包括 429 故障转移),仍会按此窗口对可用替代账户排序,但不会改变现有冷却或故障转移上限。各账户的每周用量只有在控制面板的提供商页面完成查询后才可用。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 在一次轮询选择中保留的成功新会话绑定次数。范围 1–100。 | 启用后,429 会根据 `Retry-After` 记录有界冷却,或者使用默认退避,并且可能在同一请求内轮换。亲和性是进程本地的,并且有大小上限。凭据 401/403 会将账户标记为需要重新认证。如果所有合格账户都在冷却,客户端会在已知时收到带 `Retry-After` 的 429,而不是身份验证错误。 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 f065c1054a..b0ff472746 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 @@ -126,8 +126,9 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | Key | 型別 | 預設值 | 說明 | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | 啟用 sticky 親和性與 429 冷卻容錯移轉。 | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 對於新 session,選擇在此閾值或以上的最低已知快取 5 小時用量。`0` 停用量量挑選。 | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 對於新 session,當目前帳號達到此閾值時,選擇設定視窗中最低的已知快取用量。`0` 停用配額挑選。 | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新 session 策略;quota 讀取 `quotaWindow` 指定的視窗,預設為 5 小時列。 | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 使用量型帳號選擇所評分的快取用量列。`five-hour` 保留原有行為。`weekly` 使用每週用量列,但仍會略過 5 小時用量已耗盡的帳號;每週用量相同時,優先選擇 5 小時用量較低的帳號。`max-utilization` 使用兩個用量中較高的值。`round-robin` 會忽略此設定。不會主動重新平衡健康且已有 affinity 的 session;遇到終止錯誤進行復原時(包括 429 容錯移轉),仍會按此視窗排序合格的替代帳號,但不改變現有冷卻或容錯移轉上限。每個帳號的每週用量只有在 dashboard 的供應商頁面完成查詢後才可得知。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 在一次 round-robin 選擇上保留的成功新 session 綁定。範圍 1–100。 | 啟用時,429 記錄來自 `Retry-After` 或預設 backoff 的有界冷卻,並可能在請求內輪換。親和性為行程本地且有界。憑證 401/403 將帳號標記為需要重新認證。若所有合格帳號都在冷卻,客戶端收到附帶已知 `Retry-After` 的 429,而非認證錯誤。 From 5fa17358ca8327cde2f5a3741c3a8fa7095046c8 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 21:32:24 +0900 Subject: [PATCH 10/14] test(gui): decouple quota window checks from copy --- gui/tests/anthropic-pool-quota-window.test.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/gui/tests/anthropic-pool-quota-window.test.tsx b/gui/tests/anthropic-pool-quota-window.test.tsx index 289463dc42..36dc66e5b7 100644 --- a/gui/tests/anthropic-pool-quota-window.test.tsx +++ b/gui/tests/anthropic-pool-quota-window.test.tsx @@ -131,10 +131,6 @@ describe("Anthropic account pool quota window", () => { const quotaHost = await mountPool(); expect(windowTrigger(quotaHost).disabled).toBe(false); - expect(windowTrigger(quotaHost).textContent).toContain("Weekly bar"); - expect(quotaHost.textContent).toContain("Quota window"); - // The hint replaces the inert notice whenever the setting actually scores a bar. - expect(quotaHost.textContent).toContain("breaks weekly ties by lower 5-hour usage"); stubPool({ enabled: true, @@ -146,7 +142,6 @@ describe("Anthropic account pool quota window", () => { const fillHost = await mountPool(); expect(windowTrigger(fillHost).disabled).toBe(false); - expect(windowTrigger(fillHost).textContent).toContain("5-hour bar"); }); test("quota window selector is disabled for round-robin and for fill-first with threshold 0", async () => { @@ -160,7 +155,6 @@ describe("Anthropic account pool quota window", () => { const rrHost = await mountPool(); expect(windowTrigger(rrHost).disabled).toBe(true); - expect(rrHost.textContent).toContain("scores a usage bar"); stubPool({ enabled: true, @@ -173,7 +167,6 @@ describe("Anthropic account pool quota window", () => { // fill-first only drains against a threshold; at 0 there is no bar to score. expect(windowTrigger(drainedHost).disabled).toBe(true); - expect(drainedHost.textContent).toContain("scores a usage bar"); }); test("quota window help text does not open the selector", async () => { @@ -211,8 +204,9 @@ describe("Anthropic account pool quota window", () => { windowTrigger(host).click(); await flush(); }); - const option = Array.from(testWindow.document.querySelectorAll('[role="option"]')) - .find((el) => (el.textContent ?? "").includes("Weekly bar")); + const option = testWindow.document.querySelector( + '[role="option"]:not([aria-selected="true"])', + ) as unknown as HTMLButtonElement | null; if (!option) throw new Error("weekly option missing"); await act(async () => { option.click(); @@ -228,6 +222,5 @@ describe("Anthropic account pool quota window", () => { stickyLimit: 1, quotaWindow: "weekly", }); - expect(windowTrigger(host).textContent).toContain("Weekly bar"); }); }); From dc0159f28e1f369d092b8a7cfce3c74a84d87e54 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 21:32:24 +0900 Subject: [PATCH 11/14] docs(anthropic): align localized quota window guides --- docs-site/src/content/docs/fr/guides/claude-code.md | 3 ++- docs-site/src/content/docs/tr/guides/claude-code.md | 4 ++-- docs-site/src/content/docs/zh-tw/guides/claude-code.md | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 8a5ccf79b2..baddc6b355 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -15,7 +15,8 @@ ajouter un compte). Par défaut, chaque requête utilise uniquement le compte ** Un groupe de comptes Claude **expérimental et facultatif** (`anthropicAccountPool.enabled`) ajoute l'affinité de session et le basculement en cas de délai de récupération 429 entre ces comptes OAuth. Pour les **nouvelles** sessions uniquement, `anthropicAccountPool.strategy` sélectionne un compte éligible : `quota` (par défaut) -choisit la plus faible utilisation connue sur 5 heures lorsqu'elle dépasse `autoSwitchThreshold` ; `round-robin` +choisit la plus faible utilisation connue dans la fenêtre configurée par `anthropicAccountPool.quotaWindow` +(`five-hour` par défaut, `weekly` ou `max-utilization`) lorsqu'elle dépasse `autoSwitchThreshold` ; `round-robin` répartit les sessions uniformément (`stickyLimit`, `1` par défaut) ; `fill-first` utilise le compte actif jusqu'à un délai de récupération, une réauthentification ou le seuil, puis passe au suivant. Cette fonction est **désactivée par défaut**, affiche un avertissement dans l'interface et n'a pas été éprouvée en production. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index e335bea33e..42a65eb915 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -19,7 +19,8 @@ fazla Claude hesabına giriş yapabilirsiniz. Varsayılan olarak her istek yaln bağlılığı ve 429 bekleme süresi (cooldown) yük devretmesi ekler. Yalnızca **yeni** oturumlar için `anthropicAccountPool.strategy` uygun hesaplar arasından seçim yapar: `quota` (varsayılan), `autoSwitchThreshold` üzerinde olduğunda -bilinen en düşük 5 saatlik kullanımı seçer; `round-robin` eşit olarak dağıtır +`anthropicAccountPool.quotaWindow` ile yapılandırılan penceredeki bilinen en düşük kullanımı +seçer (`five-hour` varsayılandır; `weekly` ve `max-utilization` da kullanılabilir); `round-robin` eşit olarak dağıtır (`stickyLimit`, varsayılan `1`); `fill-first`, bekleme süresi, yeniden kimlik doğrulama veya eşiğe kadar aktif hesabı tüketir, ardından ilerler. **Varsayılan olarak kapalıdır**, bir GUI uyarısı gösterir ve sahada kapsamlı olarak test @@ -607,4 +608,3 @@ modellerde opencodex varsayılan olarak bunu taslakla değiştirir (`blockedSkil aracının `model` argümanını değil, `` yönergelerini kullanır. Yönergenin hedeflenen rotayla eşleştiğinden emin olun. Model yer tutucusu olarak `"haiku"` iletin. - diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index be06746fba..bf98b5fb40 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -14,7 +14,9 @@ Code 可以使用每一個已路由的供應商——包括 OAuth 登入、帳 **實驗性、opt-in** 的 Claude 帳號池(`anthropicAccountPool.enabled`)會在這些 OAuth 帳號之間加入 sticky session affinity 與 429 冷卻故障轉移。僅對**新**工作階段,`anthropicAccountPool.strategy` -會在合格帳號之間選擇:`quota`(預設)在用量高於 `autoSwitchThreshold` 時挑選已知 5 小時用量最低者; +會在合格帳號之間選擇:`quota`(預設)在用量高於 `autoSwitchThreshold` 時,依 +`anthropicAccountPool.quotaWindow` 所設定的視窗挑選已知用量最低者(`five-hour` 為預設,亦可選 +`weekly` 或 `max-utilization`); `round-robin` 平均分散(`stickyLimit`,預設 `1`);`fill-first` 一直使用作用中帳號直到冷卻、重新認證 或達到閾值,然後前進。它**預設關閉**、會在 GUI 顯示警告,而且尚未經過實戰驗證——Anthropic 可能 限制看起來像自動輪換的帳號;輪換並不能保護你免受供應商執行機制的處置。 From 0b5727bfa0a4cf4ef0797a9b29e0eaf13be64c34 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 21:36:27 +0900 Subject: [PATCH 12/14] fix(anthropic): break max quota ties by five-hour usage --- src/oauth/anthropic-routing.ts | 2 +- tests/anthropic-account-pool.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 44ec7d22c7..c840d60ab9 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -269,7 +269,7 @@ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: const scored: ScoredAccount[] = eligible.map(accountId => ({ accountId, score: usageScore(config, accountId), - fiveHourTieBreak: window === "weekly" ? fiveHourScore(accountId) : 0, + fiveHourTieBreak: window === "five-hour" ? 0 : fiveHourScore(accountId), })); let best = scored[0]!; for (let i = 1; i < scored.length; i++) { diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index c2ca5055b6..58998f682c 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -500,6 +500,15 @@ describe("anthropic account pool quota window scoring", () => { expect(resolveAnthropicAccountForSession("max-hotter", cfg(true, 80, { quotaWindow: "max-utilization" })).accountId).toBe(bId); }); + test("max-utilization tie breaks by lower five-hour usage", async () => { + const { aId, bId } = await seedTwoAccounts(); + const updatedAt = Date.now(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 90, weeklyPercent: 20, updatedAt }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 40, weeklyPercent: 90, updatedAt }); + + expect(resolveAnthropicAccountForSession("max-five-hour-tie", cfg(true, 80, { quotaWindow: "max-utilization" })).accountId).toBe(bId); + }); + test("omitted quotaWindow equals explicit five-hour", async () => { const { aId, bId } = await seedTwoAccounts(); const updatedAt = Date.now(); From 59fd3a442dfbf108628c46b9b9b04aec1c578997 Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 21:36:27 +0900 Subject: [PATCH 13/14] docs(anthropic): document quota tie breaking --- .../src/content/docs/fr/reference/configuration/providers.md | 2 +- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- docs-site/src/content/docs/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/reference/configuration/providers.md | 2 +- .../src/content/docs/tr/reference/configuration/providers.md | 2 +- .../src/content/docs/zh-cn/reference/configuration/providers.md | 2 +- .../src/content/docs/zh-tw/reference/configuration/providers.md | 2 +- 8 files changed, 8 insertions(+), 8 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 5bfe881848..72fd1a2aca 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -195,7 +195,7 @@ rotation automatique peut déclencher des restrictions du fournisseur. | `anthropicAccountPool.enabled?` | `boolean` | `false` | Active l'affinité persistante et le basculement après une temporisation 429. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Pour les nouvelles sessions, lorsque le compte actif atteint ce seuil, choisir la plus faible utilisation connue et mise en cache dans la fenêtre configurée. `0` désactive la sélection selon le quota. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie des nouvelles sessions ; quota lit la fenêtre définie par `quotaWindow`, par défaut les barres sur 5 heures. | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Barre d'utilisation mise en cache utilisée pour classer les comptes. `five-hour` conserve le comportement actuel. `weekly` utilise la barre hebdomadaire, ignore les comptes dont la barre sur 5 heures est épuisée et départage les égalités par la plus faible utilisation sur 5 heures. `max-utilization` utilise la plus élevée des deux barres. `round-robin` ignore ce réglage. Une session saine avec affinité n'est pas rééquilibrée de manière proactive ; la récupération après une erreur terminale, y compris le basculement 429, classe néanmoins les remplaçants avec cette fenêtre sans modifier les limites de temporisation ou de basculement. Les barres hebdomadaires ne sont connues qu'après leur interrogation dans la page Fournisseurs du tableau de bord. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Barre d'utilisation mise en cache utilisée pour classer les comptes. `five-hour` conserve le comportement actuel. `weekly` utilise la barre hebdomadaire et ignore les comptes dont la barre sur 5 heures est épuisée. `max-utilization` utilise la plus élevée des deux barres. Dans les deux modes, les égalités sont départagées par la plus faible utilisation sur 5 heures. `round-robin` ignore ce réglage. Une session saine avec affinité n'est pas rééquilibrée de manière proactive ; la récupération après une erreur terminale, y compris le basculement 429, classe néanmoins les remplaçants avec cette fenêtre sans modifier les limites de temporisation ou de basculement. Les barres hebdomadaires ne sont connues qu'après leur interrogation dans la page Fournisseurs du tableau de bord. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Liaisons de nouvelle session réussies conservées sur une sélection à tour de rôle. Portée 1–100. | Lorsque cette option est activée, un 429 enregistre une temporisation bornée à partir de `Retry-After` ou d'un délai de repli, puis peut 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 c841c7ac9f..5d26b21a81 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -161,7 +161,7 @@ affinity を維持します。これらの戦略は provider enforcement を回 | `anthropicAccountPool.enabled?` | `boolean` | `false` |スティッキー アフィニティと 429 クールダウン フェイルオーバーを有効にします。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` |新しいセッションでは、アクティブなアカウントがこのしきい値に達すると、設定した期間で既知のキャッシュ使用量が最も低いアカウントを選択します。 `0` はクォータ選択を無効にします。 | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` |新しいセッション戦略。quota は `quotaWindow` で指定した期間を参照し、既定は 5 時間足です。 | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` |使用量ベースのアカウント選択で評価するキャッシュ済み使用量です。`five-hour` は従来の動作を維持します。`weekly` は週次使用量を使いますが、5 時間使用量が上限に達したアカウントを除外し、週次使用量が同じ場合は 5 時間使用量が低い方を優先します。`max-utilization` は 2 つの値のうち高い方を使います。`round-robin` はこの設定を無視します。正常な affinity セッションを先回りして再配置することはありませんが、429 フェイルオーバーを含む終端エラーからの復旧では、既存のクールダウンとフェイルオーバー上限を変えずに、この期間で代替アカウントを順位付けします。アカウント別の週次使用量は、ダッシュボードのプロバイダーページで取得した後にのみ利用できます。 | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` |使用量ベースのアカウント選択で評価するキャッシュ済み使用量です。`five-hour` は従来の動作を維持します。`weekly` は週次使用量を使いますが、5 時間使用量が上限に達したアカウントを除外します。`max-utilization` は 2 つの値のうち高い方を使います。どちらのモードでも同点の場合は 5 時間使用量が低い方を優先します。`round-robin` はこの設定を無視します。正常な affinity セッションを先回りして再配置することはありませんが、429 フェイルオーバーを含む終端エラーからの復旧では、既存のクールダウンとフェイルオーバー上限を変えずに、この期間で代替アカウントを順位付けします。アカウント別の週次使用量は、ダッシュボードのプロバイダーページで取得した後にのみ利用できます。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` |成功した新しいセッションのバインドは 1 つのラウンドロビン選択で保持されます。範囲は 1 ~ 100。 | 有効にすると、429 レコードは `Retry-After` またはデフォルトのバックオフからの制限されたクールダウンを記録し、リクエスト内でローテーションする可能性があります。アフィニティはプロセスローカルであり、サイズ制限があります。資格情報 401/403 は、アカウントに再認証が必要であることをマークします。すべての対象となるアカウントが冷却されている場合、クライアントは、既知の場合、認証エラーではなく、`Retry-After` を含む 429 を受け取ります。 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 6f3e523651..befdf1fb11 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -165,7 +165,7 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 | `anthropicAccountPool.enabled?` | `boolean` | `false` | sticky 결속과 429 쿨다운 failover를 켭니다. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 새 세션에서는 활성 계정이 이 임계값에 도달하면 설정된 창의 알려진 캐시 사용량이 가장 낮은 계정을 고릅니다. `0`이면 quota 선택을 끕니다. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 세션 전략입니다. quota는 `quotaWindow`로 지정한 창을 읽으며, 기본값은 5시간 막대입니다. | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 사용량 기반 계정 선택이 점수화할 캐시 사용량 막대입니다. `five-hour`는 기존 동작을 유지합니다. `weekly`는 주간 막대를 사용하되 5시간 막대가 소진된 계정을 건너뛰고, 주간 동점이면 5시간 사용량이 낮은 계정을 우선합니다. `max-utilization`은 두 막대 중 더 높은 값을 사용합니다. `round-robin`은 이 설정을 무시합니다. 정상 affinity 세션을 선제적으로 재배치하지 않지만, 429 failover를 포함한 종료 오류 복구에서는 기존 쿨다운과 failover 한도를 바꾸지 않은 채 이 창으로 대체 계정의 순위를 매깁니다. 계정별 주간 막대는 대시보드의 프로바이더 페이지에서 조회한 뒤에만 알 수 있습니다. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 사용량 기반 계정 선택이 점수화할 캐시 사용량 막대입니다. `five-hour`는 기존 동작을 유지합니다. `weekly`는 주간 막대를 사용하되 5시간 막대가 소진된 계정을 건너뜁니다. `max-utilization`은 두 막대 중 더 높은 값을 사용합니다. 두 모드 모두 동점이면 5시간 사용량이 낮은 계정을 우선합니다. `round-robin`은 이 설정을 무시합니다. 정상 affinity 세션을 선제적으로 재배치하지 않지만, 429 failover를 포함한 종료 오류 복구에서는 기존 쿨다운과 failover 한도를 바꾸지 않은 채 이 창으로 대체 계정의 순위를 매깁니다. 계정별 주간 막대는 대시보드의 프로바이더 페이지에서 조회한 뒤에만 알 수 있습니다. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 성공한 새 세션 결속이 한 번의 라운드로빈 선택에 유지되는 횟수입니다. 범위는 1–100입니다. | 활성화되면 429 레코드가 `Retry-After` 또는 기본 backoff에서 제한된 쿨다운을 기록하고, 요청 안에서 회전할 수 있습니다. 결속은 프로세스 로컬이며 크기가 제한됩니다. 자격 증명 401/403은 해당 계정이 재인증이 필요함을 표시합니다. 적격한 계정이 모두 쿨다운 중이면, 클라이언트는 인증 오류가 아니라 알려진 경우 `Retry-After`가 포함된 429를 받습니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d31987e325..a16c4f6c12 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -267,7 +267,7 @@ rotation may trigger provider restrictions. | `anthropicAccountPool.enabled?` | `boolean` | `false` | Enable sticky affinity and 429 cooldown failover. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, choose the lowest known cached usage in the configured window at or above this threshold. `0` disables quota picking. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; quota reads the window set by `quotaWindow`. | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Which cached usage bar usage-aware account selection scores. `five-hour` keeps the original behavior. `weekly` scores the weekly bar but still skips accounts whose 5-hour bar is exhausted, and breaks weekly ties by lower 5-hour usage. `max-utilization` scores whichever of the two bars is higher. `round-robin` ignores this setting. A healthy affinity-bound session is not proactively rebalanced; terminal recovery, including 429 failover, still ranks eligible replacement accounts with this window without changing cooldown or failover limits. Per-account weekly bars are only known once the dashboard Providers page has polled them. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Which cached usage bar usage-aware account selection scores. `five-hour` keeps the original behavior. `weekly` scores the weekly bar but still skips accounts whose 5-hour bar is exhausted. `max-utilization` scores whichever of the two bars is higher. Ties in either mode prefer lower 5-hour usage. `round-robin` ignores this setting. A healthy affinity-bound session is not proactively rebalanced; terminal recovery, including 429 failover, still ranks eligible replacement accounts with this window without changing cooldown or failover limits. Per-account weekly bars are only known once the dashboard Providers page has polled them. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection. Range 1–100. | When enabled, 429 records bounded cooldown from `Retry-After` or a default backoff and may rotate 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 32f3b875b0..a9a4a730da 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -196,7 +196,7 @@ reauth или порога исчерпания; здоровые привяза | `anthropicAccountPool.enabled?` | `boolean` | `false` | Включить sticky affinity и cooldown failover на 429. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Для новых сессий выбирать аккаунт с наименьшим известным cached usage в настроенном окне, если активный аккаунт достиг порога. `0` отключает выбор по quota. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия для новых сессий; quota смотрит на окно, заданное в `quotaWindow`; по умолчанию это 5-hour bar'ы. | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Cached usage bar для оценки при выборе аккаунта по использованию. `five-hour` сохраняет прежнее поведение. `weekly` использует недельный bar, но пропускает аккаунты с исчерпанным 5-hour bar и при равенстве недельного usage выбирает меньший 5-hour usage. `max-utilization` использует большее из двух значений. `round-robin` игнорирует настройку. Здоровая сессия с affinity не перебалансируется заранее; восстановление после терминальной ошибки, включая 429 failover, всё равно ранжирует доступные замены по этому окну, не меняя лимиты cooldown и failover. Недельные bar'ы аккаунтов известны только после опроса на странице Providers в dashboard. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Cached usage bar для оценки при выборе аккаунта по использованию. `five-hour` сохраняет прежнее поведение. `weekly` использует недельный bar, но пропускает аккаунты с исчерпанным 5-hour bar. `max-utilization` использует большее из двух значений. При равенстве в обоих режимах выбирается меньший 5-hour usage. `round-robin` игнорирует настройку. Здоровая сессия с affinity не перебалансируется заранее; восстановление после терминальной ошибки, включая 429 failover, всё равно ранжирует доступные замены по этому окну, не меняя лимиты cooldown и failover. Недельные bar'ы аккаунтов известны только после опроса на странице Providers в dashboard. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Сколько успешных bind'ов новых сессий удерживать на одном выборе round-robin. Диапазон 1–100. | Если функция включена, 429 записывает ограниченный cooldown из `Retry-After` или из default 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 5f9ea914dd..ba996da57e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -221,7 +221,7 @@ ve otomatik rotasyon sağlayıcı kısıtlamalarını tetikleyebilir. | `anthropicAccountPool.enabled?` | `boolean` | `false` | Yapışkan bağlılığı ve 429 soğuma yük devretmesini etkinleştirin. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Yeni oturumlarda etkin hesap bu eşiğe ulaştığında, yapılandırılan penceredeki bilinen en düşük önbelleğe alınmış kullanımı seçin. `0` kota seçimini devre dışı bırakır. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni oturum stratejisi; kota, `quotaWindow` ile belirlenen pencereyi okur; varsayılan 5 saatlik çubuklardır. | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Kullanıma dayalı hesap seçiminin puanladığı önbelleğe alınmış kullanım çubuğu. `five-hour` mevcut davranışı korur. `weekly` haftalık çubuğu kullanır, ancak 5 saatlik çubuğu tükenmiş hesapları atlar ve haftalık eşitliklerde daha düşük 5 saatlik kullanımı tercih eder. `max-utilization` iki çubuktan yüksek olanı kullanır. `round-robin` bu ayarı yok sayar. Sağlıklı affinity oturumları önceden yeniden dengelenmez; 429 yük devretmesi dahil terminal hata kurtarması, mevcut soğuma ve yük devretme sınırlarını değiştirmeden uygun yedek hesapları bu pencereye göre sıralar. Hesap başına haftalık çubuklar ancak dashboard Sağlayıcılar sayfasında sorgulandıktan sonra bilinir. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Kullanıma dayalı hesap seçiminin puanladığı önbelleğe alınmış kullanım çubuğu. `five-hour` mevcut davranışı korur. `weekly` haftalık çubuğu kullanır, ancak 5 saatlik çubuğu tükenmiş hesapları atlar. `max-utilization` iki çubuktan yüksek olanı kullanır. Her iki moddaki eşitliklerde daha düşük 5 saatlik kullanım tercih edilir. `round-robin` bu ayarı yok sayar. Sağlıklı affinity oturumları önceden yeniden dengelenmez; 429 yük devretmesi dahil terminal hata kurtarması, mevcut soğuma ve yük devretme sınırlarını değiştirmeden uygun yedek hesapları bu pencereye göre sıralar. Hesap başına haftalık çubuklar ancak dashboard Sağlayıcılar sayfasında sorgulandıktan sonra bilinir. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Bir round-robin seçiminde tutulan başarılı yeni oturum bağlamaları. Aralık 1–100. | Etkinleştirildiğinde 429, `Retry-After`'dan veya varsayılan bir geri çekilmeden 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 6e4cf2fb0e..77c1fdcf86 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 @@ -159,7 +159,7 @@ affinity。这些策略不能规避 provider enforcement。 | `anthropicAccountPool.enabled?` | `boolean` | `false` | 启用粘性亲和性和 429 冷却故障转移。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 对于新会话,当活动账户达到此阈值时,选择配置窗口中已知缓存使用率最低的账户。`0` 会禁用配额选择。 | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新会话策略;quota 读取 `quotaWindow` 指定的窗口,默认是 5 小时条形数据。 | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 基于用量选择账户时用于评分的缓存用量条。`five-hour` 保持原有行为。`weekly` 使用每周用量条,但仍会跳过 5 小时用量已耗尽的账户;每周用量相同时,优先选择 5 小时用量较低的账户。`max-utilization` 使用两条用量中较高的值。`round-robin` 会忽略此设置。不会主动重新平衡健康且已建立亲和性的会话;遇到终止性错误进行恢复时(包括 429 故障转移),仍会按此窗口对可用替代账户排序,但不会改变现有冷却或故障转移上限。各账户的每周用量只有在控制面板的提供商页面完成查询后才可用。 | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 基于用量选择账户时用于评分的缓存用量条。`five-hour` 保持原有行为。`weekly` 使用每周用量条,但仍会跳过 5 小时用量已耗尽的账户。`max-utilization` 使用两条用量中较高的值。两种模式分数相同时,都优先选择 5 小时用量较低的账户。`round-robin` 会忽略此设置。不会主动重新平衡健康且已建立亲和性的会话;遇到终止性错误进行恢复时(包括 429 故障转移),仍会按此窗口对可用替代账户排序,但不会改变现有冷却或故障转移上限。各账户的每周用量只有在控制面板的提供商页面完成查询后才可用。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 在一次轮询选择中保留的成功新会话绑定次数。范围 1–100。 | 启用后,429 会根据 `Retry-After` 记录有界冷却,或者使用默认退避,并且可能在同一请求内轮换。亲和性是进程本地的,并且有大小上限。凭据 401/403 会将账户标记为需要重新认证。如果所有合格账户都在冷却,客户端会在已知时收到带 `Retry-After` 的 429,而不是身份验证错误。 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 b0ff472746..cd0f54b5e3 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 @@ -128,7 +128,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | `anthropicAccountPool.enabled?` | `boolean` | `false` | 啟用 sticky 親和性與 429 冷卻容錯移轉。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 對於新 session,當目前帳號達到此閾值時,選擇設定視窗中最低的已知快取用量。`0` 停用配額挑選。 | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新 session 策略;quota 讀取 `quotaWindow` 指定的視窗,預設為 5 小時列。 | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 使用量型帳號選擇所評分的快取用量列。`five-hour` 保留原有行為。`weekly` 使用每週用量列,但仍會略過 5 小時用量已耗盡的帳號;每週用量相同時,優先選擇 5 小時用量較低的帳號。`max-utilization` 使用兩個用量中較高的值。`round-robin` 會忽略此設定。不會主動重新平衡健康且已有 affinity 的 session;遇到終止錯誤進行復原時(包括 429 容錯移轉),仍會按此視窗排序合格的替代帳號,但不改變現有冷卻或容錯移轉上限。每個帳號的每週用量只有在 dashboard 的供應商頁面完成查詢後才可得知。 | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 使用量型帳號選擇所評分的快取用量列。`five-hour` 保留原有行為。`weekly` 使用每週用量列,但仍會略過 5 小時用量已耗盡的帳號。`max-utilization` 使用兩個用量中較高的值。兩種模式分數相同時,都優先選擇 5 小時用量較低的帳號。`round-robin` 會忽略此設定。不會主動重新平衡健康且已有 affinity 的 session;遇到終止錯誤進行復原時(包括 429 容錯移轉),仍會按此視窗排序合格的替代帳號,但不改變現有冷卻或容錯移轉上限。每個帳號的每週用量只有在 dashboard 的供應商頁面完成查詢後才可得知。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 在一次 round-robin 選擇上保留的成功新 session 綁定。範圍 1–100。 | 啟用時,429 記錄來自 `Retry-After` 或預設 backoff 的有界冷卻,並可能在請求內輪換。親和性為行程本地且有界。憑證 401/403 將帳號標記為需要重新認證。若所有合格帳號都在冷卻,客戶端收到附帶已知 `Retry-After` 的 429,而非認證錯誤。 From 4911cbe4af96e1b7fb3edec9939f7a4a3be6de9c Mon Sep 17 00:00:00 2001 From: Yoonkeee Date: Tue, 25 Aug 2026 22:46:52 +0900 Subject: [PATCH 14/14] docs(anthropic): document the quota window behind fill-first and recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider tables said `quota` reads `quotaWindow` but left out that fill-first evaluates its drain threshold in that same window, and the English `autoSwitchThreshold` row read as if the account chosen had to be at or above the threshold — `pickLowestUsage` applies no such filter once the active account crosses it. The fr/tr/zh-tw Claude Code guides were also missing the recovery ranking bullet the English guide already carries. --- docs-site/src/content/docs/fr/guides/claude-code.md | 3 +++ .../src/content/docs/fr/reference/configuration/providers.md | 2 +- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- .../src/content/docs/reference/configuration/providers.md | 4 ++-- .../src/content/docs/ru/reference/configuration/providers.md | 2 +- docs-site/src/content/docs/tr/guides/claude-code.md | 3 +++ .../src/content/docs/tr/reference/configuration/providers.md | 2 +- .../content/docs/zh-cn/reference/configuration/providers.md | 2 +- docs-site/src/content/docs/zh-tw/guides/claude-code.md | 2 ++ .../content/docs/zh-tw/reference/configuration/providers.md | 2 +- 11 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index baddc6b355..671c22c233 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -32,6 +32,9 @@ Comportement lorsque cette option est activée : sélection jusqu'à sa réauthentification. - Si chaque compte éligible est en temporisation, le proxy renvoie **429** (et non 401) avec `Retry-After` lorsqu'il est connu. +- La récupération, y compris le basculement 429, utilise `quotaWindow` pour classer les comptes de + remplacement admissibles, sans modifier les limites existantes de temporisation ou de basculement ; + `round-robin` ignore `quotaWindow`. Voir [Configuration](/fr/reference/configuration/providers/#anthropicaccountpool-expérimental). 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 72fd1a2aca..68205d6059 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -194,7 +194,7 @@ rotation automatique peut déclencher des restrictions du fournisseur. | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Active l'affinité persistante et le basculement après une temporisation 429. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Pour les nouvelles sessions, lorsque le compte actif atteint ce seuil, choisir la plus faible utilisation connue et mise en cache dans la fenêtre configurée. `0` désactive la sélection selon le quota. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie des nouvelles sessions ; quota lit la fenêtre définie par `quotaWindow`, par défaut les barres sur 5 heures. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie des nouvelles sessions ; `quota` classe les comptes selon la fenêtre définie par `quotaWindow`, par défaut les barres sur 5 heures, et `fill-first` évalue son seuil d'évacuation dans cette même fenêtre. | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Barre d'utilisation mise en cache utilisée pour classer les comptes. `five-hour` conserve le comportement actuel. `weekly` utilise la barre hebdomadaire et ignore les comptes dont la barre sur 5 heures est épuisée. `max-utilization` utilise la plus élevée des deux barres. Dans les deux modes, les égalités sont départagées par la plus faible utilisation sur 5 heures. `round-robin` ignore ce réglage. Une session saine avec affinité n'est pas rééquilibrée de manière proactive ; la récupération après une erreur terminale, y compris le basculement 429, classe néanmoins les remplaçants avec cette fenêtre sans modifier les limites de temporisation ou de basculement. Les barres hebdomadaires ne sont connues qu'après leur interrogation dans la page Fournisseurs du tableau de bord. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Liaisons de nouvelle session réussies conservées sur une sélection à tour de rôle. Portée 1–100. | 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 5d26b21a81..02009f0832 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -160,7 +160,7 @@ affinity を維持します。これらの戦略は provider enforcement を回 | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` |スティッキー アフィニティと 429 クールダウン フェイルオーバーを有効にします。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` |新しいセッションでは、アクティブなアカウントがこのしきい値に達すると、設定した期間で既知のキャッシュ使用量が最も低いアカウントを選択します。 `0` はクォータ選択を無効にします。 | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` |新しいセッション戦略。quota は `quotaWindow` で指定した期間を参照し、既定は 5 時間足です。 | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` |新しいセッション戦略。`quota` は `quotaWindow` で指定した期間(既定は 5 時間足)でアカウントを順位付けし、`fill-first` も同じ期間で使い切りのしきい値を判定します。 | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` |使用量ベースのアカウント選択で評価するキャッシュ済み使用量です。`five-hour` は従来の動作を維持します。`weekly` は週次使用量を使いますが、5 時間使用量が上限に達したアカウントを除外します。`max-utilization` は 2 つの値のうち高い方を使います。どちらのモードでも同点の場合は 5 時間使用量が低い方を優先します。`round-robin` はこの設定を無視します。正常な affinity セッションを先回りして再配置することはありませんが、429 フェイルオーバーを含む終端エラーからの復旧では、既存のクールダウンとフェイルオーバー上限を変えずに、この期間で代替アカウントを順位付けします。アカウント別の週次使用量は、ダッシュボードのプロバイダーページで取得した後にのみ利用できます。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` |成功した新しいセッションのバインドは 1 つのラウンドロビン選択で保持されます。範囲は 1 ~ 100。 | 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 befdf1fb11..64433c5c2e 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -164,7 +164,7 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | sticky 결속과 429 쿨다운 failover를 켭니다. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 새 세션에서는 활성 계정이 이 임계값에 도달하면 설정된 창의 알려진 캐시 사용량이 가장 낮은 계정을 고릅니다. `0`이면 quota 선택을 끕니다. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 세션 전략입니다. quota는 `quotaWindow`로 지정한 창을 읽으며, 기본값은 5시간 막대입니다. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 세션 전략입니다. `quota`는 `quotaWindow`로 지정한 창(기본값은 5시간 막대)으로 계정 순위를 매기고, `fill-first`도 같은 창에서 소진 임계값을 판정합니다. | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 사용량 기반 계정 선택이 점수화할 캐시 사용량 막대입니다. `five-hour`는 기존 동작을 유지합니다. `weekly`는 주간 막대를 사용하되 5시간 막대가 소진된 계정을 건너뜁니다. `max-utilization`은 두 막대 중 더 높은 값을 사용합니다. 두 모드 모두 동점이면 5시간 사용량이 낮은 계정을 우선합니다. `round-robin`은 이 설정을 무시합니다. 정상 affinity 세션을 선제적으로 재배치하지 않지만, 429 failover를 포함한 종료 오류 복구에서는 기존 쿨다운과 failover 한도를 바꾸지 않은 채 이 창으로 대체 계정의 순위를 매깁니다. 계정별 주간 막대는 대시보드의 프로바이더 페이지에서 조회한 뒤에만 알 수 있습니다. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 성공한 새 세션 결속이 한 번의 라운드로빈 선택에 유지되는 횟수입니다. 범위는 1–100입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a16c4f6c12..749f739f06 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -265,8 +265,8 @@ rotation may trigger provider restrictions. | Key | Type | Default | Description | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Enable sticky affinity and 429 cooldown failover. | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, choose the lowest known cached usage in the configured window at or above this threshold. `0` disables quota picking. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; quota reads the window set by `quotaWindow`. | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, when the active account reaches this threshold, choose the lowest known cached usage in the configured window; the account chosen does not itself have to be at or above the threshold. `0` disables quota picking. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; `quota` ranks accounts by the window set by `quotaWindow`, and `fill-first` evaluates its drain threshold in that same window. | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Which cached usage bar usage-aware account selection scores. `five-hour` keeps the original behavior. `weekly` scores the weekly bar but still skips accounts whose 5-hour bar is exhausted. `max-utilization` scores whichever of the two bars is higher. Ties in either mode prefer lower 5-hour usage. `round-robin` ignores this setting. A healthy affinity-bound session is not proactively rebalanced; terminal recovery, including 429 failover, still ranks eligible replacement accounts with this window without changing cooldown or failover limits. Per-account weekly bars are only known once the dashboard Providers page has polled them. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection. Range 1–100. | 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 a9a4a730da..985f573cc8 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -195,7 +195,7 @@ reauth или порога исчерпания; здоровые привяза | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Включить sticky affinity и cooldown failover на 429. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Для новых сессий выбирать аккаунт с наименьшим известным cached usage в настроенном окне, если активный аккаунт достиг порога. `0` отключает выбор по quota. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия для новых сессий; quota смотрит на окно, заданное в `quotaWindow`; по умолчанию это 5-hour bar'ы. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия для новых сессий; `quota` ранжирует аккаунты по окну, заданному в `quotaWindow` (по умолчанию это 5-hour bar'ы), а `fill-first` в этом же окне оценивает свой порог исчерпания. | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Cached usage bar для оценки при выборе аккаунта по использованию. `five-hour` сохраняет прежнее поведение. `weekly` использует недельный bar, но пропускает аккаунты с исчерпанным 5-hour bar. `max-utilization` использует большее из двух значений. При равенстве в обоих режимах выбирается меньший 5-hour usage. `round-robin` игнорирует настройку. Здоровая сессия с affinity не перебалансируется заранее; восстановление после терминальной ошибки, включая 429 failover, всё равно ранжирует доступные замены по этому окну, не меняя лимиты cooldown и failover. Недельные bar'ы аккаунтов известны только после опроса на странице Providers в dashboard. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Сколько успешных bind'ов новых сессий удерживать на одном выборе round-robin. Диапазон 1–100. | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 42a65eb915..1fe4fd0393 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -38,6 +38,9 @@ Etkinleştirildiğinde operasyonel sözleşme: böylece yeniden kimlik doğrulanana kadar seçimden hariç tutulur. - Uygun tüm hesaplar soğutuluyorsa, proxy bilindiğinde `Retry-After` ile birlikte **429** (401 değil) döndürür. +- 429 yük devretmesi dahil kurtarma, mevcut soğuma ve yük devretme sınırlarını + değiştirmeden uygun yedek hesapları sıralamak için `quotaWindow` kullanır; + `round-robin` ise `quotaWindow` ayarını yok sayar. Bkz. [Yapılandırma](/tr/reference/configuration/#anthropicaccountpool-experimental). 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 ba996da57e..e5489ecdf5 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -220,7 +220,7 @@ ve otomatik rotasyon sağlayıcı kısıtlamalarını tetikleyebilir. | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Yapışkan bağlılığı ve 429 soğuma yük devretmesini etkinleştirin. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | Yeni oturumlarda etkin hesap bu eşiğe ulaştığında, yapılandırılan penceredeki bilinen en düşük önbelleğe alınmış kullanımı seçin. `0` kota seçimini devre dışı bırakır. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni oturum stratejisi; kota, `quotaWindow` ile belirlenen pencereyi okur; varsayılan 5 saatlik çubuklardır. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni oturum stratejisi; `quota`, `quotaWindow` ile belirlenen pencereye (varsayılan 5 saatlik çubuklar) göre hesapları sıralar ve `fill-first` de tükenme eşiğini aynı pencerede değerlendirir. | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | Kullanıma dayalı hesap seçiminin puanladığı önbelleğe alınmış kullanım çubuğu. `five-hour` mevcut davranışı korur. `weekly` haftalık çubuğu kullanır, ancak 5 saatlik çubuğu tükenmiş hesapları atlar. `max-utilization` iki çubuktan yüksek olanı kullanır. Her iki moddaki eşitliklerde daha düşük 5 saatlik kullanım tercih edilir. `round-robin` bu ayarı yok sayar. Sağlıklı affinity oturumları önceden yeniden dengelenmez; 429 yük devretmesi dahil terminal hata kurtarması, mevcut soğuma ve yük devretme sınırlarını değiştirmeden uygun yedek hesapları bu pencereye göre sıralar. Hesap başına haftalık çubuklar ancak dashboard Sağlayıcılar sayfasında sorgulandıktan sonra bilinir. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Bir round-robin seçiminde tutulan başarılı yeni oturum bağlamaları. Aralık 1–100. | 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 77c1fdcf86..bb4c827260 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 @@ -158,7 +158,7 @@ affinity。这些策略不能规避 provider enforcement。 | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | 启用粘性亲和性和 429 冷却故障转移。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 对于新会话,当活动账户达到此阈值时,选择配置窗口中已知缓存使用率最低的账户。`0` 会禁用配额选择。 | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新会话策略;quota 读取 `quotaWindow` 指定的窗口,默认是 5 小时条形数据。 | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新会话策略;`quota` 按 `quotaWindow` 指定的窗口(默认是 5 小时条形数据)对账户排序,`fill-first` 也在同一窗口中判定其耗尽阈值。 | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 基于用量选择账户时用于评分的缓存用量条。`five-hour` 保持原有行为。`weekly` 使用每周用量条,但仍会跳过 5 小时用量已耗尽的账户。`max-utilization` 使用两条用量中较高的值。两种模式分数相同时,都优先选择 5 小时用量较低的账户。`round-robin` 会忽略此设置。不会主动重新平衡健康且已建立亲和性的会话;遇到终止性错误进行恢复时(包括 429 故障转移),仍会按此窗口对可用替代账户排序,但不会改变现有冷却或故障转移上限。各账户的每周用量只有在控制面板的提供商页面完成查询后才可用。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 在一次轮询选择中保留的成功新会话绑定次数。范围 1–100。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index bf98b5fb40..2663cb3647 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -28,6 +28,8 @@ sticky session affinity 與 429 冷卻故障轉移。僅對**新**工作階段 - Affinity 是**程序本機**的(proxy 重啟後就會遺失)。 - **401/403** 憑證失敗會隔離該帳號(`needsReauth`),直到重新認證前都不會參與選擇。 - 如果每個合格帳號都在冷卻,proxy 會回傳 **429**(不是 401),並在已知時附上 `Retry-After`。 +- 復原(包括 429 容錯移轉)會使用 `quotaWindow` 為合格的替代帳號排序,且不改變現有的冷卻或 + 容錯移轉上限;`round-robin` 會忽略 `quotaWindow`。 請見 [Configuration](/zh-tw/reference/configuration/#anthropicaccountpool-experimental)。 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 cd0f54b5e3..b7c9eacbac 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 @@ -127,7 +127,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | 啟用 sticky 親和性與 429 冷卻容錯移轉。 | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | 對於新 session,當目前帳號達到此閾值時,選擇設定視窗中最低的已知快取用量。`0` 停用配額挑選。 | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新 session 策略;quota 讀取 `quotaWindow` 指定的視窗,預設為 5 小時列。 | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新 session 策略;`quota` 依 `quotaWindow` 指定的視窗(預設為 5 小時列)為帳號排序,`fill-first` 也在同一視窗中判定其排空閾值。 | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | 使用量型帳號選擇所評分的快取用量列。`five-hour` 保留原有行為。`weekly` 使用每週用量列,但仍會略過 5 小時用量已耗盡的帳號。`max-utilization` 使用兩個用量中較高的值。兩種模式分數相同時,都優先選擇 5 小時用量較低的帳號。`round-robin` 會忽略此設定。不會主動重新平衡健康且已有 affinity 的 session;遇到終止錯誤進行復原時(包括 429 容錯移轉),仍會按此視窗排序合格的替代帳號,但不改變現有冷卻或容錯移轉上限。每個帳號的每週用量只有在 dashboard 的供應商頁面完成查詢後才可得知。 | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | 在一次 round-robin 選擇上保留的成功新 session 綁定。範圍 1–100。 |