dsh-plugin-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/README.md +93 -0
- package/README.zh.md +93 -0
- package/cordis.patch.yml +12 -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 +51 -0
- package/lib/auth/rpc.js +83 -0
- package/lib/auth/store.d.ts +90 -0
- package/lib/auth/store.js +137 -0
- package/lib/client/SubscriptionsSection.d.ts +30 -0
- package/lib/client/SubscriptionsSection.js +290 -0
- package/lib/client/index.d.ts +31 -0
- package/lib/client/index.js +35 -0
- package/lib/client/locales.d.ts +45 -0
- package/lib/client/locales.js +43 -0
- package/lib/client.js +546 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +34 -0
- package/lib/index.js +2932 -0
- package/lib/providers/claude.d.ts +60 -0
- package/lib/providers/claude.js +243 -0
- package/lib/providers/codex.d.ts +96 -0
- package/lib/providers/codex.js +391 -0
- package/lib/providers/common.d.ts +185 -0
- package/lib/providers/common.js +302 -0
- package/lib/providers/grok.d.ts +90 -0
- package/lib/providers/grok.js +337 -0
- package/lib/tools/image-generate.d.ts +60 -0
- package/lib/tools/image-generate.js +142 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +195 -0
- package/lib/translate/anthropic.d.ts +120 -0
- package/lib/translate/anthropic.js +370 -0
- package/lib/translate/resolved.d.ts +35 -0
- package/lib/translate/resolved.js +40 -0
- package/lib/translate/responses.d.ts +127 -0
- package/lib/translate/responses.js +352 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +83 -0
|
@@ -0,0 +1,391 @@
|
|
|
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 { 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 { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
|
|
12
|
+
export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
13
|
+
export const CODEX_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
|
|
14
|
+
export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
|
15
|
+
export const CODEX_API_URL = 'https://chatgpt.com/backend-api/codex/responses';
|
|
16
|
+
const CODEX_SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
|
|
17
|
+
const CODEX_CALLBACK_PATH = '/auth/callback';
|
|
18
|
+
const CODEX_CONTEXT_WINDOW = 400_000;
|
|
19
|
+
const CODEX_DEFAULT_MAX_TOKENS = 128_000;
|
|
20
|
+
/** Refresh when the access token has less than this much life left. */
|
|
21
|
+
export const CODEX_PREEMPT_MS = 5 * 60_000;
|
|
22
|
+
/** Default instruction when the request carries no system prompt. */
|
|
23
|
+
const DEFAULT_CODEX_INSTRUCTIONS = 'You are Codex, a coding agent based on GPT-5. '
|
|
24
|
+
+ 'Help the user with their software engineering tasks.';
|
|
25
|
+
/** Refresh-grant rejections that mean the login is gone for good. */
|
|
26
|
+
const PERMANENT_REFRESH_CODES = new Set([
|
|
27
|
+
'refresh_token_expired',
|
|
28
|
+
'refresh_token_reused',
|
|
29
|
+
'refresh_token_invalidated',
|
|
30
|
+
'invalid_grant',
|
|
31
|
+
]);
|
|
32
|
+
const CODEX_EFFORTS = [
|
|
33
|
+
{ id: ReasoningEffortId('minimal'), name: 'Minimal' },
|
|
34
|
+
{ id: ReasoningEffortId('low'), name: 'Low' },
|
|
35
|
+
{ id: ReasoningEffortId('medium'), name: 'Medium' },
|
|
36
|
+
{ id: ReasoningEffortId('high'), name: 'High' },
|
|
37
|
+
{ id: ReasoningEffortId('xhigh'), name: 'Extra High' },
|
|
38
|
+
];
|
|
39
|
+
const CODEX_DEFAULT_EFFORT = ReasoningEffortId('high');
|
|
40
|
+
/** Every gpt-5.x codex model accepts image input. */
|
|
41
|
+
const CODEX_MODALITIES = ['text', 'image'];
|
|
42
|
+
/** Static codex flow facts for the OAuth flow engine. */
|
|
43
|
+
export const codexFlow = {
|
|
44
|
+
callbackPath: CODEX_CALLBACK_PATH,
|
|
45
|
+
listen: { host: 'localhost', ports: [1455, 1457] },
|
|
46
|
+
buildAuthorizeUrl({ redirectUri, state, pkce }) {
|
|
47
|
+
const params = new URLSearchParams({
|
|
48
|
+
response_type: 'code',
|
|
49
|
+
client_id: CODEX_CLIENT_ID,
|
|
50
|
+
redirect_uri: redirectUri,
|
|
51
|
+
scope: CODEX_SCOPE,
|
|
52
|
+
code_challenge: pkce.challenge,
|
|
53
|
+
code_challenge_method: 'S256',
|
|
54
|
+
state,
|
|
55
|
+
id_token_add_organizations: 'true',
|
|
56
|
+
codex_cli_simplified_flow: 'true',
|
|
57
|
+
originator: 'codex_cli_rs',
|
|
58
|
+
});
|
|
59
|
+
return `${CODEX_AUTHORIZE_URL}?${params.toString()}`;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
/** Pull `chatgpt_account_id` out of an id token payload. */
|
|
63
|
+
function accountIdOf(idToken) {
|
|
64
|
+
const payload = idToken === undefined ? undefined : decodeJwtPayload(idToken);
|
|
65
|
+
const auth = payload?.['https://api.openai.com/auth'];
|
|
66
|
+
const accountId = typeof auth === 'object' && auth !== null
|
|
67
|
+
? auth.chatgpt_account_id
|
|
68
|
+
: undefined;
|
|
69
|
+
if (typeof accountId !== 'string' || accountId.length === 0) {
|
|
70
|
+
throw new Error('codex login did not return a chatgpt account id; cannot use the subscription');
|
|
71
|
+
}
|
|
72
|
+
return accountId;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Decode the user-identity claims of a codex id token (pure, cheap — no
|
|
76
|
+
* verification, same trust posture as {@link accountIdOf}). Claim paths
|
|
77
|
+
* mirror codex-rs `login/src/token_data.rs`: the email is the top-level
|
|
78
|
+
* `email` claim, falling back to `https://api.openai.com/profile`.email; the
|
|
79
|
+
* plan is `https://api.openai.com/auth`.chatgpt_plan_type.
|
|
80
|
+
* @param idToken - a stored or freshly issued id token, when present.
|
|
81
|
+
* @returns whichever claims the token carried; empty when undecodable.
|
|
82
|
+
*/
|
|
83
|
+
export function codexProfileClaims(idToken) {
|
|
84
|
+
const payload = idToken === undefined ? undefined : decodeJwtPayload(idToken);
|
|
85
|
+
if (payload === undefined)
|
|
86
|
+
return {};
|
|
87
|
+
const profile = payload['https://api.openai.com/profile'];
|
|
88
|
+
const profileEmail = typeof profile === 'object' && profile !== null
|
|
89
|
+
? profile.email
|
|
90
|
+
: undefined;
|
|
91
|
+
const email = payload.email ?? profileEmail;
|
|
92
|
+
const auth = payload['https://api.openai.com/auth'];
|
|
93
|
+
const plan = typeof auth === 'object' && auth !== null
|
|
94
|
+
? auth.chatgpt_plan_type
|
|
95
|
+
: undefined;
|
|
96
|
+
return {
|
|
97
|
+
...typeof email === 'string' && email.length > 0 ? { emailAddress: email } : {},
|
|
98
|
+
...typeof plan === 'string' && plan.length > 0 ? { planType: plan } : {},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Build a session from a token response; expires_in wins, JWT exp is the fallback. */
|
|
102
|
+
function codexSession(tokens, fallback) {
|
|
103
|
+
if (typeof tokens.access_token !== 'string' || tokens.access_token.length === 0) {
|
|
104
|
+
throw new Error('codex token endpoint returned no access token');
|
|
105
|
+
}
|
|
106
|
+
const refreshToken = tokens.refresh_token ?? fallback?.refreshToken;
|
|
107
|
+
if (refreshToken === undefined)
|
|
108
|
+
throw new Error('codex token endpoint returned no refresh token');
|
|
109
|
+
let expiresAt;
|
|
110
|
+
if (typeof tokens.expires_in === 'number' && tokens.expires_in > 0) {
|
|
111
|
+
expiresAt = Date.now() + tokens.expires_in * 1000;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const exp = decodeJwtPayload(tokens.access_token)?.exp;
|
|
115
|
+
if (typeof exp === 'number' && exp > 0)
|
|
116
|
+
expiresAt = exp * 1000;
|
|
117
|
+
}
|
|
118
|
+
if (expiresAt === undefined)
|
|
119
|
+
throw new Error('codex token endpoint returned no usable expiry');
|
|
120
|
+
// Identity claims come from the freshest id token; a refresh that omits
|
|
121
|
+
// one keeps the claims the stored session already had.
|
|
122
|
+
const idToken = tokens.id_token ?? fallback?.idToken;
|
|
123
|
+
const claims = {
|
|
124
|
+
...fallback?.emailAddress === undefined ? {} : { emailAddress: fallback.emailAddress },
|
|
125
|
+
...fallback?.planType === undefined ? {} : { planType: fallback.planType },
|
|
126
|
+
...codexProfileClaims(tokens.id_token),
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
accessToken: tokens.access_token,
|
|
130
|
+
refreshToken,
|
|
131
|
+
expiresAt,
|
|
132
|
+
accountId: tokens.id_token === undefined && fallback !== undefined
|
|
133
|
+
? fallback.accountId
|
|
134
|
+
: accountIdOf(tokens.id_token),
|
|
135
|
+
...idToken === undefined ? {} : { idToken },
|
|
136
|
+
...claims,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Exchange an authorization code for a codex session (form-encoded grant).
|
|
141
|
+
* @param code - the authorization code from the callback.
|
|
142
|
+
* @param verifier - the PKCE verifier minted for the attempt.
|
|
143
|
+
* @param redirectUri - the attempt's redirect URI.
|
|
144
|
+
* @returns the session to store.
|
|
145
|
+
*/
|
|
146
|
+
export async function exchangeCodexCode(code, verifier, redirectUri) {
|
|
147
|
+
const response = await fetch(CODEX_TOKEN_URL, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
150
|
+
body: new URLSearchParams({
|
|
151
|
+
grant_type: 'authorization_code',
|
|
152
|
+
code,
|
|
153
|
+
redirect_uri: redirectUri,
|
|
154
|
+
client_id: CODEX_CLIENT_ID,
|
|
155
|
+
code_verifier: verifier,
|
|
156
|
+
}).toString(),
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
throw await oauthEndpointError(response, 'codex');
|
|
160
|
+
return codexSession(await response.json());
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Refresh a codex session (JSON grant — unlike the code exchange).
|
|
164
|
+
* @param session - the stored session.
|
|
165
|
+
* @returns the fresh session to store.
|
|
166
|
+
*/
|
|
167
|
+
export async function refreshCodex(session) {
|
|
168
|
+
const response = await fetch(CODEX_TOKEN_URL, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: { 'content-type': 'application/json' },
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
client_id: CODEX_CLIENT_ID,
|
|
173
|
+
grant_type: 'refresh_token',
|
|
174
|
+
refresh_token: session.refreshToken,
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
if (!response.ok)
|
|
178
|
+
throw await oauthEndpointError(response, 'codex');
|
|
179
|
+
return codexSession(await response.json(), session);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Whether a codex refresh failure means the login is permanently gone.
|
|
183
|
+
* @param error - the thrown refresh error.
|
|
184
|
+
* @returns true when re-login is the only fix.
|
|
185
|
+
*/
|
|
186
|
+
export function isCodexPermanentRefreshError(error) {
|
|
187
|
+
return error instanceof OAuthEndpointError
|
|
188
|
+
&& error.oauthCode !== undefined
|
|
189
|
+
&& PERMANENT_REFRESH_CODES.has(error.oauthCode);
|
|
190
|
+
}
|
|
191
|
+
export const CODEX_MODELS_URL = 'https://chatgpt.com/backend-api/codex/models';
|
|
192
|
+
/**
|
|
193
|
+
* Client version sent on the /models catalog request. The backend gates the
|
|
194
|
+
* visible model list by client version: versions below ~0.101 get an empty
|
|
195
|
+
* list, while current codex CLI releases get the full catalog — keep this in
|
|
196
|
+
* the range of current codex CLI releases.
|
|
197
|
+
*/
|
|
198
|
+
export const CODEX_CLIENT_VERSION = '0.147.0';
|
|
199
|
+
/** Display name for a wire reasoning-effort value. */
|
|
200
|
+
function effortName(effort) {
|
|
201
|
+
return effort === 'xhigh' ? 'Extra High' : effort.charAt(0).toUpperCase() + effort.slice(1);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Fetch the live codex model catalog with the session's auth headers.
|
|
205
|
+
* @param session - the stored session (used as-is; never refreshed here).
|
|
206
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
207
|
+
* @returns discovered models: hidden entries dropped, sorted by priority.
|
|
208
|
+
*/
|
|
209
|
+
export async function fetchCodexModels(session, fetchFn = fetch) {
|
|
210
|
+
const url = `${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`;
|
|
211
|
+
const response = await fetchFn(url, {
|
|
212
|
+
headers: {
|
|
213
|
+
'authorization': `Bearer ${session.accessToken}`,
|
|
214
|
+
'chatgpt-account-id': session.accountId,
|
|
215
|
+
'originator': 'codex_cli_rs',
|
|
216
|
+
'accept': 'application/json',
|
|
217
|
+
...attributionHeaders(),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
if (!response.ok)
|
|
221
|
+
throw await oauthEndpointError(response, 'codex models');
|
|
222
|
+
const payload = await response.json();
|
|
223
|
+
if (!Array.isArray(payload.models))
|
|
224
|
+
throw new Error('codex models endpoint returned no models array');
|
|
225
|
+
const discovered = [];
|
|
226
|
+
for (const entry of payload.models) {
|
|
227
|
+
if (typeof entry.slug !== 'string' || entry.slug.length === 0)
|
|
228
|
+
continue;
|
|
229
|
+
// codex-rs ModelVisibility: only "list" is picker-visible; hide/none are
|
|
230
|
+
// dropped, and an absent or unknown value is included (in doubt, include).
|
|
231
|
+
if (entry.visibility === 'hide' || entry.visibility === 'none')
|
|
232
|
+
continue;
|
|
233
|
+
const efforts = (entry.supported_reasoning_levels ?? [])
|
|
234
|
+
.filter(level => typeof level.effort === 'string' && level.effort.length > 0)
|
|
235
|
+
.map(level => ({
|
|
236
|
+
id: ReasoningEffortId(level.effort),
|
|
237
|
+
name: effortName(level.effort),
|
|
238
|
+
...level.description === undefined ? {} : { description: level.description },
|
|
239
|
+
}));
|
|
240
|
+
const defaultEffort = typeof entry.default_reasoning_level === 'string'
|
|
241
|
+
&& entry.default_reasoning_level.length > 0
|
|
242
|
+
&& efforts.some(effort => effort.id === ReasoningEffortId(entry.default_reasoning_level))
|
|
243
|
+
? ReasoningEffortId(entry.default_reasoning_level)
|
|
244
|
+
: undefined;
|
|
245
|
+
discovered.push({
|
|
246
|
+
id: entry.slug,
|
|
247
|
+
name: typeof entry.display_name === 'string' && entry.display_name.length > 0
|
|
248
|
+
? entry.display_name
|
|
249
|
+
: entry.slug,
|
|
250
|
+
...typeof entry.description === 'string' && entry.description.length > 0
|
|
251
|
+
? { description: entry.description }
|
|
252
|
+
: {},
|
|
253
|
+
...typeof entry.context_window === 'number' && entry.context_window > 0
|
|
254
|
+
? { contextWindow: entry.context_window }
|
|
255
|
+
: {},
|
|
256
|
+
...typeof entry.priority === 'number' ? { priority: entry.priority } : {},
|
|
257
|
+
...efforts.length > 0
|
|
258
|
+
? { reasoning: { efforts, ...defaultEffort === undefined ? {} : { defaultEffort } } }
|
|
259
|
+
: {},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
discovered.sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));
|
|
263
|
+
// An empty catalog from a 200 response means the backend gated us out (e.g.
|
|
264
|
+
// client_version too old): surface it as a discovery failure so the adapter
|
|
265
|
+
// falls back to the static catalog instead of vanishing from the picker.
|
|
266
|
+
if (discovered.length === 0) {
|
|
267
|
+
throw new Error(`codex models endpoint returned an empty catalog (client_version ${CODEX_CLIENT_VERSION})`);
|
|
268
|
+
}
|
|
269
|
+
return discovered;
|
|
270
|
+
}
|
|
271
|
+
/** Codex wire adapter: one instance serves the `codex` provider route. */
|
|
272
|
+
export class CodexAdapter extends LlmAdapter {
|
|
273
|
+
options;
|
|
274
|
+
catalog = new ModelCatalogCache();
|
|
275
|
+
constructor(options) {
|
|
276
|
+
super();
|
|
277
|
+
this.options = options;
|
|
278
|
+
}
|
|
279
|
+
providerInfo(provider) {
|
|
280
|
+
return { id: provider, name: 'ChatGPT (Codex)' };
|
|
281
|
+
}
|
|
282
|
+
staticModels(provider) {
|
|
283
|
+
return this.options.models.map(model => ({
|
|
284
|
+
provider,
|
|
285
|
+
id: model.id,
|
|
286
|
+
name: model.name ?? model.id,
|
|
287
|
+
inputModalities: model.inputModalities ?? CODEX_MODALITIES,
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
async listModels(provider) {
|
|
291
|
+
// Not logged in → empty catalog, so the web picker drops the provider.
|
|
292
|
+
const session = await this.options.tokens.peek();
|
|
293
|
+
if (session === undefined)
|
|
294
|
+
return [];
|
|
295
|
+
if (!this.options.discovery)
|
|
296
|
+
return this.staticModels(provider);
|
|
297
|
+
try {
|
|
298
|
+
const discovered = await this.catalog.get(() => fetchCodexModels(session, this.options.fetchFn));
|
|
299
|
+
return discovered.map(model => ({
|
|
300
|
+
provider,
|
|
301
|
+
id: model.id,
|
|
302
|
+
name: model.name,
|
|
303
|
+
...model.description === undefined ? {} : { description: model.description },
|
|
304
|
+
inputModalities: CODEX_MODALITIES,
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (error instanceof OAuthEndpointError && error.status === 401)
|
|
309
|
+
this.catalog.invalidate();
|
|
310
|
+
this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
311
|
+
return this.staticModels(provider);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
resolveModel(provider, model) {
|
|
315
|
+
// Discovered metadata (when discovery is on and the cache is warm) wins
|
|
316
|
+
// over the static entry; the static entry wins over the built-in defaults.
|
|
317
|
+
const discovered = this.options.discovery
|
|
318
|
+
? this.catalog.cached()?.find(entry => entry.id === model)
|
|
319
|
+
: undefined;
|
|
320
|
+
const configured = this.options.models.find(entry => entry.id === model);
|
|
321
|
+
return Promise.resolve({
|
|
322
|
+
provider,
|
|
323
|
+
id: model,
|
|
324
|
+
name: discovered?.name ?? configured?.name ?? model,
|
|
325
|
+
...discovered?.description === undefined ? {} : { description: discovered.description },
|
|
326
|
+
inputModalities: configured?.inputModalities ?? CODEX_MODALITIES,
|
|
327
|
+
context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? CODEX_CONTEXT_WINDOW },
|
|
328
|
+
defaultMaxTokens: configured?.maxTokens ?? CODEX_DEFAULT_MAX_TOKENS,
|
|
329
|
+
reasoning: discovered?.reasoning ?? { efforts: CODEX_EFFORTS, defaultEffort: CODEX_DEFAULT_EFFORT },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
async *stream(options) {
|
|
333
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
334
|
+
try {
|
|
335
|
+
let session = await this.options.tokens.session();
|
|
336
|
+
let response = await this.request(options, session, watchdog.signal);
|
|
337
|
+
if (response.status === 401) {
|
|
338
|
+
// One forced refresh + retry on an unexpired-but-rejected token.
|
|
339
|
+
session = await this.options.tokens.session(true);
|
|
340
|
+
response = await this.request(options, session, watchdog.signal);
|
|
341
|
+
}
|
|
342
|
+
if (!response.ok)
|
|
343
|
+
throw await httpLlmError(response, 'codex API');
|
|
344
|
+
if (response.body === null) {
|
|
345
|
+
throw new LlmError('codex API returned no response body', EMPTY_RESPONSE_CODE);
|
|
346
|
+
}
|
|
347
|
+
yield* streamResponses(response.body, () => { watchdog.pulse(); });
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
throw mapFetchFailure('codex API', error, watchdog, options.signal);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
watchdog.stop();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async request(options, session, signal) {
|
|
357
|
+
const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
|
|
358
|
+
const { instructions, input } = toResponsesInput(messages, options.system);
|
|
359
|
+
const body = {
|
|
360
|
+
model: options.model,
|
|
361
|
+
instructions: instructions ?? DEFAULT_CODEX_INSTRUCTIONS,
|
|
362
|
+
input,
|
|
363
|
+
...options.tools !== undefined && options.tools.length > 0
|
|
364
|
+
? { tools: toResponsesTools(options.tools) }
|
|
365
|
+
: {},
|
|
366
|
+
tool_choice: 'auto',
|
|
367
|
+
parallel_tool_calls: true,
|
|
368
|
+
...options.reasoningEffort !== undefined
|
|
369
|
+
? { reasoning: { effort: String(options.reasoningEffort), summary: 'auto' } }
|
|
370
|
+
: {},
|
|
371
|
+
store: false,
|
|
372
|
+
stream: true,
|
|
373
|
+
include: ['reasoning.encrypted_content'],
|
|
374
|
+
...options.sessionId !== undefined ? { prompt_cache_key: String(options.sessionId) } : {},
|
|
375
|
+
};
|
|
376
|
+
return fetch(CODEX_API_URL, {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: {
|
|
379
|
+
'authorization': `Bearer ${session.accessToken}`,
|
|
380
|
+
'chatgpt-account-id': session.accountId,
|
|
381
|
+
'originator': 'codex_cli_rs',
|
|
382
|
+
'session-id': randomUUID(),
|
|
383
|
+
'accept': 'text/event-stream',
|
|
384
|
+
'content-type': 'application/json',
|
|
385
|
+
...attributionHeaders(),
|
|
386
|
+
},
|
|
387
|
+
body: JSON.stringify(body),
|
|
388
|
+
signal,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plumbing shared by the three subscription adapters: HTTP error mapping, a
|
|
3
|
+
* stream idle watchdog, fetch failure classification, OAuth endpoint errors,
|
|
4
|
+
* and the per-provider {@link TokenManager} that owns session freshness.
|
|
5
|
+
* Concurrent refreshes for one provider coalesce behind a single in-flight
|
|
6
|
+
* promise (`inflight`), so a rotating refresh token is never spent twice.
|
|
7
|
+
*/
|
|
8
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
9
|
+
import type { ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
10
|
+
/** One configured model catalog entry. */
|
|
11
|
+
export interface ModelEntry {
|
|
12
|
+
/** Wire model id; must be non-empty. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Selector label; defaults to the id. */
|
|
15
|
+
name?: string;
|
|
16
|
+
/** Known combined request/response context capacity. */
|
|
17
|
+
contextWindow?: number;
|
|
18
|
+
/** Per-request output cap for this model. */
|
|
19
|
+
maxTokens?: number;
|
|
20
|
+
/** Accepted request modalities; when set, wins over the provider default. */
|
|
21
|
+
inputModalities?: ('text' | 'image')[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
|
|
25
|
+
* @param models - raw configured entries.
|
|
26
|
+
* @param label - diagnostic prefix naming the provider.
|
|
27
|
+
* @returns the validated entries.
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateModels(models: readonly ModelEntry[], label: string): ModelEntry[];
|
|
30
|
+
/**
|
|
31
|
+
* Build an LlmError from a non-2xx provider response, reading and truncating
|
|
32
|
+
* the body for the message and mapping the status to a stable code.
|
|
33
|
+
* @param response - the failed response.
|
|
34
|
+
* @param label - diagnostic prefix naming the provider API.
|
|
35
|
+
* @returns the classified error.
|
|
36
|
+
*/
|
|
37
|
+
export declare function httpLlmError(response: Response, label: string): Promise<LlmError>;
|
|
38
|
+
/** An idle watchdog: aborts its signal when no SSE activity arrives within the timeout. */
|
|
39
|
+
export interface IdleWatchdog {
|
|
40
|
+
/** Signal to pass to fetch and body reads; aborts on caller cancel or idle expiry. */
|
|
41
|
+
readonly signal: AbortSignal;
|
|
42
|
+
/** Reset the idle timer (call on every received SSE event). */
|
|
43
|
+
pulse(): void;
|
|
44
|
+
/** Stop the timer and detach from the caller signal. */
|
|
45
|
+
stop(): void;
|
|
46
|
+
/** Whether the last abort came from idle expiry rather than caller cancellation. */
|
|
47
|
+
timedOut(): boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create an idle watchdog chained to the caller's signal.
|
|
51
|
+
* @param caller - the request's own abort signal, when present.
|
|
52
|
+
* @param timeoutMs - maximum idle interval while a stream read is outstanding.
|
|
53
|
+
* @returns the watchdog; always {@link IdleWatchdog.stop} it when the stream ends.
|
|
54
|
+
*/
|
|
55
|
+
export declare function idleWatchdog(caller: AbortSignal | undefined, timeoutMs: number): IdleWatchdog;
|
|
56
|
+
/**
|
|
57
|
+
* Classify a thrown fetch failure. Caller cancellation maps to ABORTED, idle
|
|
58
|
+
* expiry to TIMEOUT, and everything else (DNS, TLS, refused connection) to
|
|
59
|
+
* TRANSPORT with the cause chained.
|
|
60
|
+
* @param label - diagnostic prefix naming the provider API.
|
|
61
|
+
* @param error - the thrown value.
|
|
62
|
+
* @param watchdog - the request's idle watchdog.
|
|
63
|
+
* @param caller - the request's own abort signal, when present.
|
|
64
|
+
* @returns the classified error.
|
|
65
|
+
*/
|
|
66
|
+
export declare function mapFetchFailure(label: string, error: unknown, watchdog: IdleWatchdog, caller: AbortSignal | undefined): LlmError;
|
|
67
|
+
/** OAuth token-endpoint failure carrying the provider's `error` code when it sent one. */
|
|
68
|
+
export declare class OAuthEndpointError extends Error {
|
|
69
|
+
/** HTTP status of the token endpoint response. */
|
|
70
|
+
readonly status: number;
|
|
71
|
+
/** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */
|
|
72
|
+
readonly oauthCode: string | undefined;
|
|
73
|
+
constructor(message: string, status: number, oauthCode?: string);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read an OAuth JSON error body into an {@link OAuthEndpointError}.
|
|
77
|
+
* @param response - the failed token-endpoint response.
|
|
78
|
+
* @param label - diagnostic prefix naming the provider.
|
|
79
|
+
* @returns the error to throw.
|
|
80
|
+
*/
|
|
81
|
+
export declare function oauthEndpointError(response: Response, label: string): Promise<OAuthEndpointError>;
|
|
82
|
+
/** A session fresh enough to serve a request without a refresh. */
|
|
83
|
+
interface TimedSession {
|
|
84
|
+
accessToken: string;
|
|
85
|
+
refreshToken: string;
|
|
86
|
+
expiresAt: number;
|
|
87
|
+
}
|
|
88
|
+
/** Provider hooks the token manager needs. */
|
|
89
|
+
export interface TokenManagerOptions<S extends TimedSession> {
|
|
90
|
+
/** Human-readable provider name for error messages. */
|
|
91
|
+
displayName: string;
|
|
92
|
+
/** Refresh this long before `expiresAt`. */
|
|
93
|
+
preemptMs: number;
|
|
94
|
+
load(): Promise<S | undefined>;
|
|
95
|
+
save(session: S): Promise<void>;
|
|
96
|
+
remove(): Promise<void>;
|
|
97
|
+
/** Perform the provider's refresh-token grant. */
|
|
98
|
+
refresh(session: S): Promise<S>;
|
|
99
|
+
/** Whether a refresh failure is permanent (re-login required). */
|
|
100
|
+
isPermanent(error: unknown): boolean;
|
|
101
|
+
/** Called after a permanent refresh failure deleted the stored session. */
|
|
102
|
+
onRemoved?(): void;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Per-provider session freshness: loads the stored session, refreshes
|
|
106
|
+
* proactively inside the preempt window or on demand after a 401, and
|
|
107
|
+
* coalesces concurrent refreshes behind one in-flight promise. Permanent
|
|
108
|
+
* refresh failures delete the stored session and surface INVALID_CREDENTIAL
|
|
109
|
+
* with a re-login hint; transient failures fall back to a still-valid token.
|
|
110
|
+
*/
|
|
111
|
+
export declare class TokenManager<S extends TimedSession> {
|
|
112
|
+
private readonly options;
|
|
113
|
+
private inflight;
|
|
114
|
+
constructor(options: TokenManagerOptions<S>);
|
|
115
|
+
/**
|
|
116
|
+
* Read the stored session without any refresh side effect. Catalog queries
|
|
117
|
+
* (`listModels`) use this to decide whether the provider is logged in.
|
|
118
|
+
* @returns the stored session, or `undefined` when logged out.
|
|
119
|
+
*/
|
|
120
|
+
peek(): Promise<S | undefined>;
|
|
121
|
+
/**
|
|
122
|
+
* Whether a session is currently stored (cheap; never refreshes).
|
|
123
|
+
* @returns true when logged in.
|
|
124
|
+
*/
|
|
125
|
+
hasSession(): Promise<boolean>;
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a usable session, refreshing proactively or on demand.
|
|
128
|
+
* @param forceRefresh - refresh regardless of expiry (used after a 401).
|
|
129
|
+
* @returns the persisted session to send.
|
|
130
|
+
* @throws LlmError MISSING_CREDENTIAL when logged out, INVALID_CREDENTIAL
|
|
131
|
+
* when the refresh grant is permanently rejected.
|
|
132
|
+
*/
|
|
133
|
+
session(forceRefresh?: boolean): Promise<S>;
|
|
134
|
+
private doRefresh;
|
|
135
|
+
}
|
|
136
|
+
/** Fetch signature adapters accept for discovery calls (injectable for tests). */
|
|
137
|
+
export type FetchFn = typeof fetch;
|
|
138
|
+
/** One model discovered from a provider's live model-list endpoint. */
|
|
139
|
+
export interface DiscoveredModel {
|
|
140
|
+
/** Wire model id. */
|
|
141
|
+
id: string;
|
|
142
|
+
/** Human-readable display name. */
|
|
143
|
+
name: string;
|
|
144
|
+
description?: string;
|
|
145
|
+
/** Advertised combined context capacity in tokens. */
|
|
146
|
+
contextWindow?: number;
|
|
147
|
+
/** Provider sort hint; lower sorts earlier. */
|
|
148
|
+
priority?: number;
|
|
149
|
+
/** Advertised reasoning efforts, when the provider discloses them. */
|
|
150
|
+
reasoning?: {
|
|
151
|
+
efforts: {
|
|
152
|
+
id: ReasoningEffortId;
|
|
153
|
+
name: string;
|
|
154
|
+
description?: string;
|
|
155
|
+
}[];
|
|
156
|
+
defaultEffort?: ReasoningEffortId;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** How long a discovered catalog is trusted before re-fetching. */
|
|
160
|
+
export declare const DISCOVERY_TTL_MS: number;
|
|
161
|
+
/**
|
|
162
|
+
* TTL cache for one provider's discovered model catalog. Only `listModels`
|
|
163
|
+
* populates it (via {@link get}); `resolveModel` reads {@link cached} so it
|
|
164
|
+
* never performs network I/O. A 401 during a fetch must call
|
|
165
|
+
* {@link invalidate}.
|
|
166
|
+
*/
|
|
167
|
+
export declare class ModelCatalogCache {
|
|
168
|
+
private readonly ttlMs;
|
|
169
|
+
private entry;
|
|
170
|
+
constructor(ttlMs?: number);
|
|
171
|
+
/**
|
|
172
|
+
* The cached catalog when fresh, without fetching.
|
|
173
|
+
* @returns the cached models, or `undefined` when absent or stale.
|
|
174
|
+
*/
|
|
175
|
+
cached(): readonly DiscoveredModel[] | undefined;
|
|
176
|
+
/**
|
|
177
|
+
* Return the cached catalog when fresh, otherwise fetch and cache it.
|
|
178
|
+
* @param fetcher - performs the provider's model-list request.
|
|
179
|
+
* @returns the discovered models.
|
|
180
|
+
*/
|
|
181
|
+
get(fetcher: () => Promise<DiscoveredModel[]>): Promise<readonly DiscoveredModel[]>;
|
|
182
|
+
/** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
|
|
183
|
+
invalidate(): void;
|
|
184
|
+
}
|
|
185
|
+
export {};
|