lazyclaw 5.4.4 → 6.0.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/channels/handoff.mjs +36 -0
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +73 -7399
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/daemon.mjs +23 -2085
- package/dotenv_min.mjs +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +16 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/secure_write.mjs +46 -0
- package/sessions.mjs +0 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +2 -1
- package/tui/slash_dispatcher.mjs +717 -58
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- package/mas/tools/skill_view.mjs +0 -43
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Daemon route handlers (ops), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
|
|
6
|
+
export async function trainerStatus(c) {
|
|
7
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
8
|
+
// Reads cfg.trainer.{provider, model, schedule, budget, recipe}
|
|
9
|
+
// and reports last-run state from <configDir>/trainer-state.json
|
|
10
|
+
// if present. No standalone trainer module yet; this is a thin
|
|
11
|
+
// config-surface endpoint the dashboard reads at refresh.
|
|
12
|
+
const cfg = ctx.readConfig();
|
|
13
|
+
const t = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
|
|
14
|
+
let lastRunAt = null, callsToday = null;
|
|
15
|
+
try {
|
|
16
|
+
const statePath = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'trainer-state.json');
|
|
17
|
+
if (fs.existsSync(statePath)) {
|
|
18
|
+
const st = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
19
|
+
lastRunAt = st?.lastRunAt || null;
|
|
20
|
+
// callsToday: count entries whose ts is within the current
|
|
21
|
+
// UTC day. State writer is the (future) trainer; reader
|
|
22
|
+
// tolerates absence.
|
|
23
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
24
|
+
if (Array.isArray(st?.calls)) {
|
|
25
|
+
callsToday = st.calls.filter((c) => String(c.ts || '').startsWith(today)).length;
|
|
26
|
+
} else if (typeof st?.callsToday === 'number') {
|
|
27
|
+
callsToday = st.callsToday;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch { /* missing/corrupt state → null */ }
|
|
31
|
+
return writeJson(res, 200, {
|
|
32
|
+
provider: t.provider || null,
|
|
33
|
+
model: t.model || null,
|
|
34
|
+
schedule: t.schedule || null,
|
|
35
|
+
budget: t.budget != null ? Number(t.budget) : null,
|
|
36
|
+
recipe: t.recipe || null,
|
|
37
|
+
lastRunAt,
|
|
38
|
+
callsToday,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function recall(c) {
|
|
43
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
44
|
+
// GET /recall?q=...&scope=sessions|skills|trajectories|memories|all&k=N
|
|
45
|
+
const q = url.searchParams.get('q');
|
|
46
|
+
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
47
|
+
const scopeParam = url.searchParams.get('scope') || 'all';
|
|
48
|
+
const scope = scopeParam === 'all'
|
|
49
|
+
? ['sessions', 'skills', 'trajectories', 'memories']
|
|
50
|
+
: scopeParam.split(',').map((x) => x.trim()).filter(Boolean);
|
|
51
|
+
const kParam = url.searchParams.get('k');
|
|
52
|
+
const k = kParam ? Math.max(1, Math.min(50, parseInt(kParam, 10) || 10)) : 10;
|
|
53
|
+
try {
|
|
54
|
+
const r = indexDb.recall(q, { configDir: gwConfigDir, scope, k });
|
|
55
|
+
return writeJson(res, 200, r);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return writeJson(res, 500, { error: e?.message || String(e) });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function sandboxList(c) {
|
|
62
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
63
|
+
const cfg = ctx.readConfig();
|
|
64
|
+
const sb = (cfg.sandbox && typeof cfg.sandbox === 'object') ? cfg.sandbox : {};
|
|
65
|
+
const active = sb.default || 'local';
|
|
66
|
+
const profiles = sandboxListBackends().map((name) => {
|
|
67
|
+
const section = sb[name];
|
|
68
|
+
const configured = !!section && typeof section === 'object';
|
|
69
|
+
let summary = '';
|
|
70
|
+
if (configured) {
|
|
71
|
+
if (name === 'docker' && section.image) summary = `image: ${section.image}`;
|
|
72
|
+
else if (name === 'ssh' && section.host) summary = `host: ${section.host}`;
|
|
73
|
+
else if (name === 'singularity' && section.image) summary = `image: ${section.image}`;
|
|
74
|
+
else if (name === 'modal' && section.app) summary = `app: ${section.app}`;
|
|
75
|
+
else if (name === 'daytona' && section.workspace) summary = `workspace: ${section.workspace}`;
|
|
76
|
+
else if (name === 'local' && section.confiner) summary = `confiner: ${section.confiner}`;
|
|
77
|
+
}
|
|
78
|
+
return { name, configured, summary };
|
|
79
|
+
});
|
|
80
|
+
return writeJson(res, 200, { profiles, active });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function sandboxTest(c) {
|
|
84
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
85
|
+
// POST /sandbox/<name>/test — opens a session against the named
|
|
86
|
+
// backend, runs `echo hello`, returns { ok, durationMs, stdout }.
|
|
87
|
+
const name = url.pathname.match(/^\/sandbox\/([^/]+)\/test$/)[1];
|
|
88
|
+
try {
|
|
89
|
+
const sandboxMod = await import('../../sandbox/index.mjs');
|
|
90
|
+
const cfg = ctx.readConfig();
|
|
91
|
+
// Synthesise a one-off cfg.sandbox.default override so we can
|
|
92
|
+
// test a backend without mutating the user's persisted choice.
|
|
93
|
+
const probeCfg = {
|
|
94
|
+
...cfg,
|
|
95
|
+
sandbox: { ...(cfg.sandbox || {}), default: name },
|
|
96
|
+
};
|
|
97
|
+
const t0 = Date.now();
|
|
98
|
+
const box = sandboxMod.resolveSandbox(probeCfg, null);
|
|
99
|
+
const sess = await box.open();
|
|
100
|
+
let result;
|
|
101
|
+
try {
|
|
102
|
+
result = await sess.exec(['echo', 'hello'], { stdio: 'pipe' });
|
|
103
|
+
} finally {
|
|
104
|
+
try { await sess.close(); } catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
const durationMs = Date.now() - t0;
|
|
107
|
+
const ok = result.code === 0;
|
|
108
|
+
return writeJson(res, ok ? 200 : 500, {
|
|
109
|
+
ok,
|
|
110
|
+
durationMs,
|
|
111
|
+
code: result.code,
|
|
112
|
+
stdout: String(result.stdout || '').slice(0, 200),
|
|
113
|
+
stderr: String(result.stderr || '').slice(0, 200),
|
|
114
|
+
});
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return writeJson(res, 500, { ok: false, error: e?.message || String(e), code: e?.code });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function sandboxUse(c) {
|
|
121
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
122
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
123
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
124
|
+
}
|
|
125
|
+
let body;
|
|
126
|
+
try { body = await readJson(req); }
|
|
127
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
128
|
+
const name = body && String(body.name || '').trim();
|
|
129
|
+
if (!name) return writeJson(res, 400, { error: 'name is required' });
|
|
130
|
+
if (!sandboxListBackends().includes(name)) {
|
|
131
|
+
return writeJson(res, 400, { error: `unknown sandbox backend: ${name}` });
|
|
132
|
+
}
|
|
133
|
+
const cfg = ctx.readConfig();
|
|
134
|
+
cfg.sandbox = { ...(cfg.sandbox || {}), default: name };
|
|
135
|
+
ctx.writeConfig(cfg);
|
|
136
|
+
return writeJson(res, 200, { ok: true, active: name });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function channels(c) {
|
|
140
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
141
|
+
// Aggregate cfg.channels.<name> + any channel-specific runtime
|
|
142
|
+
// state we expose. Keeps the dashboard from having to know
|
|
143
|
+
// each channel module's shape.
|
|
144
|
+
const cfg = ctx.readConfig();
|
|
145
|
+
const chCfg = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
146
|
+
// Known built-in channel names (matches channels/ + channels-*).
|
|
147
|
+
const KNOWN = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const name of KNOWN) {
|
|
150
|
+
const sec = chCfg[name];
|
|
151
|
+
if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
|
|
152
|
+
out.push({
|
|
153
|
+
name,
|
|
154
|
+
enabled: !!(sec && (sec.enabled !== false)),
|
|
155
|
+
lastInboundAt: sec?.lastInboundAt || null,
|
|
156
|
+
boundAgent: sec?.agent || sec?.boundAgent || null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Surface any additional configured channels we didn't enumerate.
|
|
160
|
+
for (const name of Object.keys(chCfg)) {
|
|
161
|
+
if (KNOWN.includes(name)) continue;
|
|
162
|
+
const sec = chCfg[name] || {};
|
|
163
|
+
out.push({
|
|
164
|
+
name,
|
|
165
|
+
enabled: sec.enabled !== false,
|
|
166
|
+
lastInboundAt: sec.lastInboundAt || null,
|
|
167
|
+
boundAgent: sec.agent || sec.boundAgent || null,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return writeJson(res, 200, { channels: out });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function indexRebuild(c) {
|
|
174
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
175
|
+
try {
|
|
176
|
+
// reindexAll, not rebuild: a bare rebuild zeroes the FTS index and
|
|
177
|
+
// never repopulates, so this route used to silently wipe recall.
|
|
178
|
+
indexDb.reindexAll(gwConfigDir);
|
|
179
|
+
ctx.indexLastRebuiltAt = new Date().toISOString();
|
|
180
|
+
return writeJson(res, 200, { ok: true, rebuiltAt: ctx.indexLastRebuiltAt });
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return writeJson(res, 500, { ok: false, error: e?.message || String(e) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Daemon route handlers (providers), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
|
|
6
|
+
export async function providersList(c) {
|
|
7
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
8
|
+
// ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
|
|
9
|
+
// The dashboard reads `custom` / `builtinOpenAICompat` / `endpoint`
|
|
10
|
+
// / `docs` to render the right pills + tooltips; CLI callers only
|
|
11
|
+
// need `name` / `requiresApiKey` / `suggestedModels` and ignore
|
|
12
|
+
// the extras (additive change, no migration).
|
|
13
|
+
let out = Object.keys(PROVIDERS).map(name => {
|
|
14
|
+
const meta = PROVIDER_INFO[name] || { name };
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
requiresApiKey: !!meta.requiresApiKey,
|
|
18
|
+
defaultModel: meta.defaultModel || null,
|
|
19
|
+
suggestedModels: meta.suggestedModels || [],
|
|
20
|
+
endpoint: meta.endpoint || null,
|
|
21
|
+
docs: meta.docs || null,
|
|
22
|
+
custom: !!meta.custom,
|
|
23
|
+
builtinOpenAICompat: !!meta.builtinOpenAICompat,
|
|
24
|
+
baseUrl: meta.baseUrl || null,
|
|
25
|
+
envKey: meta.envKey || null,
|
|
26
|
+
keyPrefix: meta.keyPrefix || null,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
const filter = url.searchParams.get('filter');
|
|
30
|
+
if (filter) {
|
|
31
|
+
const f = filter.toLowerCase();
|
|
32
|
+
out = out.filter(p => p.name.toLowerCase().includes(f));
|
|
33
|
+
}
|
|
34
|
+
const limitStr = url.searchParams.get('limit');
|
|
35
|
+
if (limitStr) {
|
|
36
|
+
const n = parseInt(limitStr, 10);
|
|
37
|
+
if (Number.isFinite(n) && n > 0) out = out.slice(0, n);
|
|
38
|
+
}
|
|
39
|
+
return writeJson(res, 200, out);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function providerGet(c) {
|
|
43
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
44
|
+
// GET /providers/<name> — full per-provider metadata
|
|
45
|
+
// (mirrors CLI `lazyclaw providers info <name>`).
|
|
46
|
+
// The `name !== 'test'` guard keeps `/providers/test`
|
|
47
|
+
// (parallel batch endpoint) from being intercepted here;
|
|
48
|
+
// switch-case order ensures the literal `GET /providers/test`
|
|
49
|
+
// case runs first anyway, but the guard makes the intent
|
|
50
|
+
// explicit for future readers.
|
|
51
|
+
const name = providerMatch[1];
|
|
52
|
+
const meta = PROVIDER_INFO[name];
|
|
53
|
+
if (!meta) {
|
|
54
|
+
return writeJson(res, 404, {
|
|
55
|
+
error: 'unknown provider',
|
|
56
|
+
name,
|
|
57
|
+
knownProviders: Object.keys(PROVIDERS),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return writeJson(res, 200, meta);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function providersTest(c) {
|
|
64
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
65
|
+
// Mirror of CLI v3.55 `lazyclaw providers test` (no name).
|
|
66
|
+
// A dashboard's "key validity" badge calls this once and
|
|
67
|
+
// gets a per-provider verdict in one round trip. HTTP
|
|
68
|
+
// status mirrors CLI exit code:
|
|
69
|
+
// 200 — every provider returned a non-empty reply
|
|
70
|
+
// 503 — at least one provider failed (Service Unavailable;
|
|
71
|
+
// "the system is partially unhealthy")
|
|
72
|
+
// 503 is the right code because a dashboard observing it
|
|
73
|
+
// can render a yellow status without parsing the body.
|
|
74
|
+
const cfg = ctx.readConfig();
|
|
75
|
+
const apiKey = cfg['api-key'] || '';
|
|
76
|
+
const sharedPrompt = url.searchParams.get('prompt') || 'ping';
|
|
77
|
+
const tAll = Date.now();
|
|
78
|
+
const results = await Promise.all(
|
|
79
|
+
Object.entries(PROVIDERS).map(async ([pid, provider]) => {
|
|
80
|
+
const meta = PROVIDER_INFO[pid] || {};
|
|
81
|
+
const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
try {
|
|
84
|
+
let reply = '';
|
|
85
|
+
const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
|
|
86
|
+
for await (const chunk of stream) {
|
|
87
|
+
if (typeof chunk === 'string') reply += chunk;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
name: pid, ok: reply.length > 0, model,
|
|
91
|
+
durationMs: Date.now() - t0,
|
|
92
|
+
replyLength: reply.length,
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
name: pid, ok: false, model,
|
|
97
|
+
durationMs: Date.now() - t0,
|
|
98
|
+
error: err?.message || String(err),
|
|
99
|
+
code: err?.code || null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const allOk = results.every(r => r.ok);
|
|
105
|
+
return writeJson(res, allOk ? 200 : 503, {
|
|
106
|
+
ok: allOk,
|
|
107
|
+
totalDurationMs: Date.now() - tAll,
|
|
108
|
+
results,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function providerTest(c) {
|
|
113
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
114
|
+
// GET /providers/<name>/test — single-provider 1-token reachability
|
|
115
|
+
// probe. Same shape as one entry of GET /providers/test, but the
|
|
116
|
+
// endpoint stops on the first failure and exposes the reply body
|
|
117
|
+
// (truncated) so the dashboard can show a real signal of life.
|
|
118
|
+
const name = providerTestMatch[1];
|
|
119
|
+
const provider = PROVIDERS[name];
|
|
120
|
+
if (!provider) return writeJson(res, 404, { error: `unknown provider: ${name}` });
|
|
121
|
+
const cfg = ctx.readConfig();
|
|
122
|
+
const apiKey = cfg['api-key'] || '';
|
|
123
|
+
const meta = PROVIDER_INFO[name] || {};
|
|
124
|
+
const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
|
|
125
|
+
const prompt = url.searchParams.get('prompt') || 'ping';
|
|
126
|
+
const t0 = Date.now();
|
|
127
|
+
try {
|
|
128
|
+
let reply = '';
|
|
129
|
+
const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
|
|
130
|
+
for await (const chunk of stream) {
|
|
131
|
+
if (typeof chunk === 'string') reply += chunk;
|
|
132
|
+
}
|
|
133
|
+
return writeJson(res, reply.length > 0 ? 200 : 503, {
|
|
134
|
+
ok: reply.length > 0,
|
|
135
|
+
name, model,
|
|
136
|
+
durationMs: Date.now() - t0,
|
|
137
|
+
replyLength: reply.length,
|
|
138
|
+
reply: reply.slice(0, 500),
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return writeJson(res, 503, {
|
|
142
|
+
ok: false, name, model,
|
|
143
|
+
durationMs: Date.now() - t0,
|
|
144
|
+
error: err?.message || String(err),
|
|
145
|
+
code: err?.code || null,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function providersCreate(c) {
|
|
151
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
152
|
+
// Register or overwrite a custom OpenAI-compatible provider.
|
|
153
|
+
// Body: { name, baseUrl, apiKey?, defaultModel? }. Persists into
|
|
154
|
+
// cfg.customProviders[] and hot-registers via the registry's
|
|
155
|
+
// registerCustomProviders() so the new entry is callable in this
|
|
156
|
+
// same process. 405 when the daemon was started without
|
|
157
|
+
// writeConfig (read-only mode). The same name as a built-in
|
|
158
|
+
// OpenAI-compat alias is allowed and overrides the built-in.
|
|
159
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
160
|
+
return writeJson(res, 405, { error: 'mutation disabled — daemon was started without writeConfig' });
|
|
161
|
+
}
|
|
162
|
+
let body;
|
|
163
|
+
try { body = await readJson(req); }
|
|
164
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
165
|
+
const reg = await import('../../providers/registry.mjs');
|
|
166
|
+
let name;
|
|
167
|
+
try { name = reg.validateCustomProviderName(body.name); }
|
|
168
|
+
catch (e) { return writeJson(res, 400, { error: e.message }); }
|
|
169
|
+
if (!body.baseUrl || typeof body.baseUrl !== 'string' || !/^https?:\/\//i.test(body.baseUrl)) {
|
|
170
|
+
return writeJson(res, 400, { error: 'baseUrl must be a string starting with http:// or https://' });
|
|
171
|
+
}
|
|
172
|
+
const cfg = ctx.readConfig();
|
|
173
|
+
cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
|
|
174
|
+
const idx = cfg.customProviders.findIndex((p) => p && p.name === name);
|
|
175
|
+
const entry = {
|
|
176
|
+
name,
|
|
177
|
+
baseUrl: String(body.baseUrl).replace(/\/+$/, ''),
|
|
178
|
+
apiKey: body.apiKey || undefined,
|
|
179
|
+
defaultModel: body.defaultModel || undefined,
|
|
180
|
+
};
|
|
181
|
+
if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
|
|
182
|
+
else cfg.customProviders.push(entry);
|
|
183
|
+
ctx.writeConfig(cfg);
|
|
184
|
+
try { reg.registerCustomProviders(cfg); } catch { /* keep going */ }
|
|
185
|
+
const overridesBuiltin = typeof reg.isBuiltinOpenAICompatName === 'function'
|
|
186
|
+
? reg.isBuiltinOpenAICompatName(name)
|
|
187
|
+
: false;
|
|
188
|
+
return writeJson(res, 200, { ok: true, name, baseUrl: entry.baseUrl, overridesBuiltin });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function providerDelete(c) {
|
|
192
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
193
|
+
// DELETE /providers/<name> — drop a custom registration. Idempotent:
|
|
194
|
+
// 200 with `removed: false` when the name wasn't a custom entry.
|
|
195
|
+
// Built-in providers can't be deleted; their PROVIDERS row is
|
|
196
|
+
// restored on next process boot if the user previously overrode it.
|
|
197
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
198
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
199
|
+
}
|
|
200
|
+
const name = providerMatch[1];
|
|
201
|
+
const cfg = ctx.readConfig();
|
|
202
|
+
if (!Array.isArray(cfg.customProviders) || cfg.customProviders.length === 0) {
|
|
203
|
+
return writeJson(res, 200, { ok: true, name, removed: false });
|
|
204
|
+
}
|
|
205
|
+
const before = cfg.customProviders.length;
|
|
206
|
+
cfg.customProviders = cfg.customProviders.filter((p) => !(p && p.name === name));
|
|
207
|
+
const removed = cfg.customProviders.length < before;
|
|
208
|
+
if (removed) ctx.writeConfig(cfg);
|
|
209
|
+
return writeJson(res, 200, { ok: true, name, removed });
|
|
210
|
+
}
|
|
211
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Daemon route handlers (rates), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
|
|
6
|
+
export async function ratesList(c) {
|
|
7
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
8
|
+
// Read-only view of cfg.rates so a dashboard's cost panel
|
|
9
|
+
// can render the configured cards without shelling to the
|
|
10
|
+
// CLI. No write surface exposed — rate-card edits go
|
|
11
|
+
// through the CLI (operator-curated, deliberate).
|
|
12
|
+
// ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
|
|
13
|
+
const cfg = ctx.readConfig();
|
|
14
|
+
const rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
|
|
15
|
+
let entries = Object.entries(rates);
|
|
16
|
+
const filter = url.searchParams.get('filter');
|
|
17
|
+
if (filter) {
|
|
18
|
+
const f = filter.toLowerCase();
|
|
19
|
+
entries = entries.filter(([key]) => key.toLowerCase().includes(f));
|
|
20
|
+
}
|
|
21
|
+
const limitStr = url.searchParams.get('limit');
|
|
22
|
+
if (limitStr) {
|
|
23
|
+
const n = parseInt(limitStr, 10);
|
|
24
|
+
if (Number.isFinite(n) && n > 0) entries = entries.slice(0, n);
|
|
25
|
+
}
|
|
26
|
+
return writeJson(res, 200, Object.fromEntries(entries));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function ratesValidate(c) {
|
|
30
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
31
|
+
// Mirror of v3.30's `lazyclaw rates validate`. Same shape
|
|
32
|
+
// (single source of truth in rates-validate.mjs). HTTP
|
|
33
|
+
// status reflects ok/issues so a UI's cost-config badge
|
|
34
|
+
// can branch on HTTP code: 200 ok, 422 issues
|
|
35
|
+
// (Unprocessable Entity, same pattern as /config/validate
|
|
36
|
+
// in v3.40).
|
|
37
|
+
const cfg = ctx.readConfig();
|
|
38
|
+
const result = validateRates(cfg.rates, PROVIDERS);
|
|
39
|
+
return writeJson(res, result.ok ? 200 : 422, result);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function ratesShape(c) {
|
|
43
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
44
|
+
// Mirror of `lazyclaw rates shape`. Returns the zero-filled
|
|
45
|
+
// reference rate-card template so a dashboard config panel
|
|
46
|
+
// or a script that scaffolds a new card can get the required
|
|
47
|
+
// fields without shelling to the CLI.
|
|
48
|
+
return writeJson(res, 200, RATE_CARD_SHAPE);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ratePut(c) {
|
|
52
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
53
|
+
// PUT /rates/<key> — set a rate card. Body is the card object
|
|
54
|
+
// ({ in, out, "cache-read"?, "cache-create"?, currency? }). The
|
|
55
|
+
// payload is merged into cfg.rates and validated as a whole;
|
|
56
|
+
// 422 on validation failure. 405 when writeConfig is unset.
|
|
57
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
58
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
59
|
+
}
|
|
60
|
+
const key = decodeURIComponent(ratesKeyMatch[1]);
|
|
61
|
+
let body;
|
|
62
|
+
try { body = await readJson(req); }
|
|
63
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
64
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be a JSON object' });
|
|
65
|
+
const cfg = ctx.readConfig();
|
|
66
|
+
cfg.rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
|
|
67
|
+
cfg.rates[key] = body;
|
|
68
|
+
const v = validateRates(cfg.rates, PROVIDERS);
|
|
69
|
+
if (!v.ok) return writeJson(res, 422, v);
|
|
70
|
+
ctx.writeConfig(cfg);
|
|
71
|
+
return writeJson(res, 200, { ok: true, key, card: cfg.rates[key] });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function rateDelete(c) {
|
|
75
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
76
|
+
// DELETE /rates/<key> — idempotent: 200 with `removed: false`
|
|
77
|
+
// when the card didn't exist.
|
|
78
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
79
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
80
|
+
}
|
|
81
|
+
const key = decodeURIComponent(ratesKeyMatch[1]);
|
|
82
|
+
const cfg = ctx.readConfig();
|
|
83
|
+
if (!cfg.rates || typeof cfg.rates !== 'object' || !(key in cfg.rates)) {
|
|
84
|
+
return writeJson(res, 200, { ok: true, key, removed: false });
|
|
85
|
+
}
|
|
86
|
+
delete cfg.rates[key];
|
|
87
|
+
ctx.writeConfig(cfg);
|
|
88
|
+
return writeJson(res, 200, { ok: true, key, removed: true });
|
|
89
|
+
}
|
|
90
|
+
|