openclaw-autoproxy 1.0.1 → 1.0.2

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.
@@ -12,7 +12,8 @@ const args = process.argv.slice(2);
12
12
 
13
13
  const currentFilePath = fileURLToPath(import.meta.url);
14
14
  const packageRoot = path.resolve(path.dirname(currentFilePath), "..");
15
- const serverEntryPath = path.join(packageRoot, "src", "gateway", "server.ts");
15
+ const serverSourceEntryPath = path.join(packageRoot, "src", "gateway", "server.ts");
16
+ const serverBuildEntryPath = path.join(packageRoot, "dist", "gateway", "server.js");
16
17
  const packageJsonPath = path.join(packageRoot, "package.json");
17
18
  const require = createRequire(import.meta.url);
18
19
 
@@ -79,7 +80,7 @@ function runTsxMode(tsxArgs) {
79
80
 
80
81
  if (!tsxCliPath || !existsSync(tsxCliPath)) {
81
82
  console.error(
82
- "Missing tsx runtime. Reinstall package or install tsx globally (npm i -g tsx) and try again.",
83
+ "Missing tsx runtime. Install tsx globally (npm i -g tsx) or run a build that generates dist files.",
83
84
  );
84
85
  process.exit(1);
85
86
  }
@@ -100,12 +101,34 @@ function runTsxMode(tsxArgs) {
100
101
  });
101
102
  }
102
103
 
104
+ function runNodeMode(nodeArgs) {
105
+ const child = spawn(process.execPath, nodeArgs, {
106
+ stdio: "inherit",
107
+ cwd: process.cwd(),
108
+ env: process.env,
109
+ });
110
+
111
+ child.on("exit", (code, signal) => {
112
+ if (signal) {
113
+ process.kill(process.pid, signal);
114
+ return;
115
+ }
116
+
117
+ process.exit(code ?? 0);
118
+ });
119
+ }
120
+
103
121
  function runDevMode(extraArgs) {
104
- runTsxMode(["watch", serverEntryPath, ...extraArgs]);
122
+ runTsxMode(["watch", serverSourceEntryPath, ...extraArgs]);
105
123
  }
106
124
 
107
125
  function runStartMode(extraArgs) {
108
- runTsxMode([serverEntryPath, ...extraArgs]);
126
+ if (existsSync(serverBuildEntryPath)) {
127
+ runNodeMode([serverBuildEntryPath, ...extraArgs]);
128
+ return;
129
+ }
130
+
131
+ runTsxMode([serverSourceEntryPath, ...extraArgs]);
109
132
  }
110
133
 
111
134
  function isHelpFlag(value) {
@@ -0,0 +1,269 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import dotenv from "dotenv";
4
+ import { parse as parseYaml } from "yaml";
5
+ dotenv.config();
6
+ function parseCsvList(value) {
7
+ if (!value) {
8
+ return [];
9
+ }
10
+ return value
11
+ .split(",")
12
+ .map((item) => item.trim())
13
+ .filter(Boolean);
14
+ }
15
+ function parseRetryCodes(value) {
16
+ const defaults = new Set([412, 429, 500, 502, 503, 504]);
17
+ if (!value) {
18
+ return defaults;
19
+ }
20
+ const parsed = value
21
+ .split(",")
22
+ .map((item) => Number.parseInt(item.trim(), 10))
23
+ .filter((code) => Number.isInteger(code) && code >= 100 && code <= 9999);
24
+ return parsed.length > 0 ? new Set(parsed) : defaults;
25
+ }
26
+ function parseRetryCodesFromConfig(value) {
27
+ if (value === undefined || value === null) {
28
+ return undefined;
29
+ }
30
+ if (!Array.isArray(value)) {
31
+ throw new Error('"retryStatusCodes" must be an array.');
32
+ }
33
+ const parsed = value
34
+ .map((item) => {
35
+ if (typeof item === "number") {
36
+ return item;
37
+ }
38
+ if (typeof item === "string") {
39
+ return Number.parseInt(item.trim(), 10);
40
+ }
41
+ return Number.NaN;
42
+ })
43
+ .filter((code) => Number.isInteger(code) && code >= 100 && code <= 9999);
44
+ if (parsed.length === 0) {
45
+ throw new Error('"retryStatusCodes" must include at least one valid status code (100-9999).');
46
+ }
47
+ return new Set(parsed);
48
+ }
49
+ function parseModelFallbackMap(value) {
50
+ if (!value) {
51
+ return {};
52
+ }
53
+ try {
54
+ const parsed = JSON.parse(value);
55
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
56
+ throw new Error("MODEL_FALLBACK_MAP must be a JSON object.");
57
+ }
58
+ return Object.fromEntries(Object.entries(parsed).map(([model, fallbacks]) => {
59
+ if (!Array.isArray(fallbacks)) {
60
+ throw new Error(`Fallback list for "${model}" must be an array.`);
61
+ }
62
+ return [
63
+ model,
64
+ fallbacks.map((item) => String(item).trim()).filter(Boolean),
65
+ ];
66
+ }));
67
+ }
68
+ catch (error) {
69
+ throw new Error(`Invalid MODEL_FALLBACK_MAP: ${error.message}`);
70
+ }
71
+ }
72
+ function parseRouteHeaders(value, routeName) {
73
+ if (value === undefined) {
74
+ return {};
75
+ }
76
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
77
+ throw new Error(`Route headers for "${routeName}" must be a YAML object.`);
78
+ }
79
+ return Object.fromEntries(Object.entries(value).map(([rawKey, rawValue]) => {
80
+ const key = String(rawKey).trim();
81
+ const val = String(rawValue).trim();
82
+ if (!key) {
83
+ throw new Error(`Route headers for "${routeName}" contains an empty key.`);
84
+ }
85
+ return [key, val];
86
+ }));
87
+ }
88
+ function parseModelList(value, routeName) {
89
+ if (typeof value === "string") {
90
+ const normalized = value.trim();
91
+ return normalized ? [normalized] : [];
92
+ }
93
+ if (!Array.isArray(value)) {
94
+ throw new Error(`Route "${routeName}" must include "model" as string or array.`);
95
+ }
96
+ return value.map((item) => String(item).trim()).filter(Boolean);
97
+ }
98
+ function resolveRouteApiKey(rawRoute, defaults) {
99
+ const apiKeyInline = typeof rawRoute.apiKey === "string" ? rawRoute.apiKey.trim() : "";
100
+ if (apiKeyInline) {
101
+ return apiKeyInline;
102
+ }
103
+ const apiKeyEnv = typeof rawRoute.apiKeyEnv === "string" ? rawRoute.apiKeyEnv.trim() : "";
104
+ if (apiKeyEnv) {
105
+ return process.env[apiKeyEnv] ?? "";
106
+ }
107
+ if (defaults.apiKey) {
108
+ return defaults.apiKey;
109
+ }
110
+ return defaults.apiKeyEnv ? process.env[defaults.apiKeyEnv] ?? "" : "";
111
+ }
112
+ function normalizeGlobalRouteDefaults(value) {
113
+ if (value === undefined || value === null) {
114
+ return {};
115
+ }
116
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
117
+ throw new Error("Route defaults must be an object.");
118
+ }
119
+ const defaults = value;
120
+ return {
121
+ authHeader: typeof defaults.authHeader === "string" && defaults.authHeader.trim()
122
+ ? defaults.authHeader.trim().toLowerCase()
123
+ : undefined,
124
+ authPrefix: typeof defaults.authPrefix === "string" ? defaults.authPrefix : undefined,
125
+ apiKey: typeof defaults.apiKey === "string" ? defaults.apiKey.trim() : undefined,
126
+ apiKeyEnv: typeof defaults.apiKeyEnv === "string" && defaults.apiKeyEnv.trim()
127
+ ? defaults.apiKeyEnv.trim()
128
+ : undefined,
129
+ headers: parseRouteHeaders(defaults.headers, "defaults"),
130
+ isBaseUrl: typeof defaults.isBaseUrl === "boolean" ? defaults.isBaseUrl : undefined,
131
+ enabled: typeof defaults.enabled === "boolean" ? defaults.enabled : undefined,
132
+ };
133
+ }
134
+ function normalizeRouteConfig(rawRoute, routeName, defaults) {
135
+ if (!rawRoute || typeof rawRoute !== "object" || Array.isArray(rawRoute)) {
136
+ throw new Error(`Route "${routeName}" must be an object.`);
137
+ }
138
+ const url = typeof rawRoute.url === "string" ? rawRoute.url.trim() : "";
139
+ if (!url) {
140
+ throw new Error(`Route "${routeName}" must include a non-empty "url".`);
141
+ }
142
+ const models = parseModelList(rawRoute.model ?? rawRoute.models, routeName);
143
+ if (models.length === 0) {
144
+ throw new Error(`Route "${routeName}" must include at least one model.`);
145
+ }
146
+ const authHeader = typeof rawRoute.authHeader === "string" && rawRoute.authHeader.trim()
147
+ ? rawRoute.authHeader.trim().toLowerCase()
148
+ : defaults.authHeader ?? "authorization";
149
+ const authPrefix = typeof rawRoute.authPrefix === "string" ? rawRoute.authPrefix : defaults.authPrefix ?? "Bearer ";
150
+ const isBaseUrl = typeof rawRoute.isBaseUrl === "boolean"
151
+ ? rawRoute.isBaseUrl
152
+ : Boolean(defaults.isBaseUrl);
153
+ const enabled = typeof rawRoute.enabled === "boolean" ? rawRoute.enabled : defaults.enabled ?? true;
154
+ const headers = {
155
+ ...(defaults.headers ?? {}),
156
+ ...parseRouteHeaders(rawRoute.headers, routeName),
157
+ };
158
+ return {
159
+ routeName,
160
+ url,
161
+ authHeader,
162
+ authPrefix,
163
+ apiKey: resolveRouteApiKey(rawRoute, defaults),
164
+ headers,
165
+ isBaseUrl,
166
+ enabled,
167
+ models,
168
+ };
169
+ }
170
+ function parseRouteArray(value, defaults) {
171
+ if (!Array.isArray(value)) {
172
+ throw new Error('Route config must include "routes" as an array.');
173
+ }
174
+ return value.map((entry, index) => {
175
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
176
+ throw new Error(`Route entry at index ${index} must be an object.`);
177
+ }
178
+ const routeData = entry;
179
+ const name = typeof routeData.name === "string" ? routeData.name.trim() : "";
180
+ const routeName = name || `routes[${index}]`;
181
+ return normalizeRouteConfig(routeData, routeName, defaults);
182
+ });
183
+ }
184
+ function parseModelRouteConfigFile(filePathRaw) {
185
+ const filePath = String(filePathRaw).trim();
186
+ if (!filePath) {
187
+ throw new Error("Route config path cannot be empty.");
188
+ }
189
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
190
+ let raw = "";
191
+ try {
192
+ raw = fs.readFileSync(resolvedPath, "utf8");
193
+ }
194
+ catch (error) {
195
+ throw new Error(`Failed to read route config file "${resolvedPath}": ${error instanceof Error ? error.message : "Unknown error"}`);
196
+ }
197
+ let parsed;
198
+ try {
199
+ parsed = parseYaml(raw);
200
+ }
201
+ catch (error) {
202
+ throw new Error(`Invalid route config YAML at "${resolvedPath}": ${error instanceof Error ? error.message : "Unknown error"}`);
203
+ }
204
+ let defaults = {};
205
+ let routeEntries;
206
+ let retryStatusCodesFromFile;
207
+ if (Array.isArray(parsed)) {
208
+ routeEntries = parsed;
209
+ }
210
+ else if (parsed && typeof parsed === "object") {
211
+ const objectParsed = parsed;
212
+ retryStatusCodesFromFile = parseRetryCodesFromConfig(objectParsed.retryStatusCodes);
213
+ defaults = normalizeGlobalRouteDefaults(objectParsed.defaults ?? objectParsed.global ?? objectParsed.auth);
214
+ routeEntries = objectParsed.routes;
215
+ }
216
+ else {
217
+ throw new Error(`Route config file "${resolvedPath}" must be a YAML object or array.`);
218
+ }
219
+ const parsedRoutes = parseRouteArray(routeEntries, defaults);
220
+ const modelRouteMap = {};
221
+ for (const route of parsedRoutes) {
222
+ if (!route.enabled) {
223
+ continue;
224
+ }
225
+ const { routeName, models, enabled: _enabled, ...routeConfig } = route;
226
+ for (const modelId of models) {
227
+ if (modelRouteMap[modelId]) {
228
+ throw new Error(`Duplicate model "${modelId}" found in enabled routes (route "${routeName}").`);
229
+ }
230
+ modelRouteMap[modelId] = {
231
+ ...routeConfig,
232
+ routeName,
233
+ };
234
+ }
235
+ }
236
+ return {
237
+ modelRouteMap,
238
+ retryStatusCodes: retryStatusCodesFromFile,
239
+ };
240
+ }
241
+ function loadRouteFileConfig() {
242
+ const defaultRouteConfigPath = path.resolve(process.cwd(), "routes.yml");
243
+ if (!fs.existsSync(defaultRouteConfigPath)) {
244
+ throw new Error(`Missing routes config file at "${defaultRouteConfigPath}". Create routes.yml in project root.`);
245
+ }
246
+ return parseModelRouteConfigFile(defaultRouteConfigPath);
247
+ }
248
+ const host = process.env.HOST ?? "0.0.0.0";
249
+ const port = Number.parseInt(process.env.PORT ?? "8787", 10);
250
+ const timeoutMs = Number.parseInt(process.env.REQUEST_TIMEOUT_MS ?? "60000", 10);
251
+ const upstreamBaseUrl = (process.env.UPSTREAM_BASE_URL ?? "https://api.openai.com").replace(/\/+$/, "");
252
+ const routeFileConfig = loadRouteFileConfig();
253
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
254
+ throw new Error("PORT must be a valid TCP port number.");
255
+ }
256
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) {
257
+ throw new Error("REQUEST_TIMEOUT_MS must be an integer >= 1000.");
258
+ }
259
+ export const config = {
260
+ host,
261
+ port,
262
+ timeoutMs,
263
+ upstreamBaseUrl,
264
+ upstreamApiKey: process.env.UPSTREAM_API_KEY ?? "",
265
+ retryStatusCodes: routeFileConfig.retryStatusCodes ?? parseRetryCodes(process.env.RETRY_STATUS_CODES),
266
+ globalFallbackModels: parseCsvList(process.env.GLOBAL_FALLBACK_MODELS),
267
+ modelFallbackMap: parseModelFallbackMap(process.env.MODEL_FALLBACK_MAP),
268
+ modelRouteMap: routeFileConfig.modelRouteMap,
269
+ };
@@ -0,0 +1,474 @@
1
+ import { PassThrough, Readable } from "node:stream";
2
+ import { config } from "./config.js";
3
+ const HOP_BY_HOP_HEADERS = new Set([
4
+ "connection",
5
+ "keep-alive",
6
+ "proxy-authenticate",
7
+ "proxy-authorization",
8
+ "te",
9
+ "trailer",
10
+ "transfer-encoding",
11
+ "upgrade",
12
+ ]);
13
+ const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
14
+ const AUTO_MODEL = "auto";
15
+ let autoModelCursor = 0;
16
+ function logProxyModelRoute(params) {
17
+ console.log(`[gateway] requested_model=${params.requestedModel ?? "-"} used_model=${params.usedModel ?? "-"} route=${params.routeName ?? "-"}`);
18
+ }
19
+ function resolveRouteNameForModel(modelId) {
20
+ if (modelId && config.modelRouteMap[modelId]) {
21
+ return config.modelRouteMap[modelId].routeName;
22
+ }
23
+ return config.modelRouteMap["*"]?.routeName ?? null;
24
+ }
25
+ function logProxyModelSwitch(params) {
26
+ console.log(`[gateway] switch trigger_status=${params.triggerStatus} from_model=${params.fromModel ?? "-"} from_route=${params.fromRoute ?? "-"} to_model=${params.toModel ?? "-"} to_route=${params.toRoute ?? "-"}`);
27
+ }
28
+ function sendJson(response, statusCode, payload) {
29
+ if (response.writableEnded) {
30
+ return;
31
+ }
32
+ const body = JSON.stringify(payload);
33
+ response.statusCode = statusCode;
34
+ response.setHeader("content-type", "application/json; charset=utf-8");
35
+ response.setHeader("content-length", Buffer.byteLength(body));
36
+ response.end(body);
37
+ }
38
+ function normalizeRequestPath(request) {
39
+ const rawUrl = request.url ?? "/";
40
+ try {
41
+ const parsed = new URL(rawUrl, "http://localhost");
42
+ return `${parsed.pathname}${parsed.search}`;
43
+ }
44
+ catch {
45
+ return rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
46
+ }
47
+ }
48
+ function rotateCandidates(candidates, startIndex) {
49
+ if (candidates.length <= 1) {
50
+ return [...candidates];
51
+ }
52
+ const normalizedStart = startIndex % candidates.length;
53
+ return [
54
+ ...candidates.slice(normalizedStart),
55
+ ...candidates.slice(0, normalizedStart),
56
+ ];
57
+ }
58
+ function buildAutoModelCandidates() {
59
+ const routableModels = Object.keys(config.modelRouteMap).filter((modelName) => modelName !== "*" && modelName.toLowerCase() !== AUTO_MODEL);
60
+ if (routableModels.length === 0) {
61
+ return [];
62
+ }
63
+ const rotated = rotateCandidates(routableModels, autoModelCursor);
64
+ autoModelCursor = (autoModelCursor + 1) % routableModels.length;
65
+ return rotated;
66
+ }
67
+ function buildModelCandidates(requestedModel) {
68
+ if (requestedModel.toLowerCase() === AUTO_MODEL) {
69
+ return buildAutoModelCandidates();
70
+ }
71
+ // Non-auto requests are pinned to the exact model specified by client.
72
+ return [requestedModel];
73
+ }
74
+ function buildRoutedUpstreamUrl(request, selectedRoute) {
75
+ if (!selectedRoute) {
76
+ return `${config.upstreamBaseUrl}${normalizeRequestPath(request)}`;
77
+ }
78
+ if (!selectedRoute.isBaseUrl) {
79
+ return selectedRoute.url;
80
+ }
81
+ const routeBase = selectedRoute.url.replace(/\/+$/, "");
82
+ const requestPath = normalizeRequestPath(request);
83
+ if (routeBase.endsWith("/v1") && requestPath.startsWith("/v1")) {
84
+ return `${routeBase}${requestPath.slice(3)}`;
85
+ }
86
+ return `${routeBase}${requestPath}`;
87
+ }
88
+ function resolveUpstreamTarget(request, modelId) {
89
+ const modelRoute = modelId ? config.modelRouteMap[modelId] ?? null : null;
90
+ const wildcardRoute = config.modelRouteMap["*"] ?? null;
91
+ const selectedRoute = modelRoute ?? wildcardRoute;
92
+ return {
93
+ upstreamUrl: buildRoutedUpstreamUrl(request, selectedRoute),
94
+ selectedRoute,
95
+ };
96
+ }
97
+ function buildUpstreamHeaders(reqHeaders, bodyLength, selectedRoute) {
98
+ const headers = new Headers();
99
+ for (const [key, value] of Object.entries(reqHeaders)) {
100
+ if (value === undefined) {
101
+ continue;
102
+ }
103
+ const lowerKey = key.toLowerCase();
104
+ if (HOP_BY_HOP_HEADERS.has(lowerKey) || lowerKey === "host" || lowerKey === "content-length") {
105
+ continue;
106
+ }
107
+ headers.set(key, Array.isArray(value) ? value.join(",") : String(value));
108
+ }
109
+ if (selectedRoute?.headers) {
110
+ for (const [key, value] of Object.entries(selectedRoute.headers)) {
111
+ headers.set(key, value);
112
+ }
113
+ }
114
+ if (selectedRoute?.apiKey) {
115
+ const authHeader = selectedRoute.authHeader || "authorization";
116
+ const authPrefix = selectedRoute.authPrefix ?? "Bearer ";
117
+ if (!headers.has(authHeader)) {
118
+ headers.set(authHeader, `${authPrefix}${selectedRoute.apiKey}`);
119
+ }
120
+ }
121
+ else if (!headers.has("authorization") && config.upstreamApiKey) {
122
+ headers.set("authorization", `Bearer ${config.upstreamApiKey}`);
123
+ }
124
+ if (typeof bodyLength === "number") {
125
+ headers.set("content-length", String(bodyLength));
126
+ }
127
+ return headers;
128
+ }
129
+ function isJsonRequest(headers) {
130
+ const contentTypeHeader = headers["content-type"];
131
+ const contentType = Array.isArray(contentTypeHeader)
132
+ ? contentTypeHeader.join(";").toLowerCase()
133
+ : String(contentTypeHeader ?? "").toLowerCase();
134
+ return contentType.includes("application/json");
135
+ }
136
+ function parseJsonBody(buffer, shouldParse) {
137
+ if (!shouldParse || buffer.length === 0) {
138
+ return { parsed: null, error: null };
139
+ }
140
+ try {
141
+ const parsed = JSON.parse(buffer.toString("utf8"));
142
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
143
+ return { parsed: parsed, error: null };
144
+ }
145
+ return { parsed: null, error: null };
146
+ }
147
+ catch {
148
+ return { parsed: null, error: "Invalid JSON body." };
149
+ }
150
+ }
151
+ function parseStatusLikeCode(value) {
152
+ if (typeof value === "number" && Number.isInteger(value)) {
153
+ return value >= 100 && value <= 9999 ? value : null;
154
+ }
155
+ if (typeof value === "string") {
156
+ const trimmed = value.trim();
157
+ if (!trimmed) {
158
+ return null;
159
+ }
160
+ const parsed = Number.parseInt(trimmed, 10);
161
+ return Number.isInteger(parsed) && parsed >= 100 && parsed <= 9999 ? parsed : null;
162
+ }
163
+ return null;
164
+ }
165
+ function extractRetryStatusFromJsonPayload(payload) {
166
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
167
+ return null;
168
+ }
169
+ const record = payload;
170
+ const candidates = [record.status, record.code, record.errorCode];
171
+ const nestedError = record.error;
172
+ if (nestedError && typeof nestedError === "object" && !Array.isArray(nestedError)) {
173
+ const nested = nestedError;
174
+ candidates.push(nested.status, nested.code, nested.errorCode);
175
+ }
176
+ const nestedErrors = record.errors;
177
+ if (Array.isArray(nestedErrors)) {
178
+ for (const item of nestedErrors) {
179
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
180
+ continue;
181
+ }
182
+ const nested = item;
183
+ candidates.push(nested.status, nested.code, nested.errorCode);
184
+ }
185
+ }
186
+ for (const candidate of candidates) {
187
+ const parsed = parseStatusLikeCode(candidate);
188
+ if (parsed !== null) {
189
+ return parsed;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+ async function detectRetryStatusFromBody(upstreamResponse, retryStatusCodes) {
195
+ const contentType = (upstreamResponse.headers.get("content-type") ?? "").toLowerCase();
196
+ if (!contentType.includes("application/json") || contentType.includes("text/event-stream")) {
197
+ return null;
198
+ }
199
+ try {
200
+ const raw = await upstreamResponse.clone().text();
201
+ if (!raw.trim()) {
202
+ return null;
203
+ }
204
+ const parsed = JSON.parse(raw);
205
+ const statusFromPayload = extractRetryStatusFromJsonPayload(parsed);
206
+ if (statusFromPayload !== null && retryStatusCodes.has(statusFromPayload)) {
207
+ return statusFromPayload;
208
+ }
209
+ }
210
+ catch {
211
+ // Ignore probe errors and continue with normal response handling.
212
+ }
213
+ return null;
214
+ }
215
+ function copyResponseHeaders(upstreamResponse, response) {
216
+ for (const [key, value] of upstreamResponse.headers.entries()) {
217
+ if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
218
+ continue;
219
+ }
220
+ response.setHeader(key, value);
221
+ }
222
+ }
223
+ async function fetchWithTimeout(url, options, timeoutMs) {
224
+ const controller = new AbortController();
225
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
226
+ try {
227
+ return await fetch(url, { ...options, signal: controller.signal });
228
+ }
229
+ finally {
230
+ clearTimeout(timeoutId);
231
+ }
232
+ }
233
+ async function disposeBody(response) {
234
+ if (!response.body) {
235
+ return;
236
+ }
237
+ try {
238
+ await response.body.cancel();
239
+ }
240
+ catch {
241
+ // Body cancellation is best effort.
242
+ }
243
+ }
244
+ function createSsePrefixedStream(source, notice) {
245
+ const passthrough = new PassThrough();
246
+ passthrough.write(`event: gateway_notice\ndata: ${JSON.stringify(notice)}\n\n`);
247
+ source.on("error", () => {
248
+ passthrough.end();
249
+ });
250
+ source.pipe(passthrough);
251
+ return passthrough;
252
+ }
253
+ async function readRequestBody(request) {
254
+ return await new Promise((resolve, reject) => {
255
+ const chunks = [];
256
+ let totalSize = 0;
257
+ let settled = false;
258
+ request.on("data", (chunk) => {
259
+ if (settled) {
260
+ return;
261
+ }
262
+ const normalizedChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
263
+ totalSize += normalizedChunk.length;
264
+ if (totalSize > MAX_REQUEST_BODY_BYTES) {
265
+ settled = true;
266
+ request.destroy();
267
+ reject(new Error(`Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.`));
268
+ return;
269
+ }
270
+ chunks.push(normalizedChunk);
271
+ });
272
+ request.on("end", () => {
273
+ if (settled) {
274
+ return;
275
+ }
276
+ settled = true;
277
+ resolve(Buffer.concat(chunks));
278
+ });
279
+ request.on("aborted", () => {
280
+ if (settled) {
281
+ return;
282
+ }
283
+ settled = true;
284
+ reject(new Error("Request was aborted by client."));
285
+ });
286
+ request.on("error", (error) => {
287
+ if (settled) {
288
+ return;
289
+ }
290
+ settled = true;
291
+ reject(error);
292
+ });
293
+ });
294
+ }
295
+ export async function proxyRequest(request, response) {
296
+ const method = (request.method ?? "GET").toUpperCase();
297
+ const supportsBody = method !== "GET" && method !== "HEAD";
298
+ let incomingBody = Buffer.alloc(0);
299
+ if (supportsBody) {
300
+ try {
301
+ incomingBody = await readRequestBody(request);
302
+ }
303
+ catch (error) {
304
+ const isBodyTooLarge = error instanceof Error &&
305
+ error.message.includes("exceeds") &&
306
+ error.message.includes("bytes");
307
+ sendJson(response, isBodyTooLarge ? 413 : 400, {
308
+ error: {
309
+ message: "Failed to read request body.",
310
+ detail: error instanceof Error ? error.message : "Unknown error",
311
+ },
312
+ });
313
+ return;
314
+ }
315
+ }
316
+ const wantsJson = isJsonRequest(request.headers);
317
+ const { parsed: parsedJsonBody, error: parseError } = parseJsonBody(incomingBody, wantsJson);
318
+ if (parseError) {
319
+ sendJson(response, 400, {
320
+ error: {
321
+ message: parseError,
322
+ },
323
+ });
324
+ return;
325
+ }
326
+ const requestedModel = parsedJsonBody &&
327
+ typeof parsedJsonBody.model === "string" &&
328
+ parsedJsonBody.model.trim()
329
+ ? parsedJsonBody.model.trim()
330
+ : null;
331
+ const modelCandidates = requestedModel
332
+ ? buildModelCandidates(requestedModel)
333
+ : [null];
334
+ if (requestedModel?.toLowerCase() === AUTO_MODEL && modelCandidates.length === 0) {
335
+ sendJson(response, 400, {
336
+ error: {
337
+ message: 'No auto model candidates configured. Set routes, GLOBAL_FALLBACK_MODELS, or MODEL_FALLBACK_MAP["auto"].',
338
+ },
339
+ });
340
+ return;
341
+ }
342
+ let lastError = null;
343
+ let lastAttemptRouteName = null;
344
+ let switchNotice = null;
345
+ for (let attemptIndex = 0; attemptIndex < modelCandidates.length; attemptIndex += 1) {
346
+ const modelId = modelCandidates[attemptIndex];
347
+ let bodyBuffer = supportsBody && incomingBody.length > 0 ? incomingBody : undefined;
348
+ if (supportsBody && parsedJsonBody && modelId) {
349
+ bodyBuffer = Buffer.from(JSON.stringify({
350
+ ...parsedJsonBody,
351
+ model: modelId,
352
+ }), "utf8");
353
+ }
354
+ const { upstreamUrl, selectedRoute } = resolveUpstreamTarget(request, modelId);
355
+ lastAttemptRouteName = selectedRoute?.routeName ?? null;
356
+ const requestBody = bodyBuffer ? new Uint8Array(bodyBuffer) : undefined;
357
+ const headers = buildUpstreamHeaders(request.headers, bodyBuffer ? bodyBuffer.length : undefined, selectedRoute);
358
+ try {
359
+ const upstreamResponse = await fetchWithTimeout(upstreamUrl, {
360
+ method,
361
+ headers,
362
+ body: requestBody,
363
+ }, config.timeoutMs);
364
+ const contentType = (upstreamResponse.headers.get("content-type") ?? "").toLowerCase();
365
+ const isEventStream = contentType.includes("text/event-stream");
366
+ const isJsonResponse = contentType.includes("application/json");
367
+ const hasNextCandidate = attemptIndex < modelCandidates.length - 1;
368
+ const httpRetryStatus = config.retryStatusCodes.has(upstreamResponse.status)
369
+ ? upstreamResponse.status
370
+ : null;
371
+ const bodyRetryStatus = !httpRetryStatus && hasNextCandidate
372
+ ? await detectRetryStatusFromBody(upstreamResponse, config.retryStatusCodes)
373
+ : null;
374
+ const retryTriggerStatus = httpRetryStatus ?? bodyRetryStatus;
375
+ const canRetry = retryTriggerStatus !== null && hasNextCandidate;
376
+ if (canRetry) {
377
+ const nextModel = modelCandidates[attemptIndex + 1];
378
+ const triggerStatus = retryTriggerStatus ?? upstreamResponse.status;
379
+ const nextRouteName = resolveRouteNameForModel(nextModel);
380
+ logProxyModelSwitch({
381
+ triggerStatus,
382
+ fromModel: modelId,
383
+ toModel: nextModel,
384
+ fromRoute: selectedRoute?.routeName ?? null,
385
+ toRoute: nextRouteName,
386
+ });
387
+ if (modelId && nextModel && nextModel !== modelId) {
388
+ switchNotice = {
389
+ trigger_status: triggerStatus,
390
+ from_model: modelId,
391
+ from_route: selectedRoute?.routeName ?? null,
392
+ to_model: nextModel,
393
+ to_route: nextRouteName,
394
+ };
395
+ }
396
+ await disposeBody(upstreamResponse);
397
+ continue;
398
+ }
399
+ const attemptCount = attemptIndex + 1;
400
+ const effectiveSwitchNotice = switchNotice;
401
+ copyResponseHeaders(upstreamResponse, response);
402
+ response.setHeader("x-gateway-attempt-count", String(attemptCount));
403
+ if (modelId) {
404
+ response.setHeader("x-gateway-model-used", modelId);
405
+ }
406
+ if (effectiveSwitchNotice) {
407
+ response.setHeader("x-gateway-switched", "1");
408
+ }
409
+ logProxyModelRoute({
410
+ requestedModel,
411
+ usedModel: modelId,
412
+ routeName: selectedRoute?.routeName ?? null,
413
+ });
414
+ response.statusCode = upstreamResponse.status;
415
+ if (!upstreamResponse.body) {
416
+ response.end();
417
+ return;
418
+ }
419
+ if (effectiveSwitchNotice && isJsonResponse && !isEventStream) {
420
+ const rawText = await upstreamResponse.text();
421
+ response.removeHeader("content-length");
422
+ try {
423
+ const parsed = JSON.parse(rawText);
424
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
425
+ parsed.gateway_notice = effectiveSwitchNotice;
426
+ response.end(JSON.stringify(parsed));
427
+ return;
428
+ }
429
+ }
430
+ catch {
431
+ // Keep original response body when JSON mutation is not possible.
432
+ }
433
+ response.end(rawText);
434
+ return;
435
+ }
436
+ const nodeStream = Readable.fromWeb(upstreamResponse.body);
437
+ if (effectiveSwitchNotice && isEventStream) {
438
+ response.removeHeader("content-length");
439
+ createSsePrefixedStream(nodeStream, effectiveSwitchNotice).pipe(response);
440
+ return;
441
+ }
442
+ nodeStream.on("error", () => {
443
+ if (!response.writableEnded) {
444
+ response.destroy();
445
+ }
446
+ });
447
+ nodeStream.pipe(response);
448
+ return;
449
+ }
450
+ catch (error) {
451
+ lastError = error;
452
+ if (attemptIndex < modelCandidates.length - 1) {
453
+ continue;
454
+ }
455
+ }
456
+ }
457
+ const timeoutLike = lastError &&
458
+ typeof lastError === "object" &&
459
+ "name" in lastError &&
460
+ lastError.name === "AbortError";
461
+ const errorStatusCode = timeoutLike ? 504 : 502;
462
+ const lastTriedModel = modelCandidates[modelCandidates.length - 1] ?? null;
463
+ logProxyModelRoute({
464
+ requestedModel,
465
+ usedModel: lastTriedModel,
466
+ routeName: lastAttemptRouteName,
467
+ });
468
+ sendJson(response, errorStatusCode, {
469
+ error: {
470
+ message: "Gateway failed to reach upstream provider.",
471
+ detail: lastError instanceof Error ? lastError.message : "Unknown error",
472
+ },
473
+ });
474
+ }
@@ -0,0 +1,55 @@
1
+ import { createServer } from "node:http";
2
+ import { config } from "./config.js";
3
+ import { proxyRequest } from "./proxy.js";
4
+ function sendJson(response, statusCode, payload) {
5
+ if (response.writableEnded) {
6
+ return;
7
+ }
8
+ const body = JSON.stringify(payload);
9
+ response.statusCode = statusCode;
10
+ response.setHeader("content-type", "application/json; charset=utf-8");
11
+ response.setHeader("content-length", Buffer.byteLength(body));
12
+ response.end(body);
13
+ }
14
+ function resolvePathname(request) {
15
+ const rawUrl = request.url ?? "/";
16
+ try {
17
+ return new URL(rawUrl, "http://localhost").pathname;
18
+ }
19
+ catch {
20
+ return rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
21
+ }
22
+ }
23
+ async function handleRequest(request, response) {
24
+ const method = (request.method ?? "GET").toUpperCase();
25
+ const pathname = resolvePathname(request);
26
+ if ((method === "GET" || method === "HEAD") && pathname === "/health") {
27
+ sendJson(response, 200, {
28
+ status: "ok",
29
+ retryStatusCodes: Array.from(config.retryStatusCodes),
30
+ enabledRouteCount: Object.keys(config.modelRouteMap).length,
31
+ });
32
+ return;
33
+ }
34
+ if (pathname === "/v1" || pathname.startsWith("/v1/")) {
35
+ await proxyRequest(request, response);
36
+ return;
37
+ }
38
+ sendJson(response, 404, {
39
+ error: {
40
+ message: "Route not found. Use /v1/* or /health.",
41
+ },
42
+ });
43
+ }
44
+ export function createGatewayHttpServer() {
45
+ return createServer((request, response) => {
46
+ void handleRequest(request, response).catch((error) => {
47
+ sendJson(response, 500, {
48
+ error: {
49
+ message: "Unexpected gateway error.",
50
+ detail: error instanceof Error ? error.message : "Unknown error",
51
+ },
52
+ });
53
+ });
54
+ });
55
+ }
@@ -0,0 +1,38 @@
1
+ import { config } from "./config.js";
2
+ import { createGatewayHttpServer } from "./server-http.js";
3
+ export async function startGatewayServer(port = config.port, opts = {}) {
4
+ const host = opts.host ?? config.host;
5
+ const server = createGatewayHttpServer();
6
+ await new Promise((resolve, reject) => {
7
+ const onError = (error) => {
8
+ server.off("listening", onListening);
9
+ reject(error);
10
+ };
11
+ const onListening = () => {
12
+ server.off("error", onError);
13
+ resolve();
14
+ };
15
+ server.once("error", onError);
16
+ server.once("listening", onListening);
17
+ server.listen({
18
+ host,
19
+ port,
20
+ });
21
+ });
22
+ const address = server.address();
23
+ const resolvedPort = typeof address === "object" && address ? address.port : port;
24
+ console.log(`Gateway listening on http://${host}:${resolvedPort} -> ${config.upstreamBaseUrl}`);
25
+ return {
26
+ close: async () => {
27
+ await new Promise((resolve, reject) => {
28
+ server.close((error) => {
29
+ if (error) {
30
+ reject(error);
31
+ return;
32
+ }
33
+ resolve();
34
+ });
35
+ });
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,27 @@
1
+ import process from "node:process";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { config } from "./config.js";
5
+ import { startGatewayServer as startGatewayServerImpl } from "./server.impl.js";
6
+ export { startGatewayServer } from "./server.impl.js";
7
+ async function main() {
8
+ try {
9
+ await startGatewayServerImpl(config.port, { host: config.host });
10
+ }
11
+ catch (error) {
12
+ console.error(error instanceof Error
13
+ ? `Failed to start gateway: ${error.message}`
14
+ : "Failed to start gateway due to an unknown error.");
15
+ process.exit(1);
16
+ }
17
+ }
18
+ const invokedAsScript = (() => {
19
+ const scriptArg = process.argv[1];
20
+ if (!scriptArg) {
21
+ return false;
22
+ }
23
+ return pathToFileURL(path.resolve(scriptArg)).href === import.meta.url;
24
+ })();
25
+ if (invokedAsScript) {
26
+ void main();
27
+ }
Binary file
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "openclaw-autoproxy",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Local model-switching proxy gateway with OpenAI-compatible APIs",
5
5
  "type": "module",
6
- "main": "src/gateway/server.ts",
6
+ "main": "dist/gateway/server.js",
7
7
  "bin": {
8
8
  "openclaw-autoproxy": "bin/openclaw-autoproxy.js"
9
9
  },
10
10
  "scripts": {
11
11
  "cli": "node bin/openclaw-autoproxy.js",
12
+ "build": "tsc -p tsconfig.build.json",
13
+ "prepack": "npm run build",
12
14
  "start": "node bin/openclaw-autoproxy.js gateway start",
13
15
  "dev": "node bin/openclaw-autoproxy.js gateway dev",
14
16
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "sourceMap": false,
8
+ "declaration": false
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["node_modules", "dist"]
12
+ }