@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
@@ -4,7 +4,31 @@
4
4
 
5
5
  import { isAccountSpecificError, parseRateLimitReason } from "../backoff.js";
6
6
  import type { UsageStats } from "../types.js";
7
- import { stripMcpPrefixFromSSE } from "./mcp.js";
7
+ import { stripMcpPrefixFromParsedEvent } from "./mcp.js";
8
+
9
+ const MAX_UNTERMINATED_SSE_BUFFER = 256 * 1024;
10
+
11
+ interface OpenContentBlockState {
12
+ type: string;
13
+ partialJson: string;
14
+ }
15
+
16
+ interface StreamTruncatedContext {
17
+ inFlightEvent?: string;
18
+ lastEventType?: string;
19
+ openContentBlockIndex?: number;
20
+ hasPartialJson?: boolean;
21
+ }
22
+
23
+ export class StreamTruncatedError extends Error {
24
+ readonly context: StreamTruncatedContext;
25
+
26
+ constructor(message: string, context: StreamTruncatedContext = {}) {
27
+ super(message);
28
+ this.name = "StreamTruncatedError";
29
+ this.context = context;
30
+ }
31
+ }
8
32
 
9
33
  /**
10
34
  * Update running usage stats from a parsed SSE event.
@@ -59,6 +83,237 @@ export function getSSEDataPayload(eventBlock: string): string | null {
59
83
  return payload;
60
84
  }
61
85
 
86
+ function getSSEEventType(eventBlock: string): string | null {
87
+ for (const line of eventBlock.split("\n")) {
88
+ if (!line.startsWith("event:")) continue;
89
+ const eventType = line.slice(6).trimStart();
90
+ if (eventType) return eventType;
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ function formatSSEEventBlock(eventType: string, parsed: unknown, prettyPrint: boolean): string {
97
+ const json = prettyPrint ? JSON.stringify(parsed, null, 2) : JSON.stringify(parsed);
98
+ const lines = [`event: ${eventType}`];
99
+
100
+ for (const line of json.split("\n")) {
101
+ lines.push(`data: ${line}`);
102
+ }
103
+
104
+ lines.push("", "");
105
+ return lines.join("\n");
106
+ }
107
+
108
+ function hasRecordedUsage(stats: UsageStats): boolean {
109
+ return stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0;
110
+ }
111
+
112
+ function getErrorMessage(parsed: unknown): string {
113
+ if (!parsed || typeof parsed !== "object") {
114
+ return "stream terminated with error event";
115
+ }
116
+
117
+ const error = (parsed as Record<string, unknown>).error;
118
+ if (!error || typeof error !== "object") {
119
+ return "stream terminated with error event";
120
+ }
121
+
122
+ const message = (error as Record<string, unknown>).message;
123
+ return typeof message === "string" && message ? message : "stream terminated with error event";
124
+ }
125
+
126
+ function getEventIndex(parsed: Record<string, unknown>, eventType: string): number {
127
+ const index = parsed.index;
128
+ if (typeof index !== "number") {
129
+ throw new Error(`invalid SSE ${eventType} event: missing numeric index`);
130
+ }
131
+
132
+ return index;
133
+ }
134
+
135
+ function getEventLabel(parsed: Record<string, unknown>, eventType: string): string {
136
+ switch (eventType) {
137
+ case "content_block_start": {
138
+ const contentBlock = parsed.content_block;
139
+ const blockType =
140
+ contentBlock && typeof contentBlock === "object" ? (contentBlock as Record<string, unknown>).type : undefined;
141
+ return typeof blockType === "string" && blockType ? `content_block_start(${blockType})` : eventType;
142
+ }
143
+
144
+ case "content_block_delta": {
145
+ const delta = parsed.delta;
146
+ const deltaType = delta && typeof delta === "object" ? (delta as Record<string, unknown>).type : undefined;
147
+ return typeof deltaType === "string" && deltaType ? `content_block_delta(${deltaType})` : eventType;
148
+ }
149
+
150
+ default:
151
+ return eventType;
152
+ }
153
+ }
154
+
155
+ function getOpenBlockContext(openContentBlocks: Map<number, OpenContentBlockState>): StreamTruncatedContext | null {
156
+ for (const [index, blockState] of openContentBlocks) {
157
+ if (blockState.type === "tool_use") {
158
+ return {
159
+ inFlightEvent: blockState.partialJson
160
+ ? "content_block_delta(input_json_delta)"
161
+ : "content_block_start(tool_use)",
162
+ openContentBlockIndex: index,
163
+ hasPartialJson: blockState.partialJson.length > 0,
164
+ };
165
+ }
166
+ }
167
+
168
+ const firstOpenBlock = openContentBlocks.entries().next().value as [number, OpenContentBlockState] | undefined;
169
+ if (!firstOpenBlock) {
170
+ return null;
171
+ }
172
+
173
+ const [index, blockState] = firstOpenBlock;
174
+ return {
175
+ inFlightEvent: `content_block_start(${blockState.type})`,
176
+ openContentBlockIndex: index,
177
+ hasPartialJson: blockState.partialJson.length > 0,
178
+ };
179
+ }
180
+
181
+ function createStreamTruncatedError(context: StreamTruncatedContext = {}): StreamTruncatedError {
182
+ return new StreamTruncatedError("Stream truncated without message_stop", context);
183
+ }
184
+
185
+ function getBufferedEventContext(eventBlock: string, lastEventType: string | null): StreamTruncatedContext {
186
+ const context: StreamTruncatedContext = {
187
+ lastEventType: lastEventType ?? undefined,
188
+ };
189
+
190
+ const payload = getSSEDataPayload(eventBlock);
191
+ if (!payload) {
192
+ return context;
193
+ }
194
+
195
+ try {
196
+ const parsed = JSON.parse(payload) as Record<string, unknown>;
197
+ const eventType = getSSEEventType(eventBlock) ?? (typeof parsed.type === "string" ? parsed.type : null);
198
+ if (eventType) {
199
+ context.inFlightEvent = getEventLabel(parsed, eventType);
200
+ }
201
+ } catch {
202
+ // JSON parse failed; context will be returned without inFlightEvent
203
+ }
204
+
205
+ return context;
206
+ }
207
+
208
+ function validateEventState(
209
+ parsed: Record<string, unknown>,
210
+ eventType: string,
211
+ openContentBlocks: Map<number, OpenContentBlockState>,
212
+ ): void {
213
+ switch (eventType) {
214
+ case "content_block_start": {
215
+ const index = getEventIndex(parsed, eventType);
216
+ const contentBlock = parsed.content_block;
217
+ if (!contentBlock || typeof contentBlock !== "object") {
218
+ throw new Error("invalid SSE content_block_start event: missing content_block");
219
+ }
220
+
221
+ if (openContentBlocks.has(index)) {
222
+ throw new Error(`duplicate content_block_start for index ${index}`);
223
+ }
224
+
225
+ const blockType = (contentBlock as Record<string, unknown>).type;
226
+ if (typeof blockType !== "string" || !blockType) {
227
+ throw new Error("invalid SSE content_block_start event: missing content_block.type");
228
+ }
229
+
230
+ openContentBlocks.set(index, {
231
+ type: blockType,
232
+ partialJson: "",
233
+ });
234
+ return;
235
+ }
236
+
237
+ case "content_block_delta": {
238
+ const index = getEventIndex(parsed, eventType);
239
+ const blockState = openContentBlocks.get(index);
240
+ if (!blockState) {
241
+ throw new Error(`orphan content_block_delta for index ${index}`);
242
+ }
243
+
244
+ const delta = parsed.delta;
245
+ if (!delta || typeof delta !== "object") {
246
+ throw new Error("invalid SSE content_block_delta event: missing delta");
247
+ }
248
+
249
+ const deltaType = (delta as Record<string, unknown>).type;
250
+ if (deltaType === "input_json_delta") {
251
+ if (blockState.type !== "tool_use") {
252
+ throw new Error(`orphan input_json_delta for non-tool_use block ${index}`);
253
+ }
254
+
255
+ const partialJson = (delta as Record<string, unknown>).partial_json;
256
+ if (typeof partialJson !== "string") {
257
+ throw new Error("invalid SSE content_block_delta event: missing delta.partial_json");
258
+ }
259
+
260
+ blockState.partialJson += partialJson;
261
+ }
262
+
263
+ return;
264
+ }
265
+
266
+ case "content_block_stop": {
267
+ const index = getEventIndex(parsed, eventType);
268
+ const blockState = openContentBlocks.get(index);
269
+ if (!blockState) {
270
+ throw new Error(`orphan content_block_stop for index ${index}`);
271
+ }
272
+
273
+ if (blockState.type === "tool_use" && blockState.partialJson) {
274
+ try {
275
+ JSON.parse(blockState.partialJson);
276
+ } catch {
277
+ throw new Error(`incomplete tool_use partial_json for index ${index}`);
278
+ }
279
+ }
280
+
281
+ openContentBlocks.delete(index);
282
+ return;
283
+ }
284
+
285
+ default:
286
+ return;
287
+ }
288
+ }
289
+
290
+ function getOpenBlockError(openContentBlocks: Map<number, OpenContentBlockState>): Error | null {
291
+ const openBlockContext = getOpenBlockContext(openContentBlocks);
292
+ return openBlockContext ? createStreamTruncatedError(openBlockContext) : null;
293
+ }
294
+
295
+ function getMessageStopBlockError(openContentBlocks: Map<number, OpenContentBlockState>): Error | null {
296
+ for (const [index, blockState] of openContentBlocks) {
297
+ if (blockState.partialJson) {
298
+ return new Error(`incomplete tool_use partial_json for index ${index}`);
299
+ }
300
+ }
301
+
302
+ return null;
303
+ }
304
+
305
+ function normalizeChunk(text: string): string {
306
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
307
+ }
308
+
309
+ function toStreamError(error: unknown): Error {
310
+ if (error instanceof Error) {
311
+ return error;
312
+ }
313
+
314
+ return new Error(String(error));
315
+ }
316
+
62
317
  /**
63
318
  * Parse one SSE event payload and return account-error details if present.
64
319
  */
@@ -102,13 +357,13 @@ export function transformResponse(
102
357
  response: Response,
103
358
  onUsage?: ((stats: UsageStats) => void) | null,
104
359
  onAccountError?: ((details: { reason: string; invalidateToken: boolean }) => void) | null,
360
+ onStreamError?: ((error: Error) => void) | null,
105
361
  ): Response {
106
- if (!response.body) return response;
362
+ if (!response.body || !isEventStreamResponse(response)) return response;
107
363
 
108
364
  const reader = response.body.getReader();
109
- const decoder = new TextDecoder();
365
+ const decoder = new TextDecoder("utf-8", { fatal: true });
110
366
  const encoder = new TextEncoder();
111
- const EMPTY_CHUNK = new Uint8Array();
112
367
 
113
368
  const stats: UsageStats = {
114
369
  inputTokens: 0,
@@ -117,107 +372,179 @@ export function transformResponse(
117
372
  cacheWriteTokens: 0,
118
373
  };
119
374
  let sseBuffer = "";
120
- let sseRewriteBuffer = "";
121
375
  let accountErrorHandled = false;
376
+ let hasSeenMessageStop = false;
377
+ let hasSeenError = false;
378
+ let lastEventType: string | null = null;
379
+ const strictEventValidation = !onUsage && !onAccountError;
380
+ const openContentBlocks = new Map<number, OpenContentBlockState>();
381
+
382
+ function enqueueNormalizedEvent(controller: ReadableStreamDefaultController<Uint8Array>, eventBlock: string): void {
383
+ const payload = getSSEDataPayload(eventBlock);
384
+ if (!payload) {
385
+ return;
386
+ }
387
+
388
+ let parsed: unknown;
389
+ try {
390
+ parsed = JSON.parse(payload);
391
+ } catch {
392
+ throw new Error("invalid SSE event: malformed JSON payload");
393
+ }
394
+
395
+ const eventType =
396
+ getSSEEventType(eventBlock) ?? ((parsed as Record<string, unknown> | null)?.type as string | undefined);
397
+ if (typeof eventType !== "string" || !eventType) {
398
+ throw new Error("invalid SSE event: missing event type");
399
+ }
400
+
401
+ const parsedRecord = parsed as Record<string, unknown>;
402
+ lastEventType = getEventLabel(parsedRecord, eventType);
403
+ if (strictEventValidation) {
404
+ validateEventState(parsedRecord, eventType, openContentBlocks);
405
+ }
406
+ stripMcpPrefixFromParsedEvent(parsedRecord);
407
+
408
+ if (onUsage) {
409
+ extractUsageFromSSEEvent(parsedRecord, stats);
410
+ }
411
+
412
+ if (onAccountError && !accountErrorHandled) {
413
+ const details = getMidStreamAccountError(parsedRecord);
414
+ if (details) {
415
+ accountErrorHandled = true;
416
+ onAccountError(details);
417
+ }
418
+ }
419
+
420
+ if (eventType === "message_stop") {
421
+ if (strictEventValidation) {
422
+ const openBlockError = getMessageStopBlockError(openContentBlocks);
423
+ if (openBlockError) {
424
+ throw openBlockError;
425
+ }
426
+
427
+ openContentBlocks.clear();
428
+ }
429
+
430
+ hasSeenMessageStop = true;
431
+ }
432
+
433
+ if (eventType === "error") {
434
+ hasSeenError = true;
435
+ }
436
+
437
+ controller.enqueue(encoder.encode(formatSSEEventBlock(eventType, parsedRecord, strictEventValidation)));
438
+
439
+ if (eventType === "error" && strictEventValidation) {
440
+ throw new Error(getErrorMessage(parsedRecord));
441
+ }
442
+ }
443
+
444
+ function processBufferedEvents(controller: ReadableStreamDefaultController<Uint8Array>): boolean {
445
+ let emitted = false;
122
446
 
123
- function processSSEBuffer(flush = false): void {
124
447
  while (true) {
125
448
  const boundary = sseBuffer.indexOf("\n\n");
126
-
127
449
  if (boundary === -1) {
128
- if (!flush) return;
129
- if (!sseBuffer.trim()) {
130
- sseBuffer = "";
131
- return;
450
+ if (sseBuffer.length > MAX_UNTERMINATED_SSE_BUFFER) {
451
+ throw new Error("unterminated SSE event buffer exceeded limit");
132
452
  }
453
+ return emitted;
133
454
  }
134
455
 
135
- const eventBlock = boundary === -1 ? sseBuffer : sseBuffer.slice(0, boundary);
136
- sseBuffer = boundary === -1 ? "" : sseBuffer.slice(boundary + 2);
456
+ const eventBlock = sseBuffer.slice(0, boundary);
457
+ sseBuffer = sseBuffer.slice(boundary + 2);
137
458
 
138
- const payload = getSSEDataPayload(eventBlock);
139
- if (!payload) {
140
- if (boundary === -1) return;
459
+ if (!eventBlock.trim()) {
141
460
  continue;
142
461
  }
143
462
 
144
- try {
145
- const parsed = JSON.parse(payload);
463
+ enqueueNormalizedEvent(controller, eventBlock);
464
+ emitted = true;
465
+ }
466
+ }
146
467
 
147
- if (onUsage) {
148
- extractUsageFromSSEEvent(parsed, stats);
149
- }
468
+ async function failStream(controller: ReadableStreamDefaultController<Uint8Array>, error: unknown): Promise<void> {
469
+ const streamError = toStreamError(error);
150
470
 
151
- if (onAccountError && !accountErrorHandled) {
152
- const details = getMidStreamAccountError(parsed);
153
- if (details) {
154
- accountErrorHandled = true;
155
- onAccountError(details);
156
- }
157
- }
471
+ if (onStreamError) {
472
+ try {
473
+ onStreamError(streamError);
158
474
  } catch {
159
- // Ignore malformed event payloads.
475
+ // Error handler failed; continue with cleanup
160
476
  }
161
-
162
- if (boundary === -1) return;
163
477
  }
164
- }
165
-
166
- function rewriteSSEChunk(chunk: string, flush = false): string {
167
- sseRewriteBuffer += chunk;
168
478
 
169
- if (!flush) {
170
- const boundary = sseRewriteBuffer.lastIndexOf("\n");
171
- if (boundary === -1) return "";
172
- const complete = sseRewriteBuffer.slice(0, boundary + 1);
173
- sseRewriteBuffer = sseRewriteBuffer.slice(boundary + 1);
174
- return stripMcpPrefixFromSSE(complete);
479
+ try {
480
+ await reader.cancel(streamError);
481
+ } catch {
482
+ // Reader cancel failed; stream may already be closed
175
483
  }
176
484
 
177
- if (!sseRewriteBuffer) return "";
178
- const finalText = stripMcpPrefixFromSSE(sseRewriteBuffer);
179
- sseRewriteBuffer = "";
180
- return finalText;
485
+ controller.error(streamError);
181
486
  }
182
487
 
183
488
  const stream = new ReadableStream({
184
489
  async pull(controller) {
185
- const { done, value } = await reader.read();
186
- if (done) {
187
- processSSEBuffer(true);
490
+ try {
491
+ while (true) {
492
+ const { done, value } = await reader.read();
493
+ if (done) {
494
+ const flushedText = decoder.decode();
495
+ if (flushedText) {
496
+ sseBuffer += normalizeChunk(flushedText);
497
+ processBufferedEvents(controller);
498
+ }
499
+
500
+ if (sseBuffer.trim()) {
501
+ if (strictEventValidation) {
502
+ throw createStreamTruncatedError(getBufferedEventContext(sseBuffer, lastEventType));
503
+ }
504
+
505
+ enqueueNormalizedEvent(controller, sseBuffer);
506
+ sseBuffer = "";
507
+ }
508
+
509
+ if (strictEventValidation) {
510
+ const openBlockError = getOpenBlockError(openContentBlocks);
511
+ if (openBlockError) {
512
+ throw openBlockError;
513
+ }
514
+
515
+ if (!hasSeenMessageStop && !hasSeenError) {
516
+ throw createStreamTruncatedError({
517
+ inFlightEvent: lastEventType ?? undefined,
518
+ lastEventType: lastEventType ?? undefined,
519
+ });
520
+ }
521
+ }
522
+
523
+ if (onUsage && hasRecordedUsage(stats)) {
524
+ onUsage(stats);
525
+ }
526
+
527
+ controller.close();
528
+ return;
529
+ }
188
530
 
189
- const rewrittenTail = rewriteSSEChunk("", true);
190
- if (rewrittenTail) {
191
- controller.enqueue(encoder.encode(rewrittenTail));
192
- }
531
+ const text = decoder.decode(value, { stream: true });
532
+ if (!text) {
533
+ continue;
534
+ }
193
535
 
194
- if (
195
- onUsage &&
196
- (stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0)
197
- ) {
198
- onUsage(stats);
536
+ sseBuffer += normalizeChunk(text);
537
+ if (processBufferedEvents(controller)) {
538
+ return;
539
+ }
199
540
  }
200
- controller.close();
201
- return;
202
- }
203
-
204
- const text = decoder.decode(value, { stream: true });
205
-
206
- if (onUsage || onAccountError) {
207
- // Normalize CRLF for parser only; preserve original bytes for passthrough.
208
- sseBuffer += text.replace(/\r\n/g, "\n");
209
- processSSEBuffer(false);
210
- }
211
-
212
- const rewrittenText = rewriteSSEChunk(text, false);
213
- if (rewrittenText) {
214
- controller.enqueue(encoder.encode(rewrittenText));
215
- } else {
216
- // Keep the pull/read loop progressing when this chunk only extends a
217
- // partial line buffered for later rewrite.
218
- controller.enqueue(EMPTY_CHUNK);
541
+ } catch (error) {
542
+ await failStream(controller, error);
219
543
  }
220
544
  },
545
+ cancel(reason) {
546
+ return reader.cancel(reason);
547
+ },
221
548
  });
222
549
 
223
550
  return new Response(stream, {