opencode-anthropic-fix 0.0.16 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.test.mjs CHANGED
@@ -32,6 +32,11 @@ vi.mock("./lib/storage.mjs", async (importOriginal) => {
32
32
  };
33
33
  });
34
34
 
35
+ vi.mock("./lib/refresh-lock.mjs", () => ({
36
+ acquireRefreshLock: vi.fn().mockResolvedValue({ acquired: true, lockPath: "/tmp/opencode-test.lock" }),
37
+ releaseRefreshLock: vi.fn().mockResolvedValue(undefined),
38
+ }));
39
+
35
40
  // Mock config — always return defaults
36
41
  vi.mock("./lib/config.mjs", async (importOriginal) => {
37
42
  const original = await importOriginal();
@@ -46,6 +51,7 @@ vi.mock("./lib/config.mjs", async (importOriginal) => {
46
51
  override_model_limits: {
47
52
  ...original.DEFAULT_CONFIG.override_model_limits,
48
53
  },
54
+ idle_refresh: { ...original.DEFAULT_CONFIG.idle_refresh, enabled: false },
49
55
  })),
50
56
  };
51
57
  });
@@ -56,6 +62,8 @@ vi.stubGlobal("fetch", mockFetch);
56
62
 
57
63
  import { AnthropicAuthPlugin } from "./index.mjs";
58
64
  import { saveAccounts, loadAccounts, clearAccounts } from "./lib/storage.mjs";
65
+ import { acquireRefreshLock } from "./lib/refresh-lock.mjs";
66
+ import { loadConfig, DEFAULT_CONFIG } from "./lib/config.mjs";
59
67
 
60
68
  beforeEach(() => {
61
69
  delete process.env.DISABLE_INTERLEAVED_THINKING;
@@ -74,6 +82,10 @@ beforeEach(() => {
74
82
  delete process.env.CLAUDE_CODE_REMOTE_SESSION_ID;
75
83
  delete process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION;
76
84
  delete process.env.OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT;
85
+ delete process.env.CLAUDE_CODE_ACCOUNT_UUID;
86
+ delete process.env.CLAUDE_CODE_USER_EMAIL;
87
+ delete process.env.CLAUDE_CODE_ORGANIZATION_UUID;
88
+ delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
77
89
  process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID = "test-signature-user";
78
90
  });
79
91
 
@@ -117,6 +129,24 @@ function makeProvider() {
117
129
  };
118
130
  }
119
131
 
132
+ /**
133
+ * Poll until condition passes or timeout.
134
+ * @param {() => void} assertion
135
+ * @param {number} [timeoutMs]
136
+ */
137
+ async function waitForAssertion(assertion, timeoutMs = 500) {
138
+ const started = Date.now();
139
+ while (true) {
140
+ try {
141
+ assertion();
142
+ return;
143
+ } catch (err) {
144
+ if (Date.now() - started >= timeoutMs) throw err;
145
+ await new Promise((resolve) => setTimeout(resolve, 10));
146
+ }
147
+ }
148
+ }
149
+
120
150
  /**
121
151
  * Build a stored account object with sensible defaults.
122
152
  * Override any field by passing partial overrides.
@@ -550,6 +580,10 @@ describe("fetch interceptor", () => {
550
580
  delete process.env.CLAUDE_CODE_REMOTE_SESSION_ID;
551
581
  delete process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION;
552
582
  delete process.env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION;
583
+ delete process.env.CLAUDE_CODE_ACCOUNT_UUID;
584
+ delete process.env.CLAUDE_CODE_USER_EMAIL;
585
+ delete process.env.CLAUDE_CODE_ORGANIZATION_UUID;
586
+ delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
553
587
  process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID = "test-signature-user";
554
588
  delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
555
589
  delete process.env.CLAUDE_CODE_ENTRYPOINT;
@@ -589,7 +623,7 @@ describe("fetch interceptor", () => {
589
623
  expect(headers.get("authorization")).toBe("Bearer test-access");
590
624
  expect(headers.get("anthropic-beta")).toContain("oauth-2025-04-20");
591
625
  expect(headers.get("anthropic-beta")).toContain("claude-code-20250219");
592
- expect(headers.get("user-agent")).toContain("claude-cli");
626
+ expect(headers.get("user-agent")).toContain("claude-cli/2.1.68");
593
627
  expect(headers.get("x-app")).toBe("cli");
594
628
  expect(headers.get("x-stainless-lang")).toBe("js");
595
629
  expect(headers.has("x-api-key")).toBe(false);
@@ -727,10 +761,10 @@ describe("fetch interceptor", () => {
727
761
 
728
762
  it("preserves verbose system instructions when prompt compaction is off", async () => {
729
763
  const configModule = await import("./lib/config.mjs");
730
- configModule.loadConfig.mockReturnValueOnce({
731
- ...configModule.loadConfig(),
764
+ loadConfig.mockReturnValueOnce({
765
+ ...loadConfig(),
732
766
  signature_emulation: {
733
- ...configModule.loadConfig().signature_emulation,
767
+ ...loadConfig().signature_emulation,
734
768
  prompt_compaction: "off",
735
769
  },
736
770
  });
@@ -798,10 +832,137 @@ describe("fetch interceptor", () => {
798
832
  });
799
833
 
800
834
  const text = await response.text();
801
- expect(text).toContain('"name": "read_file"');
835
+ expect(text).toContain('"name":"read_file"');
802
836
  expect(text).not.toContain("mcp_read_file");
803
837
  });
804
838
 
839
+ it("strips mcp_ prefix when a tool_use SSE data line is split across chunks", async () => {
840
+ const encoder = new TextEncoder();
841
+ const splitStream = new ReadableStream({
842
+ start(controller) {
843
+ controller.enqueue(
844
+ encoder.encode('data:{"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp_'),
845
+ );
846
+ controller.enqueue(encoder.encode('read_file","id":"t1"}}\n'));
847
+ controller.enqueue(encoder.encode("\n"));
848
+ controller.close();
849
+ },
850
+ });
851
+
852
+ mockFetch.mockResolvedValueOnce(
853
+ new Response(splitStream, {
854
+ status: 200,
855
+ headers: { "content-type": "text/event-stream" },
856
+ }),
857
+ );
858
+
859
+ const response = await fetchFn("https://api.anthropic.com/v1/messages", {
860
+ method: "POST",
861
+ body: JSON.stringify({ messages: [] }),
862
+ });
863
+
864
+ const text = await response.text();
865
+ expect(text).toContain('"name":"read_file"');
866
+ expect(text).not.toContain("mcp_read_file");
867
+ });
868
+
869
+ it("double-prefixes tools already named mcp_* in request body", async () => {
870
+ mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
871
+
872
+ await fetchFn("https://api.anthropic.com/v1/messages", {
873
+ method: "POST",
874
+ body: JSON.stringify({
875
+ tools: [{ name: "mcp_server", description: "An MCP server tool" }],
876
+ messages: [
877
+ {
878
+ role: "assistant",
879
+ content: [{ type: "tool_use", name: "mcp_server", id: "t1", input: {} }],
880
+ },
881
+ ],
882
+ }),
883
+ });
884
+
885
+ const [, init] = mockFetch.mock.calls[0];
886
+ const body = JSON.parse(init.body);
887
+ // Must become mcp_mcp_server so that response stripping restores the original name
888
+ expect(body.tools[0].name).toBe("mcp_mcp_server");
889
+ expect(body.messages[0].content[0].name).toBe("mcp_mcp_server");
890
+ });
891
+
892
+ it("round-trips mcp_-prefixed tool names correctly", async () => {
893
+ // Tool already named mcp_server → sent as mcp_mcp_server → response strips back to mcp_server
894
+ const responseBody =
895
+ 'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp_mcp_server","id":"t1"}}\n\n';
896
+ mockFetch.mockResolvedValueOnce(
897
+ new Response(responseBody, {
898
+ status: 200,
899
+ headers: { "content-type": "text/event-stream" },
900
+ }),
901
+ );
902
+
903
+ const response = await fetchFn("https://api.anthropic.com/v1/messages", {
904
+ method: "POST",
905
+ body: JSON.stringify({
906
+ tools: [{ name: "mcp_server", description: "An MCP server tool" }],
907
+ messages: [],
908
+ }),
909
+ });
910
+
911
+ const text = await response.text();
912
+ // Should strip one mcp_ prefix, restoring original name
913
+ expect(text).toContain('"name":"mcp_server"');
914
+ expect(text).not.toContain("mcp_mcp_server");
915
+ });
916
+
917
+ it("does not strip mcp_ from text content in response stream", async () => {
918
+ // A text content block that happens to contain "name": "mcp_foo" — should NOT be modified
919
+ const responseBody =
920
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The tool \\"name\\": \\"mcp_foo\\" was called."}}\n\n';
921
+ mockFetch.mockResolvedValueOnce(
922
+ new Response(responseBody, {
923
+ status: 200,
924
+ headers: { "content-type": "text/event-stream" },
925
+ }),
926
+ );
927
+
928
+ const response = await fetchFn("https://api.anthropic.com/v1/messages", {
929
+ method: "POST",
930
+ body: JSON.stringify({ messages: [] }),
931
+ });
932
+
933
+ const text = await response.text();
934
+ // The mcp_foo in text content should be preserved
935
+ expect(text).toContain("mcp_foo");
936
+ });
937
+
938
+ it("strips mcp_ from tool_use blocks but not text blocks in response stream", async () => {
939
+ // Two SSE events: one tool_use (should strip), one text (should preserve)
940
+ const sseBody = [
941
+ 'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp_write_file","id":"t1"}}',
942
+ "",
943
+ 'data: {"type":"content_block_start","content_block":{"type":"text","text":"Using tool \\"name\\": \\"mcp_write_file\\""}}',
944
+ "",
945
+ ].join("\n");
946
+
947
+ mockFetch.mockResolvedValueOnce(
948
+ new Response(sseBody, {
949
+ status: 200,
950
+ headers: { "content-type": "text/event-stream" },
951
+ }),
952
+ );
953
+
954
+ const response = await fetchFn("https://api.anthropic.com/v1/messages", {
955
+ method: "POST",
956
+ body: JSON.stringify({ messages: [] }),
957
+ });
958
+
959
+ const text = await response.text();
960
+ // tool_use name should have mcp_ stripped
961
+ expect(text).toContain('"name":"write_file"');
962
+ // text content should still have mcp_write_file
963
+ expect(text).toContain("mcp_write_file");
964
+ });
965
+
805
966
  it("does not show account usage toast for non-message endpoints", async () => {
806
967
  mockFetch.mockResolvedValueOnce(new Response("{}", { status: 200 }));
807
968
 
@@ -901,8 +1062,8 @@ describe("system prompt transform", () => {
901
1062
 
902
1063
  it("keeps legacy prefix behavior when emulation is disabled", async () => {
903
1064
  const configModule = await import("./lib/config.mjs");
904
- configModule.loadConfig.mockReturnValueOnce({
905
- ...configModule.loadConfig(),
1065
+ loadConfig.mockReturnValueOnce({
1066
+ ...loadConfig(),
906
1067
  signature_emulation: {
907
1068
  enabled: false,
908
1069
  fetch_claude_code_version_on_startup: false,
@@ -982,6 +1143,34 @@ describe("fetch interceptor — token refresh", () => {
982
1143
  );
983
1144
  });
984
1145
 
1146
+ it("continues request flow when auth.json persistence fails after successful refresh", async () => {
1147
+ loadAccounts.mockResolvedValue(makeAccountsData());
1148
+ saveAccounts.mockResolvedValue(undefined);
1149
+ client.auth.set.mockRejectedValueOnce(new Error("disk temporarily unavailable"));
1150
+
1151
+ const plugin = await AnthropicAuthPlugin({ client });
1152
+ const getAuth = vi.fn().mockResolvedValue({
1153
+ type: "oauth",
1154
+ refresh: "refresh-1",
1155
+ access: "expired-access",
1156
+ expires: Date.now() - 1000,
1157
+ });
1158
+ const result = await plugin.auth.loader(getAuth, makeProvider());
1159
+
1160
+ mockFetch.mockResolvedValueOnce(mockTokenRefresh("fresh-access", "refresh-1-rotated"));
1161
+ mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
1162
+
1163
+ const response = await result.fetch("https://api.anthropic.com/v1/messages", {
1164
+ method: "POST",
1165
+ body: JSON.stringify({ messages: [] }),
1166
+ });
1167
+
1168
+ expect(response.status).toBe(200);
1169
+ expect(mockFetch).toHaveBeenCalledTimes(2);
1170
+ const [, apiInit] = mockFetch.mock.calls[1];
1171
+ expect(apiInit.headers.get("authorization")).toBe("Bearer fresh-access");
1172
+ });
1173
+
985
1174
  it("coalesces concurrent refreshes for the same account", async () => {
986
1175
  loadAccounts.mockResolvedValue(makeAccountsData());
987
1176
  saveAccounts.mockResolvedValue(undefined);
@@ -1000,11 +1189,26 @@ describe("fetch interceptor — token refresh", () => {
1000
1189
  resolveRefresh = resolve;
1001
1190
  });
1002
1191
 
1003
- mockFetch.mockImplementation((url) => {
1192
+ let refreshCallCount = 0;
1193
+ /** @type {() => void} */
1194
+ let markRefreshInFlight;
1195
+ const refreshInFlight = new Promise((resolve) => {
1196
+ markRefreshInFlight = resolve;
1197
+ });
1198
+
1199
+ /** @type {string[]} */
1200
+ const apiAuthHeaders = [];
1201
+
1202
+ mockFetch.mockImplementation((url, init) => {
1004
1203
  const s = String(url);
1005
1204
  if (s.includes("/v1/oauth/token")) {
1205
+ refreshCallCount += 1;
1206
+ if (refreshCallCount === 1) markRefreshInFlight();
1006
1207
  return refreshPromise;
1007
1208
  }
1209
+
1210
+ const headers = new Headers(init?.headers ?? undefined);
1211
+ apiAuthHeaders.push(headers.get("authorization") || "");
1008
1212
  return Promise.resolve(new Response('{"content":[]}', { status: 200 }));
1009
1213
  });
1010
1214
 
@@ -1017,8 +1221,8 @@ describe("fetch interceptor — token refresh", () => {
1017
1221
  body: JSON.stringify({ messages: [] }),
1018
1222
  });
1019
1223
 
1020
- // Let both requests reach refresh path
1021
- await Promise.resolve();
1224
+ // Wait until refresh is definitely in-flight, then resolve it.
1225
+ await refreshInFlight;
1022
1226
 
1023
1227
  resolveRefresh(mockTokenRefresh("fresh-access", "refresh-1-rotated"));
1024
1228
 
@@ -1028,6 +1232,257 @@ describe("fetch interceptor — token refresh", () => {
1028
1232
 
1029
1233
  const refreshCalls = mockFetch.mock.calls.filter(([u]) => String(u).includes("/v1/oauth/token"));
1030
1234
  expect(refreshCalls).toHaveLength(1);
1235
+ expect(apiAuthHeaders).toEqual(["Bearer fresh-access", "Bearer fresh-access"]);
1236
+ });
1237
+
1238
+ it("uses disk-updated token state when refresh lock is held by another process", async () => {
1239
+ const staleToken = "refresh-1-stale";
1240
+ const rotatedToken = "refresh-1-rotated";
1241
+ const rotatedAccess = "access-from-other-process";
1242
+ const accountId = "stable-id-1";
1243
+
1244
+ loadAccounts.mockResolvedValue(
1245
+ makeAccountsData([
1246
+ {
1247
+ id: accountId,
1248
+ refreshToken: staleToken,
1249
+ access: "expired-access",
1250
+ expires: Date.now() - 1_000,
1251
+ token_updated_at: 10,
1252
+ },
1253
+ ]),
1254
+ );
1255
+ saveAccounts.mockResolvedValue(undefined);
1256
+
1257
+ acquireRefreshLock.mockResolvedValue({ acquired: false, lockPath: null });
1258
+
1259
+ const plugin = await AnthropicAuthPlugin({ client });
1260
+ const getAuth = vi.fn().mockResolvedValue({
1261
+ type: "oauth",
1262
+ refresh: staleToken,
1263
+ access: "expired-access",
1264
+ expires: Date.now() - 1_000,
1265
+ });
1266
+ const result = await plugin.auth.loader(getAuth, makeProvider());
1267
+
1268
+ // Request-time reads: sync + lock-held disk reread.
1269
+ const requestDiskReads = [
1270
+ makeAccountsData([
1271
+ {
1272
+ id: accountId,
1273
+ refreshToken: staleToken,
1274
+ access: "expired-access",
1275
+ expires: Date.now() - 1_000,
1276
+ token_updated_at: 10,
1277
+ },
1278
+ ]),
1279
+ makeAccountsData([
1280
+ {
1281
+ id: accountId,
1282
+ refreshToken: rotatedToken,
1283
+ access: rotatedAccess,
1284
+ expires: Date.now() + 3_600_000,
1285
+ token_updated_at: 999,
1286
+ },
1287
+ ]),
1288
+ ];
1289
+ loadAccounts.mockImplementation(async () => requestDiskReads.shift() ?? requestDiskReads.at(-1));
1290
+
1291
+ // No oauth refresh should be issued; only API call should happen.
1292
+ mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
1293
+
1294
+ const response = await result.fetch("https://api.anthropic.com/v1/messages", {
1295
+ method: "POST",
1296
+ body: JSON.stringify({ messages: [] }),
1297
+ });
1298
+
1299
+ expect(response.status).toBe(200);
1300
+ const oauthCalls = mockFetch.mock.calls.filter(([u]) => String(u).includes("/v1/oauth/token"));
1301
+ expect(oauthCalls).toHaveLength(0);
1302
+
1303
+ const [, apiInit] = mockFetch.mock.calls[0];
1304
+ expect(apiInit.headers.get("authorization")).toBe(`Bearer ${rotatedAccess}`);
1305
+ });
1306
+
1307
+ it("refreshes near-expiry idle account in background while serving active account", async () => {
1308
+ loadConfig.mockReturnValue({
1309
+ ...DEFAULT_CONFIG,
1310
+ signature_emulation: { ...DEFAULT_CONFIG.signature_emulation, fetch_claude_code_version_on_startup: false },
1311
+ override_model_limits: { ...DEFAULT_CONFIG.override_model_limits },
1312
+ idle_refresh: { ...DEFAULT_CONFIG.idle_refresh, enabled: true },
1313
+ });
1314
+
1315
+ loadAccounts.mockResolvedValue(
1316
+ makeAccountsData([
1317
+ { access: "access-1", expires: Date.now() + 2 * 3600_000 },
1318
+ { refreshToken: "refresh-2", access: "access-2", expires: Date.now() + 10 * 60_000 },
1319
+ ]),
1320
+ );
1321
+ saveAccounts.mockResolvedValue(undefined);
1322
+
1323
+ const plugin = await AnthropicAuthPlugin({ client });
1324
+ const getAuth = vi.fn().mockResolvedValue({
1325
+ type: "oauth",
1326
+ refresh: "refresh-1",
1327
+ access: "access-1",
1328
+ expires: Date.now() + 2 * 3600_000,
1329
+ });
1330
+ const result = await plugin.auth.loader(getAuth, makeProvider());
1331
+
1332
+ mockFetch.mockImplementation((url) => {
1333
+ const s = String(url);
1334
+ if (s.includes("/v1/oauth/token")) {
1335
+ return Promise.resolve(mockTokenRefresh("idle-fresh-access", "refresh-2-rotated"));
1336
+ }
1337
+ return Promise.resolve(new Response('{"content":[]}', { status: 200 }));
1338
+ });
1339
+
1340
+ const response = await result.fetch("https://api.anthropic.com/v1/messages", {
1341
+ method: "POST",
1342
+ body: JSON.stringify({ messages: [] }),
1343
+ });
1344
+ expect(response.status).toBe(200);
1345
+
1346
+ await waitForAssertion(() => {
1347
+ const refreshCalls = mockFetch.mock.calls.filter(([u]) => String(u).includes("/v1/oauth/token"));
1348
+ expect(refreshCalls.length).toBe(1);
1349
+ });
1350
+
1351
+ const refreshCalls = mockFetch.mock.calls.filter(([u]) => String(u).includes("/v1/oauth/token"));
1352
+ const refreshBody = JSON.parse(refreshCalls[0][1].body);
1353
+ expect(refreshBody.refresh_token).toBe("refresh-2");
1354
+ });
1355
+
1356
+ it("does not disable idle account on background refresh failure", async () => {
1357
+ loadConfig.mockReturnValue({
1358
+ ...DEFAULT_CONFIG,
1359
+ signature_emulation: { ...DEFAULT_CONFIG.signature_emulation, fetch_claude_code_version_on_startup: false },
1360
+ override_model_limits: { ...DEFAULT_CONFIG.override_model_limits },
1361
+ idle_refresh: { ...DEFAULT_CONFIG.idle_refresh, enabled: true },
1362
+ });
1363
+
1364
+ loadAccounts.mockResolvedValue(
1365
+ makeAccountsData([
1366
+ { access: "access-1", expires: Date.now() + 2 * 3600_000 },
1367
+ { refreshToken: "refresh-2", access: "access-2", expires: Date.now() + 10 * 60_000 },
1368
+ ]),
1369
+ );
1370
+ saveAccounts.mockResolvedValue(undefined);
1371
+
1372
+ const plugin = await AnthropicAuthPlugin({ client });
1373
+ const getAuth = vi.fn().mockResolvedValue({
1374
+ type: "oauth",
1375
+ refresh: "refresh-1",
1376
+ access: "access-1",
1377
+ expires: Date.now() + 2 * 3600_000,
1378
+ });
1379
+ const result = await plugin.auth.loader(getAuth, makeProvider());
1380
+
1381
+ // Background idle refresh fails terminally and retry fails too.
1382
+ mockFetch.mockImplementation((url) => {
1383
+ const s = String(url);
1384
+ if (s.includes("/v1/oauth/token")) {
1385
+ return Promise.resolve({
1386
+ ok: false,
1387
+ status: 400,
1388
+ text: async () => JSON.stringify({ error: "invalid_grant" }),
1389
+ });
1390
+ }
1391
+ return Promise.resolve(new Response('{"content":[]}', { status: 200 }));
1392
+ });
1393
+
1394
+ const response = await result.fetch("https://api.anthropic.com/v1/messages", {
1395
+ method: "POST",
1396
+ body: JSON.stringify({ messages: [] }),
1397
+ });
1398
+ expect(response.status).toBe(200);
1399
+
1400
+ await waitForAssertion(() => {
1401
+ const refreshCalls = mockFetch.mock.calls.filter(([u]) => String(u).includes("/v1/oauth/token"));
1402
+ expect(refreshCalls.length).toBeGreaterThanOrEqual(2);
1403
+ });
1404
+
1405
+ // Background maintenance should not auto-disable accounts.
1406
+ const disabledToasts = client.tui.showToast.mock.calls.filter(([arg]) =>
1407
+ String(arg?.body?.message || "").includes("Disabled"),
1408
+ );
1409
+ expect(disabledToasts).toHaveLength(0);
1410
+
1411
+ for (const [storage] of saveAccounts.mock.calls) {
1412
+ expect(storage.accounts[1]?.enabled).not.toBe(false);
1413
+ }
1414
+ });
1415
+
1416
+ it("foreground refresh does not inherit an in-flight idle refresh failure", async () => {
1417
+ loadConfig.mockReturnValue({
1418
+ ...DEFAULT_CONFIG,
1419
+ signature_emulation: { ...DEFAULT_CONFIG.signature_emulation, fetch_claude_code_version_on_startup: false },
1420
+ override_model_limits: { ...DEFAULT_CONFIG.override_model_limits },
1421
+ idle_refresh: { ...DEFAULT_CONFIG.idle_refresh, enabled: true },
1422
+ });
1423
+
1424
+ loadAccounts.mockResolvedValue(
1425
+ makeAccountsData([
1426
+ { access: "access-1", expires: Date.now() + 2 * 3600_000 },
1427
+ { refreshToken: "refresh-2", access: "stale-access-2", expires: Date.now() - 1000 },
1428
+ ]),
1429
+ );
1430
+ saveAccounts.mockResolvedValue(undefined);
1431
+
1432
+ const plugin = await AnthropicAuthPlugin({ client });
1433
+ const getAuth = vi.fn().mockResolvedValue({
1434
+ type: "oauth",
1435
+ refresh: "refresh-1",
1436
+ access: "access-1",
1437
+ expires: Date.now() + 2 * 3600_000,
1438
+ });
1439
+ const result = await plugin.auth.loader(getAuth, makeProvider());
1440
+
1441
+ /** @type {(error: Error) => void} */
1442
+ let rejectIdleRefresh;
1443
+ const idleRefreshPromise = new Promise((_, reject) => {
1444
+ rejectIdleRefresh = reject;
1445
+ });
1446
+
1447
+ let oauthCount = 0;
1448
+ mockFetch.mockImplementation((url, init) => {
1449
+ const s = String(url);
1450
+ const headers = new Headers(init?.headers ?? undefined);
1451
+
1452
+ if (s.includes("/v1/oauth/token")) {
1453
+ oauthCount += 1;
1454
+ if (oauthCount === 1) return idleRefreshPromise;
1455
+ return Promise.resolve(mockTokenRefresh("fresh-access-2", "refresh-2-rotated"));
1456
+ }
1457
+
1458
+ // First request on account 1 fails account-specific so flow switches to account 2.
1459
+ if (headers.get("authorization") === "Bearer access-1") {
1460
+ // Let idle refresh fail while foreground is preparing to use account 2.
1461
+ rejectIdleRefresh(new Error("idle refresh failed"));
1462
+ return Promise.resolve(
1463
+ new Response('{"error":{"type":"rate_limit_error","message":"Rate limit"}}', { status: 429 }),
1464
+ );
1465
+ }
1466
+
1467
+ return Promise.resolve(new Response('{"content":[]}', { status: 200 }));
1468
+ });
1469
+
1470
+ const response = await result.fetch("https://api.anthropic.com/v1/messages", {
1471
+ method: "POST",
1472
+ body: JSON.stringify({ messages: [] }),
1473
+ });
1474
+
1475
+ expect(response.status).toBe(200);
1476
+
1477
+ const oauthCalls = mockFetch.mock.calls.filter(([u]) => String(u).includes("/v1/oauth/token"));
1478
+ expect(oauthCalls).toHaveLength(2);
1479
+ const secondBody = JSON.parse(oauthCalls[1][1].body);
1480
+ expect(secondBody.refresh_token).toBe("refresh-2");
1481
+
1482
+ const disabledToasts = client.tui.showToast.mock.calls.filter(([arg]) =>
1483
+ String(arg?.body?.message || "").includes("Disabled"),
1484
+ );
1485
+ expect(disabledToasts).toHaveLength(0);
1031
1486
  });
1032
1487
 
1033
1488
  it("disables account on 401 token refresh failure and retries with next account", async () => {
@@ -1136,6 +1591,164 @@ describe("fetch interceptor — token refresh", () => {
1136
1591
  expect(response.status).toBe(200);
1137
1592
  expect(mockFetch).toHaveBeenCalledTimes(3);
1138
1593
  });
1594
+
1595
+ it("recovers when another instance rotated the refresh token (disk read before refresh)", async () => {
1596
+ // Simulate: in-memory has stale token, disk has the rotated token from another instance.
1597
+ // Plugin loads with the stale token, but readDiskRefreshToken finds the new one.
1598
+ const staleToken = "stale-refresh";
1599
+ const rotatedToken = "rotated-by-other-instance";
1600
+ const accountId = "stable-id-1";
1601
+
1602
+ // First loadAccounts call (plugin init) — stale token
1603
+ loadAccounts.mockResolvedValue(makeAccountsData([{ id: accountId, refreshToken: staleToken }]));
1604
+ saveAccounts.mockResolvedValue(undefined);
1605
+
1606
+ const plugin = await AnthropicAuthPlugin({ client });
1607
+ const getAuth = vi.fn().mockResolvedValue({
1608
+ type: "oauth",
1609
+ refresh: staleToken,
1610
+ access: "expired-access",
1611
+ expires: Date.now() - 1000, // expired — forces refresh
1612
+ });
1613
+ const result = await plugin.auth.loader(getAuth, makeProvider());
1614
+
1615
+ // Subsequent loadAccounts calls (readDiskRefreshToken) — return the rotated token
1616
+ loadAccounts.mockResolvedValue(makeAccountsData([{ id: accountId, refreshToken: rotatedToken }]));
1617
+
1618
+ // Refresh with rotated token succeeds
1619
+ mockFetch.mockResolvedValueOnce(mockTokenRefresh("new-access", "new-refresh"));
1620
+ // API call succeeds
1621
+ mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
1622
+
1623
+ const response = await result.fetch("https://api.anthropic.com/v1/messages", {
1624
+ method: "POST",
1625
+ body: JSON.stringify({ messages: [] }),
1626
+ });
1627
+
1628
+ expect(response.status).toBe(200);
1629
+ // Only 2 fetch calls: successful refresh (with rotated token) + API call
1630
+ expect(mockFetch).toHaveBeenCalledTimes(2);
1631
+ // Verify the refresh used the rotated token, not the stale one
1632
+ const refreshCall = mockFetch.mock.calls[0];
1633
+ const refreshBody = JSON.parse(refreshCall[1].body);
1634
+ expect(refreshBody.refresh_token).toBe(rotatedToken);
1635
+ });
1636
+
1637
+ it("retries with disk token when initial refresh fails with invalid_grant", async () => {
1638
+ // Simulate: two instances loaded the same token. Instance A rotated it.
1639
+ // Instance B's memory still has the old token. refreshAccountToken reads disk
1640
+ // (gets old token, same as memory), refresh fails. Retry reads disk again,
1641
+ // this time instance A's save has landed, finds the rotated token.
1642
+ const oldToken = "old-refresh";
1643
+ const rotatedToken = "rotated-refresh";
1644
+ const accountId = "stable-id-1";
1645
+
1646
+ // Default: return old token for all loadAccounts calls (init, saveToDisk merge, Option A)
1647
+ loadAccounts.mockResolvedValue(makeAccountsData([{ id: accountId, refreshToken: oldToken }]));
1648
+ saveAccounts.mockResolvedValue(undefined);
1649
+
1650
+ const plugin = await AnthropicAuthPlugin({ client });
1651
+ const getAuth = vi.fn().mockResolvedValue({
1652
+ type: "oauth",
1653
+ refresh: oldToken,
1654
+ access: "expired-access",
1655
+ expires: Date.now() - 1000,
1656
+ });
1657
+ const result = await plugin.auth.loader(getAuth, makeProvider());
1658
+
1659
+ // Request-time disk reads happen in this order:
1660
+ // 1) syncActiveIndexFromDisk, 2) pre-refresh disk read, 3) retry disk read after invalid_grant.
1661
+ const requestDiskReads = [
1662
+ makeAccountsData([{ id: accountId, refreshToken: oldToken }]),
1663
+ makeAccountsData([{ id: accountId, refreshToken: oldToken }]),
1664
+ makeAccountsData([{ id: accountId, refreshToken: rotatedToken }]),
1665
+ ];
1666
+ loadAccounts.mockImplementation(async () => {
1667
+ return requestDiskReads.shift() ?? makeAccountsData([{ id: accountId, refreshToken: rotatedToken }]);
1668
+ });
1669
+
1670
+ // First refresh fails with invalid_grant (old token was rotated by other instance)
1671
+ mockFetch.mockResolvedValueOnce({
1672
+ ok: false,
1673
+ status: 400,
1674
+ text: async () => JSON.stringify({ error: "invalid_grant" }),
1675
+ });
1676
+ // Retry refresh with rotated token succeeds
1677
+ mockFetch.mockResolvedValueOnce(mockTokenRefresh("recovered-access", "recovered-refresh"));
1678
+ // API call succeeds
1679
+ mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
1680
+
1681
+ const response = await result.fetch("https://api.anthropic.com/v1/messages", {
1682
+ method: "POST",
1683
+ body: JSON.stringify({ messages: [] }),
1684
+ });
1685
+
1686
+ expect(response.status).toBe(200);
1687
+ // 3 fetch calls: failed refresh + successful retry refresh + API call
1688
+ expect(mockFetch).toHaveBeenCalledTimes(3);
1689
+
1690
+ const refreshCalls = mockFetch.mock.calls.filter(([url]) => String(url).includes("/v1/oauth/token"));
1691
+ expect(refreshCalls).toHaveLength(2);
1692
+ const firstRefreshBody = JSON.parse(refreshCalls[0][1].body);
1693
+ const secondRefreshBody = JSON.parse(refreshCalls[1][1].body);
1694
+ expect(firstRefreshBody.refresh_token).toBe(oldToken);
1695
+ expect(secondRefreshBody.refresh_token).toBe(rotatedToken);
1696
+ });
1697
+
1698
+ it("still disables account when retry also fails", async () => {
1699
+ const oldToken = "doomed-refresh";
1700
+ const accountId = "stable-id-1";
1701
+
1702
+ loadAccounts.mockResolvedValue(makeAccountsData([{ id: accountId, refreshToken: oldToken }]));
1703
+ saveAccounts.mockResolvedValue(undefined);
1704
+
1705
+ const plugin = await AnthropicAuthPlugin({ client });
1706
+ const getAuth = vi.fn().mockResolvedValue({
1707
+ type: "oauth",
1708
+ refresh: oldToken,
1709
+ access: "expired-access",
1710
+ expires: Date.now() - 1000,
1711
+ });
1712
+ const result = await plugin.auth.loader(getAuth, makeProvider());
1713
+
1714
+ // Request-time reads: sync, pre-refresh disk read, retry-read.
1715
+ const requestDiskReads = [
1716
+ makeAccountsData([{ id: accountId, refreshToken: oldToken }]),
1717
+ makeAccountsData([{ id: accountId, refreshToken: oldToken }]),
1718
+ makeAccountsData([{ id: accountId, refreshToken: "also-bad-token" }]),
1719
+ ];
1720
+ loadAccounts.mockImplementation(async () => {
1721
+ return requestDiskReads.shift() ?? makeAccountsData([{ id: accountId, refreshToken: "also-bad-token" }]);
1722
+ });
1723
+
1724
+ // First refresh fails
1725
+ mockFetch.mockResolvedValueOnce({
1726
+ ok: false,
1727
+ status: 400,
1728
+ text: async () => JSON.stringify({ error: "invalid_grant" }),
1729
+ });
1730
+ // Retry refresh also fails
1731
+ mockFetch.mockResolvedValueOnce({
1732
+ ok: false,
1733
+ status: 400,
1734
+ text: async () => JSON.stringify({ error: "invalid_grant" }),
1735
+ });
1736
+
1737
+ // No more accounts — should throw
1738
+ await expect(
1739
+ result.fetch("https://api.anthropic.com/v1/messages", {
1740
+ method: "POST",
1741
+ body: JSON.stringify({ messages: [] }),
1742
+ }),
1743
+ ).rejects.toThrow();
1744
+
1745
+ const refreshCalls = mockFetch.mock.calls.filter(([url]) => String(url).includes("/v1/oauth/token"));
1746
+ expect(refreshCalls).toHaveLength(2);
1747
+ const firstRefreshBody = JSON.parse(refreshCalls[0][1].body);
1748
+ const secondRefreshBody = JSON.parse(refreshCalls[1][1].body);
1749
+ expect(firstRefreshBody.refresh_token).toBe(oldToken);
1750
+ expect(secondRefreshBody.refresh_token).toBe("also-bad-token");
1751
+ });
1139
1752
  });
1140
1753
 
1141
1754
  // ---------------------------------------------------------------------------
@@ -1843,6 +2456,24 @@ describe("header handling", () => {
1843
2456
  }
1844
2457
  });
1845
2458
 
2459
+ it("defaults to medium effort for Opus 4.6 when thinking has no budget_tokens", async () => {
2460
+ mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
2461
+
2462
+ await fetchFn("https://api.anthropic.com/v1/messages", {
2463
+ method: "POST",
2464
+ headers: { "content-type": "application/json" },
2465
+ body: JSON.stringify({
2466
+ model: "claude-opus-4-6",
2467
+ messages: [],
2468
+ thinking: { type: "enabled" },
2469
+ }),
2470
+ });
2471
+
2472
+ const [, init] = mockFetch.mock.calls[0];
2473
+ const parsed = JSON.parse(init.body);
2474
+ expect(parsed.thinking).toEqual({ type: "enabled", effort: "medium" });
2475
+ });
2476
+
1846
2477
  it("keeps effort if already provided for Opus 4.6", async () => {
1847
2478
  mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
1848
2479
 
@@ -2015,6 +2646,25 @@ describe("header handling", () => {
2015
2646
  expect(parsed.betas).toBeUndefined();
2016
2647
  });
2017
2648
 
2649
+ it("adds metadata fields from CLAUDE_CODE_* env vars", async () => {
2650
+ process.env.CLAUDE_CODE_ACCOUNT_UUID = "acct-uuid-123";
2651
+ process.env.CLAUDE_CODE_ORGANIZATION_UUID = "org-uuid-456";
2652
+ process.env.CLAUDE_CODE_USER_EMAIL = "dev@example.com";
2653
+ mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
2654
+
2655
+ await fetchFn("https://api.anthropic.com/v1/messages", {
2656
+ method: "POST",
2657
+ headers: { "content-type": "application/json" },
2658
+ body: JSON.stringify({ messages: [{ role: "user", content: "hello" }], system: [] }),
2659
+ });
2660
+
2661
+ const [, init] = mockFetch.mock.calls[0];
2662
+ const parsed = JSON.parse(init.body);
2663
+ expect(parsed.metadata.user_id).toContain("_account_acct-uuid-123_");
2664
+ expect(parsed.metadata.organization_uuid).toBe("org-uuid-456");
2665
+ expect(parsed.metadata.user_email).toBe("dev@example.com");
2666
+ });
2667
+
2018
2668
  it("removes unsupported top-level betas field from request body", async () => {
2019
2669
  mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
2020
2670
 
@@ -2228,55 +2878,57 @@ describe("markSuccess wiring", () => {
2228
2878
  vi.resetAllMocks();
2229
2879
  const client = makeClient();
2230
2880
  const fetchFn = await setupFetchFn(client);
2881
+ vi.useFakeTimers();
2231
2882
 
2232
- // Build an SSE stream with message_start and message_delta usage events
2233
- const sseBody = [
2234
- "event: message_start",
2235
- 'data: {"type":"message_start","message":{"usage":{"input_tokens":25,"output_tokens":1,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}}',
2236
- "",
2237
- "event: content_block_delta",
2238
- 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}',
2239
- "",
2240
- "event: message_delta",
2241
- 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":25,"output_tokens":42,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}',
2242
- "",
2243
- "event: message_stop",
2244
- 'data: {"type":"message_stop"}',
2245
- "",
2246
- ].join("\n");
2247
-
2248
- mockFetch.mockResolvedValueOnce(
2249
- new Response(sseBody, {
2250
- status: 200,
2251
- headers: { "content-type": "text/event-stream" },
2252
- }),
2253
- );
2254
-
2255
- const response = await fetchFn("https://api.anthropic.com/v1/messages", {
2256
- method: "POST",
2257
- body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
2258
- });
2883
+ try {
2884
+ // Build an SSE stream with message_start and message_delta usage events
2885
+ const sseBody = [
2886
+ "event: message_start",
2887
+ 'data: {"type":"message_start","message":{"usage":{"input_tokens":25,"output_tokens":1,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}}',
2888
+ "",
2889
+ "event: content_block_delta",
2890
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}',
2891
+ "",
2892
+ "event: message_delta",
2893
+ 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":25,"output_tokens":42,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}',
2894
+ "",
2895
+ "event: message_stop",
2896
+ 'data: {"type":"message_stop"}',
2897
+ "",
2898
+ ].join("\n");
2259
2899
 
2260
- expect(response.status).toBe(200);
2900
+ mockFetch.mockResolvedValueOnce(
2901
+ new Response(sseBody, {
2902
+ status: 200,
2903
+ headers: { "content-type": "text/event-stream" },
2904
+ }),
2905
+ );
2261
2906
 
2262
- // Consume the stream so the onUsage callback fires
2263
- const text = await response.text();
2264
- expect(text).toContain("Hello");
2907
+ const response = await fetchFn("https://api.anthropic.com/v1/messages", {
2908
+ method: "POST",
2909
+ body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
2910
+ });
2265
2911
 
2266
- // The recordUsage triggers a debounced save; flush it
2267
- vi.useFakeTimers();
2268
- vi.advanceTimersByTime(1500);
2269
- vi.useRealTimers();
2270
- await new Promise((r) => setTimeout(r, 10));
2912
+ expect(response.status).toBe(200);
2271
2913
 
2272
- // Verify usage was recorded: the stats should reflect the message_delta usage
2273
- const saveCalls = saveAccounts.mock.calls;
2274
- expect(saveCalls.length).toBeGreaterThan(0);
2275
- const lastSave = saveCalls[saveCalls.length - 1][0];
2276
- const acc = lastSave.accounts[0];
2277
- expect(acc.stats.requests).toBeGreaterThan(0);
2278
- expect(acc.stats.outputTokens).toBe(42);
2279
- expect(acc.stats.inputTokens).toBe(25);
2914
+ // Consume the stream so the onUsage callback fires
2915
+ const text = await response.text();
2916
+ expect(text).toContain("Hello");
2917
+
2918
+ // recordUsage triggers a debounced save (1s); flush deterministically.
2919
+ await vi.advanceTimersByTimeAsync(1500);
2920
+
2921
+ // Verify usage was recorded: the stats should reflect the message_delta usage
2922
+ const saveCalls = saveAccounts.mock.calls;
2923
+ expect(saveCalls.length).toBeGreaterThan(0);
2924
+ const lastSave = saveCalls[saveCalls.length - 1][0];
2925
+ const acc = lastSave.accounts[0];
2926
+ expect(acc.stats.requests).toBeGreaterThan(0);
2927
+ expect(acc.stats.outputTokens).toBe(42);
2928
+ expect(acc.stats.inputTokens).toBe(25);
2929
+ } finally {
2930
+ vi.useRealTimers();
2931
+ }
2280
2932
  });
2281
2933
 
2282
2934
  it("detects whitespace-formatted mid-stream error events and switches account on next request", async () => {
@@ -2643,6 +3295,23 @@ describe("override_model_limits", () => {
2643
3295
  expect(provider.models["claude-sonnet"].limit.context).toBe(200_000);
2644
3296
  });
2645
3297
 
3298
+ it("does not override 1M context when CLAUDE_CODE_DISABLE_1M_CONTEXT is set", async () => {
3299
+ process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = "1";
3300
+ const provider = makeProvider();
3301
+ const plugin = await AnthropicAuthPlugin({ client });
3302
+ const getAuth = vi.fn().mockResolvedValue({
3303
+ type: "oauth",
3304
+ refresh: "refresh-1",
3305
+ access: "access-1",
3306
+ expires: Date.now() + 3_600_000,
3307
+ });
3308
+
3309
+ await plugin.auth.loader(getAuth, provider);
3310
+
3311
+ expect(provider.models["claude-opus-4-6"].limit.context).toBe(200_000);
3312
+ expect(provider.models["claude-sonnet-4-1m"].limit.context).toBe(200_000);
3313
+ });
3314
+
2646
3315
  it("preserves original output limit when override_model_limits.output is 0 (default)", async () => {
2647
3316
  const provider = makeProvider();
2648
3317
  const plugin = await AnthropicAuthPlugin({ client });
@@ -2670,8 +3339,8 @@ describe("override_model_limits", () => {
2670
3339
 
2671
3340
  it("does not override limits when override_model_limits.enabled is false in config", async () => {
2672
3341
  const configModule = await import("./lib/config.mjs");
2673
- configModule.loadConfig.mockReturnValueOnce({
2674
- ...configModule.loadConfig(),
3342
+ loadConfig.mockReturnValueOnce({
3343
+ ...loadConfig(),
2675
3344
  override_model_limits: { enabled: false, context: 1_000_000, output: 0 },
2676
3345
  });
2677
3346
  const provider = makeProvider();