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 +1 -0
- package/index.mjs +142 -124
- package/lib/oauth.mjs +88 -9
- package/lib/oauth.test.mjs +120 -1
- package/lib/request-headers.mjs +174 -0
- package/lib/request-headers.test.mjs +125 -0
- package/package.json +1 -1
package/cli.mjs
CHANGED
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 {
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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) {
|
package/lib/oauth.test.mjs
CHANGED
|
@@ -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
|
+
});
|