@sahiljassal/opencode-anthropic-auth 2.4.1 → 2.6.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 -0
- package/dist/constants.d.ts +14 -1
- package/dist/constants.js +14 -2
- package/dist/index.js +104 -74
- package/dist/transform.d.ts +2 -2
- package/dist/transform.js +155 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ Additional behaviours:
|
|
|
40
40
|
|
|
41
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
42
|
- **Trailing assistant strip** — assistant messages at the tail of the request are removed before forwarding (OAuth rejects assistant prefill)
|
|
43
|
+
- **Tool pair repair** — `/compact` and `/undo` can leave a `tool_use` and its `tool_result` matched by id but no longer adjacent. Orphaned `tool_result` blocks are dropped; orphaned `tool_use` blocks are never deleted (Anthropic rejects edits to `thinking`/`redacted_thinking` blocks in the latest assistant message) — instead a placeholder `tool_result` is synthesized to restore adjacency
|
|
43
44
|
- **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
45
|
- **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
46
|
- **Buffered stream rewriting** — tool name stripping buffers partial `"name"` tokens across chunk boundaries to avoid corruption
|
package/dist/constants.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ 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
|
+
* Content for a synthesized tool_result whose real output was removed by a
|
|
12
|
+
* /compact or /undo summary insertion (see repairOrphanedToolPairs).
|
|
13
|
+
*/
|
|
14
|
+
export declare const TOOL_RESULT_PLACEHOLDER = "Tool result unavailable (removed during context compaction).";
|
|
10
15
|
/**
|
|
11
16
|
* Anthropic's cache lookback window size. Each explicit breakpoint scans at
|
|
12
17
|
* most this many content blocks backward (counting the breakpoint block as
|
|
@@ -18,7 +23,15 @@ export declare const TOOL_PREFIX = "mcp_";
|
|
|
18
23
|
export declare const ANTHROPIC_CACHE_LOOKBACK_BLOCKS = 20;
|
|
19
24
|
export declare const REQUIRED_BETAS: string[];
|
|
20
25
|
export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
|
|
21
|
-
export declare const CLAUDE_CODE_IDENTITY = "You are
|
|
26
|
+
export declare const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
27
|
+
/**
|
|
28
|
+
* Model families with adaptive thinking (thinking defaults on, display
|
|
29
|
+
* defaults to "omitted"). These models reject legacy manual thinking
|
|
30
|
+
* (type: enabled + budget_tokens) and non-default temperature/top_p/top_k.
|
|
31
|
+
*/
|
|
32
|
+
export declare const ADAPTIVE_THINKING_MODEL_PATTERN: RegExp;
|
|
33
|
+
/** Proactive OAuth refresh margin — refresh this long before actual expiry. */
|
|
34
|
+
export declare const OAUTH_REFRESH_SKEW_MS: number;
|
|
22
35
|
export declare const CCH_SALT = "59cf53e54c78";
|
|
23
36
|
export declare const CCH_POSITIONS: number[];
|
|
24
37
|
export declare const CLAUDE_CODE_VERSION = "2.1.177";
|
package/dist/constants.js
CHANGED
|
@@ -14,6 +14,11 @@ export const OAUTH_SCOPES = [
|
|
|
14
14
|
'user:file_upload',
|
|
15
15
|
];
|
|
16
16
|
export const TOOL_PREFIX = 'mcp_';
|
|
17
|
+
/**
|
|
18
|
+
* Content for a synthesized tool_result whose real output was removed by a
|
|
19
|
+
* /compact or /undo summary insertion (see repairOrphanedToolPairs).
|
|
20
|
+
*/
|
|
21
|
+
export const TOOL_RESULT_PLACEHOLDER = 'Tool result unavailable (removed during context compaction).';
|
|
17
22
|
/**
|
|
18
23
|
* Anthropic's cache lookback window size. Each explicit breakpoint scans at
|
|
19
24
|
* most this many content blocks backward (counting the breakpoint block as
|
|
@@ -27,10 +32,17 @@ export const REQUIRED_BETAS = [
|
|
|
27
32
|
'oauth-2025-04-20',
|
|
28
33
|
'claude-code-20250219',
|
|
29
34
|
'interleaved-thinking-2025-05-14',
|
|
30
|
-
'fine-grained-tool-streaming-2025-05-14',
|
|
31
35
|
];
|
|
32
36
|
export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
|
|
33
|
-
export const CLAUDE_CODE_IDENTITY = "You are
|
|
37
|
+
export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
38
|
+
/**
|
|
39
|
+
* Model families with adaptive thinking (thinking defaults on, display
|
|
40
|
+
* defaults to "omitted"). These models reject legacy manual thinking
|
|
41
|
+
* (type: enabled + budget_tokens) and non-default temperature/top_p/top_k.
|
|
42
|
+
*/
|
|
43
|
+
export const ADAPTIVE_THINKING_MODEL_PATTERN = /claude-(opus-5|opus-4-8|opus-4-7|sonnet-5|fable-5|mythos-5)/i;
|
|
44
|
+
/** Proactive OAuth refresh margin — refresh this long before actual expiry. */
|
|
45
|
+
export const OAUTH_REFRESH_SKEW_MS = 5 * 60_000;
|
|
34
46
|
export const CCH_SALT = '59cf53e54c78';
|
|
35
47
|
export const CCH_POSITIONS = [4, 7, 20];
|
|
36
48
|
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 ||
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
}
|
package/dist/transform.d.ts
CHANGED
|
@@ -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
|
|
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, TOOL_RESULT_PLACEHOLDER, 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
|
-
*
|
|
49
|
-
*
|
|
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
|
|
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,55 +461,165 @@ function stripUnsupportedEffortForHaiku(parsed) {
|
|
|
461
461
|
delete parsed.thinking;
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
|
+
const ADAPTIVE_THINKING_DEFAULT = { type: 'adaptive', display: 'summarized' };
|
|
464
465
|
/**
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
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
|
+
function toolUseIdOf(block) {
|
|
503
|
+
return isRecord(block) &&
|
|
504
|
+
block.type === 'tool_use' &&
|
|
505
|
+
typeof block.id === 'string'
|
|
506
|
+
? block.id
|
|
507
|
+
: undefined;
|
|
508
|
+
}
|
|
509
|
+
function toolResultIdOf(block) {
|
|
510
|
+
return isRecord(block) &&
|
|
511
|
+
block.type === 'tool_result' &&
|
|
512
|
+
typeof block.tool_use_id === 'string'
|
|
513
|
+
? block.tool_use_id
|
|
514
|
+
: undefined;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Reconcile tool_use/tool_result adjacency broken by a /compact or /undo
|
|
518
|
+
* summary insertion. Anthropic requires a tool_result to be the first
|
|
519
|
+
* content in the message immediately following its tool_use, and rejects
|
|
520
|
+
* partial edits to an assistant message that holds thinking/redacted_thinking
|
|
521
|
+
* blocks ("thinking blocks in the latest assistant message cannot be
|
|
522
|
+
* modified") — so orphaned tool_use blocks can't simply be deleted. Two
|
|
523
|
+
* passes:
|
|
524
|
+
*
|
|
525
|
+
* 1. Remove tool_result blocks with no adjacent preceding tool_use (these
|
|
526
|
+
* only live in user turns, so no thinking block is affected).
|
|
527
|
+
* 2. Synthesize a placeholder tool_result, adjacent, for every tool_use
|
|
528
|
+
* that still lacks one — assistant message content is never rewritten.
|
|
470
529
|
*/
|
|
471
530
|
function repairOrphanedToolPairs(parsed) {
|
|
472
531
|
if (!Array.isArray(parsed.messages))
|
|
473
532
|
return;
|
|
474
|
-
const
|
|
475
|
-
const
|
|
476
|
-
|
|
533
|
+
const messages = parsed.messages;
|
|
534
|
+
const hasAdjacentUse = (index, id) => {
|
|
535
|
+
const prev = messages[index - 1];
|
|
536
|
+
return (isRecord(prev) &&
|
|
537
|
+
Array.isArray(prev.content) &&
|
|
538
|
+
prev.content.some((block) => toolUseIdOf(block) === id));
|
|
539
|
+
};
|
|
540
|
+
const pass1 = messages.flatMap((msg, index) => {
|
|
541
|
+
if (!isRecord(msg) || !Array.isArray(msg.content))
|
|
542
|
+
return [msg];
|
|
543
|
+
const filtered = msg.content.filter((block) => {
|
|
544
|
+
const resultId = toolResultIdOf(block);
|
|
545
|
+
return resultId === undefined || hasAdjacentUse(index, resultId);
|
|
546
|
+
});
|
|
547
|
+
if (filtered.length === 0 && msg.content.length > 0)
|
|
548
|
+
return [];
|
|
549
|
+
return [
|
|
550
|
+
filtered.length === msg.content.length
|
|
551
|
+
? msg
|
|
552
|
+
: { ...msg, content: filtered },
|
|
553
|
+
];
|
|
554
|
+
});
|
|
555
|
+
const out = [];
|
|
556
|
+
for (let i = 0; i < pass1.length; i++) {
|
|
557
|
+
const msg = pass1[i];
|
|
558
|
+
out.push(msg);
|
|
477
559
|
if (!isRecord(msg) || !Array.isArray(msg.content))
|
|
478
560
|
continue;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
561
|
+
const useIds = msg.content
|
|
562
|
+
.map(toolUseIdOf)
|
|
563
|
+
.filter((id) => id !== undefined);
|
|
564
|
+
if (useIds.length === 0)
|
|
565
|
+
continue;
|
|
566
|
+
const next = pass1[i + 1];
|
|
567
|
+
const presentIds = new Set(isRecord(next) && Array.isArray(next.content)
|
|
568
|
+
? next.content
|
|
569
|
+
.map(toolResultIdOf)
|
|
570
|
+
.filter((id) => id !== undefined)
|
|
571
|
+
: []);
|
|
572
|
+
const missing = useIds.filter((id) => !presentIds.has(id));
|
|
573
|
+
if (missing.length === 0)
|
|
574
|
+
continue;
|
|
575
|
+
const synthetic = missing.map((id) => ({
|
|
576
|
+
type: 'tool_result',
|
|
577
|
+
tool_use_id: id,
|
|
578
|
+
content: TOOL_RESULT_PLACEHOLDER,
|
|
579
|
+
is_error: true,
|
|
580
|
+
}));
|
|
581
|
+
if (isRecord(next) && next.role === 'user' && Array.isArray(next.content)) {
|
|
582
|
+
out.push({ ...next, content: [...synthetic, ...next.content] });
|
|
583
|
+
i++;
|
|
584
|
+
}
|
|
585
|
+
else if (isRecord(next) &&
|
|
586
|
+
next.role === 'user' &&
|
|
587
|
+
typeof next.content === 'string') {
|
|
588
|
+
const text = next.content;
|
|
589
|
+
out.push({
|
|
590
|
+
...next,
|
|
591
|
+
content: text.length > 0 ? [...synthetic, { type: 'text', text }] : synthetic,
|
|
592
|
+
});
|
|
593
|
+
i++;
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
out.push({ role: 'user', content: synthetic });
|
|
489
597
|
}
|
|
490
598
|
}
|
|
491
|
-
parsed.messages =
|
|
599
|
+
parsed.messages = out;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Anthropic requires tool_result blocks to precede any text in a message
|
|
603
|
+
* (text-before-tool_result is a 400). Reorder in place when both are present.
|
|
604
|
+
*/
|
|
605
|
+
function reorderToolResultBlocks(parsed) {
|
|
606
|
+
if (!Array.isArray(parsed.messages))
|
|
607
|
+
return;
|
|
608
|
+
for (const msg of parsed.messages) {
|
|
492
609
|
if (!isRecord(msg) || !Array.isArray(msg.content))
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
});
|
|
610
|
+
continue;
|
|
611
|
+
const hasToolResult = msg.content.some((block) => isRecord(block) && block.type === 'tool_result');
|
|
612
|
+
if (!hasToolResult)
|
|
613
|
+
continue;
|
|
614
|
+
const results = msg.content.filter((block) => isRecord(block) && block.type === 'tool_result');
|
|
615
|
+
const rest = msg.content.filter((block) => !(isRecord(block) && block.type === 'tool_result'));
|
|
616
|
+
msg.content = [...results, ...rest];
|
|
617
|
+
}
|
|
509
618
|
}
|
|
510
619
|
/**
|
|
511
|
-
* Remove trailing assistant-role messages.
|
|
512
|
-
* that end with an assistant turn (assistant prefill is not
|
|
620
|
+
* Remove trailing assistant-role messages. Claude 4.6+ models reject
|
|
621
|
+
* requests that end with an assistant turn (assistant prefill is not
|
|
622
|
+
* supported on these model generations).
|
|
513
623
|
*/
|
|
514
624
|
function stripTrailingAssistantMessages(parsed) {
|
|
515
625
|
if (!Array.isArray(parsed.messages))
|
|
@@ -625,6 +735,9 @@ export function rewriteRequestBody(body) {
|
|
|
625
735
|
const parsed = JSON.parse(body);
|
|
626
736
|
parsed.system = prependClaudeCodeIdentity(parsed.system);
|
|
627
737
|
repairOrphanedToolPairs(parsed);
|
|
738
|
+
reorderToolResultBlocks(parsed);
|
|
739
|
+
normalizeAdaptiveThinking(parsed);
|
|
740
|
+
stripRestrictedSamplingParams(parsed);
|
|
628
741
|
stripUnsupportedEffortForHaiku(parsed);
|
|
629
742
|
stripTrailingAssistantMessages(parsed);
|
|
630
743
|
applyHybridCache1h(parsed);
|