@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 +15 -5
- package/dist/constants.d.ts +9 -7
- package/dist/constants.js +11 -7
- package/dist/transform.d.ts +12 -5
- package/dist/transform.js +322 -119
- package/package.json +1 -1
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
23
|
-
export declare const CLAUDE_CODE_ENTRYPOINT = "
|
|
24
|
-
export declare const USER_AGENT = "claude-cli/2.1.
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
33
|
-
export const CLAUDE_CODE_ENTRYPOINT = '
|
|
34
|
-
export const USER_AGENT = 'claude-cli/2.1.
|
|
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
|
package/dist/transform.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
67
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
352
|
-
|
|
332
|
+
const latestIndex = rollingIndices[rollingIndices.length - 1];
|
|
333
|
+
const rollingSet = new Set(rollingIndices);
|
|
353
334
|
let bridge;
|
|
354
|
-
let cumulativeBlocks =
|
|
355
|
-
for (let i =
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
bridge =
|
|
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.
|
|
371
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
427
|
+
* Anthropic supports max 4 cache breakpoints per request. Slot allocation:
|
|
428
|
+
*
|
|
429
|
+
* Slot 1 — system 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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, {
|