opencode-anthropic-fix 0.3.0 → 0.5.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/index.mjs CHANGED
@@ -77,7 +77,7 @@ import {
77
77
  SUBAGENT_CC_ANCHOR,
78
78
  } from "./lib/mimicry/system-prompt.mjs";
79
79
  import { buildAdapterTransport, resolveAdapterEnv } from "./lib/mimicry/adapter-input.mjs";
80
- import { buildWireCompatibleRequest } from "./lib/mimicry/wire-compat.mjs";
80
+ import { buildWireCompatibleRequest, buildWireCompatibleCountTokensRequest } from "./lib/mimicry/wire-compat.mjs";
81
81
  import {
82
82
  hasOneMillionContext,
83
83
  isEligibleFor1MContext,
@@ -3010,20 +3010,29 @@ export async function AnthropicAuthPlugin({ client }) {
3010
3010
  // 1. signature emulation on — with it off the plugin emits only
3011
3011
  // 3 headers and a minimal beta set, while the package always
3012
3012
  // emits the full Claude Code set;
3013
- // 2. the /v1/messages endpoint — the package pins
3014
- // `https://api.anthropic.com/v1/messages?beta=true`, so a
3015
- // /v1/messages/count_tokens turn sent through it would be
3016
- // silently rewritten to the wrong endpoint.
3013
+ // 2. a messages endpoint the package has a surface for. Both
3014
+ // qualify now: `buildClaudeCodeRequest` for /v1/messages and
3015
+ // `buildClaudeCodeCountTokensRequest` for
3016
+ // /v1/messages/count_tokens, each pinning its OWN endpoint.
3017
+ // The endpoint pin is inert either way — `built.url` is never
3018
+ // adopted (see the consumption comment below), so the URL on
3019
+ // the wire stays the one transformRequestUrl produced and a
3020
+ // custom ANTHROPIC_BASE_URL survives.
3017
3021
  //
3018
3022
  // The former first-party check is now structural: the plugin only
3019
3023
  // speaks to first-party Anthropic, so it is always satisfied.
3020
- // 3. a JSON body is actually present — the package requires
3021
- // `model` and `max_tokens`, so a bodiless request must keep
3022
- // using the legacy path rather than throwing INVALID_INPUT.
3024
+ // 3. a JSON body is actually present. The main-turn surface
3025
+ // requires `model` and `max_tokens`; the count surface
3026
+ // requires `model` and `messages` and accepts no
3027
+ // `max_tokens` at all. Either way a bodiless request must
3028
+ // keep using the legacy path rather than throwing
3029
+ // INVALID_INPUT.
3023
3030
  const _adapterPathname = requestUrl?.pathname;
3031
+ const _isCountTokens =
3032
+ _adapterPathname === "/v1/messages/count_tokens" || _adapterPathname === "/messages/count_tokens";
3024
3033
  const _useAdapter =
3025
3034
  getSignatureEmulationEnabled() &&
3026
- (_adapterPathname === "/v1/messages" || _adapterPathname === "/messages") &&
3035
+ (_adapterPathname === "/v1/messages" || _adapterPathname === "/messages" || _isCountTokens) &&
3027
3036
  typeof requestInit.body === "string" &&
3028
3037
  requestInit.body.length > 0;
3029
3038
 
@@ -3106,12 +3115,17 @@ export async function AnthropicAuthPlugin({ client }) {
3106
3115
  //
3107
3116
  // Adapter path: the shared package composes the headers AND
3108
3117
  // rebuilds the body (canonical system prefix, metadata, beta
3109
- // header). Legacy path: unchanged, byte for byte.
3118
+ // header). On the count-tokens surface it composes headers and a
3119
+ // body that deliberately carries NO system, metadata or
3120
+ // max_tokens. Legacy path: unchanged, byte for byte.
3110
3121
  //
3111
3122
  // The URL deliberately stays whatever transformRequestUrl already
3112
- // produced. `built.url` is the package's pinned
3113
- // `https://api.anthropic.com/v1/messages?beta=true`, which would
3114
- // override a custom ANTHROPIC_BASE_URL or proxy endpoint.
3123
+ // produced. `built.url` is the package's pinned endpoint
3124
+ // (`https://api.anthropic.com/v1/messages?beta=true`, or
3125
+ // `.../v1/messages/count_tokens?beta=true` on the count surface),
3126
+ // which would override a custom ANTHROPIC_BASE_URL or proxy
3127
+ // endpoint. transformRequestUrl already appends `?beta=true` to
3128
+ // both endpoints itself, so discarding `built.url` costs nothing.
3115
3129
  const _adapterSignature = {
3116
3130
  enabled: getSignatureEmulationEnabled(),
3117
3131
  claudeCliVersion,
@@ -3159,7 +3173,12 @@ export async function AnthropicAuthPlugin({ client }) {
3159
3173
  nodeVersion: process.version,
3160
3174
  });
3161
3175
  if (_adapterResult.applicable) {
3162
- const built = await buildWireCompatibleRequest(body, _adapterResult.transport);
3176
+ // Two package surfaces, one transport: the count-tokens
3177
+ // builder consumes a strict subset of the same input, so the
3178
+ // transport object needs no count-specific mode.
3179
+ const built = _isCountTokens
3180
+ ? await buildWireCompatibleCountTokensRequest(body, _adapterResult.transport)
3181
+ : await buildWireCompatibleRequest(body, _adapterResult.transport);
3163
3182
  requestHeaders = built.headers;
3164
3183
  adapterBody = built.body;
3165
3184
  } else {
@@ -131,12 +131,26 @@ export const ADAPTER_ENV_KEYS = Object.freeze([
131
131
  ]);
132
132
 
133
133
  /**
134
- * Protocol identity of the pinned profile (`claude-code-2.1.195`). Used to
135
- * decide whether a `profileOverride` is needed at all — see
136
- * `resolveProfileOverride` for the user-agent convergence argument.
134
+ * Protocol identity of the profile this adapter composes against
135
+ * (`claude-code-2.1.233-sdk-0.112.1`). Used to decide whether a
136
+ * `profileOverride` is needed at all — see `resolveProfileOverride` for the
137
+ * user-agent convergence argument.
138
+ *
139
+ * PROFILE INHERITANCE POLICY. The adapter never passes an explicit `profile`
140
+ * argument to the package's entry points; omitting it IS the mechanism by which
141
+ * the plugin inherits whatever `DEFAULT_PROFILE` the installed
142
+ * `@tormentalabs/claude-code-wire-compat` declares. The dependency is tracked at
143
+ * the `latest` dist-tag, so each package release can advance the default profile
144
+ * and the plugin follows without a code change to the composition path.
145
+ *
146
+ * These two constants are the ONLY thing that has to move in lockstep: they
147
+ * mirror the default profile's `cliVersion` / `userAgent` so that
148
+ * `resolveProfileOverride` stays silent in the common case. Keep them in sync
149
+ * with `FALLBACK_CLAUDE_CLI_VERSION` (lib/request-headers.mjs) on every package
150
+ * bump that changes the default profile.
137
151
  */
138
- export const PROFILE_CLI_VERSION = "2.1.195";
139
- export const PROFILE_USER_AGENT = "claude-cli/2.1.195 (external, cli)";
152
+ export const PROFILE_CLI_VERSION = "2.1.233";
153
+ export const PROFILE_USER_AGENT = "claude-cli/2.1.233 (external, cli)";
140
154
 
141
155
  // ---------------------------------------------------------------------------
142
156
  // Environment snapshot
@@ -179,12 +193,12 @@ export function buildAdapterUserAgent(version, env) {
179
193
  }
180
194
 
181
195
  /**
182
- * The pinned profile carries a FIXED `userAgent`
183
- * (`"claude-cli/2.1.195 (external, cli)"`) while the plugin builds one
196
+ * The inherited default profile carries a FIXED `userAgent`
197
+ * (`"claude-cli/2.1.233 (external, cli)"`) while the plugin builds one
184
198
  * dynamically from entrypoint / agent-sdk version / client-app. In the common
185
199
  * case — no `CLAUDE_CODE_ENTRYPOINT`, no `CLAUDE_AGENT_SDK_VERSION`, no
186
- * `CLAUDE_AGENT_SDK_CLIENT_APP`, detected CLI version `2.1.195` — both sides
187
- * produce the byte-identical `"claude-cli/2.1.195 (external, cli)"`, so no
200
+ * `CLAUDE_AGENT_SDK_CLIENT_APP`, detected CLI version `2.1.233` — both sides
201
+ * produce the byte-identical `"claude-cli/2.1.233 (external, cli)"`, so no
188
202
  * override is emitted and the request stays byte-identical to the profile.
189
203
  *
190
204
  * When they diverge, `userAgent` and `cliVersion` are validated TOGETHER by the
@@ -4,7 +4,7 @@
4
4
  * NOT promised because the returned HeaderPair array is converted to Headers.
5
5
  */
6
6
 
7
- import { buildClaudeCodeRequest } from "@tormentalabs/claude-code-wire-compat";
7
+ import { buildClaudeCodeRequest, buildClaudeCodeCountTokensRequest } from "@tormentalabs/claude-code-wire-compat";
8
8
  import { stripStainlessHelperMarkers } from "./headers.mjs";
9
9
 
10
10
  const PROFILE_OVERRIDE_ENV = "OPENCODE_ANTHROPIC_PROFILE_OVERRIDE";
@@ -288,18 +288,208 @@ export async function buildWireCompatibleRequest(body, transport) {
288
288
  try {
289
289
  built = await buildClaudeCodeRequest(toClaudeCodeRequestInput(parsed, transport));
290
290
  } catch (error) {
291
- // The package deliberately sanitizes its errors down to a bare code, and
292
- // carries the useful part in `safeDetails`, which nothing surfaces. Fold it
293
- // into the message so a rejection is diagnosable from a stack trace alone.
294
- // The error object itself is preserved, so `code`, `safeDetails` and
295
- // `instanceof` all still hold for callers that inspect them.
296
- if (error instanceof Error && "safeDetails" in error) {
297
- const detail = Object.entries(error.safeDetails ?? {})
298
- .map(([key, value]) => `${key}=${value}`)
299
- .join(" ");
300
- if (detail) error.message = `${error.message} (${detail})`;
301
- }
302
- throw error;
291
+ throw foldSafeDetailsIntoMessage(error);
303
292
  }
304
293
  return { ...built, headers: new Headers(built.headers) };
305
294
  }
295
+
296
+ /**
297
+ * The subset of `ClaudeCodeRequestInput` keys that `ClaudeCodeCountTokensInput`
298
+ * picks. Kept as data so the pick below stays a single source of truth.
299
+ */
300
+ const COUNT_TOKENS_INPUT_FIELDS = new Set([
301
+ "accessToken",
302
+ "model",
303
+ "messages",
304
+ "tools",
305
+ "runtime",
306
+ "clientRequestId",
307
+ "profileOverride",
308
+ "crypto",
309
+ "app",
310
+ "stainlessRetryCount",
311
+ "stainlessHelper",
312
+ "claudeRemoteContainerId",
313
+ "claudeRemoteSessionId",
314
+ "clientApp",
315
+ "anthropicAdditionalProtection",
316
+ "extraHeaders",
317
+ ]);
318
+
319
+ /**
320
+ * The header names the package composes itself, mirrored from `HEADER_NAMES`
321
+ * (src/headers.ts). Exported so `wire-compat.test.mjs` can pin the mirror
322
+ * against the package's real behaviour rather than trusting this copy.
323
+ */
324
+ export const PACKAGE_CANONICAL_HEADER_NAMES = new Set([
325
+ "anthropic-beta",
326
+ "anthropic-dangerous-direct-browser-access",
327
+ "anthropic-version",
328
+ "authorization",
329
+ "content-type",
330
+ "user-agent",
331
+ "x-app",
332
+ "x-claude-code-session-id",
333
+ "x-client-request-id",
334
+ "x-stainless-arch",
335
+ "x-stainless-lang",
336
+ "x-stainless-os",
337
+ "x-stainless-package-version",
338
+ "x-stainless-retry-count",
339
+ "x-stainless-runtime",
340
+ "x-stainless-runtime-version",
341
+ "x-stainless-timeout",
342
+ "x-stainless-helper",
343
+ "x-claude-remote-container-id",
344
+ "x-claude-remote-session-id",
345
+ "x-client-app",
346
+ "x-anthropic-additional-protection",
347
+ ]);
348
+
349
+ /** Mirror of the package's `FORBIDDEN_HEADER_NAMES` (src/headers.ts). */
350
+ export const PACKAGE_FORBIDDEN_HEADER_NAMES = new Set([
351
+ "x-api-key",
352
+ "cookie",
353
+ "set-cookie",
354
+ "forwarded",
355
+ "content-length",
356
+ "host",
357
+ "connection",
358
+ "transfer-encoding",
359
+ "te",
360
+ "upgrade",
361
+ "keep-alive",
362
+ ]);
363
+
364
+ /** Mirror of the package's `isForbiddenHeader` prefix rules. */
365
+ export const PACKAGE_FORBIDDEN_HEADER_PREFIXES = Object.freeze(["proxy-", "x-forwarded-"]);
366
+
367
+ /**
368
+ * Apply the package's `dropConflicting` extra-header policy PLUGIN-SIDE.
369
+ *
370
+ * WHY THIS EXISTS. `extraHeaderPolicy` is not one of the sixteen keys
371
+ * `ClaudeCodeCountTokensInput` picks, and `validateCountTokensInput` runs
372
+ * `assertExactKeys`, so passing it is `INVALID_INPUT` rather than a no-op. The
373
+ * count surface therefore always resolves extra headers under `strict`, where
374
+ * the FIRST host header the package owns raises `DUPLICATE_HEADER` (canonical
375
+ * name) or `FORBIDDEN_HEADER` (hop-by-hop / credential name) and nothing
376
+ * reaches the wire. The plugin forwards a heterogeneous host header map — the
377
+ * opencode SDK alone sends `content-type` and `accept` — so without this filter
378
+ * every count turn would throw and fall back to nothing at all.
379
+ *
380
+ * The main turn gets the same outcome for free through
381
+ * `extraHeaderPolicy: "dropConflicting"` on the transport, which the count pick
382
+ * discards; this reproduces it. Only OWNERSHIP conflicts are dropped, exactly
383
+ * as the package does: header syntax is still validated package-side and a
384
+ * caller duplicating one of its OWN extra headers still raises
385
+ * `DUPLICATE_HEADER`, because that is a caller bug and not ours to paper over.
386
+ *
387
+ * @param {readonly (readonly [string, string])[] | undefined} extraHeaders
388
+ * @returns {readonly (readonly [string, string])[] | undefined}
389
+ */
390
+ function dropConflictingExtraHeaders(extraHeaders) {
391
+ if (extraHeaders === undefined) return undefined;
392
+ const kept = extraHeaders.filter(([name]) => {
393
+ const normalized = String(name).toLowerCase();
394
+ if (PACKAGE_CANONICAL_HEADER_NAMES.has(normalized)) return false;
395
+ if (PACKAGE_FORBIDDEN_HEADER_NAMES.has(normalized)) return false;
396
+ return !PACKAGE_FORBIDDEN_HEADER_PREFIXES.some((prefix) => normalized.startsWith(prefix));
397
+ });
398
+ return kept.length > 0 ? kept : undefined;
399
+ }
400
+
401
+ /**
402
+ * Map the host body to the shared package's count-tokens input.
403
+ *
404
+ * WHY THIS DELEGATES TO THE FULL MAPPER AND PICKS. The package declares
405
+ * `ClaudeCodeCountTokensInput = Pick<ClaudeCodeRequestInput, ...>` — literally
406
+ * the same field names carrying the same types — so every value the full mapper
407
+ * produces for a retained key is already built under the exact contract the
408
+ * count-tokens surface expects. Picking is therefore safe by construction, and
409
+ * it keeps the host tolerances that DO apply in one place instead of forking
410
+ * them: the dotted->dashed model rewrite, `tools` raising on a malformed value
411
+ * instead of silently dropping it, the stainless marker/transport passthrough,
412
+ * and the profile-override resolution all carry over unchanged.
413
+ *
414
+ * The tolerances that do NOT apply are dropped by the pick rather than reaching
415
+ * the package: upstream derives the count-tokens beta set from the model alone
416
+ * and the count body carries no `system`, `metadata` or `max_tokens`. `system`
417
+ * is stripped BEFORE delegating, because `normalizeSystem` throws on a
418
+ * malformed value and a field that is contractually absent from the count body
419
+ * must not be able to fail the build.
420
+ *
421
+ * @param {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue>} body
422
+ * @param {{
423
+ * accessToken: string,
424
+ * clientRequestId: string,
425
+ * runtime: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRuntimeIdentity,
426
+ * app?: 'cli' | 'cli-bg',
427
+ * stainlessRetryCount?: number,
428
+ * stainlessHelper?: string,
429
+ * claudeRemoteContainerId?: string,
430
+ * claudeRemoteSessionId?: string,
431
+ * clientApp?: string,
432
+ * anthropicAdditionalProtection?: string,
433
+ * extraHeaders?: readonly import('@tormentalabs/claude-code-wire-compat').HeaderPair[],
434
+ * crypto?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRequestInput['crypto'],
435
+ * profileOverride?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride,
436
+ * }} transport
437
+ * @returns {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCountTokensInput}
438
+ */
439
+ export function toClaudeCodeCountTokensInput(body, transport) {
440
+ const { system: _system, ...bodyWithoutSystem } = body;
441
+ const full = toClaudeCodeRequestInput(bodyWithoutSystem, transport);
442
+ const input = Object.fromEntries(Object.entries(full).filter(([name]) => COUNT_TOKENS_INPUT_FIELDS.has(name)));
443
+ const extraHeaders = dropConflictingExtraHeaders(input.extraHeaders);
444
+ if (extraHeaders === undefined) delete input.extraHeaders;
445
+ else input.extraHeaders = extraHeaders;
446
+ if (transport.crypto !== undefined) input.crypto = transport.crypto;
447
+ return input;
448
+ }
449
+
450
+ /**
451
+ * Build a Claude Code `/v1/messages/count_tokens` request from the JSON body
452
+ * received from the host. Mirrors `buildWireCompatibleRequest`, including the
453
+ * deliberate absence of an explicit `profile` argument so the count turn
454
+ * inherits the package's `DEFAULT_PROFILE` exactly like the main turn does.
455
+ *
456
+ * @param {string | undefined} body
457
+ * @param {Parameters<typeof toClaudeCodeCountTokensInput>[1]} transport
458
+ * @returns {Promise<Omit<import('@tormentalabs/claude-code-wire-compat').BuiltClaudeCodeCountTokensRequest, 'headers'> & {headers: Headers}>}
459
+ */
460
+ export async function buildWireCompatibleCountTokensRequest(body, transport) {
461
+ if (!body || typeof body !== "string") throw new TypeError("Expected a JSON request body");
462
+ const parsed = JSON.parse(body);
463
+ // Same rationale as the main turn: `buildAdapterTransport` already derived
464
+ // `transport.stainlessHelper` from these markers, the API has never known the
465
+ // keys, and the package rejects them with INVALID_INPUT. `parsed` is a local
466
+ // object owned by this call, so the strip is in place.
467
+ stripStainlessHelperMarkers(parsed?.tools, parsed?.messages);
468
+ let built;
469
+ try {
470
+ built = await buildClaudeCodeCountTokensRequest(toClaudeCodeCountTokensInput(parsed, transport));
471
+ } catch (error) {
472
+ throw foldSafeDetailsIntoMessage(error);
473
+ }
474
+ return { ...built, headers: new Headers(built.headers) };
475
+ }
476
+
477
+ /**
478
+ * The package deliberately sanitizes its errors down to a bare code, and carries
479
+ * the useful part in `safeDetails`, which nothing surfaces. Fold it into the
480
+ * message so a rejection is diagnosable from a stack trace alone. The error
481
+ * object itself is preserved, so `code`, `safeDetails` and `instanceof` all
482
+ * still hold for callers that inspect them.
483
+ *
484
+ * @param {unknown} error
485
+ * @returns {unknown}
486
+ */
487
+ function foldSafeDetailsIntoMessage(error) {
488
+ if (error instanceof Error && "safeDetails" in error) {
489
+ const detail = Object.entries(error.safeDetails ?? {})
490
+ .map(([key, value]) => `${key}=${value}`)
491
+ .join(" ");
492
+ if (detail) error.message = `${error.message} (${detail})`;
493
+ }
494
+ return error;
495
+ }
@@ -16,7 +16,10 @@
16
16
  // Version / build / SDK constants
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
- export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.195";
19
+ // Tracks the CLI version of the wire-compat package's DEFAULT_PROFILE (see
20
+ // lib/mimicry/adapter-input.mjs PROFILE_CLI_VERSION). Bump both together when
21
+ // the package ships a newer default profile.
22
+ export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.233";
20
23
  export const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
21
24
  // Real build markers extracted from the 2.1.195 native binary
22
25
  // (Bun-embedded JS): `BUILD_TIME:"2026-06-26T01:00:56Z"`,
@@ -38,6 +41,11 @@ export const ANTHROPIC_SDK_VERSION = "0.94.0";
38
41
 
39
42
  // Map of CLI version to bundled SDK version (update when CLI version changes)
40
43
  export const CLI_TO_SDK_VERSION = new Map([
44
+ // 2.1.233: the wire-compat package's DEFAULT_PROFILE
45
+ // (`claude-code-2.1.233-sdk-0.112.1`) declares sdkVersion 0.112.1, so the
46
+ // legacy header path stays consistent with the adapter path when the tracked
47
+ // CLI version is the profile baseline.
48
+ ["2.1.233", "0.112.1"],
41
49
  // 2.1.160-2.1.195: refusal-fallback feature (server-side-fallback /
42
50
  // fallback-credit betas) is opt-in/GrowthBook-gated and never emitted on a
43
51
  // default /v1/messages turn; no wire SDK bump (binary-verified:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,6 +55,6 @@
55
55
  "vitest": "^4.0.18"
56
56
  },
57
57
  "dependencies": {
58
- "@tormentalabs/claude-code-wire-compat": "0.1.0"
58
+ "@tormentalabs/claude-code-wire-compat": "latest"
59
59
  }
60
60
  }