@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
|
@@ -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();
|
|
@@ -65,6 +70,120 @@ export async function buildFailureResponse(result) {
|
|
|
65
70
|
}, fallbackStatus);
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
async function adaptProviderResponse({
|
|
74
|
+
response,
|
|
75
|
+
stream,
|
|
76
|
+
translate,
|
|
77
|
+
sourceFormat,
|
|
78
|
+
targetFormat,
|
|
79
|
+
fallbackModel
|
|
80
|
+
}) {
|
|
81
|
+
if (stream) {
|
|
82
|
+
if (!translate) {
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
status: 200,
|
|
86
|
+
retryable: false,
|
|
87
|
+
response: passthroughResponseWithCors(response, {
|
|
88
|
+
"Content-Type": "text/event-stream",
|
|
89
|
+
"Cache-Control": "no-cache",
|
|
90
|
+
Connection: "keep-alive"
|
|
91
|
+
})
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.OPENAI) {
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
status: 200,
|
|
99
|
+
retryable: false,
|
|
100
|
+
response: handleOpenAIStreamToClaude(response)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (sourceFormat === FORMATS.OPENAI && targetFormat === FORMATS.CLAUDE) {
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
status: 200,
|
|
108
|
+
retryable: false,
|
|
109
|
+
response: handleClaudeStreamToOpenAI(response)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
status: 501,
|
|
116
|
+
retryable: false,
|
|
117
|
+
errorKind: "not_supported_error",
|
|
118
|
+
response: jsonResponse({
|
|
119
|
+
type: "error",
|
|
120
|
+
error: {
|
|
121
|
+
type: "not_supported_error",
|
|
122
|
+
message: `Streaming translation from ${targetFormat} to ${sourceFormat} is not implemented.`
|
|
123
|
+
}
|
|
124
|
+
}, 501)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!translate) {
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
status: 200,
|
|
132
|
+
retryable: false,
|
|
133
|
+
response: passthroughResponseWithCors(response)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const raw = await response.text();
|
|
138
|
+
const parsed = parseJsonSafely(raw);
|
|
139
|
+
if (!parsed) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
status: 502,
|
|
143
|
+
retryable: true,
|
|
144
|
+
response: jsonResponse({
|
|
145
|
+
type: "error",
|
|
146
|
+
error: {
|
|
147
|
+
type: "api_error",
|
|
148
|
+
message: "Provider returned invalid JSON."
|
|
149
|
+
}
|
|
150
|
+
}, 502)
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.OPENAI) {
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
status: 200,
|
|
158
|
+
retryable: false,
|
|
159
|
+
response: jsonResponse(convertOpenAINonStreamToClaude(parsed, fallbackModel))
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (sourceFormat === FORMATS.OPENAI && targetFormat === FORMATS.CLAUDE) {
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
status: 200,
|
|
167
|
+
retryable: false,
|
|
168
|
+
response: jsonResponse(claudeToOpenAINonStreamResponse(parsed))
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
status: 501,
|
|
175
|
+
retryable: false,
|
|
176
|
+
errorKind: "not_supported_error",
|
|
177
|
+
response: jsonResponse({
|
|
178
|
+
type: "error",
|
|
179
|
+
error: {
|
|
180
|
+
type: "not_supported_error",
|
|
181
|
+
message: `Non-stream translation from ${targetFormat} to ${sourceFormat} is not implemented.`
|
|
182
|
+
}
|
|
183
|
+
}, 501)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
68
187
|
export async function makeProviderCall({
|
|
69
188
|
body,
|
|
70
189
|
sourceFormat,
|
|
@@ -115,12 +234,100 @@ export async function makeProviderCall({
|
|
|
115
234
|
});
|
|
116
235
|
|
|
117
236
|
if (isSubscriptionProvider(provider)) {
|
|
118
|
-
|
|
237
|
+
const subscriptionType = String(provider?.subscriptionType || provider?.subscription_type || "").trim().toLowerCase();
|
|
238
|
+
const subscriptionResult = await makeSubscriptionProviderCall({
|
|
119
239
|
provider,
|
|
120
240
|
body: providerBody,
|
|
121
|
-
stream
|
|
241
|
+
// ChatGPT Codex backend expects stream=true; non-stream responses are reconstructed from SSE.
|
|
242
|
+
stream: subscriptionType === "chatgpt-codex" ? true : Boolean(stream),
|
|
122
243
|
env
|
|
123
244
|
});
|
|
245
|
+
|
|
246
|
+
if (!subscriptionResult?.ok) {
|
|
247
|
+
return subscriptionResult;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!(subscriptionResult.response instanceof Response)) {
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
status: 502,
|
|
254
|
+
retryable: true,
|
|
255
|
+
response: jsonResponse({
|
|
256
|
+
type: "error",
|
|
257
|
+
error: {
|
|
258
|
+
type: "api_error",
|
|
259
|
+
message: "Subscription provider returned an invalid response."
|
|
260
|
+
}
|
|
261
|
+
}, 502)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const fallbackModel = candidate?.backend || providerBody?.model || "unknown";
|
|
266
|
+
if (subscriptionType !== "chatgpt-codex") {
|
|
267
|
+
return adaptProviderResponse({
|
|
268
|
+
response: subscriptionResult.response,
|
|
269
|
+
stream,
|
|
270
|
+
translate,
|
|
271
|
+
sourceFormat,
|
|
272
|
+
targetFormat,
|
|
273
|
+
fallbackModel
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (stream) {
|
|
278
|
+
const openAIStreamResponse = handleCodexStreamToOpenAI(subscriptionResult.response, {
|
|
279
|
+
fallbackModel
|
|
280
|
+
});
|
|
281
|
+
if (sourceFormat === FORMATS.CLAUDE) {
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
status: 200,
|
|
285
|
+
retryable: false,
|
|
286
|
+
response: handleOpenAIStreamToClaude(openAIStreamResponse)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
status: 200,
|
|
292
|
+
retryable: false,
|
|
293
|
+
response: openAIStreamResponse
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const parsedSubscriptionResponse = await extractCodexFinalResponse(subscriptionResult.response);
|
|
298
|
+
if (!parsedSubscriptionResponse) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
status: 502,
|
|
302
|
+
retryable: true,
|
|
303
|
+
response: jsonResponse({
|
|
304
|
+
type: "error",
|
|
305
|
+
error: {
|
|
306
|
+
type: "api_error",
|
|
307
|
+
message: "Subscription provider stream did not contain a completed response payload."
|
|
308
|
+
}
|
|
309
|
+
}, 502)
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const openAINonStreamResponse = convertCodexResponseToOpenAIChatCompletion(parsedSubscriptionResponse, {
|
|
314
|
+
fallbackModel
|
|
315
|
+
});
|
|
316
|
+
if (sourceFormat === FORMATS.CLAUDE) {
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
status: 200,
|
|
320
|
+
retryable: false,
|
|
321
|
+
response: jsonResponse(convertOpenAINonStreamToClaude(openAINonStreamResponse, fallbackModel))
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
status: 200,
|
|
328
|
+
retryable: false,
|
|
329
|
+
response: jsonResponse(openAINonStreamResponse)
|
|
330
|
+
};
|
|
124
331
|
}
|
|
125
332
|
|
|
126
333
|
const providerUrl = resolveProviderUrl(provider, targetFormat);
|
|
@@ -192,108 +399,12 @@ export async function makeProviderCall({
|
|
|
192
399
|
};
|
|
193
400
|
}
|
|
194
401
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
"Cache-Control": "no-cache",
|
|
204
|
-
Connection: "keep-alive"
|
|
205
|
-
})
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.OPENAI) {
|
|
210
|
-
return {
|
|
211
|
-
ok: true,
|
|
212
|
-
status: 200,
|
|
213
|
-
retryable: false,
|
|
214
|
-
response: handleOpenAIStreamToClaude(response)
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (sourceFormat === FORMATS.OPENAI && targetFormat === FORMATS.CLAUDE) {
|
|
219
|
-
return {
|
|
220
|
-
ok: true,
|
|
221
|
-
status: 200,
|
|
222
|
-
retryable: false,
|
|
223
|
-
response: handleClaudeStreamToOpenAI(response)
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
ok: false,
|
|
229
|
-
status: 501,
|
|
230
|
-
retryable: false,
|
|
231
|
-
errorKind: "not_supported_error",
|
|
232
|
-
response: jsonResponse({
|
|
233
|
-
type: "error",
|
|
234
|
-
error: {
|
|
235
|
-
type: "not_supported_error",
|
|
236
|
-
message: `Streaming translation from ${targetFormat} to ${sourceFormat} is not implemented.`
|
|
237
|
-
}
|
|
238
|
-
}, 501)
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!translate) {
|
|
243
|
-
return {
|
|
244
|
-
ok: true,
|
|
245
|
-
status: 200,
|
|
246
|
-
retryable: false,
|
|
247
|
-
response: passthroughResponseWithCors(response)
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const raw = await response.text();
|
|
252
|
-
const parsed = parseJsonSafely(raw);
|
|
253
|
-
if (!parsed) {
|
|
254
|
-
return {
|
|
255
|
-
ok: false,
|
|
256
|
-
status: 502,
|
|
257
|
-
retryable: true,
|
|
258
|
-
response: jsonResponse({
|
|
259
|
-
type: "error",
|
|
260
|
-
error: {
|
|
261
|
-
type: "api_error",
|
|
262
|
-
message: "Provider returned invalid JSON."
|
|
263
|
-
}
|
|
264
|
-
}, 502)
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.OPENAI) {
|
|
269
|
-
return {
|
|
270
|
-
ok: true,
|
|
271
|
-
status: 200,
|
|
272
|
-
retryable: false,
|
|
273
|
-
response: jsonResponse(convertOpenAINonStreamToClaude(parsed, candidate.backend))
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (sourceFormat === FORMATS.OPENAI && targetFormat === FORMATS.CLAUDE) {
|
|
278
|
-
return {
|
|
279
|
-
ok: true,
|
|
280
|
-
status: 200,
|
|
281
|
-
retryable: false,
|
|
282
|
-
response: jsonResponse(claudeToOpenAINonStreamResponse(parsed))
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
return {
|
|
287
|
-
ok: false,
|
|
288
|
-
status: 501,
|
|
289
|
-
retryable: false,
|
|
290
|
-
errorKind: "not_supported_error",
|
|
291
|
-
response: jsonResponse({
|
|
292
|
-
type: "error",
|
|
293
|
-
error: {
|
|
294
|
-
type: "not_supported_error",
|
|
295
|
-
message: `Non-stream translation from ${targetFormat} to ${sourceFormat} is not implemented.`
|
|
296
|
-
}
|
|
297
|
-
}, 501)
|
|
298
|
-
};
|
|
402
|
+
return adaptProviderResponse({
|
|
403
|
+
response,
|
|
404
|
+
stream,
|
|
405
|
+
translate,
|
|
406
|
+
sourceFormat,
|
|
407
|
+
targetFormat,
|
|
408
|
+
fallbackModel: candidate.backend
|
|
409
|
+
});
|
|
299
410
|
}
|