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.
Files changed (7) hide show
  1. package/README.md +243 -270
  2. package/anthropic.js +734 -734
  3. package/bin/cli.js +406 -406
  4. package/lib/core.js +1454 -1434
  5. package/lib/prompts.js +113 -113
  6. package/openai.js +425 -425
  7. 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
- export function translateUpstreamError(msg) {
347
- if (typeof msg !== "string") return msg;
348
- for (const [pattern, english] of ZH_ERROR_MAP) {
349
- if (pattern.test(msg)) return english;
350
- }
351
- return msg;
352
- }
353
-
354
- export function getUpstreamErrorMessage(body) {
355
- const text = typeof body === "string" ? body.trim() : "";
356
-
357
- try {
358
- const parsed = JSON.parse(text);
359
- const message = typeof parsed === "string"
360
- ? parsed
361
- : parsed?.error?.message || parsed?.message || parsed?.error;
362
- if (typeof message === "string" && message.length > 0) {
363
- return translateUpstreamError(message);
364
- }
365
- return "Upstream error";
366
- } catch {
367
- const title = text.match(/<title>(.*?)<\/title>/i)?.[1];
368
- if (title) return translateUpstreamError(title);
369
- if (/<(?:html|body|!doctype)\b/i.test(text)) return "Upstream returned an invalid error response";
370
- return translateUpstreamError(text || "Upstream error");
371
- }
372
- }
373
-
374
- // Body markers that mean "this account cannot use this model until it pays" —
375
- // these are PERMANENT conditions, not transient hiccups, so they must never be
376
- // retried or fallen back on. AutoClaw surfaces them as 403+code 810000, plain
377
- // 402, or Chinese credit messages depending on which door you knock on.
378
- const QUOTA_BODY_RE = /积分不足|free quota used up|insufficient credit|quota\s*(exceed|used up)|810000/i;
379
-
380
- // Classify a failed cloud response into the client-facing error shape.
381
- // `bodyText` is the raw upstream response body (may be empty).
382
- export function classifyUpstreamError(statusCode, bodyText, modelName) {
383
- const text = typeof bodyText === "string" ? bodyText : "";
384
- const detail = getUpstreamErrorMessage(text);
385
-
386
- // Quota outranks everything upstream reports it under several statuses
387
- if (statusCode === 402 || QUOTA_BODY_RE.test(text)) {
388
- return {
389
- status: 402,
390
- type: "insufficient_credits",
391
- code: "quota_exhausted",
392
- permanent: true,
393
- message: `${modelName || "This model"} is out of credits recharge or subscribe in AutoClaw` +
394
- (detail && detail !== "Upstream error" ? ` (${detail})` : ""),
395
- };
396
- }
397
-
398
- switch (statusCode) {
399
- case 401:
400
- return {
401
- status: 401, type: "authentication_error", code: "token_expired", permanent: false,
402
- message: "AutoClaw token expired or invalid cached token invalidated, retry now",
403
- };
404
- case 403:
405
- return {
406
- status: 403, type: "permission_error", code: "forbidden_by_upstream", permanent: false,
407
- message: detail !== "Upstream error" ? detail : "AutoClaw upstream refused this request (HTTP 403)",
408
- };
409
- case 404:
410
- return {
411
- status: 404, type: "not_found_error", code: "model_not_found", permanent: true,
412
- message: `Model ${modelName || ""} is not recognized by AutoClaw upstream`.trim(),
413
- };
414
- case 429:
415
- return {
416
- status: 429, type: "rate_limit_error", code: "rate_limited_by_upstream", permanent: false,
417
- message: detail !== "Upstream error" ? detail : "Rate limited by AutoClaw upstream — slow down",
418
- };
419
- case 400:
420
- return {
421
- status: 400, type: "invalid_request_error", code: "invalid_request", permanent: false,
422
- message: detail,
423
- };
424
- default:
425
- if (statusCode >= 500) {
426
- return {
427
- status: 502, type: "api_error", code: "upstream_failure", permanent: false,
428
- message: `AutoClaw upstream failed (HTTP ${statusCode}): ${detail}`,
429
- };
430
- }
431
- return {
432
- status: statusCode >= 400 ? statusCode : 502,
433
- type: "api_error", code: "upstream_failure", permanent: false,
434
- message: detail !== "Upstream error" ? detail : "Upstream error",
435
- };
436
- }
437
- }
438
-
439
- // Classify an error raised by the local WebSocket agent path. The gateway's
440
- // FailoverError strings embed the real upstream status ("FailoverError: HTTP
441
- // 403: ...", "FailoverError: 402 status code"), so mine those first.
442
- export function classifyLocalAgentError(err, modelName) {
443
- const raw = String(err?.message || err || "");
444
-
445
- if (/\b402\b/.test(raw)) {
446
- return {
447
- status: 402, type: "insufficient_credits", code: "quota_exhausted", permanent: true,
448
- message: `${modelName || "This model"} is out of credits — recharge or subscribe in AutoClaw`,
449
- };
450
- }
451
- if (/\b403\b/.test(raw)) {
452
- if (/quota|810000/i.test(raw)) {
453
- return {
454
- status: 402, type: "insufficient_credits", code: "quota_exhausted", permanent: true,
455
- message: `${modelName || "This model"} free quota is used up — subscribe to a membership in AutoClaw`,
456
- };
457
- }
458
- return {
459
- status: 403, type: "permission_error", code: "forbidden_by_local_gateway", permanent: false,
460
- message: "AutoClaw local gateway refused this request (HTTP 403)",
461
- };
462
- }
463
- if (/timeout/i.test(raw)) {
464
- return {
465
- status: 504, type: "api_error", code: "local_gateway_timeout", permanent: false,
466
- message: "AutoClaw local gateway did not finish in time — try again or check the desktop app",
467
- };
468
- }
469
- if (/token not found|Is AutoClaw running/i.test(raw)) {
470
- return {
471
- status: 503, type: "service_unavailable", code: "no_local_gateway", permanent: true,
472
- message: "AutoClaw local gateway is not reachable — make sure the desktop app is running",
473
- };
474
- }
475
- return {
476
- status: 502, type: "api_error", code: "local_gateway_failed", permanent: false,
477
- message: getUpstreamErrorMessage(raw),
478
- };
479
- }
480
-
481
- // Classify an error thrown by the upstream transport itself — no HTTP
482
- // response ever arrived: dead token, connection reset after the retry budget,
483
- // or a 2-minute timeout.
484
- export function classifyTransportError(err) {
485
- const msg = String(err?.message || err || "");
486
-
487
- if (/Cannot read AutoClaw token/i.test(msg)) {
488
- return {
489
- status: 503, type: "service_unavailable", code: "no_token", permanent: false,
490
- message: msg,
491
- };
492
- }
493
- if (err?.code === "UPSTREAM_TIMEOUT" || /timeout/i.test(msg)) {
494
- return {
495
- status: 504, type: "api_error", code: "upstream_timeout", permanent: false,
496
- message: msg !== "Error" ? msg : "AutoClaw upstream did not respond in time",
497
- };
498
- }
499
- return {
500
- status: 502, type: "api_error", code: "upstream_connection_failed", permanent: false,
501
- message: `${msg}${err?.code ? ` (${err.code})` : ""}` || "Could not reach AutoClaw upstream",
502
- };
503
- }
504
-
505
- // Transient network failures are worth exactly one transparent retry; anything
506
- // else (timeouts included — they already burned 2 minutes) is surfaced as-is.
507
- export function isTransientNetworkError(err) {
508
- const code = err?.code || "";
509
- const msg = String(err?.message || "");
510
- return (
511
- ["ECONNRESET", "EPIPE", "ECONNABORTED", "ERR_STREAM_PREMATURE_CLOSE"].includes(code) ||
512
- /socket hang up|premature close/i.test(msg)
513
- );
514
- }
515
-
516
- // Single shared decision for "should this failure engage the local gateway".
517
- // 404 means the client asked for something that doesn't exist anywhere, and
518
- // 429 means upstream is throttling us — hammering the local agent then would
519
- // only hide the signal, so both bypass fallback.
520
- export function shouldFallbackToLocal(statusCode) {
521
- return statusCode >= 400 && statusCode !== 404 && statusCode !== 429;
522
- }
523
-
524
- // Short-lived negative cache for PERMANENT failures (quota, unknown model).
525
- // Without it, every request for a dead model replays: cloud attempt → doomed
526
- // retry sleep local agent connect failure (~30s+). With it, repeats fail
527
- // instantly with the exact same classified error until the TTL lapses.
528
- export function createPermanentFailureCache(ttlMs = 60_000) {
529
- const _cache = new Map(); // modelId -> { status, type, code, message, expiresAt }
530
- return {
531
- mark(modelId, classification) {
532
- if (!classification.permanent) return;
533
- _cache.set(modelId, {
534
- status: classification.status,
535
- type: classification.type,
536
- code: classification.code,
537
- message: classification.message,
538
- expiresAt: Date.now() + ttlMs,
539
- });
540
- },
541
- // Returns the cached classification while fresh, else clears the entry.
542
- get(modelId) {
543
- const hit = _cache.get(modelId);
544
- if (!hit) return null;
545
- if (Date.now() > hit.expiresAt) { _cache.delete(modelId); return null; }
546
- return hit;
547
- },
548
- clear() { _cache.clear(); },
549
- };
550
- }
551
-
552
- // ============================================================================
553
- // HTTP response helpers
554
- // ============================================================================
555
-
556
- export function sendJSON(res, data, status = 200) {
557
- const body = JSON.stringify(data);
558
- res.writeHead(status, {
559
- "Content-Type": "application/json",
560
- "Content-Length": Buffer.byteLength(body),
561
- });
562
- res.end(body);
563
- }
564
-
565
- // OpenAI shape: { error: { message, type, code } }
566
- export function sendErrorOpenAI(res, message, type = "api_error", status = 500, code = null) {
567
- sendJSON(res, { error: { message, type, code } }, status);
568
- }
569
-
570
- // Anthropic shape: { type: "error", error: { type, message, code } }
571
- export function sendErrorAnthropic(res, message, type = "api_error", status = 500, code = null) {
572
- sendJSON(res, { type: "error", error: { type, message, ...(code ? { code } : {}) } }, status);
573
- }
574
-
575
- // Send a classification produced by classifyUpstreamError/classifyLocalAgentError
576
- export function sendClassifiedErrorOpenAI(res, cls) {
577
- sendJSON(res, { error: { message: cls.message, type: cls.type, code: cls.code ?? null } }, cls.status);
578
- }
579
-
580
- export function sendClassifiedErrorAnthropic(res, cls) {
581
- sendJSON(res, { type: "error", error: { type: cls.type, message: cls.message, code: cls.code ?? undefined } }, cls.status);
582
- }
583
-
584
- export function isAuthorized(req, proxyKey) {
585
- if (!proxyKey) return true;
586
- const header = req.headers["authorization"] || req.headers["x-api-key"] || "";
587
- const key = header.startsWith("Bearer ") ? header.slice(7) : header;
588
- return key === proxyKey;
589
- }
590
-
591
- export function validateChatPayload(body, maxMessages = Infinity) {
592
- const MAX_MESSAGES = (maxMessages && Number.isFinite(maxMessages)) ? maxMessages : Infinity;
593
- const MAX_MESSAGE_TEXT_BYTES = 256 * 1024;
594
- const MAX_TOTAL_MESSAGE_TEXT_BYTES = 1024 * 1024;
595
- const MAX_TOOLS = 64;
596
- const MAX_TOOL_BYTES = 128 * 1024;
597
- const MAX_TOTAL_TOOL_BYTES = 512 * 1024;
598
-
599
- if (!Array.isArray(body.messages) || body.messages.length === 0) {
600
- return { message: "messages must be a non-empty array", statusCode: 400 };
601
- }
602
- if (body.messages.length > MAX_MESSAGES) {
603
- return { message: `messages must contain at most ${MAX_MESSAGES} entries`, statusCode: 413 };
604
- }
605
-
606
- let totalMessageBytes = 0;
607
- for (const message of body.messages) {
608
- const content = message?.content;
609
- const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
610
- const bytes = Buffer.byteLength(text);
611
- if (bytes > MAX_MESSAGE_TEXT_BYTES) {
612
- return { message: "an individual message is too large", statusCode: 413 };
613
- }
614
- totalMessageBytes += bytes;
615
- if (totalMessageBytes > MAX_TOTAL_MESSAGE_TEXT_BYTES) {
616
- return { message: "combined message content is too large", statusCode: 413 };
617
- }
618
- }
619
-
620
- if (body.tools !== undefined && !Array.isArray(body.tools)) {
621
- return { message: "tools must be an array", statusCode: 400 };
622
- }
623
- if (body.tools?.length > MAX_TOOLS) {
624
- return { message: `tools must contain at most ${MAX_TOOLS} entries`, statusCode: 413 };
625
- }
626
-
627
- let totalToolBytes = 0;
628
- for (const tool of body.tools || []) {
629
- const bytes = Buffer.byteLength(JSON.stringify(tool));
630
- if (bytes > MAX_TOOL_BYTES) {
631
- return { message: "an individual tool definition is too large", statusCode: 413 };
632
- }
633
- totalToolBytes += bytes;
634
- if (totalToolBytes > MAX_TOTAL_TOOL_BYTES) {
635
- return { message: "combined tool definitions are too large", statusCode: 413 };
636
- }
637
- }
638
-
639
- return null;
640
- }
641
-
642
- export function generateId() {
643
- return crypto.randomBytes(12).toString("hex");
644
- }
645
-
646
- export function readBody(req, maxBodyBytes) {
647
- return new Promise((resolve, reject) => {
648
- const ct = req.headers["content-type"] || "";
649
- if (!ct.toLowerCase().includes("application/json")) {
650
- return reject(Object.assign(new Error("Content-Type must be application/json"), { statusCode: 415 }));
651
- }
652
-
653
- let totalBytes = 0;
654
- let limitHit = false;
655
- const chunks = [];
656
- req.on("data", (c) => {
657
- totalBytes += c.length;
658
- if (totalBytes > maxBodyBytes) {
659
- if (!limitHit) {
660
- limitHit = true;
661
- reject(Object.assign(new Error("Request body too large"), { statusCode: 413 }));
662
- }
663
- // Keep draining (chunks are discarded) so the 413 response can still
664
- // be delivered on this connection... unless the client is flooding far
665
- // past the cap (4×), in which case cut the socket — nobody legitimate
666
- // sends 200MB to a 50MB-capped local proxy, and draining forever just
667
- // hands them a free upload channel.
668
- if (totalBytes > maxBodyBytes * 4) {
669
- try { req.destroy(); } catch (_) {}
670
- }
671
- return;
672
- }
673
- chunks.push(c);
674
- });
675
- req.on("end", () => {
676
- if (limitHit) return;
677
- try {
678
- let raw = Buffer.concat(chunks).toString("utf8");
679
- // Strip UTF-8 BOM if present
680
- if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
681
- resolve(JSON.parse(raw || "{}"));
682
- } catch (e) {
683
- reject(Object.assign(new Error(`Invalid JSON: ${e.message}`), { statusCode: 400 }));
684
- }
685
- });
686
- req.on("error", reject);
687
- });
688
- }
689
-
690
- // Collect a full upstream response body (error inspection / passthrough)
691
- export function collectResponse(res) {
692
- return new Promise((resolve) => {
693
- const chunks = [];
694
- res.on("data", (c) => chunks.push(c));
695
- res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
696
- res.on("error", () => resolve(""));
697
- });
698
- }
699
-
700
- // R1: never let an upstream rejection pass without its body on record —
701
- // quota walls hide behind bare status codes. One compact line,
702
- // whitespace-collapsed, capped at 500 chars.
703
- export function logUpstreamErrorBody(logger, status, bodyText) {
704
- const text = typeof bodyText === "string" ? bodyText.replace(/\s+/g, " ").trim() : "";
705
- if (!text) return;
706
- logger.warn(`Upstream ${status} body: ${text.slice(0, 500)}`);
707
- }
708
-
709
- // SSE response headers — one frozen constant instead of four copies of the
710
- // same literal across both entrypoints' streaming writeHead calls.
711
- export const SSE_HEADERS = Object.freeze({
712
- "Content-Type": "text/event-stream",
713
- "Cache-Control": "no-cache",
714
- "Connection": "keep-alive",
715
- "X-Accel-Buffering": "no",
716
- });
717
-
718
- // Model-field validation shared by both wire formats — the model drives
719
- // everything downstream, so it is checked before any format conversion.
720
- // Returns a sendable error descriptor or null.
721
- export function validateModelField(body) {
722
- if (!body.model || typeof body.model !== "string" || body.model.length > 256 || body.model.includes("..") || /[\r\n\0]/.test(body.model)) {
723
- return { status: 400, message: "model must be a valid non-empty string (max 256 chars)", type: "invalid_request_error", code: "invalid_model" };
724
- }
725
- return null;
726
- }
727
-
728
- // Last-message preview for request logs: string content verbatim, anything
729
- // else JSON-stringified.
730
- export function lastMessagePreview(messages) {
731
- const lastMsg = messages?.[messages.length - 1];
732
- return typeof lastMsg?.content === "string" ? lastMsg.content : JSON.stringify(lastMsg?.content) ?? "";
733
- }
734
-
735
- // Cloud call with the one retry for the historically flaky 400 "invalid
736
- // request" hiccup — but never for a model already confirmed permanently
737
- // broken. Buffers and logs every >=400 body along the way (R1). Returns the
738
- // terminal upstream response plus its buffered error body; success rendering
739
- // stays at the call site so wire formats never leak in here.
740
- export async function callUpstreamWithInvalidRequestRetry(callUpstream, modelId, permanentFailures, log) {
741
- let res = await callUpstream();
742
- let errBody = "";
743
- if (res.statusCode === 400) {
744
- errBody = await collectResponse(res);
745
- logUpstreamErrorBody(log, res.statusCode, errBody);
746
- if (errBody.includes('"invalid request"') && !permanentFailures.get(modelId)) {
747
- log.info("Upstream 400 invalid request — retrying once");
748
- await new Promise(r => setTimeout(r, 2000));
749
- res = await callUpstream();
750
- if (res.statusCode < 400) return { res, errBody: "" };
751
- errBody = await collectResponse(res);
752
- logUpstreamErrorBody(log, res.statusCode, errBody);
753
- }
754
- } else if (res.statusCode >= 400) {
755
- errBody = await collectResponse(res);
756
- logUpstreamErrorBody(log, res.statusCode, errBody);
757
- }
758
- return { res, errBody };
759
- }
760
-
761
- // ============================================================================
762
- // Rate limiter — simple token bucket per client IP
763
- // ============================================================================
764
-
765
- export function createRateLimiter(rateLimit) {
766
- const _buckets = new Map();
767
- function limit(ip) {
768
- const now = Date.now();
769
- const b = _buckets.get(ip);
770
- if (!b) { _buckets.set(ip, { tokens: Math.max(0, rateLimit - 1), last: now }); return true; }
771
- const elapsed = (now - b.last) / 1000;
772
- b.tokens = Math.min(rateLimit, b.tokens + elapsed * rateLimit);
773
- b.last = now;
774
- if (b.tokens < 1) return false;
775
- b.tokens -= 1;
776
- return true;
777
- }
778
- // Drop stale buckets so the map can't grow unbounded (unref'd — doesn't hold the process open)
779
- function startBucketSweep() {
780
- setInterval(() => {
781
- const cutoff = Date.now() - 24 * 3600 * 1000;
782
- for (const [ip, b] of _buckets) if (b.last < cutoff) _buckets.delete(ip);
783
- }, 3600 * 1000).unref();
784
- }
785
- return { rateLimit: limit, startBucketSweep };
786
- }
787
-
788
- // Resolve the client IP for rate limiting. X-Forwarded-For is trusted ONLY
789
- // from peers listed in TRUSTED_PROXIES (comma-separated IPs) — trusting it
790
- // from arbitrary non-loopback peers lets a remote client rotate fake IPs to
791
- // dodge the limiter. Both entrypoints share this single implementation.
792
- export function resolveClientIp(req) {
793
- const TRUSTED_PROXIES = (process.env.TRUSTED_PROXIES || "").split(",").map(s => s.trim()).filter(Boolean);
794
- const peer = (req.socket.remoteAddress || "unknown").replace(/^::ffff:/, "");
795
- if (TRUSTED_PROXIES.includes(peer)) {
796
- const xff = req.headers["x-forwarded-for"];
797
- if (xff) return xff.split(",")[0].trim().replace(/^::ffff:/, "");
798
- }
799
- return peer;
800
- }
801
-
802
- // ============================================================================
803
- // Request loggers
804
- // ============================================================================
805
-
806
- // JSON ring logger — keeps the last N requests on disk.
807
- // Concurrency-safe across processes via an exclusive lockfile: without it, two
808
- // proxies doing read-modify-write silently eat each other's entries (observed:
809
- // --test-models results vanishing while the main proxy served traffic).
810
- export function createRequestLogger(filePath) {
811
- const MAX_LOG_ENTRIES = 50;
812
- const LOCK_PATH = `${filePath}.lock`;
813
-
814
- function acquireLock(deadlineMs = 1500) {
815
- const deadline = Date.now() + deadlineMs;
816
- for (;;) {
817
- try {
818
- fs.writeFileSync(LOCK_PATH, String(process.pid), { flag: "wx" }); // exclusive create
819
- return true;
820
- } catch (_) {
821
- // Steal a stale lock (>2s old) so a crashed writer can't wedge logging
822
- try {
823
- if (Date.now() - fs.statSync(LOCK_PATH).mtimeMs > 2000) { fs.unlinkSync(LOCK_PATH); continue; }
824
- } catch (_) { /* lock vanished between stat and unlink — loop retries */ }
825
- if (Date.now() > deadline) return false; // give up; write unlocked rather than lose the entry
826
- // Synchronous sleep that doesn't starve the event loop
827
- try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); }
828
- catch (_) { const end = Date.now() + 25; while (Date.now() < end) { /* spin */ } }
829
- }
830
- }
831
- }
832
-
833
- function releaseLock() {
834
- try { fs.unlinkSync(LOCK_PATH); } catch (_) {}
835
- }
836
-
837
- function logRequest(entry) {
838
- let locked = false;
839
- try {
840
- locked = acquireLock();
841
- let entries = [];
842
- try { entries = JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch (_) {}
843
- entries.push(entry);
844
- if (entries.length > MAX_LOG_ENTRIES) entries = entries.slice(-MAX_LOG_ENTRIES);
845
- fs.writeFileSync(filePath, JSON.stringify(entries, null, 2));
846
- } catch (_) { /* never let logging break request handling */ }
847
- finally { if (locked) releaseLock(); }
848
- }
849
-
850
- return { logRequest };
851
- }
852
-
853
- // JSONL structured log — one line per request, rotated past the cap so disk
854
- // can't fill. This append-only stream is the reliable source of truth; treat
855
- // the pretty ring file above as best-effort.
856
- export function createJsonlLogger({ enabled, sync = false, file, maxBytes }) {
857
- function logJsonl(entry) {
858
- if (!enabled) return;
859
- const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
860
- try {
861
- if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
862
- } catch (_) {}
863
- try {
864
- if (sync) fs.appendFileSync(file, line);
865
- else fs.appendFile(file, line, () => {});
866
- } catch (_) {}
867
- }
868
- return { logJsonl };
869
- }
870
-
871
- // ============================================================================
872
- // Local WebSocket bridge (L-route) — drives AutoClaw's own gateway on
873
- // 127.0.0.1:18789 as a fallback when the cloud upstream fails.
874
- // ============================================================================
875
-
876
- export function encodeWsFrame(text) {
877
- const payload = Buffer.from(text, 'utf-8');
878
- const length = payload.length;
879
- let header;
880
- const mask = crypto.randomBytes(4);
881
- if (length <= 125) {
882
- header = Buffer.alloc(2 + 4);
883
- header[0] = 0x81; header[1] = 0x80 | length; mask.copy(header, 2);
884
- } else if (length <= 65535) {
885
- header = Buffer.alloc(4 + 4);
886
- header[0] = 0x81; header[1] = 0x80 | 126; header.writeUInt16BE(length, 2); mask.copy(header, 4);
887
- } else {
888
- header = Buffer.alloc(10 + 4);
889
- header[0] = 0x81; header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(length), 2); mask.copy(header, 10);
890
- }
891
- const maskedPayload = Buffer.alloc(length);
892
- for (let i = 0; i < length; i++) maskedPayload[i] = payload[i] ^ mask[i % 4];
893
- return Buffer.concat([header, maskedPayload]);
894
- }
895
-
896
- export function decodeWsFrames(buffer, onMessage) {
897
- let offset = 0;
898
- while (offset < buffer.length) {
899
- if (buffer.length - offset < 2) break;
900
- const firstByte = buffer[offset];
901
- const secondByte = buffer[offset + 1];
902
- const opcode = firstByte & 0x0f;
903
- const isMasked = (secondByte & 0x80) !== 0;
904
- let payloadLen = secondByte & 0x7f;
905
- let headerLen = 2;
906
- if (payloadLen === 126) {
907
- if (buffer.length - offset < 4) break;
908
- payloadLen = buffer.readUInt16BE(offset + 2);
909
- headerLen = 4;
910
- } else if (payloadLen === 127) {
911
- if (buffer.length - offset < 10) break;
912
- payloadLen = Number(buffer.readBigUInt64BE(offset + 2));
913
- headerLen = 10;
914
- }
915
- if (isMasked) headerLen += 4;
916
- if (buffer.length - offset < headerLen + payloadLen) break;
917
- const payload = buffer.slice(offset + headerLen, offset + headerLen + payloadLen);
918
- offset += headerLen + payloadLen;
919
- if (opcode === 1) onMessage(payload.toString('utf-8'));
920
- else if (opcode === 8) break;
921
- }
922
- return buffer.slice(offset);
923
- }
924
-
925
- export function getLocalGatewayToken() {
926
- try {
927
- const tokenFile = path.join(os.homedir(), '.openclaw-autoclaw', '.gateway-token');
928
- if (fs.existsSync(tokenFile)) {
929
- return fs.readFileSync(tokenFile, 'utf-8').trim();
930
- }
931
- } catch (_) {}
932
- return null;
933
- }
934
-
935
- // Run a prompt through AutoClaw's local `agent` RPC and stream assistant
936
- // deltas back through callbacks. NOTE: this executes a full agentic run in
937
- // the desktop app (tools included), not a chat completion — expect seconds to
938
- // minutes, and fresh sessionKey per request keeps runs isolated.
939
- //
940
- // Protocol quirk: the RPC answers TWICE — first `res ok:true` (accepted),
941
- // later possibly another `res` frame with the same id and `ok:false` carrying
942
- // the failure. Handle both, or accepted-but-failed runs hang until timeout.
943
- export function streamLocalGatewayAgent({ config, modelId, messages, onChunk, onEnd, onError, timeoutMs = 120000 }) {
944
- const token = getLocalGatewayToken();
945
- if (!token) {
946
- return onError(new Error("Local AutoClaw gateway token not found. Is AutoClaw running?"));
947
- }
948
-
949
- // Format conversation messages preserving roles
950
- const prompt = (messages || []).map((m) => {
951
- const role = (m.role || "user").toUpperCase();
952
- const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
953
- return `${role}: ${content}`;
954
- }).join("\n\n");
955
-
956
- const normalizedModel = modelId.startsWith("zai/") ? modelId : `zai/${modelId}`;
957
- const sessionKey = 'agent:main:' + crypto.randomBytes(4).toString('hex');
958
- const runId = 'key-' + Date.now() + '-' + crypto.randomBytes(3).toString('hex');
959
-
960
- let finished = false;
961
- let activeReq = null; // live upgrade request of the current attempt
962
- let upgradedSocket = null; // after the upgrade the socket detaches from `req` —
963
- // destroying req alone LEAKS the live WS connection
964
- let protocolRetried = false; // one reconnect allowed on PROTOCOL_MISMATCH
965
- const finish = (fn) => {
966
- if (finished) return;
967
- finished = true;
968
- clearTimeout(timer);
969
- try { (upgradedSocket || activeReq)?.destroy?.(); } catch (_) {}
970
- fn();
971
- };
972
-
973
- const timer = setTimeout(() => {
974
- finish(() => onError(new Error(`Local gateway execution timeout (${timeoutMs / 1000}s)`)));
975
- }, timeoutMs);
976
-
977
- // One connect attempt: upgrade + challenge + connect with the given protocol
978
- // range. The gateway rejects out-of-range offers with a structured
979
- // PROTOCOL_MISMATCH detail naming its expectedProtocol — on that exact error
980
- // we reconnect once with the server's own range (self-heals across app
981
- // updates); any other failure ends the run.
982
- const attemptConnect = (minProtocol, maxProtocol) => {
983
- const secKey = crypto.randomBytes(16).toString('base64');
984
- const req = http.request({
985
- hostname: config.LOCAL_GATEWAY_HOST,
986
- port: config.LOCAL_GATEWAY_PORT,
987
- path: '/',
988
- headers: {
989
- 'Connection': 'Upgrade',
990
- 'Upgrade': 'websocket',
991
- 'Sec-WebSocket-Version': '13',
992
- 'Sec-WebSocket-Key': secKey,
993
- 'Authorization': 'Bearer ' + token
994
- }
995
- });
996
- activeReq = req;
997
- req.on('error', (err) => finish(() => onError(err)));
998
- req.on('upgrade', (res, socket) => {
999
- upgradedSocket = socket;
1000
- socket.on('error', (err) => finish(() => onError(err)));
1001
-
1002
- let buf = Buffer.alloc(0);
1003
- let connected = false;
1004
- socket.on('data', chunk => {
1005
- buf = decodeWsFrames(Buffer.concat([buf, chunk]), rawMsg => {
1006
- try {
1007
- const msg = JSON.parse(rawMsg);
1008
- if (!connected) {
1009
- if (msg.event === 'connect.challenge') {
1010
- socket.write(encodeWsFrame(JSON.stringify({
1011
- type: 'req', id: 'conn-1', method: 'connect',
1012
- params: {
1013
- minProtocol, maxProtocol,
1014
- // client.id is allowlisted by the gateway — arbitrary
1015
- // values get INVALID_REQUEST before any agent can run
1016
- client: { id: 'gateway-client', version: getClientHeaders(config)['X-Version'] || '1.17.5', platform: 'win', mode: 'backend' },
1017
- role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'],
1018
- caps: ['tool_events'], commands: [], permissions: {}, auth: { token }, locale: 'en', userAgent: `glmproxy/${VERSION}`
1019
- }
1020
- })));
1021
- } else if (msg.id === 'conn-1') {
1022
- if (!msg.ok) {
1023
- const details = msg.error?.details;
1024
- if (details?.code === 'PROTOCOL_MISMATCH' && typeof details.expectedProtocol === 'number' && !protocolRetried) {
1025
- // the gateway told us its protocol — reconnect with it
1026
- protocolRetried = true;
1027
- console.warn(`[gateway] protocol mismatch — reconnecting with protocol v${details.expectedProtocol}`);
1028
- try { socket.destroy(); } catch (_) {}
1029
- return attemptConnect(details.expectedProtocol, details.expectedProtocol);
1030
- }
1031
- return finish(() => onError(new Error('Gateway connect failed: ' + JSON.stringify(msg.error))));
1032
- }
1033
- connected = true;
1034
- // Send agent prompt
1035
- socket.write(encodeWsFrame(JSON.stringify({
1036
- type: 'req', id: 'agent-1', method: 'agent',
1037
- params: {
1038
- sessionKey,
1039
- message: prompt,
1040
- model: normalizedModel,
1041
- idempotencyKey: runId
1042
- }
1043
- })));
1044
- }
1045
- } else if (msg.id === 'agent-1') {
1046
- if (!msg.ok) {
1047
- // Late ok:false after the earlier ok:true the run was accepted
1048
- // then failed upstream (e.g. FailoverError 402/403)
1049
- return finish(() => onError(new Error('Gateway agent start failed: ' + JSON.stringify(msg.error))));
1050
- }
1051
- } else if (msg.type === 'event') {
1052
- if (msg.event === 'agent' && msg.payload?.stream === 'assistant') {
1053
- const delta = msg.payload?.data?.delta;
1054
- if (typeof delta === 'string' && delta.length > 0) {
1055
- onChunk({ delta, reasoning: "" });
1056
- }
1057
- } else if (msg.event === 'chat' && msg.payload?.state === 'final') {
1058
- finish(() => onEnd({ finishReason: msg.payload.stopReason || 'stop' }));
1059
- }
1060
- }
1061
- } catch (err) {
1062
- finish(() => onError(err));
1063
- }
1064
- });
1065
- });
1066
- });
1067
- req.end();
1068
- };
1069
-
1070
- attemptConnect(config.GATEWAY_MIN_PROTOCOL, config.GATEWAY_MAX_PROTOCOL);
1071
- }
1072
-
1073
- // ============================================================================
1074
- // Upstream caller (cloud)
1075
- // ============================================================================
1076
-
1077
- // Keep-alive agent: reuses TCP+TLS connections instead of paying a fresh
1078
- // handshake on every request (measured latency tax under burst load).
1079
- const UPSTREAM_AGENT = new https.Agent({
1080
- keepAlive: true,
1081
- maxSockets: 32,
1082
- });
1083
-
1084
- // POST JSON upstream with exactly one transparent retry on transient network
1085
- // errors (reset pipes, hung-up sockets). Timeouts are NOT retried — they
1086
- // already consumed their full budget.
1087
- async function postUpstreamWithRetry(options, payload, log) {
1088
- const attemptOnce = () => new Promise((resolve, reject) => {
1089
- const req = https.request({ ...options, agent: UPSTREAM_AGENT }, resolve);
1090
- req.on("timeout", () => {
1091
- req.destroy();
1092
- reject(Object.assign(
1093
- new Error("Upstream timeout — AutoClaw backend did not respond within 2 minutes"),
1094
- { code: "UPSTREAM_TIMEOUT" }
1095
- ));
1096
- });
1097
- req.on("error", reject);
1098
- req.write(payload);
1099
- req.end();
1100
- });
1101
-
1102
- try {
1103
- return await attemptOnce();
1104
- } catch (err) {
1105
- if (isTransientNetworkError(err)) {
1106
- log?.warn(`Transient upstream network error (${err.code || err.message}) — retrying once`);
1107
- await new Promise((r) => setTimeout(r, 250));
1108
- return attemptOnce();
1109
- }
1110
- throw err;
1111
- }
1112
- }
1113
-
1114
- // Keep the 'zai_' prefix mapping while preserving IDs from the current catalog.
1115
- export function resolveUpstreamModelId(knownIds, modelId) {
1116
- return knownIds.has(modelId) ? modelId
1117
- : modelId === "auto" ? "zai_auto"
1118
- : `zai_${modelId}`;
1119
- }
1120
-
1121
- // upstream gates cloud requests on this exact banner inside the system prompt —
1122
- // without it every call gets 400 "invalid request" and we fall into the ws
1123
- // agent. injected on every call below. if the app ever rewords its prompt this
1124
- // breaks again and we re-bisect. full story in ROOT-CAUSE-AND-STUDY.md
1125
- // AUTOCLAW_SYSTEM_BANNER env patches a reword without a release — keep the
1126
- // "## Tooling" line intact or cloud routing silently degrades into the ws agent.
1127
- export const AUTOCLAW_SYSTEM_BANNER =
1128
- process.env.AUTOCLAW_SYSTEM_BANNER ||
1129
- "You are a personal assistant running inside OpenClaw.\n## Tooling";
1130
-
1131
- // prepends the banner (or a system msg if the client sent none), never duplicates
1132
- function injectSystemBanner(messages) {
1133
- const list = Array.isArray(messages) ? [...messages] : [];
1134
- const idx = list.findIndex((m) => m && m.role === "system");
1135
- if (idx === -1) {
1136
- list.unshift({ role: "system", content: AUTOCLAW_SYSTEM_BANNER });
1137
- return list;
1138
- }
1139
- const sys = list[idx];
1140
- const text = typeof sys.content === "string"
1141
- ? sys.content
1142
- : Array.isArray(sys.content)
1143
- ? sys.content.map((p) => (typeof p === "string" ? p : p?.text || "")).join("\n")
1144
- : String(sys.content ?? "");
1145
- if (!text.includes(AUTOCLAW_SYSTEM_BANNER)) {
1146
- list[idx] = { ...sys, content: AUTOCLAW_SYSTEM_BANNER + "\n\n" + text };
1147
- }
1148
- return list;
1149
- }
1150
-
1151
- // Only forward fields the upstream accepts; everything else is stripped.
1152
- function buildSanitizedBody(openAIBody, upstreamModelId) {
1153
- const sanitized = {
1154
- model: upstreamModelId,
1155
- messages: injectSystemBanner(openAIBody.messages || []),
1156
- stream: true,
1157
- };
1158
- if (typeof openAIBody.temperature === "number") sanitized.temperature = openAIBody.temperature;
1159
- if (typeof openAIBody.top_p === "number") sanitized.top_p = openAIBody.top_p;
1160
- if (typeof openAIBody.max_tokens === "number") sanitized.max_tokens = openAIBody.max_tokens;
1161
- if (typeof openAIBody.max_completion_tokens === "number") sanitized.max_tokens = openAIBody.max_completion_tokens;
1162
- if (openAIBody.stop !== undefined) sanitized.stop = openAIBody.stop;
1163
- if (Array.isArray(openAIBody.tools) && openAIBody.tools.length > 0) sanitized.tools = openAIBody.tools;
1164
- if (openAIBody.tool_choice !== undefined) sanitized.tool_choice = openAIBody.tool_choice;
1165
- return sanitized;
1166
- }
1167
-
1168
- // upstream wants bare ids (glm-4.7), clients send catalog ids (zai_glm-4.7)
1169
- export function stripProviderPrefix(modelId) { return String(modelId || "").replace(/^[a-z]+_/, ""); }
1170
-
1171
- // Trae and other clients send content as text-object arrays that Zhipu rejects
1172
- // (400/500) — flatten and normalize them before forwarding.
1173
- function normalizeClientMessages(body) {
1174
- return (body.messages || []).map(msg => {
1175
- const newMsg = { ...msg };
1176
-
1177
- // Normalize role: developer -> system
1178
- if (newMsg.role === "developer") {
1179
- newMsg.role = "system";
1180
- }
1181
-
1182
- // Flatten content array if it's all text blocks
1183
- if (Array.isArray(newMsg.content)) {
1184
- const textParts = [];
1185
- for (const c of newMsg.content) {
1186
- if (typeof c === "string") textParts.push(c);
1187
- else if (c?.type === "text" && typeof c.text === "string") textParts.push(c.text);
1188
- else if (c?.text) textParts.push(String(c.text));
1189
- }
1190
- newMsg.content = textParts.join("\n");
1191
- } else if (newMsg.content === null || newMsg.content === undefined) {
1192
- newMsg.content = "";
1193
- }
1194
-
1195
- return newMsg;
1196
- });
1197
- }
1198
-
1199
- async function callUpstream(config, clientHeaders, getToken, sanitizedBody, log) {
1200
- // header keeps the full catalog id; body model goes upstream bare
1201
- const payload = JSON.stringify({ ...sanitizedBody, model: stripProviderPrefix(sanitizedBody.model) });
1202
- return postUpstreamWithRetry({
1203
- hostname: "autoglm-api.autoglm.ai",
1204
- path: "/autoclaw-proxy/proxy/autoclaw/chat/completions",
1205
- method: "POST",
1206
- headers: {
1207
- "Content-Type": "application/json",
1208
- "Content-Length": Buffer.byteLength(payload),
1209
- "X-Authorization": getToken(),
1210
- "X-Request-Model": sanitizedBody.model,
1211
- "X-Request-Id": crypto.randomUUID(),
1212
- "X-Agent-Id": "main",
1213
- ...clientHeaders,
1214
- },
1215
- timeout: config.UPSTREAM_TIMEOUT_MS, // per-attempt budget (idle-based; env-tunable)
1216
- }, payload, log);
1217
- }
1218
-
1219
- // OpenAI-format entrypoint: resolves aliases/prefix mapping, normalizes
1220
- // client-shaped messages, forwards.
1221
- export function callUpstreamOpenAI(config, knownIds, clientHeaders, getToken, body, modelId, log) {
1222
- const upstreamModelId = resolveUpstreamModelId(knownIds, modelId);
1223
- const normalized = { ...body, messages: normalizeClientMessages(body) };
1224
- log?.debug(`→ upstream model=${modelId}`);
1225
- return callUpstream(config, clientHeaders, getToken, buildSanitizedBody(normalized, upstreamModelId), log);
1226
- }
1227
-
1228
- // Anthropic-format entrypoint: model already resolved, body already converted
1229
- // to OpenAI shape by the entrypoint's converter — forward as-is.
1230
- export function callUpstreamAnthropic(config, clientHeaders, getToken, openAIBody, modelId) {
1231
- return callUpstream(config, clientHeaders, getToken, buildSanitizedBody(openAIBody, modelId), null);
1232
- }
1233
-
1234
- // ============================================================================
1235
- // Credit-tier model routing
1236
- // ============================================================================
1237
-
1238
- // Fetch AutoClaw's remote model-config (the same data its UI ranks models
1239
- // with). The JWT goes in the `authorization` header (it already includes the
1240
- // "Bearer " prefix — sending it as X-Authorization returns 401). Never throws:
1241
- // returns the top-level `models` array or null so callers can degrade to
1242
- // heuristics without startup risk.
1243
- export function fetchRemoteModelConfig(config, jwt, { timeoutMs = 5000 } = {}) {
1244
- if (!jwt) return Promise.resolve(null);
1245
- return new Promise((resolve) => {
1246
- try {
1247
- const req = https.request({
1248
- hostname: "autoglm-api.autoglm.ai",
1249
- path: config.MODEL_CONFIG_PATH,
1250
- method: "GET",
1251
- headers: { authorization: jwt, ...getClientHeaders(config) },
1252
- timeout: timeoutMs,
1253
- }, async (res) => {
1254
- if (res.statusCode !== 200) { res.resume(); return resolve(null); }
1255
- try {
1256
- const data = JSON.parse(await collectResponse(res));
1257
- const models = data?.models;
1258
- resolve(Array.isArray(models) && models.length > 0 ? models.filter((m) => m?.id) : null);
1259
- } catch { resolve(null); }
1260
- });
1261
- req.on("timeout", () => { req.destroy(); resolve(null); });
1262
- req.on("error", () => resolve(null));
1263
- req.end();
1264
- } catch { resolve(null); }
1265
- });
1266
- }
1267
-
1268
- // Attach a creditConsumptionLevel to every catalog model. Remote tiers win;
1269
- // otherwise fall back to heuristics mirroring the desktop app (auto → Low,
1270
- // compact glm52 identity → High), extended with glm53/turbo rules so today's
1271
- // API ids still get sane tiers when the remote config is unreachable.
1272
- export function annotateCreditTiers(models, remoteModels) {
1273
- const remoteById = new Map((Array.isArray(remoteModels) ? remoteModels : []).map((m) => [m.id, m]));
1274
- return models.map((m) => {
1275
- let level = remoteById.get(m.id)?.creditConsumptionLevel || null;
1276
- if (!level) {
1277
- const compact = `${m.id} ${m.name}`.toLowerCase().replace(/[^a-z0-9]/g, "");
1278
- if (compact.includes("auto")) level = "Low";
1279
- else if (compact.includes("glm52") || compact.includes("glm53")) level = "High";
1280
- else if (compact.includes("turbo")) level = "Medium";
1281
- }
1282
- return { ...m, creditLevel: level };
1283
- });
1284
- }
1285
-
1286
- // Single routing authority for Claude aliases. Degradation rules when a tier
1287
- // has no candidates: opus High→Medium→Low→default; sonnet Medium→High→default;
1288
- // haiku Low(prefers non-auto)→Medium→default; default = sonnet target.
1289
- export function resolveTierTargets(models) {
1290
- const at = (level) => models.filter((m) => m.creditLevel === level);
1291
- const pick = (list) => list.find((m) => !m.id.toLowerCase().includes("auto")) || list[0] || null;
1292
-
1293
- const sonnet = pick(at("Medium")) || pick(at("High")) || models[0] || null;
1294
- const haiku = pick(at("Low")) || pick(at("Medium")) || sonnet;
1295
- const opus = pick(at("High")) || pick(at("Medium")) || pick(at("Low")) || sonnet;
1296
-
1297
- const id = (m) => (m ? m.id : null);
1298
- return { opus: id(opus), sonnet: id(sonnet), haiku: id(haiku), default: id(sonnet) };
1299
- }
1300
-
1301
- // ============================================================================
1302
- // Bootstrap helpers shared by both entrypoints
1303
- // ============================================================================
1304
-
1305
- export function makeHealthHandler(config, getToken) {
1306
- return function handleHealth(req, res) {
1307
- let tokenOk = true, tokenError = null;
1308
- try { getToken(); }
1309
- catch (e) { tokenOk = false; tokenError = e.message; }
1310
-
1311
- sendJSON(res, {
1312
- ok: tokenOk,
1313
- status: tokenOk ? "live" : "no_token",
1314
- upstream: config.UPSTREAM_BASE,
1315
- port: config.PORT,
1316
- ...(tokenError ? { error: tokenError } : {}),
1317
- });
1318
- };
1319
- }
1320
-
1321
- // Shared HTTP server: CORS, auth, rate limiting, route dispatch. Routes are
1322
- // [{ method, path, handler }] — method omitted matches any method. sendError
1323
- // carries the entrypoint's format-specific envelope.
1324
- export function createGatewayServer({ config, log, rateLimit, sendError, routes }) {
1325
- return http.createServer(async (req, res) => {
1326
- // CORS allow all origins so any local tool can talk to this proxy
1327
- res.setHeader("Access-Control-Allow-Origin", "*");
1328
- res.setHeader("X-Content-Type-Options", "nosniff");
1329
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1330
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Api-Key, Anthropic-Version, Anthropic-Beta");
1331
-
1332
- if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
1333
-
1334
- const clientIp = resolveClientIp(req);
1335
- if (!rateLimit(clientIp)) {
1336
- res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "1" });
1337
- res.end(JSON.stringify({ error: { message: "Rate limit exceeded", type: "rate_limit_error" } }));
1338
- return;
1339
- }
1340
-
1341
- if (!isAuthorized(req, config.PROXY_KEY)) {
1342
- return sendError(res, "Invalid or missing API key", "authentication_error", 401, "invalid_api_key");
1343
- }
1344
-
1345
- const { pathname } = new URL(req.url, "http://localhost");
1346
-
1347
- for (const route of routes) {
1348
- if (route.method && route.method !== req.method) continue;
1349
- if (pathname !== route.path) continue;
1350
- try {
1351
- return await route.handler(req, res);
1352
- } catch (err) {
1353
- log.error("Unhandled:", err);
1354
- if (!res.headersSent) sendError(res, err.message, "api_error", 500, "internal_error");
1355
- else { try { res.end(); } catch (_) {} }
1356
- return;
1357
- }
1358
- }
1359
-
1360
- sendError(res, `${req.method} ${pathname} not found`, "not_found_error", 404, "not_found");
1361
- }).on("error", (err) => {
1362
- if (err?.code === "EADDRINUSE") {
1363
- console.error(`✗ Port ${err.port} is already in use — another gateway instance is listening there. Stop it or choose a different port.`);
1364
- process.exitCode = 1;
1365
- process.exit(1);
1366
- }
1367
- throw err;
1368
- });
1369
- }
1370
-
1371
- // Startup banner. Long rows wrap onto multiple box lines instead of being
1372
- // truncated (the model list used to get chopped mid-name).
1373
- export const BOX_W = 56; // content width between the border pipes
1374
-
1375
- export function boxRow(text) {
1376
- // account for wide (emoji/CJK) glyphs so the right border stays aligned
1377
- let out = "";
1378
- let w = 0;
1379
- for (const ch of text) {
1380
- const cw = charWidth(ch);
1381
- if (w + cw > BOX_W) break; // truncate to keep the border aligned
1382
- out += ch;
1383
- w += cw;
1384
- }
1385
- return `│ ${out}${" ".repeat(BOX_W - w)} │`;
1386
- }
1387
-
1388
- function charWidth(ch) {
1389
- 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;
1390
- return wide.test(ch) ? 2 : 1;
1391
- }
1392
-
1393
- // Greedy-wrap text to the box width, preferring spaces/comma boundaries.
1394
- export function wrapBox(text) {
1395
- const lines = [];
1396
- let line = "", w = 0;
1397
- for (const ch of String(text)) {
1398
- const cw = charWidth(ch);
1399
- if (w + cw > BOX_W) {
1400
- // backtrack to a soft boundary if there is one in this line
1401
- const cut = Math.max(line.lastIndexOf(" "), line.lastIndexOf(","));
1402
- if (cut > BOX_W * 0.5) { lines.push(line.slice(0, cut)); line = line.slice(cut + 1); }
1403
- else { lines.push(line); line = ""; }
1404
- w = 0;
1405
- for (const c of line) w += charWidth(c);
1406
- }
1407
- line += ch;
1408
- w += cw;
1409
- }
1410
- if (line) lines.push(line);
1411
- return lines.length ? lines : [""];
1412
- }
1413
-
1414
- export function printStartupBanner({ title, rows = [], footers = [] }) {
1415
- const edge = (ch) => ` ┌${ch.repeat(BOX_W + 2)}┐`;
1416
- const mid = (ch) => ` ├${ch.repeat(BOX_W + 2)}┤`;
1417
- const bottom = ` └${"─".repeat(BOX_W + 2)}┘`;
1418
- const lines = [edge("─"), ` ${boxRow(title)}`, mid("─")];
1419
- for (const row of rows) for (const piece of wrapBox(row)) lines.push(` ${boxRow(piece)}`);
1420
- if (footers.length) {
1421
- lines.push(mid(""));
1422
- for (const f of footers) for (const piece of wrapBox(f)) lines.push(` ${boxRow(piece)}`);
1423
- }
1424
- lines.push(bottom);
1425
- console.log("\n" + lines.join("\n") + "\n");
1426
- }
1427
-
1428
- export function installProcessGuards(log) {
1429
- // Keep the server alive through unexpected async throws — log loudly instead
1430
- // of dying mid-session (an ERR_HTTP_HEADERS_SENT inside a timer callback
1431
- // used to take the whole proxy down).
1432
- process.on("uncaughtException", (e) => log.error("Uncaught exception:", e));
1433
- process.on("unhandledRejection", (e) => log.error("Unhandled rejection:", e));
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 mismatchreconnecting 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
+ }