@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 +6 -0
- package/harness/packages/core/src/index.js +1 -0
- 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/test/openrouter.test.js +90 -0
- 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/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/package.json +1 -1
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
|
|
@@ -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,
|
|
@@ -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
|
|
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; }
|
|
@@ -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