@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.
Files changed (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
@@ -0,0 +1,382 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ chunkUtf8AtOffsets,
5
+ contentBlockDeltaEvent,
6
+ contentBlockStartEvent,
7
+ contentBlockStopEvent,
8
+ encodeSSEEvent,
9
+ encodeSSEStream,
10
+ makeSSEResponse,
11
+ messageDeltaEvent,
12
+ messageStartEvent,
13
+ messageStopEvent,
14
+ } from "../__tests__/helpers/sse.js";
15
+ import { StreamTruncatedError, transformResponse } from "./streaming.js";
16
+
17
+ const encoder = new TextEncoder();
18
+ const decoder = new TextDecoder();
19
+
20
+ function joinBlocks(...blocks: string[]): string {
21
+ return blocks.join("");
22
+ }
23
+
24
+ function encodeToolUseStartEvent(
25
+ name: string,
26
+ input: Record<string, unknown> = { path: "/tmp/demo.txt" },
27
+ multiline = false,
28
+ ): string {
29
+ const payload = {
30
+ type: "content_block_start",
31
+ index: 0,
32
+ content_block: {
33
+ type: "tool_use",
34
+ id: "toolu_123",
35
+ name,
36
+ input,
37
+ },
38
+ };
39
+
40
+ return encodeSSEEvent({
41
+ data: multiline ? JSON.stringify(payload, null, 2) : JSON.stringify(payload),
42
+ });
43
+ }
44
+
45
+ function encodeInputJsonDeltaEvent(partialJson: string): string {
46
+ return encodeSSEEvent({
47
+ data: JSON.stringify({
48
+ type: "content_block_delta",
49
+ index: 0,
50
+ delta: {
51
+ type: "input_json_delta",
52
+ partial_json: partialJson,
53
+ },
54
+ }),
55
+ });
56
+ }
57
+
58
+ function makeChunkedSSEResponse(text: string, byteOffsets: number[]): Response {
59
+ const chunks = chunkUtf8AtOffsets(text, byteOffsets);
60
+
61
+ return makeSSEResponse(
62
+ new ReadableStream<Uint8Array>({
63
+ start(controller) {
64
+ for (const chunk of chunks) {
65
+ controller.enqueue(chunk);
66
+ }
67
+ controller.close();
68
+ },
69
+ }),
70
+ );
71
+ }
72
+
73
+ function makeSSEFromByteChunks(chunks: Uint8Array[], onCancel?: (reason: unknown) => void): Response {
74
+ return makeSSEResponse(
75
+ new ReadableStream<Uint8Array>({
76
+ start(controller) {
77
+ for (const chunk of chunks) {
78
+ controller.enqueue(chunk);
79
+ }
80
+ controller.close();
81
+ },
82
+ cancel(reason) {
83
+ onCancel?.(reason);
84
+ },
85
+ }),
86
+ );
87
+ }
88
+
89
+ function createControlledSSE(
90
+ chunks: Uint8Array[],
91
+ onCancel?: (reason: unknown) => void,
92
+ ): {
93
+ response: Response;
94
+ emit(index: number): void;
95
+ close(): void;
96
+ } {
97
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
98
+
99
+ const response = makeSSEResponse(
100
+ new ReadableStream<Uint8Array>({
101
+ start(innerController) {
102
+ controller = innerController;
103
+ },
104
+ cancel(reason) {
105
+ onCancel?.(reason);
106
+ },
107
+ }),
108
+ );
109
+
110
+ return {
111
+ response,
112
+ emit(index: number) {
113
+ controller?.enqueue(chunks[index]!);
114
+ },
115
+ close() {
116
+ controller?.close();
117
+ },
118
+ };
119
+ }
120
+
121
+ async function getPromiseState<T>(promise: Promise<T>): Promise<"pending" | "fulfilled" | "rejected"> {
122
+ let state: "pending" | "fulfilled" | "rejected" = "pending";
123
+
124
+ void promise.then(
125
+ () => {
126
+ state = "fulfilled";
127
+ },
128
+ () => {
129
+ state = "rejected";
130
+ },
131
+ );
132
+
133
+ await Promise.resolve();
134
+ await Promise.resolve();
135
+
136
+ return state;
137
+ }
138
+
139
+ describe("transformResponse RED - SSE termination and framing", () => {
140
+ it("accepts a multiline tool_use stream only when the final event is message_stop", async () => {
141
+ const stream = joinBlocks(
142
+ encodeSSEStream([messageStartEvent()]),
143
+ encodeToolUseStartEvent("mcp_read_file", { path: "/tmp/demo.txt" }, true),
144
+ encodeSSEStream([contentBlockStopEvent(0), messageDeltaEvent(), messageStopEvent()]),
145
+ );
146
+
147
+ const text = await transformResponse(makeSSEResponse(stream)).text();
148
+
149
+ expect(text).toContain('"type": "message_stop"');
150
+ expect(text).toContain('"name": "read_file"');
151
+ expect(text).not.toContain("mcp_read_file");
152
+ });
153
+
154
+ it("preserves multi-byte UTF-8 payloads across chunk boundaries before message_stop", async () => {
155
+ const stream = joinBlocks(
156
+ encodeSSEStream([messageStartEvent()]),
157
+ encodeToolUseStartEvent(
158
+ "mcp_unicode_lookup",
159
+ {
160
+ path: "/tmp/café-🎉.txt",
161
+ note: "привет мир",
162
+ },
163
+ true,
164
+ ),
165
+ encodeSSEStream([contentBlockStopEvent(0), messageStopEvent()]),
166
+ );
167
+
168
+ const text = await transformResponse(makeChunkedSSEResponse(stream, [1, 2, 3, 5, 8, 13, 21, 34])).text();
169
+
170
+ expect(text).toContain("café-🎉.txt");
171
+ expect(text).toContain("привет мир");
172
+ expect(text).toContain('"name": "unicode_lookup"');
173
+ });
174
+
175
+ it("rewrites multiline data payloads as one event block instead of line-by-line", async () => {
176
+ const stream = joinBlocks(
177
+ encodeToolUseStartEvent("mcp_shell_exec", { command: "ls -la" }, true),
178
+ encodeSSEStream([messageStopEvent()]),
179
+ );
180
+
181
+ const text = await transformResponse(makeSSEResponse(stream)).text();
182
+
183
+ expect(text).toContain('"name": "shell_exec"');
184
+ expect(text).not.toContain("mcp_shell_exec");
185
+ });
186
+
187
+ it("does not emit rewritten bytes until a split event block is complete", async () => {
188
+ const block = encodeToolUseStartEvent("mcp_read_file", { path: "/tmp/demo.txt" }, true);
189
+ const splitPoint = block.indexOf("\n") + 1;
190
+ const chunks = [encoder.encode(block.slice(0, splitPoint)), encoder.encode(block.slice(splitPoint))];
191
+ const controlled = createControlledSSE(chunks);
192
+
193
+ const transformed = transformResponse(controlled.response);
194
+ const reader = transformed.body!.getReader();
195
+
196
+ controlled.emit(0);
197
+
198
+ const firstRead = reader.read();
199
+ expect(await getPromiseState(firstRead)).toBe("pending");
200
+
201
+ controlled.emit(1);
202
+ controlled.close();
203
+
204
+ const resolved = await firstRead;
205
+ expect(decoder.decode(resolved.value)).toContain('"name": "read_file"');
206
+ });
207
+ });
208
+
209
+ describe("transformResponse RED - truncation and validation", () => {
210
+ it("rejects a truncated stream that ends after message_delta without message_stop", async () => {
211
+ const stream = encodeSSEStream([
212
+ messageStartEvent(),
213
+ contentBlockStartEvent(0),
214
+ contentBlockDeltaEvent(0, "hello"),
215
+ contentBlockStopEvent(0),
216
+ messageDeltaEvent(),
217
+ ]);
218
+
219
+ const error = await transformResponse(makeSSEResponse(stream))
220
+ .text()
221
+ .catch((streamError: unknown) => streamError);
222
+
223
+ expect(error).toBeInstanceOf(StreamTruncatedError);
224
+ expect(error).toBeInstanceOf(Error);
225
+ expect((error as StreamTruncatedError).message).toMatch(/message_stop|truncated/i);
226
+ expect((error as StreamTruncatedError).context).toMatchObject({
227
+ inFlightEvent: "message_delta",
228
+ lastEventType: "message_delta",
229
+ });
230
+ });
231
+
232
+ it("rejects a final message_stop event that is missing its terminating blank line", async () => {
233
+ const completeStop = encodeSSEStream([messageStopEvent()]);
234
+ const truncatedStop = completeStop.slice(0, -2);
235
+ const stream = joinBlocks(encodeSSEStream([messageStartEvent()]), truncatedStop);
236
+
237
+ await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/truncated|terminator/i);
238
+ });
239
+
240
+ it("rejects an event:error terminator with a descriptive stream failure", async () => {
241
+ const errorTerminator = encodeSSEEvent({
242
+ event: "error",
243
+ data: JSON.stringify({
244
+ type: "error",
245
+ error: {
246
+ type: "stream_error",
247
+ message: "stream aborted by upstream",
248
+ },
249
+ }),
250
+ });
251
+ const stream = joinBlocks(encodeSSEStream([messageStartEvent()]), errorTerminator);
252
+
253
+ await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/stream aborted by upstream/i);
254
+ });
255
+
256
+ it("rejects malformed JSON event blocks instead of silently ignoring them", async () => {
257
+ const malformed = joinBlocks(
258
+ encodeSSEStream([messageStartEvent()]),
259
+ 'event: message\ndata: {"type":"content_block_start","index":0,\n\n',
260
+ encodeSSEStream([messageStopEvent()]),
261
+ );
262
+
263
+ await expect(transformResponse(makeSSEResponse(malformed)).text()).rejects.toThrow(/malformed|invalid sse/i);
264
+ });
265
+
266
+ it("rejects orphan content_block_delta events", async () => {
267
+ const stream = encodeSSEStream([
268
+ messageStartEvent(),
269
+ contentBlockDeltaEvent(0, "orphan delta"),
270
+ messageStopEvent(),
271
+ ]);
272
+
273
+ await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/orphan|content_block_delta/i);
274
+ });
275
+
276
+ it("rejects orphan content_block_stop events", async () => {
277
+ const stream = encodeSSEStream([messageStartEvent(), contentBlockStopEvent(0), messageStopEvent()]);
278
+
279
+ await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/orphan|content_block_stop/i);
280
+ });
281
+
282
+ it("rejects a truncated tool_use block at EOF", async () => {
283
+ const stream = encodeSSEStream([
284
+ messageStartEvent(),
285
+ contentBlockStartEvent(0, {
286
+ content_block: {
287
+ type: "tool_use",
288
+ id: "toolu_123",
289
+ name: "mcp_read_file",
290
+ input: { path: "/tmp/demo.txt" },
291
+ },
292
+ }),
293
+ messageDeltaEvent(),
294
+ ]);
295
+
296
+ const error = await transformResponse(makeSSEResponse(stream))
297
+ .text()
298
+ .catch((streamError: unknown) => streamError);
299
+
300
+ expect(error).toBeInstanceOf(StreamTruncatedError);
301
+ expect((error as StreamTruncatedError).message).toMatch(/truncated/i);
302
+ expect((error as StreamTruncatedError).context).toMatchObject({
303
+ inFlightEvent: "content_block_start(tool_use)",
304
+ openContentBlockIndex: 0,
305
+ hasPartialJson: false,
306
+ });
307
+ });
308
+
309
+ it("rejects incomplete input_json_delta tool payloads even if message_stop arrives", async () => {
310
+ const stream = joinBlocks(
311
+ encodeSSEStream([messageStartEvent()]),
312
+ encodeSSEStream([
313
+ contentBlockStartEvent(0, {
314
+ content_block: {
315
+ type: "tool_use",
316
+ id: "toolu_123",
317
+ name: "mcp_read_file",
318
+ input: {},
319
+ },
320
+ }),
321
+ ]),
322
+ encodeInputJsonDeltaEvent('{"path":"/tmp/demo.txt"'),
323
+ encodeSSEStream([contentBlockStopEvent(0), messageStopEvent()]),
324
+ );
325
+
326
+ await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(
327
+ /partial_json|incomplete tool_use/i,
328
+ );
329
+ });
330
+
331
+ it("rejects oversized unterminated event buffers before they grow unbounded", async () => {
332
+ const oversized = `data: ${"x".repeat(256 * 1024)}`;
333
+
334
+ await expect(transformResponse(makeSSEResponse(oversized)).text()).rejects.toThrow(/buffer|limit|unterminated/i);
335
+ });
336
+
337
+ it("rejects malformed UTF-8 buffered at EOF during the final decoder flush", async () => {
338
+ const validStream = encoder.encode(encodeSSEStream([messageStartEvent(), messageStopEvent()]));
339
+ const invalidTail = new Uint8Array([0xc3]);
340
+ const response = makeSSEFromByteChunks([validStream, invalidTail]);
341
+
342
+ await expect(transformResponse(response).text()).rejects.toThrow(/utf-8|decoder|malformed/i);
343
+ });
344
+ });
345
+
346
+ describe("transformResponse RED - stream control", () => {
347
+ it("propagates cancel() to the upstream body reader", async () => {
348
+ const cancelSpy = vi.fn();
349
+ const response = makeSSEResponse(
350
+ new ReadableStream<Uint8Array>({
351
+ pull() {
352
+ return Promise.resolve();
353
+ },
354
+ cancel(reason) {
355
+ cancelSpy(reason);
356
+ },
357
+ }),
358
+ );
359
+
360
+ const transformed = transformResponse(response);
361
+ await transformed.body!.cancel("stop-now");
362
+
363
+ expect(cancelSpy).toHaveBeenCalledWith("stop-now");
364
+ });
365
+
366
+ it("bypasses the SSE transform path for non-event-stream responses", () => {
367
+ const response = new Response(
368
+ JSON.stringify({
369
+ content: [{ type: "tool_use", name: "mcp_read_file" }],
370
+ }),
371
+ {
372
+ headers: {
373
+ "content-type": "application/json",
374
+ },
375
+ },
376
+ );
377
+
378
+ const transformed = transformResponse(response);
379
+
380
+ expect(transformed).toBe(response);
381
+ });
382
+ });