lazyclaw 3.99.11 → 3.99.12
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/cli.mjs +422 -40
- package/package.json +1 -1
- package/providers/openai_compat.mjs +301 -0
- package/providers/registry.mjs +73 -0
- package/web/dashboard.html +386 -0
package/cli.mjs
CHANGED
|
@@ -39,6 +39,13 @@ function _resolveAuthKey(cfg, provider) {
|
|
|
39
39
|
const active = (cfg.authActiveProfile || {})[provider];
|
|
40
40
|
const hit = arr.find((p) => p && p.label === active) || arr[0];
|
|
41
41
|
if (hit?.key) return hit.key;
|
|
42
|
+
// Custom OpenAI-compatible providers store their api-key inline in the
|
|
43
|
+
// customProviders[] entry. Honour that before falling back to the
|
|
44
|
+
// legacy single-key cfg['api-key'].
|
|
45
|
+
const custom = Array.isArray(cfg.customProviders)
|
|
46
|
+
? cfg.customProviders.find((p) => p && p.name === provider)
|
|
47
|
+
: null;
|
|
48
|
+
if (custom?.apiKey) return custom.apiKey;
|
|
42
49
|
return cfg['api-key'] || '';
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -684,6 +691,15 @@ function require_registry_sync() {
|
|
|
684
691
|
}
|
|
685
692
|
async function ensureRegistry() {
|
|
686
693
|
if (!_registryMod) _registryMod = await import('./providers/registry.mjs');
|
|
694
|
+
// Re-run registration on every call so config changes within the same
|
|
695
|
+
// process (e.g. setup wizard adding a custom endpoint mid-session) take
|
|
696
|
+
// effect for the next chat / agent / picker invocation. registerCustom-
|
|
697
|
+
// Providers is idempotent — re-registering the same name is a no-op.
|
|
698
|
+
try {
|
|
699
|
+
if (typeof _registryMod.registerCustomProviders === 'function') {
|
|
700
|
+
_registryMod.registerCustomProviders(readConfig());
|
|
701
|
+
}
|
|
702
|
+
} catch { /* never let a malformed cfg.customProviders block startup */ }
|
|
687
703
|
return _registryMod;
|
|
688
704
|
}
|
|
689
705
|
|
|
@@ -885,7 +901,7 @@ const SUBCOMMAND_SUBS = {
|
|
|
885
901
|
config: ['get', 'set', 'list', 'delete', 'unset', 'path', 'edit', 'validate'],
|
|
886
902
|
sessions: ['list', 'show', 'clear', 'export', 'search'],
|
|
887
903
|
skills: ['list', 'show', 'install', 'remove', 'search'],
|
|
888
|
-
providers: ['list', 'info', 'test'],
|
|
904
|
+
providers: ['list', 'info', 'test', 'add', 'remove', 'models'],
|
|
889
905
|
rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
|
|
890
906
|
completion: ['bash', 'zsh'],
|
|
891
907
|
auth: ['list', 'add', 'remove', 'use', 'rotate'],
|
|
@@ -1095,7 +1111,7 @@ const HELP_SUMMARIES = {
|
|
|
1095
1111
|
onboard: 'Guided setup (use --non-interactive for scripts)',
|
|
1096
1112
|
sessions: 'Persistent chat sessions (list|show|clear|export)',
|
|
1097
1113
|
skills: 'Markdown skill bundles (list|show|install|remove)',
|
|
1098
|
-
providers: 'Inspect
|
|
1114
|
+
providers: 'Inspect / register providers (list|info|test|add|remove|models)',
|
|
1099
1115
|
daemon: 'Run the local HTTP gateway (--port, --auth-token, --allow-origin)',
|
|
1100
1116
|
version: 'Print VERSION + node + platform as JSON',
|
|
1101
1117
|
completion: 'Emit shell completion script (completion <bash|zsh>)',
|
|
@@ -1134,7 +1150,7 @@ const HELP_DETAILS = {
|
|
|
1134
1150
|
onboard: 'Usage: lazyclaw onboard [--non-interactive] [--provider X] [--model Y] [--api-key Z]\n --model accepts the unified "provider/model" string (e.g. anthropic/claude-opus-4-7).',
|
|
1135
1151
|
sessions: 'Usage: lazyclaw sessions <list [--filter <substr>] [--limit <N>]|show <id>|clear <id>|export <id> [--format md|json|text]|search <query> [--regex]>\n list — recent sessions by mtime; --filter caps to ids containing substring (case-insensitive); --limit caps result count.\n export — render in chosen format (md default for human sharing, json for tooling, text for paste).\n search — case-insensitive substring (or --regex pattern) match across all session content; returns first excerpt + match count per matching session.',
|
|
1136
1152
|
skills: 'Usage: lazyclaw skills <list [--filter <substr>] [--limit <N>]|show <name>|install <user/repo[@ref][:path]> [--prefix <p>] [--force] | install <name> [--from <path> | --from-url <https://...>]|remove <name>|search <query> [--regex]>\n list — installed skills; --filter caps to names containing substring (case-insensitive); --limit caps result count.\n install <user>/<repo>[@<ref>][:<subpath>] — fetch a GitHub tarball, install every .md under skills/ (or the explicit subpath, or repo root). Default ref is `main`.\n --prefix prepends a name prefix so a multi-skill repo doesn\'t collide with locally-managed skills. --force overwrites existing names.\n install <name> --from <path> | --from-url <https://...> — single-file install. --from-url is HTTPS-only with a 1 MiB cap.\n search — case-insensitive substring (or --regex) match across all skill markdown bodies; returns first excerpt + match count per skill.',
|
|
1137
|
-
providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T]>\n list
|
|
1153
|
+
providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T] | add <name> --base-url <url> [--api-key <k>] [--default-model <id>] [--no-probe] | remove <name> | models <name> [--filter <substr>]>\n list — registered providers (--filter case-insensitive name substring; --limit caps post-filter count).\n info — static metadata: requiresApiKey, defaultModel, suggestedModels, endpoint.\n test — send a 1-token "ping" through the provider and report ok/error + duration.\n Useful after configuring an API key to verify it works before relying on it.\n No name OR --all: tests every registered provider in parallel; exits 0 only when ALL pass.\n add — register a custom OpenAI-compatible endpoint (NIM / OpenRouter / Together / Groq / vLLM / LM Studio / …).\n Probes /v1/models on success unless --no-probe is set; persists to cfg.customProviders[].\n remove — drop a custom provider entry from cfg.customProviders[].\n models — fetch + print the live model catalogue from <provider>/v1/models (works for openai / ollama / custom).',
|
|
1138
1154
|
daemon: 'Usage: lazyclaw daemon [--port <N>] [--once] [--auth-token <token>] [--allow-origin <origin>] [--rate-limit <N>] [--response-cache] [--log <level>] [--shutdown-timeout-ms <N>] [--cost-cap-<currency> <N> ...] [--workflow-state-dir <dir>]\n Always binds 127.0.0.1. --port 0 picks a random port and prints the URL.\n --auth-token also reads $LAZYCLAW_AUTH_TOKEN; --allow-origin also reads $LAZYCLAW_ALLOW_ORIGINS.\n --rate-limit <N> caps each remote IP at N requests / 60 s.\n --response-cache enables process-scoped memoization; per-request opt-in via body.cache.\n --log <debug|info|warn|error> emits JSON-line access logs on stderr (also reads $LAZYCLAW_LOG_LEVEL).\n --shutdown-timeout-ms <N> caps graceful drain on SIGINT/SIGTERM (default 10000). Second signal forces immediate exit.\n --cost-cap-usd 100 (or any currency code in lowercase) rejects POST /agent + /chat with 402 once cumulative cost reaches the cap.\n --workflow-state-dir <dir> backs GET /workflows + GET /workflows/<id> (default .workflow-state, also reads $LAZYCLAW_WORKFLOW_STATE_DIR).',
|
|
1139
1155
|
version: 'Usage: lazyclaw version\n Aliases: --version, -v.',
|
|
1140
1156
|
completion: 'Usage: lazyclaw completion <bash|zsh>\n bash: eval "$(lazyclaw completion bash)"\n zsh: lazyclaw completion zsh > "${fpath[1]}/_lazyclaw"',
|
|
@@ -1495,7 +1511,7 @@ function _printChatBanner(activeProvName, activeModel, version) {
|
|
|
1495
1511
|
// optional pre-coloured pill (e.g. "[api key]") that lands on the
|
|
1496
1512
|
// right side of the row. `defaultIdx` lets the caller pin where the
|
|
1497
1513
|
// cursor lands; default 0.
|
|
1498
|
-
async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0 }) {
|
|
1514
|
+
async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0, searchable = false }) {
|
|
1499
1515
|
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
1500
1516
|
// Non-TTY fallback: print the labels on stderr and read a single
|
|
1501
1517
|
// line of stdin. Used when somebody pipes input to `lazyclaw
|
|
@@ -1528,24 +1544,68 @@ async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0 }) {
|
|
|
1528
1544
|
// before drawing so the picker always receives the first keypress.
|
|
1529
1545
|
process.stdin.resume();
|
|
1530
1546
|
if (process.stdin.ref) process.stdin.ref();
|
|
1531
|
-
let idx = Math.max(0, Math.min(items.length - 1, defaultIdx));
|
|
1532
1547
|
const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
|
|
1533
1548
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
1534
1549
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
1535
1550
|
|
|
1551
|
+
// Typeahead state. `query` accumulates printable chars when searchable
|
|
1552
|
+
// is on; the visible item slice is recomputed on every keystroke. We
|
|
1553
|
+
// keep `defaultIdx` semantics by mapping it to the unfiltered list and
|
|
1554
|
+
// tracking selection inside the filtered view via the item identity.
|
|
1555
|
+
let query = '';
|
|
1556
|
+
const matchScore = (it, q) => {
|
|
1557
|
+
if (!q) return 0;
|
|
1558
|
+
const hay = `${it.label || ''} ${it.desc || ''} ${it.id || ''}`.toLowerCase();
|
|
1559
|
+
const needle = q.toLowerCase();
|
|
1560
|
+
if (hay.includes(needle)) return hay.indexOf(needle) === 0 ? 2 : 1;
|
|
1561
|
+
// simple subsequence fallback so "g4o" matches "gpt-4o".
|
|
1562
|
+
let i = 0; let matched = 0;
|
|
1563
|
+
for (const ch of hay) {
|
|
1564
|
+
if (ch === needle[matched]) { matched++; if (matched === needle.length) break; }
|
|
1565
|
+
i++;
|
|
1566
|
+
}
|
|
1567
|
+
return matched === needle.length ? 0.5 : 0;
|
|
1568
|
+
};
|
|
1569
|
+
const filterItems = () => {
|
|
1570
|
+
if (!searchable || !query) return items.slice();
|
|
1571
|
+
const scored = items
|
|
1572
|
+
.map((it) => ({ it, s: matchScore(it, query) }))
|
|
1573
|
+
.filter((x) => x.s > 0)
|
|
1574
|
+
.sort((a, b) => b.s - a.s);
|
|
1575
|
+
return scored.map((x) => x.it);
|
|
1576
|
+
};
|
|
1577
|
+
let view = filterItems();
|
|
1578
|
+
let idx = Math.max(0, Math.min(view.length - 1, defaultIdx));
|
|
1579
|
+
if (idx < 0) idx = 0;
|
|
1580
|
+
|
|
1536
1581
|
const draw = () => {
|
|
1537
1582
|
process.stdout.write('\x1b[?25l\x1b[2J\x1b[H');
|
|
1538
1583
|
process.stdout.write(accent(title) + '\n');
|
|
1539
1584
|
if (subtitle) process.stdout.write(dim(subtitle) + '\n');
|
|
1540
|
-
|
|
1541
|
-
|
|
1585
|
+
const help = searchable
|
|
1586
|
+
? '↑/↓ to move · Enter to confirm · type to search · Esc to back · Ctrl+U to clear · q to quit'
|
|
1587
|
+
: '↑/↓ to move · Enter to confirm · Esc to back · q to quit';
|
|
1588
|
+
process.stdout.write(dim(help) + '\n');
|
|
1589
|
+
if (searchable) {
|
|
1590
|
+
const q = query ? bold(query) : dim('(type to filter)');
|
|
1591
|
+
process.stdout.write(dim(' search: ') + q + dim(` ${view.length}/${items.length} match`) + '\n\n');
|
|
1592
|
+
} else {
|
|
1593
|
+
process.stdout.write('\n');
|
|
1594
|
+
}
|
|
1595
|
+
if (view.length === 0) {
|
|
1596
|
+
process.stdout.write(' ' + dim('(no matches — backspace or Ctrl+U to clear the filter)') + '\n');
|
|
1597
|
+
if (footer) process.stdout.write('\n' + dim(footer) + '\n');
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const headerLines = subtitle ? 4 : 3;
|
|
1601
|
+
const rows = Math.max(6, (process.stdout.rows || 24) - (headerLines + (searchable ? 3 : 4)));
|
|
1542
1602
|
let from = Math.max(0, idx - Math.floor(rows / 2));
|
|
1543
|
-
if (from + rows >
|
|
1544
|
-
const to = Math.min(
|
|
1603
|
+
if (from + rows > view.length) from = Math.max(0, view.length - rows);
|
|
1604
|
+
const to = Math.min(view.length, from + rows);
|
|
1545
1605
|
// Pre-compute label width so descriptions line up across rows.
|
|
1546
|
-
const labelW =
|
|
1606
|
+
const labelW = view.reduce((w, it) => Math.max(w, (it.label || '').length), 12);
|
|
1547
1607
|
for (let i = from; i < to; i++) {
|
|
1548
|
-
const it =
|
|
1608
|
+
const it = view[i];
|
|
1549
1609
|
const marker = i === idx ? accent('❯ ') : ' ';
|
|
1550
1610
|
const lbl = (it.label || '').padEnd(labelW);
|
|
1551
1611
|
const lblOut = i === idx ? bold(lbl) : lbl;
|
|
@@ -1553,26 +1613,50 @@ async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0 }) {
|
|
|
1553
1613
|
const tag = it.tag ? ' ' + it.tag : '';
|
|
1554
1614
|
process.stdout.write(`${marker}${lblOut}${desc}${tag}\n`);
|
|
1555
1615
|
}
|
|
1556
|
-
if (to <
|
|
1557
|
-
process.stdout.write(`${dim(` …(${
|
|
1616
|
+
if (to < view.length) {
|
|
1617
|
+
process.stdout.write(`${dim(` …(${view.length - to} more)`)}\n`);
|
|
1558
1618
|
}
|
|
1559
1619
|
if (footer) process.stdout.write('\n' + dim(footer) + '\n');
|
|
1560
1620
|
};
|
|
1561
1621
|
|
|
1562
1622
|
draw();
|
|
1563
1623
|
return await new Promise((resolve) => {
|
|
1564
|
-
const
|
|
1624
|
+
const recompute = () => {
|
|
1625
|
+
view = filterItems();
|
|
1626
|
+
if (idx >= view.length) idx = Math.max(0, view.length - 1);
|
|
1627
|
+
draw();
|
|
1628
|
+
};
|
|
1629
|
+
const onKey = (str, key) => {
|
|
1565
1630
|
if (!key) return;
|
|
1566
|
-
if (key.name === 'up') { idx = (idx - 1 +
|
|
1567
|
-
else if (key.name === 'down') { idx = (idx + 1) %
|
|
1631
|
+
if (key.name === 'up') { if (view.length) { idx = (idx - 1 + view.length) % view.length; draw(); } }
|
|
1632
|
+
else if (key.name === 'down') { if (view.length) { idx = (idx + 1) % view.length; draw(); } }
|
|
1568
1633
|
else if (key.name === 'pageup') { idx = Math.max(0, idx - 10); draw(); }
|
|
1569
|
-
else if (key.name === 'pagedown') { idx = Math.min(
|
|
1634
|
+
else if (key.name === 'pagedown') { idx = Math.min(view.length - 1, idx + 10); draw(); }
|
|
1570
1635
|
else if (key.name === 'home') { idx = 0; draw(); }
|
|
1571
|
-
else if (key.name === 'end') { idx =
|
|
1572
|
-
else if (key.name === 'return') {
|
|
1636
|
+
else if (key.name === 'end') { idx = view.length - 1; draw(); }
|
|
1637
|
+
else if (key.name === 'return') {
|
|
1638
|
+
if (view.length === 0) return;
|
|
1639
|
+
cleanup();
|
|
1640
|
+
resolve(view[idx]);
|
|
1641
|
+
}
|
|
1573
1642
|
else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
|
|
1574
|
-
else if (key.name === '
|
|
1575
|
-
else if (key.name === '
|
|
1643
|
+
else if (key.ctrl && key.name === 'u') { if (searchable) { query = ''; recompute(); } }
|
|
1644
|
+
else if (key.name === 'escape') {
|
|
1645
|
+
if (searchable && query) { query = ''; recompute(); return; }
|
|
1646
|
+
cleanup(); resolve('BACK');
|
|
1647
|
+
}
|
|
1648
|
+
else if (key.name === 'backspace') {
|
|
1649
|
+
if (searchable && query.length > 0) { query = query.slice(0, -1); recompute(); }
|
|
1650
|
+
}
|
|
1651
|
+
else if (searchable && str && str.length === 1 && str >= ' ' && str !== '\x7f' && !key.ctrl && !key.meta) {
|
|
1652
|
+
// Printable char → append to filter buffer. We deliberately do not
|
|
1653
|
+
// intercept 'q' as a shortcut when searchable is on, because the
|
|
1654
|
+
// user might be typing a model id that contains 'q'. Use Esc / Ctrl+C
|
|
1655
|
+
// to bail out instead.
|
|
1656
|
+
query += str;
|
|
1657
|
+
recompute();
|
|
1658
|
+
}
|
|
1659
|
+
else if (!searchable && key.name === 'q') { cleanup(); resolve('CANCEL'); }
|
|
1576
1660
|
};
|
|
1577
1661
|
const cleanup = () => {
|
|
1578
1662
|
process.stdin.off('keypress', onKey);
|
|
@@ -1659,57 +1743,245 @@ async function _pickProviderInteractive() {
|
|
|
1659
1743
|
let provider = null;
|
|
1660
1744
|
while (!provider) {
|
|
1661
1745
|
const memberNames = families[family.id].members;
|
|
1662
|
-
if (memberNames.length === 1) {
|
|
1663
|
-
// Auto-advance — no point making the user pick from a single
|
|
1664
|
-
// row.
|
|
1665
|
-
provider = { id: memberNames[0] };
|
|
1666
|
-
break;
|
|
1667
|
-
}
|
|
1668
1746
|
const provItems = memberNames.map((name) => {
|
|
1669
1747
|
const meta = info[name] || {};
|
|
1670
1748
|
const models = (meta.suggestedModels || []).slice(0, 4).join(' · ') || '(default)';
|
|
1749
|
+
const isCustom = !!meta.custom;
|
|
1671
1750
|
return {
|
|
1672
1751
|
id: name,
|
|
1673
1752
|
label: name,
|
|
1674
|
-
desc:
|
|
1675
|
-
|
|
1753
|
+
desc: isCustom
|
|
1754
|
+
? `custom · ${meta.baseUrl || ''}`
|
|
1755
|
+
: `models: ${models}`,
|
|
1756
|
+
tag: isCustom
|
|
1757
|
+
? '\x1b[38;5;213m[custom]\x1b[0m'
|
|
1758
|
+
: (meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m'),
|
|
1676
1759
|
};
|
|
1677
1760
|
});
|
|
1761
|
+
// Surface a "+ Add a new custom endpoint…" entry inside the API-key
|
|
1762
|
+
// family. NIM, OpenRouter, vLLM, LM Studio, Together, Groq, etc. all
|
|
1763
|
+
// speak the OpenAI Chat-Completions wire format — this single hook
|
|
1764
|
+
// covers every one of them without shipping a per-vendor provider.
|
|
1765
|
+
if (family.id === 'api') {
|
|
1766
|
+
provItems.push({
|
|
1767
|
+
id: '__add_custom__',
|
|
1768
|
+
label: '+ Add a custom OpenAI-compatible endpoint…',
|
|
1769
|
+
desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
|
|
1770
|
+
tag: '\x1b[38;5;213m[new]\x1b[0m',
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
if (memberNames.length === 1 && family.id !== 'api') {
|
|
1774
|
+
// Auto-advance — no point making the user pick from a single row,
|
|
1775
|
+
// unless we just appended the "+ Add custom" entry above.
|
|
1776
|
+
provider = { id: memberNames[0] };
|
|
1777
|
+
break;
|
|
1778
|
+
}
|
|
1678
1779
|
const picked = await _arrowMenu({
|
|
1679
1780
|
title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
|
|
1680
|
-
subtitle: `Showing ${
|
|
1781
|
+
subtitle: `Showing ${provItems.length} ${family.label.toLowerCase()} option(s). Type to filter.`,
|
|
1681
1782
|
items: provItems,
|
|
1783
|
+
searchable: true,
|
|
1682
1784
|
});
|
|
1683
1785
|
if (picked === 'CANCEL') return null;
|
|
1684
1786
|
if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
|
|
1787
|
+
if (picked && picked.id === '__add_custom__') {
|
|
1788
|
+
const added = await _addCustomProviderInteractive();
|
|
1789
|
+
if (!added) continue; // back to provider list
|
|
1790
|
+
// Force the registry to pick up the new entry and recompute the
|
|
1791
|
+
// family bucket for the next loop iteration.
|
|
1792
|
+
await ensureRegistry();
|
|
1793
|
+
Object.assign(families, _providerFamilies());
|
|
1794
|
+
provider = { id: added.name };
|
|
1795
|
+
break;
|
|
1796
|
+
}
|
|
1685
1797
|
provider = picked;
|
|
1686
1798
|
}
|
|
1687
1799
|
|
|
1688
1800
|
// ── Step 3 — model ────────────────────────────────────────────
|
|
1689
1801
|
const meta = info[provider.id] || {};
|
|
1690
|
-
const
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1802
|
+
const baseModels = Array.isArray(meta.suggestedModels) ? meta.suggestedModels.slice() : [];
|
|
1803
|
+
const isCustom = !!meta.custom;
|
|
1804
|
+
const supportsLiveFetch = !!meta.baseUrl || provider.id === 'openai' || provider.id === 'ollama';
|
|
1805
|
+
|
|
1806
|
+
if (!baseModels.length && !supportsLiveFetch) {
|
|
1807
|
+
// Provider has no curated models AND no live-fetch surface (mock) —
|
|
1808
|
+
// return without a model so the underlying call uses the provider
|
|
1809
|
+
// default.
|
|
1694
1810
|
return { provider: provider.id, model: null };
|
|
1695
1811
|
}
|
|
1812
|
+
|
|
1813
|
+
let dynamicModels = [];
|
|
1696
1814
|
while (true) {
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1815
|
+
const allModels = Array.from(new Set([...baseModels, ...dynamicModels]));
|
|
1816
|
+
const modelItems = allModels.map((m) => ({ id: m, label: m, desc: '' }));
|
|
1817
|
+
if (supportsLiveFetch) {
|
|
1818
|
+
modelItems.unshift({
|
|
1819
|
+
id: '__fetch_models__',
|
|
1820
|
+
label: '↻ Fetch live model list from /v1/models',
|
|
1821
|
+
desc: isCustom ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
|
|
1822
|
+
tag: '\x1b[38;5;245m[live]\x1b[0m',
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
modelItems.push({
|
|
1826
|
+
id: '__custom_model__',
|
|
1827
|
+
label: '… type a custom model id',
|
|
1828
|
+
desc: 'use any model id supported by this provider, even if not listed above',
|
|
1829
|
+
tag: '\x1b[38;5;245m[free]\x1b[0m',
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
const defaultIdx = supportsLiveFetch
|
|
1833
|
+
? Math.max(0, 1 + allModels.indexOf(meta.defaultModel || allModels[0]))
|
|
1834
|
+
: Math.max(0, allModels.indexOf(meta.defaultModel || allModels[0]));
|
|
1701
1835
|
const picked = await _arrowMenu({
|
|
1702
1836
|
title: `LazyClaw setup — Step 3 of 3: pick a model for ${provider.id}`,
|
|
1703
|
-
subtitle: `
|
|
1837
|
+
subtitle: `Type to filter ${allModels.length} model(s). Enter to confirm. Backspace clears one char, Ctrl+U clears the filter.`,
|
|
1704
1838
|
items: modelItems,
|
|
1705
1839
|
defaultIdx,
|
|
1840
|
+
searchable: true,
|
|
1706
1841
|
});
|
|
1707
1842
|
if (picked === 'CANCEL') return null;
|
|
1708
1843
|
if (picked === 'BACK') return _pickProviderInteractive(); // back to step 1
|
|
1844
|
+
if (picked.id === '__custom_model__') {
|
|
1845
|
+
const typed = (await _quickPrompt(` model id for ${provider.id}: `)).trim();
|
|
1846
|
+
if (!typed) continue;
|
|
1847
|
+
return { provider: provider.id, model: typed };
|
|
1848
|
+
}
|
|
1849
|
+
if (picked.id === '__fetch_models__') {
|
|
1850
|
+
try {
|
|
1851
|
+
process.stdout.write(`\n fetching ${provider.id} model list…\n`);
|
|
1852
|
+
const fetched = await _fetchModelsForProvider(provider.id);
|
|
1853
|
+
if (!fetched.length) {
|
|
1854
|
+
process.stdout.write(` ${'\x1b[33m'}no models returned${'\x1b[0m'} — falling back to the suggested list.\n`);
|
|
1855
|
+
await _quickPrompt(' press Enter to continue ');
|
|
1856
|
+
} else {
|
|
1857
|
+
dynamicModels = fetched;
|
|
1858
|
+
process.stdout.write(` fetched ${fetched.length} model(s).\n`);
|
|
1859
|
+
await _quickPrompt(' press Enter to pick one ');
|
|
1860
|
+
}
|
|
1861
|
+
} catch (e) {
|
|
1862
|
+
process.stdout.write(`\n ${'\x1b[33m'}fetch failed:${'\x1b[0m'} ${e?.message || e}\n`);
|
|
1863
|
+
await _quickPrompt(' press Enter to continue ');
|
|
1864
|
+
}
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1709
1867
|
return { provider: provider.id, model: picked.id };
|
|
1710
1868
|
}
|
|
1711
1869
|
}
|
|
1712
1870
|
|
|
1871
|
+
// Resolve {baseUrl, apiKey} for a provider so we can call /v1/models on
|
|
1872
|
+
// its behalf. Returns null when the provider doesn't expose an OpenAI-
|
|
1873
|
+
// compatible model catalogue (e.g. anthropic, gemini, claude-cli).
|
|
1874
|
+
function _modelCatalogueFor(providerId) {
|
|
1875
|
+
const cfg = readConfig();
|
|
1876
|
+
const meta = (_registryMod.PROVIDER_INFO || {})[providerId] || {};
|
|
1877
|
+
if (meta.custom && meta.baseUrl) {
|
|
1878
|
+
const entry = (cfg.customProviders || []).find((p) => p && p.name === providerId) || {};
|
|
1879
|
+
return { baseUrl: meta.baseUrl, apiKey: entry.apiKey || cfg['api-key'] || '' };
|
|
1880
|
+
}
|
|
1881
|
+
if (providerId === 'openai') {
|
|
1882
|
+
return { baseUrl: 'https://api.openai.com/v1', apiKey: _resolveAuthKey(cfg, 'openai') };
|
|
1883
|
+
}
|
|
1884
|
+
if (providerId === 'ollama') {
|
|
1885
|
+
const host = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
|
|
1886
|
+
return { baseUrl: `${host.replace(/\/$/, '')}/v1`, apiKey: '' };
|
|
1887
|
+
}
|
|
1888
|
+
return null;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
async function _fetchModelsForProvider(providerId) {
|
|
1892
|
+
const c = _modelCatalogueFor(providerId);
|
|
1893
|
+
if (!c) throw new Error(`provider "${providerId}" does not expose an OpenAI-compatible /v1/models endpoint`);
|
|
1894
|
+
const { fetchOpenAICompatModels } = await import('./providers/openai_compat.mjs');
|
|
1895
|
+
return fetchOpenAICompatModels({ baseUrl: c.baseUrl, apiKey: c.apiKey });
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// Walk the user through registering a new OpenAI-compatible custom
|
|
1899
|
+
// provider (NIM, OpenRouter, vLLM, LM Studio, Together, Groq, …).
|
|
1900
|
+
// Persists into cfg.customProviders[] and returns { name } on success,
|
|
1901
|
+
// or null when the user backs out.
|
|
1902
|
+
async function _addCustomProviderInteractive() {
|
|
1903
|
+
const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
|
|
1904
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
1905
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
1906
|
+
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
1907
|
+
|
|
1908
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
1909
|
+
process.stdout.write(accent('Add a custom OpenAI-compatible endpoint') + '\n');
|
|
1910
|
+
process.stdout.write(dim('Works with any service that speaks the OpenAI v1 wire format.') + '\n');
|
|
1911
|
+
process.stdout.write(dim('Examples:') + '\n');
|
|
1912
|
+
process.stdout.write(dim(' · NVIDIA NIM https://integrate.api.nvidia.com/v1') + '\n');
|
|
1913
|
+
process.stdout.write(dim(' · OpenRouter https://openrouter.ai/api/v1') + '\n');
|
|
1914
|
+
process.stdout.write(dim(' · Together AI https://api.together.xyz/v1') + '\n');
|
|
1915
|
+
process.stdout.write(dim(' · Groq https://api.groq.com/openai/v1') + '\n');
|
|
1916
|
+
process.stdout.write(dim(' · vLLM / LM Studio http://localhost:8000/v1') + '\n\n');
|
|
1917
|
+
|
|
1918
|
+
const { validateCustomProviderName, registerCustomProviders, fetchOpenAICompatModels } = _registryMod;
|
|
1919
|
+
let name;
|
|
1920
|
+
while (true) {
|
|
1921
|
+
const raw = (await _quickPrompt(` ${bold('name')} ${dim('(short id, e.g. "nim", "openrouter"):')} `)).trim();
|
|
1922
|
+
if (!raw) {
|
|
1923
|
+
process.stdout.write(dim(' cancelled — back to the picker.\n'));
|
|
1924
|
+
return null;
|
|
1925
|
+
}
|
|
1926
|
+
try { name = validateCustomProviderName(raw); break; }
|
|
1927
|
+
catch (e) { process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`); }
|
|
1928
|
+
}
|
|
1929
|
+
const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
|
|
1930
|
+
if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
|
|
1931
|
+
if (!/^https?:\/\//i.test(baseUrlRaw)) {
|
|
1932
|
+
process.stdout.write(' \x1b[33mbaseUrl must start with http:// or https://\x1b[0m — cancelled.\n');
|
|
1933
|
+
return null;
|
|
1934
|
+
}
|
|
1935
|
+
const apiKey = (await _quickPrompt(` ${bold('api-key')} ${dim('(blank if the endpoint is auth-less, e.g. local vLLM):')} `)).trim();
|
|
1936
|
+
|
|
1937
|
+
// Persist to cfg.customProviders[]. Overwrite an existing entry of the
|
|
1938
|
+
// same name so re-running setup with a corrected URL just works.
|
|
1939
|
+
const cfg = readConfig();
|
|
1940
|
+
cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
|
|
1941
|
+
const existingIdx = cfg.customProviders.findIndex((p) => p && p.name === name);
|
|
1942
|
+
const entry = {
|
|
1943
|
+
name,
|
|
1944
|
+
baseUrl: baseUrlRaw.replace(/\/+$/, ''),
|
|
1945
|
+
apiKey: apiKey || undefined,
|
|
1946
|
+
};
|
|
1947
|
+
if (existingIdx >= 0) cfg.customProviders[existingIdx] = { ...cfg.customProviders[existingIdx], ...entry };
|
|
1948
|
+
else cfg.customProviders.push(entry);
|
|
1949
|
+
writeConfig(cfg);
|
|
1950
|
+
|
|
1951
|
+
// Hot-register so the provider is callable in this same process.
|
|
1952
|
+
registerCustomProviders(cfg);
|
|
1953
|
+
|
|
1954
|
+
// Best-effort live model probe so the user sees we can reach it. Skip
|
|
1955
|
+
// silently on failure — registration still succeeds and /v1/models can
|
|
1956
|
+
// be re-tried from the model picker.
|
|
1957
|
+
let probeMsg = '';
|
|
1958
|
+
try {
|
|
1959
|
+
const list = await fetchOpenAICompatModels({ baseUrl: entry.baseUrl, apiKey: entry.apiKey || '' });
|
|
1960
|
+
if (list.length) {
|
|
1961
|
+
probeMsg = ` ${ok('✓')} reachable — ${list.length} model(s) advertised at ${entry.baseUrl}/models\n`;
|
|
1962
|
+
// Persist the catalogue so the picker can show it without re-fetching.
|
|
1963
|
+
const updated = readConfig();
|
|
1964
|
+
const i = (updated.customProviders || []).findIndex((p) => p && p.name === name);
|
|
1965
|
+
if (i >= 0) {
|
|
1966
|
+
updated.customProviders[i].suggestedModels = list.slice(0, 50);
|
|
1967
|
+
if (!updated.customProviders[i].defaultModel) updated.customProviders[i].defaultModel = list[0];
|
|
1968
|
+
writeConfig(updated);
|
|
1969
|
+
registerCustomProviders(updated);
|
|
1970
|
+
}
|
|
1971
|
+
} else {
|
|
1972
|
+
probeMsg = ` ${ok('✓')} registered — /v1/models returned no entries (will rely on free-text model id).\n`;
|
|
1973
|
+
}
|
|
1974
|
+
} catch (e) {
|
|
1975
|
+
probeMsg = ` \x1b[33m!\x1b[0m registered, but /v1/models probe failed: ${e?.message || e}\n`;
|
|
1976
|
+
}
|
|
1977
|
+
process.stdout.write('\n');
|
|
1978
|
+
process.stdout.write(` ${ok(bold('✓ custom provider saved:'))} ${name} ${dim('→')} ${entry.baseUrl}\n`);
|
|
1979
|
+
process.stdout.write(probeMsg);
|
|
1980
|
+
process.stdout.write(dim(` Removable any time via: lazyclaw providers remove ${name}\n`));
|
|
1981
|
+
await _quickPrompt(' press Enter to continue ');
|
|
1982
|
+
return { name };
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1713
1985
|
async function cmdChat(flags = {}) {
|
|
1714
1986
|
await ensureRegistry();
|
|
1715
1987
|
const sessionsMod = await import('./sessions.mjs');
|
|
@@ -3113,8 +3385,116 @@ async function cmdProviders(sub, positional, flags = {}) {
|
|
|
3113
3385
|
process.exit(1);
|
|
3114
3386
|
}
|
|
3115
3387
|
}
|
|
3388
|
+
case 'add': {
|
|
3389
|
+
// Register an OpenAI-compatible custom endpoint non-interactively.
|
|
3390
|
+
// Mirrors the picker's "+ Add custom" flow but scriptable, so users
|
|
3391
|
+
// can wire NIM / OpenRouter / vLLM into config without entering the
|
|
3392
|
+
// arrow-key UI.
|
|
3393
|
+
// lazyclaw providers add nim \
|
|
3394
|
+
// --base-url https://integrate.api.nvidia.com/v1 \
|
|
3395
|
+
// --api-key nvapi-xxx \
|
|
3396
|
+
// [--default-model meta/llama-3.1-70b] \
|
|
3397
|
+
// [--no-probe]
|
|
3398
|
+
const name = positional[0];
|
|
3399
|
+
const baseUrl = flags['base-url'] || flags.baseUrl;
|
|
3400
|
+
const apiKey = flags['api-key'] || flags.apiKey || '';
|
|
3401
|
+
if (!name || !baseUrl) {
|
|
3402
|
+
console.error('Usage: lazyclaw providers add <name> --base-url <url> [--api-key <key>] [--default-model <id>] [--no-probe]');
|
|
3403
|
+
process.exit(2);
|
|
3404
|
+
}
|
|
3405
|
+
let validName;
|
|
3406
|
+
try { validName = _registryMod.validateCustomProviderName(name); }
|
|
3407
|
+
catch (e) { console.error(e.message); process.exit(2); }
|
|
3408
|
+
if (!/^https?:\/\//i.test(String(baseUrl))) {
|
|
3409
|
+
console.error('--base-url must start with http:// or https://');
|
|
3410
|
+
process.exit(2);
|
|
3411
|
+
}
|
|
3412
|
+
const cfg = readConfig();
|
|
3413
|
+
cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
|
|
3414
|
+
const idx = cfg.customProviders.findIndex((p) => p && p.name === validName);
|
|
3415
|
+
const entry = {
|
|
3416
|
+
name: validName,
|
|
3417
|
+
baseUrl: String(baseUrl).replace(/\/+$/, ''),
|
|
3418
|
+
apiKey: apiKey || undefined,
|
|
3419
|
+
};
|
|
3420
|
+
if (flags['default-model']) entry.defaultModel = flags['default-model'];
|
|
3421
|
+
if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
|
|
3422
|
+
else cfg.customProviders.push(entry);
|
|
3423
|
+
writeConfig(cfg);
|
|
3424
|
+
_registryMod.registerCustomProviders(cfg);
|
|
3425
|
+
|
|
3426
|
+
let probe = null;
|
|
3427
|
+
if (!flags['no-probe']) {
|
|
3428
|
+
try {
|
|
3429
|
+
const list = await _registryMod.fetchOpenAICompatModels({
|
|
3430
|
+
baseUrl: entry.baseUrl, apiKey: entry.apiKey || '',
|
|
3431
|
+
});
|
|
3432
|
+
probe = { ok: true, modelCount: list.length, sample: list.slice(0, 8) };
|
|
3433
|
+
if (list.length) {
|
|
3434
|
+
const updated = readConfig();
|
|
3435
|
+
const i = (updated.customProviders || []).findIndex((p) => p && p.name === validName);
|
|
3436
|
+
if (i >= 0) {
|
|
3437
|
+
updated.customProviders[i].suggestedModels = list.slice(0, 50);
|
|
3438
|
+
if (!updated.customProviders[i].defaultModel) updated.customProviders[i].defaultModel = list[0];
|
|
3439
|
+
writeConfig(updated);
|
|
3440
|
+
_registryMod.registerCustomProviders(updated);
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
} catch (e) {
|
|
3444
|
+
probe = { ok: false, error: e?.message || String(e) };
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
console.log(JSON.stringify({
|
|
3448
|
+
ok: true, added: validName, baseUrl: entry.baseUrl, hasApiKey: !!entry.apiKey, probe,
|
|
3449
|
+
}, null, 2));
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
case 'remove': {
|
|
3453
|
+
const name = positional[0];
|
|
3454
|
+
if (!name) { console.error('Usage: lazyclaw providers remove <name>'); process.exit(2); }
|
|
3455
|
+
const cfg = readConfig();
|
|
3456
|
+
const list = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
|
|
3457
|
+
const before = list.length;
|
|
3458
|
+
cfg.customProviders = list.filter((p) => !(p && p.name === name));
|
|
3459
|
+
if (cfg.customProviders.length === before) {
|
|
3460
|
+
console.error(`no custom provider named "${name}" — registered: ${list.map((p) => p.name).join(', ') || '(none)'}`);
|
|
3461
|
+
process.exit(2);
|
|
3462
|
+
}
|
|
3463
|
+
writeConfig(cfg);
|
|
3464
|
+
// The in-memory PROVIDERS map keeps the dropped entry until process
|
|
3465
|
+
// restart — fine for the CLI (each invocation re-registers from
|
|
3466
|
+
// disk). We don't try to mutate it here.
|
|
3467
|
+
console.log(JSON.stringify({ ok: true, removed: name }, null, 2));
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
case 'models': {
|
|
3471
|
+
// Fetch + print the live model list from a provider's /v1/models.
|
|
3472
|
+
// Works for any registered OpenAI-compatible endpoint (custom +
|
|
3473
|
+
// openai + ollama). Used by the picker but useful standalone too:
|
|
3474
|
+
// lazyclaw providers models nim
|
|
3475
|
+
// lazyclaw providers models openai --filter gpt-4
|
|
3476
|
+
const name = positional[0];
|
|
3477
|
+
if (!name) { console.error('Usage: lazyclaw providers models <name> [--filter <substr>]'); process.exit(2); }
|
|
3478
|
+
if (!_registryMod.PROVIDERS[name]) {
|
|
3479
|
+
console.error(`unknown provider: ${name}`);
|
|
3480
|
+
process.exit(2);
|
|
3481
|
+
}
|
|
3482
|
+
try {
|
|
3483
|
+
const list = await _fetchModelsForProvider(name);
|
|
3484
|
+
let out = list;
|
|
3485
|
+
if (flags.filter) {
|
|
3486
|
+
const f = String(flags.filter).toLowerCase();
|
|
3487
|
+
out = out.filter((m) => m.toLowerCase().includes(f));
|
|
3488
|
+
}
|
|
3489
|
+
console.log(JSON.stringify({ ok: true, provider: name, count: out.length, models: out }, null, 2));
|
|
3490
|
+
return;
|
|
3491
|
+
} catch (e) {
|
|
3492
|
+
console.log(JSON.stringify({ ok: false, provider: name, error: e?.message || String(e) }, null, 2));
|
|
3493
|
+
process.exit(1);
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3116
3496
|
default:
|
|
3117
|
-
console.error('Usage: lazyclaw providers <list|info <name>|test <name>
|
|
3497
|
+
console.error('Usage: lazyclaw providers <list|info <name>|test <name>|add <name> --base-url <url> [--api-key <k>]|remove <name>|models <name>>');
|
|
3118
3498
|
process.exit(2);
|
|
3119
3499
|
}
|
|
3120
3500
|
}
|
|
@@ -3333,6 +3713,8 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
3333
3713
|
'aggregate', // inspect (list mode): per-node stats across sessions
|
|
3334
3714
|
'all', // providers test: run all providers in parallel
|
|
3335
3715
|
'with-turn-count', // sessions list: include turn count per session
|
|
3716
|
+
'no-probe', // providers add: skip the /v1/models reachability probe
|
|
3717
|
+
'pick', // onboard / chat: force the interactive picker even when provider already set
|
|
3336
3718
|
]);
|
|
3337
3719
|
|
|
3338
3720
|
function parseArgs(argv) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "3.99.
|
|
3
|
+
"version": "3.99.12",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|