s-app-scoder-agent 0.6.0 → 0.8.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +600 -468
- package/dist/agent.js +841 -197
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +423 -0
- package/dist/index.js +129 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +103 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +32 -2
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +505 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +146 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +99 -0
- package/dist/tui/state.js +192 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
package/dist/index.js
CHANGED
|
@@ -8,56 +8,65 @@ import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
|
|
|
8
8
|
import { JsonlLogger } from "./logger.js";
|
|
9
9
|
import { collectUsage, formatCostReport } from "./cost.js";
|
|
10
10
|
import { getVersion as getPackageVersion } from "./version.js";
|
|
11
|
-
import {
|
|
11
|
+
import { dispatchCommand } from "./tui/command-registry.js";
|
|
12
12
|
import * as ui from "./ui.js";
|
|
13
13
|
// Single source of truth: read version from package.json (see src/version.ts).
|
|
14
14
|
const VERSION = getPackageVersion();
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
scoder
|
|
25
|
-
scoder
|
|
26
|
-
|
|
27
|
-
scoder
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
--
|
|
35
|
-
|
|
36
|
-
--
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
--
|
|
44
|
-
|
|
45
|
-
--
|
|
46
|
-
--
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
15
|
+
// Safety net: a stray promise rejection (e.g. a provider socket aborting after
|
|
16
|
+
// its consumer already moved on) must log — never hard-crash the agent.
|
|
17
|
+
process.on("unhandledRejection", (reason) => {
|
|
18
|
+
const msg = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
|
|
19
|
+
ui.warn(`background error (ignored): ${msg.slice(0, 200)}`);
|
|
20
|
+
});
|
|
21
|
+
const HELP = `scoder — autonomous coding agent CLI
|
|
22
|
+
|
|
23
|
+
USAGE
|
|
24
|
+
scoder [options] "<task>" Run a task autonomously until the build passes
|
|
25
|
+
scoder Start interactive session (chat + tools)
|
|
26
|
+
scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
|
|
27
|
+
scoder init First-run wizard: writes .scoderrc.json + tests the connection
|
|
28
|
+
scoder list List saved sessions for this directory
|
|
29
|
+
scoder models [provider] [--refresh]
|
|
30
|
+
Model catalog (context window, pricing) —
|
|
31
|
+
synced from models.dev, cached 24h
|
|
32
|
+
scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
|
|
33
|
+
scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
|
|
34
|
+
scoder cost [--session <id>] [--last <n>]
|
|
35
|
+
Token usage & estimated cost from JSONL logs
|
|
36
|
+
scoder serve [--port <n>] [--host <addr>]
|
|
37
|
+
Run as an HTTP server: POST tasks, stream
|
|
38
|
+
progress via Server-Sent Events (see below)
|
|
39
|
+
scoder --version | --help
|
|
40
|
+
|
|
41
|
+
OPTIONS
|
|
42
|
+
-m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
|
|
43
|
+
--base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
|
|
44
|
+
-k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
|
|
45
|
+
--provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
|
|
46
|
+
-B, --build <cmd> Build command = stop condition (auto-detected if omitted)
|
|
47
|
+
-d, --dir <path> Working directory (default: current directory)
|
|
48
|
+
-s, --max-steps <n> Max autonomous loop iterations (default 40)
|
|
49
|
+
-y, --yes Non-interactive mode (auto-approves commands)
|
|
50
|
+
--permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
|
|
51
|
+
--no-save Disable session auto-save to .scoder/sessions/
|
|
52
|
+
--log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
|
|
53
|
+
(or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
|
|
54
|
+
--session <id> (cost) Aggregate usage of one specific session
|
|
55
|
+
--last <n> (cost) Aggregate usage of the n most recent sessions
|
|
56
|
+
-h, --help Show this help
|
|
57
|
+
-v, --version Show version
|
|
58
|
+
|
|
59
|
+
CONFIG FILE
|
|
60
|
+
.scoderrc.json in the working directory (see .scoderrc.example.json):
|
|
61
|
+
{ "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
|
|
62
|
+
"permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
|
|
63
|
+
|
|
64
|
+
EXAMPLES
|
|
65
|
+
scoder "add a divide function to src/math.ts with tests"
|
|
66
|
+
scoder --build "make test" "fix the failing test in utils"
|
|
67
|
+
scoder --permission ask "npm install express and wire up a route"
|
|
68
|
+
scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
|
|
69
|
+
scoder resume last "now add unit tests"
|
|
61
70
|
`;
|
|
62
71
|
function parseArgs(argv) {
|
|
63
72
|
const args = { task: null, yes: false, help: false, version: false };
|
|
@@ -181,6 +190,13 @@ function parseArgs(argv) {
|
|
|
181
190
|
args.subcommand = "tui";
|
|
182
191
|
args.task = rest.length > 1 ? rest.slice(1).join(" ") : null;
|
|
183
192
|
}
|
|
193
|
+
else if (rest[0] === "models") {
|
|
194
|
+
// scoder models [provider] [--refresh] — models.dev catalog (I-02)
|
|
195
|
+
args.subcommand = "models";
|
|
196
|
+
args.modelsProvider = rest[1] && !rest[1].startsWith("--") ? rest[1] : undefined;
|
|
197
|
+
args.modelsRefresh = rest.includes("--refresh");
|
|
198
|
+
args.task = null;
|
|
199
|
+
}
|
|
184
200
|
else if (rest[0] === "init") {
|
|
185
201
|
args.subcommand = "init";
|
|
186
202
|
args.task = null;
|
|
@@ -204,6 +220,9 @@ async function runOnce(cfg, task) {
|
|
|
204
220
|
ui.error(err.message);
|
|
205
221
|
return 2;
|
|
206
222
|
}
|
|
223
|
+
finally {
|
|
224
|
+
agent.dispose();
|
|
225
|
+
}
|
|
207
226
|
}
|
|
208
227
|
/**
|
|
209
228
|
* `scoder init` — first-run wizard: asks for the essentials, writes
|
|
@@ -227,9 +246,19 @@ export async function runInit() {
|
|
|
227
246
|
}
|
|
228
247
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
229
248
|
const ask = (q, def) => new Promise((res) => rl.question(`${q}${def ? ` [${def}]` : ""}: `, (a) => res(a.trim() || def)));
|
|
230
|
-
|
|
249
|
+
// I-03: probe local engines first — suggest when found (no auto-switch when
|
|
250
|
+
// a cloud key exists; user always decides).
|
|
251
|
+
ui.info("probing local engines (ollama :11434, lmstudio :1234, vllm :8000)…");
|
|
252
|
+
const { discoverLocalEngines } = await import("./provider-registry.js");
|
|
253
|
+
const locals = await discoverLocalEngines();
|
|
254
|
+
for (const e of locals) {
|
|
255
|
+
ui.info(` found ${e.provider.id} at ${e.provider.baseUrl} — ${e.models.length} model(s), e.g. ${e.models[0]}`);
|
|
256
|
+
}
|
|
257
|
+
const suggestBase = locals[0]?.provider.baseUrl ?? "https://api.openai.com/v1";
|
|
258
|
+
const suggestModel = locals[0]?.models[0] ?? "gpt-4o-mini";
|
|
259
|
+
const baseUrl = await ask("API base URL (OpenAI-compatible)", suggestBase);
|
|
231
260
|
const apiKey = await ask("API key (SCODER_API_KEY/OPENAI_API_KEY also work at runtime)", "");
|
|
232
|
-
const model = await ask("Model",
|
|
261
|
+
const model = await ask("Model", suggestModel);
|
|
233
262
|
const buildCmd = await ask("Build command (empty = auto-detect)", "");
|
|
234
263
|
const permission = (await ask("Permission mode (auto | ask | readonly)", "auto")).toLowerCase();
|
|
235
264
|
rl.close();
|
|
@@ -290,35 +319,37 @@ async function repl(cfg) {
|
|
|
290
319
|
continue;
|
|
291
320
|
if (input === "exit" || input === "quit")
|
|
292
321
|
break;
|
|
293
|
-
// Slash commands —
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
console.log(formatSlashHelp("scoder"));
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
if (slash.cmd === "/clear") {
|
|
322
|
+
// Slash commands — registry dùng chung với TUI (T1). Command riêng của
|
|
323
|
+
// TUI (/review /undo /theme /diff turn-scope) do TUI tự xử lý trước.
|
|
324
|
+
const host = {
|
|
325
|
+
agent: () => agent,
|
|
326
|
+
config: cfg,
|
|
327
|
+
replaceAgent: (next) => {
|
|
303
328
|
agent.abort();
|
|
304
|
-
agent
|
|
329
|
+
agent.dispose();
|
|
330
|
+
agent = next ?? new Agent(cfg);
|
|
305
331
|
first = true;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
332
|
+
},
|
|
333
|
+
resumeAndRun: async (s) => {
|
|
334
|
+
agent.abort();
|
|
335
|
+
agent.dispose();
|
|
336
|
+
agent = new Agent(cfg);
|
|
337
|
+
first = false;
|
|
338
|
+
await agent.resume(s);
|
|
339
|
+
return { success: true, steps: 0, buildCmd: cfg.buildCmd, summary: "resumed" };
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
|
|
343
|
+
if (level === "error")
|
|
344
|
+
ui.error(text);
|
|
345
|
+
else
|
|
346
|
+
console.log(text);
|
|
347
|
+
});
|
|
348
|
+
if (handled) {
|
|
349
|
+
if (outcome?.quit)
|
|
350
|
+
break;
|
|
351
|
+
if (outcome?.notice)
|
|
352
|
+
ui.info(outcome.notice);
|
|
322
353
|
continue;
|
|
323
354
|
}
|
|
324
355
|
try {
|
|
@@ -331,6 +362,7 @@ async function repl(cfg) {
|
|
|
331
362
|
ui.error(err.message);
|
|
332
363
|
}
|
|
333
364
|
}
|
|
365
|
+
agent.dispose();
|
|
334
366
|
ui.info("bye");
|
|
335
367
|
process.exit(0);
|
|
336
368
|
}
|
|
@@ -393,9 +425,11 @@ async function main() {
|
|
|
393
425
|
});
|
|
394
426
|
try {
|
|
395
427
|
const result = await agent.resume(s, args.task ?? undefined);
|
|
428
|
+
agent.dispose();
|
|
396
429
|
process.exit(result.success ? 0 : 1);
|
|
397
430
|
}
|
|
398
431
|
catch (err) {
|
|
432
|
+
agent.dispose();
|
|
399
433
|
ui.error(err.message);
|
|
400
434
|
process.exit(2);
|
|
401
435
|
}
|
|
@@ -433,6 +467,25 @@ async function main() {
|
|
|
433
467
|
console.log(formatCostReport(sessionCosts));
|
|
434
468
|
return;
|
|
435
469
|
}
|
|
470
|
+
if (args.subcommand === "models") {
|
|
471
|
+
const { loadCatalog, cacheAgeHours } = await import("./model-catalog.js");
|
|
472
|
+
const filter = args.modelsProvider ?? null;
|
|
473
|
+
const { models, source } = await loadCatalog(args.modelsRefresh === true);
|
|
474
|
+
const age = cacheAgeHours();
|
|
475
|
+
const ageStr = source === "fallback" ? "offline fallback snapshot" : age < 0 ? "?" : `${age.toFixed(1)}h ago`;
|
|
476
|
+
console.log(`models catalog — source: ${source} (updated ${ageStr}), ${Object.keys(models).length} entries\n`);
|
|
477
|
+
const rows = Object.values(models)
|
|
478
|
+
.filter((m) => !filter || m.provider === filter || m.id.includes(filter))
|
|
479
|
+
.sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id))
|
|
480
|
+
.slice(0, 60);
|
|
481
|
+
for (const m of rows) {
|
|
482
|
+
const flags = [m.supportCache ? "cache" : null, m.supportReasoning ? "reasoning" : null].filter(Boolean).join(",");
|
|
483
|
+
console.log(` ${m.provider}/${m.id} ctx=${(m.contextWindow / 1000).toFixed(0)}k${m.pricing ? ` $${m.pricing.input}/$${m.pricing.output} per 1M` : ""}${flags ? ` [${flags}]` : ""}`);
|
|
484
|
+
}
|
|
485
|
+
if (rows.length === 0)
|
|
486
|
+
console.log(` (no models match "${filter}")`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
436
489
|
if (args.subcommand === "tui") {
|
|
437
490
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
438
491
|
ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M2: JSON-Schema subset validator — không dependency. Hỗ trợ: type,
|
|
3
|
+
* required, properties, enum, items, additionalProperties=false khi khai báo.
|
|
4
|
+
* Trả về mảng lỗi (rỗng = hợp lệ). Dùng để validate MCP tool args trước
|
|
5
|
+
* tools/call — lỗi local, không gọi server.
|
|
6
|
+
*/
|
|
7
|
+
const TYPE_CHECKS = {
|
|
8
|
+
string: (v) => typeof v === "string",
|
|
9
|
+
number: (v) => typeof v === "number" && Number.isFinite(v),
|
|
10
|
+
integer: (v) => typeof v === "number" && Number.isInteger(v),
|
|
11
|
+
boolean: (v) => typeof v === "boolean",
|
|
12
|
+
object: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
|
|
13
|
+
array: (v) => Array.isArray(v),
|
|
14
|
+
null: (v) => v === null,
|
|
15
|
+
};
|
|
16
|
+
/** Validate value theo schema subset. path dùng cho error message. */
|
|
17
|
+
export function validateSchema(value, schema, path = "args") {
|
|
18
|
+
if (!schema || typeof schema !== "object")
|
|
19
|
+
return [];
|
|
20
|
+
const errors = [];
|
|
21
|
+
const type = schema.type;
|
|
22
|
+
if (type !== undefined) {
|
|
23
|
+
const types = Array.isArray(type) ? type : [type];
|
|
24
|
+
const ok = types.some((t) => TYPE_CHECKS[t]?.(value));
|
|
25
|
+
if (!ok) {
|
|
26
|
+
errors.push(`${path}: expected ${types.join("|")}, got ${jsonType(value)}`);
|
|
27
|
+
return errors; // sai type — không kiểm tra sâu hơn
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((e) => e === value)) {
|
|
31
|
+
errors.push(`${path}: must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
if (TYPE_CHECKS.object(value)) {
|
|
34
|
+
const obj = value;
|
|
35
|
+
const props = (schema.properties ?? {});
|
|
36
|
+
for (const req of schema.required ?? []) {
|
|
37
|
+
if (!(req in obj))
|
|
38
|
+
errors.push(`${path}: missing required property "${req}"`);
|
|
39
|
+
}
|
|
40
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
41
|
+
if (props[k])
|
|
42
|
+
errors.push(...validateSchema(v, props[k], `${path}.${k}`));
|
|
43
|
+
else if (schema.additionalProperties === false)
|
|
44
|
+
errors.push(`${path}: unexpected property "${k}"`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(value) && schema.items) {
|
|
48
|
+
value.forEach((item, i) => errors.push(...validateSchema(item, schema.items, `${path}[${i}]`)));
|
|
49
|
+
}
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
function jsonType(v) {
|
|
53
|
+
if (v === null)
|
|
54
|
+
return "null";
|
|
55
|
+
if (Array.isArray(v))
|
|
56
|
+
return "array";
|
|
57
|
+
return typeof v;
|
|
58
|
+
}
|
package/dist/llm.js
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
* and optional SSE streaming.
|
|
4
4
|
*/
|
|
5
5
|
import { withRetry } from "./retry.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Secrets redaction (H-13) — shared by LLM error bodies, MCP tool output,
|
|
8
|
+
// compaction summaries and snapshot logs. Never log raw credentials.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const REDACTION_PATTERNS = [
|
|
11
|
+
[/Bearer\s+[^\s,&\]]+/gi, "Bearer ***REDACTED***"],
|
|
12
|
+
[/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***"],
|
|
13
|
+
[/sk-ant-[^\s,&\]]+/gi, "sk-ant-***REDACTED***"],
|
|
14
|
+
[/ghp_[A-Za-z0-9]{20,}/g, "ghp_***REDACTED***"],
|
|
15
|
+
[/github_pat_[A-Za-z0-9_]{20,}/g, "github_pat_***REDACTED***"],
|
|
16
|
+
[/xox[bap]-[A-Za-z0-9-]{10,}/g, "xox***REDACTED***"],
|
|
17
|
+
[/AKIA[0-9A-Z]{16}/g, "AKIA***REDACTED***"],
|
|
18
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, "***PRIVATE-KEY***"],
|
|
19
|
+
[/\b(api[_-]?key|token|secret|password)["'\s:=]+[^\s"'&]{8,}/gi, "$1=***REDACTED***"],
|
|
20
|
+
];
|
|
21
|
+
/** Redact known secret shapes from arbitrary tool/LLM/log output. */
|
|
22
|
+
export function redactSecrets(text) {
|
|
23
|
+
let out = text;
|
|
24
|
+
for (const [rx, replacement] of REDACTION_PATTERNS) {
|
|
25
|
+
out = out.replace(rx, replacement);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
6
29
|
export class LlmError extends Error {
|
|
7
30
|
status;
|
|
8
31
|
retryAfterMs;
|
|
@@ -19,8 +42,7 @@ export class LlmError extends Error {
|
|
|
19
42
|
async function toLlmError(res, url) {
|
|
20
43
|
let body = await res.text().catch(() => "");
|
|
21
44
|
// Redact Bearer token and raw key patterns to avoid leaking secrets in logs/errors
|
|
22
|
-
body = body
|
|
23
|
-
body = body.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***");
|
|
45
|
+
body = redactSecrets(body);
|
|
24
46
|
const err = new LlmError(`LLM API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
|
|
25
47
|
// Only honor a REAL Retry-After value: Number("") === 0 would zero the backoff
|
|
26
48
|
const retryAfterHeader = res.headers.get("retry-after");
|
|
@@ -53,6 +75,8 @@ export function combinedSignal(external, timeoutMs) {
|
|
|
53
75
|
}
|
|
54
76
|
return {
|
|
55
77
|
signal: ac.signal,
|
|
78
|
+
/** Clear only the request timer — keep the SIGINT listener alive. */
|
|
79
|
+
stopTimer: () => clearTimeout(timer),
|
|
56
80
|
cleanup: () => {
|
|
57
81
|
clearTimeout(timer);
|
|
58
82
|
external?.removeEventListener("abort", onExternal);
|
|
@@ -102,6 +126,8 @@ function toWireMessages(messages) {
|
|
|
102
126
|
export class LlmClient {
|
|
103
127
|
opts;
|
|
104
128
|
streamEnabled;
|
|
129
|
+
/** Last interrupted stream's accumulated tool calls (H-09 continuation). */
|
|
130
|
+
lastPartialToolCalls = [];
|
|
105
131
|
constructor(opts) {
|
|
106
132
|
this.opts = opts;
|
|
107
133
|
this.streamEnabled = opts.stream !== false; // default true
|
|
@@ -109,10 +135,17 @@ export class LlmClient {
|
|
|
109
135
|
get model() {
|
|
110
136
|
return this.opts.model;
|
|
111
137
|
}
|
|
138
|
+
/** T-04: runtime hot-swap — next request uses the new model id. */
|
|
139
|
+
setModel(model) {
|
|
140
|
+
this.opts = { ...this.opts, model };
|
|
141
|
+
}
|
|
142
|
+
get timeoutMs() {
|
|
143
|
+
return this.opts.requestTimeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
|
|
144
|
+
}
|
|
112
145
|
// ── Non-streaming ──────────────────────────────────────────────────────────
|
|
113
146
|
async chat(messages, tools, opts = {}) {
|
|
114
147
|
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
115
|
-
const { signal, cleanup } = combinedSignal(opts.signal,
|
|
148
|
+
const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
|
|
116
149
|
const body = JSON.stringify({
|
|
117
150
|
model: this.opts.model,
|
|
118
151
|
messages: toWireMessages(messages),
|
|
@@ -135,10 +168,21 @@ export class LlmClient {
|
|
|
135
168
|
return r;
|
|
136
169
|
}, {}, (attempt, delay, reason) => console.error(`⚠ LLM request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/3)...`));
|
|
137
170
|
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
cleanup(); // timer must not survive a failed request
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
let data;
|
|
176
|
+
try {
|
|
177
|
+
// Keep the timeout alive through the body read — a hung body must also
|
|
178
|
+
// surface as a timeout, not an eternal await.
|
|
179
|
+
const pending = res.json();
|
|
180
|
+
pending.catch(() => { }); // no-op catch for the abort path; awaited below
|
|
181
|
+
data = (await pending);
|
|
182
|
+
}
|
|
138
183
|
finally {
|
|
139
184
|
cleanup();
|
|
140
185
|
}
|
|
141
|
-
const data = (await res.json());
|
|
142
186
|
const choice = data.choices?.[0]?.message;
|
|
143
187
|
if (!choice)
|
|
144
188
|
throw new LlmError("LLM API returned no message in response");
|
|
@@ -157,6 +201,7 @@ export class LlmClient {
|
|
|
157
201
|
role: "assistant",
|
|
158
202
|
content: choice.content ?? null,
|
|
159
203
|
stop_reason: choice.stop_reason ?? (choice.content ? "stop" : undefined),
|
|
204
|
+
reasoning: typeof choice.reasoning_content === "string" && choice.reasoning_content ? choice.reasoning_content : undefined,
|
|
160
205
|
tool_calls: choice.tool_calls?.map((tc) => ({
|
|
161
206
|
id: tc.id ?? "",
|
|
162
207
|
name: tc.function?.name ?? "",
|
|
@@ -173,7 +218,7 @@ export class LlmClient {
|
|
|
173
218
|
*/
|
|
174
219
|
async *streamChat(messages, tools, opts = {}) {
|
|
175
220
|
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
176
|
-
const { signal, cleanup } = combinedSignal(opts.signal,
|
|
221
|
+
const { signal, cleanup, stopTimer } = combinedSignal(opts.signal, this.timeoutMs);
|
|
177
222
|
const bodyStr = JSON.stringify({
|
|
178
223
|
model: this.opts.model,
|
|
179
224
|
messages: toWireMessages(messages),
|
|
@@ -181,6 +226,9 @@ export class LlmClient {
|
|
|
181
226
|
temperature: this.opts.temperature,
|
|
182
227
|
max_tokens: this.opts.maxTokens,
|
|
183
228
|
stream: true,
|
|
229
|
+
// OpenAI-compatible providers only include usage in streaming responses
|
|
230
|
+
// when this flag is set — without it token accounting silently zeros out.
|
|
231
|
+
stream_options: { include_usage: true },
|
|
184
232
|
});
|
|
185
233
|
let res;
|
|
186
234
|
try {
|
|
@@ -203,15 +251,47 @@ export class LlmClient {
|
|
|
203
251
|
if (!res.body) {
|
|
204
252
|
throw new LlmError("Response body is not readable (not a streaming response)");
|
|
205
253
|
}
|
|
254
|
+
// Headers received — the WHOLE-request timeout has served its purpose.
|
|
255
|
+
// A long stream is normal (big models can stream for minutes), so from
|
|
256
|
+
// here on we enforce an IDLE timeout instead: reset on every received
|
|
257
|
+
// chunk; only a truly silent connection for idleTimeoutMs kills the read.
|
|
258
|
+
// (The old whole-request timer fired mid-stream on long generations —
|
|
259
|
+
// "LLM stream timed out while reading the response".) SIGINT still aborts
|
|
260
|
+
// through the same signal — stopTimer keeps the external listener alive.
|
|
261
|
+
stopTimer();
|
|
262
|
+
const idleTimeoutMs = opts.idleTimeoutMs ?? this.timeoutMs;
|
|
263
|
+
const readWithIdleTimeout = async () => {
|
|
264
|
+
const ac = new AbortController();
|
|
265
|
+
const t = setTimeout(() => ac.abort(new DOMException(`stream idle for ${idleTimeoutMs / 1000}s`, "TimeoutError")), idleTimeoutMs);
|
|
266
|
+
try {
|
|
267
|
+
return await Promise.race([
|
|
268
|
+
reader.read(),
|
|
269
|
+
new Promise((_, rej) => ac.signal.addEventListener("abort", () => rej(ac.signal.reason), { once: true })),
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
clearTimeout(t);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
206
276
|
// Accumulators for tool_calls across chunks
|
|
207
277
|
const accumulatedToolCalls = [];
|
|
208
278
|
let finalResult;
|
|
279
|
+
let finalUsage;
|
|
209
280
|
const reader = res.body.getReader();
|
|
210
281
|
const decoder = new TextDecoder();
|
|
211
282
|
let buffer = "";
|
|
283
|
+
// Reset the partial-call record for this stream (H-09 continuation).
|
|
284
|
+
this.lastPartialToolCalls = [];
|
|
285
|
+
// I-05: accumulate reasoning deltas for the final message.
|
|
286
|
+
let reasoningAccum = "";
|
|
212
287
|
try {
|
|
213
288
|
while (true) {
|
|
214
|
-
|
|
289
|
+
// Guard the pending read against a late abort (e.g. the idle timeout
|
|
290
|
+
// firing while the consumer paused at a yield): attaching a no-op
|
|
291
|
+
// catch prevents an unhandled rejection from crashing the process.
|
|
292
|
+
const pending = readWithIdleTimeout();
|
|
293
|
+
pending.catch(() => { });
|
|
294
|
+
const { done, value } = await pending;
|
|
215
295
|
if (done)
|
|
216
296
|
break;
|
|
217
297
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -229,6 +309,10 @@ export class LlmClient {
|
|
|
229
309
|
catch {
|
|
230
310
|
continue; // skip malformed JSON lines
|
|
231
311
|
}
|
|
312
|
+
// Usage-only final chunk: many providers send usage in a chunk with
|
|
313
|
+
// an empty choices array — must not be skipped.
|
|
314
|
+
if (chunkData.usage)
|
|
315
|
+
finalUsage = { ...chunkData.usage };
|
|
232
316
|
const choice = chunkData.choices?.[0];
|
|
233
317
|
if (!choice)
|
|
234
318
|
continue;
|
|
@@ -237,6 +321,11 @@ export class LlmClient {
|
|
|
237
321
|
if (delta?.content != null && delta.content.length > 0) {
|
|
238
322
|
yield { contentDelta: delta.content, done: false };
|
|
239
323
|
}
|
|
324
|
+
// I-05: reasoning_content delta (DeepSeek/OpenRouter style)
|
|
325
|
+
if (delta?.reasoning_content) {
|
|
326
|
+
reasoningAccum += delta.reasoning_content;
|
|
327
|
+
yield { reasoningDelta: delta.reasoning_content, done: false };
|
|
328
|
+
}
|
|
240
329
|
// Tool call deltas
|
|
241
330
|
if (delta?.tool_calls) {
|
|
242
331
|
for (const tc of delta.tool_calls) {
|
|
@@ -278,6 +367,7 @@ export class LlmClient {
|
|
|
278
367
|
content: null,
|
|
279
368
|
stop_reason: choice.finish_reason,
|
|
280
369
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
370
|
+
reasoning: reasoningAccum || undefined,
|
|
281
371
|
},
|
|
282
372
|
usage: chunkData.usage,
|
|
283
373
|
};
|
|
@@ -285,15 +375,39 @@ export class LlmClient {
|
|
|
285
375
|
}
|
|
286
376
|
}
|
|
287
377
|
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
// Normalize the abort timeout into LlmError so callers handle it as a
|
|
380
|
+
// normal provider failure instead of a stray DOMException.
|
|
381
|
+
const e = err;
|
|
382
|
+
if (e?.name === "TimeoutError" || /TimeoutError/.test(e?.message ?? "")) {
|
|
383
|
+
throw new LlmError(`LLM stream timed out (${(e?.message ?? "").includes("idle") ? `no data for ${e.message.replace(/^.*idle for /, "")}` : "while reading the response"})`);
|
|
384
|
+
}
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
288
387
|
finally {
|
|
289
|
-
//
|
|
388
|
+
// H-09: record complete tool calls accumulated so far, for continuation.
|
|
389
|
+
this.lastPartialToolCalls = accumulatedToolCalls
|
|
390
|
+
.filter((tc) => tc.name)
|
|
391
|
+
.map((tc) => ({ id: tc.id ?? "", name: tc.name, arguments: tc.arguments }));
|
|
392
|
+
// Cancel the body (frees the socket) and clear the request timeout.
|
|
393
|
+
// cancel() returns a promise — a rejection after abort must be swallowed
|
|
394
|
+
// or it becomes an unhandled rejection that kills the process.
|
|
290
395
|
try {
|
|
291
|
-
reader.cancel();
|
|
396
|
+
const c = reader.cancel();
|
|
397
|
+
if (c && typeof c.catch === "function")
|
|
398
|
+
c.catch(() => { });
|
|
292
399
|
}
|
|
293
400
|
catch { /* already released */ }
|
|
294
|
-
|
|
401
|
+
try {
|
|
402
|
+
reader.releaseLock();
|
|
403
|
+
}
|
|
404
|
+
catch { /* lock already released */ }
|
|
295
405
|
cleanup();
|
|
296
406
|
}
|
|
407
|
+
// Attach usage collected from a usage-only chunk (choices: []) if the
|
|
408
|
+
// finish chunk itself didn't carry it.
|
|
409
|
+
if (finalResult && !finalResult.usage && finalUsage)
|
|
410
|
+
finalResult.usage = finalUsage;
|
|
297
411
|
yield { done: true, final: finalResult };
|
|
298
412
|
}
|
|
299
413
|
}
|