opencode-anthropic-fix 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -4,9 +4,11 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { AccountManager } from "./lib/accounts.mjs";
7
- import { authorize, exchange } from "./lib/oauth.mjs";
7
+ import { authorize, exchange, refreshToken } from "./lib/oauth.mjs";
8
8
  import { loadConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
9
9
  import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
10
+ import { applyOAuthCredentials, resetAccountTracking } from "./lib/account-state.mjs";
11
+ import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
10
12
  import { isAccountSpecificError, parseRateLimitReason, parseRetryAfterHeader } from "./lib/backoff.mjs";
11
13
 
12
14
  // ---------------------------------------------------------------------------
@@ -101,7 +103,7 @@ async function promptManageAccounts(accountManager) {
101
103
  // Request building helpers (extracted from original fetch interceptor)
102
104
  // ---------------------------------------------------------------------------
103
105
 
104
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.2";
106
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.68";
105
107
  const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
106
108
  const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
107
109
  const BILLING_HASH_SALT = "59cf53e54c78";
@@ -419,6 +421,9 @@ function getFirstUserText(messages) {
419
421
  * @returns {string}
420
422
  */
421
423
  function getAccountIdentifier(account) {
424
+ // Prefer env-provided account UUID (v2.1.51+), then account record fields
425
+ const envUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID?.trim();
426
+ if (envUuid) return envUuid;
422
427
  if (account?.accountUuid && typeof account.accountUuid === "string") {
423
428
  return account.accountUuid;
424
429
  }
@@ -433,9 +438,18 @@ function getAccountIdentifier(account) {
433
438
  * @returns {{user_id: string}}
434
439
  */
435
440
  function buildRequestMetadata(input) {
436
- return {
441
+ /** @type {Record<string, any>} */
442
+ const metadata = {
437
443
  user_id: `user_${input.persistentUserId}_account_${input.accountId}_session_${input.sessionId}`,
438
444
  };
445
+
446
+ // v2.1.51: pass through SDK caller metadata when available
447
+ const orgUuid = process.env.CLAUDE_CODE_ORGANIZATION_UUID?.trim();
448
+ if (orgUuid) metadata.organization_uuid = orgUuid;
449
+ const userEmail = process.env.CLAUDE_CODE_USER_EMAIL?.trim();
450
+ if (userEmail) metadata.user_email = userEmail;
451
+
452
+ return metadata;
439
453
  }
440
454
 
441
455
  /**
@@ -773,7 +787,7 @@ function normalizeThinkingBlock(thinking, model) {
773
787
  return rest;
774
788
  }
775
789
 
776
- const effort = typeof thinking.budget_tokens === "number" ? budgetTokensToEffort(thinking.budget_tokens) : "high"; // safe default
790
+ const effort = typeof thinking.budget_tokens === "number" ? budgetTokensToEffort(thinking.budget_tokens) : "medium"; // v2.1.68 default
777
791
 
778
792
  return { type: "enabled", effort };
779
793
  }
@@ -1113,6 +1127,74 @@ function getMidStreamAccountError(parsed) {
1113
1127
  };
1114
1128
  }
1115
1129
 
1130
+ /**
1131
+ * Strip `mcp_` prefix from tool_use `name` fields in SSE data lines.
1132
+ * Only modifies `name` values inside content blocks with `"type": "tool_use"`.
1133
+ * Non-JSON lines and text blocks are left untouched.
1134
+ *
1135
+ * @param {string} text - Raw SSE chunk text (may contain multiple lines)
1136
+ * @returns {string}
1137
+ */
1138
+ function stripMcpPrefixFromSSE(text) {
1139
+ return text.replace(/^data:\s*(.+)$/gm, (_match, jsonStr) => {
1140
+ try {
1141
+ const parsed = JSON.parse(jsonStr);
1142
+ if (stripMcpPrefixFromParsedEvent(parsed)) {
1143
+ return `data: ${JSON.stringify(parsed)}`;
1144
+ }
1145
+ } catch {
1146
+ // Not valid JSON — pass through unchanged.
1147
+ }
1148
+ return _match;
1149
+ });
1150
+ }
1151
+
1152
+ /**
1153
+ * Mutate a parsed SSE event object, removing `mcp_` prefix from tool_use
1154
+ * name fields. Returns true if any modification was made.
1155
+ *
1156
+ * @param {any} parsed
1157
+ * @returns {boolean}
1158
+ */
1159
+ function stripMcpPrefixFromParsedEvent(parsed) {
1160
+ if (!parsed || typeof parsed !== "object") return false;
1161
+
1162
+ let modified = false;
1163
+
1164
+ // content_block_start: { content_block: { type: "tool_use", name: "mcp_..." } }
1165
+ if (
1166
+ parsed.content_block &&
1167
+ parsed.content_block.type === "tool_use" &&
1168
+ typeof parsed.content_block.name === "string" &&
1169
+ parsed.content_block.name.startsWith("mcp_")
1170
+ ) {
1171
+ parsed.content_block.name = parsed.content_block.name.slice(4);
1172
+ modified = true;
1173
+ }
1174
+
1175
+ // message_start: { message: { content: [{ type: "tool_use", name: "mcp_..." }] } }
1176
+ if (parsed.message && Array.isArray(parsed.message.content)) {
1177
+ for (const block of parsed.message.content) {
1178
+ if (block.type === "tool_use" && typeof block.name === "string" && block.name.startsWith("mcp_")) {
1179
+ block.name = block.name.slice(4);
1180
+ modified = true;
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ // Top-level content array (non-streaming responses forwarded through SSE)
1186
+ if (Array.isArray(parsed.content)) {
1187
+ for (const block of parsed.content) {
1188
+ if (block.type === "tool_use" && typeof block.name === "string" && block.name.startsWith("mcp_")) {
1189
+ block.name = block.name.slice(4);
1190
+ modified = true;
1191
+ }
1192
+ }
1193
+ }
1194
+
1195
+ return modified;
1196
+ }
1197
+
1116
1198
  /**
1117
1199
  * Wrap a response body stream to strip mcp_ prefix from tool names,
1118
1200
  * extract token usage stats from SSE events, and detect mid-stream
@@ -1131,10 +1213,12 @@ function transformResponse(response, onUsage, onAccountError) {
1131
1213
  const reader = response.body.getReader();
1132
1214
  const decoder = new TextDecoder();
1133
1215
  const encoder = new TextEncoder();
1216
+ const EMPTY_CHUNK = new Uint8Array();
1134
1217
 
1135
1218
  /** @type {UsageStats} */
1136
1219
  const stats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
1137
1220
  let sseBuffer = "";
1221
+ let sseRewriteBuffer = "";
1138
1222
  let accountErrorHandled = false;
1139
1223
 
1140
1224
  /**
@@ -1184,12 +1268,41 @@ function transformResponse(response, onUsage, onAccountError) {
1184
1268
  }
1185
1269
  }
1186
1270
 
1271
+ /**
1272
+ * Rewrite complete SSE lines while preserving chunk boundaries for streaming.
1273
+ * Buffers trailing partial lines to avoid parsing split JSON payloads.
1274
+ * @param {string} chunk
1275
+ * @param {boolean} [flush]
1276
+ * @returns {string}
1277
+ */
1278
+ function rewriteSSEChunk(chunk, flush = false) {
1279
+ sseRewriteBuffer += chunk;
1280
+
1281
+ if (!flush) {
1282
+ const boundary = sseRewriteBuffer.lastIndexOf("\n");
1283
+ if (boundary === -1) return "";
1284
+ const complete = sseRewriteBuffer.slice(0, boundary + 1);
1285
+ sseRewriteBuffer = sseRewriteBuffer.slice(boundary + 1);
1286
+ return stripMcpPrefixFromSSE(complete);
1287
+ }
1288
+
1289
+ if (!sseRewriteBuffer) return "";
1290
+ const finalText = stripMcpPrefixFromSSE(sseRewriteBuffer);
1291
+ sseRewriteBuffer = "";
1292
+ return finalText;
1293
+ }
1294
+
1187
1295
  const stream = new ReadableStream({
1188
1296
  async pull(controller) {
1189
1297
  const { done, value } = await reader.read();
1190
1298
  if (done) {
1191
1299
  processSSEBuffer(true);
1192
1300
 
1301
+ const rewrittenTail = rewriteSSEChunk("", true);
1302
+ if (rewrittenTail) {
1303
+ controller.enqueue(encoder.encode(rewrittenTail));
1304
+ }
1305
+
1193
1306
  if (
1194
1307
  onUsage &&
1195
1308
  (stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0)
@@ -1200,7 +1313,7 @@ function transformResponse(response, onUsage, onAccountError) {
1200
1313
  return;
1201
1314
  }
1202
1315
 
1203
- let text = decoder.decode(value, { stream: true });
1316
+ const text = decoder.decode(value, { stream: true });
1204
1317
 
1205
1318
  if (onUsage || onAccountError) {
1206
1319
  // Normalize CRLF for parser only; preserve original bytes for passthrough.
@@ -1208,8 +1321,14 @@ function transformResponse(response, onUsage, onAccountError) {
1208
1321
  processSSEBuffer(false);
1209
1322
  }
1210
1323
 
1211
- text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
1212
- controller.enqueue(encoder.encode(text));
1324
+ const rewrittenText = rewriteSSEChunk(text, false);
1325
+ if (rewrittenText) {
1326
+ controller.enqueue(encoder.encode(rewrittenText));
1327
+ } else {
1328
+ // Keep the pull/read loop progressing when this chunk only extends a
1329
+ // partial line buffered for later rewrite.
1330
+ controller.enqueue(EMPTY_CHUNK);
1331
+ }
1213
1332
  },
1214
1333
  });
1215
1334
 
@@ -1247,69 +1366,132 @@ function formatSwitchReason(status, reason) {
1247
1366
  // Token refresh (per-account)
1248
1367
  // ---------------------------------------------------------------------------
1249
1368
 
1369
+ /**
1370
+ * Read the latest auth fields for an account from disk.
1371
+ * Another instance may have rotated tokens since we loaded into memory.
1372
+ * @param {string} accountId
1373
+ * @returns {Promise<{refreshToken: string, access?: string, expires?: number, tokenUpdatedAt: number} | null>}
1374
+ */
1375
+ async function readDiskAccountAuth(accountId) {
1376
+ try {
1377
+ const diskData = await loadAccounts();
1378
+ if (!diskData) return null;
1379
+ const diskAccount = diskData.accounts.find((a) => a.id === accountId);
1380
+ if (!diskAccount) return null;
1381
+ return {
1382
+ refreshToken: diskAccount.refreshToken,
1383
+ access: diskAccount.access,
1384
+ expires: diskAccount.expires,
1385
+ tokenUpdatedAt: diskAccount.token_updated_at,
1386
+ };
1387
+ } catch {
1388
+ return null;
1389
+ }
1390
+ }
1391
+
1392
+ /**
1393
+ * @param {import('./lib/accounts.mjs').ManagedAccount} account
1394
+ * @param {number} [now]
1395
+ */
1396
+ function markTokenStateUpdated(account, now = Date.now()) {
1397
+ account.tokenUpdatedAt = now;
1398
+ }
1399
+
1400
+ /**
1401
+ * Adopt disk auth fields only when disk has fresher token state.
1402
+ * @param {import('./lib/accounts.mjs').ManagedAccount} account
1403
+ * @param {{refreshToken: string, access?: string, expires?: number, tokenUpdatedAt: number} | null} diskAuth
1404
+ * @param {{ allowExpiredFallback?: boolean }} [options]
1405
+ * @returns {boolean}
1406
+ */
1407
+ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
1408
+ if (!diskAuth) return false;
1409
+ const diskTokenUpdatedAt = diskAuth.tokenUpdatedAt || 0;
1410
+ const memTokenUpdatedAt = account.tokenUpdatedAt || 0;
1411
+ const diskHasDifferentAuth = diskAuth.refreshToken !== account.refreshToken || diskAuth.access !== account.access;
1412
+ const memAuthExpired = !account.expires || account.expires <= Date.now();
1413
+ const allowExpiredFallback = options.allowExpiredFallback === true;
1414
+ if (diskTokenUpdatedAt <= memTokenUpdatedAt && !(allowExpiredFallback && diskHasDifferentAuth && memAuthExpired)) {
1415
+ return false;
1416
+ }
1417
+ account.refreshToken = diskAuth.refreshToken;
1418
+ account.access = diskAuth.access;
1419
+ account.expires = diskAuth.expires;
1420
+ account.tokenUpdatedAt = Math.max(memTokenUpdatedAt, diskTokenUpdatedAt);
1421
+ return true;
1422
+ }
1423
+
1250
1424
  /**
1251
1425
  * Refresh an account's access token.
1426
+ *
1252
1427
  * @param {import('./lib/accounts.mjs').ManagedAccount} account
1253
1428
  * @param {ReturnType<typeof import('@opencode-ai/sdk').createOpencodeClient>} client
1429
+ * @param {"foreground" | "idle"} [source]
1254
1430
  * @returns {Promise<string>} The new access token
1255
1431
  * @throws {Error} If refresh fails
1256
1432
  */
1257
- async function refreshAccountToken(account, client) {
1258
- const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
1259
- method: "POST",
1260
- headers: {
1261
- "Content-Type": "application/json",
1262
- },
1263
- body: JSON.stringify({
1264
- grant_type: "refresh_token",
1265
- refresh_token: account.refreshToken,
1266
- client_id: CLIENT_ID,
1267
- }),
1433
+ async function refreshAccountToken(account, client, source = "foreground") {
1434
+ const lockResult = await acquireRefreshLock(account.id, {
1435
+ timeoutMs: 2_000,
1436
+ backoffMs: 60,
1437
+ staleMs: 20_000,
1268
1438
  });
1439
+ const lock =
1440
+ lockResult && typeof lockResult === "object"
1441
+ ? lockResult
1442
+ : {
1443
+ acquired: true,
1444
+ lockPath: null,
1445
+ owner: null,
1446
+ lockInode: null,
1447
+ };
1448
+
1449
+ if (!lock.acquired) {
1450
+ const diskAuth = await readDiskAccountAuth(account.id);
1451
+ const adopted = applyDiskAuthIfFresher(account, diskAuth, { allowExpiredFallback: true });
1452
+ if (adopted && account.access && account.expires && account.expires > Date.now()) {
1453
+ return account.access;
1454
+ }
1455
+ throw new Error("Refresh lock busy");
1456
+ }
1269
1457
 
1270
- if (!response.ok) {
1271
- const bodyText = await response.text().catch(() => "");
1272
- let errorCode = "";
1273
- if (bodyText) {
1274
- try {
1275
- const parsed = JSON.parse(bodyText);
1276
- if (parsed && typeof parsed.error === "string") {
1277
- errorCode = parsed.error;
1278
- }
1279
- } catch {
1280
- // body may be non-JSON
1281
- }
1458
+ try {
1459
+ const diskAuthBeforeRefresh = await readDiskAccountAuth(account.id);
1460
+ const adopted = applyDiskAuthIfFresher(account, diskAuthBeforeRefresh);
1461
+ if (source === "foreground" && adopted && account.access && account.expires && account.expires > Date.now()) {
1462
+ return account.access;
1282
1463
  }
1283
1464
 
1284
- const err = new Error(`Token refresh failed: ${response.status}${errorCode ? ` (${errorCode})` : ""}`);
1285
- // @ts-ignore JS runtime property bag
1286
- err.status = response.status;
1287
- // @ts-ignore JS runtime property bag
1288
- err.errorCode = errorCode;
1289
- // @ts-ignore JS runtime property bag
1290
- err.body = bodyText;
1291
- throw err;
1292
- }
1293
-
1294
- const json = await response.json();
1295
- account.access = json.access_token;
1296
- account.expires = Date.now() + json.expires_in * 1000;
1297
- if (json.refresh_token) {
1298
- account.refreshToken = json.refresh_token;
1299
- }
1300
-
1301
- // Also persist to OpenCode's auth.json for compatibility
1302
- await client.auth.set({
1303
- path: { id: "anthropic" },
1304
- body: {
1305
- type: "oauth",
1306
- refresh: account.refreshToken,
1307
- access: account.access,
1308
- expires: account.expires,
1309
- },
1310
- });
1465
+ const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(10_000) });
1466
+
1467
+ account.access = json.access_token;
1468
+ account.expires = Date.now() + json.expires_in * 1000;
1469
+ if (json.refresh_token) {
1470
+ account.refreshToken = json.refresh_token;
1471
+ }
1472
+ markTokenStateUpdated(account);
1473
+
1474
+ // Also persist to OpenCode's auth.json for compatibility.
1475
+ // This should be best-effort: a persistence hiccup should not invalidate an
1476
+ // otherwise successful refresh token exchange.
1477
+ try {
1478
+ await client.auth.set({
1479
+ path: { id: "anthropic" },
1480
+ body: {
1481
+ type: "oauth",
1482
+ refresh: account.refreshToken,
1483
+ access: account.access,
1484
+ expires: account.expires,
1485
+ },
1486
+ });
1487
+ } catch {
1488
+ // Ignore persistence errors; in-memory tokens remain valid for this request.
1489
+ }
1311
1490
 
1312
- return json.access_token;
1491
+ return json.access_token;
1492
+ } finally {
1493
+ await releaseRefreshLock(lock);
1494
+ }
1313
1495
  }
1314
1496
 
1315
1497
  // ---------------------------------------------------------------------------
@@ -1368,9 +1550,18 @@ export async function AnthropicAuthPlugin({ client }) {
1368
1550
  /** @type {Map<string, number>} */
1369
1551
  const debouncedToastTimestamps = new Map();
1370
1552
 
1371
- /** @type {Map<string, Promise<string>>} */
1553
+ /** @type {Map<string, { promise: Promise<string>, source: "foreground" | "idle" }>} */
1372
1554
  const refreshInFlight = new Map();
1373
1555
 
1556
+ /** @type {Map<string, number>} */
1557
+ const idleRefreshLastAttempt = new Map();
1558
+ /** @type {Set<string>} */
1559
+ const idleRefreshInFlight = new Set();
1560
+
1561
+ const IDLE_REFRESH_ENABLED = config.idle_refresh.enabled;
1562
+ const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1000;
1563
+ const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1000;
1564
+
1374
1565
  /**
1375
1566
  * Pending slash-command OAuth flows keyed by session ID.
1376
1567
  * @type {Map<string, { mode: "login" | "reauth", verifier: string, targetIndex?: number, createdAt: number }>}
@@ -1551,6 +1742,7 @@ export async function AnthropicAuthPlugin({ client }) {
1551
1742
  refreshToken: credentials.refresh,
1552
1743
  access: credentials.access,
1553
1744
  expires: credentials.expires,
1745
+ token_updated_at: now,
1554
1746
  addedAt: now,
1555
1747
  lastUsed: 0,
1556
1748
  enabled: true,
@@ -1767,31 +1959,159 @@ export async function AnthropicAuthPlugin({ client }) {
1767
1959
  });
1768
1960
  }
1769
1961
 
1962
+ /**
1963
+ * Parse refresh error details for retry/disable decisions.
1964
+ * @param {unknown} refreshError
1965
+ * @returns {{message: string, status: number, errorCode: string, isInvalidGrant: boolean, isTerminalStatus: boolean}}
1966
+ */
1967
+ function parseRefreshFailure(refreshError) {
1968
+ const message = refreshError instanceof Error ? refreshError.message : String(refreshError);
1969
+ const status =
1970
+ typeof refreshError === "object" && refreshError && "status" in refreshError ? Number(refreshError.status) : NaN;
1971
+ const errorCode =
1972
+ typeof refreshError === "object" && refreshError && ("errorCode" in refreshError || "code" in refreshError)
1973
+ ? String(refreshError.errorCode || refreshError.code || "")
1974
+ : "";
1975
+ const msgLower = message.toLowerCase();
1976
+ const isInvalidGrant =
1977
+ errorCode === "invalid_grant" || errorCode === "invalid_request" || msgLower.includes("invalid_grant");
1978
+ const isTerminalStatus = status === 400 || status === 401 || status === 403;
1979
+ return { message, status, errorCode, isInvalidGrant, isTerminalStatus };
1980
+ }
1981
+
1770
1982
  /**
1771
1983
  * Refresh a specific account token with single-flight protection.
1772
1984
  * Prevents concurrent refresh races from disabling healthy accounts.
1773
1985
  * @param {import('./lib/accounts.mjs').ManagedAccount} account
1986
+ * @param {"foreground" | "idle"} [source]
1774
1987
  * @returns {Promise<string>}
1775
1988
  */
1776
- async function refreshAccountTokenSingleFlight(account) {
1989
+ async function refreshAccountTokenSingleFlight(account, source = "foreground") {
1777
1990
  const key = account.id;
1778
1991
  const existing = refreshInFlight.get(key);
1779
- if (existing) return existing;
1992
+ if (existing) {
1993
+ // Foreground requests should not directly inherit idle refresh failures.
1994
+ // Wait for idle maintenance to finish, then re-evaluate token state.
1995
+ if (source === "foreground" && existing.source === "idle") {
1996
+ try {
1997
+ await existing.promise;
1998
+ } catch {
1999
+ // Ignore idle failure here; foreground path handles refresh decisions.
2000
+ }
2001
+
2002
+ if (account.access && account.expires && account.expires > Date.now()) {
2003
+ return account.access;
2004
+ }
2005
+ } else {
2006
+ return existing.promise;
2007
+ }
2008
+ }
1780
2009
 
2010
+ /** @type {{ promise: Promise<string>, source: "foreground" | "idle" }} */
2011
+ const entry = { source, promise: Promise.resolve("") };
1781
2012
  const p = (async () => {
1782
2013
  try {
1783
- return await refreshAccountToken(account, client);
2014
+ return await refreshAccountToken(account, client, source);
1784
2015
  } finally {
1785
- if (refreshInFlight.get(key) === p) {
2016
+ if (refreshInFlight.get(key) === entry) {
1786
2017
  refreshInFlight.delete(key);
1787
2018
  }
1788
2019
  }
1789
2020
  })();
1790
2021
 
1791
- refreshInFlight.set(key, p);
2022
+ entry.promise = p;
2023
+ refreshInFlight.set(key, entry);
1792
2024
  return p;
1793
2025
  }
1794
2026
 
2027
+ /**
2028
+ * Refresh one idle (non-active) account in the background.
2029
+ * Best-effort only: never disables accounts from background maintenance.
2030
+ * @param {import('./lib/accounts.mjs').ManagedAccount} account
2031
+ * @returns {Promise<void>}
2032
+ */
2033
+ async function refreshIdleAccount(account) {
2034
+ if (!accountManager) return;
2035
+ if (idleRefreshInFlight.has(account.id)) return;
2036
+
2037
+ idleRefreshInFlight.add(account.id);
2038
+ const attemptedRefreshToken = account.refreshToken;
2039
+
2040
+ try {
2041
+ try {
2042
+ await refreshAccountTokenSingleFlight(account, "idle");
2043
+ accountManager.requestSaveToDisk();
2044
+ return;
2045
+ } catch (err) {
2046
+ let details = parseRefreshFailure(err);
2047
+
2048
+ if (!(details.isInvalidGrant || details.isTerminalStatus)) {
2049
+ debugLog("idle refresh skipped after transient failure", {
2050
+ accountIndex: account.index,
2051
+ status: details.status,
2052
+ errorCode: details.errorCode,
2053
+ message: details.message,
2054
+ });
2055
+ return;
2056
+ }
2057
+
2058
+ const diskAuth = await readDiskAccountAuth(account.id);
2059
+ const retryToken = diskAuth?.refreshToken;
2060
+ if (retryToken && retryToken !== attemptedRefreshToken && account.refreshToken === attemptedRefreshToken) {
2061
+ account.refreshToken = retryToken;
2062
+ if (diskAuth?.tokenUpdatedAt) {
2063
+ account.tokenUpdatedAt = diskAuth.tokenUpdatedAt;
2064
+ } else {
2065
+ markTokenStateUpdated(account);
2066
+ }
2067
+ }
2068
+
2069
+ try {
2070
+ await refreshAccountTokenSingleFlight(account, "idle");
2071
+ accountManager.requestSaveToDisk();
2072
+ return;
2073
+ } catch (retryErr) {
2074
+ details = parseRefreshFailure(retryErr);
2075
+ debugLog("idle refresh retry failed", {
2076
+ accountIndex: account.index,
2077
+ status: details.status,
2078
+ errorCode: details.errorCode,
2079
+ message: details.message,
2080
+ });
2081
+ return;
2082
+ }
2083
+ }
2084
+ } finally {
2085
+ idleRefreshInFlight.delete(account.id);
2086
+ }
2087
+ }
2088
+
2089
+ /**
2090
+ * Opportunistically refresh one near-expiry idle account in background.
2091
+ * Runs during normal requests so inactive accounts stay healthy.
2092
+ * @param {import('./lib/accounts.mjs').ManagedAccount} activeAccount
2093
+ */
2094
+ function maybeRefreshIdleAccounts(activeAccount) {
2095
+ if (!IDLE_REFRESH_ENABLED || !accountManager) return;
2096
+
2097
+ const now = Date.now();
2098
+ const excluded = new Set([activeAccount.index]);
2099
+ const candidates = accountManager
2100
+ .getEnabledAccounts(excluded)
2101
+ .filter((acc) => !acc.expires || acc.expires <= now + IDLE_REFRESH_WINDOW_MS)
2102
+ .filter((acc) => {
2103
+ const last = idleRefreshLastAttempt.get(acc.id) ?? 0;
2104
+ return now - last >= IDLE_REFRESH_MIN_INTERVAL_MS;
2105
+ })
2106
+ .sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
2107
+
2108
+ const target = candidates[0];
2109
+ if (!target) return;
2110
+
2111
+ idleRefreshLastAttempt.set(target.id, now);
2112
+ void refreshIdleAccount(target);
2113
+ }
2114
+
1795
2115
  return {
1796
2116
  // A1-A4: System prompt transform (unchanged)
1797
2117
  "experimental.chat.system.transform": (input, output) => {
@@ -1836,7 +2156,11 @@ export async function AnthropicAuthPlugin({ client }) {
1836
2156
  // Override context limits for 1M-window models so OpenCode
1837
2157
  // triggers compaction at the right threshold instead of relying
1838
2158
  // on potentially stale models.dev data.
1839
- if (config.override_model_limits.enabled && (hasOneMillionContext(model.id) || isOpus46Model(model.id))) {
2159
+ if (
2160
+ config.override_model_limits.enabled &&
2161
+ !isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) &&
2162
+ (hasOneMillionContext(model.id) || isOpus46Model(model.id))
2163
+ ) {
1840
2164
  model.limit = {
1841
2165
  ...(model.limit ?? {}),
1842
2166
  context: config.override_model_limits.context,
@@ -1925,42 +2249,92 @@ export async function AnthropicAuthPlugin({ client }) {
1925
2249
  let accessToken;
1926
2250
  // Per-account token refresh
1927
2251
  if (!account.access || !account.expires || account.expires < Date.now()) {
2252
+ const attemptedRefreshToken = account.refreshToken;
1928
2253
  try {
1929
2254
  accessToken = await refreshAccountTokenSingleFlight(account);
1930
2255
  // Persist updated tokens (especially if refresh token rotated)
1931
2256
  accountManager.requestSaveToDisk();
1932
2257
  } catch (err) {
1933
- // Token refresh failed — mark account as failed
1934
- accountManager.markFailure(account);
1935
- // Disable account on terminal refresh errors
1936
- const msg = err instanceof Error ? err.message : String(err);
1937
- const status = typeof err === "object" && err && "status" in err ? Number(err.status) : NaN;
1938
- const errorCode =
1939
- typeof err === "object" && err && "errorCode" in err ? String(err.errorCode || "") : "";
1940
- const shouldDisable =
1941
- status === 400 ||
1942
- status === 401 ||
1943
- status === 403 ||
1944
- errorCode === "invalid_grant" ||
1945
- errorCode === "invalid_request" ||
1946
- msg.includes("invalid_grant");
1947
-
1948
- if (shouldDisable) {
1949
- account.enabled = false;
1950
- accountManager.requestSaveToDisk();
1951
- const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
1952
- await toast(`Disabled ${name} (token refresh failed)`, "error");
1953
- } else {
1954
- // Skip this account for the remainder of this request.
1955
- transientRefreshSkips.add(account.index);
2258
+ // Token refresh failed — check if another instance rotated the
2259
+ // refresh token and persisted it between attempts.
2260
+ let finalError = err;
2261
+ let details = parseRefreshFailure(err);
2262
+
2263
+ // Belt-and-suspenders retry: on terminal/invalid_grant failures,
2264
+ // always re-read disk token and retry once before disabling.
2265
+ if (details.isInvalidGrant || details.isTerminalStatus) {
2266
+ const diskAuth = await readDiskAccountAuth(account.id);
2267
+ const retryToken = diskAuth?.refreshToken;
2268
+ if (
2269
+ retryToken &&
2270
+ retryToken !== attemptedRefreshToken &&
2271
+ account.refreshToken === attemptedRefreshToken
2272
+ ) {
2273
+ debugLog("refresh token on disk differs from in-memory, retrying with disk token", {
2274
+ accountIndex: account.index,
2275
+ });
2276
+ account.refreshToken = retryToken;
2277
+ if (diskAuth?.tokenUpdatedAt) {
2278
+ account.tokenUpdatedAt = diskAuth.tokenUpdatedAt;
2279
+ } else {
2280
+ markTokenStateUpdated(account);
2281
+ }
2282
+ } else if (retryToken && retryToken !== attemptedRefreshToken) {
2283
+ debugLog("skipping disk token adoption because in-memory token already changed", {
2284
+ accountIndex: account.index,
2285
+ });
2286
+ }
2287
+
2288
+ try {
2289
+ accessToken = await refreshAccountTokenSingleFlight(account);
2290
+ accountManager.requestSaveToDisk();
2291
+ } catch (retryErr) {
2292
+ finalError = retryErr;
2293
+ details = parseRefreshFailure(retryErr);
2294
+ debugLog("retry refresh failed", {
2295
+ accountIndex: account.index,
2296
+ status: details.status,
2297
+ errorCode: details.errorCode,
2298
+ message: details.message,
2299
+ });
2300
+ }
2301
+ }
2302
+
2303
+ if (!accessToken) {
2304
+ accountManager.markFailure(account);
2305
+
2306
+ if (details.isInvalidGrant || details.isTerminalStatus) {
2307
+ const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
2308
+ debugLog("disabling account after terminal refresh failure", {
2309
+ accountIndex: account.index,
2310
+ status: details.status,
2311
+ errorCode: details.errorCode,
2312
+ message: details.message,
2313
+ });
2314
+ account.enabled = false;
2315
+ accountManager.requestSaveToDisk();
2316
+ const statusLabel = Number.isFinite(details.status)
2317
+ ? `HTTP ${details.status}`
2318
+ : "unknown status";
2319
+ await toast(
2320
+ `Disabled ${name} (token refresh failed: ${details.errorCode || statusLabel})`,
2321
+ "error",
2322
+ );
2323
+ } else {
2324
+ // Skip this account for the remainder of this request.
2325
+ transientRefreshSkips.add(account.index);
2326
+ }
2327
+ lastError = finalError;
2328
+ continue; // Try next account
1956
2329
  }
1957
- lastError = err;
1958
- continue; // Try next account
1959
2330
  }
1960
2331
  } else {
1961
2332
  accessToken = account.access;
1962
2333
  }
1963
2334
 
2335
+ // Keep non-active accounts warm without blocking the request.
2336
+ maybeRefreshIdleAccounts(account);
2337
+
1964
2338
  const body = transformRequestBody(
1965
2339
  requestInit.body,
1966
2340
  {
@@ -2025,6 +2399,7 @@ export async function AnthropicAuthPlugin({ client }) {
2025
2399
  if (reason === "AUTH_FAILED") {
2026
2400
  account.access = undefined;
2027
2401
  account.expires = undefined;
2402
+ markTokenStateUpdated(account);
2028
2403
  }
2029
2404
 
2030
2405
  debugLog("account-specific error, switching account", {
@@ -2077,6 +2452,7 @@ export async function AnthropicAuthPlugin({ client }) {
2077
2452
  if (details.invalidateToken) {
2078
2453
  account.access = undefined;
2079
2454
  account.expires = undefined;
2455
+ markTokenStateUpdated(account);
2080
2456
  }
2081
2457
  accountManager.markRateLimited(account, details.reason, null);
2082
2458
  }