s-app-scoder-agent 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +368 -2
- package/dist/agent.js +478 -0
- package/dist/build.js +44 -0
- package/dist/config.js +93 -0
- package/dist/context.js +44 -0
- package/dist/diff.js +156 -0
- package/dist/index.js +285 -0
- package/dist/llm.js +266 -0
- package/dist/logger.js +78 -0
- package/dist/mcp.js +189 -0
- package/dist/permissions.js +277 -0
- package/dist/provider-anthropic.js +322 -0
- package/dist/retry.js +53 -0
- package/dist/session.js +143 -0
- package/dist/tools.js +356 -0
- package/dist/ui.js +96 -0
- package/dist/version.js +18 -0
- package/package.json +48 -3
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission & Safety System
|
|
3
|
+
*
|
|
4
|
+
* Layer giữa LLM tool call và actual execution.
|
|
5
|
+
* Cung cấp 3 mode: auto (run everything), ask (prompt for shell commands), readonly.
|
|
6
|
+
* Luôn deny catastrophic commands bất kể mode nào.
|
|
7
|
+
*/
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Catastrophic command patterns — always denied, no exceptions
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const CATASTROPHIC_PATTERNS = [
|
|
13
|
+
/rm\s+-rf\s+[/~"]|(?:rm\s+-rf\s+\/\*)/i,
|
|
14
|
+
/mkfs\b/i,
|
|
15
|
+
/dd\s+.*of=\//i,
|
|
16
|
+
/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/i,
|
|
17
|
+
// curl/wget pipe-to-shell: match any content between command and '| sh'/'| bash'
|
|
18
|
+
// Handles -qO-, -X POST, etc. flags; URL-like patterns must contain '/' or '.'
|
|
19
|
+
/curl\s+.*[/.].*\s*\|\s*sh\b/i,
|
|
20
|
+
/curl\s+.*[/.].*\s*\|\s*bash\b/i,
|
|
21
|
+
/wget\s+.*[/.].*\s*\|\s*sh\b/i,
|
|
22
|
+
/wget\s+.*[/.].*\s*\|\s*bash\b/i,
|
|
23
|
+
/sudo\s+rm\s+-rf/i,
|
|
24
|
+
/\bshutdown\b|\breboot\b|\binit\s+[06]\b/i,
|
|
25
|
+
/chmod\s+-R\s+0[77][07][07]\s+[/~]/i,
|
|
26
|
+
/>\s*\/dev\/sda/i,
|
|
27
|
+
/\|\s*nc\s+-e\s+/i,
|
|
28
|
+
/\|\s*bash\s+-i/i,
|
|
29
|
+
/sysctl\s+-w\s+kernel\.panic=/i,
|
|
30
|
+
/echo\s+[^|]*>\s*\/etc\/sudoers/i,
|
|
31
|
+
];
|
|
32
|
+
function isCatastrophic(command) {
|
|
33
|
+
return CATASTROPHIC_PATTERNS.some((p) => p.test(command));
|
|
34
|
+
}
|
|
35
|
+
export function isDangerousCommand(command) {
|
|
36
|
+
return isCatastrophic(command);
|
|
37
|
+
}
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Session allowlist (runtime, in-memory)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
export class SessionAllowlist {
|
|
42
|
+
prefixes = new Set();
|
|
43
|
+
addPrefix(prefix) {
|
|
44
|
+
// Normalize to a word-bounded prefix: "git" -> "git " so that allowing
|
|
45
|
+
// "git" never matches a different binary like "gitx" or "github-cli".
|
|
46
|
+
const t = prefix.trim().replace(/\s+$/, "");
|
|
47
|
+
if (t.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
this.prefixes.add(`${t} `);
|
|
50
|
+
}
|
|
51
|
+
hasMatch(command) {
|
|
52
|
+
return [...this.prefixes].some((p) => command.startsWith(p));
|
|
53
|
+
}
|
|
54
|
+
clear() {
|
|
55
|
+
this.prefixes.clear();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function extractFirstWord(command) {
|
|
59
|
+
return command.trim().split(/\s+/)[0] ?? "";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Classify a command given current permission config and session allowlist.
|
|
63
|
+
*/
|
|
64
|
+
export function classifyCommand(command, mode, configAllow, configDeny, sessionAllow) {
|
|
65
|
+
const safeFirst = extractFirstWord(command);
|
|
66
|
+
// 1. Catastrophic — always deny
|
|
67
|
+
if (isCatastrophic(command)) {
|
|
68
|
+
return { decision: "deny", reason: "matches catastrophic command pattern" };
|
|
69
|
+
}
|
|
70
|
+
// 2. Config deny list
|
|
71
|
+
if (configDeny.some((p) => command.startsWith(p.trim()))) {
|
|
72
|
+
return { decision: "deny", reason: "matches denyCommands pattern in config" };
|
|
73
|
+
}
|
|
74
|
+
// 3. Session allowlist
|
|
75
|
+
if (sessionAllow.hasMatch(command)) {
|
|
76
|
+
return { decision: "allow", reason: "allowed by session allowlist" };
|
|
77
|
+
}
|
|
78
|
+
// 4. Config allow list
|
|
79
|
+
if (configAllow.some((p) => command.startsWith(p.trim()))) {
|
|
80
|
+
return { decision: "allow", reason: "allowed by config allowCommands" };
|
|
81
|
+
}
|
|
82
|
+
// 5. Mode-dependent
|
|
83
|
+
switch (mode) {
|
|
84
|
+
case "auto":
|
|
85
|
+
return { decision: "allow", reason: "auto mode — run without confirmation" };
|
|
86
|
+
case "readonly":
|
|
87
|
+
return {
|
|
88
|
+
decision: "deny",
|
|
89
|
+
reason: "readonly mode — shell commands are disabled",
|
|
90
|
+
};
|
|
91
|
+
case "ask":
|
|
92
|
+
return {
|
|
93
|
+
decision: "ask",
|
|
94
|
+
reason: "requires user confirmation",
|
|
95
|
+
alwaysAllowPrefix: safeFirst + " ",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Prompt user for permission. Returns true/false. "always" is signaled via
|
|
101
|
+
* the alwaysAllowPrefix callback.
|
|
102
|
+
* Uses AbortController so timeoutSecs is actually enforced (deny on timeout).
|
|
103
|
+
*/
|
|
104
|
+
export async function promptForPermission(opts, onAlwaysAllow) {
|
|
105
|
+
const cmd = opts.command.length > 80 ? opts.command.slice(0, 80) + "..." : opts.command;
|
|
106
|
+
const timeoutMs = (opts.timeoutSecs ?? 60) * 1000;
|
|
107
|
+
const ac = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
109
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, signal: ac.signal });
|
|
110
|
+
// NOTE: Node's rl.question() callback is NOT invoked (and the promise never
|
|
111
|
+
// settles) when the interface is closed by an aborted signal — so we must
|
|
112
|
+
// race it against an abort promise or promptForPermission would hang forever.
|
|
113
|
+
const question = (q) => new Promise((res, _rej) => {
|
|
114
|
+
rl.question(q, (ans) => res(ans));
|
|
115
|
+
});
|
|
116
|
+
let rejectAborted = null;
|
|
117
|
+
const aborted = new Promise((_res, rej) => {
|
|
118
|
+
rejectAborted = rej;
|
|
119
|
+
});
|
|
120
|
+
ac.signal.addEventListener("abort", () => {
|
|
121
|
+
const e = new Error("permission prompt timed out");
|
|
122
|
+
e.name = "AbortError";
|
|
123
|
+
rejectAborted?.(e);
|
|
124
|
+
}, { once: true });
|
|
125
|
+
try {
|
|
126
|
+
let answer = "";
|
|
127
|
+
while (true) {
|
|
128
|
+
const timeoutStr = Math.ceil((opts.timeoutSecs ?? 60)) + "s";
|
|
129
|
+
let prompt = `⚠ run_command requires approval: ${cmd} (timeout in ${timeoutStr})\n`;
|
|
130
|
+
if (opts.alwaysAllowPrefix) {
|
|
131
|
+
prompt += `[y] yes [a] always allow "${opts.alwaysAllowPrefix}" [n] no [q] quit: `;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
prompt += `[y] yes [n] no [q] quit: `;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
answer = (await Promise.race([question(prompt), aborted])).trim().toLowerCase();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Timeout — AbortController triggered
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
rl.close();
|
|
143
|
+
return false; // safe default: deny on timeout
|
|
144
|
+
}
|
|
145
|
+
if (answer === "q") {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
rl.close();
|
|
148
|
+
return false; // caller will abort
|
|
149
|
+
}
|
|
150
|
+
if (answer === "y") {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
rl.close();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
if (answer === "n") {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
rl.close();
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (answer === "a" && opts.alwaysAllowPrefix) {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
rl.close();
|
|
163
|
+
onAlwaysAllow?.(opts.alwaysAllowPrefix);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
// invalid answer — loop again (timeout still ticking)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
try {
|
|
172
|
+
rl.close();
|
|
173
|
+
}
|
|
174
|
+
catch { /* already closed */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export class PermissionChecker {
|
|
178
|
+
config;
|
|
179
|
+
sessionAllow = new SessionAllowlist();
|
|
180
|
+
allowAllEdits = false;
|
|
181
|
+
constructor(config) {
|
|
182
|
+
this.config = config;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Check if a command should run. Returns null = allowed immediately,
|
|
186
|
+
* or a PermissionResult if user needs to be consulted.
|
|
187
|
+
*/
|
|
188
|
+
classify(command) {
|
|
189
|
+
return classifyCommand(command, this.config.mode, this.config.allowCommands, this.config.denyCommands, this.sessionAllow);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Ask user interactively. Returns true = allowed, false = denied.
|
|
193
|
+
* In non-interactive mode, always returns false for "ask" decisions.
|
|
194
|
+
*/
|
|
195
|
+
async confirm(command) {
|
|
196
|
+
const result = this.classify(command);
|
|
197
|
+
if (result.decision === "allow")
|
|
198
|
+
return true;
|
|
199
|
+
if (result.decision === "deny")
|
|
200
|
+
return false;
|
|
201
|
+
// Decision is "ask"
|
|
202
|
+
if (this.config.nonInteractive) {
|
|
203
|
+
// In non-interactive (CI), deny commands that need asking
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return promptForPermission({
|
|
207
|
+
command,
|
|
208
|
+
alwaysAllowPrefix: result.alwaysAllowPrefix,
|
|
209
|
+
}, (prefix) => this.sessionAllow.addPrefix(prefix));
|
|
210
|
+
}
|
|
211
|
+
get sessionAllowlist() {
|
|
212
|
+
return this.sessionAllow;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Ask user to approve a file write/edit, showing the diff first.
|
|
216
|
+
* Returns true = approved, false = rejected.
|
|
217
|
+
* Enforced timeout (default 120s) — deny on timeout (safe default).
|
|
218
|
+
*/
|
|
219
|
+
async confirmEdit(path, diffText, timeoutSecs = 120) {
|
|
220
|
+
if (this.allowAllEdits)
|
|
221
|
+
return true;
|
|
222
|
+
if (this.config.nonInteractive) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
const ac = new AbortController();
|
|
226
|
+
const timer = setTimeout(() => ac.abort(), timeoutSecs * 1000);
|
|
227
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, signal: ac.signal });
|
|
228
|
+
const question = (q) => new Promise((res) => rl.question(q, (ans) => res(ans)));
|
|
229
|
+
let rejectAborted = null;
|
|
230
|
+
const aborted = new Promise((_res, rej) => {
|
|
231
|
+
rejectAborted = rej;
|
|
232
|
+
});
|
|
233
|
+
ac.signal.addEventListener("abort", () => {
|
|
234
|
+
const e = new Error("edit confirmation timed out");
|
|
235
|
+
e.name = "AbortError";
|
|
236
|
+
rejectAborted?.(e);
|
|
237
|
+
}, { once: true });
|
|
238
|
+
try {
|
|
239
|
+
for (;;) {
|
|
240
|
+
let answer;
|
|
241
|
+
try {
|
|
242
|
+
answer = (await Promise.race([
|
|
243
|
+
question(`\nApply this change to ${path}? [y]es [a] always (this session) [n]o [q]uit: `),
|
|
244
|
+
aborted,
|
|
245
|
+
])).trim().toLowerCase();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// timeout — deny (safe default)
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
if (answer === "y")
|
|
252
|
+
return true;
|
|
253
|
+
if (answer === "a") {
|
|
254
|
+
this.allowAllEdits = true;
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
if (answer === "n")
|
|
258
|
+
return false;
|
|
259
|
+
if (answer === "q") {
|
|
260
|
+
// Graceful abort signal instead of process.exit from library code
|
|
261
|
+
this.abortRequested = true;
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
// invalid answer — loop again (timeout still ticking)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
try {
|
|
270
|
+
rl.close();
|
|
271
|
+
}
|
|
272
|
+
catch { /* already closed */ }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Set when the user pressed [q]uit in confirmEdit — the agent should stop. */
|
|
276
|
+
abortRequested = false;
|
|
277
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { LlmError, LLM_REQUEST_TIMEOUT_MS, combinedSignal } from "./llm.js";
|
|
2
|
+
import { withRetry } from "./retry.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Translation: OpenAI-style -> Anthropic
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
export function toAnthropicMessages(messages) {
|
|
7
|
+
const systemParts = [];
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const m of messages) {
|
|
10
|
+
if (m.role === "system") {
|
|
11
|
+
if (m.content)
|
|
12
|
+
systemParts.push(m.content);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (m.role === "tool") {
|
|
16
|
+
// Tool results become user tool_result blocks; consecutive tool results
|
|
17
|
+
// are merged into one user message (Anthropic requirement).
|
|
18
|
+
const block = {
|
|
19
|
+
type: "tool_result",
|
|
20
|
+
tool_use_id: m.tool_call_id ?? "",
|
|
21
|
+
content: m.content ?? "",
|
|
22
|
+
};
|
|
23
|
+
const last = out[out.length - 1];
|
|
24
|
+
if (last && last.role === "user" && Array.isArray(last.content)) {
|
|
25
|
+
last.content.push(block);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
out.push({ role: "user", content: [block] });
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (m.role === "assistant") {
|
|
33
|
+
const blocks = [];
|
|
34
|
+
if (m.content)
|
|
35
|
+
blocks.push({ type: "text", text: m.content });
|
|
36
|
+
for (const tc of m.tool_calls ?? []) {
|
|
37
|
+
let input;
|
|
38
|
+
try {
|
|
39
|
+
input = tc.arguments ? JSON.parse(tc.arguments) : {};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
input = {};
|
|
43
|
+
}
|
|
44
|
+
blocks.push({ type: "tool_use", id: tc.id || `call_${tc.name}`, name: tc.name, input });
|
|
45
|
+
}
|
|
46
|
+
out.push({
|
|
47
|
+
role: "assistant",
|
|
48
|
+
content: blocks.length > 0 ? blocks : (m.content ?? ""),
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// user
|
|
53
|
+
out.push({ role: "user", content: m.content ?? "" });
|
|
54
|
+
}
|
|
55
|
+
return { system: systemParts.join("\n\n") || undefined, messages: out };
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Translation: Anthropic -> OpenAI-style ChatResult
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export function fromAnthropicResponse(data) {
|
|
61
|
+
let text = "";
|
|
62
|
+
const toolCalls = [];
|
|
63
|
+
for (const block of data.content ?? []) {
|
|
64
|
+
if (block.type === "text") {
|
|
65
|
+
text = text ? `${text}\n${block.text ?? ""}` : (block.text ?? "");
|
|
66
|
+
}
|
|
67
|
+
else if (block.type === "tool_use") {
|
|
68
|
+
toolCalls.push({
|
|
69
|
+
id: block.id ?? "",
|
|
70
|
+
name: block.name ?? "",
|
|
71
|
+
arguments: JSON.stringify(block.input ?? {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
message: {
|
|
77
|
+
role: "assistant",
|
|
78
|
+
content: text.length > 0 ? text : null,
|
|
79
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
80
|
+
},
|
|
81
|
+
usage: {
|
|
82
|
+
prompt_tokens: data.usage?.input_tokens,
|
|
83
|
+
completion_tokens: data.usage?.output_tokens,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// AnthropicClient — same surface as LlmClient
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
91
|
+
export class AnthropicClient {
|
|
92
|
+
opts;
|
|
93
|
+
constructor(opts) {
|
|
94
|
+
this.opts = opts;
|
|
95
|
+
}
|
|
96
|
+
get model() {
|
|
97
|
+
return this.opts.model;
|
|
98
|
+
}
|
|
99
|
+
async chat(messages, tools, opts = {}) {
|
|
100
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/messages`;
|
|
101
|
+
const { system, messages: translated } = toAnthropicMessages(messages);
|
|
102
|
+
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
103
|
+
const body = JSON.stringify({
|
|
104
|
+
model: this.opts.model,
|
|
105
|
+
max_tokens: this.opts.maxTokens,
|
|
106
|
+
temperature: this.opts.temperature,
|
|
107
|
+
system,
|
|
108
|
+
messages: translated,
|
|
109
|
+
tools: tools.length > 0
|
|
110
|
+
? tools.map((t) => ({
|
|
111
|
+
name: t.function.name,
|
|
112
|
+
description: t.function.description,
|
|
113
|
+
input_schema: t.function.parameters,
|
|
114
|
+
}))
|
|
115
|
+
: undefined,
|
|
116
|
+
});
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
res = await withRetry(async () => {
|
|
120
|
+
const r = await fetch(url, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: {
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
"x-api-key": this.opts.apiKey,
|
|
125
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
126
|
+
},
|
|
127
|
+
body,
|
|
128
|
+
signal,
|
|
129
|
+
});
|
|
130
|
+
if (!r.ok)
|
|
131
|
+
throw await toAnthropicError(r, url);
|
|
132
|
+
return r;
|
|
133
|
+
}, {}, (attempt, delay, reason) => console.error(`⚠ Anthropic request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/3)...`));
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
cleanup();
|
|
137
|
+
}
|
|
138
|
+
const data = (await res.json());
|
|
139
|
+
if (data.error) {
|
|
140
|
+
throw new LlmError(`Anthropic API error: ${data.error.message ?? "unknown"}`);
|
|
141
|
+
}
|
|
142
|
+
return fromAnthropicResponse(data);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Streaming: parse Anthropic SSE events (message_start, content_block_delta,
|
|
146
|
+
* message_delta, message_stop) and translate them to StreamChunks:
|
|
147
|
+
* - text_delta -> contentDelta
|
|
148
|
+
* - tool_use + input_json_delta -> toolCallDelta accumulation
|
|
149
|
+
* - message_delta stop_reason/usage -> final ChatResult
|
|
150
|
+
*/
|
|
151
|
+
async *streamChat(messages, tools, opts = {}) {
|
|
152
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/messages`;
|
|
153
|
+
const { system, messages: translated } = toAnthropicMessages(messages);
|
|
154
|
+
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
155
|
+
const body = JSON.stringify({
|
|
156
|
+
model: this.opts.model,
|
|
157
|
+
max_tokens: this.opts.maxTokens,
|
|
158
|
+
temperature: this.opts.temperature,
|
|
159
|
+
system,
|
|
160
|
+
messages: translated,
|
|
161
|
+
stream: true,
|
|
162
|
+
tools: tools.length > 0
|
|
163
|
+
? tools.map((t) => ({
|
|
164
|
+
name: t.function.name,
|
|
165
|
+
description: t.function.description,
|
|
166
|
+
input_schema: t.function.parameters,
|
|
167
|
+
}))
|
|
168
|
+
: undefined,
|
|
169
|
+
});
|
|
170
|
+
let res;
|
|
171
|
+
try {
|
|
172
|
+
res = await withRetry(async () => {
|
|
173
|
+
const r = await fetch(url, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
"x-api-key": this.opts.apiKey,
|
|
178
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
179
|
+
},
|
|
180
|
+
body,
|
|
181
|
+
signal,
|
|
182
|
+
});
|
|
183
|
+
if (!r.ok)
|
|
184
|
+
throw await toAnthropicError(r, url);
|
|
185
|
+
return r;
|
|
186
|
+
}, {}, (attempt, delay, reason) => console.error(`⚠ Anthropic stream request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s...`));
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
cleanup(); // don't leak the 120s request timer — it would hang the process
|
|
190
|
+
throw new LlmError(`Anthropic stream fetch error after retries at ${url}: ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
if (!res.body) {
|
|
193
|
+
throw new LlmError("Anthropic stream: response body is not readable");
|
|
194
|
+
}
|
|
195
|
+
// Accumulators — text content + tool_use blocks (by block index)
|
|
196
|
+
let text = "";
|
|
197
|
+
const toolBlocks = new Map();
|
|
198
|
+
let stopReason;
|
|
199
|
+
let usage;
|
|
200
|
+
let sawStop = false;
|
|
201
|
+
const reader = res.body.getReader();
|
|
202
|
+
const decoder = new TextDecoder();
|
|
203
|
+
let buffer = "";
|
|
204
|
+
const handleEvent = (raw) => {
|
|
205
|
+
let ev;
|
|
206
|
+
try {
|
|
207
|
+
ev = JSON.parse(raw);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (ev.type === "message_start") {
|
|
213
|
+
// Initial usage (input tokens) arrives on message_start
|
|
214
|
+
if (ev.message?.usage) {
|
|
215
|
+
usage = {
|
|
216
|
+
prompt_tokens: ev.message.usage.input_tokens,
|
|
217
|
+
completion_tokens: ev.message.usage.output_tokens,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (ev.type === "content_block_start" && ev.content_block?.type === "tool_use") {
|
|
223
|
+
toolBlocks.set(ev.index ?? 0, {
|
|
224
|
+
id: ev.content_block.id ?? "",
|
|
225
|
+
name: ev.content_block.name ?? "",
|
|
226
|
+
json: "",
|
|
227
|
+
});
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (ev.type === "content_block_delta") {
|
|
231
|
+
if (ev.delta?.type === "text_delta" && ev.delta.text) {
|
|
232
|
+
text += ev.delta.text;
|
|
233
|
+
return { contentDelta: ev.delta.text, done: false };
|
|
234
|
+
}
|
|
235
|
+
if (ev.delta?.type === "input_json_delta" && ev.delta.partial_json) {
|
|
236
|
+
const block = toolBlocks.get(ev.index ?? 0);
|
|
237
|
+
if (block)
|
|
238
|
+
block.json += ev.delta.partial_json;
|
|
239
|
+
return {
|
|
240
|
+
toolCallDelta: { index: ev.index ?? 0, argumentsDelta: ev.delta.partial_json },
|
|
241
|
+
done: false,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
if (ev.type === "message_delta") {
|
|
247
|
+
stopReason = ev.delta?.stop_reason ?? stopReason;
|
|
248
|
+
// Merge: message_delta usage only carries output_tokens
|
|
249
|
+
if (ev.usage) {
|
|
250
|
+
usage = {
|
|
251
|
+
...usage,
|
|
252
|
+
prompt_tokens: usage?.prompt_tokens ?? ev.usage.input_tokens,
|
|
253
|
+
completion_tokens: ev.usage.output_tokens ?? usage?.completion_tokens,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (ev.type === "message_stop") {
|
|
259
|
+
sawStop = true;
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
};
|
|
264
|
+
let finalResult;
|
|
265
|
+
try {
|
|
266
|
+
while (true) {
|
|
267
|
+
const { done, value } = await reader.read();
|
|
268
|
+
if (done)
|
|
269
|
+
break;
|
|
270
|
+
buffer += decoder.decode(value, { stream: true });
|
|
271
|
+
const lines = buffer.split("\n");
|
|
272
|
+
buffer = lines.pop() ?? "";
|
|
273
|
+
for (const rawLine of lines) {
|
|
274
|
+
const line = rawLine.trim();
|
|
275
|
+
if (!line.startsWith("data:"))
|
|
276
|
+
continue;
|
|
277
|
+
const payload = line.slice(5).trim();
|
|
278
|
+
if (!payload)
|
|
279
|
+
continue;
|
|
280
|
+
const chunk = handleEvent(payload);
|
|
281
|
+
if (chunk)
|
|
282
|
+
yield chunk;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (sawStop) {
|
|
286
|
+
const toolCalls = [...toolBlocks.values()]
|
|
287
|
+
.filter((b) => b.name)
|
|
288
|
+
.map((b) => ({ id: b.id, name: b.name, arguments: b.json || "{}" }));
|
|
289
|
+
finalResult = {
|
|
290
|
+
message: {
|
|
291
|
+
role: "assistant",
|
|
292
|
+
content: text.length > 0 ? text : null,
|
|
293
|
+
stop_reason: stopReason,
|
|
294
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
295
|
+
},
|
|
296
|
+
usage,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
try {
|
|
302
|
+
reader.cancel();
|
|
303
|
+
}
|
|
304
|
+
catch { /* already released */ }
|
|
305
|
+
reader.releaseLock();
|
|
306
|
+
cleanup();
|
|
307
|
+
}
|
|
308
|
+
yield { done: true, final: finalResult };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function toAnthropicError(res, url) {
|
|
312
|
+
let body = await res.text().catch(() => "");
|
|
313
|
+
body = body.replace(/sk-ant-[^\s,&\]]+/gi, "sk-ant-***REDACTED***");
|
|
314
|
+
const err = new LlmError(`Anthropic API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
|
|
315
|
+
const retryAfterHeader = res.headers.get("retry-after");
|
|
316
|
+
if (retryAfterHeader != null && retryAfterHeader !== "") {
|
|
317
|
+
const sec = Number(retryAfterHeader);
|
|
318
|
+
if (Number.isFinite(sec) && sec > 0)
|
|
319
|
+
err.retryAfterMs = sec * 1000;
|
|
320
|
+
}
|
|
321
|
+
return err;
|
|
322
|
+
}
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry with exponential backoff + jitter for transient failures.
|
|
3
|
+
*/
|
|
4
|
+
export class NonRetryableError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** HTTP statuses worth retrying (transient). */
|
|
7
|
+
export function isRetryableStatus(status) {
|
|
8
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
9
|
+
}
|
|
10
|
+
/** Network-level errors worth retrying. */
|
|
11
|
+
export function isRetryableNetworkError(err) {
|
|
12
|
+
const msg = String(err?.message ?? err ?? "");
|
|
13
|
+
return (/ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|socket hang up|network|fetch failed/i.test(msg));
|
|
14
|
+
}
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Run fn with retries. Retries on retryable HTTP statuses (thrown as errors
|
|
20
|
+
* carrying `.status`) and network errors. Client errors (4xx except 429)
|
|
21
|
+
* fail immediately via NonRetryableError semantics.
|
|
22
|
+
*/
|
|
23
|
+
export async function withRetry(fn, opts = {}, onRetry) {
|
|
24
|
+
const maxRetries = opts.maxRetries ?? 3;
|
|
25
|
+
const baseDelayMs = opts.baseDelayMs ?? 1000;
|
|
26
|
+
const maxDelayMs = opts.maxDelayMs ?? 30_000;
|
|
27
|
+
let lastErr;
|
|
28
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
lastErr = err;
|
|
34
|
+
const status = err.status;
|
|
35
|
+
const retryable = (typeof status === "number" && isRetryableStatus(status)) ||
|
|
36
|
+
(!(err instanceof NonRetryableError) && isRetryableNetworkError(err));
|
|
37
|
+
// Client errors (except 429) — fail immediately
|
|
38
|
+
if (typeof status === "number" && status >= 400 && status < 500 && status !== 429) {
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
if (!retryable || attempt === maxRetries) {
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
// Respect Retry-After header if present (seconds)
|
|
45
|
+
const retryAfter = err.retryAfterMs;
|
|
46
|
+
const jitter = Math.floor(Math.random() * 500);
|
|
47
|
+
const delay = Math.min(retryAfter ?? baseDelayMs * 2 ** attempt + jitter, maxDelayMs);
|
|
48
|
+
onRetry?.(attempt + 1, delay, status ? `HTTP ${status}` : String(err.message).slice(0, 80));
|
|
49
|
+
await sleep(delay);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw lastErr; // unreachable
|
|
53
|
+
}
|