babelgate 1.1.2 → 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.
package/README.md CHANGED
@@ -13,9 +13,8 @@ Supported providers right now:
13
13
  - Ollama
14
14
  - Anthropic (Claude)
15
15
 
16
- <!-- OWNER: add docs/assets/demo.gif (from the OpenRouter gate run) before merge.
17
- ![babelgate demo — Claude Code editing through the proxy](docs/assets/demo.gif)
18
- -->
16
+ ![Claude Code fixing a failing test through babelgate on a free OpenRouter model](docs/assets/demo.gif)
17
+ *Recorded with [scripts/demo](scripts/demo/); the model's thinking time is cut.*
19
18
 
20
19
  ## Quick start: Claude Code on a local model
21
20
 
@@ -90,6 +89,89 @@ with `MODEL_MAP`). Recipe: [docs/recipes/openai-sdk.md](docs/recipes/openai-sdk.
90
89
  needs serious RAM or a GPU. Smaller `qwen2.5-coder` sizes run on CPU but
91
90
  are slow and call tools less reliably.
92
91
 
92
+ ## One endpoint, many providers
93
+
94
+ Everything above is one target behind one port. With a `babelgate.yaml`
95
+ the same port serves **every client format at once** — OpenAI, Anthropic,
96
+ Gemini and Ollama SDKs all work against it — and routes each request to
97
+ one of several providers by model name, with aliases, a long-context
98
+ condition, and fallbacks before the first byte:
99
+
100
+ The npm package doesn't ship `examples/` — fetch the file first (saving
101
+ it as `babelgate.yaml` also works, since `./babelgate.yaml` is
102
+ auto-discovered):
103
+
104
+ ```bash
105
+ curl -fsSLo claude-code-hybrid.yaml \
106
+ https://raw.githubusercontent.com/dorrabbkhan/babelgate/main/examples/claude-code-hybrid.yaml
107
+ OPENROUTER_API_KEY=<your key> \
108
+ npx babelgate --config claude-code-hybrid.yaml --preset claude-code
109
+ ```
110
+
111
+ [examples/claude-code-hybrid.yaml](examples/claude-code-hybrid.yaml)
112
+ keeps Claude Code's cheap background calls on a small local model, runs
113
+ routine turns on local `qwen3-coder:30b`, sends ≥ 60 000-token contexts
114
+ to OpenRouter, and falls back to OpenRouter when Ollama is down:
115
+
116
+ ```yaml
117
+ routes:
118
+ - match: claude-*haiku*
119
+ to: ollama/qwen2.5-coder:7b
120
+ - match: claude-*
121
+ when:
122
+ min_input_tokens: 60000
123
+ to: openrouter/qwen/qwen3-coder
124
+ - match: claude-*
125
+ to: ollama/qwen3-coder:30b
126
+ fallback:
127
+ - openrouter/qwen/qwen3-coder
128
+ ```
129
+
130
+ Model resolution, in order: an explicit `<provider>/<model>` in the
131
+ request, then aliases, then routes in file order, then the `default`
132
+ provider with the model unchanged. Replies always keep the name the
133
+ client asked for. Walkthrough:
134
+ [docs/recipes/claude-code-hybrid.md](docs/recipes/claude-code-hybrid.md);
135
+ a second example with three providers and aliases lives at
136
+ [examples/multi-provider.yaml](examples/multi-provider.yaml) — fetch it
137
+ the same way.
138
+
139
+ Started without `--preset`, babelgate prints a client-facing endpoint
140
+ per dialect — each is the **base URL the SDK expects**, which differs
141
+ from the raw request paths:
142
+
143
+ ```
144
+ OpenAI http://localhost:3000/v1
145
+ Anthropic http://localhost:3000
146
+ Gemini http://localhost:3000
147
+ Ollama http://localhost:3000
148
+ ```
149
+
150
+ ```python
151
+ # OpenAI SDK (Python) — base_url includes /v1
152
+ OpenAI(base_url="http://localhost:3000/v1", api_key="babelgate")
153
+
154
+ # google-genai (Python) — http_options.base_url excludes it; the SDK
155
+ # appends /v1beta itself (a /v1beta base URL double-appends and 404s)
156
+ genai.Client(api_key="babelgate",
157
+ http_options={"base_url": "http://localhost:3000"})
158
+ ```
159
+
160
+ ```ts
161
+ // @google/genai (JS) — same, via httpOptions.baseUrl
162
+ new GoogleGenAI({ apiKey: "babelgate",
163
+ httpOptions: { baseUrl: "http://localhost:3000" } })
164
+ ```
165
+
166
+ Raw-HTTP callers use the real paths instead: `/v1/chat/completions`,
167
+ `/v1/messages`, `/v1beta/models/<model>:generateContent`, `/api/chat`.
168
+ Every proxied reply carries `x-babelgate-provider`, `x-babelgate-model`
169
+ and `x-babelgate-attempts`, and `GET …/models` returns one merged list
170
+ across all providers. Any OpenAI-compatible backend (Groq, DeepSeek,
171
+ Together, LM Studio, vLLM, llama.cpp…) is a `type: openai` provider —
172
+ base URLs and key handling are in
173
+ [docs/recipes/openai-compatible-providers.md](docs/recipes/openai-compatible-providers.md).
174
+
93
175
  ## The `babelgate` CLI
94
176
 
95
177
  `npx babelgate` parses flags with `node:util`, sets environment defaults
@@ -100,18 +182,21 @@ paste. Upstream API keys come only from the environment — never from flags.
100
182
 
101
183
  ```
102
184
  Usage: babelgate [--preset <claude-code|openai-apps>] [--target <provider>]
103
- [--model <name>] [--base-url <url>] [--port <n>] [--help]
185
+ [--model <name>] [--base-url <url>] [--port <n>]
186
+ [--config <path>] [--help]
104
187
 
105
- Upstream API keys come only from the environment
188
+ Config file discovery: --config, then BABELGATE_CONFIG,
189
+ then ./babelgate.yaml. Upstream API keys come only from the environment
106
190
  (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, OLLAMA_API_KEY).
107
191
  ```
108
192
 
109
193
  | Flag | Effect |
110
194
  | ------------- | ----------------------------------------------------------------------------- |
111
- | `--preset` | `claude-code` (source `anthropic`) or `openai-apps` (source `openai`) |
112
- | `--target` | `ollama`, `openai`, `anthropic` or `gemini` — sets `TARGET_API` |
195
+ | `--preset` | `claude-code` or `openai-apps` — picks which `export` lines to print; with no config file it also sets the v1.1 env routing |
196
+ | `--target` | `ollama`, `openai`, `anthropic` or `gemini` — sets `TARGET_API` (env-only mode) |
113
197
  | `--model` | `claude-code` preset only: `MODEL_MAP=claude-*=<model>` |
114
198
  | `--base-url` | Sets `{TARGET}_API_BASE_URL` (e.g. `https://openrouter.ai/api` for OpenRouter) |
199
+ | `--config` | Path to a `babelgate.yaml` — `--target`, `--model` and `--base-url` are then defined by the file |
115
200
  | `--port` | Listen port (default `3000`) |
116
201
  | `--help` | Usage |
117
202
 
@@ -142,12 +227,14 @@ pipeline doesn't care.
142
227
  The actual request flow:
143
228
 
144
229
  1. Request hits the Express app.
145
- 2. If the path matches the configured source provider's API prefix
146
- (e.g. `/v1/chat/completions` for OpenAI), the proxy takes it.
147
- Otherwise it falls through to a 404.
148
- 3. The body is converted to canonical, then from canonical to the target
149
- format.
150
- 4. It's forwarded upstream to `{TARGET}_API_BASE_URL` + the target's
230
+ 2. The request path decides the client's format (`/v1/chat/completions`
231
+ is OpenAI, `/v1/messages` Anthropic, `/v1beta/models` Gemini,
232
+ `/api/chat` Ollama); the route table picks it up. Unknown paths fall
233
+ through to a 404.
234
+ 3. The body is converted to canonical, routed to a provider (config
235
+ file, or the single `TARGET_API` in env-only mode), then encoded in
236
+ the target's format.
237
+ 4. It's forwarded upstream to the provider's `base_url` + the target's
151
238
  endpoint, with the right auth headers.
152
239
  5. The response comes back through the same path in reverse.
153
240
 
@@ -182,23 +269,27 @@ Every completed chat request logs token usage (`inputTokens`,
182
269
 
183
270
  ## Configuration
184
271
 
185
- Everything is environment variables. One instance serves one source→target
186
- pair; run multiple instances for more.
272
+ Two modes. A **`babelgate.yaml`** (found via `--config`, `BABELGATE_CONFIG`
273
+ or `./babelgate.yaml`) defines ≥ 1 providers, aliases and ordered routes —
274
+ see the section above and [examples/](examples/). With no file,
275
+ **env-only mode** is exactly the v1.1 setup: one provider named after
276
+ `TARGET_API`, `MODEL_MAP` entries become its routes.
187
277
 
188
278
  | Variable | What it's for |
189
279
  | ------------------------- | -------------------------------------------------------------------------------------------- |
190
- | `SOURCE_API` | Format clients send to you — `openai`, `gemini`, `ollama`, or `anthropic` |
191
- | `TARGET_API` | Format to translate to upstream — same options |
280
+ | `BABELGATE_CONFIG` | Path to a `babelgate.yaml` (same as `--config`) |
281
+ | `SOURCE_API` | **Deprecated** — ignored in 1.2 (the request path decides the client format); removed in 2.0 |
282
+ | `TARGET_API` | Format to translate to upstream — same options (env-only mode; ignored when a YAML file is loaded) |
192
283
  | `PORT` | Listen port (default `3000`) |
193
- | `{PROVIDER}_API_BASE_URL` | Upstream base URL for the target provider, e.g. `OLLAMA_API_BASE_URL=http://localhost:11434` |
194
- | `{PROVIDER}_API_KEY` | API key for the target provider (Ollama doesn't need one) |
284
+ | `{PROVIDER}_API_BASE_URL` | Upstream base URL for the target provider, e.g. `OLLAMA_API_BASE_URL=http://localhost:11434` (env-only; YAML providers use `base_url`) |
285
+ | `{PROVIDER}_API_KEY` | API key for the target provider (Ollama doesn't need one). With YAML, keys are whichever env vars `api_key_env` names |
195
286
  | `UPSTREAM_TIMEOUT_MS` | Per-request upstream timeout (default `30000`) |
196
287
  | `UPSTREAM_RETRIES` | Retries for transport failures / 429 / 503 (default `1`) |
197
288
  | `UPSTREAM_STREAM_IDLE_MS` | Longest gap allowed between chunks mid-stream (default `300000`) |
198
289
  | `MAX_BODY_SIZE` | JSON body limit (default `10mb` — base64 images need it) |
199
290
  | `PROXY_API_KEY` | Optional client auth — when set, callers send the key in their provider's native auth header (see Authentication) |
200
- | `MODEL_MAP` | Ordered `glob=model` rules, comma-separated — e.g. `claude-*haiku*=qwen2.5-coder:7b,claude-*=qwen3-coder:30b`. First match wins; replies still carry the name the client asked for |
201
- | `TARGET_CONTEXT_WINDOW` | Minimum context window the target should allocate (Ollama `options.num_ctx`) |
291
+ | `MODEL_MAP` | Ordered `glob=model` rules, comma-separated — e.g. `claude-*haiku*=qwen2.5-coder:7b,claude-*=qwen3-coder:30b`. First match wins; replies still carry the name the client asked for (env-only; YAML has `routes`) |
292
+ | `TARGET_CONTEXT_WINDOW` | Minimum context window the target should allocate (Ollama `options.num_ctx`; env-only — YAML has per-provider `context_window`) |
202
293
 
203
294
  Routing and upstream endpoints are conventions, not configuration: each
204
295
  provider's routes (`kind`, `method`, `pathPrefix`, `requestPath`) live in
@@ -270,10 +361,9 @@ npm ci
270
361
  cp .env.example .env
271
362
  ```
272
363
 
273
- For a local Ollama target, `.env` needs three lines:
364
+ For a local Ollama target, `.env` needs two lines:
274
365
 
275
366
  ```bash
276
- SOURCE_API=openai
277
367
  TARGET_API=ollama
278
368
  OLLAMA_API_BASE_URL=http://localhost:11434
279
369
  ```
@@ -426,8 +516,9 @@ fixtures/tests — see
426
516
  - Some provider-specific features (citations, extra usage fields) map to
427
517
  canonical best-effort — the format only keeps what's expressible
428
518
  across providers.
429
- - One source→target pair per instance. That's a deliberate simplification,
430
- not an oversight.
519
+ - **No config hot reload and no overall request deadline** — a
520
+ `babelgate.yaml` change needs a restart, and worst-case latency is the
521
+ sum of per-attempt timeouts across a fallback chain.
431
522
 
432
523
  ## Roadmap
433
524
 
package/dist/app.js CHANGED
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.app = void 0;
7
7
  const express_1 = __importDefault(require("express"));
8
8
  const request_logger_1 = require("./middleware/request-logger");
9
- const resolve_source_1 = require("./middleware/resolve-source");
9
+ const detect_source_1 = require("./middleware/detect-source");
10
10
  const error_handler_1 = require("./middleware/error-handler");
11
11
  const require_api_key_1 = require("./middleware/require-api-key");
12
12
  const proxy_1 = require("./routes/proxy");
@@ -14,10 +14,10 @@ const app = (0, express_1.default)();
14
14
  exports.app = app;
15
15
  // 1. Request logger - logs every incoming request
16
16
  app.use(request_logger_1.requestLogger);
17
- // 1a. Source resolver - records res.locals.sourceApi on provider paths so
18
- // the error handler renders errors (auth, body-parser, upstream) in the
19
- // client's own dialect.
20
- app.use((0, resolve_source_1.resolveSource)());
17
+ // 1a. Source detection - records the request's own format on
18
+ // res.locals.sourceApi so the error handler renders errors (auth,
19
+ // body-parser, upstream) in the client's dialect (spec §3.2).
20
+ app.use((0, detect_source_1.detectSourceMiddleware)());
21
21
  // 2. Body parser - parses application/json. LLM payloads (base64
22
22
  // images, long context) routinely exceed the 100kb default.
23
23
  app.use(express_1.default.json({ limit: process.env.MAX_BODY_SIZE || "10mb" }));
package/dist/cli.js CHANGED
@@ -5,9 +5,12 @@ exports.parseCliArgs = parseCliArgs;
5
5
  exports.presetEnv = presetEnv;
6
6
  exports.overriddenKeys = overriddenKeys;
7
7
  exports.clientInstructions = clientInstructions;
8
+ exports.endpointLines = endpointLines;
8
9
  exports.main = main;
9
10
  const node_util_1 = require("node:util");
10
11
  const env_config_1 = require("./config/env-config");
12
+ const yaml_config_1 = require("./config/yaml-config");
13
+ const table_1 = require("./routing/table");
11
14
  const index_1 = require("./index");
12
15
  const PRESETS = ["claude-code", "openai-apps"];
13
16
  const PROVIDERS = ["openai", "gemini", "ollama", "anthropic"];
@@ -24,6 +27,7 @@ function parseCliArgs(argv) {
24
27
  target: { type: "string" },
25
28
  model: { type: "string" },
26
29
  "base-url": { type: "string" },
30
+ config: { type: "string" },
27
31
  port: { type: "string" },
28
32
  help: { type: "boolean" },
29
33
  },
@@ -45,6 +49,14 @@ function parseCliArgs(argv) {
45
49
  args.model = values.model;
46
50
  if (values["base-url"] !== undefined)
47
51
  args.baseUrl = values["base-url"];
52
+ if (values.config !== undefined)
53
+ args.config = values.config;
54
+ if (args.config !== undefined &&
55
+ (args.target !== undefined ||
56
+ args.model !== undefined ||
57
+ args.baseUrl !== undefined)) {
58
+ throw new Error("--target, --model and --base-url are defined by the config file");
59
+ }
48
60
  if (values.port !== undefined) {
49
61
  const port = Number(values.port);
50
62
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
@@ -73,32 +85,31 @@ function effectiveTarget(args, env) {
73
85
  * applied. `env` is still consulted to resolve the effective target for
74
86
  * --base-url and the ollama timeout.
75
87
  */
76
- function wantedEnv(args, env) {
88
+ function wantedEnv(args, env, configMode = false) {
77
89
  const wanted = {};
78
- if (args.preset === "claude-code") {
79
- wanted.SOURCE_API = "anthropic";
80
- }
81
- else if (args.preset === "openai-apps") {
82
- wanted.SOURCE_API = "openai";
83
- }
84
- if (args.target !== undefined) {
85
- wanted.TARGET_API = args.target;
86
- }
87
- if (args.preset === "claude-code") {
88
- if (args.model !== undefined) {
89
- wanted.MODEL_MAP = `claude-*=${args.model}`;
90
+ // With a config file loaded, routing is defined by the file (spec §3.7):
91
+ // no flag or preset may set TARGET_API, MODEL_MAP, *_API_BASE_URL or
92
+ // TARGET_CONTEXT_WINDOW — only PORT still applies (§3.5).
93
+ if (!configMode) {
94
+ if (args.target !== undefined) {
95
+ wanted.TARGET_API = args.target;
90
96
  }
91
- wanted.TARGET_CONTEXT_WINDOW = "32768";
92
- if (effectiveTarget(args, env) === "ollama") {
93
- wanted.UPSTREAM_TIMEOUT_MS = "600000";
97
+ if (args.preset === "claude-code") {
98
+ if (args.model !== undefined) {
99
+ wanted.MODEL_MAP = `claude-*=${args.model}`;
100
+ }
101
+ wanted.TARGET_CONTEXT_WINDOW = "32768";
102
+ if (effectiveTarget(args, env) === "ollama") {
103
+ wanted.UPSTREAM_TIMEOUT_MS = "600000";
104
+ }
94
105
  }
95
- }
96
- if (args.baseUrl !== undefined) {
97
- const target = effectiveTarget(args, env);
98
- if (target === undefined) {
99
- throw new Error("--base-url requires --target or TARGET_API in the environment");
106
+ if (args.baseUrl !== undefined) {
107
+ const target = effectiveTarget(args, env);
108
+ if (target === undefined) {
109
+ throw new Error("--base-url requires --target or TARGET_API in the environment");
110
+ }
111
+ wanted[`${target.toUpperCase()}_API_BASE_URL`] = args.baseUrl;
100
112
  }
101
- wanted[`${target.toUpperCase()}_API_BASE_URL`] = args.baseUrl;
102
113
  }
103
114
  if (args.port !== undefined) {
104
115
  wanted.PORT = String(args.port);
@@ -109,18 +120,20 @@ function wantedEnv(args, env) {
109
120
  * Environment values implied by the flags. Keys the user's own env already
110
121
  * sets (a non-empty value) are not returned — user env always wins and
111
122
  * presets are only ever env values, never changes to config defaults.
123
+ * `configMode` (a config file was resolved) suppresses every routing env
124
+ * var; only `--port` still maps to PORT.
112
125
  */
113
- function presetEnv(args, env) {
114
- return Object.fromEntries(Object.entries(wantedEnv(args, env)).filter(([key]) => !env[key]));
126
+ function presetEnv(args, env, configMode = false) {
127
+ return Object.fromEntries(Object.entries(wantedEnv(args, env, configMode)).filter(([key]) => !env[key]));
115
128
  }
116
129
  /**
117
130
  * Names of the preset/flag env keys that lost to values already set in
118
131
  * `env` — everything the flags/preset wanted that presetEnv(args, env)
119
132
  * dropped. Names only, never values.
120
133
  */
121
- function overriddenKeys(args, env) {
122
- const applied = presetEnv(args, env);
123
- return Object.keys(wantedEnv(args, env)).filter((key) => !(key in applied));
134
+ function overriddenKeys(args, env, configMode = false) {
135
+ const applied = presetEnv(args, env, configMode);
136
+ return Object.keys(wantedEnv(args, env, configMode)).filter((key) => !(key in applied));
124
137
  }
125
138
  /**
126
139
  * The `export …` lines the user pastes into their shell to point the client
@@ -143,23 +156,29 @@ function clientInstructions(args, port, proxyApiKey) {
143
156
  }
144
157
  return [];
145
158
  }
159
+ /**
160
+ * The endpoints block printed at startup when no preset is given
161
+ * (spec §3.7): one URL per client dialect, column-aligned.
162
+ */
163
+ function endpointLines(port) {
164
+ return [
165
+ `OpenAI http://localhost:${port}/v1`,
166
+ `Anthropic http://localhost:${port}`,
167
+ `Gemini http://localhost:${port}`,
168
+ `Ollama http://localhost:${port}`,
169
+ ];
170
+ }
146
171
  function usageLines() {
147
172
  return [
148
173
  "Usage: babelgate [--preset <claude-code|openai-apps>] [--target <provider>]",
149
- " [--model <name>] [--base-url <url>] [--port <n>] [--help]",
174
+ " [--model <name>] [--base-url <url>] [--port <n>]",
175
+ " [--config <path>] [--help]",
150
176
  "",
151
- "Upstream API keys come only from the environment",
177
+ "Config file discovery: --config, then BABELGATE_CONFIG,",
178
+ "then ./babelgate.yaml. Upstream API keys come only from the environment",
152
179
  "(OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, OLLAMA_API_KEY).",
153
180
  ];
154
181
  }
155
- /** The preset whose export lines match a resolved source API. */
156
- function presetForSource(sourceApi) {
157
- if (sourceApi === "anthropic")
158
- return "claude-code";
159
- if (sourceApi === "openai")
160
- return "openai-apps";
161
- return undefined;
162
- }
163
182
  async function main(argv) {
164
183
  try {
165
184
  const args = parseCliArgs(argv);
@@ -168,8 +187,20 @@ async function main(argv) {
168
187
  console.log(line);
169
188
  return;
170
189
  }
171
- const envValues = presetEnv(args, process.env);
172
- const overridden = overriddenKeys(args, process.env);
190
+ // A config file resolves via --config, BABELGATE_CONFIG or
191
+ // ./babelgate.yaml (spec §3.5); with one loaded, the routing flags
192
+ // are errors (spec §3.7) — the parseCliArgs check only covers an
193
+ // explicit --config.
194
+ const configPath = (0, yaml_config_1.resolveConfigPath)(args.config, process.env, process.cwd());
195
+ const configMode = configPath !== undefined;
196
+ if (configMode &&
197
+ (args.target !== undefined ||
198
+ args.model !== undefined ||
199
+ args.baseUrl !== undefined)) {
200
+ throw new Error("--target, --model and --base-url are defined by the config file");
201
+ }
202
+ const envValues = presetEnv(args, process.env, configMode);
203
+ const overridden = overriddenKeys(args, process.env, configMode);
173
204
  for (const [key, value] of Object.entries(envValues)) {
174
205
  process.env[key] = value;
175
206
  }
@@ -177,21 +208,31 @@ async function main(argv) {
177
208
  console.error(`babelgate: your environment overrides ${overridden.join(", ")} ` +
178
209
  "(flags and presets never replace values you set)");
179
210
  }
180
- if (args.preset === "claude-code" &&
211
+ if (!configMode &&
212
+ args.preset === "claude-code" &&
181
213
  args.model === undefined &&
182
214
  !process.env.MODEL_MAP) {
183
215
  console.error("babelgate: no --model given; claude-* model names are forwarded " +
184
216
  "to the target unchanged");
185
217
  }
218
+ (0, env_config_1.setConfigPath)(args.config);
186
219
  await (0, index_1.startServer)();
187
220
  const config = (0, env_config_1.getConfig)();
188
221
  if (args.preset !== undefined) {
189
- // The export lines must match the server that actually started: the
190
- // user's env may have overridden the preset's SOURCE_API.
191
- for (const line of clientInstructions({ ...args, preset: presetForSource(config.sourceApi) }, config.port, config.proxyApiKey)) {
222
+ // The export lines come from the requested preset — there is no
223
+ // configured source format; the request path decides the dialect.
224
+ for (const line of clientInstructions(args, config.port, config.proxyApiKey)) {
192
225
  console.log(line);
193
226
  }
194
227
  }
228
+ else {
229
+ for (const line of endpointLines(config.port)) {
230
+ console.log(line);
231
+ }
232
+ }
233
+ for (const line of (0, table_1.formatRoutingTable)(config)) {
234
+ console.log(line);
235
+ }
195
236
  }
196
237
  catch (err) {
197
238
  console.error(err instanceof Error ? err.message : String(err));
@@ -4,11 +4,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.loadConfig = loadConfig;
7
+ exports.setConfigPath = setConfigPath;
7
8
  exports.getConfig = getConfig;
8
9
  exports.resetConfig = resetConfig;
9
10
  const dotenv_1 = __importDefault(require("dotenv"));
10
11
  const model_map_1 = require("../utils/model-map");
12
+ const registry_1 = require("../routing/registry");
13
+ const yaml_config_1 = require("./yaml-config");
11
14
  const errors_1 = require("../utils/errors");
15
+ const logger_1 = require("../utils/logger");
12
16
  dotenv_1.default.config({ quiet: true });
13
17
  const VALID_PROVIDERS = ["openai", "gemini", "ollama", "anthropic"];
14
18
  function isValidProvider(value) {
@@ -21,14 +25,10 @@ function normalizeProvider(value) {
21
25
  * Loads configuration from environment variables
22
26
  */
23
27
  function loadConfig() {
24
- if (!process.env.SOURCE_API || !process.env.TARGET_API) {
25
- throw new Error("SOURCE_API and TARGET_API environment variables are required");
28
+ if (!process.env.TARGET_API) {
29
+ throw new Error("TARGET_API environment variable is required");
26
30
  }
27
- const sourceApi = process.env.SOURCE_API;
28
31
  const targetApi = process.env.TARGET_API;
29
- if (!isValidProvider(sourceApi)) {
30
- throw new Error(`SOURCE_API must be one of: ${VALID_PROVIDERS.join(", ")}`);
31
- }
32
32
  if (!isValidProvider(targetApi)) {
33
33
  throw new Error(`TARGET_API must be one of: ${VALID_PROVIDERS.join(", ")}`);
34
34
  }
@@ -51,8 +51,32 @@ function loadConfig() {
51
51
  if (!targetBaseUrl) {
52
52
  throw new Error(`${baseUrlEnvVar} environment variable is required for target API ${targetApi}`);
53
53
  }
54
+ const modelMap = (0, model_map_1.parseModelMap)(process.env.MODEL_MAP);
55
+ const targetContextWindow = parseTargetContextWindow();
56
+ // Env-only mode (spec §3.5): one provider named after TARGET_API,
57
+ // itself the default; MODEL_MAP entries become ordered routes to it.
58
+ const registry = (0, registry_1.createRegistry)([
59
+ {
60
+ name: normalizedTarget,
61
+ type: normalizedTarget,
62
+ baseUrl: targetBaseUrl.replace(/\/$/, ""),
63
+ apiKey: targetApiKey,
64
+ apiKeyEnv: targetApiKey !== undefined
65
+ ? apiKeyEnvVars[normalizedTarget]
66
+ : undefined,
67
+ contextWindow: targetContextWindow,
68
+ },
69
+ ]);
70
+ const routing = {
71
+ defaultProvider: normalizedTarget,
72
+ aliases: {},
73
+ rules: modelMap.map((rule) => ({
74
+ match: rule.pattern,
75
+ to: { provider: normalizedTarget, model: rule.model },
76
+ fallback: [],
77
+ })),
78
+ };
54
79
  return {
55
- sourceApi: normalizeProvider(sourceApi),
56
80
  targetApi: normalizedTarget,
57
81
  port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
58
82
  targetApiKey,
@@ -67,8 +91,11 @@ function loadConfig() {
67
91
  ? parseInt(process.env.UPSTREAM_STREAM_IDLE_MS, 10)
68
92
  : 300_000,
69
93
  proxyApiKey: process.env.PROXY_API_KEY || undefined,
70
- modelMap: (0, model_map_1.parseModelMap)(process.env.MODEL_MAP),
71
- targetContextWindow: parseTargetContextWindow(),
94
+ modelMap,
95
+ targetContextWindow,
96
+ registry,
97
+ routing,
98
+ deprecatedEnv: process.env.SOURCE_API !== undefined ? ["SOURCE_API"] : [],
72
99
  };
73
100
  }
74
101
  function parseTargetContextWindow() {
@@ -85,9 +112,32 @@ function parseTargetContextWindow() {
85
112
  * Get the current configuration (lazy-loaded)
86
113
  */
87
114
  let _config = null;
115
+ let _configPath;
116
+ /**
117
+ * Explicit config-file path for getConfig()'s discovery — set by the
118
+ * CLI's --config before getConfig() runs (spec §3.5). Cleared by
119
+ * resetConfig().
120
+ */
121
+ function setConfigPath(path) {
122
+ _configPath = path;
123
+ }
88
124
  function getConfig() {
89
125
  if (!_config) {
90
- _config = loadConfig();
126
+ const yamlPath = (0, yaml_config_1.resolveConfigPath)(_configPath, process.env, process.cwd());
127
+ if (yamlPath !== undefined) {
128
+ _config = (0, yaml_config_1.loadYamlConfig)(yamlPath, process.env);
129
+ const ignored = (0, yaml_config_1.ignoredEnvKeys)(process.env);
130
+ if (ignored.length > 0) {
131
+ logger_1.logger.warn(`Ignoring env vars set but defined by the config file: ${ignored.join(", ")}`);
132
+ }
133
+ }
134
+ else {
135
+ const envConfig = loadConfig();
136
+ if (envConfig.deprecatedEnv.length > 0) {
137
+ logger_1.logger.warn("SOURCE_API is deprecated and ignored: the request path decides the client format. It will be removed in 2.0.");
138
+ }
139
+ _config = envConfig;
140
+ }
91
141
  }
92
142
  return _config;
93
143
  }
@@ -96,4 +146,5 @@ function getConfig() {
96
146
  */
97
147
  function resetConfig() {
98
148
  _config = null;
149
+ _configPath = undefined;
99
150
  }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.gatewayFileSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ const PROVIDER_NAME = /^[a-z0-9][a-z0-9_-]*$/;
6
+ const providerEntrySchema = zod_1.z
7
+ .strictObject({
8
+ type: zod_1.z.enum(["openai", "anthropic", "gemini", "ollama"]),
9
+ base_url: zod_1.z.string().min(1),
10
+ api_key_env: zod_1.z.string().optional(),
11
+ context_window: zod_1.z.number().int().positive().optional(),
12
+ timeout_ms: zod_1.z.number().int().positive().optional(),
13
+ stream_idle_ms: zod_1.z.number().int().positive().optional(),
14
+ })
15
+ .superRefine((entry, ctx) => {
16
+ if (entry.type !== "ollama" && entry.api_key_env === undefined) {
17
+ ctx.addIssue({
18
+ code: "custom",
19
+ path: ["api_key_env"],
20
+ message: 'required unless type is "ollama"',
21
+ });
22
+ }
23
+ });
24
+ /**
25
+ * babelgate.yaml schema (spec §3.5): strict objects at every level, so
26
+ * unknown keys are startup errors. Semantic checks (default is a
27
+ * provider, targets resolve, key env vars are set) happen in
28
+ * loadYamlConfig — they need the registry and the environment.
29
+ */
30
+ exports.gatewayFileSchema = zod_1.z.strictObject({
31
+ default: zod_1.z.string(),
32
+ providers: zod_1.z
33
+ .record(zod_1.z.string().regex(PROVIDER_NAME), providerEntrySchema)
34
+ .refine((map) => Object.keys(map).length >= 1, {
35
+ message: "at least one provider is required",
36
+ }),
37
+ aliases: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
38
+ routes: zod_1.z
39
+ .array(zod_1.z.strictObject({
40
+ match: zod_1.z.string().min(1),
41
+ when: zod_1.z
42
+ .strictObject({
43
+ min_input_tokens: zod_1.z.number().int().positive().optional(),
44
+ })
45
+ .optional(),
46
+ to: zod_1.z.string(),
47
+ fallback: zod_1.z.array(zod_1.z.string()).optional(),
48
+ }))
49
+ .optional(),
50
+ });