@sahiljassal/opencode-anthropic-auth 2.1.1 → 2.4.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
@@ -33,7 +33,7 @@ This fork applies **hybrid 1-hour ephemeral prompt caching** on every request, p
33
33
  |---|---|
34
34
  | **System anchor** | Last system block after the identity block (skipped when bridge occupies the slot) |
35
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 |
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
37
  | **Rolling latest** | Most recent user message beyond index 1, keeping cache hot across long sessions |
38
38
 
39
39
  Additional behaviours:
@@ -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[];
package/dist/constants.js CHANGED
@@ -15,15 +15,19 @@ 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.";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { authorize, exchange } from "./auth.js";
2
2
  import { CLIENT_ID, TOKEN_URL } from "./constants.js";
3
- import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
3
+ import { computeRetryAfterDelayMs, createStrippedStream, extractModelId, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
4
+ const MAX_429_RETRIES = 3;
4
5
  export const AnthropicAuthPlugin = async ({ client }) => {
5
6
  return {
6
7
  auth: {
@@ -103,19 +104,32 @@ export const AnthropicAuthPlugin = async ({ client }) => {
103
104
  auth.access = await refreshPromise;
104
105
  }
105
106
  const requestHeaders = mergeHeaders(input, init);
107
+ const rawBody = init?.body;
108
+ const modelId = typeof rawBody === 'string'
109
+ ? extractModelId(rawBody)
110
+ : undefined;
106
111
  // biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
107
- setOAuthHeaders(requestHeaders, auth.access);
108
- let body = init?.body;
112
+ setOAuthHeaders(requestHeaders, auth.access, modelId);
113
+ let body = rawBody;
109
114
  if (body && typeof body === 'string') {
110
115
  body = rewriteRequestBody(body);
111
116
  }
112
117
  const rewritten = rewriteUrl(input);
113
- const response = await fetch(rewritten.input, {
114
- ...init,
115
- body,
116
- headers: requestHeaders,
117
- ...(isInsecure() && { tls: { rejectUnauthorized: false } }),
118
- });
118
+ let response;
119
+ for (let attempt = 0;; attempt++) {
120
+ response = await fetch(rewritten.input, {
121
+ ...init,
122
+ body,
123
+ headers: requestHeaders,
124
+ ...(isInsecure() && { tls: { rejectUnauthorized: false } }),
125
+ });
126
+ if (response.status !== 429 || attempt >= MAX_429_RETRIES) {
127
+ break;
128
+ }
129
+ const delay = computeRetryAfterDelayMs(response.headers.get('retry-after'), attempt);
130
+ await response.body?.cancel();
131
+ await new Promise((resolve) => setTimeout(resolve, delay));
132
+ }
119
133
  return createStrippedStream(response);
120
134
  },
121
135
  };
@@ -6,13 +6,21 @@ export type FetchInput = string | URL | Request;
6
6
  export declare function mergeHeaders(input: FetchInput, init?: RequestInit): Headers;
7
7
  /**
8
8
  * Merge incoming beta headers with the required OAuth betas, deduplicating.
9
+ * Excludes betas unsupported by the target model (e.g. interleaved-thinking
10
+ * on Haiku models).
9
11
  */
10
- export declare function mergeBetaHeaders(headers: Headers): string;
12
+ export declare function mergeBetaHeaders(headers: Headers, modelId?: string): string;
11
13
  /**
12
- * Set OAuth-required headers on the request: authorization, beta, user-agent.
14
+ * Set OAuth-required headers: authorization, beta, user-agent.
13
15
  * Removes x-api-key since we're using OAuth.
14
16
  */
15
- export declare function setOAuthHeaders(headers: Headers, accessToken: string): Headers;
17
+ export declare function setOAuthHeaders(headers: Headers, accessToken: string, modelId?: string): Headers;
18
+ /**
19
+ * Extract the `model` field from a JSON request body string, if present.
20
+ * Used to make header rewriting (e.g. beta exclusions) model-aware before
21
+ * the body itself is parsed and transformed.
22
+ */
23
+ export declare function extractModelId(body: string): string | undefined;
16
24
  /**
17
25
  * Add TOOL_PREFIX to tool names in the request body.
18
26
  * Prefixes both tool definitions and tool_use blocks in messages.
@@ -31,7 +39,6 @@ export declare function isInsecure(): boolean;
31
39
  * Rewrite the request URL to add ?beta=true for /v1/messages requests.
32
40
  * When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
33
41
  * for all API requests flowing through the fetch wrapper.
34
- * Returns the modified input and URL (if applicable).
35
42
  */
36
43
  export declare function rewriteUrl(input: FetchInput): {
37
44
  input: FetchInput;
@@ -57,19 +64,22 @@ type SystemBlock = {
57
64
  text: string;
58
65
  [k: string]: unknown;
59
66
  };
67
+ /**
68
+ * Compute the delay before retrying a 429 response. Prefers the
69
+ * `retry-after` header (seconds) when present and valid, otherwise falls
70
+ * back to exponential backoff. Always capped at RETRY_AFTER_CAP_MS to avoid
71
+ * honouring an excessively long server-provided wait.
72
+ */
73
+ export declare function computeRetryAfterDelayMs(retryAfterHeader: string | null, attempt: number): number;
60
74
  /**
61
75
  * Sanitize system prompt and prepend Claude Code identity.
62
76
  * Handles all Anthropic API system formats: undefined, string, or array of text blocks.
63
77
  */
64
78
  export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[];
65
- /**
66
- * Rewrite the full request body: sanitize system prompt, prefix tool names,
67
- * and apply hybrid 1h prompt caching.
68
- */
69
79
  export declare function rewriteRequestBody(body: string): string;
70
80
  /**
71
- * Error thrown when Anthropic emits a retryable server-side error *inside*
72
- * an HTTP 200 stream. OpenCode recognises ECONNRESET + anthropic-sse syscall
81
+ * Error thrown when Anthropic emits a retryable server-side error inside
82
+ * an HTTP 200 stream. OpenCode recognises ECONNRESET + anthropic-sse syscall
73
83
  * and applies its normal auto-retry flow instead of surfacing an unknown error.
74
84
  */
75
85
  export type RetryableAnthropicStreamError = Error & {
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
  }
@@ -53,28 +44,54 @@ export function mergeHeaders(input, init) {
53
44
  }
54
45
  return headers;
55
46
  }
47
+ /**
48
+ * Anthropic rejects interleaved-thinking on Haiku models — this checks
49
+ * whether a model id refers to a Haiku model.
50
+ */
51
+ function isHaikuModel(modelId) {
52
+ return /haiku/i.test(modelId ?? '');
53
+ }
56
54
  /**
57
55
  * Merge incoming beta headers with the required OAuth betas, deduplicating.
56
+ * Excludes betas unsupported by the target model (e.g. interleaved-thinking
57
+ * on Haiku models).
58
58
  */
59
- export function mergeBetaHeaders(headers) {
59
+ export function mergeBetaHeaders(headers, modelId) {
60
60
  const incomingBeta = headers.get('anthropic-beta') || '';
61
61
  const incomingBetasList = incomingBeta
62
62
  .split(',')
63
63
  .map((b) => b.trim())
64
64
  .filter(Boolean);
65
- return [...new Set([...REQUIRED_BETAS, ...incomingBetasList])].join(',');
65
+ const requiredBetas = isHaikuModel(modelId)
66
+ ? REQUIRED_BETAS.filter((beta) => beta !== 'interleaved-thinking-2025-05-14')
67
+ : REQUIRED_BETAS;
68
+ return [...new Set([...requiredBetas, ...incomingBetasList])].join(',');
66
69
  }
67
70
  /**
68
- * Set OAuth-required headers on the request: authorization, beta, user-agent.
71
+ * Set OAuth-required headers: authorization, beta, user-agent.
69
72
  * Removes x-api-key since we're using OAuth.
70
73
  */
71
- export function setOAuthHeaders(headers, accessToken) {
74
+ export function setOAuthHeaders(headers, accessToken, modelId) {
72
75
  headers.set('authorization', `Bearer ${accessToken}`);
73
- headers.set('anthropic-beta', mergeBetaHeaders(headers));
76
+ headers.set('anthropic-beta', mergeBetaHeaders(headers, modelId));
74
77
  headers.set('user-agent', USER_AGENT);
75
78
  headers.delete('x-api-key');
76
79
  return headers;
77
80
  }
81
+ /**
82
+ * Extract the `model` field from a JSON request body string, if present.
83
+ * Used to make header rewriting (e.g. beta exclusions) model-aware before
84
+ * the body itself is parsed and transformed.
85
+ */
86
+ export function extractModelId(body) {
87
+ try {
88
+ const parsed = JSON.parse(body);
89
+ return typeof parsed?.model === 'string' ? parsed.model : undefined;
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
78
95
  /**
79
96
  * Add TOOL_PREFIX to tool names in the request body.
80
97
  * Prefixes both tool definitions and tool_use blocks in messages.
@@ -142,7 +159,6 @@ function resolveBaseUrl() {
142
159
  * Rewrite the request URL to add ?beta=true for /v1/messages requests.
143
160
  * When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
144
161
  * for all API requests flowing through the fetch wrapper.
145
- * Returns the modified input and URL (if applicable).
146
162
  */
147
163
  export function rewriteUrl(input) {
148
164
  let requestUrl = null;
@@ -192,14 +208,11 @@ export function rewriteUrl(input) {
192
208
  * somewhere in the paragraph, the removal works.
193
209
  */
194
210
  export function sanitizeSystemText(text) {
195
- // Split into paragraphs (separated by one or more blank lines)
196
211
  const paragraphs = text.split(/\n\n+/);
197
212
  const filtered = paragraphs.filter((paragraph) => {
198
213
  if (paragraph.includes(OPENCODE_IDENTITY_PREFIX)) {
199
- // If the paragraph contains the identity, drop it entirely
200
214
  return false;
201
215
  }
202
- // Remove paragraphs containing any removal anchor
203
216
  for (const anchor of PARAGRAPH_REMOVAL_ANCHORS) {
204
217
  if (paragraph.includes(anchor))
205
218
  return false;
@@ -207,7 +220,6 @@ export function sanitizeSystemText(text) {
207
220
  return true;
208
221
  });
209
222
  let result = filtered.join('\n\n');
210
- // Apply inline text replacements
211
223
  for (const rule of TEXT_REPLACEMENTS) {
212
224
  result = result.replace(rule.match, rule.replacement);
213
225
  }
@@ -217,9 +229,6 @@ function isRecord(value) {
217
229
  return value != null && typeof value === 'object' && !Array.isArray(value);
218
230
  }
219
231
  const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
220
- // ---------------------------------------------------------------------------
221
- // Cache control primitives
222
- // ---------------------------------------------------------------------------
223
232
  function removeCacheControl(value) {
224
233
  if (!isRecord(value))
225
234
  return;
@@ -248,21 +257,23 @@ function setWireCacheControl(value) {
248
257
  value.cache_control = { ...CACHE_1H };
249
258
  return true;
250
259
  }
251
- // ---------------------------------------------------------------------------
252
- // Content-block helpers
253
- // ---------------------------------------------------------------------------
254
260
  /**
255
261
  * Returns true for content block types that accept cache_control.
256
- * Anthropic rejects cache_control on thinking / redacted_thinking blocks.
262
+ * Anthropic rejects cache_control on thinking / redacted_thinking blocks
263
+ * and silently skips empty text blocks without caching them.
257
264
  */
258
265
  function isCacheableContentBlock(block) {
259
266
  if (!isRecord(block))
260
267
  return false;
261
- return block.type !== 'thinking' && block.type !== 'redacted_thinking';
268
+ if (block.type === 'thinking' || block.type === 'redacted_thinking')
269
+ return false;
270
+ if (block.type === 'text' && !String(block.text ?? '').trim())
271
+ return false;
272
+ return true;
262
273
  }
263
274
  /**
264
275
  * Normalises message content to an array of blocks, then filters to only
265
- * cacheable types. Returns undefined when there is nothing to anchor.
276
+ * cacheable types. Returns undefined when there is nothing to anchor.
266
277
  */
267
278
  function getCacheableContentBlocks(message) {
268
279
  if (!isRecord(message))
@@ -272,7 +283,6 @@ function getCacheableContentBlocks(message) {
272
283
  blocks = message.content;
273
284
  }
274
285
  else if (typeof message.content === 'string') {
275
- // Normalise inline string to block array in place so downstream sees array
276
286
  const normalised = [{ type: 'text', text: message.content }];
277
287
  message.content = normalised;
278
288
  blocks = normalised;
@@ -283,38 +293,21 @@ function getCacheableContentBlocks(message) {
283
293
  const cacheable = blocks.filter(isCacheableContentBlock);
284
294
  return cacheable.length > 0 ? cacheable : undefined;
285
295
  }
286
- /**
287
- * Total cacheable content-block count for a message (used for lookback math).
288
- */
289
296
  function messageContentBlockCount(message) {
290
297
  return getCacheableContentBlocks(message)?.length ?? 0;
291
298
  }
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
299
  function setMessageCacheAnchor(message) {
300
300
  const blocks = getCacheableContentBlocks(message);
301
301
  if (!blocks)
302
302
  return false;
303
303
  return setWireCacheControl(blocks[blocks.length - 1]);
304
304
  }
305
- /**
306
- * Anchor the FIRST cacheable block of a message (for magic-context split).
307
- */
308
305
  function setFirstMessageCacheAnchor(message) {
309
306
  const blocks = getCacheableContentBlocks(message);
310
307
  if (!blocks)
311
308
  return false;
312
309
  return setWireCacheControl(blocks[0]);
313
310
  }
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
311
  function setSecondMessageCacheAnchor(message) {
319
312
  const blocks = getCacheableContentBlocks(message);
320
313
  if (!blocks || blocks.length < 2)
@@ -322,49 +315,60 @@ function setSecondMessageCacheAnchor(message) {
322
315
  return setWireCacheControl(blocks[1]);
323
316
  }
324
317
  /**
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.
318
+ * Total positional block count for a message regardless of role or type.
319
+ * Anthropic's cache lookback window counts every content block (text,
320
+ * thinking, tool_use, tool_result, …), so distance math must use raw counts,
321
+ * not the cacheable-only filter used to validate anchor placement.
322
+ */
323
+ function rawBlockCount(message) {
324
+ if (!isRecord(message))
325
+ return 0;
326
+ const { content } = message;
327
+ if (Array.isArray(content))
328
+ return content.length;
329
+ if (typeof content === 'string')
330
+ return 1;
331
+ return 0;
332
+ }
333
+ /**
334
+ * Collect user-role anchor indices that hold at least one cacheable block,
335
+ * then pick the `latest` (index > 1) and a `bridge`. The bridge is the
336
+ * furthest-back valid user anchor where the block count between it and
337
+ * `latest` — counting ALL content blocks across user and assistant turns,
338
+ * excluding the anchor's own blocks — does not exceed
339
+ * ANTHROPIC_CACHE_LOOKBACK_BLOCKS. This keeps the older breakpoint inside
340
+ * Anthropic's sliding lookback window so the cached prefix stays reachable.
329
341
  */
330
342
  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) {
343
+ // Collect indices of user messages beyond index 1 that have ≥1 cacheable block.
344
+ // Indices 0 and 1 are handled as fixed slots in applyHybridCache1h, not rolling.
345
+ const rollingIndices = [];
346
+ messages.forEach((msg, index) => {
347
+ if (index > 1 &&
348
+ isRecord(msg) &&
349
+ msg.role === 'user' &&
350
+ messageContentBlockCount(msg) > 0) {
351
+ rollingIndices.push(index);
352
+ }
353
+ });
354
+ if (rollingIndices.length === 0) {
347
355
  return { latest: undefined, bridge: undefined };
348
356
  }
349
- // Non-null: rollingPositions is non-empty (early return guards above)
350
357
  // 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
358
+ const latestIndex = rollingIndices[rollingIndices.length - 1];
359
+ const rollingSet = new Set(rollingIndices);
353
360
  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
+ let cumulativeBlocks = 0;
362
+ for (let i = latestIndex - 1; i >= 0; i--) {
363
+ if (rollingSet.has(i)) {
364
+ if (cumulativeBlocks > ANTHROPIC_CACHE_LOOKBACK_BLOCKS)
365
+ break;
366
+ bridge = i;
361
367
  }
368
+ cumulativeBlocks += rawBlockCount(messages[i]);
362
369
  }
363
- return { latest, bridge };
370
+ return { latest: latestIndex, bridge };
364
371
  }
365
- // ---------------------------------------------------------------------------
366
- // System-anchor setter
367
- // ---------------------------------------------------------------------------
368
372
  function systemBlockText(block) {
369
373
  return isRecord(block) && typeof block.text === 'string' ? block.text : '';
370
374
  }
@@ -374,7 +378,7 @@ function systemBlockText(block) {
374
378
  * system cache anchor.
375
379
  *
376
380
  * OpenCode normally emits these as one merged block, but some hooks can cause
377
- * them to arrive split across multiple blocks. Without coalescing, byte-
381
+ * them to arrive split across multiple blocks. Without coalescing, byte-
378
382
  * identical system text flips between merged/split layouts and moves the
379
383
  * cache_control breakpoint — busting the cache every turn.
380
384
  *
@@ -388,15 +392,12 @@ function coalesceHybridSystemTail(parsed) {
388
392
  return;
389
393
  const system = parsed.system;
390
394
  let prefixCount = 0;
391
- // Skip optional billing-header block (not present in this fork but guard is safe)
392
395
  if (systemBlockText(system[prefixCount]).startsWith('x-anthropic-billing-header:')) {
393
396
  prefixCount++;
394
397
  }
395
- // Skip identity block
396
398
  if (systemBlockText(system[prefixCount]) === CLAUDE_CODE_IDENTITY) {
397
399
  prefixCount++;
398
400
  }
399
- // tailStart points to the first plugin-added block (one after primary prompt)
400
401
  const tailStart = prefixCount + 1;
401
402
  if (tailStart >= system.length - 1)
402
403
  return;
@@ -412,8 +413,8 @@ function coalesceHybridSystemTail(parsed) {
412
413
  }
413
414
  /**
414
415
  * 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.
416
+ * CLAUDE_CODE_IDENTITY block. When there are no system blocks after the
417
+ * identity, nothing is anchored.
417
418
  */
418
419
  function setHybridSystemAnchor(parsed) {
419
420
  if (!Array.isArray(parsed.system))
@@ -424,11 +425,90 @@ function setHybridSystemAnchor(parsed) {
424
425
  .filter(isRecord);
425
426
  setWireCacheControl(afterIdentity[afterIdentity.length - 1]);
426
427
  }
427
- // ---------------------------------------------------------------------------
428
- // Trailing-assistant strip (Tier 2)
429
- // ---------------------------------------------------------------------------
428
+ const RETRY_AFTER_CAP_MS = 30000;
429
+ const RETRY_BASE_DELAY_MS = 500;
430
430
  /**
431
- * Remove trailing assistant-role messages. OAuth endpoints reject requests
431
+ * Compute the delay before retrying a 429 response. Prefers the
432
+ * `retry-after` header (seconds) when present and valid, otherwise falls
433
+ * back to exponential backoff. Always capped at RETRY_AFTER_CAP_MS to avoid
434
+ * honouring an excessively long server-provided wait.
435
+ */
436
+ export function computeRetryAfterDelayMs(retryAfterHeader, attempt) {
437
+ const seconds = retryAfterHeader ? Number(retryAfterHeader) : Number.NaN;
438
+ const delay = Number.isFinite(seconds)
439
+ ? seconds * 1000
440
+ : RETRY_BASE_DELAY_MS * 2 ** attempt;
441
+ return Math.min(delay, RETRY_AFTER_CAP_MS);
442
+ }
443
+ /**
444
+ * Anthropic rejects the `effort` parameter for Haiku models. Strips
445
+ * `output_config.effort` and `thinking.effort`, removing the parent object
446
+ * entirely if `effort` was its only field.
447
+ */
448
+ function stripUnsupportedEffortForHaiku(parsed) {
449
+ if (!isHaikuModel(typeof parsed.model === 'string' ? parsed.model : undefined))
450
+ return;
451
+ const outputConfig = parsed.output_config;
452
+ if (isRecord(outputConfig) && 'effort' in outputConfig) {
453
+ delete outputConfig.effort;
454
+ if (Object.keys(outputConfig).length === 0)
455
+ delete parsed.output_config;
456
+ }
457
+ const thinking = parsed.thinking;
458
+ if (isRecord(thinking) && 'effort' in thinking) {
459
+ delete thinking.effort;
460
+ if (Object.keys(thinking).length === 0)
461
+ delete parsed.thinking;
462
+ }
463
+ }
464
+ /**
465
+ * Remove tool_use blocks with no matching tool_result, and tool_result
466
+ * blocks that reference a non-existent tool_use. Anthropic rejects requests
467
+ * with unpaired tool_use/tool_result blocks — this can happen when OpenCode
468
+ * truncates or reconstructs conversation history after an interruption.
469
+ * Messages left with no content blocks are dropped entirely.
470
+ */
471
+ function repairOrphanedToolPairs(parsed) {
472
+ if (!Array.isArray(parsed.messages))
473
+ return;
474
+ const toolUseIds = new Set();
475
+ const toolResultIds = new Set();
476
+ for (const msg of parsed.messages) {
477
+ if (!isRecord(msg) || !Array.isArray(msg.content))
478
+ continue;
479
+ for (const block of msg.content) {
480
+ if (!isRecord(block))
481
+ continue;
482
+ if (block.type === 'tool_use' && typeof block.id === 'string') {
483
+ toolUseIds.add(block.id);
484
+ }
485
+ else if (block.type === 'tool_result' &&
486
+ typeof block.tool_use_id === 'string') {
487
+ toolResultIds.add(block.tool_use_id);
488
+ }
489
+ }
490
+ }
491
+ parsed.messages = parsed.messages.filter((msg) => {
492
+ if (!isRecord(msg) || !Array.isArray(msg.content))
493
+ return true;
494
+ const filteredContent = msg.content.filter((block) => {
495
+ if (!isRecord(block))
496
+ return true;
497
+ if (block.type === 'tool_use' && typeof block.id === 'string') {
498
+ return toolResultIds.has(block.id);
499
+ }
500
+ if (block.type === 'tool_result' &&
501
+ typeof block.tool_use_id === 'string') {
502
+ return toolUseIds.has(block.tool_use_id);
503
+ }
504
+ return true;
505
+ });
506
+ msg.content = filteredContent;
507
+ return filteredContent.length > 0;
508
+ });
509
+ }
510
+ /**
511
+ * Remove trailing assistant-role messages. OAuth endpoints reject requests
432
512
  * that end with an assistant turn (assistant prefill is not supported).
433
513
  */
434
514
  function stripTrailingAssistantMessages(parsed) {
@@ -440,52 +520,60 @@ function stripTrailingAssistantMessages(parsed) {
440
520
  parsed.messages.pop();
441
521
  }
442
522
  }
443
- // ---------------------------------------------------------------------------
444
- // Main hybrid cache logic
445
- // ---------------------------------------------------------------------------
523
+ /**
524
+ * Returns true when messages[0] carries a merged stable-prefix layout
525
+ * (≥2 cacheable blocks). In that case anchoring the last block would bust
526
+ * the cache every turn because the tail is volatile; instead we anchor
527
+ * block[0] and block[1] (the two stable-prefix blocks).
528
+ */
529
+ function isMagicContextLayout(blocks) {
530
+ return blocks.length >= 2;
531
+ }
446
532
  /**
447
533
  * Apply hybrid 1h prompt-caching breakpoints to parsed request body.
448
534
  *
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)
535
+ * Anthropic supports max 4 cache breakpoints per request. Slot allocation:
536
+ *
537
+ * Slot 1system anchor: last block after identity.
538
+ * Skipped when a bridge is used (bridge takes that slot).
539
+ *
540
+ * Slots 2+3 messages[0]:
541
+ * Normal layout (1 cacheable block): slot 2 = last block of msg[0];
542
+ * slot 3 = bridge message OR messages[1] (no bridge).
543
+ * • Magic-context layout (≥2 cacheable blocks): slot 2 = block[0],
544
+ * slot 3 = block[1]. messages[1] is skipped (msg[0] uses both slots).
545
+ *
546
+ * Slot 4 — rolling latest: last user/tool-result message beyond index 1.
547
+ *
548
+ * Bridge and magic-context are independent: when both apply simultaneously,
549
+ * the slot budget is: system(skipped) + msg0-block0 + bridge + latest = 4.
550
+ * The bridge anchor is ALWAYS placed when detected, regardless of msg0 layout.
456
551
  */
457
552
  function applyHybridCache1h(parsed) {
458
553
  removeAllCacheControls(parsed);
459
554
  coalesceHybridSystemTail(parsed);
460
555
  const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
461
556
  const { latest, bridge } = selectHybridMessageAnchors(messages);
462
- // --- Slot 1: system anchor (skip when bridge will occupy a slot) ---
463
- if (!bridge) {
557
+ if (bridge === undefined) {
464
558
  setHybridSystemAnchor(parsed);
465
559
  }
466
- // --- Slots 2 & 3: messages[0] ---
467
560
  const msg0 = messages[0];
468
561
  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.
562
+ if (msg0Blocks && isMagicContextLayout(msg0Blocks)) {
472
563
  setFirstMessageCacheAnchor(msg0);
473
564
  setSecondMessageCacheAnchor(msg0);
474
565
  }
475
566
  else {
476
567
  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 {
568
+ if (bridge === undefined) {
483
569
  setMessageCacheAnchor(messages[1]);
484
570
  }
485
571
  }
486
- // --- Slot 4: rolling latest user anchor ---
487
- if (latest) {
488
- setMessageCacheAnchor(messages[latest.index]);
572
+ if (bridge !== undefined) {
573
+ setMessageCacheAnchor(messages[bridge]);
574
+ }
575
+ if (latest !== undefined) {
576
+ setMessageCacheAnchor(messages[latest]);
489
577
  }
490
578
  }
491
579
  /**
@@ -527,20 +615,17 @@ export function prependClaudeCodeIdentity(system) {
527
615
  }
528
616
  return { type: 'text', text: String(item) };
529
617
  });
530
- // Idempotency: don't double-prepend if first block already has the identity
531
618
  if (sanitized[0]?.text === CLAUDE_CODE_IDENTITY) {
532
619
  return sanitized;
533
620
  }
534
621
  return [identityBlock, ...sanitized];
535
622
  }
536
- /**
537
- * Rewrite the full request body: sanitize system prompt, prefix tool names,
538
- * and apply hybrid 1h prompt caching.
539
- */
540
623
  export function rewriteRequestBody(body) {
541
624
  try {
542
625
  const parsed = JSON.parse(body);
543
626
  parsed.system = prependClaudeCodeIdentity(parsed.system);
627
+ repairOrphanedToolPairs(parsed);
628
+ stripUnsupportedEffortForHaiku(parsed);
544
629
  stripTrailingAssistantMessages(parsed);
545
630
  applyHybridCache1h(parsed);
546
631
  return prefixToolNames(parsed);
@@ -549,7 +634,6 @@ export function rewriteRequestBody(body) {
549
634
  return body;
550
635
  }
551
636
  }
552
- /** Find the first SSE event boundary (\n\n or \r\n\r\n) in a text buffer. */
553
637
  function findSseBoundary(value) {
554
638
  const lf = value.indexOf('\n\n');
555
639
  const crlf = value.indexOf('\r\n\r\n');
@@ -615,9 +699,7 @@ function retryableAnthropicStreamErrorFromRawEvent(rawEvent) {
615
699
  if (eventName !== 'error' && stringField(data, 'type') !== 'error')
616
700
  return null;
617
701
  const errorObj = asDiagnosticRecord(data?.error);
618
- const errorType = stringField(errorObj, 'type') ??
619
- stringField(errorObj, 'code') ??
620
- undefined;
702
+ const errorType = stringField(errorObj, 'type') ?? stringField(errorObj, 'code') ?? undefined;
621
703
  const message = stringField(errorObj, 'message') ??
622
704
  stringField(data, 'message') ??
623
705
  errorType ??
@@ -645,20 +727,16 @@ function updateSseErrorState(state, text) {
645
727
  }
646
728
  return null;
647
729
  }
648
- // ---------------------------------------------------------------------------
649
- // Buffered tool-prefix rewriting (v1.10.0)
650
- // ---------------------------------------------------------------------------
651
730
  /**
652
731
  * Rewrite the tool prefix from the safe portion of a text buffer.
653
732
  * 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.
733
+ * spanning a chunk boundary. Pass flush=true on stream end to emit everything.
655
734
  */
656
735
  function splitToolPrefixRewriteBuffer(buffer, flush = false) {
657
736
  if (flush)
658
737
  return { ready: stripToolPrefix(buffer), pending: '' };
659
738
  let keepFrom = buffer.length;
660
739
  const marker = '"name"';
661
- // Hold back any suffix that starts a partial marker
662
740
  const partialStart = Math.max(0, buffer.length - marker.length + 1);
663
741
  for (let i = partialStart; i < buffer.length; i++) {
664
742
  if (marker.startsWith(buffer.slice(i))) {
@@ -666,7 +744,6 @@ function splitToolPrefixRewriteBuffer(buffer, flush = false) {
666
744
  break;
667
745
  }
668
746
  }
669
- // Also hold back if the last occurrence of the marker is incomplete
670
747
  const lastMarker = buffer.lastIndexOf(marker);
671
748
  if (lastMarker !== -1) {
672
749
  const tail = buffer.slice(lastMarker);
@@ -682,9 +759,6 @@ function splitToolPrefixRewriteBuffer(buffer, flush = false) {
682
759
  }
683
760
  return { ready: stripToolPrefix(buffer), pending: '' };
684
761
  }
685
- // ---------------------------------------------------------------------------
686
- // Stream response wrapper
687
- // ---------------------------------------------------------------------------
688
762
  /**
689
763
  * Create a streaming response that strips the tool prefix from tool names.
690
764
  * Detects retryable Anthropic server errors inside HTTP 200 streams and
@@ -711,8 +785,17 @@ export function createStrippedStream(response) {
711
785
  const { done, value } = await reader.read();
712
786
  if (done) {
713
787
  const finalDecoded = decoder.decode();
714
- const retryableError = updateSseErrorState(sseErrors, finalDecoded);
788
+ let retryableError = updateSseErrorState(sseErrors, finalDecoded);
789
+ if (!retryableError && sseErrors.pending) {
790
+ retryableError = retryableAnthropicStreamErrorFromRawEvent(sseErrors.pending);
791
+ }
715
792
  if (retryableError) {
793
+ try {
794
+ await reader.cancel();
795
+ }
796
+ catch {
797
+ /* ignore cancel failure */
798
+ }
716
799
  releaseReader();
717
800
  throw retryableError;
718
801
  }
@@ -726,6 +809,12 @@ export function createStrippedStream(response) {
726
809
  const decoded = decoder.decode(value, { stream: true });
727
810
  const retryableError = updateSseErrorState(sseErrors, decoded);
728
811
  if (retryableError) {
812
+ try {
813
+ await reader.cancel();
814
+ }
815
+ catch {
816
+ /* ignore cancel failure */
817
+ }
729
818
  releaseReader();
730
819
  throw retryableError;
731
820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sahiljassal/opencode-anthropic-auth",
3
- "version": "2.1.1",
3
+ "version": "2.4.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/shljsl75891/opencode-anthropic-auth.git"
@@ -31,10 +31,10 @@
31
31
  "@opencode-ai/plugin": "*"
32
32
  },
33
33
  "devDependencies": {
34
- "@biomejs/biome": "2.4.16",
34
+ "@biomejs/biome": "2.5.0",
35
35
  "@changesets/changelog-github": "^0.7.0",
36
36
  "@changesets/cli": "^2.31.0",
37
- "@opencode-ai/plugin": "1.17.3",
37
+ "@opencode-ai/plugin": "1.17.8",
38
38
  "@tsconfig/bun": "1.0.10",
39
39
  "@types/bun": "1.3.14",
40
40
  "dedent": "^1.7.2",