@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1
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/README.md +19 -0
- package/dist/bun-proxy.mjs +282 -55
- package/dist/opencode-anthropic-auth-cli.mjs +194 -55
- package/dist/opencode-anthropic-auth-plugin.js +1816 -594
- package/package.json +1 -1
- package/src/__tests__/billing-edge-cases.test.ts +84 -0
- package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
- package/src/__tests__/debug-gating.test.ts +76 -0
- package/src/__tests__/decomposition-smoke.test.ts +92 -0
- package/src/__tests__/fingerprint-regression.test.ts +1 -1
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
- package/src/__tests__/helpers/conversation-history.ts +376 -0
- package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
- package/src/__tests__/helpers/deferred.ts +122 -0
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
- package/src/__tests__/helpers/in-memory-storage.ts +152 -0
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
- package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
- package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
- package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
- package/src/__tests__/helpers/sse.ts +288 -0
- package/src/__tests__/index.parallel.test.ts +711 -0
- package/src/__tests__/sanitization-regex.test.ts +65 -0
- package/src/__tests__/state-bounds.test.ts +110 -0
- package/src/account-identity.test.ts +213 -0
- package/src/account-identity.ts +108 -0
- package/src/accounts.dedup.test.ts +696 -0
- package/src/accounts.test.ts +2 -1
- package/src/accounts.ts +485 -191
- package/src/bun-fetch.test.ts +379 -0
- package/src/bun-fetch.ts +447 -174
- package/src/bun-proxy.ts +289 -57
- package/src/circuit-breaker.test.ts +274 -0
- package/src/circuit-breaker.ts +235 -0
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +37 -18
- package/src/commands/router.ts +25 -5
- package/src/env.ts +1 -0
- package/src/headers/billing.ts +31 -13
- package/src/index.ts +224 -247
- package/src/oauth.ts +7 -1
- package/src/parent-pid-watcher.test.ts +219 -0
- package/src/parent-pid-watcher.ts +99 -0
- package/src/plugin-helpers.ts +112 -0
- package/src/refresh-helpers.ts +169 -0
- package/src/refresh-lock.test.ts +36 -9
- package/src/refresh-lock.ts +2 -2
- package/src/request/body.history.test.ts +398 -0
- package/src/request/body.ts +200 -13
- package/src/request/metadata.ts +6 -2
- package/src/response/index.ts +1 -1
- package/src/response/mcp.ts +60 -31
- package/src/response/streaming.test.ts +382 -0
- package/src/response/streaming.ts +403 -76
- package/src/storage.test.ts +127 -104
- package/src/storage.ts +152 -62
- package/src/system-prompt/builder.ts +33 -3
- package/src/system-prompt/sanitize.ts +12 -2
- package/src/token-refresh.test.ts +84 -1
- package/src/token-refresh.ts +14 -8
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Fetch Harness — Reusable test scaffolding for AnthropicAuthPlugin.
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean abstraction for bootstrapping the plugin with mocked
|
|
5
|
+
* dependencies, making it easy to write integration tests that exercise
|
|
6
|
+
* the fetch interceptor, account management, and request transformation.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const harness = await createFetchHarness({
|
|
11
|
+
* accounts: [{ refreshToken: "test-1", email: "test@example.com" }],
|
|
12
|
+
* mockResponses: { "https://api.anthropic.com/v1/messages": { ok: true, json: async () => ({}) } }
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* await harness.fetch("https://api.anthropic.com/v1/messages", { method: "POST" });
|
|
16
|
+
* expect(harness.mockFetch).toHaveBeenCalledTimes(1);
|
|
17
|
+
*
|
|
18
|
+
* await harness.tearDown();
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { vi, type Mock } from "vitest";
|
|
23
|
+
import type { AnthropicAuthConfig } from "../../config.js";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Map of URL patterns to mock Response objects */
|
|
30
|
+
export type MockResponseMap = Record<string, Partial<Response> | (() => Partial<Response>)>;
|
|
31
|
+
|
|
32
|
+
/** Account data structure for harness initialization */
|
|
33
|
+
export interface HarnessAccount {
|
|
34
|
+
refreshToken?: string;
|
|
35
|
+
access?: string;
|
|
36
|
+
expires?: number;
|
|
37
|
+
email?: string;
|
|
38
|
+
enabled?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for creating a fetch harness */
|
|
42
|
+
export interface HarnessOptions {
|
|
43
|
+
/** Account overrides to initialize the harness with (default: single test account) */
|
|
44
|
+
accounts?: HarnessAccount[];
|
|
45
|
+
/** Plugin configuration overrides (default: test-friendly defaults) */
|
|
46
|
+
config?: Partial<AnthropicAuthConfig>;
|
|
47
|
+
/** Mock responses for specific URLs (default: empty) */
|
|
48
|
+
mockResponses?: MockResponseMap;
|
|
49
|
+
/** Initial account index to set as active (default: 0) */
|
|
50
|
+
initialAccount?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The fetch harness instance returned by createFetchHarness */
|
|
54
|
+
export interface FetchHarness {
|
|
55
|
+
/** The fetch interceptor function returned by plugin.auth.loader */
|
|
56
|
+
fetch: (input: string | Request | URL, init?: RequestInit) => Promise<Response>;
|
|
57
|
+
/** The mocked global fetch (vi.fn()) — use for assertions */
|
|
58
|
+
mockFetch: Mock;
|
|
59
|
+
/** Cleanup function to restore global state */
|
|
60
|
+
tearDown: () => void;
|
|
61
|
+
/** Helper to wait for an assertion to pass (polling) */
|
|
62
|
+
waitFor: (assertion: () => void, timeoutMs?: number) => Promise<void>;
|
|
63
|
+
/** Get the headers from a specific mock fetch call */
|
|
64
|
+
getFetchHeaders: (callIndex: number) => Headers | undefined;
|
|
65
|
+
/** Get the URL from a specific mock fetch call */
|
|
66
|
+
getFetchUrl: (callIndex: number) => string | Request | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Internal helpers
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** Default test account factory */
|
|
74
|
+
function makeStoredAccount(index: number, overrides: HarnessAccount = {}) {
|
|
75
|
+
const addedAt = Date.now();
|
|
76
|
+
return {
|
|
77
|
+
id: `test-${addedAt}-${index}`,
|
|
78
|
+
index,
|
|
79
|
+
refreshToken: overrides.refreshToken ?? `refresh-${index + 1}`,
|
|
80
|
+
access: overrides.access ?? `access-${index + 1}`,
|
|
81
|
+
expires: overrides.expires ?? Date.now() + 3600_000,
|
|
82
|
+
token_updated_at: addedAt,
|
|
83
|
+
addedAt,
|
|
84
|
+
lastUsed: 0,
|
|
85
|
+
enabled: overrides.enabled ?? true,
|
|
86
|
+
rateLimitResetTimes: {},
|
|
87
|
+
consecutiveFailures: 0,
|
|
88
|
+
lastFailureTime: null,
|
|
89
|
+
email: overrides.email,
|
|
90
|
+
stats: {
|
|
91
|
+
requests: 0,
|
|
92
|
+
inputTokens: 0,
|
|
93
|
+
outputTokens: 0,
|
|
94
|
+
cacheReadTokens: 0,
|
|
95
|
+
cacheWriteTokens: 0,
|
|
96
|
+
lastReset: addedAt,
|
|
97
|
+
},
|
|
98
|
+
source: "oauth" as const,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Default mock client factory */
|
|
103
|
+
function makeMockClient() {
|
|
104
|
+
return {
|
|
105
|
+
auth: {
|
|
106
|
+
set: vi.fn().mockResolvedValue(undefined),
|
|
107
|
+
},
|
|
108
|
+
session: {
|
|
109
|
+
prompt: vi.fn().mockResolvedValue(undefined),
|
|
110
|
+
},
|
|
111
|
+
tui: {
|
|
112
|
+
showToast: vi.fn().mockResolvedValue(undefined),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Default mock provider factory */
|
|
118
|
+
function makeMockProvider() {
|
|
119
|
+
return {
|
|
120
|
+
models: {
|
|
121
|
+
"claude-sonnet": {
|
|
122
|
+
id: "claude-sonnet",
|
|
123
|
+
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
|
|
124
|
+
limit: { context: 200_000, output: 8192 },
|
|
125
|
+
},
|
|
126
|
+
"claude-opus-4-6": {
|
|
127
|
+
id: "claude-opus-4-6",
|
|
128
|
+
cost: { input: 15, output: 75, cache: { read: 1.5, write: 18.75 } },
|
|
129
|
+
limit: { context: 200_000, output: 32_000 },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Default test configuration */
|
|
136
|
+
const DEFAULT_TEST_CONFIG: AnthropicAuthConfig = {
|
|
137
|
+
account_selection_strategy: "sticky",
|
|
138
|
+
failure_ttl_seconds: 3600,
|
|
139
|
+
debug: false,
|
|
140
|
+
signature_emulation: {
|
|
141
|
+
enabled: true,
|
|
142
|
+
fetch_claude_code_version_on_startup: false,
|
|
143
|
+
prompt_compaction: "minimal",
|
|
144
|
+
},
|
|
145
|
+
override_model_limits: {
|
|
146
|
+
enabled: false,
|
|
147
|
+
context: 1_000_000,
|
|
148
|
+
output: 0,
|
|
149
|
+
},
|
|
150
|
+
custom_betas: [],
|
|
151
|
+
health_score: {
|
|
152
|
+
initial: 70,
|
|
153
|
+
success_reward: 1,
|
|
154
|
+
rate_limit_penalty: -10,
|
|
155
|
+
failure_penalty: -20,
|
|
156
|
+
recovery_rate_per_hour: 2,
|
|
157
|
+
min_usable: 50,
|
|
158
|
+
max_score: 100,
|
|
159
|
+
},
|
|
160
|
+
token_bucket: {
|
|
161
|
+
max_tokens: 50,
|
|
162
|
+
regeneration_rate_per_minute: 6,
|
|
163
|
+
initial_tokens: 50,
|
|
164
|
+
},
|
|
165
|
+
toasts: {
|
|
166
|
+
quiet: true,
|
|
167
|
+
debounce_seconds: 30,
|
|
168
|
+
},
|
|
169
|
+
headers: {},
|
|
170
|
+
idle_refresh: {
|
|
171
|
+
enabled: false,
|
|
172
|
+
window_minutes: 60,
|
|
173
|
+
min_interval_minutes: 30,
|
|
174
|
+
},
|
|
175
|
+
cc_credential_reuse: {
|
|
176
|
+
enabled: false,
|
|
177
|
+
auto_detect: false,
|
|
178
|
+
prefer_over_oauth: false,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Module mock state
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
let mockAccountsData: {
|
|
187
|
+
version: number;
|
|
188
|
+
accounts: ReturnType<typeof makeStoredAccount>[];
|
|
189
|
+
activeIndex: number;
|
|
190
|
+
} | null = null;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Set the mock accounts data that will be returned by loadAccounts.
|
|
194
|
+
* Call this before createFetchHarness to pre-seed accounts.
|
|
195
|
+
*/
|
|
196
|
+
export function setMockAccounts(accounts: ReturnType<typeof makeStoredAccount>[], activeIndex = 0): void {
|
|
197
|
+
mockAccountsData = { version: 1, accounts, activeIndex };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Clear the mock accounts data.
|
|
202
|
+
*/
|
|
203
|
+
export function clearMockAccounts(): void {
|
|
204
|
+
mockAccountsData = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Main harness factory
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Creates a fetch harness for testing the AnthropicAuthPlugin.
|
|
213
|
+
*
|
|
214
|
+
* This function bootstraps the plugin with mocked dependencies and returns
|
|
215
|
+
* a harness object containing the fetch interceptor and utilities for
|
|
216
|
+
* assertions and cleanup.
|
|
217
|
+
*
|
|
218
|
+
* IMPORTANT: This function must be called AFTER vi.mock() declarations
|
|
219
|
+
* at the top of your test file. The mocks are hoisted by Vitest.
|
|
220
|
+
*
|
|
221
|
+
* @param opts - Configuration options for the harness
|
|
222
|
+
* @returns A FetchHarness instance ready for testing
|
|
223
|
+
*/
|
|
224
|
+
export async function createFetchHarness(opts: HarnessOptions = {}): Promise<FetchHarness> {
|
|
225
|
+
const { accounts = [{}], config = {}, mockResponses = {}, initialAccount = 0 } = opts;
|
|
226
|
+
|
|
227
|
+
// Merge config with defaults
|
|
228
|
+
const mergedConfig: AnthropicAuthConfig = {
|
|
229
|
+
...DEFAULT_TEST_CONFIG,
|
|
230
|
+
...config,
|
|
231
|
+
signature_emulation: {
|
|
232
|
+
...DEFAULT_TEST_CONFIG.signature_emulation,
|
|
233
|
+
...config.signature_emulation,
|
|
234
|
+
},
|
|
235
|
+
health_score: { ...DEFAULT_TEST_CONFIG.health_score, ...config.health_score },
|
|
236
|
+
token_bucket: { ...DEFAULT_TEST_CONFIG.token_bucket, ...config.token_bucket },
|
|
237
|
+
toasts: { ...DEFAULT_TEST_CONFIG.toasts, ...config.toasts },
|
|
238
|
+
override_model_limits: {
|
|
239
|
+
...DEFAULT_TEST_CONFIG.override_model_limits,
|
|
240
|
+
...config.override_model_limits,
|
|
241
|
+
},
|
|
242
|
+
idle_refresh: { ...DEFAULT_TEST_CONFIG.idle_refresh, ...config.idle_refresh },
|
|
243
|
+
cc_credential_reuse: { ...DEFAULT_TEST_CONFIG.cc_credential_reuse, ...config.cc_credential_reuse },
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Create mock client
|
|
247
|
+
const client = makeMockClient();
|
|
248
|
+
|
|
249
|
+
// Store original fetch and create mock
|
|
250
|
+
const originalFetch = globalThis.fetch;
|
|
251
|
+
const mockFetch = vi.fn();
|
|
252
|
+
|
|
253
|
+
// Set up mock response handler
|
|
254
|
+
mockFetch.mockImplementation((input: string | Request | URL) => {
|
|
255
|
+
let url: string;
|
|
256
|
+
if (typeof input === "string") {
|
|
257
|
+
url = input;
|
|
258
|
+
} else if (input instanceof URL) {
|
|
259
|
+
url = input.toString();
|
|
260
|
+
} else {
|
|
261
|
+
url = input.url;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Check for matching mock response
|
|
265
|
+
for (const [pattern, response] of Object.entries(mockResponses)) {
|
|
266
|
+
if (url && url.includes(pattern)) {
|
|
267
|
+
const responseObj = typeof response === "function" ? response() : response;
|
|
268
|
+
if (responseObj instanceof Response) {
|
|
269
|
+
return Promise.resolve(responseObj);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (typeof responseObj.json === "function") {
|
|
273
|
+
return responseObj.json().then(
|
|
274
|
+
(jsonBody) =>
|
|
275
|
+
new Response(JSON.stringify(jsonBody), {
|
|
276
|
+
status: responseObj.status ?? 200,
|
|
277
|
+
statusText: responseObj.statusText ?? "OK",
|
|
278
|
+
headers:
|
|
279
|
+
responseObj.headers instanceof Headers
|
|
280
|
+
? responseObj.headers
|
|
281
|
+
: new Headers(responseObj.headers ?? { "content-type": "application/json" }),
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (typeof responseObj.text === "function") {
|
|
287
|
+
return responseObj.text().then(
|
|
288
|
+
(textBody) =>
|
|
289
|
+
new Response(textBody, {
|
|
290
|
+
status: responseObj.status ?? 200,
|
|
291
|
+
statusText: responseObj.statusText ?? "OK",
|
|
292
|
+
headers:
|
|
293
|
+
responseObj.headers instanceof Headers
|
|
294
|
+
? responseObj.headers
|
|
295
|
+
: new Headers(responseObj.headers ?? undefined),
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return Promise.resolve(
|
|
301
|
+
new Response(undefined, {
|
|
302
|
+
status: responseObj.status ?? 200,
|
|
303
|
+
statusText: responseObj.statusText ?? "OK",
|
|
304
|
+
headers:
|
|
305
|
+
responseObj.headers instanceof Headers
|
|
306
|
+
? responseObj.headers
|
|
307
|
+
: new Headers(responseObj.headers ?? undefined),
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Default: return empty successful response
|
|
314
|
+
return Promise.resolve(new Response("{}", { status: 200, headers: { "content-type": "application/json" } }));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Install mock
|
|
318
|
+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
|
|
319
|
+
|
|
320
|
+
// Build accounts data
|
|
321
|
+
const managedAccounts = accounts.map((overrides, index) => makeStoredAccount(index, overrides));
|
|
322
|
+
|
|
323
|
+
// Pre-seed mock accounts
|
|
324
|
+
mockAccountsData = { version: 1, accounts: managedAccounts, activeIndex: initialAccount };
|
|
325
|
+
|
|
326
|
+
// Dynamically import modules to get mocked versions
|
|
327
|
+
const { AnthropicAuthPlugin } = await import("../../index.js");
|
|
328
|
+
const { loadConfig, loadConfigFresh } = await import("../../config.js");
|
|
329
|
+
const { loadAccounts } = await import("../../storage.js");
|
|
330
|
+
|
|
331
|
+
// Override config mock to return our merged config
|
|
332
|
+
vi.mocked(loadConfig).mockReturnValue(mergedConfig);
|
|
333
|
+
vi.mocked(loadConfigFresh).mockReturnValue(mergedConfig);
|
|
334
|
+
vi.mocked(loadAccounts).mockResolvedValue(mockAccountsData);
|
|
335
|
+
|
|
336
|
+
// Initialize plugin
|
|
337
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
338
|
+
|
|
339
|
+
// Create mock auth getter
|
|
340
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
341
|
+
type: "oauth",
|
|
342
|
+
refresh: managedAccounts[initialAccount]?.refreshToken ?? "refresh-1",
|
|
343
|
+
access: managedAccounts[initialAccount]?.access ?? "access-1",
|
|
344
|
+
expires: Date.now() + 3600_000,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Initialize the plugin's auth loader
|
|
348
|
+
const provider = makeMockProvider();
|
|
349
|
+
const result = await plugin.auth.loader(getAuth, provider);
|
|
350
|
+
|
|
351
|
+
// Helper: wait for assertion
|
|
352
|
+
async function waitFor(assertion: () => void, timeoutMs = 500): Promise<void> {
|
|
353
|
+
const started = Date.now();
|
|
354
|
+
while (true) {
|
|
355
|
+
try {
|
|
356
|
+
assertion();
|
|
357
|
+
return;
|
|
358
|
+
} catch (err) {
|
|
359
|
+
if (Date.now() - started >= timeoutMs) throw err;
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Helper: get fetch headers
|
|
366
|
+
function getFetchHeaders(callIndex: number): Headers | undefined {
|
|
367
|
+
const [input, init] = mockFetch.mock.calls[callIndex] ?? [];
|
|
368
|
+
if (init?.headers) {
|
|
369
|
+
return init.headers instanceof Headers ? init.headers : new Headers(init.headers);
|
|
370
|
+
}
|
|
371
|
+
if (input instanceof Request) {
|
|
372
|
+
return input.headers;
|
|
373
|
+
}
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Helper: get fetch URL
|
|
378
|
+
function getFetchUrl(callIndex: number): string | Request | undefined {
|
|
379
|
+
const [input] = mockFetch.mock.calls[callIndex] ?? [];
|
|
380
|
+
return input;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Cleanup function
|
|
384
|
+
function tearDown(): void {
|
|
385
|
+
globalThis.fetch = originalFetch;
|
|
386
|
+
clearMockAccounts();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!result.fetch) {
|
|
390
|
+
throw new Error("Plugin did not return a fetch function");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
fetch: result.fetch,
|
|
395
|
+
mockFetch,
|
|
396
|
+
tearDown,
|
|
397
|
+
waitFor,
|
|
398
|
+
getFetchHeaders,
|
|
399
|
+
getFetchUrl,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
encodeSSEEvent,
|
|
4
|
+
encodeSSEStream,
|
|
5
|
+
chunkUtf8AtOffsets,
|
|
6
|
+
makeSSEResponse,
|
|
7
|
+
makeTruncatedSSEResponse,
|
|
8
|
+
makeMalformedSSEResponse,
|
|
9
|
+
messageStartEvent,
|
|
10
|
+
contentBlockStartEvent,
|
|
11
|
+
contentBlockDeltaEvent,
|
|
12
|
+
contentBlockStopEvent,
|
|
13
|
+
messageDeltaEvent,
|
|
14
|
+
messageStopEvent,
|
|
15
|
+
errorEvent,
|
|
16
|
+
} from "./sse";
|
|
17
|
+
|
|
18
|
+
describe("sse helpers", () => {
|
|
19
|
+
describe("encodeSSEEvent", () => {
|
|
20
|
+
it("formats a basic event with data", () => {
|
|
21
|
+
const event = { data: "hello world" };
|
|
22
|
+
const encoded = encodeSSEEvent(event);
|
|
23
|
+
expect(encoded).toBe("data: hello world\n\n");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("includes event type when provided", () => {
|
|
27
|
+
const event = { event: "message", data: "hello" };
|
|
28
|
+
const encoded = encodeSSEEvent(event);
|
|
29
|
+
expect(encoded).toBe("event: message\ndata: hello\n\n");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("splits multiline data into multiple data lines", () => {
|
|
33
|
+
const event = { data: "line1\nline2\nline3" };
|
|
34
|
+
const encoded = encodeSSEEvent(event);
|
|
35
|
+
expect(encoded).toBe("data: line1\ndata: line2\ndata: line3\n\n");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("includes id when provided", () => {
|
|
39
|
+
const event = { data: "hello", id: "123" };
|
|
40
|
+
const encoded = encodeSSEEvent(event);
|
|
41
|
+
expect(encoded).toBe("data: hello\nid: 123\n\n");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("includes retry when provided", () => {
|
|
45
|
+
const event = { data: "hello", retry: 5000 };
|
|
46
|
+
const encoded = encodeSSEEvent(event);
|
|
47
|
+
expect(encoded).toBe("data: hello\nretry: 5000\n\n");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("includes all fields when provided", () => {
|
|
51
|
+
const event = { event: "message", data: "hello", id: "123", retry: 5000 };
|
|
52
|
+
const encoded = encodeSSEEvent(event);
|
|
53
|
+
expect(encoded).toBe("event: message\ndata: hello\nid: 123\nretry: 5000\n\n");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("encodeSSEStream", () => {
|
|
58
|
+
it("joins multiple events", () => {
|
|
59
|
+
const events = [{ data: "event1" }, { data: "event2" }];
|
|
60
|
+
const encoded = encodeSSEStream(events);
|
|
61
|
+
expect(encoded).toBe("data: event1\n\ndata: event2\n\n");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns empty string for empty events array", () => {
|
|
65
|
+
const encoded = encodeSSEStream([]);
|
|
66
|
+
expect(encoded).toBe("");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("chunkUtf8AtOffsets", () => {
|
|
71
|
+
it("splits ASCII text at byte offsets", () => {
|
|
72
|
+
const chunks = chunkUtf8AtOffsets("hello world", [5, 8]);
|
|
73
|
+
const decoder = new TextDecoder();
|
|
74
|
+
expect(chunks.map((c) => decoder.decode(c))).toEqual(["hello", " wo", "rld"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("handles multi-byte UTF-8 characters safely", () => {
|
|
78
|
+
const chunks = chunkUtf8AtOffsets("café", [1, 2, 3]);
|
|
79
|
+
const decoder = new TextDecoder();
|
|
80
|
+
const decoded = chunks.map((c) => decoder.decode(c));
|
|
81
|
+
expect(decoded).toEqual(["c", "a", "f", "é"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("handles emoji (4-byte UTF-8)", () => {
|
|
85
|
+
const chunks = chunkUtf8AtOffsets("hello 🎉 world", [5, 6]);
|
|
86
|
+
const decoder = new TextDecoder();
|
|
87
|
+
const decoded = chunks.map((c) => decoder.decode(c));
|
|
88
|
+
expect(decoded).toEqual(["hello", " ", "🎉 world"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("deduplicates and sorts offsets", () => {
|
|
92
|
+
const chunks = chunkUtf8AtOffsets("hello", [3, 1, 3, 2]);
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
expect(chunks.map((c) => decoder.decode(c))).toEqual(["h", "e", "l", "lo"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("ignores offsets beyond string length", () => {
|
|
98
|
+
const chunks = chunkUtf8AtOffsets("hi", [1, 5, 10]);
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
expect(chunks.map((c) => decoder.decode(c))).toEqual(["h", "i"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("ignores offsets at or before current position", () => {
|
|
104
|
+
const chunks = chunkUtf8AtOffsets("hello", [0, 0, 1]);
|
|
105
|
+
const decoder = new TextDecoder();
|
|
106
|
+
expect(chunks.map((c) => decoder.decode(c))).toEqual(["h", "ello"]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("makeSSEResponse", () => {
|
|
111
|
+
it("creates response with text/event-stream content-type", () => {
|
|
112
|
+
const response = makeSSEResponse("data: hello\n\n");
|
|
113
|
+
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
114
|
+
expect(response.status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("accepts custom status code", () => {
|
|
118
|
+
const response = makeSSEResponse("data: hello\n\n", { status: 201 });
|
|
119
|
+
expect(response.status).toBe(201);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("accepts ReadableStream body", async () => {
|
|
123
|
+
const encoder = new TextEncoder();
|
|
124
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
125
|
+
start(controller) {
|
|
126
|
+
controller.enqueue(encoder.encode("data: hello\n\n"));
|
|
127
|
+
controller.close();
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const response = makeSSEResponse(stream);
|
|
131
|
+
const text = await response.text();
|
|
132
|
+
expect(text).toBe("data: hello\n\n");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("makeTruncatedSSEResponse", () => {
|
|
137
|
+
it("emits only specified number of events", async () => {
|
|
138
|
+
const events = [{ data: "event1" }, { data: "event2" }, { data: "event3" }];
|
|
139
|
+
const response = makeTruncatedSSEResponse(events, 2);
|
|
140
|
+
const text = await response.text();
|
|
141
|
+
expect(text).toContain("data: event1");
|
|
142
|
+
expect(text).toContain("data: event2");
|
|
143
|
+
expect(text).not.toContain("data: event3");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("has text/event-stream content-type", () => {
|
|
147
|
+
const events = [{ data: "event1" }];
|
|
148
|
+
const response = makeTruncatedSSEResponse(events, 1);
|
|
149
|
+
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("makeMalformedSSEResponse", () => {
|
|
154
|
+
it("emits malformed content directly", async () => {
|
|
155
|
+
const response = makeMalformedSSEResponse("not valid sse");
|
|
156
|
+
const text = await response.text();
|
|
157
|
+
expect(text).toBe("not valid sse");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("has text/event-stream content-type", () => {
|
|
161
|
+
const response = makeMalformedSSEResponse("malformed");
|
|
162
|
+
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("typed event factories", () => {
|
|
167
|
+
describe("messageStartEvent", () => {
|
|
168
|
+
it("creates a message_start event", () => {
|
|
169
|
+
const event = messageStartEvent();
|
|
170
|
+
const parsed = JSON.parse(event.data);
|
|
171
|
+
expect(parsed.type).toBe("message_start");
|
|
172
|
+
expect(parsed.message.role).toBe("assistant");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("accepts overrides", () => {
|
|
176
|
+
const event = messageStartEvent({ message: { id: "custom_id" } as any });
|
|
177
|
+
const parsed = JSON.parse(event.data);
|
|
178
|
+
expect(parsed.message.id).toBe("custom_id");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("contentBlockStartEvent", () => {
|
|
183
|
+
it("creates a content_block_start event", () => {
|
|
184
|
+
const event = contentBlockStartEvent(0);
|
|
185
|
+
const parsed = JSON.parse(event.data);
|
|
186
|
+
expect(parsed.type).toBe("content_block_start");
|
|
187
|
+
expect(parsed.index).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("accepts overrides", () => {
|
|
191
|
+
const event = contentBlockStartEvent(1, { content_block: { type: "tool_use", name: "read_file" } });
|
|
192
|
+
const parsed = JSON.parse(event.data);
|
|
193
|
+
expect(parsed.index).toBe(1);
|
|
194
|
+
expect(parsed.content_block.type).toBe("tool_use");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("contentBlockDeltaEvent", () => {
|
|
199
|
+
it("creates a content_block_delta event", () => {
|
|
200
|
+
const event = contentBlockDeltaEvent(0, "hello");
|
|
201
|
+
const parsed = JSON.parse(event.data);
|
|
202
|
+
expect(parsed.type).toBe("content_block_delta");
|
|
203
|
+
expect(parsed.delta.text).toBe("hello");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("contentBlockStopEvent", () => {
|
|
208
|
+
it("creates a content_block_stop event", () => {
|
|
209
|
+
const event = contentBlockStopEvent(0);
|
|
210
|
+
const parsed = JSON.parse(event.data);
|
|
211
|
+
expect(parsed.type).toBe("content_block_stop");
|
|
212
|
+
expect(parsed.index).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("messageDeltaEvent", () => {
|
|
217
|
+
it("creates a message_delta event", () => {
|
|
218
|
+
const event = messageDeltaEvent();
|
|
219
|
+
const parsed = JSON.parse(event.data);
|
|
220
|
+
expect(parsed.type).toBe("message_delta");
|
|
221
|
+
expect(parsed.delta.stop_reason).toBe("end_turn");
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("messageStopEvent", () => {
|
|
226
|
+
it("creates a message_stop event", () => {
|
|
227
|
+
const event = messageStopEvent();
|
|
228
|
+
const parsed = JSON.parse(event.data);
|
|
229
|
+
expect(parsed.type).toBe("message_stop");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("errorEvent", () => {
|
|
234
|
+
it("creates an error event", () => {
|
|
235
|
+
const event = errorEvent("rate_limit_error", "Rate limit exceeded");
|
|
236
|
+
const parsed = JSON.parse(event.data);
|
|
237
|
+
expect(parsed.type).toBe("error");
|
|
238
|
+
expect(parsed.error.type).toBe("rate_limit_error");
|
|
239
|
+
expect(parsed.error.message).toBe("Rate limit exceeded");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|