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/.github/workflows/publish.yml +31 -0
- package/.husky/pre-commit +2 -0
- package/.husky/pre-push +3 -0
- package/.planning/CLI-ENHANCEMENTS.md +221 -0
- package/.planning/codebase/ARCHITECTURE.md +201 -0
- package/.planning/codebase/CONCERNS.md +219 -0
- package/.planning/codebase/CONVENTIONS.md +335 -0
- package/.planning/codebase/INTEGRATIONS.md +215 -0
- package/.planning/codebase/STACK.md +160 -0
- package/.planning/codebase/STRUCTURE.md +244 -0
- package/.planning/codebase/TESTING.md +456 -0
- package/.prettierignore +3 -0
- package/.prettierrc +10 -0
- package/CONTRIBUTING.md +525 -0
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/agents.md +22 -0
- package/bun.lock +43 -0
- package/cli.mjs +1773 -0
- package/cli.test.mjs +1797 -0
- package/docs/mimese-http-header-system-prompt.md +326 -0
- package/docs/plans/afcw-upstream-watcher.json +495 -0
- package/eslint.config.mjs +69 -0
- package/index.mjs +2209 -0
- package/index.test.mjs +2690 -0
- package/lib/accounts.mjs +670 -0
- package/lib/accounts.test.mjs +782 -0
- package/lib/backoff.mjs +217 -0
- package/lib/backoff.test.mjs +296 -0
- package/lib/config.mjs +366 -0
- package/lib/config.test.mjs +307 -0
- package/lib/oauth.mjs +103 -0
- package/lib/rotation.mjs +287 -0
- package/lib/rotation.test.mjs +346 -0
- package/lib/storage.mjs +265 -0
- package/lib/storage.test.mjs +470 -0
- package/nul +1 -0
- package/package.json +40 -0
- package/script/publish.ts +29 -0
- package/scripts/build.mjs +34 -0
- package/scripts/install.mjs +319 -0
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
|
+
}
|