opencode-anthropic-fix 2.2.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
+ import { performance } from "node:perf_hooks";
3
4
  import { Readable } from "node:stream";
4
5
  import { pipeline } from "node:stream/promises";
5
6
  import { createAnthropic } from "@ai-sdk/anthropic";
7
+ import { redactString } from "../redact.mjs";
6
8
  import { adaptV2Language, V2_SDK_PACKAGE } from "./v2-language.mjs";
7
9
 
8
10
  const TICKET_HEADER = "x-opencode-anthropic-fix-request";
@@ -14,6 +16,14 @@ function abortError(message) {
14
16
  return error;
15
17
  }
16
18
 
19
+ // Only scalar diagnostic fields may reach logs, never arbitrary error objects
20
+ // (which can carry request bodies, headers or credentials).
21
+ function diagnosticValue(value) {
22
+ if (typeof value === "string") return redactString(value).slice(0, 2000);
23
+ if (typeof value === "number" && Number.isFinite(value)) return value;
24
+ return undefined;
25
+ }
26
+
17
27
  /**
18
28
  * OpenCode 2.0.21 does not pass an abortSignal to LanguageModelV3.doStream.
19
29
  * Associate the host's HTTP hook with the custom fetch using a single-use
@@ -29,7 +39,7 @@ function abortError(message) {
29
39
  * middleware; never put the returned internal fetch in provider/model settings.
30
40
  *
31
41
  * @param {{session: {hook: Function}, aisdk: {hook: Function}, event: {subscribe: Function}} context
32
- * @param {{fetch: typeof fetch, isManagedModel?: (model: object) => boolean, onError?: (error: Error) => void, auxiliaryDeadlineMs?: number}} options
42
+ * @param {{fetch: typeof fetch, isManagedModel?: (model: object) => boolean, onError?: (error: Error, summary?: object) => void, auxiliaryDeadlineMs?: number}} options
33
43
  * @returns {Promise<{fetch: typeof fetch, dispose: () => Promise<void>}>}
34
44
  */
35
45
  export async function setupV2Transport(context, options) {
@@ -70,8 +80,10 @@ export async function setupV2Transport(context, options) {
70
80
  } else if (["session.execution.succeeded", "session.execution.failed"].includes(event.type)) {
71
81
  for (const ticket of tickets.values()) {
72
82
  // A request that never reached fetch may have failed in another
73
- // HTTP hook. Keep active auxiliary streams until their own EOF.
74
- if (!ticket.started && ticket.sessionID === event.data.sessionID) release(ticket);
83
+ // HTTP hook. Keep active auxiliary streams until their own EOF, and
84
+ // claimed tickets whose body is still uploading: the bridge releases
85
+ // those on its own finish or cancel path.
86
+ if (!ticket.started && !ticket.claimed && ticket.sessionID === event.data.sessionID) release(ticket);
75
87
  }
76
88
  }
77
89
  }
@@ -79,7 +91,7 @@ export async function setupV2Transport(context, options) {
79
91
  } catch (error) {
80
92
  if (disposed) return;
81
93
  eventFailure = error instanceof Error ? error : new Error(String(error));
82
- cancelAll(eventFailure);
94
+ cancelAll(abortError(`OpenCode session event subscription failed. ${eventFailure.message}`));
83
95
  onError(eventFailure);
84
96
  }
85
97
  })();
@@ -170,7 +182,7 @@ export async function setupV2Transport(context, options) {
170
182
  // Remove our listener before aborting: cancellation already owns the
171
183
  // reader, and must not error a stream that the consumer just closed.
172
184
  signal.removeEventListener("abort", abort);
173
- ticket.controller.abort(reason ?? abortError("Anthropic response cancelled."));
185
+ ticket.controller.abort(reason ?? abortError("Anthropic response consumer cancelled."));
174
186
  try {
175
187
  await reader.cancel(reason);
176
188
  } finally {
@@ -203,9 +215,10 @@ export async function setupV2Transport(context, options) {
203
215
  };
204
216
  response.once("close", disconnect);
205
217
  void (async () => {
218
+ const startedAt = performance.now();
219
+ let size = 0;
206
220
  try {
207
221
  const chunks = [];
208
- let size = 0;
209
222
  for await (const chunk of request) {
210
223
  size += chunk.length;
211
224
  if (size > 32 * 1024 * 1024) throw new Error("Anthropic request exceeds 32 MiB.");
@@ -226,7 +239,9 @@ export async function setupV2Transport(context, options) {
226
239
  else response.end();
227
240
  } catch (error) {
228
241
  const interrupted = ticket.controller.signal.aborted;
229
- cancel(ticket, error);
242
+ const reason = interrupted ? ticket.controller.signal.reason : undefined;
243
+ const headersSent = response.headersSent;
244
+ cancel(ticket, abortError("Anthropic bridge request failed."));
230
245
  if (!response.headersSent && !response.destroyed) {
231
246
  response.writeHead(502, { "content-type": "application/json" });
232
247
  response.end(
@@ -236,7 +251,32 @@ export async function setupV2Transport(context, options) {
236
251
  }),
237
252
  );
238
253
  } else response.destroy();
239
- if (!interrupted) onError(new Error("Anthropic compatibility transport failed."));
254
+ const summary = {
255
+ name: diagnosticValue(error?.name),
256
+ message: diagnosticValue(error?.message ?? error),
257
+ code: diagnosticValue(error?.code),
258
+ status: diagnosticValue(error?.status),
259
+ cause: {
260
+ code: diagnosticValue(error?.cause?.code),
261
+ message: diagnosticValue(error?.cause?.message),
262
+ },
263
+ interrupted,
264
+ ...(interrupted && {
265
+ reason: {
266
+ name: diagnosticValue(reason?.name),
267
+ message: diagnosticValue(reason?.message ?? reason),
268
+ },
269
+ }),
270
+ headersSent,
271
+ started: ticket.started,
272
+ kind: diagnosticValue(ticket.kind),
273
+ bodyBytes: size,
274
+ elapsedMs: Math.round(performance.now() - startedAt),
275
+ };
276
+ onError(
277
+ new Error(`Anthropic compatibility transport failed. ${JSON.stringify(summary)}`, { cause: error }),
278
+ summary,
279
+ );
240
280
  } finally {
241
281
  response.removeListener("close", disconnect);
242
282
  }
@@ -270,7 +310,7 @@ export async function setupV2Transport(context, options) {
270
310
  async function releaseResources(reason) {
271
311
  disposed = true;
272
312
  cancelAll(reason);
273
- subscription.abort();
313
+ subscription.abort(abortError("Anthropic transport event subscription stopped."));
274
314
  const results = await Promise.allSettled([
275
315
  ...registrations.map(async (registration) => registration.dispose()),
276
316
  closeBridge(),
@@ -294,6 +334,7 @@ export async function setupV2Transport(context, options) {
294
334
  id: randomUUID(),
295
335
  url,
296
336
  sessionID: event.sessionID,
337
+ kind: event.kind,
297
338
  controller: new AbortController(),
298
339
  started: false,
299
340
  };
@@ -320,7 +361,7 @@ export async function setupV2Transport(context, options) {
320
361
  headers,
321
362
  });
322
363
  } catch (error) {
323
- cancel(ticket, error);
364
+ cancel(ticket, abortError("Anthropic HTTP request hook failed."));
324
365
  throw error;
325
366
  }
326
367
  },
@@ -355,7 +396,7 @@ export async function setupV2Transport(context, options) {
355
396
  );
356
397
  } catch (error) {
357
398
  try {
358
- await releaseResources(error);
399
+ await releaseResources(abortError("Anthropic transport initialization failed."));
359
400
  } catch (cleanupError) {
360
401
  throw new AggregateError([error, cleanupError], "Anthropic transport initialization and cleanup failed.", {
361
402
  cause: cleanupError,
@@ -368,7 +409,7 @@ export async function setupV2Transport(context, options) {
368
409
  fetch: managedFetch,
369
410
  async dispose() {
370
411
  if (disposed) return;
371
- await releaseResources(abortError("Anthropic plugin has been disposed."));
412
+ await releaseResources(abortError("Anthropic transport disposed."));
372
413
  },
373
414
  };
374
415
  }
package/lib/host/v2.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { createAnthropicRuntime } from "./runtime.mjs";
3
5
  import { registerAnthropicCommandRpc, registerAnthropicServerCommand } from "./command-rpc.mjs";
4
6
  import { setupV2Transport } from "./v2-transport.mjs";
@@ -6,6 +8,8 @@ import { V2_SDK_PACKAGE } from "./v2-language.mjs";
6
8
  import { prepareV2Tools } from "./tool-names.mjs";
7
9
  import { applyV2MessagePolicies, assertPortableV2History } from "./v2-context.mjs";
8
10
  import { isTruthyEnv } from "../env.mjs";
11
+ import { getConfigDir } from "../config.mjs";
12
+ import { redactString } from "../redact.mjs";
9
13
  import { hasOneMillionContext, isOpus46Model, isOpus47Model, isOpus48Model } from "../mimicry/wire-compat.mjs";
10
14
 
11
15
  export const OAUTH_METHOD_ID = "opencode-anthropic-fix";
@@ -13,6 +17,9 @@ export const OAUTH_METHOD_ID = "opencode-anthropic-fix";
13
17
  // built-in dynamic hook executes before ours, so a registry spec adds an install.
14
18
  export const V2_MODEL_PACKAGE = `aisdk:${V2_SDK_PACKAGE}`;
15
19
 
20
+ // Shared by plugin instances so reload/disposal and user cancels do not spam stderr.
21
+ let reportedInterruptedFailure = false;
22
+
16
23
  /** @param {object} auth */
17
24
  function credential(auth) {
18
25
  if (!auth?.access || !auth?.refresh) throw new Error("No enabled Anthropic OAuth account. Use /anthropic login.");
@@ -29,7 +36,7 @@ function credential(auth) {
29
36
  * v2 owns registration and presentation; the shared runtime owns credentials,
30
37
  * request construction and retries. No v1 hooks are returned to the v2 host.
31
38
  * @param {import('@opencode/plugin').Plugin.Context} context
32
- * @param {{createRuntime?: typeof createAnthropicRuntime, setupTransport?: typeof setupV2Transport}} [dependencies]
39
+ * @param {{createRuntime?: typeof createAnthropicRuntime, setupTransport?: typeof setupV2Transport, transportErrorDir?: string}} [dependencies]
33
40
  */
34
41
  export async function setupV2(context, dependencies = {}) {
35
42
  const runtime = (dependencies.createRuntime ?? createAnthropicRuntime)({
@@ -155,6 +162,37 @@ export async function setupV2(context, dependencies = {}) {
155
162
  const isManagedModel = (model) => managed && model?.providerID === "anthropic";
156
163
  transport = await (dependencies.setupTransport ?? setupV2Transport)(context, {
157
164
  isManagedModel,
165
+ onError(error, summary) {
166
+ // Never log the Error itself: its preserved cause may contain secrets.
167
+ const message = redactString(error.message);
168
+ const interrupted = summary?.interrupted === true;
169
+ const reportInterruption = interrupted && !reportedInterruptedFailure;
170
+ if (interrupted) reportedInterruptedFailure = true;
171
+ if (!interrupted) console.error(`[anthropic] ${message}`);
172
+ let sinkFailure;
173
+ try {
174
+ const directory = dependencies.transportErrorDir ?? getConfigDir();
175
+ mkdirSync(directory, { recursive: true });
176
+ const file = join(directory, "transport-errors.log");
177
+ const rotatedFile = `${file}.1`;
178
+ if (existsSync(file) && statSync(file).size > 2 * 1024 * 1024) {
179
+ if (existsSync(rotatedFile)) unlinkSync(rotatedFile);
180
+ renameSync(file, rotatedFile);
181
+ }
182
+ const entry = {
183
+ timestamp: new Date().toISOString(),
184
+ ...(summary ?? { name: redactString(error.name), message }),
185
+ };
186
+ writeFileSync(file, `${JSON.stringify(entry)}\n`, { flag: "a", mode: 0o600 });
187
+ } catch (sinkError) {
188
+ const detail = sinkError instanceof Error ? sinkError.message : sinkError;
189
+ sinkFailure = `Failed to write transport-errors.log: ${redactString(detail)}`;
190
+ if (!interrupted) console.error(`[anthropic] ${sinkFailure}`);
191
+ }
192
+ if (reportInterruption) {
193
+ console.error(`[anthropic] ${message}${sinkFailure ? ` ${sinkFailure}` : ""}`);
194
+ }
195
+ },
158
196
  async fetch(input, init) {
159
197
  if (disposed) throw new Error("Anthropic plugin is disposed");
160
198
  if (!managed) throw new Error("The Anthropic OAuth connection changed. Retry with the selected connection.");
@@ -94,6 +94,62 @@ export const CORE_TOOL_NAMES = new Set([
94
94
  "Compress",
95
95
  ]);
96
96
 
97
+ /**
98
+ * True for a text block that holds only an opencode-model-router turn-state tail
99
+ * (`<router-state>…</router-state>`): per-request state the host never persists.
100
+ *
101
+ * @param {unknown} block
102
+ * @returns {boolean}
103
+ */
104
+ function isRouterStateBlock(block) {
105
+ if (!block || typeof block !== "object" || block.type !== "text" || typeof block.text !== "string") return false;
106
+ const text = block.text.trim();
107
+ return text.startsWith("<router-state>") && text.endsWith("</router-state>");
108
+ }
109
+
110
+ /**
111
+ * Put the one message cache breakpoint on the last block of the last user
112
+ * message (real CC behaviour). When that block is a trailing `<router-state>`
113
+ * tail, the breakpoint goes on the nearest earlier block instead, so the cache
114
+ * entry ends on content the next request repeats; thinking, redacted thinking
115
+ * and blank text blocks are never marked. Without a tail, behaviour is unchanged.
116
+ *
117
+ * @param {Array<{role?: string, content?: unknown}>} messages
118
+ * @param {{type: string, ttl?: string}} marker
119
+ */
120
+ function placeMessageBreakpoint(messages, marker) {
121
+ for (let i = messages.length - 1; i >= 0; i--) {
122
+ const msg = messages[i];
123
+ if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
124
+ const lastBlock = msg.content[msg.content.length - 1];
125
+ if (!isRouterStateBlock(lastBlock)) {
126
+ if (lastBlock && typeof lastBlock === "object") lastBlock.cache_control = marker;
127
+ return;
128
+ }
129
+ for (let m = i; m >= 0; m--) {
130
+ const content = messages[m].content;
131
+ if (typeof content === "string") {
132
+ // Anthropic's string shorthand is one text block; normalize it so the
133
+ // breakpoint can sit on it (same tokens, so the cached prefix is unchanged).
134
+ if (content.trim() === "" || isRouterStateBlock({ type: "text", text: content })) continue;
135
+ messages[m].content = [{ type: "text", text: content, cache_control: marker }];
136
+ return;
137
+ }
138
+ if (!Array.isArray(content)) continue;
139
+ const start = m === i ? content.length - 2 : content.length - 1;
140
+ for (let b = start; b >= 0; b--) {
141
+ const block = content[b];
142
+ if (!block || typeof block !== "object" || isRouterStateBlock(block)) continue;
143
+ if (block.type === "thinking" || block.type === "redacted_thinking") continue;
144
+ if (block.type === "text" && (typeof block.text !== "string" || block.text.trim() === "")) continue;
145
+ block.cache_control = marker;
146
+ return;
147
+ }
148
+ }
149
+ return;
150
+ }
151
+ }
152
+
97
153
  /**
98
154
  * Transform the request body: system prompt sanitization and tool prefixing.
99
155
  * Preserves behaviors E1-E7.
@@ -508,18 +564,18 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
508
564
  }
509
565
  }
510
566
  }
511
- // Add cache_control to last user message (real CC does this).
512
- // (User messages never contain thinking blocks, so the guard above
513
- // does not affect this breakpoint.)
514
- for (let i = parsed.messages.length - 1; i >= 0; i--) {
515
- const msg = parsed.messages[i];
516
- if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
517
- const lastBlock = msg.content[msg.content.length - 1];
518
- if (lastBlock && typeof lastBlock === "object") {
519
- lastBlock.cache_control = { type: "ephemeral", ttl: ccTtl };
520
- }
521
- break;
522
- }
567
+ // At most one message breakpoint (see placeMessageBreakpoint). Normally
568
+ // it goes on the last block of the last user message with array
569
+ // content (real CC does this). When that block is a per-request
570
+ // `<router-state>` tail (opencode-model-router's turn state, never
571
+ // persisted, so a breakpoint on it writes an entry the next request
572
+ // cannot read), it walks back to the nearest earlier eligible block:
573
+ // the rest of that message, then earlier messages of any role, newest
574
+ // first, including assistant messages. The walk-back skips thinking,
575
+ // redacted_thinking, blank text and other router-state blocks, so the
576
+ // guard above still holds; earlier string content is normalized to one
577
+ // text block. With no eligible block, no message breakpoint is placed.
578
+ placeMessageBreakpoint(parsed.messages, { type: "ephemeral", ttl: ccTtl });
523
579
  }
524
580
  }
525
581
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",