babelgate 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.
@@ -5,27 +5,15 @@ exports.proxyRequest = proxyRequest;
5
5
  const env_config_1 = require("../config/env-config");
6
6
  const transformers_1 = require("../transformers");
7
7
  const adapters_1 = require("../transformers/adapters");
8
- const http_client_1 = require("./http-client");
9
- const stream_pipeline_1 = require("./stream-pipeline");
10
- const model_map_1 = require("../utils/model-map");
8
+ const plan_route_1 = require("../routing/plan-route");
11
9
  const token_estimate_1 = require("../utils/token-estimate");
12
- const upstream_errors_1 = require("./upstream-errors");
13
10
  const canonical_schema_1 = require("../types/canonical.schema");
14
11
  const errors_1 = require("../utils/errors");
15
12
  Object.defineProperty(exports, "TransformError", { enumerable: true, get: function () { return errors_1.TransformError; } });
16
13
  const logger_1 = require("../utils/logger");
17
- /**
18
- * Attach the spec §3.3 client-facing rendering to an upstream failure.
19
- * Only real upstream error statuses (≥ 400) go through the mapping
20
- * table — timeouts, transport failures and non-JSON 2xx replies already
21
- * carry their final client-facing message.
22
- */
23
- function toClientFacing(err, target) {
24
- if (err.upstreamStatus !== undefined && err.upstreamStatus >= 400) {
25
- err.clientError = (0, upstream_errors_1.toClientError)(target, err.upstreamStatus, err.upstreamBody, err.retryAfterSeconds);
26
- }
27
- return err;
28
- }
14
+ const chat_service_1 = require("./chat.service");
15
+ const chat_stream_service_1 = require("./chat-stream.service");
16
+ const models_service_1 = require("./models.service");
29
17
  /**
30
18
  * The "<method>" in a "/v1beta/models/<m>:<method>" path — the text
31
19
  * after the last ":" of the percent-decoded path, the same parsing the
@@ -43,32 +31,14 @@ function methodFromPath(path) {
43
31
  return colon === -1 ? decoded : decoded.slice(colon + 1);
44
32
  }
45
33
  /**
46
- * Provider-specific auth headers.
47
- */
48
- function buildAuthHeaders(provider, apiKey) {
49
- if (!apiKey)
50
- return {};
51
- switch (provider) {
52
- case "openai":
53
- return { Authorization: `Bearer ${apiKey}` };
54
- case "anthropic":
55
- return {
56
- "x-api-key": apiKey,
57
- "anthropic-version": "2023-06-01",
58
- };
59
- case "gemini":
60
- return { "x-goog-api-key": apiKey };
61
- case "ollama":
62
- return {};
63
- }
64
- }
65
- /**
66
- * Full proxy orchestration:
34
+ * Thin entry over the routing layer (spec §3.1):
67
35
  * path match → config → canonical boundary validation →
68
- * transform → upstream call → transform back.
36
+ * count_tokens → planRoute → chat/chat-stream/models services.
69
37
  *
70
- * Returns null when the path doesn't belong to the configured source
71
- * provider (route should call next() → 404).
38
+ * The client format comes from `opts.source` (the detect-source
39
+ * middleware's read of the request path, spec §3.2). Returns null when
40
+ * it is unset or the path doesn't belong to that source provider
41
+ * (route should call next() → 404).
72
42
  * Throws ValidationError | UpstreamError | TransformError | ConfigurationError.
73
43
  */
74
44
  async function proxyRequest(path, method, body, requestId, opts) {
@@ -79,14 +49,15 @@ async function proxyRequest(path, method, body, requestId, opts) {
79
49
  if (!isProviderPath)
80
50
  return null;
81
51
  const config = (0, env_config_1.getConfig)();
82
- const sourceApi = config.sourceApi;
83
- const targetApi = config.targetApi;
84
- // Match the incoming path to a route on the configured source provider
52
+ const sourceApi = opts?.source;
53
+ if (!sourceApi)
54
+ return null;
55
+ // Match the incoming path to a route on the detected source provider
85
56
  const route = (0, adapters_1.findSourceRoute)(sourceApi, method, path);
86
57
  if (!route)
87
58
  return null;
88
59
  if (route.kind === "models") {
89
- return proxyModelList(route, config, sourceApi, targetApi, requestId);
60
+ return (0, models_service_1.proxyModelList)(sourceApi, route, config, requestId);
90
61
  }
91
62
  // Gemini paths carry a ":<method>" suffix — anything that isn't a
92
63
  // method this route serves is refused before conversion, before any
@@ -122,27 +93,37 @@ async function proxyRequest(path, method, body, requestId, opts) {
122
93
  if (modelFromPath === "") {
123
94
  throw new errors_1.ValidationError(`Malformed ${sourceApi} request path: model is empty`, { requestId });
124
95
  }
96
+ // The chars/4 input estimate is computed once — count_tokens answers
97
+ // with it (§3.5) and routing `when` conditions consume it (§3.3).
98
+ const estimatedInputTokens = (0, token_estimate_1.estimateInputTokens)(parsed.data);
125
99
  // §3.5: count_tokens is answered locally — a chars/4 estimate with the
126
100
  // estimate header, never an upstream call.
127
101
  if (route.kind === "count_tokens") {
128
102
  logger_1.logger.info({ requestId, sourceApi }, "count_tokens answered locally");
129
103
  return {
130
- body: { input_tokens: (0, token_estimate_1.estimateInputTokens)(parsed.data) },
104
+ body: { input_tokens: estimatedInputTokens },
131
105
  headers: { "x-babelgate-token-estimate": "true" },
132
106
  };
133
107
  }
134
- // §3.5: MODEL_MAP rewrites the upstream-bound model; replies and
135
- // stream encoders keep the name the client asked for.
108
+ // §3.3: resolve the model to an ordered list of targets — env mode
109
+ // produces MODEL_MAP routes then the single default provider.
136
110
  const requestedModel = modelFromPath ?? parsed.data.model;
137
- const model = (0, model_map_1.applyModelMap)(requestedModel, config.modelMap);
138
- parsed.data.model = model;
139
- // §3.5: TARGET_CONTEXT_WINDOW → canonical contextWindow (Ollama num_ctx).
140
- if (config.targetContextWindow !== undefined) {
141
- parsed.data.config = {
142
- ...parsed.data.config,
143
- contextWindow: config.targetContextWindow,
144
- };
145
- }
111
+ const plan = (0, plan_route_1.planRoute)({
112
+ requestedModel,
113
+ estimatedInputTokens,
114
+ routing: config.routing,
115
+ providerNames: new Set(config.registry.names()),
116
+ });
117
+ const input = {
118
+ source: sourceApi,
119
+ sourceBody: body,
120
+ canonical: parsed.data,
121
+ requestedModel,
122
+ plan,
123
+ config,
124
+ requestId,
125
+ opts,
126
+ };
146
127
  // Streaming (spec §3.2): a `stream` body flag on the JSON-body sources,
147
128
  // the dedicated streaming endpoint on Gemini — which carries no body
148
129
  // flag, so canonical.stream is set here for the target adapter's
@@ -150,180 +131,7 @@ async function proxyRequest(path, method, body, requestId, opts) {
150
131
  // stream:true).
151
132
  if (parsed.data.stream === true || route.isStreamPath?.(path) === true) {
152
133
  parsed.data.stream = true;
153
- return proxyChatStream(parsed.data, body, model, requestedModel, config, sourceApi, targetApi, requestId, opts);
154
- }
155
- const targetBody = transformers_1.adapters[targetApi].fromCanonicalRequest(parsed.data);
156
- const targetRoute = transformers_1.PROVIDER_ROUTES[targetApi].find((r) => r.kind === "chat");
157
- const url = `${config.targetBaseUrl}${targetRoute.requestPath(model)}`;
158
- logger_1.logger.info({ requestId, sourceApi, targetApi, url, model, requestedModel }, "Forwarding request to upstream");
159
- const upstream = await (0, http_client_1.postJson)({
160
- url,
161
- headers: buildAuthHeaders(targetApi, config.targetApiKey),
162
- body: targetBody,
163
- timeoutMs: config.upstreamTimeoutMs,
164
- retries: config.upstreamRetries,
165
- requestId,
166
- }).catch((err) => {
167
- throw err instanceof errors_1.UpstreamError
168
- ? toClientFacing(err, targetApi)
169
- : err;
170
- });
171
- if (upstream.status < 200 || upstream.status >= 300) {
172
- const err = new errors_1.UpstreamError(`Upstream ${targetApi} returned ${upstream.status}`, upstream.status, upstream.body);
173
- err.retryAfterSeconds = upstream.retryAfterSeconds;
174
- throw toClientFacing(err, targetApi);
175
- }
176
- // Target → Canonical → Source. §3.5: replies carry the model name the
177
- // client asked for, not the mapped name sent upstream.
178
- const canonicalResp = transformers_1.adapters[targetApi].toCanonicalResponse(upstream.body, model);
179
- canonicalResp.model = requestedModel;
180
- // Adapter bug detection (non-production only): canonical response
181
- // should always be schema-valid
182
- if (process.env.NODE_ENV !== "production") {
183
- const respParsed = canonical_schema_1.canonicalResponseSchema.safeParse(canonicalResp);
184
- if (!respParsed.success) {
185
- logger_1.logger.error({ requestId, issues: respParsed.error.issues }, "Canonical response failed schema validation — adapter bug");
186
- }
187
- }
188
- logger_1.logger.info({
189
- requestId,
190
- sourceApi,
191
- targetApi,
192
- model,
193
- requestedModel,
194
- inputTokens: canonicalResp.usage?.inputTokens,
195
- outputTokens: canonicalResp.usage?.outputTokens,
196
- finishReason: canonicalResp.finishReason,
197
- upstreamDurationMs: upstream.durationMs,
198
- }, "Request completed");
199
- const sourceBody = transformers_1.adapters[sourceApi].fromCanonicalResponse(canonicalResp);
200
- return { body: sourceBody };
201
- }
202
- /**
203
- * Streaming chat flow (spec §3.2): target decoder + source encoder,
204
- * postStream for upstream bytes, runStreamPipeline for the pipe.
205
- * All four adapters stream since v1.1 Task 7 — the v1.0.1
206
- * streaming-refusal path is gone. A Gemini target uses the route's
207
- * streamRequestPath (`:streamGenerateContent?alt=sse`); the others
208
- * stream on the chat path.
209
- */
210
- async function proxyChatStream(canonical, sourceRequest, model, requestedModel, config, sourceApi, targetApi, requestId, opts) {
211
- const decoder = transformers_1.adapters[targetApi].createStreamDecoder(model);
212
- const encoder = transformers_1.adapters[sourceApi].createStreamEncoder({
213
- sourceRequest,
214
- query: opts?.query ?? {},
215
- requestedModel,
216
- });
217
- const targetBody = transformers_1.adapters[targetApi].fromCanonicalRequest(canonical);
218
- const targetRoute = transformers_1.PROVIDER_ROUTES[targetApi].find((r) => r.kind === "chat");
219
- const url = `${config.targetBaseUrl}${targetRoute.streamRequestPath?.(model) ?? targetRoute.requestPath(model)}`;
220
- logger_1.logger.info({ requestId, sourceApi, targetApi, url, model, requestedModel, stream: true }, "Forwarding request to upstream");
221
- // Client disconnect aborts the upstream request; upstreamAc lets the
222
- // pipeline's end (terminal event, failure) tear it down too — a bare
223
- // body cancel doesn't reach the request on every transport.
224
- const upstreamAc = new AbortController();
225
- const upstream = await (0, http_client_1.postStream)({
226
- url,
227
- headers: buildAuthHeaders(targetApi, config.targetApiKey),
228
- body: targetBody,
229
- timeoutMs: config.upstreamTimeoutMs,
230
- retries: config.upstreamRetries,
231
- requestId,
232
- signal: opts?.signal
233
- ? AbortSignal.any([opts.signal, upstreamAc.signal])
234
- : upstreamAc.signal,
235
- }).catch((err) => {
236
- throw err instanceof errors_1.UpstreamError
237
- ? toClientFacing(err, targetApi)
238
- : err;
239
- });
240
- // Watch the event stream for the completion log line — the pipeline
241
- // yields only encoded bytes, so the decoder is wrapped. Chunk contents
242
- // are never logged (§3.2); only usage and the finish reason are.
243
- const observed = [];
244
- const recordingDecoder = {
245
- feed(chunk) {
246
- const events = decoder.feed(chunk);
247
- observed.push(...events);
248
- return events;
249
- },
250
- end() {
251
- const events = decoder.end();
252
- observed.push(...events);
253
- return events;
254
- },
255
- };
256
- const signal = opts?.signal ?? new AbortController().signal;
257
- const chunks = (async function* () {
258
- try {
259
- yield* (0, stream_pipeline_1.runStreamPipeline)({
260
- upstream: upstream.body,
261
- decoder: recordingDecoder,
262
- encoder,
263
- idleMs: config.upstreamStreamIdleMs,
264
- signal,
265
- });
266
- }
267
- finally {
268
- // Whatever ended the pipe — terminal event, in-stream error,
269
- // client disconnect — the upstream request is done with.
270
- upstreamAc.abort();
271
- }
272
- const last = observed.at(-1);
273
- if (last?.type === "message_end") {
274
- const usageEvent = observed.find((e) => e.type === "usage");
275
- logger_1.logger.info({
276
- requestId,
277
- sourceApi,
278
- targetApi,
279
- model,
280
- requestedModel,
281
- stream: true,
282
- inputTokens: usageEvent?.type === "usage"
283
- ? usageEvent.usage.inputTokens
284
- : undefined,
285
- outputTokens: usageEvent?.type === "usage"
286
- ? usageEvent.usage.outputTokens
287
- : undefined,
288
- finishReason: last.finishReason,
289
- upstreamDurationMs: upstream.durationMs,
290
- }, "Request completed");
291
- }
292
- })();
293
- return { stream: { contentType: encoder.contentType, chunks } };
294
- }
295
- /**
296
- * GET /models flow — no request body, response transforms through
297
- * canonical like chat does.
298
- */
299
- async function proxyModelList(route, config, sourceApi, targetApi, requestId) {
300
- const targetRoute = transformers_1.PROVIDER_ROUTES[targetApi].find((r) => r.kind === "models");
301
- const url = `${config.targetBaseUrl}${targetRoute.requestPath()}`;
302
- logger_1.logger.info({ requestId, sourceApi, targetApi, url }, "Forwarding models list request to upstream");
303
- const upstream = await (0, http_client_1.getJson)({
304
- url,
305
- headers: buildAuthHeaders(targetApi, config.targetApiKey),
306
- timeoutMs: config.upstreamTimeoutMs,
307
- retries: config.upstreamRetries,
308
- requestId,
309
- }).catch((err) => {
310
- throw err instanceof errors_1.UpstreamError
311
- ? toClientFacing(err, targetApi)
312
- : err;
313
- });
314
- if (upstream.status < 200 || upstream.status >= 300) {
315
- const err = new errors_1.UpstreamError(`Upstream ${targetApi} returned ${upstream.status}`, upstream.status, upstream.body);
316
- err.retryAfterSeconds = upstream.retryAfterSeconds;
317
- throw toClientFacing(err, targetApi);
318
- }
319
- const canonical = transformers_1.adapters[targetApi].toCanonicalModelList(upstream.body);
320
- if (process.env.NODE_ENV !== "production") {
321
- const parsed = canonical_schema_1.canonicalModelListSchema.safeParse(canonical);
322
- if (!parsed.success) {
323
- logger_1.logger.error({ requestId, issues: parsed.error.issues }, "Canonical model list failed schema validation — adapter bug");
324
- }
134
+ return (0, chat_stream_service_1.proxyChatStream)(input);
325
135
  }
326
- logger_1.logger.info({ requestId, sourceApi, targetApi, modelCount: canonical.models.length }, "Models list completed");
327
- const sourceBody = transformers_1.adapters[sourceApi].fromCanonicalModelList(canonical);
328
- return { body: sourceBody };
136
+ return (0, chat_service_1.proxyChat)(input);
329
137
  }
@@ -181,7 +181,7 @@ function fromCanonicalRequest(request) {
181
181
  }
182
182
  if (calls.length > 0) {
183
183
  parts = parts.filter((p) => !("text" in p && p.text === ""));
184
- parts.push(...calls.map(gemini_tools_1.functionCallPart));
184
+ parts.push(...calls.map((call) => (0, gemini_tools_1.functionCallPart)(call)));
185
185
  }
186
186
  contents.push({ role: "model", parts });
187
187
  }
@@ -326,9 +326,10 @@ function fromCanonicalResponse(response) {
326
326
  ? response.message.content
327
327
  : "";
328
328
  const toolCalls = response.message.toolCalls ?? [];
329
+ const callPart = (call) => (0, gemini_tools_1.functionCallPart)(call, { includeId: true });
329
330
  const parts = toolCalls.length > 0 && content === ""
330
- ? toolCalls.map(gemini_tools_1.functionCallPart)
331
- : [{ text: content }, ...toolCalls.map(gemini_tools_1.functionCallPart)];
331
+ ? toolCalls.map(callPart)
332
+ : [{ text: content }, ...toolCalls.map(callPart)];
332
333
  return {
333
334
  candidates: [
334
335
  {
@@ -340,6 +341,9 @@ function fromCanonicalResponse(response) {
340
341
  index: 0,
341
342
  },
342
343
  ],
344
+ // §3.3: replies carry the requested model name — on the wire that's
345
+ // modelVersion, matching the stream encoder.
346
+ modelVersion: response.model,
343
347
  usageMetadata: response.usage
344
348
  ? {
345
349
  promptTokenCount: response.usage.inputTokens,
@@ -156,14 +156,24 @@ function functionResponseContent(res) {
156
156
  return JSON.stringify(response ?? {});
157
157
  }
158
158
  // ============ ENCODE: Canonical → Gemini ============
159
- /** Canonical tool call → Gemini functionCall part (id dropped on the wire). */
160
- function functionCallPart(call) {
159
+ /**
160
+ * Canonical tool call → Gemini functionCall part. `functionCall.id`
161
+ * is a documented optional field: the request encoder drops it
162
+ * (default — requests stay byte-identical), while the response
163
+ * encoder passes `includeId` so the client can echo
164
+ * `functionResponse.id` back, matching the stream encoder's shape.
165
+ */
166
+ function functionCallPart(call, opts = {}) {
161
167
  const parsed = (0, tool_args_1.parseToolArguments)(call.function.arguments);
162
168
  if (!parsed.valid) {
163
169
  logger_1.logger.warn("Tool call arguments are not valid JSON; sending _invalid_arguments wrapper");
164
170
  }
165
171
  return {
166
- functionCall: { name: call.function.name, args: parsed.args },
172
+ functionCall: {
173
+ ...(opts.includeId === true && { id: call.id }),
174
+ name: call.function.name,
175
+ args: parsed.args,
176
+ },
167
177
  };
168
178
  }
169
179
  /** Canonical tool message → Gemini functionResponse part. */
@@ -122,8 +122,7 @@ exports.PROVIDER_ROUTES = {
122
122
  };
123
123
  /**
124
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.
125
+ * (method + path prefix). Used by proxy.service.
127
126
  */
128
127
  function findSourceRoute(source, method, path) {
129
128
  return exports.PROVIDER_ROUTES[source].find((r) => r.method === method && path.startsWith(r.pathPrefix));
@@ -73,9 +73,12 @@ function toCanonicalRequest(request) {
73
73
  ...(opts.seed !== undefined && { seed: opts.seed }),
74
74
  };
75
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) {
76
+ // Ollama's /api/chat streams NDJSON unless the client sends
77
+ // stream:false (api.md: "stream ... if false the response will be
78
+ // returned as a single response object"; default true) — so an
79
+ // omitted flag maps to canonical stream:true and only an explicit
80
+ // false opts out.
81
+ if (request.stream !== false) {
79
82
  result.stream = true;
80
83
  }
81
84
  if (request.tools && request.tools.length > 0) {
@@ -6,6 +6,12 @@ class ProxyError extends Error {
6
6
  statusCode;
7
7
  code;
8
8
  context;
9
+ /**
10
+ * Extra response headers the error-handler sets before rendering —
11
+ * e.g. `x-babelgate-attempts` on errors after upstream attempts
12
+ * (spec §3.4).
13
+ */
14
+ responseHeaders;
9
15
  constructor(message, statusCode = 500, code = "PROXY_ERROR", context) {
10
16
  super(message);
11
17
  this.statusCode = statusCode;
@@ -1,15 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.globMatch = void 0;
3
4
  exports.parseModelMap = parseModelMap;
4
5
  exports.applyModelMap = applyModelMap;
5
6
  const errors_1 = require("./errors");
6
- /** `*` matches any run of characters; every other character is literal. */
7
- function globToRegExp(pattern) {
8
- const escaped = pattern
9
- .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
10
- .replace(/\*/g, ".*");
11
- return new RegExp(`^${escaped}$`);
12
- }
7
+ const rules_1 = require("../routing/rules");
8
+ // The glob matcher moved to routing/rules.ts with the routing layer;
9
+ // re-exported so existing model-map imports keep working.
10
+ var rules_2 = require("../routing/rules");
11
+ Object.defineProperty(exports, "globMatch", { enumerable: true, get: function () { return rules_2.globMatch; } });
13
12
  /**
14
13
  * Comma-separated `glob=model` entries → ordered rules. An entry that
15
14
  * isn't `glob=model` (empty pattern or model, no `=`) is a configuration
@@ -32,7 +31,7 @@ function parseModelMap(value) {
32
31
  /** First matching rule wins; no match leaves the model unchanged. */
33
32
  function applyModelMap(model, rules) {
34
33
  for (const rule of rules) {
35
- if (globToRegExp(rule.pattern).test(model))
34
+ if ((0, rules_1.globMatch)(rule.pattern, model))
36
35
  return rule.model;
37
36
  }
38
37
  return model;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelgate",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "bin": {
5
5
  "babelgate": "dist/cli.js"
6
6
  },
@@ -47,6 +47,7 @@
47
47
  "express": "^5.1.0",
48
48
  "pino": "^10.4.0",
49
49
  "pino-http": "^11.0.0",
50
+ "yaml": "^2.9.1",
50
51
  "zod": "^4.6.5"
51
52
  },
52
53
  "engines": {