@sahiljassal/opencode-anthropic-auth 2.5.0 → 2.7.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 +3 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.js +5 -0
- package/dist/index.js +17 -4
- package/dist/server-fallback.d.ts +29 -0
- package/dist/server-fallback.js +215 -0
- package/dist/transform.d.ts +4 -2
- package/dist/transform.js +144 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,9 @@ 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
|
|
44
|
+
- **Assistant prefill strip** — trailing whitespace-only text after the latest assistant `tool_use` is removed before replay, so a valid tool-result continuation isn't rejected as an unsupported prefill
|
|
45
|
+
- **Server-side safety fallback** — OAuth requests for `claude-fable-5`/`claude-opus-5` opt into Anthropic's `fallbacks: "default"` so a content-filter refusal is transparently rerouted instead of returned as an error. Since OpenCode has no concept of the returned `fallback` content block, it's hidden behind a signed marker on the way out and restored on the next replay before the request is sent. Only affects the OAuth fetch path — API-key auth is untouched
|
|
43
46
|
- **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
47
|
- **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
48
|
- **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
|
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
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { authorize, exchange } from "./auth.js";
|
|
2
2
|
import { CLIENT_ID, OAUTH_REFRESH_SKEW_MS, TOKEN_URL } from "./constants.js";
|
|
3
|
+
import { isRecoverableRefusalModel } from "./server-fallback.js";
|
|
3
4
|
import { computeRetryAfterDelayMs, createStrippedStream, extractModelId, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
|
|
4
5
|
const MAX_429_RETRIES = 3;
|
|
5
6
|
export const AnthropicAuthPlugin = async ({ client }) => {
|
|
@@ -121,12 +122,21 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
121
122
|
const modelId = typeof rawBody === 'string'
|
|
122
123
|
? extractModelId(rawBody)
|
|
123
124
|
: undefined;
|
|
124
|
-
// biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
|
|
125
|
-
setOAuthHeaders(requestHeaders, auth.access, modelId);
|
|
126
125
|
let body = rawBody;
|
|
127
126
|
if (body && typeof body === 'string') {
|
|
128
127
|
body = rewriteRequestBody(body);
|
|
129
128
|
}
|
|
129
|
+
let parsedBody;
|
|
130
|
+
if (typeof body === 'string') {
|
|
131
|
+
try {
|
|
132
|
+
parsedBody = JSON.parse(body);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
parsedBody = undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
|
|
139
|
+
setOAuthHeaders(requestHeaders, auth.access, modelId, parsedBody);
|
|
130
140
|
const rewritten = rewriteUrl(input);
|
|
131
141
|
let accessToken = auth.access;
|
|
132
142
|
let forcedRefreshAttempted = false;
|
|
@@ -150,7 +160,7 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
150
160
|
if (refreshed === accessToken)
|
|
151
161
|
break;
|
|
152
162
|
accessToken = refreshed;
|
|
153
|
-
setOAuthHeaders(requestHeaders, refreshed, modelId);
|
|
163
|
+
setOAuthHeaders(requestHeaders, refreshed, modelId, parsedBody);
|
|
154
164
|
continue;
|
|
155
165
|
}
|
|
156
166
|
if (response.status !== 429 || attempt >= MAX_429_RETRIES) {
|
|
@@ -160,7 +170,10 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
160
170
|
await response.body?.cancel();
|
|
161
171
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
162
172
|
}
|
|
163
|
-
|
|
173
|
+
const serverFallbackModel = isRecoverableRefusalModel(modelId)
|
|
174
|
+
? modelId
|
|
175
|
+
: undefined;
|
|
176
|
+
return createStrippedStream(response, { serverFallbackModel });
|
|
164
177
|
},
|
|
165
178
|
};
|
|
166
179
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic's server-side safety fallback (`server-side-fallback-2026-07-01`)
|
|
3
|
+
* transparently reroutes a refused Fable 5/Opus 5 request to Opus 5/Opus 4.8
|
|
4
|
+
* instead of returning a content-filter refusal. Opting OAuth requests into
|
|
5
|
+
* it means the returned `fallback` content block records the handoff.
|
|
6
|
+
* OpenCode has no concept of that block type, so it can't be persisted or
|
|
7
|
+
* replayed as-is — this module hides it behind a signed `thinking` marker on
|
|
8
|
+
* the way out and restores the original block on the way back in.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
|
|
11
|
+
export declare function isRecoverableRefusalModel(model: unknown): model is string;
|
|
12
|
+
/**
|
|
13
|
+
* Opts eligible Fable 5/Opus 5 requests into `fallbacks: "default"` and
|
|
14
|
+
* restores any hidden fallback-boundary markers left by a previous response.
|
|
15
|
+
*/
|
|
16
|
+
export declare function applyServerSideFallbackToBody(body: Record<string, unknown>): {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
restoredMarkers: number;
|
|
19
|
+
droppedMarkers: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Streaming SSE rewriter: converts a returned `fallback` content block into
|
|
23
|
+
* a hidden signed `thinking` marker, so OpenCode can store and later replay
|
|
24
|
+
* a response that crossed a server-side fallback boundary.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createServerSideFallbackStreamRewriter(): {
|
|
27
|
+
push(text: string): string;
|
|
28
|
+
flush(): string;
|
|
29
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic's server-side safety fallback (`server-side-fallback-2026-07-01`)
|
|
3
|
+
* transparently reroutes a refused Fable 5/Opus 5 request to Opus 5/Opus 4.8
|
|
4
|
+
* instead of returning a content-filter refusal. Opting OAuth requests into
|
|
5
|
+
* it means the returned `fallback` content block records the handoff.
|
|
6
|
+
* OpenCode has no concept of that block type, so it can't be persisted or
|
|
7
|
+
* replayed as-is — this module hides it behind a signed `thinking` marker on
|
|
8
|
+
* the way out and restores the original block on the way back in.
|
|
9
|
+
*/
|
|
10
|
+
export const SERVER_SIDE_FALLBACK_BETA = 'server-side-fallback-2026-07-01';
|
|
11
|
+
const MARKER_TEXT = '\u2060';
|
|
12
|
+
const SIGNATURE_PREFIX = 'opencode-anthropic-auth-server-fallback-v1:';
|
|
13
|
+
const RECOVERABLE_REFUSAL_MODEL_PATTERN = /^claude-(fable-5|opus-5)(-.*)?$/i;
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function stringField(value, key) {
|
|
18
|
+
const field = value?.[key];
|
|
19
|
+
return typeof field === 'string' ? field : undefined;
|
|
20
|
+
}
|
|
21
|
+
export function isRecoverableRefusalModel(model) {
|
|
22
|
+
return (typeof model === 'string' && RECOVERABLE_REFUSAL_MODEL_PATTERN.test(model));
|
|
23
|
+
}
|
|
24
|
+
function encodeMarkerSignature(marker) {
|
|
25
|
+
return `${SIGNATURE_PREFIX}${marker.fromModel}|${marker.toModel}`;
|
|
26
|
+
}
|
|
27
|
+
function decodeMarkerSignature(value) {
|
|
28
|
+
if (typeof value !== 'string' || !value.startsWith(SIGNATURE_PREFIX)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const encoded = value.slice(SIGNATURE_PREFIX.length);
|
|
32
|
+
const separator = encoded.indexOf('|');
|
|
33
|
+
if (separator <= 0 || separator !== encoded.lastIndexOf('|'))
|
|
34
|
+
return null;
|
|
35
|
+
const fromModel = encoded.slice(0, separator);
|
|
36
|
+
const toModel = encoded.slice(separator + 1);
|
|
37
|
+
if (!fromModel || !toModel)
|
|
38
|
+
return null;
|
|
39
|
+
return { fromModel, toModel };
|
|
40
|
+
}
|
|
41
|
+
function markerFromFallbackBlock(block) {
|
|
42
|
+
if (block.type !== 'fallback')
|
|
43
|
+
return null;
|
|
44
|
+
const fromModel = stringField(isRecord(block.from) ? block.from : undefined, 'model');
|
|
45
|
+
const toModel = stringField(isRecord(block.to) ? block.to : undefined, 'model');
|
|
46
|
+
if (!fromModel || !toModel)
|
|
47
|
+
return null;
|
|
48
|
+
return { fromModel, toModel };
|
|
49
|
+
}
|
|
50
|
+
function fallbackBlock(marker) {
|
|
51
|
+
return {
|
|
52
|
+
type: 'fallback',
|
|
53
|
+
from: { model: marker.fromModel },
|
|
54
|
+
to: { model: marker.toModel },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function rewriteStoredMarkers(body, enabled) {
|
|
58
|
+
let restoredMarkers = 0;
|
|
59
|
+
let droppedMarkers = 0;
|
|
60
|
+
if (!Array.isArray(body.messages))
|
|
61
|
+
return { restoredMarkers, droppedMarkers };
|
|
62
|
+
for (const rawMessage of body.messages) {
|
|
63
|
+
if (!isRecord(rawMessage) ||
|
|
64
|
+
rawMessage.role !== 'assistant' ||
|
|
65
|
+
!Array.isArray(rawMessage.content)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const content = rawMessage.content;
|
|
69
|
+
const rewritten = [];
|
|
70
|
+
let messageRestored = 0;
|
|
71
|
+
let messageDropped = 0;
|
|
72
|
+
for (const rawBlock of content) {
|
|
73
|
+
const block = isRecord(rawBlock) ? rawBlock : undefined;
|
|
74
|
+
const isMarker = block?.type === 'thinking' &&
|
|
75
|
+
block.thinking === MARKER_TEXT &&
|
|
76
|
+
typeof block.signature === 'string' &&
|
|
77
|
+
block.signature.startsWith(SIGNATURE_PREFIX);
|
|
78
|
+
if (!isMarker) {
|
|
79
|
+
rewritten.push(rawBlock);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const marker = decodeMarkerSignature(block.signature);
|
|
83
|
+
if (enabled && marker) {
|
|
84
|
+
messageRestored++;
|
|
85
|
+
rewritten.push(fallbackBlock(marker));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
messageDropped++;
|
|
89
|
+
}
|
|
90
|
+
// Dropping every block would leave an empty content array, which
|
|
91
|
+
// Anthropic rejects — leave the message untouched instead.
|
|
92
|
+
if (rewritten.length === 0 && content.length > 0)
|
|
93
|
+
continue;
|
|
94
|
+
restoredMarkers += messageRestored;
|
|
95
|
+
droppedMarkers += messageDropped;
|
|
96
|
+
rawMessage.content = rewritten;
|
|
97
|
+
}
|
|
98
|
+
return { restoredMarkers, droppedMarkers };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Opts eligible Fable 5/Opus 5 requests into `fallbacks: "default"` and
|
|
102
|
+
* restores any hidden fallback-boundary markers left by a previous response.
|
|
103
|
+
*/
|
|
104
|
+
export function applyServerSideFallbackToBody(body) {
|
|
105
|
+
const enabled = isRecoverableRefusalModel(body.model);
|
|
106
|
+
const markerResult = rewriteStoredMarkers(body, enabled);
|
|
107
|
+
if (enabled) {
|
|
108
|
+
body.fallbacks = 'default';
|
|
109
|
+
}
|
|
110
|
+
else if (body.fallbacks === 'default') {
|
|
111
|
+
delete body.fallbacks;
|
|
112
|
+
}
|
|
113
|
+
return { enabled, ...markerResult };
|
|
114
|
+
}
|
|
115
|
+
function findSseBoundary(value) {
|
|
116
|
+
const lf = value.indexOf('\n\n');
|
|
117
|
+
const crlf = value.indexOf('\r\n\r\n');
|
|
118
|
+
if (lf === -1)
|
|
119
|
+
return crlf === -1 ? null : { index: crlf, length: 4 };
|
|
120
|
+
if (crlf === -1 || lf < crlf)
|
|
121
|
+
return { index: lf, length: 2 };
|
|
122
|
+
return { index: crlf, length: 4 };
|
|
123
|
+
}
|
|
124
|
+
function parseSseEvent(rawEvent) {
|
|
125
|
+
let event;
|
|
126
|
+
const dataLines = [];
|
|
127
|
+
for (const line of rawEvent.split(/\r?\n/)) {
|
|
128
|
+
if (line.startsWith('event:')) {
|
|
129
|
+
event = line.slice('event:'.length).trim();
|
|
130
|
+
}
|
|
131
|
+
else if (line.startsWith('data:')) {
|
|
132
|
+
const value = line.slice('data:'.length);
|
|
133
|
+
dataLines.push(value.startsWith(' ') ? value.slice(1) : value);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const text = dataLines.join('\n');
|
|
137
|
+
if (!text || text === '[DONE]')
|
|
138
|
+
return { event };
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(text);
|
|
141
|
+
return { event, data: isRecord(parsed) ? parsed : undefined };
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return { event };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function sseFrame(event, data) {
|
|
148
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
149
|
+
}
|
|
150
|
+
function hiddenMarkerFrames(index, marker) {
|
|
151
|
+
return (sseFrame('content_block_start', {
|
|
152
|
+
type: 'content_block_start',
|
|
153
|
+
index,
|
|
154
|
+
content_block: { type: 'thinking', thinking: MARKER_TEXT },
|
|
155
|
+
}) +
|
|
156
|
+
sseFrame('content_block_delta', {
|
|
157
|
+
type: 'content_block_delta',
|
|
158
|
+
index,
|
|
159
|
+
delta: {
|
|
160
|
+
type: 'signature_delta',
|
|
161
|
+
signature: encodeMarkerSignature(marker),
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Streaming SSE rewriter: converts a returned `fallback` content block into
|
|
167
|
+
* a hidden signed `thinking` marker, so OpenCode can store and later replay
|
|
168
|
+
* a response that crossed a server-side fallback boundary.
|
|
169
|
+
*/
|
|
170
|
+
export function createServerSideFallbackStreamRewriter() {
|
|
171
|
+
let pending = '';
|
|
172
|
+
const rewriteEvent = (rawEvent, boundary) => {
|
|
173
|
+
const parsed = parseSseEvent(rawEvent);
|
|
174
|
+
const data = parsed.data;
|
|
175
|
+
const type = stringField(data, 'type') ?? parsed.event;
|
|
176
|
+
if (type === 'content_block_start') {
|
|
177
|
+
const block = isRecord(data?.content_block)
|
|
178
|
+
? data.content_block
|
|
179
|
+
: undefined;
|
|
180
|
+
const marker = block ? markerFromFallbackBlock(block) : null;
|
|
181
|
+
if (marker) {
|
|
182
|
+
const index = typeof data?.index === 'number' ? data.index : 0;
|
|
183
|
+
return hiddenMarkerFrames(index, marker);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return rawEvent + boundary;
|
|
187
|
+
};
|
|
188
|
+
const drain = () => {
|
|
189
|
+
let output = '';
|
|
190
|
+
while (true) {
|
|
191
|
+
const boundary = findSseBoundary(pending);
|
|
192
|
+
if (!boundary)
|
|
193
|
+
break;
|
|
194
|
+
const rawEvent = pending.slice(0, boundary.index);
|
|
195
|
+
const delimiter = pending.slice(boundary.index, boundary.index + boundary.length);
|
|
196
|
+
pending = pending.slice(boundary.index + boundary.length);
|
|
197
|
+
output += rewriteEvent(rawEvent, delimiter);
|
|
198
|
+
}
|
|
199
|
+
return output;
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
push(text) {
|
|
203
|
+
pending += text;
|
|
204
|
+
return drain();
|
|
205
|
+
},
|
|
206
|
+
flush() {
|
|
207
|
+
const output = drain();
|
|
208
|
+
if (!pending)
|
|
209
|
+
return output;
|
|
210
|
+
const tail = rewriteEvent(pending, '');
|
|
211
|
+
pending = '';
|
|
212
|
+
return output + tail;
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
package/dist/transform.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare function mergeBetaHeaders(headers: Headers, modelId?: string): st
|
|
|
14
14
|
* Set OAuth-required headers: authorization, beta, user-agent.
|
|
15
15
|
* Removes x-api-key since we're using OAuth.
|
|
16
16
|
*/
|
|
17
|
-
export declare function setOAuthHeaders(headers: Headers, accessToken: string, modelId?: string): Headers;
|
|
17
|
+
export declare function setOAuthHeaders(headers: Headers, accessToken: string, modelId?: string, body?: unknown): Headers;
|
|
18
18
|
/**
|
|
19
19
|
* Extract the `model` field from a JSON request body string, if present.
|
|
20
20
|
* Used to make header rewriting (e.g. beta exclusions) model-aware before
|
|
@@ -92,5 +92,7 @@ export type RetryableAnthropicStreamError = Error & {
|
|
|
92
92
|
* Detects retryable Anthropic server errors inside HTTP 200 streams and
|
|
93
93
|
* throws a connection-reset-style error so OpenCode can auto-retry.
|
|
94
94
|
*/
|
|
95
|
-
export declare function createStrippedStream(response: Response
|
|
95
|
+
export declare function createStrippedStream(response: Response, options?: {
|
|
96
|
+
serverFallbackModel?: string;
|
|
97
|
+
}): Response;
|
|
96
98
|
export {};
|
package/dist/transform.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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";
|
|
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
|
+
import { applyServerSideFallbackToBody, createServerSideFallbackStreamRewriter, SERVER_SIDE_FALLBACK_BETA, } from "./server-fallback.js";
|
|
2
3
|
function prefixName(name) {
|
|
3
4
|
return `${TOOL_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
|
4
5
|
}
|
|
@@ -71,9 +72,18 @@ export function mergeBetaHeaders(headers, modelId) {
|
|
|
71
72
|
* Set OAuth-required headers: authorization, beta, user-agent.
|
|
72
73
|
* Removes x-api-key since we're using OAuth.
|
|
73
74
|
*/
|
|
74
|
-
export function setOAuthHeaders(headers, accessToken, modelId) {
|
|
75
|
+
export function setOAuthHeaders(headers, accessToken, modelId, body) {
|
|
75
76
|
headers.set('authorization', `Bearer ${accessToken}`);
|
|
76
|
-
|
|
77
|
+
let beta = mergeBetaHeaders(headers, modelId);
|
|
78
|
+
if (isRecord(body) && body.fallbacks === 'default') {
|
|
79
|
+
beta = [
|
|
80
|
+
...new Set([
|
|
81
|
+
...beta.split(',').filter(Boolean),
|
|
82
|
+
SERVER_SIDE_FALLBACK_BETA,
|
|
83
|
+
]),
|
|
84
|
+
].join(',');
|
|
85
|
+
}
|
|
86
|
+
headers.set('anthropic-beta', beta);
|
|
77
87
|
headers.set('user-agent', USER_AGENT);
|
|
78
88
|
headers.delete('x-api-key');
|
|
79
89
|
return headers;
|
|
@@ -499,60 +509,104 @@ function stripRestrictedSamplingParams(parsed) {
|
|
|
499
509
|
delete parsed.top_p;
|
|
500
510
|
delete parsed.top_k;
|
|
501
511
|
}
|
|
512
|
+
function toolUseIdOf(block) {
|
|
513
|
+
return isRecord(block) &&
|
|
514
|
+
block.type === 'tool_use' &&
|
|
515
|
+
typeof block.id === 'string'
|
|
516
|
+
? block.id
|
|
517
|
+
: undefined;
|
|
518
|
+
}
|
|
519
|
+
function toolResultIdOf(block) {
|
|
520
|
+
return isRecord(block) &&
|
|
521
|
+
block.type === 'tool_result' &&
|
|
522
|
+
typeof block.tool_use_id === 'string'
|
|
523
|
+
? block.tool_use_id
|
|
524
|
+
: undefined;
|
|
525
|
+
}
|
|
502
526
|
/**
|
|
503
|
-
*
|
|
504
|
-
* requires a tool_result to be the first
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
527
|
+
* Reconcile tool_use/tool_result adjacency broken by a /compact or /undo
|
|
528
|
+
* summary insertion. Anthropic requires a tool_result to be the first
|
|
529
|
+
* content in the message immediately following its tool_use, and rejects
|
|
530
|
+
* partial edits to an assistant message that holds thinking/redacted_thinking
|
|
531
|
+
* blocks ("thinking blocks in the latest assistant message cannot be
|
|
532
|
+
* modified") — so orphaned tool_use blocks can't simply be deleted. Two
|
|
533
|
+
* passes:
|
|
534
|
+
*
|
|
535
|
+
* 1. Remove tool_result blocks with no adjacent preceding tool_use (these
|
|
536
|
+
* only live in user turns, so no thinking block is affected).
|
|
537
|
+
* 2. Synthesize a placeholder tool_result, adjacent, for every tool_use
|
|
538
|
+
* that still lacks one — assistant message content is never rewritten.
|
|
508
539
|
*/
|
|
509
540
|
function repairOrphanedToolPairs(parsed) {
|
|
510
541
|
if (!Array.isArray(parsed.messages))
|
|
511
542
|
return;
|
|
512
543
|
const messages = parsed.messages;
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
for (const block of msg.content) {
|
|
519
|
-
if (!isRecord(block))
|
|
520
|
-
continue;
|
|
521
|
-
if (block.type === 'tool_use' &&
|
|
522
|
-
typeof block.id === 'string' &&
|
|
523
|
-
!useMsgIndex.has(block.id)) {
|
|
524
|
-
useMsgIndex.set(block.id, index);
|
|
525
|
-
}
|
|
526
|
-
else if (block.type === 'tool_result' &&
|
|
527
|
-
typeof block.tool_use_id === 'string' &&
|
|
528
|
-
!resultMsgIndex.has(block.tool_use_id)) {
|
|
529
|
-
resultMsgIndex.set(block.tool_use_id, index);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
const isAdjacentPair = (id) => {
|
|
534
|
-
const useIndex = useMsgIndex.get(id);
|
|
535
|
-
return useIndex !== undefined && resultMsgIndex.get(id) === useIndex + 1;
|
|
544
|
+
const hasAdjacentUse = (index, id) => {
|
|
545
|
+
const prev = messages[index - 1];
|
|
546
|
+
return (isRecord(prev) &&
|
|
547
|
+
Array.isArray(prev.content) &&
|
|
548
|
+
prev.content.some((block) => toolUseIdOf(block) === id));
|
|
536
549
|
};
|
|
537
|
-
|
|
550
|
+
const pass1 = messages.flatMap((msg, index) => {
|
|
538
551
|
if (!isRecord(msg) || !Array.isArray(msg.content))
|
|
539
|
-
return
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if (block.type === 'tool_use' && typeof block.id === 'string') {
|
|
544
|
-
return isAdjacentPair(block.id) && useMsgIndex.get(block.id) === index;
|
|
545
|
-
}
|
|
546
|
-
if (block.type === 'tool_result' &&
|
|
547
|
-
typeof block.tool_use_id === 'string') {
|
|
548
|
-
return (isAdjacentPair(block.tool_use_id) &&
|
|
549
|
-
resultMsgIndex.get(block.tool_use_id) === index);
|
|
550
|
-
}
|
|
551
|
-
return true;
|
|
552
|
+
return [msg];
|
|
553
|
+
const filtered = msg.content.filter((block) => {
|
|
554
|
+
const resultId = toolResultIdOf(block);
|
|
555
|
+
return resultId === undefined || hasAdjacentUse(index, resultId);
|
|
552
556
|
});
|
|
553
|
-
msg.content
|
|
554
|
-
|
|
557
|
+
if (filtered.length === 0 && msg.content.length > 0)
|
|
558
|
+
return [];
|
|
559
|
+
return [
|
|
560
|
+
filtered.length === msg.content.length
|
|
561
|
+
? msg
|
|
562
|
+
: { ...msg, content: filtered },
|
|
563
|
+
];
|
|
555
564
|
});
|
|
565
|
+
const out = [];
|
|
566
|
+
for (let i = 0; i < pass1.length; i++) {
|
|
567
|
+
const msg = pass1[i];
|
|
568
|
+
out.push(msg);
|
|
569
|
+
if (!isRecord(msg) || !Array.isArray(msg.content))
|
|
570
|
+
continue;
|
|
571
|
+
const useIds = msg.content
|
|
572
|
+
.map(toolUseIdOf)
|
|
573
|
+
.filter((id) => id !== undefined);
|
|
574
|
+
if (useIds.length === 0)
|
|
575
|
+
continue;
|
|
576
|
+
const next = pass1[i + 1];
|
|
577
|
+
const presentIds = new Set(isRecord(next) && Array.isArray(next.content)
|
|
578
|
+
? next.content
|
|
579
|
+
.map(toolResultIdOf)
|
|
580
|
+
.filter((id) => id !== undefined)
|
|
581
|
+
: []);
|
|
582
|
+
const missing = useIds.filter((id) => !presentIds.has(id));
|
|
583
|
+
if (missing.length === 0)
|
|
584
|
+
continue;
|
|
585
|
+
const synthetic = missing.map((id) => ({
|
|
586
|
+
type: 'tool_result',
|
|
587
|
+
tool_use_id: id,
|
|
588
|
+
content: TOOL_RESULT_PLACEHOLDER,
|
|
589
|
+
is_error: true,
|
|
590
|
+
}));
|
|
591
|
+
if (isRecord(next) && next.role === 'user' && Array.isArray(next.content)) {
|
|
592
|
+
out.push({ ...next, content: [...synthetic, ...next.content] });
|
|
593
|
+
i++;
|
|
594
|
+
}
|
|
595
|
+
else if (isRecord(next) &&
|
|
596
|
+
next.role === 'user' &&
|
|
597
|
+
typeof next.content === 'string') {
|
|
598
|
+
const text = next.content;
|
|
599
|
+
out.push({
|
|
600
|
+
...next,
|
|
601
|
+
content: text.length > 0 ? [...synthetic, { type: 'text', text }] : synthetic,
|
|
602
|
+
});
|
|
603
|
+
i++;
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
out.push({ role: 'user', content: synthetic });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
parsed.messages = out;
|
|
556
610
|
}
|
|
557
611
|
/**
|
|
558
612
|
* Anthropic requires tool_result blocks to precede any text in a message
|
|
@@ -586,6 +640,37 @@ function stripTrailingAssistantMessages(parsed) {
|
|
|
586
640
|
parsed.messages.pop();
|
|
587
641
|
}
|
|
588
642
|
}
|
|
643
|
+
/**
|
|
644
|
+
* Anthropic can classify whitespace-only text after the latest assistant
|
|
645
|
+
* tool_use as assistant prefill on a later tool-result continuation, even
|
|
646
|
+
* though the request ends with a user turn. Strip it; preserve meaningful
|
|
647
|
+
* text and all earlier turns.
|
|
648
|
+
*/
|
|
649
|
+
function stripLatestAssistantToolUseTrailingWhitespace(parsed) {
|
|
650
|
+
if (!Array.isArray(parsed.messages))
|
|
651
|
+
return;
|
|
652
|
+
for (let index = parsed.messages.length - 1; index >= 0; index--) {
|
|
653
|
+
const message = parsed.messages[index];
|
|
654
|
+
if (!isRecord(message) || message.role !== 'assistant')
|
|
655
|
+
continue;
|
|
656
|
+
if (!Array.isArray(message.content))
|
|
657
|
+
return;
|
|
658
|
+
const hasToolUse = message.content.some((block) => isRecord(block) && block.type === 'tool_use');
|
|
659
|
+
if (!hasToolUse)
|
|
660
|
+
return;
|
|
661
|
+
while (message.content.length) {
|
|
662
|
+
const block = message.content[message.content.length - 1];
|
|
663
|
+
if (!isRecord(block) ||
|
|
664
|
+
block.type !== 'text' ||
|
|
665
|
+
typeof block.text !== 'string' ||
|
|
666
|
+
block.text.trim()) {
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
message.content.pop();
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
589
674
|
/**
|
|
590
675
|
* Returns true when messages[0] carries a merged stable-prefix layout
|
|
591
676
|
* (≥2 cacheable blocks). In that case anchoring the last block would bust
|
|
@@ -696,6 +781,8 @@ export function rewriteRequestBody(body) {
|
|
|
696
781
|
stripRestrictedSamplingParams(parsed);
|
|
697
782
|
stripUnsupportedEffortForHaiku(parsed);
|
|
698
783
|
stripTrailingAssistantMessages(parsed);
|
|
784
|
+
stripLatestAssistantToolUseTrailingWhitespace(parsed);
|
|
785
|
+
applyServerSideFallbackToBody(parsed);
|
|
699
786
|
applyHybridCache1h(parsed);
|
|
700
787
|
return prefixToolNames(parsed);
|
|
701
788
|
}
|
|
@@ -833,7 +920,7 @@ function splitToolPrefixRewriteBuffer(buffer, flush = false) {
|
|
|
833
920
|
* Detects retryable Anthropic server errors inside HTTP 200 streams and
|
|
834
921
|
* throws a connection-reset-style error so OpenCode can auto-retry.
|
|
835
922
|
*/
|
|
836
|
-
export function createStrippedStream(response) {
|
|
923
|
+
export function createStrippedStream(response, options) {
|
|
837
924
|
if (!response.body)
|
|
838
925
|
return response;
|
|
839
926
|
const reader = response.body.getReader();
|
|
@@ -842,6 +929,9 @@ export function createStrippedStream(response) {
|
|
|
842
929
|
let pending = '';
|
|
843
930
|
let readerReleased = false;
|
|
844
931
|
const sseErrors = createSseErrorState();
|
|
932
|
+
const fallbackRewriter = options?.serverFallbackModel
|
|
933
|
+
? createServerSideFallbackStreamRewriter()
|
|
934
|
+
: undefined;
|
|
845
935
|
const releaseReader = () => {
|
|
846
936
|
if (readerReleased)
|
|
847
937
|
return;
|
|
@@ -853,7 +943,9 @@ export function createStrippedStream(response) {
|
|
|
853
943
|
try {
|
|
854
944
|
const { done, value } = await reader.read();
|
|
855
945
|
if (done) {
|
|
856
|
-
const finalDecoded =
|
|
946
|
+
const finalDecoded = fallbackRewriter
|
|
947
|
+
? fallbackRewriter.push(decoder.decode()) + fallbackRewriter.flush()
|
|
948
|
+
: decoder.decode();
|
|
857
949
|
let retryableError = updateSseErrorState(sseErrors, finalDecoded);
|
|
858
950
|
if (!retryableError && sseErrors.pending) {
|
|
859
951
|
retryableError = retryableAnthropicStreamErrorFromRawEvent(sseErrors.pending);
|
|
@@ -875,7 +967,9 @@ export function createStrippedStream(response) {
|
|
|
875
967
|
controller.close();
|
|
876
968
|
return;
|
|
877
969
|
}
|
|
878
|
-
const decoded =
|
|
970
|
+
const decoded = fallbackRewriter
|
|
971
|
+
? fallbackRewriter.push(decoder.decode(value, { stream: true }))
|
|
972
|
+
: decoder.decode(value, { stream: true });
|
|
879
973
|
const retryableError = updateSseErrorState(sseErrors, decoded);
|
|
880
974
|
if (retryableError) {
|
|
881
975
|
try {
|