diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e3b75d..9ec0318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Bug Fixes + +* auto-switch to another authenticated Claude account after usage-limit exhaustion in multi-account setups + ## [1.4.3](https://github.com/griffinmartin/opencode-claude-auth/compare/v1.4.2...v1.4.3) (2026-04-03) diff --git a/README.md b/README.md index 54ebbcf..9633050 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ Select "Switch Claude Code account" and pick the account you want to use. Your s If only one account is found, the switcher is hidden and the plugin uses it directly. +If the active Claude account hits a usage-limit style 429 and another Claude Code account is already authenticated locally, the plugin retries once with the next available account and persists that switch for future requests. + ## Troubleshooting | Problem | Solution | diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 14e4a59..ba707b0 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -1,6 +1,10 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" -import { refreshViaOAuth, parseOAuthResponse } from "./credentials.ts" +import { + refreshViaOAuth, + parseOAuthResponse, + isUsageExhaustionResponse, +} from "./credentials.ts" import { chmodSync, mkdirSync, statSync, writeFileSync } from "node:fs" import { mkdtemp, readFile, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" @@ -77,7 +81,7 @@ export function __getReadCount() { await writeFile( tempBetas, - `export function resetExcludedBetas() {}\n`, + `export function resetExcludedBetas() {}\nexport function isLongContextError() { return false }\n`, "utf8", ) await writeFile(tempCredentials, rewritten, "utf8") @@ -105,6 +109,94 @@ export function __getReadCount() { } } +interface MockAccount { + label: string + source: string + credentials: { + accessToken: string + refreshToken: string + expiresAt: number + } +} + +async function loadCredentialsWithMockAccounts( + initialAccounts: MockAccount[], +): Promise<{ + credentialsModule: { + initAccounts: (accounts: MockAccount[]) => void + setActiveAccountSource: (source: string) => void + findAlternativeAccount: (currentSource: string | null) => MockAccount | null + } + keychainModule: { + __getReadCount: () => number + } +}> { + const tempDir = await mkdtemp(join(tmpdir(), "opencode-claude-auth-failover-")) + const tempKeychain = join(tempDir, "keychain.ts") + const tempBetas = join(tempDir, "betas.ts") + const tempLogger = join(tempDir, "logger.ts") + const tempCredentials = join(tempDir, "credentials.ts") + const sourceCredentials = await readFile( + new URL("./credentials.ts", import.meta.url), + "utf8", + ) + const rewritten = sourceCredentials.replace( + /from\s+["']\.\/(\w+)\.js["']/g, + 'from "./$1.ts"', + ) + + await writeFile( + tempLogger, + `export function log() {}\nexport function initLogger() {}\nexport function closeLogger() {}\n`, + "utf8", + ) + + await writeFile( + tempKeychain, + `let readCount = 0 +let accounts = ${JSON.stringify(initialAccounts)} + +export function readAllClaudeAccounts() { + readCount += 1 + return accounts +} + +export function refreshAccount(source) { + readCount += 1 + return accounts.find((account) => account.source === source)?.credentials ?? null +} + +export function writeBackCredentials() { return true } + +export function __getReadCount() { + return readCount +} +`, + "utf8", + ) + + await writeFile( + tempBetas, + `export function resetExcludedBetas() {}\nexport function isLongContextError(responseBody) { return responseBody.includes("Extra usage is required for long context requests") || responseBody.includes("long context beta is not yet available") }\n`, + "utf8", + ) + await writeFile(tempCredentials, rewritten, "utf8") + + const [credentialsModule, keychainModule] = await Promise.all([ + import(pathToFileURL(tempCredentials).href), + import(pathToFileURL(tempKeychain).href), + ]) + + return { + credentialsModule: credentialsModule as { + initAccounts: (accounts: MockAccount[]) => void + setActiveAccountSource: (source: string) => void + findAlternativeAccount: (currentSource: string | null) => MockAccount | null + }, + keychainModule: keychainModule as { __getReadCount: () => number }, + } +} + describe("credential caching", () => { it("getCachedCredentials reuses cached credentials within 30 second TTL", async () => { const originalNow = Date.now @@ -300,7 +392,7 @@ export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account ) await writeFile( tempBetas, - `export function resetExcludedBetas() {}\n`, + `export function resetExcludedBetas() {}\nexport function isLongContextError() { return false }\n`, "utf8", ) await writeFile( @@ -384,7 +476,7 @@ export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account ) await writeFile( tempBetas, - `export function resetExcludedBetas() {}\n`, + `export function resetExcludedBetas() {}\nexport function isLongContextError() { return false }\n`, "utf8", ) await writeFile( @@ -424,6 +516,145 @@ describe("refreshViaOAuth", () => { }) }) +describe("isUsageExhaustionResponse", () => { + it("matches a 429 usage-limit response without retry-after", () => { + assert.equal( + isUsageExhaustionResponse( + 429, + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your account has reached its weekly usage limit.", + }, + }), + ), + true, + ) + }) + + it("rejects a generic rate-limit response", () => { + assert.equal( + isUsageExhaustionResponse( + 429, + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your account has hit a rate limit.", + }, + }), + ), + false, + ) + }) + + it("rejects responses with retry-after", () => { + assert.equal( + isUsageExhaustionResponse( + 429, + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your account has reached its weekly usage limit.", + }, + }), + "60", + ), + false, + ) + }) + + it("rejects long-context usage errors", () => { + assert.equal( + isUsageExhaustionResponse( + 429, + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Extra usage is required for long context requests", + }, + }), + ), + false, + ) + }) + + it("rejects broad billing-only phrasing", () => { + assert.equal( + isUsageExhaustionResponse( + 429, + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your billing profile needs attention.", + }, + }), + ), + false, + ) + }) +}) + +describe("findAlternativeAccount", () => { + it("returns null when only one account exists", async () => { + const singleAccount: MockAccount[] = [ + { + label: "Account 1", + source: "primary", + credentials: { + accessToken: "token-1", + refreshToken: "refresh-1", + expiresAt: Date.now() + 600_000, + }, + }, + ] + const { credentialsModule, keychainModule } = + await loadCredentialsWithMockAccounts(singleAccount) + + credentialsModule.initAccounts(singleAccount) + credentialsModule.setActiveAccountSource("primary") + + assert.equal(credentialsModule.findAlternativeAccount("primary"), null) + assert.equal(keychainModule.__getReadCount(), 1) + }) + + it("returns a different source after refreshing account discovery", async () => { + const accountFixtures: MockAccount[] = [ + { + label: "Account 1", + source: "primary", + credentials: { + accessToken: "token-1", + refreshToken: "refresh-1", + expiresAt: Date.now() + 600_000, + }, + }, + { + label: "Account 2", + source: "secondary", + credentials: { + accessToken: "token-2", + refreshToken: "refresh-2", + expiresAt: Date.now() + 600_000, + }, + }, + ] + const { credentialsModule, keychainModule } = + await loadCredentialsWithMockAccounts(accountFixtures) + + credentialsModule.initAccounts(accountFixtures) + credentialsModule.setActiveAccountSource("primary") + + const alternative = credentialsModule.findAlternativeAccount("primary") + + assert.ok(alternative) + if (!alternative) { + throw new Error("expected alternative account") + } + assert.equal(alternative.source, "secondary") + assert.equal(keychainModule.__getReadCount(), 1) + }) +}) + describe("parseOAuthResponse", () => { const now = 1_700_000_000_000 const currentRefresh = "sk-ant-ort01-current" diff --git a/src/credentials.ts b/src/credentials.ts index 31f8305..2b26ca5 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -15,7 +15,7 @@ import { type ClaudeCredentials, type ClaudeAccount, } from "./keychain.ts" -import { resetExcludedBetas } from "./betas.ts" +import { isLongContextError, resetExcludedBetas } from "./betas.ts" import { log } from "./logger.ts" export type { ClaudeCredentials } from "./keychain.ts" @@ -23,6 +23,16 @@ export type { ClaudeAccount } from "./keychain.ts" const CREDENTIAL_CACHE_TTL_MS = 30_000 +const USAGE_EXHAUSTION_MESSAGE_PATTERNS = [ + "usage limit", + "usage limits", + "weekly limit", + "session limit", + "credit balance", + "purchase more credits", + "exceeded your quota", +] + const accountCacheMap = new Map< string, { creds: ClaudeCredentials; cachedAt: number } @@ -38,6 +48,10 @@ export function getAccounts(): ClaudeAccount[] { return allAccounts } +export function getActiveAccountSource(): string | null { + return activeAccountSource +} + export function setActiveAccountSource(source: string): void { const previous = activeAccountSource activeAccountSource = source @@ -90,11 +104,38 @@ export function saveAccountSource(source: string): void { const dir = dirname(path) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) writeFileSync(path, source, "utf-8") - } catch { - // Non-fatal + } catch (err) { + log("save_account_source_failed", { + source, + error: err instanceof Error ? err.message : String(err), + }) } } +export function isUsageExhaustionResponse( + status: number, + responseBody: string, + retryAfter: string | null = null, +): boolean { + if (status !== 429) return false + if (retryAfter && retryAfter.trim().length > 0) return false + if (isLongContextError(responseBody)) return false + + const lowerBody = responseBody.toLowerCase() + return USAGE_EXHAUSTION_MESSAGE_PATTERNS.some((pattern) => + lowerBody.includes(pattern), + ) +} + +export function findAlternativeAccount( + currentSource: string | null, +): ClaudeAccount | null { + if (!currentSource) return null + + const accounts = refreshAccountsList() + return accounts.find((account) => account.source !== currentSource) ?? null +} + function getAuthJsonPaths(): string[] { const xdgPath = join(homedir(), ".local", "share", "opencode", "auth.json") if (process.platform === "win32") { @@ -309,6 +350,24 @@ export function refreshIfNeeded( return null } +export function reloadActiveCredentials(): ClaudeCredentials | null { + const account = getActiveAccount() + if (!account) return null + + const refreshed = refreshAccount(account.source) + if (!refreshed) { + accountCacheMap.delete(account.source) + return null + } + + account.credentials = refreshed + accountCacheMap.set(account.source, { + creds: refreshed, + cachedAt: Date.now(), + }) + return refreshed +} + /** * Returns the active account's credentials for auth.json sync purposes. * Unlike getCachedCredentials(), this does NOT trigger a refresh. diff --git a/src/index.test.ts b/src/index.test.ts index 84756d2..43a0c38 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -195,6 +195,55 @@ export function __getReadCount() { } } +async function loadHelpersWithMockAccounts( + accountFixtures: Account[], + refreshedCredentialsBySource?: Record, + failSyncAuthJson = false, +): Promise<{ + helpersModule: typeof import("./index.ts") +}> { + const tempDir = await mkdtemp(join(tmpdir(), "opencode-claude-auth-accounts-")) + const tempKeychain = join(tempDir, "keychain.ts") + const tempCredentials = join(tempDir, "credentials.ts") + + await copySourceFiles(tempDir) + if (failSyncAuthJson) { + const credentialsSource = await readFile(tempCredentials, "utf8") + const rewrittenCredentialsSource = credentialsSource.replace( + "export function syncAuthJson(creds: ClaudeCredentials): void {", + 'export function syncAuthJson(creds: ClaudeCredentials): void {\n if (process.env.FAIL_SYNC_AUTH_JSON === "1") throw new Error("sync failed")', + ) + await writeFile(tempCredentials, rewrittenCredentialsSource, "utf8") + } + await writeFile( + tempKeychain, + `let accounts = ${JSON.stringify(accountFixtures)} +const refreshedCredentialsBySource = ${JSON.stringify( + refreshedCredentialsBySource ?? {}, + )} + +export function readAllClaudeAccounts() { + return accounts +} + +export function refreshAccount(source) { + return refreshedCredentialsBySource[source] ?? accounts.find((account) => account.source === source)?.credentials ?? null +} + +export function writeBackCredentials() { return true } + +export function buildAccountLabels(creds) { + return creds.map((_, i) => \`Account \${i + 1}\`) +} +`, + "utf8", + ) + + const helpersModule = await import(pathToFileURL(join(tempDir, "index.ts")).href) + + return { helpersModule } +} + function makeCreds(overrides?: Partial): ClaudeCredentials { return { accessToken: "sk-ant-test-access", @@ -738,6 +787,522 @@ describe("auth hook — account resolution", () => { }) }) +describe("auth fetch — account failover", () => { + it("switches to another account after confirmed usage exhaustion", async () => { + const originalNow = Date.now + const originalSetInterval = globalThis.setInterval + const originalSetTimeout = globalThis.setTimeout + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-home-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ + unref() {}, + })) as unknown as typeof setInterval + globalThis.setTimeout = (((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 0 as unknown as ReturnType + }) as unknown) as typeof setTimeout + + let callCount = 0 + const seenAuthHeaders: string[] = [] + + try { + const { helpersModule } = await loadHelpersWithMockAccounts( + accounts.slice(0, 2), + ) + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount += 1 + seenAuthHeaders.push(new Headers(init?.headers).get("authorization") ?? "") + + if (callCount <= 3) { + return new Response( + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your account has reached its weekly usage limit.", + }, + }), + { status: 429 }, + ) + } + + return new Response("ok", { status: 200 }) + }) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + { models: {} }, + ) + + const result = await authConfig.fetch( + "https://api.anthropic.com/v1/messages", + { + method: "POST", + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }, + ) + + assert.equal(result.status, 200) + assert.equal(callCount, 4) + assert.deepEqual(seenAuthHeaders, [ + "Bearer at-1", + "Bearer at-1", + "Bearer at-1", + "Bearer at-2", + ]) + + const stateFile = join( + tempHome, + ".local", + "share", + "opencode", + "claude-account-source.txt", + ) + assert.equal( + readFileSync(stateFile, "utf-8").trim(), + "Claude Code-credentials-b28bbb7c", + ) + + const authJsonPath = join( + tempHome, + ".local", + "share", + "opencode", + "auth.json", + ) + const authJson = JSON.parse(readFileSync(authJsonPath, "utf-8")) as { + anthropic: { access: string; refresh: string; expires: number } + } + assert.equal(authJson.anthropic.access, "at-2") + } finally { + Date.now = originalNow + globalThis.setInterval = originalSetInterval + globalThis.setTimeout = originalSetTimeout + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + } + }) + + it("does not fail over on a generic 429 rate limit", async () => { + const originalNow = Date.now + const originalSetInterval = globalThis.setInterval + const originalSetTimeout = globalThis.setTimeout + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-home-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ + unref() {}, + })) as unknown as typeof setInterval + globalThis.setTimeout = (((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 0 as unknown as ReturnType + }) as unknown) as typeof setTimeout + + let callCount = 0 + const seenAuthHeaders: string[] = [] + + try { + const { helpersModule } = await loadHelpersWithMockAccounts( + accounts.slice(0, 2), + ) + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount += 1 + seenAuthHeaders.push(new Headers(init?.headers).get("authorization") ?? "") + return new Response( + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your account has hit a rate limit.", + }, + }), + { status: 429 }, + ) + }) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + { models: {} }, + ) + + const result = await authConfig.fetch( + "https://api.anthropic.com/v1/messages", + { + method: "POST", + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }, + ) + + assert.equal(result.status, 429) + assert.equal(callCount, 3) + assert.deepEqual(seenAuthHeaders, [ + "Bearer at-1", + "Bearer at-1", + "Bearer at-1", + ]) + assert.equal( + existsSync( + join( + tempHome, + ".local", + "share", + "opencode", + "claude-account-source.txt", + ), + ), + false, + ) + } finally { + Date.now = originalNow + globalThis.setInterval = originalSetInterval + globalThis.setTimeout = originalSetTimeout + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + } + }) + + it("retries once after the replacement account returns 401 with refreshed credentials", async () => { + const originalNow = Date.now + const originalSetInterval = globalThis.setInterval + const originalSetTimeout = globalThis.setTimeout + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-home-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ + unref() {}, + })) as unknown as typeof setInterval + globalThis.setTimeout = (((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 0 as unknown as ReturnType + }) as unknown) as typeof setTimeout + + let callCount = 0 + const seenAuthHeaders: string[] = [] + + try { + const { helpersModule } = await loadHelpersWithMockAccounts( + [ + accounts[0], + { + ...accounts[1], + credentials: makeCreds({ accessToken: "at-2-stale" }), + }, + ], + { + "Claude Code-credentials-b28bbb7c": makeCreds({ + accessToken: "at-2-refreshed", + }), + }, + ) + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount += 1 + const authHeader = new Headers(init?.headers).get("authorization") ?? "" + seenAuthHeaders.push(authHeader) + + if (callCount <= 3) { + return new Response( + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your account has reached its weekly usage limit.", + }, + }), + { status: 429 }, + ) + } + + if (callCount === 4) { + return new Response("unauthorized", { status: 401 }) + } + + return new Response("ok", { status: 200 }) + }) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + { models: {} }, + ) + + const result = await authConfig.fetch( + "https://api.anthropic.com/v1/messages", + { + method: "POST", + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }, + ) + + assert.equal(result.status, 200) + assert.equal(callCount, 5) + assert.deepEqual(seenAuthHeaders, [ + "Bearer at-1", + "Bearer at-1", + "Bearer at-1", + "Bearer at-2-stale", + "Bearer at-2-refreshed", + ]) + + const authJsonPath = join( + tempHome, + ".local", + "share", + "opencode", + "auth.json", + ) + const authJson = JSON.parse(readFileSync(authJsonPath, "utf-8")) as { + anthropic: { access: string; refresh: string; expires: number } + } + assert.equal(authJson.anthropic.access, "at-2-refreshed") + } finally { + Date.now = originalNow + globalThis.setInterval = originalSetInterval + globalThis.setTimeout = originalSetTimeout + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + } + }) + + it("keeps the replacement account persisted when the retry also fails", async () => { + const originalNow = Date.now + const originalSetInterval = globalThis.setInterval + const originalSetTimeout = globalThis.setTimeout + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-home-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ + unref() {}, + })) as unknown as typeof setInterval + globalThis.setTimeout = (((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 0 as unknown as ReturnType + }) as unknown) as typeof setTimeout + + let callCount = 0 + + try { + const { helpersModule } = await loadHelpersWithMockAccounts( + accounts.slice(0, 2), + ) + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount += 1 + + if (callCount <= 3) { + return new Response( + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your account has reached its weekly usage limit.", + }, + }), + { status: 429 }, + ) + } + + return new Response("server error", { status: 500 }) + }) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + { models: {} }, + ) + + const result = await authConfig.fetch( + "https://api.anthropic.com/v1/messages", + { + method: "POST", + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }, + ) + + assert.equal(result.status, 500) + assert.equal(callCount, 4) + + const stateFile = join( + tempHome, + ".local", + "share", + "opencode", + "claude-account-source.txt", + ) + assert.equal( + readFileSync(stateFile, "utf-8").trim(), + "Claude Code-credentials-b28bbb7c", + ) + } finally { + Date.now = originalNow + globalThis.setInterval = originalSetInterval + globalThis.setTimeout = originalSetTimeout + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + } + }) + + it("continues failover when auth.json sync throws", async () => { + const originalNow = Date.now + const originalSetInterval = globalThis.setInterval + const originalSetTimeout = globalThis.setTimeout + const originalHome = process.env.HOME + const originalFetch = globalThis.fetch + const originalFailSync = process.env.FAIL_SYNC_AUTH_JSON + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-home-")) + process.env.HOME = tempHome + Date.now = () => 1_700_000_000_000 + globalThis.setInterval = (() => ({ + unref() {}, + })) as unknown as typeof setInterval + globalThis.setTimeout = (((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 0 as unknown as ReturnType + }) as unknown) as typeof setTimeout + + let callCount = 0 + const seenAuthHeaders: string[] = [] + + try { + const { helpersModule } = await loadHelpersWithMockAccounts( + accounts.slice(0, 2), + undefined, + true, + ) + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount += 1 + seenAuthHeaders.push(new Headers(init?.headers).get("authorization") ?? "") + + if (callCount <= 3) { + return new Response( + JSON.stringify({ + error: { + type: "rate_limit_error", + message: "Your account has reached its weekly usage limit.", + }, + }), + { status: 429 }, + ) + } + + return new Response("ok", { status: 200 }) + }) as typeof fetch + + const plugin = await helpersModule.default({} as never) + const typedPlugin = plugin as { auth?: { loader?: TestAuthLoader } } + const authConfig = await typedPlugin.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + { models: {} }, + ) + process.env.FAIL_SYNC_AUTH_JSON = "1" + + const result = await authConfig.fetch( + "https://api.anthropic.com/v1/messages", + { + method: "POST", + body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }), + }, + ) + + assert.equal(result.status, 200) + assert.equal(callCount, 4) + assert.deepEqual(seenAuthHeaders, [ + "Bearer at-1", + "Bearer at-1", + "Bearer at-1", + "Bearer at-2", + ]) + + const stateFile = join( + tempHome, + ".local", + "share", + "opencode", + "claude-account-source.txt", + ) + assert.equal( + readFileSync(stateFile, "utf-8").trim(), + "Claude Code-credentials-b28bbb7c", + ) + } finally { + Date.now = originalNow + globalThis.setInterval = originalSetInterval + globalThis.setTimeout = originalSetTimeout + globalThis.fetch = originalFetch + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + if (typeof originalFailSync === "string") { + process.env.FAIL_SYNC_AUTH_JSON = originalFailSync + } else { + delete process.env.FAIL_SYNC_AUTH_JSON + } + } + }) +}) + describe("auth hook — select prompt options", () => { it("builds one option per account", () => { assert.equal(buildSelectOptions(accounts, accounts[0].source).length, 3) diff --git a/src/index.ts b/src/index.ts index 3edda7f..cd6a802 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,10 +14,14 @@ import { import { transformBody, transformResponseStream } from "./transforms.ts" import { applyOpencodeConfig } from "./plugin-config.ts" import { + findAlternativeAccount, getCachedCredentials, + getActiveAccountSource, getCredentialsForSync, + reloadActiveCredentials, syncAuthJson, initAccounts, + isUsageExhaustionResponse, setActiveAccountSource, loadPersistedAccountSource, saveAccountSource, @@ -299,8 +303,10 @@ const plugin: Plugin = async () => { ) const body = transformBody(requestInit.body) - const headerKeys: string[] = [] - headers.forEach((_, key) => headerKeys.push(key)) + const headerKeys: string[] = [] + headers.forEach((_, key) => { + headerKeys.push(key) + }) const betas = (headers.get("anthropic-beta") ?? "") .split(",") .filter(Boolean) @@ -320,13 +326,14 @@ const plugin: Plugin = async () => { // On 401, force a credential refresh and retry once. // This handles the common case of token expiry mid-session. - if (response.status === 401) { - log("fetch_401_retry", { modelId }) - const refreshed = getCachedCredentials() - if (refreshed && refreshed.accessToken !== latest.accessToken) { - const retryHeaders = buildRequestHeaders( - input, - requestInit, + if (response.status === 401) { + log("fetch_401_retry", { modelId }) + const refreshed = reloadActiveCredentials() + if (refreshed && refreshed.accessToken !== latest.accessToken) { + syncAuthJson(refreshed) + const retryHeaders = buildRequestHeaders( + input, + requestInit, refreshed.accessToken, modelId, excluded, @@ -339,12 +346,97 @@ const plugin: Plugin = async () => { log("fetch_401_retry_result", { status: response.status, modelId, - }) + }) + } + } + + if (response.status === 429) { + const responseBody = await response.clone().text() + const currentSource = getActiveAccountSource() + + if ( + isUsageExhaustionResponse( + response.status, + responseBody, + response.headers.get("retry-after"), + ) + ) { + const replacement = findAlternativeAccount(currentSource) + + if (replacement) { + setActiveAccountSource(replacement.source) + + const replacementCreds = getCachedCredentials() + if (replacementCreds) { + saveAccountSource(replacement.source) + try { + syncAuthJson(replacementCreds) + } catch { + } + log("fetch_account_failover", { + modelId, + previousSource: currentSource ?? "none", + newSource: replacement.source, + }) + console.warn( + `opencode-claude-auth: Claude account ${currentSource ?? "unknown"} exhausted, switching to ${replacement.label}.`, + ) + + const failoverHeaders = buildRequestHeaders( + input, + requestInit, + replacementCreds.accessToken, + modelId, + excluded, + ) + + response = await fetch(input, { + ...requestInit, + body, + headers: failoverHeaders, + }) + + if (response.status === 401) { + const refreshedReplacement = reloadActiveCredentials() + if ( + refreshedReplacement && + refreshedReplacement.accessToken !== + replacementCreds.accessToken + ) { + try { + syncAuthJson(refreshedReplacement) + } catch { + } + const retryHeaders = buildRequestHeaders( + input, + requestInit, + refreshedReplacement.accessToken, + modelId, + excluded, + ) + + response = await fetch(input, { + ...requestInit, + body, + headers: retryHeaders, + }) + } + } + + log("fetch_account_failover_result", { + status: response.status, + modelId, + newSource: replacement.source, + }) + } else if (currentSource) { + setActiveAccountSource(currentSource) + } + } + } } - } - // Check for long-context beta errors and retry with betas excluded - // Try up to LONG_CONTEXT_BETAS.length times, excluding one more beta each time + // Check for long-context beta errors and retry with betas excluded + // Try up to LONG_CONTEXT_BETAS.length times, excluding one more beta each time for ( let attempt = 0; attempt < LONG_CONTEXT_BETAS.length;