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.
- package/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- package/src/web/web-server.js +470 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
|
|
6
|
+
const LOOM_DIR = path.join(os.homedir(), '.loom');
|
|
7
|
+
|
|
8
|
+
// Re-evaluated on every call (like the other stores) so tests/CI can isolate
|
|
9
|
+
// with LOOM_CONFIG_DIR; the exported MCP_FILE keeps the default for display.
|
|
10
|
+
function mcpFile() {
|
|
11
|
+
return path.join(process.env.LOOM_CONFIG_DIR || LOOM_DIR, 'mcp.json');
|
|
12
|
+
}
|
|
13
|
+
const MCP_FILE = path.join(LOOM_DIR, 'mcp.json');
|
|
14
|
+
|
|
15
|
+
function loadServers() {
|
|
16
|
+
if (!fs.existsSync(mcpFile())) return { servers: {}, seeded: false };
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(mcpFile(), 'utf8');
|
|
19
|
+
const data = JSON.parse(raw);
|
|
20
|
+
return { servers: data.servers || {}, seeded: data.seeded === true };
|
|
21
|
+
} catch {
|
|
22
|
+
return { servers: {}, seeded: false };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveServers(data) {
|
|
27
|
+
const dir = path.dirname(mcpFile());
|
|
28
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
fs.writeFileSync(mcpFile(), JSON.stringify(data, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function listServers() {
|
|
33
|
+
const { servers } = loadServers();
|
|
34
|
+
return Object.entries(servers).map(([name, cfg]) => ({
|
|
35
|
+
name,
|
|
36
|
+
command: cfg.command || '',
|
|
37
|
+
args: cfg.args || [],
|
|
38
|
+
enabled: cfg.enabled !== false,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clearMcpCache() {
|
|
43
|
+
try { require('./mcp-client').clearCache(); } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addServer(name, command, args, opts) {
|
|
47
|
+
if (!name || !command) return { error: 'Usage: /mcp add <name> <command> [args...]' };
|
|
48
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
|
49
|
+
return { error: 'Invalid server name "' + name + '": use only letters, digits, - and _' };
|
|
50
|
+
}
|
|
51
|
+
// buildToolName joins with '__' and callTool splits on the last one — a name
|
|
52
|
+
// containing '__' would make tool names unparseable.
|
|
53
|
+
if (name.indexOf('__') >= 0) return { error: 'Server name cannot contain "__"' };
|
|
54
|
+
const data = loadServers();
|
|
55
|
+
data.servers[name] = {
|
|
56
|
+
name,
|
|
57
|
+
command,
|
|
58
|
+
args: args || [],
|
|
59
|
+
enabled: !opts || opts.enabled !== false,
|
|
60
|
+
};
|
|
61
|
+
if (opts && opts.env) data.servers[name].env = opts.env;
|
|
62
|
+
saveServers(data);
|
|
63
|
+
clearMcpCache();
|
|
64
|
+
return { added: name, command, args: data.servers[name].args };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Parse the claude-compatible add syntax:
|
|
68
|
+
// /mcp add [-e KEY=V]... <name> [--] <command> [args...]
|
|
69
|
+
// The `--` separator is optional (kept for muscle memory from `claude mcp add`).
|
|
70
|
+
// Env flags may appear before the name or right before the command.
|
|
71
|
+
/**
|
|
72
|
+
* @param {string[]} argv
|
|
73
|
+
* @returns {{name: string, command: string, args: string[], env?: Record<string,string>} | {error: string}}
|
|
74
|
+
*/
|
|
75
|
+
function parseMcpAddArgs(argv) {
|
|
76
|
+
const usage = 'Usage: /mcp add [-e KEY=V]... <name> [--] <command> [args...]';
|
|
77
|
+
/** @type {Record<string, string>} */
|
|
78
|
+
const env = {};
|
|
79
|
+
const takeEnv = (i) => {
|
|
80
|
+
const pair = argv[i + 1];
|
|
81
|
+
if (!pair || pair.indexOf('=') < 1) return null;
|
|
82
|
+
const eq = pair.indexOf('=');
|
|
83
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
84
|
+
return i + 2;
|
|
85
|
+
};
|
|
86
|
+
let i = 0;
|
|
87
|
+
while (argv[i] === '-e' || argv[i] === '--env') {
|
|
88
|
+
const ni = takeEnv(i);
|
|
89
|
+
if (ni === null) return { error: 'Usage: -e KEY=VALUE (got "' + (argv[i + 1] || '') + '")' };
|
|
90
|
+
i = ni;
|
|
91
|
+
}
|
|
92
|
+
if (i >= argv.length) return { error: usage };
|
|
93
|
+
const name = argv[i];
|
|
94
|
+
i += 1;
|
|
95
|
+
while (argv[i] === '-e' || argv[i] === '--env') {
|
|
96
|
+
const ni = takeEnv(i);
|
|
97
|
+
if (ni === null) return { error: 'Usage: -e KEY=VALUE (got "' + (argv[i + 1] || '') + '")' };
|
|
98
|
+
i = ni;
|
|
99
|
+
}
|
|
100
|
+
if (argv[i] === '--') i += 1;
|
|
101
|
+
if (i >= argv.length) return { error: usage };
|
|
102
|
+
const command = argv[i];
|
|
103
|
+
const args = argv.slice(i + 1);
|
|
104
|
+
return Object.keys(env).length ? { name, command, args, env } : { name, command, args };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function seedDefaults() {
|
|
108
|
+
const data = loadServers();
|
|
109
|
+
if (data.seeded) return { skipped: true };
|
|
110
|
+
const isWin = process.platform === 'win32';
|
|
111
|
+
// npx on Windows must be wrapped: cmd /c npx -y <pkg>
|
|
112
|
+
const npx = (pkg) => (isWin ? { command: 'cmd', args: ['/c', 'npx', '-y', pkg] } : { command: 'npx', args: ['-y', pkg] });
|
|
113
|
+
// Key-free servers — enabled out of the box. Minimal on purpose: models
|
|
114
|
+
// speculate with thinking/time tools and waste turns, so they start disabled.
|
|
115
|
+
const enabled = {
|
|
116
|
+
fetch: npx('@modelcontextprotocol/server-fetch'),
|
|
117
|
+
memory: npx('@modelcontextprotocol/server-memory'),
|
|
118
|
+
};
|
|
119
|
+
// Servers that need a key/token/path or invite speculative calls — installed
|
|
120
|
+
// but disabled, toggle on after setup (/mcp toggle <name>).
|
|
121
|
+
const optional = {
|
|
122
|
+
time: npx('@modelcontextprotocol/server-time'),
|
|
123
|
+
'sequential-thinking': npx('@modelcontextprotocol/server-sequential-thinking'),
|
|
124
|
+
github: {
|
|
125
|
+
command: 'docker',
|
|
126
|
+
args: ['run', '-i', '--rm', '-e', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'ghcr.io/github/github-mcp-server'],
|
|
127
|
+
env: { GITHUB_PERSONAL_ACCESS_TOKEN: '' },
|
|
128
|
+
},
|
|
129
|
+
filesystem: {
|
|
130
|
+
command: 'npx',
|
|
131
|
+
args: ['-y', '@modelcontextprotocol/server-filesystem', process.cwd()],
|
|
132
|
+
},
|
|
133
|
+
'brave-search': {
|
|
134
|
+
command: 'npx',
|
|
135
|
+
args: ['-y', '@modelcontextprotocol/server-brave-search'],
|
|
136
|
+
env: { BRAVE_API_KEY: '' },
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const count = Object.keys(enabled).length + Object.keys(optional).length;
|
|
140
|
+
for (const [name, cfg] of Object.entries(enabled)) {
|
|
141
|
+
if (data.servers[name]) continue;
|
|
142
|
+
data.servers[name] = Object.assign({ name, enabled: true }, cfg);
|
|
143
|
+
}
|
|
144
|
+
for (const [name, cfg] of Object.entries(optional)) {
|
|
145
|
+
if (data.servers[name]) continue;
|
|
146
|
+
data.servers[name] = Object.assign({ name, enabled: false }, cfg);
|
|
147
|
+
}
|
|
148
|
+
data.seeded = true;
|
|
149
|
+
saveServers(data);
|
|
150
|
+
clearMcpCache();
|
|
151
|
+
return { seeded: count };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function removeServer(name) {
|
|
155
|
+
const data = loadServers();
|
|
156
|
+
if (!data.servers[name]) return { error: 'MCP server not found: ' + name };
|
|
157
|
+
delete data.servers[name];
|
|
158
|
+
saveServers(data);
|
|
159
|
+
clearMcpCache();
|
|
160
|
+
return { removed: name };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toggleServer(name) {
|
|
164
|
+
const data = loadServers();
|
|
165
|
+
if (!data.servers[name]) return { error: 'MCP server not found: ' + name };
|
|
166
|
+
data.servers[name].enabled = data.servers[name].enabled === false;
|
|
167
|
+
saveServers(data);
|
|
168
|
+
return { name, enabled: data.servers[name].enabled };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stdioClient(cfg) {
|
|
172
|
+
const child = spawn(cfg.command, cfg.args || [], {
|
|
173
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
174
|
+
env: Object.assign({}, process.env, cfg.env || {}),
|
|
175
|
+
// No console window for stdio MCP servers on Windows (avoids the popup
|
|
176
|
+
// nagging the user while the chat is running).
|
|
177
|
+
windowsHide: true,
|
|
178
|
+
});
|
|
179
|
+
return child;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
loadServers,
|
|
184
|
+
saveServers,
|
|
185
|
+
listServers,
|
|
186
|
+
addServer,
|
|
187
|
+
parseMcpAddArgs,
|
|
188
|
+
removeServer,
|
|
189
|
+
toggleServer,
|
|
190
|
+
stdioClient,
|
|
191
|
+
seedDefaults,
|
|
192
|
+
MCP_FILE,
|
|
193
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
const { getApiKey, getBaseUrl } = require('../config/settings');
|
|
2
|
+
|
|
3
|
+
function getKey() {
|
|
4
|
+
return getApiKey('anthropic') || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function getApiUrl() {
|
|
8
|
+
const base = getBaseUrl('anthropic') || process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
|
|
9
|
+
return base.replace(/\/$/, '') + '/v1/messages';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatContentText(text) {
|
|
13
|
+
return Array.isArray(text)
|
|
14
|
+
? text
|
|
15
|
+
: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text) }];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} AnthropicBody
|
|
20
|
+
* @property {string} model
|
|
21
|
+
* @property {number} max_tokens
|
|
22
|
+
* @property {Array<Object>} messages
|
|
23
|
+
* @property {string|Array<{type: string, text: string, cache_control?: {type: string}}>=} system
|
|
24
|
+
* @property {number=} temperature
|
|
25
|
+
* @property {Array<Object>=} tools
|
|
26
|
+
* @property {{ type: 'enabled', budget_tokens: number }=} thinking
|
|
27
|
+
* @property {boolean=} cache
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Array<Object>} messages
|
|
32
|
+
* @param {Object} options
|
|
33
|
+
* @returns {AnthropicBody}
|
|
34
|
+
*/
|
|
35
|
+
function buildBody(messages, options) {
|
|
36
|
+
const systemMsgs = [];
|
|
37
|
+
const out = [];
|
|
38
|
+
// Prompt-caching opt-out (on by default — cache_control is ignored by
|
|
39
|
+
// providers that don't support it and billed at write once, read cheap).
|
|
40
|
+
const useCache = options.cache !== false;
|
|
41
|
+
|
|
42
|
+
// System prompt comes from options.system (session.systemPrompt); also
|
|
43
|
+
// accept role:'system' entries in the message history.
|
|
44
|
+
if (options.system) systemMsgs.push(options.system);
|
|
45
|
+
|
|
46
|
+
for (const m of messages) {
|
|
47
|
+
if (m.role === 'system') { systemMsgs.push(m.content); continue; }
|
|
48
|
+
if (m.role === 'user') {
|
|
49
|
+
out.push({ role: 'user', content: formatContentText(m.content) });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (m.role === 'tool') {
|
|
53
|
+
out.push({
|
|
54
|
+
role: 'user',
|
|
55
|
+
content: [{ type: 'tool_result', tool_use_id: m.toolCallId, content: String(m.content) }],
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (m.role === 'assistant') {
|
|
60
|
+
const blocks = [];
|
|
61
|
+
// History may carry raw Anthropic content blocks (e.g. from a
|
|
62
|
+
// restored session): keep thinking blocks (with their signature)
|
|
63
|
+
// so the extended-thinking tool-use flow can resend them.
|
|
64
|
+
if (Array.isArray(m.content)) {
|
|
65
|
+
for (const b of m.content) {
|
|
66
|
+
if (b.type === 'text') blocks.push({ type: 'text', text: b.text });
|
|
67
|
+
else if (b.type === 'thinking') blocks.push({ type: 'thinking', thinking: b.thinking, signature: b.signature });
|
|
68
|
+
else if (b.type === 'tool_use') blocks.push({ type: 'tool_use', id: b.id, name: b.name, input: b.input || {} });
|
|
69
|
+
}
|
|
70
|
+
} else if (m.content) {
|
|
71
|
+
blocks.push({ type: 'text', text: m.content });
|
|
72
|
+
}
|
|
73
|
+
for (const tc of m.toolCalls || []) {
|
|
74
|
+
blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input || {} });
|
|
75
|
+
}
|
|
76
|
+
out.push({ role: 'assistant', content: blocks });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @type {AnthropicBody} */
|
|
81
|
+
const body = {
|
|
82
|
+
model: options.model,
|
|
83
|
+
max_tokens: options.maxTokens || 8192,
|
|
84
|
+
messages: out,
|
|
85
|
+
...(systemMsgs.length && {
|
|
86
|
+
system: useCache
|
|
87
|
+
? [{ type: 'text', text: systemMsgs.join('\n\n'), cache_control: { type: 'ephemeral' } }]
|
|
88
|
+
: systemMsgs.join('\n\n'),
|
|
89
|
+
}),
|
|
90
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Prompt caching (Anthropic): a cache breakpoint on the system prompt and
|
|
94
|
+
// on the LAST message means each turn re-reads the stable prefix from the
|
|
95
|
+
// cache instead of re-paying full input cost. Opt out with options.cache === false.
|
|
96
|
+
// cache_control is NOT valid on thinking blocks — skip those when picking
|
|
97
|
+
// the breakpoint block, else the API 400s and the turn dies mid-session.
|
|
98
|
+
if (useCache && out.length) {
|
|
99
|
+
const last = out[out.length - 1];
|
|
100
|
+
if (Array.isArray(last.content) && last.content.length) {
|
|
101
|
+
for (let ci = last.content.length - 1; ci >= 0; ci--) {
|
|
102
|
+
const lb = last.content[ci];
|
|
103
|
+
if (!lb || lb.type === 'thinking') continue;
|
|
104
|
+
if (!lb.cache_control) lb.cache_control = { type: 'ephemeral' };
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
} else if (typeof last.content === 'string' && last.content) {
|
|
108
|
+
last.content = [{ type: 'text', text: last.content, cache_control: { type: 'ephemeral' } }];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Extended thinking (Claude reasons BEFORE every reply, including after
|
|
113
|
+
// each tool result — the opencode-style multi-pass thinking). Only enabled
|
|
114
|
+
// when the active model supports it (options.reasoning set by the session
|
|
115
|
+
// from the model's 'reasoning' tag). Anthropic requires temperature unset
|
|
116
|
+
// and max_tokens comfortably above the budget.
|
|
117
|
+
if (options.reasoning) {
|
|
118
|
+
let maxTokens = options.maxTokens || 8192;
|
|
119
|
+
// Explicit per-turn budget (/think low|medium|high) wins over the default.
|
|
120
|
+
const budget = options.thinkingBudget
|
|
121
|
+
? Math.min(32768, Math.max(1024, Number(options.thinkingBudget)))
|
|
122
|
+
: Math.min(8192, Math.max(1024, maxTokens - 4096));
|
|
123
|
+
// budget_tokens must be strictly less than max_tokens (API requirement),
|
|
124
|
+
// so lift max_tokens when a small configured limit would collide.
|
|
125
|
+
if (budget >= maxTokens) maxTokens = budget + 1;
|
|
126
|
+
body.max_tokens = maxTokens;
|
|
127
|
+
body.thinking = { type: 'enabled', budget_tokens: budget };
|
|
128
|
+
delete body.temperature;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (options.tools && options.tools.length) {
|
|
132
|
+
body.tools = options.tools.map(t => ({
|
|
133
|
+
name: t.name,
|
|
134
|
+
description: t.description,
|
|
135
|
+
input_schema: t.input_schema || { type: 'object', properties: {} },
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return body;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeBlocks(contentBlocks) {
|
|
143
|
+
const textParts = [];
|
|
144
|
+
const toolCalls = [];
|
|
145
|
+
for (const block of contentBlocks) {
|
|
146
|
+
if (block.type === 'text') textParts.push(block.text);
|
|
147
|
+
else if (block.type === 'tool_use') {
|
|
148
|
+
toolCalls.push({ id: block.id, name: block.name, input: block.input || {} });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { content: textParts.join(''), toolCalls };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function post(body, options) {
|
|
155
|
+
const key = getKey();
|
|
156
|
+
if (!key) throw new Error("Anthropic API key not set. Use /connect anthropic or set ANTHROPIC_API_KEY.");
|
|
157
|
+
const apiUrl = getApiUrl();
|
|
158
|
+
const resp = await fetch(apiUrl, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'Content-Type': 'application/json',
|
|
162
|
+
'x-api-key': key,
|
|
163
|
+
'anthropic-version': '2023-06-01',
|
|
164
|
+
},
|
|
165
|
+
body: JSON.stringify(body),
|
|
166
|
+
signal: options.signal,
|
|
167
|
+
});
|
|
168
|
+
if (!resp.ok) {
|
|
169
|
+
/** @type {any} */
|
|
170
|
+
const err = await resp.json().catch(() => ({}));
|
|
171
|
+
const detail = err.error?.message || resp.statusText;
|
|
172
|
+
if (resp.status === 401) throw new Error("Anthropic 401 Unauthorized: the API key is invalid or expired. Run /connect anthropic and paste a new key.");
|
|
173
|
+
if (resp.status === 403) throw new Error("Anthropic 403 Forbidden: the API key is not authorized for this model. Check the model name and your account access.");
|
|
174
|
+
throw new Error(`Anthropic API error ${resp.status}: ${detail}`);
|
|
175
|
+
}
|
|
176
|
+
return resp;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function chat(messages, options = {}) {
|
|
180
|
+
const body = buildBody(messages, options);
|
|
181
|
+
const resp = await post(body, options);
|
|
182
|
+
/** @type {any} */
|
|
183
|
+
const data = await resp.json();
|
|
184
|
+
return { ...normalizeBlocks(data.content || []), usage: data.usage };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function stream(messages, options = {}, onDelta, onReasoning) {
|
|
188
|
+
const body = { ...buildBody(messages, options), stream: true };
|
|
189
|
+
const resp = await post(body, options);
|
|
190
|
+
if (!resp.body) throw new Error('Anthropic stream: empty response body');
|
|
191
|
+
const decoder = new TextDecoder('utf-8');
|
|
192
|
+
let buf = '';
|
|
193
|
+
let content = '';
|
|
194
|
+
let reasoning = '';
|
|
195
|
+
let usage = null;
|
|
196
|
+
const toolAcc = new Map();
|
|
197
|
+
|
|
198
|
+
for await (const chunk of resp.body) {
|
|
199
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
200
|
+
const lines = buf.split('\n');
|
|
201
|
+
buf = lines.pop() || '';
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
if (!line.startsWith('data: ')) continue;
|
|
204
|
+
const raw = line.slice(6);
|
|
205
|
+
if (raw === '[DONE]') continue;
|
|
206
|
+
let evt;
|
|
207
|
+
try { evt = JSON.parse(raw); } catch { continue; }
|
|
208
|
+
if (evt.type === 'message_delta' && evt.usage) usage = evt.usage;
|
|
209
|
+
if (evt.type === 'content_block_start' && evt.content_block?.type === 'tool_use') {
|
|
210
|
+
toolAcc.set(evt.index, { id: evt.content_block.id, name: evt.content_block.name, input: '' });
|
|
211
|
+
} else if (evt.type === 'content_block_delta') {
|
|
212
|
+
const d = evt.delta || {};
|
|
213
|
+
if (d.type === 'text_delta') {
|
|
214
|
+
content += d.text;
|
|
215
|
+
if (onDelta) onDelta(d.text);
|
|
216
|
+
} else if (d.type === 'thinking_delta') {
|
|
217
|
+
reasoning += d.thinking || '';
|
|
218
|
+
if (onReasoning) onReasoning(d.thinking || '');
|
|
219
|
+
} else if (d.type === 'input_json_delta') {
|
|
220
|
+
if (!toolAcc.has(evt.index)) toolAcc.set(evt.index, { id: '', name: '', input: '' });
|
|
221
|
+
toolAcc.get(evt.index).input += d.partial_json;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const toolCalls = [];
|
|
228
|
+
for (const acc of toolAcc.values()) {
|
|
229
|
+
let input = {};
|
|
230
|
+
try { input = JSON.parse(acc.input || '{}'); } catch {}
|
|
231
|
+
toolCalls.push({ id: acc.id, name: acc.name, input });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { content, reasoning, toolCalls, usage };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const models = [
|
|
238
|
+
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic', context: 200000, priceIn: 3, priceOut: 15, tags: ['reasoning'] },
|
|
239
|
+
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4', provider: 'anthropic', context: 200000, priceIn: 15, priceOut: 75, tags: ['reasoning'] },
|
|
240
|
+
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', provider: 'anthropic', context: 200000, priceIn: 0.8, priceOut: 4 },
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
module.exports = { chat, stream, models, buildBody, normalizeBlocks };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const { createOpenAICompatProvider } = require('./openai-compat');
|
|
2
|
+
const { getApiKey } = require('../config/settings');
|
|
3
|
+
|
|
4
|
+
function getKey() {
|
|
5
|
+
return getApiKey('google') || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const provider = {
|
|
9
|
+
...createOpenAICompatProvider({
|
|
10
|
+
getKey,
|
|
11
|
+
providerId: 'google',
|
|
12
|
+
envKeyHint: 'GOOGLE',
|
|
13
|
+
}),
|
|
14
|
+
models: [
|
|
15
|
+
// ── Frontier ──
|
|
16
|
+
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'google', tags: ['frontier'], context: 1000000, priceIn: 1.25, priceOut: 10 },
|
|
17
|
+
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'google', tags: ['fast'], context: 1000000, priceIn: 0.3, priceOut: 2.5 },
|
|
18
|
+
// ── Flash ──
|
|
19
|
+
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'google', tags: [], context: 1000000, priceIn: 0.1, priceOut: 0.4 },
|
|
20
|
+
{ id: 'gemini-2.0-flash-lite', name: 'Gemini 2.0 Flash Lite', provider: 'google', tags: ['free', 'fast'], context: 1000000, priceIn: 0.075, priceOut: 0.3 },
|
|
21
|
+
// ── Legacy ──
|
|
22
|
+
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', provider: 'google', tags: [], context: 1000000, priceIn: 0.075, priceOut: 0.3 },
|
|
23
|
+
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', provider: 'google', tags: [], context: 2000000, priceIn: 1.25, priceOut: 5 },
|
|
24
|
+
// ── Gemma (Local / On-device) ──
|
|
25
|
+
{ id: 'gemma-4-31b-it', name: 'Gemma 4 31B IT', provider: 'google', tags: ['small'], context: 32768, priceIn: 0.2, priceOut: 0.8 },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
module.exports = provider;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const AnthropicProvider = require('./anthropic');
|
|
2
|
+
const OpenAIProvider = require('./openai');
|
|
3
|
+
const NVIDIAProvider = require('./nvidia');
|
|
4
|
+
const GoogleProvider = require('./google');
|
|
5
|
+
const OpenRouterProvider = require('./openrouter');
|
|
6
|
+
const TokenRouterProvider = require('./tokenrouter');
|
|
7
|
+
const LocalProvider = require('./local');
|
|
8
|
+
const { createOpenAICompatProvider } = require('./openai-compat');
|
|
9
|
+
const { loadRegistry, fetchRegistry, isRegistryFresh, envNamesFor, SDK_BASE_URLS } = require('./registry');
|
|
10
|
+
const { loadConfig, getApiKey } = require('../config/settings');
|
|
11
|
+
|
|
12
|
+
const PROVIDERS = {
|
|
13
|
+
anthropic: AnthropicProvider,
|
|
14
|
+
openai: OpenAIProvider,
|
|
15
|
+
nvidia: NVIDIAProvider,
|
|
16
|
+
google: GoogleProvider,
|
|
17
|
+
openrouter: OpenRouterProvider,
|
|
18
|
+
tokenrouter: TokenRouterProvider,
|
|
19
|
+
local: LocalProvider,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Providers shipped in code (always available, always listed in /models even
|
|
23
|
+
// without a key). Everything else comes from the models.dev registry.
|
|
24
|
+
const BUILTIN_PROVIDERS = Object.keys(PROVIDERS);
|
|
25
|
+
const PROVIDER_ORDER = ['anthropic', 'openai', 'nvidia', 'google', 'openrouter', 'tokenrouter', 'local'];
|
|
26
|
+
const PROVIDER_LABELS = {
|
|
27
|
+
anthropic: 'Anthropic (Claude)',
|
|
28
|
+
openai: 'OpenAI (GPT)',
|
|
29
|
+
nvidia: 'NVIDIA NIM',
|
|
30
|
+
google: 'Google Gemini',
|
|
31
|
+
openrouter: 'OpenRouter',
|
|
32
|
+
tokenrouter: 'Token Router',
|
|
33
|
+
local: 'Local (Ollama/LM Studio)',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── models.dev registry providers ──
|
|
37
|
+
// Fetched once and cached at ~/.loom/models-dev.json (opencode uses the same
|
|
38
|
+
// dataset for its 75+ providers). Each provider becomes an OpenAI-compatible
|
|
39
|
+
// runtime provider with its real model list, prices, and context windows.
|
|
40
|
+
// Built-in providers always win the merge; registry entries without models
|
|
41
|
+
// are skipped.
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {import('./registry').RegistryProvider} rp
|
|
45
|
+
* @returns {import('./openai-compat').Provider}
|
|
46
|
+
*/
|
|
47
|
+
function makeDynamicProvider(rp) {
|
|
48
|
+
const provider = createOpenAICompatProvider({
|
|
49
|
+
getKey: () => getApiKey(rp.id),
|
|
50
|
+
providerId: rp.id,
|
|
51
|
+
envKeyHint: rp.name,
|
|
52
|
+
defaultBaseUrl: rp.baseURL || SDK_BASE_URLS[rp.npm] || undefined,
|
|
53
|
+
});
|
|
54
|
+
provider.models = rp.models.map((m) => ({
|
|
55
|
+
id: m.id,
|
|
56
|
+
name: m.name,
|
|
57
|
+
provider: rp.id,
|
|
58
|
+
context: m.context,
|
|
59
|
+
priceIn: m.priceIn,
|
|
60
|
+
priceOut: m.priceOut,
|
|
61
|
+
tags: m.tags,
|
|
62
|
+
}));
|
|
63
|
+
return provider;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Merge the cached registry into PROVIDERS/PROVIDER_ORDER/PROVIDER_LABELS
|
|
68
|
+
* (in place, so existing imports keep working). Returns the number of
|
|
69
|
+
* providers added.
|
|
70
|
+
* @returns {number}
|
|
71
|
+
*/
|
|
72
|
+
function mergeRegistry() {
|
|
73
|
+
const reg = loadRegistry();
|
|
74
|
+
if (!reg) return 0;
|
|
75
|
+
let added = 0;
|
|
76
|
+
for (const [id, rp] of Object.entries(reg)) {
|
|
77
|
+
if (PROVIDERS[id] || PROVIDER_LABELS[id]) continue;
|
|
78
|
+
if (!rp.models.length) continue;
|
|
79
|
+
PROVIDERS[id] = makeDynamicProvider(rp);
|
|
80
|
+
PROVIDER_LABELS[id] = rp.name;
|
|
81
|
+
PROVIDER_ORDER.push(id);
|
|
82
|
+
added++;
|
|
83
|
+
}
|
|
84
|
+
PROVIDER_ORDER.sort((a, b) => {
|
|
85
|
+
const ai = BUILTIN_PROVIDERS.indexOf(a);
|
|
86
|
+
const bi = BUILTIN_PROVIDERS.indexOf(b);
|
|
87
|
+
if (ai >= 0 && bi >= 0) return ai - bi;
|
|
88
|
+
if (ai >= 0) return -1;
|
|
89
|
+
if (bi >= 0) return 1;
|
|
90
|
+
return String(a).localeCompare(String(b));
|
|
91
|
+
});
|
|
92
|
+
return added;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let registryPromise = null;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Make sure the models.dev registry is available: reuse a fresh cache, else
|
|
99
|
+
* fetch once (background-friendly, never throws). Returns the number of
|
|
100
|
+
* providers currently merged.
|
|
101
|
+
* @param {boolean=} force bypass the freshness check
|
|
102
|
+
* @returns {Promise<number>}
|
|
103
|
+
*/
|
|
104
|
+
function ensureRegistry(force = false) {
|
|
105
|
+
if (!force && isRegistryFresh()) return Promise.resolve(mergeRegistry());
|
|
106
|
+
if (!registryPromise) {
|
|
107
|
+
registryPromise = fetchRegistry().then((count) => {
|
|
108
|
+
registryPromise = null;
|
|
109
|
+
if (count) mergeRegistry();
|
|
110
|
+
return count;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return registryPromise;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Merge whatever cache exists at boot (first run has none — ensureRegistry()
|
|
117
|
+
// fetches it the first time the provider picker opens).
|
|
118
|
+
mergeRegistry();
|
|
119
|
+
|
|
120
|
+
class ProviderRouter {
|
|
121
|
+
constructor() {
|
|
122
|
+
this.providers = {};
|
|
123
|
+
this.active = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
init(providerName) {
|
|
127
|
+
const config = loadConfig();
|
|
128
|
+
const name = providerName || config.provider || 'anthropic';
|
|
129
|
+
if (!PROVIDERS[name]) {
|
|
130
|
+
throw new Error(`Unknown provider: ${name}. Available: ${PROVIDER_ORDER.join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
return this.use(name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Transient switch: activate a provider without persisting anything to the
|
|
136
|
+
// config (the budget router swaps providers per turn; setModel() persists).
|
|
137
|
+
use(providerName) {
|
|
138
|
+
if (!PROVIDERS[providerName]) return null;
|
|
139
|
+
this.providers[providerName] = PROVIDERS[providerName];
|
|
140
|
+
this.active = { name: providerName, key: getApiKey(providerName) };
|
|
141
|
+
return this.active;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getChatFn() {
|
|
145
|
+
if (!this.active) this.init();
|
|
146
|
+
const active = this.active;
|
|
147
|
+
if (!active) throw new Error('No active provider');
|
|
148
|
+
return this.providers[active.name].chat;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getStreamFn() {
|
|
152
|
+
if (!this.active) this.init();
|
|
153
|
+
const active = this.active;
|
|
154
|
+
if (!active) throw new Error('No active provider');
|
|
155
|
+
return this.providers[active.name].stream;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getModels(providerName) {
|
|
159
|
+
const name = providerName || this.active?.name || 'anthropic';
|
|
160
|
+
if (!PROVIDERS[name]) return [];
|
|
161
|
+
return PROVIDERS[name].models || [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Look up model metadata (context window, $/1M token prices) for pricing/billing display.
|
|
166
|
+
function getModelMeta(providerName, modelId) {
|
|
167
|
+
if (!providerName || !modelId) return null;
|
|
168
|
+
const list = (PROVIDERS[providerName] && PROVIDERS[providerName].models) || [];
|
|
169
|
+
return list.find((m) => m.id === modelId) || null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
ProviderRouter, PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS, BUILTIN_PROVIDERS,
|
|
174
|
+
getModelMeta, ensureRegistry, mergeRegistry, envNamesFor,
|
|
175
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { createOpenAICompatProvider } = require('./openai-compat');
|
|
2
|
+
const { getApiKey } = require('../config/settings');
|
|
3
|
+
|
|
4
|
+
function getKey() {
|
|
5
|
+
return getApiKey('local') || process.env.LOCAL_API_KEY || 'local';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const provider = {
|
|
9
|
+
...createOpenAICompatProvider({
|
|
10
|
+
getKey,
|
|
11
|
+
providerId: 'local',
|
|
12
|
+
envKeyHint: 'LOCAL',
|
|
13
|
+
}),
|
|
14
|
+
models: [
|
|
15
|
+
{ id: 'llama3.2', name: 'Llama 3.2 (local)', provider: 'local', tags: ['local'], context: 131072, priceIn: 0, priceOut: 0 },
|
|
16
|
+
{ id: 'llama3.2:1b', name: 'Llama 3.2 1B (local)', provider: 'local', tags: ['local', 'small'], context: 131072, priceIn: 0, priceOut: 0 },
|
|
17
|
+
{ id: 'mistral', name: 'Mistral 7B (local)', provider: 'local', tags: ['local'], context: 32768, priceIn: 0, priceOut: 0 },
|
|
18
|
+
{ id: 'codellama', name: 'CodeLlama 7B (local)', provider: 'local', tags: ['local', 'coding'], context: 16384, priceIn: 0, priceOut: 0 },
|
|
19
|
+
{ id: 'gemma2', name: 'Gemma 2 (local)', provider: 'local', tags: ['local'], context: 8192, priceIn: 0, priceOut: 0 },
|
|
20
|
+
{ id: 'phi4', name: 'Phi-4 14B (local)', provider: 'local', tags: ['local'], context: 16384, priceIn: 0, priceOut: 0 },
|
|
21
|
+
{ id: 'deepseek-coder', name: 'DeepSeek-Coder 6.7B (local)', provider: 'local', tags: ['local', 'coding'], context: 16384, priceIn: 0, priceOut: 0 },
|
|
22
|
+
{ id: 'qwen2.5-coder', name: 'Qwen 2.5 Coder 14B (local)', provider: 'local', tags: ['local', 'coding'], context: 131072, priceIn: 0, priceOut: 0 },
|
|
23
|
+
{ id: 'starcoder2', name: 'StarCoder2 7B (local)', provider: 'local', tags: ['local', 'coding'], context: 16384, priceIn: 0, priceOut: 0 },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
module.exports = provider;
|