opencode-anthropic-fix 2.0.0 → 2.1.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.
@@ -30,7 +30,7 @@ import {
30
30
  supportsStructuredOutputs,
31
31
  supportsWebSearch,
32
32
  WIRE_PROFILE,
33
- BETA_REGISTRY_2_1_233,
33
+ BETA_REGISTRY_2_1_280,
34
34
  } from "./wire-compat.mjs";
35
35
  import { BETA_SHORTCUTS, EXPERIMENTAL_BETA_FLAGS } from "../betas.mjs";
36
36
  import { resolveCacheTtl } from "./cache.mjs";
@@ -149,7 +149,145 @@ export const MAX_SUPPRESS_BETAS = 32;
149
149
  * registry through the seam rather than re-typed here: if the package ever
150
150
  * re-dates the beta, the suppression keeps matching what the package composes.
151
151
  */
152
- export const PROMPT_CACHING_SCOPE_BETA = BETA_REGISTRY_2_1_233.PROMPT_CACHING_SCOPE.header;
152
+ export const PROMPT_CACHING_SCOPE_BETA = BETA_REGISTRY_2_1_280.PROMPT_CACHING_SCOPE.header;
153
+
154
+ /**
155
+ * Betas the session-rejected-beta latch may NEVER suppress, whatever an error
156
+ * body names. `suppressBetas` is unguarded package-side ("Suppressing
157
+ * `oauth-2025-04-20` produces a request the API rejects with 401"), so the guard
158
+ * lives here: OAuth first, and the Claude Code identity beta stays on the wire.
159
+ * Applies only to the rejected-beta input, never to the policy suppressions.
160
+ */
161
+ export const UNSUPPRESSIBLE_BETAS = new Set([BETA_REGISTRY_2_1_280.OAUTH_AUTH.header, CLAUDE_CODE_BETA_FLAG]);
162
+
163
+ /**
164
+ * Betas whose presence is COUPLED to a request-body field, so the rejected-beta
165
+ * latch must never suppress them: `suppressBetas` only filters the header, and
166
+ * the package (or the plugin's own body transform) still writes the paired body
167
+ * field, producing a request that is neither genuine nor valid. For latching
168
+ * purposes they are treated like `UNSUPPRESSIBLE_BETAS`: a rejection naming one
169
+ * surfaces as before. Headers are read from the package registry through the
170
+ * seam. The pairings, verified against wire-compat 0.7.0 `dist/betas.js` and
171
+ * `dist/request-body.js` (plus lib/mimicry/request-body.mjs for task budgets):
172
+ *
173
+ * - thinking-display-updates -> `thinking.display: "updates"` (site 12b pair)
174
+ * - context-management -> `context_management`
175
+ * - effort -> `output_config.effort`
176
+ * - structured-outputs -> `output_format`
177
+ * - fast-mode (SPEED) -> `speed`
178
+ * - task-budgets -> `output_config` (plugin body transform)
179
+ * - context-hint -> `context_hint`
180
+ * - extended-cache-ttl -> `cache_control.ttl: "1h"` (package `cacheTtl`)
181
+ * - files-api -> file references in `messages`
182
+ * - mcp-servers -> `mcp_servers` (experimental body passthrough)
183
+ *
184
+ * `thinking-binding-controls` is NOT listed: the package emits it header-only
185
+ * (its `thinking.block_binding` field is never written).
186
+ */
187
+ export const BODY_COUPLED_BETAS = new Set([
188
+ BETA_REGISTRY_2_1_280.THINKING_DISPLAY_UPDATES.header,
189
+ BETA_REGISTRY_2_1_280.CONTEXT_MANAGEMENT.header,
190
+ BETA_REGISTRY_2_1_280.EFFORT.header,
191
+ BETA_REGISTRY_2_1_280.STRUCTURED_OUTPUTS.header,
192
+ BETA_REGISTRY_2_1_280.SPEED.header,
193
+ BETA_REGISTRY_2_1_280.TASK_BUDGETS.header,
194
+ BETA_REGISTRY_2_1_280.CONTEXT_HINT.header,
195
+ BETA_REGISTRY_2_1_280.EXTENDED_CACHE_TTL.header,
196
+ BETA_REGISTRY_2_1_280.FILES_API.header,
197
+ BETA_REGISTRY_2_1_280.MCP_SERVERS.header,
198
+ ]);
199
+
200
+ /**
201
+ * Upper bound on sent betas one rejection may latch. The API names the one or
202
+ * two identifiers it refused; more matches than this means the error echoed the
203
+ * whole header back, and latching from it would strip betas that were fine.
204
+ */
205
+ export const MAX_LATCHABLE_REJECTED_BETAS = 2;
206
+
207
+ /** The package's `suppressBetas` entry grammar; anything else is `INVALID_INPUT`. */
208
+ const BETA_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
209
+ const BETA_IDENTIFIER_MAX_LENGTH = 128;
210
+
211
+ /**
212
+ * The API's invalid-beta message shape:
213
+ * "Unexpected value(s) `a`, `b` for the `anthropic-beta` header. ...".
214
+ * Group 1 is the value list; quoting varies (backtick / single / double).
215
+ * The separators are literal single spaces, not `\s+`: two `\s+` runs around a
216
+ * lazy group backtrack catastrophically on long whitespace (ReDoS).
217
+ */
218
+ const INVALID_BETA_MESSAGE_PATTERN = /Unexpected value\(s\) (.*?) for the [`'"]?anthropic-beta[`'"]? header/i;
219
+ /**
220
+ * Only this much of the message is matched. The API's invalid-beta sentence
221
+ * sits at the start and is far shorter; the cap bounds the lazy scan above on
222
+ * a hostile or runaway body (the pattern is linear per start position, and
223
+ * the cap keeps start positions x scan length small).
224
+ */
225
+ const INVALID_BETA_MESSAGE_SCAN_LIMIT = 2048;
226
+ const QUOTED_BETA_PATTERN = /[`'"]([A-Za-z0-9][A-Za-z0-9._-]*)[`'"]/g;
227
+
228
+ /**
229
+ * Extract the beta identifiers an Anthropic invalid-beta error names. Only the
230
+ * specific "Unexpected value(s) ... for the `anthropic-beta` header" shape is
231
+ * recognised, read from `error.message` when the body is the JSON error
232
+ * envelope (the raw text otherwise). Any other wording yields `[]`, so a body
233
+ * that merely mentions a beta name elsewhere never drives the latch.
234
+ *
235
+ * @param {string | null | undefined} errorBody Raw response body text.
236
+ * @returns {string[]} Named identifiers, in message order, deduplicated.
237
+ */
238
+ export function parseRejectedBetaNames(errorBody) {
239
+ if (typeof errorBody !== "string" || !errorBody) return [];
240
+ let message = errorBody;
241
+ try {
242
+ const parsed = JSON.parse(errorBody);
243
+ if (typeof parsed?.error?.message === "string") message = parsed.error.message;
244
+ } catch {
245
+ // Not JSON: match against the raw text.
246
+ }
247
+ const match = INVALID_BETA_MESSAGE_PATTERN.exec(message.slice(0, INVALID_BETA_MESSAGE_SCAN_LIMIT));
248
+ if (!match) return [];
249
+ const list = match[1];
250
+ const names = [...list.matchAll(QUOTED_BETA_PATTERN)].map((m) => m[1]);
251
+ const candidates = names.length > 0 ? names : list.split(/[\s,]+/);
252
+ return [
253
+ ...new Set(
254
+ candidates.filter(
255
+ (name) => name && name.length <= BETA_IDENTIFIER_MAX_LENGTH && BETA_IDENTIFIER_PATTERN.test(name),
256
+ ),
257
+ ),
258
+ ];
259
+ }
260
+
261
+ /**
262
+ * Decide which betas of a rejected request the latch may suppress.
263
+ *
264
+ * `namedSent` is every identifier on the failing request's `anthropic-beta`
265
+ * header that the invalid-beta message names (see `parseRejectedBetaNames`),
266
+ * UNSUPPRESSIBLE ones included; it is empty when the message is not that
267
+ * shape or when it looks like an echoed header (more than
268
+ * `MAX_LATCHABLE_REJECTED_BETAS` suppressible matches). `latchable` is
269
+ * `namedSent` minus `UNSUPPRESSIBLE_BETAS` and `BODY_COUPLED_BETAS`.
270
+ *
271
+ * @param {string | null | undefined} sentHeader The failing request's `anthropic-beta` value.
272
+ * @param {string | null | undefined} errorBody Raw response body text.
273
+ * @returns {{namedSent: string[], latchable: string[]}}
274
+ */
275
+ export function selectLatchableRejectedBetas(sentHeader, errorBody) {
276
+ const named = new Set(parseRejectedBetaNames(errorBody).map((name) => name.toLowerCase()));
277
+ if (named.size === 0) return { namedSent: [], latchable: [] };
278
+ const sent = [
279
+ ...new Set(
280
+ String(sentHeader ?? "")
281
+ .split(",")
282
+ .map((b) => b.trim())
283
+ .filter(Boolean),
284
+ ),
285
+ ];
286
+ const namedSent = sent.filter((b) => named.has(b.toLowerCase()));
287
+ const suppressible = namedSent.filter((b) => !UNSUPPRESSIBLE_BETAS.has(b.toLowerCase()));
288
+ if (suppressible.length > MAX_LATCHABLE_REJECTED_BETAS) return { namedSent: [], latchable: [] };
289
+ return { namedSent, latchable: suppressible.filter((b) => !BODY_COUPLED_BETAS.has(b.toLowerCase())) };
290
+ }
153
291
 
154
292
  /**
155
293
  * Betas the plugin opts INTO on its own (see `buildAdditionalBetas`): whether
@@ -161,11 +299,11 @@ export const PROMPT_CACHING_SCOPE_BETA = BETA_REGISTRY_2_1_233.PROMPT_CACHING_SC
161
299
  * rename or date bump surfaces as a changed emission instead of drifting
162
300
  * silently against a literal re-typed in this file.
163
301
  */
164
- export const WEB_SEARCH_BETA = BETA_REGISTRY_2_1_233.WEB_SEARCH.header;
165
- export const ADVISOR_TOOL_BETA = BETA_REGISTRY_2_1_233.ADVISOR_TOOL.header;
166
- export const FILES_API_BETA = BETA_REGISTRY_2_1_233.FILES_API.header;
167
- export const EXTENDED_CACHE_TTL_BETA = BETA_REGISTRY_2_1_233.EXTENDED_CACHE_TTL.header;
168
- export const STRUCTURED_OUTPUTS_BETA = BETA_REGISTRY_2_1_233.STRUCTURED_OUTPUTS.header;
302
+ export const WEB_SEARCH_BETA = BETA_REGISTRY_2_1_280.WEB_SEARCH.header;
303
+ export const ADVISOR_TOOL_BETA = BETA_REGISTRY_2_1_280.ADVISOR_TOOL.header;
304
+ export const FILES_API_BETA = BETA_REGISTRY_2_1_280.FILES_API.header;
305
+ export const EXTENDED_CACHE_TTL_BETA = BETA_REGISTRY_2_1_280.EXTENDED_CACHE_TTL.header;
306
+ export const STRUCTURED_OUTPUTS_BETA = BETA_REGISTRY_2_1_280.STRUCTURED_OUTPUTS.header;
169
307
 
170
308
  /**
171
309
  * `runtime.sessionId` is REQUIRED by the package (`requiredString`), while the
@@ -233,7 +371,7 @@ export const ADAPTER_ENV_KEYS = Object.freeze([
233
371
 
234
372
  /**
235
373
  * Protocol identity of the profile this adapter composes against
236
- * (`claude-code-2.1.233-sdk-0.112.1`). Used to decide whether a
374
+ * (`claude-code-2.1.280-sdk-0.112.1`). Used to decide whether a
237
375
  * `profileOverride` is needed at all — see `resolveProfileOverride` for the
238
376
  * user-agent convergence argument.
239
377
  *
@@ -298,11 +436,11 @@ export function buildAdapterUserAgent(version, env) {
298
436
 
299
437
  /**
300
438
  * The inherited default profile carries a FIXED `userAgent`
301
- * (`"claude-cli/2.1.233 (external, cli)"`) while the plugin builds one
439
+ * (`"claude-cli/2.1.280 (external, cli)"`) while the plugin builds one
302
440
  * dynamically from entrypoint / agent-sdk version / client-app. In the common
303
441
  * case — no `CLAUDE_CODE_ENTRYPOINT`, no `CLAUDE_AGENT_SDK_VERSION`, no
304
- * `CLAUDE_AGENT_SDK_CLIENT_APP`, detected CLI version `2.1.233` — both sides
305
- * produce the byte-identical `"claude-cli/2.1.233 (external, cli)"`, so no
442
+ * `CLAUDE_AGENT_SDK_CLIENT_APP`, detected CLI version `2.1.280` — both sides
443
+ * produce the byte-identical `"claude-cli/2.1.280 (external, cli)"`, so no
306
444
  * override is emitted and the request stays byte-identical to the profile.
307
445
  *
308
446
  * When they diverge, `userAgent` and `cliVersion` are validated TOGETHER by the
@@ -521,16 +659,35 @@ function buildAdditionalBetas(args) {
521
659
  * `EXPERIMENTAL_BETA_FLAGS` set is safe and avoids depending on which of them the
522
660
  * package happens to compose for a given model.
523
661
  *
524
- * @param {{isRoundRobin: boolean, disableExperimentalBetas: boolean}} args
662
+ * `rejectedBetas` carries the sent betas the API explicitly named as invalid
663
+ * for the current account (the TTL'd per-account sent-beta latch in index.mjs;
664
+ * see `selectLatchableRejectedBetas`). Dropping them from `customBetas` alone is
665
+ * not enough once the package composes the same beta itself (e.g.
666
+ * `cache-diagnosis-2026-04-07` under 2.1.280), so they are suppressed here.
667
+ * `UNSUPPRESSIBLE_BETAS` and `BODY_COUPLED_BETAS` are never taken from this
668
+ * input, entries outside the package grammar are skipped (they cannot be on the
669
+ * wire anyway), and the list is capped at `MAX_SUPPRESS_BETAS` with the policy
670
+ * suppressions taking precedence.
671
+ *
672
+ * @param {{isRoundRobin: boolean, disableExperimentalBetas: boolean, rejectedBetas?: readonly string[]}} args
525
673
  * @returns {readonly string[] | undefined}
526
674
  */
527
- function buildSuppressBetas({ isRoundRobin, disableExperimentalBetas }) {
675
+ function buildSuppressBetas({ isRoundRobin, disableExperimentalBetas, rejectedBetas }) {
528
676
  /** @type {Set<string>} */
529
677
  const suppressed = new Set();
530
678
 
531
679
  if (isRoundRobin) suppressed.add(PROMPT_CACHING_SCOPE_BETA);
532
680
  if (disableExperimentalBetas) for (const beta of EXPERIMENTAL_BETA_FLAGS) suppressed.add(beta);
533
681
 
682
+ for (const rejected of rejectedBetas ?? []) {
683
+ if (suppressed.size >= MAX_SUPPRESS_BETAS) break;
684
+ if (typeof rejected !== "string") continue;
685
+ const beta = rejected.trim();
686
+ if (!beta || beta.length > BETA_IDENTIFIER_MAX_LENGTH || !BETA_IDENTIFIER_PATTERN.test(beta)) continue;
687
+ if (UNSUPPRESSIBLE_BETAS.has(beta.toLowerCase()) || BODY_COUPLED_BETAS.has(beta.toLowerCase())) continue;
688
+ suppressed.add(beta);
689
+ }
690
+
534
691
  if (suppressed.size === 0) return undefined;
535
692
  return [...suppressed];
536
693
  }
@@ -592,7 +749,8 @@ function buildMetadataOverrides(env) {
592
749
  * @property {URL | undefined} [requestUrl] Parsed outgoing URL.
593
750
  * @property {string} provider Result of `detectProvider(requestUrl)`.
594
751
  * @property {string} clientRequestId Caller-generated `randomUUID()`.
595
- * @property {{enabled: boolean, claudeCliVersion: string, customBetas?: readonly string[], strategy?: string, sessionId?: string}} signature
752
+ * @property {{enabled: boolean, claudeCliVersion: string, customBetas?: readonly string[], rejectedBetas?: readonly string[], strategy?: string, sessionId?: string}} signature
753
+ * `rejectedBetas`: sent betas the API named as invalid for this account (TTL'd); routed to `suppressBetas`.
596
754
  * @property {{persistentUserId: string, accountId: string}} identity Correlation triple sources.
597
755
  * @property {{use1MContext?: unknown} | undefined} [adaptiveOverride]
598
756
  * @property {Record<string, unknown> | undefined} [tokenEconomy]
@@ -662,7 +820,11 @@ export function buildAdapterTransport(state) {
662
820
  disableExperimentalBetas,
663
821
  incomingBetas,
664
822
  });
665
- const suppressBetas = buildSuppressBetas({ isRoundRobin, disableExperimentalBetas });
823
+ const suppressBetas = buildSuppressBetas({
824
+ isRoundRobin,
825
+ disableExperimentalBetas,
826
+ rejectedBetas: signature.rejectedBetas,
827
+ });
666
828
 
667
829
  // 1M context is ALWAYS passed explicitly. The plugin's default is
668
830
  // `hasOneMillionContext(model)` (`/(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i`)
@@ -73,7 +73,7 @@ export const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
73
73
  *
74
74
  * These are betas the HOST SDK injects into its outgoing request that the
75
75
  * genuine Claude Code client NEVER sends. They are not stale registry entries
76
- * and they are not the package's business — `BETA_REGISTRY_2_1_233` has no slot
76
+ * and they are not the package's business — `BETA_REGISTRY_2_1_280` has no slot
77
77
  * for either, because upstream has no reason to model a header it does not
78
78
  * emit. The plugin drops them so a host's own SDK cannot leak a foreign
79
79
  * fingerprint into a request that is otherwise byte-identical to the real