babelgate 0.0.0 → 1.1.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +467 -1
- package/dist/app.js +40 -0
- package/dist/cli.js +203 -0
- package/dist/config/env-config.js +99 -0
- package/dist/index.js +60 -0
- package/dist/middleware/error-handler.js +158 -0
- package/dist/middleware/request-logger.js +66 -0
- package/dist/middleware/require-api-key.js +80 -0
- package/dist/middleware/resolve-source.js +28 -0
- package/dist/routes/proxy.js +74 -0
- package/dist/services/http-client.js +220 -0
- package/dist/services/proxy.service.js +329 -0
- package/dist/services/stream-pipeline.js +119 -0
- package/dist/services/upstream-errors.js +34 -0
- package/dist/transformers/adapters/anthropic.adapter.js +292 -0
- package/dist/transformers/adapters/anthropic.messages.js +261 -0
- package/dist/transformers/adapters/anthropic.stream.js +378 -0
- package/dist/transformers/adapters/gemini.adapter.js +418 -0
- package/dist/transformers/adapters/gemini.schema.js +61 -0
- package/dist/transformers/adapters/gemini.stream.js +341 -0
- package/dist/transformers/adapters/gemini.tools.js +246 -0
- package/dist/transformers/adapters/index.js +141 -0
- package/dist/transformers/adapters/ollama.adapter.js +288 -0
- package/dist/transformers/adapters/ollama.stream.js +294 -0
- package/dist/transformers/adapters/ollama.tools.js +107 -0
- package/dist/transformers/adapters/openai.adapter.js +460 -0
- package/dist/transformers/adapters/openai.stream.js +229 -0
- package/dist/transformers/index.js +110 -0
- package/dist/types/anthropic.types.js +2 -0
- package/dist/types/canonical.schema.js +160 -0
- package/dist/types/canonical.types.js +8 -0
- package/dist/types/gemini.types.js +2 -0
- package/dist/types/ollama.types.js +4 -0
- package/dist/types/openai.types.js +2 -0
- package/dist/utils/canonical-error.js +77 -0
- package/dist/utils/errors.js +95 -0
- package/dist/utils/ids.js +17 -0
- package/dist/utils/logger.js +21 -0
- package/dist/utils/model-map.js +39 -0
- package/dist/utils/sse.js +147 -0
- package/dist/utils/stream-reassemble.js +104 -0
- package/dist/utils/token-estimate.js +36 -0
- package/dist/utils/tool-args.js +26 -0
- package/package.json +54 -7
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TransformError = void 0;
|
|
4
|
+
exports.proxyRequest = proxyRequest;
|
|
5
|
+
const env_config_1 = require("../config/env-config");
|
|
6
|
+
const transformers_1 = require("../transformers");
|
|
7
|
+
const adapters_1 = require("../transformers/adapters");
|
|
8
|
+
const http_client_1 = require("./http-client");
|
|
9
|
+
const stream_pipeline_1 = require("./stream-pipeline");
|
|
10
|
+
const model_map_1 = require("../utils/model-map");
|
|
11
|
+
const token_estimate_1 = require("../utils/token-estimate");
|
|
12
|
+
const upstream_errors_1 = require("./upstream-errors");
|
|
13
|
+
const canonical_schema_1 = require("../types/canonical.schema");
|
|
14
|
+
const errors_1 = require("../utils/errors");
|
|
15
|
+
Object.defineProperty(exports, "TransformError", { enumerable: true, get: function () { return errors_1.TransformError; } });
|
|
16
|
+
const logger_1 = require("../utils/logger");
|
|
17
|
+
/**
|
|
18
|
+
* Attach the spec §3.3 client-facing rendering to an upstream failure.
|
|
19
|
+
* Only real upstream error statuses (≥ 400) go through the mapping
|
|
20
|
+
* table — timeouts, transport failures and non-JSON 2xx replies already
|
|
21
|
+
* carry their final client-facing message.
|
|
22
|
+
*/
|
|
23
|
+
function toClientFacing(err, target) {
|
|
24
|
+
if (err.upstreamStatus !== undefined && err.upstreamStatus >= 400) {
|
|
25
|
+
err.clientError = (0, upstream_errors_1.toClientError)(target, err.upstreamStatus, err.upstreamBody, err.retryAfterSeconds);
|
|
26
|
+
}
|
|
27
|
+
return err;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The "<method>" in a "/v1beta/models/<m>:<method>" path — the text
|
|
31
|
+
* after the last ":" of the percent-decoded path, the same parsing the
|
|
32
|
+
* route's isSupportedPath uses.
|
|
33
|
+
*/
|
|
34
|
+
function methodFromPath(path) {
|
|
35
|
+
let decoded = path;
|
|
36
|
+
try {
|
|
37
|
+
decoded = decodeURIComponent(path);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// malformed escapes — fall back to the raw path
|
|
41
|
+
}
|
|
42
|
+
const colon = decoded.lastIndexOf(":");
|
|
43
|
+
return colon === -1 ? decoded : decoded.slice(colon + 1);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Provider-specific auth headers.
|
|
47
|
+
*/
|
|
48
|
+
function buildAuthHeaders(provider, apiKey) {
|
|
49
|
+
if (!apiKey)
|
|
50
|
+
return {};
|
|
51
|
+
switch (provider) {
|
|
52
|
+
case "openai":
|
|
53
|
+
return { Authorization: `Bearer ${apiKey}` };
|
|
54
|
+
case "anthropic":
|
|
55
|
+
return {
|
|
56
|
+
"x-api-key": apiKey,
|
|
57
|
+
"anthropic-version": "2023-06-01",
|
|
58
|
+
};
|
|
59
|
+
case "gemini":
|
|
60
|
+
return { "x-goog-api-key": apiKey };
|
|
61
|
+
case "ollama":
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Full proxy orchestration:
|
|
67
|
+
* path match → config → canonical boundary validation →
|
|
68
|
+
* transform → upstream call → transform back.
|
|
69
|
+
*
|
|
70
|
+
* Returns null when the path doesn't belong to the configured source
|
|
71
|
+
* provider (route should call next() → 404).
|
|
72
|
+
* Throws ValidationError | UpstreamError | TransformError | ConfigurationError.
|
|
73
|
+
*/
|
|
74
|
+
async function proxyRequest(path, method, body, requestId, opts) {
|
|
75
|
+
// Cheap pre-check: only recognized provider API paths are proxied.
|
|
76
|
+
const isProviderPath = Object.values(transformers_1.PROVIDER_ROUTES)
|
|
77
|
+
.flat()
|
|
78
|
+
.some((r) => r.method === method && path.startsWith(r.pathPrefix));
|
|
79
|
+
if (!isProviderPath)
|
|
80
|
+
return null;
|
|
81
|
+
const config = (0, env_config_1.getConfig)();
|
|
82
|
+
const sourceApi = config.sourceApi;
|
|
83
|
+
const targetApi = config.targetApi;
|
|
84
|
+
// Match the incoming path to a route on the configured source provider
|
|
85
|
+
const route = (0, adapters_1.findSourceRoute)(sourceApi, method, path);
|
|
86
|
+
if (!route)
|
|
87
|
+
return null;
|
|
88
|
+
if (route.kind === "models") {
|
|
89
|
+
return proxyModelList(route, config, sourceApi, targetApi, requestId);
|
|
90
|
+
}
|
|
91
|
+
// Gemini paths carry a ":<method>" suffix — anything that isn't a
|
|
92
|
+
// method this route serves is refused before conversion, before any
|
|
93
|
+
// upstream call (spec §3.2, E11).
|
|
94
|
+
if (route.isSupportedPath?.(path) === false) {
|
|
95
|
+
throw new errors_1.NotSupportedError(`${methodFromPath(path)} is not supported`);
|
|
96
|
+
}
|
|
97
|
+
// Source → Canonical. The adapter may throw on malformed input —
|
|
98
|
+
// that's a client error (400), not a server error (500).
|
|
99
|
+
let canonical;
|
|
100
|
+
try {
|
|
101
|
+
canonical = transformers_1.adapters[sourceApi].toCanonicalRequest(body);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
throw new errors_1.ValidationError(`Malformed ${sourceApi} request: ${err instanceof Error ? err.message : "invalid body"}`, { requestId });
|
|
105
|
+
}
|
|
106
|
+
// Boundary validation: malformed client request → 400, not 500.
|
|
107
|
+
const parsed = canonical_schema_1.canonicalRequestSchema.safeParse(canonical);
|
|
108
|
+
if (!parsed.success) {
|
|
109
|
+
throw new errors_1.ValidationError("Malformed request body", {
|
|
110
|
+
requestId,
|
|
111
|
+
issues: parsed.error.issues.map((i) => ({
|
|
112
|
+
path: i.path.join("."),
|
|
113
|
+
message: i.message,
|
|
114
|
+
})),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// The model comes from the source path when the provider puts it there
|
|
118
|
+
// (Gemini), otherwise from the canonical request. An explicitly empty
|
|
119
|
+
// model in the path (e.g. /v1beta/models/:generateContent) is a client
|
|
120
|
+
// error, not something to send upstream.
|
|
121
|
+
const modelFromPath = route.modelFromPath?.(path);
|
|
122
|
+
if (modelFromPath === "") {
|
|
123
|
+
throw new errors_1.ValidationError(`Malformed ${sourceApi} request path: model is empty`, { requestId });
|
|
124
|
+
}
|
|
125
|
+
// §3.5: count_tokens is answered locally — a chars/4 estimate with the
|
|
126
|
+
// estimate header, never an upstream call.
|
|
127
|
+
if (route.kind === "count_tokens") {
|
|
128
|
+
logger_1.logger.info({ requestId, sourceApi }, "count_tokens answered locally");
|
|
129
|
+
return {
|
|
130
|
+
body: { input_tokens: (0, token_estimate_1.estimateInputTokens)(parsed.data) },
|
|
131
|
+
headers: { "x-babelgate-token-estimate": "true" },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// §3.5: MODEL_MAP rewrites the upstream-bound model; replies and
|
|
135
|
+
// stream encoders keep the name the client asked for.
|
|
136
|
+
const requestedModel = modelFromPath ?? parsed.data.model;
|
|
137
|
+
const model = (0, model_map_1.applyModelMap)(requestedModel, config.modelMap);
|
|
138
|
+
parsed.data.model = model;
|
|
139
|
+
// §3.5: TARGET_CONTEXT_WINDOW → canonical contextWindow (Ollama num_ctx).
|
|
140
|
+
if (config.targetContextWindow !== undefined) {
|
|
141
|
+
parsed.data.config = {
|
|
142
|
+
...parsed.data.config,
|
|
143
|
+
contextWindow: config.targetContextWindow,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Streaming (spec §3.2): a `stream` body flag on the JSON-body sources,
|
|
147
|
+
// the dedicated streaming endpoint on Gemini — which carries no body
|
|
148
|
+
// flag, so canonical.stream is set here for the target adapter's
|
|
149
|
+
// own streaming marker (openai stream_options, anthropic/ollama
|
|
150
|
+
// stream:true).
|
|
151
|
+
if (parsed.data.stream === true || route.isStreamPath?.(path) === true) {
|
|
152
|
+
parsed.data.stream = true;
|
|
153
|
+
return proxyChatStream(parsed.data, body, model, requestedModel, config, sourceApi, targetApi, requestId, opts);
|
|
154
|
+
}
|
|
155
|
+
const targetBody = transformers_1.adapters[targetApi].fromCanonicalRequest(parsed.data);
|
|
156
|
+
const targetRoute = transformers_1.PROVIDER_ROUTES[targetApi].find((r) => r.kind === "chat");
|
|
157
|
+
const url = `${config.targetBaseUrl}${targetRoute.requestPath(model)}`;
|
|
158
|
+
logger_1.logger.info({ requestId, sourceApi, targetApi, url, model, requestedModel }, "Forwarding request to upstream");
|
|
159
|
+
const upstream = await (0, http_client_1.postJson)({
|
|
160
|
+
url,
|
|
161
|
+
headers: buildAuthHeaders(targetApi, config.targetApiKey),
|
|
162
|
+
body: targetBody,
|
|
163
|
+
timeoutMs: config.upstreamTimeoutMs,
|
|
164
|
+
retries: config.upstreamRetries,
|
|
165
|
+
requestId,
|
|
166
|
+
}).catch((err) => {
|
|
167
|
+
throw err instanceof errors_1.UpstreamError
|
|
168
|
+
? toClientFacing(err, targetApi)
|
|
169
|
+
: err;
|
|
170
|
+
});
|
|
171
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
172
|
+
const err = new errors_1.UpstreamError(`Upstream ${targetApi} returned ${upstream.status}`, upstream.status, upstream.body);
|
|
173
|
+
err.retryAfterSeconds = upstream.retryAfterSeconds;
|
|
174
|
+
throw toClientFacing(err, targetApi);
|
|
175
|
+
}
|
|
176
|
+
// Target → Canonical → Source. §3.5: replies carry the model name the
|
|
177
|
+
// client asked for, not the mapped name sent upstream.
|
|
178
|
+
const canonicalResp = transformers_1.adapters[targetApi].toCanonicalResponse(upstream.body, model);
|
|
179
|
+
canonicalResp.model = requestedModel;
|
|
180
|
+
// Adapter bug detection (non-production only): canonical response
|
|
181
|
+
// should always be schema-valid
|
|
182
|
+
if (process.env.NODE_ENV !== "production") {
|
|
183
|
+
const respParsed = canonical_schema_1.canonicalResponseSchema.safeParse(canonicalResp);
|
|
184
|
+
if (!respParsed.success) {
|
|
185
|
+
logger_1.logger.error({ requestId, issues: respParsed.error.issues }, "Canonical response failed schema validation — adapter bug");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
logger_1.logger.info({
|
|
189
|
+
requestId,
|
|
190
|
+
sourceApi,
|
|
191
|
+
targetApi,
|
|
192
|
+
model,
|
|
193
|
+
requestedModel,
|
|
194
|
+
inputTokens: canonicalResp.usage?.inputTokens,
|
|
195
|
+
outputTokens: canonicalResp.usage?.outputTokens,
|
|
196
|
+
finishReason: canonicalResp.finishReason,
|
|
197
|
+
upstreamDurationMs: upstream.durationMs,
|
|
198
|
+
}, "Request completed");
|
|
199
|
+
const sourceBody = transformers_1.adapters[sourceApi].fromCanonicalResponse(canonicalResp);
|
|
200
|
+
return { body: sourceBody };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Streaming chat flow (spec §3.2): target decoder + source encoder,
|
|
204
|
+
* postStream for upstream bytes, runStreamPipeline for the pipe.
|
|
205
|
+
* All four adapters stream since v1.1 Task 7 — the v1.0.1
|
|
206
|
+
* streaming-refusal path is gone. A Gemini target uses the route's
|
|
207
|
+
* streamRequestPath (`:streamGenerateContent?alt=sse`); the others
|
|
208
|
+
* stream on the chat path.
|
|
209
|
+
*/
|
|
210
|
+
async function proxyChatStream(canonical, sourceRequest, model, requestedModel, config, sourceApi, targetApi, requestId, opts) {
|
|
211
|
+
const decoder = transformers_1.adapters[targetApi].createStreamDecoder(model);
|
|
212
|
+
const encoder = transformers_1.adapters[sourceApi].createStreamEncoder({
|
|
213
|
+
sourceRequest,
|
|
214
|
+
query: opts?.query ?? {},
|
|
215
|
+
requestedModel,
|
|
216
|
+
});
|
|
217
|
+
const targetBody = transformers_1.adapters[targetApi].fromCanonicalRequest(canonical);
|
|
218
|
+
const targetRoute = transformers_1.PROVIDER_ROUTES[targetApi].find((r) => r.kind === "chat");
|
|
219
|
+
const url = `${config.targetBaseUrl}${targetRoute.streamRequestPath?.(model) ?? targetRoute.requestPath(model)}`;
|
|
220
|
+
logger_1.logger.info({ requestId, sourceApi, targetApi, url, model, requestedModel, stream: true }, "Forwarding request to upstream");
|
|
221
|
+
// Client disconnect aborts the upstream request; upstreamAc lets the
|
|
222
|
+
// pipeline's end (terminal event, failure) tear it down too — a bare
|
|
223
|
+
// body cancel doesn't reach the request on every transport.
|
|
224
|
+
const upstreamAc = new AbortController();
|
|
225
|
+
const upstream = await (0, http_client_1.postStream)({
|
|
226
|
+
url,
|
|
227
|
+
headers: buildAuthHeaders(targetApi, config.targetApiKey),
|
|
228
|
+
body: targetBody,
|
|
229
|
+
timeoutMs: config.upstreamTimeoutMs,
|
|
230
|
+
retries: config.upstreamRetries,
|
|
231
|
+
requestId,
|
|
232
|
+
signal: opts?.signal
|
|
233
|
+
? AbortSignal.any([opts.signal, upstreamAc.signal])
|
|
234
|
+
: upstreamAc.signal,
|
|
235
|
+
}).catch((err) => {
|
|
236
|
+
throw err instanceof errors_1.UpstreamError
|
|
237
|
+
? toClientFacing(err, targetApi)
|
|
238
|
+
: err;
|
|
239
|
+
});
|
|
240
|
+
// Watch the event stream for the completion log line — the pipeline
|
|
241
|
+
// yields only encoded bytes, so the decoder is wrapped. Chunk contents
|
|
242
|
+
// are never logged (§3.2); only usage and the finish reason are.
|
|
243
|
+
const observed = [];
|
|
244
|
+
const recordingDecoder = {
|
|
245
|
+
feed(chunk) {
|
|
246
|
+
const events = decoder.feed(chunk);
|
|
247
|
+
observed.push(...events);
|
|
248
|
+
return events;
|
|
249
|
+
},
|
|
250
|
+
end() {
|
|
251
|
+
const events = decoder.end();
|
|
252
|
+
observed.push(...events);
|
|
253
|
+
return events;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
const signal = opts?.signal ?? new AbortController().signal;
|
|
257
|
+
const chunks = (async function* () {
|
|
258
|
+
try {
|
|
259
|
+
yield* (0, stream_pipeline_1.runStreamPipeline)({
|
|
260
|
+
upstream: upstream.body,
|
|
261
|
+
decoder: recordingDecoder,
|
|
262
|
+
encoder,
|
|
263
|
+
idleMs: config.upstreamStreamIdleMs,
|
|
264
|
+
signal,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
// Whatever ended the pipe — terminal event, in-stream error,
|
|
269
|
+
// client disconnect — the upstream request is done with.
|
|
270
|
+
upstreamAc.abort();
|
|
271
|
+
}
|
|
272
|
+
const last = observed.at(-1);
|
|
273
|
+
if (last?.type === "message_end") {
|
|
274
|
+
const usageEvent = observed.find((e) => e.type === "usage");
|
|
275
|
+
logger_1.logger.info({
|
|
276
|
+
requestId,
|
|
277
|
+
sourceApi,
|
|
278
|
+
targetApi,
|
|
279
|
+
model,
|
|
280
|
+
requestedModel,
|
|
281
|
+
stream: true,
|
|
282
|
+
inputTokens: usageEvent?.type === "usage"
|
|
283
|
+
? usageEvent.usage.inputTokens
|
|
284
|
+
: undefined,
|
|
285
|
+
outputTokens: usageEvent?.type === "usage"
|
|
286
|
+
? usageEvent.usage.outputTokens
|
|
287
|
+
: undefined,
|
|
288
|
+
finishReason: last.finishReason,
|
|
289
|
+
upstreamDurationMs: upstream.durationMs,
|
|
290
|
+
}, "Request completed");
|
|
291
|
+
}
|
|
292
|
+
})();
|
|
293
|
+
return { stream: { contentType: encoder.contentType, chunks } };
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* GET /models flow — no request body, response transforms through
|
|
297
|
+
* canonical like chat does.
|
|
298
|
+
*/
|
|
299
|
+
async function proxyModelList(route, config, sourceApi, targetApi, requestId) {
|
|
300
|
+
const targetRoute = transformers_1.PROVIDER_ROUTES[targetApi].find((r) => r.kind === "models");
|
|
301
|
+
const url = `${config.targetBaseUrl}${targetRoute.requestPath()}`;
|
|
302
|
+
logger_1.logger.info({ requestId, sourceApi, targetApi, url }, "Forwarding models list request to upstream");
|
|
303
|
+
const upstream = await (0, http_client_1.getJson)({
|
|
304
|
+
url,
|
|
305
|
+
headers: buildAuthHeaders(targetApi, config.targetApiKey),
|
|
306
|
+
timeoutMs: config.upstreamTimeoutMs,
|
|
307
|
+
retries: config.upstreamRetries,
|
|
308
|
+
requestId,
|
|
309
|
+
}).catch((err) => {
|
|
310
|
+
throw err instanceof errors_1.UpstreamError
|
|
311
|
+
? toClientFacing(err, targetApi)
|
|
312
|
+
: err;
|
|
313
|
+
});
|
|
314
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
315
|
+
const err = new errors_1.UpstreamError(`Upstream ${targetApi} returned ${upstream.status}`, upstream.status, upstream.body);
|
|
316
|
+
err.retryAfterSeconds = upstream.retryAfterSeconds;
|
|
317
|
+
throw toClientFacing(err, targetApi);
|
|
318
|
+
}
|
|
319
|
+
const canonical = transformers_1.adapters[targetApi].toCanonicalModelList(upstream.body);
|
|
320
|
+
if (process.env.NODE_ENV !== "production") {
|
|
321
|
+
const parsed = canonical_schema_1.canonicalModelListSchema.safeParse(canonical);
|
|
322
|
+
if (!parsed.success) {
|
|
323
|
+
logger_1.logger.error({ requestId, issues: parsed.error.issues }, "Canonical model list failed schema validation — adapter bug");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
logger_1.logger.info({ requestId, sourceApi, targetApi, modelCount: canonical.models.length }, "Models list completed");
|
|
327
|
+
const sourceBody = transformers_1.adapters[sourceApi].fromCanonicalModelList(canonical);
|
|
328
|
+
return { body: sourceBody };
|
|
329
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runStreamPipeline = runStreamPipeline;
|
|
4
|
+
const canonical_error_1 = require("../utils/canonical-error");
|
|
5
|
+
/**
|
|
6
|
+
* upstream bytes → decoder → canonical events → encoder → client bytes
|
|
7
|
+
* (spec §3.2). Provider-agnostic; the adapters own all dialect knowledge.
|
|
8
|
+
*
|
|
9
|
+
* Termination rules: an upstream read error, undecodable data or an idle
|
|
10
|
+
* gap longer than `idleMs` ends the stream with the source dialect's
|
|
11
|
+
* in-stream error form (kind `timeout` for idle, `upstream` otherwise) —
|
|
12
|
+
* the client never sees a truncated message followed by silence. An
|
|
13
|
+
* encoded `message_end` or `error` event is terminal: the encoder's tail
|
|
14
|
+
* is written, the upstream read is cancelled and the pipe ends, even
|
|
15
|
+
* when the upstream keeps its body open. Aborting `signal` cancels the
|
|
16
|
+
* upstream read and ends the pipe quietly.
|
|
17
|
+
*/
|
|
18
|
+
async function* runStreamPipeline(opts) {
|
|
19
|
+
const { upstream, decoder, encoder, idleMs, signal } = opts;
|
|
20
|
+
const reader = upstream.getReader();
|
|
21
|
+
const cancel = () => {
|
|
22
|
+
reader.cancel().catch(() => { });
|
|
23
|
+
};
|
|
24
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
25
|
+
/** Terminal events never produce empty output — the encoder writes the
|
|
26
|
+
* provider's in-stream error form, then the stream ends. */
|
|
27
|
+
function* fail(kind, message) {
|
|
28
|
+
yield encoder.encode({
|
|
29
|
+
type: "error",
|
|
30
|
+
error: (0, canonical_error_1.makeCanonicalError)(kind, message),
|
|
31
|
+
});
|
|
32
|
+
const tail = encoder.end();
|
|
33
|
+
if (tail !== "")
|
|
34
|
+
yield tail;
|
|
35
|
+
}
|
|
36
|
+
/** Yields encoded bytes; returns true at the first terminal event
|
|
37
|
+
* (message_end or error — nothing may be encoded after it). */
|
|
38
|
+
function* encodeAll(events) {
|
|
39
|
+
for (const event of events) {
|
|
40
|
+
const encoded = encoder.encode(event);
|
|
41
|
+
if (encoded !== "")
|
|
42
|
+
yield encoded;
|
|
43
|
+
if (event.type === "message_end" || event.type === "error") {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
for (;;) {
|
|
51
|
+
if (signal.aborted) {
|
|
52
|
+
cancel();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let timer;
|
|
56
|
+
let chunk;
|
|
57
|
+
try {
|
|
58
|
+
chunk = await Promise.race([
|
|
59
|
+
reader.read(),
|
|
60
|
+
new Promise((resolve) => {
|
|
61
|
+
timer = setTimeout(() => resolve("idle"), idleMs);
|
|
62
|
+
}),
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
cancel();
|
|
68
|
+
yield* fail("upstream", "Upstream stream read failed");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
if (signal.aborted) {
|
|
73
|
+
cancel();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (chunk === "idle") {
|
|
77
|
+
cancel();
|
|
78
|
+
yield* fail("timeout", `Upstream stream idle for over ${idleMs}ms`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let events;
|
|
82
|
+
if (chunk.done) {
|
|
83
|
+
try {
|
|
84
|
+
events = decoder.end();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
yield* fail("upstream", "Upstream sent a malformed stream");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
yield* encodeAll(events);
|
|
91
|
+
const tail = encoder.end();
|
|
92
|
+
if (tail !== "")
|
|
93
|
+
yield tail;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
events = decoder.feed(chunk.value);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
cancel();
|
|
101
|
+
yield* fail("upstream", "Upstream sent undecodable stream data");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (yield* encodeAll(events)) {
|
|
105
|
+
// Terminal event: end now even if the upstream keeps the body
|
|
106
|
+
// open — nothing may follow message_end or error.
|
|
107
|
+
const tail = encoder.end();
|
|
108
|
+
if (tail !== "")
|
|
109
|
+
yield tail;
|
|
110
|
+
cancel();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
signal.removeEventListener("abort", cancel);
|
|
117
|
+
cancel();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toClientError = toClientError;
|
|
4
|
+
const transformers_1 = require("../transformers");
|
|
5
|
+
const canonical_error_1 = require("../utils/canonical-error");
|
|
6
|
+
/**
|
|
7
|
+
* The spec §3.3 upstream → client mapping, applied row by row.
|
|
8
|
+
*
|
|
9
|
+
* The *target* adapter parses the upstream body for its kind and message
|
|
10
|
+
* (`toCanonicalError` never throws; unrecognised bodies get the fallback
|
|
11
|
+
* message). This function then decides what the client sees:
|
|
12
|
+
*
|
|
13
|
+
* - authentication/permission → 502 "upstream" with the credentials
|
|
14
|
+
* message (D1: the upstream rejected the *proxy's* key — a 401 would
|
|
15
|
+
* wrongly tell the client its own key is bad). Decided on the parsed
|
|
16
|
+
* kind, not the raw status, because e.g. Gemini reports a bad key as
|
|
17
|
+
* 400 + API_KEY_INVALID.
|
|
18
|
+
* - 400/422 → 400 invalid_request, 404 → not_found, 413 →
|
|
19
|
+
* request_too_large, 429 → rate_limit, 503/529 → unavailable — the
|
|
20
|
+
* message passes through, `Retry-After` too when present.
|
|
21
|
+
* - every other 5xx → 502 upstream (kindFromStatus calls a bare 500
|
|
22
|
+
* "internal", but for the client it is an upstream condition, not a
|
|
23
|
+
* proxy bug).
|
|
24
|
+
*/
|
|
25
|
+
function toClientError(target, upstreamStatus, upstreamBody, retryAfterSeconds) {
|
|
26
|
+
const upstream = transformers_1.adapters[target].toCanonicalError(upstreamStatus, upstreamBody);
|
|
27
|
+
if (upstream.kind === "authentication" || upstream.kind === "permission") {
|
|
28
|
+
return (0, canonical_error_1.makeCanonicalError)("upstream", `Upstream ${target} rejected the proxy's credentials (${upstreamStatus})`);
|
|
29
|
+
}
|
|
30
|
+
const kind = upstreamStatus >= 500 && upstream.kind !== "unavailable"
|
|
31
|
+
? "upstream"
|
|
32
|
+
: upstream.kind;
|
|
33
|
+
return (0, canonical_error_1.makeCanonicalError)(kind, upstream.message, retryAfterSeconds);
|
|
34
|
+
}
|