lazyclaw 3.99.12 → 3.99.14

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 CHANGED
@@ -54,6 +54,54 @@ lazyclaw onboard --non-interactive --provider openai \
54
54
 
55
55
  `onboard` only prompts for an api-key when the picked provider's `requiresApiKey` is true (the picker labels each row `[subscription]` / `[api key]` / `[no key]` so the choice is explicit).
56
56
 
57
+ ### Built-in OpenAI-compatible vendors
58
+
59
+ Eight popular OpenAI-compatible services ship as first-class providers — pick one in the setup picker (no `+ Add custom` walkthrough needed) or set the matching environment variable and they Just Work:
60
+
61
+ | Provider | Models include | Env var |
62
+ |---|---|---|
63
+ | `nim` | `meta/llama-3.1-405b-instruct`, `nvidia/llama-3.1-nemotron-70b-instruct`, `deepseek-ai/deepseek-r1`, `mistralai/mixtral-8x22b-instruct-v0.1` | `NVIDIA_API_KEY` (or `NIM_API_KEY`) |
64
+ | `openrouter` | `anthropic/claude-3.5-sonnet`, `openai/gpt-4o`, `meta-llama/llama-3.1-405b-instruct`, `deepseek/deepseek-r1` | `OPENROUTER_API_KEY` |
65
+ | `groq` | `llama-3.3-70b-versatile`, `mixtral-8x7b-32768`, `deepseek-r1-distill-llama-70b` | `GROQ_API_KEY` |
66
+ | `together` | `meta-llama/Llama-3.3-70B-Instruct-Turbo`, `Qwen/Qwen2.5-72B-Instruct-Turbo`, `deepseek-ai/DeepSeek-V3` | `TOGETHER_API_KEY` |
67
+ | `xai` | `grok-2-latest`, `grok-2-vision-1212` | `XAI_API_KEY` (or `GROK_API_KEY`) |
68
+ | `deepseek` | `deepseek-chat`, `deepseek-reasoner` | `DEEPSEEK_API_KEY` |
69
+ | `mistral` | `mistral-large-latest`, `codestral-latest`, `pixtral-large-latest` | `MISTRAL_API_KEY` |
70
+ | `fireworks` | `accounts/fireworks/models/llama-v3p3-70b-instruct`, `…/deepseek-r1` | `FIREWORKS_API_KEY` |
71
+
72
+ ```bash
73
+ # NVIDIA NIM via env var — no `lazyclaw onboard` needed
74
+ export NVIDIA_API_KEY=nvapi-...
75
+ lazyclaw chat --provider nim --model meta/llama-3.1-405b-instruct
76
+
77
+ # Or commit the choice to ~/.lazyclaw/config.json
78
+ lazyclaw onboard --non-interactive --provider nim \
79
+ --model nvidia/llama-3.1-nemotron-70b-instruct --api-key nvapi-...
80
+ ```
81
+
82
+ Need a vendor that's **not** built-in? `+ Add a custom OpenAI-compatible endpoint…` inside the setup picker (or `lazyclaw providers add <name> --base-url <url>`) still works for vLLM / LM Studio / private gateways / anything else that speaks the OpenAI v1 wire format.
83
+
84
+ ## Launcher (no-arg `lazyclaw`)
85
+
86
+ Running `lazyclaw` with no subcommand drops into an arrow-key launcher with every subcommand laid out as a menu. Navigation:
87
+
88
+ | Key | What it does |
89
+ |---|---|
90
+ | `↑` / `↓` / `Home` / `End` / `PgUp` / `PgDn` | Move the selection |
91
+ | `Enter` | Run the highlighted item |
92
+ | `q` / `Esc` / `Ctrl-C` | Leave lazyclaw |
93
+ | `/` | Open an inline slash-command prompt |
94
+
95
+ Slash commands at the launcher (typed after `/`):
96
+
97
+ | Slash | What it does |
98
+ |---|---|
99
+ | `/exit` / `/quit` | Leave lazyclaw |
100
+ | `/help` | List launcher slash commands inline |
101
+ | `/version` | Print version + node + platform |
102
+
103
+ The slash buffer lives just below the menu — backspace edits it, deleting past `/` returns to menu mode, and `Esc` cancels slash mode without leaving lazyclaw.
104
+
57
105
  ## Interactive chat
58
106
 
59
107
  ```bash
@@ -88,12 +136,14 @@ Slash commands inside the REPL:
88
136
  |---|---|
89
137
  | `/help` | List slash commands |
90
138
  | `/status` | Print provider + model + masked key |
91
- | `/provider X` | Switch active provider mid-session (history kept) |
92
- | `/model X` | Switch model. Accepts unified `provider/model` form |
139
+ | `/provider` | Open the family / provider / model arrow picker |
140
+ | `/provider X` | Switch active provider directly by name |
141
+ | `/model` | Open the per-provider model picker (type-filter + live `/v1/models` fetch) |
142
+ | `/model X` | Switch model directly. Accepts unified `provider/model` form |
93
143
  | `/skill a,b` | Replace the system prompt with a composition of named skills |
94
144
  | `/usage` | Message count + chars + cumulative token totals |
95
145
  | `/new` / `/reset` | Wipe history and start over |
96
- | `/exit` | Quit |
146
+ | `/exit` | Leave the chat REPL (returns to the launcher when chat was opened from it) |
97
147
 
98
148
  **Cursor-style ghost autocomplete**: type `/` and the longest matching slash command appears in dim grey after the cursor. **`→`** accepts; **`Tab`** cycles. **Ctrl-C** during a streaming reply aborts that turn (not the whole process); **Ctrl-C** at an empty prompt exits.
99
149
 
package/cli.mjs CHANGED
@@ -33,19 +33,26 @@ function writeConfig(cfg) {
33
33
  // without forcing the dynamic import on every hot-path call.
34
34
  // 1. cfg.authProfiles[provider] active label, if set
35
35
  // 2. first profile in the array
36
- // 3. legacy single `cfg["api-key"]` (pre-v3.93 configs)
36
+ // 3. customProviders[<provider>].apiKey (custom OpenAI-compat entries)
37
+ // 4. PROVIDER_INFO[<provider>].envKey / altEnvKeys env var (built-in
38
+ // OpenAI-compat: nim → NVIDIA_API_KEY, openrouter → OPENROUTER_API_KEY, …)
39
+ // 5. legacy single `cfg["api-key"]` (pre-v3.93 configs)
37
40
  function _resolveAuthKey(cfg, provider) {
38
41
  const arr = (cfg.authProfiles || {})[provider] || [];
39
42
  const active = (cfg.authActiveProfile || {})[provider];
40
43
  const hit = arr.find((p) => p && p.label === active) || arr[0];
41
44
  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
45
  const custom = Array.isArray(cfg.customProviders)
46
46
  ? cfg.customProviders.find((p) => p && p.name === provider)
47
47
  : null;
48
48
  if (custom?.apiKey) return custom.apiKey;
49
+ // Built-in OpenAI-compat env var fallback. Skipped silently when the
50
+ // registry module isn't loaded yet (every chat / agent path calls
51
+ // ensureRegistry() before _resolveAuthKey, so this is just defence-in-depth).
52
+ if (_registryMod && typeof _registryMod.resolveBuiltinEnvKey === 'function') {
53
+ const envHit = _registryMod.resolveBuiltinEnvKey(provider);
54
+ if (envHit) return envHit;
55
+ }
49
56
  return cfg['api-key'] || '';
50
57
  }
51
58
 
@@ -1798,17 +1805,58 @@ async function _pickProviderInteractive() {
1798
1805
  }
1799
1806
 
1800
1807
  // ── Step 3 — model ────────────────────────────────────────────
1801
- const meta = info[provider.id] || {};
1808
+ const picked = await _pickModelInteractive(provider.id, {
1809
+ titlePrefix: 'LazyClaw setup — Step 3 of 3:',
1810
+ onBack: 'restart',
1811
+ });
1812
+ if (picked === 'CANCEL') return null;
1813
+ if (picked === 'BACK') return _pickProviderInteractive();
1814
+ return { provider: provider.id, model: picked };
1815
+ }
1816
+
1817
+ // Pause the chat REPL's readline + ghost-autocomplete while a sub-picker
1818
+ // (provider / model arrow menu) takes over the terminal. The sub-picker
1819
+ // installs its own `keypress` listener and toggles raw mode; the chat's
1820
+ // readline would race it for stdin if we left it active. After `body`
1821
+ // returns we re-emit keypress events, restore raw mode, and re-prompt
1822
+ // so the chat resumes cleanly. `body` is awaited — exceptions propagate.
1823
+ async function _pauseChatForSubMenu(rl, ghost, body) {
1824
+ if (ghost && typeof ghost.suspend === 'function') ghost.suspend();
1825
+ try { rl.pause(); } catch (_) {}
1826
+ // Drop the readline keypress hook so the picker's own listener has
1827
+ // sole ownership while it's open. We re-arm it on the way out.
1828
+ if (process.stdin.setRawMode) {
1829
+ try { process.stdin.setRawMode(false); } catch (_) {}
1830
+ }
1831
+ try {
1832
+ await body();
1833
+ } finally {
1834
+ const readline = await import('node:readline');
1835
+ try { readline.emitKeypressEvents(process.stdin); } catch (_) {}
1836
+ if (process.stdin.setRawMode && process.stdin.isTTY) {
1837
+ try { process.stdin.setRawMode(false); } catch (_) {}
1838
+ }
1839
+ process.stdin.resume();
1840
+ if (process.stdin.ref) process.stdin.ref();
1841
+ if (ghost && typeof ghost.resume === 'function') ghost.resume();
1842
+ try { rl.resume(); } catch (_) {}
1843
+ try { rl.prompt(); } catch (_) {}
1844
+ }
1845
+ }
1846
+
1847
+ // Standalone model picker for the chat REPL's `/model` slash. Returns
1848
+ // the chosen model id (string), 'BACK', or 'CANCEL'. Falls through to
1849
+ // null when the provider has no curated models and no live-fetch surface
1850
+ // (mock) — the caller should treat that as "use the provider default".
1851
+ async function _pickModelInteractive(providerId, opts = {}) {
1852
+ const info = _registryMod.PROVIDER_INFO || {};
1853
+ const meta = info[providerId] || {};
1802
1854
  const baseModels = Array.isArray(meta.suggestedModels) ? meta.suggestedModels.slice() : [];
1803
1855
  const isCustom = !!meta.custom;
1804
- const supportsLiveFetch = !!meta.baseUrl || provider.id === 'openai' || provider.id === 'ollama';
1856
+ const isBuiltinCompat = !!meta.builtinOpenAICompat;
1857
+ const supportsLiveFetch = !!meta.baseUrl || providerId === 'openai' || providerId === 'ollama' || isBuiltinCompat;
1805
1858
 
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.
1810
- return { provider: provider.id, model: null };
1811
- }
1859
+ if (!baseModels.length && !supportsLiveFetch) return null;
1812
1860
 
1813
1861
  let dynamicModels = [];
1814
1862
  while (true) {
@@ -1818,7 +1866,7 @@ async function _pickProviderInteractive() {
1818
1866
  modelItems.unshift({
1819
1867
  id: '__fetch_models__',
1820
1868
  label: '↻ Fetch live model list from /v1/models',
1821
- desc: isCustom ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
1869
+ desc: isCustom || isBuiltinCompat ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
1822
1870
  tag: '\x1b[38;5;245m[live]\x1b[0m',
1823
1871
  });
1824
1872
  }
@@ -1832,24 +1880,25 @@ async function _pickProviderInteractive() {
1832
1880
  const defaultIdx = supportsLiveFetch
1833
1881
  ? Math.max(0, 1 + allModels.indexOf(meta.defaultModel || allModels[0]))
1834
1882
  : Math.max(0, allModels.indexOf(meta.defaultModel || allModels[0]));
1883
+ const titlePrefix = opts.titlePrefix ? `${opts.titlePrefix} ` : '';
1835
1884
  const picked = await _arrowMenu({
1836
- title: `LazyClaw setup — Step 3 of 3: pick a model for ${provider.id}`,
1885
+ title: `${titlePrefix}pick a model for ${providerId}`,
1837
1886
  subtitle: `Type to filter ${allModels.length} model(s). Enter to confirm. Backspace clears one char, Ctrl+U clears the filter.`,
1838
1887
  items: modelItems,
1839
1888
  defaultIdx,
1840
1889
  searchable: true,
1841
1890
  });
1842
- if (picked === 'CANCEL') return null;
1843
- if (picked === 'BACK') return _pickProviderInteractive(); // back to step 1
1891
+ if (picked === 'CANCEL') return 'CANCEL';
1892
+ if (picked === 'BACK') return 'BACK';
1844
1893
  if (picked.id === '__custom_model__') {
1845
- const typed = (await _quickPrompt(` model id for ${provider.id}: `)).trim();
1894
+ const typed = (await _quickPrompt(` model id for ${providerId}: `)).trim();
1846
1895
  if (!typed) continue;
1847
- return { provider: provider.id, model: typed };
1896
+ return typed;
1848
1897
  }
1849
1898
  if (picked.id === '__fetch_models__') {
1850
1899
  try {
1851
- process.stdout.write(`\n fetching ${provider.id} model list…\n`);
1852
- const fetched = await _fetchModelsForProvider(provider.id);
1900
+ process.stdout.write(`\n fetching ${providerId} model list…\n`);
1901
+ const fetched = await _fetchModelsForProvider(providerId);
1853
1902
  if (!fetched.length) {
1854
1903
  process.stdout.write(` ${'\x1b[33m'}no models returned${'\x1b[0m'} — falling back to the suggested list.\n`);
1855
1904
  await _quickPrompt(' press Enter to continue ');
@@ -1864,7 +1913,7 @@ async function _pickProviderInteractive() {
1864
1913
  }
1865
1914
  continue;
1866
1915
  }
1867
- return { provider: provider.id, model: picked.id };
1916
+ return picked.id;
1868
1917
  }
1869
1918
  }
1870
1919
 
@@ -1878,6 +1927,12 @@ function _modelCatalogueFor(providerId) {
1878
1927
  const entry = (cfg.customProviders || []).find((p) => p && p.name === providerId) || {};
1879
1928
  return { baseUrl: meta.baseUrl, apiKey: entry.apiKey || cfg['api-key'] || '' };
1880
1929
  }
1930
+ // Built-in OpenAI-compatible vendors (nim / openrouter / groq / together /
1931
+ // xai / deepseek / mistral / fireworks). The registry exposes a baseUrl
1932
+ // and the auth-key resolver already knows about the env-var fallback.
1933
+ if (meta.builtinOpenAICompat && meta.baseUrl) {
1934
+ return { baseUrl: meta.baseUrl, apiKey: _resolveAuthKey(cfg, providerId) };
1935
+ }
1881
1936
  if (providerId === 'openai') {
1882
1937
  return { baseUrl: 'https://api.openai.com/v1', apiKey: _resolveAuthKey(cfg, 'openai') };
1883
1938
  }
@@ -2133,10 +2188,28 @@ async function cmdChat(flags = {}) {
2133
2188
  // `/provider <name>` switches the active provider for subsequent
2134
2189
  // turns. The conversation history stays put — the next user
2135
2190
  // message goes to the new provider with the existing context.
2136
- // `/provider` (no arg) prints the current name.
2191
+ // `/provider` (no arg) opens the family/provider/model picker so
2192
+ // the user can switch with arrow keys instead of memorising names.
2137
2193
  const arg = line.slice('/provider'.length).trim();
2138
2194
  if (!arg) {
2139
- process.stdout.write(`provider: ${activeProvName}\n`);
2195
+ if (!useTerminal) {
2196
+ process.stdout.write(`provider: ${activeProvName}\n`);
2197
+ return true;
2198
+ }
2199
+ await _pauseChatForSubMenu(rl, _ghost, async () => {
2200
+ const picked = await _pickProviderInteractive();
2201
+ if (picked && picked.provider) {
2202
+ const next = lookupProv(picked.provider);
2203
+ if (!next) {
2204
+ process.stdout.write(`unknown provider: ${picked.provider}\n`);
2205
+ return;
2206
+ }
2207
+ activeProvName = picked.provider;
2208
+ prov = next;
2209
+ if (picked.model) activeModel = picked.model;
2210
+ process.stdout.write(`provider → ${activeProvName}${picked.model ? ` · model → ${picked.model}` : ''}\n`);
2211
+ }
2212
+ });
2140
2213
  return true;
2141
2214
  }
2142
2215
  const next = lookupProv(arg);
@@ -2151,10 +2224,20 @@ async function cmdChat(flags = {}) {
2151
2224
  }
2152
2225
  case '/model': {
2153
2226
  // `/model <name>` updates the active model without touching the
2154
- // provider. `/model` (no arg) prints the current value.
2227
+ // provider. `/model` (no arg) opens the per-provider model picker
2228
+ // — same UX as setup step 3, scoped to the active provider.
2155
2229
  const arg = line.slice('/model'.length).trim();
2156
2230
  if (!arg) {
2157
- process.stdout.write(`model: ${activeModel || '(default)'}\n`);
2231
+ if (!useTerminal) {
2232
+ process.stdout.write(`model: ${activeModel || '(default)'}\n`);
2233
+ return true;
2234
+ }
2235
+ await _pauseChatForSubMenu(rl, _ghost, async () => {
2236
+ const chosen = await _pickModelInteractive(activeProvName, { titlePrefix: 'LazyClaw chat —' });
2237
+ if (chosen === 'CANCEL' || chosen === 'BACK' || !chosen) return;
2238
+ activeModel = chosen;
2239
+ process.stdout.write(`model → ${activeModel}\n`);
2240
+ });
2158
2241
  return true;
2159
2242
  }
2160
2243
  // Honor unified provider/model: `/model anthropic/claude-opus-4-7`
@@ -4084,7 +4167,7 @@ async function cmdLauncher() {
4084
4167
  process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
4085
4168
  process.stdout.write(` ${dim('config ·')} ${dim(configPath())}\n`);
4086
4169
  process.stdout.write('\n');
4087
- process.stdout.write(` ${dim('↑/↓ to move · Enter to select · q or Esc to quit')}\n\n`);
4170
+ process.stdout.write(` ${dim('↑/↓ to move · Enter to select · / for slash command (e.g. /exit) · q or Esc to quit')}\n\n`);
4088
4171
  const rowsAvail = Math.max(items.length, (process.stdout.rows || 30) - 14);
4089
4172
  const fromIdx = Math.max(0, Math.min(items.length - rowsAvail, idx - Math.floor(rowsAvail / 2)));
4090
4173
  const toIdx = Math.min(items.length, fromIdx + rowsAvail);
@@ -4097,10 +4180,74 @@ async function cmdLauncher() {
4097
4180
  process.stdout.write('\n');
4098
4181
  };
4099
4182
 
4183
+ // Slash-command mini prompt rendered just below the menu. Lets users
4184
+ // type `/exit` / `/quit` / `/help` to leave (or get a list of slash
4185
+ // commands) without hunting for the right special key. The menu is
4186
+ // raw-mode and never sees a newline-terminated line, so we accumulate
4187
+ // keystrokes locally instead of round-tripping through readline.
4188
+ let slashBuffer = null; // null = menu mode; string = slash mode (always starts with '/')
4189
+ let slashNotice = ''; // one-line hint shown after the buffer (e.g. "unknown command")
4190
+ const LAUNCHER_SLASH_HELP = [
4191
+ { cmd: '/exit', help: 'leave lazyclaw' },
4192
+ { cmd: '/quit', help: 'alias for /exit' },
4193
+ { cmd: '/help', help: 'list slash commands' },
4194
+ { cmd: '/version', help: 'print version + node + platform' },
4195
+ ];
4196
+ const drawWithSlash = () => {
4197
+ draw();
4198
+ process.stdout.write(` ${dim('slash ›')} ${slashBuffer}`);
4199
+ if (slashNotice) process.stdout.write(` ${slashNotice}`);
4200
+ process.stdout.write('\x1b[?25h'); // show cursor while typing
4201
+ };
4202
+
4100
4203
  draw();
4101
4204
  const picked = await new Promise((resolve) => {
4102
- const onKey = (_str, key) => {
4205
+ const onKey = (str, key) => {
4103
4206
  if (!key) return;
4207
+
4208
+ // ── Slash-command input mode ─────────────────────────────────
4209
+ if (slashBuffer !== null) {
4210
+ if (key.ctrl && key.name === 'c') { cleanup(); resolve({ id: 'quit', argv: null }); return; }
4211
+ if (key.name === 'escape') { slashBuffer = null; slashNotice = ''; draw(); return; }
4212
+ if (key.name === 'return') {
4213
+ const cmd = slashBuffer.trim().toLowerCase();
4214
+ if (cmd === '/exit' || cmd === '/quit') { cleanup(); resolve({ id: 'quit', argv: null }); return; }
4215
+ if (cmd === '/help') {
4216
+ slashBuffer = '/';
4217
+ slashNotice = dim(LAUNCHER_SLASH_HELP.map(c => `${c.cmd} (${c.help})`).join(' · '));
4218
+ drawWithSlash();
4219
+ return;
4220
+ }
4221
+ if (cmd === '/version') {
4222
+ const v = readVersionFromRepo();
4223
+ slashNotice = ok(`v${v} · node ${process.version} · ${process.platform}-${process.arch}`);
4224
+ drawWithSlash();
4225
+ return;
4226
+ }
4227
+ // Unknown command — keep the buffer so the user can edit it
4228
+ // rather than retyping from scratch. Esc / Backspace bails.
4229
+ slashNotice = warn(`unknown — try ${LAUNCHER_SLASH_HELP.map(c => c.cmd).join(' · ')}`);
4230
+ drawWithSlash();
4231
+ return;
4232
+ }
4233
+ if (key.name === 'backspace') {
4234
+ slashNotice = '';
4235
+ if (slashBuffer.length > 1) slashBuffer = slashBuffer.slice(0, -1);
4236
+ else slashBuffer = null;
4237
+ slashBuffer === null ? draw() : drawWithSlash();
4238
+ return;
4239
+ }
4240
+ // Append printable characters. Filter control / meta chords so
4241
+ // Ctrl+L etc. don't pollute the buffer.
4242
+ if (str && str.length === 1 && !key.ctrl && !key.meta && str >= ' ') {
4243
+ slashBuffer += str;
4244
+ slashNotice = '';
4245
+ drawWithSlash();
4246
+ }
4247
+ return;
4248
+ }
4249
+
4250
+ // ── Menu navigation mode ─────────────────────────────────────
4104
4251
  if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
4105
4252
  else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
4106
4253
  else if (key.name === 'home') { idx = 0; draw(); }
@@ -4110,6 +4257,7 @@ async function cmdLauncher() {
4110
4257
  else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
4111
4258
  else if (key.ctrl && key.name === 'c') { cleanup(); resolve({ id: 'quit', argv: null }); }
4112
4259
  else if (key.name === 'escape' || key.name === 'q') { cleanup(); resolve({ id: 'quit', argv: null }); }
4260
+ else if (str === '/') { slashBuffer = '/'; slashNotice = ''; drawWithSlash(); }
4113
4261
  function cleanup() {
4114
4262
  process.stdin.off('keypress', onKey);
4115
4263
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.12",
3
+ "version": "3.99.14",
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",
@@ -52,6 +52,164 @@ export const mockProvider = {
52
52
  export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, claudeCliProvider };
53
53
  export { makeOpenAICompatProvider, fetchOpenAICompatModels };
54
54
 
55
+ // Built-in OpenAI-compatible vendors. Same wire format → one factory call
56
+ // each. The picker treats these like first-class providers so users don't
57
+ // have to walk through "+ Add a custom endpoint" for the popular ones.
58
+ //
59
+ // Each entry must define baseUrl + envKey (the env var the chat path
60
+ // consults when no api-key is configured) + suggestedModels (curated list
61
+ // shown before the user fetches the live /v1/models catalogue).
62
+ //
63
+ // Adding a new vendor: drop a row here. The PROVIDERS / PROVIDER_INFO loops
64
+ // below pick it up automatically.
65
+ export const OPENAI_COMPAT_BUILTINS = {
66
+ nim: {
67
+ label: 'NVIDIA NIM',
68
+ baseUrl: 'https://integrate.api.nvidia.com/v1',
69
+ envKey: 'NVIDIA_API_KEY',
70
+ altEnvKeys: ['NIM_API_KEY'],
71
+ keyPrefix: 'nvapi-',
72
+ docs: 'NVIDIA NIM hosted catalogue (Llama 3.x, Nemotron, DeepSeek-R1, Mixtral, Phi-3, Qwen, etc.). Auth: NVIDIA_API_KEY env var or in-app api-key. Endpoint speaks the OpenAI v1 wire format.',
73
+ defaultModel: 'meta/llama-3.1-405b-instruct',
74
+ suggestedModels: [
75
+ 'meta/llama-3.1-405b-instruct',
76
+ 'meta/llama-3.1-70b-instruct',
77
+ 'meta/llama-3.1-8b-instruct',
78
+ 'nvidia/llama-3.1-nemotron-70b-instruct',
79
+ 'nvidia/nemotron-mini-4b-instruct',
80
+ 'nvidia/llama-3.3-nemotron-super-49b-v1',
81
+ 'mistralai/mistral-nemo-12b-instruct',
82
+ 'mistralai/mixtral-8x22b-instruct-v0.1',
83
+ 'microsoft/phi-3-medium-4k-instruct',
84
+ 'deepseek-ai/deepseek-r1',
85
+ 'qwen/qwen2.5-7b-instruct',
86
+ 'qwen/qwen2.5-coder-32b-instruct',
87
+ ],
88
+ },
89
+ openrouter: {
90
+ label: 'OpenRouter',
91
+ baseUrl: 'https://openrouter.ai/api/v1',
92
+ envKey: 'OPENROUTER_API_KEY',
93
+ keyPrefix: 'sk-or-',
94
+ docs: 'OpenRouter unified gateway — 200+ models behind one OpenAI-compatible endpoint. Auth: OPENROUTER_API_KEY env var or in-app api-key. Uses x-title/HTTP-Referer headers for attribution.',
95
+ defaultModel: 'anthropic/claude-3.5-sonnet',
96
+ headers: { 'http-referer': 'https://github.com/cmblir/lazyclaude', 'x-title': 'lazyclaw' },
97
+ suggestedModels: [
98
+ 'anthropic/claude-3.5-sonnet',
99
+ 'anthropic/claude-3-opus',
100
+ 'openai/gpt-4o',
101
+ 'openai/gpt-4o-mini',
102
+ 'openai/o1-preview',
103
+ 'meta-llama/llama-3.1-405b-instruct',
104
+ 'meta-llama/llama-3.3-70b-instruct',
105
+ 'google/gemini-2.0-flash-exp:free',
106
+ 'google/gemini-pro-1.5',
107
+ 'deepseek/deepseek-chat',
108
+ 'deepseek/deepseek-r1',
109
+ 'qwen/qwen-2.5-coder-32b-instruct',
110
+ 'mistralai/mistral-large',
111
+ ],
112
+ },
113
+ groq: {
114
+ label: 'Groq',
115
+ baseUrl: 'https://api.groq.com/openai/v1',
116
+ envKey: 'GROQ_API_KEY',
117
+ keyPrefix: 'gsk_',
118
+ docs: 'Groq LPU inference — fastest-token-per-second tier for Llama / Mixtral / Gemma. Auth: GROQ_API_KEY env var or in-app api-key.',
119
+ defaultModel: 'llama-3.3-70b-versatile',
120
+ suggestedModels: [
121
+ 'llama-3.3-70b-versatile',
122
+ 'llama-3.1-70b-versatile',
123
+ 'llama-3.1-8b-instant',
124
+ 'llama-3.2-90b-vision-preview',
125
+ 'mixtral-8x7b-32768',
126
+ 'gemma2-9b-it',
127
+ 'qwen-2.5-coder-32b',
128
+ 'qwen-2.5-32b',
129
+ 'deepseek-r1-distill-llama-70b',
130
+ ],
131
+ },
132
+ together: {
133
+ label: 'Together AI',
134
+ baseUrl: 'https://api.together.xyz/v1',
135
+ envKey: 'TOGETHER_API_KEY',
136
+ docs: 'Together AI hosted inference for open-weight models (Llama, Mixtral, Qwen, DeepSeek, etc.). Auth: TOGETHER_API_KEY env var or in-app api-key.',
137
+ defaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
138
+ suggestedModels: [
139
+ 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
140
+ 'meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo',
141
+ 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo',
142
+ 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
143
+ 'mistralai/Mixtral-8x22B-Instruct-v0.1',
144
+ 'mistralai/Mixtral-8x7B-Instruct-v0.1',
145
+ 'Qwen/Qwen2.5-72B-Instruct-Turbo',
146
+ 'Qwen/Qwen2.5-Coder-32B-Instruct',
147
+ 'deepseek-ai/DeepSeek-V3',
148
+ 'deepseek-ai/DeepSeek-R1',
149
+ ],
150
+ },
151
+ xai: {
152
+ label: 'xAI (Grok)',
153
+ baseUrl: 'https://api.x.ai/v1',
154
+ envKey: 'XAI_API_KEY',
155
+ altEnvKeys: ['GROK_API_KEY'],
156
+ keyPrefix: 'xai-',
157
+ docs: 'xAI Grok models. Auth: XAI_API_KEY env var or in-app api-key.',
158
+ defaultModel: 'grok-2-latest',
159
+ suggestedModels: [
160
+ 'grok-2-latest',
161
+ 'grok-2-1212',
162
+ 'grok-2-vision-1212',
163
+ 'grok-beta',
164
+ 'grok-vision-beta',
165
+ ],
166
+ },
167
+ deepseek: {
168
+ label: 'DeepSeek',
169
+ baseUrl: 'https://api.deepseek.com/v1',
170
+ envKey: 'DEEPSEEK_API_KEY',
171
+ keyPrefix: 'sk-',
172
+ docs: 'DeepSeek (deepseek-chat / deepseek-reasoner). Auth: DEEPSEEK_API_KEY env var or in-app api-key.',
173
+ defaultModel: 'deepseek-chat',
174
+ suggestedModels: [
175
+ 'deepseek-chat',
176
+ 'deepseek-reasoner',
177
+ 'deepseek-coder',
178
+ ],
179
+ },
180
+ mistral: {
181
+ label: 'Mistral La Plateforme',
182
+ baseUrl: 'https://api.mistral.ai/v1',
183
+ envKey: 'MISTRAL_API_KEY',
184
+ docs: 'Mistral La Plateforme (mistral-large, codestral, ministral, pixtral). Auth: MISTRAL_API_KEY env var or in-app api-key.',
185
+ defaultModel: 'mistral-large-latest',
186
+ suggestedModels: [
187
+ 'mistral-large-latest',
188
+ 'mistral-small-latest',
189
+ 'codestral-latest',
190
+ 'ministral-8b-latest',
191
+ 'ministral-3b-latest',
192
+ 'pixtral-large-latest',
193
+ 'open-mistral-nemo',
194
+ ],
195
+ },
196
+ fireworks: {
197
+ label: 'Fireworks AI',
198
+ baseUrl: 'https://api.fireworks.ai/inference/v1',
199
+ envKey: 'FIREWORKS_API_KEY',
200
+ docs: 'Fireworks AI hosted models. Auth: FIREWORKS_API_KEY env var or in-app api-key.',
201
+ defaultModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
202
+ suggestedModels: [
203
+ 'accounts/fireworks/models/llama-v3p3-70b-instruct',
204
+ 'accounts/fireworks/models/llama-v3p1-405b-instruct',
205
+ 'accounts/fireworks/models/qwen2p5-coder-32b-instruct',
206
+ 'accounts/fireworks/models/deepseek-r1',
207
+ 'accounts/fireworks/models/deepseek-v3',
208
+ 'accounts/fireworks/models/mixtral-8x22b-instruct',
209
+ ],
210
+ },
211
+ };
212
+
55
213
  // Insertion order is the picker order. The list goes first-to-last in
56
214
  // rough "user-familiar / popular" order so a first-time onboard lands
57
215
  // the cursor on a vendor most users recognise. v3.99.5 reordered per
@@ -63,11 +221,34 @@ export const PROVIDERS = {
63
221
  // Tier 2 — Claude. CLI variant first because it's keyless.
64
222
  'claude-cli': claudeCliProvider,
65
223
  anthropic: anthropicProvider,
66
- // Tier 3 — local + dev/test.
224
+ // Tier 3 — popular OpenAI-compatible aggregators / hosted catalogues.
225
+ // Inserted by the loop below from OPENAI_COMPAT_BUILTINS so the order
226
+ // here mirrors that object's insertion order.
227
+ // Tier 4 — local + dev/test.
67
228
  ollama: ollamaProvider,
68
229
  mock: mockProvider,
69
230
  };
70
231
 
232
+ // Wire each OpenAI-compat builtin into PROVIDERS as a callable provider.
233
+ // Insertion is between Tier 2 (anthropic) and Tier 4 (ollama) by reordering
234
+ // the keys after the loop runs — JS objects honour insertion order and
235
+ // cmdLauncher's families helper relies on that for the picker.
236
+ {
237
+ const local = { ollama: PROVIDERS.ollama, mock: PROVIDERS.mock };
238
+ delete PROVIDERS.ollama;
239
+ delete PROVIDERS.mock;
240
+ for (const [name, def] of Object.entries(OPENAI_COMPAT_BUILTINS)) {
241
+ PROVIDERS[name] = makeOpenAICompatProvider({
242
+ name,
243
+ baseUrl: def.baseUrl,
244
+ defaultModel: def.defaultModel,
245
+ headers: def.headers,
246
+ });
247
+ }
248
+ PROVIDERS.ollama = local.ollama;
249
+ PROVIDERS.mock = local.mock;
250
+ }
251
+
71
252
  // Static metadata for `lazyclaw providers list/info`. Kept next to PROVIDERS
72
253
  // so adding a provider in one place can't drift from the list shown to users.
73
254
  export const PROVIDER_INFO = {
@@ -167,6 +348,44 @@ export const PROVIDER_INFO = {
167
348
  },
168
349
  };
169
350
 
351
+ // Mirror the OpenAI-compat builtins into PROVIDER_INFO so picker / docs /
352
+ // `lazyclaw providers info` see them with the same shape as the hand-written
353
+ // entries above.
354
+ for (const [name, def] of Object.entries(OPENAI_COMPAT_BUILTINS)) {
355
+ PROVIDER_INFO[name] = {
356
+ name,
357
+ label: def.label,
358
+ requiresApiKey: true,
359
+ keyPrefix: def.keyPrefix,
360
+ envKey: def.envKey,
361
+ altEnvKeys: Array.isArray(def.altEnvKeys) ? def.altEnvKeys.slice() : [],
362
+ docs: def.docs,
363
+ endpoint: `${def.baseUrl}/chat/completions`,
364
+ defaultModel: def.defaultModel,
365
+ suggestedModels: Array.isArray(def.suggestedModels) ? def.suggestedModels.slice() : [],
366
+ builtin: true,
367
+ builtinOpenAICompat: true,
368
+ baseUrl: def.baseUrl,
369
+ headers: def.headers,
370
+ };
371
+ }
372
+
373
+ /**
374
+ * Resolve an api-key for a built-in OpenAI-compatible provider from the
375
+ * environment, scanning {envKey} then any {altEnvKeys}. Returns '' when
376
+ * no env var is set so the caller can fall through to its config-based
377
+ * lookup chain.
378
+ */
379
+ export function resolveBuiltinEnvKey(provider) {
380
+ const meta = PROVIDER_INFO[provider];
381
+ if (!meta || !meta.builtinOpenAICompat) return '';
382
+ const candidates = [meta.envKey, ...(meta.altEnvKeys || [])].filter(Boolean);
383
+ for (const k of candidates) {
384
+ if (process.env[k]) return process.env[k];
385
+ }
386
+ return '';
387
+ }
388
+
170
389
  /**
171
390
  * Split a unified "provider/model" string (OpenClaw style:
172
391
  * "anthropic/claude-opus-4-7"). Also accepts a bare model id and returns
@@ -198,6 +417,8 @@ export function parseProviderModel(s) {
198
417
  // uses internally. Custom registrations must not collide with these.
199
418
  const RESERVED_PROVIDER_NAMES = new Set([
200
419
  'mock', 'claude-cli', 'anthropic', 'openai', 'gemini', 'ollama',
420
+ // OpenAI-compatible builtins (kept in lockstep with OPENAI_COMPAT_BUILTINS).
421
+ ...Object.keys(OPENAI_COMPAT_BUILTINS),
201
422
  '__add_custom__', '__custom_model__', '__fetch_models__',
202
423
  ]);
203
424