opencode-anthropic-fix 0.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,51 @@
1
+ import { OAUTH_EXPIRY_SKEW_SECONDS } from "./oauth-constants.mjs";
2
+
3
+ /** The attested 30-second expiry skew, in milliseconds. @see docs/oauth-2.1.280-contract.md §3 */
4
+ export const TOKEN_EXPIRY_SKEW_MS = OAUTH_EXPIRY_SKEW_SECONDS * 1000;
5
+
6
+ /**
7
+ * The single expiry predicate. Every "is this access token still usable?"
8
+ * question in this repository routes through here.
9
+ *
10
+ * ATTESTED: §13.8 (byte 4418342) records `expiry skew (seconds): 30`. A token is
11
+ * therefore treated as expired 30 s before its nominal expiry, so a request is
12
+ * never sent with a credential that dies in flight.
13
+ *
14
+ * WHY leadMs IS A FLOOR AND NOT AN ADDEND. Some callers deliberately refresh far
15
+ * ahead of expiry — a foreground buffer, an idle-refresh window. Adding the skew
16
+ * to those would silently retune them; dropping the skew for them would leave a
17
+ * site that can still call a token valid seconds before it dies. Taking the
18
+ * larger of the two keeps every tuned lead time exactly as it was while
19
+ * guaranteeing the 30 s floor everywhere.
20
+ *
21
+ * A missing or non-numeric `expires` is EXPIRED, not valid: an unknown expiry is
22
+ * not evidence of freshness, and the alternative is sending a dead token.
23
+ *
24
+ * @param {number | null | undefined} expires - Absolute expiry, ms since epoch
25
+ * @param {number} [leadMs=0] - Extra lead time this caller wants
26
+ * @param {number} [now=Date.now()] - Injectable clock for tests
27
+ * @returns {boolean}
28
+ * @see docs/oauth-2.1.280-contract.md §3
29
+ */
30
+ export function isTokenExpired(expires, leadMs = 0, now = Date.now()) {
31
+ if (typeof expires !== "number" || !Number.isFinite(expires)) return true;
32
+ const lead = Math.max(Number.isFinite(leadMs) ? leadMs : 0, TOKEN_EXPIRY_SKEW_MS);
33
+ return now >= expires - lead;
34
+ }
35
+
36
+ /**
37
+ * Convenience inverse of {@link isTokenExpired}. Exists so call sites read as
38
+ * the question they are asking instead of as a negation.
39
+ *
40
+ * @param {number | null | undefined} expires
41
+ * @param {number} [leadMs=0]
42
+ * @param {number} [now=Date.now()]
43
+ * @returns {boolean}
44
+ */
45
+ export function isTokenUsable(expires, leadMs = 0, now = Date.now()) {
46
+ return !isTokenExpired(expires, leadMs, now);
47
+ }
48
+
1
49
  /**
2
50
  * @typedef {import('./storage.mjs').AccountMetadata} AccountMetadata
3
51
  * @typedef {import('./storage.mjs').AccountStorage} AccountStorage
package/lib/accounts.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isTokenUsable } from "./account-state.mjs";
2
3
  import { loadAccounts, saveAccounts, createDefaultStats } from "./storage.mjs";
3
4
  import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
4
5
  import { calculateBackoffMs } from "./backoff.mjs";
5
6
  import { readCCCredentials } from "./cc-credentials.mjs";
7
+ import { HEADLESS_REFRESH_TOKEN_VAR, resolveHeadlessLogin } from "./oauth-env.mjs";
6
8
 
7
9
  /** QA fix M12: hash token fragment instead of leaking raw prefix */
8
10
  function hashTokenFragment(token) {
@@ -22,7 +24,7 @@ function hashTokenFragment(token) {
22
24
  */
23
25
 
24
26
  /**
25
- * @typedef {'oauth' | 'cc-keychain' | 'cc-file'} AccountSource
27
+ * @typedef {'oauth' | 'cc-keychain' | 'cc-file' | 'env'} AccountSource
26
28
  */
27
29
 
28
30
  /**
@@ -42,7 +44,9 @@ function hashTokenFragment(token) {
42
44
  * @property {number | null} lastFailureTime
43
45
  * @property {string} [lastSwitchReason]
44
46
  * @property {AccountStats} stats
45
- * @property {AccountSource} [source] - Origin of the account. CC accounts must not enter OAuth HTTP refresh.
47
+ * @property {AccountSource} [source] - Origin of the account. CC accounts must not enter OAuth HTTP refresh. env accounts are process-lifetime only and are never persisted.
48
+ * @property {string} [clientId] - OAuth client id to refresh with. Only set for `env` accounts.
49
+ * @property {string[]} [scopes] - Scopes the operator declared for an `env` account. In-memory only.
46
50
  */
47
51
 
48
52
  const MAX_ACCOUNTS = 10;
@@ -58,6 +62,38 @@ const RATE_LIMIT_KEY_FAST = "anthropic:fast";
58
62
 
59
63
  export { RATE_LIMIT_KEY, RATE_LIMIT_KEY_FAST };
60
64
 
65
+ /** One-shot latch so a rotating env credential warns once per process, not once per refresh. */
66
+ let ephemeralRotationWarned = false;
67
+
68
+ /**
69
+ * Warn when the server rotates the refresh token of an `env`-sourced account.
70
+ *
71
+ * An env credential is process-lifetime only by design, so its successor is
72
+ * written nowhere. Left silent, the next process start would re-seed the
73
+ * retired token from the environment and fail with no explanation. This is the
74
+ * one place that can see both the account's origin and the server's response.
75
+ *
76
+ * @param {{ source?: AccountSource, refreshToken?: string }} account
77
+ * @param {string | undefined} rotatedToken - `refresh_token` from the token response
78
+ * @returns {boolean} whether a warning was emitted
79
+ * @see docs/oauth-2.1.280-contract.md §5.2
80
+ */
81
+ export function warnOnEphemeralTokenRotation(account, rotatedToken) {
82
+ if (account?.source !== "env") return false;
83
+ if (!rotatedToken || rotatedToken === account.refreshToken) return false;
84
+ if (ephemeralRotationWarned) return false;
85
+ ephemeralRotationWarned = true;
86
+ console.warn(
87
+ `[anthropic-auth] the server rotated the refresh token for the ${HEADLESS_REFRESH_TOKEN_VAR} account. The new token is held in memory only and will be lost when this process exits; update the environment variable to keep it.`,
88
+ );
89
+ return true;
90
+ }
91
+
92
+ /** Reset the one-shot rotation warning. Exported for tests only. */
93
+ export function resetEphemeralRotationWarning() {
94
+ ephemeralRotationWarned = false;
95
+ }
96
+
61
97
  /**
62
98
  * @typedef {object} StatsDelta
63
99
  * @property {number} requests
@@ -103,9 +139,10 @@ export class AccountManager {
103
139
  * Load accounts from disk, optionally merging with an OpenCode auth fallback.
104
140
  * @param {AnthropicAuthConfig} config
105
141
  * @param {{refresh: string, access?: string, expires?: number} | null} [authFallback]
142
+ * @param {Record<string, string | undefined>} [env]
106
143
  * @returns {Promise<AccountManager>}
107
144
  */
108
- static async load(config, authFallback) {
145
+ static async load(config, authFallback, env = process.env) {
109
146
  const manager = new AccountManager(config);
110
147
  const stored = await loadAccounts();
111
148
 
@@ -144,7 +181,7 @@ export class AccountManager {
144
181
  const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
145
182
  const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
146
183
  const matchExpires = typeof match.expires === "number" ? match.expires : 0;
147
- const fallbackLooksFresh = fallbackHasAccess && fallbackExpires > Date.now();
184
+ const fallbackLooksFresh = fallbackHasAccess && isTokenUsable(fallbackExpires);
148
185
  const shouldAdoptFallback =
149
186
  fallbackLooksFresh && (!match.access || !match.expires || fallbackExpires > matchExpires);
150
187
  if (shouldAdoptFallback) {
@@ -158,6 +195,7 @@ export class AccountManager {
158
195
  // Merge CC credentials if enabled
159
196
  manager.#mergeCC(config);
160
197
 
198
+ manager.#seedEnvAccount(env);
161
199
  return manager;
162
200
  }
163
201
 
@@ -190,6 +228,7 @@ export class AccountManager {
190
228
  // Merge CC credentials if enabled
191
229
  manager.#mergeCC(config);
192
230
 
231
+ manager.#seedEnvAccount(env);
193
232
  return manager;
194
233
  }
195
234
 
@@ -270,6 +309,98 @@ export class AccountManager {
270
309
  }
271
310
  }
272
311
 
312
+ /**
313
+ * Seed a process-lifetime account from CLAUDE_CODE_OAUTH_REFRESH_TOKEN.
314
+ *
315
+ * The seeded account is marked `source: "env"` and is filtered out of every
316
+ * write to `anthropic-accounts.json`: persisting a credential the operator
317
+ * injected transiently would turn an environment variable into a stored
318
+ * secret the user never asked for.
319
+ *
320
+ * It is appended LAST so the positional mapping between `stored.accounts[i]`
321
+ * and `this.#accounts[i]` that `syncActiveIndexFromDisk` relies on still holds
322
+ * for every disk-backed account.
323
+ *
324
+ * A malformed declaration throws rather than degrading to disk accounts: an
325
+ * operator who set the variable meant to use it, and silently ignoring it
326
+ * would run the wrong credential.
327
+ *
328
+ * @param {Record<string, string | undefined> | undefined} env
329
+ */
330
+ #seedEnvAccount(env) {
331
+ const headless = resolveHeadlessLogin(env);
332
+ if (!headless) return;
333
+
334
+ // Already managed on disk. Leave the stored account untouched — re-tagging
335
+ // it `source: "env"` would make the next save silently delete it.
336
+ if (this.#accounts.some((acc) => acc.refreshToken === headless.refreshToken)) return;
337
+
338
+ if (this.#accounts.length >= MAX_ACCOUNTS) {
339
+ console.warn(
340
+ `[anthropic-auth] ${HEADLESS_REFRESH_TOKEN_VAR} was ignored: the account pool is already at its maximum of ${MAX_ACCOUNTS}.`,
341
+ );
342
+ return;
343
+ }
344
+
345
+ // The id is derived from the token hash alone and is therefore stable across
346
+ // restarts: a timestamp would add nothing the hash does not and would break
347
+ // id-keyed correlation between runs.
348
+ const now = Date.now();
349
+ this.#accounts.push({
350
+ id: `env:${hashTokenFragment(headless.refreshToken)}`,
351
+ index: this.#accounts.length,
352
+ email: undefined,
353
+ refreshToken: headless.refreshToken,
354
+ access: undefined,
355
+ expires: undefined,
356
+ tokenUpdatedAt: now,
357
+ addedAt: now,
358
+ lastUsed: 0,
359
+ enabled: true,
360
+ rateLimitResetTimes: {},
361
+ consecutiveFailures: 0,
362
+ lastFailureTime: null,
363
+ lastSwitchReason: "env",
364
+ stats: createDefaultStats(now),
365
+ source: "env",
366
+ clientId: headless.clientId,
367
+ scopes: headless.scopes,
368
+ });
369
+
370
+ if (this.#currentIndex < 0) this.#currentIndex = this.#accounts.length - 1;
371
+ }
372
+
373
+ /**
374
+ * Accounts that may be written to disk. `env` accounts are excluded by design.
375
+ * @returns {ManagedAccount[]}
376
+ */
377
+ #persistableAccounts() {
378
+ return this.#accounts.filter((acc) => acc.source !== "env");
379
+ }
380
+
381
+ /**
382
+ * Discard pending stats deltas belonging to `env` accounts.
383
+ *
384
+ * A delta is the amount to add to the value ON DISK; `recordUsage` has already
385
+ * applied the same increment to the in-memory record, which is why the normal
386
+ * save path rebuilds `mergedStats` from disk plus delta rather than from
387
+ * `acc.stats`. An env account has no disk row, so its in-memory stats are
388
+ * already the whole truth and re-applying the delta would double-count.
389
+ *
390
+ * The deltas must still be dropped: `saveToDisk` can return before the capture
391
+ * point, and a key that is never captured is never cleared, so env deltas would
392
+ * otherwise accumulate for the life of the process.
393
+ *
394
+ * @param {Set<string>} [keys] - Restrict to these delta keys; all when omitted.
395
+ */
396
+ #drainEnvStatsDeltas(keys) {
397
+ for (const acc of this.#accounts) {
398
+ if (acc.source !== "env") continue;
399
+ if (keys && !keys.has(acc.id)) continue;
400
+ this.#statsDeltas.delete(acc.id);
401
+ }
402
+ }
403
+
273
404
  /**
274
405
  * Get the number of enabled accounts.
275
406
  * @returns {number}
@@ -717,10 +848,26 @@ export class AccountManager {
717
848
  return null;
718
849
  };
719
850
 
851
+ // `env` accounts never reach disk. The persisted activeIndex is resolved by
852
+ // account id rather than by position, so it stays correct no matter where an
853
+ // env account sits in the in-memory list.
854
+ const persistable = this.#persistableAccounts();
855
+ const currentAccount = this.#currentIndex >= 0 ? this.#accounts[this.#currentIndex] : undefined;
856
+ const persistedActiveIndex = currentAccount ? persistable.findIndex((acc) => acc.id === currentAccount.id) : -1;
857
+
858
+ // Nothing to persist and nothing on disk: do not create an empty store.
859
+ // Writing `accounts: []` here would make the next load take the
860
+ // "disk is authoritative" branch and permanently refuse to bootstrap from an
861
+ // auth fallback — a process-lifetime env credential must not have that effect.
862
+ if (persistable.length === 0 && diskAccountsById === null) {
863
+ this.#drainEnvStatsDeltas();
864
+ return;
865
+ }
866
+
720
867
  /** @type {AccountStorage} */
721
868
  const storage = {
722
869
  version: 1,
723
- accounts: this.#accounts.map((acc) => {
870
+ accounts: persistable.map((acc) => {
724
871
  const delta = this.#statsDeltas.get(acc.id);
725
872
  let mergedStats = acc.stats;
726
873
  const diskAcc = findDiskAccount(acc);
@@ -794,7 +941,7 @@ export class AccountManager {
794
941
  source: acc.source,
795
942
  };
796
943
  }),
797
- activeIndex: Math.max(0, this.#currentIndex),
944
+ activeIndex: Math.max(0, persistedActiveIndex),
798
945
  };
799
946
 
800
947
  // QA fix H8: snapshot delta keys before the async write to avoid losing
@@ -809,6 +956,11 @@ export class AccountManager {
809
956
  const preloadedDiskData = diskAccountsById ? { version: 1, accounts: [...diskAccountsById.values()] } : null;
810
957
  await saveAccounts(storage, preloadedDiskData);
811
958
 
959
+ // Env accounts are absent from `storage`, so their deltas were never merged.
960
+ // Fold them into the in-memory record before the captured keys are dropped,
961
+ // otherwise every save silently discards an env account's usage.
962
+ this.#drainEnvStatsDeltas(capturedDeltaKeys);
963
+
812
964
  // Clear only the captured deltas; new ones added during await are preserved
813
965
  for (const key of capturedDeltaKeys) {
814
966
  this.#statsDeltas.delete(key);
@@ -833,9 +985,18 @@ export class AccountManager {
833
985
  const stored = await loadAccounts();
834
986
  if (!stored) return;
835
987
 
836
- // Reconcile account list/enabled states with disk (CLI may add/remove/enable/disable)
837
- const existingByTokenForSnapshot = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
838
- const memSnapshot = this.#accounts.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
988
+ // Reconcile account list/enabled states with disk (CLI may add/remove/enable/disable).
989
+ // `env` accounts have no disk representation, so they are held aside and
990
+ // re-appended; comparing them against the disk snapshot would report a
991
+ // difference on every tick and rebuild the list forever.
992
+ // Captured before any rebuild: after the list is rebuilt, position is
993
+ // meaningless and only identity survives.
994
+ const currentBeforeSync = this.#currentIndex >= 0 ? this.#accounts[this.#currentIndex] : undefined;
995
+
996
+ const envAccounts = this.#accounts.filter((acc) => acc.source === "env");
997
+ const persistedMem = this.#persistableAccounts();
998
+ const existingByTokenForSnapshot = new Map(persistedMem.map((acc) => [acc.refreshToken, acc]));
999
+ const memSnapshot = persistedMem.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
839
1000
 
840
1001
  const diskSnapshot = stored.accounts
841
1002
  .map((acc) => {
@@ -845,10 +1006,10 @@ export class AccountManager {
845
1006
  .join("|");
846
1007
 
847
1008
  if (diskSnapshot !== memSnapshot) {
848
- const existingById = new Map(this.#accounts.map((acc) => [acc.id, acc]));
849
- const existingByToken = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
1009
+ const existingById = new Map(persistedMem.map((acc) => [acc.id, acc]));
1010
+ const existingByToken = new Map(persistedMem.map((acc) => [acc.refreshToken, acc]));
850
1011
 
851
- this.#accounts = stored.accounts.map((acc, index) => {
1012
+ const rebuilt = stored.accounts.map((acc, index) => {
852
1013
  const existing =
853
1014
  (acc.id && existingById.get(acc.id)) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
854
1015
  return {
@@ -871,6 +1032,17 @@ export class AccountManager {
871
1032
  };
872
1033
  });
873
1034
 
1035
+ // An env token that has since appeared on disk is no longer ephemeral:
1036
+ // keeping both copies would give one credential two account entries that
1037
+ // invalidate each other on the first refresh.
1038
+ const rebuiltTokens = new Set(rebuilt.map((acc) => acc.refreshToken));
1039
+ const survivingEnvAccounts = envAccounts.filter((acc) => !rebuiltTokens.has(acc.refreshToken));
1040
+
1041
+ this.#accounts = [...rebuilt, ...survivingEnvAccounts];
1042
+ this.#accounts.forEach((acc, i) => {
1043
+ acc.index = i;
1044
+ });
1045
+
874
1046
  // Trackers are index-based; reset when account set/order changes.
875
1047
  this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
876
1048
  this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
@@ -886,8 +1058,35 @@ export class AccountManager {
886
1058
  this.#cursor = 0;
887
1059
  return;
888
1060
  }
1061
+
1062
+ // Re-anchor the selection by identity. Positions shift whenever disk gains
1063
+ // or loses an account, so carrying the old index over silently switches the
1064
+ // caller to a different credential.
1065
+ const reanchored = currentBeforeSync ? this.#accounts.findIndex((acc) => acc.id === currentBeforeSync.id) : -1;
1066
+ if (reanchored >= 0) {
1067
+ this.#currentIndex = reanchored;
1068
+ this.#cursor = reanchored;
1069
+ // This is a BASELINE, not an override. The disk `activeIndex` remains
1070
+ // authoritative for selection — that is the whole point of this method —
1071
+ // so the handshake below still runs. Re-anchoring only decides where we
1072
+ // stand if the handshake declines to move us (its target is disabled or
1073
+ // absent); without it we would be left on whatever account happened to
1074
+ // inherit our old numeric position.
1075
+ } else if (this.#currentIndex >= this.#accounts.length) {
1076
+ // The selected account is gone (a CLI logout-all can empty the disk list
1077
+ // while an env account survives). Land on the last remaining account.
1078
+ this.#currentIndex = this.#accounts.length - 1;
1079
+ this.#cursor = this.#currentIndex;
1080
+ }
889
1081
  }
890
1082
 
1083
+ // `activeIndex` addresses the persisted list, which has no slot for an env
1084
+ // account. When an env account is selected, `saveToDisk` necessarily wrote
1085
+ // some other index, so honouring it here would deselect the env credential on
1086
+ // the very next tick after every save.
1087
+ const current = this.#currentIndex >= 0 ? this.#accounts[this.#currentIndex] : undefined;
1088
+ if (current?.source === "env") return;
1089
+
891
1090
  const diskIndex = Math.min(stored.activeIndex, this.#accounts.length - 1);
892
1091
  if (diskIndex >= 0 && diskIndex !== this.#currentIndex) {
893
1092
  const diskAccount = stored.accounts[diskIndex];
package/lib/betas.mjs ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Host beta policy.
3
+ *
4
+ * Three things live here, and none of them is wire construction:
5
+ * - `EXPERIMENTAL_BETA_FLAGS`: the betas the HOST treats as experimental, i.e.
6
+ * the set it filters out when `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` is on;
7
+ * - `BETA_SHORTCUTS`: CLI UX aliases for `/anthropic betas` and
8
+ * `config.custom_betas`;
9
+ * - `resolveBetaShortcut`: the resolver behind those aliases.
10
+ *
11
+ * The betas that actually go on the wire are the shared package's business
12
+ * (`lib/mimicry/wire-compat.mjs`); this module only decides what the plugin
13
+ * ADDS or SUPPRESSES on top of them.
14
+ *
15
+ * RECONCILIATION IS ENFORCED BY TEST, not by convention. Every literal header
16
+ * below is checked against the package's `BETA_REGISTRY_2_1_233` — the genuine
17
+ * client's own registry — by `lib/betas.test.mjs`, which fails on a stale date
18
+ * stamp and requires the host-only entries (the ones upstream has no registry
19
+ * slot for) to be enumerated and justified rather than merely accumulated. Add
20
+ * a beta here that upstream does not ship and the test will tell you to say why.
21
+ */
22
+
23
+ /**
24
+ * Set of all known experimental/optional beta flags.
25
+ * Used to filter betas when CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1.
26
+ * WARNING: this set intentionally overlaps with always-on betas - only use
27
+ * it with the disable-experimental guard, never unconditionally.
28
+ */
29
+ export const EXPERIMENTAL_BETA_FLAGS = new Set([
30
+ "adaptive-thinking-2026-01-28",
31
+ "advanced-tool-use-2025-11-20",
32
+ "advisor-tool-2026-03-01",
33
+ "afk-mode-2026-01-31",
34
+ "cache-diagnosis-2026-04-07",
35
+ // CCR bring-your-own-cloud beta. Registered in the 2.1.154 binary's _W() table
36
+ // but not emitted on /v1/messages; kept here for the disable-experimental guard.
37
+ "ccr-byoc-2025-07-29",
38
+ "code-execution-2025-08-25",
39
+ "compact-2026-01-12",
40
+ "context-1m-2025-08-07",
41
+ "context-hint-2026-04-09",
42
+ "context-management-2025-06-27",
43
+ "environments-2025-11-01",
44
+ "extended-cache-ttl-2025-04-11",
45
+ // Refusal-fallback credit beta from the upstream Udd registry (CC 2.1.195).
46
+ // Opt-in/GrowthBook-gated; never auto-emitted on a default /v1/messages turn.
47
+ // Listed here for the disable-experimental guard + manual opt-in ONLY.
48
+ "fallback-credit-2026-06-01",
49
+ "fast-mode-2026-02-01",
50
+ "files-api-2025-04-14",
51
+ "interleaved-thinking-2025-05-14",
52
+ // Registered in the 2.1.154 binary's _W() table (mcp_servers label). Plugin
53
+ // proxies MCP tool calls inline and does not emit this on chat completions.
54
+ "mcp-servers-2025-12-04",
55
+ // Registered in CC v2.1.143 but NOT in real CC's always-on emission set
56
+ // (master registry only, not in the always-on subset). Likely GrowthBook-gated
57
+ // pending server rollout. Paired telemetry: tengu_mid_conv_system_fallback_retry.
58
+ "mid-conversation-system-2026-04-07",
59
+ "prompt-caching-scope-2026-01-05",
60
+ "redact-thinking-2026-02-12",
61
+ // Server-side refusal-fallback beta from the upstream Udd registry (CC 2.1.195).
62
+ // Opt-in/GrowthBook-gated; never auto-emitted on a default /v1/messages turn.
63
+ // Listed here for the disable-experimental guard + manual opt-in ONLY.
64
+ "server-side-fallback-2026-06-01",
65
+ "structured-outputs-2025-12-15",
66
+ // Revived in CC 2.1.159 as registry label `narration_summaries` (it was a dead
67
+ // slot from v2.1.90-2.1.154). Real CC emits it only when GrowthBook flag
68
+ // `pewter_owl_header` is on (default-off), first-party, and NOT in fast-mode.
69
+ // Listed here for the disable-guard + manual opt-in ONLY; never emitted always-on.
70
+ "summarize-connector-text-2026-03-13",
71
+ "task-budgets-2026-03-13",
72
+ "tool-search-tool-2025-10-19",
73
+ // SDK admin route beta (used for /v1/user_profiles* endpoints). Registered for
74
+ // forward-compat; the plugin proxies /v1/messages only and does NOT emit this
75
+ // on chat completions.
76
+ "user-profiles-2026-03-24",
77
+ "web-search-2025-03-05",
78
+ ]);
79
+
80
+ /** Friendly shortcut aliases for config.custom_betas values. */
81
+ export const BETA_SHORTCUTS = new Map([
82
+ ["1m", "context-1m-2025-08-07"],
83
+ ["1m-context", "context-1m-2025-08-07"],
84
+ ["context-1m", "context-1m-2025-08-07"],
85
+ ["cache-diagnosis", "cache-diagnosis-2026-04-07"],
86
+ ["cache-diag", "cache-diagnosis-2026-04-07"],
87
+ ["cache-ttl", "extended-cache-ttl-2025-04-11"],
88
+ ["context-hint", "context-hint-2026-04-09"],
89
+ ["environments", "environments-2025-11-01"],
90
+ ["extended-cache-ttl", "extended-cache-ttl-2025-04-11"],
91
+ ["hint", "context-hint-2026-04-09"],
92
+ ["fast", "fast-mode-2026-02-01"],
93
+ ["fast-mode", "fast-mode-2026-02-01"],
94
+ ["opus-fast", "fast-mode-2026-02-01"],
95
+ ["task-budgets", "task-budgets-2026-03-13"],
96
+ ["budgets", "task-budgets-2026-03-13"],
97
+ ["redact-thinking", "redact-thinking-2026-02-12"],
98
+ ["mid-conv-system", "mid-conversation-system-2026-04-07"],
99
+ ["mid-system", "mid-conversation-system-2026-04-07"],
100
+ ["context-management", "context-management-2025-06-27"],
101
+ ["structured-outputs", "structured-outputs-2025-12-15"],
102
+ ["web-search", "web-search-2025-03-05"],
103
+ ["advanced-tool-use", "advanced-tool-use-2025-11-20"],
104
+ ["tool-search-tool", "tool-search-tool-2025-10-19"],
105
+ ["effort", "effort-2025-11-24"],
106
+ ["prompt-caching-scope", "prompt-caching-scope-2026-01-05"],
107
+ ["thinking-token-count", "thinking-token-count-2026-05-13"],
108
+ ["afk-mode", "afk-mode-2026-01-31"],
109
+ ["advisor-tool", "advisor-tool-2026-03-01"],
110
+ ["mcp-servers", "mcp-servers-2025-12-04"],
111
+ ["ccr-byoc", "ccr-byoc-2025-07-29"],
112
+ ["connector-text", "summarize-connector-text-2026-03-13"],
113
+ ["narration-summaries", "summarize-connector-text-2026-03-13"],
114
+ ["summarize-connector-text", "summarize-connector-text-2026-03-13"],
115
+ ["server-side-fallback", "server-side-fallback-2026-06-01"],
116
+ ["fallback", "server-side-fallback-2026-06-01"],
117
+ ["fallback-credit", "fallback-credit-2026-06-01"],
118
+ ]);
119
+
120
+ /**
121
+ * Resolve a beta shortcut alias to its canonical flag name.
122
+ * Returns the value unchanged if no alias matches.
123
+ * @param {string | null | undefined} value
124
+ * @returns {string}
125
+ */
126
+ export function resolveBetaShortcut(value) {
127
+ if (!value) return "";
128
+ const trimmed = value.trim();
129
+ const mapped = BETA_SHORTCUTS.get(trimmed.toLowerCase());
130
+ return mapped || trimmed;
131
+ }