opencode-anthropic-fix 0.4.0 → 0.6.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 {
@@ -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,138 @@ 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
+ "extraHeaderPolicy",
318
+ ]);
319
+
320
+ /**
321
+ * Map the host body to the shared package's count-tokens input.
322
+ *
323
+ * WHY THIS DELEGATES TO THE FULL MAPPER AND PICKS. The package declares
324
+ * `ClaudeCodeCountTokensInput = Pick<ClaudeCodeRequestInput, ...>` — literally
325
+ * the same field names carrying the same types — so every value the full mapper
326
+ * produces for a retained key is already built under the exact contract the
327
+ * count-tokens surface expects. Picking is therefore safe by construction, and
328
+ * it keeps the host tolerances that DO apply in one place instead of forking
329
+ * them: the dotted->dashed model rewrite, `tools` raising on a malformed value
330
+ * instead of silently dropping it, the stainless marker/transport passthrough,
331
+ * and the profile-override resolution all carry over unchanged.
332
+ *
333
+ * The tolerances that do NOT apply are dropped by the pick rather than reaching
334
+ * the package: upstream derives the count-tokens beta set from the model alone
335
+ * and the count body carries no `system`, `metadata` or `max_tokens`. `system`
336
+ * is stripped BEFORE delegating, because `normalizeSystem` throws on a
337
+ * malformed value and a field that is contractually absent from the count body
338
+ * must not be able to fail the build.
339
+ *
340
+ * EXTRA HEADERS. The count surface takes `extraHeaderPolicy` since wire-compat
341
+ * 0.4.0, so the policy is set here exactly as the main turn sets it on the
342
+ * transport (`buildAdapterTransport`). Under the package default (`strict`) the
343
+ * first host header the package owns raises `DUPLICATE_HEADER` (canonical name)
344
+ * or `FORBIDDEN_HEADER` (hop-by-hop / credential name) and nothing reaches the
345
+ * wire — and the plugin forwards a heterogeneous host header map, the opencode
346
+ * SDK alone sending `content-type` and `accept`. `dropConflicting` drops only
347
+ * OWNERSHIP conflicts: header syntax is still validated package-side and a
348
+ * caller duplicating one of its OWN extra headers still raises
349
+ * `DUPLICATE_HEADER`, because that is a caller bug and not ours to paper over.
350
+ * The names the package drops are reported back as
351
+ * `evidence.droppedExtraHeaderNames`.
352
+ *
353
+ * @param {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue>} body
354
+ * @param {{
355
+ * accessToken: string,
356
+ * clientRequestId: string,
357
+ * runtime: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRuntimeIdentity,
358
+ * app?: 'cli' | 'cli-bg',
359
+ * stainlessRetryCount?: number,
360
+ * stainlessHelper?: string,
361
+ * claudeRemoteContainerId?: string,
362
+ * claudeRemoteSessionId?: string,
363
+ * clientApp?: string,
364
+ * anthropicAdditionalProtection?: string,
365
+ * extraHeaders?: readonly import('@tormentalabs/claude-code-wire-compat').HeaderPair[],
366
+ * crypto?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRequestInput['crypto'],
367
+ * profileOverride?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride,
368
+ * }} transport
369
+ * @returns {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCountTokensInput}
370
+ */
371
+ export function toClaudeCodeCountTokensInput(body, transport) {
372
+ const { system: _system, ...bodyWithoutSystem } = body;
373
+ const full = toClaudeCodeRequestInput(bodyWithoutSystem, transport);
374
+ const input = Object.fromEntries(Object.entries(full).filter(([name]) => COUNT_TOKENS_INPUT_FIELDS.has(name)));
375
+ input.extraHeaderPolicy = "dropConflicting";
376
+ if (transport.crypto !== undefined) input.crypto = transport.crypto;
377
+ return input;
378
+ }
379
+
380
+ /**
381
+ * Build a Claude Code `/v1/messages/count_tokens` request from the JSON body
382
+ * received from the host. Mirrors `buildWireCompatibleRequest`, including the
383
+ * deliberate absence of an explicit `profile` argument so the count turn
384
+ * inherits the package's `DEFAULT_PROFILE` exactly like the main turn does.
385
+ *
386
+ * @param {string | undefined} body
387
+ * @param {Parameters<typeof toClaudeCodeCountTokensInput>[1]} transport
388
+ * @returns {Promise<Omit<import('@tormentalabs/claude-code-wire-compat').BuiltClaudeCodeCountTokensRequest, 'headers'> & {headers: Headers}>}
389
+ */
390
+ export async function buildWireCompatibleCountTokensRequest(body, transport) {
391
+ if (!body || typeof body !== "string") throw new TypeError("Expected a JSON request body");
392
+ const parsed = JSON.parse(body);
393
+ // Same rationale as the main turn: `buildAdapterTransport` already derived
394
+ // `transport.stainlessHelper` from these markers, the API has never known the
395
+ // keys, and the package rejects them with INVALID_INPUT. `parsed` is a local
396
+ // object owned by this call, so the strip is in place.
397
+ stripStainlessHelperMarkers(parsed?.tools, parsed?.messages);
398
+ let built;
399
+ try {
400
+ built = await buildClaudeCodeCountTokensRequest(toClaudeCodeCountTokensInput(parsed, transport));
401
+ } catch (error) {
402
+ throw foldSafeDetailsIntoMessage(error);
403
+ }
404
+ return { ...built, headers: new Headers(built.headers) };
405
+ }
406
+
407
+ /**
408
+ * The package deliberately sanitizes its errors down to a bare code, and carries
409
+ * the useful part in `safeDetails`, which nothing surfaces. Fold it into the
410
+ * message so a rejection is diagnosable from a stack trace alone. The error
411
+ * object itself is preserved, so `code`, `safeDetails` and `instanceof` all
412
+ * still hold for callers that inspect them.
413
+ *
414
+ * @param {unknown} error
415
+ * @returns {unknown}
416
+ */
417
+ function foldSafeDetailsIntoMessage(error) {
418
+ if (error instanceof Error && "safeDetails" in error) {
419
+ const detail = Object.entries(error.safeDetails ?? {})
420
+ .map(([key, value]) => `${key}=${value}`)
421
+ .join(" ");
422
+ if (detail) error.message = `${error.message} (${detail})`;
423
+ }
424
+ return error;
425
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",