@sahiljassal/opencode-anthropic-auth 2.2.0 → 2.4.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/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 = init?.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
- const response = await fetch(rewritten.input, {
114
- ...init,
115
- body,
116
- headers: requestHeaders,
117
- ...(isInsecure() && { tls: { rejectUnauthorized: false } }),
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
  };
@@ -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
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.
@@ -56,6 +64,13 @@ type SystemBlock = {
56
64
  text: string;
57
65
  [k: string]: unknown;
58
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;
59
74
  /**
60
75
  * Sanitize system prompt and prepend Claude Code identity.
61
76
  * Handles all Anthropic API system formats: undefined, string, or array of text blocks.
package/dist/transform.js CHANGED
@@ -44,28 +44,54 @@ export function mergeHeaders(input, init) {
44
44
  }
45
45
  return headers;
46
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
+ }
47
54
  /**
48
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).
49
58
  */
50
- export function mergeBetaHeaders(headers) {
59
+ export function mergeBetaHeaders(headers, modelId) {
51
60
  const incomingBeta = headers.get('anthropic-beta') || '';
52
61
  const incomingBetasList = incomingBeta
53
62
  .split(',')
54
63
  .map((b) => b.trim())
55
64
  .filter(Boolean);
56
- return [...new Set([...REQUIRED_BETAS, ...incomingBetasList])].join(',');
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(',');
57
69
  }
58
70
  /**
59
71
  * Set OAuth-required headers: authorization, beta, user-agent.
60
72
  * Removes x-api-key since we're using OAuth.
61
73
  */
62
- export function setOAuthHeaders(headers, accessToken) {
74
+ export function setOAuthHeaders(headers, accessToken, modelId) {
63
75
  headers.set('authorization', `Bearer ${accessToken}`);
64
- headers.set('anthropic-beta', mergeBetaHeaders(headers));
76
+ headers.set('anthropic-beta', mergeBetaHeaders(headers, modelId));
65
77
  headers.set('user-agent', USER_AGENT);
66
78
  headers.delete('x-api-key');
67
79
  return headers;
68
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
+ }
69
95
  /**
70
96
  * Add TOOL_PREFIX to tool names in the request body.
71
97
  * Prefixes both tool definitions and tool_use blocks in messages.
@@ -399,6 +425,88 @@ function setHybridSystemAnchor(parsed) {
399
425
  .filter(isRecord);
400
426
  setWireCacheControl(afterIdentity[afterIdentity.length - 1]);
401
427
  }
428
+ const RETRY_AFTER_CAP_MS = 30000;
429
+ const RETRY_BASE_DELAY_MS = 500;
430
+ /**
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
+ }
402
510
  /**
403
511
  * Remove trailing assistant-role messages. OAuth endpoints reject requests
404
512
  * that end with an assistant turn (assistant prefill is not supported).
@@ -516,6 +624,8 @@ export function rewriteRequestBody(body) {
516
624
  try {
517
625
  const parsed = JSON.parse(body);
518
626
  parsed.system = prependClaudeCodeIdentity(parsed.system);
627
+ repairOrphanedToolPairs(parsed);
628
+ stripUnsupportedEffortForHaiku(parsed);
519
629
  stripTrailingAssistantMessages(parsed);
520
630
  applyHybridCache1h(parsed);
521
631
  return prefixToolNames(parsed);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sahiljassal/opencode-anthropic-auth",
3
- "version": "2.2.0",
3
+ "version": "2.4.1",
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.4.16",
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.3",
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",