@danieltmn/tamnora 0.23.0 → 0.24.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 +12 -0
- package/harness/packages/cli/bin/tamnora.mjs +4 -2
- package/harness/packages/cli/src/commands/bridge.js +3 -3
- package/harness/packages/cli/src/commands/update.js +13 -2
- package/harness/packages/cli/test/bin-warning.test.js +27 -0
- package/harness/packages/cli/test/tunnel.test.js +49 -1
- package/harness/packages/core/src/index.js +2 -1
- package/harness/packages/core/src/providers/index.js +12 -0
- package/harness/packages/core/src/providers/openrouter.js +6 -8
- package/harness/packages/core/src/providers/utils.js +10 -1
- package/harness/packages/core/src/storage/sqlite.js +12 -6
- package/harness/packages/core/src/tunnel.js +32 -9
- package/harness/packages/core/test/openrouter.test.js +90 -0
- package/harness/packages/server/bin/tamnora-server.mjs +4 -2
- package/harness/packages/server/public/app.js +4 -2
- package/harness/packages/server/public/chrome.js +11 -3
- package/harness/packages/server/public/index.html +36 -3
- package/harness/packages/server/public/models.js +144 -0
- package/harness/packages/server/public/modelsQuery.js +20 -0
- package/harness/packages/server/public/styles.css +50 -1
- package/harness/packages/server/src/lib/procs.js +4 -1
- package/harness/packages/server/src/routes/models.js +7 -2
- package/harness/packages/server/src/routes/providers.js +5 -0
- package/harness/packages/server/test/modelsQuery.test.js +57 -0
- package/harness/packages/server/test/procs-manager.test.js +17 -0
- package/harness/scripts/self-update.mjs +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.24.0] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- feat(modelos): catalogo completo de OpenRouter con busqueda y seleccion de modelos
|
|
13
|
+
|
|
14
|
+
## [0.23.1] - 2026-10-28
|
|
15
|
+
|
|
16
|
+
### Cambios
|
|
17
|
+
|
|
18
|
+
- fix(spawn): evita el warning DEP0190 y hace puntual el stop del bridge
|
|
19
|
+
|
|
8
20
|
## [0.23.0] - 2026-10-28
|
|
9
21
|
|
|
10
22
|
### Cambios
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// node:sqlite sigue siendo "experimental" en Node 22, que avisa aunque ande. El
|
|
3
|
-
// aviso no le aporta nada al usuario, asi que se filtra aca
|
|
4
|
-
//
|
|
3
|
+
// aviso no le aporta nada al usuario, asi que se filtra aca. Se quita el
|
|
4
|
+
// impresor default de Node para que lo que si se imprime (lo que no sea
|
|
5
|
+
// ExperimentalWarning) salga una sola vez, no dos.
|
|
6
|
+
process.removeAllListeners('warning');
|
|
5
7
|
process.on('warning', (warning) => {
|
|
6
8
|
if (warning.name !== 'ExperimentalWarning') console.warn(warning.stack ?? warning);
|
|
7
9
|
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
tailLines,
|
|
12
12
|
bridgeLogPath,
|
|
13
13
|
} from '../lib/daemon.js';
|
|
14
|
-
import { startTunnel, probeTailscale,
|
|
14
|
+
import { startTunnel, probeTailscale, stopTailscaleServe } from '../lib/tunnel.js';
|
|
15
15
|
import { qrTerminal } from '../lib/qr.js';
|
|
16
16
|
|
|
17
17
|
const OPTIONS = {
|
|
@@ -193,10 +193,10 @@ async function cmdStop() {
|
|
|
193
193
|
return 0;
|
|
194
194
|
}
|
|
195
195
|
try {
|
|
196
|
-
await
|
|
196
|
+
await stopTailscaleServe();
|
|
197
197
|
stopped.push('tailscale serve');
|
|
198
198
|
} catch (error) {
|
|
199
|
-
console.log(`No pude
|
|
199
|
+
console.log(`No pude detener tailscale serve: ${error.message}`);
|
|
200
200
|
}
|
|
201
201
|
// Mata el supervisor si quedo vivo.
|
|
202
202
|
if (state.pid && pidAlive(state.pid)) {
|
|
@@ -16,8 +16,15 @@ const OPTIONS = {
|
|
|
16
16
|
help: { type: 'boolean', short: 'h' },
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// Con `shell: true` (npm.cmd en Windows) Node concatena los args sin escapar
|
|
20
|
+
// (warning DEP0190): se manda un unico comando con args internos ya validados.
|
|
21
|
+
function npmSpawn(args, extra = {}) {
|
|
22
|
+
const [cmd, cmdArgs] = IS_WIN ? [[NPM, ...args].join(' '), []] : [NPM, args];
|
|
23
|
+
return spawnSync(cmd, cmdArgs, { windowsHide: true, shell: IS_WIN, timeout: 180000, ...extra });
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
function npm(args) {
|
|
20
|
-
return
|
|
27
|
+
return npmSpawn(args, { encoding: 'utf8' });
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/** Version del paquete instalado globalmente (la que resuelve `npm ls -g`). */
|
|
@@ -78,6 +85,10 @@ export async function updateCommand(argv) {
|
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
const tag = typeof values.tag === 'string' && values.tag ? values.tag : 'latest';
|
|
88
|
+
if (!/^[\w.-]+$/.test(tag)) {
|
|
89
|
+
console.error(`Tag invalido: ${tag}`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
81
92
|
const spec = `${PACKAGE}@${tag}`;
|
|
82
93
|
const version = readPackageVersion();
|
|
83
94
|
console.log(`Version actual: ${version}`);
|
|
@@ -115,7 +126,7 @@ export async function updateCommand(argv) {
|
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
console.log(`Actualizando a ${spec}...`);
|
|
118
|
-
const result =
|
|
129
|
+
const result = npmSpawn(['i', '-g', spec], { stdio: 'inherit' });
|
|
119
130
|
if (result.status !== 0) {
|
|
120
131
|
console.error(`La actualizacion fallo. Proba a mano: npm i -g ${spec}`);
|
|
121
132
|
if (wasRunning) restartServer(wasRunning);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const BIN = fileURLToPath(new URL('../bin/tamnora.mjs', import.meta.url));
|
|
10
|
+
|
|
11
|
+
// El bin filtra el ExperimentalWarning de node:sqlite, pero no debe duplicar el
|
|
12
|
+
// resto: sin quitar el impresor default de Node, cada warning salia dos veces.
|
|
13
|
+
// El probe difiere el warning para que salte con el handler del bin ya instalado.
|
|
14
|
+
test('el bin imprime cada warning una sola vez', async () => {
|
|
15
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-bin-'));
|
|
16
|
+
const probe = path.join(dir, 'probe.cjs');
|
|
17
|
+
await writeFile(probe, `setTimeout(() => process.emitWarning('aviso de prueba', 'CustomWarning'), 100);`);
|
|
18
|
+
const res = spawnSync(process.execPath, ['--require', probe, BIN, '--help'], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
timeout: 30000,
|
|
22
|
+
});
|
|
23
|
+
const out = `${res.stdout}\n${res.stderr}`;
|
|
24
|
+
const count = out.split('aviso de prueba').length - 1;
|
|
25
|
+
assert.equal(count, 1, `se esperaba 1 impresion del warning, hubo ${count}`);
|
|
26
|
+
assert.equal(res.status, 0);
|
|
27
|
+
});
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import {
|
|
5
|
+
parseTunnelmoleUrls,
|
|
6
|
+
parseTailscaleStatus,
|
|
7
|
+
startTunnel,
|
|
8
|
+
startTunnelmole,
|
|
9
|
+
stopTailscaleServe,
|
|
10
|
+
tailscalePreviewUrl,
|
|
11
|
+
} from '../src/lib/tunnel.js';
|
|
4
12
|
import { qrTerminal } from '../src/lib/qr.js';
|
|
5
13
|
|
|
14
|
+
function fakeChild(exitCode = 0) {
|
|
15
|
+
const child = new EventEmitter();
|
|
16
|
+
child.stdout = new EventEmitter();
|
|
17
|
+
child.stderr = new EventEmitter();
|
|
18
|
+
child.pid = 0;
|
|
19
|
+
child.killed = false;
|
|
20
|
+
setImmediate(() => child.emit('exit', exitCode));
|
|
21
|
+
return child;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
test('parseTunnelmoleUrls extracts the https URL', () => {
|
|
7
25
|
const out = parseTunnelmoleUrls('Listening at https://abc-123.tunnelmole.net (forwarding to 4517)');
|
|
8
26
|
assert.equal(out.https, 'https://abc-123.tunnelmole.net');
|
|
@@ -38,6 +56,36 @@ test('startTunnel con provider none no abre nada', async () => {
|
|
|
38
56
|
assert.equal(tun.url, '');
|
|
39
57
|
});
|
|
40
58
|
|
|
59
|
+
test('stopTailscaleServe apaga solo el handler 443 y lanza tailscale sin shell', async () => {
|
|
60
|
+
const calls = [];
|
|
61
|
+
const spawnImpl = (cmd, args, options) => {
|
|
62
|
+
calls.push({ cmd, args, options });
|
|
63
|
+
return fakeChild();
|
|
64
|
+
};
|
|
65
|
+
await stopTailscaleServe({ spawnImpl });
|
|
66
|
+
assert.equal(calls.length, 1);
|
|
67
|
+
assert.equal(calls[0].cmd, 'tailscale');
|
|
68
|
+
assert.deepEqual(calls[0].args, ['serve', '--https=443', 'off']);
|
|
69
|
+
assert.equal(calls[0].options.shell, undefined);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test(
|
|
73
|
+
'startTunnelmole con shell manda un unico comando (Node no concatena args)',
|
|
74
|
+
{ skip: process.platform !== 'win32' },
|
|
75
|
+
async () => {
|
|
76
|
+
const calls = [];
|
|
77
|
+
const spawnImpl = (cmd, args, options) => {
|
|
78
|
+
calls.push({ cmd, args, options });
|
|
79
|
+
return fakeChild(1);
|
|
80
|
+
};
|
|
81
|
+
await assert.rejects(() => startTunnelmole(4517, { spawnImpl }), /termino/);
|
|
82
|
+
assert.equal(calls.length, 1);
|
|
83
|
+
assert.equal(calls[0].cmd, 'npx --yes tunnelmole 4517');
|
|
84
|
+
assert.deepEqual(calls[0].args, []);
|
|
85
|
+
assert.equal(calls[0].options.shell, true);
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
|
|
41
89
|
test('tailscalePreviewUrl usa el 8443 y omite el 443', () => {
|
|
42
90
|
assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net'), 'https://pc.tailnet.ts.net:8443');
|
|
43
91
|
assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net', 9000), 'https://pc.tailnet.ts.net:9000');
|
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
createCustomProvider,
|
|
29
29
|
resolveProviderRuntime,
|
|
30
30
|
listProviderModels,
|
|
31
|
+
selectVisibleModels,
|
|
31
32
|
testProviderConnection,
|
|
32
33
|
streamChat,
|
|
33
34
|
} from './providers/index.js';
|
|
@@ -150,7 +151,7 @@ export {
|
|
|
150
151
|
parseTunnelmoleUrls,
|
|
151
152
|
parseTailscaleStatus,
|
|
152
153
|
probeTailscale,
|
|
153
|
-
|
|
154
|
+
stopTailscaleServe,
|
|
154
155
|
startTailscaleServe,
|
|
155
156
|
startTailscalePreview,
|
|
156
157
|
startTunnelmole,
|
|
@@ -83,6 +83,18 @@ function manualModel(model) {
|
|
|
83
83
|
return { id, label: model.label ?? id, vision: Boolean(model.vision) };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Acota un catalogo a la seleccion del usuario. `visible` vacio o ausente
|
|
88
|
+
* significa "sin seleccion": se muestra todo. Es solo de presentacion, no
|
|
89
|
+
* cambia la resolucion del provider.
|
|
90
|
+
*/
|
|
91
|
+
export function selectVisibleModels(models = [], visible = null) {
|
|
92
|
+
if (!Array.isArray(models) || !models.length) return [];
|
|
93
|
+
if (!Array.isArray(visible) || !visible.length) return models;
|
|
94
|
+
const wanted = new Set(visible.map(String));
|
|
95
|
+
return models.filter((m) => wanted.has(String(m?.id)));
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
export async function listProviderModels(settings = {}) {
|
|
87
99
|
const { provider, config } = resolveProviderRuntime(settings);
|
|
88
100
|
const manual = settings.providerModels?.[provider.id];
|
|
@@ -14,8 +14,10 @@ function openrouterHeaders({ apiKey, siteUrl, siteName, headers = {} }) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* OpenRouter adapter.
|
|
18
|
-
*
|
|
17
|
+
* OpenRouter adapter. Expone el catalogo completo (pagos y gratis), sin los
|
|
18
|
+
* alias `~familia-latest` ni las variantes `:batch` (mismo modelo, cola
|
|
19
|
+
* diferida), que son ruido de seleccion. Los gratis salen primero para que el
|
|
20
|
+
* modelo por defecto siga siendo uno sin costo.
|
|
19
21
|
*/
|
|
20
22
|
export const openrouterProvider = {
|
|
21
23
|
id: 'openrouter',
|
|
@@ -33,14 +35,10 @@ export const openrouterProvider = {
|
|
|
33
35
|
const { data } = await res.json();
|
|
34
36
|
return sortModels(
|
|
35
37
|
(data ?? [])
|
|
36
|
-
.filter(
|
|
37
|
-
(m) =>
|
|
38
|
-
m.id?.endsWith(':free') ||
|
|
39
|
-
(m.pricing?.prompt === '0' && m.pricing?.completion === '0')
|
|
40
|
-
)
|
|
38
|
+
.filter((m) => !m.id?.startsWith('~') && !m.id?.endsWith(':batch'))
|
|
41
39
|
.map(normalizeModel)
|
|
42
40
|
.filter((m) => m.id)
|
|
43
|
-
);
|
|
41
|
+
).sort((a, b) => Number(b.free) - Number(a.free));
|
|
44
42
|
},
|
|
45
43
|
|
|
46
44
|
async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
|
|
@@ -91,6 +91,12 @@ export async function providerError(response, { providerLabel, model, hasTools }
|
|
|
91
91
|
return new Error(`${providerLabel} error ${response.status}${suffix}`);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** USD por 1M de tokens, que es como se comparan los modelos. */
|
|
95
|
+
const perMillion = (value) => {
|
|
96
|
+
const n = Number(value);
|
|
97
|
+
return Number.isFinite(n) ? Math.round(n * 1e6 * 1e4) / 1e4 : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
94
100
|
export function normalizeModel(model) {
|
|
95
101
|
const id = model.id ?? model.name ?? model.model ?? '';
|
|
96
102
|
const label = model.name ?? model.label ?? model.model ?? id;
|
|
@@ -107,7 +113,10 @@ export function normalizeModel(model) {
|
|
|
107
113
|
model.context ??
|
|
108
114
|
0
|
|
109
115
|
) || null;
|
|
110
|
-
|
|
116
|
+
const prompt = perMillion(model.pricing?.prompt);
|
|
117
|
+
const completion = perMillion(model.pricing?.completion);
|
|
118
|
+
const free = id.endsWith(':free') || (prompt === 0 && completion === 0);
|
|
119
|
+
return { id, label, vision, context, free, pricing: { prompt, completion } };
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
export function sortModels(models) {
|
|
@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS providers (
|
|
|
105
105
|
base_url TEXT,
|
|
106
106
|
api_key TEXT,
|
|
107
107
|
models TEXT,
|
|
108
|
+
visible_models TEXT,
|
|
108
109
|
requires_key INTEGER,
|
|
109
110
|
custom INTEGER,
|
|
110
111
|
enabled INTEGER,
|
|
@@ -145,9 +146,12 @@ export function openDatabase(file) {
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
function migrate(db) {
|
|
148
|
-
const
|
|
149
|
-
if (!
|
|
150
|
-
if (!
|
|
149
|
+
const messages = db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name);
|
|
150
|
+
if (!messages.includes('mode')) db.exec('ALTER TABLE messages ADD COLUMN mode TEXT');
|
|
151
|
+
if (!messages.includes('agent_id')) db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');
|
|
152
|
+
|
|
153
|
+
const providers = db.prepare('PRAGMA table_info(providers)').all().map((c) => c.name);
|
|
154
|
+
if (!providers.includes('visible_models')) db.exec('ALTER TABLE providers ADD COLUMN visible_models TEXT');
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
const now = () => Date.now();
|
|
@@ -523,11 +527,11 @@ export class Store {
|
|
|
523
527
|
saveProvider(provider) {
|
|
524
528
|
this.db
|
|
525
529
|
.prepare(
|
|
526
|
-
`INSERT INTO providers (id, label, base_url, api_key, models, requires_key, custom, enabled, updated_at)
|
|
527
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
530
|
+
`INSERT INTO providers (id, label, base_url, api_key, models, visible_models, requires_key, custom, enabled, updated_at)
|
|
531
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
528
532
|
ON CONFLICT(id) DO UPDATE SET
|
|
529
533
|
label = excluded.label, base_url = excluded.base_url, api_key = excluded.api_key,
|
|
530
|
-
models = excluded.models, requires_key = excluded.requires_key,
|
|
534
|
+
models = excluded.models, visible_models = excluded.visible_models, requires_key = excluded.requires_key,
|
|
531
535
|
custom = excluded.custom, enabled = excluded.enabled, updated_at = excluded.updated_at`
|
|
532
536
|
)
|
|
533
537
|
.run(
|
|
@@ -536,6 +540,7 @@ export class Store {
|
|
|
536
540
|
provider.baseUrl ?? null,
|
|
537
541
|
provider.apiKey ?? null,
|
|
538
542
|
json(provider.models ?? []),
|
|
543
|
+
json(provider.visibleModels ?? []),
|
|
539
544
|
provider.requiresKey === undefined ? null : provider.requiresKey ? 1 : 0,
|
|
540
545
|
provider.custom ? 1 : 0,
|
|
541
546
|
provider.enabled === false ? 0 : 1,
|
|
@@ -676,6 +681,7 @@ function toProvider(row) {
|
|
|
676
681
|
baseUrl: row.base_url ?? '',
|
|
677
682
|
apiKey: row.api_key ?? '',
|
|
678
683
|
models: parseJson(row.models) ?? [],
|
|
684
|
+
visibleModels: parseJson(row.visible_models) ?? [],
|
|
679
685
|
requiresKey: row.requires_key === null ? undefined : Boolean(row.requires_key),
|
|
680
686
|
custom: Boolean(row.custom),
|
|
681
687
|
enabled: row.enabled === 0 ? false : true,
|
|
@@ -71,14 +71,19 @@ function spawnTunnel(bin, args, opts = {}) {
|
|
|
71
71
|
const parse = opts.parse || (() => ({ https: '', http: '' }));
|
|
72
72
|
const timeoutMs = opts.timeoutMs || 90000;
|
|
73
73
|
const label = opts.label || bin;
|
|
74
|
+
const useShell = process.platform === 'win32';
|
|
74
75
|
return new Promise((resolve, reject) => {
|
|
75
76
|
let child;
|
|
76
77
|
try {
|
|
77
|
-
|
|
78
|
+
// En Windows `npx` es un shim .cmd y exige shell; ahi se manda un unico
|
|
79
|
+
// comando (ver shellCommandLine): con array de args Node concatena sin
|
|
80
|
+
// escapar y emite el warning DEP0190.
|
|
81
|
+
const [cmd, cmdArgs] = useShell ? [shellCommandLine(bin, args), []] : [bin, args];
|
|
82
|
+
child = (opts.spawnImpl || spawn)(cmd, cmdArgs, {
|
|
78
83
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
79
84
|
windowsHide: true,
|
|
80
|
-
shell:
|
|
81
|
-
detached:
|
|
85
|
+
shell: useShell,
|
|
86
|
+
detached: !useShell,
|
|
82
87
|
});
|
|
83
88
|
} catch (err) {
|
|
84
89
|
reject(new Error(`no se pudo iniciar ${label}: ${err.message}`));
|
|
@@ -138,15 +143,29 @@ export function startTunnelmole(port, opts = {}) {
|
|
|
138
143
|
* tailnet reenviando a loopback, asi que no hay exposicion publica y las
|
|
139
144
|
* peticiones siguen siendo "locales" para el server. Proveedor recomendado.
|
|
140
145
|
*/
|
|
146
|
+
// Con `shell: true` Node concatena los args sin escapar (warning DEP0190): se
|
|
147
|
+
// arma un unico comando con args validados contra metacaracteres del shell.
|
|
148
|
+
const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
|
|
149
|
+
|
|
150
|
+
function shellCommandLine(bin, args) {
|
|
151
|
+
for (const arg of args) {
|
|
152
|
+
if (!SAFE_SHELL_ARG.test(String(arg))) {
|
|
153
|
+
throw new Error(`argumento no permitido para ${bin}: "${arg}"`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [bin, ...args].join(' ');
|
|
157
|
+
}
|
|
158
|
+
|
|
141
159
|
function runTailscale(args, opts = {}) {
|
|
142
160
|
const timeoutMs = opts.timeoutMs || 15000;
|
|
143
161
|
return new Promise((resolve, reject) => {
|
|
144
162
|
let child;
|
|
145
163
|
try {
|
|
146
|
-
|
|
164
|
+
// `tailscale` es un ejecutable real (.exe en Windows): resuelve por PATH
|
|
165
|
+
// sin shell, que ademas evita el warning DEP0190 de Node.
|
|
166
|
+
child = (opts.spawnImpl || spawn)('tailscale', args, {
|
|
147
167
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
148
168
|
windowsHide: true,
|
|
149
|
-
shell: process.platform === 'win32',
|
|
150
169
|
});
|
|
151
170
|
} catch (err) {
|
|
152
171
|
reject(err);
|
|
@@ -241,7 +260,7 @@ export async function startTailscaleServe(port, opts = {}) {
|
|
|
241
260
|
pid: 0,
|
|
242
261
|
child: null,
|
|
243
262
|
probeUrl: `${url}/api/health`,
|
|
244
|
-
stop: () =>
|
|
263
|
+
stop: () => stopTailscaleServe().catch(() => {}),
|
|
245
264
|
};
|
|
246
265
|
}
|
|
247
266
|
|
|
@@ -279,9 +298,13 @@ export async function probeTailscale() {
|
|
|
279
298
|
return parseTailscaleStatus(stdout);
|
|
280
299
|
}
|
|
281
300
|
|
|
282
|
-
/**
|
|
283
|
-
|
|
284
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Apaga el handler `serve` de la consola (https 443) de forma puntual: un
|
|
303
|
+
* `serve reset` borraria tambien los otros handlers (p.ej. el preview en 8443).
|
|
304
|
+
*/
|
|
305
|
+
export function stopTailscaleServe(opts = {}) {
|
|
306
|
+
const httpsPort = Number(opts.httpsPort) || 443;
|
|
307
|
+
return runTailscale(['serve', `--https=${httpsPort}`, 'off'], { timeoutMs: 30000, spawnImpl: opts.spawnImpl });
|
|
285
308
|
}
|
|
286
309
|
|
|
287
310
|
/**
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { openrouterProvider } from '../src/providers/openrouter.js';
|
|
4
|
+
import { selectVisibleModels } from '../src/providers/index.js';
|
|
5
|
+
|
|
6
|
+
// Catalogo con los casos que filtra el adapter: uno pago, dos gratis (por
|
|
7
|
+
// sufijo `:free` y por precio cero), un alias `~familia-latest` y un `:batch`.
|
|
8
|
+
const CATALOGA = {
|
|
9
|
+
data: [
|
|
10
|
+
{ id: 'prov/paid-model', name: 'Prov: Paid Model', pricing: { prompt: '0.000002', completion: '0.00001' } },
|
|
11
|
+
{ id: 'prov/free-suffix:free', name: 'Prov: Free Suffix', pricing: { prompt: '0.000002', completion: '0.00001' } },
|
|
12
|
+
{ id: 'prov/zero-price', name: 'Prov: Zero Price', pricing: { prompt: '0', completion: '0' } },
|
|
13
|
+
{ id: '~prov/alias-latest', name: 'Prov: Alias', pricing: { prompt: '0', completion: '0' } },
|
|
14
|
+
{ id: 'prov/paid-model:batch', name: 'Prov: Paid Batch', pricing: { prompt: '0', completion: '0' } },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function listarConFetchFalso() {
|
|
19
|
+
const original = globalThis.fetch;
|
|
20
|
+
globalThis.fetch = async () =>
|
|
21
|
+
new Response(JSON.stringify(CATALOGA), {
|
|
22
|
+
status: 200,
|
|
23
|
+
headers: { 'content-type': 'application/json' },
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
return await openrouterProvider.listModels({ apiKey: 'k' });
|
|
27
|
+
} finally {
|
|
28
|
+
globalThis.fetch = original;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('openrouter.listModels expone el catalogo completo (pagos y gratis)', async () => {
|
|
33
|
+
const models = await listarConFetchFalso();
|
|
34
|
+
const ids = models.map((m) => m.id);
|
|
35
|
+
assert.ok(ids.includes('prov/paid-model'), 'el modelo pago debe estar');
|
|
36
|
+
assert.ok(ids.includes('prov/free-suffix:free'), 'el gratis por sufijo debe estar');
|
|
37
|
+
assert.ok(ids.includes('prov/zero-price'), 'el gratis por precio cero debe estar');
|
|
38
|
+
assert.equal(models.length, 3);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('openrouter.listModels excluye alias ~ y variantes :batch', async () => {
|
|
42
|
+
const models = await listarConFetchFalso();
|
|
43
|
+
const ids = models.map((m) => m.id);
|
|
44
|
+
assert.ok(!ids.some((id) => id.startsWith('~')), 'sin alias ~');
|
|
45
|
+
assert.ok(!ids.some((id) => id.endsWith(':batch')), 'sin variantes :batch');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('openrouter.listModels ordena los gratis primero', async () => {
|
|
49
|
+
const models = await listarConFetchFalso();
|
|
50
|
+
const primerPago = models.findIndex((m) => !m.free);
|
|
51
|
+
const ultimoGratis = models.map((m) => m.free).lastIndexOf(true);
|
|
52
|
+
assert.notEqual(primerPago, -1, 'debe haber al menos un pago');
|
|
53
|
+
assert.ok(ultimoGratis < primerPago, 'todos los gratis antes que los pagos');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('normalizeModel marca free y convierte pricing a USD por 1M', async () => {
|
|
57
|
+
const models = await listarConFetchFalso();
|
|
58
|
+
const paid = models.find((m) => m.id === 'prov/paid-model');
|
|
59
|
+
const zero = models.find((m) => m.id === 'prov/zero-price');
|
|
60
|
+
const suffix = models.find((m) => m.id === 'prov/free-suffix:free');
|
|
61
|
+
|
|
62
|
+
assert.equal(paid.free, false);
|
|
63
|
+
assert.equal(paid.pricing.prompt, 2);
|
|
64
|
+
assert.equal(paid.pricing.completion, 10);
|
|
65
|
+
|
|
66
|
+
assert.equal(zero.free, true);
|
|
67
|
+
assert.equal(zero.pricing.prompt, 0);
|
|
68
|
+
// gratis por sufijo, aunque el precio no sea cero
|
|
69
|
+
assert.equal(suffix.free, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('selectVisibleModels sin seleccion devuelve todo', () => {
|
|
73
|
+
const models = [{ id: 'a' }, { id: 'b' }];
|
|
74
|
+
assert.deepEqual(selectVisibleModels(models, []), models);
|
|
75
|
+
assert.deepEqual(selectVisibleModels(models, null), models);
|
|
76
|
+
assert.deepEqual(selectVisibleModels(models, undefined), models);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('selectVisibleModels acota a los ids seleccionados', () => {
|
|
80
|
+
const models = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
|
81
|
+
assert.deepEqual(selectVisibleModels(models, ['a', 'c']).map((m) => m.id), ['a', 'c']);
|
|
82
|
+
// ids desconocidos se ignoran; no rompen
|
|
83
|
+
assert.deepEqual(selectVisibleModels(models, ['a', 'zzz']).map((m) => m.id), ['a']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('selectVisibleModels con catalogo vacio o no-array → []', () => {
|
|
87
|
+
assert.deepEqual(selectVisibleModels([], ['a']), []);
|
|
88
|
+
assert.deepEqual(selectVisibleModels(null, ['a']), []);
|
|
89
|
+
assert.deepEqual(selectVisibleModels(undefined, ['a']), []);
|
|
90
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// node:sqlite sigue siendo "experimental" en Node 22, que avisa aunque ande. El
|
|
3
|
-
// aviso no le aporta nada al usuario, asi que se filtra aca
|
|
4
|
-
//
|
|
3
|
+
// aviso no le aporta nada al usuario, asi que se filtra aca. Se quita el
|
|
4
|
+
// impresor default de Node para que lo que si se imprime (lo que no sea
|
|
5
|
+
// ExperimentalWarning) salga una sola vez, no dos.
|
|
6
|
+
process.removeAllListeners('warning');
|
|
5
7
|
process.on('warning', (warning) => {
|
|
6
8
|
if (warning.name !== 'ExperimentalWarning') console.warn(warning.stack ?? warning);
|
|
7
9
|
});
|
|
@@ -109,7 +109,8 @@ import {
|
|
|
109
109
|
setTunnelUi,
|
|
110
110
|
syncPreviewTunnel,
|
|
111
111
|
} from './explorer.js';
|
|
112
|
-
import { renderMarkdown } from './markdown.js';
|
|
112
|
+
import { renderMarkdown } from './markdown.js';
|
|
113
|
+
import { wireModelPicker } from './models.js';
|
|
113
114
|
import { restoreTabs, startNewSession } from './sessions.js';
|
|
114
115
|
import {
|
|
115
116
|
activateAjustesPanel,
|
|
@@ -795,7 +796,8 @@ if (brandVerEl) {
|
|
|
795
796
|
|
|
796
797
|
/* --- boot ---------------------------------------------------------------- */
|
|
797
798
|
|
|
798
|
-
async function boot() {
|
|
799
|
+
async function boot() {
|
|
800
|
+
wireModelPicker();
|
|
799
801
|
const auth = await api('/api/auth/me').catch(() => null);
|
|
800
802
|
if (auth?.enabled && !auth.authenticated) {
|
|
801
803
|
location.replace('/landing');
|
|
@@ -538,7 +538,10 @@ export async function loadModels({ save = false, prefer = null } = {}) {
|
|
|
538
538
|
models: [],
|
|
539
539
|
error: error.message,
|
|
540
540
|
}));
|
|
541
|
-
const
|
|
541
|
+
const catalog = data?.models ?? [];
|
|
542
|
+
const models = data?.visible ?? catalog;
|
|
543
|
+
state.modelCatalog = catalog;
|
|
544
|
+
state.visibleModels = data?.visibleModels ?? [];
|
|
542
545
|
clear(modelSelect);
|
|
543
546
|
|
|
544
547
|
if (!models.length) {
|
|
@@ -551,7 +554,12 @@ export async function loadModels({ save = false, prefer = null } = {}) {
|
|
|
551
554
|
return;
|
|
552
555
|
}
|
|
553
556
|
|
|
554
|
-
|
|
557
|
+
const activeModel = prefer || state.active.model;
|
|
558
|
+
// Si el modelo activo quedó fuera de la selección, lo mostramos igual para no
|
|
559
|
+
// cambiarlo en silencio: sigue siendo el usado aunque no esté tildado.
|
|
560
|
+
const hidden = activeModel && catalog.some((m) => m.id === activeModel) && !models.some((m) => m.id === activeModel);
|
|
561
|
+
const shown = hidden ? [catalog.find((m) => m.id === activeModel), ...models] : models;
|
|
562
|
+
for (const model of shown) {
|
|
555
563
|
const option = document.createElement('option');
|
|
556
564
|
option.value = model.id;
|
|
557
565
|
option.textContent = model.label && model.label !== model.id ? model.label : model.id;
|
|
@@ -560,7 +568,7 @@ export async function loadModels({ save = false, prefer = null } = {}) {
|
|
|
560
568
|
modelSelect.appendChild(option);
|
|
561
569
|
}
|
|
562
570
|
const wanted = prefer || state.active.model;
|
|
563
|
-
if (wanted &&
|
|
571
|
+
if (wanted && shown.some((m) => m.id === wanted)) {
|
|
564
572
|
modelSelect.value = wanted;
|
|
565
573
|
} else {
|
|
566
574
|
modelSelect.value = models[0].id;
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
</head>
|
|
76
76
|
<body>
|
|
77
77
|
<header class="topbar">
|
|
78
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.
|
|
78
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.0</span></div>
|
|
79
79
|
<div class="settings">
|
|
80
80
|
<button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
|
|
81
81
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
|
@@ -538,7 +538,10 @@
|
|
|
538
538
|
<label for="provider">Proveedor</label>
|
|
539
539
|
<select id="provider" title="proveedor"></select>
|
|
540
540
|
<label for="model">Modelo</label>
|
|
541
|
-
<
|
|
541
|
+
<div class="model-row">
|
|
542
|
+
<select id="model" title="modelo"></select>
|
|
543
|
+
<button type="button" id="model-pick" class="btn" title="elegir qué modelos mostrar">elegir</button>
|
|
544
|
+
</div>
|
|
542
545
|
<label class="check" title="aprobar automáticamente tools con permiso ask">
|
|
543
546
|
<input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
|
|
544
547
|
</label>
|
|
@@ -1228,6 +1231,36 @@
|
|
|
1228
1231
|
</div>
|
|
1229
1232
|
</dialog>
|
|
1230
1233
|
|
|
1231
|
-
<
|
|
1234
|
+
<dialog id="model-modal" class="modal model-modal">
|
|
1235
|
+
<div class="modal-card">
|
|
1236
|
+
<div class="row between">
|
|
1237
|
+
<h3>elegir modelos</h3>
|
|
1238
|
+
<button type="button" id="model-close" class="btn icon" title="cerrar" aria-label="cerrar">
|
|
1239
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1240
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
1241
|
+
</svg>
|
|
1242
|
+
</button>
|
|
1243
|
+
</div>
|
|
1244
|
+
<div class="model-search">
|
|
1245
|
+
<input id="model-query" aria-label="buscar modelos" type="text" placeholder="buscar por nombre o id…" spellcheck="false" autocomplete="off" />
|
|
1246
|
+
</div>
|
|
1247
|
+
<div class="model-actions">
|
|
1248
|
+
<button type="button" id="model-all" class="btn">todos</button>
|
|
1249
|
+
<button type="button" id="model-none" class="btn">ninguno</button>
|
|
1250
|
+
<button type="button" id="model-free" class="btn">solo gratis</button>
|
|
1251
|
+
<span class="spacer"></span>
|
|
1252
|
+
<span id="model-count" class="summary"></span>
|
|
1253
|
+
</div>
|
|
1254
|
+
<ul id="model-list" class="model-list"></ul>
|
|
1255
|
+
<div class="row between settings-actions">
|
|
1256
|
+
<span id="model-status" class="summary"></span>
|
|
1257
|
+
<span class="spacer"></span>
|
|
1258
|
+
<button type="button" id="model-cancel" class="btn">cancelar</button>
|
|
1259
|
+
<button type="button" id="model-save" class="btn primary">guardar</button>
|
|
1260
|
+
</div>
|
|
1261
|
+
</div>
|
|
1262
|
+
</dialog>
|
|
1263
|
+
|
|
1264
|
+
<script type="module" src="app.js"></script>
|
|
1232
1265
|
</body>
|
|
1233
1266
|
</html>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Selector de modelos: buscador + seleccion de qué modelos mostrar/usar.
|
|
2
|
+
// La logica de busqueda vive en `modelsQuery.js` (pura, testeada); esto es el
|
|
3
|
+
// DOM del modal `#model-modal`. La seleccion se guarda como `visibleModels` del
|
|
4
|
+
// proveedor: vacio = "mostrar todo"; un subconjunto = "mostrar estos".
|
|
5
|
+
import { $, api, clear, el, state } from './core.js';
|
|
6
|
+
import { loadModels, providerSelect } from './chrome.js';
|
|
7
|
+
import { filterModels } from './modelsQuery.js';
|
|
8
|
+
|
|
9
|
+
const picker = { catalog: [], checked: new Set(), query: '' };
|
|
10
|
+
|
|
11
|
+
function fmtPrice(n) {
|
|
12
|
+
return n == null ? '?' : Number.isInteger(n) ? String(n) : n.toFixed(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function priceEl(model) {
|
|
16
|
+
if (model.free) return el('span', 'model-item-free', 'gratis');
|
|
17
|
+
const p = model.pricing;
|
|
18
|
+
if (!p || (p.prompt == null && p.completion == null)) return el('span', 'model-item-price', '');
|
|
19
|
+
return el('span', 'model-item-price', `$${fmtPrice(p.prompt)}/$${fmtPrice(p.completion)}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkedCount() {
|
|
23
|
+
return picker.catalog.filter((m) => picker.checked.has(m.id)).length;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function updateModelCount() {
|
|
27
|
+
const count = $('#model-count');
|
|
28
|
+
if (count) count.textContent = `${checkedCount()} / ${picker.catalog.length} seleccionados`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setModelStatus(text, cls = '') {
|
|
32
|
+
const status = $('#model-status');
|
|
33
|
+
if (!status) return;
|
|
34
|
+
status.textContent = text;
|
|
35
|
+
status.className = `summary${cls ? ` ${cls}` : ''}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderModelPicker() {
|
|
39
|
+
const list = $('#model-list');
|
|
40
|
+
if (!list) return;
|
|
41
|
+
clear(list);
|
|
42
|
+
const shown = filterModels(picker.query, picker.catalog);
|
|
43
|
+
|
|
44
|
+
if (!shown.length) {
|
|
45
|
+
list.appendChild(el('li', 'model-empty', picker.catalog.length ? 'sin coincidencias' : 'sin modelos'));
|
|
46
|
+
updateModelCount();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const model of shown) {
|
|
51
|
+
const li = el('li');
|
|
52
|
+
const label = el('label', 'model-item');
|
|
53
|
+
const cb = el('input');
|
|
54
|
+
cb.type = 'checkbox';
|
|
55
|
+
cb.checked = picker.checked.has(model.id);
|
|
56
|
+
cb.addEventListener('change', () => {
|
|
57
|
+
if (cb.checked) picker.checked.add(model.id);
|
|
58
|
+
else picker.checked.delete(model.id);
|
|
59
|
+
updateModelCount();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
label.appendChild(cb);
|
|
63
|
+
label.appendChild(
|
|
64
|
+
el('span', 'model-item-name', model.label && model.label !== model.id ? model.label : model.id)
|
|
65
|
+
);
|
|
66
|
+
label.appendChild(el('span', 'model-item-id', model.id));
|
|
67
|
+
label.appendChild(priceEl(model));
|
|
68
|
+
li.appendChild(label);
|
|
69
|
+
list.appendChild(li);
|
|
70
|
+
}
|
|
71
|
+
updateModelCount();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setDisplayed(predicate) {
|
|
75
|
+
for (const m of filterModels(picker.query, picker.catalog)) {
|
|
76
|
+
if (predicate(m)) picker.checked.add(m.id);
|
|
77
|
+
else picker.checked.delete(m.id);
|
|
78
|
+
}
|
|
79
|
+
renderModelPicker();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function openModelPicker() {
|
|
83
|
+
const provider = providerSelect.value;
|
|
84
|
+
let catalog = state.modelCatalog;
|
|
85
|
+
if (!Array.isArray(catalog) || !catalog.length) {
|
|
86
|
+
const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch(() => null);
|
|
87
|
+
catalog = data?.models ?? [];
|
|
88
|
+
state.modelCatalog = catalog;
|
|
89
|
+
state.visibleModels = data?.visibleModels ?? [];
|
|
90
|
+
}
|
|
91
|
+
picker.catalog = catalog;
|
|
92
|
+
|
|
93
|
+
// Sin seleccion guardada = "mostrar todo": todo tildado. Con seleccion, esa.
|
|
94
|
+
const selected = Array.isArray(state.visibleModels) ? state.visibleModels : [];
|
|
95
|
+
picker.checked = new Set(selected.length ? selected : catalog.map((m) => m.id));
|
|
96
|
+
picker.query = '';
|
|
97
|
+
|
|
98
|
+
const queryInput = $('#model-query');
|
|
99
|
+
if (queryInput) queryInput.value = '';
|
|
100
|
+
setModelStatus('');
|
|
101
|
+
renderModelPicker();
|
|
102
|
+
$('#model-modal')?.showModal();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function closeModelPicker() {
|
|
106
|
+
$('#model-modal')?.close();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function saveModelPicker() {
|
|
110
|
+
const provider = providerSelect.value;
|
|
111
|
+
const catalog = picker.catalog;
|
|
112
|
+
const checkedIds = catalog.filter((m) => picker.checked.has(m.id)).map((m) => m.id);
|
|
113
|
+
if (!checkedIds.length) {
|
|
114
|
+
setModelStatus('marcá al menos un modelo', 'err');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Todo el catálogo tildado se guarda como [] = "mostrar todo" (sin filtro).
|
|
118
|
+
const visibleModels = checkedIds.length === catalog.length ? [] : checkedIds;
|
|
119
|
+
setModelStatus('guardando…');
|
|
120
|
+
try {
|
|
121
|
+
await api(`/api/providers/${encodeURIComponent(provider)}`, { method: 'PUT', body: { visibleModels } });
|
|
122
|
+
setModelStatus('guardado', 'ok');
|
|
123
|
+
$('#model-modal')?.close();
|
|
124
|
+
await loadModels();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
setModelStatus(error?.message ?? 'no se pudo guardar', 'err');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function wireModelPicker() {
|
|
131
|
+
$('#model-pick')?.addEventListener('click', () => {
|
|
132
|
+
openModelPicker();
|
|
133
|
+
});
|
|
134
|
+
$('#model-close')?.addEventListener('click', closeModelPicker);
|
|
135
|
+
$('#model-cancel')?.addEventListener('click', closeModelPicker);
|
|
136
|
+
$('#model-save')?.addEventListener('click', saveModelPicker);
|
|
137
|
+
$('#model-query')?.addEventListener('input', (event) => {
|
|
138
|
+
picker.query = event.target.value;
|
|
139
|
+
renderModelPicker();
|
|
140
|
+
});
|
|
141
|
+
$('#model-all')?.addEventListener('click', () => setDisplayed(() => true));
|
|
142
|
+
$('#model-none')?.addEventListener('click', () => setDisplayed(() => false));
|
|
143
|
+
$('#model-free')?.addEventListener('click', () => setDisplayed((m) => Boolean(m.free)));
|
|
144
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Busqueda de modelos: logica pura, sin DOM, para poder testearla en Node.
|
|
2
|
+
// Vive separada del picker (`models.js`) porque ese importa `chrome.js`, que
|
|
3
|
+
// toca el DOM al cargarse; este modulo no importa nada.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ¿El modelo coincide con la consulta? La consulta se parte en términos y
|
|
7
|
+
* todos deben aparecer (AND) en el id o el label, sin distinguir mayúsculas.
|
|
8
|
+
* Vacío coincide con todo.
|
|
9
|
+
*/
|
|
10
|
+
export function matchModel(query, model) {
|
|
11
|
+
const q = String(query ?? '').trim().toLowerCase();
|
|
12
|
+
if (!q) return true;
|
|
13
|
+
const hay = `${model?.id ?? ''} ${model?.label ?? ''}`.toLowerCase();
|
|
14
|
+
return q.split(/\s+/).every((term) => hay.includes(term));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Filtra un catálogo por consulta. `models` ausente o no-array → []. */
|
|
18
|
+
export function filterModels(query, models) {
|
|
19
|
+
return Array.isArray(models) ? models.filter((m) => matchModel(query, m)) : [];
|
|
20
|
+
}
|
|
@@ -2426,7 +2426,56 @@ dialog.modal {
|
|
|
2426
2426
|
dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: blur(3px); }
|
|
2427
2427
|
.modal-card { display: flex; flex-direction: column; gap: 12px; padding: 20px; }
|
|
2428
2428
|
.modal-card h3 { margin: 0; font: 700 17px var(--serif); letter-spacing: -0.02em; }
|
|
2429
|
-
.confirm-modal .modal-card { max-width: min(420px, 92vw); }
|
|
2429
|
+
.confirm-modal .modal-card { max-width: min(420px, 92vw); }
|
|
2430
|
+
|
|
2431
|
+
/* --- selector de modelos (busqueda + seleccion) -------------------------- */
|
|
2432
|
+
.model-row { display: flex; align-items: center; gap: 6px; }
|
|
2433
|
+
.model-row select { flex: 1; min-width: 0; }
|
|
2434
|
+
.model-modal { width: min(680px, calc(100vw - 40px)); }
|
|
2435
|
+
.model-search input {
|
|
2436
|
+
width: 100%;
|
|
2437
|
+
padding: 9px 11px;
|
|
2438
|
+
border: 1px solid var(--border);
|
|
2439
|
+
border-radius: 9px;
|
|
2440
|
+
background: var(--theirs);
|
|
2441
|
+
color: var(--text);
|
|
2442
|
+
font: 500 13px var(--sans);
|
|
2443
|
+
}
|
|
2444
|
+
.model-actions { display: flex; align-items: center; gap: 7px; }
|
|
2445
|
+
.model-list {
|
|
2446
|
+
list-style: none;
|
|
2447
|
+
margin: 0;
|
|
2448
|
+
padding: 2px;
|
|
2449
|
+
display: flex;
|
|
2450
|
+
flex-direction: column;
|
|
2451
|
+
gap: 3px;
|
|
2452
|
+
max-height: min(52vh, 420px);
|
|
2453
|
+
overflow-y: auto;
|
|
2454
|
+
border: 1px solid var(--border);
|
|
2455
|
+
border-radius: 9px;
|
|
2456
|
+
background: var(--panel);
|
|
2457
|
+
}
|
|
2458
|
+
.model-item {
|
|
2459
|
+
display: flex;
|
|
2460
|
+
align-items: center;
|
|
2461
|
+
gap: 9px;
|
|
2462
|
+
padding: 7px 9px;
|
|
2463
|
+
border-radius: 7px;
|
|
2464
|
+
cursor: pointer;
|
|
2465
|
+
}
|
|
2466
|
+
.model-item:hover { background: var(--theirs); }
|
|
2467
|
+
.model-item input { flex: 0 0 auto; }
|
|
2468
|
+
.model-item-name {
|
|
2469
|
+
flex: 1;
|
|
2470
|
+
min-width: 0;
|
|
2471
|
+
overflow: hidden;
|
|
2472
|
+
text-overflow: ellipsis;
|
|
2473
|
+
white-space: nowrap;
|
|
2474
|
+
}
|
|
2475
|
+
.model-item-id { color: var(--muted); font: 500 11px var(--mono); flex: 0 0 auto; }
|
|
2476
|
+
.model-item-price { color: var(--muted); font: 500 11px var(--mono); flex: 0 0 auto; }
|
|
2477
|
+
.model-item-free { color: var(--ok); font: 600 10px var(--sans); flex: 0 0 auto; }
|
|
2478
|
+
.model-empty { padding: 16px; color: var(--muted); text-align: center; }
|
|
2430
2479
|
.confirm-text { margin: 0; color: var(--muted, inherit); line-height: 1.5; }
|
|
2431
2480
|
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
2432
2481
|
.row .spacer { flex: 1; }
|
|
@@ -76,7 +76,10 @@ export function createProcManager({ allow } = {}) {
|
|
|
76
76
|
throw new Error(`Argumento no permitido con shell en Windows: "${bad}".`);
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
|
|
79
|
+
// Con `shell: true` y array de args Node concatena sin escapar (warning
|
|
80
|
+
// DEP0190): se manda un unico comando (los args ya pasaron SAFE_SHELL_ARG).
|
|
81
|
+
const [cmd, cmdArgs] = shell ? [`${bin} ${list.join(' ')}`, []] : [bin, list];
|
|
82
|
+
const child = spawn(cmd, cmdArgs, {
|
|
80
83
|
cwd,
|
|
81
84
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
85
|
windowsHide: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listProviderModels } from '../lib/core.js';
|
|
1
|
+
import { listProviderModels, selectVisibleModels } from '../lib/core.js';
|
|
2
2
|
import { sendJson } from '../lib/http.js';
|
|
3
3
|
import { createModelCache } from '../lib/modelCache.js';
|
|
4
4
|
|
|
@@ -13,14 +13,19 @@ export function registerModels(route, ctx) {
|
|
|
13
13
|
|
|
14
14
|
try {
|
|
15
15
|
const { models, cached, stale } = await modelCache.get(key, () => listProviderModels(settings));
|
|
16
|
+
// `models` es el catalogo completo (para buscar); `visible` es la seleccion
|
|
17
|
+
// del usuario para el selector. Sin seleccion, `visible` = todo.
|
|
18
|
+
const visibleModels = ctx.store.getProvider(providerId)?.visibleModels ?? [];
|
|
16
19
|
sendJson(res, 200, {
|
|
17
20
|
provider: providerId,
|
|
18
21
|
models,
|
|
22
|
+
visible: selectVisibleModels(models, visibleModels),
|
|
23
|
+
visibleModels,
|
|
19
24
|
cached: Boolean(cached),
|
|
20
25
|
...(stale ? { stale } : {}),
|
|
21
26
|
});
|
|
22
27
|
} catch (error) {
|
|
23
|
-
sendJson(res, 200, { provider: providerId, models: [], error: error?.message ?? String(error) });
|
|
28
|
+
sendJson(res, 200, { provider: providerId, models: [], visible: [], error: error?.message ?? String(error) });
|
|
24
29
|
}
|
|
25
30
|
});
|
|
26
31
|
}
|
|
@@ -32,6 +32,7 @@ export function providerList(ctx) {
|
|
|
32
32
|
hasKey: Boolean(row?.apiKey),
|
|
33
33
|
apiKeyHint: maskSecret(row?.apiKey),
|
|
34
34
|
models: row?.models ?? [],
|
|
35
|
+
visibleModels: row?.visibleModels ?? [],
|
|
35
36
|
});
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -49,6 +50,7 @@ export function providerList(ctx) {
|
|
|
49
50
|
hasKey: Boolean(row.apiKey),
|
|
50
51
|
apiKeyHint: maskSecret(row.apiKey),
|
|
51
52
|
models: row.models,
|
|
53
|
+
visibleModels: row.visibleModels ?? [],
|
|
52
54
|
});
|
|
53
55
|
}
|
|
54
56
|
}
|
|
@@ -140,6 +142,9 @@ export function registerProviders(route, ctx) {
|
|
|
140
142
|
models: Array.isArray(body.models)
|
|
141
143
|
? body.models.map((m) => String(m).trim()).filter(Boolean)
|
|
142
144
|
: existing.models ?? [],
|
|
145
|
+
visibleModels: Array.isArray(body.visibleModels)
|
|
146
|
+
? body.visibleModels.map((m) => String(m).trim()).filter(Boolean)
|
|
147
|
+
: existing.visibleModels ?? [],
|
|
143
148
|
requiresKey: body.requiresKey ?? existing.requiresKey ?? builtin?.requiresKey,
|
|
144
149
|
custom: builtin ? Boolean(body.custom ?? existing.custom ?? false) : true,
|
|
145
150
|
enabled: body.enabled ?? existing.enabled ?? true,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { matchModel, filterModels } from '../public/modelsQuery.js';
|
|
4
|
+
|
|
5
|
+
const CATALOG = [
|
|
6
|
+
{ id: 'z-ai/glm-5.3', label: 'Z.ai: GLM 5.3' },
|
|
7
|
+
{ id: 'z-ai/glm-5.3-flash', label: 'Z.ai: GLM 5.3 Flash' },
|
|
8
|
+
{ id: 'deepseek/deepseek-v4.1-flash-1017', label: 'DeepSeek: DeepSeek V4.1 Flash 1017' },
|
|
9
|
+
{ id: 'anthropic/claude-haiku-5.5', label: 'Anthropic: Claude Haiku 5.5' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
test('matchModel: la consulta vacía coincide con todo', () => {
|
|
13
|
+
for (const model of CATALOG) {
|
|
14
|
+
assert.equal(matchModel('', model), true);
|
|
15
|
+
assert.equal(matchModel(' ', model), true);
|
|
16
|
+
assert.equal(matchModel(undefined, model), true);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('matchModel: busca por id y por label, sin distinguir mayúsculas', () => {
|
|
21
|
+
assert.equal(matchModel('glm', CATALOG[0]), true);
|
|
22
|
+
assert.equal(matchModel('GLM', CATALOG[0]), true);
|
|
23
|
+
assert.equal(matchModel('z.ai', CATALOG[0]), true);
|
|
24
|
+
assert.equal(matchModel('deepseek v4', CATALOG[2]), true);
|
|
25
|
+
assert.equal(matchModel('haiku', CATALOG[3]), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('matchModel: todos los términos deben aparecer (AND)', () => {
|
|
29
|
+
assert.equal(matchModel('glm 5.3 flash', CATALOG[1]), true);
|
|
30
|
+
assert.equal(matchModel('glm flash', CATALOG[1]), true);
|
|
31
|
+
// "flash" no está en el label del glm-5.3 base
|
|
32
|
+
assert.equal(matchModel('glm flash', CATALOG[0]), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('matchModel: sin coincidencia devuelve false', () => {
|
|
36
|
+
assert.equal(matchModel('gpt', CATALOG[0]), false);
|
|
37
|
+
assert.equal(matchModel('no-existe', CATALOG[2]), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('matchModel: tolera modelos sin id ni label', () => {
|
|
41
|
+
assert.equal(matchModel('', {}), true);
|
|
42
|
+
assert.equal(matchModel('algo', {}), false);
|
|
43
|
+
assert.equal(matchModel('algo', null), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('filterModels: filtra el catálogo por consulta', () => {
|
|
47
|
+
assert.equal(filterModels('glm', CATALOG).length, 2);
|
|
48
|
+
assert.equal(filterModels('flash', CATALOG).length, 2);
|
|
49
|
+
assert.equal(filterModels('deepseek', CATALOG).length, 1);
|
|
50
|
+
assert.equal(filterModels('', CATALOG).length, 4);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('filterModels: no-array o ausente → []', () => {
|
|
54
|
+
assert.deepEqual(filterModels('x', null), []);
|
|
55
|
+
assert.deepEqual(filterModels('x', undefined), []);
|
|
56
|
+
assert.deepEqual(filterModels('x', 'no soy una lista'), []);
|
|
57
|
+
});
|
|
@@ -29,6 +29,23 @@ test('en Windows los args no admiten metacaracteres del shell', { skip: process.
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
test('un comando bare con args no dispara el warning DEP0190 de Node', { skip: process.platform !== 'win32' }, async () => {
|
|
33
|
+
const cwd = await workspace();
|
|
34
|
+
const procs = createProcManager({ allow: ['node'] });
|
|
35
|
+
const warnings = [];
|
|
36
|
+
const onWarn = (warning) => warnings.push(warning);
|
|
37
|
+
process.on('warning', onWarn);
|
|
38
|
+
let proc;
|
|
39
|
+
try {
|
|
40
|
+
proc = procs.start({ cwd, command: 'node', args: ['-e', 'process.exit(0)'] });
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
42
|
+
} finally {
|
|
43
|
+
process.removeListener('warning', onWarn);
|
|
44
|
+
if (proc) procs.stop(proc.id);
|
|
45
|
+
}
|
|
46
|
+
assert.deepEqual(warnings.filter((w) => w.code === 'DEP0190'), []);
|
|
47
|
+
});
|
|
48
|
+
|
|
32
49
|
test('procs arranca y detiene un proceso permitido', async () => {
|
|
33
50
|
const cwd = await workspace();
|
|
34
51
|
const procs = createProcManager({ allow: ['node'] });
|
|
@@ -77,8 +77,11 @@ async function waitDead(p, ms = 15000) {
|
|
|
77
77
|
return true;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Con `shell: true` (npm.cmd en Windows) Node concatena los args sin escapar
|
|
81
|
+
// (warning DEP0190): se manda un unico comando con args internos.
|
|
80
82
|
function runNpm(args) {
|
|
81
|
-
|
|
83
|
+
const [cmd, cmdArgs] = IS_WIN ? [[NPM, ...args].join(' '), []] : [NPM, args];
|
|
84
|
+
return spawnSync(cmd, cmdArgs, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
async function main() {
|
package/package.json
CHANGED