@sahiljassal/opencode-anthropic-auth 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -8
- package/dist/constants.d.ts +10 -3
- package/dist/constants.js +10 -3
- package/dist/transform.d.ts +12 -0
- package/dist/transform.js +423 -32
- package/package.json +4 -4
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,18 +27,28 @@ 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 a tool-heavy session pushes the latest user boundary outside Anthropic's 20-block lookback window |
|
|
37
|
+
| **Rolling latest** | Most recent user message beyond index 1, keeping cache hot across long sessions |
|
|
38
|
+
|
|
39
|
+
Additional behaviours:
|
|
34
40
|
|
|
35
|
-
|
|
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
|
|
|
39
|
-
| Variable
|
|
40
|
-
|
|
41
|
-
| `ANTHROPIC_BASE_URL` | Override API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL.
|
|
49
|
+
| Variable | Description |
|
|
50
|
+
| -------------------- | ---------------------------------------------------------------------------------------- |
|
|
51
|
+
| `ANTHROPIC_BASE_URL` | Override API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL. |
|
|
42
52
|
| `ANTHROPIC_INSECURE` | Set to `1` or `true` to skip TLS verification. Only effective with `ANTHROPIC_BASE_URL`. |
|
|
43
53
|
|
|
44
54
|
## License
|
package/dist/constants.d.ts
CHANGED
|
@@ -7,14 +7,21 @@ export declare const CODE_CALLBACK_URL = "https://platform.claude.com/oauth/code
|
|
|
7
7
|
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
|
+
/**
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ANTHROPIC_CACHE_LOOKBACK_BLOCKS = 20;
|
|
10
17
|
export declare const REQUIRED_BETAS: string[];
|
|
11
18
|
export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
|
|
12
19
|
export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
13
20
|
export declare const CCH_SALT = "59cf53e54c78";
|
|
14
21
|
export declare const CCH_POSITIONS: number[];
|
|
15
|
-
export declare const CLAUDE_CODE_VERSION = "2.1.
|
|
16
|
-
export declare const CLAUDE_CODE_ENTRYPOINT = "
|
|
17
|
-
export declare const USER_AGENT = "claude-cli/2.1.
|
|
22
|
+
export declare const CLAUDE_CODE_VERSION = "2.1.177";
|
|
23
|
+
export declare const CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
24
|
+
export declare const USER_AGENT = "claude-cli/2.1.177 (external, cli)";
|
|
18
25
|
/**
|
|
19
26
|
* Anchors that identify paragraphs to remove from the system prompt.
|
|
20
27
|
* Any paragraph (text between blank lines) containing one of these
|
package/dist/constants.js
CHANGED
|
@@ -14,6 +14,13 @@ export const OAUTH_SCOPES = [
|
|
|
14
14
|
'user:file_upload',
|
|
15
15
|
];
|
|
16
16
|
export const TOOL_PREFIX = 'mcp_';
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
export const ANTHROPIC_CACHE_LOOKBACK_BLOCKS = 20;
|
|
17
24
|
export const REQUIRED_BETAS = [
|
|
18
25
|
'oauth-2025-04-20',
|
|
19
26
|
'interleaved-thinking-2025-05-14',
|
|
@@ -22,9 +29,9 @@ export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
|
|
|
22
29
|
export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
23
30
|
export const CCH_SALT = '59cf53e54c78';
|
|
24
31
|
export const CCH_POSITIONS = [4, 7, 20];
|
|
25
|
-
export const CLAUDE_CODE_VERSION = '2.1.
|
|
26
|
-
export const CLAUDE_CODE_ENTRYPOINT = '
|
|
27
|
-
export const USER_AGENT = 'claude-cli/2.1.
|
|
32
|
+
export const CLAUDE_CODE_VERSION = '2.1.177';
|
|
33
|
+
export const CLAUDE_CODE_ENTRYPOINT = 'cli';
|
|
34
|
+
export const USER_AGENT = 'claude-cli/2.1.177 (external, cli)';
|
|
28
35
|
/**
|
|
29
36
|
* Anchors that identify paragraphs to remove from the system prompt.
|
|
30
37
|
* Any paragraph (text between blank lines) containing one of these
|
package/dist/transform.d.ts
CHANGED
|
@@ -67,8 +67,20 @@ export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[
|
|
|
67
67
|
* and apply hybrid 1h prompt caching.
|
|
68
68
|
*/
|
|
69
69
|
export declare function rewriteRequestBody(body: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* Error thrown when Anthropic emits a retryable server-side error *inside*
|
|
72
|
+
* an HTTP 200 stream. OpenCode recognises ECONNRESET + anthropic-sse syscall
|
|
73
|
+
* and applies its normal auto-retry flow instead of surfacing an unknown error.
|
|
74
|
+
*/
|
|
75
|
+
export type RetryableAnthropicStreamError = Error & {
|
|
76
|
+
code: 'ECONNRESET';
|
|
77
|
+
syscall: 'anthropic-sse';
|
|
78
|
+
providerErrorType?: string;
|
|
79
|
+
};
|
|
70
80
|
/**
|
|
71
81
|
* Create a streaming response that strips the tool prefix from tool names.
|
|
82
|
+
* Detects retryable Anthropic server errors inside HTTP 200 streams and
|
|
83
|
+
* throws a connection-reset-style error so OpenCode can auto-retry.
|
|
72
84
|
*/
|
|
73
85
|
export declare function createStrippedStream(response: Response): Response;
|
|
74
86
|
export {};
|
package/dist/transform.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, USER_AGENT, } from "./constants.js";
|
|
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
2
|
/**
|
|
3
3
|
* Prefix a tool name with TOOL_PREFIX and uppercase the first character.
|
|
4
4
|
* Claude Code uses PascalCase tool names (e.g. mcp_Bash, mcp_Read);
|
|
@@ -217,6 +217,9 @@ function isRecord(value) {
|
|
|
217
217
|
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
218
218
|
}
|
|
219
219
|
const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Cache control primitives
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
220
223
|
function removeCacheControl(value) {
|
|
221
224
|
if (!isRecord(value))
|
|
222
225
|
return;
|
|
@@ -245,36 +248,245 @@ function setWireCacheControl(value) {
|
|
|
245
248
|
value.cache_control = { ...CACHE_1H };
|
|
246
249
|
return true;
|
|
247
250
|
}
|
|
248
|
-
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Content-block helpers
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
/**
|
|
255
|
+
* Returns true for content block types that accept cache_control.
|
|
256
|
+
* Anthropic rejects cache_control on thinking / redacted_thinking blocks.
|
|
257
|
+
*/
|
|
258
|
+
function isCacheableContentBlock(block) {
|
|
259
|
+
if (!isRecord(block))
|
|
260
|
+
return false;
|
|
261
|
+
return block.type !== 'thinking' && block.type !== 'redacted_thinking';
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Normalises message content to an array of blocks, then filters to only
|
|
265
|
+
* cacheable types. Returns undefined when there is nothing to anchor.
|
|
266
|
+
*/
|
|
267
|
+
function getCacheableContentBlocks(message) {
|
|
249
268
|
if (!isRecord(message))
|
|
269
|
+
return undefined;
|
|
270
|
+
let blocks;
|
|
271
|
+
if (Array.isArray(message.content)) {
|
|
272
|
+
blocks = message.content;
|
|
273
|
+
}
|
|
274
|
+
else if (typeof message.content === 'string') {
|
|
275
|
+
// Normalise inline string to block array in place so downstream sees array
|
|
276
|
+
const normalised = [{ type: 'text', text: message.content }];
|
|
277
|
+
message.content = normalised;
|
|
278
|
+
blocks = normalised;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
const cacheable = blocks.filter(isCacheableContentBlock);
|
|
284
|
+
return cacheable.length > 0 ? cacheable : undefined;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Total cacheable content-block count for a message (used for lookback math).
|
|
288
|
+
*/
|
|
289
|
+
function messageContentBlockCount(message) {
|
|
290
|
+
return getCacheableContentBlocks(message)?.length ?? 0;
|
|
291
|
+
}
|
|
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
|
+
function setMessageCacheAnchor(message) {
|
|
300
|
+
const blocks = getCacheableContentBlocks(message);
|
|
301
|
+
if (!blocks)
|
|
250
302
|
return false;
|
|
251
|
-
|
|
252
|
-
? message.content
|
|
253
|
-
: typeof message.content === 'string'
|
|
254
|
-
? [{ type: 'text', text: message.content }]
|
|
255
|
-
: null;
|
|
256
|
-
if (!content?.length)
|
|
257
|
-
return setWireCacheControl(message);
|
|
258
|
-
message.content = content;
|
|
259
|
-
const target = [...content]
|
|
260
|
-
.reverse()
|
|
261
|
-
.find((b) => isRecord(b) && b.type !== 'thinking');
|
|
262
|
-
return setWireCacheControl(target ?? message);
|
|
303
|
+
return setWireCacheControl(blocks[blocks.length - 1]);
|
|
263
304
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Anchor the FIRST cacheable block of a message (for magic-context split).
|
|
307
|
+
*/
|
|
308
|
+
function setFirstMessageCacheAnchor(message) {
|
|
309
|
+
const blocks = getCacheableContentBlocks(message);
|
|
310
|
+
if (!blocks)
|
|
311
|
+
return false;
|
|
312
|
+
return setWireCacheControl(blocks[0]);
|
|
313
|
+
}
|
|
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
|
+
function setSecondMessageCacheAnchor(message) {
|
|
319
|
+
const blocks = getCacheableContentBlocks(message);
|
|
320
|
+
if (!blocks || blocks.length < 2)
|
|
321
|
+
return false;
|
|
322
|
+
return setWireCacheControl(blocks[1]);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
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.
|
|
329
|
+
*/
|
|
330
|
+
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) {
|
|
347
|
+
return { latest: undefined, bridge: undefined };
|
|
348
|
+
}
|
|
349
|
+
// Non-null: rollingPositions is non-empty (early return guards above)
|
|
350
|
+
// 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
|
|
353
|
+
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
|
+
}
|
|
273
362
|
}
|
|
363
|
+
return { latest, bridge };
|
|
364
|
+
}
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// System-anchor setter
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
function systemBlockText(block) {
|
|
369
|
+
return isRecord(block) && typeof block.text === 'string' ? block.text : '';
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Merge all plugin-added system instruction blocks (those after the primary
|
|
373
|
+
* OpenCode/system prompt block) into a single block before placing the hybrid
|
|
374
|
+
* system cache anchor.
|
|
375
|
+
*
|
|
376
|
+
* OpenCode normally emits these as one merged block, but some hooks can cause
|
|
377
|
+
* them to arrive split across multiple blocks. Without coalescing, byte-
|
|
378
|
+
* identical system text flips between merged/split layouts and moves the
|
|
379
|
+
* cache_control breakpoint — busting the cache every turn.
|
|
380
|
+
*
|
|
381
|
+
* Block layout after prependClaudeCodeIdentity:
|
|
382
|
+
* [billing-header?] [identity] [primary system prompt] [plugin blocks…]
|
|
383
|
+
* We preserve everything up to and including the primary prompt block and
|
|
384
|
+
* merge all remaining plugin blocks into one.
|
|
385
|
+
*/
|
|
386
|
+
function coalesceHybridSystemTail(parsed) {
|
|
387
|
+
if (!Array.isArray(parsed.system))
|
|
388
|
+
return;
|
|
389
|
+
const system = parsed.system;
|
|
390
|
+
let prefixCount = 0;
|
|
391
|
+
// Skip optional billing-header block (not present in this fork but guard is safe)
|
|
392
|
+
if (systemBlockText(system[prefixCount]).startsWith('x-anthropic-billing-header:')) {
|
|
393
|
+
prefixCount++;
|
|
394
|
+
}
|
|
395
|
+
// Skip identity block
|
|
396
|
+
if (systemBlockText(system[prefixCount]) === CLAUDE_CODE_IDENTITY) {
|
|
397
|
+
prefixCount++;
|
|
398
|
+
}
|
|
399
|
+
// tailStart points to the first plugin-added block (one after primary prompt)
|
|
400
|
+
const tailStart = prefixCount + 1;
|
|
401
|
+
if (tailStart >= system.length - 1)
|
|
402
|
+
return;
|
|
403
|
+
const firstTail = system[tailStart];
|
|
404
|
+
if (!isRecord(firstTail))
|
|
405
|
+
return;
|
|
406
|
+
const mergedText = system.slice(tailStart).map(systemBlockText).join('\n');
|
|
407
|
+
system.splice(tailStart, system.length - tailStart, {
|
|
408
|
+
...firstTail,
|
|
409
|
+
type: 'text',
|
|
410
|
+
text: mergedText,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* 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.
|
|
417
|
+
*/
|
|
418
|
+
function setHybridSystemAnchor(parsed) {
|
|
419
|
+
if (!Array.isArray(parsed.system))
|
|
420
|
+
return;
|
|
421
|
+
const identityIdx = parsed.system.findIndex((b) => isRecord(b) && b.text === CLAUDE_CODE_IDENTITY);
|
|
422
|
+
const afterIdentity = parsed.system
|
|
423
|
+
.slice(identityIdx >= 0 ? identityIdx + 1 : 0)
|
|
424
|
+
.filter(isRecord);
|
|
425
|
+
setWireCacheControl(afterIdentity[afterIdentity.length - 1]);
|
|
426
|
+
}
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Trailing-assistant strip (Tier 2)
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
/**
|
|
431
|
+
* Remove trailing assistant-role messages. OAuth endpoints reject requests
|
|
432
|
+
* that end with an assistant turn (assistant prefill is not supported).
|
|
433
|
+
*/
|
|
434
|
+
function stripTrailingAssistantMessages(parsed) {
|
|
274
435
|
if (!Array.isArray(parsed.messages))
|
|
275
436
|
return;
|
|
276
|
-
|
|
277
|
-
|
|
437
|
+
while (parsed.messages.length > 0 &&
|
|
438
|
+
isRecord(parsed.messages[parsed.messages.length - 1]) &&
|
|
439
|
+
parsed.messages[parsed.messages.length - 1].role === 'assistant') {
|
|
440
|
+
parsed.messages.pop();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Main hybrid cache logic
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
/**
|
|
447
|
+
* Apply hybrid 1h prompt-caching breakpoints to parsed request body.
|
|
448
|
+
*
|
|
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)
|
|
456
|
+
*/
|
|
457
|
+
function applyHybridCache1h(parsed) {
|
|
458
|
+
removeAllCacheControls(parsed);
|
|
459
|
+
coalesceHybridSystemTail(parsed);
|
|
460
|
+
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
|
|
461
|
+
const { latest, bridge } = selectHybridMessageAnchors(messages);
|
|
462
|
+
// --- Slot 1: system anchor (skip when bridge will occupy a slot) ---
|
|
463
|
+
if (!bridge) {
|
|
464
|
+
setHybridSystemAnchor(parsed);
|
|
465
|
+
}
|
|
466
|
+
// --- Slots 2 & 3: messages[0] ---
|
|
467
|
+
const msg0 = messages[0];
|
|
468
|
+
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.
|
|
472
|
+
setFirstMessageCacheAnchor(msg0);
|
|
473
|
+
setSecondMessageCacheAnchor(msg0);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
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 {
|
|
483
|
+
setMessageCacheAnchor(messages[1]);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// --- Slot 4: rolling latest user anchor ---
|
|
487
|
+
if (latest) {
|
|
488
|
+
setMessageCacheAnchor(messages[latest.index]);
|
|
489
|
+
}
|
|
278
490
|
}
|
|
279
491
|
/**
|
|
280
492
|
* Sanitize system prompt and prepend Claude Code identity.
|
|
@@ -329,6 +541,7 @@ export function rewriteRequestBody(body) {
|
|
|
329
541
|
try {
|
|
330
542
|
const parsed = JSON.parse(body);
|
|
331
543
|
parsed.system = prependClaudeCodeIdentity(parsed.system);
|
|
544
|
+
stripTrailingAssistantMessages(parsed);
|
|
332
545
|
applyHybridCache1h(parsed);
|
|
333
546
|
return prefixToolNames(parsed);
|
|
334
547
|
}
|
|
@@ -336,8 +549,146 @@ export function rewriteRequestBody(body) {
|
|
|
336
549
|
return body;
|
|
337
550
|
}
|
|
338
551
|
}
|
|
552
|
+
/** Find the first SSE event boundary (\n\n or \r\n\r\n) in a text buffer. */
|
|
553
|
+
function findSseBoundary(value) {
|
|
554
|
+
const lf = value.indexOf('\n\n');
|
|
555
|
+
const crlf = value.indexOf('\r\n\r\n');
|
|
556
|
+
if (lf === -1)
|
|
557
|
+
return crlf === -1 ? null : { index: crlf, length: 4 };
|
|
558
|
+
if (crlf === -1 || lf < crlf)
|
|
559
|
+
return { index: lf, length: 2 };
|
|
560
|
+
return { index: crlf, length: 4 };
|
|
561
|
+
}
|
|
562
|
+
function asDiagnosticRecord(value) {
|
|
563
|
+
return value != null && typeof value === 'object' && !Array.isArray(value)
|
|
564
|
+
? value
|
|
565
|
+
: undefined;
|
|
566
|
+
}
|
|
567
|
+
function stringField(record, key) {
|
|
568
|
+
const v = record?.[key];
|
|
569
|
+
return typeof v === 'string' ? v : undefined;
|
|
570
|
+
}
|
|
571
|
+
function isRetryableAnthropicStreamError(errorType, message) {
|
|
572
|
+
const t = errorType?.toLowerCase();
|
|
573
|
+
const m = message.toLowerCase();
|
|
574
|
+
return (t === 'api_error' ||
|
|
575
|
+
t === 'overloaded_error' ||
|
|
576
|
+
t === 'server_error' ||
|
|
577
|
+
t === 'internal_server_error' ||
|
|
578
|
+
m.includes('internal server error') ||
|
|
579
|
+
m.includes('server overloaded'));
|
|
580
|
+
}
|
|
581
|
+
function retryableAnthropicStreamError(errorType, message) {
|
|
582
|
+
const detail = errorType ? `${errorType}: ${message}` : message;
|
|
583
|
+
const err = new Error(`Anthropic stream error: ${detail}`);
|
|
584
|
+
err.code = 'ECONNRESET';
|
|
585
|
+
err.syscall = 'anthropic-sse';
|
|
586
|
+
if (errorType)
|
|
587
|
+
err.providerErrorType = errorType;
|
|
588
|
+
return err;
|
|
589
|
+
}
|
|
590
|
+
function retryableAnthropicStreamErrorFromRawEvent(rawEvent) {
|
|
591
|
+
if (!rawEvent.includes('error'))
|
|
592
|
+
return null;
|
|
593
|
+
let eventName;
|
|
594
|
+
const dataLines = [];
|
|
595
|
+
for (const line of rawEvent.split(/\r?\n/)) {
|
|
596
|
+
if (line.startsWith('event:')) {
|
|
597
|
+
eventName = line.slice('event:'.length).trim();
|
|
598
|
+
}
|
|
599
|
+
else if (line.startsWith('data:')) {
|
|
600
|
+
const v = line.slice('data:'.length);
|
|
601
|
+
dataLines.push(v.startsWith(' ') ? v.slice(1) : v);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const dataText = dataLines.join('\n');
|
|
605
|
+
if (!dataText || dataText === '[DONE]')
|
|
606
|
+
return null;
|
|
607
|
+
let parsed;
|
|
608
|
+
try {
|
|
609
|
+
parsed = JSON.parse(dataText);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
const data = asDiagnosticRecord(parsed);
|
|
615
|
+
if (eventName !== 'error' && stringField(data, 'type') !== 'error')
|
|
616
|
+
return null;
|
|
617
|
+
const errorObj = asDiagnosticRecord(data?.error);
|
|
618
|
+
const errorType = stringField(errorObj, 'type') ??
|
|
619
|
+
stringField(errorObj, 'code') ??
|
|
620
|
+
undefined;
|
|
621
|
+
const message = stringField(errorObj, 'message') ??
|
|
622
|
+
stringField(data, 'message') ??
|
|
623
|
+
errorType ??
|
|
624
|
+
'Anthropic stream error';
|
|
625
|
+
if (!isRetryableAnthropicStreamError(errorType, message))
|
|
626
|
+
return null;
|
|
627
|
+
return retryableAnthropicStreamError(errorType, message);
|
|
628
|
+
}
|
|
629
|
+
function createSseErrorState() {
|
|
630
|
+
return { pending: '' };
|
|
631
|
+
}
|
|
632
|
+
function updateSseErrorState(state, text) {
|
|
633
|
+
if (!text)
|
|
634
|
+
return null;
|
|
635
|
+
state.pending += text;
|
|
636
|
+
while (true) {
|
|
637
|
+
const boundary = findSseBoundary(state.pending);
|
|
638
|
+
if (!boundary)
|
|
639
|
+
break;
|
|
640
|
+
const rawEvent = state.pending.slice(0, boundary.index);
|
|
641
|
+
state.pending = state.pending.slice(boundary.index + boundary.length);
|
|
642
|
+
const err = retryableAnthropicStreamErrorFromRawEvent(rawEvent);
|
|
643
|
+
if (err)
|
|
644
|
+
return err;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// Buffered tool-prefix rewriting (v1.10.0)
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
/**
|
|
652
|
+
* Rewrite the tool prefix from the safe portion of a text buffer.
|
|
653
|
+
* Holds back any suffix that could be the start of a partial `"name"` marker
|
|
654
|
+
* spanning a chunk boundary. Pass flush=true on stream end to emit everything.
|
|
655
|
+
*/
|
|
656
|
+
function splitToolPrefixRewriteBuffer(buffer, flush = false) {
|
|
657
|
+
if (flush)
|
|
658
|
+
return { ready: stripToolPrefix(buffer), pending: '' };
|
|
659
|
+
let keepFrom = buffer.length;
|
|
660
|
+
const marker = '"name"';
|
|
661
|
+
// Hold back any suffix that starts a partial marker
|
|
662
|
+
const partialStart = Math.max(0, buffer.length - marker.length + 1);
|
|
663
|
+
for (let i = partialStart; i < buffer.length; i++) {
|
|
664
|
+
if (marker.startsWith(buffer.slice(i))) {
|
|
665
|
+
keepFrom = Math.min(keepFrom, i);
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Also hold back if the last occurrence of the marker is incomplete
|
|
670
|
+
const lastMarker = buffer.lastIndexOf(marker);
|
|
671
|
+
if (lastMarker !== -1) {
|
|
672
|
+
const tail = buffer.slice(lastMarker);
|
|
673
|
+
if (/^"name"\s*(?::\s*(?:"[^"]*)?)?$/.test(tail)) {
|
|
674
|
+
keepFrom = Math.min(keepFrom, lastMarker);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (keepFrom < buffer.length) {
|
|
678
|
+
return {
|
|
679
|
+
ready: stripToolPrefix(buffer.slice(0, keepFrom)),
|
|
680
|
+
pending: buffer.slice(keepFrom),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
return { ready: stripToolPrefix(buffer), pending: '' };
|
|
684
|
+
}
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// Stream response wrapper
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
339
688
|
/**
|
|
340
689
|
* Create a streaming response that strips the tool prefix from tool names.
|
|
690
|
+
* Detects retryable Anthropic server errors inside HTTP 200 streams and
|
|
691
|
+
* throws a connection-reset-style error so OpenCode can auto-retry.
|
|
341
692
|
*/
|
|
342
693
|
export function createStrippedStream(response) {
|
|
343
694
|
if (!response.body)
|
|
@@ -345,16 +696,56 @@ export function createStrippedStream(response) {
|
|
|
345
696
|
const reader = response.body.getReader();
|
|
346
697
|
const decoder = new TextDecoder();
|
|
347
698
|
const encoder = new TextEncoder();
|
|
699
|
+
let pending = '';
|
|
700
|
+
let readerReleased = false;
|
|
701
|
+
const sseErrors = createSseErrorState();
|
|
702
|
+
const releaseReader = () => {
|
|
703
|
+
if (readerReleased)
|
|
704
|
+
return;
|
|
705
|
+
readerReleased = true;
|
|
706
|
+
reader.releaseLock();
|
|
707
|
+
};
|
|
348
708
|
const stream = new ReadableStream({
|
|
349
709
|
async pull(controller) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
710
|
+
try {
|
|
711
|
+
const { done, value } = await reader.read();
|
|
712
|
+
if (done) {
|
|
713
|
+
const finalDecoded = decoder.decode();
|
|
714
|
+
const retryableError = updateSseErrorState(sseErrors, finalDecoded);
|
|
715
|
+
if (retryableError) {
|
|
716
|
+
releaseReader();
|
|
717
|
+
throw retryableError;
|
|
718
|
+
}
|
|
719
|
+
const { ready } = splitToolPrefixRewriteBuffer(`${pending}${finalDecoded}`, true);
|
|
720
|
+
if (ready)
|
|
721
|
+
controller.enqueue(encoder.encode(ready));
|
|
722
|
+
releaseReader();
|
|
723
|
+
controller.close();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
727
|
+
const retryableError = updateSseErrorState(sseErrors, decoded);
|
|
728
|
+
if (retryableError) {
|
|
729
|
+
releaseReader();
|
|
730
|
+
throw retryableError;
|
|
731
|
+
}
|
|
732
|
+
const { ready, pending: nextPending } = splitToolPrefixRewriteBuffer(pending + decoded);
|
|
733
|
+
pending = nextPending;
|
|
734
|
+
if (ready)
|
|
735
|
+
controller.enqueue(encoder.encode(ready));
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
releaseReader();
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
async cancel(reason) {
|
|
743
|
+
try {
|
|
744
|
+
await reader.cancel(reason);
|
|
745
|
+
}
|
|
746
|
+
finally {
|
|
747
|
+
releaseReader();
|
|
354
748
|
}
|
|
355
|
-
let text = decoder.decode(value, { stream: true });
|
|
356
|
-
text = stripToolPrefix(text);
|
|
357
|
-
controller.enqueue(encoder.encode(text));
|
|
358
749
|
},
|
|
359
750
|
});
|
|
360
751
|
return new Response(stream, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sahiljassal/opencode-anthropic-auth",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/shljsl75891/opencode-anthropic-auth.git"
|
|
@@ -31,14 +31,14 @@
|
|
|
31
31
|
"@opencode-ai/plugin": "*"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@biomejs/biome": "2.4.
|
|
34
|
+
"@biomejs/biome": "2.4.16",
|
|
35
35
|
"@changesets/changelog-github": "^0.7.0",
|
|
36
36
|
"@changesets/cli": "^2.31.0",
|
|
37
|
-
"@opencode-ai/plugin": "1.
|
|
37
|
+
"@opencode-ai/plugin": "1.17.3",
|
|
38
38
|
"@tsconfig/bun": "1.0.10",
|
|
39
39
|
"@types/bun": "1.3.14",
|
|
40
40
|
"dedent": "^1.7.2",
|
|
41
|
-
"lefthook": "2.1.
|
|
41
|
+
"lefthook": "2.1.9",
|
|
42
42
|
"typescript": "6.0.3"
|
|
43
43
|
}
|
|
44
44
|
}
|