glm-mcp-claude 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp.json.example +14 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/agents/glm.md +45 -0
- package/assets/demo-glm-agent-umbrella.png +0 -0
- package/assets/demo-glm-subagent-summary.png +0 -0
- package/docs/AUTOSELECT.md +58 -0
- package/docs/RULES.md +105 -0
- package/docs/research/glm-capabilities.md +241 -0
- package/docs/research/glm-failure-modes-routing.md +287 -0
- package/docs/research/glm-misc-and-integration.md +180 -0
- package/docs/research/glm-peak-usage-and-cost.md +146 -0
- package/docs/research/glm-vs-opus-scenario-matrix.md +85 -0
- package/docs/research/glm-vs-opus-toolcalling.md +134 -0
- package/glm-mcp/.env.example +32 -0
- package/glm-mcp/package-lock.json +1180 -0
- package/glm-mcp/package.json +21 -0
- package/glm-mcp/src/glmAgent.js +227 -0
- package/glm-mcp/src/glmClient.js +136 -0
- package/glm-mcp/src/index.js +306 -0
- package/glm-mcp/src/loadEnv.js +24 -0
- package/glm-mcp/src/router.js +291 -0
- package/glm-mcp/src/smoke.js +42 -0
- package/hooks/glm_subagent_router.mjs +206 -0
- package/install.mjs +132 -0
- package/package.json +47 -0
- package/uninstall.mjs +47 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "glm-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server that delegates self-contained subtasks to the GLM (Zhipu/Z.ai) Anthropic-compatible API, so Claude Code can use GLM as a cheap, peak-aware subagent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"glm-mcp": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src/index.js",
|
|
12
|
+
"smoke": "node src/smoke.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
16
|
+
"zod": "^3.23.8"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// glmAgent.js
|
|
2
|
+
// Runs GLM as a real tool-using agent against the local filesystem, with oversight
|
|
3
|
+
// built in so Opus can regulate and see exactly what GLM did:
|
|
4
|
+
// - returns a unified DIFF of every change (isolated to the files GLM touched)
|
|
5
|
+
// - returns an ACTION LOG of every read/write/edit/bash
|
|
6
|
+
// - records a non-invasive git checkpoint + revert hint (when in a git repo)
|
|
7
|
+
// - supports dry_run: GLM proposes changes to an in-memory overlay and writes NOTHING,
|
|
8
|
+
// so Opus can approve the diff before a real apply pass.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, existsSync } from "node:fs";
|
|
11
|
+
import { resolve, dirname, relative, isAbsolute, join } from "node:path";
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { glmMessage } from "./glmClient.js";
|
|
14
|
+
|
|
15
|
+
const MAX_ITERS = parseInt(process.env.GLM_AGENT_MAX_ITERS || "30", 10);
|
|
16
|
+
const BASH_TIMEOUT = parseInt(process.env.GLM_AGENT_BASH_TIMEOUT_MS || "120000", 10);
|
|
17
|
+
const FILE_READ_CAP = 100000;
|
|
18
|
+
const BASH_OUT_CAP = 30000;
|
|
19
|
+
const DIFF_CAP = 20000;
|
|
20
|
+
const DIFF_LINE_CAP = 3000;
|
|
21
|
+
|
|
22
|
+
const TOOLS = [
|
|
23
|
+
{ name: "read_file", description: "Read a UTF-8 text file (path relative to working dir or absolute).",
|
|
24
|
+
input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } },
|
|
25
|
+
{ name: "write_file", description: "Create or overwrite a file. Creates parent dirs as needed.",
|
|
26
|
+
input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
|
|
27
|
+
{ name: "edit_file", description: "Replace an exact substring in a file. old_string must appear exactly once.",
|
|
28
|
+
input_schema: { type: "object", properties: { path: { type: "string" }, old_string: { type: "string" }, new_string: { type: "string" } }, required: ["path", "old_string", "new_string"] } },
|
|
29
|
+
{ name: "list_dir", description: "List entries in a directory (relative or absolute). Defaults to '.'.",
|
|
30
|
+
input_schema: { type: "object", properties: { path: { type: "string" } } } },
|
|
31
|
+
{ name: "run_bash", description: "Run a shell command in the working dir; returns stdout+stderr. Disabled in dry_run.",
|
|
32
|
+
input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function safeResolve(root, p) {
|
|
36
|
+
return isAbsolute(p || "") ? resolve(p) : resolve(root, p || ".");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function unifiedDiff(oldStr, newStr, path) {
|
|
40
|
+
if (oldStr === newStr) return "";
|
|
41
|
+
const A = oldStr.length ? oldStr.split("\n") : [];
|
|
42
|
+
const B = newStr.length ? newStr.split("\n") : [];
|
|
43
|
+
if (A.length > DIFF_LINE_CAP || B.length > DIFF_LINE_CAP) {
|
|
44
|
+
return `--- ${path}\n+++ ${path}\n@@ large file: ${A.length} -> ${B.length} lines (detailed diff omitted) @@\n`;
|
|
45
|
+
}
|
|
46
|
+
const n = A.length, m = B.length;
|
|
47
|
+
const dp = [];
|
|
48
|
+
for (let i = 0; i <= n; i++) dp.push(new Int32Array(m + 1));
|
|
49
|
+
for (let i = n - 1; i >= 0; i--)
|
|
50
|
+
for (let j = m - 1; j >= 0; j--)
|
|
51
|
+
dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
52
|
+
const rows = [];
|
|
53
|
+
let i = 0, j = 0;
|
|
54
|
+
while (i < n && j < m) {
|
|
55
|
+
if (A[i] === B[j]) { rows.push([" ", A[i]]); i++; j++; }
|
|
56
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) { rows.push(["-", A[i]]); i++; }
|
|
57
|
+
else { rows.push(["+", B[j]]); j++; }
|
|
58
|
+
}
|
|
59
|
+
while (i < n) rows.push(["-", A[i++]]);
|
|
60
|
+
while (j < m) rows.push(["+", B[j++]]);
|
|
61
|
+
const out = [`--- ${path}`, `+++ ${path}`];
|
|
62
|
+
let ctx = [];
|
|
63
|
+
const flush = () => {
|
|
64
|
+
if (ctx.length > 6) {
|
|
65
|
+
out.push(" " + ctx[0], " " + ctx[1], `@@ ... ${ctx.length - 4} unchanged ... @@`, " " + ctx[ctx.length - 2], " " + ctx[ctx.length - 1]);
|
|
66
|
+
} else for (const c of ctx) out.push(" " + c);
|
|
67
|
+
ctx = [];
|
|
68
|
+
};
|
|
69
|
+
for (const [t, l] of rows) {
|
|
70
|
+
if (t === " ") ctx.push(l);
|
|
71
|
+
else { flush(); out.push(t + l); }
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
return out.join("\n") + "\n";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function gitCheckpoint(root) {
|
|
78
|
+
try {
|
|
79
|
+
execSync(`git -C "${root}" rev-parse --is-inside-work-tree`, { stdio: "ignore" });
|
|
80
|
+
} catch {
|
|
81
|
+
return { isRepo: false, baseline: null, revertHint: "Not a git repo — review the diff below; revert manually if needed." };
|
|
82
|
+
}
|
|
83
|
+
let baseline = "";
|
|
84
|
+
try { baseline = execSync(`git -C "${root}" stash create`, { encoding: "utf8" }).trim(); } catch {}
|
|
85
|
+
if (!baseline) {
|
|
86
|
+
try { baseline = execSync(`git -C "${root}" rev-parse HEAD`, { encoding: "utf8" }).trim(); } catch {}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
isRepo: true,
|
|
90
|
+
baseline,
|
|
91
|
+
revertHint: baseline
|
|
92
|
+
? `To revert GLM's changes: \`git -C "${root}" checkout ${baseline} -- .\` then \`git -C "${root}" clean -fd\` to drop any new files. (Baseline is a non-invasive snapshot; your working tree was not modified by the checkpoint.)`
|
|
93
|
+
: "Git repo detected but baseline capture failed; use `git diff` / `git stash` to review and revert.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function runGlmAgent({ model, task, context, workdir, maxTokens = 32768, thinking = false, dryRun = false }) {
|
|
98
|
+
const root = workdir && workdir.trim() ? resolve(workdir) : process.cwd();
|
|
99
|
+
const log = [];
|
|
100
|
+
const originals = new Map(); // abs -> pre-run disk content (string|null if didn't exist)
|
|
101
|
+
const overlay = new Map(); // dry_run staging: abs -> proposed content
|
|
102
|
+
const checkpoint = dryRun ? { isRepo: false, baseline: null, revertHint: "dry_run: nothing written." } : gitCheckpoint(root);
|
|
103
|
+
|
|
104
|
+
const recordOriginal = (abs) => {
|
|
105
|
+
if (!originals.has(abs)) {
|
|
106
|
+
try { originals.set(abs, readFileSync(abs, "utf8")); } catch { originals.set(abs, null); }
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const readCurrent = (abs) => {
|
|
110
|
+
if (dryRun && overlay.has(abs)) return overlay.get(abs);
|
|
111
|
+
return readFileSync(abs, "utf8");
|
|
112
|
+
};
|
|
113
|
+
const writeCurrent = (abs, content) => {
|
|
114
|
+
if (dryRun) { overlay.set(abs, content); return; }
|
|
115
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
116
|
+
writeFileSync(abs, content, "utf8");
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function runTool(name, input) {
|
|
120
|
+
try {
|
|
121
|
+
switch (name) {
|
|
122
|
+
case "read_file": {
|
|
123
|
+
const abs = safeResolve(root, input.path);
|
|
124
|
+
const txt = readCurrent(abs);
|
|
125
|
+
log.push(`read ${relative(root, abs) || input.path}`);
|
|
126
|
+
return txt.length > FILE_READ_CAP ? txt.slice(0, FILE_READ_CAP) + "\n…[truncated]" : txt;
|
|
127
|
+
}
|
|
128
|
+
case "write_file": {
|
|
129
|
+
const abs = safeResolve(root, input.path);
|
|
130
|
+
recordOriginal(abs);
|
|
131
|
+
writeCurrent(abs, input.content ?? "");
|
|
132
|
+
log.push(`${dryRun ? "[dry] " : ""}write ${relative(root, abs) || input.path}`);
|
|
133
|
+
return `${dryRun ? "(dry_run, staged) " : ""}Wrote ${(input.content ?? "").length} chars to ${input.path}.`;
|
|
134
|
+
}
|
|
135
|
+
case "edit_file": {
|
|
136
|
+
const abs = safeResolve(root, input.path);
|
|
137
|
+
let cur;
|
|
138
|
+
try { cur = readCurrent(abs); } catch { return `ERROR: cannot read ${input.path} to edit.`; }
|
|
139
|
+
const occ = cur.split(input.old_string).length - 1;
|
|
140
|
+
if (occ === 0) return `ERROR: old_string not found in ${input.path}. Read the file and retry with an exact match.`;
|
|
141
|
+
if (occ > 1) return `ERROR: old_string appears ${occ} times in ${input.path}; add surrounding lines to make it unique.`;
|
|
142
|
+
recordOriginal(abs);
|
|
143
|
+
writeCurrent(abs, cur.replace(input.old_string, input.new_string));
|
|
144
|
+
log.push(`${dryRun ? "[dry] " : ""}edit ${relative(root, abs) || input.path}`);
|
|
145
|
+
return `${dryRun ? "(dry_run, staged) " : ""}Edited ${input.path} (1 replacement).`;
|
|
146
|
+
}
|
|
147
|
+
case "list_dir": {
|
|
148
|
+
const abs = safeResolve(root, input.path || ".");
|
|
149
|
+
const entries = readdirSync(abs).map((e) => {
|
|
150
|
+
try { return statSync(join(abs, e)).isDirectory() ? e + "/" : e; } catch { return e; }
|
|
151
|
+
});
|
|
152
|
+
return entries.join("\n") || "(empty)";
|
|
153
|
+
}
|
|
154
|
+
case "run_bash": {
|
|
155
|
+
if (dryRun) return `[dry_run] bash disabled. Use read_file/list_dir to inspect. (cmd was: ${input.command})`;
|
|
156
|
+
log.push(`bash: ${String(input.command).slice(0, 80)}`);
|
|
157
|
+
let out;
|
|
158
|
+
try {
|
|
159
|
+
out = execSync(input.command, { cwd: root, timeout: BASH_TIMEOUT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], shell: true });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
out = `${e.stdout || ""}${e.stderr || ""}\n[exit ${e.status ?? "?"}] ${e.message}`;
|
|
162
|
+
}
|
|
163
|
+
return (out || "(no output)").slice(0, BASH_OUT_CAP);
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
return `ERROR: unknown tool ${name}`;
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return `ERROR (${name}): ${e.message}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const system =
|
|
174
|
+
`You are a capable coding agent operating directly on a local repository.\n` +
|
|
175
|
+
`Working directory: ${root}\n` +
|
|
176
|
+
(dryRun
|
|
177
|
+
? `DRY RUN: your write_file/edit_file are STAGED, not written to disk, and run_bash is disabled. ` +
|
|
178
|
+
`Produce the complete set of intended changes, then stop and summarize them.\n`
|
|
179
|
+
: `Make changes yourself with the tools; run tests/builds to verify. `) +
|
|
180
|
+
`Tools: read_file, write_file, edit_file, list_dir, run_bash. When fully done, stop calling ` +
|
|
181
|
+
`tools and reply with a concise summary of what you changed and how you verified it.`;
|
|
182
|
+
|
|
183
|
+
const messages = [{ role: "user", content: context ? `${task}\n\n--- CONTEXT ---\n${context}` : task }];
|
|
184
|
+
let lastText = "";
|
|
185
|
+
const totalUsage = { input_tokens: 0, output_tokens: 0 };
|
|
186
|
+
let iters = 0;
|
|
187
|
+
|
|
188
|
+
for (; iters < MAX_ITERS; iters++) {
|
|
189
|
+
const { raw, usage } = await glmMessage({ model, system, messages, maxTokens, thinking, tools: TOOLS });
|
|
190
|
+
totalUsage.input_tokens += usage.input_tokens || 0;
|
|
191
|
+
totalUsage.output_tokens += usage.output_tokens || 0;
|
|
192
|
+
const content = raw.content || [];
|
|
193
|
+
const textParts = content.filter((b) => b.type === "text").map((b) => b.text);
|
|
194
|
+
if (textParts.length) lastText = textParts.join("\n").trim();
|
|
195
|
+
const toolUses = content.filter((b) => b.type === "tool_use");
|
|
196
|
+
if (raw.stop_reason !== "tool_use" || toolUses.length === 0) break;
|
|
197
|
+
messages.push({ role: "assistant", content });
|
|
198
|
+
messages.push({
|
|
199
|
+
role: "user",
|
|
200
|
+
content: toolUses.map((tu) => ({ type: "tool_result", tool_use_id: tu.id, content: String(runTool(tu.name, tu.input || {})) })),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Build the diff from captured originals.
|
|
205
|
+
let diff = "";
|
|
206
|
+
for (const [abs, orig] of originals) {
|
|
207
|
+
let now;
|
|
208
|
+
if (dryRun) now = overlay.has(abs) ? overlay.get(abs) : orig ?? "";
|
|
209
|
+
else now = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
210
|
+
const d = unifiedDiff(orig ?? "", now ?? "", relative(root, abs) || abs);
|
|
211
|
+
if (d) diff += (orig == null ? `(new file)\n` : "") + d + "\n";
|
|
212
|
+
}
|
|
213
|
+
if (diff.length > DIFF_CAP) diff = diff.slice(0, DIFF_CAP) + "\n…[diff truncated]";
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
text: lastText || "(GLM finished without a summary)",
|
|
217
|
+
actions: log,
|
|
218
|
+
iters,
|
|
219
|
+
hitCap: iters >= MAX_ITERS,
|
|
220
|
+
usage: totalUsage,
|
|
221
|
+
root,
|
|
222
|
+
dryRun,
|
|
223
|
+
diff: diff.trim(),
|
|
224
|
+
changedFiles: [...originals.keys()].map((a) => relative(root, a) || a),
|
|
225
|
+
git: checkpoint,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// glmClient.js
|
|
2
|
+
// Thin client over the GLM Anthropic-compatible endpoint with two things that
|
|
3
|
+
// matter for GLM specifically:
|
|
4
|
+
// 1. A concurrency gate (GLM caps in-flight requests at ~1 even on paid tiers).
|
|
5
|
+
// 2. Exponential backoff on 429 / "concurrency" / 5xx errors.
|
|
6
|
+
|
|
7
|
+
const BASE_URL = (process.env.GLM_BASE_URL || "https://api.z.ai/api/anthropic").replace(/\/$/, "");
|
|
8
|
+
const API_KEY = process.env.GLM_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "";
|
|
9
|
+
const MAX_CONCURRENT = Math.max(1, parseInt(process.env.GLM_MAX_CONCURRENT || "1", 10));
|
|
10
|
+
const MAX_RETRIES = Math.max(0, parseInt(process.env.GLM_MAX_RETRIES || "4", 10));
|
|
11
|
+
const TIMEOUT_MS = parseInt(process.env.GLM_TIMEOUT_MS || "300000", 10);
|
|
12
|
+
|
|
13
|
+
// ---- tiny semaphore so we never exceed GLM's concurrency cap ----
|
|
14
|
+
let active = 0;
|
|
15
|
+
const waiters = [];
|
|
16
|
+
async function acquire() {
|
|
17
|
+
if (active < MAX_CONCURRENT) {
|
|
18
|
+
active++;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await new Promise((res) => waiters.push(res));
|
|
22
|
+
active++;
|
|
23
|
+
}
|
|
24
|
+
function release() {
|
|
25
|
+
active--;
|
|
26
|
+
const next = waiters.shift();
|
|
27
|
+
if (next) next();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
function isRetryable(status, bodyText) {
|
|
33
|
+
if (status === 429 || status === 503 || status === 502 || status === 500) return true;
|
|
34
|
+
if (bodyText && /concurren|rate.?limit|too\s+much/i.test(bodyText)) return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Call GLM's /v1/messages (Anthropic Messages API shape).
|
|
40
|
+
* @param {object} p
|
|
41
|
+
* @param {string} p.model
|
|
42
|
+
* @param {Array} p.messages Anthropic-style messages
|
|
43
|
+
* @param {string} [p.system]
|
|
44
|
+
* @param {number} [p.maxTokens]
|
|
45
|
+
* @param {boolean}[p.thinking]
|
|
46
|
+
* @returns {Promise<{text:string, usage:object, raw:object}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function glmMessage({ model, messages, system, maxTokens = 32768, thinking = false, tools }) {
|
|
49
|
+
if (!API_KEY) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"GLM_API_KEY (or ANTHROPIC_AUTH_TOKEN) is not set. Add it to glm-mcp/.env or the MCP server env in .mcp.json."
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const body = {
|
|
56
|
+
model,
|
|
57
|
+
max_tokens: maxTokens,
|
|
58
|
+
messages,
|
|
59
|
+
...(system ? { system } : {}),
|
|
60
|
+
...(tools && tools.length ? { tools } : {}),
|
|
61
|
+
...(thinking ? { thinking: { type: "enabled", budget_tokens: Math.min(maxTokens, 8000) } } : {}),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await acquire();
|
|
65
|
+
try {
|
|
66
|
+
let attempt = 0;
|
|
67
|
+
// eslint-disable-next-line no-constant-condition
|
|
68
|
+
while (true) {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
71
|
+
let res, txt;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetch(`${BASE_URL}/v1/messages`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
"content-type": "application/json",
|
|
77
|
+
authorization: `Bearer ${API_KEY}`,
|
|
78
|
+
"x-api-key": API_KEY,
|
|
79
|
+
"anthropic-version": "2023-06-01",
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
txt = await res.text();
|
|
85
|
+
} catch (e) {
|
|
86
|
+
clearTimeout(t);
|
|
87
|
+
if (attempt < MAX_RETRIES) {
|
|
88
|
+
await sleep(backoff(attempt++));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`GLM request failed (network/timeout): ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
clearTimeout(t);
|
|
94
|
+
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
if (isRetryable(res.status, txt) && attempt < MAX_RETRIES) {
|
|
97
|
+
await sleep(backoff(attempt++, txt));
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`GLM API error ${res.status}: ${truncate(txt, 800)}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let json;
|
|
104
|
+
try {
|
|
105
|
+
json = JSON.parse(txt);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`GLM returned non-JSON response: ${truncate(txt, 800)}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const text = (json.content || [])
|
|
111
|
+
.filter((b) => b.type === "text")
|
|
112
|
+
.map((b) => b.text)
|
|
113
|
+
.join("\n")
|
|
114
|
+
.trim();
|
|
115
|
+
|
|
116
|
+
return { text, usage: json.usage || {}, raw: json };
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
release();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function backoff(attempt, bodyText) {
|
|
124
|
+
// Honor concurrency errors with a slightly longer floor.
|
|
125
|
+
const concurrency = bodyText && /concurren|too\s+much/i.test(bodyText);
|
|
126
|
+
const base = concurrency ? 2000 : 800;
|
|
127
|
+
const jitter = Math.random() * 400;
|
|
128
|
+
return Math.min(base * 2 ** attempt + jitter, 30000);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function truncate(s, n) {
|
|
132
|
+
if (!s) return "";
|
|
133
|
+
return s.length > n ? s.slice(0, n) + "…[truncated]" : s;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const config = { BASE_URL, MAX_CONCURRENT, MAX_RETRIES, hasKey: Boolean(API_KEY) };
|