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 +466 -90
- package/lib/account-state.mjs +50 -0
- package/lib/account-state.test.mjs +109 -0
- package/lib/accounts.mjs +100 -11
- package/lib/accounts.test.mjs +95 -0
- package/lib/config.mjs +60 -0
- package/lib/oauth.mjs +35 -0
- package/lib/refresh-lock.mjs +123 -0
- package/lib/refresh-lock.test.mjs +79 -0
- package/lib/storage.mjs +76 -1
- package/lib/storage.test.mjs +133 -0
- package/package.json +7 -1
- package/.github/workflows/publish.yml +0 -31
- package/.husky/pre-commit +0 -2
- package/.husky/pre-push +0 -3
- package/.planning/CLI-ENHANCEMENTS.md +0 -221
- package/.planning/codebase/ARCHITECTURE.md +0 -201
- package/.planning/codebase/CONCERNS.md +0 -219
- package/.planning/codebase/CONVENTIONS.md +0 -335
- package/.planning/codebase/INTEGRATIONS.md +0 -215
- package/.planning/codebase/STACK.md +0 -160
- package/.planning/codebase/STRUCTURE.md +0 -244
- package/.planning/codebase/TESTING.md +0 -456
- package/.prettierignore +0 -3
- package/.prettierrc +0 -10
- package/CONTRIBUTING.md +0 -525
- package/agents.md +0 -22
- package/bun.lock +0 -43
- package/cli.mjs +0 -1773
- package/cli.test.mjs +0 -1797
- package/docs/mimese-http-header-system-prompt.md +0 -326
- package/docs/plans/afcw-upstream-watcher.json +0 -495
- package/eslint.config.mjs +0 -69
- package/index.test.mjs +0 -2690
- package/nul +0 -1
- package/script/publish.ts +0 -29
- package/scripts/build.mjs +0 -34
- package/scripts/install.mjs +0 -319
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.
|
|
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
|
-
|
|
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) : "
|
|
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
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
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
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
if (
|
|
1274
|
-
|
|
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
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
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)
|
|
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) ===
|
|
2016
|
+
if (refreshInFlight.get(key) === entry) {
|
|
1786
2017
|
refreshInFlight.delete(key);
|
|
1787
2018
|
}
|
|
1788
2019
|
}
|
|
1789
2020
|
})();
|
|
1790
2021
|
|
|
1791
|
-
|
|
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 (
|
|
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 —
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
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
|
}
|