opencode-anthropic 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,575 @@
1
+ // End-to-end tests for the opencode-anthropic plugin. They run the real plugin
2
+ // code against a fake OpenCode context and a mock token server; no network
3
+ // access or real credentials are involved.
4
+ import http from "node:http";
5
+ import assert from "node:assert/strict";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import test, { after } from "node:test";
10
+
11
+ const workdir = mkdtempSync(join(tmpdir(), "opencode-anthropic-test-"));
12
+ process.env.XDG_DATA_HOME = join(workdir, "xdg");
13
+
14
+ let tokenMode = "rotate";
15
+ let tokenRequests = [];
16
+ const tokenServer = http.createServer((request, response) => {
17
+ let body = "";
18
+ request.on("data", (chunk) => (body += chunk));
19
+ request.on("end", () => {
20
+ tokenRequests.push(body);
21
+ if (tokenMode === "fail") {
22
+ response.writeHead(400, { "content-type": "application/json" });
23
+ response.end(JSON.stringify({ error: "invalid_grant" }));
24
+ return;
25
+ }
26
+ response.writeHead(200, { "content-type": "application/json" });
27
+ response.end(
28
+ JSON.stringify({
29
+ access_token: `access-${tokenRequests.length}`,
30
+ refresh_token: `refresh-${tokenRequests.length}`,
31
+ expires_in: 3600,
32
+ }),
33
+ );
34
+ });
35
+ });
36
+ await new Promise((resolve) => tokenServer.listen(0, "127.0.0.1", resolve));
37
+ process.env.ANTHROPIC_TOKEN_URL = `http://127.0.0.1:${tokenServer.address().port}/token`;
38
+
39
+ const { default: plugin, LOGIN_HINT } = await import("../index.js");
40
+ const { SYSTEM_IDENTITY, buildToolNameMaps, rewriteEvent, transformResponseStream, sessionUUID, translateToolInput } =
41
+ await import("../transform.mjs");
42
+ const { parseRetryAfter } = await import("../retry.mjs");
43
+
44
+ after(() => {
45
+ tokenServer.close();
46
+ rmSync(workdir, { recursive: true, force: true });
47
+ });
48
+
49
+ function makeContext({ connection = null, credential = null, options = {}, stored = null } = {}) {
50
+ const storage = new Map();
51
+ if (stored) storage.set("tokens", stored);
52
+ const hooks = new Map();
53
+ const registrations = [];
54
+ const state = { connection, credential };
55
+ const ctx = {
56
+ options,
57
+ storage: {
58
+ get: async (key) => storage.get(key),
59
+ set: async (key, value) => void storage.set(key, value),
60
+ },
61
+ integration: {
62
+ connection: {
63
+ active: async () => state.connection,
64
+ resolve: async () => state.credential,
65
+ },
66
+ transform: async (callback) => {
67
+ callback({ method: { update: (input) => registrations.push(input) } });
68
+ return { dispose: async () => {} };
69
+ },
70
+ },
71
+ session: {
72
+ hook: async (name, callback) => {
73
+ if (!hooks.has(name)) hooks.set(name, []);
74
+ hooks.get(name).push(callback);
75
+ return { dispose: async () => {} };
76
+ },
77
+ },
78
+ tool: {
79
+ hook: async (name, callback) => {
80
+ const key = `tool:${name}`;
81
+ if (!hooks.has(key)) hooks.set(key, []);
82
+ hooks.get(key).push(callback);
83
+ return { dispose: async () => {} };
84
+ },
85
+ },
86
+ };
87
+ return {
88
+ ctx,
89
+ state,
90
+ storage,
91
+ registrations,
92
+ async request(body, kind = "primary") {
93
+ const event = {
94
+ sessionID: "ses_test",
95
+ kind,
96
+ request: new Request("https://api.anthropic.com/v1/messages", {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json", "x-api-key": "must-be-removed" },
99
+ body: JSON.stringify(body),
100
+ }),
101
+ };
102
+ for (const hook of hooks.get("http.request") ?? []) await hook(event);
103
+ return event.request;
104
+ },
105
+ async respond(response) {
106
+ const event = { response };
107
+ for (const hook of hooks.get("http.response") ?? []) await hook(event);
108
+ return event.response;
109
+ },
110
+ async retry(error, attempt = 2, decision = { retry: false }) {
111
+ const event = { error, attempt, decision };
112
+ for (const hook of hooks.get("retry") ?? []) await hook(event);
113
+ return event.decision;
114
+ },
115
+ async toolBefore(tool, input) {
116
+ const event = { tool, input };
117
+ for (const hook of hooks.get("tool:execute.before") ?? []) await hook(event);
118
+ return event.input;
119
+ },
120
+ };
121
+ }
122
+
123
+ const oauthConnection = (id) => ({ type: "credential", id, label: "Claude Pro/Max", method: "oauth" });
124
+ const keyConnection = { type: "credential", id: "cred_key", label: "API key", method: "key" };
125
+ const expiredOAuth = {
126
+ type: "oauth",
127
+ methodID: "claude-pro-max",
128
+ access: "stale-access",
129
+ refresh: "old-refresh",
130
+ expires: Date.now() - 1000,
131
+ };
132
+
133
+ function sampleBody() {
134
+ return {
135
+ model: "claude-sonnet-4-5",
136
+ system: [{ type: "text", text: "You are an AI agent running in OpenCode", cache_control: { type: "ephemeral" } }],
137
+ tools: [
138
+ { name: "read" },
139
+ { name: "todowrite" },
140
+ { name: "compress" },
141
+ { name: "question" },
142
+ { name: "shell" },
143
+ { name: "subagent" },
144
+ ],
145
+ messages: [{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "write", input: {} }] }],
146
+ stream: true,
147
+ };
148
+ }
149
+
150
+ async function captureConsoleErrors(run) {
151
+ const errors = [];
152
+ const original = console.error;
153
+ console.error = (...args) => errors.push(args.join(" "));
154
+ try {
155
+ await run();
156
+ } finally {
157
+ console.error = original;
158
+ }
159
+ return errors;
160
+ }
161
+
162
+ test("refreshes expired OAuth tokens, transforms the request, and caches the result", async () => {
163
+ tokenMode = "rotate";
164
+ tokenRequests = [];
165
+ const fake = makeContext({ connection: oauthConnection("cred_refresh"), credential: expiredOAuth });
166
+ await plugin.setup(fake.ctx);
167
+
168
+ const request = await fake.request(sampleBody());
169
+ assert.equal(request.headers.get("authorization"), "Bearer access-1", "fresh bearer injected");
170
+ assert.equal(request.headers.get("x-api-key"), null, "x-api-key removed");
171
+ assert.equal(request.headers.get("x-app"), "cli");
172
+ assert.match(request.headers.get("user-agent"), /^claude-cli\//);
173
+ assert.match(request.headers.get("anthropic-beta"), /oauth-2025-04-20/);
174
+ assert.match(request.headers.get("anthropic-beta"), /claude-code-20250219/);
175
+
176
+ const parsed = JSON.parse(await request.text());
177
+ assert.match(parsed.system[0].text, /^x-anthropic-billing-header: cc_version=/, "billing header first");
178
+ assert.equal(parsed.system[1].text, SYSTEM_IDENTITY, "identity prepended");
179
+ assert.equal(parsed.system[2].text, "You are an AI agent running in OpenCode", "original prompt kept");
180
+ assert.deepEqual(
181
+ parsed.tools.map((tool) => tool.name),
182
+ ["Read", "TodoWrite", "compress", "AskUserQuestion", "Bash", "Agent"],
183
+ "tools renamed to Claude Code casing",
184
+ );
185
+ assert.equal(parsed.messages[0].content[0].name, "Write", "history tool_use renamed");
186
+
187
+ assert.equal(tokenRequests.length, 1, "one refresh call made");
188
+ assert.match(tokenRequests[0], /grant_type=refresh_token/);
189
+ assert.match(tokenRequests[0], /refresh_token=old-refresh/);
190
+ const stored = fake.storage.get("tokens");
191
+ assert.equal(stored.access, "access-1", "refreshed access persisted");
192
+ assert.equal(stored.refresh, "refresh-1", "rotated refresh persisted");
193
+ assert.equal(stored.connectionID, "cred_refresh", "cached tokens are bound to the connection");
194
+
195
+ const second = await fake.request(sampleBody());
196
+ assert.equal(tokenRequests.length, 1, "no refresh while the token is valid");
197
+ assert.equal(second.headers.get("authorization"), "Bearer access-1");
198
+ });
199
+
200
+ test("leaves API-key connections untouched", async () => {
201
+ tokenMode = "rotate";
202
+ const fake = makeContext({ connection: keyConnection, credential: { type: "key", key: "sk-ant-test" } });
203
+ await plugin.setup(fake.ctx);
204
+ const request = await fake.request(sampleBody());
205
+ assert.equal(request.headers.get("x-api-key"), "must-be-removed", "x-api-key untouched");
206
+ assert.equal(request.headers.get("authorization"), null, "no bearer injected");
207
+ const parsed = JSON.parse(await request.text());
208
+ assert.equal(parsed.system[0].text, "You are an AI agent running in OpenCode", "system untouched");
209
+ assert.deepEqual(
210
+ parsed.tools.map((tool) => tool.name),
211
+ ["read", "todowrite", "compress", "question", "shell", "subagent"],
212
+ "tools untouched",
213
+ );
214
+ });
215
+
216
+ test("stays inactive when there is no active credential (e.g. after logout)", async () => {
217
+ const fake = makeContext({
218
+ connection: null,
219
+ credential: null,
220
+ stored: { access: "cached", refresh: "cached-refresh", expires: Date.now() + 3600_000, connectionID: "cred_2" },
221
+ });
222
+ await plugin.setup(fake.ctx);
223
+ const request = await fake.request(sampleBody());
224
+ assert.equal(request.headers.get("authorization"), null, "no bearer injected");
225
+ assert.equal(request.headers.get("x-api-key"), "must-be-removed", "request left alone");
226
+ });
227
+
228
+ test("ignores cached tokens issued for a different connection", async () => {
229
+ tokenMode = "rotate";
230
+ tokenRequests = [];
231
+ const fake = makeContext({
232
+ connection: oauthConnection("cred_mismatch"),
233
+ credential: expiredOAuth,
234
+ stored: {
235
+ access: "other-access",
236
+ refresh: "other-refresh",
237
+ expires: Date.now() + 3600_000,
238
+ connectionID: "cred_other",
239
+ },
240
+ });
241
+ await plugin.setup(fake.ctx);
242
+ const request = await fake.request(sampleBody());
243
+ assert.equal(request.headers.get("authorization"), "Bearer access-1", "refreshed from the active credential");
244
+ assert.equal(tokenRequests.length, 1, "refresh was attempted");
245
+ });
246
+
247
+ test("falls back to a still-valid cached token when refresh fails", async () => {
248
+ tokenMode = "fail";
249
+ tokenRequests = [];
250
+ const errors = await captureConsoleErrors(async () => {
251
+ const fake = makeContext({
252
+ connection: oauthConnection("cred_fallback"),
253
+ credential: expiredOAuth,
254
+ stored: {
255
+ access: "cached-valid",
256
+ refresh: "old-refresh",
257
+ expires: Date.now() + 5 * 60 * 1000,
258
+ connectionID: "cred_fallback",
259
+ },
260
+ });
261
+ await plugin.setup(fake.ctx);
262
+ const request = await fake.request(sampleBody());
263
+ assert.equal(request.headers.get("authorization"), "Bearer cached-valid");
264
+ assert.equal(tokenRequests.length, 1, "refresh was attempted");
265
+ });
266
+ tokenMode = "rotate";
267
+ assert.equal(errors.length, 0, "no error logged when a fallback exists");
268
+ });
269
+
270
+ test("reports a login hint when refresh fails with no usable token", async () => {
271
+ tokenMode = "fail";
272
+ tokenRequests = [];
273
+ const errors = await captureConsoleErrors(async () => {
274
+ const fake = makeContext({ connection: oauthConnection("cred_hint"), credential: expiredOAuth });
275
+ await plugin.setup(fake.ctx);
276
+ const request = await fake.request(sampleBody());
277
+ assert.equal(request.headers.get("authorization"), null, "request left for the next login");
278
+ assert.equal(request.headers.get("x-api-key"), "must-be-removed");
279
+ });
280
+ tokenMode = "rotate";
281
+ assert.ok(
282
+ errors.some((message) => message.includes(LOGIN_HINT)),
283
+ "login hint logged",
284
+ );
285
+ });
286
+
287
+ test("rewrites streamed tool names without touching tool arguments", async () => {
288
+ const { reverse } = buildToolNameMaps();
289
+ const frames = [
290
+ 'event: content_block_start\ndata: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}}\n\n',
291
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"path\\": \\"Read\\"}"}}\n\n',
292
+ 'event: message_stop\ndata: {"type":"message_stop"}\n\n',
293
+ ].join("");
294
+ const encoder = new TextEncoder();
295
+ const chunks = [];
296
+ for (let index = 0; index < frames.length; index += 7) chunks.push(frames.slice(index, index + 7));
297
+ const source = new ReadableStream({
298
+ start(controller) {
299
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
300
+ controller.close();
301
+ },
302
+ });
303
+ const transformed = transformResponseStream(new Response(source, { status: 200 }), { reverse });
304
+ const text = await transformed.text();
305
+ const lines = text.split("\n");
306
+ const startLine = lines.find((line) => line.startsWith("data:") && line.includes("content_block_start"));
307
+ assert.equal(JSON.parse(startLine.slice(5)).content_block.name, "read", "tool name reversed");
308
+ const deltaLine = lines.find((line) => line.includes("input_json_delta"));
309
+ assert.equal(
310
+ JSON.parse(deltaLine.slice(5)).delta.partial_json,
311
+ '{"path": "Read"}',
312
+ "partial_json byte-for-byte preserved",
313
+ );
314
+
315
+ const aliasEvent = rewriteEvent(
316
+ 'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"AskUserQuestion"}}\n\n',
317
+ reverse,
318
+ );
319
+ assert.match(aliasEvent, /"name":"question"/, "aliased tool maps back to its OpenCode name");
320
+ });
321
+
322
+ test("translates streamed Claude Code tool arguments before OpenCode parses them", async () => {
323
+ const { reverse } = buildToolNameMaps();
324
+ const frames = [
325
+ 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_2","name":"Read","input":{}}}\n\n',
326
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"file_"}}\n\n',
327
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"path\\": \\"/tmp/a.txt\\"}"}}\n\n',
328
+ 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n',
329
+ ].join("");
330
+ const encoder = new TextEncoder();
331
+ const source = new ReadableStream({
332
+ start(controller) {
333
+ controller.enqueue(encoder.encode(frames));
334
+ controller.close();
335
+ },
336
+ });
337
+ const transformed = await transformResponseStream(new Response(source, { status: 200 }), { reverse }).text();
338
+ const startLine = transformed.split("\n").find((line) => line.startsWith("data:") && line.includes("content_block_start"));
339
+ assert.equal(JSON.parse(startLine.slice(5)).content_block.name, "read", "tool name reversed");
340
+ const deltaLine = transformed.split("\n").find((line) => line.startsWith("data:") && line.includes("input_json_delta"));
341
+ assert.equal(
342
+ JSON.parse(deltaLine.slice(5)).delta.partial_json,
343
+ '{"path":"/tmp/a.txt"}',
344
+ "arguments translated to OpenCode's schema",
345
+ );
346
+ assert.ok(transformed.includes('"content_block_stop"'), "stop frame still emitted");
347
+ });
348
+
349
+ test("falls back to the original frames when streamed arguments are not valid JSON", async () => {
350
+ const { reverse } = buildToolNameMaps();
351
+ const frames = [
352
+ 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_3","name":"Read","input":{}}}\n\n',
353
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{broken"}}\n\n',
354
+ 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n',
355
+ ].join("");
356
+ const encoder = new TextEncoder();
357
+ const source = new ReadableStream({
358
+ start(controller) {
359
+ controller.enqueue(encoder.encode(frames));
360
+ controller.close();
361
+ },
362
+ });
363
+ const transformed = await transformResponseStream(new Response(source, { status: 200 }), { reverse }).text();
364
+ assert.ok(transformed.includes('"{broken"'), "raw fragments preserved when they cannot be parsed");
365
+ assert.ok(transformed.includes('"content_block_stop"'), "stop frame still emitted");
366
+ });
367
+
368
+ test("supports replace and off system modes", async () => {
369
+ tokenMode = "rotate";
370
+ const fresh = { ...expiredOAuth, access: "fresh", expires: Date.now() + 3600_000 };
371
+
372
+ const replaceFake = makeContext({
373
+ connection: oauthConnection("cred_modes"),
374
+ credential: fresh,
375
+ options: { systemMode: "replace" },
376
+ });
377
+ await plugin.setup(replaceFake.ctx);
378
+ const replaced = JSON.parse(await (await replaceFake.request(sampleBody())).text());
379
+ assert.match(replaced.system[0].text, /^x-anthropic-billing-header:/);
380
+ assert.equal(replaced.system[1].text, SYSTEM_IDENTITY);
381
+ assert.equal(replaced.system[1].cache_control.type, "ephemeral", "first block metadata kept");
382
+ assert.equal(replaced.system.length, 2, "billing header plus the replaced block");
383
+
384
+ const offFake = makeContext({
385
+ connection: oauthConnection("cred_modes"),
386
+ credential: fresh,
387
+ options: { systemMode: "off" },
388
+ });
389
+ await plugin.setup(offFake.ctx);
390
+ const off = JSON.parse(await (await offFake.request(sampleBody())).text());
391
+ assert.equal(off.system[0].text, "You are an AI agent running in OpenCode");
392
+ assert.equal(off.system.length, 1, "system untouched");
393
+ });
394
+
395
+ test("maps Claude Code argument shapes onto OpenCode schemas", () => {
396
+ assert.deepEqual(translateToolInput("read", { file_path: "/a", offset: 2, pages: "1-3" }), {
397
+ path: "/a",
398
+ offset: 2,
399
+ });
400
+ assert.deepEqual(translateToolInput("write", { file_path: "/b", content: "x" }), { path: "/b", content: "x" });
401
+ assert.deepEqual(translateToolInput("edit", { file_path: "/c", old_string: "a", new_string: "b", replace_all: true }), {
402
+ path: "/c",
403
+ oldString: "a",
404
+ newString: "b",
405
+ replaceAll: true,
406
+ });
407
+ assert.deepEqual(translateToolInput("shell", { command: "ls", description: "list", run_in_background: true }), {
408
+ command: "ls",
409
+ background: true,
410
+ });
411
+ assert.deepEqual(
412
+ translateToolInput("grep", { pattern: "x", glob: "*.ts", head_limit: 5, "-i": true, output_mode: "files" }),
413
+ { pattern: "x", include: "*.ts", limit: 5, caseSensitive: false },
414
+ );
415
+ assert.deepEqual(
416
+ translateToolInput("subagent", {
417
+ description: "d",
418
+ prompt: "p",
419
+ subagent_type: "explore",
420
+ run_in_background: true,
421
+ isolation: "worktree",
422
+ }),
423
+ { description: "d", prompt: "p", agent: "explore", background: true },
424
+ );
425
+ assert.deepEqual(translateToolInput("skill", { skill: "pdf", args: "x" }), { id: "pdf" });
426
+ assert.deepEqual(translateToolInput("webfetch", { url: "https://x", prompt: "summarize" }), { url: "https://x" });
427
+ assert.deepEqual(translateToolInput("websearch", { query: "q", allowed_domains: ["a"] }), { query: "q" });
428
+ assert.deepEqual(translateToolInput("question", { questions: [{ question: "q", multiSelect: true }] }), {
429
+ questions: [{ question: "q", multiple: true }],
430
+ });
431
+ assert.deepEqual(translateToolInput("todowrite", { todos: [{ content: "c", status: "pending", activeForm: "doing" }] }), {
432
+ todos: [{ content: "c", status: "pending" }],
433
+ });
434
+
435
+ // OpenCode-shaped input is untouched, and the lookup is case-insensitive.
436
+ const native = { path: "/a", offset: 1 };
437
+ assert.deepEqual(translateToolInput("read", native), native);
438
+ assert.deepEqual(translateToolInput("Read", { file_path: "/a" }), { path: "/a" });
439
+ assert.deepEqual(translateToolInput("execute", { code: "1+1" }), { code: "1+1" });
440
+ });
441
+
442
+ test("renames shell and subagent to their Claude Code counterparts", () => {
443
+ const { forward, reverse } = buildToolNameMaps();
444
+ assert.equal(forward.get("shell"), "Bash");
445
+ assert.equal(forward.get("subagent"), "Agent");
446
+ assert.equal(reverse.get("Bash"), "shell");
447
+ assert.equal(reverse.get("Agent"), "subagent");
448
+ assert.equal(reverse.get("Task"), "subagent", "legacy Task name still maps back");
449
+ const custom = buildToolNameMaps({ subagent: "Task" });
450
+ assert.equal(custom.forward.get("subagent"), "Task", "aliases can opt into legacy names");
451
+ });
452
+
453
+ test("derives a stable Claude Code style session id", () => {
454
+ const id = sessionUUID("ses_abc");
455
+ assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-a[0-9a-f]{3}-[0-9a-f]{12}$/);
456
+ assert.equal(sessionUUID("ses_abc"), id, "deterministic per session");
457
+ assert.notEqual(sessionUUID("ses_xyz"), id);
458
+ });
459
+
460
+ test("sends the Claude Code session header and the updated beta flags", async () => {
461
+ const fresh = { ...expiredOAuth, access: "fresh", expires: Date.now() + 3600_000 };
462
+ const fake = makeContext({ connection: oauthConnection("cred_sid"), credential: fresh });
463
+ await plugin.setup(fake.ctx);
464
+ const request = await fake.request(sampleBody());
465
+ assert.equal(request.headers.get("x-claude-code-session-id"), sessionUUID("ses_test"));
466
+ const beta = request.headers.get("anthropic-beta");
467
+ assert.match(beta, /thinking-token-count-2026-05-13/);
468
+ assert.match(beta, /context-management-2025-06-27/);
469
+ assert.match(beta, /oauth-2025-04-20/);
470
+ });
471
+
472
+ test("parses retry-after values", () => {
473
+ assert.equal(parseRetryAfter("7"), 7000);
474
+ assert.equal(parseRetryAfter("0"), 0);
475
+ assert.equal(parseRetryAfter(null), null);
476
+ assert.equal(parseRetryAfter("not-a-date"), null);
477
+ const parsed = parseRetryAfter(new Date(Date.now() + 5000).toUTCString());
478
+ assert.ok(parsed > 3000 && parsed <= 5000, "http-date parsed");
479
+ });
480
+
481
+ test("reuses retry-after hints in OpenCode's retry hook", async () => {
482
+ const fake = makeContext({ connection: keyConnection, credential: { type: "key", key: "k" } });
483
+ await plugin.setup(fake.ctx);
484
+ assert.deepEqual(await fake.retry({ status: 429, type: "rate_limit", message: "429" }), { retry: false });
485
+ await fake.respond(new Response(null, { status: 429, headers: { "retry-after": "12" } }));
486
+ assert.deepEqual(await fake.retry({ status: 429, type: "rate_limit", message: "429" }), { retry: true, delay: 12000 });
487
+ await fake.respond(new Response(null, { status: 200 }));
488
+ assert.deepEqual(
489
+ await fake.retry({ status: 429, type: "rate_limit", message: "429" }),
490
+ { retry: false },
491
+ "a success clears the stored hint",
492
+ );
493
+ });
494
+
495
+ test("translates Claude Code arguments through the tool hook", async () => {
496
+ const fake = makeContext({ connection: keyConnection, credential: { type: "key", key: "k" } });
497
+ await plugin.setup(fake.ctx);
498
+ assert.deepEqual(await fake.toolBefore("read", { file_path: "/x" }), { path: "/x" });
499
+ assert.deepEqual(await fake.toolBefore("read", { path: "/x" }), { path: "/x" });
500
+ });
501
+
502
+ test("mirrors Claude Code metadata, thinking and SDK headers", async () => {
503
+ const fresh = { ...expiredOAuth, access: "fresh", expires: Date.now() + 3600_000 };
504
+ const fake = makeContext({ connection: oauthConnection("cred_cc"), credential: fresh });
505
+ await plugin.setup(fake.ctx);
506
+ const request = await fake.request({ ...sampleBody(), max_tokens: 64000 });
507
+ assert.equal(request.headers.get("accept"), "application/json");
508
+ assert.equal(request.headers.get("x-stainless-package-version"), "0.128.0");
509
+ assert.equal(request.headers.get("x-stainless-runtime-version"), "v26.3.0");
510
+ const parsed = JSON.parse(await request.text());
511
+ assert.match(parsed.system[0].text, /^x-anthropic-billing-header: cc_version=2\.1\.289\.45c; cc_entrypoint=cli;$/);
512
+ const user = JSON.parse(parsed.metadata.user_id);
513
+ assert.match(user.device_id, /^[0-9a-f]{64}$/);
514
+ assert.equal(user.account_uuid, "");
515
+ assert.equal(user.session_id, sessionUUID("ses_test"));
516
+ assert.deepEqual(parsed.thinking, { type: "enabled", budget_tokens: 63999, display: "omitted" });
517
+ assert.deepEqual(parsed.context_management, { edits: [{ type: "clear_thinking_20251015", keep: "all" }] });
518
+ });
519
+
520
+ test("leaves thinking alone for non-thinking models and non-primary flows", async () => {
521
+ const fresh = { ...expiredOAuth, access: "fresh", expires: Date.now() + 3600_000 };
522
+ const haiku = makeContext({ connection: oauthConnection("cred_h"), credential: fresh });
523
+ await plugin.setup(haiku.ctx);
524
+ const haikuBody = JSON.parse(
525
+ await (await haiku.request({ ...sampleBody(), model: "claude-haiku-4-5", max_tokens: 32000 })).text(),
526
+ );
527
+ assert.equal(haikuBody.thinking, undefined, "no thinking for haiku");
528
+
529
+ const title = makeContext({ connection: oauthConnection("cred_t"), credential: fresh });
530
+ await plugin.setup(title.ctx);
531
+ const titleBody = JSON.parse(await (await title.request({ ...sampleBody(), max_tokens: 64000 }, "title")).text());
532
+ assert.equal(titleBody.thinking, undefined, "no thinking for title requests");
533
+
534
+ const existing = makeContext({ connection: oauthConnection("cred_e"), credential: fresh });
535
+ await plugin.setup(existing.ctx);
536
+ const existingBody = JSON.parse(
537
+ await (
538
+ await existing.request({ ...sampleBody(), max_tokens: 64000, thinking: { type: "enabled", budget_tokens: 5000 } })
539
+ ).text(),
540
+ );
541
+ assert.deepEqual(existingBody.thinking, { type: "enabled", budget_tokens: 5000 }, "existing thinking config preserved");
542
+ });
543
+
544
+ test("registers Claude Pro/Max as a native OAuth method for /connect", async () => {
545
+ tokenMode = "rotate";
546
+ tokenRequests = [];
547
+ const fake = makeContext({ connection: null, credential: null });
548
+ await plugin.setup(fake.ctx);
549
+ const registration = fake.registrations.find(
550
+ (item) => item.integrationID === "anthropic" && item.method?.type === "oauth",
551
+ );
552
+ assert.ok(registration, "OAuth method registered");
553
+ assert.equal(registration.method.id, "claude-pro-max");
554
+ assert.equal(registration.method.label, "Claude Pro/Max");
555
+
556
+ const authorization = await registration.authorize({});
557
+ assert.equal(authorization.mode, "code");
558
+ assert.match(authorization.url, /^https:\/\/claude\.ai\/oauth\/authorize\?/);
559
+ assert.match(authorization.url, /client_id=9d1c250a/);
560
+ assert.equal(typeof authorization.callback, "function", "code mode exposes a callback that receives the pasted code");
561
+
562
+ const before = tokenRequests.length;
563
+ const credential = await authorization.callback("test-code#test-state");
564
+ assert.equal(tokenRequests.length, before + 1, "code exchanged with the token endpoint");
565
+ assert.match(tokenRequests.at(-1), /grant_type=authorization_code/);
566
+ assert.equal(credential.type, "oauth");
567
+ assert.equal(credential.methodID, "claude-pro-max");
568
+ assert.match(credential.access, /^access-/);
569
+ assert.match(credential.refresh, /^refresh-/);
570
+ assert.ok(credential.expires > Date.now());
571
+
572
+ const refreshed = await registration.refresh({ ...credential });
573
+ assert.match(refreshed.access, /^access-/);
574
+ assert.equal(refreshed.methodID, "claude-pro-max", "credential metadata preserved on refresh");
575
+ });