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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveConfigPath = resolveConfigPath;
|
|
4
|
+
exports.ignoredEnvKeys = ignoredEnvKeys;
|
|
5
|
+
exports.loadYamlConfig = loadYamlConfig;
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
const yaml_1 = require("yaml");
|
|
9
|
+
const registry_1 = require("../routing/registry");
|
|
10
|
+
const rules_1 = require("../routing/rules");
|
|
11
|
+
const errors_1 = require("../utils/errors");
|
|
12
|
+
const gateway_schema_1 = require("./gateway.schema");
|
|
13
|
+
/**
|
|
14
|
+
* Discovery order (spec §3.5): explicit --config path, then
|
|
15
|
+
* BABELGATE_CONFIG, then ./babelgate.yaml, then env-only mode. A named
|
|
16
|
+
* path that does not exist is a startup error; a missing
|
|
17
|
+
* ./babelgate.yaml just means env mode.
|
|
18
|
+
*/
|
|
19
|
+
function resolveConfigPath(explicit, env, cwd) {
|
|
20
|
+
const named = explicit ?? env.BABELGATE_CONFIG;
|
|
21
|
+
if (named !== undefined) {
|
|
22
|
+
const resolved = (0, node_path_1.resolve)(cwd, named);
|
|
23
|
+
if (!(0, node_fs_1.existsSync)(resolved)) {
|
|
24
|
+
throw new errors_1.ConfigurationError(`Config file not found: ${resolved}`);
|
|
25
|
+
}
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
const local = (0, node_path_1.join)(cwd, "babelgate.yaml");
|
|
29
|
+
return (0, node_fs_1.existsSync)(local) ? local : undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Env vars that apply in env-only mode and are ignored when a YAML
|
|
33
|
+
* file is loaded (spec §3.5): TARGET_API, every *_API_BASE_URL,
|
|
34
|
+
* MODEL_MAP, TARGET_CONTEXT_WINDOW and SOURCE_API — the ones that are
|
|
35
|
+
* set, in that order. Names only, never values.
|
|
36
|
+
*/
|
|
37
|
+
function ignoredEnvKeys(env) {
|
|
38
|
+
const baseUrlKeys = Object.keys(env)
|
|
39
|
+
.filter((key) => key.endsWith("_API_BASE_URL"))
|
|
40
|
+
.sort();
|
|
41
|
+
return [
|
|
42
|
+
...(env.TARGET_API !== undefined ? ["TARGET_API"] : []),
|
|
43
|
+
...baseUrlKeys,
|
|
44
|
+
...(env.MODEL_MAP !== undefined ? ["MODEL_MAP"] : []),
|
|
45
|
+
...(env.TARGET_CONTEXT_WINDOW !== undefined
|
|
46
|
+
? ["TARGET_CONTEXT_WINDOW"]
|
|
47
|
+
: []),
|
|
48
|
+
...(env.SOURCE_API !== undefined ? ["SOURCE_API"] : []),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
/** ["routes", 1, "to"] → "routes[1].to"; the root is "". */
|
|
52
|
+
function formatIssuePath(segments) {
|
|
53
|
+
let out = "";
|
|
54
|
+
for (const segment of segments) {
|
|
55
|
+
if (typeof segment === "number") {
|
|
56
|
+
out += `[${segment}]`;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
out += out === "" ? String(segment) : `.${String(segment)}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function requireTarget(text, providerNames, file, at) {
|
|
65
|
+
const target = (0, rules_1.parseTarget)(text, providerNames);
|
|
66
|
+
if (target === undefined) {
|
|
67
|
+
throw new errors_1.ConfigurationError(`${file}: ${at} "${text}" is not <provider>/<model> for a configured provider`);
|
|
68
|
+
}
|
|
69
|
+
return target;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Loads a babelgate.yaml into a GatewayConfig (spec §3.5). Scalars
|
|
73
|
+
* (PORT, PROXY_API_KEY, UPSTREAM_*) come from `env` exactly as in env
|
|
74
|
+
* mode; `api_key_env` names are resolved against `env` and the values
|
|
75
|
+
* become `apiKey` — only the env var names may appear in errors.
|
|
76
|
+
*/
|
|
77
|
+
function loadYamlConfig(path, env) {
|
|
78
|
+
let text;
|
|
79
|
+
try {
|
|
80
|
+
text = (0, node_fs_1.readFileSync)(path, "utf8");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new errors_1.ConfigurationError(`Config file not found: ${path}`);
|
|
84
|
+
}
|
|
85
|
+
const doc = (0, yaml_1.parseDocument)(text, { uniqueKeys: true });
|
|
86
|
+
const parseError = doc.errors[0];
|
|
87
|
+
if (parseError !== undefined) {
|
|
88
|
+
throw new errors_1.ConfigurationError(`${path}: ${parseError.message}`);
|
|
89
|
+
}
|
|
90
|
+
const raw = doc.toJS();
|
|
91
|
+
if (raw === null || raw === undefined) {
|
|
92
|
+
throw new errors_1.ConfigurationError(`${path}: the config file is empty — 'providers' is required`);
|
|
93
|
+
}
|
|
94
|
+
const parsed = gateway_schema_1.gatewayFileSchema.safeParse(raw);
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
const detail = parsed.error.issues
|
|
97
|
+
.map((issue) => {
|
|
98
|
+
const at = formatIssuePath(issue.path);
|
|
99
|
+
return at === "" ? issue.message : `${at}: ${issue.message}`;
|
|
100
|
+
})
|
|
101
|
+
.join("; ");
|
|
102
|
+
throw new errors_1.ConfigurationError(`${path}: ${detail}`);
|
|
103
|
+
}
|
|
104
|
+
const file = parsed.data;
|
|
105
|
+
const providerNames = new Set(Object.keys(file.providers));
|
|
106
|
+
if (!providerNames.has(file.default)) {
|
|
107
|
+
throw new errors_1.ConfigurationError(`${path}: default "${file.default}" is not a configured provider`);
|
|
108
|
+
}
|
|
109
|
+
const entries = Object.entries(file.providers).map(([name, provider]) => {
|
|
110
|
+
let apiKey;
|
|
111
|
+
if (provider.api_key_env !== undefined) {
|
|
112
|
+
const value = env[provider.api_key_env];
|
|
113
|
+
if (value === undefined || value === "") {
|
|
114
|
+
throw new errors_1.ConfigurationError(`${path}: providers.${name}.api_key_env names ${provider.api_key_env}, which is unset or empty`);
|
|
115
|
+
}
|
|
116
|
+
apiKey = value;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
name,
|
|
120
|
+
type: provider.type,
|
|
121
|
+
baseUrl: provider.base_url.replace(/\/$/, ""),
|
|
122
|
+
apiKey,
|
|
123
|
+
apiKeyEnv: provider.api_key_env,
|
|
124
|
+
contextWindow: provider.context_window,
|
|
125
|
+
timeoutMs: provider.timeout_ms,
|
|
126
|
+
streamIdleMs: provider.stream_idle_ms,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
const aliases = {};
|
|
130
|
+
for (const [name, text] of Object.entries(file.aliases ?? {})) {
|
|
131
|
+
if (providerNames.has(name)) {
|
|
132
|
+
throw new errors_1.ConfigurationError(`${path}: aliases.${name} — "${name}" is also a provider name`);
|
|
133
|
+
}
|
|
134
|
+
aliases[name] = requireTarget(text, providerNames, path, `aliases.${name}`);
|
|
135
|
+
}
|
|
136
|
+
const rules = (file.routes ?? []).map((route, index) => ({
|
|
137
|
+
match: route.match,
|
|
138
|
+
when: route.when !== undefined
|
|
139
|
+
? { minInputTokens: route.when.min_input_tokens }
|
|
140
|
+
: undefined,
|
|
141
|
+
to: requireTarget(route.to, providerNames, path, `routes[${index}].to`),
|
|
142
|
+
fallback: (route.fallback ?? []).map((text, i) => requireTarget(text, providerNames, path, `routes[${index}].fallback[${i}]`)),
|
|
143
|
+
}));
|
|
144
|
+
const routing = {
|
|
145
|
+
defaultProvider: file.default,
|
|
146
|
+
aliases,
|
|
147
|
+
rules,
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
port: env.PORT ? parseInt(env.PORT, 10) : 3000,
|
|
151
|
+
proxyApiKey: env.PROXY_API_KEY || undefined,
|
|
152
|
+
upstreamTimeoutMs: env.UPSTREAM_TIMEOUT_MS
|
|
153
|
+
? parseInt(env.UPSTREAM_TIMEOUT_MS, 10)
|
|
154
|
+
: 30_000,
|
|
155
|
+
upstreamRetries: env.UPSTREAM_RETRIES
|
|
156
|
+
? parseInt(env.UPSTREAM_RETRIES, 10)
|
|
157
|
+
: 1,
|
|
158
|
+
upstreamStreamIdleMs: env.UPSTREAM_STREAM_IDLE_MS
|
|
159
|
+
? parseInt(env.UPSTREAM_STREAM_IDLE_MS, 10)
|
|
160
|
+
: 300_000,
|
|
161
|
+
registry: (0, registry_1.createRegistry)(entries),
|
|
162
|
+
routing,
|
|
163
|
+
};
|
|
164
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.startServer = startServer;
|
|
4
4
|
const app_1 = require("./app");
|
|
5
5
|
const env_config_1 = require("./config/env-config");
|
|
6
|
+
const table_1 = require("./routing/table");
|
|
6
7
|
const logger_1 = require("./utils/logger");
|
|
7
8
|
/**
|
|
8
9
|
* Starts the proxy and resolves once it is listening. Importing this module
|
|
@@ -33,11 +34,12 @@ function startServer() {
|
|
|
33
34
|
server.once("listening", () => {
|
|
34
35
|
logger_1.logger.info({
|
|
35
36
|
port,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
targetBaseUrl: config.targetBaseUrl,
|
|
39
|
-
targetApiKey: config.targetApiKey ? "****" : "Not configured",
|
|
37
|
+
defaultProvider: config.routing.defaultProvider,
|
|
38
|
+
providers: config.registry.names(),
|
|
40
39
|
}, "Server started");
|
|
40
|
+
for (const line of (0, table_1.formatRoutingTable)(config)) {
|
|
41
|
+
logger_1.logger.info(line);
|
|
42
|
+
}
|
|
41
43
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
42
44
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
43
45
|
resolve(server);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectSourceMiddleware = detectSourceMiddleware;
|
|
4
|
+
const detect_source_1 = require("../routing/detect-source");
|
|
5
|
+
/**
|
|
6
|
+
* Records the detected client format on `res.locals.sourceApi` so the
|
|
7
|
+
* proxy route and the error handler use the request's own dialect
|
|
8
|
+
* (spec §3.2). Mounted before the body parser and auth so their errors
|
|
9
|
+
* render in the detected dialect too. Reads no config — detection is
|
|
10
|
+
* pure method + path + headers.
|
|
11
|
+
*/
|
|
12
|
+
function detectSourceMiddleware() {
|
|
13
|
+
return (req, res, next) => {
|
|
14
|
+
const sourceApi = (0, detect_source_1.detectSource)(req.method, req.path, req.headers);
|
|
15
|
+
if (sourceApi) {
|
|
16
|
+
res.locals.sourceApi = sourceApi;
|
|
17
|
+
}
|
|
18
|
+
next();
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -83,6 +83,14 @@ const errorHandler = (err, req, res, _next) => {
|
|
|
83
83
|
const isProduction = process.env.NODE_ENV === "production";
|
|
84
84
|
const requestId = req.id;
|
|
85
85
|
const sourceApi = res.locals.sourceApi;
|
|
86
|
+
// §3.4: errors after upstream attempts carry x-babelgate-attempts
|
|
87
|
+
// (and any other responseHeaders the pipeline attached) — set them
|
|
88
|
+
// before the dialect rendering below writes the body.
|
|
89
|
+
if (err instanceof errors_1.ProxyError && err.responseHeaders !== undefined) {
|
|
90
|
+
for (const [name, value] of Object.entries(err.responseHeaders)) {
|
|
91
|
+
res.setHeader(name, value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
86
94
|
if (err instanceof errors_1.UpstreamError) {
|
|
87
95
|
logUpstreamError(err, requestId);
|
|
88
96
|
}
|
package/dist/routes/proxy.js
CHANGED
|
@@ -21,6 +21,9 @@ exports.proxyRouter.use(async (req, res, next) => {
|
|
|
21
21
|
});
|
|
22
22
|
try {
|
|
23
23
|
const result = await (0, proxy_service_1.proxyRequest)(req.path, req.method, req.body, String(req.id), {
|
|
24
|
+
// Client format detected by the middleware; undefined → the
|
|
25
|
+
// service returns null → 404.
|
|
26
|
+
source: res.locals.sourceApi,
|
|
24
27
|
query: Object.fromEntries(Object.entries(req.query).map(([k, v]) => [k, String(v)])),
|
|
25
28
|
signal: ac.signal,
|
|
26
29
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectSource = detectSource;
|
|
4
|
+
const adapters_1 = require("../transformers/adapters");
|
|
5
|
+
/**
|
|
6
|
+
* Pure request → client-format detection (spec §3.2): the request's
|
|
7
|
+
* method + path prefix selects the provider whose dialect the client
|
|
8
|
+
* speaks, over the same PROVIDER_ROUTES table the services match on.
|
|
9
|
+
*
|
|
10
|
+
* The only ambiguous route is `GET /v1/models`, shared by openai and
|
|
11
|
+
* anthropic. Anthropic SDKs always send `anthropic-version`
|
|
12
|
+
* (@anthropic-ai/sdk client.js:842 — unconditional default header).
|
|
13
|
+
* Claude Code model discovery does not: its gateway docs ("Model
|
|
14
|
+
* discovery → Request and response",
|
|
15
|
+
* https://code.claude.com/docs/en/llm-gateway-protocol) show
|
|
16
|
+
* `GET /v1/models?limit=1000` carrying `Authorization`, `x-api-key` and
|
|
17
|
+
* `ANTHROPIC_CUSTOM_HEADERS` — Claude Code ≥ 2.1.248 sends both
|
|
18
|
+
* credential headers. Either `anthropic-version` or `x-api-key` marks
|
|
19
|
+
* the client as anthropic; OpenAI SDKs authenticate with
|
|
20
|
+
* `Authorization: Bearer` alone, so neither header → openai.
|
|
21
|
+
* `headers` are the request's own (Node lowercases the names).
|
|
22
|
+
*/
|
|
23
|
+
function detectSource(method, path, headers) {
|
|
24
|
+
const matches = Object.keys(adapters_1.PROVIDER_ROUTES).filter((provider) => adapters_1.PROVIDER_ROUTES[provider].some((r) => r.method === method && path.startsWith(r.pathPrefix)));
|
|
25
|
+
if (matches.length === 0)
|
|
26
|
+
return undefined;
|
|
27
|
+
const headerNames = new Set(Object.keys(headers).map((name) => name.toLowerCase()));
|
|
28
|
+
const anthropicClient = headerNames.has("anthropic-version") || headerNames.has("x-api-key");
|
|
29
|
+
if (anthropicClient && matches.includes("anthropic")) {
|
|
30
|
+
return "anthropic";
|
|
31
|
+
}
|
|
32
|
+
return matches[0];
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hopOutcome = hopOutcome;
|
|
4
|
+
exports.isFallbackEligible = isFallbackEligible;
|
|
5
|
+
const errors_1 = require("../utils/errors");
|
|
6
|
+
/** Upstream statuses that move the request to the next route target. */
|
|
7
|
+
const FALLBACK_STATUSES = new Set([429, 500, 502, 503, 504, 529]);
|
|
8
|
+
/**
|
|
9
|
+
* The hop's outcome for the spec §3.4 route log line, classified from
|
|
10
|
+
* the UpstreamError shapes http-client produces: `upstreamStatus` set →
|
|
11
|
+
* "http_error"; no status with a 504 client code → "timeout"; no status
|
|
12
|
+
* with a 502 client code → "transport". Anything that isn't an
|
|
13
|
+
* UpstreamError never reached the upstream — undefined, not a hop
|
|
14
|
+
* outcome.
|
|
15
|
+
*/
|
|
16
|
+
function hopOutcome(err) {
|
|
17
|
+
if (!(err instanceof errors_1.UpstreamError))
|
|
18
|
+
return undefined;
|
|
19
|
+
if (err.upstreamStatus !== undefined)
|
|
20
|
+
return "http_error";
|
|
21
|
+
return err.statusCode === 504 ? "timeout" : "transport";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fallback-eligible failures, exactly the spec §3.4 list: transport
|
|
25
|
+
* failure; header/connect timeout; upstream 429, 500, 502, 503, 504,
|
|
26
|
+
* 529. Everything else — client-facing 4xx, a non-JSON 2xx body, errors
|
|
27
|
+
* raised before the upstream call (validation, transform) — fails
|
|
28
|
+
* immediately.
|
|
29
|
+
*/
|
|
30
|
+
function isFallbackEligible(err) {
|
|
31
|
+
if (!(err instanceof errors_1.UpstreamError))
|
|
32
|
+
return false;
|
|
33
|
+
if (err.upstreamStatus === undefined) {
|
|
34
|
+
return err.statusCode === 502 || err.statusCode === 504;
|
|
35
|
+
}
|
|
36
|
+
return FALLBACK_STATUSES.has(err.upstreamStatus);
|
|
37
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.planRoute = planRoute;
|
|
4
|
+
const rules_1 = require("./rules");
|
|
5
|
+
/**
|
|
6
|
+
* Pure model → route resolution (spec §3.3): no I/O, clock or
|
|
7
|
+
* randomness. Resolution order:
|
|
8
|
+
* 1. explicit `provider/model` (first-`/` prefix is a registered
|
|
9
|
+
* provider) — rules are skipped;
|
|
10
|
+
* 2. alias — exact, case-sensitive name;
|
|
11
|
+
* 3. routes in order — first `match` glob whose `when` also holds;
|
|
12
|
+
* 4. the default provider, model unchanged.
|
|
13
|
+
* `targets` is the matched target followed by its fallback list;
|
|
14
|
+
* explicit, alias and default plans carry no fallbacks.
|
|
15
|
+
*/
|
|
16
|
+
function planRoute(input) {
|
|
17
|
+
const explicit = (0, rules_1.parseTarget)(input.requestedModel, input.providerNames);
|
|
18
|
+
if (explicit !== undefined) {
|
|
19
|
+
return { targets: [explicit], reason: "explicit" };
|
|
20
|
+
}
|
|
21
|
+
const alias = Object.hasOwn(input.routing.aliases, input.requestedModel)
|
|
22
|
+
? input.routing.aliases[input.requestedModel]
|
|
23
|
+
: undefined;
|
|
24
|
+
if (alias !== undefined) {
|
|
25
|
+
return { targets: [alias], reason: `alias:${input.requestedModel}` };
|
|
26
|
+
}
|
|
27
|
+
for (const [index, rule] of input.routing.rules.entries()) {
|
|
28
|
+
if (!(0, rules_1.globMatch)(rule.match, input.requestedModel))
|
|
29
|
+
continue;
|
|
30
|
+
const min = rule.when?.minInputTokens;
|
|
31
|
+
if (min !== undefined && input.estimatedInputTokens < min)
|
|
32
|
+
continue;
|
|
33
|
+
return {
|
|
34
|
+
targets: [rule.to, ...rule.fallback],
|
|
35
|
+
reason: `rule:${index}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
targets: [
|
|
40
|
+
{
|
|
41
|
+
provider: input.routing.defaultProvider,
|
|
42
|
+
model: input.requestedModel,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
reason: "default",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRegistry = createRegistry;
|
|
4
|
+
exports.authHeaders = authHeaders;
|
|
5
|
+
const errors_1 = require("../utils/errors");
|
|
6
|
+
/** A duplicate provider name is a startup error, never a silent win. */
|
|
7
|
+
function createRegistry(entries) {
|
|
8
|
+
const byName = new Map();
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
if (byName.has(entry.name)) {
|
|
11
|
+
throw new errors_1.ConfigurationError(`Duplicate provider name: ${entry.name}`);
|
|
12
|
+
}
|
|
13
|
+
byName.set(entry.name, entry);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
get(name) {
|
|
17
|
+
const entry = byName.get(name);
|
|
18
|
+
if (entry === undefined) {
|
|
19
|
+
throw new errors_1.ConfigurationError(`Unknown provider: ${name}`);
|
|
20
|
+
}
|
|
21
|
+
return entry;
|
|
22
|
+
},
|
|
23
|
+
has: (name) => byName.has(name),
|
|
24
|
+
names: () => [...byName.keys()],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Provider-specific auth headers — the body of proxy.service's former
|
|
29
|
+
* buildAuthHeaders, now keyed on the provider entry.
|
|
30
|
+
*/
|
|
31
|
+
function authHeaders(entry) {
|
|
32
|
+
if (!entry.apiKey)
|
|
33
|
+
return {};
|
|
34
|
+
switch (entry.type) {
|
|
35
|
+
case "openai":
|
|
36
|
+
return { Authorization: `Bearer ${entry.apiKey}` };
|
|
37
|
+
case "anthropic":
|
|
38
|
+
return {
|
|
39
|
+
"x-api-key": entry.apiKey,
|
|
40
|
+
"anthropic-version": "2023-06-01",
|
|
41
|
+
};
|
|
42
|
+
case "gemini":
|
|
43
|
+
return { "x-goog-api-key": entry.apiKey };
|
|
44
|
+
case "ollama":
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.logRoute = logRoute;
|
|
4
|
+
const logger_1 = require("../utils/logger");
|
|
5
|
+
/** One info line per chat request, replacing "Request completed". */
|
|
6
|
+
function logRoute(record) {
|
|
7
|
+
logger_1.logger.info(record, "Request completed");
|
|
8
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.globMatch = globMatch;
|
|
4
|
+
exports.parseTarget = parseTarget;
|
|
5
|
+
/** `*` matches any run of characters; every other character is literal. */
|
|
6
|
+
function globToRegExp(pattern) {
|
|
7
|
+
const escaped = pattern
|
|
8
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
9
|
+
.replace(/\*/g, ".*");
|
|
10
|
+
return new RegExp(`^${escaped}$`);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Glob test used by routing rules — the matcher moved here unchanged
|
|
14
|
+
* from utils/model-map.ts (spec §3.3: `*` matches any run of
|
|
15
|
+
* characters; everything else is literal).
|
|
16
|
+
*/
|
|
17
|
+
function globMatch(pattern, value) {
|
|
18
|
+
return globToRegExp(pattern).test(value);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `provider/model` → {provider, model} (spec §3.3): splits on the
|
|
22
|
+
* first `/` only, and only when the prefix is a registered provider
|
|
23
|
+
* name (case-sensitive) and the rest is non-empty — model names may
|
|
24
|
+
* contain `/` and `:`. Anything else is not a target.
|
|
25
|
+
*/
|
|
26
|
+
function parseTarget(text, providerNames) {
|
|
27
|
+
const slash = text.indexOf("/");
|
|
28
|
+
if (slash === -1)
|
|
29
|
+
return undefined;
|
|
30
|
+
const provider = text.slice(0, slash);
|
|
31
|
+
const model = text.slice(slash + 1);
|
|
32
|
+
if (model === "" || !providerNames.has(provider))
|
|
33
|
+
return undefined;
|
|
34
|
+
return { provider, model };
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatRoutingTable = formatRoutingTable;
|
|
4
|
+
/**
|
|
5
|
+
* The startup routing table (spec §3.5): one line per provider, one per
|
|
6
|
+
* alias, one per route in order, then the default. Key env var names
|
|
7
|
+
* may be shown; key values never are.
|
|
8
|
+
*/
|
|
9
|
+
function formatRoutingTable(config) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
for (const name of config.registry.names()) {
|
|
12
|
+
const entry = config.registry.get(name);
|
|
13
|
+
const key = entry.apiKeyEnv !== undefined ? `env ${entry.apiKeyEnv}` : "none";
|
|
14
|
+
lines.push(`provider ${name} (type ${entry.type}, ${entry.baseUrl}, key ${key})`);
|
|
15
|
+
}
|
|
16
|
+
for (const [name, target] of Object.entries(config.routing.aliases)) {
|
|
17
|
+
lines.push(`alias ${name} -> ${target.provider}/${target.model}`);
|
|
18
|
+
}
|
|
19
|
+
for (const [index, rule] of config.routing.rules.entries()) {
|
|
20
|
+
const when = rule.when?.minInputTokens !== undefined
|
|
21
|
+
? ` when min_input_tokens>=${rule.when.minInputTokens}`
|
|
22
|
+
: "";
|
|
23
|
+
const fallback = rule.fallback.length > 0
|
|
24
|
+
? ` fallback ${rule.fallback
|
|
25
|
+
.map((target) => `${target.provider}/${target.model}`)
|
|
26
|
+
.join(", ")}`
|
|
27
|
+
: "";
|
|
28
|
+
lines.push(`route ${index} ${rule.match}${when} -> ${rule.to.provider}/${rule.to.model}${fallback}`);
|
|
29
|
+
}
|
|
30
|
+
lines.push(`default -> ${config.routing.defaultProvider}`);
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toClientFacing = toClientFacing;
|
|
4
|
+
exports.canonicalForTarget = canonicalForTarget;
|
|
5
|
+
exports.attemptTargets = attemptTargets;
|
|
6
|
+
const fallback_1 = require("../routing/fallback");
|
|
7
|
+
const errors_1 = require("../utils/errors");
|
|
8
|
+
const upstream_errors_1 = require("./upstream-errors");
|
|
9
|
+
/**
|
|
10
|
+
* Attach the spec §3.3 client-facing rendering to an upstream failure
|
|
11
|
+
* (moved from proxy.service). Only real upstream error statuses (≥ 400)
|
|
12
|
+
* go through the mapping table — timeouts, transport failures and
|
|
13
|
+
* non-JSON 2xx replies already carry their final client-facing message.
|
|
14
|
+
*/
|
|
15
|
+
function toClientFacing(err, target) {
|
|
16
|
+
if (err.upstreamStatus !== undefined && err.upstreamStatus >= 400) {
|
|
17
|
+
err.clientError = (0, upstream_errors_1.toClientError)(target, err.upstreamStatus, err.upstreamBody, err.retryAfterSeconds);
|
|
18
|
+
}
|
|
19
|
+
return err;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The canonical request this attempt sends upstream: the routed model
|
|
23
|
+
* and the target provider's contextWindow (unset → config untouched,
|
|
24
|
+
* spec §3.1), set on a COPY — the decoded request is never mutated.
|
|
25
|
+
*/
|
|
26
|
+
function canonicalForTarget(canonical, entry, target) {
|
|
27
|
+
const out = { ...canonical, model: target.model };
|
|
28
|
+
if (entry.contextWindow !== undefined) {
|
|
29
|
+
out.config = { ...canonical.config, contextWindow: entry.contextWindow };
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The fallback loop shared by both chat services (spec §3.4): try each
|
|
35
|
+
* of plan's targets in order. A client abort rethrows immediately —
|
|
36
|
+
* no further target is tried. A fallback-eligible failure with targets
|
|
37
|
+
* remaining records its hop and continues; anything else, and the last
|
|
38
|
+
* target's error, carries `x-babelgate-attempts` in responseHeaders and
|
|
39
|
+
* is rethrown. `ctx.hops`, when given, is filled with every upstream
|
|
40
|
+
* hop (ok and failed) so the caller can emit the route log line even on
|
|
41
|
+
* failure; the same array is returned on success.
|
|
42
|
+
*/
|
|
43
|
+
async function attemptTargets(plan, registry, attempt, ctx) {
|
|
44
|
+
const hops = ctx.hops ?? [];
|
|
45
|
+
for (const [index, target] of plan.targets.entries()) {
|
|
46
|
+
const entry = registry.get(target.provider);
|
|
47
|
+
const start = Date.now();
|
|
48
|
+
try {
|
|
49
|
+
const value = await attempt(entry, target);
|
|
50
|
+
hops.push({
|
|
51
|
+
provider: target.provider,
|
|
52
|
+
model: target.model,
|
|
53
|
+
outcome: "ok",
|
|
54
|
+
ms: Date.now() - start,
|
|
55
|
+
});
|
|
56
|
+
return { value, target, entry, hops };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
// Client went away — stop; try no further target.
|
|
60
|
+
if (ctx.signal?.aborted)
|
|
61
|
+
throw err;
|
|
62
|
+
// Only upstream failures are hops: an error raised before the
|
|
63
|
+
// request left (validation, transform) is no route outcome.
|
|
64
|
+
const outcome = (0, fallback_1.hopOutcome)(err);
|
|
65
|
+
if (outcome !== undefined) {
|
|
66
|
+
hops.push({
|
|
67
|
+
provider: target.provider,
|
|
68
|
+
model: target.model,
|
|
69
|
+
outcome,
|
|
70
|
+
...(outcome === "http_error"
|
|
71
|
+
? { status: err.upstreamStatus }
|
|
72
|
+
: {}),
|
|
73
|
+
ms: Date.now() - start,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (index < plan.targets.length - 1 &&
|
|
77
|
+
(0, fallback_1.isFallbackEligible)(err)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (err instanceof errors_1.ProxyError) {
|
|
81
|
+
err.responseHeaders = {
|
|
82
|
+
...err.responseHeaders,
|
|
83
|
+
"x-babelgate-attempts": String(index + 1),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// plan.targets is never empty (planRoute always returns ≥ 1 target).
|
|
90
|
+
throw new Error("attemptTargets: plan has no targets");
|
|
91
|
+
}
|