From 6dddafca2c68beb9db1295c3e4513d1aa3571b08 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 04:14:24 +0000 Subject: [PATCH 01/17] fix(anthropic): batch affinity snapshot writes --- src/oauth/anthropic-session-affinity-store.ts | 25 +++++++++++-------- structure/providers/anthropic-account-pool.md | 3 ++- .../anthropic-affinity-store.test.ts | 6 +++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/oauth/anthropic-session-affinity-store.ts b/src/oauth/anthropic-session-affinity-store.ts index 299d06d3249..bc3afba9a26 100644 --- a/src/oauth/anthropic-session-affinity-store.ts +++ b/src/oauth/anthropic-session-affinity-store.ts @@ -12,7 +12,7 @@ import type { AnthropicInstanceId } from "../providers/anthropic-instance-id"; export const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000; export const MAX_AFFINITY_ENTRIES = 2_000; export const MAX_AFFINITY_FILE_BYTES = 1_048_576; -const TOUCH_DELAY_MS = 1_000; +const WRITE_DELAY_MS = 1_000; type Entry = { accountId: string; lastUsedAt: number; loginFence?: string }; type AccountSnapshot = ReturnType; type Fences = ReadonlyMap; @@ -79,16 +79,22 @@ export class AnthropicSessionAffinityStore implements Iterable<[string, Entry]> } private trim(scope: Scope): void { if (scope.rows.size <= MAX_AFFINITY_ENTRIES) return; - const ordered = [...scope.rows].sort((a, b) => b[1].lastUsedAt - a[1].lastUsedAt); - for (const [key] of ordered.slice(MAX_AFFINITY_ENTRIES)) scope.rows.delete(key); + if (scope.rows.size > MAX_AFFINITY_ENTRIES + 1) { + const ordered = [...scope.rows].sort((a, b) => b[1].lastUsedAt - a[1].lastUsedAt); + for (const [key] of ordered.slice(MAX_AFFINITY_ENTRIES)) scope.rows.delete(key); + return; + } + let oldest: [string, Entry] | undefined; + for (const row of scope.rows) { + if (!oldest || row[1].lastUsedAt <= oldest[1].lastUsedAt) oldest = row; + } + if (oldest) scope.rows.delete(oldest[0]); } - private changed(scope: Scope, touch = false): void { + private changed(scope: Scope): void { scope.dirty = true; scope.revision++; - if (touch) { - scope.timer ??= setTimeout(() => { scope.timer = undefined; void this.flushScope(scope); }, TOUCH_DELAY_MS); - scope.timer.unref(); - } else { void this.flushScope(scope); } + scope.timer ??= setTimeout(() => { scope.timer = undefined; void this.flushScope(scope); }, WRITE_DELAY_MS); + scope.timer.unref(); } private flushSync(scope: Scope): void { if (!scope.active || !scope.dirty || this.scope !== scope) return; @@ -142,11 +148,10 @@ export class AnthropicSessionAffinityStore implements Iterable<[string, Entry]> const fences = this.fences(snapshot); const scope = this.current(fences); if (!fences.has(value.accountId)) { this.delete(key); return; } - const previous = scope.rows.get(key); const loginFence = fences.get(value.accountId); scope.rows.set(key, { accountId: value.accountId, lastUsedAt: value.lastUsedAt, loginFence }); this.trim(scope); - this.changed(scope, previous?.accountId === value.accountId && previous.loginFence === loginFence); + this.changed(scope); } /** Roster mutations validate every binding once, against one captured auth generation. */ invalidate(snapshot: AccountSnapshot = getAccountSet(this.instance), keep: (accountId: string) => boolean = () => true): number { diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 0770a80b59d..bc4a0231f06 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -498,7 +498,8 @@ touched row; ordinary publication does not reread auth or revalidate every bindi ACL settlement barrier and private no-follow atomic publisher; bounded hydration rejects final-entry symlinks, junctions and non-regular files with identifier-free warnings. Aliases above the config root remain supported. Memory and pending writes belong to the resolved config home; reset or an observed home change retires that scope. -Timestamp-only updates coalesce for at most one second, with a best-effort shutdown flush. Failures emit identifier-free warnings +All mutations coalesce behind a one-second publication timer so caller-selected session keys cannot trigger synchronous +snapshot writes; explicit and best-effort shutdown flushes publish immediately. Failures emit identifier-free warnings and preserve the previous snapshot. `tests/adapters/anthropic/anthropic-affinity-restart.test.ts` and `tests/adapters/anthropic/anthropic-affinity-store.test.ts` exercise routing and persistence boundaries. diff --git a/tests/adapters/anthropic/anthropic-affinity-store.test.ts b/tests/adapters/anthropic/anthropic-affinity-store.test.ts index ead6f20538d..62dd653e376 100644 --- a/tests/adapters/anthropic/anthropic-affinity-store.test.ts +++ b/tests/adapters/anthropic/anthropic-affinity-store.test.ts @@ -44,12 +44,14 @@ test("snapshot contains only digests, opaque account and timestamp", async () => store.resetMemory(); expect(store.get(key)?.accountId).toBe(id); }); test("back-to-back structural mutations are both persisted and raw keys are refused", async () => { - const store = owner(); const other = anthropicAffinityDigest("anthropic", "second"); + let writes = 0; const store = owner("anthropic", (...args) => { writes++; atomicWriteFile(...args); }); + const other = anthropicAffinityDigest("anthropic", "second"); store.set(key, { accountId: id, lastUsedAt: Date.now() }); store.set(other, { accountId: id, lastUsedAt: Date.now() }); store.set(secret, { accountId: id, lastUsedAt: Date.now() }); + expect(writes).toBe(0); await store.flush(); store.resetMemory(); - expect(store.size).toBe(2); expect(store.get(other)?.accountId).toBe(id); + expect(writes).toBe(1); expect(store.size).toBe(2); expect(store.get(other)?.accountId).toBe(id); expect(readFileSync(path(), "utf8")).not.toContain(secret); }); test("load rejects expired and future rows and keeps the newest 2000", async () => { From fd47f61249b4996ac38690fa97d450dfa2b9dc6d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 21:04:01 +0900 Subject: [PATCH 02/17] fix(anthropic): confirm manual affinity reset publication --- src/oauth/anthropic-routing.ts | 18 +++++- src/oauth/anthropic-session-affinity-store.ts | 8 +++ src/server/management/oauth-account-routes.ts | 30 +++++++--- structure/gui-and-management-api.md | 12 ++-- structure/providers/anthropic-account-pool.md | 12 ++-- .../anthropic-affinity-restart.test.ts | 55 ++++++++++++++++++- .../anthropic-affinity-store.test.ts | 46 ++++++++++++++++ 7 files changed, 162 insertions(+), 19 deletions(-) diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 4fe1120d905..be88d99e632 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -349,6 +349,7 @@ type RoutingFacadeSymbol = | "promoteAnthropicActiveAccount" | "commitAnthropicSelectionRouting" | "resetAnthropicRoutingForManualSelection" + | "flushAnthropicManualSelectionReset" | "getAnthropicPoolAccessToken" | "getAnthropicPoolAccessSnapshot" | "canRefreshAnthropicPoolAccount"; @@ -372,6 +373,7 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting // The startup baseline comes from the authoritative store, never a second persisted pin. let manualPreference: OAuthAccountSelection | null | undefined; let manualSelectionGeneration = 0; + let manualResetPending = false; /** Automatic pointer moves preserve affinity; manual selection revokes older bindings. */ function captureAnthropicManualSelectionGeneration(): number { return manualSelectionGeneration; } @@ -530,6 +532,7 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting sessionAffinity.resetMemory(); manualPreference = undefined; manualSelectionGeneration++; + manualResetPending = false; quorumCache = null; } @@ -1321,6 +1324,7 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting function resetAnthropicRoutingForManualSelection(accountId: string): void { sessionAffinity.clear(); manualSelectionGeneration++; + manualResetPending = true; manualPreference = captureOAuthAccountSelection(PROVIDER); seedPoolRotationAccount(poolKey, accountId); // A manual account selection is an operator statement about the roster; do not answer the @@ -1328,6 +1332,14 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting quorumCache = null; } + async function flushAnthropicManualSelectionReset(): Promise { + if (!manualResetPending) return true; + const generation = manualSelectionGeneration; + const persisted = await sessionAffinity.flushConfirmed(); + if (persisted && generation === manualSelectionGeneration) manualResetPending = false; + return persisted && generation === manualSelectionGeneration; + } + /** * Resolve a bearer for pool traffic without adopting a newer global Claude CLI * credential into a background multiauth `local-cli` slot (same fail-closed rule @@ -1439,7 +1451,7 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting resolveAnthropicAccountForSession, resolveAnthropicDispatchAccountId, bindAnthropicSessionAffinity, clearAnthropicSessionAffinityForAccount, rotateAnthropicAccountOn429, rotateAnthropicAccountOnRefusal, recordAnthropicAccount429, recordAnthropicAccountRefusal, promoteAnthropicActiveAccount, - commitAnthropicSelectionRouting, resetAnthropicRoutingForManualSelection, getAnthropicPoolAccessToken, + commitAnthropicSelectionRouting, resetAnthropicRoutingForManualSelection, flushAnthropicManualSelectionReset, getAnthropicPoolAccessToken, getAnthropicPoolAccessSnapshot, canRefreshAnthropicPoolAccount, }); } @@ -1669,6 +1681,10 @@ export function resetAnthropicRoutingForManualSelection(accountId: string): void return anthropicRoutingFor("anthropic").resetAnthropicRoutingForManualSelection(accountId); } +export function flushAnthropicManualSelectionReset(): Promise { + return anthropicRoutingFor("anthropic").flushAnthropicManualSelectionReset(); +} + export function getAnthropicPoolAccessToken(accountId: string): Promise { return anthropicRoutingFor("anthropic").getAnthropicPoolAccessToken(accountId); } diff --git a/src/oauth/anthropic-session-affinity-store.ts b/src/oauth/anthropic-session-affinity-store.ts index bc3afba9a26..92a23847f8a 100644 --- a/src/oauth/anthropic-session-affinity-store.ts +++ b/src/oauth/anthropic-session-affinity-store.ts @@ -109,6 +109,14 @@ export class AnthropicSessionAffinityStore implements Iterable<[string, Entry]> const scope = this.scope; if (scope) await this.flushScope(scope); } + /** Operator acknowledgements require publication, not only completion of an attempt. */ + async flushConfirmed(): Promise { + const scope = this.scope; + if (!scope) return true; + await this.flushScope(scope); + if (scope.pending) await scope.pending; + return this.scope === scope && scope.active && !scope.dirty && getConfigDir() === scope.home; + } private async flushScope(scope: Scope): Promise { if (!scope?.active) return; if (getConfigDir() !== scope.home) { if (this.scope === scope) this.resetMemory(); return; } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 16a1bb8419d..7c60a470d94 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -595,15 +595,20 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // side carries resetCodexRoutingForManualSelection for. const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel"); seedPoolRotationAccount(genericPoolKey(provider), body.accountId); - if (isAnthropicOAuthInstance(provider)) { - const { anthropicRoutingFor } = await import("../../oauth/anthropic-routing"); - anthropicRoutingFor(provider).resetAnthropicRoutingForManualSelection(body.accountId); - } + const anthropicRouting = isAnthropicOAuthInstance(provider) + ? (await import("../../oauth/anthropic-routing")).anthropicRoutingFor(provider) : undefined; + anthropicRouting?.resetAnthropicRoutingForManualSelection(body.accountId); + const affinityReset = anthropicRouting?.flushAnthropicManualSelectionReset(); const { clearModelCache } = await import("../../codex/model-cache"); const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog"); clearModelCache(provider); clearGatherRoutedModelsInflight(); clearQuotaCachesAfterAccountMutation(provider, false); + if (affinityReset && !await affinityReset) { + return jsonResponse({ error: "Account selection was saved, but its session-affinity reset was not persisted. Retry the selection.", + code: "affinity_reset_not_persisted", provider, activeAccountId: body.accountId, + selectionCommitted: true, retryable: true }, 503); + } return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); } @@ -629,10 +634,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const result = await setAccountPaused(provider, body.accountId, body.paused); if (result.status === "not-found") return jsonResponse({ error: "account not found" }, 404); + const anthropicRouting = isAnthropicOAuthInstance(provider) + ? (await import("../../oauth/anthropic-routing")).anthropicRoutingFor(provider) : undefined; + let affinityReset: Promise | undefined; if (result.activeAccountChanged) { - if (isAnthropicOAuthInstance(provider)) { - const { anthropicRoutingFor } = await import("../../oauth/anthropic-routing"); - anthropicRoutingFor(provider).resetAnthropicRoutingForManualSelection(result.activeAccountId); + if (anthropicRouting) { + anthropicRouting.resetAnthropicRoutingForManualSelection(result.activeAccountId); + affinityReset = anthropicRouting.flushAnthropicManualSelectionReset(); } else { const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel"); seedPoolRotationAccount(genericPoolKey(provider), result.activeAccountId); @@ -644,6 +652,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearQuotaCachesAfterAccountMutation(provider, false); } + // An unchanged retry must finish a reset left pending by the first request. + affinityReset ??= anthropicRouting?.flushAnthropicManualSelectionReset(); + if (affinityReset && !await affinityReset) { + return jsonResponse({ error: "Account pause was saved, but its session-affinity reset was not persisted. Retry the pause request.", + code: "affinity_reset_not_persisted", provider, accountId: body.accountId, paused: body.paused, + activeAccountId: result.activeAccountId, activeAccountChanged: result.activeAccountChanged, + pauseCommitted: true, retryable: true }, 503); + } return jsonResponse({ ok: true, provider, diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 898df8af8d1..1057d412cf9 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -388,12 +388,12 @@ User aliases are display metadata only. Codex pool aliases live on `CodexAccount identity, active selection, and routing never consult these fields. The matching CLI is `ocx account alias ` (`rename` is accepted as a synonym). -OAuth manual and automatic selection share `commitOAuthAccountSelection` in the auth store. -The caller resolves a usable credential, commits its matching selection, then dispatches it; -request-local token replacement must not leave a different dashboard account selected. -Opaque selection revisions protect manual reselection and A→B→A changes from older requests. -Credential-only refresh preserves the revision. Generic proactive routing is opt-in and retains -a healthy selected account; reactive 429 recovery remains available even when the pool is off. +OAuth manual and automatic selection share `commitOAuthAccountSelection`; dispatch uses the credential matching the persisted selection. +Opaque revisions protect manual reselection and A→B→A changes; credential-only refresh preserves the revision. +Anthropic manual selection and pause-induced selection changes also await confirmed affinity-reset publication before success. +A failure returns 503 `affinity_reset_not_persisted`, `retryable: true` and `selectionCommitted` or `pauseCommitted`; the account mutation stays applied. +An unchanged pause retry completes a pending reset. Ordinary request-driven affinity writes remain batched. +Generic proactive routing is opt-in and retains a healthy selected account; reactive 429 recovery remains available even when the pool is off. API-key manual selection and failover similarly share `commitProviderApiKeySelection`, carrying stable entry identity and selection revision instead of comparing a resolved secret with an env reference. diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index bc4a0231f06..a630ae7d41b 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -486,7 +486,7 @@ session digest, opaque account ID, last-use timestamp and hashed persistent logi without `loginId` retain runtime-only affinity. Token refresh preserves the fence; replacement login, removal/re-add, pause and reauthentication invalidate it. Invalid, oversized, future and expired rows are ignored; the newest 2,000 entries survive, with a 24-hour idle lifetime. A restored binding precedes the startup active -preference; an explicit runtime selection clears bindings and persists the reset. +preference; an acknowledged management selection clears bindings and persists the reset. Temporary throttle and model-route/family detours retain a valid original binding and refresh its idle timestamp. Commit preserves a binding excluded by the route, an active admission pause or family rejection, or a transient @@ -498,9 +498,13 @@ touched row; ordinary publication does not reread auth or revalidate every bindi ACL settlement barrier and private no-follow atomic publisher; bounded hydration rejects final-entry symlinks, junctions and non-regular files with identifier-free warnings. Aliases above the config root remain supported. Memory and pending writes belong to the resolved config home; reset or an observed home change retires that scope. -All mutations coalesce behind a one-second publication timer so caller-selected session keys cannot trigger synchronous -snapshot writes; explicit and best-effort shutdown flushes publish immediately. Failures emit identifier-free warnings -and preserve the previous snapshot. `tests/adapters/anthropic/anthropic-affinity-restart.test.ts` and +Ordinary routing mutations coalesce behind a one-second publication timer so caller-selected session keys cannot trigger +synchronous snapshot writes. Manual management selection and pause-induced selection changes explicitly await confirmed +reset publication before reporting success. A failed publication leaves the account mutation committed and returns +503 `affinity_reset_not_persisted` with `selectionCommitted` or `pauseCommitted` and `retryable: true`; repeating the +request finishes the pending reset, including an unchanged pause retry. The synchronous routing-reset API is preserved. +Explicit best-effort and shutdown flushes remain distinct from confirmed operator publication. Failures emit identifier-free +warnings and preserve the previous snapshot. `tests/adapters/anthropic/anthropic-affinity-restart.test.ts` and `tests/adapters/anthropic/anthropic-affinity-store.test.ts` exercise routing and persistence boundaries. ## JEV advisory publication diff --git a/tests/adapters/anthropic/anthropic-affinity-restart.test.ts b/tests/adapters/anthropic/anthropic-affinity-restart.test.ts index 89744c72920..60c66fafa10 100644 --- a/tests/adapters/anthropic/anthropic-affinity-restart.test.ts +++ b/tests/adapters/anthropic/anthropic-affinity-restart.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test, spyOn } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; @@ -15,6 +15,16 @@ let clock: ReturnType | undefined; const config = { providers: { anthropic: { adapter: "anthropic", authMode: "oauth" } }, anthropicAccountPool: { enabled: true, autoSwitchThreshold: 0 } } as unknown as OcxConfig; const session = "private-session-L7-never-persist-raw"; +async function accountMutation(path: "active" | "pause", body: Record) { + const { handleOauthAccountRoutes } = await import("../../../src/server/management/oauth-account-routes"); + const req = new Request(`http://127.0.0.1:10100/api/oauth/accounts/${path}`, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + return handleOauthAccountRoutes({ + req, url: new URL(req.url), config, deps: {}, version: "test", principal: "gui-session", + syncClaudeAgentDefsBestEffort: async () => {}, + }); +} async function flush() { // This owner exists with the implementation; keeping the regression runnable on dev // proves the behavioral failure rather than a missing-export failure. @@ -67,6 +77,49 @@ test("runtime manual selection overrides binding", async () => { resetAnthropicRoutingForManualSelection(b); expect(resolveAnthropicAccountForSession(session, config).accountId).toBe(b); }); + +test("manual selection success survives restart without a shutdown flush", async () => { + const { b } = await seed(); + await flush(); + const response = await accountMutation("active", { provider: "anthropic", accountId: b }); + expect(response?.status).toBe(200); + expect(await response!.json()).toMatchObject({ ok: true, activeAccountId: b }); + // Drop process-local state immediately after the success response. No explicit + // flush or shutdown hook may repair disk state before hydration is checked. + clearAnthropicAccountPoolState(); + expect(resolveAnthropicAccountForSession(session, config).accountId).toBe(b); +}, 20_000); + +for (const action of ["active", "pause"] as const) { + test(`${action} reports an unpersisted reset and an unchanged retry completes it`, async () => { + const { a, b } = await seed(); + await flush(); + const { AnthropicSessionAffinityStore, anthropicAffinityPath } = await import("../../../src/oauth/anthropic-session-affinity-store"); + const original = AnthropicSessionAffinityStore.prototype.flushConfirmed; + let attempts = 0; + const confirmation = spyOn(AnthropicSessionAffinityStore.prototype, "flushConfirmed") + .mockImplementation(function (this: InstanceType) { + return ++attempts === 1 ? Promise.resolve(false) : original.call(this); + }); + const body = action === "active" ? { provider: "anthropic", accountId: b } + : { provider: "anthropic", accountId: a, paused: true }; + try { + const failed = await accountMutation(action, body); + expect(failed?.status).toBe(503); + expect(await failed!.json()).toMatchObject({ code: "affinity_reset_not_persisted", activeAccountId: b, + retryable: true, ...(action === "active" ? { selectionCommitted: true } : { pauseCommitted: true }) }); + expect(getAccountSet("anthropic")?.activeAccountId).toBe(b); + const retry = await accountMutation(action, body); + expect(retry?.status).toBe(200); + const completed = await retry!.json(); + if (action === "pause") expect(completed).toMatchObject({ activeAccountChanged: false, paused: true }); + expect(attempts).toBe(2); + expect(JSON.parse(readFileSync(anthropicAffinityPath(home, "anthropic"), "utf8")).entries).toEqual([]); + clearAnthropicAccountPoolState(); + expect(resolveAnthropicAccountForSession(session, config).accountId).toBe(b); + } finally { confirmation.mockRestore(); } + }, 20_000); +} test("normal transient detour returns to the original and refreshes idle lifetime", async () => { const { a, b } = await seed(); let now = Date.now(); clock = spyOn(Date, "now").mockImplementation(() => now); diff --git a/tests/adapters/anthropic/anthropic-affinity-store.test.ts b/tests/adapters/anthropic/anthropic-affinity-store.test.ts index 62dd653e376..708a44f4c79 100644 --- a/tests/adapters/anthropic/anthropic-affinity-store.test.ts +++ b/tests/adapters/anthropic/anthropic-affinity-store.test.ts @@ -54,6 +54,33 @@ test("back-to-back structural mutations are both persisted and raw keys are refu expect(writes).toBe(1); expect(store.size).toBe(2); expect(store.get(other)?.accountId).toBe(id); expect(readFileSync(path(), "utf8")).not.toContain(secret); }); +test("the publication timer writes the latest mutations without an explicit flush", async () => { + let writes = 0; + const store = owner("anthropic", (...args) => { writes++; atomicWriteFile(...args); }); + const other = anthropicAffinityDigest("anthropic", "timer-second"); + store.set(key, { accountId: id, lastUsedAt: Date.now() }); + store.set(other, { accountId: id, lastUsedAt: Date.now() }); + store.delete(key); + expect(writes).toBe(0); + await new Promise(resolve => setTimeout(resolve, 1_100)); + expect((await settleBackgroundWork({ timeoutMs: 5_000 })).settled).toBe(true); + expect(writes).toBe(1); + expect(JSON.parse(readFileSync(path(), "utf8")).entries.map((row: { sessionDigest: string }) => row.sessionDigest)).toEqual([other]); +}, 10_000); + +test("capacity ties preserve stable ordering and evict the newest equal-timestamp binding", async () => { + const { store, data } = await snapshot(); + const now = Date.now(); + data.entries = Array.from({ length: 2_000 }, (_, i) => ({ ...data.entries[0], + sessionDigest: i.toString(16).padStart(64, "0"), lastUsedAt: now })); + writeFileSync(path(), JSON.stringify(data)); store.resetMemory(); + const newest = "f".repeat(64); + store.set(newest, { accountId: id, lastUsedAt: now }); + expect(store.size).toBe(2_000); + expect(store.get(newest)).toBeUndefined(); + expect(store.get("0".repeat(64))?.accountId).toBe(id); +}); + test("load rejects expired and future rows and keeps the newest 2000", async () => { const { store, data } = await snapshot(); const now = Date.now(); const base = data.entries[0]; data.entries = Array.from({ length: 2002 }, (_, i) => ({ ...base, @@ -108,6 +135,25 @@ test("failed atomic publication retains previous bytes and reports no identifier store.resetMemory(); } finally { warning.mockRestore(); } }); +test("confirmed flush reports a failed reset and succeeds on an explicit retry", async () => { + await snapshot(); + const before = readFileSync(path(), "utf8"); + let fail = true; + const store = owner("anthropic", (...args) => { + if (fail) throw new Error("fixture publication failure"); + atomicWriteFileNoFollow(...args); + }); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + store.clear(); + expect(await store.flushConfirmed()).toBe(false); + expect(readFileSync(path(), "utf8")).toBe(before); + fail = false; + expect(await store.flushConfirmed()).toBe(true); + expect(JSON.parse(readFileSync(path(), "utf8")).entries).toEqual([]); + expect(JSON.stringify(warning.mock.calls)).not.toContain(secret); + } finally { store.resetMemory(); warning.mockRestore(); } +}); test("timestamp touches coalesce and shutdown flushes the latest use", async () => { let writes = 0; const store = owner("anthropic", (...args) => { writes++; atomicWriteFile(...args); }); const now = Date.now(); store.set(key, { accountId: id, lastUsedAt: now - 100 }); await store.flush(); From 54a9f1febc9e8b73ad24ea78406c86ef433c8033 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 21:22:03 +0900 Subject: [PATCH 03/17] fix(gui): retry committed affinity resets without undoing account changes --- .../docs/fr/reference/management-api.md | 4 +- .../docs/ja/reference/management-api.md | 4 +- .../docs/ko/reference/management-api.md | 4 +- .../content/docs/reference/management-api.md | 4 +- .../docs/ru/reference/management-api.md | 4 +- .../docs/tr/reference/management-api.md | 4 +- .../docs/zh-cn/reference/management-api.md | 4 +- .../docs/zh-tw/reference/management-api.md | 4 +- .../provider-workspace/ProviderAuthPanel.tsx | 8 +++ .../components/provider-workspace/types.ts | 2 + gui/src/hooks/useProviderAccountPools.ts | 56 ++++++++++++++++--- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Providers.tsx | 2 + gui/tests/auth-panel-refresh-render.test.tsx | 16 +++++- .../provider-account-pause-refresh.test.tsx | 31 ++++++++++ structure/gui-and-management-api.md | 2 +- 26 files changed, 133 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 25f81a99073..a297d4ca85d 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -279,11 +279,11 @@ Tant qu’une liste initiale fiable n’est pas disponible, les requêtes PUT va | `GET /api/oauth/status` | Sonder le flux OAuth d'un fournisseur | 400 fournisseur inconnu | | `POST /api/oauth/logout` | Supprimer les informations d'identification du fournisseur sélectionné | 400 fournisseur inconnu ; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | Répertorier les comptes masqués ou supprimer un compte Les lignes Kiro ajoutent `autoSelectable` et un `skipReason` fermé en cas d’exclusion de la sélection automatique ; un compte actif unique peut encore servir. Le quota reste facultatif. | 400 invalide provider/id ; 404 compte manquant ; `oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | Sélectionnez le compte OAuth actif | 400 invalide provider/account ; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | Sélectionnez le compte OAuth actif | 400 invalide provider/account ; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `selectionCommitted: true`, `retryable: true`; sélection enregistrée ; renvoyer la même requête même si le compte est actif) | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Lire ou mettre à jour la stratégie du pool OAuth Anthropic | 400 fournisseur non Anthropic ou stratégie invalide | | `POST /api/oauth/accounts/clear-cooldown` | Effacer le temps de recharge d'un compte OAuth | 400 invalide provider/account | | `PUT /api/oauth/accounts/alias` | Définir ou supprimer un alias de compte OAuth | 400 invalide provider/account/alias | -| `PUT /api/oauth/accounts/pause` | Suspendre/reprendre Anthropic ou un compte OAuth générique. Body `{ provider, accountId, paused }` ; la suspension du compte actif sélectionne un autre compte utilisable s’il existe. | 400 fournisseur non pris en charge ou body invalide ; 404 compte absent ; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/pause` | Suspendre/reprendre Anthropic ou un compte OAuth générique. Body `{ provider, accountId, paused }` ; la suspension du compte actif sélectionne un autre compte utilisable s’il existe. | 400 fournisseur non pris en charge ou body invalide ; 404 compte absent ; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `pauseCommitted: true`, `retryable: true`; état de suspension enregistré ; renvoyer la même requête sans inverser cet état) | | `GET, POST, DELETE /api/providers/keys` | Répertorier les clés de fournisseur masquées, en ajouter ou en activer une, ou en supprimer une | 400 saisie invalide ; 404 fournisseur ou clé manquante | | `PUT /api/providers/keys/active` | Sélectionnez la clé active d'un fournisseur | 400 saisie invalide ; 404 provider/key manquant | | `PUT /api/providers/keys/alias` | Définir ou supprimer un alias de clé de fournisseur | 400 saisie invalide ; 404 provider/key manquant | diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 86e5f8c1831..1d80a907b9e 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -247,11 +247,11 @@ Aside プロファイルの変更はこの場合でも一つだけ保存しま | `GET /api/oauth/status` | 1 つのプロバイダーの OAuth フローをポーリングする | 400 不明なプロバイダー | | `POST /api/oauth/logout` |選択したプロバイダー資格情報を削除します | 400 不明なプロバイダー。 `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | マスクされたアカウントを一覧表示するか、アカウントを 1 つ削除する Kiro の行には自動選択の `autoSelectable` と、除外時には閉じた集合の `skipReason` が含まれます。アクティブな単一アカウントは送信を続けられ、クォータ取得は任意です。 | 400 無効なプロバイダー/ID。 404 アカウントがありません。 `oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` |アクティブな OAuth アカウントを選択します | 400 無効なプロバイダー/アカウント。 `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` |アクティブな OAuth アカウントを選択します | 400 無効なプロバイダー/アカウント。 `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `selectionCommitted: true`, `retryable: true`; アカウント選択は保存済み。すでに有効でも同じリクエストを再送信) | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic OAuth プール ポリシーの読み取りまたは更新 | 400 非 Anthropic プロバイダーまたは無効なポリシー | | `POST /api/oauth/accounts/clear-cooldown` | 1 つの OAuth アカウントのランタイム クールダウンをクリアする | 400 無効なプロバイダー/アカウント | | `PUT /api/oauth/accounts/alias` | OAuth アカウント エイリアスを設定またはクリアする | 400 無効なプロバイダー/アカウント/エイリアス | -| `PUT /api/oauth/accounts/pause` | Anthropic または汎用 OAuth アカウントを一時停止・再開。Body `{ provider, accountId, paused }`。アクティブなアカウントを停止すると、利用可能な別のアカウントがあれば切り替えます。 | 400 未対応のプロバイダーまたは無効な body;404 アカウントなし;`oauth_mutation_busy` | +| `PUT /api/oauth/accounts/pause` | Anthropic または汎用 OAuth アカウントを一時停止・再開。Body `{ provider, accountId, paused }`。アクティブなアカウントを停止すると、利用可能な別のアカウントがあれば切り替えます。 | 400 未対応のプロバイダーまたは無効な body;404 アカウントなし;`oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `pauseCommitted: true`, `retryable: true`; 一時停止状態は保存済み。状態を反転せず同じリクエストを再送信) | | `GET, POST, DELETE /api/providers/keys` |マスクされたプロバイダー キーを一覧表示し、1 つを追加/アクティブ化するか、1 つを削除します。 400 無効な入力。 404 プロバイダー/キーがありません | | `PUT /api/providers/keys/active` |プロバイダーのアクティブなキーを選択します | 400 無効な入力。 404 プロバイダー/キーがありません | | `PUT /api/providers/keys/alias` |プロバイダー キー エイリアスを設定またはクリアする | 400 無効な入力。 404 プロバイダー/キーがありません | diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index ae9420f8118..e96dfa7d0b3 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -255,12 +255,12 @@ Aside 프로필 변경은 이때도 한 가지를 저장합니다. 확인을 보 | `GET /api/oauth/status` | 하나의 provider OAuth 흐름을 조회합니다 | 400 알 수 없는 provider | | `POST /api/oauth/logout` | 선택된 provider 자격 증명을 제거합니다 | 400 알 수 없는 provider; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | 마스킹된 계정을 나열하거나 계정 하나를 제거합니다 Kiro 행에는 자동 선택 가능 여부인 `autoSelectable`과 제외 시 닫힌 집합의 `skipReason`이 포함됩니다. 활성 단일 계정은 여전히 요청을 보낼 수 있고 quota 조회는 선택 사항입니다. | 400 잘못된 provider/id; 404 계정 없음; `oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | 활성 OAuth 계정을 선택합니다 | 400 잘못된 provider/account; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | 활성 OAuth 계정을 선택합니다 | 400 잘못된 provider/account; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `selectionCommitted: true`, `retryable: true`; 계정 선택은 저장됨; 이미 활성 상태여도 동일 요청을 재전송) | | `GET, PUT, PATCH /api/pool/settings` | 모든 pool 종류(codex, anthropic, generic)의 policy를 읽거나 업데이트합니다. 세 종류 모두 같은 키로 응답하고, 해당 종류가 실제로 적용하는 필드는 `supported`에 나옵니다 | 400 알 수 없는 provider, 해당 종류가 지원하지 않는 필드, 잘못된 값 | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic과 일반 OAuth provider의 기존 pool policy입니다. `/api/pool/settings`로 대체되었고 기존 클라이언트를 위해 유지합니다 | 400 codex 또는 API 키 provider, 잘못된 policy | | `POST /api/oauth/accounts/clear-cooldown` | OAuth 계정 하나의 런타임 cooldown을 지웁니다 | 400 잘못된 provider/account | | `PUT /api/oauth/accounts/alias` | OAuth 계정 alias를 설정하거나 지웁니다 | 400 잘못된 provider/account/alias | -| `PUT /api/oauth/accounts/pause` | Anthropic 또는 일반 OAuth 계정을 정지·재개합니다. Body `{ provider, accountId, paused }`. 활성 계정을 정지하면 사용 가능한 다른 계정이 있을 때 전환합니다. | 400 지원하지 않는 provider 또는 잘못된 body; 404 계정 없음; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/pause` | Anthropic 또는 일반 OAuth 계정을 정지·재개합니다. Body `{ provider, accountId, paused }`. 활성 계정을 정지하면 사용 가능한 다른 계정이 있을 때 전환합니다. | 400 지원하지 않는 provider 또는 잘못된 body; 404 계정 없음; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `pauseCommitted: true`, `retryable: true`; 일시정지 상태는 저장됨; 상태를 반대로 바꾸지 말고 동일 요청을 재전송) | | `GET, POST, DELETE /api/providers/keys` | 마스킹된 provider key를 나열, 추가/활성화, 또는 제거합니다 | 400 잘못된 입력; 404 provider/key 없음 | | `PUT /api/providers/keys/active` | provider의 활성 key를 선택합니다 | 400 잘못된 입력; 404 provider/key 없음 | | `PUT /api/providers/keys/alias` | provider-key alias를 설정하거나 지웁니다 | 400 잘못된 입력; 404 provider/key 없음 | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 7da2cada27a..ebded8efc80 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -614,8 +614,8 @@ Labels are read on login/import or refresh, never inferred from quota percentage | `POST /api/oauth/logout` | Remove the selected provider credential | 400 unknown provider; `oauth_mutation_busy` | | `GET /api/oauth/accounts` | List masked accounts; Anthropic and generic OAuth account rows include their `paused` state. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in. | 400 invalid provider | | `DELETE /api/oauth/accounts` | Remove one account | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; 404 account missing; 409 account paused; `oauth_mutation_busy` | -| `PUT /api/oauth/accounts/pause` | Pause or resume one Anthropic or generic OAuth account. Body `{ provider, accountId, paused }`; pausing the active account selects the next usable account when available. Pause is durable and independent of pool enablement; resume preserves health and credentials. | 400 unsupported provider or invalid body; 404 account missing; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; 404 account missing; 409 account paused; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `selectionCommitted: true`, `retryable: true`; account change saved; resend the same request even if already active) | +| `PUT /api/oauth/accounts/pause` | Pause or resume one Anthropic or generic OAuth account. Body `{ provider, accountId, paused }`; pausing the active account selects the next usable account when available. Pause is durable and independent of pool enablement; resume preserves health and credentials. | 400 unsupported provider or invalid body; 404 account missing; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `pauseCommitted: true`, `retryable: true`; pause state saved; resend the identical request, do not reverse the pause) | | `GET, PUT, PATCH /api/pool/settings` | Read or update pool policy for any kind (codex, anthropic, generic); answers with the same keys for all three and declares in `supported` which the kind honours | 400 unknown provider, a field the kind does not support, or an invalid value | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Legacy per-pool policy for Anthropic and generic OAuth providers; superseded by `/api/pool/settings` and kept for existing clients | 400 codex or api-key provider, or invalid policy | | `POST /api/oauth/accounts/clear-cooldown` | Clear one OAuth account's runtime cooldown | 400 invalid provider/account | diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 3d59f47db89..afa65dc1c42 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -271,12 +271,12 @@ Endpoint'ы storage cleanup могут перемещать или навсег | `GET /api/oauth/status` | Опрашивать OAuth-flow одного провайдера | 400 unknown provider | | `POST /api/oauth/logout` | Удалить сохранённый credential выбранного провайдера | 400 unknown provider; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | Показать список masked-аккаунтов или удалить один аккаунт Строки Kiro содержат `autoSelectable` и закрытый `skipReason` при исключении из автоматического выбора; единственный активный аккаунт всё ещё может отправлять запросы. Чтение квоты остаётся необязательным. | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | Выбрать активный OAuth-аккаунт | 400 invalid provider/account; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | Выбрать активный OAuth-аккаунт | 400 invalid provider/account; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `selectionCommitted: true`, `retryable: true`; выбор сохранён; повторите тот же запрос, даже если аккаунт уже активен) | | `GET, PUT, PATCH /api/pool/settings` | Прочитать или обновить policy пула любого вида (codex, anthropic, generic); все три отвечают одинаковыми ключами, а поля, которые вид действительно применяет, перечислены в `supported` | 400 неизвестный provider, поле, которое вид не поддерживает, или недопустимое значение | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Прежняя policy пула для Anthropic и обычных OAuth-провайдеров; заменена на `/api/pool/settings` и сохранена для существующих клиентов | 400 codex или api-key provider, либо недопустимая policy | | `POST /api/oauth/accounts/clear-cooldown` | Очистить runtime cooldown одного OAuth-аккаунта | 400 invalid provider/account | | `PUT /api/oauth/accounts/alias` | Задать или очистить alias OAuth-аккаунта | 400 invalid provider/account/alias | -| `PUT /api/oauth/accounts/pause` | Приостановить/возобновить Anthropic или обычный OAuth-аккаунт. Body `{ provider, accountId, paused }`; при паузе активного аккаунта выбирается другой пригодный аккаунт, если он есть. | 400 неподдерживаемый provider или неверный body; 404 аккаунт не найден; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/pause` | Приостановить/возобновить Anthropic или обычный OAuth-аккаунт. Body `{ provider, accountId, paused }`; при паузе активного аккаунта выбирается другой пригодный аккаунт, если он есть. | 400 неподдерживаемый provider или неверный body; 404 аккаунт не найден; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `pauseCommitted: true`, `retryable: true`; состояние паузы сохранено; повторите тот же запрос, не меняя состояние на противоположное) | | `GET, POST, DELETE /api/providers/keys` | Показать список masked provider-key'ов, добавить/активировать один или удалить один | 400 invalid input; 404 provider/key missing | | `PUT /api/providers/keys/active` | Выбрать активный ключ провайдера | 400 invalid input; 404 provider/key missing | | `PUT /api/providers/keys/alias` | Задать или очистить alias provider-key'а | 400 invalid input; 404 provider/key missing | diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index 33824ff6c6c..1a2c52cddd4 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -296,11 +296,11 @@ Güvenilir ilk model listesi hazır olana kadar `/api/selected-models` ve `/api/ | `GET /api/oauth/status` | Bir sağlayıcının OAuth akışını yoklayın | 400 bilinmeyen sağlayıcı | | `POST /api/oauth/logout` | Seçilen sağlayıcı kimlik bilgisini kaldırın | 400 bilinmeyen sağlayıcı; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | Maskelenmiş hesapları listeleyin veya bir hesabı kaldırın Kiro satırları otomatik seçimden dışlandığında `autoSelectable` ve kapalı bir `skipReason` taşır; tek etkin hesap yine istek gönderebilir. Kota isteğe bağlıdır. | 400 geçersiz sağlayıcı/kimlik; 404 hesap eksik; `oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | Aktif OAuth hesabını seçin | 400 geçersiz sağlayıcı/hesap; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | Aktif OAuth hesabını seçin | 400 geçersiz sağlayıcı/hesap; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `selectionCommitted: true`, `retryable: true`; hesap seçimi kaydedildi; hesap etkin olsa bile aynı isteği yeniden gönderin) | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic OAuth havuz politikasını okuyun veya güncelleyin | 400 Anthropic olmayan sağlayıcı veya geçersiz politika | | `POST /api/oauth/accounts/clear-cooldown` | Bir OAuth hesabının çalışma zamanı soğuma süresini temizleyin | 400 geçersiz sağlayıcı/hesap | | `PUT /api/oauth/accounts/alias` | Bir OAuth hesap takma adını ayarlayın veya temizleyin | 400 geçersiz sağlayıcı/hesap/takma ad | -| `PUT /api/oauth/accounts/pause` | Anthropic veya genel OAuth hesabını duraklatın/sürdürün. Body `{ provider, accountId, paused }`; etkin hesap duraklatıldığında varsa kullanılabilir başka hesaba geçilir. | 400 desteklenmeyen sağlayıcı veya geçersiz body; 404 hesap yok; `oauth_mutation_busy` | +| `PUT /api/oauth/accounts/pause` | Anthropic veya genel OAuth hesabını duraklatın/sürdürün. Body `{ provider, accountId, paused }`; etkin hesap duraklatıldığında varsa kullanılabilir başka hesaba geçilir. | 400 desteklenmeyen sağlayıcı veya geçersiz body; 404 hesap yok; `oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `pauseCommitted: true`, `retryable: true`; duraklatma durumu kaydedildi; durumu tersine çevirmeden aynı isteği yeniden gönderin) | | `GET, POST, DELETE /api/providers/keys` | Maskelenmiş sağlayıcı anahtarlarını listeleyin, bir tane ekleyin/etkinleştirin veya kaldırın | 400 geçersiz girdi; 404 sağlayıcı/anahtar eksik | | `PUT /api/providers/keys/active` | Bir sağlayıcının etkin anahtarını seçin | 400 geçersiz girdi; 404 sağlayıcı/anahtar eksik | | `PUT /api/providers/keys/alias` | Bir sağlayıcı anahtarı takma adını ayarlayın veya temizleyin | 400 geçersiz girdi; 404 sağlayıcı/anahtar eksik | diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 3679c8c5390..92b39f33ac4 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -237,11 +237,11 @@ Aside 配置档的变更在这种情况下仍会保存一件事:确认之后 | `GET /api/oauth/status` | 轮询某个 provider 的 OAuth 流程 | 400 provider 未知 | | `POST /api/oauth/logout` | 移除选定的 provider 凭证 | 400 provider 未知;`oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | 列出已脱敏账户或移除一个账户 Kiro 行包含自动选择状态 `autoSelectable`,被排除时还包含封闭集合的 `skipReason`。唯一的活动账户仍可发送请求,配额查询仍为可选。 | 400 provider/id 无效;404 账户缺失;`oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | 选择当前活跃的 OAuth 账户 | 400 provider/账户无效;`oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | 选择当前活跃的 OAuth 账户 | 400 provider/账户无效;`oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `selectionCommitted: true`, `retryable: true`; 账户选择已保存;即使已激活,也请重新发送相同请求) | | `GET, PUT, PATCH /api/oauth/accounts/pool` | 读取或更新 Anthropic OAuth 池策略 | 400 非 Anthropic provider 或策略无效 | | `POST /api/oauth/accounts/clear-cooldown` | 清除一个 OAuth 账户的运行时冷却 | 400 provider/账户无效 | | `PUT /api/oauth/accounts/alias` | 设置或清除 OAuth 账户别名 | 400 provider/账户/别名无效 | -| `PUT /api/oauth/accounts/pause` | 暂停或恢复 Anthropic 或通用 OAuth 账户。Body `{ provider, accountId, paused }`;暂停活跃账户时,如有其他可用账户则切换过去。 | 400 不支持的 provider 或无效 body;404 账户不存在;`oauth_mutation_busy` | +| `PUT /api/oauth/accounts/pause` | 暂停或恢复 Anthropic 或通用 OAuth 账户。Body `{ provider, accountId, paused }`;暂停活跃账户时,如有其他可用账户则切换过去。 | 400 不支持的 provider 或无效 body;404 账户不存在;`oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `pauseCommitted: true`, `retryable: true`; 暂停状态已保存;请重新发送相同请求,不要反转暂停状态) | | `GET, POST, DELETE /api/providers/keys` | 列出已脱敏的 provider 密钥,添加/激活一个,或移除一个 | 400 输入无效;404 provider/密钥缺失 | | `PUT /api/providers/keys/active` | 选择某个 provider 的活跃密钥 | 400 输入无效;404 provider/密钥缺失 | | `PUT /api/providers/keys/alias` | 设置或清除 provider 密钥别名 | 400 输入无效;404 provider/密钥缺失 | diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index f4d13b561b4..638c0c96fbd 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -241,8 +241,8 @@ Aside 設定檔的變更在這種情況下仍會儲存一件事:確認之後 | `POST /api/oauth/logout` | 移除所選的供應商憑證 | 400 未知供應商;`oauth_mutation_busy` | | `GET /api/oauth/accounts` | 列出遮罩帳號;Anthropic 與通用 OAuth 帳號列也會提供 `paused` 狀態。Kiro 列包含自動選取狀態 `autoSelectable`,排除時還包含封閉集合的 `skipReason`。唯一的有效帳號仍可傳送請求,配額查詢仍為選用。 | 400 無效供應商 | | `DELETE /api/oauth/accounts` | 移除一個帳號 | 400 無效供應商/id;404 帳號缺失;`oauth_mutation_busy` | -| `PUT /api/oauth/accounts/active` | 選擇現用 OAuth 帳號 | 400 無效供應商/帳號;404 帳號缺失;409 帳號已暫停;`oauth_mutation_busy` | -| `PUT /api/oauth/accounts/pause` | 暫停或恢復一個 Anthropic 或通用 OAuth 帳號。Body `{ provider, accountId, paused }`;若暫停現用帳號,且有可用帳號,會切換至下一個。暫停會持久儲存且不受帳號池開關影響,恢復保留健康狀態與憑證。 | 400 不支援的供應商或無效 body;404 帳號缺失;`oauth_mutation_busy` | +| `PUT /api/oauth/accounts/active` | 選擇現用 OAuth 帳號 | 400 無效供應商/帳號;404 帳號缺失;409 帳號已暫停;`oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `selectionCommitted: true`, `retryable: true`; 帳戶選擇已儲存;即使已啟用,也請重新傳送相同請求) | +| `PUT /api/oauth/accounts/pause` | 暫停或恢復一個 Anthropic 或通用 OAuth 帳號。Body `{ provider, accountId, paused }`;若暫停現用帳號,且有可用帳號,會切換至下一個。暫停會持久儲存且不受帳號池開關影響,恢復保留健康狀態與憑證。 | 400 不支援的供應商或無效 body;404 帳號缺失;`oauth_mutation_busy`; 503 `affinity_reset_not_persisted` (Anthropic: `pauseCommitted: true`, `retryable: true`; 暫停狀態已儲存;請重新傳送相同請求,不要反轉暫停狀態) | | `GET, PUT, PATCH /api/oauth/accounts/pool` | 讀取或更新 Anthropic OAuth 池政策 | 400 非 Anthropic 供應商或無效政策 | | `POST /api/oauth/accounts/clear-cooldown` | 清除一個 OAuth 帳號的 runtime 冷卻 | 400 無效供應商/帳號 | | `PUT /api/oauth/accounts/alias` | 設定或清除 OAuth 帳號別名 | 400 無效供應商/帳號/別名 | diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index d6f6b0c833c..6540caf0949 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -535,6 +535,14 @@ export default function ProviderAuthPanel({ )} + {authHandlers.pendingAffinityResets?.[item.name] && ( +
+ {t("prov.affinityResetPending")} + {authHandlers.onRetryAffinityReset && } +
+ )} {accountLoadState === "loading" && accounts.length === 0 && (