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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +467 -1
  4. package/dist/app.js +40 -0
  5. package/dist/cli.js +203 -0
  6. package/dist/config/env-config.js +99 -0
  7. package/dist/index.js +60 -0
  8. package/dist/middleware/error-handler.js +158 -0
  9. package/dist/middleware/request-logger.js +66 -0
  10. package/dist/middleware/require-api-key.js +80 -0
  11. package/dist/middleware/resolve-source.js +28 -0
  12. package/dist/routes/proxy.js +74 -0
  13. package/dist/services/http-client.js +220 -0
  14. package/dist/services/proxy.service.js +329 -0
  15. package/dist/services/stream-pipeline.js +119 -0
  16. package/dist/services/upstream-errors.js +34 -0
  17. package/dist/transformers/adapters/anthropic.adapter.js +292 -0
  18. package/dist/transformers/adapters/anthropic.messages.js +261 -0
  19. package/dist/transformers/adapters/anthropic.stream.js +378 -0
  20. package/dist/transformers/adapters/gemini.adapter.js +418 -0
  21. package/dist/transformers/adapters/gemini.schema.js +61 -0
  22. package/dist/transformers/adapters/gemini.stream.js +341 -0
  23. package/dist/transformers/adapters/gemini.tools.js +246 -0
  24. package/dist/transformers/adapters/index.js +141 -0
  25. package/dist/transformers/adapters/ollama.adapter.js +288 -0
  26. package/dist/transformers/adapters/ollama.stream.js +294 -0
  27. package/dist/transformers/adapters/ollama.tools.js +107 -0
  28. package/dist/transformers/adapters/openai.adapter.js +460 -0
  29. package/dist/transformers/adapters/openai.stream.js +229 -0
  30. package/dist/transformers/index.js +110 -0
  31. package/dist/types/anthropic.types.js +2 -0
  32. package/dist/types/canonical.schema.js +160 -0
  33. package/dist/types/canonical.types.js +8 -0
  34. package/dist/types/gemini.types.js +2 -0
  35. package/dist/types/ollama.types.js +4 -0
  36. package/dist/types/openai.types.js +2 -0
  37. package/dist/utils/canonical-error.js +77 -0
  38. package/dist/utils/errors.js +95 -0
  39. package/dist/utils/ids.js +17 -0
  40. package/dist/utils/logger.js +21 -0
  41. package/dist/utils/model-map.js +39 -0
  42. package/dist/utils/sse.js +147 -0
  43. package/dist/utils/stream-reassemble.js +104 -0
  44. package/dist/utils/token-estimate.js +36 -0
  45. package/dist/utils/tool-args.js +26 -0
  46. package/package.json +54 -7
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.anthropicAdapter = exports.ollamaAdapter = exports.geminiAdapter = exports.openaiAdapter = exports.PROVIDER_PATHS = exports.PROVIDER_ROUTES = exports.adapters = void 0;
4
+ exports.findSourceRoute = findSourceRoute;
5
+ const openai_adapter_1 = require("./openai.adapter");
6
+ Object.defineProperty(exports, "openaiAdapter", { enumerable: true, get: function () { return openai_adapter_1.openaiAdapter; } });
7
+ const gemini_adapter_1 = require("./gemini.adapter");
8
+ Object.defineProperty(exports, "geminiAdapter", { enumerable: true, get: function () { return gemini_adapter_1.geminiAdapter; } });
9
+ const ollama_adapter_1 = require("./ollama.adapter");
10
+ Object.defineProperty(exports, "ollamaAdapter", { enumerable: true, get: function () { return ollama_adapter_1.ollamaAdapter; } });
11
+ const anthropic_adapter_1 = require("./anthropic.adapter");
12
+ Object.defineProperty(exports, "anthropicAdapter", { enumerable: true, get: function () { return anthropic_adapter_1.anthropicAdapter; } });
13
+ exports.adapters = {
14
+ openai: openai_adapter_1.openaiAdapter,
15
+ gemini: gemini_adapter_1.geminiAdapter,
16
+ ollama: ollama_adapter_1.ollamaAdapter,
17
+ anthropic: anthropic_adapter_1.anthropicAdapter,
18
+ };
19
+ /**
20
+ * The "<model>:<method>" pair carried in a gemini chat path
21
+ * (/v1beta/models/<model>:<method>). The method suffix is
22
+ * ":"-separated; the model id may be percent-encoded and may contain
23
+ * "/" (e.g. tunedModels/x). Undefined when the path has no such pair.
24
+ */
25
+ function geminiPathParts(path) {
26
+ let decoded = path;
27
+ try {
28
+ decoded = decodeURIComponent(path);
29
+ }
30
+ catch {
31
+ // malformed escapes — fall back to the raw path
32
+ }
33
+ const prefix = "/v1beta/models/";
34
+ if (!decoded.startsWith(prefix))
35
+ return undefined;
36
+ const rest = decoded.slice(prefix.length);
37
+ const colon = rest.lastIndexOf(":");
38
+ if (colon === -1)
39
+ return undefined;
40
+ return { model: rest.slice(0, colon), method: rest.slice(colon + 1) };
41
+ }
42
+ /**
43
+ * Route conventions per provider. Each provider exposes routes for every
44
+ * endpoint kind it supports — matched by method + pathPrefix.
45
+ */
46
+ exports.PROVIDER_ROUTES = {
47
+ openai: [
48
+ {
49
+ kind: "chat",
50
+ method: "POST",
51
+ pathPrefix: "/v1/chat/completions",
52
+ requestPath: () => "/v1/chat/completions",
53
+ },
54
+ {
55
+ kind: "models",
56
+ method: "GET",
57
+ pathPrefix: "/v1/models",
58
+ requestPath: () => "/v1/models",
59
+ },
60
+ ],
61
+ gemini: [
62
+ {
63
+ kind: "chat",
64
+ method: "POST",
65
+ pathPrefix: "/v1beta/models",
66
+ requestPath: (model) => `/v1beta/models/${model}:generateContent`,
67
+ streamRequestPath: (model) => `/v1beta/models/${model}:streamGenerateContent?alt=sse`,
68
+ modelFromPath: (path) => geminiPathParts(path)?.model,
69
+ isStreamPath: (path) => geminiPathParts(path)?.method === "streamGenerateContent",
70
+ // A path without a "<m>:<method>" pair isn't claimed here — it
71
+ // falls through to the model-from-body flow as before.
72
+ isSupportedPath: (path) => {
73
+ const method = geminiPathParts(path)?.method;
74
+ return (method === undefined ||
75
+ method === "generateContent" ||
76
+ method === "streamGenerateContent");
77
+ },
78
+ },
79
+ {
80
+ kind: "models",
81
+ method: "GET",
82
+ pathPrefix: "/v1beta/models",
83
+ requestPath: () => "/v1beta/models",
84
+ },
85
+ ],
86
+ ollama: [
87
+ {
88
+ kind: "chat",
89
+ method: "POST",
90
+ pathPrefix: "/api/chat",
91
+ requestPath: () => "/api/chat",
92
+ },
93
+ {
94
+ kind: "models",
95
+ method: "GET",
96
+ pathPrefix: "/api/tags",
97
+ requestPath: () => "/api/tags",
98
+ },
99
+ ],
100
+ anthropic: [
101
+ {
102
+ // Answered locally (spec §3.5) — must precede the /v1/messages
103
+ // chat route: it shares that prefix and routes match in order.
104
+ kind: "count_tokens",
105
+ method: "POST",
106
+ pathPrefix: "/v1/messages/count_tokens",
107
+ requestPath: () => "/v1/messages",
108
+ },
109
+ {
110
+ kind: "chat",
111
+ method: "POST",
112
+ pathPrefix: "/v1/messages",
113
+ requestPath: () => "/v1/messages",
114
+ },
115
+ {
116
+ kind: "models",
117
+ method: "GET",
118
+ pathPrefix: "/v1/models",
119
+ requestPath: () => "/v1/models",
120
+ },
121
+ ],
122
+ };
123
+ /**
124
+ * Match an incoming request to a route on the given source provider
125
+ * (method + path prefix). Used by resolve-source middleware and
126
+ * proxy.service.
127
+ */
128
+ function findSourceRoute(source, method, path) {
129
+ return exports.PROVIDER_ROUTES[source].find((r) => r.method === method && path.startsWith(r.pathPrefix));
130
+ }
131
+ /**
132
+ * Back-compat view of the chat routes — chat endpoint per provider.
133
+ * Prefer PROVIDER_ROUTES for new code.
134
+ */
135
+ exports.PROVIDER_PATHS = Object.fromEntries(Object.keys(exports.PROVIDER_ROUTES).map((provider) => {
136
+ const chat = exports.PROVIDER_ROUTES[provider].find((r) => r.kind === "chat");
137
+ return [
138
+ provider,
139
+ { pathPrefix: chat.pathPrefix, requestPath: chat.requestPath },
140
+ ];
141
+ }));
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ollamaAdapter = 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.toCanonicalError = toCanonicalError;
11
+ exports.fromCanonicalError = fromCanonicalError;
12
+ const crypto_1 = require("crypto");
13
+ const canonical_error_1 = require("../../utils/canonical-error");
14
+ const errors_1 = require("../../utils/errors");
15
+ const ids_1 = require("../../utils/ids");
16
+ const ollama_stream_1 = require("./ollama.stream");
17
+ const ollama_tools_1 = require("./ollama.tools");
18
+ exports.ollamaAdapter = {
19
+ toCanonicalRequest,
20
+ fromCanonicalRequest,
21
+ toCanonicalResponse,
22
+ fromCanonicalResponse,
23
+ toCanonicalModelList,
24
+ fromCanonicalModelList,
25
+ toCanonicalError,
26
+ fromCanonicalError,
27
+ createStreamDecoder: ollama_stream_1.createStreamDecoder,
28
+ createStreamEncoder: ollama_stream_1.createStreamEncoder,
29
+ };
30
+ // ============ REQUEST: Ollama → Canonical ============
31
+ function toCanonicalRequest(request) {
32
+ // Wire ids are kept when present; id-less calls draw call_0, call_1,…
33
+ // (docs/decisions.md "v1.1 Task 3" convention).
34
+ const ids = (0, ids_1.sequentialIds)();
35
+ // Tool results carry only `tool_name` on the wire (docs.ollama.com
36
+ // api.md "Chat request (With history, with tools)"); pair each one
37
+ // with the earliest unconsumed earlier call of that name (FIFO).
38
+ const pendingByName = new Map();
39
+ const messages = (request.messages || []).map((m) => {
40
+ const out = {
41
+ role: m.role,
42
+ content: extractContent(m),
43
+ };
44
+ const wireCalls = m.tool_calls;
45
+ if (wireCalls && wireCalls.length > 0) {
46
+ out.toolCalls = (0, ollama_tools_1.toCanonicalToolCalls)(wireCalls, ids);
47
+ for (const tc of out.toolCalls) {
48
+ (0, ollama_tools_1.recordCallId)(pendingByName, tc.function.name, tc.id);
49
+ }
50
+ }
51
+ if (typeof m.tool_name === "string" && m.tool_name.length > 0) {
52
+ out.toolCallId = (0, ollama_tools_1.resolveCallId)(pendingByName, m.tool_name) ?? m.tool_name;
53
+ }
54
+ return out;
55
+ });
56
+ // Extract system prompt from first system message
57
+ const systemMsg = messages.find((m) => m.role === "system");
58
+ const systemPrompt = typeof systemMsg?.content === "string" ? systemMsg.content : undefined;
59
+ const result = {
60
+ model: request.model,
61
+ messages,
62
+ ...(systemPrompt && { systemPrompt }),
63
+ };
64
+ // Convert options to config
65
+ if (request.options) {
66
+ const opts = request.options;
67
+ result.config = {
68
+ ...(opts.temperature !== undefined && { temperature: opts.temperature }),
69
+ ...(opts.num_predict !== undefined && { maxTokens: opts.num_predict }),
70
+ ...(opts.top_p !== undefined && { topP: opts.top_p }),
71
+ ...(opts.top_k !== undefined && { topK: opts.top_k }),
72
+ ...(opts.stop && { stopSequences: opts.stop }),
73
+ ...(opts.seed !== undefined && { seed: opts.seed }),
74
+ };
75
+ }
76
+ // Canonical treats absent and stream:false identically — only an
77
+ // explicit true survives, keeping the request round-trip idempotent.
78
+ if (request.stream === true) {
79
+ result.stream = true;
80
+ }
81
+ if (request.tools && request.tools.length > 0) {
82
+ result.tools = (0, ollama_tools_1.toCanonicalTools)(request.tools);
83
+ }
84
+ return result;
85
+ }
86
+ function extractContent(message) {
87
+ if (!message.images || message.images.length === 0) {
88
+ return message.content;
89
+ }
90
+ // Has images - return multipart
91
+ const parts = [
92
+ { type: "text", text: message.content },
93
+ ];
94
+ for (const img of message.images) {
95
+ // Ollama images can be string (base64) or Uint8Array
96
+ const data = typeof img === "string" ? img : Buffer.from(img).toString("base64");
97
+ parts.push({
98
+ type: "image",
99
+ image: {
100
+ data,
101
+ mimeType: "image/png", // Ollama doesn't specify mime type
102
+ },
103
+ });
104
+ }
105
+ return parts;
106
+ }
107
+ // ============ REQUEST: Canonical → Ollama ============
108
+ function fromCanonicalRequest(request) {
109
+ // Ollama has no tool_choice parameter (api.md "Tool calling"); auto
110
+ // (or absent) is its only behaviour. Anything else → 400.
111
+ if (request.toolChoice !== undefined && request.toolChoice !== "auto") {
112
+ const value = typeof request.toolChoice === "string"
113
+ ? request.toolChoice
114
+ : JSON.stringify(request.toolChoice);
115
+ throw new errors_1.ValidationError(`Ollama does not support tool_choice "${value}"`);
116
+ }
117
+ // `tool_name` on a result is looked up from the matching assistant
118
+ // call in the same request (Ollama carries names, not call ids).
119
+ // The lookup is built while walking the messages, so a tool message
120
+ // resolves only against calls that come BEFORE it.
121
+ const callNameById = new Map();
122
+ const messages = request.messages.map((m) => {
123
+ const out = convertToOllamaMessage(m, callNameById);
124
+ for (const tc of m.toolCalls ?? []) {
125
+ callNameById.set(tc.id, tc.function.name);
126
+ }
127
+ return out;
128
+ });
129
+ const result = {
130
+ model: request.model,
131
+ messages,
132
+ };
133
+ if (request.tools && request.tools.length > 0) {
134
+ result.tools = (0, ollama_tools_1.toOllamaTools)(request.tools);
135
+ }
136
+ // Convert config to options
137
+ if (request.config) {
138
+ const c = request.config;
139
+ const options = {};
140
+ if (c.temperature !== undefined)
141
+ options.temperature = c.temperature;
142
+ if (c.maxTokens !== undefined)
143
+ options.num_predict = c.maxTokens;
144
+ if (c.topP !== undefined)
145
+ options.top_p = c.topP;
146
+ if (c.topK !== undefined)
147
+ options.top_k = c.topK;
148
+ if (c.stopSequences)
149
+ options.stop = c.stopSequences;
150
+ if (c.seed !== undefined)
151
+ options.seed = c.seed;
152
+ if (c.contextWindow !== undefined)
153
+ options.num_ctx = c.contextWindow;
154
+ if (Object.keys(options).length > 0) {
155
+ result.options = options;
156
+ }
157
+ }
158
+ // Ollama streams NDJSON by default; send stream:false unless the
159
+ // canonical request explicitly asked for streaming.
160
+ result.stream = request.stream ?? false;
161
+ return result;
162
+ }
163
+ function convertToOllamaMessage(msg, callNameById) {
164
+ const result = {
165
+ role: msg.role,
166
+ content: "",
167
+ };
168
+ if (typeof msg.content === "string") {
169
+ result.content = msg.content;
170
+ }
171
+ else if (Array.isArray(msg.content)) {
172
+ // Extract text and images
173
+ const textParts = msg.content
174
+ .filter((p) => p.type === "text")
175
+ .map((p) => p.text || "")
176
+ .join("\n");
177
+ const images = msg.content
178
+ .filter((p) => p.type === "image" && p.image)
179
+ .map((p) => p.image.data);
180
+ result.content = textParts;
181
+ if (images.length > 0) {
182
+ result.images = images;
183
+ }
184
+ }
185
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
186
+ result.tool_calls = (0, ollama_tools_1.toOllamaToolCalls)(msg.toolCalls);
187
+ }
188
+ if (msg.role === "tool") {
189
+ result.tool_name = (0, ollama_tools_1.resolveToolName)(callNameById, msg.toolCallId);
190
+ }
191
+ return result;
192
+ }
193
+ // ============ RESPONSE: Ollama → Canonical ============
194
+ function toCanonicalResponse(response, model, ids = (0, ids_1.createIdGenerator)()) {
195
+ const message = {
196
+ role: "assistant",
197
+ content: response.message?.content ?? "",
198
+ };
199
+ const wireCalls = response.message?.tool_calls;
200
+ if (wireCalls && wireCalls.length > 0) {
201
+ message.toolCalls = (0, ollama_tools_1.toCanonicalToolCalls)(wireCalls, ids);
202
+ }
203
+ return {
204
+ id: `ollama-${(0, crypto_1.randomUUID)()}`,
205
+ model,
206
+ message,
207
+ // A reply ending in tool calls is a "tool_call" finish — the same
208
+ // rule the stream decoder applies (docs/decisions.md "v1.1 Task 6").
209
+ finishReason: message.toolCalls
210
+ ? "tool_call"
211
+ : response.done
212
+ ? "stop"
213
+ : "unknown",
214
+ usage: {
215
+ inputTokens: response.prompt_eval_count ?? 0,
216
+ outputTokens: response.eval_count ?? 0,
217
+ totalTokens: (response.prompt_eval_count ?? 0) + (response.eval_count ?? 0),
218
+ },
219
+ };
220
+ }
221
+ // ============ RESPONSE: Canonical → Ollama ============
222
+ function fromCanonicalResponse(response) {
223
+ const content = typeof response.message.content === "string"
224
+ ? response.message.content
225
+ : "";
226
+ const toolCalls = response.message.toolCalls;
227
+ // "stop" covers canonical "tool_call": recorded Ollama answers that
228
+ // end in tool calls report done_reason "stop".
229
+ const stopped = response.finishReason === "stop" ||
230
+ response.finishReason === "tool_call";
231
+ return {
232
+ model: response.model,
233
+ created_at: new Date(),
234
+ message: {
235
+ role: "assistant",
236
+ content,
237
+ ...(toolCalls &&
238
+ toolCalls.length > 0 && {
239
+ tool_calls: (0, ollama_tools_1.toOllamaToolCalls)(toolCalls),
240
+ }),
241
+ },
242
+ done: stopped,
243
+ done_reason: stopped ? "stop" : "length",
244
+ total_duration: 0,
245
+ load_duration: 0,
246
+ prompt_eval_count: response.usage?.inputTokens ?? 0,
247
+ prompt_eval_duration: 0,
248
+ eval_count: response.usage?.outputTokens ?? 0,
249
+ eval_duration: 0,
250
+ };
251
+ }
252
+ function toCanonicalModelList(response) {
253
+ const res = response;
254
+ return {
255
+ models: (res.models ?? [])
256
+ .filter((m) => Boolean(m.name ?? m.model))
257
+ .map((m) => ({
258
+ id: (m.name ?? m.model),
259
+ // ISO timestamp → Unix seconds
260
+ created: m.modified_at
261
+ ? Math.floor(Date.parse(m.modified_at) / 1000)
262
+ : undefined,
263
+ })),
264
+ };
265
+ }
266
+ function fromCanonicalModelList(list) {
267
+ return {
268
+ models: list.models.map((m) => ({
269
+ name: m.id,
270
+ model: m.id,
271
+ modified_at: m.created
272
+ ? new Date(m.created * 1000).toISOString()
273
+ : undefined,
274
+ })),
275
+ };
276
+ }
277
+ // ============ ERRORS ============
278
+ function toCanonicalError(status, body) {
279
+ const err = body?.error;
280
+ const message = typeof err === "string" && err.length > 0
281
+ ? err
282
+ : `Upstream ollama returned ${status}`;
283
+ return (0, canonical_error_1.makeCanonicalError)((0, canonical_error_1.kindFromStatus)(status), message);
284
+ }
285
+ function fromCanonicalError(err) {
286
+ return { error: err.message };
287
+ }
288
+ // Streaming lives in ./ollama.stream — re-exported on the adapter above.