@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 +1 -1
- package/dist/constants.d.ts +6 -4
- package/dist/constants.js +8 -4
- package/dist/index.js +23 -9
- package/dist/transform.d.ts +20 -10
- package/dist/transform.js +223 -134
- package/package.json +3 -3
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
|
|
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:
|
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[];
|
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
|
|
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.";
|
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 =
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
};
|
package/dist/transform.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
72
|
-
* an HTTP 200 stream.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
|
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) {
|
|
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
|
|
352
|
-
|
|
358
|
+
const latestIndex = rollingIndices[rollingIndices.length - 1];
|
|
359
|
+
const rollingSet = new Set(rollingIndices);
|
|
353
360
|
let bridge;
|
|
354
|
-
let cumulativeBlocks =
|
|
355
|
-
for (let i =
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
bridge =
|
|
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.
|
|
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.
|
|
416
|
-
*
|
|
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
|
-
|
|
429
|
-
// ---------------------------------------------------------------------------
|
|
428
|
+
const RETRY_AFTER_CAP_MS = 30000;
|
|
429
|
+
const RETRY_BASE_DELAY_MS = 500;
|
|
430
430
|
/**
|
|
431
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
535
|
+
* Anthropic supports max 4 cache breakpoints per request. Slot allocation:
|
|
536
|
+
*
|
|
537
|
+
* Slot 1 — system 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|