opencode-anthropic-fix 0.6.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.
- package/README.md +30 -3
- package/index.mjs +271 -184
- package/lib/betas.mjs +131 -0
- package/lib/diagnose.mjs +4 -2
- package/lib/mimicry/adapter-input.mjs +131 -29
- package/lib/mimicry/headers.mjs +143 -15
- package/lib/mimicry/request-body.mjs +51 -14
- package/lib/mimicry/system-prompt.mjs +72 -293
- package/lib/mimicry/wire-compat.mjs +133 -11
- package/lib/oauth.mjs +3 -2
- package/lib/passthrough-headers.mjs +152 -0
- package/package.json +1 -1
- package/lib/mimicry/models.mjs +0 -146
- package/lib/request-headers.mjs +0 -303
|
@@ -4,9 +4,124 @@
|
|
|
4
4
|
* NOT promised because the returned HeaderPair array is converted to Headers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
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 —
|
|
67
|
-
* — so a host can legitimately hand us
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* is
|
|
73
|
-
*
|
|
74
|
-
* `
|
|
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
|
|
77
|
-
*
|
|
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
|
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
|
-
//
|
|
27
|
-
//
|
|
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
package/lib/mimicry/models.mjs
DELETED
|
@@ -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
|
-
}
|