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
package/dist/app.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.app = void 0;
|
|
7
|
+
const express_1 = __importDefault(require("express"));
|
|
8
|
+
const request_logger_1 = require("./middleware/request-logger");
|
|
9
|
+
const resolve_source_1 = require("./middleware/resolve-source");
|
|
10
|
+
const error_handler_1 = require("./middleware/error-handler");
|
|
11
|
+
const require_api_key_1 = require("./middleware/require-api-key");
|
|
12
|
+
const proxy_1 = require("./routes/proxy");
|
|
13
|
+
const app = (0, express_1.default)();
|
|
14
|
+
exports.app = app;
|
|
15
|
+
// 1. Request logger - logs every incoming request
|
|
16
|
+
app.use(request_logger_1.requestLogger);
|
|
17
|
+
// 1a. Source resolver - records res.locals.sourceApi on provider paths so
|
|
18
|
+
// the error handler renders errors (auth, body-parser, upstream) in the
|
|
19
|
+
// client's own dialect.
|
|
20
|
+
app.use((0, resolve_source_1.resolveSource)());
|
|
21
|
+
// 2. Body parser - parses application/json. LLM payloads (base64
|
|
22
|
+
// images, long context) routinely exceed the 100kb default.
|
|
23
|
+
app.use(express_1.default.json({ limit: process.env.MAX_BODY_SIZE || "10mb" }));
|
|
24
|
+
// 3. Routes
|
|
25
|
+
app.get("/health", (_req, res) => {
|
|
26
|
+
res.status(200).json({ status: "ok" });
|
|
27
|
+
});
|
|
28
|
+
// 3a. Client auth — active only when PROXY_API_KEY is set.
|
|
29
|
+
// /health above stays open for probes.
|
|
30
|
+
app.use((0, require_api_key_1.requireApiKey)(process.env.PROXY_API_KEY || undefined));
|
|
31
|
+
// 3b. Proxy routes - transforms and forwards LLM API requests
|
|
32
|
+
app.use(proxy_1.proxyRouter);
|
|
33
|
+
// 4. 404 handler - catch unmatched routes
|
|
34
|
+
app.use((_req, res) => {
|
|
35
|
+
res
|
|
36
|
+
.status(404)
|
|
37
|
+
.json({ error: { message: "Not Found", code: "NOT_FOUND", status: 404 } });
|
|
38
|
+
});
|
|
39
|
+
// 5. Error handler - must be last
|
|
40
|
+
app.use(error_handler_1.errorHandler);
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.parseCliArgs = parseCliArgs;
|
|
5
|
+
exports.presetEnv = presetEnv;
|
|
6
|
+
exports.overriddenKeys = overriddenKeys;
|
|
7
|
+
exports.clientInstructions = clientInstructions;
|
|
8
|
+
exports.main = main;
|
|
9
|
+
const node_util_1 = require("node:util");
|
|
10
|
+
const env_config_1 = require("./config/env-config");
|
|
11
|
+
const index_1 = require("./index");
|
|
12
|
+
const PRESETS = ["claude-code", "openai-apps"];
|
|
13
|
+
const PROVIDERS = ["openai", "gemini", "ollama", "anthropic"];
|
|
14
|
+
/**
|
|
15
|
+
* Parses CLI arguments. Unknown flags and invalid values throw an error
|
|
16
|
+
* naming the flag; upstream keys only ever come from the environment.
|
|
17
|
+
*/
|
|
18
|
+
function parseCliArgs(argv) {
|
|
19
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
20
|
+
args: argv,
|
|
21
|
+
strict: true,
|
|
22
|
+
options: {
|
|
23
|
+
preset: { type: "string" },
|
|
24
|
+
target: { type: "string" },
|
|
25
|
+
model: { type: "string" },
|
|
26
|
+
"base-url": { type: "string" },
|
|
27
|
+
port: { type: "string" },
|
|
28
|
+
help: { type: "boolean" },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const args = {};
|
|
32
|
+
if (values.preset !== undefined) {
|
|
33
|
+
if (!PRESETS.includes(values.preset)) {
|
|
34
|
+
throw new Error(`--preset must be one of: ${PRESETS.join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
args.preset = values.preset;
|
|
37
|
+
}
|
|
38
|
+
if (values.target !== undefined) {
|
|
39
|
+
if (!PROVIDERS.includes(values.target)) {
|
|
40
|
+
throw new Error(`--target must be one of: ${PROVIDERS.join(", ")}`);
|
|
41
|
+
}
|
|
42
|
+
args.target = values.target;
|
|
43
|
+
}
|
|
44
|
+
if (values.model !== undefined)
|
|
45
|
+
args.model = values.model;
|
|
46
|
+
if (values["base-url"] !== undefined)
|
|
47
|
+
args.baseUrl = values["base-url"];
|
|
48
|
+
if (values.port !== undefined) {
|
|
49
|
+
const port = Number(values.port);
|
|
50
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
51
|
+
throw new Error("--port must be an integer between 1 and 65535");
|
|
52
|
+
}
|
|
53
|
+
args.port = port;
|
|
54
|
+
}
|
|
55
|
+
if (values.help)
|
|
56
|
+
args.help = true;
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The provider the proxy will target: `--target` when given, else a valid
|
|
61
|
+
* TARGET_API already in the env. Used wherever a preset/flag value depends
|
|
62
|
+
* on which target is effective.
|
|
63
|
+
*/
|
|
64
|
+
function effectiveTarget(args, env) {
|
|
65
|
+
if (args.target !== undefined)
|
|
66
|
+
return args.target;
|
|
67
|
+
return PROVIDERS.includes(env.TARGET_API)
|
|
68
|
+
? env.TARGET_API
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Every env value the flags/preset would set, before "user env wins" is
|
|
73
|
+
* applied. `env` is still consulted to resolve the effective target for
|
|
74
|
+
* --base-url and the ollama timeout.
|
|
75
|
+
*/
|
|
76
|
+
function wantedEnv(args, env) {
|
|
77
|
+
const wanted = {};
|
|
78
|
+
if (args.preset === "claude-code") {
|
|
79
|
+
wanted.SOURCE_API = "anthropic";
|
|
80
|
+
}
|
|
81
|
+
else if (args.preset === "openai-apps") {
|
|
82
|
+
wanted.SOURCE_API = "openai";
|
|
83
|
+
}
|
|
84
|
+
if (args.target !== undefined) {
|
|
85
|
+
wanted.TARGET_API = args.target;
|
|
86
|
+
}
|
|
87
|
+
if (args.preset === "claude-code") {
|
|
88
|
+
if (args.model !== undefined) {
|
|
89
|
+
wanted.MODEL_MAP = `claude-*=${args.model}`;
|
|
90
|
+
}
|
|
91
|
+
wanted.TARGET_CONTEXT_WINDOW = "32768";
|
|
92
|
+
if (effectiveTarget(args, env) === "ollama") {
|
|
93
|
+
wanted.UPSTREAM_TIMEOUT_MS = "600000";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (args.baseUrl !== undefined) {
|
|
97
|
+
const target = effectiveTarget(args, env);
|
|
98
|
+
if (target === undefined) {
|
|
99
|
+
throw new Error("--base-url requires --target or TARGET_API in the environment");
|
|
100
|
+
}
|
|
101
|
+
wanted[`${target.toUpperCase()}_API_BASE_URL`] = args.baseUrl;
|
|
102
|
+
}
|
|
103
|
+
if (args.port !== undefined) {
|
|
104
|
+
wanted.PORT = String(args.port);
|
|
105
|
+
}
|
|
106
|
+
return wanted;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Environment values implied by the flags. Keys the user's own env already
|
|
110
|
+
* sets (a non-empty value) are not returned — user env always wins and
|
|
111
|
+
* presets are only ever env values, never changes to config defaults.
|
|
112
|
+
*/
|
|
113
|
+
function presetEnv(args, env) {
|
|
114
|
+
return Object.fromEntries(Object.entries(wantedEnv(args, env)).filter(([key]) => !env[key]));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Names of the preset/flag env keys that lost to values already set in
|
|
118
|
+
* `env` — everything the flags/preset wanted that presetEnv(args, env)
|
|
119
|
+
* dropped. Names only, never values.
|
|
120
|
+
*/
|
|
121
|
+
function overriddenKeys(args, env) {
|
|
122
|
+
const applied = presetEnv(args, env);
|
|
123
|
+
return Object.keys(wantedEnv(args, env)).filter((key) => !(key in applied));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The `export …` lines the user pastes into their shell to point the client
|
|
127
|
+
* app at this proxy. PROXY_API_KEY is the proxy's own client-facing key, not
|
|
128
|
+
* an upstream key.
|
|
129
|
+
*/
|
|
130
|
+
function clientInstructions(args, port, proxyApiKey) {
|
|
131
|
+
const token = proxyApiKey ?? "babelgate";
|
|
132
|
+
if (args.preset === "claude-code") {
|
|
133
|
+
return [
|
|
134
|
+
`export ANTHROPIC_BASE_URL=http://localhost:${port}`,
|
|
135
|
+
`export ANTHROPIC_AUTH_TOKEN=${token}`,
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
if (args.preset === "openai-apps") {
|
|
139
|
+
return [
|
|
140
|
+
`export OPENAI_BASE_URL=http://localhost:${port}/v1`,
|
|
141
|
+
`export OPENAI_API_KEY=${token}`,
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
function usageLines() {
|
|
147
|
+
return [
|
|
148
|
+
"Usage: babelgate [--preset <claude-code|openai-apps>] [--target <provider>]",
|
|
149
|
+
" [--model <name>] [--base-url <url>] [--port <n>] [--help]",
|
|
150
|
+
"",
|
|
151
|
+
"Upstream API keys come only from the environment",
|
|
152
|
+
"(OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, OLLAMA_API_KEY).",
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
/** The preset whose export lines match a resolved source API. */
|
|
156
|
+
function presetForSource(sourceApi) {
|
|
157
|
+
if (sourceApi === "anthropic")
|
|
158
|
+
return "claude-code";
|
|
159
|
+
if (sourceApi === "openai")
|
|
160
|
+
return "openai-apps";
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
async function main(argv) {
|
|
164
|
+
try {
|
|
165
|
+
const args = parseCliArgs(argv);
|
|
166
|
+
if (args.help) {
|
|
167
|
+
for (const line of usageLines())
|
|
168
|
+
console.log(line);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const envValues = presetEnv(args, process.env);
|
|
172
|
+
const overridden = overriddenKeys(args, process.env);
|
|
173
|
+
for (const [key, value] of Object.entries(envValues)) {
|
|
174
|
+
process.env[key] = value;
|
|
175
|
+
}
|
|
176
|
+
if (overridden.length > 0) {
|
|
177
|
+
console.error(`babelgate: your environment overrides ${overridden.join(", ")} ` +
|
|
178
|
+
"(flags and presets never replace values you set)");
|
|
179
|
+
}
|
|
180
|
+
if (args.preset === "claude-code" &&
|
|
181
|
+
args.model === undefined &&
|
|
182
|
+
!process.env.MODEL_MAP) {
|
|
183
|
+
console.error("babelgate: no --model given; claude-* model names are forwarded " +
|
|
184
|
+
"to the target unchanged");
|
|
185
|
+
}
|
|
186
|
+
await (0, index_1.startServer)();
|
|
187
|
+
const config = (0, env_config_1.getConfig)();
|
|
188
|
+
if (args.preset !== undefined) {
|
|
189
|
+
// The export lines must match the server that actually started: the
|
|
190
|
+
// user's env may have overridden the preset's SOURCE_API.
|
|
191
|
+
for (const line of clientInstructions({ ...args, preset: presetForSource(config.sourceApi) }, config.port, config.proxyApiKey)) {
|
|
192
|
+
console.log(line);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (require.main === module) {
|
|
202
|
+
void main(process.argv.slice(2));
|
|
203
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadConfig = loadConfig;
|
|
7
|
+
exports.getConfig = getConfig;
|
|
8
|
+
exports.resetConfig = resetConfig;
|
|
9
|
+
const dotenv_1 = __importDefault(require("dotenv"));
|
|
10
|
+
const model_map_1 = require("../utils/model-map");
|
|
11
|
+
const errors_1 = require("../utils/errors");
|
|
12
|
+
dotenv_1.default.config();
|
|
13
|
+
const VALID_PROVIDERS = ["openai", "gemini", "ollama", "anthropic"];
|
|
14
|
+
function isValidProvider(value) {
|
|
15
|
+
return VALID_PROVIDERS.includes(value.toLowerCase());
|
|
16
|
+
}
|
|
17
|
+
function normalizeProvider(value) {
|
|
18
|
+
return value.toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Loads configuration from environment variables
|
|
22
|
+
*/
|
|
23
|
+
function loadConfig() {
|
|
24
|
+
if (!process.env.SOURCE_API || !process.env.TARGET_API) {
|
|
25
|
+
throw new Error("SOURCE_API and TARGET_API environment variables are required");
|
|
26
|
+
}
|
|
27
|
+
const sourceApi = process.env.SOURCE_API;
|
|
28
|
+
const targetApi = process.env.TARGET_API;
|
|
29
|
+
if (!isValidProvider(sourceApi)) {
|
|
30
|
+
throw new Error(`SOURCE_API must be one of: ${VALID_PROVIDERS.join(", ")}`);
|
|
31
|
+
}
|
|
32
|
+
if (!isValidProvider(targetApi)) {
|
|
33
|
+
throw new Error(`TARGET_API must be one of: ${VALID_PROVIDERS.join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
const normalizedTarget = normalizeProvider(targetApi);
|
|
36
|
+
// Get API key based on target
|
|
37
|
+
const apiKeyEnvVars = {
|
|
38
|
+
openai: "OPENAI_API_KEY",
|
|
39
|
+
gemini: "GEMINI_API_KEY",
|
|
40
|
+
ollama: "OLLAMA_API_KEY",
|
|
41
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
42
|
+
};
|
|
43
|
+
const targetApiKey = process.env[apiKeyEnvVars[normalizedTarget]];
|
|
44
|
+
// Ollama doesn't require an API key
|
|
45
|
+
if (!targetApiKey && normalizedTarget !== "ollama") {
|
|
46
|
+
throw new Error(`${apiKeyEnvVars[normalizedTarget]} environment variable is required for target API ${targetApi}`);
|
|
47
|
+
}
|
|
48
|
+
// Upstream base URL follows the {PROVIDER}_API_BASE_URL convention
|
|
49
|
+
const baseUrlEnvVar = `${normalizedTarget.toUpperCase()}_API_BASE_URL`;
|
|
50
|
+
const targetBaseUrl = process.env[baseUrlEnvVar];
|
|
51
|
+
if (!targetBaseUrl) {
|
|
52
|
+
throw new Error(`${baseUrlEnvVar} environment variable is required for target API ${targetApi}`);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
sourceApi: normalizeProvider(sourceApi),
|
|
56
|
+
targetApi: normalizedTarget,
|
|
57
|
+
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
|
|
58
|
+
targetApiKey,
|
|
59
|
+
targetBaseUrl: targetBaseUrl.replace(/\/$/, ""),
|
|
60
|
+
upstreamTimeoutMs: process.env.UPSTREAM_TIMEOUT_MS
|
|
61
|
+
? parseInt(process.env.UPSTREAM_TIMEOUT_MS, 10)
|
|
62
|
+
: 30_000,
|
|
63
|
+
upstreamRetries: process.env.UPSTREAM_RETRIES
|
|
64
|
+
? parseInt(process.env.UPSTREAM_RETRIES, 10)
|
|
65
|
+
: 1,
|
|
66
|
+
upstreamStreamIdleMs: process.env.UPSTREAM_STREAM_IDLE_MS
|
|
67
|
+
? parseInt(process.env.UPSTREAM_STREAM_IDLE_MS, 10)
|
|
68
|
+
: 300_000,
|
|
69
|
+
proxyApiKey: process.env.PROXY_API_KEY || undefined,
|
|
70
|
+
modelMap: (0, model_map_1.parseModelMap)(process.env.MODEL_MAP),
|
|
71
|
+
targetContextWindow: parseTargetContextWindow(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function parseTargetContextWindow() {
|
|
75
|
+
const raw = process.env.TARGET_CONTEXT_WINDOW;
|
|
76
|
+
if (raw === undefined || raw === "")
|
|
77
|
+
return undefined;
|
|
78
|
+
const value = Number(raw);
|
|
79
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
80
|
+
throw new errors_1.ConfigurationError("TARGET_CONTEXT_WINDOW must be a positive integer");
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get the current configuration (lazy-loaded)
|
|
86
|
+
*/
|
|
87
|
+
let _config = null;
|
|
88
|
+
function getConfig() {
|
|
89
|
+
if (!_config) {
|
|
90
|
+
_config = loadConfig();
|
|
91
|
+
}
|
|
92
|
+
return _config;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Reset config (for testing)
|
|
96
|
+
*/
|
|
97
|
+
function resetConfig() {
|
|
98
|
+
_config = null;
|
|
99
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startServer = startServer;
|
|
4
|
+
const app_1 = require("./app");
|
|
5
|
+
const env_config_1 = require("./config/env-config");
|
|
6
|
+
const logger_1 = require("./utils/logger");
|
|
7
|
+
/**
|
|
8
|
+
* Starts the proxy and resolves once it is listening. Importing this module
|
|
9
|
+
* has no side effects — the server only starts when startServer() is called
|
|
10
|
+
* (or when index.js is run directly, for `npm start` / `npm run dev`).
|
|
11
|
+
*/
|
|
12
|
+
function startServer() {
|
|
13
|
+
const config = (0, env_config_1.getConfig)();
|
|
14
|
+
const port = config.port;
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
// No callback to app.listen: Express 5 registers a passed callback on
|
|
17
|
+
// both "listening" and "error", which would resolve on a failed bind.
|
|
18
|
+
const server = app_1.app.listen(port);
|
|
19
|
+
// Graceful shutdown — drain in-flight requests instead of killing them
|
|
20
|
+
// mid-generation. server.close() stops accepting new connections and
|
|
21
|
+
// resolves once active requests finish; the timer is the escape hatch.
|
|
22
|
+
function shutdown(signal) {
|
|
23
|
+
logger_1.logger.info({ signal }, "Shutting down");
|
|
24
|
+
server.close(() => {
|
|
25
|
+
logger_1.logger.info("All connections drained");
|
|
26
|
+
process.exit(0);
|
|
27
|
+
});
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
logger_1.logger.warn("Forcing exit — connections did not drain in time");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}, 10_000).unref();
|
|
32
|
+
}
|
|
33
|
+
server.once("listening", () => {
|
|
34
|
+
logger_1.logger.info({
|
|
35
|
+
port,
|
|
36
|
+
sourceApi: config.sourceApi,
|
|
37
|
+
targetApi: config.targetApi,
|
|
38
|
+
targetBaseUrl: config.targetBaseUrl,
|
|
39
|
+
targetApiKey: config.targetApiKey ? "****" : "Not configured",
|
|
40
|
+
}, "Server started");
|
|
41
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
42
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
43
|
+
resolve(server);
|
|
44
|
+
});
|
|
45
|
+
server.once("error", reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (require.main === module) {
|
|
49
|
+
try {
|
|
50
|
+
(0, env_config_1.getConfig)();
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
logger_1.logger.fatal({ err }, "Invalid configuration — check required env vars");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
startServer().catch((err) => {
|
|
57
|
+
logger_1.logger.fatal({ err }, "Failed to start server");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.errorHandler = void 0;
|
|
4
|
+
const logger_1 = require("../utils/logger");
|
|
5
|
+
const errors_1 = require("../utils/errors");
|
|
6
|
+
const transformers_1 = require("../transformers");
|
|
7
|
+
const canonical_error_1 = require("../utils/canonical-error");
|
|
8
|
+
/**
|
|
9
|
+
* The provider's own error classification (type/code) and message,
|
|
10
|
+
* extracted from an upstream body of whatever shape. Read-only and
|
|
11
|
+
* generic — no provider-specific branches.
|
|
12
|
+
*/
|
|
13
|
+
function summarizeUpstreamBody(body) {
|
|
14
|
+
if (!body || typeof body !== "object")
|
|
15
|
+
return {};
|
|
16
|
+
const err = body.error;
|
|
17
|
+
// Ollama-style: { error: "<message>" }
|
|
18
|
+
if (typeof err === "string")
|
|
19
|
+
return { message: err };
|
|
20
|
+
if (!err || typeof err !== "object")
|
|
21
|
+
return {};
|
|
22
|
+
const e = err;
|
|
23
|
+
return {
|
|
24
|
+
...(typeof e.type === "string" && { type: e.type }),
|
|
25
|
+
...(typeof e.code === "string"
|
|
26
|
+
? { code: e.code }
|
|
27
|
+
: typeof e.status === "string"
|
|
28
|
+
? { code: e.status }
|
|
29
|
+
: {}),
|
|
30
|
+
...(typeof e.message === "string" && { message: e.message }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Any error → CanonicalError for dialect rendering (spec §3.3):
|
|
35
|
+
* ProxyError subclasses map via toCanonical(); body-parser errors map by
|
|
36
|
+
* their `type`; anything else is `internal` with a generic message in
|
|
37
|
+
* production.
|
|
38
|
+
*/
|
|
39
|
+
function toCanonical(err, isProduction) {
|
|
40
|
+
if (err instanceof errors_1.ProxyError)
|
|
41
|
+
return err.toCanonical();
|
|
42
|
+
const type = err?.type;
|
|
43
|
+
if (type === "entity.too.large") {
|
|
44
|
+
const limit = err.limit;
|
|
45
|
+
return (0, canonical_error_1.makeCanonicalError)("request_too_large", `Request body too large (limit: ${limit})`);
|
|
46
|
+
}
|
|
47
|
+
if (type === "entity.parse.failed") {
|
|
48
|
+
return (0, canonical_error_1.makeCanonicalError)("invalid_request", "Malformed JSON body");
|
|
49
|
+
}
|
|
50
|
+
return (0, canonical_error_1.makeCanonicalError)("internal", isProduction || !(err instanceof Error)
|
|
51
|
+
? "Internal Server Error"
|
|
52
|
+
: err.message);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* §3.3 log policy: an upstream failure is logged at warn with the status,
|
|
56
|
+
* the provider's own type/code and at most the first 200 characters of
|
|
57
|
+
* the message — and no message at all for auth failures. The raw
|
|
58
|
+
* upstream body and any body preview are never logged: both can echo
|
|
59
|
+
* keys or completion text.
|
|
60
|
+
*/
|
|
61
|
+
function logUpstreamError(err, requestId) {
|
|
62
|
+
const summary = summarizeUpstreamBody(err.upstreamBody);
|
|
63
|
+
// An "upstream" client kind on a sub-500 status only arises from the
|
|
64
|
+
// credentials rule — i.e. the upstream rejected the proxy's key, even
|
|
65
|
+
// when it didn't use 401/403 (Gemini's 400 + API_KEY_INVALID).
|
|
66
|
+
const authFailure = err.upstreamStatus === 401 ||
|
|
67
|
+
err.upstreamStatus === 403 ||
|
|
68
|
+
(err.clientError?.kind === "upstream" &&
|
|
69
|
+
err.upstreamStatus !== undefined &&
|
|
70
|
+
err.upstreamStatus < 500);
|
|
71
|
+
logger_1.logger.warn({
|
|
72
|
+
requestId,
|
|
73
|
+
statusCode: err.statusCode,
|
|
74
|
+
upstreamStatus: err.upstreamStatus,
|
|
75
|
+
upstreamType: summary.type,
|
|
76
|
+
upstreamCode: summary.code,
|
|
77
|
+
...(!authFailure && summary.message
|
|
78
|
+
? { upstreamMessage: summary.message.slice(0, 200) }
|
|
79
|
+
: {}),
|
|
80
|
+
}, "Upstream error");
|
|
81
|
+
}
|
|
82
|
+
const errorHandler = (err, req, res, _next) => {
|
|
83
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
84
|
+
const requestId = req.id;
|
|
85
|
+
const sourceApi = res.locals.sourceApi;
|
|
86
|
+
if (err instanceof errors_1.UpstreamError) {
|
|
87
|
+
logUpstreamError(err, requestId);
|
|
88
|
+
}
|
|
89
|
+
else if (err instanceof errors_1.ProxyError) {
|
|
90
|
+
logger_1.logger.error({
|
|
91
|
+
requestId,
|
|
92
|
+
code: err.code,
|
|
93
|
+
statusCode: err.statusCode,
|
|
94
|
+
context: err.context,
|
|
95
|
+
stack: err.stack,
|
|
96
|
+
}, err.message);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// Not err wholesale: body-parser errors carry the raw request body
|
|
100
|
+
// (prompt text) on `err.body`, and their message echoes a fragment
|
|
101
|
+
// of it — so entity.* errors log only their type.
|
|
102
|
+
const errType = err?.type;
|
|
103
|
+
logger_1.logger.error({
|
|
104
|
+
requestId,
|
|
105
|
+
errType,
|
|
106
|
+
message: typeof errType === "string" && errType.startsWith("entity.")
|
|
107
|
+
? `body-parser error: ${errType}`
|
|
108
|
+
: err instanceof Error
|
|
109
|
+
? err.message
|
|
110
|
+
: String(err),
|
|
111
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
112
|
+
}, "Unhandled error");
|
|
113
|
+
}
|
|
114
|
+
// On a provider route every error — including auth and body-parser
|
|
115
|
+
// errors — renders in the client's own dialect.
|
|
116
|
+
if (sourceApi) {
|
|
117
|
+
const canonical = toCanonical(err, isProduction);
|
|
118
|
+
if (canonical.retryAfterSeconds !== undefined) {
|
|
119
|
+
res.set("Retry-After", String(canonical.retryAfterSeconds));
|
|
120
|
+
}
|
|
121
|
+
const body = transformers_1.adapters[sourceApi].fromCanonicalError(canonical);
|
|
122
|
+
// Owner decision: the non-JSON-2xx body preview stays in
|
|
123
|
+
// non-production `details` (it can aid debugging) but is never
|
|
124
|
+
// logged — it can be completion text.
|
|
125
|
+
const bodyPreview = !isProduction && err instanceof errors_1.UpstreamError
|
|
126
|
+
? err.context?.bodyPreview
|
|
127
|
+
: undefined;
|
|
128
|
+
res.status(canonical.status).json(bodyPreview !== undefined
|
|
129
|
+
? { ...body, details: { bodyPreview } }
|
|
130
|
+
: body);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Outside provider routes: today's generic shape.
|
|
134
|
+
if (err && err.type === "entity.too.large") {
|
|
135
|
+
res.status(413).json({
|
|
136
|
+
error: {
|
|
137
|
+
message: `Request body too large (limit: ${err.limit})`,
|
|
138
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
139
|
+
status: 413,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (err instanceof errors_1.ProxyError) {
|
|
145
|
+
res.status(err.statusCode).json(err.toJSON());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const response = {
|
|
149
|
+
error: {
|
|
150
|
+
message: isProduction ? "Internal Server Error" : err.message,
|
|
151
|
+
code: "INTERNAL_ERROR",
|
|
152
|
+
status: 500,
|
|
153
|
+
...(!isProduction && { details: { stack: err.stack } }),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
res.status(500).json(response);
|
|
157
|
+
};
|
|
158
|
+
exports.errorHandler = errorHandler;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.requestLogger = void 0;
|
|
7
|
+
exports.createRequestLogger = createRequestLogger;
|
|
8
|
+
const pino_http_1 = __importDefault(require("pino-http"));
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
10
|
+
const logger_1 = require("../utils/logger");
|
|
11
|
+
// Header names that can carry a client credential; their values never
|
|
12
|
+
// reach a log line (Node lowercases inbound header names).
|
|
13
|
+
const CREDENTIAL_HEADERS = new Set([
|
|
14
|
+
'authorization',
|
|
15
|
+
'proxy-authorization',
|
|
16
|
+
'x-api-key',
|
|
17
|
+
'x-goog-api-key',
|
|
18
|
+
'cookie',
|
|
19
|
+
]);
|
|
20
|
+
// A Gemini-style `?key=` credential can sit anywhere in the query string;
|
|
21
|
+
// every other parameter is logged as it is.
|
|
22
|
+
function redactUrl(url) {
|
|
23
|
+
return url?.replace(/([?&]key=)[^&]*/g, '$1[REDACTED]');
|
|
24
|
+
}
|
|
25
|
+
// pino-http feeds the standard-serialized req through this before each
|
|
26
|
+
// line is written — the last chance to strip credentials.
|
|
27
|
+
function serializeReq(req) {
|
|
28
|
+
const headers = { ...req.headers };
|
|
29
|
+
for (const name of Object.keys(headers)) {
|
|
30
|
+
if (CREDENTIAL_HEADERS.has(name.toLowerCase())) {
|
|
31
|
+
headers[name] = '[REDACTED]';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
req.headers = headers;
|
|
35
|
+
req.url = redactUrl(req.url);
|
|
36
|
+
// Express parses the query onto req.query, so `key` would survive the
|
|
37
|
+
// url redaction above; blank the parsed value too.
|
|
38
|
+
if (req.query && typeof req.query === 'object' && 'key' in req.query) {
|
|
39
|
+
req.query = { ...req.query, key: '[REDACTED]' };
|
|
40
|
+
}
|
|
41
|
+
return req;
|
|
42
|
+
}
|
|
43
|
+
function createRequestLogger(log) {
|
|
44
|
+
return (0, pino_http_1.default)({
|
|
45
|
+
logger: log,
|
|
46
|
+
genReqId: (req) => req.headers['x-request-id'] || (0, crypto_1.randomUUID)(),
|
|
47
|
+
customLogLevel: (_req, res, err) => {
|
|
48
|
+
if (err || res.statusCode >= 500)
|
|
49
|
+
return 'error';
|
|
50
|
+
if (res.statusCode >= 400)
|
|
51
|
+
return 'warn';
|
|
52
|
+
return 'info';
|
|
53
|
+
},
|
|
54
|
+
serializers: { req: serializeReq },
|
|
55
|
+
customSuccessMessage: (req, res) => {
|
|
56
|
+
return `${req.method} ${redactUrl(req.url)} ${res.statusCode}`;
|
|
57
|
+
},
|
|
58
|
+
customErrorMessage: (req, res) => {
|
|
59
|
+
return `${req.method} ${redactUrl(req.url)} ${res.statusCode}`;
|
|
60
|
+
},
|
|
61
|
+
customProps: (req) => ({
|
|
62
|
+
requestId: req.id,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
exports.requestLogger = createRequestLogger(logger_1.logger);
|