openclaw-autoproxy 1.0.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,431 @@
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
+
6
+ dotenv.config();
7
+
8
+ type RouteHeaders = Record<string, string>;
9
+
10
+ interface GlobalRouteDefaults {
11
+ authHeader?: string;
12
+ authPrefix?: string;
13
+ apiKey?: string;
14
+ apiKeyEnv?: string;
15
+ headers?: RouteHeaders;
16
+ isBaseUrl?: boolean;
17
+ enabled?: boolean;
18
+ }
19
+
20
+ interface RouteConfigInput {
21
+ name?: unknown;
22
+ url?: unknown;
23
+ model?: unknown;
24
+ models?: unknown;
25
+ authHeader?: unknown;
26
+ authPrefix?: unknown;
27
+ apiKey?: unknown;
28
+ apiKeyEnv?: unknown;
29
+ headers?: unknown;
30
+ isBaseUrl?: unknown;
31
+ enabled?: unknown;
32
+ }
33
+
34
+ interface NormalizedRouteConfig {
35
+ routeName: string;
36
+ url: string;
37
+ authHeader: string;
38
+ authPrefix: string;
39
+ apiKey: string;
40
+ headers: RouteHeaders;
41
+ isBaseUrl: boolean;
42
+ enabled: boolean;
43
+ models: string[];
44
+ }
45
+
46
+ export interface ModelRouteConfig {
47
+ routeName: string;
48
+ url: string;
49
+ authHeader: string;
50
+ authPrefix: string;
51
+ apiKey: string;
52
+ headers: RouteHeaders;
53
+ isBaseUrl: boolean;
54
+ }
55
+
56
+ export interface GatewayConfig {
57
+ host: string;
58
+ port: number;
59
+ timeoutMs: number;
60
+ upstreamBaseUrl: string;
61
+ upstreamApiKey: string;
62
+ retryStatusCodes: Set<number>;
63
+ globalFallbackModels: string[];
64
+ modelFallbackMap: Record<string, string[]>;
65
+ modelRouteMap: Record<string, ModelRouteConfig>;
66
+ }
67
+
68
+ interface ParsedRouteFileConfig {
69
+ modelRouteMap: Record<string, ModelRouteConfig>;
70
+ retryStatusCodes?: Set<number>;
71
+ }
72
+
73
+ function parseCsvList(value: string | undefined): string[] {
74
+ if (!value) {
75
+ return [];
76
+ }
77
+
78
+ return value
79
+ .split(",")
80
+ .map((item) => item.trim())
81
+ .filter(Boolean);
82
+ }
83
+
84
+ function parseRetryCodes(value: string | undefined): Set<number> {
85
+ const defaults = new Set([412, 429, 500, 502, 503, 504]);
86
+
87
+ if (!value) {
88
+ return defaults;
89
+ }
90
+
91
+ const parsed = value
92
+ .split(",")
93
+ .map((item) => Number.parseInt(item.trim(), 10))
94
+ .filter((code) => Number.isInteger(code) && code >= 100 && code <= 9999);
95
+
96
+ return parsed.length > 0 ? new Set(parsed) : defaults;
97
+ }
98
+
99
+ function parseRetryCodesFromConfig(value: unknown): Set<number> | undefined {
100
+ if (value === undefined || value === null) {
101
+ return undefined;
102
+ }
103
+
104
+ if (!Array.isArray(value)) {
105
+ throw new Error('"retryStatusCodes" must be an array.');
106
+ }
107
+
108
+ const parsed = value
109
+ .map((item) => {
110
+ if (typeof item === "number") {
111
+ return item;
112
+ }
113
+
114
+ if (typeof item === "string") {
115
+ return Number.parseInt(item.trim(), 10);
116
+ }
117
+
118
+ return Number.NaN;
119
+ })
120
+ .filter((code) => Number.isInteger(code) && code >= 100 && code <= 9999);
121
+
122
+ if (parsed.length === 0) {
123
+ throw new Error('"retryStatusCodes" must include at least one valid status code (100-9999).');
124
+ }
125
+
126
+ return new Set(parsed);
127
+ }
128
+
129
+ function parseModelFallbackMap(value: string | undefined): Record<string, string[]> {
130
+ if (!value) {
131
+ return {};
132
+ }
133
+
134
+ try {
135
+ const parsed = JSON.parse(value);
136
+
137
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
138
+ throw new Error("MODEL_FALLBACK_MAP must be a JSON object.");
139
+ }
140
+
141
+ return Object.fromEntries(
142
+ Object.entries(parsed).map(([model, fallbacks]) => {
143
+ if (!Array.isArray(fallbacks)) {
144
+ throw new Error(`Fallback list for "${model}" must be an array.`);
145
+ }
146
+
147
+ return [
148
+ model,
149
+ fallbacks.map((item) => String(item).trim()).filter(Boolean),
150
+ ];
151
+ }),
152
+ );
153
+ } catch (error) {
154
+ throw new Error(`Invalid MODEL_FALLBACK_MAP: ${(error as Error).message}`);
155
+ }
156
+ }
157
+
158
+ function parseRouteHeaders(value: unknown, routeName: string): RouteHeaders {
159
+ if (value === undefined) {
160
+ return {};
161
+ }
162
+
163
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
164
+ throw new Error(`Route headers for "${routeName}" must be a YAML object.`);
165
+ }
166
+
167
+ return Object.fromEntries(
168
+ Object.entries(value as Record<string, unknown>).map(([rawKey, rawValue]) => {
169
+ const key = String(rawKey).trim();
170
+ const val = String(rawValue).trim();
171
+
172
+ if (!key) {
173
+ throw new Error(`Route headers for "${routeName}" contains an empty key.`);
174
+ }
175
+
176
+ return [key, val];
177
+ }),
178
+ );
179
+ }
180
+
181
+ function parseModelList(value: unknown, routeName: string): string[] {
182
+ if (typeof value === "string") {
183
+ const normalized = value.trim();
184
+ return normalized ? [normalized] : [];
185
+ }
186
+
187
+ if (!Array.isArray(value)) {
188
+ throw new Error(`Route "${routeName}" must include "model" as string or array.`);
189
+ }
190
+
191
+ return value.map((item) => String(item).trim()).filter(Boolean);
192
+ }
193
+
194
+ function resolveRouteApiKey(rawRoute: RouteConfigInput, defaults: GlobalRouteDefaults): string {
195
+ const apiKeyInline = typeof rawRoute.apiKey === "string" ? rawRoute.apiKey.trim() : "";
196
+
197
+ if (apiKeyInline) {
198
+ return apiKeyInline;
199
+ }
200
+
201
+ const apiKeyEnv = typeof rawRoute.apiKeyEnv === "string" ? rawRoute.apiKeyEnv.trim() : "";
202
+
203
+ if (apiKeyEnv) {
204
+ return process.env[apiKeyEnv] ?? "";
205
+ }
206
+
207
+ if (defaults.apiKey) {
208
+ return defaults.apiKey;
209
+ }
210
+
211
+ return defaults.apiKeyEnv ? process.env[defaults.apiKeyEnv] ?? "" : "";
212
+ }
213
+
214
+ function normalizeGlobalRouteDefaults(value: unknown): GlobalRouteDefaults {
215
+ if (value === undefined || value === null) {
216
+ return {};
217
+ }
218
+
219
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
220
+ throw new Error("Route defaults must be an object.");
221
+ }
222
+
223
+ const defaults = value as Record<string, unknown>;
224
+
225
+ return {
226
+ authHeader:
227
+ typeof defaults.authHeader === "string" && defaults.authHeader.trim()
228
+ ? defaults.authHeader.trim().toLowerCase()
229
+ : undefined,
230
+ authPrefix: typeof defaults.authPrefix === "string" ? defaults.authPrefix : undefined,
231
+ apiKey: typeof defaults.apiKey === "string" ? defaults.apiKey.trim() : undefined,
232
+ apiKeyEnv:
233
+ typeof defaults.apiKeyEnv === "string" && defaults.apiKeyEnv.trim()
234
+ ? defaults.apiKeyEnv.trim()
235
+ : undefined,
236
+ headers: parseRouteHeaders(defaults.headers, "defaults"),
237
+ isBaseUrl: typeof defaults.isBaseUrl === "boolean" ? defaults.isBaseUrl : undefined,
238
+ enabled: typeof defaults.enabled === "boolean" ? defaults.enabled : undefined,
239
+ };
240
+ }
241
+
242
+ function normalizeRouteConfig(
243
+ rawRoute: RouteConfigInput,
244
+ routeName: string,
245
+ defaults: GlobalRouteDefaults,
246
+ ): NormalizedRouteConfig {
247
+ if (!rawRoute || typeof rawRoute !== "object" || Array.isArray(rawRoute)) {
248
+ throw new Error(`Route "${routeName}" must be an object.`);
249
+ }
250
+
251
+ const url = typeof rawRoute.url === "string" ? rawRoute.url.trim() : "";
252
+
253
+ if (!url) {
254
+ throw new Error(`Route "${routeName}" must include a non-empty "url".`);
255
+ }
256
+
257
+ const models = parseModelList(rawRoute.model ?? rawRoute.models, routeName);
258
+
259
+ if (models.length === 0) {
260
+ throw new Error(`Route "${routeName}" must include at least one model.`);
261
+ }
262
+
263
+ const authHeader =
264
+ typeof rawRoute.authHeader === "string" && rawRoute.authHeader.trim()
265
+ ? rawRoute.authHeader.trim().toLowerCase()
266
+ : defaults.authHeader ?? "authorization";
267
+ const authPrefix =
268
+ typeof rawRoute.authPrefix === "string" ? rawRoute.authPrefix : defaults.authPrefix ?? "Bearer ";
269
+ const isBaseUrl =
270
+ typeof rawRoute.isBaseUrl === "boolean"
271
+ ? rawRoute.isBaseUrl
272
+ : Boolean(defaults.isBaseUrl);
273
+ const enabled =
274
+ typeof rawRoute.enabled === "boolean" ? rawRoute.enabled : defaults.enabled ?? true;
275
+ const headers = {
276
+ ...(defaults.headers ?? {}),
277
+ ...parseRouteHeaders(rawRoute.headers, routeName),
278
+ };
279
+
280
+ return {
281
+ routeName,
282
+ url,
283
+ authHeader,
284
+ authPrefix,
285
+ apiKey: resolveRouteApiKey(rawRoute, defaults),
286
+ headers,
287
+ isBaseUrl,
288
+ enabled,
289
+ models,
290
+ };
291
+ }
292
+
293
+ function parseRouteArray(value: unknown, defaults: GlobalRouteDefaults): NormalizedRouteConfig[] {
294
+ if (!Array.isArray(value)) {
295
+ throw new Error('Route config must include "routes" as an array.');
296
+ }
297
+
298
+ return value.map((entry, index) => {
299
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
300
+ throw new Error(`Route entry at index ${index} must be an object.`);
301
+ }
302
+
303
+ const routeData = entry as RouteConfigInput;
304
+ const name = typeof routeData.name === "string" ? routeData.name.trim() : "";
305
+ const routeName = name || `routes[${index}]`;
306
+
307
+ return normalizeRouteConfig(routeData, routeName, defaults);
308
+ });
309
+ }
310
+
311
+ function parseModelRouteConfigFile(filePathRaw: string): ParsedRouteFileConfig {
312
+ const filePath = String(filePathRaw).trim();
313
+
314
+ if (!filePath) {
315
+ throw new Error("Route config path cannot be empty.");
316
+ }
317
+
318
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
319
+ let raw = "";
320
+
321
+ try {
322
+ raw = fs.readFileSync(resolvedPath, "utf8");
323
+ } catch (error) {
324
+ throw new Error(
325
+ `Failed to read route config file "${resolvedPath}": ${
326
+ error instanceof Error ? error.message : "Unknown error"
327
+ }`,
328
+ );
329
+ }
330
+
331
+ let parsed: unknown;
332
+
333
+ try {
334
+ parsed = parseYaml(raw);
335
+ } catch (error) {
336
+ throw new Error(
337
+ `Invalid route config YAML at "${resolvedPath}": ${
338
+ error instanceof Error ? error.message : "Unknown error"
339
+ }`,
340
+ );
341
+ }
342
+
343
+ let defaults: GlobalRouteDefaults = {};
344
+ let routeEntries: unknown;
345
+ let retryStatusCodesFromFile: Set<number> | undefined;
346
+
347
+ if (Array.isArray(parsed)) {
348
+ routeEntries = parsed;
349
+ } else if (parsed && typeof parsed === "object") {
350
+ const objectParsed = parsed as Record<string, unknown>;
351
+ retryStatusCodesFromFile = parseRetryCodesFromConfig(objectParsed.retryStatusCodes);
352
+ defaults = normalizeGlobalRouteDefaults(
353
+ objectParsed.defaults ?? objectParsed.global ?? objectParsed.auth,
354
+ );
355
+ routeEntries = objectParsed.routes;
356
+ } else {
357
+ throw new Error(
358
+ `Route config file "${resolvedPath}" must be a YAML object or array.`,
359
+ );
360
+ }
361
+
362
+ const parsedRoutes = parseRouteArray(routeEntries, defaults);
363
+ const modelRouteMap: Record<string, ModelRouteConfig> = {};
364
+
365
+ for (const route of parsedRoutes) {
366
+ if (!route.enabled) {
367
+ continue;
368
+ }
369
+
370
+ const { routeName, models, enabled: _enabled, ...routeConfig } = route;
371
+
372
+ for (const modelId of models) {
373
+ if (modelRouteMap[modelId]) {
374
+ throw new Error(
375
+ `Duplicate model "${modelId}" found in enabled routes (route "${routeName}").`,
376
+ );
377
+ }
378
+
379
+ modelRouteMap[modelId] = {
380
+ ...routeConfig,
381
+ routeName,
382
+ };
383
+ }
384
+ }
385
+
386
+ return {
387
+ modelRouteMap,
388
+ retryStatusCodes: retryStatusCodesFromFile,
389
+ };
390
+ }
391
+
392
+ function loadRouteFileConfig(): ParsedRouteFileConfig {
393
+ const defaultRouteConfigPath = path.resolve(process.cwd(), "routes.yml");
394
+
395
+ if (!fs.existsSync(defaultRouteConfigPath)) {
396
+ throw new Error(
397
+ `Missing routes config file at "${defaultRouteConfigPath}". Create routes.yml in project root.`,
398
+ );
399
+ }
400
+
401
+ return parseModelRouteConfigFile(defaultRouteConfigPath);
402
+ }
403
+
404
+ const host = process.env.HOST ?? "0.0.0.0";
405
+ const port = Number.parseInt(process.env.PORT ?? "8787", 10);
406
+ const timeoutMs = Number.parseInt(process.env.REQUEST_TIMEOUT_MS ?? "60000", 10);
407
+ const upstreamBaseUrl = (process.env.UPSTREAM_BASE_URL ?? "https://api.openai.com").replace(
408
+ /\/+$/,
409
+ "",
410
+ );
411
+ const routeFileConfig = loadRouteFileConfig();
412
+
413
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
414
+ throw new Error("PORT must be a valid TCP port number.");
415
+ }
416
+
417
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) {
418
+ throw new Error("REQUEST_TIMEOUT_MS must be an integer >= 1000.");
419
+ }
420
+
421
+ export const config: GatewayConfig = {
422
+ host,
423
+ port,
424
+ timeoutMs,
425
+ upstreamBaseUrl,
426
+ upstreamApiKey: process.env.UPSTREAM_API_KEY ?? "",
427
+ retryStatusCodes: routeFileConfig.retryStatusCodes ?? parseRetryCodes(process.env.RETRY_STATUS_CODES),
428
+ globalFallbackModels: parseCsvList(process.env.GLOBAL_FALLBACK_MODELS),
429
+ modelFallbackMap: parseModelFallbackMap(process.env.MODEL_FALLBACK_MAP),
430
+ modelRouteMap: routeFileConfig.modelRouteMap,
431
+ };