@khanglvm/llm-router 1.1.1 → 1.2.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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Codex Responses API -> OpenAI Chat Completions response transformer.
3
+ */
4
+
5
+ import { withCorsHeaders } from './handler/http.js';
6
+
7
+ function ensureChatCompletionId(value) {
8
+ const raw = String(value || '').trim();
9
+ if (!raw) return `chatcmpl_${Date.now()}`;
10
+ if (raw.startsWith('chatcmpl_')) return raw;
11
+ return `chatcmpl_${raw}`;
12
+ }
13
+
14
+ function toOpenAIUsage(usage) {
15
+ if (!usage || typeof usage !== 'object') return undefined;
16
+ const promptTokens = Number.isFinite(usage.input_tokens) ? Number(usage.input_tokens) : 0;
17
+ const completionTokens = Number.isFinite(usage.output_tokens) ? Number(usage.output_tokens) : 0;
18
+ const totalTokens = Number.isFinite(usage.total_tokens)
19
+ ? Number(usage.total_tokens)
20
+ : (promptTokens + completionTokens);
21
+ return {
22
+ prompt_tokens: promptTokens,
23
+ completion_tokens: completionTokens,
24
+ total_tokens: totalTokens
25
+ };
26
+ }
27
+
28
+ function inferFinishReason({ response, hasToolCalls = false } = {}) {
29
+ if (hasToolCalls) return 'tool_calls';
30
+ const reason = String(response?.incomplete_details?.reason || '').trim().toLowerCase();
31
+ if (reason === 'max_output_tokens' || reason === 'max_tokens') return 'length';
32
+ if (reason === 'content_filter') return 'content_filter';
33
+ return 'stop';
34
+ }
35
+
36
+ function extractAssistantMessage(response) {
37
+ const outputItems = Array.isArray(response?.output) ? response.output : [];
38
+ const textParts = [];
39
+ const toolCalls = [];
40
+
41
+ for (let index = 0; index < outputItems.length; index += 1) {
42
+ const item = outputItems[index];
43
+ if (!item || typeof item !== 'object') continue;
44
+
45
+ if (item.type === 'message' && item.role === 'assistant' && Array.isArray(item.content)) {
46
+ for (const contentPart of item.content) {
47
+ if (!contentPart || typeof contentPart !== 'object') continue;
48
+ if (contentPart.type === 'output_text' && typeof contentPart.text === 'string') {
49
+ textParts.push(contentPart.text);
50
+ continue;
51
+ }
52
+ if (contentPart.type === 'refusal' && typeof contentPart.refusal === 'string') {
53
+ textParts.push(contentPart.refusal);
54
+ }
55
+ }
56
+ continue;
57
+ }
58
+
59
+ if (item.type === 'function_call') {
60
+ toolCalls.push({
61
+ id: String(item.call_id || item.id || `call_${index}`),
62
+ type: 'function',
63
+ function: {
64
+ name: String(item.name || 'tool'),
65
+ arguments: typeof item.arguments === 'string' ? item.arguments : ''
66
+ }
67
+ });
68
+ }
69
+ }
70
+
71
+ const fallbackOutputText = typeof response?.output_text === 'string' ? response.output_text : '';
72
+ const text = textParts.length > 0 ? textParts.join('') : fallbackOutputText;
73
+ return {
74
+ text,
75
+ toolCalls
76
+ };
77
+ }
78
+
79
+ export function convertCodexResponseToOpenAIChatCompletion(response, { fallbackModel = 'unknown' } = {}) {
80
+ const assistant = extractAssistantMessage(response);
81
+ const finishReason = inferFinishReason({
82
+ response,
83
+ hasToolCalls: assistant.toolCalls.length > 0
84
+ });
85
+
86
+ const message = {
87
+ role: 'assistant',
88
+ content: assistant.text.length > 0 ? assistant.text : null
89
+ };
90
+ if (assistant.toolCalls.length > 0) {
91
+ message.tool_calls = assistant.toolCalls;
92
+ }
93
+
94
+ return {
95
+ id: ensureChatCompletionId(response?.id),
96
+ object: 'chat.completion',
97
+ created: Number.isFinite(response?.created_at)
98
+ ? Number(response.created_at)
99
+ : Math.floor(Date.now() / 1000),
100
+ model: response?.model || fallbackModel,
101
+ choices: [
102
+ {
103
+ index: 0,
104
+ message,
105
+ finish_reason: finishReason
106
+ }
107
+ ],
108
+ usage: toOpenAIUsage(response?.usage)
109
+ };
110
+ }
111
+
112
+ function makeOpenAIChunk(state, delta = {}, finishReason = null, usage = undefined) {
113
+ const chunk = {
114
+ id: state.chatId,
115
+ object: 'chat.completion.chunk',
116
+ created: state.created,
117
+ model: state.model || 'unknown',
118
+ choices: [
119
+ {
120
+ index: 0,
121
+ delta,
122
+ finish_reason: finishReason
123
+ }
124
+ ]
125
+ };
126
+ if (usage) chunk.usage = usage;
127
+ return chunk;
128
+ }
129
+
130
+ function ensureAssistantRoleChunk(state, chunks) {
131
+ if (state.roleSent) return;
132
+ state.roleSent = true;
133
+ chunks.push(makeOpenAIChunk(state, { role: 'assistant' }, null));
134
+ }
135
+
136
+ function parseSseBlock(block) {
137
+ let eventType = '';
138
+ const dataLines = [];
139
+ for (const rawLine of block.split('\n')) {
140
+ const line = rawLine.trimEnd();
141
+ if (!line) continue;
142
+ if (line.startsWith('event:')) {
143
+ eventType = line.slice(6).trim();
144
+ continue;
145
+ }
146
+ if (line.startsWith('data:')) {
147
+ dataLines.push(line.slice(5).trimStart());
148
+ }
149
+ }
150
+ return {
151
+ eventType,
152
+ data: dataLines.join('\n').trim()
153
+ };
154
+ }
155
+
156
+ function extractCodexResponseFromEventPayload(payload) {
157
+ if (!payload || typeof payload !== 'object') return null;
158
+ if (payload.object === 'response') return payload;
159
+ const eventType = String(payload.type || '').trim();
160
+ if (
161
+ (eventType === 'response.completed' || eventType === 'response.failed' || eventType === 'response.incomplete')
162
+ && payload.response
163
+ && typeof payload.response === 'object'
164
+ ) {
165
+ return payload.response;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ export function extractCodexFinalResponseFromText(rawText) {
171
+ const text = String(rawText || '').trim();
172
+ if (!text) return null;
173
+
174
+ try {
175
+ const asJson = JSON.parse(text);
176
+ const direct = extractCodexResponseFromEventPayload(asJson);
177
+ if (direct) return direct;
178
+ } catch {
179
+ // Not plain JSON; continue as SSE.
180
+ }
181
+
182
+ const normalized = text.replace(/\r\n/g, '\n');
183
+ const blocks = normalized.split('\n\n');
184
+ let latestResponse = null;
185
+
186
+ for (const block of blocks) {
187
+ if (!block || !block.trim()) continue;
188
+ const parsedBlock = parseSseBlock(block);
189
+ if (!parsedBlock.data || parsedBlock.data === '[DONE]') continue;
190
+
191
+ let payload;
192
+ try {
193
+ payload = JSON.parse(parsedBlock.data);
194
+ } catch {
195
+ continue;
196
+ }
197
+ const response = extractCodexResponseFromEventPayload(payload);
198
+ if (response) {
199
+ latestResponse = response;
200
+ }
201
+ }
202
+
203
+ return latestResponse;
204
+ }
205
+
206
+ export async function extractCodexFinalResponse(response) {
207
+ const raw = await response.text();
208
+ return extractCodexFinalResponseFromText(raw);
209
+ }
210
+
211
+ function resolveToolIndex(state, event) {
212
+ if (Number.isFinite(event?.output_index)) {
213
+ const fromOutputIndex = state.toolCallByOutputIndex.get(Number(event.output_index));
214
+ if (fromOutputIndex !== undefined) return fromOutputIndex;
215
+ }
216
+ if (typeof event?.item_id === 'string' && event.item_id.trim()) {
217
+ const fromItemId = state.toolCallByItemId.get(event.item_id.trim());
218
+ if (fromItemId !== undefined) return fromItemId;
219
+ }
220
+ const toolIndex = state.nextToolCallIndex;
221
+ state.nextToolCallIndex += 1;
222
+ if (Number.isFinite(event?.output_index)) {
223
+ state.toolCallByOutputIndex.set(Number(event.output_index), toolIndex);
224
+ }
225
+ if (typeof event?.item_id === 'string' && event.item_id.trim()) {
226
+ state.toolCallByItemId.set(event.item_id.trim(), toolIndex);
227
+ }
228
+ return toolIndex;
229
+ }
230
+
231
+ function updateStateFromResponse(state, response, fallbackModel) {
232
+ if (!response || typeof response !== 'object') return;
233
+ state.chatId = ensureChatCompletionId(response.id || state.chatId);
234
+ if (Number.isFinite(response.created_at)) {
235
+ state.created = Number(response.created_at);
236
+ }
237
+ if (typeof response.model === 'string' && response.model.trim()) {
238
+ state.model = response.model;
239
+ } else if (!state.model) {
240
+ state.model = fallbackModel;
241
+ }
242
+ }
243
+
244
+ function eventToOpenAIChunks(event, state, { fallbackModel = 'unknown' } = {}) {
245
+ if (!event || typeof event !== 'object') return [];
246
+ const type = String(event.type || '').trim();
247
+ const chunks = [];
248
+
249
+ if (type === 'response.created' || type === 'response.in_progress' || type === 'response.output_item.done') {
250
+ updateStateFromResponse(state, event.response, fallbackModel);
251
+ return chunks;
252
+ }
253
+
254
+ if (type === 'response.output_item.added') {
255
+ updateStateFromResponse(state, event.response, fallbackModel);
256
+ const item = event.item;
257
+ if (!item || typeof item !== 'object') return chunks;
258
+ if (item.type === 'function_call') {
259
+ ensureAssistantRoleChunk(state, chunks);
260
+ const toolIndex = resolveToolIndex(state, event);
261
+ state.hasToolCalls = true;
262
+ chunks.push(makeOpenAIChunk(state, {
263
+ tool_calls: [
264
+ {
265
+ index: toolIndex,
266
+ id: String(item.call_id || item.id || `call_${toolIndex}`),
267
+ type: 'function',
268
+ function: {
269
+ name: String(item.name || 'tool'),
270
+ arguments: ''
271
+ }
272
+ }
273
+ ]
274
+ }, null));
275
+ return chunks;
276
+ }
277
+ if (item.type === 'message') {
278
+ ensureAssistantRoleChunk(state, chunks);
279
+ }
280
+ return chunks;
281
+ }
282
+
283
+ if (type === 'response.output_text.delta') {
284
+ ensureAssistantRoleChunk(state, chunks);
285
+ if (typeof event.item_id === 'string' && event.item_id.trim()) {
286
+ state.textDeltaItemIds.add(event.item_id.trim());
287
+ }
288
+ chunks.push(makeOpenAIChunk(state, { content: String(event.delta || '') }, null));
289
+ return chunks;
290
+ }
291
+
292
+ if (type === 'response.output_text.done') {
293
+ const itemId = typeof event.item_id === 'string' ? event.item_id.trim() : '';
294
+ if (itemId && !state.textDeltaItemIds.has(itemId) && typeof event.text === 'string' && event.text) {
295
+ ensureAssistantRoleChunk(state, chunks);
296
+ chunks.push(makeOpenAIChunk(state, { content: event.text }, null));
297
+ }
298
+ return chunks;
299
+ }
300
+
301
+ if (type === 'response.function_call_arguments.delta') {
302
+ ensureAssistantRoleChunk(state, chunks);
303
+ const toolIndex = resolveToolIndex(state, event);
304
+ state.hasToolCalls = true;
305
+ chunks.push(makeOpenAIChunk(state, {
306
+ tool_calls: [
307
+ {
308
+ index: toolIndex,
309
+ function: {
310
+ arguments: String(event.delta || '')
311
+ }
312
+ }
313
+ ]
314
+ }, null));
315
+ return chunks;
316
+ }
317
+
318
+ if (type === 'response.function_call_arguments.done') {
319
+ ensureAssistantRoleChunk(state, chunks);
320
+ const toolIndex = resolveToolIndex(state, event);
321
+ state.hasToolCalls = true;
322
+ chunks.push(makeOpenAIChunk(state, {
323
+ tool_calls: [
324
+ {
325
+ index: toolIndex,
326
+ function: {
327
+ arguments: String(event.arguments || '')
328
+ }
329
+ }
330
+ ]
331
+ }, null));
332
+ return chunks;
333
+ }
334
+
335
+ if (type === 'response.completed' || type === 'response.failed') {
336
+ updateStateFromResponse(state, event.response, fallbackModel);
337
+ ensureAssistantRoleChunk(state, chunks);
338
+ const responseUsage = toOpenAIUsage(event.response?.usage);
339
+ const hasResponseToolCalls = Array.isArray(event.response?.output)
340
+ ? event.response.output.some((item) => item?.type === 'function_call')
341
+ : false;
342
+ const finishReason = inferFinishReason({
343
+ response: event.response,
344
+ hasToolCalls: state.hasToolCalls || hasResponseToolCalls
345
+ });
346
+ chunks.push(makeOpenAIChunk(state, {}, finishReason, responseUsage));
347
+ chunks.push('[DONE]');
348
+ state.doneSent = true;
349
+ return chunks;
350
+ }
351
+
352
+ return chunks;
353
+ }
354
+
355
+ function serializeOpenAIChunk(chunk) {
356
+ if (chunk === '[DONE]') return 'data: [DONE]\n\n';
357
+ return `data: ${JSON.stringify(chunk)}\n\n`;
358
+ }
359
+
360
+ export function handleCodexStreamToOpenAI(response, { fallbackModel = 'unknown' } = {}) {
361
+ const decoder = new TextDecoder();
362
+ const encoder = new TextEncoder();
363
+ const state = {
364
+ chatId: ensureChatCompletionId(''),
365
+ created: Math.floor(Date.now() / 1000),
366
+ model: fallbackModel || 'unknown',
367
+ roleSent: false,
368
+ doneSent: false,
369
+ hasToolCalls: false,
370
+ toolCallByOutputIndex: new Map(),
371
+ toolCallByItemId: new Map(),
372
+ nextToolCallIndex: 0,
373
+ textDeltaItemIds: new Set()
374
+ };
375
+
376
+ let buffer = '';
377
+
378
+ const transformStream = new TransformStream({
379
+ transform(chunk, controller) {
380
+ buffer += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');
381
+
382
+ let boundaryIndex;
383
+ while ((boundaryIndex = buffer.indexOf('\n\n')) >= 0) {
384
+ const block = buffer.slice(0, boundaryIndex);
385
+ buffer = buffer.slice(boundaryIndex + 2);
386
+ if (!block.trim()) continue;
387
+
388
+ const parsedBlock = parseSseBlock(block);
389
+ if (!parsedBlock.data) continue;
390
+
391
+ if (parsedBlock.data === '[DONE]') {
392
+ if (!state.doneSent) {
393
+ state.doneSent = true;
394
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
395
+ }
396
+ continue;
397
+ }
398
+
399
+ let payload;
400
+ try {
401
+ payload = JSON.parse(parsedBlock.data);
402
+ } catch {
403
+ continue;
404
+ }
405
+
406
+ const chunks = eventToOpenAIChunks(payload, state, { fallbackModel });
407
+ for (const translated of chunks) {
408
+ controller.enqueue(encoder.encode(serializeOpenAIChunk(translated)));
409
+ }
410
+ }
411
+ },
412
+
413
+ flush(controller) {
414
+ if (state.doneSent) return;
415
+ if (!state.roleSent) {
416
+ controller.enqueue(encoder.encode(serializeOpenAIChunk(makeOpenAIChunk(state, { role: 'assistant' }, null))));
417
+ }
418
+ const finishReason = state.hasToolCalls ? 'tool_calls' : 'stop';
419
+ controller.enqueue(encoder.encode(serializeOpenAIChunk(makeOpenAIChunk(state, {}, finishReason))));
420
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
421
+ state.doneSent = true;
422
+ }
423
+ });
424
+
425
+ return new Response(response.body.pipeThrough(transformStream), {
426
+ status: 200,
427
+ headers: withCorsHeaders({
428
+ 'Content-Type': 'text/event-stream',
429
+ 'Cache-Control': 'no-cache',
430
+ Connection: 'keep-alive'
431
+ })
432
+ });
433
+ }
@@ -8,7 +8,7 @@ import { CODEX_SUBSCRIPTION_MODELS } from "./subscription-constants.js";
8
8
 
9
9
  export const CONFIG_VERSION = 2;
10
10
  export const MIN_SUPPORTED_CONFIG_VERSION = 1;
11
- export const PROVIDER_ID_PATTERN = /^[a-z][a-zA-Z0-9-]*$/;
11
+ export const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
12
12
  const DEFAULT_PROVIDER_USER_AGENT_NAME = "AICodeClient";
13
13
  const DEFAULT_PROVIDER_USER_AGENT_VERSION = "1.0.0";
14
14
  export const DEFAULT_PROVIDER_USER_AGENT = buildDefaultProviderUserAgent();
@@ -322,11 +322,13 @@ function normalizeSubscriptionModels(models, subscriptionType) {
322
322
  return normalizedModels;
323
323
  }
324
324
 
325
- const configuredById = new Map(normalizedModels.map((model) => [model.id, model]));
326
- return CODEX_SUBSCRIPTION_MODELS.map((modelId) => ({
327
- ...(configuredById.get(modelId) || {}),
328
- id: modelId
329
- }));
325
+ // ChatGPT Codex subscription models are prefilled defaults. Users can still
326
+ // customize (add/remove) the model list explicitly.
327
+ if (normalizedModels.length > 0) {
328
+ return normalizedModels;
329
+ }
330
+
331
+ return CODEX_SUBSCRIPTION_MODELS.map((modelId) => ({ id: modelId }));
330
332
  }
331
333
 
332
334
  function sanitizeModelFallbackReferences(providers) {
@@ -924,7 +926,7 @@ export function validateRuntimeConfig(config, { requireMasterKey = false, requir
924
926
  const isSubscriptionProvider = provider?.type === "subscription";
925
927
  if (!provider.id) errors.push("Provider missing id.");
926
928
  if (provider.id && !PROVIDER_ID_PATTERN.test(provider.id)) {
927
- errors.push(`Provider id '${provider.id}' is invalid. Use slug/camelCase (e.g. openrouter or myProvider).`);
929
+ errors.push(`Provider id '${provider.id}' is invalid. Use lowercase slug format (e.g. openrouter-primary).`);
928
930
  }
929
931
  if (!isSubscriptionProvider && !provider.baseUrl) errors.push(`Provider ${provider.id || "(unknown)"} missing baseUrl.`);
930
932
  if (isSubscriptionProvider && !provider.subscriptionType) {
@@ -17,6 +17,11 @@ import { resolveUpstreamTimeoutMs } from "./request.js";
17
17
  import { parseJsonSafely } from "./utils.js";
18
18
  import { buildTimeoutSignal } from "../../shared/timeout-signal.js";
19
19
  import { isSubscriptionProvider, makeSubscriptionProviderCall } from "../subscription-provider.js";
20
+ import {
21
+ convertCodexResponseToOpenAIChatCompletion,
22
+ extractCodexFinalResponse,
23
+ handleCodexStreamToOpenAI
24
+ } from "../codex-response-transformer.js";
20
25
 
21
26
  async function toProviderError(response) {
22
27
  const raw = await response.text();
@@ -115,12 +120,88 @@ export async function makeProviderCall({
115
120
  });
116
121
 
117
122
  if (isSubscriptionProvider(provider)) {
118
- return makeSubscriptionProviderCall({
123
+ const subscriptionResult = await makeSubscriptionProviderCall({
119
124
  provider,
120
125
  body: providerBody,
121
- stream,
126
+ // ChatGPT Codex backend expects stream=true; non-stream responses are reconstructed from SSE.
127
+ stream: true,
122
128
  env
123
129
  });
130
+
131
+ if (!subscriptionResult?.ok) {
132
+ return subscriptionResult;
133
+ }
134
+
135
+ if (!(subscriptionResult.response instanceof Response)) {
136
+ return {
137
+ ok: false,
138
+ status: 502,
139
+ retryable: true,
140
+ response: jsonResponse({
141
+ type: "error",
142
+ error: {
143
+ type: "api_error",
144
+ message: "Subscription provider returned an invalid response."
145
+ }
146
+ }, 502)
147
+ };
148
+ }
149
+
150
+ const fallbackModel = candidate?.backend || providerBody?.model || "unknown";
151
+ if (stream) {
152
+ const openAIStreamResponse = handleCodexStreamToOpenAI(subscriptionResult.response, {
153
+ fallbackModel
154
+ });
155
+ if (sourceFormat === FORMATS.CLAUDE) {
156
+ return {
157
+ ok: true,
158
+ status: 200,
159
+ retryable: false,
160
+ response: handleOpenAIStreamToClaude(openAIStreamResponse)
161
+ };
162
+ }
163
+ return {
164
+ ok: true,
165
+ status: 200,
166
+ retryable: false,
167
+ response: openAIStreamResponse
168
+ };
169
+ }
170
+
171
+ const parsedSubscriptionResponse = await extractCodexFinalResponse(subscriptionResult.response);
172
+ if (!parsedSubscriptionResponse) {
173
+ return {
174
+ ok: false,
175
+ status: 502,
176
+ retryable: true,
177
+ response: jsonResponse({
178
+ type: "error",
179
+ error: {
180
+ type: "api_error",
181
+ message: "Subscription provider stream did not contain a completed response payload."
182
+ }
183
+ }, 502)
184
+ };
185
+ }
186
+
187
+ const openAINonStreamResponse = convertCodexResponseToOpenAIChatCompletion(parsedSubscriptionResponse, {
188
+ fallbackModel
189
+ });
190
+ if (sourceFormat === FORMATS.CLAUDE) {
191
+ return {
192
+ ok: true,
193
+ status: 200,
194
+ retryable: false,
195
+ response: jsonResponse(convertOpenAINonStreamToClaude(openAINonStreamResponse, fallbackModel))
196
+ };
197
+ }
198
+
199
+ return {
200
+ ok: true,
201
+ status: 200,
202
+ retryable: false,
203
+ response: jsonResponse(openAINonStreamResponse)
204
+ };
124
205
  }
125
206
 
126
207
  const providerUrl = resolveProviderUrl(provider, targetFormat);
@@ -5,6 +5,7 @@
5
5
 
6
6
  import http from 'node:http';
7
7
  import crypto from 'node:crypto';
8
+ import { spawn } from 'node:child_process';
8
9
  import { CODEX_OAUTH_CONFIG } from './subscription-constants.js';
9
10
  import { saveTokens, loadTokens, isTokenExpired, deleteTokens, listTokenProfiles as listTokenProfilesFromStore } from './subscription-tokens.js';
10
11
 
@@ -27,6 +28,26 @@ function generateState() {
27
28
  return crypto.randomBytes(16).toString('hex');
28
29
  }
29
30
 
31
+ function tryOpenBrowser(url) {
32
+ const target = String(url || '').trim();
33
+ if (!target) return false;
34
+
35
+ try {
36
+ let child;
37
+ if (process.platform === 'darwin') {
38
+ child = spawn('open', [target], { stdio: 'ignore', detached: true });
39
+ } else if (process.platform === 'win32') {
40
+ child = spawn('cmd', ['/c', 'start', '', target], { stdio: 'ignore', detached: true });
41
+ } else {
42
+ child = spawn('xdg-open', [target], { stdio: 'ignore', detached: true });
43
+ }
44
+ child.unref();
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
30
51
  /**
31
52
  * Refresh an access token using refresh token.
32
53
  * @param {string} refreshToken - OAuth refresh token
@@ -148,10 +169,16 @@ export async function loginWithBrowser(profileId, options = {}) {
148
169
  authUrl.searchParams.set('client_id', CODEX_OAUTH_CONFIG.clientId);
149
170
  authUrl.searchParams.set('redirect_uri', redirectUri);
150
171
  authUrl.searchParams.set('scope', CODEX_OAUTH_CONFIG.scopes);
151
- authUrl.searchParams.set('audience', CODEX_OAUTH_CONFIG.audience);
152
172
  authUrl.searchParams.set('state', state);
153
173
  authUrl.searchParams.set('code_challenge', pkce.challenge);
154
174
  authUrl.searchParams.set('code_challenge_method', 'S256');
175
+ if (CODEX_OAUTH_CONFIG.authorizeParams && typeof CODEX_OAUTH_CONFIG.authorizeParams === 'object') {
176
+ for (const [key, value] of Object.entries(CODEX_OAUTH_CONFIG.authorizeParams)) {
177
+ if (value !== undefined && value !== null && String(value).trim() !== '') {
178
+ authUrl.searchParams.set(key, String(value));
179
+ }
180
+ }
181
+ }
155
182
 
156
183
  return new Promise((resolve, reject) => {
157
184
  const server = http.createServer(async (req, res) => {
@@ -203,8 +230,9 @@ export async function loginWithBrowser(profileId, options = {}) {
203
230
 
204
231
  server.listen(port, () => {
205
232
  const authUrlStr = authUrl.toString();
233
+ const openedBrowser = options.autoOpen !== false ? tryOpenBrowser(authUrlStr) : false;
206
234
  if (options.onUrl) {
207
- options.onUrl(authUrlStr);
235
+ options.onUrl(authUrlStr, { openedBrowser });
208
236
  }
209
237
  });
210
238
 
@@ -231,8 +259,7 @@ export async function loginWithDeviceCode(profileId, options = {}) {
231
259
  },
232
260
  body: new URLSearchParams({
233
261
  client_id: CODEX_OAUTH_CONFIG.clientId,
234
- scope: CODEX_OAUTH_CONFIG.scopes,
235
- audience: CODEX_OAUTH_CONFIG.audience
262
+ scope: CODEX_OAUTH_CONFIG.scopes
236
263
  }).toString()
237
264
  });
238
265
 
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Hardcoded Codex subscription models.
3
- * These are official ChatGPT Codex models that users cannot edit.
4
- * Updated via llm-router version releases to reflect OpenAI changes.
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
 
@@ -13,14 +13,18 @@ export const CODEX_SUBSCRIPTION_MODELS = Object.freeze([
13
13
  * OAuth configuration for ChatGPT Codex subscription.
14
14
  */
15
15
  export const CODEX_OAUTH_CONFIG = Object.freeze({
16
- authorizeUrl: 'https://auth.openai.com/authorize',
16
+ authorizeUrl: 'https://auth.openai.com/oauth/authorize',
17
17
  tokenUrl: 'https://auth.openai.com/oauth/token',
18
18
  deviceCodeUrl: 'https://auth.openai.com/oauth/device/code',
19
19
  callbackPort: 1455,
20
- callbackPath: '/callback',
20
+ callbackPath: '/auth/callback',
21
21
  scopes: 'openid profile email offline_access',
22
- clientId: 'pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh', // Public Codex CLI client ID
23
- audience: 'https://api.openai.com/v1',
22
+ clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', // Matches current codex-cli browser login flow
23
+ authorizeParams: Object.freeze({
24
+ id_token_add_organizations: 'true',
25
+ codex_cli_simplified_flow: 'true',
26
+ originator: 'codex_cli_rs'
27
+ }),
24
28
  tokenRefreshBufferMs: 5 * 60 * 1000 // 5 minutes before expiration
25
29
  });
26
30