dsh-subscriptions 0.1.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/LICENSE +21 -0
- package/README.md +129 -0
- package/cordis.patch.yml +12 -0
- package/lib/auth/claude-code-creds.d.ts +23 -0
- package/lib/auth/claude-code-creds.js +172 -0
- package/lib/auth/device-flow.d.ts +55 -0
- package/lib/auth/device-flow.js +177 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +209 -0
- package/lib/auth/rpc.js +386 -0
- package/lib/auth/store.d.ts +196 -0
- package/lib/auth/store.js +468 -0
- package/lib/client/CursorCard.d.ts +9 -0
- package/lib/client/CursorCard.js +113 -0
- package/lib/client/ExternalUsageCards.d.ts +9 -0
- package/lib/client/ExternalUsageCards.js +72 -0
- package/lib/client/ImageGallery.d.ts +54 -0
- package/lib/client/ImageGallery.js +112 -0
- package/lib/client/ImageGenerateToolview.d.ts +48 -0
- package/lib/client/ImageGenerateToolview.js +133 -0
- package/lib/client/ProviderAccountManager.d.ts +13 -0
- package/lib/client/ProviderAccountManager.js +147 -0
- package/lib/client/ProviderModelEditor.d.ts +39 -0
- package/lib/client/ProviderModelEditor.js +107 -0
- package/lib/client/SpeedSelect.d.ts +71 -0
- package/lib/client/SpeedSelect.js +177 -0
- package/lib/client/SubscriptionUsageBadge.d.ts +116 -0
- package/lib/client/SubscriptionUsageBadge.js +543 -0
- package/lib/client/SubscriptionsSection.d.ts +140 -0
- package/lib/client/SubscriptionsSection.js +544 -0
- package/lib/client/UsageBadgeDisplaySetting.d.ts +7 -0
- package/lib/client/UsageBadgeDisplaySetting.js +36 -0
- package/lib/client/VideoGenerateToolview.d.ts +42 -0
- package/lib/client/VideoGenerateToolview.js +175 -0
- package/lib/client/account-preferences.d.ts +24 -0
- package/lib/client/account-preferences.js +21 -0
- package/lib/client/fast-command.d.ts +2 -0
- package/lib/client/fast-command.js +4 -0
- package/lib/client/host-contract.d.ts +90 -0
- package/lib/client/host-contract.js +101 -0
- package/lib/client/host-icons.d.ts +24 -0
- package/lib/client/host-icons.js +21 -0
- package/lib/client/index.d.ts +27 -0
- package/lib/client/index.js +136 -0
- package/lib/client/locales.d.ts +297 -0
- package/lib/client/locales.js +295 -0
- package/lib/client/subscription-card-styles.d.ts +3 -0
- package/lib/client/subscription-card-styles.js +53 -0
- package/lib/client/subscriptions-rpc.d.ts +14 -0
- package/lib/client/subscriptions-rpc.js +34 -0
- package/lib/client/usage-badge-preferences.d.ts +11 -0
- package/lib/client/usage-badge-preferences.js +41 -0
- package/lib/client.js +4472 -0
- package/lib/client.js.map +1 -0
- package/lib/compat.d.ts +36 -0
- package/lib/compat.js +20 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +15 -0
- package/lib/index.d.ts +80 -0
- package/lib/index.js +14018 -0
- package/lib/model-defaults.d.ts +23 -0
- package/lib/model-defaults.js +237 -0
- package/lib/provider-settings.d.ts +39 -0
- package/lib/provider-settings.js +151 -0
- package/lib/providers/account-preferences.d.ts +41 -0
- package/lib/providers/account-preferences.js +153 -0
- package/lib/providers/accounts.d.ts +112 -0
- package/lib/providers/accounts.js +142 -0
- package/lib/providers/antigravity-oauth-client.d.ts +7 -0
- package/lib/providers/antigravity-oauth-client.js +7 -0
- package/lib/providers/antigravity.d.ts +110 -0
- package/lib/providers/antigravity.js +516 -0
- package/lib/providers/catalog-store.d.ts +48 -0
- package/lib/providers/catalog-store.js +243 -0
- package/lib/providers/claude-cli-version.d.ts +15 -0
- package/lib/providers/claude-cli-version.js +24 -0
- package/lib/providers/claude.d.ts +181 -0
- package/lib/providers/claude.js +680 -0
- package/lib/providers/codex-client-version.d.ts +8 -0
- package/lib/providers/codex-client-version.js +17 -0
- package/lib/providers/codex-search.d.ts +29 -0
- package/lib/providers/codex-search.js +198 -0
- package/lib/providers/codex.d.ts +229 -0
- package/lib/providers/codex.js +821 -0
- package/lib/providers/common.d.ts +389 -0
- package/lib/providers/common.js +656 -0
- package/lib/providers/copilot.d.ts +344 -0
- package/lib/providers/copilot.js +888 -0
- package/lib/providers/cursor-adapter.d.ts +15 -0
- package/lib/providers/cursor-adapter.js +39 -0
- package/lib/providers/cursor-auth.d.ts +43 -0
- package/lib/providers/cursor-auth.js +169 -0
- package/lib/providers/cursor-model-settings.d.ts +15 -0
- package/lib/providers/cursor-model-settings.js +51 -0
- package/lib/providers/cursor-usage.d.ts +9 -0
- package/lib/providers/cursor-usage.js +112 -0
- package/lib/providers/external-usage-controller.d.ts +19 -0
- package/lib/providers/external-usage-controller.js +32 -0
- package/lib/providers/external-usage.d.ts +8 -0
- package/lib/providers/external-usage.js +82 -0
- package/lib/providers/grok.d.ts +205 -0
- package/lib/providers/grok.js +713 -0
- package/lib/providers/image-pool.d.ts +30 -0
- package/lib/providers/image-pool.js +89 -0
- package/lib/providers/npm-cli-version.d.ts +56 -0
- package/lib/providers/npm-cli-version.js +98 -0
- package/lib/providers/pool-family.d.ts +56 -0
- package/lib/providers/pool-family.js +45 -0
- package/lib/providers/pool-health.d.ts +74 -0
- package/lib/providers/pool-health.js +150 -0
- package/lib/providers/pool-usage.d.ts +86 -0
- package/lib/providers/pool-usage.js +223 -0
- package/lib/providers/pool.d.ts +108 -0
- package/lib/providers/pool.js +368 -0
- package/lib/providers/rate-limit.d.ts +192 -0
- package/lib/providers/rate-limit.js +338 -0
- package/lib/tools/image-generate.d.ts +128 -0
- package/lib/tools/image-generate.js +438 -0
- package/lib/tools/registration.d.ts +15 -0
- package/lib/tools/registration.js +24 -0
- package/lib/tools/video-generate.d.ts +89 -0
- package/lib/tools/video-generate.js +257 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +197 -0
- package/lib/translate/anthropic.d.ts +161 -0
- package/lib/translate/anthropic.js +515 -0
- package/lib/translate/antigravity-schema.d.ts +4 -0
- package/lib/translate/antigravity-schema.js +145 -0
- package/lib/translate/antigravity-thinking.d.ts +5 -0
- package/lib/translate/antigravity-thinking.js +44 -0
- package/lib/translate/antigravity.d.ts +114 -0
- package/lib/translate/antigravity.js +366 -0
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +382 -0
- package/lib/translate/resolved.d.ts +80 -0
- package/lib/translate/resolved.js +118 -0
- package/lib/translate/responses.d.ts +179 -0
- package/lib/translate/responses.js +408 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +114 -0
- package/vendor/cursor/LICENSE +21 -0
- package/vendor/cursor/index.d.ts +18 -0
- package/vendor/cursor/index.js +3574 -0
- package/vendor/cursor/proto.js +254 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT/Codex subscription provider: OAuth against auth.openai.com with the
|
|
3
|
+
* Codex CLI client id, and streaming against the ChatGPT backend Responses
|
|
4
|
+
* endpoint.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
import { decodeJwtPayload } from '../auth/jwt.js';
|
|
9
|
+
import { resolveImages } from '../translate/resolved.js';
|
|
10
|
+
import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
|
|
11
|
+
import { deterministicSessionId, effortDisplayName, httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isDiscoveryAuthFailure, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
|
|
12
|
+
import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
|
|
13
|
+
import { hostFetch } from '../http.js';
|
|
14
|
+
import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, jsonBody, resetFromFields, subscriptionRetryPolicy, } from './rate-limit.js';
|
|
15
|
+
export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
16
|
+
export const CODEX_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
|
|
17
|
+
export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
|
18
|
+
export const CODEX_API_URL = 'https://chatgpt.com/backend-api/codex/responses';
|
|
19
|
+
const CODEX_SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
|
|
20
|
+
const CODEX_CALLBACK_PATH = '/auth/callback';
|
|
21
|
+
const CODEX_CONTEXT_WINDOW = 400_000;
|
|
22
|
+
const CODEX_DEFAULT_MAX_TOKENS = 128_000;
|
|
23
|
+
/** Refresh when the access token has less than this much life left. */
|
|
24
|
+
export const CODEX_PREEMPT_MS = 5 * 60_000;
|
|
25
|
+
/**
|
|
26
|
+
* Body fields the backend uses to name a reset. A window-exhaustion rejection
|
|
27
|
+
* carries `usage_limit_reached` with the seconds left on the window — the case
|
|
28
|
+
* that used to classify as a terminal quota and never be retried at all.
|
|
29
|
+
*/
|
|
30
|
+
const CODEX_RESET_FIELDS = ['resets_in_seconds', 'reset_after_seconds', 'resets_at', 'reset_at'];
|
|
31
|
+
/**
|
|
32
|
+
* Reads the reset instant of the Codex window that rejected a request.
|
|
33
|
+
*
|
|
34
|
+
* Body only. The `x-codex-{primary,secondary}-reset-after-seconds` headers are
|
|
35
|
+
* rollover snapshots the backend attaches to every response, one per window,
|
|
36
|
+
* so they say nothing about which window refused: a burst 429 that would clear
|
|
37
|
+
* in seconds still carries a primary rollover hours out, and reading it would
|
|
38
|
+
* park the turn for those hours. They reach the operator through
|
|
39
|
+
* `rateLimitDiagnostics` instead.
|
|
40
|
+
*/
|
|
41
|
+
export const codexRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), CODEX_RESET_FIELDS, now);
|
|
42
|
+
/** Default instruction when the request carries no system prompt. */
|
|
43
|
+
const DEFAULT_CODEX_INSTRUCTIONS = 'You are Codex, a coding agent based on GPT-5. '
|
|
44
|
+
+ 'Help the user with their software engineering tasks.';
|
|
45
|
+
/** Refresh-grant rejections that mean the login is gone for good. */
|
|
46
|
+
const PERMANENT_REFRESH_CODES = new Set([
|
|
47
|
+
'refresh_token_expired',
|
|
48
|
+
'refresh_token_reused',
|
|
49
|
+
'refresh_token_invalidated',
|
|
50
|
+
'invalid_grant',
|
|
51
|
+
]);
|
|
52
|
+
const CODEX_EFFORTS = [
|
|
53
|
+
{ id: ReasoningEffortId('minimal'), name: 'Minimal' },
|
|
54
|
+
{ id: ReasoningEffortId('low'), name: 'Low' },
|
|
55
|
+
{ id: ReasoningEffortId('medium'), name: 'Medium' },
|
|
56
|
+
{ id: ReasoningEffortId('high'), name: 'High' },
|
|
57
|
+
{ id: ReasoningEffortId('xhigh'), name: 'Extra High' },
|
|
58
|
+
];
|
|
59
|
+
const CODEX_DEFAULT_EFFORT = ReasoningEffortId('high');
|
|
60
|
+
/** Every gpt-5.x codex model accepts image input. */
|
|
61
|
+
const CODEX_MODALITIES = ['text', 'image'];
|
|
62
|
+
/**
|
|
63
|
+
* Fast tier (the codex CLI's "fast mode"): the Responses `service_tier` wire
|
|
64
|
+
* value for priority processing, mirroring codex-rs
|
|
65
|
+
* `ServiceTier::Fast.request_value()`. The legacy catalog spelling is the
|
|
66
|
+
* `additional_speed_tiers` entry "fast".
|
|
67
|
+
*/
|
|
68
|
+
export const CODEX_FAST_SERVICE_TIER = 'priority';
|
|
69
|
+
const CODEX_FAST_SPEED_TIER = 'fast';
|
|
70
|
+
/** Static codex flow facts for the OAuth flow engine. */
|
|
71
|
+
export const codexFlow = {
|
|
72
|
+
callbackPath: CODEX_CALLBACK_PATH,
|
|
73
|
+
listen: { host: 'localhost', ports: [1455, 1457] },
|
|
74
|
+
buildAuthorizeUrl({ redirectUri, state, pkce }) {
|
|
75
|
+
const params = new URLSearchParams({
|
|
76
|
+
response_type: 'code',
|
|
77
|
+
client_id: CODEX_CLIENT_ID,
|
|
78
|
+
redirect_uri: redirectUri,
|
|
79
|
+
scope: CODEX_SCOPE,
|
|
80
|
+
code_challenge: pkce.challenge,
|
|
81
|
+
code_challenge_method: 'S256',
|
|
82
|
+
state,
|
|
83
|
+
id_token_add_organizations: 'true',
|
|
84
|
+
codex_cli_simplified_flow: 'true',
|
|
85
|
+
originator: 'codex_cli_rs',
|
|
86
|
+
});
|
|
87
|
+
return `${CODEX_AUTHORIZE_URL}?${params.toString()}`;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
/** Pull `chatgpt_account_id` out of an id token payload. */
|
|
91
|
+
function accountIdOf(idToken) {
|
|
92
|
+
const payload = idToken === undefined ? undefined : decodeJwtPayload(idToken);
|
|
93
|
+
const auth = payload?.['https://api.openai.com/auth'];
|
|
94
|
+
const accountId = typeof auth === 'object' && auth !== null
|
|
95
|
+
? auth.chatgpt_account_id
|
|
96
|
+
: undefined;
|
|
97
|
+
if (typeof accountId !== 'string' || accountId.length === 0) {
|
|
98
|
+
throw new Error('codex login did not return a chatgpt account id; cannot use the subscription');
|
|
99
|
+
}
|
|
100
|
+
return accountId;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Decode the user-identity claims of a codex id token (pure, cheap — no
|
|
104
|
+
* verification, same trust posture as {@link accountIdOf}). Claim paths
|
|
105
|
+
* mirror codex-rs `login/src/token_data.rs`: the email is the top-level
|
|
106
|
+
* `email` claim, falling back to `https://api.openai.com/profile`.email; the
|
|
107
|
+
* plan is `https://api.openai.com/auth`.chatgpt_plan_type.
|
|
108
|
+
* @param idToken - a stored or freshly issued id token, when present.
|
|
109
|
+
* @returns whichever claims the token carried; empty when undecodable.
|
|
110
|
+
*/
|
|
111
|
+
export function codexProfileClaims(idToken) {
|
|
112
|
+
const payload = idToken === undefined ? undefined : decodeJwtPayload(idToken);
|
|
113
|
+
if (payload === undefined)
|
|
114
|
+
return {};
|
|
115
|
+
const profile = payload['https://api.openai.com/profile'];
|
|
116
|
+
const profileEmail = typeof profile === 'object' && profile !== null
|
|
117
|
+
? profile.email
|
|
118
|
+
: undefined;
|
|
119
|
+
const email = payload.email ?? profileEmail;
|
|
120
|
+
const auth = payload['https://api.openai.com/auth'];
|
|
121
|
+
const plan = typeof auth === 'object' && auth !== null
|
|
122
|
+
? auth.chatgpt_plan_type
|
|
123
|
+
: undefined;
|
|
124
|
+
return {
|
|
125
|
+
...typeof email === 'string' && email.length > 0 ? { emailAddress: email } : {},
|
|
126
|
+
...typeof plan === 'string' && plan.length > 0 ? { planType: plan } : {},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Build a session from a token response; expires_in wins, JWT exp is the fallback. */
|
|
130
|
+
function codexSession(tokens, fallback) {
|
|
131
|
+
if (typeof tokens.access_token !== 'string' || tokens.access_token.length === 0) {
|
|
132
|
+
throw new Error('codex token endpoint returned no access token');
|
|
133
|
+
}
|
|
134
|
+
const refreshToken = tokens.refresh_token ?? fallback?.refreshToken;
|
|
135
|
+
if (refreshToken === undefined)
|
|
136
|
+
throw new Error('codex token endpoint returned no refresh token');
|
|
137
|
+
let expiresAt;
|
|
138
|
+
if (typeof tokens.expires_in === 'number' && tokens.expires_in > 0) {
|
|
139
|
+
expiresAt = Date.now() + tokens.expires_in * 1000;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const exp = decodeJwtPayload(tokens.access_token)?.exp;
|
|
143
|
+
if (typeof exp === 'number' && exp > 0)
|
|
144
|
+
expiresAt = exp * 1000;
|
|
145
|
+
}
|
|
146
|
+
if (expiresAt === undefined)
|
|
147
|
+
throw new Error('codex token endpoint returned no usable expiry');
|
|
148
|
+
// Identity claims come from the freshest id token; a refresh that omits
|
|
149
|
+
// one keeps the claims the stored session already had.
|
|
150
|
+
const idToken = tokens.id_token ?? fallback?.idToken;
|
|
151
|
+
const claims = {
|
|
152
|
+
...fallback?.emailAddress === undefined ? {} : { emailAddress: fallback.emailAddress },
|
|
153
|
+
...fallback?.planType === undefined ? {} : { planType: fallback.planType },
|
|
154
|
+
...codexProfileClaims(tokens.id_token),
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
accessToken: tokens.access_token,
|
|
158
|
+
refreshToken,
|
|
159
|
+
expiresAt,
|
|
160
|
+
accountId: tokens.id_token === undefined && fallback !== undefined
|
|
161
|
+
? fallback.accountId
|
|
162
|
+
: accountIdOf(tokens.id_token),
|
|
163
|
+
...idToken === undefined ? {} : { idToken },
|
|
164
|
+
...claims,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Exchange an authorization code for a codex session (form-encoded grant).
|
|
169
|
+
* @param code - the authorization code from the callback.
|
|
170
|
+
* @param verifier - the PKCE verifier minted for the attempt.
|
|
171
|
+
* @param redirectUri - the attempt's redirect URI.
|
|
172
|
+
* @returns the session to store.
|
|
173
|
+
*/
|
|
174
|
+
export async function exchangeCodexCode(code, verifier, redirectUri) {
|
|
175
|
+
const response = await hostFetch(CODEX_TOKEN_URL, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
178
|
+
body: new URLSearchParams({
|
|
179
|
+
grant_type: 'authorization_code',
|
|
180
|
+
code,
|
|
181
|
+
redirect_uri: redirectUri,
|
|
182
|
+
client_id: CODEX_CLIENT_ID,
|
|
183
|
+
code_verifier: verifier,
|
|
184
|
+
}).toString(),
|
|
185
|
+
});
|
|
186
|
+
if (!response.ok)
|
|
187
|
+
throw await oauthEndpointError(response, 'codex');
|
|
188
|
+
return codexSession(await response.json());
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Refresh a codex session (JSON grant — unlike the code exchange).
|
|
192
|
+
* @param session - the stored session.
|
|
193
|
+
* @returns the fresh session to store.
|
|
194
|
+
*/
|
|
195
|
+
export async function refreshCodex(session) {
|
|
196
|
+
const response = await hostFetch(CODEX_TOKEN_URL, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'content-type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({
|
|
200
|
+
client_id: CODEX_CLIENT_ID,
|
|
201
|
+
grant_type: 'refresh_token',
|
|
202
|
+
refresh_token: session.refreshToken,
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
if (!response.ok)
|
|
206
|
+
throw await oauthEndpointError(response, 'codex');
|
|
207
|
+
return codexSession(await response.json(), session);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Whether a codex refresh failure means the login is permanently gone.
|
|
211
|
+
* @param error - the thrown refresh error.
|
|
212
|
+
* @returns true when re-login is the only fix.
|
|
213
|
+
*/
|
|
214
|
+
export function isCodexPermanentRefreshError(error) {
|
|
215
|
+
return error instanceof OAuthEndpointError
|
|
216
|
+
&& error.oauthCode !== undefined
|
|
217
|
+
&& PERMANENT_REFRESH_CODES.has(error.oauthCode);
|
|
218
|
+
}
|
|
219
|
+
export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
|
220
|
+
/** Seconds of the canonical 5-hour session and 7-day weekly windows. */
|
|
221
|
+
const SESSION_WINDOW_SECONDS = 5 * 60 * 60;
|
|
222
|
+
const WEEKLY_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
|
223
|
+
/** Whether a reported duration approximately matches the expected window length. */
|
|
224
|
+
function matchesWindow(seconds, expected) {
|
|
225
|
+
return seconds >= expected * 0.95 && seconds <= expected * 1.05;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Classify a wham/usage window by its reported duration. The backend has been
|
|
229
|
+
* observed to place the weekly lane in `primary_window` with no secondary
|
|
230
|
+
* window, so slot position alone is unreliable; the caller's positional
|
|
231
|
+
* fallback applies only when the duration is absent.
|
|
232
|
+
*/
|
|
233
|
+
function codexWindowKind(window, fallback) {
|
|
234
|
+
const seconds = window.limit_window_seconds;
|
|
235
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0)
|
|
236
|
+
return fallback;
|
|
237
|
+
if (matchesWindow(seconds, SESSION_WINDOW_SECONDS))
|
|
238
|
+
return 'session';
|
|
239
|
+
if (matchesWindow(seconds, WEEKLY_WINDOW_SECONDS))
|
|
240
|
+
return 'weekly';
|
|
241
|
+
return 'other';
|
|
242
|
+
}
|
|
243
|
+
/** Map one wham/usage window into a {@link UsageWindow}; undefined when unusable. */
|
|
244
|
+
function codexUsageWindow(value, fallbackKind) {
|
|
245
|
+
if (typeof value !== 'object' || value === null)
|
|
246
|
+
return undefined;
|
|
247
|
+
const window = value;
|
|
248
|
+
if (typeof window.used_percent !== 'number' || !Number.isFinite(window.used_percent))
|
|
249
|
+
return undefined;
|
|
250
|
+
let resetsAt;
|
|
251
|
+
if (typeof window.reset_at === 'number' && window.reset_at > 0) {
|
|
252
|
+
resetsAt = window.reset_at * 1000;
|
|
253
|
+
}
|
|
254
|
+
else if (typeof window.reset_after_seconds === 'number' && window.reset_after_seconds > 0) {
|
|
255
|
+
resetsAt = Date.now() + window.reset_after_seconds * 1000;
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
kind: codexWindowKind(window, fallbackKind),
|
|
259
|
+
usedPercent: window.used_percent,
|
|
260
|
+
...resetsAt === undefined ? {} : { resetsAt },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Fetch the codex subscription usage from the ChatGPT backend wham/usage
|
|
265
|
+
* endpoint (the source of the codex CLI `/status` rate-limit lines). The
|
|
266
|
+
* windows are classified by their reported duration (`limit_window_seconds`)
|
|
267
|
+
* rather than by slot, since the backend has been observed to report the
|
|
268
|
+
* weekly lane as `primary_window` without a secondary window; slot order is
|
|
269
|
+
* kept only as a fallback when the duration is absent. The lookup itself
|
|
270
|
+
* consumes no rate-limit budget.
|
|
271
|
+
* @param session - the stored session (used as-is; never refreshed here).
|
|
272
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
273
|
+
* @param signal - caller cancellation from the RPC transport.
|
|
274
|
+
* @returns the mapped usage snapshot.
|
|
275
|
+
*/
|
|
276
|
+
export async function fetchCodexUsage(session, fetchFn = hostFetch, signal) {
|
|
277
|
+
const response = await fetchFn(CODEX_USAGE_URL, {
|
|
278
|
+
headers: {
|
|
279
|
+
'authorization': `Bearer ${session.accessToken}`,
|
|
280
|
+
'chatgpt-account-id': session.accountId,
|
|
281
|
+
'originator': 'codex_cli_rs',
|
|
282
|
+
'accept': 'application/json',
|
|
283
|
+
...attributionHeaders(),
|
|
284
|
+
},
|
|
285
|
+
...signal === undefined ? {} : { signal },
|
|
286
|
+
});
|
|
287
|
+
if (!response.ok)
|
|
288
|
+
throw await oauthEndpointError(response, 'codex usage');
|
|
289
|
+
const payload = await response.json();
|
|
290
|
+
const windows = [];
|
|
291
|
+
const primary = codexUsageWindow(payload.rate_limit?.primary_window, 'session');
|
|
292
|
+
const secondary = codexUsageWindow(payload.rate_limit?.secondary_window, 'weekly');
|
|
293
|
+
if (primary !== undefined)
|
|
294
|
+
windows.push(primary);
|
|
295
|
+
if (secondary !== undefined)
|
|
296
|
+
windows.push(secondary);
|
|
297
|
+
return {
|
|
298
|
+
supported: true,
|
|
299
|
+
windows,
|
|
300
|
+
...typeof payload.plan_type === 'string' && payload.plan_type.length > 0
|
|
301
|
+
? { plan: payload.plan_type }
|
|
302
|
+
: {},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
export const CODEX_MODELS_URL = 'https://chatgpt.com/backend-api/codex/models';
|
|
306
|
+
/**
|
|
307
|
+
* Client version sent on the /models catalog request. The backend gates the
|
|
308
|
+
* visible model list by client version. Verified 2026-09-05: the same account
|
|
309
|
+
* omitted GPT-6 Astra at 0.147.0 and listed it at stable CLI 0.153.4. This is
|
|
310
|
+
* not an entitlement guarantee; the server remains authoritative.
|
|
311
|
+
*/
|
|
312
|
+
export const CODEX_CLIENT_VERSION = '0.153.4';
|
|
313
|
+
/** Validate an explicit catalog compatibility version before using it on the wire. */
|
|
314
|
+
export function codexClientVersion(value = CODEX_CLIENT_VERSION) {
|
|
315
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(value)) {
|
|
316
|
+
throw new Error('codexClientVersion must be a version such as 0.153.4');
|
|
317
|
+
}
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Whether a catalog entry advertises the fast tier. Mirrors codex-rs
|
|
322
|
+
* `ModelPreset::supports_fast_mode`: a `service_tiers` id matching the fast
|
|
323
|
+
* wire value, or the legacy `additional_speed_tiers` "fast" entry.
|
|
324
|
+
*/
|
|
325
|
+
function supportsFastTier(entry) {
|
|
326
|
+
return (entry.service_tiers ?? []).some(tier => tier.id === CODEX_FAST_SERVICE_TIER)
|
|
327
|
+
|| (entry.additional_speed_tiers ?? []).includes(CODEX_FAST_SPEED_TIER);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Fetch the live codex model catalog with the session's auth headers.
|
|
331
|
+
* @param session - the stored session (used as-is; never refreshed here).
|
|
332
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
333
|
+
* @param signal - caller cancellation (pool-assembly timeout).
|
|
334
|
+
* @param clientVersion - catalog compatibility version, not the plugin version.
|
|
335
|
+
* @returns discovered models: hidden entries dropped, sorted by priority.
|
|
336
|
+
*/
|
|
337
|
+
export async function fetchCodexModels(session, fetchFn = hostFetch, signal, clientVersion = CODEX_CLIENT_VERSION) {
|
|
338
|
+
const version = codexClientVersion(clientVersion);
|
|
339
|
+
const url = `${CODEX_MODELS_URL}?client_version=${encodeURIComponent(version)}`;
|
|
340
|
+
const response = await fetchFn(url, {
|
|
341
|
+
headers: {
|
|
342
|
+
'authorization': `Bearer ${session.accessToken}`,
|
|
343
|
+
'chatgpt-account-id': session.accountId,
|
|
344
|
+
'originator': 'codex_cli_rs',
|
|
345
|
+
'accept': 'application/json',
|
|
346
|
+
...attributionHeaders(),
|
|
347
|
+
},
|
|
348
|
+
...signal === undefined ? {} : { signal },
|
|
349
|
+
});
|
|
350
|
+
if (!response.ok)
|
|
351
|
+
throw await oauthEndpointError(response, 'codex models');
|
|
352
|
+
const payload = await response.json();
|
|
353
|
+
if (!Array.isArray(payload.models))
|
|
354
|
+
throw new Error('codex models endpoint returned no models array');
|
|
355
|
+
const discovered = [];
|
|
356
|
+
for (const entry of payload.models) {
|
|
357
|
+
if (typeof entry.slug !== 'string' || entry.slug.length === 0)
|
|
358
|
+
continue;
|
|
359
|
+
// codex-rs ModelVisibility: only "list" is picker-visible; hide/none are
|
|
360
|
+
// dropped, and an absent or unknown value is included (in doubt, include).
|
|
361
|
+
if (entry.visibility === 'hide' || entry.visibility === 'none')
|
|
362
|
+
continue;
|
|
363
|
+
const efforts = (entry.supported_reasoning_levels ?? [])
|
|
364
|
+
.filter(level => typeof level.effort === 'string' && level.effort.length > 0)
|
|
365
|
+
.map(level => ({
|
|
366
|
+
id: ReasoningEffortId(level.effort),
|
|
367
|
+
name: effortDisplayName(level.effort),
|
|
368
|
+
...level.description === undefined ? {} : { description: level.description },
|
|
369
|
+
}));
|
|
370
|
+
const defaultEffort = typeof entry.default_reasoning_level === 'string'
|
|
371
|
+
&& entry.default_reasoning_level.length > 0
|
|
372
|
+
&& efforts.some(effort => effort.id === ReasoningEffortId(entry.default_reasoning_level))
|
|
373
|
+
? ReasoningEffortId(entry.default_reasoning_level)
|
|
374
|
+
: undefined;
|
|
375
|
+
const model = {
|
|
376
|
+
id: entry.slug,
|
|
377
|
+
name: typeof entry.display_name === 'string' && entry.display_name.length > 0
|
|
378
|
+
? entry.display_name
|
|
379
|
+
: entry.slug,
|
|
380
|
+
...typeof entry.description === 'string' && entry.description.length > 0
|
|
381
|
+
? { description: entry.description }
|
|
382
|
+
: {},
|
|
383
|
+
...typeof entry.context_window === 'number' && entry.context_window > 0
|
|
384
|
+
? { contextWindow: entry.context_window }
|
|
385
|
+
: {},
|
|
386
|
+
...Number.isSafeInteger(entry.max_context_window) && entry.max_context_window > 0
|
|
387
|
+
? { maxContextWindow: entry.max_context_window }
|
|
388
|
+
: {},
|
|
389
|
+
...typeof entry.priority === 'number' ? { priority: entry.priority } : {},
|
|
390
|
+
...efforts.length > 0
|
|
391
|
+
? { reasoning: { efforts, ...defaultEffort === undefined ? {} : { defaultEffort } } }
|
|
392
|
+
: {},
|
|
393
|
+
...supportsFastTier(entry) ? { fastTier: true } : {},
|
|
394
|
+
};
|
|
395
|
+
discovered.push(model);
|
|
396
|
+
}
|
|
397
|
+
discovered.sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));
|
|
398
|
+
// An empty catalog from a 200 response means the backend gated us out (e.g.
|
|
399
|
+
// client_version too old): surface it as a discovery failure so the adapter
|
|
400
|
+
// falls back to the static catalog instead of vanishing from the picker.
|
|
401
|
+
if (discovered.length === 0) {
|
|
402
|
+
throw new Error(`codex models endpoint returned an empty catalog (client_version ${version})`);
|
|
403
|
+
}
|
|
404
|
+
return discovered;
|
|
405
|
+
}
|
|
406
|
+
const CODEX_CALL_ID_MAX_LENGTH = 64;
|
|
407
|
+
const CODEX_CALL_ID_PREFIX = 'call_';
|
|
408
|
+
/**
|
|
409
|
+
* Bound tool-call ids at the Codex wire boundary without changing the shared
|
|
410
|
+
* Responses translation used by Grok. Short ids stay verbatim. Oversized ids
|
|
411
|
+
* become deterministic hashes, and every id already present in this request
|
|
412
|
+
* is reserved first so a generated id cannot collide with a legitimate short
|
|
413
|
+
* one (or another oversized id).
|
|
414
|
+
*/
|
|
415
|
+
function normalizeCodexCallIds(input) {
|
|
416
|
+
const mapping = new Map();
|
|
417
|
+
const used = new Set();
|
|
418
|
+
const callId = (item) => (item.type === 'function_call' || item.type === 'function_call_output') && typeof item.call_id === 'string'
|
|
419
|
+
? item.call_id
|
|
420
|
+
: undefined;
|
|
421
|
+
for (const item of input) {
|
|
422
|
+
const id = callId(item);
|
|
423
|
+
if (id !== undefined && id.length <= CODEX_CALL_ID_MAX_LENGTH) {
|
|
424
|
+
mapping.set(id, id);
|
|
425
|
+
used.add(id);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (const item of input) {
|
|
429
|
+
const id = callId(item);
|
|
430
|
+
if (id === undefined || mapping.has(id))
|
|
431
|
+
continue;
|
|
432
|
+
let attempt = 0;
|
|
433
|
+
let normalized;
|
|
434
|
+
do {
|
|
435
|
+
const hash = createHash('sha256');
|
|
436
|
+
if (attempt > 0)
|
|
437
|
+
hash.update(String(attempt)).update('\0');
|
|
438
|
+
const digest = hash.update(id).digest('hex');
|
|
439
|
+
normalized = `${CODEX_CALL_ID_PREFIX}${digest.slice(0, CODEX_CALL_ID_MAX_LENGTH - CODEX_CALL_ID_PREFIX.length)}`;
|
|
440
|
+
attempt += 1;
|
|
441
|
+
} while (used.has(normalized));
|
|
442
|
+
mapping.set(id, normalized);
|
|
443
|
+
used.add(normalized);
|
|
444
|
+
}
|
|
445
|
+
return input.map((item) => {
|
|
446
|
+
const id = callId(item);
|
|
447
|
+
if (id === undefined)
|
|
448
|
+
return item;
|
|
449
|
+
const normalized = mapping.get(id) ?? id;
|
|
450
|
+
return normalized === id ? item : { ...item, call_id: normalized };
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/** Text marking a repaired tool output as an unknown outcome; the model must verify before retrying. */
|
|
454
|
+
export const CODEX_UNKNOWN_TOOL_OUTCOME = 'The tool call has no recorded result. Its outcome is unknown; verify external state before retrying any operation that may have side effects.';
|
|
455
|
+
/**
|
|
456
|
+
* Reconcile one Responses input's tool-call pairing after id normalization:
|
|
457
|
+
* every `function_call` gets an output and every `function_call_output`
|
|
458
|
+
* matches a call. Missing outputs become error-style outputs whose text marks
|
|
459
|
+
* the outcome unknown; orphan outputs are dropped. Duplicate calls of one id
|
|
460
|
+
* receive exactly one repair output. The request is local repair only: the
|
|
461
|
+
* durable history stays authoritative and unchanged.
|
|
462
|
+
* @param input - normalized Responses input items.
|
|
463
|
+
* @returns the same array when already balanced, otherwise a repaired array.
|
|
464
|
+
*/
|
|
465
|
+
export function reconcileResponsesToolCalls(input) {
|
|
466
|
+
const calls = new Set();
|
|
467
|
+
const outputs = new Set();
|
|
468
|
+
for (const item of input) {
|
|
469
|
+
if (item.type === 'function_call' && typeof item.call_id === 'string')
|
|
470
|
+
calls.add(item.call_id);
|
|
471
|
+
else if (item.type === 'function_call_output' && typeof item.call_id === 'string')
|
|
472
|
+
outputs.add(item.call_id);
|
|
473
|
+
}
|
|
474
|
+
const missing = new Set([...calls].filter(callId => !outputs.has(callId)));
|
|
475
|
+
if (missing.size === 0 && [...outputs].every(callId => calls.has(callId)))
|
|
476
|
+
return input;
|
|
477
|
+
const balanced = [];
|
|
478
|
+
for (const item of input) {
|
|
479
|
+
if (item.type === 'function_call_output' && (typeof item.call_id !== 'string' || !calls.has(item.call_id))) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
balanced.push(item);
|
|
483
|
+
if (item.type === 'function_call' && typeof item.call_id === 'string' && missing.delete(item.call_id)) {
|
|
484
|
+
balanced.push({ type: 'function_call_output', call_id: item.call_id, output: CODEX_UNKNOWN_TOOL_OUTCOME });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return balanced;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* The Responses request body for one generation. A fast-tier request (the
|
|
491
|
+
* composer Speed toggle, the codex CLI's fast mode) carries
|
|
492
|
+
* `service_tier: priority`; the tier field is omitted entirely otherwise,
|
|
493
|
+
* matching the CLI (it never sends an explicit standard tier).
|
|
494
|
+
*/
|
|
495
|
+
export function codexRequestBody(options, resolved, fast) {
|
|
496
|
+
return {
|
|
497
|
+
model: options.model,
|
|
498
|
+
instructions: resolved.instructions ?? DEFAULT_CODEX_INSTRUCTIONS,
|
|
499
|
+
input: reconcileResponsesToolCalls(normalizeCodexCallIds(resolved.input)),
|
|
500
|
+
// Omit tool controls for tool-less requests, matching the other adapters.
|
|
501
|
+
// This is request-shape consistency, not a claim that Codex rejects the
|
|
502
|
+
// controls when no tools are supplied.
|
|
503
|
+
...options.tools !== undefined && options.tools.length > 0
|
|
504
|
+
? { tools: toResponsesTools(options.tools, { strict: false }), tool_choice: 'auto', parallel_tool_calls: true }
|
|
505
|
+
: {},
|
|
506
|
+
...options.reasoningEffort !== undefined
|
|
507
|
+
? { reasoning: { effort: String(options.reasoningEffort), summary: 'auto' } }
|
|
508
|
+
: {},
|
|
509
|
+
store: false,
|
|
510
|
+
stream: true,
|
|
511
|
+
include: ['reasoning.encrypted_content'],
|
|
512
|
+
...options.sessionId !== undefined ? { prompt_cache_key: String(options.sessionId) } : {},
|
|
513
|
+
...fast ? { service_tier: CODEX_FAST_SERVICE_TIER } : {},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/** Codex wire adapter: one instance serves the `codex` provider route. */
|
|
517
|
+
export class CodexAdapter extends LlmAdapter {
|
|
518
|
+
options;
|
|
519
|
+
catalog;
|
|
520
|
+
/** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
|
|
521
|
+
accountCatalogs = new Map();
|
|
522
|
+
/** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
|
|
523
|
+
catalogOwner;
|
|
524
|
+
constructor(options) {
|
|
525
|
+
super();
|
|
526
|
+
this.options = options;
|
|
527
|
+
codexClientVersion(options.clientVersion);
|
|
528
|
+
this.catalog = new ModelCatalogCache(options.catalogStore);
|
|
529
|
+
}
|
|
530
|
+
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
531
|
+
async fetchCatalog(account, signal) {
|
|
532
|
+
const version = this.options.clientVersion ?? await this.options.resolveClientVersion?.();
|
|
533
|
+
signal?.throwIfAborted();
|
|
534
|
+
return fetchCodexModels(await this.options.tokens.session(account), this.options.fetchFn, signal, version);
|
|
535
|
+
}
|
|
536
|
+
/** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
|
|
537
|
+
clearAccountCatalog(account) {
|
|
538
|
+
// invalidate() also drops the persisted snapshot, so a logged-out account
|
|
539
|
+
// cannot resurface its models from disk.
|
|
540
|
+
if (account === undefined) {
|
|
541
|
+
for (const cache of this.accountCatalogs.values())
|
|
542
|
+
cache.invalidate();
|
|
543
|
+
this.accountCatalogs.clear();
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
this.accountCatalogs.get(account)?.invalidate();
|
|
547
|
+
this.accountCatalogs.delete(account);
|
|
548
|
+
}
|
|
549
|
+
if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
|
|
550
|
+
this.catalogOwner = undefined;
|
|
551
|
+
this.catalog.invalidate();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** Persisted cache for the default account; a throwaway cache for any other. */
|
|
555
|
+
async catalogFor(account) {
|
|
556
|
+
const defaultKey = await this.options.tokens.defaultAccount();
|
|
557
|
+
const key = account ?? defaultKey;
|
|
558
|
+
if (key === undefined || key === defaultKey) {
|
|
559
|
+
if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
|
|
560
|
+
this.catalog.invalidate();
|
|
561
|
+
}
|
|
562
|
+
this.catalogOwner = defaultKey;
|
|
563
|
+
return this.catalog;
|
|
564
|
+
}
|
|
565
|
+
let cache = this.accountCatalogs.get(key);
|
|
566
|
+
if (cache === undefined) {
|
|
567
|
+
cache = new ModelCatalogCache(this.options.accountCatalogStore?.(key));
|
|
568
|
+
this.accountCatalogs.set(key, cache);
|
|
569
|
+
}
|
|
570
|
+
return cache;
|
|
571
|
+
}
|
|
572
|
+
/** Picker rows for discovered entries (the wire id doubles as the route id). */
|
|
573
|
+
listed(provider, models) {
|
|
574
|
+
return models.map(model => ({
|
|
575
|
+
provider,
|
|
576
|
+
id: model.id,
|
|
577
|
+
name: model.name,
|
|
578
|
+
...model.description === undefined ? {} : { description: model.description },
|
|
579
|
+
inputModalities: CODEX_MODALITIES,
|
|
580
|
+
...model.priority === undefined ? {} : { priority: model.priority },
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The last catalog one account successfully listed, without any network.
|
|
585
|
+
* Routing asks for this when live discovery times out: an account that
|
|
586
|
+
* listed a model minutes ago is still a better answer than "no models".
|
|
587
|
+
*/
|
|
588
|
+
async lastKnownOwnModels(provider, account) {
|
|
589
|
+
if (!this.options.discovery || !await this.options.tokens.hasSession(account))
|
|
590
|
+
return undefined;
|
|
591
|
+
const known = (await this.catalogFor(account)).lastKnown();
|
|
592
|
+
return known === undefined ? undefined : this.listed(provider, known);
|
|
593
|
+
}
|
|
594
|
+
providerInfo(provider) {
|
|
595
|
+
return { id: provider, name: 'ChatGPT (Codex)' };
|
|
596
|
+
}
|
|
597
|
+
providerRetryPolicy(provider) {
|
|
598
|
+
return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `codex: provider "${provider}" retryPolicy`);
|
|
599
|
+
}
|
|
600
|
+
staticModels(provider) {
|
|
601
|
+
return this.options.models.map(model => ({
|
|
602
|
+
provider,
|
|
603
|
+
id: model.id,
|
|
604
|
+
name: model.name ?? model.id,
|
|
605
|
+
inputModalities: model.inputModalities ?? CODEX_MODALITIES,
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
async listModels(provider) {
|
|
609
|
+
const own = await this.listOwnModels(provider);
|
|
610
|
+
const pool = this.options.pool?.();
|
|
611
|
+
if (pool === undefined)
|
|
612
|
+
return own;
|
|
613
|
+
const extra = await pool.modelsForProvider(provider);
|
|
614
|
+
const seen = new Set(own.map(model => model.id));
|
|
615
|
+
// Account pools reuse the catalog row; only configured tiers are extra.
|
|
616
|
+
return [...own, ...extra.filter(model => !seen.has(model.id))];
|
|
617
|
+
}
|
|
618
|
+
/** The provider's own catalog: union of every account, or one account when named. */
|
|
619
|
+
async listOwnModels(provider, account, signal) {
|
|
620
|
+
if (account === undefined) {
|
|
621
|
+
const accounts = (await this.options.tokens.list()).map(entry => entry.key);
|
|
622
|
+
if (accounts.length === 0)
|
|
623
|
+
return [];
|
|
624
|
+
return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: this.options.discoveryTimeoutMs ?? DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
|
|
625
|
+
}
|
|
626
|
+
if (!await this.options.tokens.hasSession(account)) {
|
|
627
|
+
return [];
|
|
628
|
+
}
|
|
629
|
+
if (!this.options.discovery)
|
|
630
|
+
return this.staticModels(provider);
|
|
631
|
+
const catalog = await this.catalogFor(account);
|
|
632
|
+
try {
|
|
633
|
+
// The fetcher runs only on a cache miss, and resolves the session
|
|
634
|
+
// through the refresh-aware path so an expired access token renews here
|
|
635
|
+
// instead of failing discovery into the static fallback.
|
|
636
|
+
const discovered = await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)));
|
|
637
|
+
return this.listed(provider, discovered);
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
// A cancelled discovery must not fall back to the static catalog — the
|
|
641
|
+
// caller (pool assembly) treats abort as "this account sits out".
|
|
642
|
+
if (isDiscoveryAborted(error, signal))
|
|
643
|
+
throw error;
|
|
644
|
+
// A permanent refresh failure deletes the stored session: the provider
|
|
645
|
+
// is logged out, so hide it instead of showing a stale static catalog.
|
|
646
|
+
if (isMissingOrInvalidCredential(error))
|
|
647
|
+
return [];
|
|
648
|
+
// The backend rejected the token again right after a forced refresh:
|
|
649
|
+
// the login is revoked server-side. Listing the built-in catalog here
|
|
650
|
+
// would route requests to a dead account and show phantom models.
|
|
651
|
+
if (isDiscoveryAuthFailure(error)) {
|
|
652
|
+
const who = (await this.options.tokens.peek(account))?.emailAddress ?? account ?? 'default account';
|
|
653
|
+
this.options.onWarn?.(`codex rejected the login of ${who}; hiding its models until it logs in again (${errorChain(error)})`);
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
// A transient failure (network, 5xx, timeout) must not demote a
|
|
657
|
+
// working account to the built-in catalog, which lags the backend by
|
|
658
|
+
// generations: the last successful discovery is the truthful answer.
|
|
659
|
+
const known = catalog.lastKnown();
|
|
660
|
+
if (known !== undefined) {
|
|
661
|
+
this.options.onWarn?.(`codex model discovery failed; using the last discovered catalog (${errorChain(error)})`);
|
|
662
|
+
return this.listed(provider, known);
|
|
663
|
+
}
|
|
664
|
+
this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
665
|
+
return this.staticModels(provider);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The discovered entry for one model. Resolved through the cache's
|
|
670
|
+
* stale-while-revalidate path so capability metadata stays stable across a
|
|
671
|
+
* long conversation: a discovered-only effort (one missing from the static
|
|
672
|
+
* CODEX_EFFORTS list) selected by the user must not vanish — and fail the
|
|
673
|
+
* call — just because the TTL lapsed mid-turn.
|
|
674
|
+
*/
|
|
675
|
+
async discovered(model, account) {
|
|
676
|
+
if (!this.options.discovery)
|
|
677
|
+
return undefined;
|
|
678
|
+
const accounts = account === undefined
|
|
679
|
+
? (await this.options.tokens.list()).map(entry => entry.key)
|
|
680
|
+
: [account];
|
|
681
|
+
return discoverAcrossAccounts(accounts, async (account) => {
|
|
682
|
+
const catalog = await this.catalogFor(account);
|
|
683
|
+
const models = await catalog.resolve(() => this.fetchCatalog(account));
|
|
684
|
+
return models?.find(entry => entry.id === model);
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
/** Whether the discovered catalog advertises a fast tier for this model. */
|
|
688
|
+
async supportsFastTier(model, account) {
|
|
689
|
+
return (await this.discovered(model, account))?.fastTier === true;
|
|
690
|
+
}
|
|
691
|
+
/** Ids of every discovered model with a fast tier (the Speed toggle's visibility list). */
|
|
692
|
+
async fastCapableModels() {
|
|
693
|
+
if (!this.options.discovery)
|
|
694
|
+
return [];
|
|
695
|
+
// Not logged in → no fast models, so the Speed toggle hides after logout
|
|
696
|
+
// (mirrors the listModels guard above). Union every account: a fast-capable
|
|
697
|
+
// model only the non-default lists (e.g. gpt-5.6-sol) must still show Speed.
|
|
698
|
+
const accounts = (await this.options.tokens.list()).map(entry => entry.key);
|
|
699
|
+
if (accounts.length === 0)
|
|
700
|
+
return [];
|
|
701
|
+
const seen = new Set();
|
|
702
|
+
const ids = [];
|
|
703
|
+
for (const account of accounts) {
|
|
704
|
+
try {
|
|
705
|
+
const catalog = await this.catalogFor(account);
|
|
706
|
+
const models = await catalog.resolve(() => this.fetchCatalog(account));
|
|
707
|
+
for (const model of models ?? []) {
|
|
708
|
+
if (model.fastTier !== true || seen.has(model.id))
|
|
709
|
+
continue;
|
|
710
|
+
seen.add(model.id);
|
|
711
|
+
ids.push(model.id);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
// sit out
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return ids;
|
|
719
|
+
}
|
|
720
|
+
async resolveModel(provider, model) {
|
|
721
|
+
const pool = this.options.pool?.();
|
|
722
|
+
if (pool !== undefined && await pool.owns(provider, model)) {
|
|
723
|
+
return pool.resolveModel(provider, model);
|
|
724
|
+
}
|
|
725
|
+
return this.resolveOwnModel(provider, model);
|
|
726
|
+
}
|
|
727
|
+
/** Capability resolution of the provider's own models (the pool resolves members here). */
|
|
728
|
+
async resolveOwnModel(provider, model, account) {
|
|
729
|
+
// Discovered metadata (when discovery is on) wins over the static entry;
|
|
730
|
+
// the static entry wins over the built-in defaults. A configured default
|
|
731
|
+
// effort merges over both.
|
|
732
|
+
const discovered = await this.discovered(model, account);
|
|
733
|
+
const configured = this.options.models.find(entry => entry.id === model);
|
|
734
|
+
// `extendable` only while falling back to the built-in list: that one is
|
|
735
|
+
// known to trail the backend, so a configured level it omits still has to
|
|
736
|
+
// be selectable. A discovered catalog is the truth about what the model
|
|
737
|
+
// accepts, and a stale override must not be forced onto every request.
|
|
738
|
+
const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), discovered?.reasoning ?? { efforts: CODEX_EFFORTS, defaultEffort: CODEX_DEFAULT_EFFORT }, { extendable: discovered?.reasoning === undefined });
|
|
739
|
+
return {
|
|
740
|
+
provider,
|
|
741
|
+
id: model,
|
|
742
|
+
name: discovered?.name ?? configured?.name ?? model,
|
|
743
|
+
...discovered?.description === undefined ? {} : { description: discovered.description },
|
|
744
|
+
inputModalities: configured?.inputModalities ?? CODEX_MODALITIES,
|
|
745
|
+
context: { contextWindow: await this.contextWindowFor(model, account) },
|
|
746
|
+
defaultMaxTokens: configured?.maxTokens ?? CODEX_DEFAULT_MAX_TOKENS,
|
|
747
|
+
...(reasoning === undefined ? {} : { reasoning }),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/** Account-specific bounds; absent maximum conservatively keeps the advertised default. */
|
|
751
|
+
async contextLimits(model, account) {
|
|
752
|
+
const discovered = await this.discovered(model, account);
|
|
753
|
+
const configured = this.options.models.find(entry => entry.id === model);
|
|
754
|
+
const fallback = discovered?.contextWindow ?? configured?.contextWindow ?? CODEX_CONTEXT_WINDOW;
|
|
755
|
+
return { default: fallback, max: discovered?.maxContextWindow ?? fallback };
|
|
756
|
+
}
|
|
757
|
+
async contextWindowFor(model, account) {
|
|
758
|
+
const limits = await this.contextLimits(model, account);
|
|
759
|
+
return Math.min(this.options.contextWindowOf?.(model) ?? limits.default, limits.max);
|
|
760
|
+
}
|
|
761
|
+
async *stream(options) {
|
|
762
|
+
const pool = this.options.pool?.();
|
|
763
|
+
if (pool !== undefined && await pool.owns(options.provider, options.model)) {
|
|
764
|
+
yield* pool.stream(options);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
yield* this.streamCore(options);
|
|
768
|
+
}
|
|
769
|
+
/** Pool seam: stream through one specific account instead of the default. */
|
|
770
|
+
streamAccount(options, account) {
|
|
771
|
+
return this.streamCore(options, account);
|
|
772
|
+
}
|
|
773
|
+
async *streamCore(options, account) {
|
|
774
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
775
|
+
try {
|
|
776
|
+
let session = await this.options.tokens.session(account);
|
|
777
|
+
let response = await this.request(options, session, watchdog.signal);
|
|
778
|
+
if (response.status === 401) {
|
|
779
|
+
// One forced refresh + retry on an unexpired-but-rejected token.
|
|
780
|
+
session = await this.options.tokens.session(account, true);
|
|
781
|
+
response = await this.request(options, session, watchdog.signal);
|
|
782
|
+
}
|
|
783
|
+
if (!response.ok) {
|
|
784
|
+
throw await httpLlmError(response, 'codex API', {
|
|
785
|
+
rateLimitReset: codexRateLimitReset,
|
|
786
|
+
...this.options.onWarn === undefined ? {} : { onWarn: this.options.onWarn },
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (response.body === null) {
|
|
790
|
+
throw new LlmError('codex API returned no response body', EMPTY_RESPONSE_CODE);
|
|
791
|
+
}
|
|
792
|
+
yield* streamResponses(response.body, () => { watchdog.pulse(); });
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
throw mapFetchFailure('codex API', error, watchdog, options.signal);
|
|
796
|
+
}
|
|
797
|
+
finally {
|
|
798
|
+
watchdog.stop();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
async request(options, session, signal) {
|
|
802
|
+
const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
|
|
803
|
+
const fast = this.options.speedFor !== undefined
|
|
804
|
+
&& await this.options.speedFor(options.sessionId, options.model);
|
|
805
|
+
const body = codexRequestBody(options, toResponsesInput(messages, options.system), fast);
|
|
806
|
+
return hostFetch(CODEX_API_URL, {
|
|
807
|
+
method: 'POST',
|
|
808
|
+
headers: {
|
|
809
|
+
'authorization': `Bearer ${session.accessToken}`,
|
|
810
|
+
'chatgpt-account-id': session.accountId,
|
|
811
|
+
'originator': 'codex_cli_rs',
|
|
812
|
+
'session-id': deterministicSessionId(options.sessionId),
|
|
813
|
+
'accept': 'text/event-stream',
|
|
814
|
+
'content-type': 'application/json',
|
|
815
|
+
...attributionHeaders(),
|
|
816
|
+
},
|
|
817
|
+
body: JSON.stringify(body),
|
|
818
|
+
signal,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|