@sahiljassal/opencode-anthropic-auth 2.1.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,12 +27,22 @@ 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 |
34
38
 
35
- This reduces token usage and latency on repeated requests by reusing cached prompt prefixes.
39
+ Additional behaviours:
40
+
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
 
@@ -19,9 +19,9 @@ export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
19
19
  export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
20
20
  export declare const CCH_SALT = "59cf53e54c78";
21
21
  export declare const CCH_POSITIONS: number[];
22
- export declare const CLAUDE_CODE_VERSION = "2.1.87";
23
- export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
24
- 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)";
25
25
  /**
26
26
  * Anchors that identify paragraphs to remove from the system prompt.
27
27
  * Any paragraph (text between blank lines) containing one of these
package/dist/constants.js CHANGED
@@ -29,9 +29,9 @@ export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
29
29
  export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
30
30
  export const CCH_SALT = '59cf53e54c78';
31
31
  export const CCH_POSITIONS = [4, 7, 20];
32
- export const CLAUDE_CODE_VERSION = '2.1.87';
33
- export const CLAUDE_CODE_ENTRYPOINT = 'sdk-cli';
34
- 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)';
35
35
  /**
36
36
  * Anchors that identify paragraphs to remove from the system prompt.
37
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
@@ -365,6 +365,51 @@ function selectHybridMessageAnchors(messages) {
365
365
  // ---------------------------------------------------------------------------
366
366
  // System-anchor setter
367
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
+ }
368
413
  /**
369
414
  * Place a cache anchor on the last system block that follows the
370
415
  * CLAUDE_CODE_IDENTITY block. When there are no system blocks, or all
@@ -411,6 +456,7 @@ function stripTrailingAssistantMessages(parsed) {
411
456
  */
412
457
  function applyHybridCache1h(parsed) {
413
458
  removeAllCacheControls(parsed);
459
+ coalesceHybridSystemTail(parsed);
414
460
  const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
415
461
  const { latest, bridge } = selectHybridMessageAnchors(messages);
416
462
  // --- Slot 1: system anchor (skip when bridge will occupy a slot) ---
@@ -503,8 +549,146 @@ export function rewriteRequestBody(body) {
503
549
  return body;
504
550
  }
505
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
+ // ---------------------------------------------------------------------------
506
688
  /**
507
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.
508
692
  */
509
693
  export function createStrippedStream(response) {
510
694
  if (!response.body)
@@ -512,16 +696,56 @@ export function createStrippedStream(response) {
512
696
  const reader = response.body.getReader();
513
697
  const decoder = new TextDecoder();
514
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
+ };
515
708
  const stream = new ReadableStream({
516
709
  async pull(controller) {
517
- const { done, value } = await reader.read();
518
- if (done) {
519
- controller.close();
520
- 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();
521
748
  }
522
- let text = decoder.decode(value, { stream: true });
523
- text = stripToolPrefix(text);
524
- controller.enqueue(encoder.encode(text));
525
749
  },
526
750
  });
527
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.1.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"