@khanglvm/llm-router 1.1.1 → 1.3.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 +40 -0
- package/README.md +29 -14
- package/package.json +1 -1
- package/src/cli/router-module.js +1469 -565
- package/src/node/config-workflows.js +3 -1
- package/src/runtime/codex-request-transformer.js +284 -28
- package/src/runtime/codex-response-transformer.js +433 -0
- package/src/runtime/config.js +21 -15
- package/src/runtime/handler/provider-call.js +217 -106
- package/src/runtime/subscription-auth.js +228 -95
- package/src/runtime/subscription-constants.js +43 -7
- package/src/runtime/subscription-provider.js +311 -38
|
@@ -1,12 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OAuth authentication for
|
|
3
|
-
*
|
|
2
|
+
* OAuth authentication for subscription providers.
|
|
3
|
+
* Supports ChatGPT Codex and Claude Code OAuth flows.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import crypto from 'node:crypto';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import {
|
|
10
|
+
CODEX_OAUTH_CONFIG,
|
|
11
|
+
CLAUDE_CODE_OAUTH_CONFIG
|
|
12
|
+
} from './subscription-constants.js';
|
|
13
|
+
import {
|
|
14
|
+
saveTokens,
|
|
15
|
+
loadTokens,
|
|
16
|
+
isTokenExpired,
|
|
17
|
+
deleteTokens,
|
|
18
|
+
listTokenProfiles as listTokenProfilesFromStore
|
|
19
|
+
} from './subscription-tokens.js';
|
|
20
|
+
|
|
21
|
+
const SUBSCRIPTION_TYPE_CHATGPT_CODEX = 'chatgpt-codex';
|
|
22
|
+
const SUBSCRIPTION_TYPE_CLAUDE_CODE = 'claude-code';
|
|
23
|
+
const CLAUDE_PROFILE_PREFIX = `${SUBSCRIPTION_TYPE_CLAUDE_CODE}__`;
|
|
24
|
+
|
|
25
|
+
function normalizeSubscriptionType(value, { allowEmpty = false } = {}) {
|
|
26
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
27
|
+
if (!normalized) {
|
|
28
|
+
return allowEmpty ? '' : SUBSCRIPTION_TYPE_CHATGPT_CODEX;
|
|
29
|
+
}
|
|
30
|
+
if (normalized === SUBSCRIPTION_TYPE_CHATGPT_CODEX) return SUBSCRIPTION_TYPE_CHATGPT_CODEX;
|
|
31
|
+
if (normalized === SUBSCRIPTION_TYPE_CLAUDE_CODE) return SUBSCRIPTION_TYPE_CLAUDE_CODE;
|
|
32
|
+
throw new Error(`Unsupported subscription type '${value}'.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveOAuthConfig(subscriptionType) {
|
|
36
|
+
const normalized = normalizeSubscriptionType(subscriptionType);
|
|
37
|
+
if (normalized === SUBSCRIPTION_TYPE_CLAUDE_CODE) {
|
|
38
|
+
return CLAUDE_CODE_OAUTH_CONFIG;
|
|
39
|
+
}
|
|
40
|
+
return CODEX_OAUTH_CONFIG;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toTokenProfileKey(profileId, subscriptionType) {
|
|
44
|
+
const normalizedProfile = String(profileId || 'default').trim() || 'default';
|
|
45
|
+
const normalizedType = normalizeSubscriptionType(subscriptionType);
|
|
46
|
+
if (normalizedType === SUBSCRIPTION_TYPE_CLAUDE_CODE) {
|
|
47
|
+
return `${CLAUDE_PROFILE_PREFIX}${normalizedProfile}`;
|
|
48
|
+
}
|
|
49
|
+
return normalizedProfile;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fromTokenProfileKey(profileKey) {
|
|
53
|
+
const value = String(profileKey || '').trim();
|
|
54
|
+
if (value.startsWith(CLAUDE_PROFILE_PREFIX)) {
|
|
55
|
+
return {
|
|
56
|
+
profileId: value.slice(CLAUDE_PROFILE_PREFIX.length) || 'default',
|
|
57
|
+
subscriptionType: SUBSCRIPTION_TYPE_CLAUDE_CODE
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
profileId: value || 'default',
|
|
62
|
+
subscriptionType: SUBSCRIPTION_TYPE_CHATGPT_CODEX
|
|
63
|
+
};
|
|
64
|
+
}
|
|
10
65
|
|
|
11
66
|
/**
|
|
12
67
|
* Generate PKCE code verifier and challenge.
|
|
@@ -27,22 +82,60 @@ function generateState() {
|
|
|
27
82
|
return crypto.randomBytes(16).toString('hex');
|
|
28
83
|
}
|
|
29
84
|
|
|
85
|
+
function tryOpenBrowser(url) {
|
|
86
|
+
const target = String(url || '').trim();
|
|
87
|
+
if (!target) return false;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
let child;
|
|
91
|
+
if (process.platform === 'darwin') {
|
|
92
|
+
child = spawn('open', [target], { stdio: 'ignore', detached: true });
|
|
93
|
+
} else if (process.platform === 'win32') {
|
|
94
|
+
child = spawn('cmd', ['/c', 'start', '', target], { stdio: 'ignore', detached: true });
|
|
95
|
+
} else {
|
|
96
|
+
child = spawn('xdg-open', [target], { stdio: 'ignore', detached: true });
|
|
97
|
+
}
|
|
98
|
+
child.unref();
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeTokenData(data, fallbackRefreshToken = undefined) {
|
|
106
|
+
return {
|
|
107
|
+
accessToken: data.access_token,
|
|
108
|
+
refreshToken: data.refresh_token || fallbackRefreshToken,
|
|
109
|
+
expiresAt: Date.now() + (Number(data.expires_in || 0) * 1000),
|
|
110
|
+
tokenType: data.token_type || 'Bearer',
|
|
111
|
+
scope: data.scope
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
30
115
|
/**
|
|
31
116
|
* Refresh an access token using refresh token.
|
|
32
117
|
* @param {string} refreshToken - OAuth refresh token
|
|
118
|
+
* @param {Object} [options] - Options
|
|
119
|
+
* @param {string} [options.subscriptionType] - Subscription type
|
|
33
120
|
* @returns {Promise<Object>} New token data
|
|
34
121
|
*/
|
|
35
|
-
export async function refreshAccessToken(refreshToken) {
|
|
36
|
-
const
|
|
122
|
+
export async function refreshAccessToken(refreshToken, options = {}) {
|
|
123
|
+
const config = resolveOAuthConfig(options.subscriptionType);
|
|
124
|
+
const body = {
|
|
125
|
+
grant_type: 'refresh_token',
|
|
126
|
+
refresh_token: refreshToken,
|
|
127
|
+
client_id: config.clientId
|
|
128
|
+
};
|
|
129
|
+
if (typeof config.scopes === 'string' && config.scopes.trim()) {
|
|
130
|
+
body.scope = config.scopes;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const response = await fetch(config.tokenUrl, {
|
|
37
134
|
method: 'POST',
|
|
38
135
|
headers: {
|
|
39
136
|
'Content-Type': 'application/json'
|
|
40
137
|
},
|
|
41
|
-
body: JSON.stringify(
|
|
42
|
-
grant_type: 'refresh_token',
|
|
43
|
-
refresh_token: refreshToken,
|
|
44
|
-
client_id: CODEX_OAUTH_CONFIG.clientId
|
|
45
|
-
})
|
|
138
|
+
body: JSON.stringify(body)
|
|
46
139
|
});
|
|
47
140
|
|
|
48
141
|
if (!response.ok) {
|
|
@@ -51,38 +144,36 @@ export async function refreshAccessToken(refreshToken) {
|
|
|
51
144
|
}
|
|
52
145
|
|
|
53
146
|
const data = await response.json();
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
accessToken: data.access_token,
|
|
57
|
-
refreshToken: data.refresh_token || refreshToken,
|
|
58
|
-
expiresAt: Date.now() + (data.expires_in * 1000),
|
|
59
|
-
tokenType: data.token_type || 'Bearer',
|
|
60
|
-
scope: data.scope
|
|
61
|
-
};
|
|
147
|
+
return normalizeTokenData(data, refreshToken);
|
|
62
148
|
}
|
|
63
149
|
|
|
64
150
|
/**
|
|
65
151
|
* Get valid access token for a profile, refreshing if needed.
|
|
66
152
|
* @param {string} profileId - Provider profile ID
|
|
153
|
+
* @param {Object} [options] - Options
|
|
154
|
+
* @param {string} [options.subscriptionType] - Subscription type
|
|
67
155
|
* @returns {Promise<string|null>} Valid access token or null
|
|
68
156
|
*/
|
|
69
|
-
export async function getValidAccessToken(profileId) {
|
|
70
|
-
const
|
|
157
|
+
export async function getValidAccessToken(profileId, options = {}) {
|
|
158
|
+
const config = resolveOAuthConfig(options.subscriptionType);
|
|
159
|
+
const tokenProfileKey = toTokenProfileKey(profileId, options.subscriptionType);
|
|
160
|
+
const tokens = await loadTokens(tokenProfileKey);
|
|
71
161
|
if (!tokens) return null;
|
|
72
162
|
|
|
73
163
|
// Check if token needs refresh
|
|
74
|
-
if (isTokenExpired(tokens,
|
|
164
|
+
if (isTokenExpired(tokens, config.tokenRefreshBufferMs)) {
|
|
75
165
|
if (!tokens.refreshToken) {
|
|
76
|
-
return null;
|
|
166
|
+
return null;
|
|
77
167
|
}
|
|
78
168
|
|
|
79
169
|
try {
|
|
80
|
-
const newTokens = await refreshAccessToken(tokens.refreshToken
|
|
81
|
-
|
|
170
|
+
const newTokens = await refreshAccessToken(tokens.refreshToken, {
|
|
171
|
+
subscriptionType: options.subscriptionType
|
|
172
|
+
});
|
|
173
|
+
await saveTokens(tokenProfileKey, newTokens);
|
|
82
174
|
return newTokens.accessToken;
|
|
83
175
|
} catch {
|
|
84
|
-
|
|
85
|
-
await deleteTokens(profileId);
|
|
176
|
+
await deleteTokens(tokenProfileKey);
|
|
86
177
|
return null;
|
|
87
178
|
}
|
|
88
179
|
}
|
|
@@ -95,21 +186,30 @@ export async function getValidAccessToken(profileId) {
|
|
|
95
186
|
* @param {string} code - Authorization code
|
|
96
187
|
* @param {string} codeVerifier - PKCE code verifier
|
|
97
188
|
* @param {string} redirectUri - Redirect URI used in auth request
|
|
189
|
+
* @param {Object} [options] - Options
|
|
190
|
+
* @param {string} [options.subscriptionType] - Subscription type
|
|
191
|
+
* @param {string} [options.state] - OAuth state
|
|
98
192
|
* @returns {Promise<Object>} Token data
|
|
99
193
|
*/
|
|
100
|
-
async function exchangeCodeForTokens(code, codeVerifier, redirectUri) {
|
|
101
|
-
const
|
|
194
|
+
async function exchangeCodeForTokens(code, codeVerifier, redirectUri, options = {}) {
|
|
195
|
+
const config = resolveOAuthConfig(options.subscriptionType);
|
|
196
|
+
const body = {
|
|
197
|
+
grant_type: 'authorization_code',
|
|
198
|
+
code,
|
|
199
|
+
code_verifier: codeVerifier,
|
|
200
|
+
redirect_uri: redirectUri,
|
|
201
|
+
client_id: config.clientId
|
|
202
|
+
};
|
|
203
|
+
if (typeof options.state === 'string' && options.state.trim()) {
|
|
204
|
+
body.state = options.state.trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const response = await fetch(config.tokenUrl, {
|
|
102
208
|
method: 'POST',
|
|
103
209
|
headers: {
|
|
104
210
|
'Content-Type': 'application/json'
|
|
105
211
|
},
|
|
106
|
-
body: JSON.stringify(
|
|
107
|
-
grant_type: 'authorization_code',
|
|
108
|
-
code,
|
|
109
|
-
code_verifier: codeVerifier,
|
|
110
|
-
redirect_uri: redirectUri,
|
|
111
|
-
client_id: CODEX_OAUTH_CONFIG.clientId
|
|
112
|
-
})
|
|
212
|
+
body: JSON.stringify(body)
|
|
113
213
|
});
|
|
114
214
|
|
|
115
215
|
if (!response.ok) {
|
|
@@ -118,47 +218,57 @@ async function exchangeCodeForTokens(code, codeVerifier, redirectUri) {
|
|
|
118
218
|
}
|
|
119
219
|
|
|
120
220
|
const data = await response.json();
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
accessToken: data.access_token,
|
|
124
|
-
refreshToken: data.refresh_token,
|
|
125
|
-
expiresAt: Date.now() + (data.expires_in * 1000),
|
|
126
|
-
tokenType: data.token_type || 'Bearer',
|
|
127
|
-
scope: data.scope
|
|
128
|
-
};
|
|
221
|
+
return normalizeTokenData(data);
|
|
129
222
|
}
|
|
130
223
|
|
|
131
224
|
/**
|
|
132
225
|
* Start browser-based OAuth login.
|
|
133
226
|
* @param {string} profileId - Provider profile ID
|
|
134
227
|
* @param {Object} [options] - Options
|
|
228
|
+
* @param {string} [options.subscriptionType] - Subscription type
|
|
135
229
|
* @param {number} [options.port] - Callback server port
|
|
136
230
|
* @param {function} [options.onUrl] - Callback when auth URL is ready
|
|
137
231
|
* @returns {Promise<boolean>} Success status
|
|
138
232
|
*/
|
|
139
233
|
export async function loginWithBrowser(profileId, options = {}) {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
234
|
+
const config = resolveOAuthConfig(options.subscriptionType);
|
|
235
|
+
const tokenProfileKey = toTokenProfileKey(profileId, options.subscriptionType);
|
|
236
|
+
const port = options.port || config.callbackPort;
|
|
237
|
+
const redirectUri = `http://localhost:${port}${config.callbackPath}`;
|
|
238
|
+
|
|
143
239
|
const pkce = generatePKCE();
|
|
144
240
|
const state = generateState();
|
|
145
|
-
|
|
146
|
-
const authUrl = new URL(
|
|
241
|
+
|
|
242
|
+
const authUrl = new URL(config.authorizeUrl);
|
|
147
243
|
authUrl.searchParams.set('response_type', 'code');
|
|
148
|
-
authUrl.searchParams.set('client_id',
|
|
244
|
+
authUrl.searchParams.set('client_id', config.clientId);
|
|
149
245
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
150
|
-
authUrl.searchParams.set('scope',
|
|
151
|
-
authUrl.searchParams.set('audience', CODEX_OAUTH_CONFIG.audience);
|
|
246
|
+
authUrl.searchParams.set('scope', config.scopes);
|
|
152
247
|
authUrl.searchParams.set('state', state);
|
|
153
248
|
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
154
249
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
250
|
+
if (config.authorizeParams && typeof config.authorizeParams === 'object') {
|
|
251
|
+
for (const [key, value] of Object.entries(config.authorizeParams)) {
|
|
252
|
+
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
|
253
|
+
authUrl.searchParams.set(key, String(value));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
155
257
|
|
|
156
258
|
return new Promise((resolve, reject) => {
|
|
259
|
+
let completed = false;
|
|
260
|
+
const finish = (fn) => {
|
|
261
|
+
if (completed) return;
|
|
262
|
+
completed = true;
|
|
263
|
+
clearTimeout(timeout);
|
|
264
|
+
fn();
|
|
265
|
+
};
|
|
266
|
+
|
|
157
267
|
const server = http.createServer(async (req, res) => {
|
|
158
268
|
try {
|
|
159
269
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
160
|
-
|
|
161
|
-
if (url.pathname !==
|
|
270
|
+
|
|
271
|
+
if (url.pathname !== config.callbackPath) {
|
|
162
272
|
res.writeHead(404);
|
|
163
273
|
res.end('Not found');
|
|
164
274
|
return;
|
|
@@ -172,7 +282,7 @@ export async function loginWithBrowser(profileId, options = {}) {
|
|
|
172
282
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
173
283
|
res.end(`<h1>Authentication failed</h1><p>${error}</p>`);
|
|
174
284
|
server.close();
|
|
175
|
-
reject(new Error(`OAuth error: ${error}`));
|
|
285
|
+
finish(() => reject(new Error(`OAuth error: ${error}`)));
|
|
176
286
|
return;
|
|
177
287
|
}
|
|
178
288
|
|
|
@@ -180,38 +290,39 @@ export async function loginWithBrowser(profileId, options = {}) {
|
|
|
180
290
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
181
291
|
res.end('<h1>Invalid callback</h1><p>Missing or invalid state/code</p>');
|
|
182
292
|
server.close();
|
|
183
|
-
reject(new Error('Invalid OAuth callback'));
|
|
293
|
+
finish(() => reject(new Error('Invalid OAuth callback')));
|
|
184
294
|
return;
|
|
185
295
|
}
|
|
186
296
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
297
|
+
const tokens = await exchangeCodeForTokens(code, pkce.verifier, redirectUri, {
|
|
298
|
+
subscriptionType: options.subscriptionType,
|
|
299
|
+
state
|
|
300
|
+
});
|
|
301
|
+
await saveTokens(tokenProfileKey, tokens);
|
|
190
302
|
|
|
191
303
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
192
304
|
res.end('<h1>Success!</h1><p>You can close this window and return to the terminal.</p>');
|
|
193
|
-
|
|
194
305
|
server.close();
|
|
195
|
-
resolve(true);
|
|
306
|
+
finish(() => resolve(true));
|
|
196
307
|
} catch (err) {
|
|
197
308
|
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
198
309
|
res.end(`<h1>Error</h1><p>${err.message}</p>`);
|
|
199
310
|
server.close();
|
|
200
|
-
reject(err);
|
|
311
|
+
finish(() => reject(err));
|
|
201
312
|
}
|
|
202
313
|
});
|
|
203
314
|
|
|
204
315
|
server.listen(port, () => {
|
|
205
316
|
const authUrlStr = authUrl.toString();
|
|
317
|
+
const openedBrowser = options.autoOpen !== false ? tryOpenBrowser(authUrlStr) : false;
|
|
206
318
|
if (options.onUrl) {
|
|
207
|
-
options.onUrl(authUrlStr);
|
|
319
|
+
options.onUrl(authUrlStr, { openedBrowser });
|
|
208
320
|
}
|
|
209
321
|
});
|
|
210
322
|
|
|
211
|
-
|
|
212
|
-
setTimeout(() => {
|
|
323
|
+
const timeout = setTimeout(() => {
|
|
213
324
|
server.close();
|
|
214
|
-
reject(new Error('Login timed out after 5 minutes'));
|
|
325
|
+
finish(() => reject(new Error('Login timed out after 5 minutes')));
|
|
215
326
|
}, 5 * 60 * 1000);
|
|
216
327
|
});
|
|
217
328
|
}
|
|
@@ -219,20 +330,26 @@ export async function loginWithBrowser(profileId, options = {}) {
|
|
|
219
330
|
/**
|
|
220
331
|
* Start device code OAuth login (for headless environments).
|
|
221
332
|
* @param {string} profileId - Provider profile ID
|
|
333
|
+
* @param {Object} [options] - Options
|
|
334
|
+
* @param {string} [options.subscriptionType] - Subscription type
|
|
222
335
|
* @param {function} [options.onCode] - Callback when device code is ready
|
|
223
336
|
* @returns {Promise<boolean>} Success status
|
|
224
337
|
*/
|
|
225
338
|
export async function loginWithDeviceCode(profileId, options = {}) {
|
|
226
|
-
|
|
227
|
-
|
|
339
|
+
const config = resolveOAuthConfig(options.subscriptionType);
|
|
340
|
+
if (!config.deviceCodeUrl) {
|
|
341
|
+
throw new Error(`Device code OAuth flow is not supported for subscription type '${normalizeSubscriptionType(options.subscriptionType)}'.`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const tokenProfileKey = toTokenProfileKey(profileId, options.subscriptionType);
|
|
345
|
+
const response = await fetch(config.deviceCodeUrl, {
|
|
228
346
|
method: 'POST',
|
|
229
347
|
headers: {
|
|
230
348
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
231
349
|
},
|
|
232
350
|
body: new URLSearchParams({
|
|
233
|
-
client_id:
|
|
234
|
-
scope:
|
|
235
|
-
audience: CODEX_OAUTH_CONFIG.audience
|
|
351
|
+
client_id: config.clientId,
|
|
352
|
+
scope: config.scopes
|
|
236
353
|
}).toString()
|
|
237
354
|
});
|
|
238
355
|
|
|
@@ -248,18 +365,16 @@ export async function loginWithDeviceCode(profileId, options = {}) {
|
|
|
248
365
|
const expiresIn = data.expires_in;
|
|
249
366
|
const interval = data.interval || 5;
|
|
250
367
|
|
|
251
|
-
// Notify user
|
|
252
368
|
if (options.onCode) {
|
|
253
369
|
options.onCode({ userCode, verificationUri, expiresIn });
|
|
254
370
|
}
|
|
255
371
|
|
|
256
|
-
// Poll for token
|
|
257
372
|
const startTime = Date.now();
|
|
258
373
|
while (Date.now() - startTime < expiresIn * 1000) {
|
|
259
|
-
await new Promise(
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
260
375
|
|
|
261
376
|
try {
|
|
262
|
-
const tokenResponse = await fetch(
|
|
377
|
+
const tokenResponse = await fetch(config.tokenUrl, {
|
|
263
378
|
method: 'POST',
|
|
264
379
|
headers: {
|
|
265
380
|
'Content-Type': 'application/json'
|
|
@@ -267,29 +382,22 @@ export async function loginWithDeviceCode(profileId, options = {}) {
|
|
|
267
382
|
body: JSON.stringify({
|
|
268
383
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
269
384
|
device_code: deviceCode,
|
|
270
|
-
client_id:
|
|
385
|
+
client_id: config.clientId
|
|
271
386
|
})
|
|
272
387
|
});
|
|
273
388
|
|
|
274
389
|
if (tokenResponse.ok) {
|
|
275
390
|
const tokenData = await tokenResponse.json();
|
|
276
|
-
|
|
277
|
-
accessToken: tokenData.access_token,
|
|
278
|
-
refreshToken: tokenData.refresh_token,
|
|
279
|
-
expiresAt: Date.now() + (tokenData.expires_in * 1000),
|
|
280
|
-
tokenType: tokenData.token_type || 'Bearer',
|
|
281
|
-
scope: tokenData.scope
|
|
282
|
-
};
|
|
283
|
-
await saveTokens(profileId, tokens);
|
|
391
|
+
await saveTokens(tokenProfileKey, normalizeTokenData(tokenData));
|
|
284
392
|
return true;
|
|
285
393
|
}
|
|
286
394
|
|
|
287
395
|
const errorData = await tokenResponse.json();
|
|
288
396
|
if (errorData.error === 'authorization_pending') {
|
|
289
|
-
continue;
|
|
397
|
+
continue;
|
|
290
398
|
}
|
|
291
399
|
if (errorData.error === 'slow_down') {
|
|
292
|
-
await new Promise(
|
|
400
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
293
401
|
continue;
|
|
294
402
|
}
|
|
295
403
|
throw new Error(`Token polling error: ${errorData.error}`);
|
|
@@ -307,32 +415,41 @@ export async function loginWithDeviceCode(profileId, options = {}) {
|
|
|
307
415
|
/**
|
|
308
416
|
* Logout (delete tokens) for a profile.
|
|
309
417
|
* @param {string} profileId - Provider profile ID
|
|
418
|
+
* @param {Object} [options] - Options
|
|
419
|
+
* @param {string} [options.subscriptionType] - Subscription type
|
|
310
420
|
*/
|
|
311
|
-
export async function logout(profileId) {
|
|
312
|
-
|
|
421
|
+
export async function logout(profileId, options = {}) {
|
|
422
|
+
const tokenProfileKey = toTokenProfileKey(profileId, options.subscriptionType);
|
|
423
|
+
await deleteTokens(tokenProfileKey);
|
|
313
424
|
}
|
|
314
425
|
|
|
315
426
|
/**
|
|
316
427
|
* Check authentication status for a profile.
|
|
317
428
|
* @param {string} profileId - Provider profile ID
|
|
429
|
+
* @param {Object} [options] - Options
|
|
430
|
+
* @param {string} [options.subscriptionType] - Subscription type
|
|
318
431
|
* @returns {Promise<Object>} Status object
|
|
319
432
|
*/
|
|
320
|
-
export async function getAuthStatus(profileId) {
|
|
321
|
-
const
|
|
322
|
-
|
|
433
|
+
export async function getAuthStatus(profileId, options = {}) {
|
|
434
|
+
const config = resolveOAuthConfig(options.subscriptionType);
|
|
435
|
+
const tokenProfileKey = toTokenProfileKey(profileId, options.subscriptionType);
|
|
436
|
+
const tokens = await loadTokens(tokenProfileKey);
|
|
437
|
+
|
|
323
438
|
if (!tokens) {
|
|
324
439
|
return {
|
|
325
440
|
authenticated: false,
|
|
326
441
|
profileId,
|
|
442
|
+
subscriptionType: normalizeSubscriptionType(options.subscriptionType),
|
|
327
443
|
reason: 'No tokens found'
|
|
328
444
|
};
|
|
329
445
|
}
|
|
330
446
|
|
|
331
|
-
const expired = isTokenExpired(tokens,
|
|
332
|
-
|
|
447
|
+
const expired = isTokenExpired(tokens, config.tokenRefreshBufferMs);
|
|
448
|
+
|
|
333
449
|
return {
|
|
334
450
|
authenticated: !expired,
|
|
335
451
|
profileId,
|
|
452
|
+
subscriptionType: normalizeSubscriptionType(options.subscriptionType),
|
|
336
453
|
expiresAt: tokens.expiresAt,
|
|
337
454
|
expiresAtIso: new Date(tokens.expiresAt).toISOString(),
|
|
338
455
|
expired,
|
|
@@ -342,8 +459,24 @@ export async function getAuthStatus(profileId) {
|
|
|
342
459
|
|
|
343
460
|
/**
|
|
344
461
|
* List all token profiles with stored subscription credentials.
|
|
462
|
+
* When subscriptionType is provided, returns only profiles for that type.
|
|
463
|
+
* @param {Object} [options] - Options
|
|
464
|
+
* @param {string} [options.subscriptionType] - Subscription type filter
|
|
345
465
|
* @returns {Promise<string[]>} Profile IDs
|
|
346
466
|
*/
|
|
347
|
-
export async function listTokenProfiles() {
|
|
348
|
-
|
|
467
|
+
export async function listTokenProfiles(options = {}) {
|
|
468
|
+
const requestedType = normalizeSubscriptionType(options.subscriptionType, { allowEmpty: true });
|
|
469
|
+
const profileKeys = await listTokenProfilesFromStore();
|
|
470
|
+
const visibleProfiles = [];
|
|
471
|
+
const seen = new Set();
|
|
472
|
+
|
|
473
|
+
for (const profileKey of profileKeys) {
|
|
474
|
+
const parsed = fromTokenProfileKey(profileKey);
|
|
475
|
+
if (requestedType && parsed.subscriptionType !== requestedType) continue;
|
|
476
|
+
if (seen.has(parsed.profileId)) continue;
|
|
477
|
+
seen.add(parsed.profileId);
|
|
478
|
+
visibleProfiles.push(parsed.profileId);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return visibleProfiles;
|
|
349
482
|
}
|
|
@@ -1,29 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hardcoded Codex subscription models.
|
|
3
|
-
* These are
|
|
4
|
-
*
|
|
3
|
+
* These are used as the default seed list for ChatGPT subscription providers.
|
|
4
|
+
* Users can still customize the final saved model list.
|
|
5
5
|
*/
|
|
6
6
|
export const CODEX_SUBSCRIPTION_MODELS = Object.freeze([
|
|
7
7
|
'gpt-5.3-codex',
|
|
8
|
-
'gpt-5.2',
|
|
8
|
+
'gpt-5.2-codex',
|
|
9
9
|
'gpt-5.1-codex-mini'
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Hardcoded Claude Code subscription models.
|
|
14
|
+
* These defaults mirror current Claude Code model naming.
|
|
15
|
+
* Users can still customize the final saved model list.
|
|
16
|
+
*/
|
|
17
|
+
export const CLAUDE_CODE_SUBSCRIPTION_MODELS = Object.freeze([
|
|
18
|
+
'claude-sonnet-4-6',
|
|
19
|
+
'claude-opus-4-6',
|
|
20
|
+
'claude-haiku-4-5'
|
|
21
|
+
]);
|
|
22
|
+
|
|
12
23
|
/**
|
|
13
24
|
* OAuth configuration for ChatGPT Codex subscription.
|
|
14
25
|
*/
|
|
15
26
|
export const CODEX_OAUTH_CONFIG = Object.freeze({
|
|
16
|
-
authorizeUrl: 'https://auth.openai.com/authorize',
|
|
27
|
+
authorizeUrl: 'https://auth.openai.com/oauth/authorize',
|
|
17
28
|
tokenUrl: 'https://auth.openai.com/oauth/token',
|
|
18
29
|
deviceCodeUrl: 'https://auth.openai.com/oauth/device/code',
|
|
19
30
|
callbackPort: 1455,
|
|
20
|
-
callbackPath: '/callback',
|
|
31
|
+
callbackPath: '/auth/callback',
|
|
21
32
|
scopes: 'openid profile email offline_access',
|
|
22
|
-
clientId: '
|
|
23
|
-
|
|
33
|
+
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', // Matches current codex-cli browser login flow
|
|
34
|
+
authorizeParams: Object.freeze({
|
|
35
|
+
id_token_add_organizations: 'true',
|
|
36
|
+
codex_cli_simplified_flow: 'true',
|
|
37
|
+
originator: 'codex_cli_rs'
|
|
38
|
+
}),
|
|
24
39
|
tokenRefreshBufferMs: 5 * 60 * 1000 // 5 minutes before expiration
|
|
25
40
|
});
|
|
26
41
|
|
|
42
|
+
/**
|
|
43
|
+
* OAuth configuration for Claude Code subscription.
|
|
44
|
+
* Values align with the current Claude Code CLI runtime.
|
|
45
|
+
*/
|
|
46
|
+
export const CLAUDE_CODE_OAUTH_CONFIG = Object.freeze({
|
|
47
|
+
authorizeUrl: 'https://claude.ai/oauth/authorize',
|
|
48
|
+
tokenUrl: 'https://platform.claude.com/v1/oauth/token',
|
|
49
|
+
callbackPort: 1456,
|
|
50
|
+
callbackPath: '/callback',
|
|
51
|
+
manualRedirectUrl: 'https://platform.claude.com/oauth/code/callback',
|
|
52
|
+
scopes: 'user:profile user:inference user:sessions:claude_code user:mcp_servers',
|
|
53
|
+
clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
|
54
|
+
authorizeParams: Object.freeze({
|
|
55
|
+
code: 'true'
|
|
56
|
+
}),
|
|
57
|
+
oauthBeta: 'oauth-2025-04-20',
|
|
58
|
+
apiBaseUrl: 'https://api.anthropic.com',
|
|
59
|
+
messagesPath: '/v1/messages?beta=true',
|
|
60
|
+
tokenRefreshBufferMs: 5 * 60 * 1000
|
|
61
|
+
});
|
|
62
|
+
|
|
27
63
|
/**
|
|
28
64
|
* Token storage directory relative to home.
|
|
29
65
|
*/
|