@worca/app 1.0.0 → 1.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
package/src/core/model-env.mjs
CHANGED
|
@@ -20,13 +20,17 @@ export const EFFORTS = ['medium', 'high', 'xhigh', 'max'];
|
|
|
20
20
|
// Env keys a model entry may NOT set (§4.4): process fundamentals and worca's
|
|
21
21
|
// own runtime knobs, any of which injection could otherwise subvert (mock
|
|
22
22
|
// mode, the claude binary path, the effort flag name). Everything else —
|
|
23
|
-
// including all ANTHROPIC_* / CLAUDE_* — is allowed: routing them is the
|
|
23
|
+
// including all other ANTHROPIC_* / CLAUDE_* — is allowed: routing them is the
|
|
24
24
|
// point. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is the CLI-2.1.220 permission-mode
|
|
25
25
|
// landmine documented at claude-runner.mjs#buildSpawnEnv.
|
|
26
|
+
// CLAUDE_CODE_SUBAGENT_MODEL is reserved too: the per-node `subagentModel`
|
|
27
|
+
// prompt policy is the only sanctioned wire for a child's model, and a catalog
|
|
28
|
+
// entry silently flooring every fan-out child would contradict the per-node
|
|
29
|
+
// control the UI shows.
|
|
26
30
|
export const RESERVED_MODEL_ENV_KEYS = [
|
|
27
31
|
'PATH', 'HOME', 'TMPDIR', 'SHELL', 'USER', 'LOGNAME', 'TERM',
|
|
28
32
|
'NODE_OPTIONS', 'NODE_EXTRA_CA_CERTS',
|
|
29
|
-
'CLAUDECODE', 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB',
|
|
33
|
+
'CLAUDECODE', 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB', 'CLAUDE_CODE_SUBAGENT_MODEL',
|
|
30
34
|
];
|
|
31
35
|
export const RESERVED_MODEL_ENV_PREFIXES = ['WORCA_'];
|
|
32
36
|
|
|
@@ -49,9 +53,12 @@ export function modelEnvRef(value) {
|
|
|
49
53
|
/**
|
|
50
54
|
* Defensive spawn-time pass over a model entry's env (§4.4): drops reserved
|
|
51
55
|
* keys and non-string values, and expands whole-value ${VAR} refs from
|
|
52
|
-
* `sourceEnv` (an unset or empty var drops the key).
|
|
53
|
-
*
|
|
54
|
-
*
|
|
56
|
+
* `sourceEnv` (an unset or empty var drops the key). Values are trimmed and a
|
|
57
|
+
* value that is empty after trimming drops the key — an ANTHROPIC_MODEL wire id
|
|
58
|
+
* (#374) reaches `--model` verbatim, so a stray-whitespace or empty value must
|
|
59
|
+
* not survive. The user catalog rejects reserved keys at write time and plugin
|
|
60
|
+
* manifests strip them at normalize time, so a drop here means a hand-edited
|
|
61
|
+
* settings file — the caller owns warning about `dropped`.
|
|
55
62
|
* @param {Record<string,*>|undefined} modelEnv
|
|
56
63
|
* @param {Record<string,string|undefined>} [sourceEnv]
|
|
57
64
|
* @returns {{env: Record<string,string>, dropped: string[]}}
|
|
@@ -64,11 +71,176 @@ export function prepareModelEnv(modelEnv, sourceEnv = process.env) {
|
|
|
64
71
|
const ref = modelEnvRef(v);
|
|
65
72
|
if (ref !== null) {
|
|
66
73
|
const resolved = sourceEnv ? sourceEnv[ref] : undefined;
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
const t = typeof resolved === 'string' ? resolved.trim() : '';
|
|
75
|
+
if (!t) { dropped.push(k); continue; }
|
|
76
|
+
env[k] = t;
|
|
69
77
|
} else {
|
|
70
|
-
|
|
78
|
+
const t = v.trim();
|
|
79
|
+
if (!t) { dropped.push(k); continue; }
|
|
80
|
+
env[k] = t;
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
return { env, dropped };
|
|
74
84
|
}
|
|
85
|
+
|
|
86
|
+
// ── env flags + masking (shared by claude-runner.mjs, plugin-shim.mjs, ui/server.mjs)
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The ONE "is this env flag on" rule for worca's own knobs (WORCA_MOCK,
|
|
90
|
+
* WORCA_SUBAGENT_HOOKS, WORCA_DEBUG_SPAWN, …): a denylist — anything but unset,
|
|
91
|
+
* "", "0" and "false" (any case) is on. Several names may be given; the first
|
|
92
|
+
* one that is set wins (WORCA_MOCK ?? ORCH_MOCK). Lives in this zero-import leaf
|
|
93
|
+
* so every gate shares it instead of hand-copying the comparison.
|
|
94
|
+
* @param {...string} names
|
|
95
|
+
*/
|
|
96
|
+
export function envFlag(...names) {
|
|
97
|
+
let v;
|
|
98
|
+
for (const n of names) { v = process.env[n]; if (v !== undefined) break; }
|
|
99
|
+
return !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Mask a model-env VALUE for an operator-facing display (the Models editor):
|
|
104
|
+
* six bullets + the last 4 chars when longer than 8, else six bullets. The
|
|
105
|
+
* `••` prefix is what ui/server.mjs#isMaskedEcho keys on to treat an echoed
|
|
106
|
+
* value as "keep", so the shape is a contract — change both together. For LOG
|
|
107
|
+
* lines use describeModelEnvEntry: a per-spawn log must not carry a suffix.
|
|
108
|
+
*/
|
|
109
|
+
export function maskModelEnvValue(v) {
|
|
110
|
+
const s = String(v ?? '');
|
|
111
|
+
return s.length > 8 ? `••••••${s.slice(-4)}` : '••••••';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Keys whose value is routing configuration, not a credential, and therefore
|
|
115
|
+
// SAFE to print in a spawn log: which endpoint / which wire id a spawn used is
|
|
116
|
+
// exactly the diagnostic question, and masking them makes two gateway cards
|
|
117
|
+
// indistinguishable. Everything else (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY,
|
|
118
|
+
// ANTHROPIC_CUSTOM_HEADERS, plugin {secret} values, …) is treated as a secret.
|
|
119
|
+
const READABLE_MODEL_ENV_KEYS = new Set([
|
|
120
|
+
'ANTHROPIC_MODEL', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL',
|
|
121
|
+
]);
|
|
122
|
+
const READABLE_MODEL_ENV_KEY_RES = [/^ANTHROPIC_DEFAULT_[A-Z0-9]+_MODEL$/, /^CLAUDE_CODE_USE_[A-Z0-9]+$/];
|
|
123
|
+
|
|
124
|
+
/** Whether a model-env key's value may be printed verbatim in a log line. */
|
|
125
|
+
export function isReadableModelEnvKey(key) {
|
|
126
|
+
return typeof key === 'string'
|
|
127
|
+
&& (READABLE_MODEL_ENV_KEYS.has(key) || READABLE_MODEL_ENV_KEY_RES.some((re) => re.test(key)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One `KEY=value` fragment for a log line. Readable keys print their value
|
|
132
|
+
* (a URL with userinfo has the credentials stripped; an unparsable URL is
|
|
133
|
+
* treated as a secret); every other key prints `<set, N chars>` — presence and
|
|
134
|
+
* length prove the env reached the spawn without leaking any part of it.
|
|
135
|
+
*/
|
|
136
|
+
export function describeModelEnvEntry(key, value) {
|
|
137
|
+
const s = String(value ?? '');
|
|
138
|
+
const secret = `<set, ${s.length} chars>`;
|
|
139
|
+
if (!isReadableModelEnvKey(key)) return `${key}=${secret}`;
|
|
140
|
+
if (key === 'ANTHROPIC_BASE_URL') {
|
|
141
|
+
let u;
|
|
142
|
+
try { u = new URL(s); } catch { return `${key}=${secret}`; }
|
|
143
|
+
if (u.username || u.password) { u.username = ''; u.password = ''; }
|
|
144
|
+
return `${key}=${u.href}`;
|
|
145
|
+
}
|
|
146
|
+
return `${key}=${s}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The sorted, log-safe `KEY=value, …` rendering of a whole model env. */
|
|
150
|
+
export function describeModelEnv(env) {
|
|
151
|
+
return Object.keys(env || {}).sort().map((k) => describeModelEnvEntry(k, env[k])).join(', ');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── per-model cost override (opt-in pricing, config.mjs resolveModelCost) ─────
|
|
155
|
+
// Lives HERE for the same reason the env policy does: BOTH catalog layers must
|
|
156
|
+
// validate it against one rule. settings.mjs owns the user's global catalog and
|
|
157
|
+
// plugin-manifest.mjs owns a plugin's models — neither may import the other, and
|
|
158
|
+
// this leaf imports nothing. A plugin that ships a model routed at its own
|
|
159
|
+
// endpoint is exactly the case that needs a price pinned, so its manifest
|
|
160
|
+
// carries `cost` with the same shape and the same validation as a global entry.
|
|
161
|
+
|
|
162
|
+
/** Allowed per-million-token rate keys for a model's `cost.perMtok` table. */
|
|
163
|
+
export const COST_RATE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite', 'cacheWrite1h'];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validate a model `cost` override. Returns the normalized shape or undefined;
|
|
167
|
+
* THROWS on malformed input (callers that must not throw catch and drop).
|
|
168
|
+
* { free: true } → recorded spend is always $0
|
|
169
|
+
* { perMtok: { input, output, cacheRead, … } } → USD per million tokens, >= 0
|
|
170
|
+
* `{ free: false }` / `{}` mean "no override" → undefined.
|
|
171
|
+
* @param {*} cost
|
|
172
|
+
* @returns {{free:true}|{perMtok:Record<string,number>}|undefined}
|
|
173
|
+
* @throws {Error}
|
|
174
|
+
*/
|
|
175
|
+
export function assertModelCost(cost) {
|
|
176
|
+
if (cost === undefined || cost === null) return undefined;
|
|
177
|
+
if (typeof cost !== 'object' || Array.isArray(cost)) throw new Error('cost must be an object');
|
|
178
|
+
if (cost.free !== undefined && typeof cost.free !== 'boolean') throw new Error('cost.free must be a boolean');
|
|
179
|
+
if (cost.free === true) return { free: true };
|
|
180
|
+
if (cost.perMtok !== undefined) {
|
|
181
|
+
const p = cost.perMtok;
|
|
182
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) {
|
|
183
|
+
throw new Error('cost.perMtok must be an object of USD-per-million-token rates');
|
|
184
|
+
}
|
|
185
|
+
const rates = {};
|
|
186
|
+
for (const [k, v] of Object.entries(p)) {
|
|
187
|
+
if (!COST_RATE_KEYS.includes(k)) {
|
|
188
|
+
throw new Error(`unknown cost.perMtok rate ${JSON.stringify(k)} — allowed: ${COST_RATE_KEYS.join(', ')}`);
|
|
189
|
+
}
|
|
190
|
+
const n = Number(v);
|
|
191
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`cost.perMtok.${k} must be a finite number >= 0`);
|
|
192
|
+
rates[k] = n;
|
|
193
|
+
}
|
|
194
|
+
if (!Object.keys(rates).length) throw new Error('cost.perMtok must define at least one rate');
|
|
195
|
+
return { perMtok: rates };
|
|
196
|
+
}
|
|
197
|
+
return undefined; // { free: false } or {} — no override
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── sub-agent model policy (per-node `subagentModel`) ─────────────────────────
|
|
201
|
+
// What a fan-out node's Task/Agent children run on. ONE wire — a prompt block
|
|
202
|
+
// (phases.mjs#subagentModelDirective) that tells the agent to pass `model` on
|
|
203
|
+
// every Task call — because the CLI resolves a child's model as Task-call
|
|
204
|
+
// `model` > the agent definition's own `model:` frontmatter > env default >
|
|
205
|
+
// parent: only the explicit Task-level value reliably binds every child. (The
|
|
206
|
+
// earlier CLAUDE_CODE_SUBAGENT_MODEL env floor was removed for exactly that
|
|
207
|
+
// reason — it bound only agents with no model key — and the key is reserved
|
|
208
|
+
// above so a catalog entry cannot resurrect it.)
|
|
209
|
+
//
|
|
210
|
+
// The vocabulary is deliberately NOT the worca catalog: the CLI's Task tool
|
|
211
|
+
// accepts an ALIAS enum, so a catalog id (or an ANTHROPIC_MODEL wire id) would
|
|
212
|
+
// be rejected at spawn time. Haiku is excluded by product decision.
|
|
213
|
+
export const SUBAGENT_MODELS = ['sonnet', 'opus', 'fable'];
|
|
214
|
+
|
|
215
|
+
/** "the agent picks per Task call" — a choice rubric in the prompt. */
|
|
216
|
+
export const SUBAGENT_AUTO = 'auto';
|
|
217
|
+
|
|
218
|
+
/** "children ride the CLI's own resolution" (an agent definition's frontmatter,
|
|
219
|
+
* else the parent's model) — the pre-feature prompt. Stored explicitly,
|
|
220
|
+
* because the DEFAULT for an unset node is `auto`, not this. */
|
|
221
|
+
export const SUBAGENT_INHERIT = 'inherit';
|
|
222
|
+
|
|
223
|
+
/** Every storable `subagentModel`. '' / absent are NOT storable — they mean
|
|
224
|
+
* "unset", which the runtime resolves to SUBAGENT_DEFAULT. */
|
|
225
|
+
export const SUBAGENT_MODEL_VALUES = [...SUBAGENT_MODELS, SUBAGENT_AUTO, SUBAGENT_INHERIT];
|
|
226
|
+
|
|
227
|
+
/** What an unset node resolves to at run time: agents choose BY DEFAULT. */
|
|
228
|
+
export const SUBAGENT_DEFAULT = SUBAGENT_AUTO;
|
|
229
|
+
|
|
230
|
+
/** Whether `v` is a storable subagentModel. */
|
|
231
|
+
export function isSubagentModelValue(v) {
|
|
232
|
+
return typeof v === 'string' && SUBAGENT_MODEL_VALUES.includes(v);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The policy a raw stored value puts in force: a legal value is itself; '' /
|
|
236
|
+
* absent / anything that escaped validation is the auto default. */
|
|
237
|
+
export function effectiveSubagentModel(v) {
|
|
238
|
+
return isSubagentModelValue(v) ? v : SUBAGENT_DEFAULT;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The one validation message every writer shares, '' when `v` is acceptable
|
|
242
|
+
* ('' and null/undefined mean clear/absent and are always fine). */
|
|
243
|
+
export function subagentModelIssue(v) {
|
|
244
|
+
if (v == null || v === '' || isSubagentModelValue(v)) return '';
|
|
245
|
+
return `unknown sub-agent model ${JSON.stringify(String(v))}`;
|
|
246
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/core/model-test.mjs
|
|
2
|
+
// One minimal live claude spawn to verify a catalog model actually works —
|
|
3
|
+
// the Models-view Test button (POST /api/models/:id/test). Mirrors the cheap
|
|
4
|
+
// aux-run pattern of title.mjs: the model's catalog routing env travels with
|
|
5
|
+
// the id via resolveModelEnv, no tools, low effort, hard timeout. Never
|
|
6
|
+
// throws — the outcome is a result object either way.
|
|
7
|
+
|
|
8
|
+
import { runClaude } from './claude-runner.mjs';
|
|
9
|
+
import { resolveModelEnv } from './config.mjs';
|
|
10
|
+
import { classifyError } from './recoverable-error.mjs';
|
|
11
|
+
|
|
12
|
+
const TEST_TIMEOUT_MS = 60_000;
|
|
13
|
+
const REPLY_CAP = 100;
|
|
14
|
+
|
|
15
|
+
const SYSTEM = 'You are a connectivity check. Reply with exactly OK.';
|
|
16
|
+
|
|
17
|
+
/** Actionable hint for a recovery class ('' when there is nothing to add —
|
|
18
|
+
* the raw runner message already carries the detail). Pure, for tests. */
|
|
19
|
+
export function hintFor(errorClass) {
|
|
20
|
+
switch (errorClass) {
|
|
21
|
+
case 'auth': return 'authentication failed — check the token/secret for this model';
|
|
22
|
+
case 'network': return 'endpoint unreachable — check ANTHROPIC_BASE_URL';
|
|
23
|
+
case 'rate_limit': return 'the endpoint is rate-limiting or overloaded — try again shortly';
|
|
24
|
+
case 'quota': return 'quota/billing problem — check the account behind this endpoint';
|
|
25
|
+
case 'usage_limit': return 'usage limit reached on the account behind this endpoint';
|
|
26
|
+
case 'timeout': return `no reply — the test timed out after ${TEST_TIMEOUT_MS / 1000}s`;
|
|
27
|
+
default: return '';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Live connectivity check for a catalog model id (global or plugin — the
|
|
33
|
+
* resolution precedence is resolveModelEnv's). Explicit user action only.
|
|
34
|
+
* @param {string} id catalog model id
|
|
35
|
+
* @param {{signal?:AbortSignal, bin?:string, run?:typeof runClaude}} [opts]
|
|
36
|
+
* `run` is injectable for unit tests.
|
|
37
|
+
* @returns {Promise<{ok:true, text:string}|{ok:false, errorClass:(string|null), message:string, hint?:string}>}
|
|
38
|
+
*/
|
|
39
|
+
export async function testModel(id, { signal, bin, run = runClaude } = {}) {
|
|
40
|
+
const ctrl = new AbortController();
|
|
41
|
+
const onOuterAbort = () => ctrl.abort();
|
|
42
|
+
if (signal) {
|
|
43
|
+
if (signal.aborted) ctrl.abort();
|
|
44
|
+
else signal.addEventListener('abort', onOuterAbort, { once: true });
|
|
45
|
+
}
|
|
46
|
+
const timer = setTimeout(() => ctrl.abort(), TEST_TIMEOUT_MS);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
try {
|
|
49
|
+
const { text } = await run({
|
|
50
|
+
cwd: process.cwd(),
|
|
51
|
+
systemPrompt: SYSTEM,
|
|
52
|
+
prompt: 'Reply with exactly OK.',
|
|
53
|
+
model: id,
|
|
54
|
+
modelEnv: resolveModelEnv(id),
|
|
55
|
+
effort: 'low',
|
|
56
|
+
permissionMode: 'acceptEdits',
|
|
57
|
+
allowedTools: [], // empty → no --allowedTools flag; pure text gen
|
|
58
|
+
signal: ctrl.signal,
|
|
59
|
+
bin,
|
|
60
|
+
onEvent: () => {},
|
|
61
|
+
});
|
|
62
|
+
const reply = String(text || '').split(/\r?\n/).map((l) => l.trim()).find((l) => l) || '';
|
|
63
|
+
if (!reply) {
|
|
64
|
+
return { ok: false, errorClass: null, message: 'the model returned an empty reply' };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, text: reply.slice(0, REPLY_CAP) };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err && err.name === 'AbortError') {
|
|
69
|
+
return { ok: false, errorClass: 'timeout', message: `Timed out after ${TEST_TIMEOUT_MS / 1000}s`, hint: hintFor('timeout') };
|
|
70
|
+
}
|
|
71
|
+
const message = err && err.message ? err.message : String(err);
|
|
72
|
+
const errorClass = (err && err.errorClass) || classifyError(message);
|
|
73
|
+
const hint = hintFor(errorClass);
|
|
74
|
+
return { ok: false, errorClass, message, ...(hint ? { hint } : {}) };
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
if (signal) signal.removeEventListener?.('abort', onOuterAbort);
|
|
78
|
+
}
|
|
79
|
+
}
|