@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/harness/packages/cli/bin/tamnora.mjs +4 -2
  3. package/harness/packages/cli/src/commands/bridge.js +3 -3
  4. package/harness/packages/cli/src/commands/update.js +13 -2
  5. package/harness/packages/cli/test/bin-warning.test.js +27 -0
  6. package/harness/packages/cli/test/tunnel.test.js +49 -1
  7. package/harness/packages/core/src/index.js +2 -1
  8. package/harness/packages/core/src/providers/index.js +12 -0
  9. package/harness/packages/core/src/providers/openrouter.js +6 -8
  10. package/harness/packages/core/src/providers/utils.js +10 -1
  11. package/harness/packages/core/src/storage/sqlite.js +12 -6
  12. package/harness/packages/core/src/tunnel.js +32 -9
  13. package/harness/packages/core/test/openrouter.test.js +90 -0
  14. package/harness/packages/server/bin/tamnora-server.mjs +4 -2
  15. package/harness/packages/server/public/app.js +4 -2
  16. package/harness/packages/server/public/chrome.js +11 -3
  17. package/harness/packages/server/public/index.html +36 -3
  18. package/harness/packages/server/public/models.js +144 -0
  19. package/harness/packages/server/public/modelsQuery.js +20 -0
  20. package/harness/packages/server/public/styles.css +50 -1
  21. package/harness/packages/server/src/lib/procs.js +4 -1
  22. package/harness/packages/server/src/routes/models.js +7 -2
  23. package/harness/packages/server/src/routes/providers.js +5 -0
  24. package/harness/packages/server/test/modelsQuery.test.js +57 -0
  25. package/harness/packages/server/test/procs-manager.test.js +17 -0
  26. package/harness/scripts/self-update.mjs +4 -1
  27. 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 (lo que no sea un
4
- // warning de Node se imprime igual).
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, resetTailscaleServe } from '../lib/tunnel.js';
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 resetTailscaleServe();
196
+ await stopTailscaleServe();
197
197
  stopped.push('tailscale serve');
198
198
  } catch (error) {
199
- console.log(`No pude ejecutar tailscale serve reset: ${error.message}`);
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 spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
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 = spawnSync(NPM, ['i', '-g', spec], { stdio: 'inherit', windowsHide: true, shell: IS_WIN });
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 { parseTunnelmoleUrls, parseTailscaleStatus, startTunnel, tailscalePreviewUrl } from '../src/lib/tunnel.js';
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
- resetTailscaleServe,
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. Only exposes free models, matching the app's original
18
- * behavior (`:free` suffix or zero prompt/completion pricing).
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
- return { id, label, vision, context };
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 columns = db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name);
149
- if (!columns.includes('mode')) db.exec('ALTER TABLE messages ADD COLUMN mode TEXT');
150
- if (!columns.includes('agent_id')) db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');
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
- child = spawn(bin, args, {
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: process.platform === 'win32',
81
- detached: process.platform !== 'win32',
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
- child = spawn('tailscale', args, {
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: () => runTailscale(['serve', 'reset']).catch(() => {}),
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
- /** Quita toda la configuracion de `tailscale serve` de este nodo. */
283
- export function resetTailscaleServe() {
284
- return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
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 (lo que no sea un
4
- // warning de Node se imprime igual).
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 models = data?.models ?? [];
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
- for (const model of models) {
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 && models.some((m) => m.id === 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.23.0</span></div>
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
- <select id="model" title="modelo"></select>
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
- <script type="module" src="app.js"></script>
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
- const child = spawn(bin, list, {
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
- return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",