opencode-anthropic-fix 0.1.30 → 0.1.32

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
@@ -30,6 +30,7 @@ import {
30
30
  parseRateLimitReason,
31
31
  parseRetryAfterHeader,
32
32
  parseRetryAfterMsHeader,
33
+ parseUnifiedResetMsHeader,
33
34
  parseShouldRetryHeader,
34
35
  TRANSIENT_RETRY_THRESHOLD_MS,
35
36
  } from "./lib/backoff.mjs";
@@ -3745,7 +3746,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3745
3746
  // Account-specific errors (429/401/billing/permission)
3746
3747
  if (accountSpecific) {
3747
3748
  const reason = parseRateLimitReason(response.status, errorBody);
3748
- const retryAfterMs = parseRetryAfterMsHeader(response) ?? parseRetryAfterHeader(response);
3749
+ const retryAfterMs =
3750
+ parseRetryAfterMsHeader(response) ??
3751
+ parseRetryAfterHeader(response) ??
3752
+ parseUnifiedResetMsHeader(response);
3749
3753
 
3750
3754
  // Transient 429: short retry-after (<=10s) is a burst throttle.
3751
3755
  // Retry on the SAME account instead of rotating — avoids wasting
@@ -6661,7 +6665,7 @@ function getClaudeEntrypoint() {
6661
6665
  }
6662
6666
 
6663
6667
  /**
6664
- * @param {string | undefined} body
6668
+ * @param {string | undefined} model
6665
6669
  * @returns {string | undefined}
6666
6670
  */
6667
6671
  function logTransformedSystemPrompt(body) {
@@ -6773,8 +6777,33 @@ function isSonnet46Model(model) {
6773
6777
  * @param {string | undefined} body
6774
6778
  * @returns {boolean}
6775
6779
  */
6776
- function isAdaptiveThinkingModel(model) {
6777
- return isOpus46Model(model) || isOpus47Model(model) || isOpus48Model(model) || isSonnet46Model(model);
6780
+ export function isFable5Model(model) {
6781
+ if (!model) return false;
6782
+ return /claude-fable-5|fable[._-]5/i.test(model);
6783
+ }
6784
+
6785
+ /**
6786
+ * @param {string | undefined} model
6787
+ * @returns {boolean}
6788
+ */
6789
+ export function isMythos5Model(model) {
6790
+ if (!model) return false;
6791
+ return /claude-mythos-5|mythos[._-]5/i.test(model);
6792
+ }
6793
+
6794
+ /**
6795
+ * @param {string | undefined} model
6796
+ * @returns {boolean}
6797
+ */
6798
+ export function isAdaptiveThinkingModel(model) {
6799
+ return (
6800
+ isOpus46Model(model) ||
6801
+ isOpus47Model(model) ||
6802
+ isOpus48Model(model) ||
6803
+ isSonnet46Model(model) ||
6804
+ isFable5Model(model) ||
6805
+ isMythos5Model(model)
6806
+ );
6778
6807
  }
6779
6808
 
6780
6809
  /**
@@ -7062,6 +7091,14 @@ const MAX_SUBAGENT_CC_PREFIX = MAX_SAFE_SYSTEM_TEXT_LENGTH;
7062
7091
  const SUBAGENT_CC_ANCHOR = "You are an interactive";
7063
7092
  let cachedCCPrompt = null;
7064
7093
 
7094
+ // Perf: module-scope regexes reused across per-request hot paths. None use the
7095
+ // `/g` flag and all are consumed via `.test()`, so a single shared instance is
7096
+ // stateless and safe (no `lastIndex` to reset between calls).
7097
+ const CLAUDE_3_MODEL_RE = /claude-3-/i;
7098
+ const TAIL_IMPORTANT_RE = /\b(MUST|NEVER|CRITICAL|IMPORTANT|REQUIRED|DO NOT|ALWAYS|FORBIDDEN)\b/i;
7099
+ const TAIL_HEADER_RE = /^#{1,4}\s/;
7100
+ const TAIL_LIST_ITEM_RE = /^\s*[-*]\s/;
7101
+
7065
7102
  function sanitizeSystemText(text) {
7066
7103
  // QA fix M4: use word boundaries to avoid mangling URLs and code identifiers
7067
7104
  let sanitized = text.replace(/\bOpenCode\b/g, "Claude Code").replace(/\bopencode\b/gi, "Claude");
@@ -7084,9 +7121,9 @@ function tailSystemBlock(text, maxChars, turnThreshold) {
7084
7121
  const lines = text.split("\n");
7085
7122
  const kept = [];
7086
7123
  let charCount = 0;
7087
- const importantRe = /\b(MUST|NEVER|CRITICAL|IMPORTANT|REQUIRED|DO NOT|ALWAYS|FORBIDDEN)\b/i;
7088
- const headerRe = /^#{1,4}\s/;
7089
- const listItemRe = /^\s*[-*]\s/;
7124
+ const importantRe = TAIL_IMPORTANT_RE;
7125
+ const headerRe = TAIL_HEADER_RE;
7126
+ const listItemRe = TAIL_LIST_ITEM_RE;
7090
7127
  // Always keep the first paragraph (identity/role definition)
7091
7128
  let firstParaEnd = 0;
7092
7129
  for (let j = 0; j < lines.length; j++) {
@@ -7575,6 +7612,22 @@ function buildSystemPromptBlocks(system, signature) {
7575
7612
  * accumulates fast-mode independently of actual body content.
7576
7613
  * @returns {string}
7577
7614
  */
7615
+ // Mirrors CC's Kw(model) effort eligibility: returns false for claude-3-* and the
7616
+ // explicit older 4.x exclusion set (opus-4-0/4-1, sonnet-4-0/4-5, haiku-4-5), true
7617
+ // for every other model (effort-capable: Opus 4.5/4.6/4.7/4.8, Sonnet 4.6, etc.).
7618
+ const _EFFORT_EXCLUDED_MODELS = [
7619
+ /claude-opus-4-0/i,
7620
+ /claude-opus-4-1/i,
7621
+ /claude-sonnet-4-0/i,
7622
+ /claude-sonnet-4-5/i,
7623
+ /claude-haiku-4-5/i,
7624
+ ];
7625
+ function isEffortCapableModel(model) {
7626
+ if (!model) return false;
7627
+ if (CLAUDE_3_MODEL_RE.test(model)) return false;
7628
+ return !_EFFORT_EXCLUDED_MODELS.some((re) => re.test(model));
7629
+ }
7630
+
7578
7631
  function buildAnthropicBetaHeader(
7579
7632
  incomingBeta,
7580
7633
  signatureEnabled,
@@ -7628,13 +7681,15 @@ function buildAnthropicBetaHeader(
7628
7681
 
7629
7682
  // v2.1.150: fast-mode-2026-02-01 removed from always-on (CC sends only when speed feature active).
7630
7683
 
7631
- // v2.1.150: effort-2025-11-24 removed from always-on (CC sends only when effort is explicitly requested).
7684
+ // v2.1.195: effort-2025-11-24 is no longer always-on; it is now a model-gated
7685
+ // default emitted in the conditional section below for effort-capable models
7686
+ // (mirrors CC's Kw(model)).
7632
7687
 
7633
7688
  // Interleaved thinking — real CC's i01 pushes via hv4(model), which is
7634
7689
  // (firstParty && non-Claude-3). Claude 3.x models don't support interleaved
7635
7690
  // thinking and real CC never sends this flag for them, so emitting it
7636
7691
  // diverges the fingerprint for legacy Haiku/Sonnet 3.x requests.
7637
- if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && !/claude-3-/i.test(model)) {
7692
+ if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && !CLAUDE_3_MODEL_RE.test(model)) {
7638
7693
  betas.push("interleaved-thinking-2025-05-14");
7639
7694
  }
7640
7695
 
@@ -7660,11 +7715,28 @@ function buildAnthropicBetaHeader(
7660
7715
 
7661
7716
  // === CONDITIONAL BETAS (model/context-dependent) ===
7662
7717
 
7663
- // v2.1.150: context-management gated behind opt-in (CC has hardcoded && false).
7664
- if (te.context_management && !/claude-3-/i.test(model)) {
7718
+ // First-party provider predicate (OAuth path). Mirrors CC's ZO(provider) which
7719
+ // excludes bedrock/vertex/mantle. context-management and effort are first-party
7720
+ // only here, so they must never be pushed on bedrock/vertex.
7721
+ const isFirstPartyProvider = provider !== "vertex" && provider !== "bedrock" && provider !== "mantle";
7722
+
7723
+ // v2.1.195: context-management-2025-06-27 is default-ON for first-party non-claude-3
7724
+ // models (incl. Haiku 4.5), mirroring CC's n0d(model) eligibility path. Earlier
7725
+ // analyses read only the separate USE_API_CONTEXT_MANAGEMENT env term (hardcoded
7726
+ // && false) and recorded it as off; 2.1.195 confirms it ships by default for modern
7727
+ // first-party models. Opt out via token_economy.context_management = false.
7728
+ if (te.context_management !== false && isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model)) {
7665
7729
  betas.push("context-management-2025-06-27");
7666
7730
  }
7667
7731
 
7732
+ // v2.1.195: effort-2025-11-24 is a model-gated default for effort-capable models
7733
+ // (Opus 4.5/4.6/4.7/4.8, Sonnet 4.6), mirroring CC's Kw(model). Excluded for
7734
+ // claude-3-*, opus-4-0, opus-4-1, sonnet-4-0, sonnet-4-5, haiku-4-5. First-party
7735
+ // only; opt out via token_economy.effort = false.
7736
+ if (te.effort !== false && isFirstPartyProvider && isEffortCapableModel(model)) {
7737
+ betas.push(_EFFORT_BETA_FLAG);
7738
+ }
7739
+
7668
7740
  // v2.1.150: structured-outputs gated behind opt-in (CC has behind tengu_tool_pear flag).
7669
7741
  if (te.structured_outputs && supportsStructuredOutputs(model)) {
7670
7742
  betas.push("structured-outputs-2025-12-15");
@@ -7679,7 +7751,7 @@ function buildAnthropicBetaHeader(
7679
7751
  // (tengu_sage_compass2) and firstParty+isLoggedIn. Since we can't check
7680
7752
  // CC's feature flags, include it unconditionally for Claude 4+ models.
7681
7753
  // CC v108 sends it in MITM captures for Max/Pro users.
7682
- if (!/claude-3-/i.test(model)) {
7754
+ if (!CLAUDE_3_MODEL_RE.test(model)) {
7683
7755
  betas.push("advisor-tool-2026-03-01");
7684
7756
  }
7685
7757
 
@@ -7691,12 +7763,11 @@ function buildAnthropicBetaHeader(
7691
7763
  // token-efficient context compaction. Sticky: disabled permanently on
7692
7764
  // 400/409/529 errors referencing the hint. Users can opt out via
7693
7765
  // token_economy.context_hint = false.
7694
- const isFirstPartyProvider = provider !== "vertex" && provider !== "bedrock" && provider !== "mantle";
7695
7766
  // Mimicry note: real CC gates context-hint on querySource.startsWith("repl_main_thread").
7696
7767
  // We infer main-thread from body shape via classifyRequestRole and pass it via
7697
7768
  // tokenEconomy.__requestRole. Treat absent marker as "main" for backward compat.
7698
7769
  const _isMainThread = te.__requestRole == null || te.__requestRole === "main";
7699
- if (isFirstPartyProvider && !/claude-3-/i.test(model) && te.context_hint !== false && _isMainThread) {
7770
+ if (isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model) && te.context_hint !== false && _isMainThread) {
7700
7771
  betas.push("context-hint-2026-04-09");
7701
7772
  }
7702
7773
 
@@ -7714,13 +7785,13 @@ function buildAnthropicBetaHeader(
7714
7785
 
7715
7786
  // v2.1.150: redact-thinking is default-ON in CC (first-party, non-SDK, thinking models).
7716
7787
  // Opt out via `/anthropic set redact-thinking off` to see thinking content.
7717
- if (te.redact_thinking !== false && !disableExperimentalBetas && !/claude-3-/i.test(model)) {
7788
+ if (te.redact_thinking !== false && !disableExperimentalBetas && !CLAUDE_3_MODEL_RE.test(model)) {
7718
7789
  betas.push("redact-thinking-2026-02-12");
7719
7790
  }
7720
7791
 
7721
7792
  // v2.1.150: thinking-token-count for token budget tracking (behind tengu_chert_bezel in CC).
7722
7793
  // Default ON for token economy visibility. Opt out via token_economy.thinking_token_count = false.
7723
- if (te.thinking_token_count !== false && !disableExperimentalBetas && !/claude-3-/i.test(model)) {
7794
+ if (te.thinking_token_count !== false && !disableExperimentalBetas && !CLAUDE_3_MODEL_RE.test(model)) {
7724
7795
  betas.push("thinking-token-count-2026-05-13");
7725
7796
  }
7726
7797
 
@@ -8014,7 +8085,9 @@ function buildRequestHeaders(
8014
8085
  requestHeaders.set("x-anthropic-additional-protection", "true");
8015
8086
  }
8016
8087
 
8017
- // x-client-request-id: NOT sent by real CC (confirmed via proxy capture). Removed.
8088
+ // x-client-request-id: real CC 2.1.195's first-party fetch middleware (Ukd) sets
8089
+ // this to crypto.randomUUID() on every first-party request. Re-emit a random uuid.
8090
+ requestHeaders.set("x-client-request-id", randomUUID());
8018
8091
  }
8019
8092
  requestHeaders.delete("x-api-key");
8020
8093
  // x-session-affinity: set by opencode SDK but NOT in real CC. Strip it.
@@ -8155,16 +8228,16 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
8155
8228
  delete parsed.temperature;
8156
8229
 
8157
8230
  // Claude Code v2.1.84: inject the context_management body field ONLY when the
8158
- // context-management beta is actually enabled (token_economy.context_management,
8159
- // the SAME flag that gates the beta in buildAnthropicBetaHeader). A top-level
8160
- // context_management field WITHOUT the beta is rejected by the API with
8161
- // "context_management: Extra inputs are not permitted". Keeping the field and
8162
- // the anthropic-beta header in lockstep prevents that 400 — previously the
8163
- // field was injected on every thinking turn regardless of the beta, which only
8164
- // worked if the beta happened to be forced on via custom_betas/ANTHROPIC_BETAS.
8231
+ // user has explicitly opted in via token_economy.context_management. Note: as of
8232
+ // v2.1.195 the context-management *beta header* is default-ON for first-party
8233
+ // non-claude-3 models (see buildAnthropicBetaHeader), but the body field stays
8234
+ // opt-in. A top-level context_management field WITHOUT the beta is rejected with
8235
+ // "context_management: Extra inputs are not permitted"; the beta being present
8236
+ // without the field is fine. Gating the field on explicit opt-in keeps the field
8237
+ // ⊆ beta invariant, so the 400 never fires.
8165
8238
  if (
8166
8239
  config?.token_economy?.context_management &&
8167
- !/claude-3-/i.test(parsed.model || "") &&
8240
+ !CLAUDE_3_MODEL_RE.test(parsed.model || "") &&
8168
8241
  !parsed.context_management
8169
8242
  ) {
8170
8243
  parsed.context_management = {
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import { isAdaptiveThinkingModel, isFable5Model, isMythos5Model } from "../index.mjs";
4
+
5
+ describe("Claude 5 adaptive thinking model predicates", () => {
6
+ test("isFable5Model matches version-anchored fable 5 model names", () => {
7
+ expect(isFable5Model("claude-fable-5")).toBe(true);
8
+ expect(isFable5Model("anthropic/claude-fable-5")).toBe(true);
9
+ });
10
+
11
+ test("isFable5Model rejects non-fable-5 model names", () => {
12
+ expect(isFable5Model("claude-opus-4-8")).toBe(false);
13
+ expect(isFable5Model("claude-haiku-4-5")).toBe(false);
14
+ expect(isFable5Model("")).toBe(false);
15
+ });
16
+
17
+ test("isMythos5Model matches version-anchored mythos 5 model names", () => {
18
+ expect(isMythos5Model("claude-mythos-5")).toBe(true);
19
+ });
20
+
21
+ test("isMythos5Model rejects non-mythos-5 model names", () => {
22
+ expect(isMythos5Model("claude-opus-4-8")).toBe(false);
23
+ expect(isMythos5Model("claude-haiku-4-5")).toBe(false);
24
+ expect(isMythos5Model("")).toBe(false);
25
+ });
26
+
27
+ test("isAdaptiveThinkingModel includes fable 5 models", () => {
28
+ expect(isAdaptiveThinkingModel("claude-fable-5")).toBe(true);
29
+ });
30
+ });
package/lib/backoff.mjs CHANGED
@@ -64,6 +64,29 @@ export function parseRetryAfterHeader(response) {
64
64
  return null;
65
65
  }
66
66
 
67
+ /**
68
+ * Parse the unified rate-limit reset header from a response (Claude Code 2.1.195+).
69
+ * Reads `anthropic-ratelimit-unified-reset`, falling back to
70
+ * `anthropic-ratelimit-unified-overage-reset` when the primary header is absent.
71
+ * Both are UNIX epochs in SECONDS (parsed via `Number()`, mirroring upstream).
72
+ * Returns milliseconds-until-reset, or null when the header is missing, not a
73
+ * number, or already in the past.
74
+ * @param {Response} response
75
+ * @returns {number | null} Milliseconds until reset, or null
76
+ */
77
+ export function parseUnifiedResetMsHeader(response) {
78
+ const header =
79
+ response.headers.get("anthropic-ratelimit-unified-reset") ??
80
+ response.headers.get("anthropic-ratelimit-unified-overage-reset");
81
+ if (!header) return null;
82
+
83
+ const epochSeconds = Number(header);
84
+ if (isNaN(epochSeconds)) return null;
85
+
86
+ const ms = epochSeconds * 1000 - Date.now();
87
+ return ms > 0 ? ms : null;
88
+ }
89
+
67
90
  /**
68
91
  * Parse normalized error signals from a response body.
69
92
  * Accepts raw strings, parsed objects, or null.
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { parseRateLimitReason, calculateBackoffMs, parseRetryAfterHeader, isAccountSpecificError } from "./backoff.mjs";
2
+ import {
3
+ parseRateLimitReason,
4
+ calculateBackoffMs,
5
+ parseRetryAfterHeader,
6
+ parseUnifiedResetMsHeader,
7
+ isAccountSpecificError,
8
+ } from "./backoff.mjs";
3
9
 
4
10
  // ---------------------------------------------------------------------------
5
11
  // isAccountSpecificError
@@ -343,3 +349,63 @@ describe("parseRetryAfterHeader", () => {
343
349
  expect(parseRetryAfterHeader(response)).toBeNull();
344
350
  });
345
351
  });
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // parseUnifiedResetMsHeader
355
+ // ---------------------------------------------------------------------------
356
+
357
+ describe("parseUnifiedResetMsHeader", () => {
358
+ it("returns null when no unified-reset header present", () => {
359
+ const response = new Response(null, { headers: {} });
360
+ expect(parseUnifiedResetMsHeader(response)).toBeNull();
361
+ });
362
+
363
+ it("returns ~ms-until-reset for a future epoch (seconds)", () => {
364
+ const futureEpochSeconds = Math.floor(Date.now() / 1000) + 120;
365
+ const response = new Response(null, {
366
+ headers: { "anthropic-ratelimit-unified-reset": String(futureEpochSeconds) },
367
+ });
368
+ const result = parseUnifiedResetMsHeader(response);
369
+ expect(result).toBeGreaterThan(110_000);
370
+ expect(result).toBeLessThanOrEqual(120_000);
371
+ });
372
+
373
+ it("returns null for a non-numeric value (NaN)", () => {
374
+ const response = new Response(null, {
375
+ headers: { "anthropic-ratelimit-unified-reset": "not-a-number" },
376
+ });
377
+ expect(parseUnifiedResetMsHeader(response)).toBeNull();
378
+ });
379
+
380
+ it("returns null for a past epoch (<= now)", () => {
381
+ const pastEpochSeconds = Math.floor(Date.now() / 1000) - 60;
382
+ const response = new Response(null, {
383
+ headers: { "anthropic-ratelimit-unified-reset": String(pastEpochSeconds) },
384
+ });
385
+ expect(parseUnifiedResetMsHeader(response)).toBeNull();
386
+ });
387
+
388
+ it("falls back to overage-reset when unified-reset is absent", () => {
389
+ const futureEpochSeconds = Math.floor(Date.now() / 1000) + 90;
390
+ const response = new Response(null, {
391
+ headers: { "anthropic-ratelimit-unified-overage-reset": String(futureEpochSeconds) },
392
+ });
393
+ const result = parseUnifiedResetMsHeader(response);
394
+ expect(result).toBeGreaterThan(80_000);
395
+ expect(result).toBeLessThanOrEqual(90_000);
396
+ });
397
+
398
+ it("prefers unified-reset over overage-reset when both present", () => {
399
+ const resetEpoch = Math.floor(Date.now() / 1000) + 120;
400
+ const overageEpoch = Math.floor(Date.now() / 1000) + 300;
401
+ const response = new Response(null, {
402
+ headers: {
403
+ "anthropic-ratelimit-unified-reset": String(resetEpoch),
404
+ "anthropic-ratelimit-unified-overage-reset": String(overageEpoch),
405
+ },
406
+ });
407
+ const result = parseUnifiedResetMsHeader(response);
408
+ expect(result).toBeGreaterThan(110_000);
409
+ expect(result).toBeLessThanOrEqual(120_000);
410
+ });
411
+ });
package/lib/config.mjs CHANGED
@@ -104,6 +104,7 @@ import { randomBytes } from "node:crypto";
104
104
  * @property {{ enabled: boolean, default_cooldown_ms: number, poll_quota_on_overload: boolean }} overload_recovery
105
105
  * @property {{ proactive_disabled: boolean }} account_management
106
106
  * @property {{ enabled: boolean, length_anchors: boolean }} anti_verbosity
107
+ * @property {{ sdk_token_useragent: boolean }} oauth
107
108
  */
108
109
 
109
110
  /** @type {AnthropicAuthConfig} */
@@ -434,6 +435,18 @@ export const DEFAULT_CONFIG = {
434
435
  /** Also inject numeric length anchors (≤25 words between tool calls, ≤100 words final). */
435
436
  length_anchors: true,
436
437
  },
438
+ /** OAuth token-endpoint request fingerprint. */
439
+ oauth: {
440
+ /** When true (DEFAULT), the OAuth token EXCHANGE/REFRESH POSTs use Claude
441
+ * Code 2.1.195's SDK-native-fetch fingerprint (`User-Agent:
442
+ * anthropic-sdk-typescript/0.94.0 userOAuthProvider`, `anthropic-beta:
443
+ * oauth-2025-04-20`, no axios `Accept`). When false, the legacy axios
444
+ * fingerprint (`User-Agent: axios/1.13.6`, axios `Accept` header) is sent
445
+ * byte-for-byte as before.
446
+ * Default ON to match upstream CC 2.1.195's userOAuthProvider. Set to
447
+ * `false` to revert to the legacy axios/1.13.6 + `Accept` fingerprint. */
448
+ sdk_token_useragent: true,
449
+ },
437
450
  };
438
451
 
439
452
  export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
@@ -473,6 +486,7 @@ function createDefaultConfig() {
473
486
  microcompact: { ...DEFAULT_CONFIG.microcompact },
474
487
  overload_recovery: { ...DEFAULT_CONFIG.overload_recovery },
475
488
  account_management: { ...DEFAULT_CONFIG.account_management },
489
+ oauth: { ...DEFAULT_CONFIG.oauth },
476
490
  };
477
491
  }
478
492
 
@@ -1002,6 +1016,15 @@ function validateConfig(raw) {
1002
1016
  };
1003
1017
  }
1004
1018
 
1019
+ // OAuth token-endpoint fingerprint sub-config
1020
+ if (raw.oauth && typeof raw.oauth === "object") {
1021
+ const o = /** @type {Record<string, unknown>} */ (raw.oauth);
1022
+ config.oauth = {
1023
+ sdk_token_useragent:
1024
+ typeof o.sdk_token_useragent === "boolean" ? o.sdk_token_useragent : DEFAULT_CONFIG.oauth.sdk_token_useragent,
1025
+ };
1026
+ }
1027
+
1005
1028
  return config;
1006
1029
  }
1007
1030
 
package/lib/oauth.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { CLIENT_ID } from "./config.mjs";
2
+ import { CLIENT_ID, loadConfig } from "./config.mjs";
3
3
  import { parseRetryAfterHeader, parseRetryAfterMsHeader } from "./backoff.mjs";
4
4
 
5
5
  // ---------------------------------------------------------------------------
@@ -17,6 +17,17 @@ const OAUTH_REVOKE_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/revoke`;
17
17
  const OAUTH_AXIOS_VERSION = "1.13.6";
18
18
  const OAUTH_USER_AGENT = `axios/${OAUTH_AXIOS_VERSION}`;
19
19
  const OAUTH_ACCEPT = "application/json, text/plain, */*";
20
+ // Claude Code 2.1.195 migrated the OAuth token client from axios to the
21
+ // Anthropic TS SDK's native-fetch `userOAuthProvider`. When the
22
+ // `oauth.sdk_token_useragent` config flag is ON, the token POST switches to
23
+ // this fingerprint: `User-Agent: anthropic-sdk-typescript/0.94.0
24
+ // userOAuthProvider`, `anthropic-beta: oauth-2025-04-20`, JSON body, and NO
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.
28
+ const OAUTH_SDK_VERSION = "0.94.0";
29
+ const OAUTH_SDK_USER_AGENT = `anthropic-sdk-typescript/${OAUTH_SDK_VERSION} userOAuthProvider`;
30
+ const OAUTH_SDK_BETA = "oauth-2025-04-20";
20
31
  const OAUTH_MAX_RETRIES = 2;
21
32
  const OAUTH_MAX_RETRY_DELAY_MS = 30_000;
22
33
  const OAUTH_RATE_LIMIT_COOLDOWN_MS = 30_000;
@@ -44,6 +55,44 @@ function generatePKCE() {
44
55
  return { verifier, challenge, state };
45
56
  }
46
57
 
58
+ /**
59
+ * Resolve whether the SDK-native-fetch token fingerprint should be used.
60
+ * An explicit boolean override (used by callers/tests) wins; otherwise the
61
+ * live `oauth.sdk_token_useragent` config flag is read (default true).
62
+ * @param {boolean | undefined} override
63
+ * @returns {boolean}
64
+ */
65
+ function useSdkTokenFingerprint(override) {
66
+ if (typeof override === "boolean") return override;
67
+ try {
68
+ return loadConfig()?.oauth?.sdk_token_useragent === true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Build the headers for an OAuth token POST (exchange/refresh).
76
+ * @param {boolean} useSdk - When true, emit the CC 2.1.195 SDK-native-fetch
77
+ * fingerprint; when false, emit the legacy axios fingerprint (default).
78
+ * @returns {Record<string, string>}
79
+ */
80
+ function buildTokenHeaders(useSdk) {
81
+ if (useSdk) {
82
+ // Native fetch sends no default Accept header — omit it deliberately.
83
+ return {
84
+ "Content-Type": "application/json",
85
+ "anthropic-beta": OAUTH_SDK_BETA,
86
+ "User-Agent": OAUTH_SDK_USER_AGENT,
87
+ };
88
+ }
89
+ return {
90
+ Accept: OAUTH_ACCEPT,
91
+ "Content-Type": "application/json",
92
+ "User-Agent": OAUTH_USER_AGENT,
93
+ };
94
+ }
95
+
47
96
  /**
48
97
  * @param {number} ms
49
98
  * @returns {Promise<void>}
@@ -261,6 +310,8 @@ export function parseOAuthCallback(input) {
261
310
  * Exchange an authorization code for tokens.
262
311
  * @param {string} code
263
312
  * @param {string} verifier
313
+ * @param {{ sdkTokenUserAgent?: boolean }} [options] - `sdkTokenUserAgent`
314
+ * overrides the `oauth.sdk_token_useragent` config flag (mainly for tests).
264
315
  * @returns {Promise<
265
316
  * | { type: "success", refresh: string, access: string, expires: number, email?: string }
266
317
  * | {
@@ -274,7 +325,8 @@ export function parseOAuthCallback(input) {
274
325
  * }
275
326
  * >}
276
327
  */
277
- export async function exchange(code, verifier) {
328
+ export async function exchange(code, verifier, options = {}) {
329
+ const tokenHeaders = buildTokenHeaders(useSdkTokenFingerprint(options.sdkTokenUserAgent));
278
330
  const fail = (status, rawText = "", cooldownHint = { retryAfterMs: null }) => {
279
331
  const { errorCode, reason } = parseOAuthErrorBody(rawText);
280
332
  const retryAfterMs =
@@ -312,11 +364,7 @@ export async function exchange(code, verifier) {
312
364
  };
313
365
  result = await fetch(OAUTH_TOKEN_URL, {
314
366
  method: "POST",
315
- headers: {
316
- Accept: OAUTH_ACCEPT,
317
- "Content-Type": "application/json",
318
- "User-Agent": OAUTH_USER_AGENT,
319
- },
367
+ headers: tokenHeaders,
320
368
  body: JSON.stringify(_exchangeBody),
321
369
  signal: AbortSignal.timeout(15_000),
322
370
  });
@@ -413,24 +461,23 @@ export async function revoke(refreshToken) {
413
461
  /**
414
462
  * Refresh an OAuth access token.
415
463
  * @param {string} refreshTokenValue - The refresh token to use
416
- * @param {{ signal?: AbortSignal }} [options]
464
+ * @param {{ signal?: AbortSignal, scopes?: string[], sdkTokenUserAgent?: boolean }} [options]
465
+ * `sdkTokenUserAgent` overrides the `oauth.sdk_token_useragent` config flag
466
+ * (mainly for tests).
417
467
  * @returns {Promise<{access_token: string, refresh_token: string, expires_in: number}>}
418
468
  * @throws {Error} On HTTP errors or network failures
419
469
  */
420
470
  export async function refreshToken(refreshTokenValue, options = {}) {
421
471
  // QA fix M13: default 30s timeout to prevent hanging on unresponsive server
422
472
  const signal = options.signal ?? AbortSignal.timeout(30_000);
473
+ const tokenHeaders = buildTokenHeaders(useSdkTokenFingerprint(options.sdkTokenUserAgent));
423
474
 
424
475
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
425
476
  let resp;
426
477
  try {
427
478
  resp = await fetch(OAUTH_TOKEN_URL, {
428
479
  method: "POST",
429
- headers: {
430
- Accept: OAUTH_ACCEPT,
431
- "Content-Type": "application/json",
432
- "User-Agent": OAUTH_USER_AGENT,
433
- },
480
+ headers: tokenHeaders,
434
481
  body: JSON.stringify({
435
482
  grant_type: "refresh_token",
436
483
  refresh_token: refreshTokenValue,
@@ -13,7 +13,7 @@ describe("oauth headers", () => {
13
13
  vi.unstubAllGlobals();
14
14
  });
15
15
 
16
- it("sends Claude Code user-agent on token exchange", async () => {
16
+ it("sends SDK native-fetch fingerprint by default on token exchange", async () => {
17
17
  mockFetch.mockResolvedValueOnce({
18
18
  ok: false,
19
19
  status: 400,
@@ -23,10 +23,12 @@ describe("oauth headers", () => {
23
23
  await exchange("code#state", "verifier");
24
24
 
25
25
  const [, init] = mockFetch.mock.calls[0];
26
- expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
26
+ expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
27
+ expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
28
+ expect("Accept" in init.headers).toBe(false);
27
29
  });
28
30
 
29
- it("sends Claude Code user-agent on token refresh", async () => {
31
+ it("sends SDK native-fetch fingerprint by default on token refresh", async () => {
30
32
  mockFetch.mockResolvedValueOnce({
31
33
  ok: true,
32
34
  json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
@@ -35,7 +37,9 @@ describe("oauth headers", () => {
35
37
  await refreshToken("refresh-token");
36
38
 
37
39
  const [, init] = mockFetch.mock.calls[0];
38
- expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
40
+ expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
41
+ expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
42
+ expect("Accept" in init.headers).toBe(false);
39
43
  });
40
44
 
41
45
  it("sends Claude Code user-agent on token revoke", async () => {
@@ -47,6 +51,60 @@ describe("oauth headers", () => {
47
51
  expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
48
52
  });
49
53
 
54
+ it("flag OFF (default) keeps axios fingerprint on exchange", async () => {
55
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 400, text: async () => "" });
56
+
57
+ await exchange("code#state", "verifier", { sdkTokenUserAgent: false });
58
+
59
+ const [, init] = mockFetch.mock.calls[0];
60
+ expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
61
+ expect(init.headers["Accept"]).toBe("application/json, text/plain, */*");
62
+ expect(init.headers["Content-Type"]).toBe("application/json");
63
+ expect(init.headers["anthropic-beta"]).toBeUndefined();
64
+ });
65
+
66
+ it("flag ON sends SDK native-fetch fingerprint on exchange", async () => {
67
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 400, text: async () => "" });
68
+
69
+ await exchange("code#state", "verifier", { sdkTokenUserAgent: true });
70
+
71
+ const [, init] = mockFetch.mock.calls[0];
72
+ expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
73
+ expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
74
+ expect(init.headers["Content-Type"]).toBe("application/json");
75
+ expect("Accept" in init.headers).toBe(false);
76
+ });
77
+
78
+ it("flag OFF (default) keeps axios fingerprint on refresh", async () => {
79
+ mockFetch.mockResolvedValueOnce({
80
+ ok: true,
81
+ json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
82
+ });
83
+
84
+ await refreshToken("refresh-token", { sdkTokenUserAgent: false });
85
+
86
+ const [, init] = mockFetch.mock.calls[0];
87
+ expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
88
+ expect(init.headers["Accept"]).toBe("application/json, text/plain, */*");
89
+ expect(init.headers["Content-Type"]).toBe("application/json");
90
+ expect(init.headers["anthropic-beta"]).toBeUndefined();
91
+ });
92
+
93
+ it("flag ON sends SDK native-fetch fingerprint on refresh", async () => {
94
+ mockFetch.mockResolvedValueOnce({
95
+ ok: true,
96
+ json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
97
+ });
98
+
99
+ await refreshToken("refresh-token", { sdkTokenUserAgent: true });
100
+
101
+ const [, init] = mockFetch.mock.calls[0];
102
+ expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
103
+ expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
104
+ expect(init.headers["Content-Type"]).toBe("application/json");
105
+ expect("Accept" in init.headers).toBe(false);
106
+ });
107
+
50
108
  it("parses Anthropic nested error payloads on exchange failure", async () => {
51
109
  mockFetch.mockResolvedValueOnce({
52
110
  ok: false,
@@ -16,15 +16,15 @@
16
16
  // Version / build / SDK constants
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
- export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.159";
19
+ export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.195";
20
20
  export const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
21
- // Real build markers extracted from the 2.1.159 linux-x64 native binary
22
- // (Bun-embedded JS): `BUILD_TIME:"2026-05-31T16:22:50Z"`,
23
- // `GIT_SHA:"dd8c11fc8d05cea0b2b9fc8f5a99a5c5c5dffc9b"`.
24
- // (Prior 2.1.154 win32-x64: BUILD_TIME 2026-05-28T12:27:24Z,
25
- // GIT_SHA b84d2da9ada13121515426fc644786a303e9ac53.)
26
- export const CLAUDE_CODE_BUILD_TIME = "2026-05-31T16:22:50Z";
27
- export const CLAUDE_CODE_GIT_SHA = "dd8c11fc8d05cea0b2b9fc8f5a99a5c5c5dffc9b";
21
+ // Real build markers extracted from the 2.1.195 native binary
22
+ // (Bun-embedded JS): `BUILD_TIME:"2026-06-26T01:00:56Z"`,
23
+ // `GIT_SHA:"4603aa3f2ea164bd0974f82eb413ae7acc99a7ee"`.
24
+ // (Prior 2.1.159 linux-x64: BUILD_TIME 2026-05-31T16:22:50Z,
25
+ // GIT_SHA dd8c11fc8d05cea0b2b9fc8f5a99a5c5c5dffc9b.)
26
+ export const CLAUDE_CODE_BUILD_TIME = "2026-06-26T01:00:56Z";
27
+ export const CLAUDE_CODE_GIT_SHA = "4603aa3f2ea164bd0974f82eb413ae7acc99a7ee";
28
28
 
29
29
  // The @anthropic-ai/sdk version bundled with Claude Code.
30
30
  // This is distinct from the CLI version and goes in X-Stainless-Package-Version.
@@ -38,6 +38,46 @@ export const ANTHROPIC_SDK_VERSION = "0.94.0";
38
38
 
39
39
  // Map of CLI version to bundled SDK version (update when CLI version changes)
40
40
  export const CLI_TO_SDK_VERSION = new Map([
41
+ // 2.1.160-2.1.195: refusal-fallback feature (server-side-fallback /
42
+ // fallback-credit betas) is opt-in/GrowthBook-gated and never emitted on a
43
+ // default /v1/messages turn; no wire SDK bump (binary-verified:
44
+ // x-stainless-package-version still "0.94.0" in 2.1.195).
45
+ ["2.1.195", "0.94.0"],
46
+ ["2.1.194", "0.94.0"],
47
+ ["2.1.193", "0.94.0"],
48
+ ["2.1.192", "0.94.0"],
49
+ ["2.1.191", "0.94.0"],
50
+ ["2.1.190", "0.94.0"],
51
+ ["2.1.189", "0.94.0"],
52
+ ["2.1.188", "0.94.0"],
53
+ ["2.1.187", "0.94.0"],
54
+ ["2.1.186", "0.94.0"],
55
+ ["2.1.185", "0.94.0"],
56
+ ["2.1.184", "0.94.0"],
57
+ ["2.1.183", "0.94.0"],
58
+ ["2.1.182", "0.94.0"],
59
+ ["2.1.181", "0.94.0"],
60
+ ["2.1.180", "0.94.0"],
61
+ ["2.1.179", "0.94.0"],
62
+ ["2.1.178", "0.94.0"],
63
+ ["2.1.177", "0.94.0"],
64
+ ["2.1.176", "0.94.0"],
65
+ ["2.1.175", "0.94.0"],
66
+ ["2.1.174", "0.94.0"],
67
+ ["2.1.173", "0.94.0"],
68
+ ["2.1.172", "0.94.0"],
69
+ ["2.1.171", "0.94.0"],
70
+ ["2.1.170", "0.94.0"],
71
+ ["2.1.169", "0.94.0"],
72
+ ["2.1.168", "0.94.0"],
73
+ ["2.1.167", "0.94.0"],
74
+ ["2.1.166", "0.94.0"],
75
+ ["2.1.165", "0.94.0"],
76
+ ["2.1.164", "0.94.0"],
77
+ ["2.1.163", "0.94.0"],
78
+ ["2.1.162", "0.94.0"],
79
+ ["2.1.161", "0.94.0"],
80
+ ["2.1.160", "0.94.0"],
41
81
  // 2.1.155-2.1.159: only wire change is the revival of
42
82
  // summarize-connector-text-2026-03-13 (label narration_summaries), gated by
43
83
  // GrowthBook pewter_owl_header (default-off) — not always-on. SDK still 0.94.0
@@ -146,6 +186,10 @@ export const EXPERIMENTAL_BETA_FLAGS = new Set([
146
186
  "context-management-2025-06-27",
147
187
  "environments-2025-11-01",
148
188
  "extended-cache-ttl-2025-04-11",
189
+ // Refusal-fallback credit beta from the upstream Udd registry (CC 2.1.195).
190
+ // Opt-in/GrowthBook-gated; never auto-emitted on a default /v1/messages turn.
191
+ // Listed here for the disable-experimental guard + manual opt-in ONLY.
192
+ "fallback-credit-2026-06-01",
149
193
  "fast-mode-2026-02-01",
150
194
  "files-api-2025-04-14",
151
195
  "interleaved-thinking-2025-05-14",
@@ -158,6 +202,10 @@ export const EXPERIMENTAL_BETA_FLAGS = new Set([
158
202
  "mid-conversation-system-2026-04-07",
159
203
  "prompt-caching-scope-2026-01-05",
160
204
  "redact-thinking-2026-02-12",
205
+ // Server-side refusal-fallback beta from the upstream Udd registry (CC 2.1.195).
206
+ // Opt-in/GrowthBook-gated; never auto-emitted on a default /v1/messages turn.
207
+ // Listed here for the disable-experimental guard + manual opt-in ONLY.
208
+ "server-side-fallback-2026-06-01",
161
209
  "structured-outputs-2025-12-15",
162
210
  // Revived in CC 2.1.159 as registry label `narration_summaries` (it was a dead
163
211
  // slot from v2.1.90-2.1.154). Real CC emits it only when GrowthBook flag
@@ -208,6 +256,9 @@ export const BETA_SHORTCUTS = new Map([
208
256
  ["connector-text", "summarize-connector-text-2026-03-13"],
209
257
  ["narration-summaries", "summarize-connector-text-2026-03-13"],
210
258
  ["summarize-connector-text", "summarize-connector-text-2026-03-13"],
259
+ ["server-side-fallback", "server-side-fallback-2026-06-01"],
260
+ ["fallback", "server-side-fallback-2026-06-01"],
261
+ ["fallback-credit", "fallback-credit-2026-06-01"],
211
262
  ]);
212
263
 
213
264
  // ---------------------------------------------------------------------------
@@ -8,18 +8,21 @@ import {
8
8
  ANTHROPIC_SDK_VERSION,
9
9
  getSdkVersion,
10
10
  BETA_SHORTCUTS,
11
+ EXPERIMENTAL_BETA_FLAGS,
11
12
  resolveBetaShortcut,
12
13
  buildExtendedUserAgent,
13
14
  } from "./request-headers.mjs";
14
15
 
15
16
  describe("request-headers constants", () => {
16
- it("FALLBACK_CLAUDE_CLI_VERSION is 2.1.159", () => {
17
- expect(FALLBACK_CLAUDE_CLI_VERSION).toBe("2.1.159");
17
+ it("FALLBACK_CLAUDE_CLI_VERSION is 2.1.195", () => {
18
+ expect(FALLBACK_CLAUDE_CLI_VERSION).toBe("2.1.195");
18
19
  });
19
20
 
20
21
  it("ANTHROPIC_SDK_VERSION matches expected value", () => {
21
22
  expect(typeof ANTHROPIC_SDK_VERSION).toBe("string");
22
23
  expect(ANTHROPIC_SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
24
+ // The wire SDK version did NOT bump for 2.1.195.
25
+ expect(ANTHROPIC_SDK_VERSION).toBe("0.94.0");
23
26
  });
24
27
  });
25
28
 
@@ -31,6 +34,12 @@ describe("getSdkVersion", () => {
31
34
  expect(v).toMatch(/^\d+\.\d+\.\d+$/);
32
35
  });
33
36
 
37
+ it("resolves 2.1.195 (and the 2.1.160-2.1.194 range) to 0.94.0", () => {
38
+ expect(getSdkVersion("2.1.195")).toBe("0.94.0");
39
+ expect(getSdkVersion("2.1.160")).toBe("0.94.0");
40
+ expect(getSdkVersion("2.1.180")).toBe("0.94.0");
41
+ });
42
+
34
43
  it("falls back to ANTHROPIC_SDK_VERSION for an unknown CLI version", () => {
35
44
  expect(getSdkVersion("9.99.999")).toBe(ANTHROPIC_SDK_VERSION);
36
45
  });
@@ -53,6 +62,26 @@ describe("BETA_SHORTCUTS", () => {
53
62
  it("has a cache-diag alias for cache-diagnosis-2026-04-07", () => {
54
63
  expect(BETA_SHORTCUTS.get("cache-diag")).toBe("cache-diagnosis-2026-04-07");
55
64
  });
65
+
66
+ it("has server-side-fallback / fallback aliases for server-side-fallback-2026-06-01", () => {
67
+ expect(BETA_SHORTCUTS.get("server-side-fallback")).toBe("server-side-fallback-2026-06-01");
68
+ expect(BETA_SHORTCUTS.get("fallback")).toBe("server-side-fallback-2026-06-01");
69
+ });
70
+
71
+ it("has a fallback-credit alias for fallback-credit-2026-06-01", () => {
72
+ expect(BETA_SHORTCUTS.get("fallback-credit")).toBe("fallback-credit-2026-06-01");
73
+ });
74
+ });
75
+
76
+ describe("EXPERIMENTAL_BETA_FLAGS", () => {
77
+ it("is a Set", () => {
78
+ expect(EXPERIMENTAL_BETA_FLAGS).toBeInstanceOf(Set);
79
+ });
80
+
81
+ it("includes the 2.1.195 refusal-fallback registry betas", () => {
82
+ expect(EXPERIMENTAL_BETA_FLAGS.has("server-side-fallback-2026-06-01")).toBe(true);
83
+ expect(EXPERIMENTAL_BETA_FLAGS.has("fallback-credit-2026-06-01")).toBe(true);
84
+ });
56
85
  });
57
86
 
58
87
  describe("resolveBetaShortcut", () => {
@@ -72,6 +101,15 @@ describe("resolveBetaShortcut", () => {
72
101
  expect(resolveBetaShortcut("fast")).toBe("fast-mode-2026-02-01");
73
102
  });
74
103
 
104
+ it("expands server-side-fallback / fallback shortcuts", () => {
105
+ expect(resolveBetaShortcut("server-side-fallback")).toBe("server-side-fallback-2026-06-01");
106
+ expect(resolveBetaShortcut("fallback")).toBe("server-side-fallback-2026-06-01");
107
+ });
108
+
109
+ it("expands fallback-credit shortcut", () => {
110
+ expect(resolveBetaShortcut("fallback-credit")).toBe("fallback-credit-2026-06-01");
111
+ });
112
+
75
113
  it("returns the input unchanged when no alias matches", () => {
76
114
  expect(resolveBetaShortcut("context-hint-2026-04-09")).toBe("context-hint-2026-04-09");
77
115
  expect(resolveBetaShortcut("no-such-beta")).toBe("no-such-beta");
@@ -121,5 +159,6 @@ describe("buildExtendedUserAgent", () => {
121
159
  it("uses FALLBACK_CLAUDE_CLI_VERSION when explicitly passed as the version", () => {
122
160
  const ua = buildExtendedUserAgent(FALLBACK_CLAUDE_CLI_VERSION);
123
161
  expect(ua).toContain(FALLBACK_CLAUDE_CLI_VERSION);
162
+ expect(ua).toBe("claude-cli/2.1.195 (external, cli)");
124
163
  });
125
164
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",