@eddyskywalker/dsh-chatgpt-subscription 0.13.2 → 0.15.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/CHANGELOG.md +54 -0
- package/README.md +76 -8
- package/lib/client.js +183 -115
- package/lib/client.js.map +1 -1
- package/lib/index.js +1110 -76
- package/lib/types/client/CodexSubscriptionSection.d.ts +1 -1
- package/lib/types/client/CodexSubscriptionSection.d.ts.map +1 -1
- package/lib/types/client/ProviderHubSection.d.ts.map +1 -1
- package/lib/types/client/api.d.ts +12 -0
- package/lib/types/client/api.d.ts.map +1 -1
- package/lib/types/client/claude/ClaudeSection.d.ts.map +1 -1
- package/lib/types/client/claude/locales.d.ts +2 -11
- package/lib/types/client/claude/locales.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +13 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/host/claude/model-catalog.d.ts +42 -20
- package/lib/types/host/claude/model-catalog.d.ts.map +1 -1
- package/lib/types/host/claude/types.d.ts +7 -2
- package/lib/types/host/claude/types.d.ts.map +1 -1
- package/lib/types/host/codex-account-pool.d.ts +129 -1
- package/lib/types/host/codex-account-pool.d.ts.map +1 -1
- package/lib/types/host/codex-adopt.d.ts +255 -0
- package/lib/types/host/codex-adopt.d.ts.map +1 -0
- package/lib/types/host/reasoning-collapse-guard/index.d.ts +166 -0
- package/lib/types/host/reasoning-collapse-guard/index.d.ts.map +1 -1
- package/lib/types/host/routes.d.ts +11 -1
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/host/token-store.d.ts +26 -1
- package/lib/types/host/token-store.d.ts.map +1 -1
- package/lib/types/host/workbuddy/client.d.ts +1 -1
- package/lib/types/host/workbuddy/client.d.ts.map +1 -1
- package/lib/types/host/workbuddy/model-catalog.d.ts +77 -4
- package/lib/types/host/workbuddy/model-catalog.d.ts.map +1 -1
- package/lib/types/host/workbuddy/routes.d.ts +7 -1
- package/lib/types/host/workbuddy/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/shared/contracts.d.ts +22 -0
- package/lib/types/shared/contracts.d.ts.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
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
|
|
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
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
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
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
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
|
}
|
|
@@ -29731,6 +30276,94 @@ function convergeWorkBuddyEffort(effort, ladder) {
|
|
|
29731
30276
|
//#endregion
|
|
29732
30277
|
//#region src/host/workbuddy/model-catalog.ts
|
|
29733
30278
|
/**
|
|
30279
|
+
* Models the gateway serves but does not publish in `/v3/config`.
|
|
30280
|
+
*
|
|
30281
|
+
* The catalog is a published list, not the served one: measured on the
|
|
30282
|
+
* international backend, `gpt-6-sol`, `gpt-6-luna` and `gemini-3.8-flash` all
|
|
30283
|
+
* answer a normal streaming completion with 200 while being absent from
|
|
30284
|
+
* `/v3/config` entirely, and a reachable gateway *replaces* the shipped table
|
|
30285
|
+
* with that list. Without these rows the picker would simply never show them, so
|
|
30286
|
+
* they are declared here and merged into a live catalog too
|
|
30287
|
+
* ({@link withUnpublishedModels}) rather than living in the fallback alone.
|
|
30288
|
+
*
|
|
30289
|
+
* What each row records, and how it was established, differs by field:
|
|
30290
|
+
*
|
|
30291
|
+
* - **Existence, the reasoning ladder, image support** were measured against the
|
|
30292
|
+
* live endpoint, not inferred from the name. A rejected level answers 400
|
|
30293
|
+
* `code 11133` with `extError` `400002`; `gpt-6-sol` takes exactly the ladder
|
|
30294
|
+
* `gpt-6-astra` publishes (`minimal` refused, `low`/`medium`/`high`/`xhigh`/
|
|
30295
|
+
* `max` accepted) and both accept a 1x1 PNG.
|
|
30296
|
+
* - **Context window, output cap and `canDisableThinking` are inherited from the
|
|
30297
|
+
* published sibling in the same family** — `gpt-6-astra` for `gpt-6-sol` and
|
|
30298
|
+
* `gpt-6-luna`, `gemini-3.5-flash` for `gemini-3.8-flash`. They are marked
|
|
30299
|
+
* here rather than measured because the gateway publishes nothing for these
|
|
30300
|
+
* ids and offers no cheap way to read the real figure: it accepts a
|
|
30301
|
+
* `max_tokens` larger than any published window (even for `gpt-6-astra`, whose
|
|
30302
|
+
* own catalog caps at 128000), so the number is neither enforced nor
|
|
30303
|
+
* discoverable from the request side.
|
|
30304
|
+
*
|
|
30305
|
+
* Every field stays overridable per model from the settings card, and an
|
|
30306
|
+
* inherited window errs low rather than high: over-declaring overflows the
|
|
30307
|
+
* gateway mid-session, while under-declaring only compresses earlier.
|
|
30308
|
+
*/
|
|
30309
|
+
const UNPUBLISHED_MODELS = [
|
|
30310
|
+
{
|
|
30311
|
+
id: "gpt-6-sol",
|
|
30312
|
+
name: "GPT-6-Sol",
|
|
30313
|
+
contextWindow: 4e5,
|
|
30314
|
+
maxContextWindow: 1e6,
|
|
30315
|
+
maxTokens: 128e3,
|
|
30316
|
+
regions: ["intl"],
|
|
30317
|
+
supportsImage: true,
|
|
30318
|
+
reasoningEfforts: [
|
|
30319
|
+
"low",
|
|
30320
|
+
"medium",
|
|
30321
|
+
"high",
|
|
30322
|
+
"xhigh",
|
|
30323
|
+
"max"
|
|
30324
|
+
],
|
|
30325
|
+
defaultReasoningEffort: "high",
|
|
30326
|
+
canDisableThinking: true,
|
|
30327
|
+
description: "OpenAI 旗舰模型,擅长复杂推理与长程任务"
|
|
30328
|
+
},
|
|
30329
|
+
{
|
|
30330
|
+
id: "gpt-6-luna",
|
|
30331
|
+
name: "GPT-6-Luna",
|
|
30332
|
+
contextWindow: 4e5,
|
|
30333
|
+
maxContextWindow: 1e6,
|
|
30334
|
+
maxTokens: 128e3,
|
|
30335
|
+
regions: ["intl"],
|
|
30336
|
+
supportsImage: true,
|
|
30337
|
+
reasoningEfforts: [
|
|
30338
|
+
"low",
|
|
30339
|
+
"medium",
|
|
30340
|
+
"high",
|
|
30341
|
+
"xhigh",
|
|
30342
|
+
"max"
|
|
30343
|
+
],
|
|
30344
|
+
defaultReasoningEffort: "high",
|
|
30345
|
+
canDisableThinking: true,
|
|
30346
|
+
description: "OpenAI 轻量模型,响应快速,适合日常任务"
|
|
30347
|
+
},
|
|
30348
|
+
{
|
|
30349
|
+
id: "gemini-3.8-flash",
|
|
30350
|
+
name: "Gemini-3.8-Flash",
|
|
30351
|
+
contextWindow: 1e6,
|
|
30352
|
+
maxContextWindow: 1e6,
|
|
30353
|
+
maxTokens: 65536,
|
|
30354
|
+
regions: ["intl"],
|
|
30355
|
+
supportsImage: true,
|
|
30356
|
+
reasoningEfforts: [
|
|
30357
|
+
"low",
|
|
30358
|
+
"high",
|
|
30359
|
+
"max"
|
|
30360
|
+
],
|
|
30361
|
+
defaultReasoningEffort: "medium",
|
|
30362
|
+
canDisableThinking: false,
|
|
30363
|
+
description: "能力均衡,适合日常使用"
|
|
30364
|
+
}
|
|
30365
|
+
];
|
|
30366
|
+
/**
|
|
29734
30367
|
* Offline fallback catalog, transcribed from a live `/v3/config` read.
|
|
29735
30368
|
*
|
|
29736
30369
|
* Used when the gateway cannot be reached (first run before sign-in, or a
|
|
@@ -29827,6 +30460,7 @@ const FALLBACK_MODELS$1 = [
|
|
|
29827
30460
|
canDisableThinking: false,
|
|
29828
30461
|
description: "深度推理,适合深度分析与难题"
|
|
29829
30462
|
},
|
|
30463
|
+
...UNPUBLISHED_MODELS.filter((model) => model.id === "gpt-6-sol" || model.id === "gpt-6-luna"),
|
|
29830
30464
|
{
|
|
29831
30465
|
id: "gpt-6-astra",
|
|
29832
30466
|
name: "GPT-6-Astra",
|
|
@@ -29944,7 +30578,7 @@ const FALLBACK_MODELS$1 = [
|
|
|
29944
30578
|
name: "GLM-5.3",
|
|
29945
30579
|
contextWindow: 1e6,
|
|
29946
30580
|
maxContextWindow: 1e6,
|
|
29947
|
-
maxTokens:
|
|
30581
|
+
maxTokens: 48e3,
|
|
29948
30582
|
regions: ["intl", "cn"],
|
|
29949
30583
|
supportsImage: true,
|
|
29950
30584
|
reasoningEfforts: [
|
|
@@ -29959,10 +30593,10 @@ const FALLBACK_MODELS$1 = [
|
|
|
29959
30593
|
{
|
|
29960
30594
|
id: "glm-5.3-flash",
|
|
29961
30595
|
name: "GLM-5.3-Flash",
|
|
29962
|
-
contextWindow:
|
|
30596
|
+
contextWindow: 3e5,
|
|
29963
30597
|
maxContextWindow: 1e6,
|
|
29964
30598
|
maxTokens: 32e3,
|
|
29965
|
-
regions: ["cn"],
|
|
30599
|
+
regions: ["intl", "cn"],
|
|
29966
30600
|
supportsImage: true,
|
|
29967
30601
|
reasoningEfforts: [
|
|
29968
30602
|
"low",
|
|
@@ -29978,7 +30612,7 @@ const FALLBACK_MODELS$1 = [
|
|
|
29978
30612
|
name: "GLM-5.2",
|
|
29979
30613
|
contextWindow: 1e6,
|
|
29980
30614
|
maxContextWindow: 1e6,
|
|
29981
|
-
maxTokens:
|
|
30615
|
+
maxTokens: 48e3,
|
|
29982
30616
|
regions: ["intl", "cn"],
|
|
29983
30617
|
supportsImage: true,
|
|
29984
30618
|
reasoningEfforts: ["high", "xhigh"],
|
|
@@ -30132,7 +30766,7 @@ const FALLBACK_MODELS$1 = [
|
|
|
30132
30766
|
name: "Kimi-K2.8-Preview",
|
|
30133
30767
|
contextWindow: 3e5,
|
|
30134
30768
|
maxContextWindow: 1e6,
|
|
30135
|
-
maxTokens:
|
|
30769
|
+
maxTokens: 32e3,
|
|
30136
30770
|
regions: ["intl", "cn"],
|
|
30137
30771
|
supportsImage: true,
|
|
30138
30772
|
reasoningEfforts: [
|
|
@@ -30311,7 +30945,7 @@ const FALLBACK_MODELS$1 = [
|
|
|
30311
30945
|
description: "混元思考模型,具有增强的推理能力"
|
|
30312
30946
|
},
|
|
30313
30947
|
{
|
|
30314
|
-
id: "hy4-preview-
|
|
30948
|
+
id: "hy4-preview-f",
|
|
30315
30949
|
name: "Hy4 preview",
|
|
30316
30950
|
contextWindow: 1e6,
|
|
30317
30951
|
maxContextWindow: 1e6,
|
|
@@ -30366,6 +31000,25 @@ const FALLBACK_MODELS$1 = [
|
|
|
30366
31000
|
canDisableThinking: false,
|
|
30367
31001
|
description: ""
|
|
30368
31002
|
},
|
|
31003
|
+
{
|
|
31004
|
+
id: "space-bunny",
|
|
31005
|
+
name: "Space-Bunny",
|
|
31006
|
+
contextWindow: 1e6,
|
|
31007
|
+
maxContextWindow: 1e6,
|
|
31008
|
+
maxTokens: 128e3,
|
|
31009
|
+
regions: ["cn"],
|
|
31010
|
+
supportsImage: true,
|
|
31011
|
+
reasoningEfforts: [
|
|
31012
|
+
"low",
|
|
31013
|
+
"medium",
|
|
31014
|
+
"high",
|
|
31015
|
+
"xhigh",
|
|
31016
|
+
"max"
|
|
31017
|
+
],
|
|
31018
|
+
defaultReasoningEffort: "max",
|
|
31019
|
+
canDisableThinking: false,
|
|
31020
|
+
description: "推理速度极快,编码能力强劲,并支持原生多模态输入的匿名大模型"
|
|
31021
|
+
},
|
|
30369
31022
|
{
|
|
30370
31023
|
id: "minimax-m3",
|
|
30371
31024
|
name: "MiniMax-M3",
|
|
@@ -30417,6 +31070,7 @@ const FALLBACK_MODELS$1 = [
|
|
|
30417
31070
|
canDisableThinking: false,
|
|
30418
31071
|
description: ""
|
|
30419
31072
|
},
|
|
31073
|
+
...UNPUBLISHED_MODELS.filter((model) => model.id === "gemini-3.8-flash"),
|
|
30420
31074
|
{
|
|
30421
31075
|
id: "gemini-3.5-flash",
|
|
30422
31076
|
name: "Gemini-3.5-Flash",
|
|
@@ -30452,6 +31106,21 @@ const DEFAULT_VISIBLE_MODEL_IDS$1 = [
|
|
|
30452
31106
|
/** Every id the fallback knows, in declaration order. */
|
|
30453
31107
|
const WORKBUDDY_MODEL_IDS = FALLBACK_MODELS$1.map((model) => model.id);
|
|
30454
31108
|
/**
|
|
31109
|
+
* Add the served-but-unpublished models to a live `/v3/config` catalog.
|
|
31110
|
+
*
|
|
31111
|
+
* A reachable gateway replaces the shipped table with the list it publishes, so
|
|
31112
|
+
* a model it serves without listing ({@link UNPUBLISHED_MODELS}) would drop out
|
|
31113
|
+
* of the picker the moment the catalog loads. Merging here keeps it offered.
|
|
31114
|
+
*
|
|
31115
|
+
* A published entry always wins: the gateway is the authority on anything it
|
|
31116
|
+
* does describe, so these rows only fill ids the payload is silent about.
|
|
31117
|
+
*/
|
|
31118
|
+
function withUnpublishedModels(catalog, region) {
|
|
31119
|
+
const known = new Set(catalog.map((model) => model.id));
|
|
31120
|
+
const extra = UNPUBLISHED_MODELS.filter((model) => model.regions.includes(region) && !known.has(model.id));
|
|
31121
|
+
return extra.length > 0 ? [...catalog, ...extra] : [...catalog];
|
|
31122
|
+
}
|
|
31123
|
+
/**
|
|
30455
31124
|
* Resolve one entry from a catalog.
|
|
30456
31125
|
*
|
|
30457
31126
|
* An unknown id resolves to a synthetic text-only entry rather than the first
|
|
@@ -30483,9 +31152,131 @@ function resolveWorkBuddyModel(id, catalog = FALLBACK_MODELS$1) {
|
|
|
30483
31152
|
description: ""
|
|
30484
31153
|
};
|
|
30485
31154
|
}
|
|
30486
|
-
/**
|
|
31155
|
+
/**
|
|
31156
|
+
* Vendor families, resolved from a model id.
|
|
31157
|
+
*
|
|
31158
|
+
* The gateway orders `/v3/config` the way it feels like, which interleaves
|
|
31159
|
+
* vendors: a DeepSeek row, then a GPT row, then two Hunyuan rows, then a Kimi
|
|
31160
|
+
* row, then three more GPT rows. In a picker that reads as noise — the eye
|
|
31161
|
+
* cannot tell that the GPT entries belong together — so the list is grouped by
|
|
31162
|
+
* family before it is shown.
|
|
31163
|
+
*
|
|
31164
|
+
* The rules are ordered, first match wins. Only families this subscription
|
|
31165
|
+
* actually serves are listed: a rule for a model that cannot appear would be a
|
|
31166
|
+
* guess about a future catalog.
|
|
31167
|
+
*
|
|
31168
|
+
* An id no rule claims is its own family rather than a shared `other` bucket, so
|
|
31169
|
+
* an unrecognised model keeps its place in the order instead of being swept into
|
|
31170
|
+
* a pile of models it has nothing to do with.
|
|
31171
|
+
*/
|
|
31172
|
+
const WORKBUDDY_VENDOR_RULES = [
|
|
31173
|
+
{
|
|
31174
|
+
re: /^(default|fast|balanced|primary|deep)(-model)?$/,
|
|
31175
|
+
family: "alias"
|
|
31176
|
+
},
|
|
31177
|
+
{
|
|
31178
|
+
re: /^gpt-/,
|
|
31179
|
+
family: "openai"
|
|
31180
|
+
},
|
|
31181
|
+
{
|
|
31182
|
+
re: /^gemini-/,
|
|
31183
|
+
family: "google"
|
|
31184
|
+
},
|
|
31185
|
+
{
|
|
31186
|
+
re: /^glm-/,
|
|
31187
|
+
family: "zhipu"
|
|
31188
|
+
},
|
|
31189
|
+
{
|
|
31190
|
+
re: /^kimi-/,
|
|
31191
|
+
family: "moonshot"
|
|
31192
|
+
},
|
|
31193
|
+
{
|
|
31194
|
+
re: /^deepseek-/,
|
|
31195
|
+
family: "deepseek"
|
|
31196
|
+
},
|
|
31197
|
+
{
|
|
31198
|
+
re: /^hy[0-9]/,
|
|
31199
|
+
family: "tencent"
|
|
31200
|
+
},
|
|
31201
|
+
{
|
|
31202
|
+
re: /^hunyuan/,
|
|
31203
|
+
family: "tencent"
|
|
31204
|
+
},
|
|
31205
|
+
{
|
|
31206
|
+
re: /^minimax-/,
|
|
31207
|
+
family: "minimax"
|
|
31208
|
+
},
|
|
31209
|
+
{
|
|
31210
|
+
re: /^space-bunny$/,
|
|
31211
|
+
family: "space-bunny"
|
|
31212
|
+
}
|
|
31213
|
+
];
|
|
31214
|
+
/** The vendor family one model belongs to. */
|
|
31215
|
+
function workBuddyModelVendor(id) {
|
|
31216
|
+
const value = id.toLowerCase();
|
|
31217
|
+
for (const rule of WORKBUDDY_VENDOR_RULES) if (rule.re.test(value)) return rule.family;
|
|
31218
|
+
return id;
|
|
31219
|
+
}
|
|
31220
|
+
/**
|
|
31221
|
+
* Numeric segments of a model id, e.g. `gpt-6-astra` → [6].
|
|
31222
|
+
*
|
|
31223
|
+
* Version numbers are compared as numbers, so GPT-6.0 sorts below GPT-6.6 rather
|
|
31224
|
+
* than above it as it would if the strings were compared.
|
|
31225
|
+
*/
|
|
31226
|
+
function versionSegments$1(id) {
|
|
31227
|
+
return [...id.toLowerCase().matchAll(/\d+/g)].map((match) => Number(match[0]));
|
|
31228
|
+
}
|
|
31229
|
+
/**
|
|
31230
|
+
* Order two models of one family, newest generation first.
|
|
31231
|
+
*
|
|
31232
|
+
* Catalog order alone is not good enough. The models served but not published
|
|
31233
|
+
* are appended after the published list, which left `gpt-6-sol` below
|
|
31234
|
+
* `gpt-5.4` — the newest models at the bottom of their own group. Comparing the
|
|
31235
|
+
* numeric segments puts every GPT-6 row together and ahead of every GPT-5 one,
|
|
31236
|
+
* and leaves an id whose version cannot be read (an alias, a nickname) in the
|
|
31237
|
+
* catalog's own order rather than guessing where it belongs.
|
|
31238
|
+
*
|
|
31239
|
+
* Ties keep catalog order, so `deepseek-v4.1-flash` still leads its
|
|
31240
|
+
* `-flash-sg` sibling.
|
|
31241
|
+
*/
|
|
31242
|
+
function compareWithinFamily(a, b) {
|
|
31243
|
+
const left = versionSegments$1(a.id);
|
|
31244
|
+
const right = versionSegments$1(b.id);
|
|
31245
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
31246
|
+
const x = left[index];
|
|
31247
|
+
const y = right[index];
|
|
31248
|
+
if (x === void 0) return 1;
|
|
31249
|
+
if (y === void 0) return -1;
|
|
31250
|
+
if (x !== y) return y - x;
|
|
31251
|
+
}
|
|
31252
|
+
return 0;
|
|
31253
|
+
}
|
|
31254
|
+
/**
|
|
31255
|
+
* Group a list so every vendor's models sit together.
|
|
31256
|
+
*
|
|
31257
|
+
* Families appear in the order their first model does — the catalog decides
|
|
31258
|
+
* which vendor leads, not this function — and each family is ordered newest
|
|
31259
|
+
* generation first.
|
|
31260
|
+
*/
|
|
31261
|
+
function groupWorkBuddyModelsByVendor(models) {
|
|
31262
|
+
const families = /* @__PURE__ */ new Map();
|
|
31263
|
+
for (const model of models) {
|
|
31264
|
+
const family = workBuddyModelVendor(model.id);
|
|
31265
|
+
const bucket = families.get(family);
|
|
31266
|
+
if (bucket === void 0) families.set(family, [model]);
|
|
31267
|
+
else bucket.push(model);
|
|
31268
|
+
}
|
|
31269
|
+
return [...families.values()].map((bucket) => [...bucket].sort(compareWithinFamily)).flat();
|
|
31270
|
+
}
|
|
31271
|
+
/**
|
|
31272
|
+
* Models an account in `region` can actually call, grouped by vendor.
|
|
31273
|
+
*
|
|
31274
|
+
* Grouped here rather than in the settings card because this is also what the
|
|
31275
|
+
* request path resolves against: one ordering rule, so the picker and the
|
|
31276
|
+
* adapter can never disagree about which models a region offers.
|
|
31277
|
+
*/
|
|
30487
31278
|
function modelsForRegion(region, catalog = FALLBACK_MODELS$1) {
|
|
30488
|
-
return catalog.filter((model) => model.regions.includes(region));
|
|
31279
|
+
return groupWorkBuddyModelsByVendor(catalog.filter((model) => model.regions.includes(region)));
|
|
30489
31280
|
}
|
|
30490
31281
|
/** Whether one model accepts image input. */
|
|
30491
31282
|
function workBuddyModelSupportsImage(id, catalog = FALLBACK_MODELS$1) {
|
|
@@ -31435,7 +32226,7 @@ async function fetchConfigCatalog(credentials, options = {}) {
|
|
|
31435
32226
|
signal: timeoutSignal$1(options.signal, DISCOVERY_TIMEOUT_MS$1)
|
|
31436
32227
|
});
|
|
31437
32228
|
if (!response.ok) throw new Error(`${PROVIDER_NAME$1} model catalog failed: ${response.status}`);
|
|
31438
|
-
return parseConfigModels(await response.json(), credentials.region);
|
|
32229
|
+
return withUnpublishedModels(parseConfigModels(await response.json(), credentials.region), credentials.region);
|
|
31439
32230
|
}
|
|
31440
32231
|
/** Cached catalog with a TTL; a failed refresh keeps the previous snapshot.
|
|
31441
32232
|
*
|
|
@@ -31469,7 +32260,7 @@ async function loadConfigCatalog(credentials, options = {}) {
|
|
|
31469
32260
|
}, (fetchedAt, models) => {
|
|
31470
32261
|
cachedCatalog = {
|
|
31471
32262
|
region: credentials.region,
|
|
31472
|
-
models,
|
|
32263
|
+
models: withUnpublishedModels(models, credentials.region),
|
|
31473
32264
|
fetchedAt
|
|
31474
32265
|
};
|
|
31475
32266
|
}, 0) > 0) {
|
|
@@ -31995,7 +32786,13 @@ function resolveEnabledModelIds$2(stored, available, enabled = true) {
|
|
|
31995
32786
|
const known = new Set(available);
|
|
31996
32787
|
return stored.filter((id) => known.has(id));
|
|
31997
32788
|
}
|
|
31998
|
-
/**
|
|
32789
|
+
/**
|
|
32790
|
+
* Build the model options the settings card renders.
|
|
32791
|
+
*
|
|
32792
|
+
* Order comes from the caller, which passes a vendor-grouped list so a family
|
|
32793
|
+
* is not interleaved with the next one; this preserves whatever order it is
|
|
32794
|
+
* given rather than imposing a second one.
|
|
32795
|
+
*/
|
|
31999
32796
|
function buildModelOptions(catalog, available, enabledModelIds, overrides) {
|
|
32000
32797
|
const enabled = new Set(enabledModelIds);
|
|
32001
32798
|
return available.map((id) => {
|
|
@@ -33974,14 +34771,19 @@ const ANTHROPIC_VERSION = "2023-06-01";
|
|
|
33974
34771
|
* table, whether or not a floor for that model has been observed: upstream has
|
|
33975
34772
|
* so far gated a new model on the release that added it (Opus 5.5 arrived in
|
|
33976
34773
|
* 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
|
|
34774
|
+
* so 2.1.283 could not be relied on to reach it. Claude Haiku 5.5 arrived in
|
|
34775
|
+
* 2.1.293, so 2.1.292 could not be relied on to reach it either — and that is
|
|
34776
|
+
* not hypothetical: when this row was added, npm's `stable` dist-tag was STILL
|
|
34777
|
+
* sitting at 2.1.285, five releases below the one that ships the model, while
|
|
34778
|
+
* `latest` was already 2.1.293. The warning above about reading `stable` is
|
|
34779
|
+
* therefore about this table's own newest model, not a hypothetical.
|
|
33978
34780
|
*
|
|
33979
34781
|
* It is still not a literal that only moves when this package is republished: a
|
|
33980
34782
|
* user whose installed baseline has aged past the server's floor has to be able
|
|
33981
34783
|
* to raise it without waiting for a release. Read the effective value with
|
|
33982
34784
|
* {@link claudeCliVersion}, pin one with {@link setClaudeCliVersion}.
|
|
33983
34785
|
*/
|
|
33984
|
-
const CLAUDE_CLI_VERSION = "2.1.
|
|
34786
|
+
const CLAUDE_CLI_VERSION = "2.1.293";
|
|
33985
34787
|
/**
|
|
33986
34788
|
* Loopback callback port the subscription flow defaults to.
|
|
33987
34789
|
*
|
|
@@ -34342,10 +35144,11 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
|
|
|
34342
35144
|
* TWO KINDS OF ROW ARE NOT FULLY TRANSCRIBED, and they are different things.
|
|
34343
35145
|
* Do not collapse them.
|
|
34344
35146
|
*
|
|
34345
|
-
* 1. `claude-sonnet-5-5` — the snapshot this table
|
|
34346
|
-
* entry for
|
|
34347
|
-
* the vendor's own documentation and no
|
|
34348
|
-
* mirror list in the test is
|
|
35147
|
+
* 1. `claude-sonnet-5-5` and `claude-haiku-5-5` — the snapshot this table
|
|
35148
|
+
* was copied from has no entry for either, so each whole row is CURATED:
|
|
35149
|
+
* every value below comes from the vendor's own documentation and no
|
|
35150
|
+
* snapshot field can check it. The mirror list in the test is
|
|
35151
|
+
* `LOCALLY_CURATED_MODEL_IDS`.
|
|
34349
35152
|
*
|
|
34350
35153
|
* 2. `claude-opus-5-5` — a NEWER snapshot (pi-ai >= 0.87.1) does carry it, so
|
|
34351
35154
|
* the row sits in the snapshot's own position and EVERY field is checked
|
|
@@ -34358,22 +35161,35 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
|
|
|
34358
35161
|
* stands. The test asserts the row still disagrees with the snapshot, so a
|
|
34359
35162
|
* future snapshot that agrees fails loudly and retires the entry.
|
|
34360
35163
|
*
|
|
34361
|
-
*
|
|
34362
|
-
*
|
|
34363
|
-
* is
|
|
34364
|
-
*
|
|
35164
|
+
* 3. `claude-haiku-5-5` (released 2026-10-07) is the second fully curated
|
|
35165
|
+
* row, and the first one whose documented `canDisableThinking` is TRUE. That
|
|
35166
|
+
* flag is not a free choice here and the row's own comment records why the
|
|
35167
|
+
* documented answer is safe to send: `thinking: { type: 'disabled' }` is
|
|
35168
|
+
* accepted at `low`/`medium`/`high` and is a 400 at `xhigh`/`max`, and this
|
|
35169
|
+
* line only sends that form when the caller asked for thinking OFF, in which
|
|
35170
|
+
* case it names no effort at all and the server's own default (`medium`) is
|
|
35171
|
+
* what the request runs at. Its documented default effort is MEDIUM, so it is
|
|
35172
|
+
* 'adaptive' rather than 'mid-convo' for exactly the reason Opus 5.5 is —
|
|
35173
|
+
* 'mid-convo' would force `high` — and it runs the preserved-thinking prefix
|
|
35174
|
+
* check, so `bindsThinkingToPrefix` is set even though the form is adaptive.
|
|
35175
|
+
*
|
|
35176
|
+
* All three must be changed together with the mirror list in the test. An
|
|
35177
|
+
* invented row that mimics the format of a checked one is worse than a missing
|
|
35178
|
+
* row: it is unverifiable and it looks verified. So each row is marked at the
|
|
35179
|
+
* row itself, the test asserts the id is on the curated list, and the test
|
|
34365
35180
|
* asserts the curated list is exactly the ids the snapshot lacks — which keeps
|
|
34366
35181
|
* the lock narrow instead of merely weaker.
|
|
34367
35182
|
*
|
|
34368
|
-
*
|
|
34369
|
-
* (the model overview page plus the extended-thinking
|
|
34370
|
-
* snapshot, and they are the values a person read off
|
|
35183
|
+
* `claude-sonnet-5-5`'s fields come from the vendor's own published
|
|
35184
|
+
* documentation for the model (the model overview page plus the extended-thinking
|
|
35185
|
+
* effort page), NOT from the snapshot, and they are the values a person read off
|
|
35186
|
+
* those pages. That is a
|
|
34371
35187
|
* weaker source than the snapshot and it is labelled as such: nothing here
|
|
34372
35188
|
* proves the account is entitled to the model, and the server's own
|
|
34373
35189
|
* `GET /v1/models` still outranks it.
|
|
34374
35190
|
*
|
|
34375
|
-
* Two of
|
|
34376
|
-
* like mistakes against their neighbours:
|
|
35191
|
+
* Two of that row's fields are the ones worth recording a WHY for, because both
|
|
35192
|
+
* look like mistakes against their neighbours:
|
|
34377
35193
|
*
|
|
34378
35194
|
* 1. `canDisableThinking: false`. The vendor documents that thinking cannot be
|
|
34379
35195
|
* turned off on this model at all: sending `thinking: { type: 'disabled' }`
|
|
@@ -34399,17 +35215,18 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
|
|
|
34399
35215
|
* A future reader who notices the shared `supportsTemperature: false` and
|
|
34400
35216
|
* "fixes" item 2 to 'mid-convo' has reintroduced exactly that bug.
|
|
34401
35217
|
*
|
|
34402
|
-
* One more field on
|
|
34403
|
-
* floor is the number upstream itself states when it refuses
|
|
34404
|
-
* (`claude_code_version_too_old`, "version 2.1.280 or newer is
|
|
35218
|
+
* One more field on `claude-opus-5-5` comes from neither source: its
|
|
35219
|
+
* `minCliVersion` floor is the number upstream itself states when it refuses
|
|
35220
|
+
* the model (`claude_code_version_too_old`, "version 2.1.280 or newer is
|
|
35221
|
+
* required"). That
|
|
34405
35222
|
* is a stronger witness than a documentation page — the server said it — and it
|
|
34406
35223
|
* is recorded here so a claim below it is refused locally, before the request is
|
|
34407
35224
|
* sent, instead of as an upstream 400 that names neither the model nor the number
|
|
34408
35225
|
* the user has to reach.
|
|
34409
35226
|
*/
|
|
34410
35227
|
/**
|
|
34411
|
-
*
|
|
34412
|
-
* followed by the
|
|
35228
|
+
* Seventeen rows: the snapshot's own 15, in the snapshot's own declaration order,
|
|
35229
|
+
* followed by the two rows the snapshot still predates (see LOCAL ADDITIONS above).
|
|
34413
35230
|
*
|
|
34414
35231
|
* The order matters and is not cosmetic. The test asserts the snapshot's ids
|
|
34415
35232
|
* appear in the table as a SUBSEQUENCE, so a curated row may be appended or
|
|
@@ -34697,6 +35514,24 @@ const CLAUDE_MODELS = Object.freeze([
|
|
|
34697
35514
|
],
|
|
34698
35515
|
canDisableThinking: false,
|
|
34699
35516
|
bindsThinkingToPrefix: true
|
|
35517
|
+
},
|
|
35518
|
+
{
|
|
35519
|
+
id: "claude-haiku-5-5",
|
|
35520
|
+
name: "Claude Haiku 5.5",
|
|
35521
|
+
contextWindow: 1e6,
|
|
35522
|
+
maxTokens: 128e3,
|
|
35523
|
+
supportsImage: true,
|
|
35524
|
+
supportsTemperature: false,
|
|
35525
|
+
thinkingMode: "adaptive",
|
|
35526
|
+
reasoningEfforts: [
|
|
35527
|
+
"low",
|
|
35528
|
+
"medium",
|
|
35529
|
+
"high",
|
|
35530
|
+
"xhigh",
|
|
35531
|
+
"max"
|
|
35532
|
+
],
|
|
35533
|
+
canDisableThinking: true,
|
|
35534
|
+
bindsThinkingToPrefix: true
|
|
34700
35535
|
}
|
|
34701
35536
|
]);
|
|
34702
35537
|
/** Every id the fallback table knows, in declaration order. */
|
|
@@ -34721,6 +35556,13 @@ const FALLBACK_MODELS = CLAUDE_MODELS;
|
|
|
34721
35556
|
* than one that leads with the general-purpose ids. The rest stay selectable
|
|
34722
35557
|
* from the model card and are one click away.
|
|
34723
35558
|
*
|
|
35559
|
+
* The list tracks ONE current model per family rather than the newest of each,
|
|
35560
|
+
* so a slot follows its family's newest release in place instead of growing: the
|
|
35561
|
+
* Haiku slot is `claude-haiku-5-5`, which replaced `claude-haiku-4-5` when that
|
|
35562
|
+
* model shipped. That is a deliberate swap and not a silent append — a list that
|
|
35563
|
+
* offered both generations of every family would be longer than the picker it
|
|
35564
|
+
* feeds, and the older Haiku stays one click away in the card.
|
|
35565
|
+
*
|
|
34724
35566
|
* This list is the SHIPPED DEFAULT, and edits to it are a deliberate onboarding
|
|
34725
35567
|
* decision rather than a no-op: a stored list that still equals the shipped
|
|
34726
35568
|
* default counts as "never edited", and the routes and adapter read it that way
|
|
@@ -34734,7 +35576,7 @@ const DEFAULT_VISIBLE_MODEL_IDS = [
|
|
|
34734
35576
|
"claude-opus-5-5",
|
|
34735
35577
|
"claude-opus-4-6",
|
|
34736
35578
|
"claude-sonnet-4-6",
|
|
34737
|
-
"claude-haiku-
|
|
35579
|
+
"claude-haiku-5-5",
|
|
34738
35580
|
"claude-opus-4-5"
|
|
34739
35581
|
];
|
|
34740
35582
|
new Map(CLAUDE_MODELS.map((model) => [model.id, model]));
|
|
@@ -44960,6 +45802,22 @@ function installRelayProbe(ctx, options) {
|
|
|
44960
45802
|
* degenerate stream scores near one. On a hit it stops yielding chunks, so the
|
|
44961
45803
|
* in-flight request ends, and lets the turn resume on a fresh step.
|
|
44962
45804
|
*
|
|
45805
|
+
* Uniqueness alone only sees verbatim repetition. A stream can restate one
|
|
45806
|
+
* conclusion over and over in fresh words, score 0.48 where the threshold is
|
|
45807
|
+
* 0.85, and run to the output ceiling untouched. So a second, independent
|
|
45808
|
+
* signal scores the same window for recurrence: how strongly each sentence
|
|
45809
|
+
* repeats something the model already said at least `semanticLag` sentences
|
|
45810
|
+
* earlier. Enumerative reasoning repeats itself too, but adjacently — each item
|
|
45811
|
+
* borrows the previous item's frame — and recurrence ignores adjacency, which
|
|
45812
|
+
* is what separates the two.
|
|
45813
|
+
*
|
|
45814
|
+
* Neither signal stops anything on its own. A break needs both over their
|
|
45815
|
+
* thresholds in the same window, and then `semanticConfirmations` consecutive
|
|
45816
|
+
* such windows: one signal alone, or a single anomalous window, is logged and
|
|
45817
|
+
* released. That gradient is the whole design. A guard that cuts a healthy
|
|
45818
|
+
* answer is worse than one that lets a runaway finish, because the user sees a
|
|
45819
|
+
* turn stop mid-sentence with no explanation, and the fix costs a whole turn.
|
|
45820
|
+
*
|
|
44963
45821
|
* Seam choice: the guard wraps `llm/stream`, not `agent/assistant-stream`.
|
|
44964
45822
|
* `agent/assistant-stream` is emit-mode — it can observe but cannot stop a
|
|
44965
45823
|
* stream — and it does not exist before harness 0.1.5, which would make the
|
|
@@ -44996,6 +45854,15 @@ const DEFAULT_GUARD_OPTIONS = {
|
|
|
44996
45854
|
maxBreaksPerTurn: 3,
|
|
44997
45855
|
cooldownMs: 3e4,
|
|
44998
45856
|
includeModels: [],
|
|
45857
|
+
semanticThreshold: .35,
|
|
45858
|
+
semanticLag: 6,
|
|
45859
|
+
semanticMinChars: 360,
|
|
45860
|
+
semanticMinSentences: 8,
|
|
45861
|
+
semanticConfirmations: 3,
|
|
45862
|
+
semanticLiteralFloor: .6,
|
|
45863
|
+
semanticStrictThreshold: .45,
|
|
45864
|
+
semanticDistinctCeiling: .47,
|
|
45865
|
+
semanticStrictConfirmations: 5,
|
|
44999
45866
|
now: () => Date.now()
|
|
45000
45867
|
};
|
|
45001
45868
|
function pruneUndefined(options) {
|
|
@@ -45021,8 +45888,21 @@ function resolveGuardOptions(options = {}) {
|
|
|
45021
45888
|
if (!Number.isFinite(resolved.threshold) || resolved.threshold <= 0 || resolved.threshold >= 1) throw new Error(`reasoning-collapse-guard: invalid threshold ${String(resolved.threshold)} — must be strictly between 0 and 1`);
|
|
45022
45889
|
if (!Number.isInteger(resolved.maxBreaksPerTurn) || resolved.maxBreaksPerTurn < 1) throw new Error(`reasoning-collapse-guard: invalid maxBreaksPerTurn ${String(resolved.maxBreaksPerTurn)} — must be an integer >= 1`);
|
|
45023
45890
|
if (!Number.isInteger(resolved.cooldownMs) || resolved.cooldownMs < 0) throw new Error(`reasoning-collapse-guard: invalid cooldownMs ${String(resolved.cooldownMs)} — must be an integer >= 0`);
|
|
45891
|
+
if (!Number.isFinite(resolved.semanticThreshold) || resolved.semanticThreshold <= 0 || resolved.semanticThreshold >= 1) throw new Error(`reasoning-collapse-guard: invalid semanticThreshold ${String(resolved.semanticThreshold)} — must be strictly between 0 and 1`);
|
|
45892
|
+
if (!Number.isInteger(resolved.semanticLag) || resolved.semanticLag < 1) throw new Error(`reasoning-collapse-guard: invalid semanticLag ${String(resolved.semanticLag)} — must be an integer >= 1`);
|
|
45893
|
+
if (!Number.isInteger(resolved.semanticMinChars) || resolved.semanticMinChars < 1) throw new Error(`reasoning-collapse-guard: invalid semanticMinChars ${String(resolved.semanticMinChars)} — must be an integer >= 1`);
|
|
45894
|
+
if (resolved.semanticMinChars > resolved.windowChars) throw new Error(`reasoning-collapse-guard: invalid semanticMinChars ${resolved.semanticMinChars} — must not exceed windowChars ${resolved.windowChars}`);
|
|
45895
|
+
if (!Number.isInteger(resolved.semanticMinSentences) || resolved.semanticMinSentences < 2) throw new Error(`reasoning-collapse-guard: invalid semanticMinSentences ${String(resolved.semanticMinSentences)} — must be an integer >= 2`);
|
|
45896
|
+
if (!Number.isInteger(resolved.semanticConfirmations) || resolved.semanticConfirmations < 1) throw new Error(`reasoning-collapse-guard: invalid semanticConfirmations ${String(resolved.semanticConfirmations)} — must be an integer >= 1`);
|
|
45897
|
+
if (!Number.isFinite(resolved.semanticLiteralFloor) || resolved.semanticLiteralFloor <= 0 || resolved.semanticLiteralFloor >= 1) throw new Error(`reasoning-collapse-guard: invalid semanticLiteralFloor ${String(resolved.semanticLiteralFloor)} — must be strictly between 0 and 1`);
|
|
45898
|
+
if (!Number.isFinite(resolved.semanticStrictThreshold) || resolved.semanticStrictThreshold <= 0 || resolved.semanticStrictThreshold >= 1) throw new Error(`reasoning-collapse-guard: invalid semanticStrictThreshold ${String(resolved.semanticStrictThreshold)} — must be strictly between 0 and 1`);
|
|
45899
|
+
if (!Number.isFinite(resolved.semanticDistinctCeiling) || resolved.semanticDistinctCeiling <= 0 || resolved.semanticDistinctCeiling >= 1) throw new Error(`reasoning-collapse-guard: invalid semanticDistinctCeiling ${String(resolved.semanticDistinctCeiling)} — must be strictly between 0 and 1`);
|
|
45900
|
+
if (!Number.isInteger(resolved.semanticStrictConfirmations) || resolved.semanticStrictConfirmations < 1) throw new Error(`reasoning-collapse-guard: invalid semanticStrictConfirmations ${String(resolved.semanticStrictConfirmations)} — must be an integer >= 1`);
|
|
45024
45901
|
if (!Array.isArray(resolved.includeModels) || resolved.includeModels.some((entry) => typeof entry !== "string")) throw new Error("reasoning-collapse-guard: includeModels must be an array of strings");
|
|
45025
45902
|
if (typeof resolved.now !== "function") throw new Error("reasoning-collapse-guard: now must be a function");
|
|
45903
|
+
resolved.semanticStrictThreshold = Math.max(resolved.semanticStrictThreshold, resolved.semanticThreshold);
|
|
45904
|
+
resolved.semanticStrictConfirmations = Math.max(resolved.semanticStrictConfirmations, resolved.semanticConfirmations + 1);
|
|
45905
|
+
resolved.semanticLiteralFloor = Math.min(resolved.semanticLiteralFloor, resolved.threshold);
|
|
45026
45906
|
return resolved;
|
|
45027
45907
|
}
|
|
45028
45908
|
/**
|
|
@@ -45045,6 +45925,92 @@ function collapseScore(window, n) {
|
|
|
45045
45925
|
if (total === 0) return 0;
|
|
45046
45926
|
return 1 - grams.size / total;
|
|
45047
45927
|
}
|
|
45928
|
+
/**
|
|
45929
|
+
* Split reasoning text into sentences, for mixed CJK and Latin streams.
|
|
45930
|
+
*
|
|
45931
|
+
* A Chinese full stop ends a sentence whatever follows it. An ASCII `.!?` only
|
|
45932
|
+
* ends one when whitespace or the end of the text follows, because the same
|
|
45933
|
+
* characters carry out decimals, file names and version numbers
|
|
45934
|
+
* (`selectors.test.ts:745`, `3.14`) that must not become sentence
|
|
45935
|
+
* edges. Whitespace runs collapse and case folds, so a difference in spacing
|
|
45936
|
+
* or capitalisation cannot read as a difference in wording.
|
|
45937
|
+
*
|
|
45938
|
+
* @param text - reasoning text, typically one trailing window.
|
|
45939
|
+
* @returns non-empty normalized sentences, in order.
|
|
45940
|
+
*/
|
|
45941
|
+
function splitSentences(text) {
|
|
45942
|
+
const parts = [];
|
|
45943
|
+
let start = 0;
|
|
45944
|
+
for (let i = 0; i < text.length; i++) {
|
|
45945
|
+
const char = text[i];
|
|
45946
|
+
const next = text[i + 1];
|
|
45947
|
+
const cjk = CJK_TERMINATORS.has(char);
|
|
45948
|
+
const latin = LATIN_TERMINATORS.has(char) && (next === void 0 || TRAILING_SPACE.test(next));
|
|
45949
|
+
if (!cjk && !latin) continue;
|
|
45950
|
+
parts.push(text.slice(start, i + 1));
|
|
45951
|
+
start = i + 1;
|
|
45952
|
+
}
|
|
45953
|
+
parts.push(text.slice(start));
|
|
45954
|
+
const sentences = [];
|
|
45955
|
+
for (const part of parts) {
|
|
45956
|
+
const normalized = part.replace(WHITESPACE_RUN, " ").trim().toLowerCase();
|
|
45957
|
+
if (normalized.length > 0) sentences.push(normalized);
|
|
45958
|
+
}
|
|
45959
|
+
return sentences;
|
|
45960
|
+
}
|
|
45961
|
+
/** The three Chinese sentence terminators, which need no trailing space. */
|
|
45962
|
+
const CJK_TERMINATORS = /* @__PURE__ */ new Set([
|
|
45963
|
+
"。",
|
|
45964
|
+
"!",
|
|
45965
|
+
"?"
|
|
45966
|
+
]);
|
|
45967
|
+
/** The three ASCII terminators, which only close a sentence before whitespace. */
|
|
45968
|
+
const LATIN_TERMINATORS = /* @__PURE__ */ new Set([
|
|
45969
|
+
".",
|
|
45970
|
+
"!",
|
|
45971
|
+
"?"
|
|
45972
|
+
]);
|
|
45973
|
+
const TRAILING_SPACE = /\s/;
|
|
45974
|
+
const WHITESPACE_RUN = /\s+/g;
|
|
45975
|
+
/** Character n-grams of one normalized sentence, as a set. */
|
|
45976
|
+
function sentenceGrams(sentence) {
|
|
45977
|
+
const grams = /* @__PURE__ */ new Set();
|
|
45978
|
+
for (let i = 0; i + SEMANTIC_NGRAM_SIZE <= sentence.length; i++) grams.add(sentence.slice(i, i + SEMANTIC_NGRAM_SIZE));
|
|
45979
|
+
return grams;
|
|
45980
|
+
}
|
|
45981
|
+
function gramJaccard(a, b) {
|
|
45982
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
45983
|
+
let shared = 0;
|
|
45984
|
+
for (const gram of a) if (b.has(gram)) shared++;
|
|
45985
|
+
return shared / (a.size + b.size - shared);
|
|
45986
|
+
}
|
|
45987
|
+
/** {@link semanticRecurrence} over sentences the caller has already split. */
|
|
45988
|
+
function sentenceRecurrence(sentences, lag) {
|
|
45989
|
+
const grams = sentences.map(sentenceGrams);
|
|
45990
|
+
if (grams.length <= lag) return 0;
|
|
45991
|
+
let total = 0;
|
|
45992
|
+
let counted = 0;
|
|
45993
|
+
for (let i = lag; i < grams.length; i++) {
|
|
45994
|
+
const current = grams[i];
|
|
45995
|
+
let best = 0;
|
|
45996
|
+
for (let j = 0; j <= i - lag; j++) {
|
|
45997
|
+
const earlier = grams[j];
|
|
45998
|
+
if (Math.min(current.size, earlier.size) / Math.max(current.size, earlier.size) <= best) continue;
|
|
45999
|
+
const similarity = gramJaccard(current, earlier);
|
|
46000
|
+
if (similarity > best) best = similarity;
|
|
46001
|
+
}
|
|
46002
|
+
total += best;
|
|
46003
|
+
counted++;
|
|
46004
|
+
}
|
|
46005
|
+
return counted === 0 ? 0 : total / counted;
|
|
46006
|
+
}
|
|
46007
|
+
/** Character n-gram size both halves of the semantic signal are built on. */
|
|
46008
|
+
const SEMANTIC_NGRAM_SIZE = 3;
|
|
46009
|
+
/** {@link distinctSentenceRatio} over sentences the caller already split. */
|
|
46010
|
+
function distinctRatioOf(sentences) {
|
|
46011
|
+
if (sentences.length < 2) return 1;
|
|
46012
|
+
return new Set(sentences).size / sentences.length;
|
|
46013
|
+
}
|
|
45048
46014
|
const RESUME_HINT = "Your previous reasoning became repetitive and was stopped before it finished. Resume from what you already established instead of restarting the analysis, and keep the rest of this response short.";
|
|
45049
46015
|
/** The second resume message, used once a resume has also collapsed. */
|
|
45050
46016
|
const RESUME_HINT_STRICT = "Your reasoning has now repeated across two attempts. Do not re-derive the analysis a third time. Either state the conclusion you already reached, or make the single next tool call that moves the task forward. Keep this response to a few sentences.";
|
|
@@ -45053,7 +46019,11 @@ function createBreakerState() {
|
|
|
45053
46019
|
breaksThisTurn: 0,
|
|
45054
46020
|
lastBreakAt: -Infinity,
|
|
45055
46021
|
exhausted: false,
|
|
45056
|
-
resuming: false
|
|
46022
|
+
resuming: false,
|
|
46023
|
+
dualStreak: 0,
|
|
46024
|
+
lowStreak: 0,
|
|
46025
|
+
notedLiteralOnly: false,
|
|
46026
|
+
notedSemanticOnly: false
|
|
45057
46027
|
};
|
|
45058
46028
|
}
|
|
45059
46029
|
/**
|
|
@@ -45208,10 +46178,12 @@ function installReasoningCollapseGuard(ctx, options = {}) {
|
|
|
45208
46178
|
* point — and the log says so, because a break that cannot resume is
|
|
45209
46179
|
* indistinguishable from a broken turn to the user.
|
|
45210
46180
|
*/
|
|
45211
|
-
function breakOff(state,
|
|
46181
|
+
function breakOff(state, literal, semantic, path, agent, sessionId) {
|
|
45212
46182
|
state.breaksThisTurn++;
|
|
45213
46183
|
state.lastBreakAt = resolved.now();
|
|
45214
|
-
|
|
46184
|
+
state.dualStreak = 0;
|
|
46185
|
+
state.lowStreak = 0;
|
|
46186
|
+
ctx.logger?.warn(`reasoning-collapse-guard: stopped degenerate reasoning on the ${path} path after ${path === "high-literal" ? resolved.semanticConfirmations : resolved.semanticStrictConfirmations} confirmed windows (literal ${literal.toFixed(3)}, semantic ${semantic.toFixed(3)}, break ${state.breaksThisTurn}/${resolved.maxBreaksPerTurn}, session ${sessionId ?? "unidentified"}${agent === void 0 ? ", no agent to resume" : ""})`);
|
|
45215
46187
|
if (agent === void 0) return;
|
|
45216
46188
|
agent.cancel?.({
|
|
45217
46189
|
kind: "hook",
|
|
@@ -45229,6 +46201,48 @@ function installReasoningCollapseGuard(ctx, options = {}) {
|
|
|
45229
46201
|
}
|
|
45230
46202
|
});
|
|
45231
46203
|
}
|
|
46204
|
+
/**
|
|
46205
|
+
* Report one signal holding without the other.
|
|
46206
|
+
*
|
|
46207
|
+
* Reported once per kind per turn, not once per window: a runaway can hold
|
|
46208
|
+
* one signal for hundreds of windows, and a line each would bury the report
|
|
46209
|
+
* of the one that mattered. This is the diagnostic half of the gradient —
|
|
46210
|
+
* the reason a near miss is still diagnosable after the fact.
|
|
46211
|
+
*/
|
|
46212
|
+
function noteLoneSignal(state, kind, literal, semantic, sessionId) {
|
|
46213
|
+
if (kind === "literal" ? state.notedLiteralOnly : state.notedSemanticOnly) return;
|
|
46214
|
+
if (kind === "literal") state.notedLiteralOnly = true;
|
|
46215
|
+
else state.notedSemanticOnly = true;
|
|
46216
|
+
ctx.logger?.warn(`reasoning-collapse-guard: observed the ${kind} signal alone (literal ${literal === void 0 ? "not scored" : literal.toFixed(3)}, semantic ${semantic === void 0 ? "not scored" : semantic.toFixed(3)}, session ${sessionId ?? "unidentified"}) — the other signal has not agreed, not acting`);
|
|
46217
|
+
}
|
|
46218
|
+
/**
|
|
46219
|
+
* Report a run of agreeing windows that ended before it reached the count.
|
|
46220
|
+
*
|
|
46221
|
+
* Without this, two short runs of agreeing windows look in the log exactly
|
|
46222
|
+
* like one long one that simply stopped short, and the confirmation count
|
|
46223
|
+
* becomes impossible to reason about after the fact.
|
|
46224
|
+
*/
|
|
46225
|
+
function noteStreakCleared(state, reached, sessionId) {
|
|
46226
|
+
ctx.logger?.warn(`reasoning-collapse-guard: the signals stopped agreeing after ${reached} of ${resolved.semanticConfirmations} windows (session ${sessionId ?? "unidentified"}) — streak cleared, not acting`);
|
|
46227
|
+
}
|
|
46228
|
+
/** Report the low-literal run that ended before it reached its longer count. */
|
|
46229
|
+
function noteLowStreakCleared(reached, sessionId) {
|
|
46230
|
+
ctx.logger?.warn(`reasoning-collapse-guard: the low-literal bars stopped holding after ${reached} of ${resolved.semanticStrictConfirmations} windows (session ${sessionId ?? "unidentified"}) — streak cleared, not acting`);
|
|
46231
|
+
}
|
|
46232
|
+
/** Report a low-literal window that qualifies but has not been confirmed enough. */
|
|
46233
|
+
function notePendingLowConfirmation(state, literal, semantic, distinct, sessionId) {
|
|
46234
|
+
ctx.logger?.warn(`reasoning-collapse-guard: low-literal bars hold in window ${state.lowStreak}/${resolved.semanticStrictConfirmations} (literal ${literal.toFixed(3)} >= ${resolved.semanticLiteralFloor}, semantic ${semantic.toFixed(3)} >= ${resolved.semanticStrictThreshold}, distinct ${distinct.toFixed(3)} <= ${resolved.semanticDistinctCeiling}, session ${sessionId ?? "unidentified"}) — not acting yet`);
|
|
46235
|
+
}
|
|
46236
|
+
/**
|
|
46237
|
+
* Report a window where both signals held but the streak is still short.
|
|
46238
|
+
*
|
|
46239
|
+
* `semanticConfirmations` is what makes a break a decision rather than a
|
|
46240
|
+
* reflex, so each pending confirmation is named, and the log then says how
|
|
46241
|
+
* close a stream came to being cut by a guard that did not cut it.
|
|
46242
|
+
*/
|
|
46243
|
+
function notePendingConfirmation(state, literal, semantic, sessionId) {
|
|
46244
|
+
ctx.logger?.warn(`reasoning-collapse-guard: both signals hold in window ${state.dualStreak}/${resolved.semanticConfirmations} (literal ${literal.toFixed(3)}, semantic ${semantic.toFixed(3)}, session ${sessionId ?? "unidentified"}) — not acting yet`);
|
|
46245
|
+
}
|
|
45232
46246
|
function guardStream(request, next) {
|
|
45233
46247
|
const state = breakerFor(request.signal ?? STATIC_KEY);
|
|
45234
46248
|
if (state.exhausted || !watchedModel(request.model) || !conversationCall(request)) return next();
|
|
@@ -45239,13 +46253,33 @@ function installReasoningCollapseGuard(ctx, options = {}) {
|
|
|
45239
46253
|
if (chunk.type === "block-start") window = "";
|
|
45240
46254
|
if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") {
|
|
45241
46255
|
window = appendWindow(window, chunk.text, resolved.windowChars);
|
|
45242
|
-
if (
|
|
45243
|
-
const
|
|
45244
|
-
|
|
46256
|
+
if (resolved.now() - state.lastBreakAt >= resolved.cooldownMs) {
|
|
46257
|
+
const literal = window.length >= resolved.minWindowChars ? collapseScore(window, resolved.ngramSize) : void 0;
|
|
46258
|
+
const sentences = window.length >= resolved.semanticMinChars ? splitSentences(window) : [];
|
|
46259
|
+
const semantic = sentences.length >= resolved.semanticMinSentences ? sentenceRecurrence(sentences, resolved.semanticLag) : void 0;
|
|
46260
|
+
const distinct = sentences.length >= resolved.semanticMinSentences ? distinctRatioOf(sentences) : void 0;
|
|
46261
|
+
const high = literal !== void 0 && literal >= resolved.threshold && semantic !== void 0 && semantic >= resolved.semanticThreshold;
|
|
46262
|
+
const low = literal !== void 0 && literal >= resolved.semanticLiteralFloor && literal < resolved.threshold && semantic !== void 0 && semantic >= resolved.semanticStrictThreshold && distinct !== void 0 && distinct <= resolved.semanticDistinctCeiling;
|
|
46263
|
+
const reachedHigh = state.dualStreak;
|
|
46264
|
+
const reachedLow = state.lowStreak;
|
|
46265
|
+
state.dualStreak = high ? reachedHigh + 1 : 0;
|
|
46266
|
+
state.lowStreak = low ? reachedLow + 1 : 0;
|
|
46267
|
+
if (reachedHigh !== 0 && state.dualStreak === 0) noteStreakCleared(state, reachedHigh, request.sessionId);
|
|
46268
|
+
if (reachedLow !== 0 && state.lowStreak === 0) noteLowStreakCleared(reachedLow, request.sessionId);
|
|
46269
|
+
if (high && state.dualStreak >= resolved.semanticConfirmations) if (state.breaksThisTurn >= resolved.maxBreaksPerTurn) state.exhausted = true;
|
|
46270
|
+
else {
|
|
46271
|
+
breakOff(state, literal, semantic, "high-literal", agent, request.sessionId);
|
|
46272
|
+
return;
|
|
46273
|
+
}
|
|
46274
|
+
else if (low && state.lowStreak >= resolved.semanticStrictConfirmations) if (state.breaksThisTurn >= resolved.maxBreaksPerTurn) state.exhausted = true;
|
|
45245
46275
|
else {
|
|
45246
|
-
breakOff(state,
|
|
46276
|
+
breakOff(state, literal, semantic, "low-literal", agent, request.sessionId);
|
|
45247
46277
|
return;
|
|
45248
46278
|
}
|
|
46279
|
+
else if (high) notePendingConfirmation(state, literal, semantic, request.sessionId);
|
|
46280
|
+
else if (low) notePendingLowConfirmation(state, literal, semantic, distinct, request.sessionId);
|
|
46281
|
+
else if (literal !== void 0 && literal >= resolved.threshold) noteLoneSignal(state, "literal", literal, semantic, request.sessionId);
|
|
46282
|
+
else if (semantic !== void 0 && semantic >= resolved.semanticThreshold) noteLoneSignal(state, "semantic", literal, semantic, request.sessionId);
|
|
45249
46283
|
}
|
|
45250
46284
|
}
|
|
45251
46285
|
yield chunk;
|