@sahiljassal/opencode-anthropic-auth 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,9 +36,9 @@ This reduces token usage and latency on repeated requests by reusing cached prom
36
36
 
37
37
  ## Configuration
38
38
 
39
- | Variable | Description |
40
- |---|---|
41
- | `ANTHROPIC_BASE_URL` | Override API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL. |
39
+ | Variable | Description |
40
+ | -------------------- | ---------------------------------------------------------------------------------------- |
41
+ | `ANTHROPIC_BASE_URL` | Override API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL. |
42
42
  | `ANTHROPIC_INSECURE` | Set to `1` or `true` to skip TLS verification. Only effective with `ANTHROPIC_BASE_URL`. |
43
43
 
44
44
  ## License
@@ -7,6 +7,13 @@ 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.";
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',
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,199 @@ function setWireCacheControl(value) {
245
248
  value.cache_control = { ...CACHE_1H };
246
249
  return true;
247
250
  }
248
- function setMessageCacheAnchor(message) {
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
- const content = Array.isArray(message.content)
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
- function applyHybridCache1h(parsed) {
265
- removeAllCacheControls(parsed);
266
- // Cache last system block after the identity block
267
- if (Array.isArray(parsed.system)) {
268
- const identityIdx = parsed.system.findIndex((b) => isRecord(b) && b.text === CLAUDE_CODE_IDENTITY);
269
- const cacheableSystem = parsed.system
270
- .slice(identityIdx >= 0 ? identityIdx + 1 : 0)
271
- .filter(isRecord);
272
- setWireCacheControl(cacheableSystem[cacheableSystem.length - 1]);
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
+ /**
369
+ * Place a cache anchor on the last system block that follows the
370
+ * CLAUDE_CODE_IDENTITY block. When there are no system blocks, or all
371
+ * system blocks precede the identity, nothing is anchored.
372
+ */
373
+ function setHybridSystemAnchor(parsed) {
374
+ if (!Array.isArray(parsed.system))
375
+ return;
376
+ const identityIdx = parsed.system.findIndex((b) => isRecord(b) && b.text === CLAUDE_CODE_IDENTITY);
377
+ const afterIdentity = parsed.system
378
+ .slice(identityIdx >= 0 ? identityIdx + 1 : 0)
379
+ .filter(isRecord);
380
+ setWireCacheControl(afterIdentity[afterIdentity.length - 1]);
381
+ }
382
+ // ---------------------------------------------------------------------------
383
+ // Trailing-assistant strip (Tier 2)
384
+ // ---------------------------------------------------------------------------
385
+ /**
386
+ * Remove trailing assistant-role messages. OAuth endpoints reject requests
387
+ * that end with an assistant turn (assistant prefill is not supported).
388
+ */
389
+ function stripTrailingAssistantMessages(parsed) {
274
390
  if (!Array.isArray(parsed.messages))
275
391
  return;
276
- setMessageCacheAnchor(parsed.messages[0]);
277
- setMessageCacheAnchor(parsed.messages[1]);
392
+ while (parsed.messages.length > 0 &&
393
+ isRecord(parsed.messages[parsed.messages.length - 1]) &&
394
+ parsed.messages[parsed.messages.length - 1].role === 'assistant') {
395
+ parsed.messages.pop();
396
+ }
397
+ }
398
+ // ---------------------------------------------------------------------------
399
+ // Main hybrid cache logic
400
+ // ---------------------------------------------------------------------------
401
+ /**
402
+ * Apply hybrid 1h prompt-caching breakpoints to parsed request body.
403
+ *
404
+ * Breakpoint slots (Anthropic supports max 4 per request):
405
+ * 1. Last system block after the identity block (skipped when bridge used)
406
+ * 2. messages[0] — first cacheable block (magic-context: slot 2a)
407
+ * 3. messages[0] — second cacheable block (magic-context split only)
408
+ * OR messages[1] last cacheable block (normal path)
409
+ * OR bridge user message (when bridge detected)
410
+ * 4. Latest user/tool-result message (index > 1) (when present)
411
+ */
412
+ function applyHybridCache1h(parsed) {
413
+ removeAllCacheControls(parsed);
414
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
415
+ const { latest, bridge } = selectHybridMessageAnchors(messages);
416
+ // --- Slot 1: system anchor (skip when bridge will occupy a slot) ---
417
+ if (!bridge) {
418
+ setHybridSystemAnchor(parsed);
419
+ }
420
+ // --- Slots 2 & 3: messages[0] ---
421
+ const msg0 = messages[0];
422
+ const msg0Blocks = getCacheableContentBlocks(msg0);
423
+ if (msg0Blocks && msg0Blocks.length >= 2) {
424
+ // Magic-context split: stable prefix is in block[0] and block[1];
425
+ // anchoring last block would bust cache every turn.
426
+ setFirstMessageCacheAnchor(msg0);
427
+ setSecondMessageCacheAnchor(msg0);
428
+ }
429
+ else {
430
+ setMessageCacheAnchor(msg0);
431
+ // --- Slot 3 (normal): messages[1] or bridge ---
432
+ if (bridge) {
433
+ setHybridSystemAnchor(parsed); // system anchor reclaimed for bridge support
434
+ setMessageCacheAnchor(messages[bridge.index]);
435
+ }
436
+ else {
437
+ setMessageCacheAnchor(messages[1]);
438
+ }
439
+ }
440
+ // --- Slot 4: rolling latest user anchor ---
441
+ if (latest) {
442
+ setMessageCacheAnchor(messages[latest.index]);
443
+ }
278
444
  }
279
445
  /**
280
446
  * Sanitize system prompt and prepend Claude Code identity.
@@ -329,6 +495,7 @@ export function rewriteRequestBody(body) {
329
495
  try {
330
496
  const parsed = JSON.parse(body);
331
497
  parsed.system = prependClaudeCodeIdentity(parsed.system);
498
+ stripTrailingAssistantMessages(parsed);
332
499
  applyHybridCache1h(parsed);
333
500
  return prefixToolNames(parsed);
334
501
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sahiljassal/opencode-anthropic-auth",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
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.15",
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.14.50",
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.6",
41
+ "lefthook": "2.1.9",
42
42
  "typescript": "6.0.3"
43
43
  }
44
44
  }