@spinabot/brigade 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +208 -0
- package/assets/brigade-wordmark-on-black.png +0 -0
- package/assets/brigade-wordmark.png +0 -0
- package/brigade.mjs +96 -0
- package/dist/cli/chat-cmd.js +120 -0
- package/dist/cli/config-cmd.js +132 -0
- package/dist/cli/connect-cmd.js +447 -0
- package/dist/cli/doctor-cmd.js +317 -0
- package/dist/cli/gateway-cmd.js +92 -0
- package/dist/cli.js +287 -0
- package/dist/core/agent.js +1123 -0
- package/dist/core/config.js +80 -0
- package/dist/core/console-stream.js +188 -0
- package/dist/core/error-classifier.js +354 -0
- package/dist/core/event-logger.js +122 -0
- package/dist/core/model-caps.js +185 -0
- package/dist/core/provider-payload-mutators.js +517 -0
- package/dist/core/provider-quirks.js +285 -0
- package/dist/core/server.js +459 -0
- package/dist/core/smart-compaction.js +209 -0
- package/dist/core/system-prompt-defaults.js +88 -0
- package/dist/core/system-prompt-guidance.js +269 -0
- package/dist/core/system-prompt.js +884 -0
- package/dist/index.js +30 -0
- package/dist/integrations/ollama.js +140 -0
- package/dist/protocol.js +49 -0
- package/dist/providers/catalog.js +100 -0
- package/dist/providers/validate-key.js +197 -0
- package/dist/tui/client.js +263 -0
- package/dist/ui/brand-frames-cli.js +20 -0
- package/dist/ui/brand-frames.js +36 -0
- package/dist/ui/brand.js +402 -0
- package/dist/ui/chat.js +929 -0
- package/dist/ui/onboarding.js +400 -0
- package/dist/ui/theme.js +51 -0
- package/package.json +92 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brigade's own user-preference config.
|
|
3
|
+
*
|
|
4
|
+
* Stored at `~/.brigade/config.json`. Persists the user's chosen default
|
|
5
|
+
* provider + model so subsequent launches skip the onboarding wizard.
|
|
6
|
+
*
|
|
7
|
+
* This is SEPARATE from Pi's `~/.brigade/auth.json` (managed by AuthStorage)
|
|
8
|
+
* and `~/.brigade/models.json` (managed by ModelRegistry). We only own
|
|
9
|
+
* Brigade-specific UX state here.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs/promises";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
/**
|
|
15
|
+
* `~/.brigade` — Pi's agentDir for our app. Env override `BRIGADE_DIR` takes
|
|
16
|
+
* precedence so tests, dotfile-managers, and CI runners can redirect Brigade's
|
|
17
|
+
* state to a sandbox dir without symlinking the home directory.
|
|
18
|
+
*/
|
|
19
|
+
export const BRIGADE_DIR = process.env.BRIGADE_DIR && process.env.BRIGADE_DIR.trim().length > 0
|
|
20
|
+
? path.resolve(process.env.BRIGADE_DIR)
|
|
21
|
+
: path.join(os.homedir(), ".brigade");
|
|
22
|
+
/** ~/.brigade/config.json — our user preferences. */
|
|
23
|
+
const CONFIG_FILE = path.join(BRIGADE_DIR, "config.json");
|
|
24
|
+
/**
|
|
25
|
+
* Compute the session directory for a given working directory, rooted at
|
|
26
|
+
* Brigade's own agentDir.
|
|
27
|
+
*
|
|
28
|
+
* Without this, Pi's `SessionManager.continueRecent(cwd)` defaults to
|
|
29
|
+
* `~/.pi/agent/sessions/<encoded-cwd>/` — i.e. sessions land in Pi's
|
|
30
|
+
* GLOBAL state directory instead of inside `~/.brigade/`. That makes
|
|
31
|
+
* "delete my chat history" require digging into a non-Brigade folder, and
|
|
32
|
+
* test runs leak sessions outside their tempdir.
|
|
33
|
+
*
|
|
34
|
+
* The encoding mirrors Pi's internal `getDefaultSessionDir` (see
|
|
35
|
+
* `node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js`)
|
|
36
|
+
* so the per-cwd directory naming stays consistent if anyone ever needs to
|
|
37
|
+
* port history between agentDirs.
|
|
38
|
+
*/
|
|
39
|
+
export function getBrigadeSessionDir(cwd, agentDir = BRIGADE_DIR) {
|
|
40
|
+
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
41
|
+
return path.join(agentDir, "sessions", safePath);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read user preferences from disk. Returns `{}` if missing or unparseable
|
|
45
|
+
* (a corrupt config should never block boot — the user just sees onboarding).
|
|
46
|
+
*
|
|
47
|
+
* `configPath` overrides the default location — used by tests to redirect
|
|
48
|
+
* to a tempdir without mutating $HOME.
|
|
49
|
+
*/
|
|
50
|
+
export async function loadConfig(configPath = CONFIG_FILE) {
|
|
51
|
+
try {
|
|
52
|
+
const data = await fs.readFile(configPath, "utf8");
|
|
53
|
+
return JSON.parse(data);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Persist user preferences. MERGES with existing config — fields not in
|
|
61
|
+
* `partial` are preserved from disk. This is critical because callers like
|
|
62
|
+
* the model-switch path only pass `{ defaultProvider, defaultModelId }`
|
|
63
|
+
* and would otherwise wipe out `thinkingLevel`, `fallbackProvider`, etc.
|
|
64
|
+
*
|
|
65
|
+
* Pass `{}` to no-op (just touches `installedAt` if missing).
|
|
66
|
+
*
|
|
67
|
+
* `configPath` overrides the default location — used by tests.
|
|
68
|
+
*/
|
|
69
|
+
export async function saveConfig(partial, configPath = CONFIG_FILE) {
|
|
70
|
+
const dir = path.dirname(configPath);
|
|
71
|
+
await fs.mkdir(dir, { recursive: true });
|
|
72
|
+
const existing = await loadConfig(configPath);
|
|
73
|
+
const merged = {
|
|
74
|
+
...existing,
|
|
75
|
+
...partial,
|
|
76
|
+
installedAt: existing.installedAt ?? partial.installedAt ?? new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
await fs.writeFile(configPath, JSON.stringify(merged, null, 2), "utf8");
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brigade gateway live console stream.
|
|
3
|
+
*
|
|
4
|
+
* When `brigade gateway --verbose` is set, the server writes a one-line
|
|
5
|
+
* summary of every interesting event to stderr in real time so the operator
|
|
6
|
+
* watching the gateway terminal can SEE what's happening — instead of
|
|
7
|
+
* tailing the JSONL log file in another window.
|
|
8
|
+
*
|
|
9
|
+
* Format:
|
|
10
|
+
* - Subsystem prefix in brackets, color per subsystem e.g. `[gateway]`
|
|
11
|
+
* - Directional arrow: `←` req, `⇄` res, `→` event, `•` info
|
|
12
|
+
* - Optional status token after the verb: `✓` success, `✗` error
|
|
13
|
+
* - Compact key=value tail, truncated per field `tool=read path=src/cli.ts`
|
|
14
|
+
*
|
|
15
|
+
* The stream is a STRICT SUBSET of what's in the JSONL log. Noisy events
|
|
16
|
+
* (`message_update` deltas — one per token) are dropped by default and only
|
|
17
|
+
* emitted at `--log-level debug`. The JSONL file remains the source of truth.
|
|
18
|
+
*
|
|
19
|
+
* Output goes to STDERR so it doesn't pollute any future stdout protocol
|
|
20
|
+
* the gateway might speak (e.g. JSON-RPC mode for ACP clients).
|
|
21
|
+
*/
|
|
22
|
+
import process from "node:process";
|
|
23
|
+
import chalk from "chalk";
|
|
24
|
+
/* ─────────────────────────── construction ─────────────────────────── */
|
|
25
|
+
const LEVEL_RANK = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
26
|
+
/** Subsystems get a stable, human-pleasing color. Hash-pick from a small palette. */
|
|
27
|
+
const SUBSYSTEM_COLORS = {
|
|
28
|
+
gateway: chalk.cyan,
|
|
29
|
+
agent: chalk.magenta,
|
|
30
|
+
tool: chalk.yellow,
|
|
31
|
+
auth: chalk.blue,
|
|
32
|
+
"": chalk.dim,
|
|
33
|
+
};
|
|
34
|
+
/** Pi event types we drop unless the level is `debug`. The JSONL log keeps them. */
|
|
35
|
+
const NOISY_EVENT_TYPES = new Set([
|
|
36
|
+
"message_update", // one per text/thinking delta — floods the terminal
|
|
37
|
+
]);
|
|
38
|
+
export function createConsoleStream(opts = {}) {
|
|
39
|
+
const level = opts.level ?? "info";
|
|
40
|
+
const useColor = opts.color ?? true;
|
|
41
|
+
const write = opts.write ?? ((line) => void process.stderr.write(`${line}\n`));
|
|
42
|
+
const c = (color, s) => (useColor ? color(s) : s);
|
|
43
|
+
const subTag = (sub) => {
|
|
44
|
+
const colorFn = SUBSYSTEM_COLORS[sub] ?? SUBSYSTEM_COLORS[""];
|
|
45
|
+
return c(colorFn, `[${sub}]`.padEnd(11));
|
|
46
|
+
};
|
|
47
|
+
const ts = () => c(chalk.dim, new Date().toISOString().slice(11, 23)); // HH:mm:ss.SSS
|
|
48
|
+
const allow = (msgLevel) => LEVEL_RANK[msgLevel] <= LEVEL_RANK[level];
|
|
49
|
+
const arrow = (kind) => {
|
|
50
|
+
const sym = kind === "req" ? "←" : kind === "res" ? "⇄" : kind === "event" ? "→" : "•";
|
|
51
|
+
const colorFn = kind === "req" ? chalk.green : kind === "res" ? chalk.yellow : kind === "event" ? chalk.cyan : chalk.dim;
|
|
52
|
+
return c(colorFn, sym);
|
|
53
|
+
};
|
|
54
|
+
const status = (ok) => c(ok ? chalk.green : chalk.red, ok ? "✓" : "✗");
|
|
55
|
+
const trunc = (v, max = 60) => {
|
|
56
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
57
|
+
if (!s)
|
|
58
|
+
return "";
|
|
59
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
60
|
+
};
|
|
61
|
+
const fields = (kv) => Object.entries(kv)
|
|
62
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
|
63
|
+
.map(([k, v]) => `${c(chalk.dim, k)}=${trunc(v)}`)
|
|
64
|
+
.join(" ");
|
|
65
|
+
const line = (levelFor, sub, body) => {
|
|
66
|
+
if (!allow(levelFor))
|
|
67
|
+
return;
|
|
68
|
+
write(`${ts()} ${subTag(sub)} ${body}`);
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
pi(event) {
|
|
72
|
+
const t = event.type;
|
|
73
|
+
if (NOISY_EVENT_TYPES.has(t) && !allow("debug"))
|
|
74
|
+
return;
|
|
75
|
+
// Pick subsystem + extract the most useful 2–3 fields per event type.
|
|
76
|
+
// Catch-all at the bottom keeps the stream COMPLETE — every event
|
|
77
|
+
// type produces a line, even if the body is just the type name.
|
|
78
|
+
const ev = event;
|
|
79
|
+
let sub = "agent";
|
|
80
|
+
let body = "";
|
|
81
|
+
let levelFor = "info";
|
|
82
|
+
switch (t) {
|
|
83
|
+
case "agent_start":
|
|
84
|
+
body = `${arrow("event")} agent_start`;
|
|
85
|
+
break;
|
|
86
|
+
case "agent_end": {
|
|
87
|
+
const messages = Array.isArray(ev.messages) ? ev.messages : [];
|
|
88
|
+
const last = messages[messages.length - 1];
|
|
89
|
+
const stopReason = last?.stopReason;
|
|
90
|
+
const isError = stopReason === "error" || stopReason === "aborted";
|
|
91
|
+
body = `${arrow("event")} agent_end ${status(!isError)} ${fields({ stopReason, messages: messages.length })}`;
|
|
92
|
+
if (isError)
|
|
93
|
+
levelFor = "warn";
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case "turn_start":
|
|
97
|
+
body = `${arrow("event")} turn_start`;
|
|
98
|
+
break;
|
|
99
|
+
case "turn_end": {
|
|
100
|
+
const usage = ev.message?.usage;
|
|
101
|
+
body = `${arrow("event")} turn_end ${fields({
|
|
102
|
+
in: usage?.input,
|
|
103
|
+
out: usage?.output,
|
|
104
|
+
cost: usage?.cost ? `$${Number(usage.cost).toFixed(4)}` : undefined,
|
|
105
|
+
})}`;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "tool_execution_start":
|
|
109
|
+
sub = "tool";
|
|
110
|
+
body = `${arrow("event")} tool_start ${fields({
|
|
111
|
+
tool: ev.toolName,
|
|
112
|
+
args: ev.args ? trunc(ev.args, 80) : undefined,
|
|
113
|
+
})}`;
|
|
114
|
+
break;
|
|
115
|
+
case "tool_execution_end":
|
|
116
|
+
sub = "tool";
|
|
117
|
+
body = `${arrow("event")} tool_end ${status(!ev.isError)} ${fields({
|
|
118
|
+
tool: ev.toolName,
|
|
119
|
+
})}`;
|
|
120
|
+
if (ev.isError)
|
|
121
|
+
levelFor = "warn";
|
|
122
|
+
break;
|
|
123
|
+
case "message_start":
|
|
124
|
+
case "message_end":
|
|
125
|
+
body = `${arrow("event")} ${t} ${fields({ role: ev.message?.role })}`;
|
|
126
|
+
levelFor = "debug";
|
|
127
|
+
break;
|
|
128
|
+
case "message_update":
|
|
129
|
+
// Already filtered above unless debug; show inner type only.
|
|
130
|
+
body = `${arrow("event")} message_update ${fields({ inner: ev.assistantMessageEvent?.type })}`;
|
|
131
|
+
levelFor = "debug";
|
|
132
|
+
break;
|
|
133
|
+
case "compaction_start":
|
|
134
|
+
body = `${arrow("event")} compaction_start`;
|
|
135
|
+
break;
|
|
136
|
+
case "compaction_end":
|
|
137
|
+
body = `${arrow("event")} compaction_end ${status(!ev.aborted)}`;
|
|
138
|
+
if (ev.aborted)
|
|
139
|
+
levelFor = "warn";
|
|
140
|
+
break;
|
|
141
|
+
case "auto_retry_start":
|
|
142
|
+
body = `${arrow("event")} auto_retry_start ${fields({
|
|
143
|
+
attempt: ev.attempt,
|
|
144
|
+
max: ev.maxAttempts,
|
|
145
|
+
delayMs: ev.delayMs,
|
|
146
|
+
})}`;
|
|
147
|
+
levelFor = "warn";
|
|
148
|
+
break;
|
|
149
|
+
case "auto_retry_end":
|
|
150
|
+
body = `${arrow("event")} auto_retry_end ${status(ev.success !== false)} ${fields({ attempt: ev.attempt })}`;
|
|
151
|
+
if (ev.success === false)
|
|
152
|
+
levelFor = "warn";
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
body = `${arrow("event")} ${t}`;
|
|
156
|
+
levelFor = "debug";
|
|
157
|
+
}
|
|
158
|
+
line(levelFor, sub, body);
|
|
159
|
+
},
|
|
160
|
+
wsRequest(method, id, clientLabel) {
|
|
161
|
+
line("info", "gateway", `${arrow("req")} req ${method} ${fields({ id, from: clientLabel })}`);
|
|
162
|
+
},
|
|
163
|
+
wsResponse(method, id, ok, durationMs) {
|
|
164
|
+
line(ok ? "info" : "warn", "gateway", `${arrow("res")} res ${method} ${status(ok)} ${fields({ id, ms: durationMs })}`);
|
|
165
|
+
},
|
|
166
|
+
clientConnected(label, totalClients) {
|
|
167
|
+
line("info", "gateway", `${arrow("info")} client connected ${fields({ from: label, total: totalClients })}`);
|
|
168
|
+
},
|
|
169
|
+
clientDisconnected(label, totalClients) {
|
|
170
|
+
line("info", "gateway", `${arrow("info")} client disconnected ${fields({ from: label, total: totalClients })}`);
|
|
171
|
+
},
|
|
172
|
+
info(message) {
|
|
173
|
+
line("info", "gateway", `${arrow("info")} ${message}`);
|
|
174
|
+
},
|
|
175
|
+
warn(message) {
|
|
176
|
+
line("warn", "gateway", `${arrow("info")} ${c(chalk.yellow, message)}`);
|
|
177
|
+
},
|
|
178
|
+
error(message) {
|
|
179
|
+
line("error", "gateway", `${arrow("info")} ${c(chalk.red, message)}`);
|
|
180
|
+
},
|
|
181
|
+
banner(host, port, logPath) {
|
|
182
|
+
line("info", "gateway", `${arrow("info")} listening on ws://${host}:${port}`);
|
|
183
|
+
line("info", "gateway", `${arrow("info")} jsonl log: ${logPath}`);
|
|
184
|
+
line("info", "gateway", `${arrow("info")} log level: ${c(chalk.bold, level)} ${c(chalk.dim, "— Ctrl+C to stop")}`);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=console-stream.js.map
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider error classification — coerces a raw provider/transport error
|
|
3
|
+
* into a structured class plus retry-policy decisions.
|
|
4
|
+
*
|
|
5
|
+
* The same network blip can mean 5 different things to 5 different providers;
|
|
6
|
+
* Brigade needs to know which CLASS an error falls into so the right recovery
|
|
7
|
+
* strategy fires:
|
|
8
|
+
*
|
|
9
|
+
* - rate_limit → wait the Retry-After header (capped), then retry
|
|
10
|
+
* - server_5xx → exponential backoff retry
|
|
11
|
+
* - network → quick retry (cap 3 attempts)
|
|
12
|
+
* - timeout → quick retry, then fall through to model fallback
|
|
13
|
+
* - context_overflow → trigger smart compaction THEN retry SAME model
|
|
14
|
+
* (DON'T fallback — every model has the same window
|
|
15
|
+
* until you compact)
|
|
16
|
+
* - auth → fall through to next model (or rotate key first)
|
|
17
|
+
* - auth_permanent → fall through, mark provider unusable for the session
|
|
18
|
+
* - content_filter → fall through (model declined; another model might not)
|
|
19
|
+
* - unknown → fall through (treat as failover-eligible)
|
|
20
|
+
*
|
|
21
|
+
* Brigade's `runWithFallback` consults this classifier to decide whether to
|
|
22
|
+
* retry on the same model, advance to the next fallback, or rethrow.
|
|
23
|
+
*
|
|
24
|
+
* Heuristics are pattern matches on the error MESSAGE (since providers don't
|
|
25
|
+
* universally use HTTP status codes — Pi normalizes some, others come through
|
|
26
|
+
* raw). False classification costs a wrong recovery; we lean toward "retry"
|
|
27
|
+
* over "rethrow" to keep the user's turn alive.
|
|
28
|
+
*/
|
|
29
|
+
/* ─────────────────────────── pattern tables ─────────────────────────── */
|
|
30
|
+
/** Node/HTTP transport error codes that indicate a transient network failure. */
|
|
31
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
32
|
+
"ETIMEDOUT",
|
|
33
|
+
"ESOCKETTIMEDOUT",
|
|
34
|
+
"ECONNRESET",
|
|
35
|
+
"ECONNABORTED",
|
|
36
|
+
"ECONNREFUSED",
|
|
37
|
+
"ENETUNREACH",
|
|
38
|
+
"EHOSTUNREACH",
|
|
39
|
+
"EHOSTDOWN",
|
|
40
|
+
"ENETRESET",
|
|
41
|
+
"EPIPE",
|
|
42
|
+
"EAI_AGAIN",
|
|
43
|
+
]);
|
|
44
|
+
/** HTTP status codes commonly returned during transient upstream outages. */
|
|
45
|
+
const SERVER_5XX_CODES = new Set([499, 500, 502, 503, 504, 521, 522, 523, 524, 529]);
|
|
46
|
+
/** Provider error patterns that signal "context too long". */
|
|
47
|
+
const CONTEXT_OVERFLOW_PATTERNS = [
|
|
48
|
+
/context\s+(?:length|size|window)/i,
|
|
49
|
+
/maximum\s+context/i,
|
|
50
|
+
/token\s+limit/i,
|
|
51
|
+
/too\s+many\s+tokens/i,
|
|
52
|
+
/reduce\s+the\s+length/i,
|
|
53
|
+
/exceeds?\s+the\s+limit/i,
|
|
54
|
+
/prompt\s+is\s+too\s+long/i,
|
|
55
|
+
/context_window_exceeded/i,
|
|
56
|
+
];
|
|
57
|
+
const RATE_LIMIT_PATTERNS = [
|
|
58
|
+
/rate\s*limit/i,
|
|
59
|
+
/too\s+many\s+requests/i,
|
|
60
|
+
/requests\s+per\s+(?:minute|hour|day|second)/i,
|
|
61
|
+
/quota/i,
|
|
62
|
+
/throttl(?:ed|ing)/i,
|
|
63
|
+
/\b429\b/,
|
|
64
|
+
/tokens?\s+per\s+day/i,
|
|
65
|
+
/overloaded/i,
|
|
66
|
+
];
|
|
67
|
+
const AUTH_PATTERNS = [
|
|
68
|
+
/invalid\s+api\s+key/i,
|
|
69
|
+
/(?:un)?authenticat/i,
|
|
70
|
+
/unauthor[is]z/i,
|
|
71
|
+
/forbidden/i,
|
|
72
|
+
/invalid\s+token/i,
|
|
73
|
+
/access\s+denied/i,
|
|
74
|
+
/token\s+expired/i,
|
|
75
|
+
/token\s+revoked/i,
|
|
76
|
+
/incorrect\s+api\s+key/i,
|
|
77
|
+
];
|
|
78
|
+
/** auth errors that ARE NOT recoverable by waiting/retry — billing, quota exhausted, key revoked */
|
|
79
|
+
const AUTH_PERMANENT_PATTERNS = [
|
|
80
|
+
/billing/i,
|
|
81
|
+
/payment\s+required/i,
|
|
82
|
+
/insufficient\s+(?:funds|credit|quota)/i,
|
|
83
|
+
/account\s+(?:disabled|suspended|terminated)/i,
|
|
84
|
+
];
|
|
85
|
+
const CONTENT_FILTER_PATTERNS = [
|
|
86
|
+
/content\s+filter/i,
|
|
87
|
+
/content\s+policy/i,
|
|
88
|
+
/safety/i,
|
|
89
|
+
// "I cannot respond to that" / "I'm unable to assist" / "won't comply" etc.
|
|
90
|
+
// Allow "to" in between OR not, since both phrasings are common.
|
|
91
|
+
/\b(?:cannot|can(?:'|’)?t|unable\s+to|won(?:'|’)?t)\s+(?:to\s+)?(?:respond|comply|assist|help|provide|do\s+that|continue)/i,
|
|
92
|
+
/refus(?:al|ed)/i,
|
|
93
|
+
];
|
|
94
|
+
const MODEL_NOT_FOUND_PATTERNS = [
|
|
95
|
+
/model\s+(?:not|does\s+not)\s+(?:found|exist|available)/i,
|
|
96
|
+
// "model gpt-99 does not exist" — allow chars between "model" and "does not exist"
|
|
97
|
+
/\bmodel\b[^.\n]{0,80}(?:does\s+not\s+exist|not\s+(?:found|available)|is\s+(?:invalid|deprecated))/i,
|
|
98
|
+
/no\s+such\s+model/i,
|
|
99
|
+
/unknown\s+model/i,
|
|
100
|
+
/\b404\b.*model/i,
|
|
101
|
+
];
|
|
102
|
+
/* ─────────────────────────── classifier ─────────────────────────── */
|
|
103
|
+
/**
|
|
104
|
+
* Classify a thrown error or an `errorMessage` string from an assistant
|
|
105
|
+
* message with `stopReason: "error"`.
|
|
106
|
+
*
|
|
107
|
+
* `unknown` is the safe default — the caller's fallback chain will treat
|
|
108
|
+
* it as failover-eligible just like a known transient error.
|
|
109
|
+
*/
|
|
110
|
+
export function classifyError(err) {
|
|
111
|
+
const message = extractMessage(err);
|
|
112
|
+
const code = extractCode(err);
|
|
113
|
+
const status = extractStatus(err);
|
|
114
|
+
// Network errors first — code is the most reliable signal.
|
|
115
|
+
if (code && NETWORK_ERROR_CODES.has(code)) {
|
|
116
|
+
return {
|
|
117
|
+
class: code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT" || code === "ECONNABORTED"
|
|
118
|
+
? "timeout"
|
|
119
|
+
: "network",
|
|
120
|
+
message,
|
|
121
|
+
retryableOnSameModel: true, // network blip — same model is likely fine
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// HTTP status code matches — these are the most reliable for HTTP errors.
|
|
125
|
+
if (typeof status === "number") {
|
|
126
|
+
if (status === 429) {
|
|
127
|
+
return {
|
|
128
|
+
class: "rate_limit",
|
|
129
|
+
message,
|
|
130
|
+
retryAfterMs: parseRetryAfter(err),
|
|
131
|
+
retryableOnSameModel: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (SERVER_5XX_CODES.has(status)) {
|
|
135
|
+
return {
|
|
136
|
+
class: "server_5xx",
|
|
137
|
+
message,
|
|
138
|
+
retryableOnSameModel: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (status === 401 || status === 403) {
|
|
142
|
+
const isPermanent = AUTH_PERMANENT_PATTERNS.some((p) => p.test(message));
|
|
143
|
+
return {
|
|
144
|
+
class: isPermanent ? "auth_permanent" : "auth",
|
|
145
|
+
message,
|
|
146
|
+
retryableOnSameModel: false, // need different credentials → fall through
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (status === 402) {
|
|
150
|
+
return {
|
|
151
|
+
class: "auth_permanent", // payment required = billing-blocked
|
|
152
|
+
message,
|
|
153
|
+
retryableOnSameModel: false,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (status === 404) {
|
|
157
|
+
return {
|
|
158
|
+
class: "model_not_found",
|
|
159
|
+
message,
|
|
160
|
+
retryableOnSameModel: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Pattern matches on message — last resort, but covers providers that
|
|
165
|
+
// don't surface clean status codes (xAI, custom OpenAI-compatible endpoints).
|
|
166
|
+
if (CONTEXT_OVERFLOW_PATTERNS.some((p) => p.test(message))) {
|
|
167
|
+
return {
|
|
168
|
+
class: "context_overflow",
|
|
169
|
+
message,
|
|
170
|
+
retryableOnSameModel: true, // after compaction, same model is fine
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (RATE_LIMIT_PATTERNS.some((p) => p.test(message))) {
|
|
174
|
+
return {
|
|
175
|
+
class: "rate_limit",
|
|
176
|
+
message,
|
|
177
|
+
retryAfterMs: parseRetryAfter(err),
|
|
178
|
+
retryableOnSameModel: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (AUTH_PERMANENT_PATTERNS.some((p) => p.test(message))) {
|
|
182
|
+
return {
|
|
183
|
+
class: "auth_permanent",
|
|
184
|
+
message,
|
|
185
|
+
retryableOnSameModel: false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (AUTH_PATTERNS.some((p) => p.test(message))) {
|
|
189
|
+
return {
|
|
190
|
+
class: "auth",
|
|
191
|
+
message,
|
|
192
|
+
retryableOnSameModel: false,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (CONTENT_FILTER_PATTERNS.some((p) => p.test(message))) {
|
|
196
|
+
return {
|
|
197
|
+
class: "content_filter",
|
|
198
|
+
message,
|
|
199
|
+
retryableOnSameModel: false, // model declined; pivot to a different one
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (MODEL_NOT_FOUND_PATTERNS.some((p) => p.test(message))) {
|
|
203
|
+
return {
|
|
204
|
+
class: "model_not_found",
|
|
205
|
+
message,
|
|
206
|
+
retryableOnSameModel: false,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
class: "unknown",
|
|
211
|
+
message,
|
|
212
|
+
retryableOnSameModel: false,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Decide what to do with a classified error. Returns the next backoff and
|
|
217
|
+
* whether to retry on the same model.
|
|
218
|
+
*
|
|
219
|
+
* Defaults follow a transient cooldown ladder of 30s → 60s → 5min for the
|
|
220
|
+
* first three attempts on rate-limit / 5xx.
|
|
221
|
+
* Network errors get a much shorter backoff (200ms → 1s → 3s) since they're
|
|
222
|
+
* usually transient TCP blips.
|
|
223
|
+
*
|
|
224
|
+
* `context_overflow` is special: caller should run smart compaction BEFORE
|
|
225
|
+
* retrying — the delay is 0 because we're not waiting on the network, we're
|
|
226
|
+
* waiting on local work.
|
|
227
|
+
*/
|
|
228
|
+
export function decideRetry(c, opts) {
|
|
229
|
+
const max = opts.maxAttempts ?? 3;
|
|
230
|
+
const maxDelay = opts.maxDelayMs ?? 60_000;
|
|
231
|
+
if (!c.retryableOnSameModel) {
|
|
232
|
+
return { retry: false, delayMs: 0, reason: `${c.class} — advance to fallback` };
|
|
233
|
+
}
|
|
234
|
+
if (opts.attempt > max) {
|
|
235
|
+
return { retry: false, delayMs: 0, reason: `${c.class} — exhausted retries on this model` };
|
|
236
|
+
}
|
|
237
|
+
switch (c.class) {
|
|
238
|
+
case "rate_limit": {
|
|
239
|
+
// Honor Retry-After when present, capped at maxDelay. Otherwise
|
|
240
|
+
// use the cooldown ladder (30s / 60s / 5min).
|
|
241
|
+
const ladder = [30_000, 60_000, 5 * 60_000];
|
|
242
|
+
const fromLadder = ladder[Math.min(opts.attempt - 1, ladder.length - 1)];
|
|
243
|
+
const delay = c.retryAfterMs
|
|
244
|
+
? Math.min(c.retryAfterMs, maxDelay)
|
|
245
|
+
: Math.min(fromLadder, maxDelay);
|
|
246
|
+
return { retry: true, delayMs: delay, reason: `rate-limited — waiting ${delay}ms (attempt ${opts.attempt}/${max})` };
|
|
247
|
+
}
|
|
248
|
+
case "server_5xx": {
|
|
249
|
+
// Exponential 1s/2s/4s + jitter; cap at maxDelay.
|
|
250
|
+
const base = 1000 * 2 ** (opts.attempt - 1);
|
|
251
|
+
const jitter = Math.floor(Math.random() * 500);
|
|
252
|
+
const delay = Math.min(base + jitter, maxDelay);
|
|
253
|
+
return { retry: true, delayMs: delay, reason: `server error — retrying in ${delay}ms (attempt ${opts.attempt}/${max})` };
|
|
254
|
+
}
|
|
255
|
+
case "network":
|
|
256
|
+
case "timeout": {
|
|
257
|
+
// Quick retry — TCP blips usually heal in <1s.
|
|
258
|
+
const ladder = [200, 1_000, 3_000];
|
|
259
|
+
const delay = ladder[Math.min(opts.attempt - 1, ladder.length - 1)];
|
|
260
|
+
return { retry: true, delayMs: delay, reason: `${c.class} — quick retry in ${delay}ms` };
|
|
261
|
+
}
|
|
262
|
+
case "context_overflow": {
|
|
263
|
+
// Caller should compact first; no network wait.
|
|
264
|
+
return { retry: true, delayMs: 0, reason: `context overflow — compact then retry` };
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
return { retry: false, delayMs: 0, reason: `${c.class} — not retryable on same model` };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/* ─────────────────────────── helpers ─────────────────────────── */
|
|
271
|
+
function extractMessage(err) {
|
|
272
|
+
if (err instanceof Error)
|
|
273
|
+
return err.message;
|
|
274
|
+
if (typeof err === "string")
|
|
275
|
+
return err;
|
|
276
|
+
if (err && typeof err === "object" && "message" in err)
|
|
277
|
+
return String(err.message);
|
|
278
|
+
return String(err);
|
|
279
|
+
}
|
|
280
|
+
function extractCode(err) {
|
|
281
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
282
|
+
const c = err.code;
|
|
283
|
+
if (typeof c === "string")
|
|
284
|
+
return c;
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Pull HTTP status from common error shapes:
|
|
290
|
+
* - error.status (Anthropic SDK)
|
|
291
|
+
* - error.statusCode (some Node libs)
|
|
292
|
+
* - error.response.status (Axios / fetch wrappers)
|
|
293
|
+
* - "HTTP 429" / "status 503" in message
|
|
294
|
+
*/
|
|
295
|
+
function extractStatus(err) {
|
|
296
|
+
if (!err || typeof err !== "object")
|
|
297
|
+
return undefined;
|
|
298
|
+
const e = err;
|
|
299
|
+
if (typeof e.status === "number")
|
|
300
|
+
return e.status;
|
|
301
|
+
if (typeof e.statusCode === "number")
|
|
302
|
+
return e.statusCode;
|
|
303
|
+
if (e.response && typeof e.response.status === "number")
|
|
304
|
+
return e.response.status;
|
|
305
|
+
// Last resort: scrape the message.
|
|
306
|
+
const msg = extractMessage(err);
|
|
307
|
+
const httpMatch = msg.match(/\b(?:HTTP|status)\s*(?::|\s)\s*(\d{3})\b/i);
|
|
308
|
+
if (httpMatch) {
|
|
309
|
+
const n = Number(httpMatch[1]);
|
|
310
|
+
if (n >= 100 && n < 600)
|
|
311
|
+
return n;
|
|
312
|
+
}
|
|
313
|
+
// Bare 4xx/5xx in the message — only trust if it's clearly a status (e.g. " 429 ").
|
|
314
|
+
const bareMatch = msg.match(/\b([45]\d{2})\b/);
|
|
315
|
+
if (bareMatch) {
|
|
316
|
+
const n = Number(bareMatch[1]);
|
|
317
|
+
// Sanity: don't pick up token counts. Require it to look HTTP-y.
|
|
318
|
+
if ([401, 403, 404, 429, 500, 502, 503, 504].includes(n))
|
|
319
|
+
return n;
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Parse Retry-After. Providers express this as either a delta-seconds integer
|
|
325
|
+
* or an HTTP-date string per RFC 7231; we accept both.
|
|
326
|
+
*
|
|
327
|
+
* Sources:
|
|
328
|
+
* - error.headers["retry-after"] (fetch-style)
|
|
329
|
+
* - error.response.headers["retry-after"] (Axios)
|
|
330
|
+
* - "retry after Ns" / "retry in Ns" in message
|
|
331
|
+
*/
|
|
332
|
+
function parseRetryAfter(err) {
|
|
333
|
+
if (!err || typeof err !== "object")
|
|
334
|
+
return undefined;
|
|
335
|
+
const e = err;
|
|
336
|
+
const fromHeader = e.headers?.["retry-after"] ??
|
|
337
|
+
e.response?.headers?.["retry-after"] ??
|
|
338
|
+
e.response?.headers?.get?.("retry-after");
|
|
339
|
+
if (fromHeader) {
|
|
340
|
+
const seconds = Number(fromHeader);
|
|
341
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
342
|
+
return Math.floor(seconds * 1000);
|
|
343
|
+
const date = Date.parse(String(fromHeader));
|
|
344
|
+
if (Number.isFinite(date))
|
|
345
|
+
return Math.max(0, date - Date.now());
|
|
346
|
+
}
|
|
347
|
+
// Scrape from message: "retry after 30s" / "try again in 60 seconds"
|
|
348
|
+
const msg = extractMessage(err);
|
|
349
|
+
const m = msg.match(/(?:retry|try again)\s+(?:after|in)\s+(\d+)\s*s(?:ec)?/i);
|
|
350
|
+
if (m)
|
|
351
|
+
return Number(m[1]) * 1000;
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
//# sourceMappingURL=error-classifier.js.map
|