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 +116 -25
- package/dist/app.js +5 -5
- package/dist/cli.js +84 -43
- package/dist/config/env-config.js +61 -10
- package/dist/config/gateway.schema.js +50 -0
- package/dist/config/yaml-config.js +164 -0
- package/dist/index.js +6 -4
- package/dist/middleware/detect-source.js +20 -0
- package/dist/middleware/error-handler.js +8 -0
- package/dist/routes/proxy.js +3 -0
- package/dist/routing/detect-source.js +33 -0
- package/dist/routing/fallback.js +37 -0
- package/dist/routing/plan-route.js +47 -0
- package/dist/routing/registry.js +47 -0
- package/dist/routing/route-log.js +8 -0
- package/dist/routing/rules.js +35 -0
- package/dist/routing/table.js +32 -0
- package/dist/services/attempt-targets.js +91 -0
- package/dist/services/chat-stream.service.js +131 -0
- package/dist/services/chat.service.js +91 -0
- package/dist/services/models.service.js +160 -0
- package/dist/services/proxy.service.js +39 -231
- package/dist/transformers/adapters/gemini.adapter.js +7 -3
- package/dist/transformers/adapters/gemini.tools.js +13 -3
- package/dist/transformers/adapters/index.js +1 -2
- package/dist/utils/errors.js +6 -0
- package/dist/utils/model-map.js +7 -8
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -13,9 +13,8 @@ Supported providers right now:
|
|
|
13
13
|
- Ollama
|
|
14
14
|
- Anthropic (Claude)
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-->
|
|
16
|
+

|
|
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>]
|
|
185
|
+
[--model <name>] [--base-url <url>] [--port <n>]
|
|
186
|
+
[--config <path>] [--help]
|
|
104
187
|
|
|
105
|
-
|
|
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`
|
|
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.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
| `
|
|
191
|
-
| `
|
|
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
|
|
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
|
-
-
|
|
430
|
-
|
|
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
|
|
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
|
|
18
|
-
// the error handler renders errors (auth,
|
|
19
|
-
// client's
|
|
20
|
-
app.use((0,
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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>]
|
|
174
|
+
" [--model <name>] [--base-url <url>] [--port <n>]",
|
|
175
|
+
" [--config <path>] [--help]",
|
|
150
176
|
"",
|
|
151
|
-
"
|
|
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
|
-
|
|
172
|
-
|
|
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 (
|
|
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
|
|
190
|
-
//
|
|
191
|
-
for (const line of clientInstructions(
|
|
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.
|
|
25
|
-
throw new Error("
|
|
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
|
|
71
|
-
targetContextWindow
|
|
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
|
-
|
|
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
|
+
});
|