@sahiljassal/opencode-anthropic-auth 2.1.0 → 2.2.0

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 the block count between two user anchors (counting ALL block types across every role) exceeds 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
 
@@ -8,10 +8,12 @@ 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
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.
11
+ * Anthropic's cache lookback window size. Each explicit breakpoint scans at
12
+ * most this many content blocks backward (counting the breakpoint block as
13
+ * position 1, across all roles and types text, thinking, tool_use,
14
+ * tool_result, …). When the estimated block count between two user-role anchors
15
+ * would exceed this threshold, a bridge anchor is inserted to keep the older
16
+ * breakpoint inside the window and the cached prefix reachable.
15
17
  */
16
18
  export declare const ANTHROPIC_CACHE_LOOKBACK_BLOCKS = 20;
17
19
  export declare const REQUIRED_BETAS: string[];
@@ -19,9 +21,9 @@ export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
19
21
  export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
20
22
  export declare const CCH_SALT = "59cf53e54c78";
21
23
  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)";
24
+ export declare const CLAUDE_CODE_VERSION = "2.1.177";
25
+ export declare const CLAUDE_CODE_ENTRYPOINT = "cli";
26
+ export declare const USER_AGENT = "claude-cli/2.1.177 (external, cli)";
25
27
  /**
26
28
  * Anchors that identify paragraphs to remove from the system prompt.
27
29
  * Any paragraph (text between blank lines) containing one of these
package/dist/constants.js CHANGED
@@ -15,23 +15,27 @@ export const OAUTH_SCOPES = [
15
15
  ];
16
16
  export const TOOL_PREFIX = 'mcp_';
17
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.
18
+ * Anthropic's cache lookback window size. Each explicit breakpoint scans at
19
+ * most this many content blocks backward (counting the breakpoint block as
20
+ * position 1, across all roles and types text, thinking, tool_use,
21
+ * tool_result, …). When the estimated block count between two user-role anchors
22
+ * would exceed this threshold, a bridge anchor is inserted to keep the older
23
+ * breakpoint inside the window and the cached prefix reachable.
22
24
  */
23
25
  export const ANTHROPIC_CACHE_LOOKBACK_BLOCKS = 20;
24
26
  export const REQUIRED_BETAS = [
25
27
  'oauth-2025-04-20',
28
+ 'claude-code-20250219',
26
29
  'interleaved-thinking-2025-05-14',
30
+ 'fine-grained-tool-streaming-2025-05-14',
27
31
  ];
28
32
  export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
29
33
  export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
30
34
  export const CCH_SALT = '59cf53e54c78';
31
35
  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)';
36
+ export const CLAUDE_CODE_VERSION = '2.1.177';
37
+ export const CLAUDE_CODE_ENTRYPOINT = 'cli';
38
+ export const USER_AGENT = 'claude-cli/2.1.177 (external, cli)';
35
39
  /**
36
40
  * Anchors that identify paragraphs to remove from the system prompt.
37
41
  * Any paragraph (text between blank lines) containing one of these
@@ -9,7 +9,7 @@ export declare function mergeHeaders(input: FetchInput, init?: RequestInit): Hea
9
9
  */
10
10
  export declare function mergeBetaHeaders(headers: Headers): string;
11
11
  /**
12
- * Set OAuth-required headers on the request: authorization, beta, user-agent.
12
+ * Set OAuth-required headers: authorization, beta, user-agent.
13
13
  * Removes x-api-key since we're using OAuth.
14
14
  */
15
15
  export declare function setOAuthHeaders(headers: Headers, accessToken: string): Headers;
@@ -31,7 +31,6 @@ export declare function isInsecure(): boolean;
31
31
  * Rewrite the request URL to add ?beta=true for /v1/messages requests.
32
32
  * When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
33
33
  * for all API requests flowing through the fetch wrapper.
34
- * Returns the modified input and URL (if applicable).
35
34
  */
36
35
  export declare function rewriteUrl(input: FetchInput): {
37
36
  input: FetchInput;
@@ -62,13 +61,21 @@ type SystemBlock = {
62
61
  * Handles all Anthropic API system formats: undefined, string, or array of text blocks.
63
62
  */
64
63
  export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[];
64
+ export declare function rewriteRequestBody(body: string): string;
65
65
  /**
66
- * Rewrite the full request body: sanitize system prompt, prefix tool names,
67
- * and apply hybrid 1h prompt caching.
66
+ * Error thrown when Anthropic emits a retryable server-side error inside
67
+ * an HTTP 200 stream. OpenCode recognises ECONNRESET + anthropic-sse syscall
68
+ * and applies its normal auto-retry flow instead of surfacing an unknown error.
68
69
  */
69
- export declare function rewriteRequestBody(body: string): string;
70
+ export type RetryableAnthropicStreamError = Error & {
71
+ code: 'ECONNRESET';
72
+ syscall: 'anthropic-sse';
73
+ providerErrorType?: string;
74
+ };
70
75
  /**
71
76
  * Create a streaming response that strips the tool prefix from tool names.
77
+ * Detects retryable Anthropic server errors inside HTTP 200 streams and
78
+ * throws a connection-reset-style error so OpenCode can auto-retry.
72
79
  */
73
80
  export declare function createStrippedStream(response: Response): Response;
74
81
  export {};
package/dist/transform.js CHANGED
@@ -1,17 +1,8 @@
1
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
- /**
3
- * Prefix a tool name with TOOL_PREFIX and uppercase the first character.
4
- * Claude Code uses PascalCase tool names (e.g. mcp_Bash, mcp_Read);
5
- * lowercase names (mcp_bash, mcp_read) are flagged as non-Claude-Code clients.
6
- */
7
2
  function prefixName(name) {
8
3
  return `${TOOL_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
9
4
  }
10
- /**
11
- * Reverse prefixName: strip TOOL_PREFIX and restore the original leading case.
12
- */
13
5
  function unprefixName(name) {
14
- // StructuredOutput is still used as StructuredOutput
15
6
  if (name === 'StructuredOutput') {
16
7
  return name;
17
8
  }
@@ -65,7 +56,7 @@ export function mergeBetaHeaders(headers) {
65
56
  return [...new Set([...REQUIRED_BETAS, ...incomingBetasList])].join(',');
66
57
  }
67
58
  /**
68
- * Set OAuth-required headers on the request: authorization, beta, user-agent.
59
+ * Set OAuth-required headers: authorization, beta, user-agent.
69
60
  * Removes x-api-key since we're using OAuth.
70
61
  */
71
62
  export function setOAuthHeaders(headers, accessToken) {
@@ -142,7 +133,6 @@ function resolveBaseUrl() {
142
133
  * Rewrite the request URL to add ?beta=true for /v1/messages requests.
143
134
  * When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
144
135
  * for all API requests flowing through the fetch wrapper.
145
- * Returns the modified input and URL (if applicable).
146
136
  */
147
137
  export function rewriteUrl(input) {
148
138
  let requestUrl = null;
@@ -192,14 +182,11 @@ export function rewriteUrl(input) {
192
182
  * somewhere in the paragraph, the removal works.
193
183
  */
194
184
  export function sanitizeSystemText(text) {
195
- // Split into paragraphs (separated by one or more blank lines)
196
185
  const paragraphs = text.split(/\n\n+/);
197
186
  const filtered = paragraphs.filter((paragraph) => {
198
187
  if (paragraph.includes(OPENCODE_IDENTITY_PREFIX)) {
199
- // If the paragraph contains the identity, drop it entirely
200
188
  return false;
201
189
  }
202
- // Remove paragraphs containing any removal anchor
203
190
  for (const anchor of PARAGRAPH_REMOVAL_ANCHORS) {
204
191
  if (paragraph.includes(anchor))
205
192
  return false;
@@ -207,7 +194,6 @@ export function sanitizeSystemText(text) {
207
194
  return true;
208
195
  });
209
196
  let result = filtered.join('\n\n');
210
- // Apply inline text replacements
211
197
  for (const rule of TEXT_REPLACEMENTS) {
212
198
  result = result.replace(rule.match, rule.replacement);
213
199
  }
@@ -217,9 +203,6 @@ function isRecord(value) {
217
203
  return value != null && typeof value === 'object' && !Array.isArray(value);
218
204
  }
219
205
  const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
220
- // ---------------------------------------------------------------------------
221
- // Cache control primitives
222
- // ---------------------------------------------------------------------------
223
206
  function removeCacheControl(value) {
224
207
  if (!isRecord(value))
225
208
  return;
@@ -248,21 +231,23 @@ function setWireCacheControl(value) {
248
231
  value.cache_control = { ...CACHE_1H };
249
232
  return true;
250
233
  }
251
- // ---------------------------------------------------------------------------
252
- // Content-block helpers
253
- // ---------------------------------------------------------------------------
254
234
  /**
255
235
  * Returns true for content block types that accept cache_control.
256
- * Anthropic rejects cache_control on thinking / redacted_thinking blocks.
236
+ * Anthropic rejects cache_control on thinking / redacted_thinking blocks
237
+ * and silently skips empty text blocks without caching them.
257
238
  */
258
239
  function isCacheableContentBlock(block) {
259
240
  if (!isRecord(block))
260
241
  return false;
261
- return block.type !== 'thinking' && block.type !== 'redacted_thinking';
242
+ if (block.type === 'thinking' || block.type === 'redacted_thinking')
243
+ return false;
244
+ if (block.type === 'text' && !String(block.text ?? '').trim())
245
+ return false;
246
+ return true;
262
247
  }
263
248
  /**
264
249
  * Normalises message content to an array of blocks, then filters to only
265
- * cacheable types. Returns undefined when there is nothing to anchor.
250
+ * cacheable types. Returns undefined when there is nothing to anchor.
266
251
  */
267
252
  function getCacheableContentBlocks(message) {
268
253
  if (!isRecord(message))
@@ -272,7 +257,6 @@ function getCacheableContentBlocks(message) {
272
257
  blocks = message.content;
273
258
  }
274
259
  else if (typeof message.content === 'string') {
275
- // Normalise inline string to block array in place so downstream sees array
276
260
  const normalised = [{ type: 'text', text: message.content }];
277
261
  message.content = normalised;
278
262
  blocks = normalised;
@@ -283,38 +267,21 @@ function getCacheableContentBlocks(message) {
283
267
  const cacheable = blocks.filter(isCacheableContentBlock);
284
268
  return cacheable.length > 0 ? cacheable : undefined;
285
269
  }
286
- /**
287
- * Total cacheable content-block count for a message (used for lookback math).
288
- */
289
270
  function messageContentBlockCount(message) {
290
271
  return getCacheableContentBlocks(message)?.length ?? 0;
291
272
  }
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
273
  function setMessageCacheAnchor(message) {
300
274
  const blocks = getCacheableContentBlocks(message);
301
275
  if (!blocks)
302
276
  return false;
303
277
  return setWireCacheControl(blocks[blocks.length - 1]);
304
278
  }
305
- /**
306
- * Anchor the FIRST cacheable block of a message (for magic-context split).
307
- */
308
279
  function setFirstMessageCacheAnchor(message) {
309
280
  const blocks = getCacheableContentBlocks(message);
310
281
  if (!blocks)
311
282
  return false;
312
283
  return setWireCacheControl(blocks[0]);
313
284
  }
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
285
  function setSecondMessageCacheAnchor(message) {
319
286
  const blocks = getCacheableContentBlocks(message);
320
287
  if (!blocks || blocks.length < 2)
@@ -322,53 +289,106 @@ function setSecondMessageCacheAnchor(message) {
322
289
  return setWireCacheControl(blocks[1]);
323
290
  }
324
291
  /**
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.
292
+ * Total positional block count for a message regardless of role or type.
293
+ * Anthropic's cache lookback window counts every content block (text,
294
+ * thinking, tool_use, tool_result, …), so distance math must use raw counts,
295
+ * not the cacheable-only filter used to validate anchor placement.
296
+ */
297
+ function rawBlockCount(message) {
298
+ if (!isRecord(message))
299
+ return 0;
300
+ const { content } = message;
301
+ if (Array.isArray(content))
302
+ return content.length;
303
+ if (typeof content === 'string')
304
+ return 1;
305
+ return 0;
306
+ }
307
+ /**
308
+ * Collect user-role anchor indices that hold at least one cacheable block,
309
+ * then pick the `latest` (index > 1) and a `bridge`. The bridge is the
310
+ * furthest-back valid user anchor where the block count between it and
311
+ * `latest` — counting ALL content blocks across user and assistant turns,
312
+ * excluding the anchor's own blocks — does not exceed
313
+ * ANTHROPIC_CACHE_LOOKBACK_BLOCKS. This keeps the older breakpoint inside
314
+ * Anthropic's sliding lookback window so the cached prefix stays reachable.
329
315
  */
330
316
  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) {
317
+ // Collect indices of user messages beyond index 1 that have ≥1 cacheable block.
318
+ // Indices 0 and 1 are handled as fixed slots in applyHybridCache1h, not rolling.
319
+ const rollingIndices = [];
320
+ messages.forEach((msg, index) => {
321
+ if (index > 1 &&
322
+ isRecord(msg) &&
323
+ msg.role === 'user' &&
324
+ messageContentBlockCount(msg) > 0) {
325
+ rollingIndices.push(index);
326
+ }
327
+ });
328
+ if (rollingIndices.length === 0) {
347
329
  return { latest: undefined, bridge: undefined };
348
330
  }
349
- // Non-null: rollingPositions is non-empty (early return guards above)
350
331
  // 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
332
+ const latestIndex = rollingIndices[rollingIndices.length - 1];
333
+ const rollingSet = new Set(rollingIndices);
353
334
  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;
335
+ let cumulativeBlocks = 0;
336
+ for (let i = latestIndex - 1; i >= 0; i--) {
337
+ if (rollingSet.has(i)) {
338
+ if (cumulativeBlocks > ANTHROPIC_CACHE_LOOKBACK_BLOCKS)
339
+ break;
340
+ bridge = i;
361
341
  }
342
+ cumulativeBlocks += rawBlockCount(messages[i]);
362
343
  }
363
- return { latest, bridge };
344
+ return { latest: latestIndex, bridge };
345
+ }
346
+ function systemBlockText(block) {
347
+ return isRecord(block) && typeof block.text === 'string' ? block.text : '';
348
+ }
349
+ /**
350
+ * Merge all plugin-added system instruction blocks (those after the primary
351
+ * OpenCode/system prompt block) into a single block before placing the hybrid
352
+ * system cache anchor.
353
+ *
354
+ * OpenCode normally emits these as one merged block, but some hooks can cause
355
+ * them to arrive split across multiple blocks. Without coalescing, byte-
356
+ * identical system text flips between merged/split layouts and moves the
357
+ * cache_control breakpoint — busting the cache every turn.
358
+ *
359
+ * Block layout after prependClaudeCodeIdentity:
360
+ * [billing-header?] [identity] [primary system prompt] [plugin blocks…]
361
+ * We preserve everything up to and including the primary prompt block and
362
+ * merge all remaining plugin blocks into one.
363
+ */
364
+ function coalesceHybridSystemTail(parsed) {
365
+ if (!Array.isArray(parsed.system))
366
+ return;
367
+ const system = parsed.system;
368
+ let prefixCount = 0;
369
+ if (systemBlockText(system[prefixCount]).startsWith('x-anthropic-billing-header:')) {
370
+ prefixCount++;
371
+ }
372
+ if (systemBlockText(system[prefixCount]) === CLAUDE_CODE_IDENTITY) {
373
+ prefixCount++;
374
+ }
375
+ const tailStart = prefixCount + 1;
376
+ if (tailStart >= system.length - 1)
377
+ return;
378
+ const firstTail = system[tailStart];
379
+ if (!isRecord(firstTail))
380
+ return;
381
+ const mergedText = system.slice(tailStart).map(systemBlockText).join('\n');
382
+ system.splice(tailStart, system.length - tailStart, {
383
+ ...firstTail,
384
+ type: 'text',
385
+ text: mergedText,
386
+ });
364
387
  }
365
- // ---------------------------------------------------------------------------
366
- // System-anchor setter
367
- // ---------------------------------------------------------------------------
368
388
  /**
369
389
  * Place a cache anchor on the last system block that follows the
370
- * CLAUDE_CODE_IDENTITY block. When there are no system blocks, or all
371
- * system blocks precede the identity, nothing is anchored.
390
+ * CLAUDE_CODE_IDENTITY block. When there are no system blocks after the
391
+ * identity, nothing is anchored.
372
392
  */
373
393
  function setHybridSystemAnchor(parsed) {
374
394
  if (!Array.isArray(parsed.system))
@@ -379,11 +399,8 @@ function setHybridSystemAnchor(parsed) {
379
399
  .filter(isRecord);
380
400
  setWireCacheControl(afterIdentity[afterIdentity.length - 1]);
381
401
  }
382
- // ---------------------------------------------------------------------------
383
- // Trailing-assistant strip (Tier 2)
384
- // ---------------------------------------------------------------------------
385
402
  /**
386
- * Remove trailing assistant-role messages. OAuth endpoints reject requests
403
+ * Remove trailing assistant-role messages. OAuth endpoints reject requests
387
404
  * that end with an assistant turn (assistant prefill is not supported).
388
405
  */
389
406
  function stripTrailingAssistantMessages(parsed) {
@@ -395,51 +412,60 @@ function stripTrailingAssistantMessages(parsed) {
395
412
  parsed.messages.pop();
396
413
  }
397
414
  }
398
- // ---------------------------------------------------------------------------
399
- // Main hybrid cache logic
400
- // ---------------------------------------------------------------------------
415
+ /**
416
+ * Returns true when messages[0] carries a merged stable-prefix layout
417
+ * (≥2 cacheable blocks). In that case anchoring the last block would bust
418
+ * the cache every turn because the tail is volatile; instead we anchor
419
+ * block[0] and block[1] (the two stable-prefix blocks).
420
+ */
421
+ function isMagicContextLayout(blocks) {
422
+ return blocks.length >= 2;
423
+ }
401
424
  /**
402
425
  * Apply hybrid 1h prompt-caching breakpoints to parsed request body.
403
426
  *
404
- * Breakpoint slots (Anthropic supports max 4 per request):
405
- * 1. Last system block after the identity block (skipped when bridge used)
406
- * 2. messages[0]first cacheable block (magic-context: slot 2a)
407
- * 3. messages[0] second cacheable block (magic-context split only)
408
- * OR messages[1] last cacheable block (normal path)
409
- * OR bridge user message (when bridge detected)
410
- * 4. Latest user/tool-result message (index > 1) (when present)
427
+ * Anthropic supports max 4 cache breakpoints per request. Slot allocation:
428
+ *
429
+ * Slot 1system anchor: last block after identity.
430
+ * Skipped when a bridge is used (bridge takes that slot).
431
+ *
432
+ * Slots 2+3 messages[0]:
433
+ * Normal layout (1 cacheable block): slot 2 = last block of msg[0];
434
+ * slot 3 = bridge message OR messages[1] (no bridge).
435
+ * • Magic-context layout (≥2 cacheable blocks): slot 2 = block[0],
436
+ * slot 3 = block[1]. messages[1] is skipped (msg[0] uses both slots).
437
+ *
438
+ * Slot 4 — rolling latest: last user/tool-result message beyond index 1.
439
+ *
440
+ * Bridge and magic-context are independent: when both apply simultaneously,
441
+ * the slot budget is: system(skipped) + msg0-block0 + bridge + latest = 4.
442
+ * The bridge anchor is ALWAYS placed when detected, regardless of msg0 layout.
411
443
  */
412
444
  function applyHybridCache1h(parsed) {
413
445
  removeAllCacheControls(parsed);
446
+ coalesceHybridSystemTail(parsed);
414
447
  const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
415
448
  const { latest, bridge } = selectHybridMessageAnchors(messages);
416
- // --- Slot 1: system anchor (skip when bridge will occupy a slot) ---
417
- if (!bridge) {
449
+ if (bridge === undefined) {
418
450
  setHybridSystemAnchor(parsed);
419
451
  }
420
- // --- Slots 2 & 3: messages[0] ---
421
452
  const msg0 = messages[0];
422
453
  const msg0Blocks = getCacheableContentBlocks(msg0);
423
- if (msg0Blocks && msg0Blocks.length >= 2) {
424
- // Magic-context split: stable prefix is in block[0] and block[1];
425
- // anchoring last block would bust cache every turn.
454
+ if (msg0Blocks && isMagicContextLayout(msg0Blocks)) {
426
455
  setFirstMessageCacheAnchor(msg0);
427
456
  setSecondMessageCacheAnchor(msg0);
428
457
  }
429
458
  else {
430
459
  setMessageCacheAnchor(msg0);
431
- // --- Slot 3 (normal): messages[1] or bridge ---
432
- if (bridge) {
433
- setHybridSystemAnchor(parsed); // system anchor reclaimed for bridge support
434
- setMessageCacheAnchor(messages[bridge.index]);
435
- }
436
- else {
460
+ if (bridge === undefined) {
437
461
  setMessageCacheAnchor(messages[1]);
438
462
  }
439
463
  }
440
- // --- Slot 4: rolling latest user anchor ---
441
- if (latest) {
442
- setMessageCacheAnchor(messages[latest.index]);
464
+ if (bridge !== undefined) {
465
+ setMessageCacheAnchor(messages[bridge]);
466
+ }
467
+ if (latest !== undefined) {
468
+ setMessageCacheAnchor(messages[latest]);
443
469
  }
444
470
  }
445
471
  /**
@@ -481,16 +507,11 @@ export function prependClaudeCodeIdentity(system) {
481
507
  }
482
508
  return { type: 'text', text: String(item) };
483
509
  });
484
- // Idempotency: don't double-prepend if first block already has the identity
485
510
  if (sanitized[0]?.text === CLAUDE_CODE_IDENTITY) {
486
511
  return sanitized;
487
512
  }
488
513
  return [identityBlock, ...sanitized];
489
514
  }
490
- /**
491
- * Rewrite the full request body: sanitize system prompt, prefix tool names,
492
- * and apply hybrid 1h prompt caching.
493
- */
494
515
  export function rewriteRequestBody(body) {
495
516
  try {
496
517
  const parsed = JSON.parse(body);
@@ -503,8 +524,135 @@ export function rewriteRequestBody(body) {
503
524
  return body;
504
525
  }
505
526
  }
527
+ function findSseBoundary(value) {
528
+ const lf = value.indexOf('\n\n');
529
+ const crlf = value.indexOf('\r\n\r\n');
530
+ if (lf === -1)
531
+ return crlf === -1 ? null : { index: crlf, length: 4 };
532
+ if (crlf === -1 || lf < crlf)
533
+ return { index: lf, length: 2 };
534
+ return { index: crlf, length: 4 };
535
+ }
536
+ function asDiagnosticRecord(value) {
537
+ return value != null && typeof value === 'object' && !Array.isArray(value)
538
+ ? value
539
+ : undefined;
540
+ }
541
+ function stringField(record, key) {
542
+ const v = record?.[key];
543
+ return typeof v === 'string' ? v : undefined;
544
+ }
545
+ function isRetryableAnthropicStreamError(errorType, message) {
546
+ const t = errorType?.toLowerCase();
547
+ const m = message.toLowerCase();
548
+ return (t === 'api_error' ||
549
+ t === 'overloaded_error' ||
550
+ t === 'server_error' ||
551
+ t === 'internal_server_error' ||
552
+ m.includes('internal server error') ||
553
+ m.includes('server overloaded'));
554
+ }
555
+ function retryableAnthropicStreamError(errorType, message) {
556
+ const detail = errorType ? `${errorType}: ${message}` : message;
557
+ const err = new Error(`Anthropic stream error: ${detail}`);
558
+ err.code = 'ECONNRESET';
559
+ err.syscall = 'anthropic-sse';
560
+ if (errorType)
561
+ err.providerErrorType = errorType;
562
+ return err;
563
+ }
564
+ function retryableAnthropicStreamErrorFromRawEvent(rawEvent) {
565
+ if (!rawEvent.includes('error'))
566
+ return null;
567
+ let eventName;
568
+ const dataLines = [];
569
+ for (const line of rawEvent.split(/\r?\n/)) {
570
+ if (line.startsWith('event:')) {
571
+ eventName = line.slice('event:'.length).trim();
572
+ }
573
+ else if (line.startsWith('data:')) {
574
+ const v = line.slice('data:'.length);
575
+ dataLines.push(v.startsWith(' ') ? v.slice(1) : v);
576
+ }
577
+ }
578
+ const dataText = dataLines.join('\n');
579
+ if (!dataText || dataText === '[DONE]')
580
+ return null;
581
+ let parsed;
582
+ try {
583
+ parsed = JSON.parse(dataText);
584
+ }
585
+ catch {
586
+ return null;
587
+ }
588
+ const data = asDiagnosticRecord(parsed);
589
+ if (eventName !== 'error' && stringField(data, 'type') !== 'error')
590
+ return null;
591
+ const errorObj = asDiagnosticRecord(data?.error);
592
+ const errorType = stringField(errorObj, 'type') ?? stringField(errorObj, 'code') ?? undefined;
593
+ const message = stringField(errorObj, 'message') ??
594
+ stringField(data, 'message') ??
595
+ errorType ??
596
+ 'Anthropic stream error';
597
+ if (!isRetryableAnthropicStreamError(errorType, message))
598
+ return null;
599
+ return retryableAnthropicStreamError(errorType, message);
600
+ }
601
+ function createSseErrorState() {
602
+ return { pending: '' };
603
+ }
604
+ function updateSseErrorState(state, text) {
605
+ if (!text)
606
+ return null;
607
+ state.pending += text;
608
+ while (true) {
609
+ const boundary = findSseBoundary(state.pending);
610
+ if (!boundary)
611
+ break;
612
+ const rawEvent = state.pending.slice(0, boundary.index);
613
+ state.pending = state.pending.slice(boundary.index + boundary.length);
614
+ const err = retryableAnthropicStreamErrorFromRawEvent(rawEvent);
615
+ if (err)
616
+ return err;
617
+ }
618
+ return null;
619
+ }
620
+ /**
621
+ * Rewrite the tool prefix from the safe portion of a text buffer.
622
+ * Holds back any suffix that could be the start of a partial `"name"` marker
623
+ * spanning a chunk boundary. Pass flush=true on stream end to emit everything.
624
+ */
625
+ function splitToolPrefixRewriteBuffer(buffer, flush = false) {
626
+ if (flush)
627
+ return { ready: stripToolPrefix(buffer), pending: '' };
628
+ let keepFrom = buffer.length;
629
+ const marker = '"name"';
630
+ const partialStart = Math.max(0, buffer.length - marker.length + 1);
631
+ for (let i = partialStart; i < buffer.length; i++) {
632
+ if (marker.startsWith(buffer.slice(i))) {
633
+ keepFrom = Math.min(keepFrom, i);
634
+ break;
635
+ }
636
+ }
637
+ const lastMarker = buffer.lastIndexOf(marker);
638
+ if (lastMarker !== -1) {
639
+ const tail = buffer.slice(lastMarker);
640
+ if (/^"name"\s*(?::\s*(?:"[^"]*)?)?$/.test(tail)) {
641
+ keepFrom = Math.min(keepFrom, lastMarker);
642
+ }
643
+ }
644
+ if (keepFrom < buffer.length) {
645
+ return {
646
+ ready: stripToolPrefix(buffer.slice(0, keepFrom)),
647
+ pending: buffer.slice(keepFrom),
648
+ };
649
+ }
650
+ return { ready: stripToolPrefix(buffer), pending: '' };
651
+ }
506
652
  /**
507
653
  * Create a streaming response that strips the tool prefix from tool names.
654
+ * Detects retryable Anthropic server errors inside HTTP 200 streams and
655
+ * throws a connection-reset-style error so OpenCode can auto-retry.
508
656
  */
509
657
  export function createStrippedStream(response) {
510
658
  if (!response.body)
@@ -512,16 +660,71 @@ export function createStrippedStream(response) {
512
660
  const reader = response.body.getReader();
513
661
  const decoder = new TextDecoder();
514
662
  const encoder = new TextEncoder();
663
+ let pending = '';
664
+ let readerReleased = false;
665
+ const sseErrors = createSseErrorState();
666
+ const releaseReader = () => {
667
+ if (readerReleased)
668
+ return;
669
+ readerReleased = true;
670
+ reader.releaseLock();
671
+ };
515
672
  const stream = new ReadableStream({
516
673
  async pull(controller) {
517
- const { done, value } = await reader.read();
518
- if (done) {
519
- controller.close();
520
- return;
674
+ try {
675
+ const { done, value } = await reader.read();
676
+ if (done) {
677
+ const finalDecoded = decoder.decode();
678
+ let retryableError = updateSseErrorState(sseErrors, finalDecoded);
679
+ if (!retryableError && sseErrors.pending) {
680
+ retryableError = retryableAnthropicStreamErrorFromRawEvent(sseErrors.pending);
681
+ }
682
+ if (retryableError) {
683
+ try {
684
+ await reader.cancel();
685
+ }
686
+ catch {
687
+ /* ignore cancel failure */
688
+ }
689
+ releaseReader();
690
+ throw retryableError;
691
+ }
692
+ const { ready } = splitToolPrefixRewriteBuffer(`${pending}${finalDecoded}`, true);
693
+ if (ready)
694
+ controller.enqueue(encoder.encode(ready));
695
+ releaseReader();
696
+ controller.close();
697
+ return;
698
+ }
699
+ const decoded = decoder.decode(value, { stream: true });
700
+ const retryableError = updateSseErrorState(sseErrors, decoded);
701
+ if (retryableError) {
702
+ try {
703
+ await reader.cancel();
704
+ }
705
+ catch {
706
+ /* ignore cancel failure */
707
+ }
708
+ releaseReader();
709
+ throw retryableError;
710
+ }
711
+ const { ready, pending: nextPending } = splitToolPrefixRewriteBuffer(pending + decoded);
712
+ pending = nextPending;
713
+ if (ready)
714
+ controller.enqueue(encoder.encode(ready));
715
+ }
716
+ catch (error) {
717
+ releaseReader();
718
+ throw error;
719
+ }
720
+ },
721
+ async cancel(reason) {
722
+ try {
723
+ await reader.cancel(reason);
724
+ }
725
+ finally {
726
+ releaseReader();
521
727
  }
522
- let text = decoder.decode(value, { stream: true });
523
- text = stripToolPrefix(text);
524
- controller.enqueue(encoder.encode(text));
525
728
  },
526
729
  });
527
730
  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.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/shljsl75891/opencode-anthropic-auth.git"