@danieltmn/tamnora 0.1.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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/SECURITY.md +31 -0
- package/harness/packages/cli/bin/tamnora.mjs +11 -0
- package/harness/packages/cli/package.json +10 -0
- package/harness/packages/cli/src/cli.js +90 -0
- package/harness/packages/cli/src/commands/autostart.js +121 -0
- package/harness/packages/cli/src/commands/bridge.js +274 -0
- package/harness/packages/cli/src/commands/doctor.js +73 -0
- package/harness/packages/cli/src/commands/eval.js +183 -0
- package/harness/packages/cli/src/commands/init.js +31 -0
- package/harness/packages/cli/src/commands/mcp.js +101 -0
- package/harness/packages/cli/src/commands/repl.js +172 -0
- package/harness/packages/cli/src/commands/run.js +165 -0
- package/harness/packages/cli/src/commands/serve.js +1 -0
- package/harness/packages/cli/src/commands/server.js +345 -0
- package/harness/packages/cli/src/commands/tui.js +110 -0
- package/harness/packages/cli/src/commands/update.js +76 -0
- package/harness/packages/cli/src/commands/user.js +135 -0
- package/harness/packages/cli/src/lib/core.js +2 -0
- package/harness/packages/cli/src/lib/daemon.js +153 -0
- package/harness/packages/cli/src/lib/qr.js +203 -0
- package/harness/packages/cli/src/lib/runtime.js +110 -0
- package/harness/packages/cli/src/lib/tunnel.js +113 -0
- package/harness/packages/cli/src/lib/util.js +24 -0
- package/harness/packages/cli/src/tui/app.js +302 -0
- package/harness/packages/cli/src/tui/keys.js +78 -0
- package/harness/packages/cli/src/tui/layout.js +130 -0
- package/harness/packages/cli/src/tui/screen.js +13 -0
- package/harness/packages/cli/src/ui/color.js +15 -0
- package/harness/packages/cli/test/release.test.js +34 -0
- package/harness/packages/cli/test/tui.test.js +99 -0
- package/harness/packages/cli/test/tunnel.test.js +20 -0
- package/harness/packages/core/package.json +14 -0
- package/harness/packages/core/src/agent/context.js +66 -0
- package/harness/packages/core/src/agent/modes.js +26 -0
- package/harness/packages/core/src/agent/runAgent.js +257 -0
- package/harness/packages/core/src/config/app.js +49 -0
- package/harness/packages/core/src/config/home.js +98 -0
- package/harness/packages/core/src/config/identity.js +41 -0
- package/harness/packages/core/src/config/index.js +69 -0
- package/harness/packages/core/src/config/providers.js +24 -0
- package/harness/packages/core/src/env.js +19 -0
- package/harness/packages/core/src/eval/dataset.js +47 -0
- package/harness/packages/core/src/eval/judge.js +39 -0
- package/harness/packages/core/src/eval/report.js +48 -0
- package/harness/packages/core/src/eval/runTask.js +71 -0
- package/harness/packages/core/src/eval/runner.js +150 -0
- package/harness/packages/core/src/eval/scorers.js +217 -0
- package/harness/packages/core/src/eval/task.js +59 -0
- package/harness/packages/core/src/index.js +72 -0
- package/harness/packages/core/src/mcp/common.js +82 -0
- package/harness/packages/core/src/mcp/http.js +149 -0
- package/harness/packages/core/src/mcp/index.js +167 -0
- package/harness/packages/core/src/mcp/stdio.js +218 -0
- package/harness/packages/core/src/providers/index.js +119 -0
- package/harness/packages/core/src/providers/ollama.js +76 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
- package/harness/packages/core/src/providers/openrouter.js +57 -0
- package/harness/packages/core/src/providers/presets.js +19 -0
- package/harness/packages/core/src/providers/resilience.js +74 -0
- package/harness/packages/core/src/providers/stream.js +200 -0
- package/harness/packages/core/src/providers/utils.js +66 -0
- package/harness/packages/core/src/storage/sqlite.js +453 -0
- package/harness/packages/core/src/tools/diff.js +90 -0
- package/harness/packages/core/src/tools/executors.js +277 -0
- package/harness/packages/core/src/tools/glob.js +67 -0
- package/harness/packages/core/src/tools/index.js +28 -0
- package/harness/packages/core/src/tools/paths.js +29 -0
- package/harness/packages/core/src/tools/permissions.js +69 -0
- package/harness/packages/core/src/tools/registry.js +44 -0
- package/harness/packages/core/src/tools/schema.js +177 -0
- package/harness/packages/core/test/agent.test.js +237 -0
- package/harness/packages/core/test/config.test.js +33 -0
- package/harness/packages/core/test/context.test.js +44 -0
- package/harness/packages/core/test/diff.test.js +20 -0
- package/harness/packages/core/test/eval.test.js +144 -0
- package/harness/packages/core/test/git-tools.test.js +44 -0
- package/harness/packages/core/test/home.test.js +62 -0
- package/harness/packages/core/test/mcp.test.js +158 -0
- package/harness/packages/core/test/providers.test.js +80 -0
- package/harness/packages/core/test/resilience.test.js +61 -0
- package/harness/packages/core/test/storage.test.js +55 -0
- package/harness/packages/core/test/stream.test.js +91 -0
- package/harness/packages/core/test/tools.test.js +96 -0
- package/harness/packages/server/bin/tamnora-server.mjs +11 -0
- package/harness/packages/server/package.json +10 -0
- package/harness/packages/server/public/app.js +2188 -0
- package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
- package/harness/packages/server/public/index.html +368 -0
- package/harness/packages/server/public/landing/index.html +523 -0
- package/harness/packages/server/public/landing/landing.js +89 -0
- package/harness/packages/server/public/markdown.js +174 -0
- package/harness/packages/server/public/styles.css +923 -0
- package/harness/packages/server/src/auth.js +146 -0
- package/harness/packages/server/src/cli.js +61 -0
- package/harness/packages/server/src/index.js +1 -0
- package/harness/packages/server/src/lib/approvals.js +51 -0
- package/harness/packages/server/src/lib/core.js +2 -0
- package/harness/packages/server/src/lib/http.js +74 -0
- package/harness/packages/server/src/lib/modelCache.js +32 -0
- package/harness/packages/server/src/lib/prompt.js +48 -0
- package/harness/packages/server/src/lib/workspace.js +74 -0
- package/harness/packages/server/src/routes/auth.js +56 -0
- package/harness/packages/server/src/routes/chat.js +222 -0
- package/harness/packages/server/src/routes/eval.js +100 -0
- package/harness/packages/server/src/routes/files.js +117 -0
- package/harness/packages/server/src/routes/folders.js +80 -0
- package/harness/packages/server/src/routes/mcp.js +50 -0
- package/harness/packages/server/src/routes/models.js +26 -0
- package/harness/packages/server/src/routes/providers.js +135 -0
- package/harness/packages/server/src/routes/sessions.js +157 -0
- package/harness/packages/server/src/server.js +249 -0
- package/harness/packages/server/src/static.js +67 -0
- package/harness/packages/server/test/markdown.test.js +36 -0
- package/harness/packages/server/test/modelCache.test.js +72 -0
- package/harness/packages/server/test/server.test.js +809 -0
- package/harness/scripts/example-dataset.jsonl +4 -0
- package/harness/scripts/mcp-echo-server.mjs +108 -0
- package/harness/scripts/mock-provider.mjs +134 -0
- package/harness/tamnora.example.json +18 -0
- package/package.json +70 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// MCP client entry point: picks the transport (HTTP or stdio) and builds a
|
|
2
|
+
// single tool registry from every enabled server.
|
|
3
|
+
|
|
4
|
+
import { connectMcpHttpServer } from './http.js';
|
|
5
|
+
import { connectMcpStdioServer } from './stdio.js';
|
|
6
|
+
import {
|
|
7
|
+
qualifiedToolName,
|
|
8
|
+
mcpToolToDefinition,
|
|
9
|
+
formatResources,
|
|
10
|
+
formatPrompts,
|
|
11
|
+
} from './common.js';
|
|
12
|
+
|
|
13
|
+
export { connectMcpHttpServer } from './http.js';
|
|
14
|
+
export { connectMcpStdioServer } from './stdio.js';
|
|
15
|
+
export {
|
|
16
|
+
PROTOCOL_VERSION,
|
|
17
|
+
CLIENT_INFO,
|
|
18
|
+
qualifiedToolName,
|
|
19
|
+
mcpToolToDefinition,
|
|
20
|
+
mcpResultToText,
|
|
21
|
+
mcpResourceToText,
|
|
22
|
+
mcpPromptToText,
|
|
23
|
+
formatResources,
|
|
24
|
+
formatPrompts,
|
|
25
|
+
} from './common.js';
|
|
26
|
+
|
|
27
|
+
/** Decide the transport for a server config. */
|
|
28
|
+
export function mcpTransport(server = {}) {
|
|
29
|
+
if (server.type === 'stdio' || server.command) return 'stdio';
|
|
30
|
+
if (server.type === 'http' || server.url) return 'http';
|
|
31
|
+
throw new Error(`MCP: el servidor ${server.id ?? ''} necesita "url" (http) o "command" (stdio)`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Connect to a single MCP server using the right transport. */
|
|
35
|
+
export async function connectMcpServer(server, opts = {}) {
|
|
36
|
+
return mcpTransport(server) === 'stdio'
|
|
37
|
+
? connectMcpStdioServer(server, opts)
|
|
38
|
+
: connectMcpHttpServer(server, opts);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function syntheticDefinition(name, description, parameters) {
|
|
42
|
+
return {
|
|
43
|
+
type: 'function',
|
|
44
|
+
function: {
|
|
45
|
+
name,
|
|
46
|
+
description,
|
|
47
|
+
parameters: parameters ?? { type: 'object', properties: {} },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Connect to every enabled server and build a single registry:
|
|
54
|
+
* - OpenAI-schema definitions for the servers' tools, plus a synthetic
|
|
55
|
+
* `list_resources` / `read_resource` / `list_prompts` / `get_prompt` pair
|
|
56
|
+
* per server that advertises the capability;
|
|
57
|
+
* - a router for calls (reconnecting a dead stdio session once).
|
|
58
|
+
*/
|
|
59
|
+
export async function buildMcpRegistry(servers = [], { signal } = {}) {
|
|
60
|
+
const enabled = servers.filter((s) => s.enabled !== false);
|
|
61
|
+
const results = await Promise.all(
|
|
62
|
+
enabled.map(async (server) => {
|
|
63
|
+
try {
|
|
64
|
+
return { server, session: await connectMcpServer(server, { signal }) };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { server, error: error?.message ?? String(error) };
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const map = new Map();
|
|
72
|
+
const definitions = [];
|
|
73
|
+
const sessions = [];
|
|
74
|
+
const errors = [];
|
|
75
|
+
|
|
76
|
+
const add = (name, definition, call) => {
|
|
77
|
+
map.set(name, call);
|
|
78
|
+
definitions.push(definition);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
for (const r of results) {
|
|
82
|
+
if (r.error) {
|
|
83
|
+
errors.push({ server: r.server, error: r.error });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const { server, session } = r;
|
|
87
|
+
sessions.push(session);
|
|
88
|
+
const holder = { server, session };
|
|
89
|
+
|
|
90
|
+
const reconnect = async () => {
|
|
91
|
+
const current = holder.session;
|
|
92
|
+
if (current.alive && !current.alive()) {
|
|
93
|
+
try {
|
|
94
|
+
holder.session = await connectMcpServer(holder.server, { signal });
|
|
95
|
+
} catch {
|
|
96
|
+
/* keep the dead session */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return holder.session;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const tool of session.tools ?? []) {
|
|
103
|
+
const name = qualifiedToolName(server.id, tool.name);
|
|
104
|
+
add(name, mcpToolToDefinition(tool, server, name), async (args) => (await reconnect()).callTool(tool.name, args));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (session.capabilities?.resources || session.resources?.length) {
|
|
108
|
+
const label = server.name ?? server.id;
|
|
109
|
+
const listName = qualifiedToolName(server.id, 'list_resources');
|
|
110
|
+
add(
|
|
111
|
+
listName,
|
|
112
|
+
syntheticDefinition(listName, `Lista los recursos MCP del servidor ${label}.`),
|
|
113
|
+
async () => formatResources(await (await reconnect()).listResources())
|
|
114
|
+
);
|
|
115
|
+
const readName = qualifiedToolName(server.id, 'read_resource');
|
|
116
|
+
add(
|
|
117
|
+
readName,
|
|
118
|
+
syntheticDefinition(readName, `Lee un recurso MCP del servidor ${label} a partir de su uri.`, {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: { uri: { type: 'string', description: 'URI del recurso' } },
|
|
121
|
+
required: ['uri'],
|
|
122
|
+
}),
|
|
123
|
+
async (args) => (await reconnect()).readResource(args.uri)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (session.capabilities?.prompts || session.prompts?.length) {
|
|
128
|
+
const label = server.name ?? server.id;
|
|
129
|
+
const listName = qualifiedToolName(server.id, 'list_prompts');
|
|
130
|
+
add(
|
|
131
|
+
listName,
|
|
132
|
+
syntheticDefinition(listName, `Lista los prompts MCP del servidor ${label}.`),
|
|
133
|
+
async () => formatPrompts(await (await reconnect()).listPrompts())
|
|
134
|
+
);
|
|
135
|
+
const getName = qualifiedToolName(server.id, 'get_prompt');
|
|
136
|
+
add(
|
|
137
|
+
getName,
|
|
138
|
+
syntheticDefinition(getName, `Obtiene un prompt MCP del servidor ${label}.`, {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
name: { type: 'string', description: 'Nombre del prompt' },
|
|
142
|
+
arguments: { type: 'object', description: 'Argumentos del prompt' },
|
|
143
|
+
},
|
|
144
|
+
required: ['name'],
|
|
145
|
+
}),
|
|
146
|
+
async (args) => (await reconnect()).getPrompt(args.name, args.arguments)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
definitions,
|
|
153
|
+
sessions,
|
|
154
|
+
errors,
|
|
155
|
+
resources: sessions.flatMap((s) => s.resources ?? []),
|
|
156
|
+
prompts: sessions.flatMap((s) => s.prompts ?? []),
|
|
157
|
+
handles: (name) => map.has(name),
|
|
158
|
+
call: async (name, args = {}) => {
|
|
159
|
+
const handler = map.get(name);
|
|
160
|
+
if (!handler) throw new Error(`Herramienta MCP desconocida: ${name}`);
|
|
161
|
+
return handler(args);
|
|
162
|
+
},
|
|
163
|
+
close: () => {
|
|
164
|
+
for (const session of sessions) session.close?.();
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// MCP (Model Context Protocol) client over the stdio transport (Node only).
|
|
2
|
+
//
|
|
3
|
+
// Spawns the server as a child process and speaks newline-delimited JSON-RPC
|
|
4
|
+
// 2.0 over its stdin/stdout.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import {
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
CLIENT_INFO,
|
|
10
|
+
mcpResultToText,
|
|
11
|
+
mcpResourceToText,
|
|
12
|
+
mcpPromptToText,
|
|
13
|
+
} from './common.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* On Windows, bare commands like `npx`/`uvx` are `.cmd` shims that require a
|
|
17
|
+
* shell. Absolute paths (node.exe, ...) must NOT use a shell or spaces break.
|
|
18
|
+
*/
|
|
19
|
+
function shouldUseShell(server, command) {
|
|
20
|
+
if (typeof server.shell === 'boolean') return server.shell;
|
|
21
|
+
if (process.platform !== 'win32') return false;
|
|
22
|
+
if (/[\\/]/.test(command)) return false;
|
|
23
|
+
// Ejecutables reales: sin shell (evita el warning DEP0190 de Node).
|
|
24
|
+
if (/^(node|nodejs|deno|bun|python3?|php)$/i.test(command)) return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Launch an MCP stdio server, initialize it and list its tools.
|
|
30
|
+
*
|
|
31
|
+
* @param {{id:string, name?:string, command:string, args?:string[], env?:object, cwd?:string, shell?:boolean}} server
|
|
32
|
+
* @param {{signal?:AbortSignal, timeout?:number}} [opts]
|
|
33
|
+
*/
|
|
34
|
+
export async function connectMcpStdioServer(server, { signal, timeout = 30000 } = {}) {
|
|
35
|
+
const command = server.command;
|
|
36
|
+
if (!command) throw new Error(`MCP stdio: falta "command" en el servidor ${server.id ?? ''}`);
|
|
37
|
+
|
|
38
|
+
const child = spawn(command, server.args ?? [], {
|
|
39
|
+
cwd: server.cwd,
|
|
40
|
+
env: { ...process.env, ...(server.env ?? {}) },
|
|
41
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
shell: shouldUseShell(server, command),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let buffer = '';
|
|
47
|
+
let closed = false;
|
|
48
|
+
let toolsStale = false;
|
|
49
|
+
let rpcId = 0;
|
|
50
|
+
const pending = new Map();
|
|
51
|
+
const stderrChunks = [];
|
|
52
|
+
|
|
53
|
+
const stderrTail = () => {
|
|
54
|
+
const text = stderrChunks.join('').trim();
|
|
55
|
+
return text ? `: ${text.split('\n').slice(-3).join(' ')}` : '';
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const failAll = (error) => {
|
|
59
|
+
for (const { reject, timer } of pending.values()) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
reject(error);
|
|
62
|
+
}
|
|
63
|
+
pending.clear();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
child.stdout.setEncoding('utf8');
|
|
67
|
+
child.stdout.on('data', (chunk) => {
|
|
68
|
+
buffer += chunk;
|
|
69
|
+
let index;
|
|
70
|
+
while ((index = buffer.indexOf('\n')) !== -1) {
|
|
71
|
+
const line = buffer.slice(0, index).trim();
|
|
72
|
+
buffer = buffer.slice(index + 1);
|
|
73
|
+
if (!line) continue;
|
|
74
|
+
let message;
|
|
75
|
+
try {
|
|
76
|
+
message = JSON.parse(line);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (message.id === undefined) {
|
|
81
|
+
if (message.method === 'notifications/tools/list_changed') toolsStale = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const entry = pending.get(message.id);
|
|
85
|
+
if (!entry) continue;
|
|
86
|
+
pending.delete(message.id);
|
|
87
|
+
clearTimeout(entry.timer);
|
|
88
|
+
if (message.error) entry.reject(new Error(message.error.message ?? 'MCP error'));
|
|
89
|
+
else entry.resolve(message.result);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
child.stderr.setEncoding('utf8');
|
|
94
|
+
child.stderr.on('data', (chunk) => {
|
|
95
|
+
stderrChunks.push(chunk);
|
|
96
|
+
if (stderrChunks.length > 50) stderrChunks.shift();
|
|
97
|
+
});
|
|
98
|
+
child.on('exit', (code) => {
|
|
99
|
+
closed = true;
|
|
100
|
+
failAll(new Error(`MCP stdio: el proceso de ${server.id ?? command} terminó (code ${code})${stderrTail()}`));
|
|
101
|
+
});
|
|
102
|
+
child.on('error', (error) => {
|
|
103
|
+
closed = true;
|
|
104
|
+
failAll(error);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function send(message) {
|
|
108
|
+
if (closed) throw new Error('MCP stdio: proceso cerrado');
|
|
109
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function request(method, params) {
|
|
113
|
+
const id = ++rpcId;
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
pending.delete(id);
|
|
117
|
+
reject(new Error(`MCP stdio: timeout en "${method}" (${timeout}ms)`));
|
|
118
|
+
}, timeout);
|
|
119
|
+
timer.unref?.();
|
|
120
|
+
pending.set(id, { resolve, reject, timer });
|
|
121
|
+
try {
|
|
122
|
+
send({ jsonrpc: '2.0', id, method, params });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
pending.delete(id);
|
|
126
|
+
reject(error);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function notify(method, params) {
|
|
132
|
+
send({ jsonrpc: '2.0', method, params });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function close() {
|
|
136
|
+
if (closed) return;
|
|
137
|
+
closed = true;
|
|
138
|
+
signal?.removeEventListener('abort', close);
|
|
139
|
+
failAll(new Error('MCP stdio: sesión cerrada'));
|
|
140
|
+
try {
|
|
141
|
+
child.stdin.end();
|
|
142
|
+
} catch {
|
|
143
|
+
// already closed
|
|
144
|
+
}
|
|
145
|
+
if (!child.killed) child.kill();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
signal?.addEventListener('abort', close, { once: true });
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const init = await request('initialize', {
|
|
152
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
153
|
+
capabilities: {},
|
|
154
|
+
clientInfo: CLIENT_INFO,
|
|
155
|
+
});
|
|
156
|
+
notify('notifications/initialized', {});
|
|
157
|
+
const capabilities = init?.capabilities ?? {};
|
|
158
|
+
const tools = (await request('tools/list', {}))?.tools ?? [];
|
|
159
|
+
|
|
160
|
+
let resources = [];
|
|
161
|
+
if (capabilities.resources) {
|
|
162
|
+
try {
|
|
163
|
+
resources = (await request('resources/list', {}))?.resources ?? [];
|
|
164
|
+
} catch {
|
|
165
|
+
resources = [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
let prompts = [];
|
|
169
|
+
if (capabilities.prompts) {
|
|
170
|
+
try {
|
|
171
|
+
prompts = (await request('prompts/list', {}))?.prompts ?? [];
|
|
172
|
+
} catch {
|
|
173
|
+
prompts = [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
server,
|
|
179
|
+
transport: 'stdio',
|
|
180
|
+
capabilities,
|
|
181
|
+
tools,
|
|
182
|
+
resources,
|
|
183
|
+
prompts,
|
|
184
|
+
stderr: () => stderrChunks.join(''),
|
|
185
|
+
alive: () => !closed,
|
|
186
|
+
async callTool(name, args) {
|
|
187
|
+
const result = await request('tools/call', { name, arguments: args ?? {} });
|
|
188
|
+
return mcpResultToText(result);
|
|
189
|
+
},
|
|
190
|
+
async refreshTools() {
|
|
191
|
+
if (!toolsStale) return tools;
|
|
192
|
+
toolsStale = false;
|
|
193
|
+
const fresh = (await request('tools/list', {}))?.tools;
|
|
194
|
+
if (Array.isArray(fresh)) {
|
|
195
|
+
tools.length = 0;
|
|
196
|
+
tools.push(...fresh);
|
|
197
|
+
}
|
|
198
|
+
return tools;
|
|
199
|
+
},
|
|
200
|
+
async listResources() {
|
|
201
|
+
return (await request('resources/list', {}))?.resources ?? resources;
|
|
202
|
+
},
|
|
203
|
+
async readResource(uri) {
|
|
204
|
+
return mcpResourceToText(await request('resources/read', { uri }));
|
|
205
|
+
},
|
|
206
|
+
async listPrompts() {
|
|
207
|
+
return (await request('prompts/list', {}))?.prompts ?? prompts;
|
|
208
|
+
},
|
|
209
|
+
async getPrompt(name, args) {
|
|
210
|
+
return mcpPromptToText(await request('prompts/get', { name, arguments: args ?? {} }));
|
|
211
|
+
},
|
|
212
|
+
close,
|
|
213
|
+
};
|
|
214
|
+
} catch (error) {
|
|
215
|
+
close();
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { openrouterProvider } from './openrouter.js';
|
|
2
|
+
import { openaiCompatibleProvider } from './openaiCompatible.js';
|
|
3
|
+
import { ollamaProvider } from './ollama.js';
|
|
4
|
+
import { OPENAI_COMPATIBLE_PRESETS } from './presets.js';
|
|
5
|
+
import { envValue } from '../env.js';
|
|
6
|
+
|
|
7
|
+
export const PROVIDERS = [
|
|
8
|
+
openrouterProvider,
|
|
9
|
+
...OPENAI_COMPATIBLE_PRESETS,
|
|
10
|
+
openaiCompatibleProvider,
|
|
11
|
+
ollamaProvider,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** Lightweight metadata for the settings UI. */
|
|
15
|
+
export const PROVIDER_META = PROVIDERS.map(({ id, label, defaultBaseUrl, requiresKey }) => ({
|
|
16
|
+
id,
|
|
17
|
+
label,
|
|
18
|
+
defaultBaseUrl,
|
|
19
|
+
requiresKey,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build an adapter from a user-defined custom provider
|
|
24
|
+
* ({ id, label, baseUrl, models: string[] }).
|
|
25
|
+
*/
|
|
26
|
+
export function createCustomProvider(def) {
|
|
27
|
+
return {
|
|
28
|
+
...openaiCompatibleProvider,
|
|
29
|
+
id: def.id,
|
|
30
|
+
label: def.label || def.baseUrl,
|
|
31
|
+
defaultBaseUrl: def.baseUrl,
|
|
32
|
+
requiresKey: true,
|
|
33
|
+
custom: true,
|
|
34
|
+
manualModels: def.models ?? [],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getProvider(id, customProviders = []) {
|
|
39
|
+
const builtIn = PROVIDERS.find((p) => p.id === id);
|
|
40
|
+
if (builtIn) return builtIn;
|
|
41
|
+
const def = customProviders.find((c) => c.id === id);
|
|
42
|
+
if (def) return createCustomProvider(def);
|
|
43
|
+
return openrouterProvider;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a provider adapter plus its runtime config, falling back to
|
|
48
|
+
* environment defaults (e.g. a bundled OpenRouter key).
|
|
49
|
+
*/
|
|
50
|
+
export function resolveProviderRuntime({
|
|
51
|
+
providerId = 'openrouter',
|
|
52
|
+
apiKey = '',
|
|
53
|
+
baseUrl = '',
|
|
54
|
+
providerApiKey,
|
|
55
|
+
providerBaseUrl,
|
|
56
|
+
customProviders = [],
|
|
57
|
+
siteUrl,
|
|
58
|
+
siteName,
|
|
59
|
+
} = {}) {
|
|
60
|
+
const provider = getProvider(providerId, customProviders);
|
|
61
|
+
const keyInput = apiKey || providerApiKey || '';
|
|
62
|
+
const baseUrlInput = baseUrl || providerBaseUrl || '';
|
|
63
|
+
const envKey =
|
|
64
|
+
provider.id === 'openrouter'
|
|
65
|
+
? envValue(['OPENROUTER_API_KEY', 'VITE_OPENROUTER_API_KEY'], '')
|
|
66
|
+
: '';
|
|
67
|
+
const resolvedKey = keyInput || envKey;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
provider,
|
|
71
|
+
config: {
|
|
72
|
+
apiKey: resolvedKey,
|
|
73
|
+
baseUrl: baseUrlInput || provider.defaultBaseUrl,
|
|
74
|
+
siteUrl: siteUrl ?? envValue(['SITE_URL', 'VITE_SITE_URL'], 'https://tamnora.com'),
|
|
75
|
+
siteName: siteName ?? envValue(['SITE_NAME', 'VITE_SITE_NAME'], 'Tamnora Chat'),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function manualModel(model) {
|
|
81
|
+
if (typeof model === 'string') return { id: model, label: model, vision: false };
|
|
82
|
+
const id = model.id ?? model.name ?? '';
|
|
83
|
+
return { id, label: model.label ?? id, vision: Boolean(model.vision) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function listProviderModels(settings = {}) {
|
|
87
|
+
const { provider, config } = resolveProviderRuntime(settings);
|
|
88
|
+
const manual = settings.providerModels?.[provider.id];
|
|
89
|
+
if (manual?.length) return manual.map(manualModel).filter((m) => m.id);
|
|
90
|
+
if (provider.manualModels?.length) {
|
|
91
|
+
return provider.manualModels.map(manualModel).filter((m) => m.id);
|
|
92
|
+
}
|
|
93
|
+
return provider.listModels(config);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Test a provider's connection by fetching its model list.
|
|
98
|
+
* If the endpoint is unavailable but a manual model list exists, it still
|
|
99
|
+
* reports success (the chat endpoint may work while /models does not).
|
|
100
|
+
*/
|
|
101
|
+
export async function testProviderConnection(settings = {}) {
|
|
102
|
+
const { provider, config } = resolveProviderRuntime(settings);
|
|
103
|
+
const manual = settings.providerModels?.[provider.id] ?? [];
|
|
104
|
+
try {
|
|
105
|
+
const models = await provider.listModels(config);
|
|
106
|
+
return { ok: true, count: models.length, source: 'api', models };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const message = error?.message ?? String(error);
|
|
109
|
+
if (manual.length) {
|
|
110
|
+
return { ok: true, count: manual.length, source: 'manual', note: message };
|
|
111
|
+
}
|
|
112
|
+
return { ok: false, error: message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function streamChat(settings, params) {
|
|
117
|
+
const { provider, config } = resolveProviderRuntime(settings);
|
|
118
|
+
return provider.streamChat(config, params);
|
|
119
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readNDJSON } from './stream.js';
|
|
2
|
+
import { joinUrl, providerError, sortModels } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const OLLAMA_BASE_URL = 'http://localhost:11434';
|
|
5
|
+
|
|
6
|
+
function toOllamaModel(model) {
|
|
7
|
+
const id = model.name ?? model.model ?? '';
|
|
8
|
+
const family = model.details?.family ?? '';
|
|
9
|
+
const vision = /vision|llava|bakllava|moondream/i.test(`${id} ${family}`);
|
|
10
|
+
return { id, label: id, vision, context: null };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ollama takes images as a sibling `images` array of base64 (no data URL
|
|
15
|
+
* prefix) rather than OpenAI-style `image_url` content parts. Normalize any
|
|
16
|
+
* OpenAI-shaped multimodal message so the same history works across providers.
|
|
17
|
+
*/
|
|
18
|
+
export function toOllamaMessages(messages = []) {
|
|
19
|
+
return messages.map((message) => {
|
|
20
|
+
if (!Array.isArray(message.content)) return message;
|
|
21
|
+
const texts = [];
|
|
22
|
+
const images = [];
|
|
23
|
+
for (const part of message.content) {
|
|
24
|
+
if (part?.type === 'text') texts.push(part.text ?? '');
|
|
25
|
+
else if (part?.type === 'image_url') {
|
|
26
|
+
const url = part.image_url?.url ?? '';
|
|
27
|
+
const comma = url.indexOf(',');
|
|
28
|
+
if (url.startsWith('data:') && comma !== -1) images.push(url.slice(comma + 1));
|
|
29
|
+
else if (url) images.push(url);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const out = { ...message, content: texts.join('\n') };
|
|
33
|
+
if (images.length) out.images = images;
|
|
34
|
+
return out;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ollama adapter (local models). Uses the native `/api/chat` and `/api/tags`
|
|
40
|
+
* endpoints, which stream NDJSON rather than SSE. No API key required.
|
|
41
|
+
*/
|
|
42
|
+
export const ollamaProvider = {
|
|
43
|
+
id: 'ollama',
|
|
44
|
+
label: 'Ollama (local)',
|
|
45
|
+
defaultBaseUrl: OLLAMA_BASE_URL,
|
|
46
|
+
requiresKey: false,
|
|
47
|
+
|
|
48
|
+
async listModels({ baseUrl }) {
|
|
49
|
+
const res = await fetch(joinUrl(baseUrl, '/api/tags'));
|
|
50
|
+
if (!res.ok) throw new Error(`No se pudo conectar con Ollama (HTTP ${res.status})`);
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
return sortModels((data.models ?? []).map(toOllamaModel).filter((m) => m.id));
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent }) {
|
|
56
|
+
const body = { model, messages: toOllamaMessages(messages), stream: true };
|
|
57
|
+
if (tools?.length) body.tools = tools;
|
|
58
|
+
|
|
59
|
+
const res = await fetch(joinUrl(baseUrl, '/api/chat'), {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
signal,
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
throw await providerError(res, {
|
|
68
|
+
providerLabel: 'Ollama',
|
|
69
|
+
model,
|
|
70
|
+
hasTools: Boolean(tools?.length),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return readNDJSON(res, { onEvent });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readOpenAISSE } from './stream.js';
|
|
2
|
+
import { joinUrl, authHeaders, providerError, normalizeModel, sortModels } from './utils.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic adapter for any OpenAI-compatible chat completions API
|
|
6
|
+
* (OpenAI, Groq, DeepSeek, Together, llama.cpp, vLLM, LM Studio, ...).
|
|
7
|
+
*/
|
|
8
|
+
export const openaiCompatibleProvider = {
|
|
9
|
+
id: 'openai-compatible',
|
|
10
|
+
label: 'OpenAI-compatible',
|
|
11
|
+
defaultBaseUrl: 'https://api.openai.com/v1',
|
|
12
|
+
requiresKey: true,
|
|
13
|
+
|
|
14
|
+
async listModels({ baseUrl, apiKey, headers }) {
|
|
15
|
+
const res = await fetch(joinUrl(baseUrl, '/models'), {
|
|
16
|
+
headers: authHeaders({ apiKey, headers }),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) throw new Error(`No se pudo listar modelos (HTTP ${res.status})`);
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
const list = data.data ?? data.models ?? [];
|
|
21
|
+
return sortModels(list.map(normalizeModel).filter((m) => m.id));
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent }) {
|
|
25
|
+
const body = { model, messages, stream: true };
|
|
26
|
+
if (tools?.length) body.tools = tools;
|
|
27
|
+
|
|
28
|
+
return requestStream({
|
|
29
|
+
url: joinUrl(baseUrl, '/chat/completions'),
|
|
30
|
+
headers: authHeaders({ apiKey, headers }),
|
|
31
|
+
providerLabel: 'OpenAI-compatible',
|
|
32
|
+
model,
|
|
33
|
+
body,
|
|
34
|
+
signal,
|
|
35
|
+
onEvent,
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export async function requestStream({ url, headers, providerLabel, model, body, signal, onEvent }) {
|
|
41
|
+
const res = await fetch(url, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
signal,
|
|
44
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
throw await providerError(res, {
|
|
50
|
+
providerLabel,
|
|
51
|
+
model,
|
|
52
|
+
hasTools: Boolean(body.tools?.length),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return readOpenAISSE(res, { onEvent });
|
|
57
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { requestStream } from './openaiCompatible.js';
|
|
2
|
+
import { joinUrl, normalizeModel, sortModels } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
5
|
+
|
|
6
|
+
function openrouterHeaders({ apiKey, siteUrl, siteName, headers = {} }) {
|
|
7
|
+
return {
|
|
8
|
+
Authorization: `Bearer ${apiKey}`,
|
|
9
|
+
'HTTP-Referer': siteUrl ?? 'https://tamnora.com',
|
|
10
|
+
'X-Title': siteName ?? 'Tamnora Chat',
|
|
11
|
+
...headers,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* OpenRouter adapter. Only exposes free models, matching the app's original
|
|
17
|
+
* behavior (`:free` suffix or zero prompt/completion pricing).
|
|
18
|
+
*/
|
|
19
|
+
export const openrouterProvider = {
|
|
20
|
+
id: 'openrouter',
|
|
21
|
+
label: 'OpenRouter',
|
|
22
|
+
defaultBaseUrl: OPENROUTER_BASE_URL,
|
|
23
|
+
requiresKey: true,
|
|
24
|
+
|
|
25
|
+
async listModels({ apiKey, siteUrl, siteName }) {
|
|
26
|
+
const res = await fetch(joinUrl(OPENROUTER_BASE_URL, '/models'), {
|
|
27
|
+
headers: openrouterHeaders({ apiKey, siteUrl, siteName }),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) throw new Error(`No se pudo listar modelos (HTTP ${res.status})`);
|
|
30
|
+
const { data } = await res.json();
|
|
31
|
+
return sortModels(
|
|
32
|
+
(data ?? [])
|
|
33
|
+
.filter(
|
|
34
|
+
(m) =>
|
|
35
|
+
m.id?.endsWith(':free') ||
|
|
36
|
+
(m.pricing?.prompt === '0' && m.pricing?.completion === '0')
|
|
37
|
+
)
|
|
38
|
+
.map(normalizeModel)
|
|
39
|
+
.filter((m) => m.id)
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async streamChat(ctx, { model, messages, tools, signal, onEvent }) {
|
|
44
|
+
const body = { model, messages, stream: true };
|
|
45
|
+
if (tools?.length) body.tools = tools;
|
|
46
|
+
|
|
47
|
+
return requestStream({
|
|
48
|
+
url: joinUrl(OPENROUTER_BASE_URL, '/chat/completions'),
|
|
49
|
+
headers: openrouterHeaders(ctx),
|
|
50
|
+
providerLabel: 'OpenRouter',
|
|
51
|
+
model,
|
|
52
|
+
body,
|
|
53
|
+
signal,
|
|
54
|
+
onEvent,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|