glmproxy 2.6.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -250,6 +250,9 @@ glmproxy --test-models
250
250
  | `PREFER_LOCAL` | off | Set to `1` to use the local AutoClaw gateway first, skipping cloud attempts |
251
251
  | `TRUSTED_PROXIES` | empty | Comma-separated IPs whose `X-Forwarded-For` header is trusted for rate limiting |
252
252
  | `MAX_BODY_BYTES` | `52428800` | Max request body (50 MB) |
253
+ | `MAX_MESSAGE_TEXT_BYTES` | `262144` | Max per-message TEXT size (256 KB; base64 image data is not counted as text) |
254
+ | `MAX_TOTAL_MESSAGE_TEXT_BYTES` | `1048576` | Max combined message text (1 MB) |
255
+ | `MAX_IMAGE_BYTES` | `20971520` | Max decoded size per image attachment (20 MB) |
253
256
  | `JSONL_LOG` | off | Write structured JSONL request log when `true` (also on with `LOG_LEVEL=debug`) |
254
257
  | `JSONL_FILE` | `proxy_requests.jsonl` (Anthropic: `proxy_requests_anthropic.jsonl`) | JSONL output path |
255
258
  | `JSONL_SYNC` | off | Write JSONL lines synchronously when `true` (flush every line) |
@@ -264,6 +267,7 @@ glmproxy --test-models
264
267
  | `--limit [n]` | — | Set or clear the max message/entity limit (e.g. `--limit 256`; bare `--limit` prints the current value) |
265
268
  | `--doctor` | — | Scan AutoClaw's current runtime model catalog and show Anthropic routing |
266
269
  | `--test-models` / `--test` | — | Live health check: test every catalog model through the full pipeline |
270
+ | `--stop` | — | Kill any other running glmproxy instances (npm-global or `bin/cli.js` starts) and exit |
267
271
  | `--help`, `-h` | — | Show CLI help |
268
272
 
269
273
  **JSONL request logging.** Set `JSONL_LOG=true` (or `LOG_LEVEL=debug`) to write one JSON line per request:
@@ -387,6 +391,7 @@ TRUSTED_PROXIES=127.0.0.1 glmproxy --host 0.0.0.0
387
391
  - Upstream 400 `"invalid request"` gets one retry after a 2s delay (a known upstream hiccup). Quota/plan errors are never retried
388
392
  - The cloud upstream requires AutoClaw's app system-prompt banner in every request. The proxy injects it automatically and never duplicates it. In practice it doesn't change much since your harness's own system prompt overrides it anyway
389
393
  - Max output is clamped to each model's real upstream cap (131K for every GLM model, 393K for DeepSeek). AutoClaw's runtime catalog overstates GLM-5.3's cap (307K), and asking the cloud for more than a model's real cap makes it **silently run a DeepSeek model instead and bill DeepSeek credits** — the proxy clamps so your `zai_glm-5.3` stays GLM-5.3
394
+ - Images pass through natively: pasted/attached images (`image_url` data URLs, Anthropic `image` blocks) reach vision-capable models (e.g. `zai_glm-5.3-flash`) as real image parts — the model sees them directly, no OCR bridge. Text-only models reject them upstream and the request falls back to the desktop agent, mirroring AutoClaw's own gating
390
395
  - The token file is watched for changes, so AutoClaw can rotate auth mid-session without a restart
391
396
  - AutoClaw's client identity (app version, platform, channel) loads dynamically from its runtime file, same as the model catalog, so an AutoClaw app update is picked up without editing or restarting the proxy
392
397
  - The fallback model catalog lives in `lib/fallback-models.json` (override with `FALLBACK_MODELS_PATH`). The built-in list is only a last resort when AutoClaw's runtime file is unreadable
package/anthropic.js CHANGED
@@ -113,11 +113,26 @@ function anthropicToOpenAI(body, modelId) {
113
113
  const toolResults = [];
114
114
  const toolUses = [];
115
115
  const textParts = [];
116
+ const imageParts = [];
117
+
118
+ // Anthropic image block → OpenAI image_url part (AutoClaw's cloud accepts
119
+ // native vision parts on the chat/completions wire; probe-verified 2026-09-04).
120
+ const toOpenAIImage = (block) => {
121
+ const src = block?.source;
122
+ if (src?.type === "base64" && typeof src.data === "string") {
123
+ return { type: "image_url", image_url: { url: `data:${src.media_type || "image/png"};base64,${src.data}` } };
124
+ }
125
+ if (src?.type === "url" && typeof src.url === "string") {
126
+ return { type: "image_url", image_url: { url: src.url } };
127
+ }
128
+ return null;
129
+ };
116
130
 
117
131
  for (const block of content) {
118
132
  if (block.type === "tool_result") toolResults.push(block);
119
133
  else if (block.type === "tool_use") toolUses.push(block);
120
134
  else if (block.type === "text") textParts.push(block.text);
135
+ else if (block.type === "image") { const p = toOpenAIImage(block); if (p) imageParts.push(p); }
121
136
  else if (block.type === "thinking") { /* skip */ }
122
137
  }
123
138
 
@@ -149,6 +164,13 @@ function anthropicToOpenAI(body, modelId) {
149
164
  };
150
165
  if (textParts.length > 0) msgObj.content = textParts.join("\n");
151
166
  messages.push(msgObj);
167
+ } else if (imageParts.length > 0 && msg.role !== "assistant") {
168
+ // Multimodal user turn: OpenAI array content with text + image parts.
169
+ const parts = [
170
+ ...textParts.map((t) => ({ type: "text", text: t })),
171
+ ...imageParts,
172
+ ];
173
+ messages.push({ role: msg.role, content: parts });
152
174
  } else if (textParts.length > 0) {
153
175
  messages.push({ role: msg.role, content: textParts.join("\n") });
154
176
  } else if (toolResults.length === 0 && toolUses.length === 0) {
package/bin/cli.js CHANGED
@@ -5,6 +5,7 @@ import fs from "fs";
5
5
  import { fileURLToPath, pathToFileURL } from "url";
6
6
  import { promptSelect, promptInput, promptNumber } from "../lib/prompts.js";
7
7
  import http from "http";
8
+ import { spawnSync } from "child_process";
8
9
  import {
9
10
  getModelCatalog, loadConfig, createTokenLayer,
10
11
  fetchRemoteModelConfig, annotateCreditTiers, resolveTierTargets,
@@ -15,7 +16,7 @@ import { DEFAULT_PORTS, DEFAULT_HOST, DEFAULT_PROXY_KEY, TEST_PROXY_PORT } from
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
17
  const args = process.argv.slice(2);
17
18
 
18
- const FLAGS = ["--anthropic", "--openai", "--port", "--host", "--key", "--rate-limit", "--max-messages", "--doctor", "--test-models", "--test", "--limit", "--help", "-h"];
19
+ const FLAGS = ["--anthropic", "--openai", "--port", "--host", "--key", "--rate-limit", "--max-messages", "--doctor", "--test-models", "--test", "--limit", "--stop", "--help", "-h"];
19
20
 
20
21
  // Current effective MAX_MESSAGES env value as a finite number, or Infinity.
21
22
  function effectiveMaxMessages() {
@@ -49,6 +50,7 @@ function showHelp() {
49
50
  If you have a compression system, leaving this unlimited is preferred.
50
51
  --doctor Live credit-tier scan of AutoClaw's catalog + routing map
51
52
  --test-models Test all configured models against upstream and show live health
53
+ --stop Kill any other running glmproxy instances and exit
52
54
  --limit Set or clear the max entity / messages limit (own menu item)
53
55
  --help, -h Show this help message
54
56
 
@@ -288,6 +290,70 @@ if (args.includes("--doctor")) {
288
290
  process.exit(0);
289
291
  }
290
292
 
293
+ // ─── --stop / stop: kill other running glmproxy instances ───────────────────
294
+ // Matches processes whose command line mentions glmproxy AND cli.js (covers
295
+ // npm-global installs and repo checkouts started via bin/cli.js) while never
296
+ // matching this very process. Dev entrypoints launched as `node openai.js`
297
+ // carry no glmproxy marker — stop those by hand.
298
+ function findGlmproxyInstances() {
299
+ const selfPid = process.pid;
300
+ const hits = [];
301
+ if (process.platform === "win32") {
302
+ const res = spawnSync("powershell", ["-NoProfile", "-Command",
303
+ `Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^node' -and $_.CommandLine -match 'glmproxy' -and $_.CommandLine -match 'cli\\.js' } | ForEach-Object { "$($_.ProcessId)|$($_.CommandLine)" }`,
304
+ ], { encoding: "utf8", windowsHide: true });
305
+ for (const line of (res.stdout || "").split(/\r?\n/)) {
306
+ const idx = line.indexOf("|");
307
+ if (idx <= 0) continue;
308
+ const pid = Number.parseInt(line.slice(0, idx), 10);
309
+ const cmd = line.slice(idx + 1);
310
+ if (Number.isInteger(pid) && pid > 0 && pid !== selfPid) hits.push({ pid, cmd });
311
+ }
312
+ } else {
313
+ const res = spawnSync("ps", ["-eo", "pid=,command="], { encoding: "utf8" });
314
+ for (const line of (res.stdout || "").split("\n")) {
315
+ const m = line.match(/^\s*(\d+)\s+(.+)$/);
316
+ if (!m) continue;
317
+ const pid = Number.parseInt(m[1], 10);
318
+ const cmd = m[2];
319
+ if (cmd.includes("glmproxy") && cmd.includes("cli.js") && pid !== selfPid) hits.push({ pid, cmd });
320
+ }
321
+ }
322
+ return hits;
323
+ }
324
+
325
+ function runStop() {
326
+ console.log(`\n Looking for other glmproxy instances…`);
327
+ let hits = [];
328
+ try { hits = findGlmproxyInstances(); }
329
+ catch (e) { console.log(` ${COLORS.RED}✗ scan failed: ${e.message}${COLORS.RESET}\n`); return; }
330
+
331
+ if (hits.length === 0) {
332
+ console.log(` ${COLORS.GRAY}No other instances running.${COLORS.RESET}\n`);
333
+ return;
334
+ }
335
+
336
+ let killed = 0, failed = 0;
337
+ for (const { pid, cmd } of hits) {
338
+ const short = cmd.length > 96 ? `${cmd.slice(0, 96)}…` : cmd;
339
+ try {
340
+ process.kill(pid, "SIGTERM");
341
+ console.log(` ${COLORS.GREEN}✓${COLORS.RESET} stopped PID ${pid} — ${short}`);
342
+ killed++;
343
+ } catch (e) {
344
+ console.log(` ${COLORS.RED}✗${COLORS.RESET} PID ${pid} — ${e.code || e.message} — ${short}`);
345
+ failed++;
346
+ }
347
+ }
348
+ console.log(`\n ${killed} instance(s) stopped${failed ? `, ${failed} failed` : ""}.\n`);
349
+ }
350
+
351
+ // --stop / stop: kill other running glmproxy instances, then exit.
352
+ if (args.includes("--stop") || args[0] === "stop") {
353
+ runStop();
354
+ process.exit(0);
355
+ }
356
+
291
357
  // --limit: set or clear the max entity / messages limit, then exit.
292
358
  if (args.includes("--limit")) {
293
359
  const limitIdx = args.indexOf("--limit");
package/lib/core.js CHANGED
@@ -637,9 +637,19 @@ export function isAuthorized(req, proxyKey) {
637
637
  }
638
638
 
639
639
  export function validateChatPayload(body, maxMessages = Infinity) {
640
+ const envInt = (name, fallback) => {
641
+ const raw = process.env[name];
642
+ const n = raw ? Number.parseInt(raw, 10) : NaN;
643
+ return Number.isFinite(n) && n > 0 ? n : fallback;
644
+ };
640
645
  const MAX_MESSAGES = (maxMessages && Number.isFinite(maxMessages)) ? maxMessages : Infinity;
641
- const MAX_MESSAGE_TEXT_BYTES = 256 * 1024;
642
- const MAX_TOTAL_MESSAGE_TEXT_BYTES = 1024 * 1024;
646
+ // Text caps guard against runaway harness spam. Image payloads (base64
647
+ // data URLs) are sized SEPARATELY — the cloud accepts native image_url
648
+ // parts and screenshots are legitimately large; counting base64 as text
649
+ // caused the 413 "an individual message is too large" bug (2026-09-04).
650
+ const MAX_MESSAGE_TEXT_BYTES = envInt("MAX_MESSAGE_TEXT_BYTES", 256 * 1024);
651
+ const MAX_TOTAL_MESSAGE_TEXT_BYTES = envInt("MAX_TOTAL_MESSAGE_TEXT_BYTES", 1024 * 1024);
652
+ const MAX_IMAGE_BYTES = envInt("MAX_IMAGE_BYTES", 20 * 1024 * 1024);
643
653
  const MAX_TOOLS = 64;
644
654
  const MAX_TOOL_BYTES = 128 * 1024;
645
655
  const MAX_TOTAL_TOOL_BYTES = 512 * 1024;
@@ -651,15 +661,39 @@ export function validateChatPayload(body, maxMessages = Infinity) {
651
661
  return { message: `messages must contain at most ${MAX_MESSAGES} entries`, statusCode: 413 };
652
662
  }
653
663
 
664
+ // Measure one message's content: text bytes (strings + text parts) and
665
+ // image bytes (decoded size of data: URLs in image_url parts).
666
+ const measure = (content) => {
667
+ if (typeof content === "string") return { textBytes: Buffer.byteLength(content), imageBytes: 0 };
668
+ let textBytes = 0, imageBytes = 0;
669
+ if (Array.isArray(content)) {
670
+ for (const part of content) {
671
+ if (typeof part === "string") { textBytes += Buffer.byteLength(part); continue; }
672
+ if (part?.type === "text") { textBytes += Buffer.byteLength(typeof part.text === "string" ? part.text : JSON.stringify(part.text ?? "")); continue; }
673
+ if (part?.type === "image_url") {
674
+ const url = part?.image_url?.url;
675
+ if (typeof url === "string" && url.startsWith("data:")) {
676
+ const comma = url.indexOf(",");
677
+ const b64Chars = comma >= 0 ? url.length - comma - 1 : 0;
678
+ imageBytes += Math.floor((b64Chars * 3) / 4); // base64 → bytes
679
+ }
680
+ // remote http(s) image references cost nothing locally
681
+ }
682
+ }
683
+ }
684
+ return { textBytes, imageBytes };
685
+ };
686
+
654
687
  let totalMessageBytes = 0;
655
688
  for (const message of body.messages) {
656
- const content = message?.content;
657
- const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
658
- const bytes = Buffer.byteLength(text);
659
- if (bytes > MAX_MESSAGE_TEXT_BYTES) {
689
+ const { textBytes, imageBytes } = measure(message?.content);
690
+ if (imageBytes > MAX_IMAGE_BYTES) {
691
+ return { message: `an image attachment is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB per image)`, statusCode: 413 };
692
+ }
693
+ if (textBytes > MAX_MESSAGE_TEXT_BYTES) {
660
694
  return { message: "an individual message is too large", statusCode: 413 };
661
695
  }
662
- totalMessageBytes += bytes;
696
+ totalMessageBytes += textBytes;
663
697
  if (totalMessageBytes > MAX_TOTAL_MESSAGE_TEXT_BYTES) {
664
698
  return { message: "combined message content is too large", statusCode: 413 };
665
699
  }
@@ -1185,11 +1219,24 @@ function injectSystemBanner(messages) {
1185
1219
  return list;
1186
1220
  }
1187
1221
  const sys = list[idx];
1188
- const text = typeof sys.content === "string"
1189
- ? sys.content
1190
- : Array.isArray(sys.content)
1191
- ? sys.content.map((p) => (typeof p === "string" ? p : p?.text || "")).join("\n")
1192
- : String(sys.content ?? "");
1222
+ if (typeof sys.content === "string") {
1223
+ if (!sys.content.includes(AUTOCLAW_SYSTEM_BANNER)) {
1224
+ list[idx] = { ...sys, content: AUTOCLAW_SYSTEM_BANNER + "\n\n" + sys.content };
1225
+ }
1226
+ return list;
1227
+ }
1228
+ if (Array.isArray(sys.content)) {
1229
+ // Multimodal system message — prepend the banner as a text part instead of
1230
+ // flattening, so image parts survive.
1231
+ const hasBanner = sys.content.some((p) =>
1232
+ typeof p === "string" ? p.includes(AUTOCLAW_SYSTEM_BANNER)
1233
+ : p?.type === "text" && typeof p?.text === "string" && p.text.includes(AUTOCLAW_SYSTEM_BANNER));
1234
+ if (!hasBanner) {
1235
+ list[idx] = { ...sys, content: [{ type: "text", text: AUTOCLAW_SYSTEM_BANNER }, ...sys.content] };
1236
+ }
1237
+ return list;
1238
+ }
1239
+ const text = String(sys.content ?? "");
1193
1240
  if (!text.includes(AUTOCLAW_SYSTEM_BANNER)) {
1194
1241
  list[idx] = { ...sys, content: AUTOCLAW_SYSTEM_BANNER + "\n\n" + text };
1195
1242
  }
@@ -1221,8 +1268,10 @@ function buildSanitizedBody(openAIBody, upstreamModelId) {
1221
1268
  export function stripProviderPrefix(modelId) { return String(modelId || "").replace(/^[a-z]+_/, ""); }
1222
1269
 
1223
1270
  // Trae and other clients send content as text-object arrays that Zhipu rejects
1224
- // (400/500) — flatten and normalize them before forwarding.
1225
- function normalizeClientMessages(body) {
1271
+ // (400/500) — flatten those to plain strings. Arrays carrying anything
1272
+ // non-text (image_url parts — AutoClaw's cloud accepts native vision parts,
1273
+ // probe-verified 2026-09-04) are preserved untouched so images reach the model.
1274
+ export function normalizeClientMessages(body) {
1226
1275
  return (body.messages || []).map(msg => {
1227
1276
  const newMsg = { ...msg };
1228
1277
 
@@ -1231,8 +1280,10 @@ function normalizeClientMessages(body) {
1231
1280
  newMsg.role = "system";
1232
1281
  }
1233
1282
 
1234
- // Flatten content array if it's all text blocks
1283
+ // Flatten content array only when it's all text blocks
1235
1284
  if (Array.isArray(newMsg.content)) {
1285
+ const hasNonText = newMsg.content.some((c) => typeof c !== "string" && c?.type !== "text");
1286
+ if (hasNonText) return newMsg; // multimodal content — keep the part shapes
1236
1287
  const textParts = [];
1237
1288
  for (const c of newMsg.content) {
1238
1289
  if (typeof c === "string") textParts.push(c);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glmproxy",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "description": "Local OpenAI- & Anthropic-compatible proxy for AutoClaw's Zhipu GLM-5.3 / GLM-5 / DeepSeek models",
5
5
  "main": "openai.js",
6
6
  "type": "module",