babelgate 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.proxyChatStream = proxyChatStream;
4
+ const transformers_1 = require("../transformers");
5
+ const registry_1 = require("../routing/registry");
6
+ const route_log_1 = require("../routing/route-log");
7
+ const errors_1 = require("../utils/errors");
8
+ const http_client_1 = require("./http-client");
9
+ const stream_pipeline_1 = require("./stream-pipeline");
10
+ const attempt_targets_1 = require("./attempt-targets");
11
+ /**
12
+ * Streaming chat over a RoutePlan (spec §3.2): per target — encode the
13
+ * canonical copy, postStream to its stream endpoint, pipe through
14
+ * runStreamPipeline. The postStream call resolves on response headers,
15
+ * so the attempt is what the §3.4 fallback decision hangs off: once 2xx
16
+ * headers arrive the target is committed and mid-stream failures end in
17
+ * an in-stream error, never a fallback. A Gemini target uses the
18
+ * route's streamRequestPath (:streamGenerateContent?alt=sse); the
19
+ * others stream on the chat path.
20
+ */
21
+ async function proxyChatStream(input) {
22
+ const { source, sourceBody, canonical, requestedModel, plan, config, requestId, opts, } = input;
23
+ const encoder = transformers_1.adapters[source].createStreamEncoder({
24
+ sourceRequest: sourceBody,
25
+ query: opts?.query ?? {},
26
+ requestedModel,
27
+ });
28
+ const hops = [];
29
+ let result;
30
+ try {
31
+ result = await (0, attempt_targets_1.attemptTargets)(plan, config.registry, async (entry, target) => {
32
+ const decoder = transformers_1.adapters[entry.type].createStreamDecoder(target.model);
33
+ const targetBody = transformers_1.adapters[entry.type].fromCanonicalRequest((0, attempt_targets_1.canonicalForTarget)(canonical, entry, target));
34
+ const targetRoute = transformers_1.PROVIDER_ROUTES[entry.type].find((r) => r.kind === "chat");
35
+ const url = `${entry.baseUrl}${targetRoute.streamRequestPath?.(target.model) ??
36
+ targetRoute.requestPath(target.model)}`;
37
+ // Client disconnect aborts the upstream request; upstreamAc lets
38
+ // the pipeline's end (terminal event, failure) tear it down too —
39
+ // a bare body cancel doesn't reach the request on every transport.
40
+ const upstreamAc = new AbortController();
41
+ const upstream = await (0, http_client_1.postStream)({
42
+ url,
43
+ headers: (0, registry_1.authHeaders)(entry),
44
+ body: targetBody,
45
+ timeoutMs: entry.timeoutMs ?? config.upstreamTimeoutMs,
46
+ retries: config.upstreamRetries,
47
+ requestId,
48
+ signal: opts?.signal
49
+ ? AbortSignal.any([opts.signal, upstreamAc.signal])
50
+ : upstreamAc.signal,
51
+ }).catch((err) => {
52
+ throw err instanceof errors_1.UpstreamError
53
+ ? (0, attempt_targets_1.toClientFacing)(err, entry.type)
54
+ : err;
55
+ });
56
+ return { upstream, upstreamAc, decoder };
57
+ }, { requestId, signal: opts?.signal, hops });
58
+ }
59
+ catch (err) {
60
+ (0, route_log_1.logRoute)({
61
+ requestId,
62
+ sourceApi: source,
63
+ requestedModel,
64
+ reason: plan.reason,
65
+ hops,
66
+ stream: true,
67
+ });
68
+ throw err;
69
+ }
70
+ const { upstream, upstreamAc, decoder } = result.value;
71
+ // Watch the event stream for the route log line — the pipeline
72
+ // yields only encoded bytes, so the decoder is wrapped. Chunk contents
73
+ // are never logged (§3.2); only usage and the finish reason are.
74
+ const observed = [];
75
+ const recordingDecoder = {
76
+ feed(chunk) {
77
+ const events = decoder.feed(chunk);
78
+ observed.push(...events);
79
+ return events;
80
+ },
81
+ end() {
82
+ const events = decoder.end();
83
+ observed.push(...events);
84
+ return events;
85
+ },
86
+ };
87
+ const signal = opts?.signal ?? new AbortController().signal;
88
+ const chunks = (async function* () {
89
+ try {
90
+ yield* (0, stream_pipeline_1.runStreamPipeline)({
91
+ upstream: upstream.body,
92
+ decoder: recordingDecoder,
93
+ encoder,
94
+ idleMs: result.entry.streamIdleMs ?? config.upstreamStreamIdleMs,
95
+ signal,
96
+ });
97
+ }
98
+ finally {
99
+ // Whatever ended the pipe — terminal event, in-stream error,
100
+ // client disconnect — the upstream request is done with, and the
101
+ // route log line goes out exactly once (spec §3.4).
102
+ upstreamAc.abort();
103
+ const last = observed.at(-1);
104
+ const usageEvent = observed.find((e) => e.type === "usage");
105
+ (0, route_log_1.logRoute)({
106
+ requestId,
107
+ sourceApi: source,
108
+ requestedModel,
109
+ reason: plan.reason,
110
+ hops: result.hops,
111
+ winner: result.target.provider,
112
+ stream: true,
113
+ inputTokens: usageEvent?.type === "usage"
114
+ ? usageEvent.usage.inputTokens
115
+ : undefined,
116
+ outputTokens: usageEvent?.type === "usage"
117
+ ? usageEvent.usage.outputTokens
118
+ : undefined,
119
+ finishReason: last?.type === "message_end" ? last.finishReason : undefined,
120
+ });
121
+ }
122
+ })();
123
+ return {
124
+ stream: { contentType: encoder.contentType, chunks },
125
+ headers: {
126
+ "x-babelgate-provider": result.target.provider,
127
+ "x-babelgate-model": result.target.model,
128
+ "x-babelgate-attempts": String(result.hops.length),
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.proxyChat = proxyChat;
4
+ const transformers_1 = require("../transformers");
5
+ const registry_1 = require("../routing/registry");
6
+ const route_log_1 = require("../routing/route-log");
7
+ const canonical_schema_1 = require("../types/canonical.schema");
8
+ const errors_1 = require("../utils/errors");
9
+ const logger_1 = require("../utils/logger");
10
+ const http_client_1 = require("./http-client");
11
+ const attempt_targets_1 = require("./attempt-targets");
12
+ /**
13
+ * Non-streaming chat over a RoutePlan: per target — encode with the
14
+ * registry provider's adapter, postJson with its URL, auth and
15
+ * timeouts; decode the reply → model := requestedModel → encode in the
16
+ * client's format (moved from proxy.service, spec §3.1). The route log
17
+ * line and the x-babelgate-* reply headers come from spec §3.4.
18
+ */
19
+ async function proxyChat(input) {
20
+ const { source, canonical, requestedModel, plan, config, requestId } = input;
21
+ const hops = [];
22
+ let result;
23
+ try {
24
+ result = await (0, attempt_targets_1.attemptTargets)(plan, config.registry, async (entry, target) => {
25
+ const targetBody = transformers_1.adapters[entry.type].fromCanonicalRequest((0, attempt_targets_1.canonicalForTarget)(canonical, entry, target));
26
+ const targetRoute = transformers_1.PROVIDER_ROUTES[entry.type].find((r) => r.kind === "chat");
27
+ const url = `${entry.baseUrl}${targetRoute.requestPath(target.model)}`;
28
+ const upstream = await (0, http_client_1.postJson)({
29
+ url,
30
+ headers: (0, registry_1.authHeaders)(entry),
31
+ body: targetBody,
32
+ timeoutMs: entry.timeoutMs ?? config.upstreamTimeoutMs,
33
+ retries: config.upstreamRetries,
34
+ requestId,
35
+ }).catch((err) => {
36
+ throw err instanceof errors_1.UpstreamError
37
+ ? (0, attempt_targets_1.toClientFacing)(err, entry.type)
38
+ : err;
39
+ });
40
+ if (upstream.status < 200 || upstream.status >= 300) {
41
+ const err = new errors_1.UpstreamError(`Upstream ${entry.type} returned ${upstream.status}`, upstream.status, upstream.body);
42
+ err.retryAfterSeconds = upstream.retryAfterSeconds;
43
+ throw (0, attempt_targets_1.toClientFacing)(err, entry.type);
44
+ }
45
+ return upstream;
46
+ }, { requestId, signal: input.opts?.signal, hops });
47
+ }
48
+ catch (err) {
49
+ (0, route_log_1.logRoute)({
50
+ requestId,
51
+ sourceApi: source,
52
+ requestedModel,
53
+ reason: plan.reason,
54
+ hops,
55
+ stream: false,
56
+ });
57
+ throw err;
58
+ }
59
+ // Target → Canonical → Source. §3.5: replies carry the model name the
60
+ // client asked for, not the routed name sent upstream.
61
+ const canonicalResp = transformers_1.adapters[result.entry.type].toCanonicalResponse(result.value.body, result.target.model);
62
+ canonicalResp.model = requestedModel;
63
+ // Adapter bug detection (non-production only): canonical response
64
+ // should always be schema-valid
65
+ if (process.env.NODE_ENV !== "production") {
66
+ const respParsed = canonical_schema_1.canonicalResponseSchema.safeParse(canonicalResp);
67
+ if (!respParsed.success) {
68
+ logger_1.logger.error({ requestId, issues: respParsed.error.issues }, "Canonical response failed schema validation — adapter bug");
69
+ }
70
+ }
71
+ (0, route_log_1.logRoute)({
72
+ requestId,
73
+ sourceApi: source,
74
+ requestedModel,
75
+ reason: plan.reason,
76
+ hops: result.hops,
77
+ winner: result.target.provider,
78
+ stream: false,
79
+ inputTokens: canonicalResp.usage?.inputTokens,
80
+ outputTokens: canonicalResp.usage?.outputTokens,
81
+ finishReason: canonicalResp.finishReason,
82
+ });
83
+ return {
84
+ body: transformers_1.adapters[source].fromCanonicalResponse(canonicalResp),
85
+ headers: {
86
+ "x-babelgate-provider": result.target.provider,
87
+ "x-babelgate-model": result.target.model,
88
+ "x-babelgate-attempts": String(result.hops.length),
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createModelListCache = createModelListCache;
4
+ exports.resetModelListCache = resetModelListCache;
5
+ exports.proxyModelList = proxyModelList;
6
+ const transformers_1 = require("../transformers");
7
+ const registry_1 = require("../routing/registry");
8
+ const fallback_1 = require("../routing/fallback");
9
+ const canonical_schema_1 = require("../types/canonical.schema");
10
+ const errors_1 = require("../utils/errors");
11
+ const logger_1 = require("../utils/logger");
12
+ const http_client_1 = require("./http-client");
13
+ const attempt_targets_1 = require("./attempt-targets");
14
+ /** spec §3.6: each provider's model list is cached in memory for 60 s. */
15
+ const MODEL_LIST_TTL_MS = 60_000;
16
+ function createModelListCache(ttlMs = MODEL_LIST_TTL_MS, now = Date.now) {
17
+ const entries = new Map();
18
+ return {
19
+ get(provider, baseUrl) {
20
+ const hit = entries.get(`${provider} ${baseUrl}`);
21
+ if (hit === undefined || now() - hit.fetchedAt >= ttlMs) {
22
+ return undefined;
23
+ }
24
+ return hit.list;
25
+ },
26
+ set(provider, baseUrl, list) {
27
+ entries.set(`${provider} ${baseUrl}`, { list, fetchedAt: now() });
28
+ },
29
+ clear() {
30
+ entries.clear();
31
+ },
32
+ };
33
+ }
34
+ const sharedCache = createModelListCache();
35
+ /** Tests reset the process-wide cache between cases. */
36
+ function resetModelListCache() {
37
+ sharedCache.clear();
38
+ }
39
+ /**
40
+ * One provider's model list, decoded by that provider's own adapter
41
+ * into canonical. Non-2xx upstream replies become an UpstreamError
42
+ * carrying upstreamStatus, exactly as the v1.1 single-provider flow.
43
+ */
44
+ async function fetchProviderModels(entry, source, config, requestId) {
45
+ const targetRoute = transformers_1.PROVIDER_ROUTES[entry.type].find((r) => r.kind === "models");
46
+ const url = `${entry.baseUrl}${targetRoute.requestPath()}`;
47
+ logger_1.logger.info({
48
+ requestId,
49
+ sourceApi: source,
50
+ provider: entry.name,
51
+ targetApi: entry.type,
52
+ url,
53
+ }, "Forwarding models list request to upstream");
54
+ const upstream = await (0, http_client_1.getJson)({
55
+ url,
56
+ headers: (0, registry_1.authHeaders)(entry),
57
+ timeoutMs: entry.timeoutMs ?? config.upstreamTimeoutMs,
58
+ retries: config.upstreamRetries,
59
+ requestId,
60
+ });
61
+ if (upstream.status < 200 || upstream.status >= 300) {
62
+ const err = new errors_1.UpstreamError(`Upstream ${entry.type} returned ${upstream.status}`, upstream.status, upstream.body);
63
+ err.retryAfterSeconds = upstream.retryAfterSeconds;
64
+ throw err;
65
+ }
66
+ return transformers_1.adapters[entry.type].toCanonicalModelList(upstream.body);
67
+ }
68
+ /**
69
+ * GET /models flow, spec §3.6: every registered provider's list is
70
+ * fetched in parallel (60 s cache, successful fetches only), merged
71
+ * and encoded once in the client's format. Order: aliases as bare
72
+ * names; the default provider's models bare (the v1.1 list); every
73
+ * other provider's models as <provider>/<model>. First occurrence
74
+ * wins on a duplicate id. A failed provider is omitted with a warn
75
+ * and x-babelgate-partial: true; if every provider fails, the default
76
+ * provider's error is returned exactly as v1.1 did.
77
+ */
78
+ async function proxyModelList(source, route, config, requestId) {
79
+ const names = config.registry.names();
80
+ const defaultName = config.routing.defaultProvider;
81
+ const fetchCached = async (entry) => {
82
+ const cached = sharedCache.get(entry.name, entry.baseUrl);
83
+ if (cached !== undefined)
84
+ return cached;
85
+ const list = await fetchProviderModels(entry, source, config, requestId);
86
+ sharedCache.set(entry.name, entry.baseUrl, list);
87
+ return list;
88
+ };
89
+ const settled = await Promise.allSettled(names.map((name) => fetchCached(config.registry.get(name))));
90
+ const failed = settled
91
+ .map((r, i) => r.status === "rejected" ? { name: names[i], reason: r.reason } : null)
92
+ .filter((f) => f !== null);
93
+ if (failed.length === names.length) {
94
+ const reason = settled[names.indexOf(defaultName)].reason;
95
+ const entry = config.registry.get(defaultName);
96
+ throw reason instanceof errors_1.UpstreamError
97
+ ? (0, attempt_targets_1.toClientFacing)(reason, entry.type)
98
+ : reason;
99
+ }
100
+ // One warn per failed provider: name and status/outcome only —
101
+ // never a key or a response body.
102
+ for (const f of failed) {
103
+ const err = f.reason;
104
+ logger_1.logger.warn({
105
+ requestId,
106
+ provider: f.name,
107
+ outcome: err instanceof errors_1.UpstreamError
108
+ ? ((0, fallback_1.hopOutcome)(err) ?? "error")
109
+ : "error",
110
+ ...(err instanceof errors_1.UpstreamError &&
111
+ err.upstreamStatus !== undefined
112
+ ? { status: err.upstreamStatus }
113
+ : {}),
114
+ }, "Model list fetch failed; provider omitted");
115
+ }
116
+ const seen = new Set();
117
+ const merged = [];
118
+ const push = (model, id) => {
119
+ if (seen.has(id))
120
+ return;
121
+ seen.add(id);
122
+ merged.push({ ...model, id });
123
+ };
124
+ for (const alias of Object.keys(config.routing.aliases)) {
125
+ push({ id: alias }, alias);
126
+ }
127
+ const pushProvider = (name, prefix) => {
128
+ const r = settled[names.indexOf(name)];
129
+ if (r?.status !== "fulfilled")
130
+ return;
131
+ for (const m of r.value.models) {
132
+ push(m, prefix ? `${name}/${m.id}` : m.id);
133
+ }
134
+ };
135
+ pushProvider(defaultName, false);
136
+ for (const name of names) {
137
+ if (name !== defaultName)
138
+ pushProvider(name, true);
139
+ }
140
+ const mergedList = { models: merged };
141
+ if (process.env.NODE_ENV !== "production") {
142
+ const parsed = canonical_schema_1.canonicalModelListSchema.safeParse(mergedList);
143
+ if (!parsed.success) {
144
+ logger_1.logger.error({ requestId, issues: parsed.error.issues }, "Canonical model list failed schema validation — adapter bug");
145
+ }
146
+ }
147
+ logger_1.logger.info({
148
+ requestId,
149
+ sourceApi: source,
150
+ modelCount: merged.length,
151
+ ...(failed.length > 0 ? { partial: true } : {}),
152
+ }, "Models list completed");
153
+ const result = {
154
+ body: transformers_1.adapters[source].fromCanonicalModelList(mergedList),
155
+ };
156
+ if (failed.length > 0) {
157
+ result.headers = { "x-babelgate-partial": "true" };
158
+ }
159
+ return result;
160
+ }