opencode-anthropic-fix 0.0.41 → 0.0.43
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/index.mjs +203 -75
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -2354,28 +2354,35 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2354
2354
|
// pin the first attempt to that account so files are accessible.
|
|
2355
2355
|
// Without this, round-robin could route to an account that doesn't
|
|
2356
2356
|
// have the referenced files, causing file_not_found errors.
|
|
2357
|
-
|
|
2358
|
-
|
|
2357
|
+
// Parse body ONCE before the retry loop and reuse for all downstream logic.
|
|
2358
|
+
// This eliminates 4-5 redundant JSON.parse calls per turn (file pinning,
|
|
2359
|
+
// parseRequestBodyMetadata, adaptive context, toast, microcompact).
|
|
2360
|
+
let _parsedBodyOnce = null;
|
|
2361
|
+
if (typeof requestInit.body === "string") {
|
|
2359
2362
|
try {
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2363
|
+
_parsedBodyOnce = JSON.parse(requestInit.body);
|
|
2364
|
+
} catch {
|
|
2365
|
+
// Non-JSON body — downstream code handles gracefully
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
let pinnedAccount = null;
|
|
2370
|
+
if (_parsedBodyOnce && fileAccountMap.size > 0) {
|
|
2371
|
+
const fileIds = extractFileIds(_parsedBodyOnce);
|
|
2372
|
+
for (const fid of fileIds) {
|
|
2373
|
+
const pinnedIndex = fileAccountMap.get(fid);
|
|
2374
|
+
if (pinnedIndex !== undefined) {
|
|
2375
|
+
const candidates = accountManager.getEnabledAccounts();
|
|
2376
|
+
pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
|
|
2377
|
+
if (pinnedAccount) {
|
|
2378
|
+
debugLog("file-id pinning: routing to account", {
|
|
2379
|
+
fileId: fid,
|
|
2380
|
+
accountIndex: pinnedIndex,
|
|
2381
|
+
email: pinnedAccount.email,
|
|
2382
|
+
});
|
|
2383
|
+
break;
|
|
2375
2384
|
}
|
|
2376
2385
|
}
|
|
2377
|
-
} catch {
|
|
2378
|
-
// Non-JSON body or parse error — skip pinning
|
|
2379
2386
|
}
|
|
2380
2387
|
}
|
|
2381
2388
|
|
|
@@ -2538,8 +2545,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2538
2545
|
|
|
2539
2546
|
// Pre-compute the beta header so it can be injected into both the
|
|
2540
2547
|
// request body (betas field) and the anthropic-beta header.
|
|
2548
|
+
// Reuse _parsedBodyOnce to avoid redundant JSON.parse.
|
|
2541
2549
|
const { model: _reqModel, hasFileReferences: _reqHasFileRefs } = parseRequestBodyMetadata(
|
|
2542
2550
|
requestInit.body,
|
|
2551
|
+
_parsedBodyOnce,
|
|
2543
2552
|
);
|
|
2544
2553
|
const _reqProvider = detectProvider(requestUrl);
|
|
2545
2554
|
|
|
@@ -2555,12 +2564,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2555
2564
|
escalation_threshold: 150_000,
|
|
2556
2565
|
deescalation_threshold: 100_000,
|
|
2557
2566
|
},
|
|
2567
|
+
_parsedBodyOnce,
|
|
2558
2568
|
);
|
|
2559
2569
|
// Emit visual cue on state transitions (only when adaptive mode is on)
|
|
2560
2570
|
if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
|
|
2561
2571
|
const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
|
|
2562
2572
|
const variant = adaptiveContextState.active ? "info" : "success";
|
|
2563
|
-
const est =
|
|
2573
|
+
const est = _parsedBodyOnce
|
|
2574
|
+
? estimatePromptTokensFromParsed(_parsedBodyOnce)
|
|
2575
|
+
: estimatePromptTokens(requestInit.body);
|
|
2564
2576
|
toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
|
|
2565
2577
|
debounceKey: "adaptive-ctx",
|
|
2566
2578
|
}).catch(() => {});
|
|
@@ -2578,7 +2590,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2578
2590
|
// Microcompact: inject clear betas at high context utilization
|
|
2579
2591
|
let _microcompactBetas = null;
|
|
2580
2592
|
if (requestInit.body) {
|
|
2581
|
-
const estimatedTokens =
|
|
2593
|
+
const estimatedTokens = _parsedBodyOnce
|
|
2594
|
+
? estimatePromptTokensFromParsed(_parsedBodyOnce)
|
|
2595
|
+
: estimatePromptTokens(requestInit.body);
|
|
2582
2596
|
if (shouldMicrocompact(estimatedTokens, config)) {
|
|
2583
2597
|
_microcompactBetas = buildMicrocompactBetas();
|
|
2584
2598
|
if (!microcompactState.active) {
|
|
@@ -2973,7 +2987,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2973
2987
|
},
|
|
2974
2988
|
...tail,
|
|
2975
2989
|
];
|
|
2976
|
-
|
|
2990
|
+
// Repair any orphaned tool_use blocks created by the trim boundary
|
|
2991
|
+
// (e.g. first 2 messages include an assistant tool_use whose
|
|
2992
|
+
// tool_result was in a trimmed middle message).
|
|
2993
|
+
parsedBody.messages = repairOrphanedToolUseBlocks(trimmed);
|
|
2977
2994
|
requestInit.body = JSON.stringify(parsedBody);
|
|
2978
2995
|
requestInit._reactiveCompactAttempted = true;
|
|
2979
2996
|
// Retry with trimmed messages (decrement attempt to not consume account slot)
|
|
@@ -3141,7 +3158,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3141
3158
|
consecutive529Count = 0;
|
|
3142
3159
|
}
|
|
3143
3160
|
|
|
3144
|
-
const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount),
|
|
3161
|
+
const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 3);
|
|
3145
3162
|
const jitter = 1 - Math.random() * 0.25;
|
|
3146
3163
|
const sleepMs = Math.round(baseDelay * jitter * 1000);
|
|
3147
3164
|
const retryLabel = response.status === 529 ? "overloaded" : "unavailable";
|
|
@@ -4117,48 +4134,59 @@ function estimatePromptTokens(bodyString) {
|
|
|
4117
4134
|
if (!bodyString || typeof bodyString !== "string") return 0;
|
|
4118
4135
|
try {
|
|
4119
4136
|
const parsed = JSON.parse(bodyString);
|
|
4120
|
-
|
|
4137
|
+
return estimatePromptTokensFromParsed(parsed);
|
|
4138
|
+
} catch {
|
|
4139
|
+
// Fallback: raw body length / 4 if JSON parsing fails
|
|
4140
|
+
return Math.ceil(bodyString.length / 4);
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4121
4143
|
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4144
|
+
/**
|
|
4145
|
+
* Estimate prompt tokens from an already-parsed request body object.
|
|
4146
|
+
* Avoids redundant JSON.parse when the caller already has the parsed object.
|
|
4147
|
+
* @param {object} parsed - The parsed request body
|
|
4148
|
+
* @returns {number} Estimated token count
|
|
4149
|
+
*/
|
|
4150
|
+
function estimatePromptTokensFromParsed(parsed) {
|
|
4151
|
+
if (!parsed || typeof parsed !== "object") return 0;
|
|
4152
|
+
let charCount = 0;
|
|
4153
|
+
|
|
4154
|
+
// Count system prompt text
|
|
4155
|
+
if (Array.isArray(parsed.system)) {
|
|
4156
|
+
for (const block of parsed.system) {
|
|
4157
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4158
|
+
charCount += block.text.length;
|
|
4128
4159
|
}
|
|
4129
|
-
} else if (typeof parsed.system === "string") {
|
|
4130
|
-
charCount += parsed.system.length;
|
|
4131
4160
|
}
|
|
4161
|
+
} else if (typeof parsed.system === "string") {
|
|
4162
|
+
charCount += parsed.system.length;
|
|
4163
|
+
}
|
|
4132
4164
|
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
}
|
|
4165
|
+
// Count messages content (text blocks, tool results) — skip tool definitions
|
|
4166
|
+
if (Array.isArray(parsed.messages)) {
|
|
4167
|
+
for (const msg of parsed.messages) {
|
|
4168
|
+
if (typeof msg.content === "string") {
|
|
4169
|
+
charCount += msg.content.length;
|
|
4170
|
+
} else if (Array.isArray(msg.content)) {
|
|
4171
|
+
for (const block of msg.content) {
|
|
4172
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4173
|
+
charCount += block.text.length;
|
|
4174
|
+
} else if (block.type === "tool_result" && typeof block.content === "string") {
|
|
4175
|
+
charCount += block.content.length;
|
|
4176
|
+
} else if (block.type === "tool_use") {
|
|
4177
|
+
// Count serialized input as tokens
|
|
4178
|
+
charCount += JSON.stringify(block.input || {}).length;
|
|
4179
|
+
} else if (block.type === "image" || block.type === "image_url") {
|
|
4180
|
+
// Images: ~2000 tokens per image (Anthropic tile-based counting)
|
|
4181
|
+
charCount += 8000; // 2000 tokens * 4 chars/token
|
|
4151
4182
|
}
|
|
4152
4183
|
}
|
|
4153
4184
|
}
|
|
4154
4185
|
}
|
|
4155
|
-
|
|
4156
|
-
// 4 chars/token heuristic for text content (reasonable for English + code + JSON)
|
|
4157
|
-
return Math.ceil(charCount / 4);
|
|
4158
|
-
} catch {
|
|
4159
|
-
// Fallback: raw body length / 4 if JSON parsing fails
|
|
4160
|
-
return Math.ceil(bodyString.length / 4);
|
|
4161
4186
|
}
|
|
4187
|
+
|
|
4188
|
+
// 4 chars/token heuristic for text content (reasonable for English + code + JSON)
|
|
4189
|
+
return Math.ceil(charCount / 4);
|
|
4162
4190
|
}
|
|
4163
4191
|
|
|
4164
4192
|
/**
|
|
@@ -4281,7 +4309,7 @@ function analyzeRequestContext(bodyStr) {
|
|
|
4281
4309
|
* @param {import('./lib/config.mjs').AdaptiveContextConfig} adaptiveConfig
|
|
4282
4310
|
* @returns {boolean}
|
|
4283
4311
|
*/
|
|
4284
|
-
function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
4312
|
+
function resolveAdaptiveContext(bodyString, model, adaptiveConfig, parsedBody) {
|
|
4285
4313
|
// Non-adaptive: use static check (only explicit "1m" models)
|
|
4286
4314
|
if (!adaptiveConfig.enabled) {
|
|
4287
4315
|
return hasOneMillionContext(model);
|
|
@@ -4297,7 +4325,7 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
|
4297
4325
|
return false;
|
|
4298
4326
|
}
|
|
4299
4327
|
|
|
4300
|
-
const estimatedTokens = estimatePromptTokens(bodyString);
|
|
4328
|
+
const estimatedTokens = parsedBody ? estimatePromptTokensFromParsed(parsedBody) : estimatePromptTokens(bodyString);
|
|
4301
4329
|
const turnsSinceTransition = sessionMetrics.turns - adaptiveContextState.lastTransitionTurn;
|
|
4302
4330
|
|
|
4303
4331
|
if (adaptiveContextState.active) {
|
|
@@ -4739,6 +4767,94 @@ function computeBillingCacheHash(firstUserMessage, version) {
|
|
|
4739
4767
|
* @param {Array} messages — The messages array from the parsed request body
|
|
4740
4768
|
* @returns {Array} — Filtered messages array
|
|
4741
4769
|
*/
|
|
4770
|
+
|
|
4771
|
+
/**
|
|
4772
|
+
* Repair orphaned tool_use blocks in the message array.
|
|
4773
|
+
*
|
|
4774
|
+
* The Anthropic API requires that every assistant message containing `tool_use`
|
|
4775
|
+
* blocks is immediately followed by a user message with `tool_result` blocks
|
|
4776
|
+
* for each tool_use ID. When OpenCode crashes or hangs mid-tool-execution, the
|
|
4777
|
+
* conversation may be persisted with assistant tool_use blocks that lack
|
|
4778
|
+
* corresponding tool_result responses, causing:
|
|
4779
|
+
*
|
|
4780
|
+
* "messages.N: `tool_use` ids were found without `tool_result` blocks
|
|
4781
|
+
* immediately after: toolu_XXXXX"
|
|
4782
|
+
*
|
|
4783
|
+
* This function scans the entire message array and inserts synthetic
|
|
4784
|
+
* tool_result user messages wherever they are missing.
|
|
4785
|
+
*
|
|
4786
|
+
* @param {Array} messages — The messages array from the parsed request body
|
|
4787
|
+
* @returns {Array} — Repaired messages array
|
|
4788
|
+
*/
|
|
4789
|
+
function repairOrphanedToolUseBlocks(messages) {
|
|
4790
|
+
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
4791
|
+
|
|
4792
|
+
const repaired = [];
|
|
4793
|
+
|
|
4794
|
+
for (let i = 0; i < messages.length; i++) {
|
|
4795
|
+
const msg = messages[i];
|
|
4796
|
+
repaired.push(msg);
|
|
4797
|
+
|
|
4798
|
+
// Only check assistant messages with array content
|
|
4799
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
4800
|
+
|
|
4801
|
+
// Collect tool_use IDs from this assistant message
|
|
4802
|
+
const toolUseIds = [];
|
|
4803
|
+
for (const block of msg.content) {
|
|
4804
|
+
if (block.type === "tool_use" && block.id) {
|
|
4805
|
+
toolUseIds.push(block.id);
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
if (toolUseIds.length === 0) continue;
|
|
4809
|
+
|
|
4810
|
+
// Check if the next message is a user message with matching tool_results
|
|
4811
|
+
const next = messages[i + 1];
|
|
4812
|
+
if (next && next.role === "user" && Array.isArray(next.content)) {
|
|
4813
|
+
// Collect tool_result IDs present in the next user message
|
|
4814
|
+
const resultIds = new Set();
|
|
4815
|
+
for (const block of next.content) {
|
|
4816
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
4817
|
+
resultIds.add(block.tool_use_id);
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4821
|
+
// Find which tool_use IDs are missing from the tool_results
|
|
4822
|
+
const missingIds = toolUseIds.filter((id) => !resultIds.has(id));
|
|
4823
|
+
if (missingIds.length === 0) continue; // All paired, nothing to fix
|
|
4824
|
+
|
|
4825
|
+
// There are missing tool_results — inject them into the existing user message.
|
|
4826
|
+
// Clone the next message to avoid mutating the original.
|
|
4827
|
+
const patchedNext = {
|
|
4828
|
+
...next,
|
|
4829
|
+
content: [
|
|
4830
|
+
...missingIds.map((id) => ({
|
|
4831
|
+
type: "tool_result",
|
|
4832
|
+
tool_use_id: id,
|
|
4833
|
+
content: "[Result unavailable — tool execution was interrupted]",
|
|
4834
|
+
})),
|
|
4835
|
+
...next.content,
|
|
4836
|
+
],
|
|
4837
|
+
};
|
|
4838
|
+
// Replace the next message in-place by skipping it and pushing the patched version
|
|
4839
|
+
i++; // skip original next
|
|
4840
|
+
repaired.push(patchedNext);
|
|
4841
|
+
} else {
|
|
4842
|
+
// Next message is missing or is not a user message — synthesize a full
|
|
4843
|
+
// tool_result user message for all tool_use IDs.
|
|
4844
|
+
repaired.push({
|
|
4845
|
+
role: "user",
|
|
4846
|
+
content: toolUseIds.map((id) => ({
|
|
4847
|
+
type: "tool_result",
|
|
4848
|
+
tool_use_id: id,
|
|
4849
|
+
content: "[Result unavailable — tool execution was interrupted]",
|
|
4850
|
+
})),
|
|
4851
|
+
});
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
|
|
4855
|
+
return repaired;
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4742
4858
|
function stripSlashCommandMessages(messages) {
|
|
4743
4859
|
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
4744
4860
|
|
|
@@ -5140,21 +5256,27 @@ function detectProvider(requestUrl) {
|
|
|
5140
5256
|
* @param {any} body
|
|
5141
5257
|
* @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
|
|
5142
5258
|
*/
|
|
5143
|
-
function parseRequestBodyMetadata(body) {
|
|
5144
|
-
|
|
5259
|
+
function parseRequestBodyMetadata(body, parsedBody) {
|
|
5260
|
+
const parsed =
|
|
5261
|
+
parsedBody ||
|
|
5262
|
+
(typeof body === "string"
|
|
5263
|
+
? (() => {
|
|
5264
|
+
try {
|
|
5265
|
+
return JSON.parse(body);
|
|
5266
|
+
} catch {
|
|
5267
|
+
return null;
|
|
5268
|
+
}
|
|
5269
|
+
})()
|
|
5270
|
+
: null);
|
|
5271
|
+
if (!parsed) {
|
|
5145
5272
|
return { model: "", tools: [], messages: [], hasFileReferences: false };
|
|
5146
5273
|
}
|
|
5147
5274
|
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
const hasFileReferences = extractFileIds(parsed).length > 0;
|
|
5154
|
-
return { model, tools, messages, hasFileReferences };
|
|
5155
|
-
} catch {
|
|
5156
|
-
return { model: "", tools: [], messages: [], hasFileReferences: false };
|
|
5157
|
-
}
|
|
5275
|
+
const model = typeof parsed?.model === "string" ? parsed.model : "";
|
|
5276
|
+
const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
|
|
5277
|
+
const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
|
|
5278
|
+
const hasFileReferences = extractFileIds(parsed).length > 0;
|
|
5279
|
+
return { model, tools, messages, hasFileReferences };
|
|
5158
5280
|
}
|
|
5159
5281
|
|
|
5160
5282
|
/**
|
|
@@ -6130,14 +6252,20 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
|
6130
6252
|
parsed.speed = "fast";
|
|
6131
6253
|
}
|
|
6132
6254
|
|
|
6133
|
-
// Guard:
|
|
6134
|
-
// The API
|
|
6255
|
+
// Guard: repair orphaned tool_use blocks anywhere in the message array.
|
|
6256
|
+
// The Anthropic API requires that every assistant message containing tool_use
|
|
6257
|
+
// blocks is immediately followed by a user message with matching tool_result
|
|
6258
|
+
// blocks. When OpenCode crashes/hangs mid-tool-execution, the conversation
|
|
6259
|
+
// state may be saved with unpaired tool_use blocks. This causes:
|
|
6260
|
+
// "messages.N: `tool_use` ids were found without `tool_result` blocks
|
|
6261
|
+
// immediately after: toolu_XXXXX"
|
|
6262
|
+
// We scan the full array and synthesize missing tool_result messages.
|
|
6135
6263
|
if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
|
|
6264
|
+
parsed.messages = repairOrphanedToolUseBlocks(parsed.messages);
|
|
6265
|
+
|
|
6266
|
+
// Also ensure the array never ends with an assistant message (prefill guard).
|
|
6136
6267
|
const lastMsg = parsed.messages[parsed.messages.length - 1];
|
|
6137
6268
|
if (lastMsg && lastMsg.role === "assistant") {
|
|
6138
|
-
// Check if the assistant message contains tool_use blocks — if so,
|
|
6139
|
-
// synthesize tool_result responses instead of bare "Continue." to
|
|
6140
|
-
// respect the Anthropic tool protocol.
|
|
6141
6269
|
const lastContent = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
|
6142
6270
|
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
6143
6271
|
if (toolUseBlocks.length > 0) {
|