babelgate 0.0.0 → 1.1.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +467 -1
- package/dist/app.js +40 -0
- package/dist/cli.js +203 -0
- package/dist/config/env-config.js +99 -0
- package/dist/index.js +60 -0
- package/dist/middleware/error-handler.js +158 -0
- package/dist/middleware/request-logger.js +66 -0
- package/dist/middleware/require-api-key.js +80 -0
- package/dist/middleware/resolve-source.js +28 -0
- package/dist/routes/proxy.js +74 -0
- package/dist/services/http-client.js +220 -0
- package/dist/services/proxy.service.js +329 -0
- package/dist/services/stream-pipeline.js +119 -0
- package/dist/services/upstream-errors.js +34 -0
- package/dist/transformers/adapters/anthropic.adapter.js +292 -0
- package/dist/transformers/adapters/anthropic.messages.js +261 -0
- package/dist/transformers/adapters/anthropic.stream.js +378 -0
- package/dist/transformers/adapters/gemini.adapter.js +418 -0
- package/dist/transformers/adapters/gemini.schema.js +61 -0
- package/dist/transformers/adapters/gemini.stream.js +341 -0
- package/dist/transformers/adapters/gemini.tools.js +246 -0
- package/dist/transformers/adapters/index.js +141 -0
- package/dist/transformers/adapters/ollama.adapter.js +288 -0
- package/dist/transformers/adapters/ollama.stream.js +294 -0
- package/dist/transformers/adapters/ollama.tools.js +107 -0
- package/dist/transformers/adapters/openai.adapter.js +460 -0
- package/dist/transformers/adapters/openai.stream.js +229 -0
- package/dist/transformers/index.js +110 -0
- package/dist/types/anthropic.types.js +2 -0
- package/dist/types/canonical.schema.js +160 -0
- package/dist/types/canonical.types.js +8 -0
- package/dist/types/gemini.types.js +2 -0
- package/dist/types/ollama.types.js +4 -0
- package/dist/types/openai.types.js +2 -0
- package/dist/utils/canonical-error.js +77 -0
- package/dist/utils/errors.js +95 -0
- package/dist/utils/ids.js +17 -0
- package/dist/utils/logger.js +21 -0
- package/dist/utils/model-map.js +39 -0
- package/dist/utils/sse.js +147 -0
- package/dist/utils/stream-reassemble.js +104 -0
- package/dist/utils/token-estimate.js +36 -0
- package/dist/utils/tool-args.js +26 -0
- package/package.json +54 -7
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openaiAdapter = void 0;
|
|
4
|
+
exports.toCanonicalRequest = toCanonicalRequest;
|
|
5
|
+
exports.fromCanonicalRequest = fromCanonicalRequest;
|
|
6
|
+
exports.toCanonicalResponse = toCanonicalResponse;
|
|
7
|
+
exports.fromCanonicalResponse = fromCanonicalResponse;
|
|
8
|
+
exports.toCanonicalModelList = toCanonicalModelList;
|
|
9
|
+
exports.fromCanonicalModelList = fromCanonicalModelList;
|
|
10
|
+
exports.mapToOpenaiFinishReason = mapToOpenaiFinishReason;
|
|
11
|
+
exports.toCanonicalError = toCanonicalError;
|
|
12
|
+
exports.fromCanonicalError = fromCanonicalError;
|
|
13
|
+
const crypto_1 = require("crypto");
|
|
14
|
+
const canonical_error_1 = require("../../utils/canonical-error");
|
|
15
|
+
const errors_1 = require("../../utils/errors");
|
|
16
|
+
const logger_1 = require("../../utils/logger");
|
|
17
|
+
const openai_stream_1 = require("./openai.stream");
|
|
18
|
+
exports.openaiAdapter = {
|
|
19
|
+
toCanonicalRequest,
|
|
20
|
+
fromCanonicalRequest,
|
|
21
|
+
toCanonicalResponse,
|
|
22
|
+
fromCanonicalResponse,
|
|
23
|
+
toCanonicalModelList,
|
|
24
|
+
fromCanonicalModelList,
|
|
25
|
+
toCanonicalError,
|
|
26
|
+
fromCanonicalError,
|
|
27
|
+
createStreamDecoder: openai_stream_1.createStreamDecoder,
|
|
28
|
+
createStreamEncoder: openai_stream_1.createStreamEncoder,
|
|
29
|
+
};
|
|
30
|
+
// ============ REQUEST: OpenAI → Canonical ============
|
|
31
|
+
function toCanonicalRequest(request) {
|
|
32
|
+
const messages = [];
|
|
33
|
+
let systemPrompt;
|
|
34
|
+
for (const msg of request.messages) {
|
|
35
|
+
if (msg.role === "system") {
|
|
36
|
+
systemPrompt = extractTextContent(msg);
|
|
37
|
+
}
|
|
38
|
+
messages.push(convertToCanonicalMessage(msg));
|
|
39
|
+
}
|
|
40
|
+
// Canonical has no field for `parallel_tool_calls` (a spec
|
|
41
|
+
// non-goal) — make the drop visible instead of silent, same as
|
|
42
|
+
// Anthropic's `disable_parallel_tool_use` (task 9).
|
|
43
|
+
if (request.parallel_tool_calls !== undefined) {
|
|
44
|
+
logger_1.logger.debug('Dropping request field "parallel_tool_calls" (no canonical equivalent)');
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
model: request.model,
|
|
48
|
+
messages,
|
|
49
|
+
...(systemPrompt && { systemPrompt }),
|
|
50
|
+
config: {
|
|
51
|
+
...(request.temperature !== undefined &&
|
|
52
|
+
request.temperature !== null && { temperature: request.temperature }),
|
|
53
|
+
...(request.max_tokens !== undefined &&
|
|
54
|
+
request.max_tokens !== null && { maxTokens: request.max_tokens }),
|
|
55
|
+
...(request.top_p !== undefined &&
|
|
56
|
+
request.top_p !== null && { topP: request.top_p }),
|
|
57
|
+
...(request.stop && {
|
|
58
|
+
stopSequences: Array.isArray(request.stop)
|
|
59
|
+
? request.stop
|
|
60
|
+
: [request.stop],
|
|
61
|
+
}),
|
|
62
|
+
...(request.n !== undefined &&
|
|
63
|
+
request.n !== null && { candidateCount: request.n }),
|
|
64
|
+
...(request.seed !== undefined &&
|
|
65
|
+
request.seed !== null && { seed: request.seed }),
|
|
66
|
+
...(request.frequency_penalty !== undefined &&
|
|
67
|
+
request.frequency_penalty !== null && {
|
|
68
|
+
frequencyPenalty: request.frequency_penalty,
|
|
69
|
+
}),
|
|
70
|
+
...(request.presence_penalty !== undefined &&
|
|
71
|
+
request.presence_penalty !== null && {
|
|
72
|
+
presencePenalty: request.presence_penalty,
|
|
73
|
+
}),
|
|
74
|
+
},
|
|
75
|
+
...(request.stream === true && { stream: true }),
|
|
76
|
+
...(request.stream === false && { stream: false }),
|
|
77
|
+
...(request.tools && { tools: toCanonicalTools(request.tools) }),
|
|
78
|
+
...(request.tool_choice !== undefined && {
|
|
79
|
+
toolChoice: toCanonicalToolChoice(request.tool_choice),
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function toCanonicalTools(tools) {
|
|
84
|
+
return tools.map((tool) => {
|
|
85
|
+
if (tool.type !== "function" || !("function" in tool)) {
|
|
86
|
+
throw new errors_1.ValidationError(`Unsupported OpenAI tool type "${String(tool.type)}"`);
|
|
87
|
+
}
|
|
88
|
+
// `strict` has no canonical equivalent — dropped, but visibly.
|
|
89
|
+
if (tool.function.strict != null) {
|
|
90
|
+
logger_1.logger.debug('Dropping tool field "strict" (no canonical equivalent)');
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
type: "function",
|
|
94
|
+
function: {
|
|
95
|
+
name: tool.function.name,
|
|
96
|
+
...(tool.function.description !== undefined && {
|
|
97
|
+
description: tool.function.description,
|
|
98
|
+
}),
|
|
99
|
+
...(tool.function.parameters !== undefined && {
|
|
100
|
+
parameters: tool.function.parameters,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function toCanonicalToolChoice(choice) {
|
|
107
|
+
if (choice === "auto" || choice === "none" || choice === "required") {
|
|
108
|
+
return choice;
|
|
109
|
+
}
|
|
110
|
+
if (typeof choice === "object" &&
|
|
111
|
+
choice.type === "function" &&
|
|
112
|
+
typeof choice.function?.name === "string") {
|
|
113
|
+
return { type: "function", function: { name: choice.function.name } };
|
|
114
|
+
}
|
|
115
|
+
throw new errors_1.ValidationError(`Unsupported OpenAI tool_choice "${typeof choice === "object" ? String(choice.type) : String(choice)}"`);
|
|
116
|
+
}
|
|
117
|
+
function convertToCanonicalMessage(msg) {
|
|
118
|
+
const base = {
|
|
119
|
+
role: msg.role,
|
|
120
|
+
content: "",
|
|
121
|
+
};
|
|
122
|
+
if ("content" in msg && msg.content !== null) {
|
|
123
|
+
if (typeof msg.content === "string") {
|
|
124
|
+
base.content = msg.content;
|
|
125
|
+
}
|
|
126
|
+
else if (Array.isArray(msg.content)) {
|
|
127
|
+
base.content = msg.content.map(convertToCanonicalPart);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if ("tool_calls" in msg && msg.tool_calls) {
|
|
131
|
+
base.toolCalls = msg.tool_calls.map((tc) => {
|
|
132
|
+
if (tc.type !== "function" || !("function" in tc)) {
|
|
133
|
+
// e.g. type:"custom" — no canonical equivalent; 400 naming
|
|
134
|
+
// the type rather than silently filtering the call out.
|
|
135
|
+
throw new errors_1.ValidationError(`OpenAI tool call type "${String(tc.type)}" is not supported`);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
id: tc.id,
|
|
139
|
+
type: "function",
|
|
140
|
+
function: {
|
|
141
|
+
name: tc.function.name,
|
|
142
|
+
arguments: tc.function.arguments,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if ("tool_call_id" in msg && msg.tool_call_id) {
|
|
148
|
+
base.toolCallId = msg.tool_call_id;
|
|
149
|
+
}
|
|
150
|
+
if ("name" in msg && msg.name) {
|
|
151
|
+
base.name = msg.name;
|
|
152
|
+
}
|
|
153
|
+
return base;
|
|
154
|
+
}
|
|
155
|
+
function convertToCanonicalPart(part) {
|
|
156
|
+
if (part.type === "text" && part.text) {
|
|
157
|
+
return { type: "text", text: part.text };
|
|
158
|
+
}
|
|
159
|
+
if (part.type === "image_url" && part.image_url) {
|
|
160
|
+
const url = part.image_url.url;
|
|
161
|
+
if (url.startsWith("data:")) {
|
|
162
|
+
const [meta, data] = url.split(",");
|
|
163
|
+
const mimeType = (meta.split(":")[1]?.split(";")[0] ||
|
|
164
|
+
"image/png");
|
|
165
|
+
return {
|
|
166
|
+
type: "image",
|
|
167
|
+
image: { data, mimeType },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// URL-based images - store as text placeholder
|
|
171
|
+
return { type: "text", text: `[Image: ${url}]` };
|
|
172
|
+
}
|
|
173
|
+
return { type: "text", text: "" };
|
|
174
|
+
}
|
|
175
|
+
function extractTextContent(msg) {
|
|
176
|
+
if (!("content" in msg) || msg.content === null)
|
|
177
|
+
return "";
|
|
178
|
+
if (typeof msg.content === "string")
|
|
179
|
+
return msg.content;
|
|
180
|
+
if (Array.isArray(msg.content)) {
|
|
181
|
+
return msg.content
|
|
182
|
+
.filter((p) => p.type === "text")
|
|
183
|
+
.map((p) => p.text)
|
|
184
|
+
.join("\n");
|
|
185
|
+
}
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
// ============ REQUEST: Canonical → OpenAI ============
|
|
189
|
+
function fromCanonicalRequest(request) {
|
|
190
|
+
const messages = request.messages.map(convertFromCanonicalMessage);
|
|
191
|
+
const result = {
|
|
192
|
+
model: request.model,
|
|
193
|
+
messages,
|
|
194
|
+
};
|
|
195
|
+
if (request.config) {
|
|
196
|
+
const c = request.config;
|
|
197
|
+
if (c.temperature !== undefined)
|
|
198
|
+
result.temperature = c.temperature;
|
|
199
|
+
if (c.maxTokens !== undefined)
|
|
200
|
+
result.max_tokens = c.maxTokens;
|
|
201
|
+
if (c.topP !== undefined)
|
|
202
|
+
result.top_p = c.topP;
|
|
203
|
+
if (c.stopSequences && c.stopSequences.length > 0) {
|
|
204
|
+
result.stop = c.stopSequences;
|
|
205
|
+
}
|
|
206
|
+
if (c.candidateCount !== undefined)
|
|
207
|
+
result.n = c.candidateCount;
|
|
208
|
+
if (c.seed !== undefined)
|
|
209
|
+
result.seed = c.seed;
|
|
210
|
+
if (c.frequencyPenalty !== undefined) {
|
|
211
|
+
result.frequency_penalty = c.frequencyPenalty;
|
|
212
|
+
}
|
|
213
|
+
if (c.presencePenalty !== undefined) {
|
|
214
|
+
result.presence_penalty = c.presencePenalty;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (request.tools && request.tools.length > 0) {
|
|
218
|
+
result.tools = request.tools.map((tool) => ({
|
|
219
|
+
type: "function",
|
|
220
|
+
function: {
|
|
221
|
+
name: tool.function.name,
|
|
222
|
+
...(tool.function.description !== undefined && {
|
|
223
|
+
description: tool.function.description,
|
|
224
|
+
}),
|
|
225
|
+
...(tool.function.parameters !== undefined && {
|
|
226
|
+
parameters: tool.function.parameters,
|
|
227
|
+
}),
|
|
228
|
+
},
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
if (request.toolChoice !== undefined) {
|
|
232
|
+
result.tool_choice = request.toolChoice;
|
|
233
|
+
}
|
|
234
|
+
if (request.stream !== undefined) {
|
|
235
|
+
result.stream = request.stream;
|
|
236
|
+
}
|
|
237
|
+
if (request.stream === true) {
|
|
238
|
+
// Ask the upstream for a final usage chunk (spec §3.2).
|
|
239
|
+
result
|
|
240
|
+
.stream_options = { include_usage: true };
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
function convertFromCanonicalMessage(msg) {
|
|
245
|
+
const content = convertFromCanonicalContent(msg.content);
|
|
246
|
+
if (msg.role === "assistant") {
|
|
247
|
+
const assistantMsg = {
|
|
248
|
+
role: "assistant",
|
|
249
|
+
content: typeof content === "string" ? content : null,
|
|
250
|
+
};
|
|
251
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
252
|
+
assistantMsg.tool_calls = msg.toolCalls.map((tc) => ({
|
|
253
|
+
id: tc.id,
|
|
254
|
+
type: "function",
|
|
255
|
+
function: {
|
|
256
|
+
name: tc.function.name,
|
|
257
|
+
arguments: tc.function.arguments,
|
|
258
|
+
},
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
return assistantMsg;
|
|
262
|
+
}
|
|
263
|
+
if (msg.role === "tool") {
|
|
264
|
+
return {
|
|
265
|
+
role: "tool",
|
|
266
|
+
content: typeof content === "string" ? content : "",
|
|
267
|
+
tool_call_id: msg.toolCallId || "",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (msg.role === "system") {
|
|
271
|
+
return {
|
|
272
|
+
role: "system",
|
|
273
|
+
content: typeof content === "string" ? content : "",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
// User message
|
|
277
|
+
return { role: "user", content };
|
|
278
|
+
}
|
|
279
|
+
function convertFromCanonicalContent(content) {
|
|
280
|
+
if (typeof content === "string")
|
|
281
|
+
return content;
|
|
282
|
+
return content.map((part) => {
|
|
283
|
+
if (part.type === "text") {
|
|
284
|
+
return { type: "text", text: part.text || "" };
|
|
285
|
+
}
|
|
286
|
+
if (part.type === "image" && part.image) {
|
|
287
|
+
return {
|
|
288
|
+
type: "image_url",
|
|
289
|
+
image_url: {
|
|
290
|
+
url: `data:${part.image.mimeType};base64,${part.image.data}`,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return { type: "text", text: "" };
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// ============ RESPONSE: OpenAI → Canonical ============
|
|
298
|
+
function toCanonicalResponse(response, model) {
|
|
299
|
+
const choice = response.choices[0];
|
|
300
|
+
const message = {
|
|
301
|
+
role: "assistant",
|
|
302
|
+
content: choice?.message?.content ?? "",
|
|
303
|
+
};
|
|
304
|
+
if (choice?.message?.tool_calls) {
|
|
305
|
+
message.toolCalls = choice.message.tool_calls.map((tc) => {
|
|
306
|
+
if (tc.type !== "function" || !("function" in tc)) {
|
|
307
|
+
// e.g. type:"custom" — an upstream wire shape canonical
|
|
308
|
+
// cannot express; error naming the type (kind "upstream",
|
|
309
|
+
// like the stream decoder's undecodable-chunk error) rather
|
|
310
|
+
// than returning an empty toolCalls.
|
|
311
|
+
throw new errors_1.UpstreamError(`Upstream openai sent unsupported tool call type "${String(tc.type)}"`);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
id: tc.id,
|
|
315
|
+
type: "function",
|
|
316
|
+
function: {
|
|
317
|
+
name: tc.function.name,
|
|
318
|
+
arguments: tc.function.arguments,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
id: response.id,
|
|
325
|
+
model: model || response.model,
|
|
326
|
+
message,
|
|
327
|
+
finishReason: mapFinishReason(choice?.finish_reason),
|
|
328
|
+
usage: response.usage
|
|
329
|
+
? {
|
|
330
|
+
inputTokens: response.usage.prompt_tokens,
|
|
331
|
+
outputTokens: response.usage.completion_tokens,
|
|
332
|
+
totalTokens: response.usage.total_tokens,
|
|
333
|
+
}
|
|
334
|
+
: undefined,
|
|
335
|
+
created: response.created,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function mapFinishReason(reason) {
|
|
339
|
+
switch (reason) {
|
|
340
|
+
case "stop":
|
|
341
|
+
return "stop";
|
|
342
|
+
case "length":
|
|
343
|
+
return "length";
|
|
344
|
+
case "tool_calls":
|
|
345
|
+
case "function_call":
|
|
346
|
+
return "tool_call";
|
|
347
|
+
case "content_filter":
|
|
348
|
+
return "content_filter";
|
|
349
|
+
default:
|
|
350
|
+
return "stop";
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// ============ RESPONSE: Canonical → OpenAI ============
|
|
354
|
+
function fromCanonicalResponse(response) {
|
|
355
|
+
const toolCalls = response.message.toolCalls?.map((tc) => ({
|
|
356
|
+
id: tc.id,
|
|
357
|
+
type: "function",
|
|
358
|
+
function: {
|
|
359
|
+
name: tc.function.name,
|
|
360
|
+
arguments: tc.function.arguments,
|
|
361
|
+
},
|
|
362
|
+
}));
|
|
363
|
+
return {
|
|
364
|
+
id: response.id || `chatcmpl-${(0, crypto_1.randomUUID)()}`,
|
|
365
|
+
object: "chat.completion",
|
|
366
|
+
created: response.created || Math.floor(Date.now() / 1000),
|
|
367
|
+
model: response.model,
|
|
368
|
+
choices: [
|
|
369
|
+
{
|
|
370
|
+
index: 0,
|
|
371
|
+
message: {
|
|
372
|
+
role: "assistant",
|
|
373
|
+
content: typeof response.message.content === "string"
|
|
374
|
+
? response.message.content
|
|
375
|
+
: "",
|
|
376
|
+
refusal: null,
|
|
377
|
+
...(toolCalls && { tool_calls: toolCalls }),
|
|
378
|
+
},
|
|
379
|
+
finish_reason: mapToOpenaiFinishReason(response.finishReason),
|
|
380
|
+
logprobs: null,
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
usage: response.usage
|
|
384
|
+
? {
|
|
385
|
+
prompt_tokens: response.usage.inputTokens,
|
|
386
|
+
completion_tokens: response.usage.outputTokens,
|
|
387
|
+
total_tokens: response.usage.totalTokens,
|
|
388
|
+
}
|
|
389
|
+
: undefined,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function toCanonicalModelList(response) {
|
|
393
|
+
const res = response;
|
|
394
|
+
return {
|
|
395
|
+
models: (res.data ?? [])
|
|
396
|
+
.filter((m) => Boolean(m.id))
|
|
397
|
+
.map((m) => ({
|
|
398
|
+
id: m.id,
|
|
399
|
+
created: m.created,
|
|
400
|
+
ownedBy: m.owned_by,
|
|
401
|
+
})),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function fromCanonicalModelList(list) {
|
|
405
|
+
return {
|
|
406
|
+
object: "list",
|
|
407
|
+
data: list.models.map((m) => ({
|
|
408
|
+
id: m.id,
|
|
409
|
+
object: "model",
|
|
410
|
+
created: m.created ?? 0,
|
|
411
|
+
owned_by: m.ownedBy ?? "babelgate",
|
|
412
|
+
})),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function mapToOpenaiFinishReason(reason) {
|
|
416
|
+
switch (reason) {
|
|
417
|
+
case "stop":
|
|
418
|
+
return "stop";
|
|
419
|
+
case "length":
|
|
420
|
+
return "length";
|
|
421
|
+
case "tool_call":
|
|
422
|
+
return "tool_calls";
|
|
423
|
+
case "content_filter":
|
|
424
|
+
return "content_filter";
|
|
425
|
+
default:
|
|
426
|
+
return "stop";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// ============ ERRORS ============
|
|
430
|
+
const OPENAI_ERROR_TYPE = {
|
|
431
|
+
invalid_request: "invalid_request_error",
|
|
432
|
+
authentication: "authentication_error",
|
|
433
|
+
permission: "permission_error",
|
|
434
|
+
not_found: "not_found_error",
|
|
435
|
+
request_too_large: "invalid_request_error",
|
|
436
|
+
rate_limit: "rate_limit_error",
|
|
437
|
+
unsupported: "invalid_request_error",
|
|
438
|
+
upstream: "api_error",
|
|
439
|
+
unavailable: "service_unavailable_error",
|
|
440
|
+
timeout: "api_error",
|
|
441
|
+
internal: "api_error",
|
|
442
|
+
};
|
|
443
|
+
function toCanonicalError(status, body) {
|
|
444
|
+
const err = body?.error;
|
|
445
|
+
const message = typeof err?.message === "string" && err.message.length > 0
|
|
446
|
+
? err.message
|
|
447
|
+
: `Upstream openai returned ${status}`;
|
|
448
|
+
return (0, canonical_error_1.makeCanonicalError)((0, canonical_error_1.kindFromStatus)(status), message);
|
|
449
|
+
}
|
|
450
|
+
function fromCanonicalError(err) {
|
|
451
|
+
return {
|
|
452
|
+
error: {
|
|
453
|
+
message: err.message,
|
|
454
|
+
type: OPENAI_ERROR_TYPE[err.kind],
|
|
455
|
+
param: null,
|
|
456
|
+
code: err.kind,
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// Streaming lives in ./openai.stream — re-exported on the adapter above.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createStreamDecoder = createStreamDecoder;
|
|
4
|
+
exports.createStreamEncoder = createStreamEncoder;
|
|
5
|
+
const ids_1 = require("../../utils/ids");
|
|
6
|
+
const canonical_error_1 = require("../../utils/canonical-error");
|
|
7
|
+
const sse_1 = require("../../utils/sse");
|
|
8
|
+
const openai_adapter_1 = require("./openai.adapter");
|
|
9
|
+
const OPENAI_FINISH = {
|
|
10
|
+
stop: "stop",
|
|
11
|
+
length: "length",
|
|
12
|
+
tool_calls: "tool_call",
|
|
13
|
+
function_call: "tool_call",
|
|
14
|
+
content_filter: "content_filter",
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* OpenAI SSE → canonical events (spec §3.3): every `data:` line carries a
|
|
18
|
+
* complete chat.completion.chunk JSON payload, `data: [DONE]` ends the
|
|
19
|
+
* stream. Lines are handled individually — the blank-line event
|
|
20
|
+
* separators real OpenAI sends are just ignored, so recordings without
|
|
21
|
+
* them decode identically. Tool calls are re-keyed from wire
|
|
22
|
+
* `tool_calls[].index` to canonical indices in first-appearance order; a
|
|
23
|
+
* call closes when the finish chunk arrives (or at end-of-stream). The
|
|
24
|
+
* `choices: []` usage chunk is the usage event. A stream that ends
|
|
25
|
+
* without `[DONE]` ends in an `error` event.
|
|
26
|
+
*/
|
|
27
|
+
function createStreamDecoder(model, ids = (0, ids_1.createIdGenerator)()) {
|
|
28
|
+
const utf8 = (0, sse_1.createUtf8Decoder)();
|
|
29
|
+
const lines = (0, sse_1.createLineSplitter)();
|
|
30
|
+
let started = false;
|
|
31
|
+
let finished = false;
|
|
32
|
+
let finish;
|
|
33
|
+
let usage;
|
|
34
|
+
let nextIndex = 0;
|
|
35
|
+
const callsByWire = new Map();
|
|
36
|
+
const openCalls = [];
|
|
37
|
+
function closeAll(out) {
|
|
38
|
+
for (const index of openCalls.splice(0)) {
|
|
39
|
+
out.push({ type: "tool_call_end", index });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function handleChunk(chunk, out) {
|
|
43
|
+
if (chunk.error) {
|
|
44
|
+
// In-stream error object (spec §3.3): ends the stream.
|
|
45
|
+
out.push({ type: "error", error: (0, openai_adapter_1.toCanonicalError)(502, chunk) });
|
|
46
|
+
finished = true;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!started) {
|
|
50
|
+
started = true;
|
|
51
|
+
out.push({
|
|
52
|
+
type: "message_start",
|
|
53
|
+
id: typeof chunk.id === "string" ? chunk.id : ids(),
|
|
54
|
+
model: typeof chunk.model === "string" ? chunk.model : model,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
for (const choice of chunk.choices ?? []) {
|
|
58
|
+
const delta = choice?.delta;
|
|
59
|
+
if (typeof delta?.content === "string" && delta.content.length > 0) {
|
|
60
|
+
out.push({ type: "text_delta", text: delta.content });
|
|
61
|
+
}
|
|
62
|
+
for (const tc of delta?.tool_calls ?? []) {
|
|
63
|
+
const wireIndex = typeof tc.index === "number" ? tc.index : 0;
|
|
64
|
+
let index = callsByWire.get(wireIndex);
|
|
65
|
+
if (index === undefined) {
|
|
66
|
+
index = nextIndex++;
|
|
67
|
+
callsByWire.set(wireIndex, index);
|
|
68
|
+
openCalls.push(index);
|
|
69
|
+
out.push({
|
|
70
|
+
type: "tool_call_start",
|
|
71
|
+
index,
|
|
72
|
+
id: typeof tc.id === "string" ? tc.id : ids(),
|
|
73
|
+
name: tc.function?.name ?? "",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (typeof tc.function?.arguments === "string" &&
|
|
77
|
+
tc.function.arguments.length > 0) {
|
|
78
|
+
out.push({
|
|
79
|
+
type: "tool_call_delta",
|
|
80
|
+
index,
|
|
81
|
+
argumentsDelta: tc.function.arguments,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (choice?.finish_reason && finish === undefined) {
|
|
86
|
+
finish = OPENAI_FINISH[choice.finish_reason] ?? "unknown";
|
|
87
|
+
closeAll(out);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (chunk.usage && usage === undefined) {
|
|
91
|
+
usage = {
|
|
92
|
+
inputTokens: chunk.usage.prompt_tokens ?? 0,
|
|
93
|
+
outputTokens: chunk.usage.completion_tokens ?? 0,
|
|
94
|
+
totalTokens: chunk.usage.total_tokens ??
|
|
95
|
+
(chunk.usage.prompt_tokens ?? 0) +
|
|
96
|
+
(chunk.usage.completion_tokens ?? 0),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function handleLine(line, out) {
|
|
101
|
+
if (finished || !line.startsWith("data:"))
|
|
102
|
+
return;
|
|
103
|
+
const data = line.slice(5).replace(/^ /, "");
|
|
104
|
+
if (data === "[DONE]") {
|
|
105
|
+
finished = true;
|
|
106
|
+
closeAll(out);
|
|
107
|
+
if (usage)
|
|
108
|
+
out.push({ type: "usage", usage });
|
|
109
|
+
out.push({ type: "message_end", finishReason: finish ?? "unknown" });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (data === "")
|
|
113
|
+
return;
|
|
114
|
+
let chunk;
|
|
115
|
+
try {
|
|
116
|
+
chunk = JSON.parse(data);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
out.push({
|
|
120
|
+
type: "error",
|
|
121
|
+
error: (0, canonical_error_1.makeCanonicalError)("upstream", "Upstream sent an undecodable stream chunk"),
|
|
122
|
+
});
|
|
123
|
+
finished = true;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
handleChunk(chunk, out);
|
|
127
|
+
}
|
|
128
|
+
function drain(completed, out) {
|
|
129
|
+
for (const line of completed)
|
|
130
|
+
handleLine(line, out);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
feed(chunk) {
|
|
134
|
+
const out = [];
|
|
135
|
+
drain(lines.feed(utf8(chunk)), out);
|
|
136
|
+
return out;
|
|
137
|
+
},
|
|
138
|
+
end() {
|
|
139
|
+
const out = [];
|
|
140
|
+
drain(lines.feed(utf8(new Uint8Array(0))), out);
|
|
141
|
+
drain(lines.end(), out);
|
|
142
|
+
if (!finished) {
|
|
143
|
+
closeAll(out);
|
|
144
|
+
out.push({
|
|
145
|
+
type: "error",
|
|
146
|
+
error: (0, canonical_error_1.makeCanonicalError)("upstream", "Upstream stream ended without completing"),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Canonical events → OpenAI SSE (spec §3.3): text deltas and tool-call
|
|
155
|
+
* pieces become chat.completion.chunk data lines; a usage event becomes
|
|
156
|
+
* the `choices: []` usage chunk, but only when the client asked for it
|
|
157
|
+
* (`stream_options.include_usage`); an error event is a
|
|
158
|
+
* `data: {"error":{…}}` line. `end()` writes `data: [DONE]`.
|
|
159
|
+
*/
|
|
160
|
+
function createStreamEncoder(ctx) {
|
|
161
|
+
const includeUsage = ctx.sourceRequest.stream_options?.include_usage === true;
|
|
162
|
+
const created = Math.floor(Date.now() / 1000);
|
|
163
|
+
let id = "chatcmpl-babelgate";
|
|
164
|
+
const chunk = (delta, finishReason = null) => (0, sse_1.formatSse)(JSON.stringify({
|
|
165
|
+
id,
|
|
166
|
+
object: "chat.completion.chunk",
|
|
167
|
+
created,
|
|
168
|
+
model: ctx.requestedModel,
|
|
169
|
+
choices: [
|
|
170
|
+
{ index: 0, delta, logprobs: null, finish_reason: finishReason },
|
|
171
|
+
],
|
|
172
|
+
}));
|
|
173
|
+
return {
|
|
174
|
+
contentType: "text/event-stream",
|
|
175
|
+
encode(event) {
|
|
176
|
+
switch (event.type) {
|
|
177
|
+
case "message_start":
|
|
178
|
+
id = event.id;
|
|
179
|
+
return chunk({ role: "assistant", content: "" });
|
|
180
|
+
case "text_delta":
|
|
181
|
+
return chunk({ content: event.text });
|
|
182
|
+
case "tool_call_start":
|
|
183
|
+
return chunk({
|
|
184
|
+
tool_calls: [
|
|
185
|
+
{
|
|
186
|
+
index: event.index,
|
|
187
|
+
id: event.id,
|
|
188
|
+
type: "function",
|
|
189
|
+
function: { name: event.name, arguments: "" },
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
case "tool_call_delta":
|
|
194
|
+
return chunk({
|
|
195
|
+
tool_calls: [
|
|
196
|
+
{
|
|
197
|
+
index: event.index,
|
|
198
|
+
function: { arguments: event.argumentsDelta },
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
case "tool_call_end":
|
|
203
|
+
return "";
|
|
204
|
+
case "usage":
|
|
205
|
+
if (!includeUsage)
|
|
206
|
+
return "";
|
|
207
|
+
return (0, sse_1.formatSse)(JSON.stringify({
|
|
208
|
+
id,
|
|
209
|
+
object: "chat.completion.chunk",
|
|
210
|
+
created,
|
|
211
|
+
model: ctx.requestedModel,
|
|
212
|
+
choices: [],
|
|
213
|
+
usage: {
|
|
214
|
+
prompt_tokens: event.usage.inputTokens,
|
|
215
|
+
completion_tokens: event.usage.outputTokens,
|
|
216
|
+
total_tokens: event.usage.totalTokens,
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
case "message_end":
|
|
220
|
+
return chunk({}, (0, openai_adapter_1.mapToOpenaiFinishReason)(event.finishReason));
|
|
221
|
+
case "error":
|
|
222
|
+
return (0, sse_1.formatSse)(JSON.stringify((0, openai_adapter_1.fromCanonicalError)(event.error)));
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
end() {
|
|
226
|
+
return (0, sse_1.formatSse)("[DONE]");
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|