@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.
- package/CHANGELOG.md +17 -0
- package/README.md +17 -13
- package/package.json +1 -1
- package/src/cli/router-module.js +1289 -511
- package/src/runtime/codex-request-transformer.js +284 -28
- package/src/runtime/codex-response-transformer.js +433 -0
- package/src/runtime/config.js +9 -7
- package/src/runtime/handler/provider-call.js +83 -2
- package/src/runtime/subscription-auth.js +31 -4
- package/src/runtime/subscription-constants.js +11 -7
- package/src/runtime/subscription-provider.js +159 -32
|
@@ -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
|
+
}
|
package/src/runtime/config.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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: '
|
|
23
|
-
|
|
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
|
|