@eddyskywalker/dsh-chatgpt-subscription 0.13.2 → 0.14.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/lib/index.js CHANGED
@@ -993,7 +993,7 @@ function parseStoredCredentials(value) {
993
993
  if (typeof candidate !== "string") throw new Error(`${key} is invalid`);
994
994
  return candidate;
995
995
  };
996
- return {
996
+ const credentials = {
997
997
  accessToken: record.accessToken,
998
998
  refreshToken: record.refreshToken,
999
999
  expiresAt: record.expiresAt,
@@ -1002,6 +1002,16 @@ function parseStoredCredentials(value) {
1002
1002
  email: optional("email"),
1003
1003
  planType: optional("planType")
1004
1004
  };
1005
+ if (record.adopted === true) {
1006
+ const source = optional("source");
1007
+ if (source !== void 0) {
1008
+ credentials.adopted = true;
1009
+ credentials.source = source;
1010
+ const sourcePath = optional("sourcePath");
1011
+ if (sourcePath !== void 0) credentials.sourcePath = sourcePath;
1012
+ }
1013
+ }
1014
+ return credentials;
1005
1015
  }
1006
1016
  //#endregion
1007
1017
  //#region src/host/token-store-windows.ts
@@ -2895,7 +2905,433 @@ function poolQuota(fetchedAt, windows) {
2895
2905
  };
2896
2906
  }
2897
2907
  //#endregion
2908
+ //#region src/host/codex-adopt.ts
2909
+ /**
2910
+ * OPT-IN adoption of a pre-existing Codex CLI sign-in.
2911
+ *
2912
+ * WHAT THIS IS FOR. A user who already runs the unmodified Codex CLI is already
2913
+ * signed in to the very subscription this provider serves. Asking them to sign in
2914
+ * a second time is pure friction, so this module offers the one thing that
2915
+ * removes it: reading back the credential the CLI itself stored in
2916
+ * `$CODEX_HOME/auth.json` (`~/.codex/auth.json` when the variable is unset). It
2917
+ * is opt-in — nothing here runs until the user asks for it.
2918
+ *
2919
+ * ---------------------------------------------------------------------------
2920
+ * RULE 1 — THIS MODULE NEVER WRITES. IT IS A READER, AND ONLY A READER.
2921
+ * ---------------------------------------------------------------------------
2922
+ * It must not create, modify, move or delete any file belonging to the Codex CLI.
2923
+ * No file this module touches is ever opened for writing, no directory is
2924
+ * created, nothing is renamed and nothing is unlinked. The guarantee is
2925
+ * structural rather than a promise in prose: the module imports exactly two
2926
+ * functions BY NAME from 'node:fs/promises' — readFile and stat — instead of the
2927
+ * whole namespace, so introducing a write would require a visible change to the
2928
+ * import list, which is the line a reviewer reads first. Nothing exported here
2929
+ * writes. The tests assert the stronger, observable form of the rule: a
2930
+ * credential file's bytes, size and mtime are identical before and after both
2931
+ * entry points run, and its directory gains no entries.
2932
+ *
2933
+ * ---------------------------------------------------------------------------
2934
+ * RULE 2 — PRESENCE AND CONTENT ARE TWO SEPARATE FUNCTIONS
2935
+ * ---------------------------------------------------------------------------
2936
+ * codexCliCredentialPresence() is cheap enough to call on plugin startup and
2937
+ * answers only yes/no: it stats the candidate and never opens it for reading. It
2938
+ * exists so the settings card can offer the import before the user has agreed to
2939
+ * it, and so a user who never opts in never has their token read at all.
2940
+ *
2941
+ * readCodexCliCredentials() is the full read, and it is called only AFTER the
2942
+ * user explicitly opts in. Keeping the two apart is the whole point: a startup
2943
+ * path that slurped the file "just in case" would make the opt-in decorative.
2944
+ *
2945
+ * ---------------------------------------------------------------------------
2946
+ * RULE 3 — AN ADOPTED CREDENTIAL IS A SNAPSHOT, AND THIS PLUGIN NEVER REFRESHES IT
2947
+ * ---------------------------------------------------------------------------
2948
+ * An adopted credential is used only while its own accessToken is still valid.
2949
+ * Once it is expired the pool reports it as expired and tells the user to sign in
2950
+ * again in the Codex CLI and re-adopt. That is deliberate, not a gap: the
2951
+ * ChatGPT refresh token the CLI stores ROTATES, and the CLI refreshes its own
2952
+ * file in place. Two processes refreshing the same grant therefore invalidate
2953
+ * each other — whoever loses the race holds a token the server has already
2954
+ * retired, and the user ends up signed OUT of the Codex CLI by this plugin's good
2955
+ * intentions. An in-process single-flight cannot fix that, because the race is
2956
+ * across PROCESSES: this plugin and the CLI are separate programs with separate
2957
+ * memories, and the only shared state between them is the file — which this
2958
+ * module must not write back into (Rule 1).
2959
+ *
2960
+ * So the honest contract is: borrow the access token for as long as the CLI itself
2961
+ * vouched for it, and never touch the refresh token. Expiry is therefore part of
2962
+ * the adoption decision rather than a detail — see
2963
+ * {@link isAdoptedCodexCredentialExpired}.
2964
+ *
2965
+ * The marker the pool chunk must honour is the literal 'adopted: true' plus
2966
+ * {@link isAdoptedCodexCredential}; an account carrying it must be treated as
2967
+ * read-only, non-refreshable and disposable. There is deliberately no refresh
2968
+ * function in this module to find in the first place.
2969
+ *
2970
+ * ---------------------------------------------------------------------------
2971
+ * RULE 4 — AN UNRECOGNISED SHAPE YIELDS undefined, NEVER AN EXCEPTION
2972
+ * ---------------------------------------------------------------------------
2973
+ * The card shows 'unrecognised local sign-in format' for undefined, which is a far
2974
+ * better outcome than a thrown error escaping into the settings route. Nothing is
2975
+ * guessed: a document in another auth_mode, a missing or non-object tokens
2976
+ * block, a missing access or refresh token, an access token that is not a JWT
2977
+ * with a usable 'exp', an unreadable or unparsable file — all of them are
2978
+ * undefined. Extra and unknown fields are ignored rather than rejected, so a
2979
+ * future CLI that adds one does not break adoption.
2980
+ *
2981
+ * THE TRAP THIS PARSER EXISTS TO AVOID. A Codex auth.json is NOT a flat bag of
2982
+ * tokens. It is a document that may carry an API key alongside the OAuth tokens,
2983
+ * and a "find the first accessToken anywhere in the JSON" scan would pick
2984
+ * whichever one the serializer happened to emit first — which is how an API key
2985
+ * or an unrelated token ends up presented to the Responses API as the user's
2986
+ * subscription credential. The read is therefore a direct, specific lookup of
2987
+ * tokens.access_token and its siblings — never a tree walk — and the test suite
2988
+ * holds a fixture carrying an unrelated token beside the real one and asserts
2989
+ * which comes back.
2990
+ *
2991
+ * ---------------------------------------------------------------------------
2992
+ * THE CLAIMS ARE DERIVED LOCALLY, NEVER ASKED OF ANYBODY
2993
+ * ---------------------------------------------------------------------------
2994
+ * expiresAt comes from the access token's own 'exp' claim, email and planType
2995
+ * from the id token's claims. The JWT payload is decoded in process; no token is
2996
+ * ever sent anywhere to be inspected, and no token value is logged or returned
2997
+ * from anything but the credential object the caller asked for.
2998
+ *
2999
+ * WHY EXPIRY IS REFUSED RATHER THAN DEFAULTED. A snapshot whose validity cannot
3000
+ * be established must not be used. Rather than stamping "now" or "never" and
3001
+ * hoping, this module yields undefined: the card then says the local sign-in
3002
+ * format is unrecognised, which is true, instead of importing a credential that
3003
+ * would 401 on the first request.
3004
+ */
3005
+ /**
3006
+ * Environment variable that relocates the Codex CLI's home directory.
3007
+ *
3008
+ * Highest precedence, and honoured exactly as the CLI honours it: when it is set
3009
+ * to a non-blank value it names the home that holds auth.json, whatever the
3010
+ * platform's conventional location happens to be. This is also what makes the
3011
+ * whole module testable without touching a developer's real profile.
3012
+ */
3013
+ const CODEX_HOME_ENV = "CODEX_HOME";
3014
+ /** File name of the credential document inside the Codex home. */
3015
+ const CODEX_CLI_CREDENTIAL_FILE = "auth.json";
3016
+ /**
3017
+ * Top-level key holding the OAuth tokens.
3018
+ *
3019
+ * Named as a constant because the specificity of this key IS the defence
3020
+ * described in the module comment: the read addresses this key, not the first
3021
+ * token it can find anywhere in the document.
3022
+ */
3023
+ const CODEX_CLI_TOKENS_KEY = "tokens";
3024
+ /** Where an adopted snapshot came from, for the card and for the pool record. */
3025
+ const CODEX_CLI_CREDENTIAL_SOURCE = "codex";
3026
+ /** Namespace the id token carries the ChatGPT account claims under. */
3027
+ const CODEX_AUTH_CLAIMS_KEY = "https://api.openai.com/auth";
3028
+ /**
3029
+ * What the user is told when an adopted credential has aged out.
3030
+ *
3031
+ * Exported so the pool report and the settings card say the same sentence, and
3032
+ * so the instruction is stated once: the re-sign-in happens in the CODEX CLI, not
3033
+ * here, and the import has to be repeated afterwards.
3034
+ */
3035
+ const ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT = "The sign-in imported from the Codex CLI has expired. Sign in again in the Codex CLI, then import it again from this settings card.";
3036
+ function isRecord$31(value) {
3037
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3038
+ }
3039
+ /** A trimmed, non-empty string, or nothing. */
3040
+ function nonEmptyString$5(value) {
3041
+ if (typeof value !== "string") return void 0;
3042
+ const trimmed = value.trim();
3043
+ return trimmed === "" ? void 0 : trimmed;
3044
+ }
3045
+ /**
3046
+ * The Codex CLI's home directory.
3047
+ *
3048
+ * The environment override wins whenever it is set to a non-blank value — a blank
3049
+ * or whitespace-only variable is treated as unset rather than as "the directory
3050
+ * whose name is empty", which on Windows would resolve to a drive-relative path.
3051
+ * Otherwise the conventional home/.codex directory is used, os.homedir() being
3052
+ * the same %USERPROFILE% on Windows and ~ on POSIX that the CLI itself uses.
3053
+ *
3054
+ * Read at CALL time rather than captured in a module constant: a test (or an
3055
+ * embedder) that sets the variable after import must see the change.
3056
+ */
3057
+ function codexHomeDir() {
3058
+ const override = process.env[CODEX_HOME_ENV]?.trim();
3059
+ if (override) return override;
3060
+ return path.join(os.homedir(), ".codex");
3061
+ }
3062
+ /**
3063
+ * Every path this module will look at, most authoritative first.
3064
+ *
3065
+ * A single candidate, which is a judgement call stated plainly. The Claude reader
3066
+ * in this package also falls back to the conventional home when the environment
3067
+ * override points somewhere else; here the override IS the user's own statement of
3068
+ * where this CLI keeps its sign-in, and honouring a stale scratch directory is
3069
+ * more dangerous than reporting nothing: a snapshot belonging to a profile the CLI
3070
+ * is not currently using would be adopted silently. The path actually consulted is
3071
+ * returned and printed by the caller, so "not found" is always accompanied by the
3072
+ * exact place that was looked at.
3073
+ *
3074
+ * A pure function — no filesystem access at all — so it is safe to call just to
3075
+ * render the card, and so its precedence is testable without touching a disk.
3076
+ */
3077
+ function codexCliCredentialPaths() {
3078
+ return [path.join(codexHomeDir(), CODEX_CLI_CREDENTIAL_FILE)];
3079
+ }
3080
+ /**
3081
+ * Whether a plain file exists at one path.
3082
+ *
3083
+ * stat, not lstat: a credential file reached through a symlink is a legitimate
3084
+ * layout — a user may keep profiles behind links — and the question asked here is
3085
+ * whether the client has a file there, not how it is reached.
3086
+ *
3087
+ * Any error (ENOENT, EACCES, ENOTDIR, an unsupported path shape) answers "no". A
3088
+ * failure to establish existence is not evidence of existence, and this path runs
3089
+ * on startup where throwing would take the plugin down over a missing file.
3090
+ */
3091
+ async function existsAsFile$1(candidate) {
3092
+ try {
3093
+ return (await stat(candidate)).isFile();
3094
+ } catch {
3095
+ return false;
3096
+ }
3097
+ }
3098
+ /**
3099
+ * Is there a local Codex CLI sign-in to offer?
3100
+ *
3101
+ * Safe on plugin startup: this NEVER opens a candidate for reading, so a user who
3102
+ * never opts in never has the credential's contents touched — not by this
3103
+ * function, not by the route that renders the card. Only stat runs, which reveals
3104
+ * existence and timestamps and nothing else.
3105
+ *
3106
+ * Deliberately NOT reported here: whether the file is valid, expired, or even
3107
+ * JSON. Answering that would require the read this function exists to avoid, and
3108
+ * the card does not need it — validity is answered by the read, after opt-in, and
3109
+ * lands on the card as either a credential or 'unrecognised local sign-in
3110
+ * format'.
3111
+ *
3112
+ * A boolean, rather than the record the Claude reader returns, because the two
3113
+ * callers want different things and neither needs the other's shape: the status
3114
+ * route wants a flag, and reports the path it consulted beside it. paths
3115
+ * is injectable so tests drive it against fixtures instead of a real profile;
3116
+ * production callers take the default.
3117
+ */
3118
+ async function codexCliCredentialPresence(paths = codexCliCredentialPaths()) {
3119
+ for (const candidate of paths) if (await existsAsFile$1(candidate)) return true;
3120
+ return false;
3121
+ }
3122
+ /**
3123
+ * The claims of a JWT, decoded in process, or nothing.
3124
+ *
3125
+ * The payload is the second base64url segment; the signature is never touched and
3126
+ * the result is used only for the three non-secret facts below. Never sent
3127
+ * anywhere: a token handed to a decoder service is a token handed away.
3128
+ *
3129
+ * No verification is claimed and none is possible here — the signing key is the
3130
+ * issuer's, and this module has no business holding it. What it does instead is
3131
+ * refuse to invent: a token that does not decode is treated as stating nothing.
3132
+ */
3133
+ function decodeJwtClaims(token) {
3134
+ const parts = token.split(".");
3135
+ if (parts.length !== 3) return void 0;
3136
+ try {
3137
+ const value = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
3138
+ return isRecord$31(value) ? value : void 0;
3139
+ } catch {
3140
+ return;
3141
+ }
3142
+ }
3143
+ /**
3144
+ * The access token's expiry, in EPOCH MILLISECONDS, or nothing.
3145
+ *
3146
+ * RFC 7519 states 'exp' in SECONDS since the epoch, and the store's
3147
+ * StoredOAuthCredentials states milliseconds — a thousand-fold difference that is
3148
+ * silent when it goes the wrong way (a credential stamped a thousand times too
3149
+ * late reads as unexpired for centuries) and catastrophic the other. The
3150
+ * conversion is therefore done here, once, with a comment on it.
3151
+ *
3152
+ * Only a finite, non-negative number is accepted. A string, null, NaN, a
3153
+ * negative value or a missing claim are all "this document states no usable
3154
+ * expiry", and the caller's answer to that is to refuse the whole document rather
3155
+ * than import a credential whose validity nothing established.
3156
+ */
3157
+ function expiryFromAccessToken(accessToken) {
3158
+ const claims = decodeJwtClaims(accessToken);
3159
+ if (claims === void 0) return void 0;
3160
+ const exp = claims.exp;
3161
+ if (typeof exp !== "number" || !Number.isFinite(exp) || exp < 0) return void 0;
3162
+ return Math.round(exp * 1e3);
3163
+ }
3164
+ /**
3165
+ * Map one parsed credential document onto the store's credential shape.
3166
+ *
3167
+ * Pure and exported so the format rules can be tested without a filesystem, and
3168
+ * so a later chunk that reads the document from somewhere else (a backup, a pasted
3169
+ * blob) reuses the one set of rules instead of growing a second, drifting copy.
3170
+ *
3171
+ * Returns undefined for every shape it does not recognise, and never throws — see
3172
+ * Rule 4 in the module comment. The gate is deliberately narrow: the document must
3173
+ * be a JSON object in the ChatGPT auth mode, its tokens block must be an object,
3174
+ * and it must carry BOTH a non-empty access token and a non-empty refresh token.
3175
+ * An access token alone is not adopted because the store's record is a sign-in,
3176
+ * not a bare token, and a record with an empty one could never be re-recognised.
3177
+ *
3178
+ * The unrelated-token trap is handled by construction: value.tokens is addressed
3179
+ * directly and no other key of the document is ever traversed, so whatever else
3180
+ * the file carries cannot reach the result.
3181
+ */
3182
+ function parseAdoptedCodexCredential(value, sourcePath) {
3183
+ if (!isRecord$31(value)) return void 0;
3184
+ if (value.auth_mode !== "chatgpt") return void 0;
3185
+ const tokens = value[CODEX_CLI_TOKENS_KEY];
3186
+ if (!isRecord$31(tokens)) return void 0;
3187
+ const accessToken = nonEmptyString$5(tokens.access_token);
3188
+ if (accessToken === void 0) return void 0;
3189
+ const refreshToken = nonEmptyString$5(tokens.refresh_token);
3190
+ if (refreshToken === void 0) return void 0;
3191
+ const expiresAt = expiryFromAccessToken(accessToken);
3192
+ if (expiresAt === void 0) return void 0;
3193
+ const credentials = {
3194
+ accessToken,
3195
+ refreshToken,
3196
+ expiresAt
3197
+ };
3198
+ const idToken = nonEmptyString$5(tokens.id_token);
3199
+ if (idToken !== void 0) {
3200
+ credentials.idToken = idToken;
3201
+ const claims = decodeJwtClaims(idToken);
3202
+ const email = claims === void 0 ? void 0 : nonEmptyString$5(claims.email);
3203
+ if (email !== void 0) credentials.email = email;
3204
+ const authClaims = claims === void 0 ? void 0 : claims[CODEX_AUTH_CLAIMS_KEY];
3205
+ const planType = isRecord$31(authClaims) ? nonEmptyString$5(authClaims.chatgpt_plan_type) : void 0;
3206
+ if (planType !== void 0) credentials.planType = planType;
3207
+ }
3208
+ const accountId = nonEmptyString$5(tokens.account_id);
3209
+ if (accountId !== void 0) credentials.accountId = accountId;
3210
+ return {
3211
+ adopted: true,
3212
+ source: CODEX_CLI_CREDENTIAL_SOURCE,
3213
+ sourcePath,
3214
+ credentials
3215
+ };
3216
+ }
3217
+ /**
3218
+ * Read the local Codex CLI sign-in; the first usable candidate wins.
3219
+ *
3220
+ * ONLY call this after the user has explicitly opted in — see the module comment.
3221
+ *
3222
+ * Never throws and never rejects: an absent file, an unreadable file, a file that
3223
+ * is not JSON, and a JSON document of an unrecognised shape all produce undefined,
3224
+ * which the card renders as 'unrecognised local sign-in format'. A settings route
3225
+ * must not be able to fail because a user's file was mid-write.
3226
+ */
3227
+ async function readCodexCliCredentials(paths = codexCliCredentialPaths()) {
3228
+ for (const candidate of paths) try {
3229
+ const raw = await readFile(candidate, "utf8");
3230
+ const adopted = parseAdoptedCodexCredential(JSON.parse(raw), candidate);
3231
+ if (adopted !== void 0) return adopted;
3232
+ } catch {}
3233
+ }
3234
+ /**
3235
+ * Whether an adopted credential's access token is already unusable.
3236
+ *
3237
+ * A structural mirror of the expiry test the OAuth module applies to its own
3238
+ * credentials (expiresAt - Date.now() > 0), asked of a pooled credential so the
3239
+ * pool can refuse it WITHOUT ever reaching for a refresh this module deliberately
3240
+ * does not perform. A non-finite expiresAt counts as expired, matching the
3241
+ * refusal in the reader: no established validity, no use.
3242
+ *
3243
+ * now is injectable so a test can reason about a fixed clock.
3244
+ */
3245
+ function isAdoptedCodexCredentialExpired(credential, now = Date.now()) {
3246
+ const expiresAt = credential.expiresAt;
3247
+ return !(typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > now);
3248
+ }
3249
+ //#endregion
2898
3250
  //#region src/host/codex-account-pool.ts
3251
+ /**
3252
+ * The ChatGPT account pool.
3253
+ *
3254
+ * TWO KINDS OF ACCOUNT, AND ONLY ONE OF THEM MAY BE REFRESHED.
3255
+ *
3256
+ * - managed — signed in through this plugin. Its credential lives in the pool's
3257
+ * encrypted storage AND in the pre-pool credential document beside it, it is
3258
+ * refreshable, and deleting it is this plugin's business.
3259
+ * - adopted — a SNAPSHOT read out of a local Codex CLI sign-in by 'codex-adopt.ts'
3260
+ * (marker 'adopted: true', source 'codex'). It is used only while its own access
3261
+ * token is still valid.
3262
+ *
3263
+ * An adopted snapshot is NEVER refreshed, and the reason is not an optimisation:
3264
+ * the ChatGPT refresh token the CLI stores rotates, and the CLI refreshes its own
3265
+ * file in place. Two processes spending one grant invalidate each other, and
3266
+ * whoever loses the race holds a token the server has already retired — which
3267
+ * leaves the user signed OUT of the Codex CLI by this plugin's good intentions. An
3268
+ * in-process single-flight cannot fix that, because the race is across PROCESSES
3269
+ * and the only state they share is a file 'codex-adopt.ts' must never write. Once
3270
+ * such a snapshot has expired the pool takes it out of rotation and surfaces
3271
+ * ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT — sign in again in the CODEX CLI and
3272
+ * re-import, not here.
3273
+ *
3274
+ * The rule is enforced twice on purpose: the needsRefresh hook answers false for
3275
+ * an adopted credential no matter what its expiry says, and the refresh hook
3276
+ * itself refuses one, so a future caller that forgets the first fails loudly
3277
+ * instead of silently spending a grant that is not ours to spend.
3278
+ *
3279
+ * MANAGED BEATS ADOPTED on a conflict, and the marker is therefore written on
3280
+ * EVERY account this file creates, including the explicit false case: the core
3281
+ * merges a re-authorization as { ...existing, ...created }, so an omitted key
3282
+ * would leave a stored 'adopted: true' — and its never-refresh consequence — on a
3283
+ * record that is now plugin-owned.
3284
+ */
3285
+ /**
3286
+ * Whether a pooled credential is an adopted snapshot.
3287
+ *
3288
+ * The two fields, and only those two: a plain credential carries no 'adopted'
3289
+ * field and must never be mistaken for a snapshot, because treating a
3290
+ * plugin-owned account as a disposable one would freeze it out of refreshing and
3291
+ * make deleteAccount refuse to remove an account the user signed in here.
3292
+ */
3293
+ function isAdoptedCodexPoolCredential(credentials) {
3294
+ return credentials.adopted === true && credentials.source === "codex";
3295
+ }
3296
+ /**
3297
+ * Whether a credential must be refreshed before the next request.
3298
+ *
3299
+ * FALSE for every adopted snapshot, valid or expired. That is this pool's core
3300
+ * rule: an adopted credential is borrowed for exactly as long as the Codex CLI
3301
+ * vouched for it, and this pool has no business rotating a token another process
3302
+ * is rotating too. See the module comment.
3303
+ */
3304
+ function codexNeedsRefresh(credentials, now) {
3305
+ if (isAdoptedCodexPoolCredential(credentials)) return false;
3306
+ return credentials.expiresAt - now <= TOKEN_REFRESH_MARGIN_MS;
3307
+ }
3308
+ /**
3309
+ * Why a stored credential is already known to be unusable, or nothing.
3310
+ *
3311
+ * Reported purely — nothing is written here — and consulted by the core for both
3312
+ * routing eligibility and credential usability, which is what makes an expired
3313
+ * snapshot non-routable WITHOUT a write on every read. The hint names the remedy
3314
+ * in the Codex CLI, because that is where the user has to go: re-adopting here is
3315
+ * only possible after the CLI itself signed in again.
3316
+ */
3317
+ function codexAuthRejectedReason(credentials) {
3318
+ if (!isAdoptedCodexPoolCredential(credentials)) return void 0;
3319
+ return isAdoptedCodexCredentialExpired(credentials) ? ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT : void 0;
3320
+ }
3321
+ /**
3322
+ * Message raised if a refresh of a still-valid adopted snapshot were attempted.
3323
+ *
3324
+ * Unreachable through this pool's own paths (see the module comment); it exists
3325
+ * because 'unreachable' is a claim the guard has to be able to make good on.
3326
+ */
3327
+ const ADOPTED_NEVER_REFRESHED_MESSAGE$1 = "The sign-in imported from the Codex CLI is never refreshed by this plugin: the Codex CLI refreshes the same rotating token, and two refreshers would invalidate each other. Sign in again in the Codex CLI, then import the new sign-in from this settings card.";
3328
+ /** Raised when a refresh of an adopted snapshot is attempted. Never a 401. */
3329
+ var CodexAdoptedCredentialError = class extends Error {
3330
+ constructor(message) {
3331
+ super(message);
3332
+ this.name = "CodexAdoptedCredentialError";
3333
+ }
3334
+ };
2899
3335
  /** Encrypted pool file the ChatGPT line owns; the single-credential store stays the mirror. */
2900
3336
  function codexPoolPath() {
2901
3337
  return path.join(dshHomeDir(), "storages", "codex-pool.json");
@@ -2916,13 +3352,18 @@ function parseCodexPoolData(value) {
2916
3352
  const raw = item;
2917
3353
  if (typeof raw.id !== "string") continue;
2918
3354
  const credentials = parseStoredCredentials(raw.credentials);
3355
+ const adopted = isAdoptedCodexPoolCredential(credentials);
2919
3356
  const account = {
2920
3357
  id: raw.id,
2921
3358
  alias: typeof raw.alias === "string" ? raw.alias : credentials.email || "账号",
2922
3359
  credentials,
2923
3360
  addedAt: typeof raw.addedAt === "number" ? raw.addedAt : Date.now(),
2924
- isPrimary: raw.isPrimary === true
3361
+ isPrimary: raw.isPrimary === true,
3362
+ adopted,
3363
+ source: adopted ? CODEX_CLI_CREDENTIAL_SOURCE : "managed"
2925
3364
  };
3365
+ const sourcePath = optionalString$9(raw, "sourcePath") ?? credentials.sourcePath;
3366
+ if (adopted && sourcePath !== void 0) account.sourcePath = sourcePath;
2926
3367
  const email = optionalString$9(raw, "email") ?? credentials.email;
2927
3368
  if (email !== void 0) account.email = email;
2928
3369
  const planType = optionalString$9(raw, "planType") ?? credentials.planType;
@@ -2945,14 +3386,6 @@ function parseCodexPoolData(value) {
2945
3386
  return result;
2946
3387
  }
2947
3388
  /**
2948
- * ChatGPT's account pool.
2949
- *
2950
- * Thin wrapper over {@link AccountPoolCore}: it only supplies the credential
2951
- * shape, the identity a duplicate sign-in collapses onto, and the two provider
2952
- * hooks the core cannot know — how to refresh one account's tokens and which
2953
- * accounts a cached quota window already rules out.
2954
- */
2955
- /**
2956
3389
  * Per-account in-flight requests, shared by every ChatGPT request this host
2957
3390
  * makes. A fan-out of subagents is what reaches a plan's concurrency bound, so
2958
3391
  * the bound is enforced here rather than discovered by cooling every account at
@@ -2997,23 +3430,31 @@ var CodexAccountPool = class extends AccountPoolCore {
2997
3430
  parsePoolData: parseCodexPoolData,
2998
3431
  dedupeKey: (credentials) => credentials.accountId ?? credentials.email,
2999
3432
  defaultAlias: (credentials, position) => credentials.email ?? `账号 ${position}`,
3000
- createAccount: ({ id, alias, credentials, addedAt, isPrimary }) => ({
3001
- id,
3002
- alias,
3003
- credentials,
3004
- addedAt,
3005
- isPrimary,
3006
- ...credentials.email === void 0 ? {} : { email: credentials.email },
3007
- ...credentials.planType === void 0 ? {} : { planType: credentials.planType },
3008
- ...credentials.accountId === void 0 ? {} : { accountId: credentials.accountId }
3009
- }),
3433
+ createAccount: ({ id, alias, credentials, addedAt, isPrimary }) => {
3434
+ const adopted = isAdoptedCodexPoolCredential(credentials);
3435
+ return {
3436
+ id,
3437
+ alias,
3438
+ credentials,
3439
+ addedAt,
3440
+ isPrimary,
3441
+ adopted,
3442
+ source: adopted ? CODEX_CLI_CREDENTIAL_SOURCE : "managed",
3443
+ sourcePath: adopted ? credentials.sourcePath : void 0,
3444
+ ...credentials.email === void 0 ? {} : { email: credentials.email },
3445
+ ...credentials.planType === void 0 ? {} : { planType: credentials.planType },
3446
+ ...credentials.accountId === void 0 ? {} : { accountId: credentials.accountId }
3447
+ };
3448
+ },
3010
3449
  expiresAt: (credentials) => credentials.expiresAt,
3011
- needsRefresh: (credentials, now) => credentials.expiresAt - now <= TOKEN_REFRESH_MARGIN_MS,
3450
+ needsRefresh: (credentials, now) => codexNeedsRefresh(credentials, now),
3012
3451
  refresh: async (credentials) => {
3452
+ if (isAdoptedCodexPoolCredential(credentials)) throw new CodexAdoptedCredentialError(isAdoptedCodexCredentialExpired(credentials) ? ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT : ADOPTED_NEVER_REFRESHED_MESSAGE$1);
3013
3453
  const refresher = ref.current;
3014
3454
  if (refresher === void 0) throw new Error("ChatGPT 凭据刷新服务尚未就绪");
3015
3455
  return refresher.refreshAccount(credentials);
3016
3456
  },
3457
+ authRejectedReason: (credentials) => codexAuthRejectedReason(credentials),
3017
3458
  refreshFailureStatus: (error) => error instanceof OAuthServiceError && (error.status === 400 || error.status === 401) ? "expired" : void 0,
3018
3459
  legacyAccount: async () => {
3019
3460
  const stored = await store.load();
@@ -3024,6 +3465,8 @@ var CodexAccountPool = class extends AccountPoolCore {
3024
3465
  credentials: stored,
3025
3466
  addedAt: Date.now(),
3026
3467
  isPrimary: true,
3468
+ adopted: false,
3469
+ source: "managed",
3027
3470
  ...stored.email === void 0 ? {} : { email: stored.email },
3028
3471
  ...stored.planType === void 0 ? {} : { planType: stored.planType },
3029
3472
  ...stored.accountId === void 0 ? {} : { accountId: stored.accountId }
@@ -3034,17 +3477,30 @@ var CodexAccountPool = class extends AccountPoolCore {
3034
3477
  await store.clear();
3035
3478
  return;
3036
3479
  }
3480
+ if (isAdoptedCodexPoolCredential(credentials)) return;
3037
3481
  await store.save(credentials);
3038
3482
  },
3039
- extendSummary: (account, base) => ({
3040
- ...base,
3041
- ...account.email === void 0 ? {} : { email: account.email },
3042
- ...account.planType === void 0 ? {} : { planLabel: account.planType },
3043
- ...(() => {
3044
- const quota = quotaSnapshotRef.current?.(account);
3045
- return quota === void 0 ? {} : { quota };
3046
- })()
3047
- }),
3483
+ extendSummary: (account, base) => {
3484
+ const adopted = account.adopted === true;
3485
+ const expired = adopted && isAdoptedCodexCredentialExpired(account.credentials);
3486
+ return {
3487
+ ...base,
3488
+ ...account.email === void 0 ? {} : { email: account.email },
3489
+ ...account.planType === void 0 ? {} : { planLabel: account.planType },
3490
+ adopted,
3491
+ source: account.source,
3492
+ removable: !adopted,
3493
+ ...account.sourcePath === void 0 ? {} : { sourcePath: account.sourcePath },
3494
+ ...(() => {
3495
+ const quota = quotaSnapshotRef.current?.(account);
3496
+ return quota === void 0 ? {} : { quota };
3497
+ })(),
3498
+ ...expired ? {
3499
+ authStatus: "expired",
3500
+ authFailedReason: ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT
3501
+ } : {}
3502
+ };
3503
+ },
3048
3504
  emptyMessage: "未登录 ChatGPT 账号,请在「设置 → 订阅服务 → ChatGPT」中添加并登录账号。",
3049
3505
  ...options.backend === void 0 ? {} : { backend: options.backend },
3050
3506
  ...options.maxAccounts === void 0 ? {} : { maxAccounts: options.maxAccounts }
@@ -3098,6 +3554,35 @@ var CodexAccountPool = class extends AccountPoolCore {
3098
3554
  if (this.refresherRef.current === void 0) throw new Error("ChatGPT 凭据刷新服务尚未就绪");
3099
3555
  return this.renewCredential(accountId, fetch, account.credentials);
3100
3556
  }
3557
+ /**
3558
+ * Remove one account inside a transaction, refusing a borrowed snapshot.
3559
+ *
3560
+ * The check lives HERE rather than in the public delete method so it cannot be
3561
+ * separated from the removal by a concurrent change, and so every caller of the
3562
+ * core's own deletion inherits it. The remedy is a separate action
3563
+ * ({@link removeImportedAccount}): a card must not destroy an account this plugin
3564
+ * did not create with the same button that deletes its own.
3565
+ */
3566
+ deleteAccountFromPool(data, accountId) {
3567
+ if (data.accounts.find((account) => account.id === accountId)?.adopted === true) throw new Error("The sign-in imported from the Codex CLI is not an account this plugin owns; remove the import instead.");
3568
+ super.deleteAccountFromPool(data, accountId);
3569
+ }
3570
+ /**
3571
+ * Drop an imported snapshot from the pool — the card's 'remove import' action.
3572
+ *
3573
+ * Separate from {@link deleteAccount} because that refuses one, and because the
3574
+ * user is asking to stop borrowing a sign-in, not to sign out of an account. The
3575
+ * Codex CLI's own file is touched by neither path: not deleted, not rewritten,
3576
+ * not moved. This only forgets the copy.
3577
+ */
3578
+ async removeImportedAccount(accountId) {
3579
+ return this.updatePool((data) => {
3580
+ const target = data.accounts.find((account) => account.id === accountId);
3581
+ if (target === void 0) return;
3582
+ if (target.adopted !== true) throw new Error("That account was signed in through this plugin; delete it instead of removing an import.");
3583
+ super.deleteAccountFromPool(data, accountId);
3584
+ });
3585
+ }
3101
3586
  /** The single-credential store this pool mirrors its primary account into. */
3102
3587
  mirrorStore() {
3103
3588
  return this.store;
@@ -7147,12 +7632,16 @@ function identityKey(credentials) {
7147
7632
  //#endregion
7148
7633
  //#region src/host/routes.ts
7149
7634
  const MAX_BODY_BYTES$6 = 64 * 1024;
7150
- function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwitcher, routeAudit, accountPool, fetchConfiguration) {
7635
+ function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwitcher, routeAudit, accountPool, fetchConfiguration, adopt) {
7636
+ const adoptPaths = adopt?.adoptPaths;
7151
7637
  const handler = async (request, response) => {
7152
7638
  const url = new URL(request.url ?? "/", "http://dsh.local");
7153
7639
  if (request.method === "GET" && url.pathname === `/api/dsh-chatgpt-subscription/status`) {
7154
7640
  const oauthStatus = await oauth.status();
7155
7641
  const quota = await usage.status(oauthStatus.authenticated, false, { backgroundRefresh: true });
7642
+ const codexCliPaths = adoptPaths ?? codexCliCredentialPaths();
7643
+ const codexCliSignInAvailable = await codexCliCredentialPresence(codexCliPaths).catch(() => false);
7644
+ const codexCliSignInPath = codexCliPaths[0];
7156
7645
  json(response, {
7157
7646
  ok: true,
7158
7647
  value: {
@@ -7160,6 +7649,8 @@ function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwit
7160
7649
  quota,
7161
7650
  quotaRefreshing: usage.refreshing,
7162
7651
  preferences: preferences.status(),
7652
+ codexCliSignInAvailable,
7653
+ codexCliSignInPath,
7163
7654
  detectedProxy: proxyManager?.getSystemProxy() ?? null,
7164
7655
  activeProxy: proxyManager?.resolveActiveProxyUrl() ?? null,
7165
7656
  switcher: searchSwitcher?.status() ?? null,
@@ -7238,6 +7729,14 @@ function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwit
7238
7729
  });
7239
7730
  return;
7240
7731
  }
7732
+ const refreshedStatus = async () => {
7733
+ const afterAction = await oauth.status();
7734
+ return {
7735
+ ...afterAction,
7736
+ quota: await usage.status(afterAction.authenticated),
7737
+ preferences: preferences.status()
7738
+ };
7739
+ };
7241
7740
  try {
7242
7741
  switch (url.pathname) {
7243
7742
  case `${ROUTE_PREFIX$5}/login/start`:
@@ -7267,14 +7766,60 @@ function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwit
7267
7766
  else if (action === "clear-cooldown" && accountId !== null) await accountPool.clearCooldown(accountId);
7268
7767
  else if (action === "strategy" && (body.strategy === "sequential" || body.strategy === "round-robin" || body.strategy === "sticky")) await accountPool.setStrategy(body.strategy);
7269
7768
  else if (action === "relogin" && accountId !== null) await accountPool.clearAuthFailed(accountId);
7270
- const afterAction = await oauth.status();
7271
7769
  json(response, {
7272
7770
  ok: true,
7273
- value: {
7274
- ...afterAction,
7275
- quota: await usage.status(afterAction.authenticated),
7276
- preferences: preferences.status()
7277
- }
7771
+ value: await refreshedStatus()
7772
+ });
7773
+ return;
7774
+ }
7775
+ case `${ROUTE_PREFIX$5}/adopt`: {
7776
+ if (accountPool === void 0) {
7777
+ jsonError(response, 400, {
7778
+ code: "bad-request",
7779
+ message: "Importing a local Codex CLI sign-in needs the account pool, which is not installed."
7780
+ });
7781
+ return;
7782
+ }
7783
+ if (field(body, "source") !== "codex") {
7784
+ jsonError(response, 400, {
7785
+ code: "bad-request",
7786
+ message: "That local sign-in is not imported here. Claude Code and MiniMax Code are imported from their own provider settings."
7787
+ });
7788
+ return;
7789
+ }
7790
+ const adopted = await readCodexCliCredentials(adoptPaths ?? codexCliCredentialPaths());
7791
+ if (adopted === void 0) {
7792
+ jsonError(response, 400, {
7793
+ code: "bad-request",
7794
+ message: "No usable local Codex CLI sign-in was found (unrecognised local sign-in format)."
7795
+ });
7796
+ return;
7797
+ }
7798
+ await accountPool.addAccount({
7799
+ ...adopted.credentials,
7800
+ adopted: true,
7801
+ source: CODEX_CLI_CREDENTIAL_SOURCE,
7802
+ sourcePath: adopted.sourcePath
7803
+ });
7804
+ json(response, {
7805
+ ok: true,
7806
+ value: await refreshedStatus()
7807
+ });
7808
+ return;
7809
+ }
7810
+ case `${ROUTE_PREFIX$5}/adopt/disable`: {
7811
+ if (accountPool === void 0) {
7812
+ jsonError(response, 400, {
7813
+ code: "bad-request",
7814
+ message: "The ChatGPT account pool is not installed."
7815
+ });
7816
+ return;
7817
+ }
7818
+ const imported = ((await accountPool.read().catch(() => null))?.accounts ?? []).filter((account) => account.adopted === true);
7819
+ for (const account of imported) await accountPool.removeImportedAccount(account.id);
7820
+ json(response, {
7821
+ ok: true,
7822
+ value: await refreshedStatus()
7278
7823
  });
7279
7824
  return;
7280
7825
  }
@@ -33974,14 +34519,19 @@ const ANTHROPIC_VERSION = "2023-06-01";
33974
34519
  * table, whether or not a floor for that model has been observed: upstream has
33975
34520
  * so far gated a new model on the release that added it (Opus 5.5 arrived in
33976
34521
  * 2.1.280 and is refused below 2.1.280). Claude Sonnet 5.5 arrived in 2.1.284,
33977
- * so 2.1.283 could not be relied on to reach it; 2.1.285 is npm `latest`.
34522
+ * so 2.1.283 could not be relied on to reach it. Claude Haiku 5.5 arrived in
34523
+ * 2.1.293, so 2.1.292 could not be relied on to reach it either — and that is
34524
+ * not hypothetical: when this row was added, npm's `stable` dist-tag was STILL
34525
+ * sitting at 2.1.285, five releases below the one that ships the model, while
34526
+ * `latest` was already 2.1.293. The warning above about reading `stable` is
34527
+ * therefore about this table's own newest model, not a hypothetical.
33978
34528
  *
33979
34529
  * It is still not a literal that only moves when this package is republished: a
33980
34530
  * user whose installed baseline has aged past the server's floor has to be able
33981
34531
  * to raise it without waiting for a release. Read the effective value with
33982
34532
  * {@link claudeCliVersion}, pin one with {@link setClaudeCliVersion}.
33983
34533
  */
33984
- const CLAUDE_CLI_VERSION = "2.1.285";
34534
+ const CLAUDE_CLI_VERSION = "2.1.293";
33985
34535
  /**
33986
34536
  * Loopback callback port the subscription flow defaults to.
33987
34537
  *
@@ -34342,10 +34892,11 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
34342
34892
  * TWO KINDS OF ROW ARE NOT FULLY TRANSCRIBED, and they are different things.
34343
34893
  * Do not collapse them.
34344
34894
  *
34345
- * 1. `claude-sonnet-5-5` — the snapshot this table was copied from has no
34346
- * entry for it, so the whole row is CURATED: every value below comes from
34347
- * the vendor's own documentation and no snapshot field can check it. The
34348
- * mirror list in the test is `LOCALLY_CURATED_MODEL_IDS`.
34895
+ * 1. `claude-sonnet-5-5` and `claude-haiku-5-5` — the snapshot this table
34896
+ * was copied from has no entry for either, so each whole row is CURATED:
34897
+ * every value below comes from the vendor's own documentation and no
34898
+ * snapshot field can check it. The mirror list in the test is
34899
+ * `LOCALLY_CURATED_MODEL_IDS`.
34349
34900
  *
34350
34901
  * 2. `claude-opus-5-5` — a NEWER snapshot (pi-ai >= 0.87.1) does carry it, so
34351
34902
  * the row sits in the snapshot's own position and EVERY field is checked
@@ -34358,22 +34909,35 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
34358
34909
  * stands. The test asserts the row still disagrees with the snapshot, so a
34359
34910
  * future snapshot that agrees fails loudly and retires the entry.
34360
34911
  *
34361
- * Both must be changed together with the mirror list in the test. An invented
34362
- * row that mimics the format of a checked one is worse than a missing row: it
34363
- * is unverifiable and it looks verified. So each row is marked at the row
34364
- * itself, the test asserts the id is on the curated list, and the test
34912
+ * 3. `claude-haiku-5-5` (released 2026-10-07) is the second fully curated
34913
+ * row, and the first one whose documented `canDisableThinking` is TRUE. That
34914
+ * flag is not a free choice here and the row's own comment records why the
34915
+ * documented answer is safe to send: `thinking: { type: 'disabled' }` is
34916
+ * accepted at `low`/`medium`/`high` and is a 400 at `xhigh`/`max`, and this
34917
+ * line only sends that form when the caller asked for thinking OFF, in which
34918
+ * case it names no effort at all and the server's own default (`medium`) is
34919
+ * what the request runs at. Its documented default effort is MEDIUM, so it is
34920
+ * 'adaptive' rather than 'mid-convo' for exactly the reason Opus 5.5 is —
34921
+ * 'mid-convo' would force `high` — and it runs the preserved-thinking prefix
34922
+ * check, so `bindsThinkingToPrefix` is set even though the form is adaptive.
34923
+ *
34924
+ * All three must be changed together with the mirror list in the test. An
34925
+ * invented row that mimics the format of a checked one is worse than a missing
34926
+ * row: it is unverifiable and it looks verified. So each row is marked at the
34927
+ * row itself, the test asserts the id is on the curated list, and the test
34365
34928
  * asserts the curated list is exactly the ids the snapshot lacks — which keeps
34366
34929
  * the lock narrow instead of merely weaker.
34367
34930
  *
34368
- * Its fields come from the vendor's own published documentation for the model
34369
- * (the model overview page plus the extended-thinking effort page), NOT from the
34370
- * snapshot, and they are the values a person read off those pages. That is a
34931
+ * `claude-sonnet-5-5`'s fields come from the vendor's own published
34932
+ * documentation for the model (the model overview page plus the extended-thinking
34933
+ * effort page), NOT from the snapshot, and they are the values a person read off
34934
+ * those pages. That is a
34371
34935
  * weaker source than the snapshot and it is labelled as such: nothing here
34372
34936
  * proves the account is entitled to the model, and the server's own
34373
34937
  * `GET /v1/models` still outranks it.
34374
34938
  *
34375
- * Two of its fields are the ones worth recording a WHY for, because both look
34376
- * like mistakes against their neighbours:
34939
+ * Two of that row's fields are the ones worth recording a WHY for, because both
34940
+ * look like mistakes against their neighbours:
34377
34941
  *
34378
34942
  * 1. `canDisableThinking: false`. The vendor documents that thinking cannot be
34379
34943
  * turned off on this model at all: sending `thinking: { type: 'disabled' }`
@@ -34399,17 +34963,18 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
34399
34963
  * A future reader who notices the shared `supportsTemperature: false` and
34400
34964
  * "fixes" item 2 to 'mid-convo' has reintroduced exactly that bug.
34401
34965
  *
34402
- * One more field on that row comes from neither source: its `minCliVersion`
34403
- * floor is the number upstream itself states when it refuses the model
34404
- * (`claude_code_version_too_old`, "version 2.1.280 or newer is required"). That
34966
+ * One more field on `claude-opus-5-5` comes from neither source: its
34967
+ * `minCliVersion` floor is the number upstream itself states when it refuses
34968
+ * the model (`claude_code_version_too_old`, "version 2.1.280 or newer is
34969
+ * required"). That
34405
34970
  * is a stronger witness than a documentation page — the server said it — and it
34406
34971
  * is recorded here so a claim below it is refused locally, before the request is
34407
34972
  * sent, instead of as an upstream 400 that names neither the model nor the number
34408
34973
  * the user has to reach.
34409
34974
  */
34410
34975
  /**
34411
- * Sixteen rows: the snapshot's own 15, in the snapshot's own declaration order,
34412
- * followed by the one row the snapshot still predates (see LOCAL ADDITIONS above).
34976
+ * Seventeen rows: the snapshot's own 15, in the snapshot's own declaration order,
34977
+ * followed by the two rows the snapshot still predates (see LOCAL ADDITIONS above).
34413
34978
  *
34414
34979
  * The order matters and is not cosmetic. The test asserts the snapshot's ids
34415
34980
  * appear in the table as a SUBSEQUENCE, so a curated row may be appended or
@@ -34697,6 +35262,24 @@ const CLAUDE_MODELS = Object.freeze([
34697
35262
  ],
34698
35263
  canDisableThinking: false,
34699
35264
  bindsThinkingToPrefix: true
35265
+ },
35266
+ {
35267
+ id: "claude-haiku-5-5",
35268
+ name: "Claude Haiku 5.5",
35269
+ contextWindow: 1e6,
35270
+ maxTokens: 128e3,
35271
+ supportsImage: true,
35272
+ supportsTemperature: false,
35273
+ thinkingMode: "adaptive",
35274
+ reasoningEfforts: [
35275
+ "low",
35276
+ "medium",
35277
+ "high",
35278
+ "xhigh",
35279
+ "max"
35280
+ ],
35281
+ canDisableThinking: true,
35282
+ bindsThinkingToPrefix: true
34700
35283
  }
34701
35284
  ]);
34702
35285
  /** Every id the fallback table knows, in declaration order. */
@@ -34721,6 +35304,13 @@ const FALLBACK_MODELS = CLAUDE_MODELS;
34721
35304
  * than one that leads with the general-purpose ids. The rest stay selectable
34722
35305
  * from the model card and are one click away.
34723
35306
  *
35307
+ * The list tracks ONE current model per family rather than the newest of each,
35308
+ * so a slot follows its family's newest release in place instead of growing: the
35309
+ * Haiku slot is `claude-haiku-5-5`, which replaced `claude-haiku-4-5` when that
35310
+ * model shipped. That is a deliberate swap and not a silent append — a list that
35311
+ * offered both generations of every family would be longer than the picker it
35312
+ * feeds, and the older Haiku stays one click away in the card.
35313
+ *
34724
35314
  * This list is the SHIPPED DEFAULT, and edits to it are a deliberate onboarding
34725
35315
  * decision rather than a no-op: a stored list that still equals the shipped
34726
35316
  * default counts as "never edited", and the routes and adapter read it that way
@@ -34734,7 +35324,7 @@ const DEFAULT_VISIBLE_MODEL_IDS = [
34734
35324
  "claude-opus-5-5",
34735
35325
  "claude-opus-4-6",
34736
35326
  "claude-sonnet-4-6",
34737
- "claude-haiku-4-5",
35327
+ "claude-haiku-5-5",
34738
35328
  "claude-opus-4-5"
34739
35329
  ];
34740
35330
  new Map(CLAUDE_MODELS.map((model) => [model.id, model]));