opencode-anthropic-fix 0.5.0 → 1.0.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.
@@ -4,9 +4,124 @@
4
4
  * NOT promised because the returned HeaderPair array is converted to Headers.
5
5
  */
6
6
 
7
- import { buildClaudeCodeRequest, buildClaudeCodeCountTokensRequest } from "@tormentalabs/claude-code-wire-compat";
7
+ import {
8
+ BETA_REGISTRY_2_1_233,
9
+ buildClaudeCodeRequest,
10
+ buildClaudeCodeCountTokensRequest,
11
+ CLAUDE_CODE_2_1_233_PROFILE,
12
+ hasOneMillionContext,
13
+ isAdaptiveThinkingModel,
14
+ isClaude3Model,
15
+ isEligibleFor1MContext,
16
+ isFable5Model,
17
+ isHaikuModel,
18
+ isMythos5Model,
19
+ isOpus46Model,
20
+ isOpus47Model,
21
+ isOpus48Model,
22
+ supportsStructuredOutputs,
23
+ supportsWebSearch,
24
+ } from "@tormentalabs/claude-code-wire-compat";
8
25
  import { stripStainlessHelperMarkers } from "./headers.mjs";
9
26
 
27
+ /**
28
+ * Model-family predicates, re-exported so the rest of the tree classifies models
29
+ * through the SAME catalogue the request builder classifies them with.
30
+ *
31
+ * These used to be hand-written regexes in `lib/mimicry/models.mjs`, maintained
32
+ * in parallel with the package's model catalogue and therefore free to drift
33
+ * from it: a request could be composed as an adaptive-thinking model by the
34
+ * package while the plugin's own gating said otherwise. Sourcing both sides from
35
+ * one place removes that whole class of divergence. The behavioural differences
36
+ * this migration introduces are pinned, row by row, in
37
+ * `test/conformance/model-predicates-parity.test.mjs`.
38
+ *
39
+ * `isEligibleFor1MContext` is deliberately NOT re-exported raw — see
40
+ * `isEligibleFor1MContextWire` below.
41
+ *
42
+ * The CAPABILITY predicates (`isHaikuModel`, `supportsStructuredOutputs`,
43
+ * `supportsWebSearch`) join them for the same reason. `lib/mimicry/headers.mjs`
44
+ * still declares its own local copies, but those now feed ONLY the frozen legacy
45
+ * header forge; every adapter-path decision reads the package's, which are
46
+ * ported from the genuine client's own capability table rather than guessed from
47
+ * a regex. The one measured behavioural difference —
48
+ * `supportsStructuredOutputs("claude-haiku-4-5")` is `true` upstream and was
49
+ * `false` under the plugin's "haiku never supports it" heuristic — is a mimicry
50
+ * fidelity FIX, and is pinned by the migration harness baseline.
51
+ */
52
+ export {
53
+ hasOneMillionContext,
54
+ isAdaptiveThinkingModel,
55
+ isClaude3Model,
56
+ isFable5Model,
57
+ isHaikuModel,
58
+ isMythos5Model,
59
+ isOpus46Model,
60
+ isOpus47Model,
61
+ isOpus48Model,
62
+ supportsStructuredOutputs,
63
+ supportsWebSearch,
64
+ };
65
+
66
+ /**
67
+ * The genuine client's beta registry for the profile the plugin emulates,
68
+ * re-exported so the host's own beta tables can be RECONCILED against it
69
+ * without a second import point.
70
+ *
71
+ * `lib/betas.mjs` is host policy — what the plugin adds or suppresses on top of
72
+ * what the package composes — and it names beta headers as literal strings. A
73
+ * literal is exactly the thing that rots when upstream bumps a beta's date, and
74
+ * nothing used to notice. `lib/betas.test.mjs` now walks this registry and fails
75
+ * on a header that has drifted from it, so the rot is caught at test time rather
76
+ * than on the wire.
77
+ *
78
+ * Shape: `{ KEY: { featureKey, header } }`, deep-frozen by the package. It is
79
+ * pinned to 2.1.233 rather than derived from `WIRE_PROFILE` because the package
80
+ * exports no profile->registry accessor; if the emulated baseline moves, this
81
+ * binding and `WIRE_PROFILE` move together.
82
+ */
83
+ export { BETA_REGISTRY_2_1_233 };
84
+
85
+ /**
86
+ * 1M-context eligibility under the profile the plugin actually emulates.
87
+ *
88
+ * Unlike its siblings, the package's `isEligibleFor1MContext` is
89
+ * PROFILE-DEPENDENT: its second parameter selects the model catalogue, and it
90
+ * defaults to the package's own baseline (2.1.195), not to ours. The two
91
+ * disagree in practice — `claude-mythos-5` is ineligible under 2.1.195 and
92
+ * eligible under 2.1.233 — so calling it bare would gate the plugin's context
93
+ * decisions on a profile the plugin does not emulate anywhere else.
94
+ *
95
+ * This wrapper binds `WIRE_PROFILE`, so eligibility follows the emulated
96
+ * profile automatically if that baseline is ever bumped.
97
+ *
98
+ * @param {string | undefined} model
99
+ * @returns {boolean}
100
+ */
101
+ export function isEligibleFor1MContextWire(model) {
102
+ return isEligibleFor1MContext(model, WIRE_PROFILE);
103
+ }
104
+
105
+ /**
106
+ * The protocol profile the plugin composes against, re-exported so the rest of
107
+ * the tree can read the baseline CLI version WITHOUT importing the package.
108
+ *
109
+ * This module is the single seam through which
110
+ * `@tormentalabs/claude-code-wire-compat` enters the plugin (pinned by
111
+ * `test/conformance/package-dependency-policy.test.mjs`), so a consumer that
112
+ * needs `cliVersion` — the startup value of `claudeCliVersion` in index.mjs, the
113
+ * `mimicryBaseline` reported by `lib/diagnose.mjs` — reads it from here instead
114
+ * of re-declaring a hand-maintained copy.
115
+ *
116
+ * NOTE: this is a BASELINE, not the profile the request path uses. The
117
+ * composition path deliberately passes no explicit `profile` so it inherits the
118
+ * package's `DEFAULT_PROFILE` (see `lib/mimicry/adapter-input.mjs`); naming a
119
+ * profile here only pins what the plugin reports about itself.
120
+ *
121
+ * @type {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProtocolProfile}
122
+ */
123
+ export const WIRE_PROFILE = CLAUDE_CODE_2_1_233_PROFILE;
124
+
10
125
  const PROFILE_OVERRIDE_ENV = "OPENCODE_ANTHROPIC_PROFILE_OVERRIDE";
11
126
 
12
127
  const BODY_FIELD_NAMES = new Set([
@@ -63,18 +178,25 @@ function normalizeSystem(system) {
63
178
  * `normalizeModelId` (dist/model-identity.js) tests `includes("claude-opus-4-7")`
64
179
  * and never the dotted spelling, because the genuine Claude Code client emits
65
180
  * dashed ids. The plugin, by contrast, deliberately TOLERATES dotted ids
66
- * throughout — `lib/mimicry/models.mjs` matches with the `[._-]` character class
67
- * — so a host can legitimately hand us `claude-opus-4.7`.
181
+ * throughout — the package's own family predicates re-exported above accept
182
+ * `claude-opus-4.7` — so a host can legitimately hand us that spelling.
183
+ *
184
+ * WHY IT SURVIVED THE PACKAGE MIGRATION. The consolidation plan predicted this
185
+ * rewrite could be deleted once the family predicates came from the package,
186
+ * on the premise that the package normalizes the id for us. Measured against
187
+ * the installed package, that premise is WRONG, and the distinction is:
68
188
  *
69
- * Passing a dotted id straight through MISCLASSIFIES it. For
70
- * `claude-opus-4.7` no `includes` branch matches, so the fallback
71
- * `/claude-opus-4(?!-\d(?!\d))/` matches (the character after `claude-opus-4`
72
- * is `.`, not `-`) and the model resolves to `claude-opus-4-0`. That silently
73
- * drops `adaptiveThinking`, `supportsEffort`, `supportsMaxEffort` and
74
- * `supportsXhighEffort`, and wrongly turns `supportsTemperature` ON.
189
+ * - DECISIONS are dotted-tolerant. The package's own predicates classify
190
+ * `claude-opus-4.7` correctly (pinned as data in
191
+ * `test/conformance/model-predicates-parity.test.mjs`).
192
+ * - The BODY is not normalized. The package copies the caller's model
193
+ * through VERBATIM apart from stripping `[1m]`-style markers, so feeding
194
+ * it `claude-opus-4.7` bare yields `body.model === "claude-opus-4.7"`.
75
195
  *
76
- * The tolerance belongs to the plugin, so it is spent here and the package
77
- * receives the dashed id it is built for. The rewrite is deliberately narrow:
196
+ * The real API only accepts the dashed spelling, so dropping this rewrite
197
+ * would put an id on the wire that Anthropic rejects. The tolerance therefore
198
+ * belongs to the plugin, is spent here, and the package receives the dashed id
199
+ * it is built for. The rewrite is deliberately narrow:
78
200
  * a dot is only replaced when it FOLLOWS a letter or digit and PRECEDES a
79
201
  * digit, i.e. when it is a version separator (`claude-opus-4.7`,
80
202
  * `claude-3.5-sonnet`). A dot before a letter is left alone, so vendor
@@ -314,90 +436,9 @@ const COUNT_TOKENS_INPUT_FIELDS = new Set([
314
436
  "clientApp",
315
437
  "anthropicAdditionalProtection",
316
438
  "extraHeaders",
439
+ "extraHeaderPolicy",
317
440
  ]);
318
441
 
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
442
  /**
402
443
  * Map the host body to the shared package's count-tokens input.
403
444
  *
@@ -418,6 +459,19 @@ function dropConflictingExtraHeaders(extraHeaders) {
418
459
  * malformed value and a field that is contractually absent from the count body
419
460
  * must not be able to fail the build.
420
461
  *
462
+ * EXTRA HEADERS. The count surface takes `extraHeaderPolicy` since wire-compat
463
+ * 0.4.0, so the policy is set here exactly as the main turn sets it on the
464
+ * transport (`buildAdapterTransport`). Under the package default (`strict`) the
465
+ * first host header the package owns raises `DUPLICATE_HEADER` (canonical name)
466
+ * or `FORBIDDEN_HEADER` (hop-by-hop / credential name) and nothing reaches the
467
+ * wire — and the plugin forwards a heterogeneous host header map, the opencode
468
+ * SDK alone sending `content-type` and `accept`. `dropConflicting` drops only
469
+ * OWNERSHIP conflicts: header syntax is still validated package-side and a
470
+ * caller duplicating one of its OWN extra headers still raises
471
+ * `DUPLICATE_HEADER`, because that is a caller bug and not ours to paper over.
472
+ * The names the package drops are reported back as
473
+ * `evidence.droppedExtraHeaderNames`.
474
+ *
421
475
  * @param {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue>} body
422
476
  * @param {{
423
477
  * accessToken: string,
@@ -440,9 +494,7 @@ export function toClaudeCodeCountTokensInput(body, transport) {
440
494
  const { system: _system, ...bodyWithoutSystem } = body;
441
495
  const full = toClaudeCodeRequestInput(bodyWithoutSystem, transport);
442
496
  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;
497
+ input.extraHeaderPolicy = "dropConflicting";
446
498
  if (transport.crypto !== undefined) input.crypto = transport.crypto;
447
499
  return input;
448
500
  }
package/lib/oauth.mjs CHANGED
@@ -23,8 +23,9 @@ const OAUTH_ACCEPT = "application/json, text/plain, */*";
23
23
  // this fingerprint: `User-Agent: anthropic-sdk-typescript/0.94.0
24
24
  // userOAuthProvider`, `anthropic-beta: oauth-2025-04-20`, JSON body, and NO
25
25
  // axios `Accept` header (native fetch sends no default Accept). The SDK version
26
- // mirrors request-headers.mjs `ANTHROPIC_SDK_VERSION` ("0.94.0"); hardcoded here
27
- // to avoid coupling the OAuth path to the messages-header module.
26
+ // is the one real Claude Code 2.1.195 shipped ("0.94.0"); it is hardcoded here
27
+ // so the OAuth token path stays decoupled from the wire seam
28
+ // (lib/mimicry/wire-compat.mjs), which only describes /v1/messages traffic.
28
29
  const OAUTH_SDK_VERSION = "0.94.0";
29
30
  const OAUTH_SDK_USER_AGENT = `anthropic-sdk-typescript/${OAUTH_SDK_VERSION} userOAuthProvider`;
30
31
  const OAUTH_SDK_BETA = "oauth-2025-04-20";
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The emulation-OFF request envelope.
3
+ *
4
+ * WHY THIS MODULE IS NOT UNDER lib/mimicry/. That is the point of it. With
5
+ * `signature_emulation.enabled === false` the plugin makes NO attempt to look
6
+ * like Claude Code, so no mimicry function may compose the outgoing headers —
7
+ * if one did, "off" would keep meaning "less mimicry" instead of "none", which
8
+ * is exactly the half-state Phase 2.2 removed.
9
+ *
10
+ * WHAT SURVIVES, AND WHY IT IS NOT MIMICRY. The plugin is an OAuth transport:
11
+ * it owns the AUTH ENVELOPE and nothing else.
12
+ *
13
+ * - `authorization` — the selected account's bearer. Without it there is no
14
+ * request; account rotation is the plugin's entire reason to exist.
15
+ * - `anthropic-beta` — ADDITIVE, never substitutive. The host's value is
16
+ * preserved verbatim and `oauth-2025-04-20` is appended when missing. That
17
+ * beta is a CONTRACT of the OAuth token, not a fingerprint: the API rejects
18
+ * an OAuth bearer without it (docs/claude-code-reverse-engineering.md §14.2
19
+ * item 3 and §14.3, both listing it as MUST for OAuth). Forging the rest of
20
+ * the beta list, or replacing the host's, would be mimicry — and is gone.
21
+ * - `x-api-key`, `x-session-affinity` — REMOVED. The first is a competing
22
+ * credential that would travel next to our bearer; the second is an opencode
23
+ * SDK routing hint that leaks session identity upstream. Both are envelope
24
+ * hygiene, not disguise.
25
+ *
26
+ * Everything else the host sent — `user-agent` above all — goes out untouched.
27
+ */
28
+
29
+ import { stripStainlessHelperMarkers } from "./mimicry/headers.mjs";
30
+
31
+ /** The beta the Anthropic API requires alongside an OAuth bearer. */
32
+ export const OAUTH_BETA_FLAG = "oauth-2025-04-20";
33
+
34
+ /**
35
+ * Credentials and routing hints that must not travel with our bearer.
36
+ * @see the module comment
37
+ */
38
+ const STRIPPED_HOST_HEADERS = ["x-api-key", "x-session-affinity"];
39
+
40
+ /**
41
+ * Copy the host's headers, from either carrier, into one mutable set.
42
+ *
43
+ * @param {Request | URL | string | undefined} input
44
+ * @param {RequestInit} requestInit
45
+ * @returns {Headers}
46
+ */
47
+ function collectHostHeaders(input, requestInit) {
48
+ const headers = new Headers();
49
+
50
+ if (input instanceof Request) {
51
+ input.headers.forEach((value, name) => headers.set(name, value));
52
+ }
53
+
54
+ const initHeaders = requestInit?.headers;
55
+ if (initHeaders instanceof Headers) {
56
+ initHeaders.forEach((value, name) => headers.set(name, value));
57
+ } else if (Array.isArray(initHeaders)) {
58
+ for (const [name, value] of initHeaders) {
59
+ if (typeof value !== "undefined") headers.set(name, String(value));
60
+ }
61
+ } else if (initHeaders && typeof initHeaders === "object") {
62
+ for (const [name, value] of Object.entries(initHeaders)) {
63
+ if (typeof value !== "undefined") headers.set(name, String(value));
64
+ }
65
+ }
66
+
67
+ return headers;
68
+ }
69
+
70
+ /**
71
+ * Build the outgoing headers for a request that carries NO Claude Code
72
+ * emulation: the host's own headers plus the auth envelope.
73
+ *
74
+ * @param {Request | URL | string | undefined} input The host's fetch input.
75
+ * @param {RequestInit} requestInit The host's fetch init.
76
+ * @param {string} accessToken The selected account's access token.
77
+ * @returns {Headers}
78
+ */
79
+ export function buildPassthroughHeaders(input, requestInit, accessToken) {
80
+ const headers = collectHostHeaders(input, requestInit);
81
+
82
+ for (const name of STRIPPED_HOST_HEADERS) headers.delete(name);
83
+
84
+ // ANTHROPIC_AUTH_TOKEN is the documented manual-bearer escape hatch and
85
+ // belongs to the same envelope: it selects WHICH token authenticates, not how
86
+ // the request looks.
87
+ const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
88
+ headers.set("authorization", `Bearer ${authTokenOverride || accessToken}`);
89
+
90
+ const hostBetas = (headers.get("anthropic-beta") || "")
91
+ .split(",")
92
+ .map((beta) => beta.trim())
93
+ .filter(Boolean);
94
+ if (!hostBetas.includes(OAUTH_BETA_FLAG)) hostBetas.push(OAUTH_BETA_FLAG);
95
+ headers.set("anthropic-beta", hostBetas.join(","));
96
+
97
+ return headers;
98
+ }
99
+
100
+ /**
101
+ * Remove the body fields the Anthropic API does not accept, and nothing else.
102
+ *
103
+ * THE ONLY BODY EDITS LEFT WITH EMULATION OFF. Every structural normalization
104
+ * `transformRequestBody` performs (output cap, thinking, effort ->
105
+ * output_config, system sanitize/compact, metadata, cache breakpoints) is
106
+ * skipped on this path — the host's body goes out as the host wrote it. These
107
+ * two strips are the exception, on the same grounds as the auth envelope: they
108
+ * are what keeps the request VALID, not what makes it look like Claude Code.
109
+ * Both were already applied unconditionally with emulation off, so keeping them
110
+ * preserves a working request rather than adding policy.
111
+ *
112
+ * - `betas` — never a first-party field. It existed for Bedrock, which cannot
113
+ * forward custom HTTP headers; the first-party API answers "Extra inputs are
114
+ * not permitted".
115
+ * - the stainless-helper markers — a HOST-side signal (opencode tags a tool or
116
+ * message so the plugin can derive `x-stainless-helper`). The plugin never
117
+ * injects them and, with emulation off, never derives the header either; the
118
+ * API has never known the keys and rejects them inside a tool definition.
119
+ * The marker list lives in lib/mimicry/headers.mjs and is imported rather
120
+ * than copied: the boundary rule this module enforces is that no mimicry
121
+ * function COMPOSES a fingerprint here, and removing a field the API rejects
122
+ * is the exact inverse of that.
123
+ *
124
+ * When neither is present the ORIGINAL string is returned, byte for byte: a
125
+ * re-serialization would reorder nothing today but would make the passthrough
126
+ * claim untrue tomorrow.
127
+ *
128
+ * @param {unknown} body
129
+ * @returns {unknown} The body, with the non-API fields removed if present.
130
+ */
131
+ export function stripNonApiBodyFields(body) {
132
+ if (typeof body !== "string" || body.length === 0) return body;
133
+
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(body);
137
+ } catch {
138
+ // Not JSON — there is no field to strip, and rewriting is not our business.
139
+ return body;
140
+ }
141
+
142
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return body;
143
+
144
+ let changed = false;
145
+ if (Object.hasOwn(parsed, "betas")) {
146
+ delete parsed.betas;
147
+ changed = true;
148
+ }
149
+ if (stripStainlessHelperMarkers(parsed.tools, parsed.messages) > 0) changed = true;
150
+
151
+ return changed ? JSON.stringify(parsed) : body;
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.5.0",
3
+ "version": "1.0.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,146 +0,0 @@
1
- import { isTruthyEnv } from "../env.mjs";
2
-
3
- // Perf: module-scope regex reused across per-request hot paths. It does not use
4
- // the `/g` flag and is consumed via `.test()`, so the shared instance is
5
- // stateless and safe (no `lastIndex` to reset between calls).
6
- export const CLAUDE_3_MODEL_RE = /claude-3-/i;
7
-
8
- /**
9
- * Detects claude-opus-4.6 / claude-opus-4-6 model IDs.
10
- * These models use adaptive thinking (effort parameter) instead of
11
- * manual budgetTokens.
12
- * @param {string | undefined} model
13
- * @returns {boolean}
14
- */
15
- export function isOpus46Model(model) {
16
- if (!model) return false;
17
- // Match standard IDs (claude-opus-4-6, claude-opus-4.6) and Bedrock ARNs
18
- // (arn:aws:bedrock:...anthropic.claude-opus-4-6-...).
19
- // Also match bare "opus-4-6" / "opus-4.6" fragments for non-standard strings.
20
- return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
21
- }
22
-
23
- /**
24
- * Detects claude-opus-4.7 / claude-opus-4-7 model IDs.
25
- * @param {string | undefined} model
26
- * @returns {boolean}
27
- */
28
- export function isOpus47Model(model) {
29
- if (!model) return false;
30
- return /claude-opus-4[._-]7|opus[._-]4[._-]7/i.test(model);
31
- }
32
-
33
- /**
34
- * Detects claude-opus-4.8 / claude-opus-4-8 model IDs.
35
- * Opus 4.8 (launched 2026-05-28) is an adaptive-thinking model: manual
36
- * `thinking: {type: "enabled", budget_tokens}` returns a 400 — it MUST use
37
- * `thinking: {type: "adaptive"}` + the effort parameter. It also supports
38
- * `speed: "fast"` (fast-mode research preview) and 1M context by default.
39
- * @param {string | undefined} model
40
- * @returns {boolean}
41
- */
42
- export function isOpus48Model(model) {
43
- if (!model) return false;
44
- return /claude-opus-4[._-]8|opus[._-]4[._-]8/i.test(model);
45
- }
46
-
47
- /**
48
- * Check if a model is eligible for 1M context (can receive context-1m beta).
49
- * Real CC v2.1.97 U01(): claude-sonnet-4* || opus-4-6 are eligible.
50
- * Also matches explicit "1m" in the name (e.g. "claude-opus-4-6[1m]").
51
- * @param {string} model
52
- * @returns {boolean}
53
- */
54
- export function isEligibleFor1MContext(model) {
55
- if (!model) return false;
56
- if (/(^|[-_ ])1m($|[-_ ])|context[-_]?1m|\[1m\]/i.test(model)) return true;
57
- return (
58
- /claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model) || isOpus47Model(model) || isOpus48Model(model)
59
- );
60
- }
61
-
62
- /**
63
- * Check if a model should ALWAYS use 1M context (static mode, no adaptive gating).
64
- * Only models with explicit "1m" in the name — NOT bare Opus 4.6.
65
- * When adaptive_context is enabled, Opus 4.6 uses the adaptive decision instead.
66
- * @param {string} model
67
- * @returns {boolean}
68
- */
69
- export function hasOneMillionContext(model) {
70
- return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model);
71
- }
72
-
73
- /**
74
- * Detects claude-sonnet-4.6 / claude-sonnet-4-6 model IDs.
75
- * @param {string | undefined} model
76
- * @returns {boolean}
77
- */
78
- export function isSonnet46Model(model) {
79
- if (!model) return false;
80
- return /claude-sonnet-4[._-]6|sonnet[._-]4[._-]6/i.test(model);
81
- }
82
-
83
- /**
84
- * @param {string | undefined} model
85
- * @returns {boolean}
86
- */
87
- export function isFable5Model(model) {
88
- if (!model) return false;
89
- return /claude-fable-5|fable[._-]5/i.test(model);
90
- }
91
-
92
- /**
93
- * @param {string | undefined} model
94
- * @returns {boolean}
95
- */
96
- export function isMythos5Model(model) {
97
- if (!model) return false;
98
- return /claude-mythos-5|mythos[._-]5/i.test(model);
99
- }
100
-
101
- /**
102
- * Detects models that support adaptive thinking ({type: "adaptive"}).
103
- * Currently: Opus 4.6, Opus 4.7, Opus 4.8, Sonnet 4.6, Fable 5, and Mythos 5.
104
- * @param {string | undefined} model
105
- * @returns {boolean}
106
- */
107
- export function isAdaptiveThinkingModel(model) {
108
- return (
109
- isOpus46Model(model) ||
110
- isOpus47Model(model) ||
111
- isOpus48Model(model) ||
112
- isSonnet46Model(model) ||
113
- isFable5Model(model) ||
114
- isMythos5Model(model)
115
- );
116
- }
117
-
118
- /**
119
- * @param {any} thinking
120
- * @param {string} model
121
- * @returns {any}
122
- */
123
- export function normalizeThinkingBlock(thinking, model) {
124
- // If thinking is absent or not an object, pass through
125
- if (!thinking || typeof thinking !== "object") {
126
- return thinking;
127
- }
128
-
129
- // Adaptive thinking models always get { type: "adaptive" }
130
- // regardless of what format the incoming thinking block has
131
- if (isAdaptiveThinkingModel(model)) {
132
- // Check for env-var override to force budget_tokens fallback
133
- if (isTruthyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING)) {
134
- // Fallback: return as-is if already budget_tokens shape, otherwise default
135
- if (thinking.type === "enabled" && typeof thinking.budget_tokens === "number") {
136
- return thinking;
137
- }
138
- const parsedBudget = parseInt(process.env.MAX_THINKING_TOKENS, 10);
139
- return { type: "enabled", budget_tokens: Number.isNaN(parsedBudget) ? 16000 : parsedBudget };
140
- }
141
- return { type: "adaptive" };
142
- }
143
-
144
- // Non-adaptive models: pass through unchanged
145
- return thinking;
146
- }