opencode-anthropic-fix 0.0.14

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 ADDED
@@ -0,0 +1,2209 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { AccountManager } from "./lib/accounts.mjs";
7
+ import { authorize, exchange } from "./lib/oauth.mjs";
8
+ import { loadConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
9
+ import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
10
+ import { isAccountSpecificError, parseRateLimitReason, parseRetryAfterHeader } from "./lib/backoff.mjs";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Account management CLI prompts
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * @param {import('./lib/accounts.mjs').AccountManager} accountManager
18
+ * @returns {Promise<'add' | 'fresh' | 'manage' | 'cancel'>}
19
+ */
20
+ async function promptAccountMenu(accountManager) {
21
+ const accounts = accountManager.getAccountsSnapshot();
22
+ const currentIndex = accountManager.getCurrentIndex();
23
+ const rl = createInterface({ input: stdin, output: stdout });
24
+
25
+ try {
26
+ console.log(`\n${accounts.length} account(s) configured:`);
27
+ for (const acc of accounts) {
28
+ const name = acc.email || `Account ${acc.index + 1}`;
29
+ const active = acc.index === currentIndex ? " (active)" : "";
30
+ const disabled = !acc.enabled ? " [disabled]" : "";
31
+ console.log(` ${acc.index + 1}. ${name}${active}${disabled}`);
32
+ }
33
+ console.log("");
34
+
35
+ while (true) {
36
+ const answer = await rl.question("(a)dd new, (f)resh start, (m)anage, (c)ancel? [a/f/m/c]: ");
37
+ const normalized = answer.trim().toLowerCase();
38
+ if (normalized === "a" || normalized === "add") return "add";
39
+ if (normalized === "f" || normalized === "fresh") return "fresh";
40
+ if (normalized === "m" || normalized === "manage") return "manage";
41
+ if (normalized === "c" || normalized === "cancel") return "cancel";
42
+ console.log("Please enter 'a', 'f', 'm', or 'c'.");
43
+ }
44
+ } finally {
45
+ rl.close();
46
+ }
47
+ }
48
+
49
+ /**
50
+ * @param {import('./lib/accounts.mjs').AccountManager} accountManager
51
+ * @returns {Promise<void>}
52
+ */
53
+ async function promptManageAccounts(accountManager) {
54
+ const accounts = accountManager.getAccountsSnapshot();
55
+ const rl = createInterface({ input: stdin, output: stdout });
56
+
57
+ try {
58
+ console.log("\nManage accounts:");
59
+ for (const acc of accounts) {
60
+ const name = acc.email || `Account ${acc.index + 1}`;
61
+ const status = acc.enabled ? "enabled" : "disabled";
62
+ console.log(` ${acc.index + 1}. ${name} [${status}]`);
63
+ }
64
+ console.log("");
65
+
66
+ while (true) {
67
+ const answer = await rl.question("Enter account number to toggle, (d)N to delete (e.g. d1), or (b)ack: ");
68
+ const normalized = answer.trim().toLowerCase();
69
+
70
+ if (normalized === "b" || normalized === "back") return;
71
+
72
+ // Delete: d1, d2, etc.
73
+ const deleteMatch = normalized.match(/^d(\d+)$/);
74
+ if (deleteMatch) {
75
+ const idx = parseInt(deleteMatch[1], 10) - 1;
76
+ if (idx >= 0 && idx < accounts.length) {
77
+ accountManager.removeAccount(idx);
78
+ console.log(`Removed account ${idx + 1}.`);
79
+ return;
80
+ }
81
+ console.log("Invalid account number.");
82
+ continue;
83
+ }
84
+
85
+ // Toggle: just the number
86
+ const num = parseInt(normalized, 10);
87
+ if (!isNaN(num) && num >= 1 && num <= accounts.length) {
88
+ const newState = accountManager.toggleAccount(num - 1);
89
+ console.log(`Account ${num} is now ${newState ? "enabled" : "disabled"}.`);
90
+ continue;
91
+ }
92
+
93
+ console.log("Invalid input.");
94
+ }
95
+ } finally {
96
+ rl.close();
97
+ }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Request building helpers (extracted from original fetch interceptor)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.2";
105
+ const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
106
+ const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
107
+ const BILLING_HASH_SALT = "59cf53e54c78";
108
+ const BILLING_HASH_POSITIONS = [4, 7, 20];
109
+ const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
110
+ const KNOWN_IDENTITY_STRINGS = new Set([
111
+ CLAUDE_CODE_IDENTITY_STRING,
112
+ "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.",
113
+ "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
114
+ ]);
115
+ const BEDROCK_UNSUPPORTED_BETAS = new Set([
116
+ "interleaved-thinking-2025-05-14",
117
+ "context-1m-2025-08-07",
118
+ "tool-search-tool-2025-10-19",
119
+ "tool-examples-2025-10-29",
120
+ ]);
121
+ const STAINLESS_HELPER_KEYS = [
122
+ "x_stainless_helper",
123
+ "x-stainless-helper",
124
+ "stainless_helper",
125
+ "stainlessHelper",
126
+ "_stainless_helper",
127
+ ];
128
+ const USER_ID_STORAGE_FILE = "anthropic-signature-user-id";
129
+ const DEBUG_SYSTEM_PROMPT_ENV = "OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT";
130
+ const COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT = [
131
+ "You are a title generator. You output ONLY a thread title. Nothing else.",
132
+ "",
133
+ "Rules:",
134
+ "- Use the same language as the user message.",
135
+ "- Output exactly one line.",
136
+ "- Keep the title at or below 50 characters.",
137
+ "- No explanations, prefixes, or suffixes.",
138
+ "- Keep important technical terms, numbers, and filenames when present.",
139
+ ].join("\n");
140
+
141
+ /**
142
+ * @param {string | undefined} value
143
+ * @returns {boolean}
144
+ */
145
+ function isTruthyEnv(value) {
146
+ if (!value) return false;
147
+ const normalized = value.trim().toLowerCase();
148
+ return normalized === "1" || normalized === "true" || normalized === "yes";
149
+ }
150
+
151
+ /**
152
+ * @param {string | undefined} value
153
+ * @returns {boolean}
154
+ */
155
+ function isFalsyEnv(value) {
156
+ if (!value) return false;
157
+ const normalized = value.trim().toLowerCase();
158
+ return normalized === "0" || normalized === "false" || normalized === "no";
159
+ }
160
+
161
+ /**
162
+ * @returns {boolean}
163
+ */
164
+ function isNonInteractiveMode() {
165
+ if (isTruthyEnv(process.env.CI)) return true;
166
+ return !process.stdout.isTTY;
167
+ }
168
+
169
+ /**
170
+ * @returns {string}
171
+ */
172
+ function getClaudeEntrypoint() {
173
+ return process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
174
+ }
175
+
176
+ /**
177
+ * @param {string} claudeCliVersion
178
+ * @returns {string}
179
+ */
180
+ function buildUserAgent(claudeCliVersion) {
181
+ const sdkSuffix = process.env.CLAUDE_AGENT_SDK_VERSION ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` : "";
182
+ const appSuffix = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
183
+ ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`
184
+ : "";
185
+ return `claude-cli/${claudeCliVersion} (external, ${getClaudeEntrypoint()}${sdkSuffix}${appSuffix})`;
186
+ }
187
+
188
+ /**
189
+ * @returns {Record<string, string>}
190
+ */
191
+ function parseAnthropicCustomHeaders() {
192
+ const raw = process.env.ANTHROPIC_CUSTOM_HEADERS;
193
+ if (!raw) return {};
194
+
195
+ /** @type {Record<string, string>} */
196
+ const headers = {};
197
+ for (const line of raw.split(/\r?\n/)) {
198
+ const trimmed = line.trim();
199
+ if (!trimmed) continue;
200
+ const sep = trimmed.indexOf(":");
201
+ if (sep <= 0) continue;
202
+ const key = trimmed.slice(0, sep).trim();
203
+ const value = trimmed.slice(sep + 1).trim();
204
+ if (!key || !value) continue;
205
+ headers[key] = value;
206
+ }
207
+
208
+ return headers;
209
+ }
210
+
211
+ /**
212
+ * @returns {string}
213
+ */
214
+ function getOrCreateSignatureUserId() {
215
+ const envUserId = process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID?.trim();
216
+ if (envUserId) return envUserId;
217
+
218
+ const configDir = getConfigDir();
219
+ const userIdPath = join(configDir, USER_ID_STORAGE_FILE);
220
+
221
+ try {
222
+ if (existsSync(userIdPath)) {
223
+ const existing = readFileSync(userIdPath, "utf-8").trim();
224
+ if (existing) return existing;
225
+ }
226
+ } catch {
227
+ // fall through and generate a new id
228
+ }
229
+
230
+ const generated = randomUUID();
231
+ try {
232
+ mkdirSync(configDir, { recursive: true });
233
+ writeFileSync(userIdPath, `${generated}\n`, { encoding: "utf-8", mode: 0o600 });
234
+ } catch {
235
+ // Ignore filesystem errors; caller still gets generated ID for this runtime.
236
+ }
237
+ return generated;
238
+ }
239
+
240
+ /**
241
+ * @returns {boolean}
242
+ */
243
+ function shouldDebugSystemPrompt() {
244
+ return isTruthyEnv(process.env[DEBUG_SYSTEM_PROMPT_ENV]);
245
+ }
246
+
247
+ /**
248
+ * @param {string | undefined} body
249
+ */
250
+ function logTransformedSystemPrompt(body) {
251
+ if (!shouldDebugSystemPrompt()) return;
252
+ if (!body || typeof body !== "string") return;
253
+
254
+ try {
255
+ const parsed = JSON.parse(body);
256
+ if (!Object.prototype.hasOwnProperty.call(parsed, "system")) return;
257
+ if (isTitleGeneratorSystemBlocks(normalizeSystemTextBlocks(parsed.system))) return;
258
+ console.error(
259
+ "[opencode-anthropic-auth][system-debug] transformed system:",
260
+ JSON.stringify(parsed.system, null, 2),
261
+ );
262
+ } catch {
263
+ // Ignore parse errors in debug logging path.
264
+ }
265
+ }
266
+
267
+ /**
268
+ * @param {string} model
269
+ * @returns {boolean}
270
+ */
271
+ function isHaikuModel(model) {
272
+ return /haiku/i.test(model);
273
+ }
274
+
275
+ /**
276
+ * @param {string} model
277
+ * @returns {boolean}
278
+ */
279
+ function supportsThinking(model) {
280
+ if (!model) return true;
281
+ return /claude|sonnet|opus|haiku/i.test(model);
282
+ }
283
+
284
+ /**
285
+ * Detects claude-opus-4.6 / claude-opus-4-6 model IDs.
286
+ * These models use adaptive thinking (effort parameter) instead of
287
+ * manual budgetTokens.
288
+ * @param {string} model
289
+ * @returns {boolean}
290
+ */
291
+ function isOpus46Model(model) {
292
+ if (!model) return false;
293
+ return /claude-opus-4[._-]6/i.test(model);
294
+ }
295
+
296
+ /**
297
+ * @param {string} model
298
+ * @returns {boolean}
299
+ */
300
+ function hasOneMillionContext(model) {
301
+ return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model);
302
+ }
303
+
304
+ /**
305
+ * @param {string} model
306
+ * @returns {boolean}
307
+ */
308
+ function supportsStructuredOutputs(model) {
309
+ if (!/claude|sonnet|opus|haiku/i.test(model)) return false;
310
+ return !isHaikuModel(model);
311
+ }
312
+
313
+ /**
314
+ * @param {string} model
315
+ * @returns {boolean}
316
+ */
317
+ function supportsWebSearch(model) {
318
+ return /claude|sonnet|opus|haiku|gpt|gemini/i.test(model);
319
+ }
320
+
321
+ /**
322
+ * @param {URL | null} requestUrl
323
+ * @returns {"anthropic" | "bedrock" | "vertex" | "foundry"}
324
+ */
325
+ function detectProvider(requestUrl) {
326
+ if (!requestUrl) return "anthropic";
327
+ const host = requestUrl.hostname.toLowerCase();
328
+ if (host.includes("bedrock") || host.includes("amazonaws.com")) return "bedrock";
329
+ if (host.includes("aiplatform") || host.includes("vertex")) return "vertex";
330
+ if (host.includes("foundry") || host.includes("azure")) return "foundry";
331
+ return "anthropic";
332
+ }
333
+
334
+ /**
335
+ * @param {string | undefined} body
336
+ * @returns {{model: string, tools: any[], messages: any[]}}
337
+ */
338
+ function parseRequestBodyMetadata(body) {
339
+ if (!body || typeof body !== "string") {
340
+ return { model: "", tools: [], messages: [] };
341
+ }
342
+
343
+ try {
344
+ const parsed = JSON.parse(body);
345
+ const model = typeof parsed?.model === "string" ? parsed.model : "";
346
+ const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
347
+ const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
348
+ return { model, tools, messages };
349
+ } catch {
350
+ return { model: "", tools: [], messages: [] };
351
+ }
352
+ }
353
+
354
+ /**
355
+ * @param {any[]} tools
356
+ * @param {any[]} messages
357
+ * @returns {string}
358
+ */
359
+ function buildStainlessHelperHeader(tools, messages) {
360
+ const helpers = new Set();
361
+
362
+ const collect = (value) => {
363
+ if (!value || typeof value !== "object") return;
364
+
365
+ for (const key of STAINLESS_HELPER_KEYS) {
366
+ if (typeof value[key] === "string" && value[key]) {
367
+ helpers.add(value[key]);
368
+ }
369
+ }
370
+
371
+ if (Array.isArray(value.content)) {
372
+ for (const contentBlock of value.content) {
373
+ collect(contentBlock);
374
+ }
375
+ }
376
+ };
377
+
378
+ for (const tool of tools) collect(tool);
379
+ for (const message of messages) collect(message);
380
+
381
+ return Array.from(helpers).join(", ");
382
+ }
383
+
384
+ /**
385
+ * @param {any} content
386
+ * @returns {string}
387
+ */
388
+ function extractTextContent(content) {
389
+ if (typeof content === "string") return content;
390
+ if (!Array.isArray(content)) return "";
391
+
392
+ const parts = [];
393
+ for (const block of content) {
394
+ if (!block || typeof block !== "object") continue;
395
+ if (block.type === "text" && typeof block.text === "string") {
396
+ parts.push(block.text);
397
+ }
398
+ }
399
+ return parts.join("\n");
400
+ }
401
+
402
+ /**
403
+ * @param {any[]} messages
404
+ * @returns {string}
405
+ */
406
+ function getFirstUserText(messages) {
407
+ for (const message of messages) {
408
+ if (!message || typeof message !== "object") continue;
409
+ if (message.role !== "user") continue;
410
+
411
+ const text = extractTextContent(message.content);
412
+ if (text) return text;
413
+ }
414
+ return "";
415
+ }
416
+
417
+ /**
418
+ * @param {{id?: string, accountUuid?: string} | null | undefined} account
419
+ * @returns {string}
420
+ */
421
+ function getAccountIdentifier(account) {
422
+ if (account?.accountUuid && typeof account.accountUuid === "string") {
423
+ return account.accountUuid;
424
+ }
425
+ if (account?.id && typeof account.id === "string") {
426
+ return account.id;
427
+ }
428
+ return "";
429
+ }
430
+
431
+ /**
432
+ * @param {{persistentUserId: string, accountId: string, sessionId: string}} input
433
+ * @returns {{user_id: string}}
434
+ */
435
+ function buildRequestMetadata(input) {
436
+ return {
437
+ user_id: `user_${input.persistentUserId}_account_${input.accountId}_session_${input.sessionId}`,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * @param {string} claudeCliVersion
443
+ * @param {any[]} messages
444
+ * @returns {string}
445
+ */
446
+ function buildAnthropicBillingHeader(claudeCliVersion, messages) {
447
+ if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
448
+
449
+ const firstUserText = getFirstUserText(messages);
450
+ const sampled = BILLING_HASH_POSITIONS.map((position) => firstUserText[position] || "0").join("");
451
+ const hash = createHash("sha256")
452
+ .update(`${BILLING_HASH_SALT}${sampled}${claudeCliVersion}`)
453
+ .digest("hex")
454
+ .slice(0, 3);
455
+
456
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "unknown";
457
+ return `x-anthropic-billing-header: cc_version=${claudeCliVersion}.${hash}; cc_entrypoint=${entrypoint}; cch=00000;`;
458
+ }
459
+
460
+ /**
461
+ * @param {string} text
462
+ * @returns {string}
463
+ */
464
+ function sanitizeSystemText(text) {
465
+ return text.replace(/OpenCode/g, "Claude Code").replace(/opencode/gi, "Claude");
466
+ }
467
+
468
+ /**
469
+ * @param {string} text
470
+ * @param {'minimal' | 'off'} mode
471
+ * @returns {string}
472
+ */
473
+ function compactSystemText(text, mode) {
474
+ const withoutDuplicateIdentityPrefix = text.startsWith(`${CLAUDE_CODE_IDENTITY_STRING}\n`)
475
+ ? text.slice(CLAUDE_CODE_IDENTITY_STRING.length).trimStart()
476
+ : text;
477
+
478
+ if (mode === "off") {
479
+ return withoutDuplicateIdentityPrefix.trim();
480
+ }
481
+
482
+ const compacted = withoutDuplicateIdentityPrefix.replace(/<example>[\s\S]*?<\/example>/gi, "\n");
483
+
484
+ const dedupedLines = [];
485
+ let prevNormalized = "";
486
+ for (const line of compacted.split("\n")) {
487
+ const normalized = line.trim();
488
+ if (normalized && normalized === prevNormalized) continue;
489
+ dedupedLines.push(line);
490
+ prevNormalized = normalized;
491
+ }
492
+
493
+ return dedupedLines
494
+ .join("\n")
495
+ .replace(/\n{3,}/g, "\n\n")
496
+ .trim();
497
+ }
498
+
499
+ /**
500
+ * @param {string} text
501
+ * @returns {string}
502
+ */
503
+ function normalizeSystemTextForComparison(text) {
504
+ return text
505
+ .replace(/\r\n/g, "\n")
506
+ .split("\n")
507
+ .map((line) => line.trim())
508
+ .join("\n")
509
+ .replace(/\n{3,}/g, "\n\n")
510
+ .trim();
511
+ }
512
+
513
+ /**
514
+ * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
515
+ * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
516
+ */
517
+ function dedupeSystemBlocks(system) {
518
+ const exactSeen = new Set();
519
+ const exactDeduped = [];
520
+
521
+ for (const item of system) {
522
+ const normalized = normalizeSystemTextForComparison(item.text);
523
+ const key = `${item.type}:${normalized}`;
524
+ if (exactSeen.has(key)) continue;
525
+ exactSeen.add(key);
526
+ exactDeduped.push(item);
527
+ }
528
+
529
+ const normalizedBlocks = exactDeduped.map((item) => normalizeSystemTextForComparison(item.text));
530
+ return exactDeduped.filter((_, index) => {
531
+ const current = normalizedBlocks[index];
532
+ if (current.length < 80) return true;
533
+
534
+ for (let otherIndex = 0; otherIndex < normalizedBlocks.length; otherIndex += 1) {
535
+ if (otherIndex === index) continue;
536
+ const other = normalizedBlocks[otherIndex];
537
+ if (other.length <= current.length + 20) continue;
538
+ if (other.includes(current)) return false;
539
+ }
540
+
541
+ return true;
542
+ });
543
+ }
544
+
545
+ /**
546
+ * @param {string} text
547
+ * @returns {boolean}
548
+ */
549
+ function isTitleGeneratorSystemText(text) {
550
+ const normalized = text.trim().toLowerCase();
551
+ return normalized.includes("you are a title generator") || normalized.includes("generate a brief title");
552
+ }
553
+
554
+ /**
555
+ * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
556
+ * @returns {boolean}
557
+ */
558
+ function isTitleGeneratorSystemBlocks(system) {
559
+ return system.some(
560
+ (item) => item.type === "text" && typeof item.text === "string" && isTitleGeneratorSystemText(item.text),
561
+ );
562
+ }
563
+
564
+ /**
565
+ * @param {any[] | undefined} system
566
+ * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
567
+ */
568
+ function normalizeSystemTextBlocks(system) {
569
+ const output = [];
570
+ if (!Array.isArray(system)) return output;
571
+
572
+ for (const item of system) {
573
+ if (typeof item === "string") {
574
+ output.push({ type: "text", text: item });
575
+ continue;
576
+ }
577
+
578
+ if (!item || typeof item !== "object") continue;
579
+ if (typeof item.text !== "string") continue;
580
+
581
+ const normalized = {
582
+ type: typeof item.type === "string" ? item.type : "text",
583
+ text: item.text,
584
+ };
585
+
586
+ if (item.cache_control && typeof item.cache_control === "object" && !Array.isArray(item.cache_control)) {
587
+ normalized.cache_control = item.cache_control;
588
+ } else if (typeof item.cacheScope === "string" && item.cacheScope) {
589
+ // Backward compatibility for older shape used by this plugin.
590
+ normalized.cache_control = { type: "ephemeral" };
591
+ }
592
+
593
+ output.push(normalized);
594
+ }
595
+
596
+ return output;
597
+ }
598
+
599
+ /**
600
+ * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
601
+ * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off'}} signature
602
+ * @param {any[]} messages
603
+ * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
604
+ */
605
+ function buildSystemPromptBlocks(system, signature, messages) {
606
+ const titleGeneratorRequest = isTitleGeneratorSystemBlocks(system);
607
+
608
+ let sanitized = system.map((item) => ({
609
+ ...item,
610
+ text: compactSystemText(sanitizeSystemText(item.text), signature.promptCompactionMode),
611
+ }));
612
+
613
+ if (titleGeneratorRequest) {
614
+ sanitized = [{ type: "text", text: COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT }];
615
+ } else if (signature.promptCompactionMode !== "off") {
616
+ sanitized = dedupeSystemBlocks(sanitized);
617
+ }
618
+
619
+ if (!signature.enabled) {
620
+ return sanitized;
621
+ }
622
+
623
+ const filtered = sanitized.filter(
624
+ (item) => !item.text.startsWith("x-anthropic-billing-header:") && !KNOWN_IDENTITY_STRINGS.has(item.text),
625
+ );
626
+
627
+ const blocks = [];
628
+ const billingHeader = buildAnthropicBillingHeader(signature.claudeCliVersion, messages);
629
+ if (billingHeader) {
630
+ blocks.push({ type: "text", text: billingHeader });
631
+ }
632
+
633
+ blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY_STRING, cache_control: { type: "ephemeral" } });
634
+ blocks.push(...filtered);
635
+
636
+ return blocks;
637
+ }
638
+
639
+ /**
640
+ * @param {string} incomingBeta
641
+ * @param {boolean} signatureEnabled
642
+ * @param {string} model
643
+ * @param {"anthropic" | "bedrock" | "vertex" | "foundry"} provider
644
+ * @returns {string}
645
+ */
646
+ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider) {
647
+ const incomingBetasList = incomingBeta
648
+ .split(",")
649
+ .map((b) => b.trim())
650
+ .filter(Boolean);
651
+
652
+ const betas = ["oauth-2025-04-20"];
653
+
654
+ if (!signatureEnabled) {
655
+ betas.push("interleaved-thinking-2025-05-14");
656
+ return [...new Set([...betas, ...incomingBetasList])].join(",");
657
+ }
658
+
659
+ const nonInteractive = isNonInteractiveMode();
660
+ const haiku = isHaikuModel(model);
661
+
662
+ if (!haiku) {
663
+ betas.push(CLAUDE_CODE_BETA_FLAG);
664
+ }
665
+
666
+ if (isOpus46Model(model)) {
667
+ // Opus 4.6 uses adaptive thinking — model decides how much to think based on task complexity.
668
+ // The effort parameter guides it; budgetTokens/interleaved-thinking are for older models.
669
+ if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING)) {
670
+ betas.push("adaptive-thinking-2026-01-28");
671
+ }
672
+ } else if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && supportsThinking(model)) {
673
+ betas.push("interleaved-thinking-2025-05-14");
674
+ }
675
+
676
+ // context-1m-2025-08-07 is only supported for API key users; OAuth provider does not support it.
677
+ // For OAuth (this plugin's only auth mode), compaction is gated by model.limit.input instead.
678
+ if (hasOneMillionContext(model) && provider !== "anthropic") {
679
+ betas.push("context-1m-2025-08-07");
680
+ }
681
+
682
+ if (
683
+ nonInteractive &&
684
+ (isTruthyEnv(process.env.USE_API_CONTEXT_MANAGEMENT) || isTruthyEnv(process.env.TENGU_MARBLE_ANVIL))
685
+ ) {
686
+ betas.push("context-management-2025-06-27");
687
+ }
688
+
689
+ if (supportsStructuredOutputs(model) && isTruthyEnv(process.env.TENGU_TOOL_PEAR)) {
690
+ betas.push("structured-outputs-2025-12-15");
691
+ }
692
+
693
+ if (nonInteractive && isTruthyEnv(process.env.TENGU_SCARF_COFFEE)) {
694
+ betas.push("tool-examples-2025-10-29");
695
+ }
696
+
697
+ if ((provider === "vertex" || provider === "foundry") && supportsWebSearch(model)) {
698
+ betas.push("web-search-2025-03-05");
699
+ }
700
+
701
+ if (nonInteractive) {
702
+ betas.push("prompt-caching-scope-2026-01-05");
703
+ }
704
+
705
+ if (process.env.ANTHROPIC_BETAS && !haiku) {
706
+ const envBetas = process.env.ANTHROPIC_BETAS.split(",")
707
+ .map((b) => b.trim())
708
+ .filter(Boolean);
709
+ betas.push(...envBetas);
710
+ }
711
+
712
+ betas.push("fine-grained-tool-streaming-2025-05-14");
713
+
714
+ const mergedBetas = [...new Set([...betas, ...incomingBetasList])];
715
+ if (provider === "bedrock") {
716
+ return mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta)).join(",");
717
+ }
718
+ return mergedBetas.join(",");
719
+ }
720
+
721
+ /** @typedef {'low' | 'medium' | 'high' | 'max'} ThinkingEffort */
722
+
723
+ /**
724
+ * Map budgetTokens to an adaptive thinking effort level.
725
+ * Used when an Opus 4.6 request arrives with the legacy budgetTokens shape.
726
+ * @param {number} budgetTokens
727
+ * @returns {ThinkingEffort}
728
+ */
729
+ function budgetTokensToEffort(budgetTokens) {
730
+ if (budgetTokens <= 1024) return "low";
731
+ if (budgetTokens <= 8000) return "medium";
732
+ if (budgetTokens <= 16000) return "high";
733
+ return "max";
734
+ }
735
+
736
+ /**
737
+ * Validate that a given value is a valid ThinkingEffort string.
738
+ * @param {unknown} value
739
+ * @returns {value is ThinkingEffort}
740
+ */
741
+ function isValidEffort(value) {
742
+ return value === "low" || value === "medium" || value === "high" || value === "max";
743
+ }
744
+
745
+ /**
746
+ * Normalise the `thinking` block in the request body for the target model:
747
+ * - Opus 4.6 (adaptive thinking): produces `{ type: "enabled", effort: <effort> }`
748
+ * - Older models: passes the existing thinking block through unchanged.
749
+ *
750
+ * Handles three incoming shapes:
751
+ * 1. Already adaptive: `{ type: "enabled", effort: "..." }` → kept as-is for Opus 4.6
752
+ * 2. Legacy manual: `{ type: "enabled", budget_tokens: N }` → mapped to effort for Opus 4.6
753
+ * 3. Absent / disabled: no transform
754
+ *
755
+ * @param {any} thinking
756
+ * @param {string} model
757
+ * @returns {any}
758
+ */
759
+ function normalizeThinkingBlock(thinking, model) {
760
+ if (!thinking || typeof thinking !== "object" || thinking.type !== "enabled") {
761
+ return thinking;
762
+ }
763
+
764
+ if (!isOpus46Model(model)) {
765
+ // Older models: pass through unchanged (may have budget_tokens)
766
+ return thinking;
767
+ }
768
+
769
+ // Opus 4.6: use adaptive thinking with effort
770
+ if (isValidEffort(thinking.effort)) {
771
+ // Already in adaptive shape — just strip any legacy budget_tokens field
772
+ const { budget_tokens: _dropped, ...rest } = thinking;
773
+ return rest;
774
+ }
775
+
776
+ const effort = typeof thinking.budget_tokens === "number" ? budgetTokensToEffort(thinking.budget_tokens) : "high"; // safe default
777
+
778
+ return { type: "enabled", effort };
779
+ }
780
+
781
+ /**
782
+ * Map Node.js platform to Stainless OS header value.
783
+ * @param {NodeJS.Platform} value
784
+ * @returns {string}
785
+ */
786
+ function getStainlessOs(value) {
787
+ if (value === "darwin") return "MacOS";
788
+ if (value === "win32") return "Windows";
789
+ if (value === "linux") return "Linux";
790
+ return value;
791
+ }
792
+
793
+ /**
794
+ * Normalize Node.js arch to Stainless arch header value.
795
+ * @param {string} value
796
+ * @returns {string}
797
+ */
798
+ function getStainlessArch(value) {
799
+ if (value === "x64") return "x64";
800
+ if (value === "arm64") return "arm64";
801
+ return value;
802
+ }
803
+
804
+ /**
805
+ * Resolve latest claude-code package version from npm registry.
806
+ * Returns null on timeout/network/parse failures.
807
+ * @param {number} timeoutMs
808
+ * @returns {Promise<string | null>}
809
+ */
810
+ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
811
+ const controller = new AbortController();
812
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
813
+ try {
814
+ const response = await fetch(CLAUDE_CODE_NPM_LATEST_URL, {
815
+ method: "GET",
816
+ headers: { accept: "application/json" },
817
+ signal: controller.signal,
818
+ });
819
+ if (!response.ok) return null;
820
+ const data = await response.json();
821
+ if (!data || typeof data !== "object") return null;
822
+ return typeof data.version === "string" && data.version ? data.version : null;
823
+ } catch {
824
+ return null;
825
+ } finally {
826
+ clearTimeout(timeout);
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Build request headers from input and init, applying OAuth requirements.
832
+ * Preserves behaviors D1-D7.
833
+ *
834
+ * @param {any} input
835
+ * @param {Record<string, any>} requestInit
836
+ * @param {string} accessToken
837
+ * @param {string | undefined} requestBody
838
+ * @param {URL | null} requestUrl
839
+ * @param {{enabled: boolean, claudeCliVersion: string}} signature
840
+ * @returns {Headers}
841
+ */
842
+ function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature) {
843
+ const requestHeaders = new Headers();
844
+ if (input instanceof Request) {
845
+ input.headers.forEach((value, key) => {
846
+ requestHeaders.set(key, value);
847
+ });
848
+ }
849
+ if (requestInit.headers) {
850
+ if (requestInit.headers instanceof Headers) {
851
+ requestInit.headers.forEach((value, key) => {
852
+ requestHeaders.set(key, value);
853
+ });
854
+ } else if (Array.isArray(requestInit.headers)) {
855
+ for (const [key, value] of requestInit.headers) {
856
+ if (typeof value !== "undefined") {
857
+ requestHeaders.set(key, String(value));
858
+ }
859
+ }
860
+ } else {
861
+ for (const [key, value] of Object.entries(requestInit.headers)) {
862
+ if (typeof value !== "undefined") {
863
+ requestHeaders.set(key, String(value));
864
+ }
865
+ }
866
+ }
867
+ }
868
+
869
+ // Preserve all incoming beta headers while ensuring OAuth requirements
870
+ const incomingBeta = requestHeaders.get("anthropic-beta") || "";
871
+ const { model, tools, messages } = parseRequestBodyMetadata(requestBody);
872
+ const provider = detectProvider(requestUrl);
873
+ const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider);
874
+
875
+ const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
876
+ const bearerToken = authTokenOverride || accessToken;
877
+
878
+ requestHeaders.set("authorization", `Bearer ${bearerToken}`);
879
+ requestHeaders.set("anthropic-beta", mergedBetas);
880
+ requestHeaders.set("user-agent", buildUserAgent(signature.claudeCliVersion));
881
+ if (signature.enabled) {
882
+ requestHeaders.set("anthropic-version", "2023-06-01");
883
+ requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");
884
+ requestHeaders.set("x-app", "cli");
885
+ requestHeaders.set("x-stainless-arch", getStainlessArch(process.arch));
886
+ requestHeaders.set("x-stainless-lang", "js");
887
+ requestHeaders.set("x-stainless-os", getStainlessOs(process.platform));
888
+ requestHeaders.set("x-stainless-package-version", signature.claudeCliVersion);
889
+ requestHeaders.set("x-stainless-runtime", "node");
890
+ requestHeaders.set("x-stainless-runtime-version", process.version);
891
+ requestHeaders.set("x-stainless-helper-method", "stream");
892
+ const incomingRetryCount = requestHeaders.get("x-stainless-retry-count");
893
+ requestHeaders.set(
894
+ "x-stainless-retry-count",
895
+ incomingRetryCount && !isFalsyEnv(incomingRetryCount) ? incomingRetryCount : "0",
896
+ );
897
+ const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
898
+ if (stainlessHelpers) {
899
+ requestHeaders.set("x-stainless-helper", stainlessHelpers);
900
+ }
901
+
902
+ for (const [key, value] of Object.entries(parseAnthropicCustomHeaders())) {
903
+ requestHeaders.set(key, value);
904
+ }
905
+ if (process.env.CLAUDE_CODE_CONTAINER_ID) {
906
+ requestHeaders.set("x-claude-remote-container-id", process.env.CLAUDE_CODE_CONTAINER_ID);
907
+ }
908
+ if (process.env.CLAUDE_CODE_REMOTE_SESSION_ID) {
909
+ requestHeaders.set("x-claude-remote-session-id", process.env.CLAUDE_CODE_REMOTE_SESSION_ID);
910
+ }
911
+ if (process.env.CLAUDE_AGENT_SDK_CLIENT_APP) {
912
+ requestHeaders.set("x-client-app", process.env.CLAUDE_AGENT_SDK_CLIENT_APP);
913
+ }
914
+ if (isTruthyEnv(process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION)) {
915
+ requestHeaders.set("x-anthropic-additional-protection", "true");
916
+ }
917
+ }
918
+ requestHeaders.delete("x-api-key");
919
+
920
+ return requestHeaders;
921
+ }
922
+
923
+ /**
924
+ * Transform the request body: system prompt sanitization and tool prefixing.
925
+ * Preserves behaviors E1-E7.
926
+ *
927
+ * @param {string | undefined} body
928
+ * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off'}} signature
929
+ * @param {{persistentUserId: string, sessionId: string, accountId: string}} runtime
930
+ * @returns {string | undefined}
931
+ */
932
+ function transformRequestBody(body, signature, runtime) {
933
+ if (!body || typeof body !== "string") return body;
934
+
935
+ const TOOL_PREFIX = "mcp_";
936
+
937
+ try {
938
+ const parsed = JSON.parse(body);
939
+ if (Object.prototype.hasOwnProperty.call(parsed, "betas")) {
940
+ delete parsed.betas;
941
+ }
942
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
943
+
944
+ // Normalize thinking block for adaptive (Opus 4.6) vs manual (older models).
945
+ if (Object.prototype.hasOwnProperty.call(parsed, "thinking")) {
946
+ parsed.thinking = normalizeThinkingBlock(parsed.thinking, parsed.model || "");
947
+ }
948
+
949
+ // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
950
+ parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signature, messages);
951
+
952
+ if (signature.enabled) {
953
+ const currentMetadata =
954
+ parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
955
+ ? parsed.metadata
956
+ : {};
957
+ parsed.metadata = {
958
+ ...currentMetadata,
959
+ ...buildRequestMetadata({
960
+ persistentUserId: runtime.persistentUserId,
961
+ accountId: runtime.accountId,
962
+ sessionId: runtime.sessionId,
963
+ }),
964
+ };
965
+ }
966
+
967
+ // Add prefix to tools definitions
968
+ if (parsed.tools && Array.isArray(parsed.tools)) {
969
+ parsed.tools = parsed.tools.map((tool) => ({
970
+ ...tool,
971
+ name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name,
972
+ }));
973
+ }
974
+ // Add prefix to tool_use blocks in messages
975
+ if (parsed.messages && Array.isArray(parsed.messages)) {
976
+ parsed.messages = parsed.messages.map((msg) => {
977
+ if (msg.content && Array.isArray(msg.content)) {
978
+ msg.content = msg.content.map((block) => {
979
+ if (block.type === "tool_use" && block.name) {
980
+ return {
981
+ ...block,
982
+ name: `${TOOL_PREFIX}${block.name}`,
983
+ };
984
+ }
985
+ return block;
986
+ });
987
+ }
988
+ return msg;
989
+ });
990
+ }
991
+ return JSON.stringify(parsed);
992
+ } catch {
993
+ // ignore parse errors
994
+ return body;
995
+ }
996
+ }
997
+
998
+ /**
999
+ * Transform the request URL: add ?beta=true to /v1/messages.
1000
+ * Preserves behaviors F1-F3.
1001
+ *
1002
+ * @param {any} input
1003
+ * @returns {{requestInput: any, requestUrl: URL | null}}
1004
+ */
1005
+ function transformRequestUrl(input) {
1006
+ let requestInput = input;
1007
+ let requestUrl = null;
1008
+ try {
1009
+ if (typeof input === "string" || input instanceof URL) {
1010
+ requestUrl = new URL(input.toString());
1011
+ } else if (input instanceof Request) {
1012
+ requestUrl = new URL(input.url);
1013
+ }
1014
+ } catch {
1015
+ requestUrl = null;
1016
+ }
1017
+
1018
+ if (requestUrl && requestUrl.pathname === "/v1/messages" && !requestUrl.searchParams.has("beta")) {
1019
+ requestUrl.searchParams.set("beta", "true");
1020
+ requestInput = input instanceof Request ? new Request(requestUrl.toString(), input) : requestUrl;
1021
+ }
1022
+
1023
+ return { requestInput, requestUrl };
1024
+ }
1025
+
1026
+ /**
1027
+ * @typedef {object} UsageStats
1028
+ * @property {number} inputTokens
1029
+ * @property {number} outputTokens
1030
+ * @property {number} cacheReadTokens
1031
+ * @property {number} cacheWriteTokens
1032
+ */
1033
+
1034
+ /**
1035
+ * Update running usage stats from a parsed SSE event.
1036
+ * @param {any} parsed
1037
+ * @param {UsageStats} stats
1038
+ */
1039
+ function extractUsageFromSSEEvent(parsed, stats) {
1040
+ // message_delta: cumulative usage (preferred, overwrites)
1041
+ if (parsed?.type === "message_delta" && parsed.usage) {
1042
+ const u = parsed.usage;
1043
+ if (typeof u.input_tokens === "number") stats.inputTokens = u.input_tokens;
1044
+ if (typeof u.output_tokens === "number") stats.outputTokens = u.output_tokens;
1045
+ if (typeof u.cache_read_input_tokens === "number") stats.cacheReadTokens = u.cache_read_input_tokens;
1046
+ if (typeof u.cache_creation_input_tokens === "number") stats.cacheWriteTokens = u.cache_creation_input_tokens;
1047
+ return;
1048
+ }
1049
+
1050
+ // message_start: initial usage (only set if we haven't seen message_delta yet)
1051
+ if (parsed?.type === "message_start" && parsed.message?.usage) {
1052
+ const u = parsed.message.usage;
1053
+ if (stats.inputTokens === 0 && typeof u.input_tokens === "number") {
1054
+ stats.inputTokens = u.input_tokens;
1055
+ }
1056
+ if (stats.cacheReadTokens === 0 && typeof u.cache_read_input_tokens === "number") {
1057
+ stats.cacheReadTokens = u.cache_read_input_tokens;
1058
+ }
1059
+ if (stats.cacheWriteTokens === 0 && typeof u.cache_creation_input_tokens === "number") {
1060
+ stats.cacheWriteTokens = u.cache_creation_input_tokens;
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Extract the combined SSE data payload from one event block.
1067
+ * @param {string} eventBlock
1068
+ * @returns {string | null}
1069
+ */
1070
+ function getSSEDataPayload(eventBlock) {
1071
+ if (!eventBlock) return null;
1072
+
1073
+ const dataLines = [];
1074
+ for (const line of eventBlock.split("\n")) {
1075
+ if (!line.startsWith("data:")) continue;
1076
+ dataLines.push(line.slice(5).trimStart());
1077
+ }
1078
+
1079
+ if (dataLines.length === 0) return null;
1080
+ const payload = dataLines.join("\n");
1081
+ if (!payload || payload === "[DONE]") return null;
1082
+ return payload;
1083
+ }
1084
+
1085
+ /**
1086
+ * Parse one SSE event payload and return account-error details if present.
1087
+ * @param {any} parsed
1088
+ * @returns {{reason: import('./lib/backoff.mjs').RateLimitReason, invalidateToken: boolean} | null}
1089
+ */
1090
+ function getMidStreamAccountError(parsed) {
1091
+ if (!parsed || parsed.type !== "error" || !parsed.error) {
1092
+ return null;
1093
+ }
1094
+
1095
+ const errorBody = {
1096
+ error: {
1097
+ type: String(parsed.error.type || ""),
1098
+ message: String(parsed.error.message || ""),
1099
+ },
1100
+ };
1101
+
1102
+ // Mid-stream errors do not include a reliable HTTP status. Use 400-style
1103
+ // body parsing to identify account-specific errors.
1104
+ if (!isAccountSpecificError(400, errorBody)) {
1105
+ return null;
1106
+ }
1107
+
1108
+ const reason = parseRateLimitReason(400, errorBody);
1109
+
1110
+ return {
1111
+ reason,
1112
+ invalidateToken: reason === "AUTH_FAILED",
1113
+ };
1114
+ }
1115
+
1116
+ /**
1117
+ * Wrap a response body stream to strip mcp_ prefix from tool names,
1118
+ * extract token usage stats from SSE events, and detect mid-stream
1119
+ * account-specific errors (so the account can be marked for the NEXT request).
1120
+ * Preserves behaviors G1-G5.
1121
+ *
1122
+ * @param {Response} response
1123
+ * @param {((stats: UsageStats) => void) | null} [onUsage] - Called when stream ends with final usage
1124
+ * @param {((details: {reason: import('./lib/backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [onAccountError]
1125
+ * Called if a mid-stream error looks account-specific
1126
+ * @returns {Response}
1127
+ */
1128
+ function transformResponse(response, onUsage, onAccountError) {
1129
+ if (!response.body) return response;
1130
+
1131
+ const reader = response.body.getReader();
1132
+ const decoder = new TextDecoder();
1133
+ const encoder = new TextEncoder();
1134
+
1135
+ /** @type {UsageStats} */
1136
+ const stats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
1137
+ let sseBuffer = "";
1138
+ let accountErrorHandled = false;
1139
+
1140
+ /**
1141
+ * Process buffered SSE event blocks.
1142
+ * @param {boolean} flush
1143
+ */
1144
+ function processSSEBuffer(flush = false) {
1145
+ while (true) {
1146
+ const boundary = sseBuffer.indexOf("\n\n");
1147
+
1148
+ if (boundary === -1) {
1149
+ if (!flush) return;
1150
+ if (!sseBuffer.trim()) {
1151
+ sseBuffer = "";
1152
+ return;
1153
+ }
1154
+ }
1155
+
1156
+ const eventBlock = boundary === -1 ? sseBuffer : sseBuffer.slice(0, boundary);
1157
+ sseBuffer = boundary === -1 ? "" : sseBuffer.slice(boundary + 2);
1158
+
1159
+ const payload = getSSEDataPayload(eventBlock);
1160
+ if (!payload) {
1161
+ if (boundary === -1) return;
1162
+ continue;
1163
+ }
1164
+
1165
+ try {
1166
+ const parsed = JSON.parse(payload);
1167
+
1168
+ if (onUsage) {
1169
+ extractUsageFromSSEEvent(parsed, stats);
1170
+ }
1171
+
1172
+ if (onAccountError && !accountErrorHandled) {
1173
+ const details = getMidStreamAccountError(parsed);
1174
+ if (details) {
1175
+ accountErrorHandled = true;
1176
+ onAccountError(details);
1177
+ }
1178
+ }
1179
+ } catch {
1180
+ // Ignore malformed event payloads.
1181
+ }
1182
+
1183
+ if (boundary === -1) return;
1184
+ }
1185
+ }
1186
+
1187
+ const stream = new ReadableStream({
1188
+ async pull(controller) {
1189
+ const { done, value } = await reader.read();
1190
+ if (done) {
1191
+ processSSEBuffer(true);
1192
+
1193
+ if (
1194
+ onUsage &&
1195
+ (stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0)
1196
+ ) {
1197
+ onUsage(stats);
1198
+ }
1199
+ controller.close();
1200
+ return;
1201
+ }
1202
+
1203
+ let text = decoder.decode(value, { stream: true });
1204
+
1205
+ if (onUsage || onAccountError) {
1206
+ // Normalize CRLF for parser only; preserve original bytes for passthrough.
1207
+ sseBuffer += text.replace(/\r\n/g, "\n");
1208
+ processSSEBuffer(false);
1209
+ }
1210
+
1211
+ text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
1212
+ controller.enqueue(encoder.encode(text));
1213
+ },
1214
+ });
1215
+
1216
+ return new Response(stream, {
1217
+ status: response.status,
1218
+ statusText: response.statusText,
1219
+ headers: response.headers,
1220
+ });
1221
+ }
1222
+
1223
+ /**
1224
+ * Check whether a response is an SSE event stream.
1225
+ * @param {Response} response
1226
+ * @returns {boolean}
1227
+ */
1228
+ function isEventStreamResponse(response) {
1229
+ const contentType = response.headers.get("content-type") || "";
1230
+ return contentType.toLowerCase().includes("text/event-stream");
1231
+ }
1232
+
1233
+ /**
1234
+ * Build user-facing switch reason text for account-specific errors.
1235
+ * @param {number} status
1236
+ * @param {import('./lib/backoff.mjs').RateLimitReason} reason
1237
+ * @returns {string}
1238
+ */
1239
+ function formatSwitchReason(status, reason) {
1240
+ if (reason === "AUTH_FAILED") return "auth failed";
1241
+ if (status === 403 && reason === "QUOTA_EXHAUSTED") return "permission denied";
1242
+ if (reason === "QUOTA_EXHAUSTED") return "quota exhausted";
1243
+ return "rate-limited";
1244
+ }
1245
+
1246
+ // ---------------------------------------------------------------------------
1247
+ // Token refresh (per-account)
1248
+ // ---------------------------------------------------------------------------
1249
+
1250
+ /**
1251
+ * Refresh an account's access token.
1252
+ * @param {import('./lib/accounts.mjs').ManagedAccount} account
1253
+ * @param {ReturnType<typeof import('@opencode-ai/sdk').createOpencodeClient>} client
1254
+ * @returns {Promise<string>} The new access token
1255
+ * @throws {Error} If refresh fails
1256
+ */
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
+ }),
1268
+ });
1269
+
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
+ }
1282
+ }
1283
+
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
+ });
1311
+
1312
+ return json.access_token;
1313
+ }
1314
+
1315
+ // ---------------------------------------------------------------------------
1316
+ // Plugin entry point
1317
+ // ---------------------------------------------------------------------------
1318
+
1319
+ const ANTHROPIC_COMMAND_HANDLED = "__ANTHROPIC_COMMAND_HANDLED__";
1320
+ const PENDING_OAUTH_TTL_MS = 10 * 60 * 1000;
1321
+
1322
+ /**
1323
+ * Remove ANSI color/control codes from output text.
1324
+ * @param {string} value
1325
+ * @returns {string}
1326
+ */
1327
+ function stripAnsi(value) {
1328
+ return value.replace(/\x1b\[[0-9;]*m/g, ""); // eslint-disable-line no-control-regex
1329
+ }
1330
+
1331
+ /**
1332
+ * Parse command arguments with minimal quote support.
1333
+ *
1334
+ * Examples:
1335
+ * a b "c d" -> ["a", "b", "c d"]
1336
+ * a 'c d' -> ["a", "c d"]
1337
+ *
1338
+ * @param {string} raw
1339
+ * @returns {string[]}
1340
+ */
1341
+ function parseCommandArgs(raw) {
1342
+ if (!raw || !raw.trim()) return [];
1343
+ const parts = [];
1344
+ const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
1345
+ let match;
1346
+ while ((match = re.exec(raw)) !== null) {
1347
+ const token = match[1] ?? match[2] ?? match[3] ?? "";
1348
+ parts.push(token.replace(/\\(["'\\])/g, "$1"));
1349
+ }
1350
+ return parts;
1351
+ }
1352
+
1353
+ /**
1354
+ * @type {import('@opencode-ai/plugin').Plugin}
1355
+ */
1356
+ export async function AnthropicAuthPlugin({ client }) {
1357
+ const config = loadConfig();
1358
+ const signatureEmulationEnabled = config.signature_emulation.enabled;
1359
+ const promptCompactionMode = config.signature_emulation.prompt_compaction === "off" ? "off" : "minimal";
1360
+ const shouldFetchClaudeCodeVersion =
1361
+ signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
1362
+
1363
+ /** @type {AccountManager | null} */
1364
+ let accountManager = null;
1365
+
1366
+ /** Track account usage toasts; show once per account change (including first use). */
1367
+ let lastToastedIndex = -1;
1368
+ /** @type {Map<string, number>} */
1369
+ const debouncedToastTimestamps = new Map();
1370
+
1371
+ /** @type {Map<string, Promise<string>>} */
1372
+ const refreshInFlight = new Map();
1373
+
1374
+ /**
1375
+ * Pending slash-command OAuth flows keyed by session ID.
1376
+ * @type {Map<string, { mode: "login" | "reauth", verifier: string, targetIndex?: number, createdAt: number }>}
1377
+ */
1378
+ const pendingSlashOAuth = new Map();
1379
+
1380
+ /**
1381
+ * Send an informational message into the current session.
1382
+ * @param {string} sessionID
1383
+ * @param {string} text
1384
+ */
1385
+ async function sendCommandMessage(sessionID, text) {
1386
+ await client.session?.prompt({
1387
+ path: { id: sessionID },
1388
+ body: {
1389
+ noReply: true,
1390
+ parts: [{ type: "text", text, ignored: true }],
1391
+ },
1392
+ });
1393
+ }
1394
+
1395
+ /**
1396
+ * Keep in-memory AccountManager in sync with disk mutations made via slash commands.
1397
+ */
1398
+ async function reloadAccountManagerFromDisk() {
1399
+ if (!accountManager) return;
1400
+ accountManager = await AccountManager.load(config, null);
1401
+ }
1402
+
1403
+ /**
1404
+ * Persist OAuth credentials into OpenCode auth storage for immediate compatibility.
1405
+ * @param {string} refresh
1406
+ * @param {string} access
1407
+ * @param {number} expires
1408
+ */
1409
+ async function persistOpenCodeAuth(refresh, access, expires) {
1410
+ await client.auth.set({
1411
+ path: { id: "anthropic" },
1412
+ body: { type: "oauth", refresh, access, expires },
1413
+ });
1414
+ }
1415
+
1416
+ /**
1417
+ * Remove expired pending OAuth flows.
1418
+ */
1419
+ function pruneExpiredPendingOAuth() {
1420
+ const now = Date.now();
1421
+ for (const [sessionID, pending] of pendingSlashOAuth.entries()) {
1422
+ if (now - pending.createdAt > PENDING_OAUTH_TTL_MS) {
1423
+ pendingSlashOAuth.delete(sessionID);
1424
+ }
1425
+ }
1426
+ }
1427
+
1428
+ /**
1429
+ * Execute CLI main(argv) in-process and capture console output.
1430
+ * @param {string[]} argv
1431
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
1432
+ */
1433
+ async function runCliCommand(argv) {
1434
+ const logs = [];
1435
+ const errors = [];
1436
+
1437
+ /** @type {number} */
1438
+ let code = 1;
1439
+ try {
1440
+ const { main: cliMain } = await import("./cli.mjs");
1441
+ code = await cliMain(argv, {
1442
+ io: {
1443
+ log: (...args) => logs.push(args.join(" ")),
1444
+ error: (...args) => errors.push(args.join(" ")),
1445
+ },
1446
+ });
1447
+ } catch (err) {
1448
+ errors.push(err instanceof Error ? err.message : String(err));
1449
+ }
1450
+
1451
+ return {
1452
+ code,
1453
+ stdout: stripAnsi(logs.join("\n")).trim(),
1454
+ stderr: stripAnsi(errors.join("\n")).trim(),
1455
+ };
1456
+ }
1457
+
1458
+ /**
1459
+ * Start a slash-command OAuth flow and store verifier in-memory.
1460
+ * @param {string} sessionID
1461
+ * @param {"login" | "reauth"} mode
1462
+ * @param {number} [targetIndex]
1463
+ */
1464
+ async function startSlashOAuth(sessionID, mode, targetIndex) {
1465
+ pruneExpiredPendingOAuth();
1466
+ const { url, verifier } = await authorize("max");
1467
+ pendingSlashOAuth.set(sessionID, {
1468
+ mode,
1469
+ verifier,
1470
+ targetIndex,
1471
+ createdAt: Date.now(),
1472
+ });
1473
+
1474
+ const action = mode === "login" ? "login" : `reauth ${targetIndex + 1}`;
1475
+ const followup =
1476
+ mode === "login" ? "/anthropic login complete <code#state>" : "/anthropic reauth complete <code#state>";
1477
+
1478
+ await sendCommandMessage(
1479
+ sessionID,
1480
+ [
1481
+ "▣ Anthropic OAuth",
1482
+ "",
1483
+ `Started ${action} flow.`,
1484
+ "Open this URL in your browser:",
1485
+ url,
1486
+ "",
1487
+ `Then run: ${followup}`,
1488
+ "(Paste the full authorization code, including #state)",
1489
+ ].join("\n"),
1490
+ );
1491
+ }
1492
+
1493
+ /**
1494
+ * Complete a pending slash-command OAuth flow.
1495
+ * @param {string} sessionID
1496
+ * @param {string} code
1497
+ * @returns {Promise<{ ok: boolean, message: string }>}
1498
+ */
1499
+ async function completeSlashOAuth(sessionID, code) {
1500
+ const pending = pendingSlashOAuth.get(sessionID);
1501
+ if (!pending) {
1502
+ pruneExpiredPendingOAuth();
1503
+ return {
1504
+ ok: false,
1505
+ message: "No pending OAuth flow. Start with /anthropic login or /anthropic reauth <N>.",
1506
+ };
1507
+ }
1508
+
1509
+ if (Date.now() - pending.createdAt > PENDING_OAUTH_TTL_MS) {
1510
+ pendingSlashOAuth.delete(sessionID);
1511
+ return {
1512
+ ok: false,
1513
+ message: "Pending OAuth flow expired. Start again with /anthropic login or /anthropic reauth <N>.",
1514
+ };
1515
+ }
1516
+
1517
+ const credentials = await exchange(code, pending.verifier);
1518
+ if (credentials.type === "failed") {
1519
+ return { ok: false, message: "Token exchange failed. The code may be invalid or expired." };
1520
+ }
1521
+
1522
+ const stored = (await loadAccounts()) || { version: 1, accounts: [], activeIndex: 0 };
1523
+
1524
+ if (pending.mode === "login") {
1525
+ const existingIdx = stored.accounts.findIndex((acc) => acc.refreshToken === credentials.refresh);
1526
+ if (existingIdx >= 0) {
1527
+ const acc = stored.accounts[existingIdx];
1528
+ acc.access = credentials.access;
1529
+ acc.expires = credentials.expires;
1530
+ if (credentials.email) acc.email = credentials.email;
1531
+ acc.enabled = true;
1532
+ acc.consecutiveFailures = 0;
1533
+ acc.lastFailureTime = null;
1534
+ acc.rateLimitResetTimes = {};
1535
+ await saveAccounts(stored);
1536
+ await persistOpenCodeAuth(acc.refreshToken, acc.access, acc.expires);
1537
+ await reloadAccountManagerFromDisk();
1538
+ pendingSlashOAuth.delete(sessionID);
1539
+ const name = acc.email || `Account ${existingIdx + 1}`;
1540
+ return { ok: true, message: `Updated existing account #${existingIdx + 1} (${name}).` };
1541
+ }
1542
+
1543
+ if (stored.accounts.length >= 10) {
1544
+ return { ok: false, message: "Maximum of 10 accounts reached. Remove one first." };
1545
+ }
1546
+
1547
+ const now = Date.now();
1548
+ stored.accounts.push({
1549
+ id: `${now}:${credentials.refresh.slice(0, 12)}`,
1550
+ email: credentials.email,
1551
+ refreshToken: credentials.refresh,
1552
+ access: credentials.access,
1553
+ expires: credentials.expires,
1554
+ addedAt: now,
1555
+ lastUsed: 0,
1556
+ enabled: true,
1557
+ rateLimitResetTimes: {},
1558
+ consecutiveFailures: 0,
1559
+ lastFailureTime: null,
1560
+ stats: createDefaultStats(now),
1561
+ });
1562
+ await saveAccounts(stored);
1563
+ const newAccount = stored.accounts[stored.accounts.length - 1];
1564
+ await persistOpenCodeAuth(newAccount.refreshToken, newAccount.access, newAccount.expires);
1565
+ await reloadAccountManagerFromDisk();
1566
+ pendingSlashOAuth.delete(sessionID);
1567
+ const label = credentials.email || `Account ${stored.accounts.length}`;
1568
+ return { ok: true, message: `Added account #${stored.accounts.length} (${label}).` };
1569
+ }
1570
+
1571
+ // reauth flow
1572
+ const idx = pending.targetIndex ?? -1;
1573
+ if (idx < 0 || idx >= stored.accounts.length) {
1574
+ pendingSlashOAuth.delete(sessionID);
1575
+ return { ok: false, message: "Target account no longer exists. Start reauth again." };
1576
+ }
1577
+
1578
+ const existing = stored.accounts[idx];
1579
+ existing.refreshToken = credentials.refresh;
1580
+ existing.access = credentials.access;
1581
+ existing.expires = credentials.expires;
1582
+ if (credentials.email) existing.email = credentials.email;
1583
+ existing.enabled = true;
1584
+ existing.consecutiveFailures = 0;
1585
+ existing.lastFailureTime = null;
1586
+ existing.rateLimitResetTimes = {};
1587
+
1588
+ await saveAccounts(stored);
1589
+ await persistOpenCodeAuth(existing.refreshToken, existing.access, existing.expires);
1590
+ await reloadAccountManagerFromDisk();
1591
+ pendingSlashOAuth.delete(sessionID);
1592
+ const name = existing.email || `Account ${idx + 1}`;
1593
+ return { ok: true, message: `Re-authenticated account #${idx + 1} (${name}).` };
1594
+ }
1595
+
1596
+ /**
1597
+ * Handle /anthropic slash commands.
1598
+ *
1599
+ * Supported examples:
1600
+ * /anthropic
1601
+ * /anthropic usage
1602
+ * /anthropic switch 2
1603
+ * /anthropic login
1604
+ * /anthropic login complete <code#state>
1605
+ * /anthropic reauth 1
1606
+ * /anthropic reauth complete <code#state>
1607
+ *
1608
+ * @param {{ command: string, arguments?: string, sessionID: string }} input
1609
+ */
1610
+ async function handleAnthropicSlashCommand(input) {
1611
+ const args = parseCommandArgs(input.arguments || "");
1612
+ const primary = (args[0] || "list").toLowerCase();
1613
+
1614
+ // Friendly alias: /anthropic usage -> list
1615
+ if (primary === "usage") {
1616
+ const result = await runCliCommand(["list"]);
1617
+ const heading = result.code === 0 ? "▣ Anthropic" : "▣ Anthropic (error)";
1618
+ const body = result.stdout || result.stderr || "No output.";
1619
+ await sendCommandMessage(input.sessionID, [heading, "", body].join("\n"));
1620
+ await reloadAccountManagerFromDisk();
1621
+ return;
1622
+ }
1623
+
1624
+ // Two-step login flow for slash commands
1625
+ if (primary === "login") {
1626
+ if ((args[1] || "").toLowerCase() === "complete") {
1627
+ const code = args.slice(2).join(" ").trim();
1628
+ if (!code) {
1629
+ await sendCommandMessage(
1630
+ input.sessionID,
1631
+ "▣ Anthropic OAuth\n\nMissing code. Use: /anthropic login complete <code#state>",
1632
+ );
1633
+ return;
1634
+ }
1635
+ const result = await completeSlashOAuth(input.sessionID, code);
1636
+ const heading = result.ok ? "▣ Anthropic OAuth" : "▣ Anthropic OAuth (error)";
1637
+ await sendCommandMessage(input.sessionID, `${heading}\n\n${result.message}`);
1638
+ return;
1639
+ }
1640
+
1641
+ await startSlashOAuth(input.sessionID, "login");
1642
+ return;
1643
+ }
1644
+
1645
+ // Two-step reauth flow for slash commands
1646
+ if (primary === "reauth") {
1647
+ if ((args[1] || "").toLowerCase() === "complete") {
1648
+ const code = args.slice(2).join(" ").trim();
1649
+ if (!code) {
1650
+ await sendCommandMessage(
1651
+ input.sessionID,
1652
+ "▣ Anthropic OAuth\n\nMissing code. Use: /anthropic reauth complete <code#state>",
1653
+ );
1654
+ return;
1655
+ }
1656
+ const result = await completeSlashOAuth(input.sessionID, code);
1657
+ const heading = result.ok ? "▣ Anthropic OAuth" : "▣ Anthropic OAuth (error)";
1658
+ await sendCommandMessage(input.sessionID, `${heading}\n\n${result.message}`);
1659
+ return;
1660
+ }
1661
+
1662
+ const n = parseInt(args[1], 10);
1663
+ if (Number.isNaN(n) || n < 1) {
1664
+ await sendCommandMessage(
1665
+ input.sessionID,
1666
+ "▣ Anthropic OAuth\n\nProvide an account number. Example: /anthropic reauth 1",
1667
+ );
1668
+ return;
1669
+ }
1670
+ const stored = await loadAccounts();
1671
+ if (!stored || stored.accounts.length === 0) {
1672
+ await sendCommandMessage(input.sessionID, "▣ Anthropic OAuth (error)\n\nNo accounts configured.");
1673
+ return;
1674
+ }
1675
+ const idx = n - 1;
1676
+ if (idx >= stored.accounts.length) {
1677
+ await sendCommandMessage(
1678
+ input.sessionID,
1679
+ `▣ Anthropic OAuth (error)\n\nAccount ${n} does not exist. You have ${stored.accounts.length} account(s).`,
1680
+ );
1681
+ return;
1682
+ }
1683
+
1684
+ await startSlashOAuth(input.sessionID, "reauth", idx);
1685
+ return;
1686
+ }
1687
+
1688
+ // Interactive CLI command is not compatible with slash flow.
1689
+ if (primary === "manage" || primary === "mg") {
1690
+ await sendCommandMessage(
1691
+ input.sessionID,
1692
+ "▣ Anthropic\n\n`manage` is interactive-only. Use granular slash commands (switch/enable/disable/remove/reset) or run `opencode-anthropic-auth manage` in a terminal.",
1693
+ );
1694
+ return;
1695
+ }
1696
+
1697
+ // Route remaining commands through the CLI command surface.
1698
+ const cliArgs = [...args];
1699
+ if (cliArgs.length === 0) cliArgs.push("list");
1700
+
1701
+ // Avoid readline prompts in slash mode.
1702
+ if (
1703
+ (primary === "remove" || primary === "rm" || primary === "logout" || primary === "lo") &&
1704
+ !cliArgs.includes("--force")
1705
+ ) {
1706
+ cliArgs.push("--force");
1707
+ }
1708
+
1709
+ const result = await runCliCommand(cliArgs);
1710
+ const heading = result.code === 0 ? "▣ Anthropic" : "▣ Anthropic (error)";
1711
+ const body = result.stdout || result.stderr || "No output.";
1712
+ await sendCommandMessage(input.sessionID, [heading, "", body].join("\n"));
1713
+ await reloadAccountManagerFromDisk();
1714
+ }
1715
+
1716
+ /**
1717
+ * Show a toast in the TUI. Silently fails if TUI is not running.
1718
+ * @param {string} message
1719
+ * @param {"info" | "success" | "warning" | "error"} variant
1720
+ * @param {{debounceKey?: string}} [options]
1721
+ */
1722
+ async function toast(message, variant = "info", options = {}) {
1723
+ // Quiet mode suppresses non-error toasts
1724
+ if (config.toasts.quiet && variant !== "error") return;
1725
+
1726
+ // Debounce configured toast categories to reduce chatter.
1727
+ if (variant !== "error" && options.debounceKey) {
1728
+ const minGapMs = Math.max(0, config.toasts.debounce_seconds) * 1000;
1729
+ if (minGapMs > 0) {
1730
+ const now = Date.now();
1731
+ const lastAt = debouncedToastTimestamps.get(options.debounceKey) ?? 0;
1732
+ if (now - lastAt < minGapMs) {
1733
+ return;
1734
+ }
1735
+ debouncedToastTimestamps.set(options.debounceKey, now);
1736
+ }
1737
+ }
1738
+
1739
+ try {
1740
+ await client.tui?.showToast({ body: { message, variant } });
1741
+ } catch {
1742
+ // TUI may not be available
1743
+ }
1744
+ }
1745
+
1746
+ /**
1747
+ * Emit debug logs when config.debug is enabled.
1748
+ * @param {...unknown} args
1749
+ */
1750
+ function debugLog(...args) {
1751
+ if (!config.debug) return;
1752
+ console.error("[opencode-anthropic-auth]", ...args);
1753
+ }
1754
+
1755
+ let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
1756
+ const signatureSessionId = randomUUID();
1757
+ const signatureUserId = getOrCreateSignatureUserId();
1758
+ if (shouldFetchClaudeCodeVersion) {
1759
+ fetchLatestClaudeCodeVersion()
1760
+ .then((version) => {
1761
+ if (!version) return;
1762
+ claudeCliVersion = version;
1763
+ debugLog("resolved claude-code version from npm", version);
1764
+ })
1765
+ .catch(() => {
1766
+ // Ignore fetch errors and keep fallback version.
1767
+ });
1768
+ }
1769
+
1770
+ /**
1771
+ * Refresh a specific account token with single-flight protection.
1772
+ * Prevents concurrent refresh races from disabling healthy accounts.
1773
+ * @param {import('./lib/accounts.mjs').ManagedAccount} account
1774
+ * @returns {Promise<string>}
1775
+ */
1776
+ async function refreshAccountTokenSingleFlight(account) {
1777
+ const key = account.id;
1778
+ const existing = refreshInFlight.get(key);
1779
+ if (existing) return existing;
1780
+
1781
+ const p = (async () => {
1782
+ try {
1783
+ return await refreshAccountToken(account, client);
1784
+ } finally {
1785
+ if (refreshInFlight.get(key) === p) {
1786
+ refreshInFlight.delete(key);
1787
+ }
1788
+ }
1789
+ })();
1790
+
1791
+ refreshInFlight.set(key, p);
1792
+ return p;
1793
+ }
1794
+
1795
+ return {
1796
+ // A1-A4: System prompt transform (unchanged)
1797
+ "experimental.chat.system.transform": (input, output) => {
1798
+ const prefix = CLAUDE_CODE_IDENTITY_STRING;
1799
+ if (!signatureEmulationEnabled && input.model?.providerID === "anthropic") {
1800
+ output.system.unshift(prefix);
1801
+ if (output.system[1]) output.system[1] = prefix + "\n\n" + output.system[1];
1802
+ }
1803
+ },
1804
+ config: async (input) => {
1805
+ input.command ??= {};
1806
+ input.command["anthropic"] = {
1807
+ template: "/anthropic",
1808
+ description: "Manage Anthropic multi-account auth (status, usage, switch, login, reauth, logout)",
1809
+ };
1810
+ },
1811
+ "command.execute.before": async (input) => {
1812
+ if (input.command !== "anthropic") return;
1813
+
1814
+ try {
1815
+ await handleAnthropicSlashCommand(input);
1816
+ } catch (err) {
1817
+ const message = err instanceof Error ? err.message : String(err);
1818
+ await sendCommandMessage(input.sessionID, `▣ Anthropic (error)\n\n${message}`);
1819
+ }
1820
+
1821
+ throw new Error(ANTHROPIC_COMMAND_HANDLED);
1822
+ },
1823
+ auth: {
1824
+ provider: "anthropic",
1825
+ async loader(getAuth, provider) {
1826
+ const auth = await getAuth();
1827
+ if (auth.type === "oauth") {
1828
+ // B1-B2: Zero out cost for max plan and optionally override context limits.
1829
+ for (const model of Object.values(provider.models)) {
1830
+ model.cost = {
1831
+ input: 0,
1832
+ output: 0,
1833
+ cache: { read: 0, write: 0 },
1834
+ };
1835
+
1836
+ // Override context limits for 1M-window models so OpenCode
1837
+ // triggers compaction at the right threshold instead of relying
1838
+ // on potentially stale models.dev data.
1839
+ if (config.override_model_limits.enabled && (hasOneMillionContext(model.id) || isOpus46Model(model.id))) {
1840
+ model.limit = {
1841
+ ...(model.limit ?? {}),
1842
+ context: config.override_model_limits.context,
1843
+ ...(config.override_model_limits.output > 0 ? { output: config.override_model_limits.output } : {}),
1844
+ };
1845
+ }
1846
+ }
1847
+
1848
+ // Initialize AccountManager from disk + OpenCode auth fallback
1849
+ accountManager = await AccountManager.load(config, {
1850
+ refresh: auth.refresh,
1851
+ access: auth.access,
1852
+ expires: auth.expires,
1853
+ });
1854
+
1855
+ // If we bootstrapped from auth.json and have no stored accounts file,
1856
+ // save immediately to create it (debounced save may not fire in time)
1857
+ if (accountManager.getAccountCount() > 0) {
1858
+ await accountManager.saveToDisk();
1859
+ }
1860
+
1861
+ return {
1862
+ apiKey: "",
1863
+ /**
1864
+ * @param {any} input
1865
+ * @param {any} init
1866
+ */
1867
+ async fetch(input, init) {
1868
+ // Re-read auth for non-oauth fallback
1869
+ const currentAuth = await getAuth();
1870
+ if (currentAuth.type !== "oauth") return fetch(input, init);
1871
+
1872
+ // Transform URL once (shared across retries)
1873
+ const requestInit = init ?? {};
1874
+ const { requestInput, requestUrl } = transformRequestUrl(input);
1875
+ const requestMethod = String(
1876
+ requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST"),
1877
+ ).toUpperCase();
1878
+ let showUsageToast;
1879
+ try {
1880
+ showUsageToast = new URL(requestUrl).pathname === "/v1/messages" && requestMethod === "POST";
1881
+ } catch {
1882
+ showUsageToast = false;
1883
+ }
1884
+
1885
+ let lastError = null;
1886
+ const transientRefreshSkips = new Set();
1887
+
1888
+ // Sync with CLI changes at request start.
1889
+ if (accountManager) {
1890
+ await accountManager.syncActiveIndexFromDisk();
1891
+ }
1892
+
1893
+ // Try each account at most once. If the error is account-specific,
1894
+ // switch to the next account. If it's service-wide, return immediately.
1895
+ const maxAttempts = accountManager.getTotalAccountCount();
1896
+
1897
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1898
+ // Select account
1899
+ const account = accountManager.getCurrentAccount(transientRefreshSkips);
1900
+
1901
+ // Toast account usage on first use and whenever the account changes
1902
+ if (showUsageToast && account && accountManager) {
1903
+ const currentIndex = accountManager.getCurrentIndex();
1904
+ if (currentIndex !== lastToastedIndex) {
1905
+ const name = account.email || `Account ${currentIndex + 1}`;
1906
+ const total = accountManager.getAccountCount();
1907
+ const msg = total > 1 ? `Claude: ${name} (${currentIndex + 1}/${total})` : `Claude: ${name}`;
1908
+ await toast(msg, "info", { debounceKey: "account-usage" });
1909
+ lastToastedIndex = currentIndex;
1910
+ }
1911
+ }
1912
+
1913
+ if (!account) {
1914
+ const enabledCount = accountManager.getAccountCount();
1915
+ if (enabledCount === 0) {
1916
+ throw new Error(
1917
+ "No enabled Anthropic accounts available. Enable one with 'opencode-anthropic-auth enable <N>'.",
1918
+ );
1919
+ }
1920
+ // All accounts excluded (transient refresh failures) — give up
1921
+ throw new Error("No available Anthropic account for request.");
1922
+ }
1923
+
1924
+ // Determine access token
1925
+ let accessToken;
1926
+ // Per-account token refresh
1927
+ if (!account.access || !account.expires || account.expires < Date.now()) {
1928
+ try {
1929
+ accessToken = await refreshAccountTokenSingleFlight(account);
1930
+ // Persist updated tokens (especially if refresh token rotated)
1931
+ accountManager.requestSaveToDisk();
1932
+ } 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);
1956
+ }
1957
+ lastError = err;
1958
+ continue; // Try next account
1959
+ }
1960
+ } else {
1961
+ accessToken = account.access;
1962
+ }
1963
+
1964
+ const body = transformRequestBody(
1965
+ requestInit.body,
1966
+ {
1967
+ enabled: signatureEmulationEnabled,
1968
+ claudeCliVersion,
1969
+ promptCompactionMode,
1970
+ },
1971
+ {
1972
+ persistentUserId: signatureUserId,
1973
+ sessionId: signatureSessionId,
1974
+ accountId: getAccountIdentifier(account),
1975
+ },
1976
+ );
1977
+ logTransformedSystemPrompt(body);
1978
+
1979
+ // Build headers with the selected account's token
1980
+ const requestHeaders = buildRequestHeaders(input, requestInit, accessToken, body, requestUrl, {
1981
+ enabled: signatureEmulationEnabled,
1982
+ claudeCliVersion,
1983
+ });
1984
+ // Execute the request
1985
+ let response;
1986
+ try {
1987
+ response = await fetch(requestInput, {
1988
+ ...requestInit,
1989
+ body,
1990
+ headers: requestHeaders,
1991
+ });
1992
+ } catch (err) {
1993
+ const fetchError = err instanceof Error ? err : new Error(String(err));
1994
+
1995
+ if (accountManager && account) {
1996
+ accountManager.markFailure(account);
1997
+ transientRefreshSkips.add(account.index);
1998
+ lastError = fetchError;
1999
+ debugLog("request fetch threw, trying next account", {
2000
+ accountIndex: account.index,
2001
+ message: fetchError.message,
2002
+ });
2003
+ continue;
2004
+ }
2005
+
2006
+ throw fetchError;
2007
+ }
2008
+
2009
+ // On error, check if it's account-specific or service-wide
2010
+ if (!response.ok && accountManager && account) {
2011
+ let errorBody = null;
2012
+ try {
2013
+ errorBody = await response.clone().text();
2014
+ } catch {
2015
+ // Ignore read errors
2016
+ }
2017
+
2018
+ if (isAccountSpecificError(response.status, errorBody)) {
2019
+ // Account-specific: mark this account, try the next one
2020
+ const reason = parseRateLimitReason(response.status, errorBody);
2021
+ const retryAfterMs = parseRetryAfterHeader(response);
2022
+ const authOrPermissionIssue = reason === "AUTH_FAILED";
2023
+
2024
+ // Auth failures should force token refresh on next use.
2025
+ if (reason === "AUTH_FAILED") {
2026
+ account.access = undefined;
2027
+ account.expires = undefined;
2028
+ }
2029
+
2030
+ debugLog("account-specific error, switching account", {
2031
+ accountIndex: account.index,
2032
+ status: response.status,
2033
+ reason,
2034
+ });
2035
+
2036
+ accountManager.markRateLimited(account, reason, authOrPermissionIssue ? null : retryAfterMs);
2037
+
2038
+ const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
2039
+ const total = accountManager.getAccountCount();
2040
+ if (total > 1) {
2041
+ const switchReason = formatSwitchReason(response.status, reason);
2042
+ await toast(`${name} ${switchReason}, switching account`, "warning", {
2043
+ debounceKey: "account-switch",
2044
+ });
2045
+ }
2046
+
2047
+ continue; // Try next account immediately
2048
+ }
2049
+
2050
+ // Service-wide error (529, 503, 500, etc.) — return to caller,
2051
+ // switching accounts won't help
2052
+ debugLog("service-wide response error, returning directly", {
2053
+ status: response.status,
2054
+ });
2055
+ return transformResponse(response);
2056
+ }
2057
+
2058
+ // Success
2059
+ if (account && accountManager) {
2060
+ if (response.ok) {
2061
+ accountManager.markSuccess(account);
2062
+ }
2063
+ }
2064
+
2065
+ // Wire usage tracking and mid-stream error detection for SSE responses only.
2066
+ const shouldInspectStream = response.ok && account && accountManager && isEventStreamResponse(response);
2067
+
2068
+ const usageCallback = shouldInspectStream
2069
+ ? (/** @type {UsageStats} */ usage) => {
2070
+ accountManager.recordUsage(account.index, usage);
2071
+ }
2072
+ : null;
2073
+
2074
+ const accountErrorCallback = shouldInspectStream
2075
+ ? (details) => {
2076
+ // Mid-stream account error: mark for NEXT request
2077
+ if (details.invalidateToken) {
2078
+ account.access = undefined;
2079
+ account.expires = undefined;
2080
+ }
2081
+ accountManager.markRateLimited(account, details.reason, null);
2082
+ }
2083
+ : null;
2084
+
2085
+ return transformResponse(response, usageCallback, accountErrorCallback);
2086
+ }
2087
+
2088
+ // All accounts tried
2089
+ if (lastError) throw lastError;
2090
+ throw new Error("All accounts exhausted — no account could serve this request");
2091
+ },
2092
+ };
2093
+ }
2094
+
2095
+ return {};
2096
+ },
2097
+ methods: [
2098
+ {
2099
+ // H1: Claude Pro/Max OAuth — now with multi-account support
2100
+ label: "Claude Pro/Max (multi-account)",
2101
+ type: "oauth",
2102
+ authorize: async () => {
2103
+ // Check for existing accounts
2104
+ const stored = await loadAccounts();
2105
+ if (stored && stored.accounts.length > 0 && accountManager) {
2106
+ const action = await promptAccountMenu(accountManager);
2107
+
2108
+ if (action === "cancel") {
2109
+ return {
2110
+ url: "about:blank",
2111
+ instructions: "Cancelled.",
2112
+ method: "code",
2113
+ callback: async () => ({ type: "failed" }),
2114
+ };
2115
+ }
2116
+
2117
+ if (action === "manage") {
2118
+ await promptManageAccounts(accountManager);
2119
+ await accountManager.saveToDisk();
2120
+ return {
2121
+ url: "about:blank",
2122
+ instructions: "Account management complete. Re-run auth to add accounts.",
2123
+ method: "code",
2124
+ callback: async () => ({ type: "failed" }),
2125
+ };
2126
+ }
2127
+
2128
+ if (action === "fresh") {
2129
+ await clearAccounts();
2130
+ accountManager.clearAll();
2131
+ }
2132
+
2133
+ // action === "add" or "fresh" — fall through to OAuth flow
2134
+ }
2135
+
2136
+ const { url, verifier } = await authorize("max");
2137
+ return {
2138
+ url: url,
2139
+ instructions: "Paste the authorization code here: ",
2140
+ method: "code",
2141
+ callback: async (code) => {
2142
+ const credentials = await exchange(code, verifier);
2143
+ if (credentials.type === "failed") return credentials;
2144
+
2145
+ // Initialize AccountManager if not yet loaded (first login —
2146
+ // loader() hasn't run yet because auth hasn't completed)
2147
+ if (!accountManager) {
2148
+ accountManager = await AccountManager.load(config, null);
2149
+ }
2150
+
2151
+ // Add to account pool and persist immediately
2152
+ const countBefore = accountManager.getAccountCount();
2153
+ accountManager.addAccount(
2154
+ credentials.refresh,
2155
+ credentials.access,
2156
+ credentials.expires,
2157
+ credentials.email,
2158
+ );
2159
+ await accountManager.saveToDisk();
2160
+
2161
+ // Toast the result
2162
+ const total = accountManager.getAccountCount();
2163
+ const name = credentials.email || "account";
2164
+ if (countBefore > 0) {
2165
+ await toast(`Added ${name} — ${total} accounts`, "success");
2166
+ } else {
2167
+ await toast(`Authenticated (${name})`, "success");
2168
+ }
2169
+
2170
+ return credentials;
2171
+ },
2172
+ };
2173
+ },
2174
+ },
2175
+ {
2176
+ // H2: Create an API Key (unchanged)
2177
+ label: "Create an API Key",
2178
+ type: "oauth",
2179
+ authorize: async () => {
2180
+ const { url, verifier } = await authorize("console");
2181
+ return {
2182
+ url: url,
2183
+ instructions: "Paste the authorization code here: ",
2184
+ method: "code",
2185
+ callback: async (code) => {
2186
+ const credentials = await exchange(code, verifier);
2187
+ if (credentials.type === "failed") return credentials;
2188
+ const result = await fetch(`https://api.anthropic.com/api/oauth/claude_cli/create_api_key`, {
2189
+ method: "POST",
2190
+ headers: {
2191
+ "Content-Type": "application/json",
2192
+ authorization: `Bearer ${credentials.access}`,
2193
+ },
2194
+ }).then((r) => r.json());
2195
+ return { type: "success", key: result.raw_key };
2196
+ },
2197
+ };
2198
+ },
2199
+ },
2200
+ {
2201
+ // H3: Manual API Key (unchanged)
2202
+ provider: "anthropic",
2203
+ label: "Manually enter API Key",
2204
+ type: "api",
2205
+ },
2206
+ ],
2207
+ },
2208
+ };
2209
+ }