@sahiljassal/opencode-anthropic-auth 2.0.0 → 2.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 CHANGED
@@ -5,7 +5,7 @@
5
5
  >
6
6
  > Use your best judgment and don't abuse your subscription.
7
7
 
8
- Fork of [ex-machina-co/opencode-anthropic-auth](https://github.com/ex-machina-co/opencode-anthropic-auth).
8
+ Fork of [ex-machina-co/opencode-anthropic-auth](https://github.com/ex-machina-co/opencode-anthropic-auth), with caching improvements inspired by [cortexkit/anthropic-auth](https://github.com/cortexkit/anthropic-auth).
9
9
 
10
10
  An [OpenCode](https://github.com/anomalyco/opencode) plugin that provides Anthropic OAuth authentication, enabling Claude Pro/Max users to use their subscription directly with OpenCode.
11
11
 
@@ -27,18 +27,28 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
27
27
 
28
28
  ## Prompt Caching
29
29
 
30
- This fork applies **hybrid 1-hour ephemeral prompt caching** on every request:
30
+ This fork applies **hybrid 1-hour ephemeral prompt caching** on every request, placing up to 4 breakpoints strategically:
31
31
 
32
- - Strips any existing `cache_control` blocks from the request
33
- - Anchors a `1h` ephemeral cache on the last system block (after identity) and the first two user messages
32
+ | Breakpoint | Behaviour |
33
+ |---|---|
34
+ | **System anchor** | Last system block after the identity block (skipped when bridge occupies the slot) |
35
+ | **messages[0]** | Magic-context split: anchors block[0] + block[1] when stable prefix and volatile delta are merged; otherwise anchors the last cacheable block |
36
+ | **messages[1] / bridge** | Last cacheable block of messages[1]; replaced by a bridge anchor when a tool-heavy session pushes the latest user boundary outside Anthropic's 20-block lookback window |
37
+ | **Rolling latest** | Most recent user message beyond index 1, keeping cache hot across long sessions |
38
+
39
+ Additional behaviours:
34
40
 
35
- This reduces token usage and latency on repeated requests by reusing cached prompt prefixes.
41
+ - **System tail coalescing** plugin-added system blocks beyond the primary prompt are merged into one block before placing the system anchor, preventing cache busts when block layout changes between requests
42
+ - **Trailing assistant strip** — assistant messages at the tail of the request are removed before forwarding (OAuth rejects assistant prefill)
43
+ - **Thinking block guard** — `thinking` and `redacted_thinking` blocks are excluded from cache anchor placement; messages containing only thinking blocks receive no `cache_control` (avoids Anthropic 400)
44
+ - **SSE retryable errors** — transient server errors (`api_error`, `overloaded_error`, `server_error`) emitted inside HTTP 200 streams are detected and thrown as connection-reset errors so OpenCode auto-retries
45
+ - **Buffered stream rewriting** — tool name stripping buffers partial `"name"` tokens across chunk boundaries to avoid corruption
36
46
 
37
47
  ## Configuration
38
48
 
39
- | Variable | Description |
40
- |---|---|
41
- | `ANTHROPIC_BASE_URL` | Override API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL. |
49
+ | Variable | Description |
50
+ | -------------------- | ---------------------------------------------------------------------------------------- |
51
+ | `ANTHROPIC_BASE_URL` | Override API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL. |
42
52
  | `ANTHROPIC_INSECURE` | Set to `1` or `true` to skip TLS verification. Only effective with `ANTHROPIC_BASE_URL`. |
43
53
 
44
54
  ## License
@@ -7,14 +7,21 @@ export declare const CODE_CALLBACK_URL = "https://platform.claude.com/oauth/code
7
7
  export declare const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
8
8
  export declare const OAUTH_SCOPES: string[];
9
9
  export declare const TOOL_PREFIX = "mcp_";
10
+ /**
11
+ * Anthropic's sliding-window lookback for cache breakpoints.
12
+ * If the distance (in content blocks) between the previous user-role
13
+ * message anchor and the latest one exceeds this threshold, a bridge
14
+ * anchor is needed so the earlier slots are still within the window.
15
+ */
16
+ export declare const ANTHROPIC_CACHE_LOOKBACK_BLOCKS = 20;
10
17
  export declare const REQUIRED_BETAS: string[];
11
18
  export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
12
19
  export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
13
20
  export declare const CCH_SALT = "59cf53e54c78";
14
21
  export declare const CCH_POSITIONS: number[];
15
- export declare const CLAUDE_CODE_VERSION = "2.1.87";
16
- export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
17
- export declare const USER_AGENT = "claude-cli/2.1.87 (external, cli)";
22
+ export declare const CLAUDE_CODE_VERSION = "2.1.177";
23
+ export declare const CLAUDE_CODE_ENTRYPOINT = "cli";
24
+ export declare const USER_AGENT = "claude-cli/2.1.177 (external, cli)";
18
25
  /**
19
26
  * Anchors that identify paragraphs to remove from the system prompt.
20
27
  * Any paragraph (text between blank lines) containing one of these
package/dist/constants.js CHANGED
@@ -14,6 +14,13 @@ export const OAUTH_SCOPES = [
14
14
  'user:file_upload',
15
15
  ];
16
16
  export const TOOL_PREFIX = 'mcp_';
17
+ /**
18
+ * Anthropic's sliding-window lookback for cache breakpoints.
19
+ * If the distance (in content blocks) between the previous user-role
20
+ * message anchor and the latest one exceeds this threshold, a bridge
21
+ * anchor is needed so the earlier slots are still within the window.
22
+ */
23
+ export const ANTHROPIC_CACHE_LOOKBACK_BLOCKS = 20;
17
24
  export const REQUIRED_BETAS = [
18
25
  'oauth-2025-04-20',
19
26
  'interleaved-thinking-2025-05-14',
@@ -22,9 +29,9 @@ export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
22
29
  export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
23
30
  export const CCH_SALT = '59cf53e54c78';
24
31
  export const CCH_POSITIONS = [4, 7, 20];
25
- export const CLAUDE_CODE_VERSION = '2.1.87';
26
- export const CLAUDE_CODE_ENTRYPOINT = 'sdk-cli';
27
- export const USER_AGENT = 'claude-cli/2.1.87 (external, cli)';
32
+ export const CLAUDE_CODE_VERSION = '2.1.177';
33
+ export const CLAUDE_CODE_ENTRYPOINT = 'cli';
34
+ export const USER_AGENT = 'claude-cli/2.1.177 (external, cli)';
28
35
  /**
29
36
  * Anchors that identify paragraphs to remove from the system prompt.
30
37
  * Any paragraph (text between blank lines) containing one of these
@@ -67,8 +67,20 @@ export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[
67
67
  * and apply hybrid 1h prompt caching.
68
68
  */
69
69
  export declare function rewriteRequestBody(body: string): string;
70
+ /**
71
+ * Error thrown when Anthropic emits a retryable server-side error *inside*
72
+ * an HTTP 200 stream. OpenCode recognises ECONNRESET + anthropic-sse syscall
73
+ * and applies its normal auto-retry flow instead of surfacing an unknown error.
74
+ */
75
+ export type RetryableAnthropicStreamError = Error & {
76
+ code: 'ECONNRESET';
77
+ syscall: 'anthropic-sse';
78
+ providerErrorType?: string;
79
+ };
70
80
  /**
71
81
  * Create a streaming response that strips the tool prefix from tool names.
82
+ * Detects retryable Anthropic server errors inside HTTP 200 streams and
83
+ * throws a connection-reset-style error so OpenCode can auto-retry.
72
84
  */
73
85
  export declare function createStrippedStream(response: Response): Response;
74
86
  export {};
package/dist/transform.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, USER_AGENT, } from "./constants.js";
1
+ import { ANTHROPIC_CACHE_LOOKBACK_BLOCKS, CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, USER_AGENT, } from "./constants.js";
2
2
  /**
3
3
  * Prefix a tool name with TOOL_PREFIX and uppercase the first character.
4
4
  * Claude Code uses PascalCase tool names (e.g. mcp_Bash, mcp_Read);
@@ -217,6 +217,9 @@ function isRecord(value) {
217
217
  return value != null && typeof value === 'object' && !Array.isArray(value);
218
218
  }
219
219
  const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
220
+ // ---------------------------------------------------------------------------
221
+ // Cache control primitives
222
+ // ---------------------------------------------------------------------------
220
223
  function removeCacheControl(value) {
221
224
  if (!isRecord(value))
222
225
  return;
@@ -245,36 +248,245 @@ function setWireCacheControl(value) {
245
248
  value.cache_control = { ...CACHE_1H };
246
249
  return true;
247
250
  }
248
- function setMessageCacheAnchor(message) {
251
+ // ---------------------------------------------------------------------------
252
+ // Content-block helpers
253
+ // ---------------------------------------------------------------------------
254
+ /**
255
+ * Returns true for content block types that accept cache_control.
256
+ * Anthropic rejects cache_control on thinking / redacted_thinking blocks.
257
+ */
258
+ function isCacheableContentBlock(block) {
259
+ if (!isRecord(block))
260
+ return false;
261
+ return block.type !== 'thinking' && block.type !== 'redacted_thinking';
262
+ }
263
+ /**
264
+ * Normalises message content to an array of blocks, then filters to only
265
+ * cacheable types. Returns undefined when there is nothing to anchor.
266
+ */
267
+ function getCacheableContentBlocks(message) {
249
268
  if (!isRecord(message))
269
+ return undefined;
270
+ let blocks;
271
+ if (Array.isArray(message.content)) {
272
+ blocks = message.content;
273
+ }
274
+ else if (typeof message.content === 'string') {
275
+ // Normalise inline string to block array in place so downstream sees array
276
+ const normalised = [{ type: 'text', text: message.content }];
277
+ message.content = normalised;
278
+ blocks = normalised;
279
+ }
280
+ else {
281
+ return undefined;
282
+ }
283
+ const cacheable = blocks.filter(isCacheableContentBlock);
284
+ return cacheable.length > 0 ? cacheable : undefined;
285
+ }
286
+ /**
287
+ * Total cacheable content-block count for a message (used for lookback math).
288
+ */
289
+ function messageContentBlockCount(message) {
290
+ return getCacheableContentBlocks(message)?.length ?? 0;
291
+ }
292
+ // ---------------------------------------------------------------------------
293
+ // Message-anchor setters
294
+ // ---------------------------------------------------------------------------
295
+ /**
296
+ * Anchor the last cacheable block of a message.
297
+ * Returns false (and sets nothing) when there are no cacheable blocks.
298
+ */
299
+ function setMessageCacheAnchor(message) {
300
+ const blocks = getCacheableContentBlocks(message);
301
+ if (!blocks)
250
302
  return false;
251
- const content = Array.isArray(message.content)
252
- ? message.content
253
- : typeof message.content === 'string'
254
- ? [{ type: 'text', text: message.content }]
255
- : null;
256
- if (!content?.length)
257
- return setWireCacheControl(message);
258
- message.content = content;
259
- const target = [...content]
260
- .reverse()
261
- .find((b) => isRecord(b) && b.type !== 'thinking');
262
- return setWireCacheControl(target ?? message);
303
+ return setWireCacheControl(blocks[blocks.length - 1]);
263
304
  }
264
- function applyHybridCache1h(parsed) {
265
- removeAllCacheControls(parsed);
266
- // Cache last system block after the identity block
267
- if (Array.isArray(parsed.system)) {
268
- const identityIdx = parsed.system.findIndex((b) => isRecord(b) && b.text === CLAUDE_CODE_IDENTITY);
269
- const cacheableSystem = parsed.system
270
- .slice(identityIdx >= 0 ? identityIdx + 1 : 0)
271
- .filter(isRecord);
272
- setWireCacheControl(cacheableSystem[cacheableSystem.length - 1]);
305
+ /**
306
+ * Anchor the FIRST cacheable block of a message (for magic-context split).
307
+ */
308
+ function setFirstMessageCacheAnchor(message) {
309
+ const blocks = getCacheableContentBlocks(message);
310
+ if (!blocks)
311
+ return false;
312
+ return setWireCacheControl(blocks[0]);
313
+ }
314
+ /**
315
+ * Anchor the SECOND cacheable block of a message (for magic-context split).
316
+ * Returns false when fewer than two cacheable blocks exist.
317
+ */
318
+ function setSecondMessageCacheAnchor(message) {
319
+ const blocks = getCacheableContentBlocks(message);
320
+ if (!blocks || blocks.length < 2)
321
+ return false;
322
+ return setWireCacheControl(blocks[1]);
323
+ }
324
+ /**
325
+ * Walk all messages and collect user-role (or tool_result) anchor positions.
326
+ * Returns the `latest` position (index > 1) and a `bridge` position placed
327
+ * whenever the cumulative block distance from bridge→latest exceeds
328
+ * ANTHROPIC_CACHE_LOOKBACK_BLOCKS, ensuring both are within the sliding window.
329
+ */
330
+ function selectHybridMessageAnchors(messages) {
331
+ // Collect positions of user-role messages that have cacheable content
332
+ const userPositions = messages
333
+ .map((msg, index) => {
334
+ if (!isRecord(msg))
335
+ return null;
336
+ if (msg.role !== 'user')
337
+ return null;
338
+ const blockCount = messageContentBlockCount(msg);
339
+ if (blockCount === 0)
340
+ return null;
341
+ return { index, blockCount };
342
+ })
343
+ .filter((p) => p !== null);
344
+ // We only care about positions beyond index 1 (0 and 1 are always anchored)
345
+ const rollingPositions = userPositions.filter((p) => p.index > 1);
346
+ if (rollingPositions.length === 0) {
347
+ return { latest: undefined, bridge: undefined };
348
+ }
349
+ // Non-null: rollingPositions is non-empty (early return guards above)
350
+ // biome-ignore lint/style/noNonNullAssertion: guarded by length check above
351
+ const latest = rollingPositions[rollingPositions.length - 1];
352
+ // Find a bridge: walk back from latest until cumulative blocks exceed window
353
+ let bridge;
354
+ let cumulativeBlocks = latest.blockCount;
355
+ for (let i = rollingPositions.length - 2; i >= 0; i--) {
356
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
357
+ cumulativeBlocks += rollingPositions[i].blockCount;
358
+ if (cumulativeBlocks > ANTHROPIC_CACHE_LOOKBACK_BLOCKS) {
359
+ bridge = rollingPositions[i];
360
+ break;
361
+ }
273
362
  }
363
+ return { latest, bridge };
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // System-anchor setter
367
+ // ---------------------------------------------------------------------------
368
+ function systemBlockText(block) {
369
+ return isRecord(block) && typeof block.text === 'string' ? block.text : '';
370
+ }
371
+ /**
372
+ * Merge all plugin-added system instruction blocks (those after the primary
373
+ * OpenCode/system prompt block) into a single block before placing the hybrid
374
+ * system cache anchor.
375
+ *
376
+ * OpenCode normally emits these as one merged block, but some hooks can cause
377
+ * them to arrive split across multiple blocks. Without coalescing, byte-
378
+ * identical system text flips between merged/split layouts and moves the
379
+ * cache_control breakpoint — busting the cache every turn.
380
+ *
381
+ * Block layout after prependClaudeCodeIdentity:
382
+ * [billing-header?] [identity] [primary system prompt] [plugin blocks…]
383
+ * We preserve everything up to and including the primary prompt block and
384
+ * merge all remaining plugin blocks into one.
385
+ */
386
+ function coalesceHybridSystemTail(parsed) {
387
+ if (!Array.isArray(parsed.system))
388
+ return;
389
+ const system = parsed.system;
390
+ let prefixCount = 0;
391
+ // Skip optional billing-header block (not present in this fork but guard is safe)
392
+ if (systemBlockText(system[prefixCount]).startsWith('x-anthropic-billing-header:')) {
393
+ prefixCount++;
394
+ }
395
+ // Skip identity block
396
+ if (systemBlockText(system[prefixCount]) === CLAUDE_CODE_IDENTITY) {
397
+ prefixCount++;
398
+ }
399
+ // tailStart points to the first plugin-added block (one after primary prompt)
400
+ const tailStart = prefixCount + 1;
401
+ if (tailStart >= system.length - 1)
402
+ return;
403
+ const firstTail = system[tailStart];
404
+ if (!isRecord(firstTail))
405
+ return;
406
+ const mergedText = system.slice(tailStart).map(systemBlockText).join('\n');
407
+ system.splice(tailStart, system.length - tailStart, {
408
+ ...firstTail,
409
+ type: 'text',
410
+ text: mergedText,
411
+ });
412
+ }
413
+ /**
414
+ * Place a cache anchor on the last system block that follows the
415
+ * CLAUDE_CODE_IDENTITY block. When there are no system blocks, or all
416
+ * system blocks precede the identity, nothing is anchored.
417
+ */
418
+ function setHybridSystemAnchor(parsed) {
419
+ if (!Array.isArray(parsed.system))
420
+ return;
421
+ const identityIdx = parsed.system.findIndex((b) => isRecord(b) && b.text === CLAUDE_CODE_IDENTITY);
422
+ const afterIdentity = parsed.system
423
+ .slice(identityIdx >= 0 ? identityIdx + 1 : 0)
424
+ .filter(isRecord);
425
+ setWireCacheControl(afterIdentity[afterIdentity.length - 1]);
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // Trailing-assistant strip (Tier 2)
429
+ // ---------------------------------------------------------------------------
430
+ /**
431
+ * Remove trailing assistant-role messages. OAuth endpoints reject requests
432
+ * that end with an assistant turn (assistant prefill is not supported).
433
+ */
434
+ function stripTrailingAssistantMessages(parsed) {
274
435
  if (!Array.isArray(parsed.messages))
275
436
  return;
276
- setMessageCacheAnchor(parsed.messages[0]);
277
- setMessageCacheAnchor(parsed.messages[1]);
437
+ while (parsed.messages.length > 0 &&
438
+ isRecord(parsed.messages[parsed.messages.length - 1]) &&
439
+ parsed.messages[parsed.messages.length - 1].role === 'assistant') {
440
+ parsed.messages.pop();
441
+ }
442
+ }
443
+ // ---------------------------------------------------------------------------
444
+ // Main hybrid cache logic
445
+ // ---------------------------------------------------------------------------
446
+ /**
447
+ * Apply hybrid 1h prompt-caching breakpoints to parsed request body.
448
+ *
449
+ * Breakpoint slots (Anthropic supports max 4 per request):
450
+ * 1. Last system block after the identity block (skipped when bridge used)
451
+ * 2. messages[0] — first cacheable block (magic-context: slot 2a)
452
+ * 3. messages[0] — second cacheable block (magic-context split only)
453
+ * OR messages[1] last cacheable block (normal path)
454
+ * OR bridge user message (when bridge detected)
455
+ * 4. Latest user/tool-result message (index > 1) (when present)
456
+ */
457
+ function applyHybridCache1h(parsed) {
458
+ removeAllCacheControls(parsed);
459
+ coalesceHybridSystemTail(parsed);
460
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
461
+ const { latest, bridge } = selectHybridMessageAnchors(messages);
462
+ // --- Slot 1: system anchor (skip when bridge will occupy a slot) ---
463
+ if (!bridge) {
464
+ setHybridSystemAnchor(parsed);
465
+ }
466
+ // --- Slots 2 & 3: messages[0] ---
467
+ const msg0 = messages[0];
468
+ const msg0Blocks = getCacheableContentBlocks(msg0);
469
+ if (msg0Blocks && msg0Blocks.length >= 2) {
470
+ // Magic-context split: stable prefix is in block[0] and block[1];
471
+ // anchoring last block would bust cache every turn.
472
+ setFirstMessageCacheAnchor(msg0);
473
+ setSecondMessageCacheAnchor(msg0);
474
+ }
475
+ else {
476
+ setMessageCacheAnchor(msg0);
477
+ // --- Slot 3 (normal): messages[1] or bridge ---
478
+ if (bridge) {
479
+ setHybridSystemAnchor(parsed); // system anchor reclaimed for bridge support
480
+ setMessageCacheAnchor(messages[bridge.index]);
481
+ }
482
+ else {
483
+ setMessageCacheAnchor(messages[1]);
484
+ }
485
+ }
486
+ // --- Slot 4: rolling latest user anchor ---
487
+ if (latest) {
488
+ setMessageCacheAnchor(messages[latest.index]);
489
+ }
278
490
  }
279
491
  /**
280
492
  * Sanitize system prompt and prepend Claude Code identity.
@@ -329,6 +541,7 @@ export function rewriteRequestBody(body) {
329
541
  try {
330
542
  const parsed = JSON.parse(body);
331
543
  parsed.system = prependClaudeCodeIdentity(parsed.system);
544
+ stripTrailingAssistantMessages(parsed);
332
545
  applyHybridCache1h(parsed);
333
546
  return prefixToolNames(parsed);
334
547
  }
@@ -336,8 +549,146 @@ export function rewriteRequestBody(body) {
336
549
  return body;
337
550
  }
338
551
  }
552
+ /** Find the first SSE event boundary (\n\n or \r\n\r\n) in a text buffer. */
553
+ function findSseBoundary(value) {
554
+ const lf = value.indexOf('\n\n');
555
+ const crlf = value.indexOf('\r\n\r\n');
556
+ if (lf === -1)
557
+ return crlf === -1 ? null : { index: crlf, length: 4 };
558
+ if (crlf === -1 || lf < crlf)
559
+ return { index: lf, length: 2 };
560
+ return { index: crlf, length: 4 };
561
+ }
562
+ function asDiagnosticRecord(value) {
563
+ return value != null && typeof value === 'object' && !Array.isArray(value)
564
+ ? value
565
+ : undefined;
566
+ }
567
+ function stringField(record, key) {
568
+ const v = record?.[key];
569
+ return typeof v === 'string' ? v : undefined;
570
+ }
571
+ function isRetryableAnthropicStreamError(errorType, message) {
572
+ const t = errorType?.toLowerCase();
573
+ const m = message.toLowerCase();
574
+ return (t === 'api_error' ||
575
+ t === 'overloaded_error' ||
576
+ t === 'server_error' ||
577
+ t === 'internal_server_error' ||
578
+ m.includes('internal server error') ||
579
+ m.includes('server overloaded'));
580
+ }
581
+ function retryableAnthropicStreamError(errorType, message) {
582
+ const detail = errorType ? `${errorType}: ${message}` : message;
583
+ const err = new Error(`Anthropic stream error: ${detail}`);
584
+ err.code = 'ECONNRESET';
585
+ err.syscall = 'anthropic-sse';
586
+ if (errorType)
587
+ err.providerErrorType = errorType;
588
+ return err;
589
+ }
590
+ function retryableAnthropicStreamErrorFromRawEvent(rawEvent) {
591
+ if (!rawEvent.includes('error'))
592
+ return null;
593
+ let eventName;
594
+ const dataLines = [];
595
+ for (const line of rawEvent.split(/\r?\n/)) {
596
+ if (line.startsWith('event:')) {
597
+ eventName = line.slice('event:'.length).trim();
598
+ }
599
+ else if (line.startsWith('data:')) {
600
+ const v = line.slice('data:'.length);
601
+ dataLines.push(v.startsWith(' ') ? v.slice(1) : v);
602
+ }
603
+ }
604
+ const dataText = dataLines.join('\n');
605
+ if (!dataText || dataText === '[DONE]')
606
+ return null;
607
+ let parsed;
608
+ try {
609
+ parsed = JSON.parse(dataText);
610
+ }
611
+ catch {
612
+ return null;
613
+ }
614
+ const data = asDiagnosticRecord(parsed);
615
+ if (eventName !== 'error' && stringField(data, 'type') !== 'error')
616
+ return null;
617
+ const errorObj = asDiagnosticRecord(data?.error);
618
+ const errorType = stringField(errorObj, 'type') ??
619
+ stringField(errorObj, 'code') ??
620
+ undefined;
621
+ const message = stringField(errorObj, 'message') ??
622
+ stringField(data, 'message') ??
623
+ errorType ??
624
+ 'Anthropic stream error';
625
+ if (!isRetryableAnthropicStreamError(errorType, message))
626
+ return null;
627
+ return retryableAnthropicStreamError(errorType, message);
628
+ }
629
+ function createSseErrorState() {
630
+ return { pending: '' };
631
+ }
632
+ function updateSseErrorState(state, text) {
633
+ if (!text)
634
+ return null;
635
+ state.pending += text;
636
+ while (true) {
637
+ const boundary = findSseBoundary(state.pending);
638
+ if (!boundary)
639
+ break;
640
+ const rawEvent = state.pending.slice(0, boundary.index);
641
+ state.pending = state.pending.slice(boundary.index + boundary.length);
642
+ const err = retryableAnthropicStreamErrorFromRawEvent(rawEvent);
643
+ if (err)
644
+ return err;
645
+ }
646
+ return null;
647
+ }
648
+ // ---------------------------------------------------------------------------
649
+ // Buffered tool-prefix rewriting (v1.10.0)
650
+ // ---------------------------------------------------------------------------
651
+ /**
652
+ * Rewrite the tool prefix from the safe portion of a text buffer.
653
+ * Holds back any suffix that could be the start of a partial `"name"` marker
654
+ * spanning a chunk boundary. Pass flush=true on stream end to emit everything.
655
+ */
656
+ function splitToolPrefixRewriteBuffer(buffer, flush = false) {
657
+ if (flush)
658
+ return { ready: stripToolPrefix(buffer), pending: '' };
659
+ let keepFrom = buffer.length;
660
+ const marker = '"name"';
661
+ // Hold back any suffix that starts a partial marker
662
+ const partialStart = Math.max(0, buffer.length - marker.length + 1);
663
+ for (let i = partialStart; i < buffer.length; i++) {
664
+ if (marker.startsWith(buffer.slice(i))) {
665
+ keepFrom = Math.min(keepFrom, i);
666
+ break;
667
+ }
668
+ }
669
+ // Also hold back if the last occurrence of the marker is incomplete
670
+ const lastMarker = buffer.lastIndexOf(marker);
671
+ if (lastMarker !== -1) {
672
+ const tail = buffer.slice(lastMarker);
673
+ if (/^"name"\s*(?::\s*(?:"[^"]*)?)?$/.test(tail)) {
674
+ keepFrom = Math.min(keepFrom, lastMarker);
675
+ }
676
+ }
677
+ if (keepFrom < buffer.length) {
678
+ return {
679
+ ready: stripToolPrefix(buffer.slice(0, keepFrom)),
680
+ pending: buffer.slice(keepFrom),
681
+ };
682
+ }
683
+ return { ready: stripToolPrefix(buffer), pending: '' };
684
+ }
685
+ // ---------------------------------------------------------------------------
686
+ // Stream response wrapper
687
+ // ---------------------------------------------------------------------------
339
688
  /**
340
689
  * Create a streaming response that strips the tool prefix from tool names.
690
+ * Detects retryable Anthropic server errors inside HTTP 200 streams and
691
+ * throws a connection-reset-style error so OpenCode can auto-retry.
341
692
  */
342
693
  export function createStrippedStream(response) {
343
694
  if (!response.body)
@@ -345,16 +696,56 @@ export function createStrippedStream(response) {
345
696
  const reader = response.body.getReader();
346
697
  const decoder = new TextDecoder();
347
698
  const encoder = new TextEncoder();
699
+ let pending = '';
700
+ let readerReleased = false;
701
+ const sseErrors = createSseErrorState();
702
+ const releaseReader = () => {
703
+ if (readerReleased)
704
+ return;
705
+ readerReleased = true;
706
+ reader.releaseLock();
707
+ };
348
708
  const stream = new ReadableStream({
349
709
  async pull(controller) {
350
- const { done, value } = await reader.read();
351
- if (done) {
352
- controller.close();
353
- return;
710
+ try {
711
+ const { done, value } = await reader.read();
712
+ if (done) {
713
+ const finalDecoded = decoder.decode();
714
+ const retryableError = updateSseErrorState(sseErrors, finalDecoded);
715
+ if (retryableError) {
716
+ releaseReader();
717
+ throw retryableError;
718
+ }
719
+ const { ready } = splitToolPrefixRewriteBuffer(`${pending}${finalDecoded}`, true);
720
+ if (ready)
721
+ controller.enqueue(encoder.encode(ready));
722
+ releaseReader();
723
+ controller.close();
724
+ return;
725
+ }
726
+ const decoded = decoder.decode(value, { stream: true });
727
+ const retryableError = updateSseErrorState(sseErrors, decoded);
728
+ if (retryableError) {
729
+ releaseReader();
730
+ throw retryableError;
731
+ }
732
+ const { ready, pending: nextPending } = splitToolPrefixRewriteBuffer(pending + decoded);
733
+ pending = nextPending;
734
+ if (ready)
735
+ controller.enqueue(encoder.encode(ready));
736
+ }
737
+ catch (error) {
738
+ releaseReader();
739
+ throw error;
740
+ }
741
+ },
742
+ async cancel(reason) {
743
+ try {
744
+ await reader.cancel(reason);
745
+ }
746
+ finally {
747
+ releaseReader();
354
748
  }
355
- let text = decoder.decode(value, { stream: true });
356
- text = stripToolPrefix(text);
357
- controller.enqueue(encoder.encode(text));
358
749
  },
359
750
  });
360
751
  return new Response(stream, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sahiljassal/opencode-anthropic-auth",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/shljsl75891/opencode-anthropic-auth.git"
@@ -31,14 +31,14 @@
31
31
  "@opencode-ai/plugin": "*"
32
32
  },
33
33
  "devDependencies": {
34
- "@biomejs/biome": "2.4.15",
34
+ "@biomejs/biome": "2.4.16",
35
35
  "@changesets/changelog-github": "^0.7.0",
36
36
  "@changesets/cli": "^2.31.0",
37
- "@opencode-ai/plugin": "1.14.50",
37
+ "@opencode-ai/plugin": "1.17.3",
38
38
  "@tsconfig/bun": "1.0.10",
39
39
  "@types/bun": "1.3.14",
40
40
  "dedent": "^1.7.2",
41
- "lefthook": "2.1.6",
41
+ "lefthook": "2.1.9",
42
42
  "typescript": "6.0.3"
43
43
  }
44
44
  }