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.
- package/README.md +30 -3
- package/cli.mjs +77 -24
- package/index.mjs +297 -200
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +211 -12
- package/lib/betas.mjs +131 -0
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +4 -2
- package/lib/mimicry/adapter-input.mjs +137 -33
- package/lib/mimicry/headers.mjs +143 -15
- package/lib/mimicry/request-body.mjs +51 -14
- package/lib/mimicry/system-prompt.mjs +72 -293
- package/lib/mimicry/wire-compat.mjs +133 -11
- package/lib/oauth-constants.mjs +109 -0
- package/lib/oauth-env.mjs +187 -0
- package/lib/oauth-scopes.mjs +119 -0
- package/lib/oauth.mjs +270 -90
- package/lib/passthrough-headers.mjs +152 -0
- package/lib/token-economy/microcompact.mjs +3 -12
- package/package.json +2 -2
- package/lib/mimicry/models.mjs +0 -146
- package/lib/request-headers.mjs +0 -303
package/lib/account-state.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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,
|
|
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
|
-
|
|
838
|
-
|
|
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(
|
|
849
|
-
const existingByToken = new Map(
|
|
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
|
-
|
|
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
|
+
}
|