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/cli.mjs +2 -0
- package/index.mjs +466 -90
- package/index.test.mjs +725 -56
- package/lib/account-state.mjs +50 -0
- package/lib/account-state.test.mjs +109 -0
- package/lib/accounts.mjs +100 -11
- package/lib/accounts.test.mjs +95 -0
- package/lib/config.mjs +60 -0
- package/lib/oauth.mjs +35 -0
- package/lib/refresh-lock.mjs +123 -0
- package/lib/refresh-lock.test.mjs +79 -0
- package/lib/storage.mjs +76 -1
- package/lib/storage.test.mjs +133 -0
- package/nul +1 -1
- package/package.json +1 -1
- package/test/helpers/accounts-fixtures.mjs +51 -0
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
|
-
|
|
731
|
-
...
|
|
764
|
+
loadConfig.mockReturnValueOnce({
|
|
765
|
+
...loadConfig(),
|
|
732
766
|
signature_emulation: {
|
|
733
|
-
...
|
|
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":
|
|
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
|
-
|
|
905
|
-
...
|
|
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
|
-
|
|
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
|
-
//
|
|
1021
|
-
await
|
|
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
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
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
|
-
|
|
2900
|
+
mockFetch.mockResolvedValueOnce(
|
|
2901
|
+
new Response(sseBody, {
|
|
2902
|
+
status: 200,
|
|
2903
|
+
headers: { "content-type": "text/event-stream" },
|
|
2904
|
+
}),
|
|
2905
|
+
);
|
|
2261
2906
|
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
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
|
-
|
|
2674
|
-
...
|
|
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();
|