@danieltmn/tamnora 0.23.1 → 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 CHANGED
@@ -5,6 +5,12 @@ 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
+
8
14
  ## [0.23.1] - 2026-10-28
9
15
 
10
16
  ### Cambios
@@ -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';
@@ -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,
@@ -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
+ });
@@ -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.1</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; }
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.23.1",
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",