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.
Files changed (2) hide show
  1. package/index.mjs +203 -75
  2. 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
- let pinnedAccount = null;
2358
- if (typeof requestInit.body === "string" && fileAccountMap.size > 0) {
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
- const bodyObj = JSON.parse(requestInit.body);
2361
- const fileIds = extractFileIds(bodyObj);
2362
- for (const fid of fileIds) {
2363
- const pinnedIndex = fileAccountMap.get(fid);
2364
- if (pinnedIndex !== undefined) {
2365
- const candidates = accountManager.getEnabledAccounts();
2366
- pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
2367
- if (pinnedAccount) {
2368
- debugLog("file-id pinning: routing to account", {
2369
- fileId: fid,
2370
- accountIndex: pinnedIndex,
2371
- email: pinnedAccount.email,
2372
- });
2373
- break;
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 = estimatePromptTokens(requestInit.body);
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 = estimatePromptTokens(requestInit.body);
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
- parsedBody.messages = trimmed;
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), 8);
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
- let charCount = 0;
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
- // Count system prompt text
4123
- if (Array.isArray(parsed.system)) {
4124
- for (const block of parsed.system) {
4125
- if (block.type === "text" && typeof block.text === "string") {
4126
- charCount += block.text.length;
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
- // Count messages content (text blocks, tool results) — skip tool definitions
4134
- if (Array.isArray(parsed.messages)) {
4135
- for (const msg of parsed.messages) {
4136
- if (typeof msg.content === "string") {
4137
- charCount += msg.content.length;
4138
- } else if (Array.isArray(msg.content)) {
4139
- for (const block of msg.content) {
4140
- if (block.type === "text" && typeof block.text === "string") {
4141
- charCount += block.text.length;
4142
- } else if (block.type === "tool_result" && typeof block.content === "string") {
4143
- charCount += block.content.length;
4144
- } else if (block.type === "tool_use") {
4145
- // Count serialized input as tokens
4146
- charCount += JSON.stringify(block.input || {}).length;
4147
- } else if (block.type === "image" || block.type === "image_url") {
4148
- // Images: ~2000 tokens per image (Anthropic tile-based counting)
4149
- charCount += 8000; // 2000 tokens * 4 chars/token
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
- if (!body || typeof body !== "string") {
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
- try {
5149
- const parsed = JSON.parse(body);
5150
- const model = typeof parsed?.model === "string" ? parsed.model : "";
5151
- const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
5152
- const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
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: ensure messages array never ends with an assistant message.
6134
- // The API rejects this as "assistant message prefill" when extended thinking is active.
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [