loom-agent 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,85 @@
1
+ const { createOpenAICompatProvider } = require('./openai-compat');
2
+ const { getApiKey } = require('../config/settings');
3
+
4
+ function getKey() {
5
+ return getApiKey('nvidia') || process.env.NVIDIA_API_KEY || process.env.NVIDIA_NIM_API_KEY;
6
+ }
7
+
8
+ // Model IDs below are a curated subset of NVIDIA's live GET /v1/models registry
9
+ // (fetched 2026-08-13). Stale IDs — e.g. meta/llama-3.1-405b-instruct,
10
+ // deepseek-ai/deepseek-v4-flash, minimax-ai/minimax-m2.7 — return 404/410 and
11
+ // were deliberately removed. Re-verify against /v1/models before adding any ID.
12
+ const provider = {
13
+ ...createOpenAICompatProvider({
14
+ getKey,
15
+ providerId: 'nvidia',
16
+ envKeyHint: 'NVIDIA',
17
+ }),
18
+ models: [
19
+ // ── DeepSeek ──
20
+ { id: 'deepseek-ai/deepseek-v4-flash-0731', name: 'DeepSeek V4 Flash', provider: 'nvidia', tags: ['free', 'fast'], context: 200000, priceIn: 0, priceOut: 0 },
21
+ { id: 'deepseek-ai/deepseek-coder-6.7b-instruct', name: 'DeepSeek Coder 6.7B', provider: 'nvidia', tags: ['coding'], context: 16384, priceIn: 0.10, priceOut: 0.10 },
22
+
23
+ // ── Meta Llama ──
24
+ { id: 'meta/llama-3.1-8b-instruct', name: 'Llama 3.1 8B Instruct', provider: 'nvidia', tags: ['fast'], context: 128000, priceIn: 0.05, priceOut: 0.08 },
25
+ { id: 'meta/llama-3.1-70b-instruct', name: 'Llama 3.1 70B Instruct', provider: 'nvidia', tags: ['general'], context: 128000, priceIn: 0.35, priceOut: 0.40 },
26
+ { id: 'meta/llama-3.2-3b-instruct', name: 'Llama 3.2 3B Instruct', provider: 'nvidia', tags: ['fast'], context: 128000, priceIn: 0.02, priceOut: 0.02 },
27
+ { id: 'meta/llama-3.2-11b-vision-instruct', name: 'Llama 3.2 11B Vision', provider: 'nvidia', tags: ['vision', 'fast'], context: 128000, priceIn: 0.055, priceOut: 0.055 },
28
+ { id: 'meta/llama-3.2-90b-vision-instruct', name: 'Llama 3.2 90B Vision', provider: 'nvidia', tags: ['vision'], context: 128000, priceIn: 0.35, priceOut: 0.40 },
29
+ { id: 'meta/llama-3.3-70b-instruct', name: 'Llama 3.3 70B Instruct', provider: 'nvidia', tags: ['general'], context: 128000, priceIn: 0.35, priceOut: 0.40 },
30
+ { id: 'meta/codellama-70b', name: 'CodeLlama 70B', provider: 'nvidia', tags: ['coding'], context: 16384, priceIn: 0.30, priceOut: 0.30 },
31
+ { id: 'meta/muse-glimmer-30b', name: 'Llama Muse Glimmer 30B', provider: 'nvidia', tags: ['creative'], context: 32768, priceIn: 0.20, priceOut: 0.20 },
32
+
33
+ // ── NVIDIA Nemotron ──
34
+ { id: 'nvidia/llama-3.1-nemotron-70b-instruct', name: 'Llama 3.1 Nemotron 70B', provider: 'nvidia', tags: ['general'], context: 128000, priceIn: 0.35, priceOut: 0.40 },
35
+ { id: 'nvidia/llama-3.1-nemotron-ultra-253b-v1', name: 'Nemotron Ultra 253B', provider: 'nvidia', tags: ['frontier'], context: 128000, priceIn: 1.50, priceOut: 6.00 },
36
+ { id: 'nvidia/llama-3.3-nemotron-super-49b-v1', name: 'Nemotron Super 49B', provider: 'nvidia', tags: ['reasoning'], context: 128000, priceIn: 0.25, priceOut: 0.90 },
37
+ { id: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', name: 'Nemotron Super 49B v1.5', provider: 'nvidia', tags: ['reasoning'], context: 128000, priceIn: 0.25, priceOut: 0.90 },
38
+ { id: 'nvidia/nemotron-3-ultra-550b-a55b', name: 'Nemotron 3 Ultra 550B', provider: 'nvidia', tags: ['frontier'], context: 256000, priceIn: 2.00, priceOut: 8.00 },
39
+ { id: 'nvidia/nemotron-3-super-120b-a12b', name: 'Nemotron 3 Super 120B', provider: 'nvidia', tags: ['frontier'], context: 256000, priceIn: 1.00, priceOut: 4.00 },
40
+ { id: 'nvidia/nemotron-3-nano-30b-a3b', name: 'Nemotron 3 Nano 30B', provider: 'nvidia', tags: ['fast'], context: 128000, priceIn: 0.10, priceOut: 0.40 },
41
+ { id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning', name: 'Nemotron 3 Nano Omni (Reasoning)', provider: 'nvidia', tags: ['reasoning', 'small'], context: 128000, priceIn: 0.10, priceOut: 0.40 },
42
+ { id: 'nvidia/nemotron-4-340b-instruct', name: 'Nemotron-4 340B Instruct', provider: 'nvidia', tags: ['frontier'], context: 4096, priceIn: 1.00, priceOut: 3.00 },
43
+ { id: 'nvidia/nemotron-mini-4b-instruct', name: 'Nemotron Mini 4B', provider: 'nvidia', tags: ['fast'], context: 32768, priceIn: 0.03, priceOut: 0.03 },
44
+ { id: 'nvidia/nemotron-nano-3-30b-a3b', name: 'Nemotron Nano 3 30B', provider: 'nvidia', tags: ['fast'], context: 128000, priceIn: 0.10, priceOut: 0.40 },
45
+ { id: 'nvidia/nemotron-nano-12b-v2-vl', name: 'Nemotron Nano 12B V2 VL', provider: 'nvidia', tags: ['vision'], context: 128000, priceIn: 0.10, priceOut: 0.40 },
46
+ { id: 'nvidia/nvidia-nemotron-nano-9b-v2', name: 'Nemotron Nano 9B V2', provider: 'nvidia', tags: ['fast'], context: 128000, priceIn: 0.05, priceOut: 0.20 },
47
+
48
+ // ── Mistral ──
49
+ { id: 'mistralai/mistral-7b-instruct-v0.3', name: 'Mistral 7B Instruct', provider: 'nvidia', tags: ['fast'], context: 32768, priceIn: 0.05, priceOut: 0.05 },
50
+ { id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'nvidia', tags: ['frontier'], context: 128000, priceIn: 2.00, priceOut: 6.00 },
51
+ { id: 'mistralai/mistral-large-2-instruct', name: 'Mistral Large 2 Instruct', provider: 'nvidia', tags: ['frontier'], context: 128000, priceIn: 2.00, priceOut: 6.00 },
52
+ { id: 'mistralai/codestral-22b-instruct-v0.1', name: 'Codestral 22B', provider: 'nvidia', tags: ['coding'], context: 32768, priceIn: 0.30, priceOut: 0.60 },
53
+ { id: 'mistralai/mixtral-8x22b-v0.1', name: 'Mixtral 8x22B', provider: 'nvidia', tags: [], context: 65536, priceIn: 0.60, priceOut: 0.60 },
54
+ { id: 'nv-mistralai/mistral-nemo-12b-instruct', name: 'Mistral NeMo 12B', provider: 'nvidia', tags: ['fast'], context: 128000, priceIn: 0.05, priceOut: 0.05 },
55
+
56
+ // ── Microsoft ──
57
+ { id: 'microsoft/phi-3.5-moe-instruct', name: 'Phi 3.5 MoE', provider: 'nvidia', tags: ['fast'], context: 128000, priceIn: 0.05, priceOut: 0.05 },
58
+ { id: 'microsoft/phi-3-vision-128k-instruct', name: 'Phi 3 Vision 128K', provider: 'nvidia', tags: ['vision'], context: 128000, priceIn: 0.05, priceOut: 0.05 },
59
+
60
+ // ── Google ──
61
+ { id: 'google/gemma-3-4b-it', name: 'Gemma 3 4B IT', provider: 'nvidia', tags: ['fast'], context: 32768, priceIn: 0.03, priceOut: 0.03 },
62
+ { id: 'google/gemma-3-12b-it', name: 'Gemma 3 12B IT', provider: 'nvidia', tags: ['general'], context: 128000, priceIn: 0.10, priceOut: 0.10 },
63
+ { id: 'google/gemma-4-31b-it', name: 'Gemma 4 31B IT', provider: 'nvidia', tags: ['general'], context: 32768, priceIn: 0.20, priceOut: 0.80 },
64
+ { id: 'google/codegemma-1.1-7b', name: 'CodeGemma 1.1 7B', provider: 'nvidia', tags: ['coding', 'fast'], context: 16384, priceIn: 0.05, priceOut: 0.05 },
65
+
66
+ // ── OpenAI ──
67
+ { id: 'openai/gpt-oss-120b', name: 'GPT-OSS 120B', provider: 'nvidia', tags: ['openai', 'general'], context: 131072, priceIn: 0.25, priceOut: 1.00 },
68
+ { id: 'openai/gpt-oss-20b', name: 'GPT-OSS 20B', provider: 'nvidia', tags: ['openai', 'fast'], context: 131072, priceIn: 0.10, priceOut: 0.30 },
69
+
70
+ // ── Other ──
71
+ { id: 'z-ai/glm-5.2', name: 'GLM-5.2', provider: 'nvidia', tags: [], context: 131072, priceIn: 0.30, priceOut: 1.20 },
72
+ { id: 'moonshotai/kimi-k2.6', name: 'Kimi K2.6', provider: 'nvidia', tags: [], context: 128000, priceIn: 0.20, priceOut: 0.80 },
73
+ { id: 'minimaxai/minimax-m3', name: 'MiniMax M3', provider: 'nvidia', tags: [], context: 200000, priceIn: 0.40, priceOut: 1.60 },
74
+ { id: 'poolside/laguna-xs-2.1', name: 'Laguna XS 2.1', provider: 'nvidia', tags: ['free', 'coding'], context: 131072, priceIn: 0, priceOut: 0 },
75
+ { id: 'stepfun-ai/step-3.7-flash', name: 'Step 3.7 Flash', provider: 'nvidia', tags: ['fast'], context: 128000, priceIn: 0.15, priceOut: 0.40 },
76
+ { id: '01-ai/yi-large', name: 'Yi Large', provider: 'nvidia', tags: [], context: 32768, priceIn: 0.30, priceOut: 0.90 },
77
+ { id: 'ai21labs/jamba-1.5-large-instruct', name: 'Jamba 1.5 Large', provider: 'nvidia', tags: [], context: 256000, priceIn: 0.20, priceOut: 0.80 },
78
+ { id: 'databricks/dbrx-instruct', name: 'DBRX Instruct', provider: 'nvidia', tags: [], context: 32768, priceIn: 0.60, priceOut: 0.60 },
79
+ { id: 'ibm/granite-3.0-8b-instruct', name: 'Granite 3.0 8B', provider: 'nvidia', tags: ['fast'], context: 32768, priceIn: 0.05, priceOut: 0.05 },
80
+ { id: 'ibm/granite-34b-code-instruct', name: 'Granite 34B Code', provider: 'nvidia', tags: ['coding'], context: 16384, priceIn: 0.20, priceOut: 0.20 },
81
+ { id: 'zyphra/zamba2-7b-instruct', name: 'Zamba2 7B', provider: 'nvidia', tags: ['fast'], context: 32768, priceIn: 0.05, priceOut: 0.05 },
82
+ ],
83
+ };
84
+
85
+ module.exports = provider;
@@ -0,0 +1,269 @@
1
+ /** @type {typeof import('openai').default} */
2
+ const OpenAI = /** @type {any} */ (require('openai'));
3
+ const { getBaseUrl } = require('../config/settings');
4
+
5
+ function formatMessages(messages, options) {
6
+ const out = [];
7
+ // System prompt comes from options.system (session.systemPrompt).
8
+ if (options?.system) out.push({ role: 'system', content: options.system });
9
+ for (const m of messages) {
10
+ if (m.role === 'system') continue;
11
+ if (m.role === 'tool') {
12
+ out.push({ role: 'tool', tool_call_id: m.toolCallId, content: String(m.content) });
13
+ continue;
14
+ }
15
+ const msg = { role: m.role, content: m.content || '' };
16
+ if (m.toolCalls && m.toolCalls.length) {
17
+ msg.tool_calls = m.toolCalls.map(tc => ({
18
+ id: tc.id,
19
+ type: 'function',
20
+ function: { name: tc.name, arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input || {}) },
21
+ }));
22
+ }
23
+ out.push(msg);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ function parseToolCalls(message) {
29
+ const toolCalls = [];
30
+ for (const tc of message.tool_calls || []) {
31
+ let input = {};
32
+ try { input = JSON.parse(tc.function.arguments || '{}'); } catch {}
33
+ toolCalls.push({ id: tc.id, name: tc.function.name, input });
34
+ }
35
+ return toolCalls;
36
+ }
37
+
38
+ function formatTools(tools) {
39
+ if (!tools || !tools.length) return undefined;
40
+ return tools.map(t => ({
41
+ type: 'function',
42
+ function: {
43
+ name: t.name,
44
+ description: t.description,
45
+ parameters: t.input_schema || { type: 'object', properties: {} },
46
+ },
47
+ }));
48
+ }
49
+
50
+ function buildRequest(messages, options) {
51
+ const body = {
52
+ model: options.model,
53
+ messages: formatMessages(messages, options),
54
+ tools: formatTools(options.tools),
55
+ max_tokens: options.maxTokens || 8192,
56
+ temperature: options.temperature ?? 0.7,
57
+ };
58
+ // Reasoning models (o1/o3/o4/gpt-5, tagged 'reasoning' by the session):
59
+ // push the effort to "high" so the model reasons deeply on every step —
60
+ // opencode-style multi-pass thinking. Other reasoning families (DeepSeek
61
+ // R1, Qwen QwQ, Kimi K2) emit reasoning_content on their own and don't
62
+ // accept the param, so only the OpenAI effort-capable ids get it. OpenAI
63
+ // effort models also reject a temperature != 1, so drop it.
64
+ if (options.reasoning && /^(o1|o3|o4|gpt-5)/.test(String(options.model || ''))) {
65
+ body.reasoning_effort = 'high';
66
+ // Reasoning models reject max_tokens; the limit moves to
67
+ // max_completion_tokens unchanged.
68
+ body.max_completion_tokens = body.max_tokens;
69
+ delete body.max_tokens;
70
+ delete body.temperature;
71
+ }
72
+ return body;
73
+ }
74
+
75
+ function normalize(resp) {
76
+ const choice = resp.choices?.[0];
77
+ const message = choice?.message || {};
78
+ const toolCalls = parseToolCalls(message);
79
+ return {
80
+ content: message.content || '',
81
+ toolCalls,
82
+ usage: resp.usage,
83
+ };
84
+ }
85
+
86
+ // Some providers (NVIDIA NIM, Akamai) return an HTML error page for bad model
87
+ // IDs. Strip the tags so the TUI shows a readable line instead of raw markup.
88
+ function sanitizeErrBody(msg) {
89
+ if (!msg || !/<\/?\w+[^>]*>/.test(msg)) return msg;
90
+ const stripped = String(msg)
91
+ .replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
92
+ .replace(/<[^>]+>/g, ' ')
93
+ .replace(/&amp;/g, '&')
94
+ .replace(/&lt;/g, '<')
95
+ .replace(/&gt;/g, '>')
96
+ .replace(/&#x27;|&#39;/g, "'")
97
+ .replace(/&quot;/g, '"')
98
+ .replace(/&nbsp;/g, ' ')
99
+ .replace(/\s+/g, ' ')
100
+ .trim();
101
+ return stripped || msg;
102
+ }
103
+
104
+ function wrapErr(err, modelId, envKeyHint, baseURL) {
105
+ const raw = err.message || 'Unknown error';
106
+ const msg = sanitizeErrBody(raw);
107
+ if (!err.status) return new Error(`${envKeyHint || 'API'} error: ${msg}`);
108
+ const status = Number(err.status);
109
+ const hint = String(envKeyHint || 'api').toLowerCase();
110
+ if (status === 401) {
111
+ return new Error(`${envKeyHint || 'API'} 401 Unauthorized: the API key is invalid or expired. Run /connect ${hint} and paste a new key.`);
112
+ }
113
+ if (status === 403) {
114
+ return new Error(`${envKeyHint || 'API'} 403 Forbidden: the API key is not authorized for ${modelId || 'this model'}. You may need to accept the model's terms on the provider site or use a key with access. Run /connect ${hint} to change the key.`);
115
+ }
116
+ if (status === 402) return new Error(`${envKeyHint || 'API'} quota exceeded (${err.status}). The model may not be available on your billing tier for model ${(modelId || '?')}.`);
117
+ if (status === 404) {
118
+ const modelHint = modelId ? ` ${modelId}` : '';
119
+ return new Error(`${envKeyHint || 'API'} 404: model${modelHint} does not exist (or was removed) on this provider. Try /models and pick a listed model, or /model ${modelId ? 'to switch' : ''}.`);
120
+ }
121
+ if (err.error && err.error.message) return new Error(`${envKeyHint || 'API'} error ${status}: ${sanitizeErrBody(err.error.message)}`);
122
+ if (err.body && err.body.message) return new Error(`${envKeyHint || 'API'} error ${status}: ${sanitizeErrBody(err.body.message)}`);
123
+ if (!msg || msg.indexOf('no body') >= 0) {
124
+ const modelHint = modelId ? ` (model: ${modelId})` : '';
125
+ return new Error(`${envKeyHint || 'API'} error ${status}${modelHint}. Check model name or URL:\n${baseURL || '(default)'}`);
126
+ }
127
+ return new Error(`${envKeyHint || 'API'} error ${status}: ${msg}`);
128
+ }
129
+
130
+ async function retryWithBackoff(fn, maxTries, envKeyHint) {
131
+ let lastErr;
132
+ for (let attempt = 0; attempt < maxTries; attempt++) {
133
+ try {
134
+ return await fn();
135
+ } catch (err) {
136
+ lastErr = err;
137
+ if (isAbortError(err)) throw err;
138
+ const status = err.status || err.code;
139
+ if (status !== 429 && status !== 503 && status !== 502) throw lastErr;
140
+ if (attempt > 0) {
141
+ const delay = Math.min(1000 * Math.pow(3, attempt - 1), 30000);
142
+ await new Promise((resolve) => setTimeout(resolve, delay));
143
+ }
144
+ }
145
+ }
146
+ throw lastErr;
147
+ }
148
+
149
+ // Aborts surface as AbortError, APIUserAbortError (openai SDK), or wrapped
150
+ // messages containing "aborted". Never let an interrupt be retried or wrapped.
151
+ function isAbortError(err) {
152
+ if (!err) return false;
153
+ const name = String(err.name || err.error?.name || '');
154
+ if (name === 'AbortError' || name === 'APIUserAbortError') return true;
155
+ return /aborted|cancel(led|ed)/i.test(String(err.message || ''));
156
+ }
157
+
158
+ /**
159
+ * @typedef {Object} ProviderModel
160
+ * @property {string} id
161
+ * @property {string} name
162
+ * @property {string} provider
163
+ * @property {Array<string>=} tags
164
+ * @property {number} context
165
+ * @property {number} priceIn
166
+ * @property {number} priceOut
167
+ */
168
+
169
+ /**
170
+ * @typedef {Object} Provider
171
+ * @property {(messages: Array<Object>, options: Object) => Promise<Object>} chat
172
+ * @property {(messages: Array<Object>, options: Object, onDelta?: (text: string) => void) => Promise<Object>} stream
173
+ * @property {Array<ProviderModel>} models
174
+ */
175
+
176
+ /**
177
+ * @param {{ getKey: () => string|undefined, providerId: string, envKeyHint: string, clientFactory?: () => any, defaultBaseUrl?: string }} config
178
+ * @returns {Provider}
179
+ */
180
+ function createOpenAICompatProvider({ getKey, providerId, envKeyHint, clientFactory, defaultBaseUrl }) {
181
+ function getClient() {
182
+ if (clientFactory) return clientFactory();
183
+ const key = getKey();
184
+ if (!key) throw new Error(`${envKeyHint || 'API'} key not set. Use /connect or set the ${envKeyHint || 'API'}_API_KEY env var.`);
185
+ const baseURL = getBaseUrl(providerId) || defaultBaseUrl || getDefaultBaseUrl(providerId);
186
+ return new OpenAI({ apiKey: key, baseURL });
187
+ }
188
+
189
+ async function chat(messages, options = {}) {
190
+ const client = getClient();
191
+ const req = buildRequest(messages, options);
192
+ const requestOpts = { signal: options.signal };
193
+ try {
194
+ const resp = await retryWithBackoff(() => client.chat.completions.create(req, requestOpts), 4, envKeyHint);
195
+ return normalize(resp);
196
+ } catch (err) {
197
+ if (isAbortError(err)) throw err;
198
+ throw wrapErr(err, options.model || '?', envKeyHint, getBaseUrl(providerId));
199
+ }
200
+ }
201
+
202
+ async function stream(messages, options = {}, onDelta, onReasoning) {
203
+ const client = getClient();
204
+ const req = { ...buildRequest(messages, options), stream: true, stream_options: { include_usage: true } };
205
+ const requestOpts = { signal: options.signal };
206
+ try {
207
+ const s = await retryWithBackoff(() => client.chat.completions.create(req, requestOpts), 4, envKeyHint);
208
+ let content = '';
209
+ let reasoning = '';
210
+ let usage = null;
211
+ const toolAcc = new Map();
212
+
213
+ for await (const chunk of s) {
214
+ if (chunk.usage) usage = chunk.usage;
215
+ const delta = chunk.choices?.[0]?.delta || {};
216
+ // DeepSeek-style reasoning_content / OpenRouter reasoning deltas.
217
+ const rt = delta.reasoning_content || delta.reasoning;
218
+ if (typeof rt === 'string' && rt) {
219
+ reasoning += rt;
220
+ if (onReasoning) onReasoning(rt);
221
+ continue;
222
+ }
223
+ if (delta.content) {
224
+ content += delta.content;
225
+ if (onDelta) onDelta(delta.content);
226
+ continue;
227
+ }
228
+ if (delta.tool_calls) {
229
+ for (const tc of delta.tool_calls) {
230
+ if (!toolAcc.has(tc.index)) toolAcc.set(tc.index, { id: '', name: '', args: '' });
231
+ const acc = toolAcc.get(tc.index);
232
+ if (tc.id) acc.id = tc.id;
233
+ if (tc.function?.name) acc.name += tc.function.name;
234
+ if (tc.function?.arguments) acc.args += tc.function.arguments;
235
+ }
236
+ }
237
+ }
238
+
239
+ const toolCalls = [];
240
+ for (const acc of toolAcc.values()) {
241
+ let input = {};
242
+ try { input = JSON.parse(acc.args || '{}'); } catch {}
243
+ toolCalls.push({ id: acc.id, name: acc.name, input });
244
+ }
245
+
246
+ return { content, reasoning, toolCalls, usage };
247
+ } catch (err) {
248
+ if (isAbortError(err)) throw err;
249
+ throw wrapErr(err, options.model || '?', envKeyHint, getBaseUrl(providerId));
250
+ }
251
+ }
252
+
253
+ return { chat, stream, models: /** @type {Array<ProviderModel>} */ ([]) };
254
+ }
255
+
256
+ function getDefaultBaseUrl(providerId) {
257
+ const defaults = {
258
+ anthropic: 'https://api.anthropic.com',
259
+ openai: 'https://api.openai.com/v1',
260
+ nvidia: 'https://integrate.api.nvidia.com/v1',
261
+ google: 'https://generativelanguage.googleapis.com/v1beta/openai/',
262
+ openrouter: 'https://openrouter.ai/api/v1',
263
+ tokenrouter: 'https://api.tokenrouter.com/v1',
264
+ local: 'http://localhost:11434/v1',
265
+ };
266
+ return defaults[providerId] || undefined;
267
+ }
268
+
269
+ module.exports = { createOpenAICompatProvider, wrapErr, buildRequest, formatMessages, formatTools, parseToolCalls, normalize, retryWithBackoff, sanitizeErrBody };
@@ -0,0 +1,35 @@
1
+ const { createOpenAICompatProvider } = require('./openai-compat');
2
+
3
+ function getKey() {
4
+ return process.env.OPENAI_API_KEY;
5
+ }
6
+
7
+ const provider = {
8
+ ...createOpenAICompatProvider({
9
+ getKey,
10
+ providerId: 'openai',
11
+ envKeyHint: 'OPENAI',
12
+ }),
13
+ models: [
14
+ // Flagship / GPT-5 Series
15
+ { id: 'gpt-5', name: 'GPT-5', provider: 'openai', tags: ['frontier'], context: 400000, priceIn: 1.25, priceOut: 10 },
16
+ { id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'openai', tags: ['fast'], context: 400000, priceIn: 0.25, priceOut: 2.00 },
17
+ { id: 'gpt-5-nano', name: 'GPT-5 Nano', provider: 'openai', tags: ['cheap', 'fast'], context: 400000, priceIn: 0.10, priceOut: 0.40 },
18
+ { id: 'gpt-5-chat-latest', name: 'GPT-5 Chat (Latest)', provider: 'openai', tags: ['frontier'], context: 400000, priceIn: 5.00, priceOut: 30.00 },
19
+
20
+ // GPT-4 Series & Omni Models
21
+ { id: 'gpt-4o', name: 'GPT-4o', provider: 'openai', tags: ['legacy', 'multimodal'], context: 128000, priceIn: 2.50, priceOut: 10.00 },
22
+ { id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'openai', tags: ['fast', 'multimodal'], context: 128000, priceIn: 0.15, priceOut: 0.60 },
23
+ { id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', tags: ['frontier'], context: 1048576, priceIn: 2.00, priceOut: 8.00 },
24
+
25
+ // O-Series (Reasoning) Models
26
+ { id: 'o3', name: 'o3', provider: 'openai', tags: ['reasoning', 'frontier'], context: 200000, priceIn: 2.00, priceOut: 8.00 },
27
+ { id: 'o3-mini', name: 'o3 Mini', provider: 'openai', tags: ['reasoning', 'fast'], context: 200000, priceIn: 1.10, priceOut: 4.40 },
28
+ { id: 'o4-mini', name: 'o4 Mini', provider: 'openai', tags: ['reasoning', 'fast'], context: 200000, priceIn: 1.10, priceOut: 4.40 },
29
+ { id: 'o1', name: 'o1', provider: 'openai', tags: ['reasoning'], context: 200000, priceIn: 15.00, priceOut: 60.00 },
30
+ { id: 'o1-pro', name: 'o1 Pro', provider: 'openai', tags: ['reasoning', 'pro'], context: 200000, priceIn: 150.00, priceOut: 600.00 },
31
+ { id: 'o1-mini', name: 'o1 Mini', provider: 'openai', tags: ['reasoning', 'cheap'], context: 128000, priceIn: 1.10, priceOut: 4.40 },
32
+ ],
33
+ };
34
+
35
+ module.exports = provider;
@@ -0,0 +1,43 @@
1
+ const { createOpenAICompatProvider } = require('./openai-compat');
2
+ const { getApiKey } = require('../config/settings');
3
+
4
+ function getKey() {
5
+ return getApiKey('openrouter') || process.env.OPENROUTER_API_KEY;
6
+ }
7
+
8
+ const provider = {
9
+ ...createOpenAICompatProvider({
10
+ getKey,
11
+ providerId: 'openrouter',
12
+ envKeyHint: 'OPENROUTER',
13
+ }),
14
+ models: [
15
+ // Anthropic Models
16
+ { id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus (via OpenRouter)', provider: 'openrouter', context: 200000, priceIn: 15, priceOut: 75 },
17
+
18
+ // OpenAI Models
19
+ { id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini (via OpenRouter)', provider: 'openrouter', context: 128000, priceIn: 0.15, priceOut: 0.60 },
20
+ { id: 'openai/gpt-4o', name: 'GPT-4o (via OpenRouter)', provider: 'openrouter', context: 128000, priceIn: 2.50, priceOut: 10.00 },
21
+ { id: 'openai/o3-mini-high', name: 'O3 Mini High (via OpenRouter)', provider: 'openrouter', context: 200000, priceIn: 1.10, priceOut: 4.40 },
22
+ { id: 'openai/o1', name: 'O1 (via OpenRouter)', provider: 'openrouter', context: 200000, priceIn: 15, priceOut: 60 },
23
+
24
+ // Google Models
25
+ { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (via OpenRouter)', provider: 'openrouter', context: 1048576, priceIn: 1.25, priceOut: 10 },
26
+ { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (via OpenRouter)', provider: 'openrouter', context: 1048576, priceIn: 0.15, priceOut: 1.25 },
27
+
28
+ // DeepSeek
29
+ { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash (via OpenRouter)', provider: 'openrouter', context: 1048576, priceIn: 0.07, priceOut: 0.18 },
30
+
31
+ // NVIDIA free-tier variants (the :free slugs are genuinely free on
32
+ // OpenRouter; the un-suffixed Nemotron IDs are paid and must not be tagged free)
33
+ { id: 'nvidia/nemotron-3-super-120b-a12b:free', name: 'Nemotron 3 Super (free, via OpenRouter)', provider: 'openrouter', tags: ['free'], context: 262144, priceIn: 0, priceOut: 0 },
34
+ { id: 'nvidia/nemotron-3-ultra-550b-a55b:free', name: 'Nemotron 3 Ultra (free, via OpenRouter)', provider: 'openrouter', tags: ['free'], context: 1048576, priceIn: 0, priceOut: 0 },
35
+
36
+ // Other Top-Tier Alternatives
37
+ { id: 'zhipuai/glm-4-plus', name: 'GLM-4 Plus (via OpenRouter)', provider: 'openrouter', context: 128000, priceIn: 1.5, priceOut: 1.5 },
38
+ { id: 'minimax/minimax-01', name: 'MiniMax-01 (via OpenRouter)', provider: 'openrouter', context: 1000000, priceIn: 0.14, priceOut: 1.4 },
39
+ { id: 'mistralai/mistral-large-2411', name: 'Mistral Large 2411 (via OpenRouter)', provider: 'openrouter', context: 128000, priceIn: 2, priceOut: 6 }
40
+ ]
41
+ };
42
+
43
+ module.exports = provider;
@@ -0,0 +1,196 @@
1
+ // models.dev provider/model registry — the same open-source dataset opencode
2
+ // uses for its provider + model lists (https://models.dev/api.json). Loom
3
+ // fetches it once, caches it at ~/.loom/models-dev.json, and merges every
4
+ // provider into the runtime registry. Everything degrades gracefully offline:
5
+ // no cache, no network -> the 7 built-in providers keep working.
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ const REGISTRY_URL = 'https://models.dev/api.json';
11
+ const REGISTRY_FILE = path.join(path.join(os.homedir(), '.loom'), 'models-dev.json');
12
+ const MAX_AGE_MS = 7 * 24 * 3600 * 1000;
13
+
14
+ // Re-evaluated on every call (like the other stores) so tests/CI can isolate
15
+ // with LOOM_CONFIG_DIR; REGISTRY_FILE keeps the default for display.
16
+ function registryFile() {
17
+ return path.join(process.env.LOOM_CONFIG_DIR || path.join(os.homedir(), '.loom'), 'models-dev.json');
18
+ }
19
+
20
+ /**
21
+ * @typedef {Object} RegistryModel
22
+ * @property {string} id
23
+ * @property {string} name
24
+ * @property {number} context
25
+ * @property {number} priceIn $/1M input tokens
26
+ * @property {number} priceOut $/1M output tokens
27
+ * @property {Array<string>} tags
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} RegistryProvider
32
+ * @property {string} id
33
+ * @property {string} name
34
+ * @property {Array<string>} env accepted API-key env var names
35
+ * @property {string} npm SDK the provider expects (info only)
36
+ * @property {string=} baseURL OpenAI-compatible endpoint, when known
37
+ * @property {Array<RegistryModel>} models
38
+ */
39
+
40
+ /**
41
+ * @param {string} id
42
+ * @param {*} raw
43
+ * @returns {RegistryProvider}
44
+ */
45
+ function normalizeProvider(id, raw) {
46
+ /** @type {Array<RegistryModel>} */
47
+ const models = [];
48
+ for (const [mid, m] of Object.entries(raw.models || {})) {
49
+ if (!m || typeof m !== 'object') continue;
50
+ /** @type {Array<string>} */
51
+ const tags = [];
52
+ if (m.reasoning) tags.push('reasoning');
53
+ models.push({
54
+ id: mid,
55
+ name: m.name || mid,
56
+ context: (m.limit && m.limit.context) || 0,
57
+ priceIn: (m.cost && Number(m.cost.input)) || 0,
58
+ priceOut: (m.cost && Number(m.cost.output)) || 0,
59
+ tags,
60
+ });
61
+ }
62
+ models.sort((a, b) => a.id.localeCompare(b.id));
63
+ return {
64
+ id,
65
+ name: raw.name || id,
66
+ env: Array.isArray(raw.env) && raw.env.length ? raw.env : [id.toUpperCase() + '_API_KEY'],
67
+ npm: raw.npm || '',
68
+ baseURL: typeof raw.api === 'string' ? raw.api : undefined,
69
+ models,
70
+ };
71
+ }
72
+
73
+ // Well-known OpenAI-compatible base URLs for npm SDKs that models.dev lists
74
+ // without an `api` field (their SDKs embed the endpoint). A registry `api`
75
+ // field always wins over this map.
76
+ const SDK_BASE_URLS = {
77
+ '@ai-sdk/openai': 'https://api.openai.com/v1',
78
+ '@ai-sdk/anthropic': 'https://api.anthropic.com',
79
+ '@ai-sdk/google': 'https://generativelanguage.googleapis.com/v1beta/openai/',
80
+ '@ai-sdk/mistral': 'https://api.mistral.ai/v1',
81
+ '@ai-sdk/xai': 'https://api.x.ai/v1',
82
+ '@ai-sdk/groq': 'https://api.groq.com/openai/v1',
83
+ '@ai-sdk/perplexity': 'https://api.perplexity.ai',
84
+ '@ai-sdk/cerebras': 'https://api.cerebras.ai/v1',
85
+ '@ai-sdk/cohere': 'https://api.cohere.com/v2',
86
+ '@openrouter/ai-sdk-provider': 'https://openrouter.ai/api/v1',
87
+ };
88
+
89
+ // Load the cached registry (if any) into normalized providers.
90
+ // @returns {Object<string, RegistryProvider> | null} null when no cache exists
91
+ //
92
+ // Memoized: the cache file is static within a process, and callers (hasApiKey
93
+ // -> envNamesFor, once per provider) would otherwise re-parse the multi-MB
94
+ // file hundreds of times — the #1 source of UI stalls (e.g. a 4s /models).
95
+ // fetchRegistry() invalidates the memo after writing a fresh cache; tests use
96
+ // cache-busted imports, so each fixture gets its own memo.
97
+ let registryCache = null;
98
+ let registryCacheKey = "";
99
+ let registryCacheMtime = 0;
100
+ function invalidateRegistryCache() {
101
+ registryCache = null;
102
+ registryCacheKey = "";
103
+ registryCacheMtime = 0;
104
+ }
105
+ function loadRegistry() {
106
+ const file = registryFile();
107
+ if (!fs.existsSync(file)) return null;
108
+ try {
109
+ const mtime = fs.statSync(file).mtimeMs;
110
+ if (registryCache && registryCacheKey === file && registryCacheMtime === mtime) {
111
+ return registryCache;
112
+ }
113
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
114
+ if (!raw || typeof raw !== 'object') return null;
115
+ /** @type {Object<string, RegistryProvider>} */
116
+ const out = {};
117
+ for (const [id, p] of Object.entries(raw)) {
118
+ if (!p || typeof p !== 'object' || !p.models) continue;
119
+ out[id] = normalizeProvider(id, p);
120
+ }
121
+ registryCache = out;
122
+ registryCacheKey = file;
123
+ registryCacheMtime = mtime;
124
+ return out;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Whether the cached registry is fresh enough to skip a re-fetch.
132
+ * @returns {boolean}
133
+ */
134
+ function isRegistryFresh() {
135
+ const file = registryFile();
136
+ if (!fs.existsSync(file)) return false;
137
+ try {
138
+ const age = Date.now() - fs.statSync(file).mtimeMs;
139
+ return age < MAX_AGE_MS;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Fetch the registry from models.dev and cache it. Returns the number of
147
+ * providers cached, or 0 on any failure (offline — the previous cache or the
148
+ * built-in providers keep working).
149
+ * @param {number=} timeoutMs
150
+ * @returns {Promise<number>}
151
+ */
152
+ async function fetchRegistry(timeoutMs = 30000) {
153
+ const ctrl = new AbortController();
154
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
155
+ try {
156
+ const res = await fetch(REGISTRY_URL, { signal: ctrl.signal });
157
+ if (!res.ok) return 0;
158
+ const parsed = JSON.parse(await res.text());
159
+ if (!parsed || typeof parsed !== 'object') return 0;
160
+ const count = Object.keys(parsed).length;
161
+ if (!count) return 0;
162
+ const file = registryFile();
163
+ fs.mkdirSync(path.dirname(file), { recursive: true });
164
+ fs.writeFileSync(file, JSON.stringify(parsed));
165
+ invalidateRegistryCache();
166
+ return count;
167
+ } catch {
168
+ return 0;
169
+ } finally {
170
+ clearTimeout(timer);
171
+ }
172
+ }
173
+
174
+ // Env var names that hold a provider's API key. Built-ins keep their legacy
175
+ // names; registry providers use the env names models.dev publishes for them.
176
+ const BUILTIN_ENV = {
177
+ anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'],
178
+ openai: ['OPENAI_API_KEY'],
179
+ nvidia: ['NVIDIA_API_KEY', 'NVIDIA_NIM_API_KEY'],
180
+ google: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
181
+ openrouter: ['OPENROUTER_API_KEY'],
182
+ tokenrouter: ['TOKENROUTER_API_KEY'],
183
+ };
184
+
185
+ /**
186
+ * @param {string} id
187
+ * @returns {Array<string>}
188
+ */
189
+ function envNamesFor(id) {
190
+ if (BUILTIN_ENV[id]) return BUILTIN_ENV[id];
191
+ const reg = loadRegistry();
192
+ if (reg && reg[id] && reg[id].env.length) return reg[id].env;
193
+ return [id.toUpperCase() + '_API_KEY'];
194
+ }
195
+
196
+ module.exports = { REGISTRY_URL, REGISTRY_FILE, loadRegistry, fetchRegistry, isRegistryFresh, envNamesFor, normalizeProvider, SDK_BASE_URLS, invalidateRegistryCache };
@@ -0,0 +1,19 @@
1
+ const { createOpenAICompatProvider } = require('./openai-compat');
2
+ const { getApiKey } = require('../config/settings');
3
+
4
+ function getKey() {
5
+ return getApiKey('tokenrouter') || process.env.TOKENROUTER_API_KEY;
6
+ }
7
+
8
+ const provider = {
9
+ ...createOpenAICompatProvider({
10
+ getKey,
11
+ providerId: 'tokenrouter',
12
+ envKeyHint: 'TOKENROUTER',
13
+ }),
14
+ models: [
15
+ { id: 'moonshotai/kimi-k3-free', name: 'Kimi K3 Free', provider: 'tokenrouter', tags: ['free'], context: 128000, priceIn: 0, priceOut: 0 },
16
+ ],
17
+ };
18
+
19
+ module.exports = provider;