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.
@@ -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
- sourceApi: config.sourceApi,
37
- targetApi: config.targetApi,
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
  }
@@ -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
+ }