glmproxy 2.5.1 → 2.6.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/README.md +243 -270
- package/anthropic.js +734 -734
- package/bin/cli.js +406 -406
- package/lib/core.js +1454 -1434
- package/lib/prompts.js +113 -113
- package/openai.js +425 -425
- package/package.json +1 -1
package/lib/core.js
CHANGED
|
@@ -1,1434 +1,1454 @@
|
|
|
1
|
-
// Shared machinery for the OpenAI and Anthropic proxy entrypoints.
|
|
2
|
-
//
|
|
3
|
-
// Layout contract: each entrypoint owns only its endpoint routes and wire
|
|
4
|
-
// format. Everything both of them need — config, token layer, model catalog,
|
|
5
|
-
// upstream calls, local-gateway client, error classification, loggers, server
|
|
6
|
-
// bootstrap — lives here so no logic is ever duplicated across formats.
|
|
7
|
-
|
|
8
|
-
import http from "http";
|
|
9
|
-
import https from "https";
|
|
10
|
-
import fs from "fs";
|
|
11
|
-
import path from "path";
|
|
12
|
-
import os from "os";
|
|
13
|
-
import crypto from "crypto";
|
|
14
|
-
import {
|
|
15
|
-
DEFAULT_PORTS, DEFAULT_HOST, DEFAULT_PROXY_KEY,
|
|
16
|
-
LOCAL_GATEWAY_HOST as DEFAULT_GATEWAY_HOST,
|
|
17
|
-
LOCAL_GATEWAY_PORT as DEFAULT_GATEWAY_PORT,
|
|
18
|
-
} from "./constants.js";
|
|
19
|
-
|
|
20
|
-
// Single source of truth for the package version (used by the UA string and
|
|
21
|
-
// the startup banners). Read from package.json so a release bump is one edit,
|
|
22
|
-
// not five. package.json is always present in the published tarball.
|
|
23
|
-
let VERSION = "0.0.1"; // fallback if package.json can't be read
|
|
24
|
-
try {
|
|
25
|
-
const v = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
26
|
-
if (typeof v === "string" && v.length > 0) VERSION = v;
|
|
27
|
-
} catch (_) { /* keep fallback */ }
|
|
28
|
-
export { VERSION };
|
|
29
|
-
|
|
30
|
-
// ============================================================================
|
|
31
|
-
// Config
|
|
32
|
-
// ============================================================================
|
|
33
|
-
|
|
34
|
-
// Built-in last-resort catalog if even fallback-models.json is unreadable.
|
|
35
|
-
// The editable copy lives in lib/fallback-models.json — keep both in sync.
|
|
36
|
-
const BUILTIN_FALLBACK_MODELS = [
|
|
37
|
-
{ id: "zai_auto", name: "Auto", contextWindow: 1_048_576, maxTokens: 393_216 },
|
|
38
|
-
{ id: "zaicoding_glm-5.3", name: "GLM-5.3", contextWindow: 1_048_576, maxTokens: 307_200 },
|
|
39
|
-
{ id: "zai_glm-5-turbo", name: "GLM-5-Turbo", contextWindow: 204_800, maxTokens: 131_072 },
|
|
40
|
-
{ id: "zai_glm-5.3-flash", name: "GLM-5.3-Flash", contextWindow: 1_048_576, maxTokens: 131_072 },
|
|
41
|
-
{ id: "tdpsk_deepseek-v4-flash-202605", name: "Deepseek-V4-Flash", contextWindow: 1_048_576, maxTokens: 393_216 },
|
|
42
|
-
{ id: "tdpsk_deepseek-v4-pro-202606", name: "DeepSeek-V4-Pro", contextWindow: 1_048_576, maxTokens: 393_216 },
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
// External fallback catalog (editable without a release), overridable via
|
|
46
|
-
// FALLBACK_MODELS_PATH. Never throws — a missing or malformed file degrades
|
|
47
|
-
// to the built-ins above, same as today.
|
|
48
|
-
function loadFallbackModels() {
|
|
49
|
-
try {
|
|
50
|
-
const override = process.env.FALLBACK_MODELS_PATH;
|
|
51
|
-
const source = override ? path.resolve(override) : new URL("./fallback-models.json", import.meta.url);
|
|
52
|
-
const parsed = JSON.parse(fs.readFileSync(source, "utf8"));
|
|
53
|
-
const models = Array.isArray(parsed?.models) ? parsed.models : parsed;
|
|
54
|
-
if (Array.isArray(models) && models.length > 0
|
|
55
|
-
&& models.every((m) => m && typeof m.id === "string")) return models;
|
|
56
|
-
} catch (_) { /* fall through to built-ins */ }
|
|
57
|
-
return BUILTIN_FALLBACK_MODELS;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Destructuring defaults evaluate in source order — `format` MUST come before
|
|
61
|
-
// `defaultPort` (which reads DEFAULT_PORTS[format]) or it hits the TDZ.
|
|
62
|
-
export function loadConfig({ format = "openai", defaultPort = DEFAULT_PORTS[format] } = {}) {
|
|
63
|
-
const PORT = parseInt(process.env.PORT || String(defaultPort), 10) || defaultPort;
|
|
64
|
-
const HOST = process.env.HOST || DEFAULT_HOST;
|
|
65
|
-
const PROXY_KEY = process.env.PROXY_KEY || DEFAULT_PROXY_KEY;
|
|
66
|
-
const LOG_LEVEL = process.env.LOG_LEVEL || "info"; // "debug" | "info" | "silent"
|
|
67
|
-
const MAX_BODY_BYTES = parseInt(process.env.MAX_BODY_BYTES || String(50 * 1024 * 1024), 10) || 50 * 1024 * 1024;
|
|
68
|
-
const RATE_LIMIT = parseInt(process.env.RATE_LIMIT || "30", 10) || 30; // req/s per IP
|
|
69
|
-
// entity / message limit. 0 / unset / non-numeric → unlimited (no cap).
|
|
70
|
-
// A compression system upstream is the preferred way to handle large
|
|
71
|
-
// contexts; the cap here is only a guard for setups without one.
|
|
72
|
-
const MAX_MESSAGES = (() => {
|
|
73
|
-
const raw = process.env.MAX_MESSAGES;
|
|
74
|
-
if (!raw) return Infinity;
|
|
75
|
-
const n = parseInt(raw, 10);
|
|
76
|
-
if (!Number.isFinite(n) || n <= 0) return Infinity;
|
|
77
|
-
return n;
|
|
78
|
-
})();
|
|
79
|
-
|
|
80
|
-
// PREFER_LOCAL=1 skips the cloud attempt entirely when the local AutoClaw
|
|
81
|
-
// gateway is available — useful while credits are exhausted, where every
|
|
82
|
-
// doomed cloud round-trip just adds latency before the fallback fires anyway.
|
|
83
|
-
const PREFER_LOCAL = process.env.PREFER_LOCAL === "1";
|
|
84
|
-
|
|
85
|
-
const JSONL_LOG = process.env.JSONL_LOG === "true" || process.env.LOG_LEVEL === "debug";
|
|
86
|
-
const JSONL_SYNC = process.env.JSONL_SYNC === "true";
|
|
87
|
-
const JSONL_MAX_BYTES = parseInt(process.env.JSONL_MAX_BYTES || String(10 * 1024 * 1024), 10) || 10 * 1024 * 1024;
|
|
88
|
-
|
|
89
|
-
// Per-format log filenames unless explicitly overridden via env
|
|
90
|
-
const REQUEST_LOG_FILE = process.env.REQUEST_LOG_FILE
|
|
91
|
-
|| path.join(process.cwd(), format === "anthropic" ? "proxy_requests_anthropic.json" : "proxy_requests.json");
|
|
92
|
-
const JSONL_FILE = process.env.JSONL_FILE
|
|
93
|
-
|| path.join(process.cwd(), format === "anthropic" ? "proxy_requests_anthropic.jsonl" : "proxy_requests.jsonl");
|
|
94
|
-
|
|
95
|
-
const UPSTREAM_BASE = "https://autoglm-api.autoglm.ai/autoclaw-proxy/proxy/autoclaw";
|
|
96
|
-
const MODEL_CONFIG_PATH = "/autoclaw-proxy/proxy/autoclaw-model-config";
|
|
97
|
-
|
|
98
|
-
// Operator escape hatches. The vendor budgets 20 min (timeoutSeconds: 1200)
|
|
99
|
-
// per call; the proxy defaults to 2 min per attempt — tune via env if needed.
|
|
100
|
-
const UPSTREAM_TIMEOUT_MS = parseInt(process.env.UPSTREAM_TIMEOUT_MS || "120000", 10) || 120000;
|
|
101
|
-
// Local-gateway WS protocol range (self-heals to the server's range on
|
|
102
|
-
// mismatch anyway — these are the initial offer and manual override).
|
|
103
|
-
const GATEWAY_MIN_PROTOCOL = parseInt(process.env.GATEWAY_MIN_PROTOCOL || "3", 10) || 3;
|
|
104
|
-
const GATEWAY_MAX_PROTOCOL = parseInt(process.env.GATEWAY_MAX_PROTOCOL || "4", 10) || 4;
|
|
105
|
-
const LOCAL_GATEWAY_HOST = process.env.LOCAL_GATEWAY_HOST || DEFAULT_GATEWAY_HOST;
|
|
106
|
-
const LOCAL_GATEWAY_PORT = parseInt(process.env.LOCAL_GATEWAY_PORT || String(DEFAULT_GATEWAY_PORT), 10) || DEFAULT_GATEWAY_PORT;
|
|
107
|
-
|
|
108
|
-
// AutoClaw writes fresh auth headers here whenever the token rotates
|
|
109
|
-
const TOKEN_FILE = path.join(os.homedir(), ".openclaw-autoclaw", "request-headers.json");
|
|
110
|
-
const TOKEN_TTL_MS = 5 * 60 * 1000; // re-read file at most every 5 min
|
|
111
|
-
|
|
112
|
-
// Identifies the request as coming from the AutoClaw desktop client
|
|
113
|
-
// (fallback base — getClientHeaders() overlays live values from the runtime file)
|
|
114
|
-
const CLIENT_HEADERS = {
|
|
115
|
-
"X-Tm": "win",
|
|
116
|
-
"X-Version": "1.17.5",
|
|
117
|
-
"X-Product": "autoclaw",
|
|
118
|
-
"X-Channel": "AutoClaw4",
|
|
119
|
-
"X-Lang": "en",
|
|
120
|
-
"X-Client-Type": "pc",
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const RUNTIME_FILE = path.join(os.homedir(), ".openclaw-autoclaw", "openclaw.runtime.json");
|
|
124
|
-
const RUNTIME_LAST_GOOD = path.join(os.homedir(), ".openclaw-autoclaw", "openclaw.runtime.json.last-good");
|
|
125
|
-
// Ordered fallbacks — try newest first, degrade gracefully
|
|
126
|
-
const RUNTIME_CANDIDATES = [RUNTIME_FILE, RUNTIME_LAST_GOOD];
|
|
127
|
-
|
|
128
|
-
const FALLBACK_MODELS = loadFallbackModels();
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
PORT, HOST, PROXY_KEY, LOG_LEVEL, MAX_BODY_BYTES, RATE_LIMIT, MAX_MESSAGES, PREFER_LOCAL,
|
|
132
|
-
JSONL_LOG, JSONL_SYNC, JSONL_FILE, JSONL_MAX_BYTES, REQUEST_LOG_FILE,
|
|
133
|
-
UPSTREAM_BASE, MODEL_CONFIG_PATH, TOKEN_FILE, TOKEN_TTL_MS,
|
|
134
|
-
UPSTREAM_TIMEOUT_MS, GATEWAY_MIN_PROTOCOL, GATEWAY_MAX_PROTOCOL,
|
|
135
|
-
LOCAL_GATEWAY_HOST, LOCAL_GATEWAY_PORT,
|
|
136
|
-
CLIENT_HEADERS, RUNTIME_FILE, RUNTIME_LAST_GOOD, RUNTIME_CANDIDATES, FALLBACK_MODELS,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ============================================================================
|
|
141
|
-
// Dynamic client headers — AutoClaw app version & client identity
|
|
142
|
-
// ============================================================================
|
|
143
|
-
|
|
144
|
-
// AutoClaw's runtime file (the same one we read for the model catalog) carries
|
|
145
|
-
// the app's own request headers per model entry: X-Version, X-Tm, X-Product,
|
|
146
|
-
// X-Channel, X-Lang, X-Client-Type. Load them the same way we load tokens —
|
|
147
|
-
// read the file, merge over the hardcoded defaults, refresh on a TTL — so an
|
|
148
|
-
// AutoClaw app update is picked up without editing or restarting the proxy.
|
|
149
|
-
// Only whitelisted identity keys are copied: the entry ALSO contains
|
|
150
|
-
// X-Authorization (a live JWT) and X-Request-Model (per-model), which must
|
|
151
|
-
// never leak into the static header set.
|
|
152
|
-
const CLIENT_IDENTITY_KEYS = ["X-Version", "X-Tm", "X-Product", "X-Channel", "X-Lang", "X-Client-Type"];
|
|
153
|
-
|
|
154
|
-
let _clientHeaders = null;
|
|
155
|
-
let _clientHeadersAt = 0;
|
|
156
|
-
|
|
157
|
-
export function getClientHeaders(config) {
|
|
158
|
-
const now = Date.now();
|
|
159
|
-
if (_clientHeaders && now - _clientHeadersAt < config.TOKEN_TTL_MS) return _clientHeaders;
|
|
160
|
-
|
|
161
|
-
const headers = { ...config.CLIENT_HEADERS };
|
|
162
|
-
for (const candidate of config.RUNTIME_CANDIDATES) {
|
|
163
|
-
try {
|
|
164
|
-
const data = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
|
165
|
-
const entry = data?.models?.providers?.zai?.models?.[0]?.headers;
|
|
166
|
-
if (!entry || typeof entry !== "object" || Object.keys(entry).length === 0) continue;
|
|
167
|
-
for (const key of CLIENT_IDENTITY_KEYS) {
|
|
168
|
-
if (typeof entry[key] === "string" && entry[key]) headers[key] = entry[key];
|
|
169
|
-
}
|
|
170
|
-
break;
|
|
171
|
-
} catch (_) { /* try the next candidate */ }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
_clientHeaders = headers;
|
|
175
|
-
_clientHeadersAt = now;
|
|
176
|
-
return headers;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ============================================================================
|
|
180
|
-
// Model catalog — auto-healed from AutoClaw's runtime config
|
|
181
|
-
// ============================================================================
|
|
182
|
-
|
|
183
|
-
export function readRuntimeModels(config) {
|
|
184
|
-
for (const candidate of config.RUNTIME_CANDIDATES) {
|
|
185
|
-
try {
|
|
186
|
-
const raw = fs.readFileSync(candidate, "utf-8");
|
|
187
|
-
const data = JSON.parse(raw);
|
|
188
|
-
const rawModels = data?.models?.providers?.zai?.models;
|
|
189
|
-
if (!Array.isArray(rawModels) || rawModels.length === 0) continue;
|
|
190
|
-
|
|
191
|
-
const models = rawModels.map((m) => ({
|
|
192
|
-
id: m.id,
|
|
193
|
-
name: m.name || m.id,
|
|
194
|
-
contextWindow: m.contextWindow || 1_048_576,
|
|
195
|
-
maxTokens: m.maxTokens || 131_072,
|
|
196
|
-
}));
|
|
197
|
-
|
|
198
|
-
if (models.length > 0) return { models, source: candidate };
|
|
199
|
-
} catch (_) { /* try next candidate */ }
|
|
200
|
-
}
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function loadModelsFromRuntime(config) {
|
|
205
|
-
const catalog = readRuntimeModels(config);
|
|
206
|
-
if (catalog) {
|
|
207
|
-
console.log(` 📋 Loaded ${catalog.models.length} model(s) from ${path.basename(catalog.source)}`);
|
|
208
|
-
return catalog.models;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Nothing worked — use hardcoded fallback
|
|
212
|
-
console.warn(" ⚠️ Could not read runtime models — using built-in fallback");
|
|
213
|
-
return config.FALLBACK_MODELS;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function getModelCatalog(config) {
|
|
217
|
-
const catalog = readRuntimeModels(config);
|
|
218
|
-
return {
|
|
219
|
-
models: catalog?.models || config.FALLBACK_MODELS,
|
|
220
|
-
source: catalog?.source || null,
|
|
221
|
-
fallback: !catalog,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Load MODELS once; each entrypoint keeps its own module-level snapshot
|
|
226
|
-
export function loadModelCatalog(config) {
|
|
227
|
-
return { MODELS: loadModelsFromRuntime(config) };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ============================================================================
|
|
231
|
-
// Logger
|
|
232
|
-
// ============================================================================
|
|
233
|
-
|
|
234
|
-
const COLORS = {
|
|
235
|
-
RESET: '\x1b[0m',
|
|
236
|
-
RED: '\x1b[31m',
|
|
237
|
-
GREEN: '\x1b[32m',
|
|
238
|
-
YELLOW: '\x1b[33m',
|
|
239
|
-
BLUE: '\x1b[34m',
|
|
240
|
-
MAGENTA: '\x1b[35m',
|
|
241
|
-
CYAN: '\x1b[36m',
|
|
242
|
-
GRAY: '\x1b[90m'
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
export { COLORS };
|
|
246
|
-
|
|
247
|
-
export function formatLog(level, color, ...args) {
|
|
248
|
-
const timestamp = new Date().toISOString();
|
|
249
|
-
return [
|
|
250
|
-
`${COLORS.GRAY}[${timestamp}]${COLORS.RESET}`,
|
|
251
|
-
`${color}[${level}]${COLORS.RESET}`,
|
|
252
|
-
...args
|
|
253
|
-
];
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
export function createLogger(logLevel) {
|
|
257
|
-
const log = {
|
|
258
|
-
debug: (...a) => logLevel === "debug" && console.log(...formatLog('DEBUG', COLORS.MAGENTA, ...a)),
|
|
259
|
-
info: (...a) => logLevel !== "silent" && console.log(...formatLog('INFO', COLORS.BLUE, ...a)),
|
|
260
|
-
warn: (...a) => logLevel !== "silent" && console.warn(...formatLog('WARN', COLORS.YELLOW, ...a)),
|
|
261
|
-
error: (...a) => console.error(...formatLog('ERROR', COLORS.RED, ...a)),
|
|
262
|
-
success: (...a) => logLevel !== "silent" && console.log(...formatLog('SUCCESS', COLORS.GREEN, ...a)),
|
|
263
|
-
};
|
|
264
|
-
return { log };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ============================================================================
|
|
268
|
-
// Token layer (mirrors acc's token-extractor.js)
|
|
269
|
-
// ============================================================================
|
|
270
|
-
|
|
271
|
-
export function createTokenLayer(config, log) {
|
|
272
|
-
let _token = null;
|
|
273
|
-
let _tokenReadAt = 0;
|
|
274
|
-
|
|
275
|
-
// Read the X-Authorization JWT from AutoClaw's local token file. Throws if AutoClaw isn't running / logged in.
|
|
276
|
-
function loadToken() {
|
|
277
|
-
try {
|
|
278
|
-
const raw = fs.readFileSync(config.TOKEN_FILE, "utf-8");
|
|
279
|
-
const data = JSON.parse(raw);
|
|
280
|
-
const auth = data?.headers?.["X-Authorization"];
|
|
281
|
-
if (!auth) throw new Error("X-Authorization field missing");
|
|
282
|
-
return auth; // "Bearer <jwt>"
|
|
283
|
-
} catch (err) {
|
|
284
|
-
throw new Error(
|
|
285
|
-
`Cannot read AutoClaw token from ${config.TOKEN_FILE}. ` +
|
|
286
|
-
`Make sure AutoClaw is running and you are logged in. (${err.message})`
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Return a cached token, refreshing from disk if the TTL has elapsed.
|
|
292
|
-
function getToken() {
|
|
293
|
-
if (!_token || Date.now() - _tokenReadAt > config.TOKEN_TTL_MS) {
|
|
294
|
-
_token = loadToken();
|
|
295
|
-
_tokenReadAt = Date.now();
|
|
296
|
-
log.info(`Token loaded (expires cache in ${config.TOKEN_TTL_MS / 60_000} min)`);
|
|
297
|
-
}
|
|
298
|
-
return _token;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Force the next getToken() call to re-read the file.
|
|
302
|
-
function invalidateToken() {
|
|
303
|
-
_token = null;
|
|
304
|
-
_tokenReadAt = 0;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Hot-reload token when AutoClaw rotates it — avoids restart
|
|
308
|
-
function startWatch() {
|
|
309
|
-
fs.watchFile(config.TOKEN_FILE, { interval: 1000 }, () => {
|
|
310
|
-
try {
|
|
311
|
-
_token = loadToken();
|
|
312
|
-
log.info("Token reloaded");
|
|
313
|
-
} catch (e) {
|
|
314
|
-
log.warn(`Token reload failed: ${e.message}`);
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
return { loadToken, getToken, invalidateToken, startWatch };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ============================================================================
|
|
323
|
-
// Error taxonomy — one classifier decides status/type/code/message for every
|
|
324
|
-
// failure, so clients never see a generic blob again.
|
|
325
|
-
//
|
|
326
|
-
// quota / 402 / code-810000 / 积分不足 → 402 insufficient_credits
|
|
327
|
-
// unknown model → 404 not_found_error
|
|
328
|
-
// rate limited → 429 rate_limit_error (passthrough)
|
|
329
|
-
// bad client input → 400 / 413 / 415 (handled pre-upstream)
|
|
330
|
-
// cloud token missing → 503 service_unavailable
|
|
331
|
-
// upstream timeout → 504
|
|
332
|
-
// other upstream/network failures → 502 (with upstream status noted)
|
|
333
|
-
// ============================================================================
|
|
334
|
-
|
|
335
|
-
// Translate common Chinese upstream error messages to English
|
|
336
|
-
const ZH_ERROR_MAP = [
|
|
337
|
-
[/积分不足/, "Insufficient credits — please recharge your AutoClaw account"],
|
|
338
|
-
[/非法模型/, "Invalid model — the requested model ID is not recognized upstream"],
|
|
339
|
-
[/请求频率/, "Rate limited by upstream — too many requests"],
|
|
340
|
-
[/令牌.*过期|token.*expired/i, "Authentication token expired"],
|
|
341
|
-
[/参数.*错误|invalid.*param/i, "Invalid request parameters"],
|
|
342
|
-
[/服务.*繁忙/, "Upstream service is busy — please retry"],
|
|
343
|
-
[/请求.*超时/, "Upstream request timed out"],
|
|
344
|
-
]
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
case
|
|
420
|
-
return {
|
|
421
|
-
status:
|
|
422
|
-
message:
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
return
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
export function
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
export function
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
//
|
|
711
|
-
export
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
return
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
function
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
if (
|
|
865
|
-
|
|
866
|
-
} catch (_) {}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
export function
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
export function
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
if (
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
//
|
|
1152
|
-
function
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
//
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
"
|
|
1211
|
-
|
|
1212
|
-
"
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
//
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
return
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
//
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
};
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
export function
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
}
|
|
1
|
+
// Shared machinery for the OpenAI and Anthropic proxy entrypoints.
|
|
2
|
+
//
|
|
3
|
+
// Layout contract: each entrypoint owns only its endpoint routes and wire
|
|
4
|
+
// format. Everything both of them need — config, token layer, model catalog,
|
|
5
|
+
// upstream calls, local-gateway client, error classification, loggers, server
|
|
6
|
+
// bootstrap — lives here so no logic is ever duplicated across formats.
|
|
7
|
+
|
|
8
|
+
import http from "http";
|
|
9
|
+
import https from "https";
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import os from "os";
|
|
13
|
+
import crypto from "crypto";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_PORTS, DEFAULT_HOST, DEFAULT_PROXY_KEY,
|
|
16
|
+
LOCAL_GATEWAY_HOST as DEFAULT_GATEWAY_HOST,
|
|
17
|
+
LOCAL_GATEWAY_PORT as DEFAULT_GATEWAY_PORT,
|
|
18
|
+
} from "./constants.js";
|
|
19
|
+
|
|
20
|
+
// Single source of truth for the package version (used by the UA string and
|
|
21
|
+
// the startup banners). Read from package.json so a release bump is one edit,
|
|
22
|
+
// not five. package.json is always present in the published tarball.
|
|
23
|
+
let VERSION = "0.0.1"; // fallback if package.json can't be read
|
|
24
|
+
try {
|
|
25
|
+
const v = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
26
|
+
if (typeof v === "string" && v.length > 0) VERSION = v;
|
|
27
|
+
} catch (_) { /* keep fallback */ }
|
|
28
|
+
export { VERSION };
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Config
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
// Built-in last-resort catalog if even fallback-models.json is unreadable.
|
|
35
|
+
// The editable copy lives in lib/fallback-models.json — keep both in sync.
|
|
36
|
+
const BUILTIN_FALLBACK_MODELS = [
|
|
37
|
+
{ id: "zai_auto", name: "Auto", contextWindow: 1_048_576, maxTokens: 393_216 },
|
|
38
|
+
{ id: "zaicoding_glm-5.3", name: "GLM-5.3", contextWindow: 1_048_576, maxTokens: 307_200 },
|
|
39
|
+
{ id: "zai_glm-5-turbo", name: "GLM-5-Turbo", contextWindow: 204_800, maxTokens: 131_072 },
|
|
40
|
+
{ id: "zai_glm-5.3-flash", name: "GLM-5.3-Flash", contextWindow: 1_048_576, maxTokens: 131_072 },
|
|
41
|
+
{ id: "tdpsk_deepseek-v4-flash-202605", name: "Deepseek-V4-Flash", contextWindow: 1_048_576, maxTokens: 393_216 },
|
|
42
|
+
{ id: "tdpsk_deepseek-v4-pro-202606", name: "DeepSeek-V4-Pro", contextWindow: 1_048_576, maxTokens: 393_216 },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// External fallback catalog (editable without a release), overridable via
|
|
46
|
+
// FALLBACK_MODELS_PATH. Never throws — a missing or malformed file degrades
|
|
47
|
+
// to the built-ins above, same as today.
|
|
48
|
+
function loadFallbackModels() {
|
|
49
|
+
try {
|
|
50
|
+
const override = process.env.FALLBACK_MODELS_PATH;
|
|
51
|
+
const source = override ? path.resolve(override) : new URL("./fallback-models.json", import.meta.url);
|
|
52
|
+
const parsed = JSON.parse(fs.readFileSync(source, "utf8"));
|
|
53
|
+
const models = Array.isArray(parsed?.models) ? parsed.models : parsed;
|
|
54
|
+
if (Array.isArray(models) && models.length > 0
|
|
55
|
+
&& models.every((m) => m && typeof m.id === "string")) return models;
|
|
56
|
+
} catch (_) { /* fall through to built-ins */ }
|
|
57
|
+
return BUILTIN_FALLBACK_MODELS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Destructuring defaults evaluate in source order — `format` MUST come before
|
|
61
|
+
// `defaultPort` (which reads DEFAULT_PORTS[format]) or it hits the TDZ.
|
|
62
|
+
export function loadConfig({ format = "openai", defaultPort = DEFAULT_PORTS[format] } = {}) {
|
|
63
|
+
const PORT = parseInt(process.env.PORT || String(defaultPort), 10) || defaultPort;
|
|
64
|
+
const HOST = process.env.HOST || DEFAULT_HOST;
|
|
65
|
+
const PROXY_KEY = process.env.PROXY_KEY || DEFAULT_PROXY_KEY;
|
|
66
|
+
const LOG_LEVEL = process.env.LOG_LEVEL || "info"; // "debug" | "info" | "silent"
|
|
67
|
+
const MAX_BODY_BYTES = parseInt(process.env.MAX_BODY_BYTES || String(50 * 1024 * 1024), 10) || 50 * 1024 * 1024;
|
|
68
|
+
const RATE_LIMIT = parseInt(process.env.RATE_LIMIT || "30", 10) || 30; // req/s per IP
|
|
69
|
+
// entity / message limit. 0 / unset / non-numeric → unlimited (no cap).
|
|
70
|
+
// A compression system upstream is the preferred way to handle large
|
|
71
|
+
// contexts; the cap here is only a guard for setups without one.
|
|
72
|
+
const MAX_MESSAGES = (() => {
|
|
73
|
+
const raw = process.env.MAX_MESSAGES;
|
|
74
|
+
if (!raw) return Infinity;
|
|
75
|
+
const n = parseInt(raw, 10);
|
|
76
|
+
if (!Number.isFinite(n) || n <= 0) return Infinity;
|
|
77
|
+
return n;
|
|
78
|
+
})();
|
|
79
|
+
|
|
80
|
+
// PREFER_LOCAL=1 skips the cloud attempt entirely when the local AutoClaw
|
|
81
|
+
// gateway is available — useful while credits are exhausted, where every
|
|
82
|
+
// doomed cloud round-trip just adds latency before the fallback fires anyway.
|
|
83
|
+
const PREFER_LOCAL = process.env.PREFER_LOCAL === "1";
|
|
84
|
+
|
|
85
|
+
const JSONL_LOG = process.env.JSONL_LOG === "true" || process.env.LOG_LEVEL === "debug";
|
|
86
|
+
const JSONL_SYNC = process.env.JSONL_SYNC === "true";
|
|
87
|
+
const JSONL_MAX_BYTES = parseInt(process.env.JSONL_MAX_BYTES || String(10 * 1024 * 1024), 10) || 10 * 1024 * 1024;
|
|
88
|
+
|
|
89
|
+
// Per-format log filenames unless explicitly overridden via env
|
|
90
|
+
const REQUEST_LOG_FILE = process.env.REQUEST_LOG_FILE
|
|
91
|
+
|| path.join(process.cwd(), format === "anthropic" ? "proxy_requests_anthropic.json" : "proxy_requests.json");
|
|
92
|
+
const JSONL_FILE = process.env.JSONL_FILE
|
|
93
|
+
|| path.join(process.cwd(), format === "anthropic" ? "proxy_requests_anthropic.jsonl" : "proxy_requests.jsonl");
|
|
94
|
+
|
|
95
|
+
const UPSTREAM_BASE = "https://autoglm-api.autoglm.ai/autoclaw-proxy/proxy/autoclaw";
|
|
96
|
+
const MODEL_CONFIG_PATH = "/autoclaw-proxy/proxy/autoclaw-model-config";
|
|
97
|
+
|
|
98
|
+
// Operator escape hatches. The vendor budgets 20 min (timeoutSeconds: 1200)
|
|
99
|
+
// per call; the proxy defaults to 2 min per attempt — tune via env if needed.
|
|
100
|
+
const UPSTREAM_TIMEOUT_MS = parseInt(process.env.UPSTREAM_TIMEOUT_MS || "120000", 10) || 120000;
|
|
101
|
+
// Local-gateway WS protocol range (self-heals to the server's range on
|
|
102
|
+
// mismatch anyway — these are the initial offer and manual override).
|
|
103
|
+
const GATEWAY_MIN_PROTOCOL = parseInt(process.env.GATEWAY_MIN_PROTOCOL || "3", 10) || 3;
|
|
104
|
+
const GATEWAY_MAX_PROTOCOL = parseInt(process.env.GATEWAY_MAX_PROTOCOL || "4", 10) || 4;
|
|
105
|
+
const LOCAL_GATEWAY_HOST = process.env.LOCAL_GATEWAY_HOST || DEFAULT_GATEWAY_HOST;
|
|
106
|
+
const LOCAL_GATEWAY_PORT = parseInt(process.env.LOCAL_GATEWAY_PORT || String(DEFAULT_GATEWAY_PORT), 10) || DEFAULT_GATEWAY_PORT;
|
|
107
|
+
|
|
108
|
+
// AutoClaw writes fresh auth headers here whenever the token rotates
|
|
109
|
+
const TOKEN_FILE = path.join(os.homedir(), ".openclaw-autoclaw", "request-headers.json");
|
|
110
|
+
const TOKEN_TTL_MS = 5 * 60 * 1000; // re-read file at most every 5 min
|
|
111
|
+
|
|
112
|
+
// Identifies the request as coming from the AutoClaw desktop client
|
|
113
|
+
// (fallback base — getClientHeaders() overlays live values from the runtime file)
|
|
114
|
+
const CLIENT_HEADERS = {
|
|
115
|
+
"X-Tm": "win",
|
|
116
|
+
"X-Version": "1.17.5",
|
|
117
|
+
"X-Product": "autoclaw",
|
|
118
|
+
"X-Channel": "AutoClaw4",
|
|
119
|
+
"X-Lang": "en",
|
|
120
|
+
"X-Client-Type": "pc",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const RUNTIME_FILE = path.join(os.homedir(), ".openclaw-autoclaw", "openclaw.runtime.json");
|
|
124
|
+
const RUNTIME_LAST_GOOD = path.join(os.homedir(), ".openclaw-autoclaw", "openclaw.runtime.json.last-good");
|
|
125
|
+
// Ordered fallbacks — try newest first, degrade gracefully
|
|
126
|
+
const RUNTIME_CANDIDATES = [RUNTIME_FILE, RUNTIME_LAST_GOOD];
|
|
127
|
+
|
|
128
|
+
const FALLBACK_MODELS = loadFallbackModels();
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
PORT, HOST, PROXY_KEY, LOG_LEVEL, MAX_BODY_BYTES, RATE_LIMIT, MAX_MESSAGES, PREFER_LOCAL,
|
|
132
|
+
JSONL_LOG, JSONL_SYNC, JSONL_FILE, JSONL_MAX_BYTES, REQUEST_LOG_FILE,
|
|
133
|
+
UPSTREAM_BASE, MODEL_CONFIG_PATH, TOKEN_FILE, TOKEN_TTL_MS,
|
|
134
|
+
UPSTREAM_TIMEOUT_MS, GATEWAY_MIN_PROTOCOL, GATEWAY_MAX_PROTOCOL,
|
|
135
|
+
LOCAL_GATEWAY_HOST, LOCAL_GATEWAY_PORT,
|
|
136
|
+
CLIENT_HEADERS, RUNTIME_FILE, RUNTIME_LAST_GOOD, RUNTIME_CANDIDATES, FALLBACK_MODELS,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// Dynamic client headers — AutoClaw app version & client identity
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
// AutoClaw's runtime file (the same one we read for the model catalog) carries
|
|
145
|
+
// the app's own request headers per model entry: X-Version, X-Tm, X-Product,
|
|
146
|
+
// X-Channel, X-Lang, X-Client-Type. Load them the same way we load tokens —
|
|
147
|
+
// read the file, merge over the hardcoded defaults, refresh on a TTL — so an
|
|
148
|
+
// AutoClaw app update is picked up without editing or restarting the proxy.
|
|
149
|
+
// Only whitelisted identity keys are copied: the entry ALSO contains
|
|
150
|
+
// X-Authorization (a live JWT) and X-Request-Model (per-model), which must
|
|
151
|
+
// never leak into the static header set.
|
|
152
|
+
const CLIENT_IDENTITY_KEYS = ["X-Version", "X-Tm", "X-Product", "X-Channel", "X-Lang", "X-Client-Type"];
|
|
153
|
+
|
|
154
|
+
let _clientHeaders = null;
|
|
155
|
+
let _clientHeadersAt = 0;
|
|
156
|
+
|
|
157
|
+
export function getClientHeaders(config) {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (_clientHeaders && now - _clientHeadersAt < config.TOKEN_TTL_MS) return _clientHeaders;
|
|
160
|
+
|
|
161
|
+
const headers = { ...config.CLIENT_HEADERS };
|
|
162
|
+
for (const candidate of config.RUNTIME_CANDIDATES) {
|
|
163
|
+
try {
|
|
164
|
+
const data = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
|
165
|
+
const entry = data?.models?.providers?.zai?.models?.[0]?.headers;
|
|
166
|
+
if (!entry || typeof entry !== "object" || Object.keys(entry).length === 0) continue;
|
|
167
|
+
for (const key of CLIENT_IDENTITY_KEYS) {
|
|
168
|
+
if (typeof entry[key] === "string" && entry[key]) headers[key] = entry[key];
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
} catch (_) { /* try the next candidate */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_clientHeaders = headers;
|
|
175
|
+
_clientHeadersAt = now;
|
|
176
|
+
return headers;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// Model catalog — auto-healed from AutoClaw's runtime config
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
export function readRuntimeModels(config) {
|
|
184
|
+
for (const candidate of config.RUNTIME_CANDIDATES) {
|
|
185
|
+
try {
|
|
186
|
+
const raw = fs.readFileSync(candidate, "utf-8");
|
|
187
|
+
const data = JSON.parse(raw);
|
|
188
|
+
const rawModels = data?.models?.providers?.zai?.models;
|
|
189
|
+
if (!Array.isArray(rawModels) || rawModels.length === 0) continue;
|
|
190
|
+
|
|
191
|
+
const models = rawModels.map((m) => ({
|
|
192
|
+
id: m.id,
|
|
193
|
+
name: m.name || m.id,
|
|
194
|
+
contextWindow: m.contextWindow || 1_048_576,
|
|
195
|
+
maxTokens: m.maxTokens || 131_072,
|
|
196
|
+
}));
|
|
197
|
+
|
|
198
|
+
if (models.length > 0) return { models, source: candidate };
|
|
199
|
+
} catch (_) { /* try next candidate */ }
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function loadModelsFromRuntime(config) {
|
|
205
|
+
const catalog = readRuntimeModels(config);
|
|
206
|
+
if (catalog) {
|
|
207
|
+
console.log(` 📋 Loaded ${catalog.models.length} model(s) from ${path.basename(catalog.source)}`);
|
|
208
|
+
return catalog.models;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Nothing worked — use hardcoded fallback
|
|
212
|
+
console.warn(" ⚠️ Could not read runtime models — using built-in fallback");
|
|
213
|
+
return config.FALLBACK_MODELS;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function getModelCatalog(config) {
|
|
217
|
+
const catalog = readRuntimeModels(config);
|
|
218
|
+
return {
|
|
219
|
+
models: catalog?.models || config.FALLBACK_MODELS,
|
|
220
|
+
source: catalog?.source || null,
|
|
221
|
+
fallback: !catalog,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Load MODELS once; each entrypoint keeps its own module-level snapshot
|
|
226
|
+
export function loadModelCatalog(config) {
|
|
227
|
+
return { MODELS: loadModelsFromRuntime(config) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Logger
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
const COLORS = {
|
|
235
|
+
RESET: '\x1b[0m',
|
|
236
|
+
RED: '\x1b[31m',
|
|
237
|
+
GREEN: '\x1b[32m',
|
|
238
|
+
YELLOW: '\x1b[33m',
|
|
239
|
+
BLUE: '\x1b[34m',
|
|
240
|
+
MAGENTA: '\x1b[35m',
|
|
241
|
+
CYAN: '\x1b[36m',
|
|
242
|
+
GRAY: '\x1b[90m'
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export { COLORS };
|
|
246
|
+
|
|
247
|
+
export function formatLog(level, color, ...args) {
|
|
248
|
+
const timestamp = new Date().toISOString();
|
|
249
|
+
return [
|
|
250
|
+
`${COLORS.GRAY}[${timestamp}]${COLORS.RESET}`,
|
|
251
|
+
`${color}[${level}]${COLORS.RESET}`,
|
|
252
|
+
...args
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createLogger(logLevel) {
|
|
257
|
+
const log = {
|
|
258
|
+
debug: (...a) => logLevel === "debug" && console.log(...formatLog('DEBUG', COLORS.MAGENTA, ...a)),
|
|
259
|
+
info: (...a) => logLevel !== "silent" && console.log(...formatLog('INFO', COLORS.BLUE, ...a)),
|
|
260
|
+
warn: (...a) => logLevel !== "silent" && console.warn(...formatLog('WARN', COLORS.YELLOW, ...a)),
|
|
261
|
+
error: (...a) => console.error(...formatLog('ERROR', COLORS.RED, ...a)),
|
|
262
|
+
success: (...a) => logLevel !== "silent" && console.log(...formatLog('SUCCESS', COLORS.GREEN, ...a)),
|
|
263
|
+
};
|
|
264
|
+
return { log };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// Token layer (mirrors acc's token-extractor.js)
|
|
269
|
+
// ============================================================================
|
|
270
|
+
|
|
271
|
+
export function createTokenLayer(config, log) {
|
|
272
|
+
let _token = null;
|
|
273
|
+
let _tokenReadAt = 0;
|
|
274
|
+
|
|
275
|
+
// Read the X-Authorization JWT from AutoClaw's local token file. Throws if AutoClaw isn't running / logged in.
|
|
276
|
+
function loadToken() {
|
|
277
|
+
try {
|
|
278
|
+
const raw = fs.readFileSync(config.TOKEN_FILE, "utf-8");
|
|
279
|
+
const data = JSON.parse(raw);
|
|
280
|
+
const auth = data?.headers?.["X-Authorization"];
|
|
281
|
+
if (!auth) throw new Error("X-Authorization field missing");
|
|
282
|
+
return auth; // "Bearer <jwt>"
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Cannot read AutoClaw token from ${config.TOKEN_FILE}. ` +
|
|
286
|
+
`Make sure AutoClaw is running and you are logged in. (${err.message})`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Return a cached token, refreshing from disk if the TTL has elapsed.
|
|
292
|
+
function getToken() {
|
|
293
|
+
if (!_token || Date.now() - _tokenReadAt > config.TOKEN_TTL_MS) {
|
|
294
|
+
_token = loadToken();
|
|
295
|
+
_tokenReadAt = Date.now();
|
|
296
|
+
log.info(`Token loaded (expires cache in ${config.TOKEN_TTL_MS / 60_000} min)`);
|
|
297
|
+
}
|
|
298
|
+
return _token;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Force the next getToken() call to re-read the file.
|
|
302
|
+
function invalidateToken() {
|
|
303
|
+
_token = null;
|
|
304
|
+
_tokenReadAt = 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Hot-reload token when AutoClaw rotates it — avoids restart
|
|
308
|
+
function startWatch() {
|
|
309
|
+
fs.watchFile(config.TOKEN_FILE, { interval: 1000 }, () => {
|
|
310
|
+
try {
|
|
311
|
+
_token = loadToken();
|
|
312
|
+
log.info("Token reloaded");
|
|
313
|
+
} catch (e) {
|
|
314
|
+
log.warn(`Token reload failed: ${e.message}`);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { loadToken, getToken, invalidateToken, startWatch };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ============================================================================
|
|
323
|
+
// Error taxonomy — one classifier decides status/type/code/message for every
|
|
324
|
+
// failure, so clients never see a generic blob again.
|
|
325
|
+
//
|
|
326
|
+
// quota / 402 / code-810000 / 积分不足 → 402 insufficient_credits
|
|
327
|
+
// unknown model → 404 not_found_error
|
|
328
|
+
// rate limited → 429 rate_limit_error (passthrough)
|
|
329
|
+
// bad client input → 400 / 413 / 415 (handled pre-upstream)
|
|
330
|
+
// cloud token missing → 503 service_unavailable
|
|
331
|
+
// upstream timeout → 504
|
|
332
|
+
// other upstream/network failures → 502 (with upstream status noted)
|
|
333
|
+
// ============================================================================
|
|
334
|
+
|
|
335
|
+
// Translate common Chinese upstream error messages to English
|
|
336
|
+
const ZH_ERROR_MAP = [
|
|
337
|
+
[/积分不足/, "Insufficient credits — please recharge your AutoClaw account"],
|
|
338
|
+
[/非法模型/, "Invalid model — the requested model ID is not recognized upstream"],
|
|
339
|
+
[/请求频率/, "Rate limited by upstream — too many requests"],
|
|
340
|
+
[/令牌.*过期|token.*expired/i, "Authentication token expired"],
|
|
341
|
+
[/参数.*错误|invalid.*param/i, "Invalid request parameters"],
|
|
342
|
+
[/服务.*繁忙/, "Upstream service is busy — please retry"],
|
|
343
|
+
[/请求.*超时/, "Upstream request timed out"],
|
|
344
|
+
[/账号.*封禁|已封禁/, "Account banned by AutoClaw"],
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
export function translateUpstreamError(msg) {
|
|
348
|
+
if (typeof msg !== "string") return msg;
|
|
349
|
+
for (const [pattern, english] of ZH_ERROR_MAP) {
|
|
350
|
+
if (pattern.test(msg)) return english;
|
|
351
|
+
}
|
|
352
|
+
return msg;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function getUpstreamErrorMessage(body) {
|
|
356
|
+
const text = typeof body === "string" ? body.trim() : "";
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const parsed = JSON.parse(text);
|
|
360
|
+
const message = typeof parsed === "string"
|
|
361
|
+
? parsed
|
|
362
|
+
: parsed?.error?.message || parsed?.message || parsed?.error;
|
|
363
|
+
if (typeof message === "string" && message.length > 0) {
|
|
364
|
+
return translateUpstreamError(message);
|
|
365
|
+
}
|
|
366
|
+
return "Upstream error";
|
|
367
|
+
} catch {
|
|
368
|
+
const title = text.match(/<title>(.*?)<\/title>/i)?.[1];
|
|
369
|
+
if (title) return translateUpstreamError(title);
|
|
370
|
+
if (/<(?:html|body|!doctype)\b/i.test(text)) return "Upstream returned an invalid error response";
|
|
371
|
+
return translateUpstreamError(text || "Upstream error");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Body markers that mean "this account cannot use this model until it pays" —
|
|
376
|
+
// these are PERMANENT conditions, not transient hiccups, so they must never be
|
|
377
|
+
// retried or fallen back on. AutoClaw surfaces them as 403+code 810000, plain
|
|
378
|
+
// 402, or Chinese credit messages depending on which door you knock on.
|
|
379
|
+
const QUOTA_BODY_RE = /积分不足|free quota used up|insufficient credit|quota\s*(exceed|used up)|810000/i;
|
|
380
|
+
|
|
381
|
+
// Account-level ban (403 + code 410004 / "账号已被封禁"). PERMANENT, like quota:
|
|
382
|
+
// repeat requests must fail instantly instead of replaying doomed cloud
|
|
383
|
+
// attempts and falling into the local agent on every call.
|
|
384
|
+
const BANNED_BODY_RE = /账号.*封禁|已封禁|410004/i;
|
|
385
|
+
|
|
386
|
+
// Classify a failed cloud response into the client-facing error shape.
|
|
387
|
+
// `bodyText` is the raw upstream response body (may be empty).
|
|
388
|
+
export function classifyUpstreamError(statusCode, bodyText, modelName) {
|
|
389
|
+
const text = typeof bodyText === "string" ? bodyText : "";
|
|
390
|
+
const detail = getUpstreamErrorMessage(text);
|
|
391
|
+
|
|
392
|
+
// Account bans outrank generic 403s — same account feeds the local agent,
|
|
393
|
+
// so there is no fallback worth replaying either.
|
|
394
|
+
if (statusCode === 403 && BANNED_BODY_RE.test(text)) {
|
|
395
|
+
return {
|
|
396
|
+
status: 403,
|
|
397
|
+
type: "permission_error",
|
|
398
|
+
code: "account_banned",
|
|
399
|
+
permanent: true,
|
|
400
|
+
message: detail && detail !== "Upstream error"
|
|
401
|
+
? `${modelName || "This model"} — ${detail}`
|
|
402
|
+
: `${modelName || "This model"} — account banned by AutoClaw`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Quota outranks everything — upstream reports it under several statuses
|
|
407
|
+
if (statusCode === 402 || QUOTA_BODY_RE.test(text)) {
|
|
408
|
+
return {
|
|
409
|
+
status: 402,
|
|
410
|
+
type: "insufficient_credits",
|
|
411
|
+
code: "quota_exhausted",
|
|
412
|
+
permanent: true,
|
|
413
|
+
message: `${modelName || "This model"} is out of credits — recharge or subscribe in AutoClaw` +
|
|
414
|
+
(detail && detail !== "Upstream error" ? ` (${detail})` : ""),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
switch (statusCode) {
|
|
419
|
+
case 401:
|
|
420
|
+
return {
|
|
421
|
+
status: 401, type: "authentication_error", code: "token_expired", permanent: false,
|
|
422
|
+
message: "AutoClaw token expired or invalid — cached token invalidated, retry now",
|
|
423
|
+
};
|
|
424
|
+
case 403:
|
|
425
|
+
return {
|
|
426
|
+
status: 403, type: "permission_error", code: "forbidden_by_upstream", permanent: false,
|
|
427
|
+
message: detail !== "Upstream error" ? detail : "AutoClaw upstream refused this request (HTTP 403)",
|
|
428
|
+
};
|
|
429
|
+
case 404:
|
|
430
|
+
return {
|
|
431
|
+
status: 404, type: "not_found_error", code: "model_not_found", permanent: true,
|
|
432
|
+
message: `Model ${modelName || ""} is not recognized by AutoClaw upstream`.trim(),
|
|
433
|
+
};
|
|
434
|
+
case 429:
|
|
435
|
+
return {
|
|
436
|
+
status: 429, type: "rate_limit_error", code: "rate_limited_by_upstream", permanent: false,
|
|
437
|
+
message: detail !== "Upstream error" ? detail : "Rate limited by AutoClaw upstream — slow down",
|
|
438
|
+
};
|
|
439
|
+
case 400:
|
|
440
|
+
return {
|
|
441
|
+
status: 400, type: "invalid_request_error", code: "invalid_request", permanent: false,
|
|
442
|
+
message: detail,
|
|
443
|
+
};
|
|
444
|
+
default:
|
|
445
|
+
if (statusCode >= 500) {
|
|
446
|
+
return {
|
|
447
|
+
status: 502, type: "api_error", code: "upstream_failure", permanent: false,
|
|
448
|
+
message: `AutoClaw upstream failed (HTTP ${statusCode}): ${detail}`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
status: statusCode >= 400 ? statusCode : 502,
|
|
453
|
+
type: "api_error", code: "upstream_failure", permanent: false,
|
|
454
|
+
message: detail !== "Upstream error" ? detail : "Upstream error",
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Classify an error raised by the local WebSocket agent path. The gateway's
|
|
460
|
+
// FailoverError strings embed the real upstream status ("FailoverError: HTTP
|
|
461
|
+
// 403: ...", "FailoverError: 402 status code"), so mine those first.
|
|
462
|
+
export function classifyLocalAgentError(err, modelName) {
|
|
463
|
+
const raw = String(err?.message || err || "");
|
|
464
|
+
|
|
465
|
+
if (/\b402\b/.test(raw)) {
|
|
466
|
+
return {
|
|
467
|
+
status: 402, type: "insufficient_credits", code: "quota_exhausted", permanent: true,
|
|
468
|
+
message: `${modelName || "This model"} is out of credits — recharge or subscribe in AutoClaw`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (/\b403\b/.test(raw)) {
|
|
472
|
+
if (/quota|810000/i.test(raw)) {
|
|
473
|
+
return {
|
|
474
|
+
status: 402, type: "insufficient_credits", code: "quota_exhausted", permanent: true,
|
|
475
|
+
message: `${modelName || "This model"} free quota is used up — subscribe to a membership in AutoClaw`,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
status: 403, type: "permission_error", code: "forbidden_by_local_gateway", permanent: false,
|
|
480
|
+
message: "AutoClaw local gateway refused this request (HTTP 403)",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (/timeout/i.test(raw)) {
|
|
484
|
+
return {
|
|
485
|
+
status: 504, type: "api_error", code: "local_gateway_timeout", permanent: false,
|
|
486
|
+
message: "AutoClaw local gateway did not finish in time — try again or check the desktop app",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (/token not found|Is AutoClaw running/i.test(raw)) {
|
|
490
|
+
return {
|
|
491
|
+
status: 503, type: "service_unavailable", code: "no_local_gateway", permanent: true,
|
|
492
|
+
message: "AutoClaw local gateway is not reachable — make sure the desktop app is running",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
status: 502, type: "api_error", code: "local_gateway_failed", permanent: false,
|
|
497
|
+
message: getUpstreamErrorMessage(raw),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Classify an error thrown by the upstream transport itself — no HTTP
|
|
502
|
+
// response ever arrived: dead token, connection reset after the retry budget,
|
|
503
|
+
// or a 2-minute timeout.
|
|
504
|
+
export function classifyTransportError(err) {
|
|
505
|
+
const msg = String(err?.message || err || "");
|
|
506
|
+
|
|
507
|
+
if (/Cannot read AutoClaw token/i.test(msg)) {
|
|
508
|
+
return {
|
|
509
|
+
status: 503, type: "service_unavailable", code: "no_token", permanent: false,
|
|
510
|
+
message: msg,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (err?.code === "UPSTREAM_TIMEOUT" || /timeout/i.test(msg)) {
|
|
514
|
+
return {
|
|
515
|
+
status: 504, type: "api_error", code: "upstream_timeout", permanent: false,
|
|
516
|
+
message: msg !== "Error" ? msg : "AutoClaw upstream did not respond in time",
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
status: 502, type: "api_error", code: "upstream_connection_failed", permanent: false,
|
|
521
|
+
message: `${msg}${err?.code ? ` (${err.code})` : ""}` || "Could not reach AutoClaw upstream",
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Transient network failures are worth exactly one transparent retry; anything
|
|
526
|
+
// else (timeouts included — they already burned 2 minutes) is surfaced as-is.
|
|
527
|
+
export function isTransientNetworkError(err) {
|
|
528
|
+
const code = err?.code || "";
|
|
529
|
+
const msg = String(err?.message || "");
|
|
530
|
+
return (
|
|
531
|
+
["ECONNRESET", "EPIPE", "ECONNABORTED", "ERR_STREAM_PREMATURE_CLOSE"].includes(code) ||
|
|
532
|
+
/socket hang up|premature close/i.test(msg)
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Single shared decision for "should this failure engage the local gateway".
|
|
537
|
+
// 404 means the client asked for something that doesn't exist anywhere, and
|
|
538
|
+
// 429 means upstream is throttling us — hammering the local agent then would
|
|
539
|
+
// only hide the signal, so both bypass fallback.
|
|
540
|
+
export function shouldFallbackToLocal(statusCode) {
|
|
541
|
+
return statusCode >= 400 && statusCode !== 404 && statusCode !== 429;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Short-lived negative cache for PERMANENT failures (quota, unknown model).
|
|
545
|
+
// Without it, every request for a dead model replays: cloud attempt → doomed
|
|
546
|
+
// retry sleep → local agent connect → failure (~30s+). With it, repeats fail
|
|
547
|
+
// instantly with the exact same classified error until the TTL lapses.
|
|
548
|
+
export function createPermanentFailureCache(ttlMs = 60_000) {
|
|
549
|
+
const _cache = new Map(); // modelId -> { status, type, code, message, expiresAt }
|
|
550
|
+
return {
|
|
551
|
+
mark(modelId, classification) {
|
|
552
|
+
if (!classification.permanent) return;
|
|
553
|
+
_cache.set(modelId, {
|
|
554
|
+
status: classification.status,
|
|
555
|
+
type: classification.type,
|
|
556
|
+
code: classification.code,
|
|
557
|
+
message: classification.message,
|
|
558
|
+
expiresAt: Date.now() + ttlMs,
|
|
559
|
+
});
|
|
560
|
+
},
|
|
561
|
+
// Returns the cached classification while fresh, else clears the entry.
|
|
562
|
+
get(modelId) {
|
|
563
|
+
const hit = _cache.get(modelId);
|
|
564
|
+
if (!hit) return null;
|
|
565
|
+
if (Date.now() > hit.expiresAt) { _cache.delete(modelId); return null; }
|
|
566
|
+
return hit;
|
|
567
|
+
},
|
|
568
|
+
clear() { _cache.clear(); },
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ============================================================================
|
|
573
|
+
// HTTP response helpers
|
|
574
|
+
// ============================================================================
|
|
575
|
+
|
|
576
|
+
export function sendJSON(res, data, status = 200) {
|
|
577
|
+
const body = JSON.stringify(data);
|
|
578
|
+
res.writeHead(status, {
|
|
579
|
+
"Content-Type": "application/json",
|
|
580
|
+
"Content-Length": Buffer.byteLength(body),
|
|
581
|
+
});
|
|
582
|
+
res.end(body);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// OpenAI shape: { error: { message, type, code } }
|
|
586
|
+
export function sendErrorOpenAI(res, message, type = "api_error", status = 500, code = null) {
|
|
587
|
+
sendJSON(res, { error: { message, type, code } }, status);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Anthropic shape: { type: "error", error: { type, message, code } }
|
|
591
|
+
export function sendErrorAnthropic(res, message, type = "api_error", status = 500, code = null) {
|
|
592
|
+
sendJSON(res, { type: "error", error: { type, message, ...(code ? { code } : {}) } }, status);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Send a classification produced by classifyUpstreamError/classifyLocalAgentError
|
|
596
|
+
export function sendClassifiedErrorOpenAI(res, cls) {
|
|
597
|
+
sendJSON(res, { error: { message: cls.message, type: cls.type, code: cls.code ?? null } }, cls.status);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function sendClassifiedErrorAnthropic(res, cls) {
|
|
601
|
+
sendJSON(res, { type: "error", error: { type: cls.type, message: cls.message, code: cls.code ?? undefined } }, cls.status);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function isAuthorized(req, proxyKey) {
|
|
605
|
+
if (!proxyKey) return true;
|
|
606
|
+
const header = req.headers["authorization"] || req.headers["x-api-key"] || "";
|
|
607
|
+
const key = header.startsWith("Bearer ") ? header.slice(7) : header;
|
|
608
|
+
return key === proxyKey;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function validateChatPayload(body, maxMessages = Infinity) {
|
|
612
|
+
const MAX_MESSAGES = (maxMessages && Number.isFinite(maxMessages)) ? maxMessages : Infinity;
|
|
613
|
+
const MAX_MESSAGE_TEXT_BYTES = 256 * 1024;
|
|
614
|
+
const MAX_TOTAL_MESSAGE_TEXT_BYTES = 1024 * 1024;
|
|
615
|
+
const MAX_TOOLS = 64;
|
|
616
|
+
const MAX_TOOL_BYTES = 128 * 1024;
|
|
617
|
+
const MAX_TOTAL_TOOL_BYTES = 512 * 1024;
|
|
618
|
+
|
|
619
|
+
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
|
620
|
+
return { message: "messages must be a non-empty array", statusCode: 400 };
|
|
621
|
+
}
|
|
622
|
+
if (body.messages.length > MAX_MESSAGES) {
|
|
623
|
+
return { message: `messages must contain at most ${MAX_MESSAGES} entries`, statusCode: 413 };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let totalMessageBytes = 0;
|
|
627
|
+
for (const message of body.messages) {
|
|
628
|
+
const content = message?.content;
|
|
629
|
+
const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
|
|
630
|
+
const bytes = Buffer.byteLength(text);
|
|
631
|
+
if (bytes > MAX_MESSAGE_TEXT_BYTES) {
|
|
632
|
+
return { message: "an individual message is too large", statusCode: 413 };
|
|
633
|
+
}
|
|
634
|
+
totalMessageBytes += bytes;
|
|
635
|
+
if (totalMessageBytes > MAX_TOTAL_MESSAGE_TEXT_BYTES) {
|
|
636
|
+
return { message: "combined message content is too large", statusCode: 413 };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (body.tools !== undefined && !Array.isArray(body.tools)) {
|
|
641
|
+
return { message: "tools must be an array", statusCode: 400 };
|
|
642
|
+
}
|
|
643
|
+
if (body.tools?.length > MAX_TOOLS) {
|
|
644
|
+
return { message: `tools must contain at most ${MAX_TOOLS} entries`, statusCode: 413 };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let totalToolBytes = 0;
|
|
648
|
+
for (const tool of body.tools || []) {
|
|
649
|
+
const bytes = Buffer.byteLength(JSON.stringify(tool));
|
|
650
|
+
if (bytes > MAX_TOOL_BYTES) {
|
|
651
|
+
return { message: "an individual tool definition is too large", statusCode: 413 };
|
|
652
|
+
}
|
|
653
|
+
totalToolBytes += bytes;
|
|
654
|
+
if (totalToolBytes > MAX_TOTAL_TOOL_BYTES) {
|
|
655
|
+
return { message: "combined tool definitions are too large", statusCode: 413 };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export function generateId() {
|
|
663
|
+
return crypto.randomBytes(12).toString("hex");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function readBody(req, maxBodyBytes) {
|
|
667
|
+
return new Promise((resolve, reject) => {
|
|
668
|
+
const ct = req.headers["content-type"] || "";
|
|
669
|
+
if (!ct.toLowerCase().includes("application/json")) {
|
|
670
|
+
return reject(Object.assign(new Error("Content-Type must be application/json"), { statusCode: 415 }));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
let totalBytes = 0;
|
|
674
|
+
let limitHit = false;
|
|
675
|
+
const chunks = [];
|
|
676
|
+
req.on("data", (c) => {
|
|
677
|
+
totalBytes += c.length;
|
|
678
|
+
if (totalBytes > maxBodyBytes) {
|
|
679
|
+
if (!limitHit) {
|
|
680
|
+
limitHit = true;
|
|
681
|
+
reject(Object.assign(new Error("Request body too large"), { statusCode: 413 }));
|
|
682
|
+
}
|
|
683
|
+
// Keep draining (chunks are discarded) so the 413 response can still
|
|
684
|
+
// be delivered on this connection... unless the client is flooding far
|
|
685
|
+
// past the cap (4×), in which case cut the socket — nobody legitimate
|
|
686
|
+
// sends 200MB to a 50MB-capped local proxy, and draining forever just
|
|
687
|
+
// hands them a free upload channel.
|
|
688
|
+
if (totalBytes > maxBodyBytes * 4) {
|
|
689
|
+
try { req.destroy(); } catch (_) {}
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
chunks.push(c);
|
|
694
|
+
});
|
|
695
|
+
req.on("end", () => {
|
|
696
|
+
if (limitHit) return;
|
|
697
|
+
try {
|
|
698
|
+
let raw = Buffer.concat(chunks).toString("utf8");
|
|
699
|
+
// Strip UTF-8 BOM if present
|
|
700
|
+
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
701
|
+
resolve(JSON.parse(raw || "{}"));
|
|
702
|
+
} catch (e) {
|
|
703
|
+
reject(Object.assign(new Error(`Invalid JSON: ${e.message}`), { statusCode: 400 }));
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
req.on("error", reject);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Collect a full upstream response body (error inspection / passthrough)
|
|
711
|
+
export function collectResponse(res) {
|
|
712
|
+
return new Promise((resolve) => {
|
|
713
|
+
const chunks = [];
|
|
714
|
+
res.on("data", (c) => chunks.push(c));
|
|
715
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
716
|
+
res.on("error", () => resolve(""));
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// R1: never let an upstream rejection pass without its body on record —
|
|
721
|
+
// quota walls hide behind bare status codes. One compact line,
|
|
722
|
+
// whitespace-collapsed, capped at 500 chars.
|
|
723
|
+
export function logUpstreamErrorBody(logger, status, bodyText) {
|
|
724
|
+
const text = typeof bodyText === "string" ? bodyText.replace(/\s+/g, " ").trim() : "";
|
|
725
|
+
if (!text) return;
|
|
726
|
+
logger.warn(`Upstream ${status} body: ${text.slice(0, 500)}`);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// SSE response headers — one frozen constant instead of four copies of the
|
|
730
|
+
// same literal across both entrypoints' streaming writeHead calls.
|
|
731
|
+
export const SSE_HEADERS = Object.freeze({
|
|
732
|
+
"Content-Type": "text/event-stream",
|
|
733
|
+
"Cache-Control": "no-cache",
|
|
734
|
+
"Connection": "keep-alive",
|
|
735
|
+
"X-Accel-Buffering": "no",
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// Model-field validation shared by both wire formats — the model drives
|
|
739
|
+
// everything downstream, so it is checked before any format conversion.
|
|
740
|
+
// Returns a sendable error descriptor or null.
|
|
741
|
+
export function validateModelField(body) {
|
|
742
|
+
if (!body.model || typeof body.model !== "string" || body.model.length > 256 || body.model.includes("..") || /[\r\n\0]/.test(body.model)) {
|
|
743
|
+
return { status: 400, message: "model must be a valid non-empty string (max 256 chars)", type: "invalid_request_error", code: "invalid_model" };
|
|
744
|
+
}
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Last-message preview for request logs: string content verbatim, anything
|
|
749
|
+
// else JSON-stringified.
|
|
750
|
+
export function lastMessagePreview(messages) {
|
|
751
|
+
const lastMsg = messages?.[messages.length - 1];
|
|
752
|
+
return typeof lastMsg?.content === "string" ? lastMsg.content : JSON.stringify(lastMsg?.content) ?? "";
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Cloud call with the one retry for the historically flaky 400 "invalid
|
|
756
|
+
// request" hiccup — but never for a model already confirmed permanently
|
|
757
|
+
// broken. Buffers and logs every >=400 body along the way (R1). Returns the
|
|
758
|
+
// terminal upstream response plus its buffered error body; success rendering
|
|
759
|
+
// stays at the call site so wire formats never leak in here.
|
|
760
|
+
export async function callUpstreamWithInvalidRequestRetry(callUpstream, modelId, permanentFailures, log) {
|
|
761
|
+
let res = await callUpstream();
|
|
762
|
+
let errBody = "";
|
|
763
|
+
if (res.statusCode === 400) {
|
|
764
|
+
errBody = await collectResponse(res);
|
|
765
|
+
logUpstreamErrorBody(log, res.statusCode, errBody);
|
|
766
|
+
if (errBody.includes('"invalid request"') && !permanentFailures.get(modelId)) {
|
|
767
|
+
log.info("Upstream 400 invalid request — retrying once");
|
|
768
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
769
|
+
res = await callUpstream();
|
|
770
|
+
if (res.statusCode < 400) return { res, errBody: "" };
|
|
771
|
+
errBody = await collectResponse(res);
|
|
772
|
+
logUpstreamErrorBody(log, res.statusCode, errBody);
|
|
773
|
+
}
|
|
774
|
+
} else if (res.statusCode >= 400) {
|
|
775
|
+
errBody = await collectResponse(res);
|
|
776
|
+
logUpstreamErrorBody(log, res.statusCode, errBody);
|
|
777
|
+
}
|
|
778
|
+
return { res, errBody };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ============================================================================
|
|
782
|
+
// Rate limiter — simple token bucket per client IP
|
|
783
|
+
// ============================================================================
|
|
784
|
+
|
|
785
|
+
export function createRateLimiter(rateLimit) {
|
|
786
|
+
const _buckets = new Map();
|
|
787
|
+
function limit(ip) {
|
|
788
|
+
const now = Date.now();
|
|
789
|
+
const b = _buckets.get(ip);
|
|
790
|
+
if (!b) { _buckets.set(ip, { tokens: Math.max(0, rateLimit - 1), last: now }); return true; }
|
|
791
|
+
const elapsed = (now - b.last) / 1000;
|
|
792
|
+
b.tokens = Math.min(rateLimit, b.tokens + elapsed * rateLimit);
|
|
793
|
+
b.last = now;
|
|
794
|
+
if (b.tokens < 1) return false;
|
|
795
|
+
b.tokens -= 1;
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
// Drop stale buckets so the map can't grow unbounded (unref'd — doesn't hold the process open)
|
|
799
|
+
function startBucketSweep() {
|
|
800
|
+
setInterval(() => {
|
|
801
|
+
const cutoff = Date.now() - 24 * 3600 * 1000;
|
|
802
|
+
for (const [ip, b] of _buckets) if (b.last < cutoff) _buckets.delete(ip);
|
|
803
|
+
}, 3600 * 1000).unref();
|
|
804
|
+
}
|
|
805
|
+
return { rateLimit: limit, startBucketSweep };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Resolve the client IP for rate limiting. X-Forwarded-For is trusted ONLY
|
|
809
|
+
// from peers listed in TRUSTED_PROXIES (comma-separated IPs) — trusting it
|
|
810
|
+
// from arbitrary non-loopback peers lets a remote client rotate fake IPs to
|
|
811
|
+
// dodge the limiter. Both entrypoints share this single implementation.
|
|
812
|
+
export function resolveClientIp(req) {
|
|
813
|
+
const TRUSTED_PROXIES = (process.env.TRUSTED_PROXIES || "").split(",").map(s => s.trim()).filter(Boolean);
|
|
814
|
+
const peer = (req.socket.remoteAddress || "unknown").replace(/^::ffff:/, "");
|
|
815
|
+
if (TRUSTED_PROXIES.includes(peer)) {
|
|
816
|
+
const xff = req.headers["x-forwarded-for"];
|
|
817
|
+
if (xff) return xff.split(",")[0].trim().replace(/^::ffff:/, "");
|
|
818
|
+
}
|
|
819
|
+
return peer;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ============================================================================
|
|
823
|
+
// Request loggers
|
|
824
|
+
// ============================================================================
|
|
825
|
+
|
|
826
|
+
// JSON ring logger — keeps the last N requests on disk.
|
|
827
|
+
// Concurrency-safe across processes via an exclusive lockfile: without it, two
|
|
828
|
+
// proxies doing read-modify-write silently eat each other's entries (observed:
|
|
829
|
+
// --test-models results vanishing while the main proxy served traffic).
|
|
830
|
+
export function createRequestLogger(filePath) {
|
|
831
|
+
const MAX_LOG_ENTRIES = 50;
|
|
832
|
+
const LOCK_PATH = `${filePath}.lock`;
|
|
833
|
+
|
|
834
|
+
function acquireLock(deadlineMs = 1500) {
|
|
835
|
+
const deadline = Date.now() + deadlineMs;
|
|
836
|
+
for (;;) {
|
|
837
|
+
try {
|
|
838
|
+
fs.writeFileSync(LOCK_PATH, String(process.pid), { flag: "wx" }); // exclusive create
|
|
839
|
+
return true;
|
|
840
|
+
} catch (_) {
|
|
841
|
+
// Steal a stale lock (>2s old) so a crashed writer can't wedge logging
|
|
842
|
+
try {
|
|
843
|
+
if (Date.now() - fs.statSync(LOCK_PATH).mtimeMs > 2000) { fs.unlinkSync(LOCK_PATH); continue; }
|
|
844
|
+
} catch (_) { /* lock vanished between stat and unlink — loop retries */ }
|
|
845
|
+
if (Date.now() > deadline) return false; // give up; write unlocked rather than lose the entry
|
|
846
|
+
// Synchronous sleep that doesn't starve the event loop
|
|
847
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); }
|
|
848
|
+
catch (_) { const end = Date.now() + 25; while (Date.now() < end) { /* spin */ } }
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function releaseLock() {
|
|
854
|
+
try { fs.unlinkSync(LOCK_PATH); } catch (_) {}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function logRequest(entry) {
|
|
858
|
+
let locked = false;
|
|
859
|
+
try {
|
|
860
|
+
locked = acquireLock();
|
|
861
|
+
let entries = [];
|
|
862
|
+
try { entries = JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch (_) {}
|
|
863
|
+
entries.push(entry);
|
|
864
|
+
if (entries.length > MAX_LOG_ENTRIES) entries = entries.slice(-MAX_LOG_ENTRIES);
|
|
865
|
+
fs.writeFileSync(filePath, JSON.stringify(entries, null, 2));
|
|
866
|
+
} catch (_) { /* never let logging break request handling */ }
|
|
867
|
+
finally { if (locked) releaseLock(); }
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { logRequest };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// JSONL structured log — one line per request, rotated past the cap so disk
|
|
874
|
+
// can't fill. This append-only stream is the reliable source of truth; treat
|
|
875
|
+
// the pretty ring file above as best-effort.
|
|
876
|
+
export function createJsonlLogger({ enabled, sync = false, file, maxBytes }) {
|
|
877
|
+
function logJsonl(entry) {
|
|
878
|
+
if (!enabled) return;
|
|
879
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
|
|
880
|
+
try {
|
|
881
|
+
if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
|
|
882
|
+
} catch (_) {}
|
|
883
|
+
try {
|
|
884
|
+
if (sync) fs.appendFileSync(file, line);
|
|
885
|
+
else fs.appendFile(file, line, () => {});
|
|
886
|
+
} catch (_) {}
|
|
887
|
+
}
|
|
888
|
+
return { logJsonl };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ============================================================================
|
|
892
|
+
// Local WebSocket bridge (L-route) — drives AutoClaw's own gateway on
|
|
893
|
+
// 127.0.0.1:18789 as a fallback when the cloud upstream fails.
|
|
894
|
+
// ============================================================================
|
|
895
|
+
|
|
896
|
+
export function encodeWsFrame(text) {
|
|
897
|
+
const payload = Buffer.from(text, 'utf-8');
|
|
898
|
+
const length = payload.length;
|
|
899
|
+
let header;
|
|
900
|
+
const mask = crypto.randomBytes(4);
|
|
901
|
+
if (length <= 125) {
|
|
902
|
+
header = Buffer.alloc(2 + 4);
|
|
903
|
+
header[0] = 0x81; header[1] = 0x80 | length; mask.copy(header, 2);
|
|
904
|
+
} else if (length <= 65535) {
|
|
905
|
+
header = Buffer.alloc(4 + 4);
|
|
906
|
+
header[0] = 0x81; header[1] = 0x80 | 126; header.writeUInt16BE(length, 2); mask.copy(header, 4);
|
|
907
|
+
} else {
|
|
908
|
+
header = Buffer.alloc(10 + 4);
|
|
909
|
+
header[0] = 0x81; header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(length), 2); mask.copy(header, 10);
|
|
910
|
+
}
|
|
911
|
+
const maskedPayload = Buffer.alloc(length);
|
|
912
|
+
for (let i = 0; i < length; i++) maskedPayload[i] = payload[i] ^ mask[i % 4];
|
|
913
|
+
return Buffer.concat([header, maskedPayload]);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export function decodeWsFrames(buffer, onMessage) {
|
|
917
|
+
let offset = 0;
|
|
918
|
+
while (offset < buffer.length) {
|
|
919
|
+
if (buffer.length - offset < 2) break;
|
|
920
|
+
const firstByte = buffer[offset];
|
|
921
|
+
const secondByte = buffer[offset + 1];
|
|
922
|
+
const opcode = firstByte & 0x0f;
|
|
923
|
+
const isMasked = (secondByte & 0x80) !== 0;
|
|
924
|
+
let payloadLen = secondByte & 0x7f;
|
|
925
|
+
let headerLen = 2;
|
|
926
|
+
if (payloadLen === 126) {
|
|
927
|
+
if (buffer.length - offset < 4) break;
|
|
928
|
+
payloadLen = buffer.readUInt16BE(offset + 2);
|
|
929
|
+
headerLen = 4;
|
|
930
|
+
} else if (payloadLen === 127) {
|
|
931
|
+
if (buffer.length - offset < 10) break;
|
|
932
|
+
payloadLen = Number(buffer.readBigUInt64BE(offset + 2));
|
|
933
|
+
headerLen = 10;
|
|
934
|
+
}
|
|
935
|
+
if (isMasked) headerLen += 4;
|
|
936
|
+
if (buffer.length - offset < headerLen + payloadLen) break;
|
|
937
|
+
const payload = buffer.slice(offset + headerLen, offset + headerLen + payloadLen);
|
|
938
|
+
offset += headerLen + payloadLen;
|
|
939
|
+
if (opcode === 1) onMessage(payload.toString('utf-8'));
|
|
940
|
+
else if (opcode === 8) break;
|
|
941
|
+
}
|
|
942
|
+
return buffer.slice(offset);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function getLocalGatewayToken() {
|
|
946
|
+
try {
|
|
947
|
+
const tokenFile = path.join(os.homedir(), '.openclaw-autoclaw', '.gateway-token');
|
|
948
|
+
if (fs.existsSync(tokenFile)) {
|
|
949
|
+
return fs.readFileSync(tokenFile, 'utf-8').trim();
|
|
950
|
+
}
|
|
951
|
+
} catch (_) {}
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Run a prompt through AutoClaw's local `agent` RPC and stream assistant
|
|
956
|
+
// deltas back through callbacks. NOTE: this executes a full agentic run in
|
|
957
|
+
// the desktop app (tools included), not a chat completion — expect seconds to
|
|
958
|
+
// minutes, and fresh sessionKey per request keeps runs isolated.
|
|
959
|
+
//
|
|
960
|
+
// Protocol quirk: the RPC answers TWICE — first `res ok:true` (accepted),
|
|
961
|
+
// later possibly another `res` frame with the same id and `ok:false` carrying
|
|
962
|
+
// the failure. Handle both, or accepted-but-failed runs hang until timeout.
|
|
963
|
+
export function streamLocalGatewayAgent({ config, modelId, messages, onChunk, onEnd, onError, timeoutMs = 120000 }) {
|
|
964
|
+
const token = getLocalGatewayToken();
|
|
965
|
+
if (!token) {
|
|
966
|
+
return onError(new Error("Local AutoClaw gateway token not found. Is AutoClaw running?"));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Format conversation messages preserving roles
|
|
970
|
+
const prompt = (messages || []).map((m) => {
|
|
971
|
+
const role = (m.role || "user").toUpperCase();
|
|
972
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
|
|
973
|
+
return `${role}: ${content}`;
|
|
974
|
+
}).join("\n\n");
|
|
975
|
+
|
|
976
|
+
const normalizedModel = modelId.startsWith("zai/") ? modelId : `zai/${modelId}`;
|
|
977
|
+
const sessionKey = 'agent:main:' + crypto.randomBytes(4).toString('hex');
|
|
978
|
+
const runId = 'key-' + Date.now() + '-' + crypto.randomBytes(3).toString('hex');
|
|
979
|
+
|
|
980
|
+
let finished = false;
|
|
981
|
+
let activeReq = null; // live upgrade request of the current attempt
|
|
982
|
+
let upgradedSocket = null; // after the upgrade the socket detaches from `req` —
|
|
983
|
+
// destroying req alone LEAKS the live WS connection
|
|
984
|
+
let protocolRetried = false; // one reconnect allowed on PROTOCOL_MISMATCH
|
|
985
|
+
const finish = (fn) => {
|
|
986
|
+
if (finished) return;
|
|
987
|
+
finished = true;
|
|
988
|
+
clearTimeout(timer);
|
|
989
|
+
try { (upgradedSocket || activeReq)?.destroy?.(); } catch (_) {}
|
|
990
|
+
fn();
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const timer = setTimeout(() => {
|
|
994
|
+
finish(() => onError(new Error(`Local gateway execution timeout (${timeoutMs / 1000}s)`)));
|
|
995
|
+
}, timeoutMs);
|
|
996
|
+
|
|
997
|
+
// One connect attempt: upgrade + challenge + connect with the given protocol
|
|
998
|
+
// range. The gateway rejects out-of-range offers with a structured
|
|
999
|
+
// PROTOCOL_MISMATCH detail naming its expectedProtocol — on that exact error
|
|
1000
|
+
// we reconnect once with the server's own range (self-heals across app
|
|
1001
|
+
// updates); any other failure ends the run.
|
|
1002
|
+
const attemptConnect = (minProtocol, maxProtocol) => {
|
|
1003
|
+
const secKey = crypto.randomBytes(16).toString('base64');
|
|
1004
|
+
const req = http.request({
|
|
1005
|
+
hostname: config.LOCAL_GATEWAY_HOST,
|
|
1006
|
+
port: config.LOCAL_GATEWAY_PORT,
|
|
1007
|
+
path: '/',
|
|
1008
|
+
headers: {
|
|
1009
|
+
'Connection': 'Upgrade',
|
|
1010
|
+
'Upgrade': 'websocket',
|
|
1011
|
+
'Sec-WebSocket-Version': '13',
|
|
1012
|
+
'Sec-WebSocket-Key': secKey,
|
|
1013
|
+
'Authorization': 'Bearer ' + token
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
activeReq = req;
|
|
1017
|
+
req.on('error', (err) => finish(() => onError(err)));
|
|
1018
|
+
req.on('upgrade', (res, socket) => {
|
|
1019
|
+
upgradedSocket = socket;
|
|
1020
|
+
socket.on('error', (err) => finish(() => onError(err)));
|
|
1021
|
+
|
|
1022
|
+
let buf = Buffer.alloc(0);
|
|
1023
|
+
let connected = false;
|
|
1024
|
+
socket.on('data', chunk => {
|
|
1025
|
+
buf = decodeWsFrames(Buffer.concat([buf, chunk]), rawMsg => {
|
|
1026
|
+
try {
|
|
1027
|
+
const msg = JSON.parse(rawMsg);
|
|
1028
|
+
if (!connected) {
|
|
1029
|
+
if (msg.event === 'connect.challenge') {
|
|
1030
|
+
socket.write(encodeWsFrame(JSON.stringify({
|
|
1031
|
+
type: 'req', id: 'conn-1', method: 'connect',
|
|
1032
|
+
params: {
|
|
1033
|
+
minProtocol, maxProtocol,
|
|
1034
|
+
// client.id is allowlisted by the gateway — arbitrary
|
|
1035
|
+
// values get INVALID_REQUEST before any agent can run
|
|
1036
|
+
client: { id: 'gateway-client', version: getClientHeaders(config)['X-Version'] || '1.17.5', platform: 'win', mode: 'backend' },
|
|
1037
|
+
role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'],
|
|
1038
|
+
caps: ['tool_events'], commands: [], permissions: {}, auth: { token }, locale: 'en', userAgent: `glmproxy/${VERSION}`
|
|
1039
|
+
}
|
|
1040
|
+
})));
|
|
1041
|
+
} else if (msg.id === 'conn-1') {
|
|
1042
|
+
if (!msg.ok) {
|
|
1043
|
+
const details = msg.error?.details;
|
|
1044
|
+
if (details?.code === 'PROTOCOL_MISMATCH' && typeof details.expectedProtocol === 'number' && !protocolRetried) {
|
|
1045
|
+
// the gateway told us its protocol — reconnect with it
|
|
1046
|
+
protocolRetried = true;
|
|
1047
|
+
console.warn(`[gateway] protocol mismatch — reconnecting with protocol v${details.expectedProtocol}`);
|
|
1048
|
+
try { socket.destroy(); } catch (_) {}
|
|
1049
|
+
return attemptConnect(details.expectedProtocol, details.expectedProtocol);
|
|
1050
|
+
}
|
|
1051
|
+
return finish(() => onError(new Error('Gateway connect failed: ' + JSON.stringify(msg.error))));
|
|
1052
|
+
}
|
|
1053
|
+
connected = true;
|
|
1054
|
+
// Send agent prompt
|
|
1055
|
+
socket.write(encodeWsFrame(JSON.stringify({
|
|
1056
|
+
type: 'req', id: 'agent-1', method: 'agent',
|
|
1057
|
+
params: {
|
|
1058
|
+
sessionKey,
|
|
1059
|
+
message: prompt,
|
|
1060
|
+
model: normalizedModel,
|
|
1061
|
+
idempotencyKey: runId
|
|
1062
|
+
}
|
|
1063
|
+
})));
|
|
1064
|
+
}
|
|
1065
|
+
} else if (msg.id === 'agent-1') {
|
|
1066
|
+
if (!msg.ok) {
|
|
1067
|
+
// Late ok:false after the earlier ok:true — the run was accepted
|
|
1068
|
+
// then failed upstream (e.g. FailoverError 402/403)
|
|
1069
|
+
return finish(() => onError(new Error('Gateway agent start failed: ' + JSON.stringify(msg.error))));
|
|
1070
|
+
}
|
|
1071
|
+
} else if (msg.type === 'event') {
|
|
1072
|
+
if (msg.event === 'agent' && msg.payload?.stream === 'assistant') {
|
|
1073
|
+
const delta = msg.payload?.data?.delta;
|
|
1074
|
+
if (typeof delta === 'string' && delta.length > 0) {
|
|
1075
|
+
onChunk({ delta, reasoning: "" });
|
|
1076
|
+
}
|
|
1077
|
+
} else if (msg.event === 'chat' && msg.payload?.state === 'final') {
|
|
1078
|
+
finish(() => onEnd({ finishReason: msg.payload.stopReason || 'stop' }));
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
finish(() => onError(err));
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
req.end();
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
attemptConnect(config.GATEWAY_MIN_PROTOCOL, config.GATEWAY_MAX_PROTOCOL);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ============================================================================
|
|
1094
|
+
// Upstream caller (cloud)
|
|
1095
|
+
// ============================================================================
|
|
1096
|
+
|
|
1097
|
+
// Keep-alive agent: reuses TCP+TLS connections instead of paying a fresh
|
|
1098
|
+
// handshake on every request (measured latency tax under burst load).
|
|
1099
|
+
const UPSTREAM_AGENT = new https.Agent({
|
|
1100
|
+
keepAlive: true,
|
|
1101
|
+
maxSockets: 32,
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// POST JSON upstream with exactly one transparent retry on transient network
|
|
1105
|
+
// errors (reset pipes, hung-up sockets). Timeouts are NOT retried — they
|
|
1106
|
+
// already consumed their full budget.
|
|
1107
|
+
async function postUpstreamWithRetry(options, payload, log) {
|
|
1108
|
+
const attemptOnce = () => new Promise((resolve, reject) => {
|
|
1109
|
+
const req = https.request({ ...options, agent: UPSTREAM_AGENT }, resolve);
|
|
1110
|
+
req.on("timeout", () => {
|
|
1111
|
+
req.destroy();
|
|
1112
|
+
reject(Object.assign(
|
|
1113
|
+
new Error("Upstream timeout — AutoClaw backend did not respond within 2 minutes"),
|
|
1114
|
+
{ code: "UPSTREAM_TIMEOUT" }
|
|
1115
|
+
));
|
|
1116
|
+
});
|
|
1117
|
+
req.on("error", reject);
|
|
1118
|
+
req.write(payload);
|
|
1119
|
+
req.end();
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
return await attemptOnce();
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
if (isTransientNetworkError(err)) {
|
|
1126
|
+
log?.warn(`Transient upstream network error (${err.code || err.message}) — retrying once`);
|
|
1127
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1128
|
+
return attemptOnce();
|
|
1129
|
+
}
|
|
1130
|
+
throw err;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Keep the 'zai_' prefix mapping while preserving IDs from the current catalog.
|
|
1135
|
+
export function resolveUpstreamModelId(knownIds, modelId) {
|
|
1136
|
+
return knownIds.has(modelId) ? modelId
|
|
1137
|
+
: modelId === "auto" ? "zai_auto"
|
|
1138
|
+
: `zai_${modelId}`;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// upstream gates cloud requests on this exact banner inside the system prompt —
|
|
1142
|
+
// without it every call gets 400 "invalid request" and we fall into the ws
|
|
1143
|
+
// agent. injected on every call below. if the app ever rewords its prompt this
|
|
1144
|
+
// breaks again and we re-bisect. full story in ROOT-CAUSE-AND-STUDY.md
|
|
1145
|
+
// AUTOCLAW_SYSTEM_BANNER env patches a reword without a release — keep the
|
|
1146
|
+
// "## Tooling" line intact or cloud routing silently degrades into the ws agent.
|
|
1147
|
+
export const AUTOCLAW_SYSTEM_BANNER =
|
|
1148
|
+
process.env.AUTOCLAW_SYSTEM_BANNER ||
|
|
1149
|
+
"You are a personal assistant running inside OpenClaw.\n## Tooling";
|
|
1150
|
+
|
|
1151
|
+
// prepends the banner (or a system msg if the client sent none), never duplicates
|
|
1152
|
+
function injectSystemBanner(messages) {
|
|
1153
|
+
const list = Array.isArray(messages) ? [...messages] : [];
|
|
1154
|
+
const idx = list.findIndex((m) => m && m.role === "system");
|
|
1155
|
+
if (idx === -1) {
|
|
1156
|
+
list.unshift({ role: "system", content: AUTOCLAW_SYSTEM_BANNER });
|
|
1157
|
+
return list;
|
|
1158
|
+
}
|
|
1159
|
+
const sys = list[idx];
|
|
1160
|
+
const text = typeof sys.content === "string"
|
|
1161
|
+
? sys.content
|
|
1162
|
+
: Array.isArray(sys.content)
|
|
1163
|
+
? sys.content.map((p) => (typeof p === "string" ? p : p?.text || "")).join("\n")
|
|
1164
|
+
: String(sys.content ?? "");
|
|
1165
|
+
if (!text.includes(AUTOCLAW_SYSTEM_BANNER)) {
|
|
1166
|
+
list[idx] = { ...sys, content: AUTOCLAW_SYSTEM_BANNER + "\n\n" + text };
|
|
1167
|
+
}
|
|
1168
|
+
return list;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Only forward fields the upstream accepts; everything else is stripped.
|
|
1172
|
+
function buildSanitizedBody(openAIBody, upstreamModelId) {
|
|
1173
|
+
const sanitized = {
|
|
1174
|
+
model: upstreamModelId,
|
|
1175
|
+
messages: injectSystemBanner(openAIBody.messages || []),
|
|
1176
|
+
stream: true,
|
|
1177
|
+
};
|
|
1178
|
+
if (typeof openAIBody.temperature === "number") sanitized.temperature = openAIBody.temperature;
|
|
1179
|
+
if (typeof openAIBody.top_p === "number") sanitized.top_p = openAIBody.top_p;
|
|
1180
|
+
if (typeof openAIBody.max_tokens === "number") sanitized.max_tokens = openAIBody.max_tokens;
|
|
1181
|
+
if (typeof openAIBody.max_completion_tokens === "number") sanitized.max_tokens = openAIBody.max_completion_tokens;
|
|
1182
|
+
if (openAIBody.stop !== undefined) sanitized.stop = openAIBody.stop;
|
|
1183
|
+
if (Array.isArray(openAIBody.tools) && openAIBody.tools.length > 0) sanitized.tools = openAIBody.tools;
|
|
1184
|
+
if (openAIBody.tool_choice !== undefined) sanitized.tool_choice = openAIBody.tool_choice;
|
|
1185
|
+
return sanitized;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// upstream wants bare ids (glm-4.7), clients send catalog ids (zai_glm-4.7)
|
|
1189
|
+
export function stripProviderPrefix(modelId) { return String(modelId || "").replace(/^[a-z]+_/, ""); }
|
|
1190
|
+
|
|
1191
|
+
// Trae and other clients send content as text-object arrays that Zhipu rejects
|
|
1192
|
+
// (400/500) — flatten and normalize them before forwarding.
|
|
1193
|
+
function normalizeClientMessages(body) {
|
|
1194
|
+
return (body.messages || []).map(msg => {
|
|
1195
|
+
const newMsg = { ...msg };
|
|
1196
|
+
|
|
1197
|
+
// Normalize role: developer -> system
|
|
1198
|
+
if (newMsg.role === "developer") {
|
|
1199
|
+
newMsg.role = "system";
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Flatten content array if it's all text blocks
|
|
1203
|
+
if (Array.isArray(newMsg.content)) {
|
|
1204
|
+
const textParts = [];
|
|
1205
|
+
for (const c of newMsg.content) {
|
|
1206
|
+
if (typeof c === "string") textParts.push(c);
|
|
1207
|
+
else if (c?.type === "text" && typeof c.text === "string") textParts.push(c.text);
|
|
1208
|
+
else if (c?.text) textParts.push(String(c.text));
|
|
1209
|
+
}
|
|
1210
|
+
newMsg.content = textParts.join("\n");
|
|
1211
|
+
} else if (newMsg.content === null || newMsg.content === undefined) {
|
|
1212
|
+
newMsg.content = "";
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
return newMsg;
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function callUpstream(config, clientHeaders, getToken, sanitizedBody, log) {
|
|
1220
|
+
// header keeps the full catalog id; body model goes upstream bare
|
|
1221
|
+
const payload = JSON.stringify({ ...sanitizedBody, model: stripProviderPrefix(sanitizedBody.model) });
|
|
1222
|
+
return postUpstreamWithRetry({
|
|
1223
|
+
hostname: "autoglm-api.autoglm.ai",
|
|
1224
|
+
path: "/autoclaw-proxy/proxy/autoclaw/chat/completions",
|
|
1225
|
+
method: "POST",
|
|
1226
|
+
headers: {
|
|
1227
|
+
"Content-Type": "application/json",
|
|
1228
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
1229
|
+
"X-Authorization": getToken(),
|
|
1230
|
+
"X-Request-Model": sanitizedBody.model,
|
|
1231
|
+
"X-Request-Id": crypto.randomUUID(),
|
|
1232
|
+
"X-Agent-Id": "main",
|
|
1233
|
+
...clientHeaders,
|
|
1234
|
+
},
|
|
1235
|
+
timeout: config.UPSTREAM_TIMEOUT_MS, // per-attempt budget (idle-based; env-tunable)
|
|
1236
|
+
}, payload, log);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// OpenAI-format entrypoint: resolves aliases/prefix mapping, normalizes
|
|
1240
|
+
// client-shaped messages, forwards.
|
|
1241
|
+
export function callUpstreamOpenAI(config, knownIds, clientHeaders, getToken, body, modelId, log) {
|
|
1242
|
+
const upstreamModelId = resolveUpstreamModelId(knownIds, modelId);
|
|
1243
|
+
const normalized = { ...body, messages: normalizeClientMessages(body) };
|
|
1244
|
+
log?.debug(`→ upstream model=${modelId}`);
|
|
1245
|
+
return callUpstream(config, clientHeaders, getToken, buildSanitizedBody(normalized, upstreamModelId), log);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Anthropic-format entrypoint: model already resolved, body already converted
|
|
1249
|
+
// to OpenAI shape by the entrypoint's converter — forward as-is.
|
|
1250
|
+
export function callUpstreamAnthropic(config, clientHeaders, getToken, openAIBody, modelId) {
|
|
1251
|
+
return callUpstream(config, clientHeaders, getToken, buildSanitizedBody(openAIBody, modelId), null);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// ============================================================================
|
|
1255
|
+
// Credit-tier model routing
|
|
1256
|
+
// ============================================================================
|
|
1257
|
+
|
|
1258
|
+
// Fetch AutoClaw's remote model-config (the same data its UI ranks models
|
|
1259
|
+
// with). The JWT goes in the `authorization` header (it already includes the
|
|
1260
|
+
// "Bearer " prefix — sending it as X-Authorization returns 401). Never throws:
|
|
1261
|
+
// returns the top-level `models` array or null so callers can degrade to
|
|
1262
|
+
// heuristics without startup risk.
|
|
1263
|
+
export function fetchRemoteModelConfig(config, jwt, { timeoutMs = 5000 } = {}) {
|
|
1264
|
+
if (!jwt) return Promise.resolve(null);
|
|
1265
|
+
return new Promise((resolve) => {
|
|
1266
|
+
try {
|
|
1267
|
+
const req = https.request({
|
|
1268
|
+
hostname: "autoglm-api.autoglm.ai",
|
|
1269
|
+
path: config.MODEL_CONFIG_PATH,
|
|
1270
|
+
method: "GET",
|
|
1271
|
+
headers: { authorization: jwt, ...getClientHeaders(config) },
|
|
1272
|
+
timeout: timeoutMs,
|
|
1273
|
+
}, async (res) => {
|
|
1274
|
+
if (res.statusCode !== 200) { res.resume(); return resolve(null); }
|
|
1275
|
+
try {
|
|
1276
|
+
const data = JSON.parse(await collectResponse(res));
|
|
1277
|
+
const models = data?.models;
|
|
1278
|
+
resolve(Array.isArray(models) && models.length > 0 ? models.filter((m) => m?.id) : null);
|
|
1279
|
+
} catch { resolve(null); }
|
|
1280
|
+
});
|
|
1281
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
1282
|
+
req.on("error", () => resolve(null));
|
|
1283
|
+
req.end();
|
|
1284
|
+
} catch { resolve(null); }
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Attach a creditConsumptionLevel to every catalog model. Remote tiers win;
|
|
1289
|
+
// otherwise fall back to heuristics mirroring the desktop app (auto → Low,
|
|
1290
|
+
// compact glm52 identity → High), extended with glm53/turbo rules so today's
|
|
1291
|
+
// API ids still get sane tiers when the remote config is unreachable.
|
|
1292
|
+
export function annotateCreditTiers(models, remoteModels) {
|
|
1293
|
+
const remoteById = new Map((Array.isArray(remoteModels) ? remoteModels : []).map((m) => [m.id, m]));
|
|
1294
|
+
return models.map((m) => {
|
|
1295
|
+
let level = remoteById.get(m.id)?.creditConsumptionLevel || null;
|
|
1296
|
+
if (!level) {
|
|
1297
|
+
const compact = `${m.id} ${m.name}`.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1298
|
+
if (compact.includes("auto")) level = "Low";
|
|
1299
|
+
else if (compact.includes("glm52") || compact.includes("glm53")) level = "High";
|
|
1300
|
+
else if (compact.includes("turbo")) level = "Medium";
|
|
1301
|
+
}
|
|
1302
|
+
return { ...m, creditLevel: level };
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Single routing authority for Claude aliases. Degradation rules when a tier
|
|
1307
|
+
// has no candidates: opus High→Medium→Low→default; sonnet Medium→High→default;
|
|
1308
|
+
// haiku Low(prefers non-auto)→Medium→default; default = sonnet target.
|
|
1309
|
+
export function resolveTierTargets(models) {
|
|
1310
|
+
const at = (level) => models.filter((m) => m.creditLevel === level);
|
|
1311
|
+
const pick = (list) => list.find((m) => !m.id.toLowerCase().includes("auto")) || list[0] || null;
|
|
1312
|
+
|
|
1313
|
+
const sonnet = pick(at("Medium")) || pick(at("High")) || models[0] || null;
|
|
1314
|
+
const haiku = pick(at("Low")) || pick(at("Medium")) || sonnet;
|
|
1315
|
+
const opus = pick(at("High")) || pick(at("Medium")) || pick(at("Low")) || sonnet;
|
|
1316
|
+
|
|
1317
|
+
const id = (m) => (m ? m.id : null);
|
|
1318
|
+
return { opus: id(opus), sonnet: id(sonnet), haiku: id(haiku), default: id(sonnet) };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// ============================================================================
|
|
1322
|
+
// Bootstrap helpers shared by both entrypoints
|
|
1323
|
+
// ============================================================================
|
|
1324
|
+
|
|
1325
|
+
export function makeHealthHandler(config, getToken) {
|
|
1326
|
+
return function handleHealth(req, res) {
|
|
1327
|
+
let tokenOk = true, tokenError = null;
|
|
1328
|
+
try { getToken(); }
|
|
1329
|
+
catch (e) { tokenOk = false; tokenError = e.message; }
|
|
1330
|
+
|
|
1331
|
+
sendJSON(res, {
|
|
1332
|
+
ok: tokenOk,
|
|
1333
|
+
status: tokenOk ? "live" : "no_token",
|
|
1334
|
+
upstream: config.UPSTREAM_BASE,
|
|
1335
|
+
port: config.PORT,
|
|
1336
|
+
...(tokenError ? { error: tokenError } : {}),
|
|
1337
|
+
});
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Shared HTTP server: CORS, auth, rate limiting, route dispatch. Routes are
|
|
1342
|
+
// [{ method, path, handler }] — method omitted matches any method. sendError
|
|
1343
|
+
// carries the entrypoint's format-specific envelope.
|
|
1344
|
+
export function createGatewayServer({ config, log, rateLimit, sendError, routes }) {
|
|
1345
|
+
return http.createServer(async (req, res) => {
|
|
1346
|
+
// CORS — allow all origins so any local tool can talk to this proxy
|
|
1347
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1348
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1349
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1350
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Api-Key, Anthropic-Version, Anthropic-Beta");
|
|
1351
|
+
|
|
1352
|
+
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
1353
|
+
|
|
1354
|
+
const clientIp = resolveClientIp(req);
|
|
1355
|
+
if (!rateLimit(clientIp)) {
|
|
1356
|
+
res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "1" });
|
|
1357
|
+
res.end(JSON.stringify({ error: { message: "Rate limit exceeded", type: "rate_limit_error" } }));
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (!isAuthorized(req, config.PROXY_KEY)) {
|
|
1362
|
+
return sendError(res, "Invalid or missing API key", "authentication_error", 401, "invalid_api_key");
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const { pathname } = new URL(req.url, "http://localhost");
|
|
1366
|
+
|
|
1367
|
+
for (const route of routes) {
|
|
1368
|
+
if (route.method && route.method !== req.method) continue;
|
|
1369
|
+
if (pathname !== route.path) continue;
|
|
1370
|
+
try {
|
|
1371
|
+
return await route.handler(req, res);
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
log.error("Unhandled:", err);
|
|
1374
|
+
if (!res.headersSent) sendError(res, err.message, "api_error", 500, "internal_error");
|
|
1375
|
+
else { try { res.end(); } catch (_) {} }
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
sendError(res, `${req.method} ${pathname} not found`, "not_found_error", 404, "not_found");
|
|
1381
|
+
}).on("error", (err) => {
|
|
1382
|
+
if (err?.code === "EADDRINUSE") {
|
|
1383
|
+
console.error(`✗ Port ${err.port} is already in use — another gateway instance is listening there. Stop it or choose a different port.`);
|
|
1384
|
+
process.exitCode = 1;
|
|
1385
|
+
process.exit(1);
|
|
1386
|
+
}
|
|
1387
|
+
throw err;
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Startup banner. Long rows wrap onto multiple box lines instead of being
|
|
1392
|
+
// truncated (the model list used to get chopped mid-name).
|
|
1393
|
+
export const BOX_W = 56; // content width between the border pipes
|
|
1394
|
+
|
|
1395
|
+
export function boxRow(text) {
|
|
1396
|
+
// account for wide (emoji/CJK) glyphs so the right border stays aligned
|
|
1397
|
+
let out = "";
|
|
1398
|
+
let w = 0;
|
|
1399
|
+
for (const ch of text) {
|
|
1400
|
+
const cw = charWidth(ch);
|
|
1401
|
+
if (w + cw > BOX_W) break; // truncate to keep the border aligned
|
|
1402
|
+
out += ch;
|
|
1403
|
+
w += cw;
|
|
1404
|
+
}
|
|
1405
|
+
return `│ ${out}${" ".repeat(BOX_W - w)} │`;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function charWidth(ch) {
|
|
1409
|
+
const wide = /[\u{1100}-\u{115F}\u{2E80}-\u{A4CF}\u{AC00}-\u{D7A3}\u{F900}-\u{FAFF}\u{FE30}-\u{FE4F}\u{FF00}-\u{FF60}\u{FFE0}-\u{FFE6}\u{1F300}-\u{1FAFF}]/u;
|
|
1410
|
+
return wide.test(ch) ? 2 : 1;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// Greedy-wrap text to the box width, preferring spaces/comma boundaries.
|
|
1414
|
+
export function wrapBox(text) {
|
|
1415
|
+
const lines = [];
|
|
1416
|
+
let line = "", w = 0;
|
|
1417
|
+
for (const ch of String(text)) {
|
|
1418
|
+
const cw = charWidth(ch);
|
|
1419
|
+
if (w + cw > BOX_W) {
|
|
1420
|
+
// backtrack to a soft boundary if there is one in this line
|
|
1421
|
+
const cut = Math.max(line.lastIndexOf(" "), line.lastIndexOf(","));
|
|
1422
|
+
if (cut > BOX_W * 0.5) { lines.push(line.slice(0, cut)); line = line.slice(cut + 1); }
|
|
1423
|
+
else { lines.push(line); line = ""; }
|
|
1424
|
+
w = 0;
|
|
1425
|
+
for (const c of line) w += charWidth(c);
|
|
1426
|
+
}
|
|
1427
|
+
line += ch;
|
|
1428
|
+
w += cw;
|
|
1429
|
+
}
|
|
1430
|
+
if (line) lines.push(line);
|
|
1431
|
+
return lines.length ? lines : [""];
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
export function printStartupBanner({ title, rows = [], footers = [] }) {
|
|
1435
|
+
const edge = (ch) => ` ┌${ch.repeat(BOX_W + 2)}┐`;
|
|
1436
|
+
const mid = (ch) => ` ├${ch.repeat(BOX_W + 2)}┤`;
|
|
1437
|
+
const bottom = ` └${"─".repeat(BOX_W + 2)}┘`;
|
|
1438
|
+
const lines = [edge("─"), ` ${boxRow(title)}`, mid("─")];
|
|
1439
|
+
for (const row of rows) for (const piece of wrapBox(row)) lines.push(` ${boxRow(piece)}`);
|
|
1440
|
+
if (footers.length) {
|
|
1441
|
+
lines.push(mid("─"));
|
|
1442
|
+
for (const f of footers) for (const piece of wrapBox(f)) lines.push(` ${boxRow(piece)}`);
|
|
1443
|
+
}
|
|
1444
|
+
lines.push(bottom);
|
|
1445
|
+
console.log("\n" + lines.join("\n") + "\n");
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
export function installProcessGuards(log) {
|
|
1449
|
+
// Keep the server alive through unexpected async throws — log loudly instead
|
|
1450
|
+
// of dying mid-session (an ERR_HTTP_HEADERS_SENT inside a timer callback
|
|
1451
|
+
// used to take the whole proxy down).
|
|
1452
|
+
process.on("uncaughtException", (e) => log.error("Uncaught exception:", e));
|
|
1453
|
+
process.on("unhandledRejection", (e) => log.error("Unhandled rejection:", e));
|
|
1454
|
+
}
|