opencode-anthropic-fix 0.1.24 → 0.1.26

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/cli.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  /**
2
3
  * CLI for managing Anthropic multi-account OAuth configuration.
3
4
  *
package/index.mjs CHANGED
@@ -5,7 +5,21 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve, basename } from "node:path";
6
6
  import xxhashInit from "xxhash-wasm";
7
7
  import { AccountManager } from "./lib/accounts.mjs";
8
- import { authorize as oauthAuthorize, exchange as oauthExchange, refreshToken } from "./lib/oauth.mjs";
8
+ import {
9
+ authorize as oauthAuthorize,
10
+ exchange as oauthExchange,
11
+ parseOAuthCallback,
12
+ refreshToken,
13
+ } from "./lib/oauth.mjs";
14
+ import {
15
+ FALLBACK_CLAUDE_CLI_VERSION,
16
+ CLAUDE_CODE_NPM_LATEST_URL,
17
+ CLAUDE_CODE_BUILD_TIME,
18
+ EXPERIMENTAL_BETA_FLAGS,
19
+ BETA_SHORTCUTS,
20
+ resolveBetaShortcut,
21
+ buildExtendedUserAgent,
22
+ } from "./lib/request-headers.mjs";
9
23
  import { loadConfig, loadConfigFresh, saveConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
10
24
  import { loadContextHintDisabledFlag, saveContextHintDisabledFlag } from "./lib/context-hint-persist.mjs";
11
25
  import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
@@ -240,6 +254,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
240
254
  lastHeader: null,
241
255
  };
242
256
 
257
+ // F4: Session-level latch for rejected custom betas.
258
+ // When a custom beta triggers a 400/anthropic-beta or 413-with-signal rejection,
259
+ // its canonical name is stored here so subsequent requests within
260
+ // SESSION_REJECTED_BETA_TTL_MS skip that beta without paying a first-fail each time.
261
+ // Memory only - not persisted to disk.
262
+ const SESSION_REJECTED_BETA_TTL_MS = 5 * 60 * 1000; // 5 minutes
263
+ /** @type {Map<string, number>} canonical-beta to rejected-at epoch ms */
264
+ const sessionRejectedBetas = new Map();
265
+
243
266
  // Context-hint controller (CC v2.1.110+). Mirrors real CC's `createContextHintController`:
244
267
  // sticky on across requests until the server responds with a specific error family.
245
268
  // - 422/424 → apply hint compaction (clear thinking + microcompact) and retry
@@ -491,8 +514,13 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
491
514
 
492
515
  // Validate CSRF state parameter (RFC 6749 §10.12)
493
516
  // If we stored a state, the returned code MUST include a matching state (QA fix C2)
494
- const codeParts = code.split("#");
495
- const returnedState = codeParts[1];
517
+ // parseOAuthCallback splits "code#state" or a full callback URL into parts.
518
+ // _parsedCode is unused after F3 fix (exchange() re-parses internally); kept
519
+ // only to extract returnedState for the CSRF check below.
520
+ const { code: _parsedCode, state: returnedState } = parseOAuthCallback(code);
521
+ // F3: `exchange()` calls parseOAuthCallback() internally; pass the original
522
+ // user input so it can forward `state` to the token endpoint.
523
+ // CSRF validation already done above via returnedState === pending.state.
496
524
  if (pending.state) {
497
525
  if (!returnedState || returnedState !== pending.state) {
498
526
  pendingSlashOAuth.delete(sessionID);
@@ -2627,6 +2655,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2627
2655
  let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
2628
2656
  let _overloadRecoveryAttempted = false; // Guard: only one quota-aware switch per request
2629
2657
  let _connectionResetRetries = 0; // Cap ECONNRESET/EPIPE retries to prevent infinite loop
2658
+ let customBetasStripped = false; // One-shot latch: strip config.custom_betas once per logical request
2630
2659
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2631
2660
  // Select account — use pinned account on first attempt if available
2632
2661
  const account =
@@ -2845,12 +2874,27 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2845
2874
  }
2846
2875
  }
2847
2876
 
2877
+ // F4: filter out session-rejected custom betas before building the header.
2878
+ // This means the second+ request in the same plugin instance already omits
2879
+ // a rejected beta without needing to pay a first-fail again.
2880
+ const _sessionFilteredCustomBetas = customBetasStripped
2881
+ ? []
2882
+ : (config.custom_betas ?? []).filter((b) => {
2883
+ const canonical = resolveBetaShortcut(b);
2884
+ const rejectedAt = sessionRejectedBetas.get(canonical);
2885
+ if (rejectedAt == null) return true;
2886
+ if (Date.now() - rejectedAt > SESSION_REJECTED_BETA_TTL_MS) {
2887
+ sessionRejectedBetas.delete(canonical);
2888
+ return true;
2889
+ }
2890
+ return false;
2891
+ });
2848
2892
  let computedBetaHeader = buildAnthropicBetaHeader(
2849
2893
  "",
2850
2894
  getSignatureEmulationEnabled(),
2851
2895
  _reqModel,
2852
2896
  _reqProvider,
2853
- config.custom_betas,
2897
+ _sessionFilteredCustomBetas,
2854
2898
  getEffectiveStrategy(),
2855
2899
  requestUrl?.pathname,
2856
2900
  _reqHasFileRefs,
@@ -2884,6 +2928,33 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2884
2928
  merged.delete("context-hint-2026-04-09");
2885
2929
  betaLatchState.sent.delete("context-hint-2026-04-09");
2886
2930
  }
2931
+ // Custom-beta strip kill switch: when the server rejected our custom
2932
+ // betas (customBetasStripped latch fired), evict them from the latch so
2933
+ // they do not re-appear on the retry attempt.
2934
+ // F1: delete both the raw alias AND the canonical form so that aliases
2935
+ // like "cache-diag" (cache-diagnosis-2026-04-07) are fully evicted.
2936
+ if (customBetasStripped && config.custom_betas?.length) {
2937
+ for (const b of config.custom_betas) {
2938
+ const canonical = resolveBetaShortcut(b);
2939
+ merged.delete(b);
2940
+ merged.delete(canonical);
2941
+ betaLatchState.sent.delete(b);
2942
+ betaLatchState.sent.delete(canonical);
2943
+ }
2944
+ }
2945
+ // F4: Evict session-rejected betas from the latch on every request so
2946
+ // they cannot re-enter the sent set from a prior latched header.
2947
+ if (sessionRejectedBetas.size > 0) {
2948
+ const _now = Date.now();
2949
+ for (const [_canonical, _rejectedAt] of sessionRejectedBetas) {
2950
+ if (_now - _rejectedAt <= SESSION_REJECTED_BETA_TTL_MS) {
2951
+ merged.delete(_canonical);
2952
+ betaLatchState.sent.delete(_canonical);
2953
+ } else {
2954
+ sessionRejectedBetas.delete(_canonical);
2955
+ }
2956
+ }
2957
+ }
2887
2958
  computedBetaHeader = [...merged].join(",");
2888
2959
  betaLatchState.lastHeader = computedBetaHeader;
2889
2960
  }
@@ -2961,7 +3032,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2961
3032
  {
2962
3033
  enabled: getSignatureEmulationEnabled(),
2963
3034
  claudeCliVersion,
2964
- customBetas: config.custom_betas,
3035
+ customBetas: _sessionFilteredCustomBetas,
2965
3036
  strategy: getEffectiveStrategy(),
2966
3037
  sessionId: signatureSessionId,
2967
3038
  },
@@ -3390,6 +3461,51 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3390
3461
  }
3391
3462
  }
3392
3463
 
3464
+ // Selective custom-beta retry: if the server rejects the request citing an
3465
+ // anthropic-beta issue not caught by the context-hint handler above, strip
3466
+ // config.custom_betas once and retry.
3467
+ // F2: 413 only triggers this path when the body contains an explicit signal
3468
+ // keyword (anthropic-beta, beta header, unsupported/unknown/invalid beta,
3469
+ // context_window, long context, 1m/million context) to avoid false positives
3470
+ // on generic 413s (e.g. plain upload-size limits with an empty body).
3471
+ // One retry per logical request (latch prevents loop).
3472
+ if (
3473
+ !customBetasStripped &&
3474
+ (config.custom_betas?.length ?? 0) > 0 &&
3475
+ ((response.status === 400 &&
3476
+ errorBody &&
3477
+ errorBody.includes("anthropic-beta") &&
3478
+ !errorBody.includes("context-hint")) ||
3479
+ (response.status === 413 &&
3480
+ errorBody &&
3481
+ /anthropic-beta|beta header|unsupported beta|unknown beta|invalid beta|context_window|long context|1m context|million context/i.test(
3482
+ errorBody,
3483
+ )))
3484
+ ) {
3485
+ customBetasStripped = true;
3486
+ // F4: record rejection in session latch so next logical request already
3487
+ // omits the rejected beta without needing a first-fail.
3488
+ // Only record betas explicitly mentioned in the error body (raw or canonical);
3489
+ // fall back to recording all custom betas if the body names none specifically.
3490
+ {
3491
+ const _allCustom = config.custom_betas ?? [];
3492
+ const _mentioned = _allCustom.filter((_sb) => {
3493
+ const _rawLc = _sb.toLowerCase();
3494
+ const _canLc = resolveBetaShortcut(_sb).toLowerCase();
3495
+ const _bodyLc = (errorBody || "").toLowerCase();
3496
+ return _bodyLc.includes(_rawLc) || _bodyLc.includes(_canLc);
3497
+ });
3498
+ const _toRecord = _mentioned.length > 0 ? _mentioned : _allCustom;
3499
+ const _recordedAt = Date.now();
3500
+ for (const _sb of _toRecord) {
3501
+ sessionRejectedBetas.set(resolveBetaShortcut(_sb), _recordedAt);
3502
+ }
3503
+ }
3504
+ attempt--;
3505
+ debugLog("custom beta/context rejection - retrying without custom betas");
3506
+ continue;
3507
+ }
3508
+
3393
3509
  // Reactive compaction: on "prompt too long" error, trim oldest messages and retry once
3394
3510
  if (
3395
3511
  response.status === 400 &&
@@ -5390,61 +5506,6 @@ process.once("beforeExit", _beforeExitHandler);
5390
5506
  // Request building helpers (extracted from original fetch interceptor)
5391
5507
  // ---------------------------------------------------------------------------
5392
5508
 
5393
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.119";
5394
- const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
5395
- const CLAUDE_CODE_BUILD_TIME = "2026-04-23T19:08:52Z";
5396
-
5397
- // The @anthropic-ai/sdk version bundled with Claude Code.
5398
- // This is distinct from the CLI version and goes in X-Stainless-Package-Version.
5399
- // v2.1.107 switched from @anthropic-ai/sdk v0.208.0 to v0.81.0 (confirmed via bundle var x$H="0.81.0").
5400
- // Still 0.81.0 in v2.1.119 (verified via strings extraction from native Bun binary;
5401
- // adds cache-diagnosis-2026-04-07 beta gated by GrowthBook tengu_prompt_cache_diagnostics;
5402
- // no other beta additions/removals vs 2.1.117; OAuth flow unchanged).
5403
- const ANTHROPIC_SDK_VERSION = "0.81.0";
5404
-
5405
- // Map of CLI version → bundled SDK version (update when CLI version changes)
5406
- const CLI_TO_SDK_VERSION = new Map([
5407
- ["2.1.119", "0.81.0"],
5408
- ["2.1.117", "0.81.0"],
5409
- ["2.1.116", "0.81.0"],
5410
- ["2.1.115", "0.81.0"],
5411
- ["2.1.114", "0.81.0"],
5412
- ["2.1.113", "0.81.0"],
5413
- ["2.1.112", "0.81.0"],
5414
- ["2.1.111", "0.81.0"],
5415
- ["2.1.110", "0.81.0"],
5416
- ["2.1.109", "0.81.0"],
5417
- ["2.1.108", "0.81.0"],
5418
- ["2.1.107", "0.81.0"],
5419
- ["2.1.105", "0.81.0"],
5420
- ["2.1.97", "0.208.0"],
5421
- ["2.1.96", "0.208.0"],
5422
- ["2.1.95", "0.208.0"],
5423
- ["2.1.94", "0.208.0"],
5424
- ["2.1.93", "0.208.0"],
5425
- ["2.1.92", "0.208.0"],
5426
- ["2.1.91", "0.208.0"],
5427
- ["2.1.90", "0.208.0"],
5428
- ["2.1.89", "0.208.0"],
5429
- ["2.1.88", "0.208.0"],
5430
- ["2.1.87", "0.208.0"],
5431
- ["2.1.86", "0.208.0"],
5432
- ["2.1.85", "0.208.0"],
5433
- ["2.1.84", "0.208.0"],
5434
- ["2.1.83", "0.208.0"],
5435
- ["2.1.81", "0.208.0"],
5436
- ["2.1.80", "0.208.0"],
5437
- ]);
5438
-
5439
- /**
5440
- * Get the SDK version corresponding to a CLI version.
5441
- * Falls back to ANTHROPIC_SDK_VERSION constant.
5442
- * @param {string} cliVersion
5443
- * @returns {string}
5444
- */
5445
- function getSdkVersion(cliVersion) {
5446
- return CLI_TO_SDK_VERSION.get(cliVersion) ?? ANTHROPIC_SDK_VERSION;
5447
- }
5448
5509
  const BILLING_HASH_SALT = "59cf53e54c78";
5449
5510
  const BILLING_HASH_INDICES = [4, 7, 20];
5450
5511
 
@@ -6216,42 +6277,6 @@ const CORE_TOOL_NAMES = new Set([
6216
6277
  "Compress",
6217
6278
  ]);
6218
6279
  const HOST_SDK_BETAS_BLOCKLIST = new Set(["fine-grained-tool-streaming-2025-05-14", "structured-outputs-2025-11-13"]);
6219
- const EXPERIMENTAL_BETA_FLAGS = new Set([
6220
- "adaptive-thinking-2026-01-28",
6221
- "advanced-tool-use-2025-11-20",
6222
- "advisor-tool-2026-03-01",
6223
- "afk-mode-2026-01-31",
6224
- "cache-diagnosis-2026-04-07",
6225
- "code-execution-2025-08-25",
6226
- "compact-2026-01-12",
6227
- "context-1m-2025-08-07",
6228
- "context-hint-2026-04-09",
6229
- "context-management-2025-06-27",
6230
- "fast-mode-2026-02-01",
6231
- "files-api-2025-04-14",
6232
- "interleaved-thinking-2025-05-14",
6233
- "prompt-caching-scope-2026-01-05",
6234
- "redact-thinking-2026-02-12",
6235
- "structured-outputs-2025-12-15",
6236
- "task-budgets-2026-03-13",
6237
- "tool-search-tool-2025-10-19",
6238
- "web-search-2025-03-05",
6239
- ]);
6240
- const BETA_SHORTCUTS = new Map([
6241
- ["1m", "context-1m-2025-08-07"],
6242
- ["1m-context", "context-1m-2025-08-07"],
6243
- ["context-1m", "context-1m-2025-08-07"],
6244
- ["cache-diagnosis", "cache-diagnosis-2026-04-07"],
6245
- ["cache-diag", "cache-diagnosis-2026-04-07"],
6246
- ["context-hint", "context-hint-2026-04-09"],
6247
- ["hint", "context-hint-2026-04-09"],
6248
- ["fast", "fast-mode-2026-02-01"],
6249
- ["fast-mode", "fast-mode-2026-02-01"],
6250
- ["opus-fast", "fast-mode-2026-02-01"],
6251
- ["task-budgets", "task-budgets-2026-03-13"],
6252
- ["budgets", "task-budgets-2026-03-13"],
6253
- ["redact-thinking", "redact-thinking-2026-02-12"],
6254
- ]);
6255
6280
  const STAINLESS_HELPER_KEYS = [
6256
6281
  "x_stainless_helper",
6257
6282
  "x-stainless-helper",
@@ -6355,17 +6380,6 @@ function isFalsyEnv(value) {
6355
6380
  return normalized === "0" || normalized === "false" || normalized === "no";
6356
6381
  }
6357
6382
 
6358
- /**
6359
- * @param {string | undefined} value
6360
- * @returns {string}
6361
- */
6362
- function resolveBetaShortcut(value) {
6363
- if (!value) return "";
6364
- const trimmed = value.trim();
6365
- const mapped = BETA_SHORTCUTS.get(trimmed.toLowerCase());
6366
- return mapped || trimmed;
6367
- }
6368
-
6369
6383
  /**
6370
6384
  * @returns {boolean}
6371
6385
  */
@@ -6374,22 +6388,6 @@ function isNonInteractiveMode() {
6374
6388
  return !process.stdout.isTTY;
6375
6389
  }
6376
6390
 
6377
- /**
6378
- * Build the extended User-Agent for API calls.
6379
- * Real CC v96 sends "claude-cli/{version} (external, {entrypoint})" — confirmed via
6380
- * proxy capture of real CC on Windows/Node.js.
6381
- * @param {string} version
6382
- * @returns {string}
6383
- */
6384
- function buildExtendedUserAgent(version) {
6385
- const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
6386
- const sdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` : "";
6387
- const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
6388
- ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`
6389
- : "";
6390
- return `claude-cli/${version} (external, ${entrypoint}${sdkVersion}${clientApp})`;
6391
- }
6392
-
6393
6391
  /**
6394
6392
  * @returns {Record<string, string>}
6395
6393
  */
@@ -7410,6 +7408,12 @@ function buildAnthropicBetaHeader(
7410
7408
  betas.push("context-hint-2026-04-09");
7411
7409
  }
7412
7410
 
7411
+ // extended-cache-ttl-2025-04-11 — found in CC v2.1.133. Extends server-side
7412
+ // prompt cache TTL. Auto-on for 1P + Claude 4+ to improve cache hit rates.
7413
+ if (isFirstPartyProvider && !/claude-3-/i.test(model)) {
7414
+ betas.push("extended-cache-ttl-2025-04-11");
7415
+ }
7416
+
7413
7417
  // Files API — scoped to file endpoints/references
7414
7418
  if (isFilesEndpoint || hasFileReferences) {
7415
7419
  betas.push("files-api-2025-04-14");
@@ -7800,6 +7804,18 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7800
7804
  delete parsed.effort;
7801
7805
  }
7802
7806
 
7807
+ // Claude Code v2.1.117+: default effort for Pro/Max on adaptive models is
7808
+ // "high" (was "medium"). If the host omits effort entirely, inject the
7809
+ // default so the wire shape matches real CC.
7810
+ if (parsed.model && isAdaptiveThinkingModel(parsed.model)) {
7811
+ if (!parsed.output_config || typeof parsed.output_config !== "object") {
7812
+ parsed.output_config = {};
7813
+ }
7814
+ if (!("effort" in parsed.output_config)) {
7815
+ parsed.output_config.effort = "high";
7816
+ }
7817
+ }
7818
+
7803
7819
  // Claude Code temperature rule: when extended thinking is active (any type),
7804
7820
  // temperature must be omitted (undefined). Otherwise default to 1.
7805
7821
  const thinkingActive =
@@ -8824,7 +8840,9 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
8824
8840
  try {
8825
8841
  const diskAuthBeforeRefresh = await readDiskAccountAuth(account.id);
8826
8842
  const adopted = applyDiskAuthIfFresher(account, diskAuthBeforeRefresh);
8827
- if (source === "foreground" && adopted && account.access && account.expires && account.expires > Date.now()) {
8843
+ // Apply fresher disk tokens for both foreground and idle paths — prevents an
8844
+ // unnecessary HTTP refresh when another process already rotated the token.
8845
+ if (adopted && account.access && account.expires && account.expires > Date.now()) {
8828
8846
  return account.access;
8829
8847
  }
8830
8848
 
package/lib/oauth.mjs CHANGED
@@ -179,6 +179,84 @@ export async function authorize(mode) {
179
179
  };
180
180
  }
181
181
 
182
+ /**
183
+ * Parse an OAuth callback input into { code, state }.
184
+ *
185
+ * Tolerates all these input shapes:
186
+ * - Full redirect URL: "https://host/path?code=X&state=Y"
187
+ * - Query string: "?code=X&state=Y" or "code=X&state=Y"
188
+ * - Hash fragment URL: "https://host/path#code=X&state=Y"
189
+ * - Hash fragment bare: "#code=X&state=Y"
190
+ * - Legacy code#state: "abc123#statevalue"
191
+ * - Plain code: "abc123"
192
+ *
193
+ * Inputs are trimmed. URLSearchParams decoding is applied where applicable.
194
+ * @param {string | null | undefined} input
195
+ * @returns {{ code: string, state: string | null }}
196
+ */
197
+ export function parseOAuthCallback(input) {
198
+ if (!input || typeof input !== "string") return { code: "", state: null };
199
+ const trimmed = input.trim();
200
+ if (!trimmed) return { code: "", state: null };
201
+
202
+ // --- Attempt 1: full URL (https://... or http://...) ---
203
+ if (trimmed.includes("://")) {
204
+ try {
205
+ const url = new URL(trimmed);
206
+ // Prefer hash params if they carry a "code" key (some OAuth flows use fragment)
207
+ const hashStr = url.hash.slice(1);
208
+ if (hashStr && hashStr.includes("=")) {
209
+ const hashParams = new URLSearchParams(hashStr);
210
+ const hCode = hashParams.get("code");
211
+ if (hCode) return { code: hCode, state: hashParams.get("state") };
212
+ }
213
+ const qCode = url.searchParams.get("code");
214
+ if (qCode) return { code: qCode, state: url.searchParams.get("state") };
215
+ } catch {
216
+ // fall through
217
+ }
218
+ }
219
+
220
+ // --- Attempt 2: query string ("?code=X&state=Y" or "code=X&state=Y") ---
221
+ {
222
+ const qs = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
223
+ if (qs.includes("=") && (qs.startsWith("code") || qs.includes("&code"))) {
224
+ try {
225
+ const params = new URLSearchParams(qs);
226
+ const qCode = params.get("code");
227
+ if (qCode) return { code: qCode, state: params.get("state") };
228
+ } catch {
229
+ // fall through
230
+ }
231
+ }
232
+ }
233
+
234
+ // --- Attempt 3: hash fragment bare ("#code=X&state=Y") ---
235
+ if (trimmed.startsWith("#")) {
236
+ const hashStr = trimmed.slice(1);
237
+ if (hashStr.includes("=")) {
238
+ try {
239
+ const params = new URLSearchParams(hashStr);
240
+ const hCode = params.get("code");
241
+ if (hCode) return { code: hCode, state: params.get("state") };
242
+ } catch {
243
+ // fall through
244
+ }
245
+ }
246
+ }
247
+
248
+ // --- Attempt 4: "code#state" bare format ---
249
+ const hashIdx = trimmed.indexOf("#");
250
+ if (hashIdx > 0) {
251
+ const codePart = trimmed.slice(0, hashIdx);
252
+ const statePart = trimmed.slice(hashIdx + 1);
253
+ return { code: codePart, state: statePart || null };
254
+ }
255
+
256
+ // --- Attempt 5: plain code ---
257
+ return { code: trimmed, state: null };
258
+ }
259
+
182
260
  /**
183
261
  * Exchange an authorization code for tokens.
184
262
  * @param {string} code
@@ -220,10 +298,18 @@ export async function exchange(code, verifier) {
220
298
  };
221
299
  };
222
300
 
223
- const splits = code.split("#");
301
+ const { code: _authCode, state: _authState } = parseOAuthCallback(code);
224
302
  let result;
225
303
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
226
304
  try {
305
+ const _exchangeBody = {
306
+ grant_type: "authorization_code",
307
+ code: _authCode,
308
+ redirect_uri: OAUTH_REDIRECT_URI,
309
+ client_id: CLIENT_ID,
310
+ code_verifier: verifier,
311
+ ...(_authState != null ? { state: _authState } : {}),
312
+ };
227
313
  result = await fetch(OAUTH_TOKEN_URL, {
228
314
  method: "POST",
229
315
  headers: {
@@ -231,14 +317,7 @@ export async function exchange(code, verifier) {
231
317
  "Content-Type": "application/json",
232
318
  "User-Agent": OAUTH_USER_AGENT,
233
319
  },
234
- body: JSON.stringify({
235
- grant_type: "authorization_code",
236
- code: splits[0],
237
- redirect_uri: OAUTH_REDIRECT_URI,
238
- client_id: CLIENT_ID,
239
- code_verifier: verifier,
240
- state: splits[1],
241
- }),
320
+ body: JSON.stringify(_exchangeBody),
242
321
  signal: AbortSignal.timeout(15_000),
243
322
  });
244
323
  } catch (err) {
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
2
- import { exchange, refreshToken, revoke } from "./oauth.mjs";
2
+ import { exchange, parseOAuthCallback, refreshToken, revoke } from "./oauth.mjs";
3
3
 
4
4
  const mockFetch = vi.fn();
5
5
 
@@ -164,3 +164,122 @@ describe("oauth headers", () => {
164
164
  }
165
165
  });
166
166
  });
167
+
168
+ describe("parseOAuthCallback", () => {
169
+ it("returns empty code and null state for empty/falsy input", () => {
170
+ expect(parseOAuthCallback("")).toEqual({ code: "", state: null });
171
+ expect(parseOAuthCallback(" ")).toEqual({ code: "", state: null });
172
+ expect(parseOAuthCallback(null)).toEqual({ code: "", state: null });
173
+ });
174
+
175
+ it("handles plain authorization code (no state)", () => {
176
+ expect(parseOAuthCallback("myplaincode123")).toEqual({ code: "myplaincode123", state: null });
177
+ });
178
+
179
+ it("trims surrounding whitespace from plain code", () => {
180
+ expect(parseOAuthCallback(" abc123 ")).toEqual({ code: "abc123", state: null });
181
+ });
182
+
183
+ it("parses legacy code#state bare format", () => {
184
+ expect(parseOAuthCallback("authcode#statetoken")).toEqual({ code: "authcode", state: "statetoken" });
185
+ });
186
+
187
+ it("returns null state when # present but empty state segment", () => {
188
+ expect(parseOAuthCallback("authcode#")).toEqual({ code: "authcode", state: null });
189
+ });
190
+
191
+ it("parses full redirect URL with query params", () => {
192
+ expect(parseOAuthCallback("https://example.com/callback?code=MYCODE&state=MYSTATE")).toEqual({
193
+ code: "MYCODE",
194
+ state: "MYSTATE",
195
+ });
196
+ });
197
+
198
+ it("parses full redirect URL with only code param", () => {
199
+ expect(parseOAuthCallback("https://example.com/callback?code=ONLYCODE")).toEqual({
200
+ code: "ONLYCODE",
201
+ state: null,
202
+ });
203
+ });
204
+
205
+ it("parses query string with leading ?", () => {
206
+ expect(parseOAuthCallback("?code=QS_CODE&state=QS_STATE")).toEqual({
207
+ code: "QS_CODE",
208
+ state: "QS_STATE",
209
+ });
210
+ });
211
+
212
+ it("parses bare query string without leading ?", () => {
213
+ expect(parseOAuthCallback("code=BARE_CODE&state=BARE_STATE")).toEqual({
214
+ code: "BARE_CODE",
215
+ state: "BARE_STATE",
216
+ });
217
+ });
218
+
219
+ it("parses hash fragment URL (OAuth implicit / SPA flows)", () => {
220
+ expect(parseOAuthCallback("https://example.com/callback#code=HASH_CODE&state=HASH_STATE")).toEqual({
221
+ code: "HASH_CODE",
222
+ state: "HASH_STATE",
223
+ });
224
+ });
225
+
226
+ it("parses bare hash fragment string", () => {
227
+ expect(parseOAuthCallback("#code=HFRAG_CODE&state=HFRAG_STATE")).toEqual({
228
+ code: "HFRAG_CODE",
229
+ state: "HFRAG_STATE",
230
+ });
231
+ });
232
+
233
+ it("URL-decodes percent-encoded code and state values", () => {
234
+ expect(parseOAuthCallback("?code=hello%20world&state=foo%2Bbar")).toEqual({
235
+ code: "hello world",
236
+ state: "foo+bar",
237
+ });
238
+ });
239
+
240
+ it("returns empty string (not null) when state query param is present but empty", () => {
241
+ // state= is explicitly present with an empty value; URLSearchParams.get() returns ""
242
+ // for keys that exist with no value, so state is "" not null.
243
+ expect(parseOAuthCallback("?code=CODE&state=")).toEqual({ code: "CODE", state: "" });
244
+ });
245
+
246
+ it("exchange function uses parseOAuthCallback internally (code#state still works)", async () => {
247
+ const mockFetch = vi.fn().mockResolvedValueOnce({
248
+ ok: true,
249
+ status: 200,
250
+ headers: { get: () => null },
251
+ json: async () => ({ access_token: "test_token", token_type: "bearer" }),
252
+ text: async () => '{"access_token":"test_token","token_type":"bearer"}',
253
+ });
254
+ vi.stubGlobal("fetch", mockFetch);
255
+ try {
256
+ await exchange("CODE_VALUE#STATE_VALUE", "verifier123");
257
+ expect(mockFetch).toHaveBeenCalledTimes(1);
258
+ const bodyStr = mockFetch.mock.calls[0][1].body;
259
+ const body = JSON.parse(bodyStr);
260
+ expect(body.code).toBe("CODE_VALUE");
261
+ expect(body.state).toBe("STATE_VALUE");
262
+ } finally {
263
+ vi.unstubAllGlobals();
264
+ }
265
+ });
266
+
267
+ it("exchange omits state field in body when input is a plain code", async () => {
268
+ const mockFetch = vi.fn().mockResolvedValueOnce({
269
+ ok: false,
270
+ status: 400,
271
+ headers: { get: () => null },
272
+ text: async () => "",
273
+ });
274
+ vi.stubGlobal("fetch", mockFetch);
275
+ try {
276
+ await exchange("PLAINCODE", "verifier456");
277
+ const bodyStr = mockFetch.mock.calls[0][1].body;
278
+ const body = JSON.parse(bodyStr);
279
+ expect(body.code).toBe("PLAINCODE");
280
+ expect(Object.prototype.hasOwnProperty.call(body, "state")).toBe(false);
281
+ } finally {
282
+ vi.unstubAllGlobals();
283
+ }
284
+ });
285
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Pure header/mimicry helpers extracted from index.mjs.
3
+ * No runtime state, no imports needed - all values are self-contained constants
4
+ * or derive from process.env at call time.
5
+ *
6
+ * Exported:
7
+ * - Version/build/SDK constants and CLI_TO_SDK_VERSION map
8
+ * - getSdkVersion(cliVersion)
9
+ * - EXPERIMENTAL_BETA_FLAGS (Set<string>)
10
+ * - BETA_SHORTCUTS (Map<string, string>)
11
+ * - resolveBetaShortcut(value)
12
+ * - buildExtendedUserAgent(version)
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Version / build / SDK constants
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.133";
20
+ export const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
21
+ export const CLAUDE_CODE_BUILD_TIME = "2026-05-07T18:26:46Z";
22
+
23
+ // The @anthropic-ai/sdk version bundled with Claude Code.
24
+ // This is distinct from the CLI version and goes in X-Stainless-Package-Version.
25
+ // v2.1.107 switched from @anthropic-ai/sdk v0.208.0 to v0.81.0 (confirmed via bundle var x$H="0.81.0").
26
+ // Still 0.81.0 in v2.1.133 (verified via changelog; 2.1.120-2.1.133 are OAuth/MCP/Bedrock
27
+ // fixes with no SDK version bump; cache-diagnosis-2026-04-07 added in 2.1.119 behind
28
+ // GrowthBook tengu_prompt_cache_diagnostics; default effort changed to "high" in 2.1.117).
29
+ export const ANTHROPIC_SDK_VERSION = "0.81.0";
30
+
31
+ // Map of CLI version to bundled SDK version (update when CLI version changes)
32
+ export const CLI_TO_SDK_VERSION = new Map([
33
+ ["2.1.133", "0.81.0"],
34
+ ["2.1.132", "0.81.0"],
35
+ ["2.1.131", "0.81.0"],
36
+ ["2.1.130", "0.81.0"],
37
+ ["2.1.129", "0.81.0"],
38
+ ["2.1.128", "0.81.0"],
39
+ ["2.1.127", "0.81.0"],
40
+ ["2.1.126", "0.81.0"],
41
+ ["2.1.125", "0.81.0"],
42
+ ["2.1.124", "0.81.0"],
43
+ ["2.1.123", "0.81.0"],
44
+ ["2.1.122", "0.81.0"],
45
+ ["2.1.121", "0.81.0"],
46
+ ["2.1.120", "0.81.0"],
47
+ ["2.1.119", "0.81.0"],
48
+ ["2.1.117", "0.81.0"],
49
+ ["2.1.116", "0.81.0"],
50
+ ["2.1.115", "0.81.0"],
51
+ ["2.1.114", "0.81.0"],
52
+ ["2.1.113", "0.81.0"],
53
+ ["2.1.112", "0.81.0"],
54
+ ["2.1.111", "0.81.0"],
55
+ ["2.1.110", "0.81.0"],
56
+ ["2.1.109", "0.81.0"],
57
+ ["2.1.108", "0.81.0"],
58
+ ["2.1.107", "0.81.0"],
59
+ ["2.1.105", "0.81.0"],
60
+ ["2.1.97", "0.208.0"],
61
+ ["2.1.96", "0.208.0"],
62
+ ["2.1.95", "0.208.0"],
63
+ ["2.1.94", "0.208.0"],
64
+ ["2.1.93", "0.208.0"],
65
+ ["2.1.92", "0.208.0"],
66
+ ["2.1.91", "0.208.0"],
67
+ ["2.1.90", "0.208.0"],
68
+ ["2.1.89", "0.208.0"],
69
+ ["2.1.88", "0.208.0"],
70
+ ["2.1.87", "0.208.0"],
71
+ ["2.1.86", "0.208.0"],
72
+ ["2.1.85", "0.208.0"],
73
+ ["2.1.84", "0.208.0"],
74
+ ["2.1.83", "0.208.0"],
75
+ ["2.1.81", "0.208.0"],
76
+ ["2.1.80", "0.208.0"],
77
+ ]);
78
+
79
+ /**
80
+ * Get the SDK version corresponding to a CLI version.
81
+ * Falls back to ANTHROPIC_SDK_VERSION constant.
82
+ * @param {string | null | undefined} cliVersion
83
+ * @returns {string}
84
+ */
85
+ export function getSdkVersion(cliVersion) {
86
+ return CLI_TO_SDK_VERSION.get(cliVersion) ?? ANTHROPIC_SDK_VERSION;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Beta flag registries
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Set of all known experimental/optional beta flags.
95
+ * Used to filter betas when CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1.
96
+ * WARNING: this set intentionally overlaps with always-on betas - only use
97
+ * it with the disable-experimental guard, never unconditionally.
98
+ */
99
+ export const EXPERIMENTAL_BETA_FLAGS = new Set([
100
+ "adaptive-thinking-2026-01-28",
101
+ "advanced-tool-use-2025-11-20",
102
+ "advisor-tool-2026-03-01",
103
+ "afk-mode-2026-01-31",
104
+ "cache-diagnosis-2026-04-07",
105
+ "code-execution-2025-08-25",
106
+ "compact-2026-01-12",
107
+ "context-1m-2025-08-07",
108
+ "context-hint-2026-04-09",
109
+ "context-management-2025-06-27",
110
+ "environments-2025-11-01",
111
+ "extended-cache-ttl-2025-04-11",
112
+ "fast-mode-2026-02-01",
113
+ "files-api-2025-04-14",
114
+ "interleaved-thinking-2025-05-14",
115
+ "prompt-caching-scope-2026-01-05",
116
+ "redact-thinking-2026-02-12",
117
+ "structured-outputs-2025-12-15",
118
+ "task-budgets-2026-03-13",
119
+ "tool-search-tool-2025-10-19",
120
+ "web-search-2025-03-05",
121
+ ]);
122
+
123
+ /** Friendly shortcut aliases for config.custom_betas values. */
124
+ export const BETA_SHORTCUTS = new Map([
125
+ ["1m", "context-1m-2025-08-07"],
126
+ ["1m-context", "context-1m-2025-08-07"],
127
+ ["context-1m", "context-1m-2025-08-07"],
128
+ ["cache-diagnosis", "cache-diagnosis-2026-04-07"],
129
+ ["cache-diag", "cache-diagnosis-2026-04-07"],
130
+ ["cache-ttl", "extended-cache-ttl-2025-04-11"],
131
+ ["context-hint", "context-hint-2026-04-09"],
132
+ ["environments", "environments-2025-11-01"],
133
+ ["extended-cache-ttl", "extended-cache-ttl-2025-04-11"],
134
+ ["hint", "context-hint-2026-04-09"],
135
+ ["fast", "fast-mode-2026-02-01"],
136
+ ["fast-mode", "fast-mode-2026-02-01"],
137
+ ["opus-fast", "fast-mode-2026-02-01"],
138
+ ["task-budgets", "task-budgets-2026-03-13"],
139
+ ["budgets", "task-budgets-2026-03-13"],
140
+ ["redact-thinking", "redact-thinking-2026-02-12"],
141
+ ]);
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Helper functions
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Resolve a beta shortcut alias to its canonical flag name.
149
+ * Returns the value unchanged if no alias matches.
150
+ * @param {string | null | undefined} value
151
+ * @returns {string}
152
+ */
153
+ export function resolveBetaShortcut(value) {
154
+ if (!value) return "";
155
+ const trimmed = value.trim();
156
+ const mapped = BETA_SHORTCUTS.get(trimmed.toLowerCase());
157
+ return mapped || trimmed;
158
+ }
159
+
160
+ /**
161
+ * Build the extended User-Agent for API calls.
162
+ * Real CC v96 sends "claude-cli/{version} (external, {entrypoint})" - confirmed via
163
+ * proxy capture of real CC on Windows/Node.js.
164
+ * @param {string} version
165
+ * @returns {string}
166
+ */
167
+ export function buildExtendedUserAgent(version) {
168
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
169
+ const sdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` : "";
170
+ const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
171
+ ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`
172
+ : "";
173
+ return `claude-cli/${version} (external, ${entrypoint}${sdkVersion}${clientApp})`;
174
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Smoke tests for lib/request-headers.mjs exports.
3
+ * Verifies version constants, beta shortcuts, and user-agent builder.
4
+ */
5
+ import { describe, it, expect, afterEach } from "vitest";
6
+ import {
7
+ FALLBACK_CLAUDE_CLI_VERSION,
8
+ ANTHROPIC_SDK_VERSION,
9
+ getSdkVersion,
10
+ BETA_SHORTCUTS,
11
+ resolveBetaShortcut,
12
+ buildExtendedUserAgent,
13
+ } from "./request-headers.mjs";
14
+
15
+ describe("request-headers constants", () => {
16
+ it("FALLBACK_CLAUDE_CLI_VERSION is 2.1.133", () => {
17
+ expect(FALLBACK_CLAUDE_CLI_VERSION).toBe("2.1.133");
18
+ });
19
+
20
+ it("ANTHROPIC_SDK_VERSION matches expected value", () => {
21
+ expect(typeof ANTHROPIC_SDK_VERSION).toBe("string");
22
+ expect(ANTHROPIC_SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
23
+ });
24
+ });
25
+
26
+ describe("getSdkVersion", () => {
27
+ it("returns known SDK version for CLI version 2.1.133", () => {
28
+ // 2.1.133 is in the VERSION_TO_SDK_MAP
29
+ const v = getSdkVersion("2.1.133");
30
+ expect(typeof v).toBe("string");
31
+ expect(v).toMatch(/^\d+\.\d+\.\d+$/);
32
+ });
33
+
34
+ it("falls back to ANTHROPIC_SDK_VERSION for an unknown CLI version", () => {
35
+ expect(getSdkVersion("9.99.999")).toBe(ANTHROPIC_SDK_VERSION);
36
+ });
37
+
38
+ it("falls back for undefined/null input", () => {
39
+ expect(getSdkVersion(undefined)).toBe(ANTHROPIC_SDK_VERSION);
40
+ expect(getSdkVersion(null)).toBe(ANTHROPIC_SDK_VERSION);
41
+ });
42
+ });
43
+
44
+ describe("BETA_SHORTCUTS", () => {
45
+ it("is a Map", () => {
46
+ expect(BETA_SHORTCUTS).toBeInstanceOf(Map);
47
+ });
48
+
49
+ it("has cache-diagnosis shortcut resolving to cache-diagnosis-2026-04-07", () => {
50
+ expect(BETA_SHORTCUTS.get("cache-diagnosis")).toBe("cache-diagnosis-2026-04-07");
51
+ });
52
+
53
+ it("has a cache-diag alias for cache-diagnosis-2026-04-07", () => {
54
+ expect(BETA_SHORTCUTS.get("cache-diag")).toBe("cache-diagnosis-2026-04-07");
55
+ });
56
+ });
57
+
58
+ describe("resolveBetaShortcut", () => {
59
+ it("expands cache-diagnosis shortcut", () => {
60
+ expect(resolveBetaShortcut("cache-diagnosis")).toBe("cache-diagnosis-2026-04-07");
61
+ });
62
+
63
+ it("expands cache-diag alias", () => {
64
+ expect(resolveBetaShortcut("cache-diag")).toBe("cache-diagnosis-2026-04-07");
65
+ });
66
+
67
+ it("expands 1m shortcut", () => {
68
+ expect(resolveBetaShortcut("1m")).toBe("context-1m-2025-08-07");
69
+ });
70
+
71
+ it("expands fast shortcut", () => {
72
+ expect(resolveBetaShortcut("fast")).toBe("fast-mode-2026-02-01");
73
+ });
74
+
75
+ it("returns the input unchanged when no alias matches", () => {
76
+ expect(resolveBetaShortcut("context-hint-2026-04-09")).toBe("context-hint-2026-04-09");
77
+ expect(resolveBetaShortcut("no-such-beta")).toBe("no-such-beta");
78
+ });
79
+
80
+ it("returns empty string for falsy/empty input", () => {
81
+ expect(resolveBetaShortcut(undefined)).toBe("");
82
+ expect(resolveBetaShortcut("")).toBe("");
83
+ expect(resolveBetaShortcut(null)).toBe("");
84
+ });
85
+ });
86
+
87
+ describe("buildExtendedUserAgent", () => {
88
+ afterEach(() => {
89
+ delete process.env.CLAUDE_CODE_ENTRYPOINT;
90
+ delete process.env.CLAUDE_AGENT_SDK_VERSION;
91
+ delete process.env.CLAUDE_AGENT_SDK_CLIENT_APP;
92
+ });
93
+
94
+ it("returns default cli format without env vars", () => {
95
+ delete process.env.CLAUDE_CODE_ENTRYPOINT;
96
+ delete process.env.CLAUDE_AGENT_SDK_VERSION;
97
+ delete process.env.CLAUDE_AGENT_SDK_CLIENT_APP;
98
+ const ua = buildExtendedUserAgent("2.1.133");
99
+ expect(ua).toBe("claude-cli/2.1.133 (external, cli)");
100
+ });
101
+
102
+ it("uses CLAUDE_CODE_ENTRYPOINT when set", () => {
103
+ process.env.CLAUDE_CODE_ENTRYPOINT = "vscode";
104
+ const ua = buildExtendedUserAgent("2.1.133");
105
+ expect(ua).toBe("claude-cli/2.1.133 (external, vscode)");
106
+ });
107
+
108
+ it("appends agent-sdk suffix when CLAUDE_AGENT_SDK_VERSION is set", () => {
109
+ process.env.CLAUDE_AGENT_SDK_VERSION = "1.2.3";
110
+ const ua = buildExtendedUserAgent("2.1.133");
111
+ expect(ua).toContain("agent-sdk/1.2.3");
112
+ expect(ua).toMatch(/^claude-cli\/2\.1\.133/);
113
+ });
114
+
115
+ it("appends client-app suffix when CLAUDE_AGENT_SDK_CLIENT_APP is set", () => {
116
+ process.env.CLAUDE_AGENT_SDK_CLIENT_APP = "myapp";
117
+ const ua = buildExtendedUserAgent("2.1.133");
118
+ expect(ua).toContain("client-app/myapp");
119
+ });
120
+
121
+ it("uses FALLBACK_CLAUDE_CLI_VERSION when explicitly passed as the version", () => {
122
+ const ua = buildExtendedUserAgent(FALLBACK_CLAUDE_CLI_VERSION);
123
+ expect(ua).toContain(FALLBACK_CLAUDE_CLI_VERSION);
124
+ });
125
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",