@sahiljassal/opencode-anthropic-auth 2.4.0 → 2.5.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.
@@ -18,7 +18,15 @@ export declare const TOOL_PREFIX = "mcp_";
18
18
  export declare const ANTHROPIC_CACHE_LOOKBACK_BLOCKS = 20;
19
19
  export declare const REQUIRED_BETAS: string[];
20
20
  export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
21
- export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
21
+ export declare const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
22
+ /**
23
+ * Model families with adaptive thinking (thinking defaults on, display
24
+ * defaults to "omitted"). These models reject legacy manual thinking
25
+ * (type: enabled + budget_tokens) and non-default temperature/top_p/top_k.
26
+ */
27
+ export declare const ADAPTIVE_THINKING_MODEL_PATTERN: RegExp;
28
+ /** Proactive OAuth refresh margin — refresh this long before actual expiry. */
29
+ export declare const OAUTH_REFRESH_SKEW_MS: number;
22
30
  export declare const CCH_SALT = "59cf53e54c78";
23
31
  export declare const CCH_POSITIONS: number[];
24
32
  export declare const CLAUDE_CODE_VERSION = "2.1.177";
package/dist/constants.js CHANGED
@@ -27,10 +27,17 @@ export const REQUIRED_BETAS = [
27
27
  'oauth-2025-04-20',
28
28
  'claude-code-20250219',
29
29
  'interleaved-thinking-2025-05-14',
30
- 'fine-grained-tool-streaming-2025-05-14',
31
30
  ];
32
31
  export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
33
- export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
32
+ export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
33
+ /**
34
+ * Model families with adaptive thinking (thinking defaults on, display
35
+ * defaults to "omitted"). These models reject legacy manual thinking
36
+ * (type: enabled + budget_tokens) and non-default temperature/top_p/top_k.
37
+ */
38
+ export const ADAPTIVE_THINKING_MODEL_PATTERN = /claude-(opus-5|opus-4-8|opus-4-7|sonnet-5|fable-5|mythos-5)/i;
39
+ /** Proactive OAuth refresh margin — refresh this long before actual expiry. */
40
+ export const OAUTH_REFRESH_SKEW_MS = 5 * 60_000;
34
41
  export const CCH_SALT = '59cf53e54c78';
35
42
  export const CCH_POSITIONS = [4, 7, 20];
36
43
  export const CLAUDE_CODE_VERSION = '2.1.177';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { authorize, exchange } from "./auth.js";
2
- import { CLIENT_ID, TOKEN_URL } from "./constants.js";
2
+ import { CLIENT_ID, OAUTH_REFRESH_SKEW_MS, TOKEN_URL } from "./constants.js";
3
3
  import { computeRetryAfterDelayMs, createStrippedStream, extractModelId, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
4
4
  const MAX_429_RETRIES = 3;
5
5
  export const AnthropicAuthPlugin = async ({ client }) => {
@@ -23,85 +23,98 @@ export const AnthropicAuthPlugin = async ({ client }) => {
23
23
  // Shared inflight refresh promise — prevents concurrent token refreshes
24
24
  // from racing against each other (and causing 401 cascades with token rotation)
25
25
  let refreshPromise = null;
26
+ function triggerRefresh() {
27
+ if (!refreshPromise) {
28
+ refreshPromise = (async () => {
29
+ const maxRetries = 2;
30
+ const baseDelayMs = 500;
31
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
32
+ try {
33
+ if (attempt > 0) {
34
+ const delay = baseDelayMs * 2 ** (attempt - 1);
35
+ await new Promise((resolve) => setTimeout(resolve, delay));
36
+ }
37
+ // Re-read auth to get the latest refresh token.
38
+ // The outer `auth` snapshot may be stale if tokens
39
+ // were rotated since the fetch() call was made.
40
+ const freshAuth = await getAuth();
41
+ const response = await fetch(TOKEN_URL, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ Accept: 'application/json, text/plain, */*',
46
+ 'User-Agent': 'axios/1.13.6',
47
+ },
48
+ body: JSON.stringify({
49
+ grant_type: 'refresh_token',
50
+ refresh_token: freshAuth.refresh,
51
+ client_id: CLIENT_ID,
52
+ }),
53
+ });
54
+ if (!response.ok) {
55
+ if (response.status === 429 && attempt < maxRetries) {
56
+ // Honor the token endpoint's own backoff hint instead
57
+ // of the generic exponential delay below.
58
+ const retryAfterDelay = computeRetryAfterDelayMs(response.headers.get('retry-after'), attempt);
59
+ await response.body?.cancel();
60
+ await new Promise((resolve) => setTimeout(resolve, retryAfterDelay));
61
+ continue;
62
+ }
63
+ if (response.status >= 500 && attempt < maxRetries) {
64
+ await response.body?.cancel();
65
+ continue;
66
+ }
67
+ const body = await response.text().catch(() => '');
68
+ throw new Error(`Token refresh failed: ${response.status} — ${body}`);
69
+ }
70
+ const json = (await response.json());
71
+ // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
72
+ await client.auth.set({
73
+ path: {
74
+ id: 'anthropic',
75
+ },
76
+ body: {
77
+ type: 'oauth',
78
+ refresh: json.refresh_token,
79
+ access: json.access_token,
80
+ expires: Date.now() + json.expires_in * 1000,
81
+ },
82
+ });
83
+ return json.access_token;
84
+ }
85
+ catch (error) {
86
+ const isNetworkError = error instanceof Error &&
87
+ (error.message.includes('fetch failed') ||
88
+ ('code' in error &&
89
+ (error.code === 'ECONNRESET' ||
90
+ error.code === 'ECONNREFUSED' ||
91
+ error.code === 'ETIMEDOUT' ||
92
+ error.code === 'UND_ERR_CONNECT_TIMEOUT')));
93
+ if (attempt < maxRetries && isNetworkError) {
94
+ continue;
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+ // Unreachable — each iteration either returns or throws.
100
+ // Kept as a TypeScript exhaustiveness guard.
101
+ throw new Error('Token refresh exhausted all retries');
102
+ })().finally(() => {
103
+ refreshPromise = null;
104
+ });
105
+ }
106
+ return refreshPromise;
107
+ }
26
108
  return {
27
109
  apiKey: '',
28
110
  async fetch(input, init) {
29
111
  const auth = await getAuth();
30
112
  if (auth.type !== 'oauth')
31
113
  return fetch(input, init);
32
- if (!auth.access || !auth.expires || auth.expires < Date.now()) {
33
- if (!refreshPromise) {
34
- refreshPromise = (async () => {
35
- const maxRetries = 2;
36
- const baseDelayMs = 500;
37
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
38
- try {
39
- if (attempt > 0) {
40
- const delay = baseDelayMs * 2 ** (attempt - 1);
41
- await new Promise((resolve) => setTimeout(resolve, delay));
42
- }
43
- // Re-read auth to get the latest refresh token.
44
- // The outer `auth` snapshot may be stale if tokens
45
- // were rotated since the fetch() call was made.
46
- const freshAuth = await getAuth();
47
- const response = await fetch(TOKEN_URL, {
48
- method: 'POST',
49
- headers: {
50
- 'Content-Type': 'application/json',
51
- Accept: 'application/json, text/plain, */*',
52
- 'User-Agent': 'axios/1.13.6',
53
- },
54
- body: JSON.stringify({
55
- grant_type: 'refresh_token',
56
- refresh_token: freshAuth.refresh,
57
- client_id: CLIENT_ID,
58
- }),
59
- });
60
- if (!response.ok) {
61
- if (response.status >= 500 && attempt < maxRetries) {
62
- await response.body?.cancel();
63
- continue;
64
- }
65
- const body = await response.text().catch(() => '');
66
- throw new Error(`Token refresh failed: ${response.status} — ${body}`);
67
- }
68
- const json = (await response.json());
69
- // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
70
- await client.auth.set({
71
- path: {
72
- id: 'anthropic',
73
- },
74
- body: {
75
- type: 'oauth',
76
- refresh: json.refresh_token,
77
- access: json.access_token,
78
- expires: Date.now() + json.expires_in * 1000,
79
- },
80
- });
81
- return json.access_token;
82
- }
83
- catch (error) {
84
- const isNetworkError = error instanceof Error &&
85
- (error.message.includes('fetch failed') ||
86
- ('code' in error &&
87
- (error.code === 'ECONNRESET' ||
88
- error.code === 'ECONNREFUSED' ||
89
- error.code === 'ETIMEDOUT' ||
90
- error.code === 'UND_ERR_CONNECT_TIMEOUT')));
91
- if (attempt < maxRetries && isNetworkError) {
92
- continue;
93
- }
94
- throw error;
95
- }
96
- }
97
- // Unreachable — each iteration either returns or throws.
98
- // Kept as a TypeScript exhaustiveness guard.
99
- throw new Error('Token refresh exhausted all retries');
100
- })().finally(() => {
101
- refreshPromise = null;
102
- });
103
- }
104
- auth.access = await refreshPromise;
114
+ if (!auth.access ||
115
+ !auth.expires ||
116
+ auth.expires < Date.now() + OAUTH_REFRESH_SKEW_MS) {
117
+ auth.access = await triggerRefresh();
105
118
  }
106
119
  const requestHeaders = mergeHeaders(input, init);
107
120
  const rawBody = init?.body;
@@ -115,6 +128,8 @@ export const AnthropicAuthPlugin = async ({ client }) => {
115
128
  body = rewriteRequestBody(body);
116
129
  }
117
130
  const rewritten = rewriteUrl(input);
131
+ let accessToken = auth.access;
132
+ let forcedRefreshAttempted = false;
118
133
  let response;
119
134
  for (let attempt = 0;; attempt++) {
120
135
  response = await fetch(rewritten.input, {
@@ -123,6 +138,21 @@ export const AnthropicAuthPlugin = async ({ client }) => {
123
138
  headers: requestHeaders,
124
139
  ...(isInsecure() && { tls: { rejectUnauthorized: false } }),
125
140
  });
141
+ if (response.status === 401 && !forcedRefreshAttempted) {
142
+ forcedRefreshAttempted = true;
143
+ await response.body?.cancel();
144
+ // Force a refresh regardless of the cached expiry — the
145
+ // token may have been rejected before local expiry (e.g.
146
+ // revoked, or clock skew). Only retry if the refreshed
147
+ // token actually changed, to avoid looping forever
148
+ // against a permanently-rejected grant.
149
+ const refreshed = await triggerRefresh();
150
+ if (refreshed === accessToken)
151
+ break;
152
+ accessToken = refreshed;
153
+ setOAuthHeaders(requestHeaders, refreshed, modelId);
154
+ continue;
155
+ }
126
156
  if (response.status !== 429 || attempt >= MAX_429_RETRIES) {
127
157
  break;
128
158
  }
@@ -6,8 +6,8 @@ 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
+ * Excludes betas that are no-ops on the target model (e.g. interleaved-thinking
10
+ * on Haiku models, which don't support it).
11
11
  */
12
12
  export declare function mergeBetaHeaders(headers: Headers, modelId?: string): string;
13
13
  /**
package/dist/transform.js CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { ADAPTIVE_THINKING_MODEL_PATTERN, 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
  function prefixName(name) {
3
3
  return `${TOOL_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
4
4
  }
@@ -45,16 +45,16 @@ export function mergeHeaders(input, init) {
45
45
  return headers;
46
46
  }
47
47
  /**
48
- * Anthropic rejects interleaved-thinking on Haiku models this checks
49
- * whether a model id refers to a Haiku model.
48
+ * Haiku models don't support interleaved thinking checks whether a model
49
+ * id refers to a Haiku model.
50
50
  */
51
51
  function isHaikuModel(modelId) {
52
52
  return /haiku/i.test(modelId ?? '');
53
53
  }
54
54
  /**
55
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).
56
+ * Excludes betas that are no-ops on the target model (e.g. interleaved-thinking
57
+ * on Haiku models, which don't support it).
58
58
  */
59
59
  export function mergeBetaHeaders(headers, modelId) {
60
60
  const incomingBeta = headers.get('anthropic-beta') || '';
@@ -461,45 +461,92 @@ function stripUnsupportedEffortForHaiku(parsed) {
461
461
  delete parsed.thinking;
462
462
  }
463
463
  }
464
+ const ADAPTIVE_THINKING_DEFAULT = { type: 'adaptive', display: 'summarized' };
464
465
  /**
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.
466
+ * Adaptive-thinking models (Opus 5, Sonnet 5, …) default to hidden thinking
467
+ * and reject legacy manual `thinking.type: "enabled"` + budget_tokens (400
468
+ * on 4.7+). Normalizes to adaptive+summarized, canonicalizes disabled
469
+ * thinking to a bare object (extra fields 400), and demotes xhigh/max
470
+ * effort to high when thinking is disabled (that combination always 400s).
471
+ */
472
+ function normalizeAdaptiveThinking(parsed) {
473
+ if (!ADAPTIVE_THINKING_MODEL_PATTERN.test(String(parsed.model ?? '')))
474
+ return;
475
+ const thinking = parsed.thinking;
476
+ if (isRecord(thinking) && thinking.type === 'disabled') {
477
+ parsed.thinking = { type: 'disabled' };
478
+ const outputConfig = parsed.output_config;
479
+ if (isRecord(outputConfig) &&
480
+ (outputConfig.effort === 'xhigh' || outputConfig.effort === 'max')) {
481
+ outputConfig.effort = 'high';
482
+ }
483
+ return;
484
+ }
485
+ parsed.thinking = { ...ADAPTIVE_THINKING_DEFAULT };
486
+ }
487
+ /**
488
+ * Adaptive-thinking models reject non-default temperature/top_p/top_k with
489
+ * a 400, regardless of whether thinking is enabled. Anthropic's default
490
+ * temperature is 1 — anything else is non-default and must be stripped.
491
+ * top_p/top_k have no usable default on these models, so always stripped.
492
+ */
493
+ function stripRestrictedSamplingParams(parsed) {
494
+ if (!ADAPTIVE_THINKING_MODEL_PATTERN.test(String(parsed.model ?? '')))
495
+ return;
496
+ if (parsed.temperature !== undefined && parsed.temperature !== 1) {
497
+ delete parsed.temperature;
498
+ }
499
+ delete parsed.top_p;
500
+ delete parsed.top_k;
501
+ }
502
+ /**
503
+ * Remove tool_use/tool_result blocks that are not adjacent pairs. Anthropic
504
+ * requires a tool_result to be the first content in the message immediately
505
+ * following its tool_use — a summary inserted by /undo or /compact can leave
506
+ * the ids matched but no longer adjacent, which the API rejects with a 400.
469
507
  * Messages left with no content blocks are dropped entirely.
470
508
  */
471
509
  function repairOrphanedToolPairs(parsed) {
472
510
  if (!Array.isArray(parsed.messages))
473
511
  return;
474
- const toolUseIds = new Set();
475
- const toolResultIds = new Set();
476
- for (const msg of parsed.messages) {
512
+ const messages = parsed.messages;
513
+ const useMsgIndex = new Map();
514
+ const resultMsgIndex = new Map();
515
+ messages.forEach((msg, index) => {
477
516
  if (!isRecord(msg) || !Array.isArray(msg.content))
478
- continue;
517
+ return;
479
518
  for (const block of msg.content) {
480
519
  if (!isRecord(block))
481
520
  continue;
482
- if (block.type === 'tool_use' && typeof block.id === 'string') {
483
- toolUseIds.add(block.id);
521
+ if (block.type === 'tool_use' &&
522
+ typeof block.id === 'string' &&
523
+ !useMsgIndex.has(block.id)) {
524
+ useMsgIndex.set(block.id, index);
484
525
  }
485
526
  else if (block.type === 'tool_result' &&
486
- typeof block.tool_use_id === 'string') {
487
- toolResultIds.add(block.tool_use_id);
527
+ typeof block.tool_use_id === 'string' &&
528
+ !resultMsgIndex.has(block.tool_use_id)) {
529
+ resultMsgIndex.set(block.tool_use_id, index);
488
530
  }
489
531
  }
490
- }
491
- parsed.messages = parsed.messages.filter((msg) => {
532
+ });
533
+ const isAdjacentPair = (id) => {
534
+ const useIndex = useMsgIndex.get(id);
535
+ return useIndex !== undefined && resultMsgIndex.get(id) === useIndex + 1;
536
+ };
537
+ parsed.messages = messages.filter((msg, index) => {
492
538
  if (!isRecord(msg) || !Array.isArray(msg.content))
493
539
  return true;
494
540
  const filteredContent = msg.content.filter((block) => {
495
541
  if (!isRecord(block))
496
542
  return true;
497
543
  if (block.type === 'tool_use' && typeof block.id === 'string') {
498
- return toolResultIds.has(block.id);
544
+ return isAdjacentPair(block.id) && useMsgIndex.get(block.id) === index;
499
545
  }
500
546
  if (block.type === 'tool_result' &&
501
547
  typeof block.tool_use_id === 'string') {
502
- return toolUseIds.has(block.tool_use_id);
548
+ return (isAdjacentPair(block.tool_use_id) &&
549
+ resultMsgIndex.get(block.tool_use_id) === index);
503
550
  }
504
551
  return true;
505
552
  });
@@ -508,8 +555,27 @@ function repairOrphanedToolPairs(parsed) {
508
555
  });
509
556
  }
510
557
  /**
511
- * Remove trailing assistant-role messages. OAuth endpoints reject requests
512
- * that end with an assistant turn (assistant prefill is not supported).
558
+ * Anthropic requires tool_result blocks to precede any text in a message
559
+ * (text-before-tool_result is a 400). Reorder in place when both are present.
560
+ */
561
+ function reorderToolResultBlocks(parsed) {
562
+ if (!Array.isArray(parsed.messages))
563
+ return;
564
+ for (const msg of parsed.messages) {
565
+ if (!isRecord(msg) || !Array.isArray(msg.content))
566
+ continue;
567
+ const hasToolResult = msg.content.some((block) => isRecord(block) && block.type === 'tool_result');
568
+ if (!hasToolResult)
569
+ continue;
570
+ const results = msg.content.filter((block) => isRecord(block) && block.type === 'tool_result');
571
+ const rest = msg.content.filter((block) => !(isRecord(block) && block.type === 'tool_result'));
572
+ msg.content = [...results, ...rest];
573
+ }
574
+ }
575
+ /**
576
+ * Remove trailing assistant-role messages. Claude 4.6+ models reject
577
+ * requests that end with an assistant turn (assistant prefill is not
578
+ * supported on these model generations).
513
579
  */
514
580
  function stripTrailingAssistantMessages(parsed) {
515
581
  if (!Array.isArray(parsed.messages))
@@ -625,6 +691,9 @@ export function rewriteRequestBody(body) {
625
691
  const parsed = JSON.parse(body);
626
692
  parsed.system = prependClaudeCodeIdentity(parsed.system);
627
693
  repairOrphanedToolPairs(parsed);
694
+ reorderToolResultBlocks(parsed);
695
+ normalizeAdaptiveThinking(parsed);
696
+ stripRestrictedSamplingParams(parsed);
628
697
  stripUnsupportedEffortForHaiku(parsed);
629
698
  stripTrailingAssistantMessages(parsed);
630
699
  applyHybridCache1h(parsed);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sahiljassal/opencode-anthropic-auth",
3
- "version": "2.4.0",
3
+ "version": "2.5.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.5.0",
34
+ "@biomejs/biome": "2.5.2",
35
35
  "@changesets/changelog-github": "^0.7.0",
36
36
  "@changesets/cli": "^2.31.0",
37
- "@opencode-ai/plugin": "1.17.8",
37
+ "@opencode-ai/plugin": "1.17.13",
38
38
  "@tsconfig/bun": "1.0.10",
39
39
  "@types/bun": "1.3.14",
40
40
  "dedent": "^1.7.2",