@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.
@@ -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
+ }
@@ -4,11 +4,14 @@
4
4
  */
5
5
 
6
6
  import { FORMATS } from "../translator/index.js";
7
- import { CODEX_SUBSCRIPTION_MODELS } from "./subscription-constants.js";
7
+ import {
8
+ CODEX_SUBSCRIPTION_MODELS,
9
+ CLAUDE_CODE_SUBSCRIPTION_MODELS
10
+ } from "./subscription-constants.js";
8
11
 
9
12
  export const CONFIG_VERSION = 2;
10
13
  export const MIN_SUPPORTED_CONFIG_VERSION = 1;
11
- export const PROVIDER_ID_PATTERN = /^[a-z][a-zA-Z0-9-]*$/;
14
+ export const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
12
15
  const DEFAULT_PROVIDER_USER_AGENT_NAME = "AICodeClient";
13
16
  const DEFAULT_PROVIDER_USER_AGENT_VERSION = "1.0.0";
14
17
  export const DEFAULT_PROVIDER_USER_AGENT = buildDefaultProviderUserAgent();
@@ -34,7 +37,8 @@ const ALLOWED_RATE_LIMIT_WINDOW_UNITS = new Set([
34
37
  ]);
35
38
  const ALIAS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
36
39
  const SUBSCRIPTION_PROVIDER_TYPES = Object.freeze({
37
- CHATGPT_CODEX: "chatgpt-codex"
40
+ CHATGPT_CODEX: "chatgpt-codex",
41
+ CLAUDE_CODE: "claude-code"
38
42
  });
39
43
  let runtimeEnvCache = null;
40
44
 
@@ -318,15 +322,14 @@ function normalizeSubscriptionModels(models, subscriptionType) {
318
322
  .filter(Boolean)
319
323
  .filter((item) => item.enabled !== false);
320
324
 
321
- if (subscriptionType !== SUBSCRIPTION_PROVIDER_TYPES.CHATGPT_CODEX) {
322
- return normalizedModels;
323
- }
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
+ const defaultModelsByType = {
326
+ [SUBSCRIPTION_PROVIDER_TYPES.CHATGPT_CODEX]: CODEX_SUBSCRIPTION_MODELS,
327
+ [SUBSCRIPTION_PROVIDER_TYPES.CLAUDE_CODE]: CLAUDE_CODE_SUBSCRIPTION_MODELS
328
+ };
329
+ const defaultModels = defaultModelsByType[subscriptionType];
330
+ if (!defaultModels) return normalizedModels;
331
+ if (normalizedModels.length > 0) return normalizedModels;
332
+ return defaultModels.map((modelId) => ({ id: modelId }));
330
333
  }
331
334
 
332
335
  function sanitizeModelFallbackReferences(providers) {
@@ -409,8 +412,11 @@ function normalizeProvider(provider, index = 0) {
409
412
  ? dedupeStrings([preferredFormat, ...formats])
410
413
  : formats;
411
414
 
412
- // For subscription providers, default to OpenAI format
413
- const defaultFormat = isSubscription ? FORMATS.OPENAI : (orderedFormats[0] || FORMATS.OPENAI);
415
+ // Subscription providers have type-specific target formats.
416
+ const defaultSubscriptionFormat = subscriptionType === SUBSCRIPTION_PROVIDER_TYPES.CLAUDE_CODE
417
+ ? FORMATS.CLAUDE
418
+ : FORMATS.OPENAI;
419
+ const defaultFormat = isSubscription ? defaultSubscriptionFormat : (orderedFormats[0] || FORMATS.OPENAI);
414
420
 
415
421
  const baseUrl = explicitBaseUrl
416
422
  || (preferredFormat && baseUrlByFormat?.[preferredFormat])
@@ -924,7 +930,7 @@ export function validateRuntimeConfig(config, { requireMasterKey = false, requir
924
930
  const isSubscriptionProvider = provider?.type === "subscription";
925
931
  if (!provider.id) errors.push("Provider missing id.");
926
932
  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).`);
933
+ errors.push(`Provider id '${provider.id}' is invalid. Use lowercase slug format (e.g. openrouter-primary).`);
928
934
  }
929
935
  if (!isSubscriptionProvider && !provider.baseUrl) errors.push(`Provider ${provider.id || "(unknown)"} missing baseUrl.`);
930
936
  if (isSubscriptionProvider && !provider.subscriptionType) {