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,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(/&/g, '&')
|
|
94
|
+
.replace(/</g, '<')
|
|
95
|
+
.replace(/>/g, '>')
|
|
96
|
+
.replace(/'|'/g, "'")
|
|
97
|
+
.replace(/"/g, '"')
|
|
98
|
+
.replace(/ /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;
|