heartcode 0.1.14 → 0.1.15
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 +11 -0
- package/package.json +1 -1
- package/src/settings.html +152 -17
- package/src/settings.js +33 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in each version of HeartCode, newest first.
|
|
4
4
|
|
|
5
|
+
## 0.1.15 (2026-10-30)
|
|
6
|
+
|
|
7
|
+
- Settings page, Providers & models: Test on each model sends it "Hello" and shows what was sent
|
|
8
|
+
and what came back. Tick several and Test them all at once (a provider's one after another, for
|
|
9
|
+
its rate limit); then keep ticked only the ones that answered.
|
|
10
|
+
- Free only: just the models a provider's list prices at $0, marked free; sort by price puts them first.
|
|
11
|
+
- Prices read right: Airforce's were 100 times too high (its list is in cents), Cloudflare's are read
|
|
12
|
+
now. Model lists saved before are read again.
|
|
13
|
+
- Each provider has a box to add a model by its name; one it has already, or one in your list, is
|
|
14
|
+
said and shown instead.
|
|
15
|
+
|
|
5
16
|
## 0.1.14 (2026-10-30)
|
|
6
17
|
|
|
7
18
|
- Settings page, Providers & models: one full-width list. Tick models (or a whole provider, or
|
package/package.json
CHANGED
package/src/settings.html
CHANGED
|
@@ -155,8 +155,9 @@
|
|
|
155
155
|
.cat-top input[type=search] { flex: 1; min-width: 140px; }
|
|
156
156
|
.cat-top select { flex: none; width: auto; padding-right: 30px; }
|
|
157
157
|
.cat-top button { flex: none; }
|
|
158
|
+
.cat-top .free-only { display: flex; align-items: center; gap: 7px; flex: none; font-size: 13px; color: var(--muted); cursor: pointer; white-space: nowrap; }
|
|
158
159
|
.cat-top button.ghost:hover:not(:disabled) { color: var(--text); border-color: transparent; }
|
|
159
|
-
.cat-top input[type=checkbox], .prov-h .pick-all, .mwrap .pick { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
|
|
160
|
+
.cat-top input[type=checkbox], .prov-h .pick-all, .mwrap .pick, .free-only input { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
|
|
160
161
|
/* what to do with the ticked models: at the bottom of the list, only while some are ticked */
|
|
161
162
|
.picks { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--line); background: var(--accent-soft); font-size: 13px; }
|
|
162
163
|
.picks .n { font-weight: 600; margin-right: auto; }
|
|
@@ -185,6 +186,22 @@
|
|
|
185
186
|
.mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); }
|
|
186
187
|
.mwrap .mrow { border-bottom: 0; }
|
|
187
188
|
.mwrap .forget { border: 0; background: none; color: var(--faint); padding: 0 12px; }
|
|
189
|
+
.mwrap .try { flex: none; margin-right: 12px; }
|
|
190
|
+
.prov-h .by-name { display: flex; gap: 6px; flex: none; }
|
|
191
|
+
.prov-h .by-name input { width: 200px; height: 28px; padding: 0 8px; font-size: 12.5px; font-family: var(--mono); }
|
|
192
|
+
.prov-h .by-name button, .prov-h > button.small { height: 28px; }
|
|
193
|
+
.hello-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
|
|
194
|
+
.hello-cols pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; max-height: 55vh; overflow: auto; }
|
|
195
|
+
#hello-status.ok { color: var(--ok); }
|
|
196
|
+
#hello-status.bad { color: var(--err); }
|
|
197
|
+
@media (max-width: 860px) { .hello-cols { grid-template-columns: 1fr; } }
|
|
198
|
+
#hellos-list { max-height: 60vh; overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
|
|
199
|
+
#hellos-list button { display: flex; gap: 12px; width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: none; padding: 9px 12px; font-size: 12.5px; }
|
|
200
|
+
#hellos-list button:last-child { border-bottom: 0; }
|
|
201
|
+
#hellos-list .id { flex: none; width: 40%; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
202
|
+
#hellos-list .said { flex: 1; min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
203
|
+
#hellos-list .ok .said { color: var(--ok); }
|
|
204
|
+
#hellos-list .bad .said { color: var(--err); }
|
|
188
205
|
.mwrap { padding-left: 16px; align-items: center; }
|
|
189
206
|
.mwrap .pick + .mrow { padding-left: 12px; }
|
|
190
207
|
.mwrap .forget:hover { color: var(--err); }
|
|
@@ -440,7 +457,8 @@
|
|
|
440
457
|
<div class="cat-top">
|
|
441
458
|
<input type="checkbox" id="pick-shown" aria-label="select every model shown">
|
|
442
459
|
<input type="search" id="m-search" placeholder="Search all models…">
|
|
443
|
-
<
|
|
460
|
+
<label class="free-only" title="only the models their provider lists at $0 (a provider whose list has no prices can't say)"><input type="checkbox" id="free-only">Free only</label>
|
|
461
|
+
<select id="m-sort" aria-label="sort the models"><option value="name">Sort by name</option><option value="price">Sort by price (free first)</option></select>
|
|
444
462
|
<button class="small ghost" id="fold-all" type="button">Collapse all</button>
|
|
445
463
|
<button class="small" id="dev-all" type="button" title="ask models.dev what each listed model can do: tool calls, context, thinking, images">Get from models.dev</button>
|
|
446
464
|
</div>
|
|
@@ -606,7 +624,7 @@
|
|
|
606
624
|
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
607
625
|
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
608
626
|
<div id="p-more" hidden>
|
|
609
|
-
<label class="opt" style="margin-top:16px"><div class="t"><b>Load its model list</b><span>On: its models are asked from the provider. Off: never asked; only the models you add by name show (
|
|
627
|
+
<label class="opt" style="margin-top:16px"><div class="t"><b>Load its model list</b><span>On: its models are asked from the provider. Off: never asked; only the models you add by name show (the box next to its name).</span></div><span class="sw"><input type="checkbox" id="p-list-on"><i></i></span></label>
|
|
610
628
|
<div class="actions" id="p-added"></div>
|
|
611
629
|
</div>
|
|
612
630
|
<div class="actions"><button class="danger" id="p-delete" hidden>Delete provider</button><span class="spacer"></span><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
|
|
@@ -624,6 +642,21 @@
|
|
|
624
642
|
<div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
|
|
625
643
|
</dialog>
|
|
626
644
|
<dialog id="k-form" aria-labelledby="k-title"></dialog>
|
|
645
|
+
<dialog id="hello" aria-labelledby="hello-title">
|
|
646
|
+
<h2 id="hello-title"></h2>
|
|
647
|
+
<p class="sub" id="hello-said"></p>
|
|
648
|
+
<div class="hello-cols">
|
|
649
|
+
<div><h3>Sent</h3><pre id="hello-sent"></pre></div>
|
|
650
|
+
<div><h3>Received <span id="hello-status"></span></h3><pre id="hello-got"></pre></div>
|
|
651
|
+
</div>
|
|
652
|
+
<div class="actions"><button id="hello-again" type="button">Send again</button><span class="spacer"></span><button class="primary" id="hello-close" type="button">Close</button></div>
|
|
653
|
+
</dialog>
|
|
654
|
+
<dialog id="hellos" aria-labelledby="hellos-title">
|
|
655
|
+
<h2 id="hellos-title"></h2>
|
|
656
|
+
<p class="sub" id="hellos-said"></p>
|
|
657
|
+
<div id="hellos-list"></div>
|
|
658
|
+
<div class="actions"><button id="hellos-ok" type="button">Tick the ones that answered</button><span class="spacer"></span><button class="primary" id="hellos-close" type="button">Close</button></div>
|
|
659
|
+
</dialog>
|
|
627
660
|
<dialog id="pr" aria-labelledby="pr-title">
|
|
628
661
|
<form>
|
|
629
662
|
<h2 id="pr-title">Provider ranking</h2>
|
|
@@ -951,6 +984,14 @@ let modelQuery = ''
|
|
|
951
984
|
// storage isn't: the page's address has a new port each time)
|
|
952
985
|
const savePref = (key, value) => api('/api/prefs', { key, value }).catch((e) => toast(`not kept: ${e.message}`, true))
|
|
953
986
|
let catSort = 'name'
|
|
987
|
+
// only the models their provider lists at $0 (what its own list says: the price shown under each)
|
|
988
|
+
let freeOnly = false
|
|
989
|
+
const isFree = (d) => Boolean(d?.price) && !d.price.input && !d.price.output
|
|
990
|
+
$('free-only').onchange = (e) => {
|
|
991
|
+
freeOnly = e.target.checked
|
|
992
|
+
savePref('freeOnly', freeOnly)
|
|
993
|
+
if (S) drawProviders(S)
|
|
994
|
+
}
|
|
954
995
|
$('m-sort').onchange = (e) => {
|
|
955
996
|
catSort = e.target.value
|
|
956
997
|
savePref('catSort', catSort)
|
|
@@ -1194,12 +1235,14 @@ function drawProviders(s) {
|
|
|
1194
1235
|
|
|
1195
1236
|
// every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
1196
1237
|
// again only when what it lists changes (a provider, its models, the search); a change to your
|
|
1197
|
-
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it
|
|
1198
|
-
|
|
1238
|
+
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it
|
|
1239
|
+
// (but one of yours a provider doesn't list gets a row of its own).
|
|
1240
|
+
const unlisted = order.filter((t) => !(MODELS[t.provider]?.list ?? []).includes(t.model)).map(keyOf).sort()
|
|
1241
|
+
const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + ((MODELS[p.name] ?? { loading: true }).loading ? 'loading' : '')), modelQuery.trim(), catSort, freeOnly, unlisted])
|
|
1199
1242
|
if (shape !== catalogShape) (buildCatalog(s), applyFolds())
|
|
1200
1243
|
catalogShape = shape
|
|
1201
1244
|
markCatalog()
|
|
1202
|
-
$('m-search').placeholder = `Search ${Object.values(MODELS).reduce((n, m) => n + (m.list?.length ?? 0), 0) || 'all'} models
|
|
1245
|
+
$('m-search').placeholder = `Search ${Object.values(MODELS).reduce((n, m) => n + (m.list?.length ?? 0), 0) || 'all'} models…`
|
|
1203
1246
|
$('n-providers').textContent = s.providers.length || ''
|
|
1204
1247
|
}
|
|
1205
1248
|
// Your models: pick one up (anywhere on it with a mouse, by its ⋮⋮ on a touch screen) and the
|
|
@@ -1333,6 +1376,80 @@ function canCell(d, mine) {
|
|
|
1333
1376
|
const text = [can, priceText(price)].filter(Boolean).join(' · ')
|
|
1334
1377
|
return text ? h('span', { className: 'can', textContent: text }) : null
|
|
1335
1378
|
}
|
|
1379
|
+
// Test on a model's row: "Hello" sent to it (by the server), and the request and the answer shown.
|
|
1380
|
+
// r: an answer already had (from Test on the ticked ones): shown, not sent again.
|
|
1381
|
+
let helloOf = null
|
|
1382
|
+
async function hello(provider, model, had) {
|
|
1383
|
+
helloOf = { provider, model }
|
|
1384
|
+
const show = (sent, got, status, cls) => {
|
|
1385
|
+
$('hello-sent').textContent = sent
|
|
1386
|
+
$('hello-got').textContent = got
|
|
1387
|
+
$('hello-status').textContent = status
|
|
1388
|
+
$('hello-status').className = cls
|
|
1389
|
+
}
|
|
1390
|
+
$('hello-title').textContent = `${provider} · ${model}`
|
|
1391
|
+
$('hello-said').textContent = 'Sending "Hello"…'
|
|
1392
|
+
show('', '', '', '')
|
|
1393
|
+
$('hello-again').disabled = true
|
|
1394
|
+
if (!$('hello').open) $('hello').showModal()
|
|
1395
|
+
const asked = helloOf
|
|
1396
|
+
const r = had ?? (await sendHello(provider, model))
|
|
1397
|
+
if (asked !== helloOf) return // another one asked meanwhile
|
|
1398
|
+
$('hello-again').disabled = false
|
|
1399
|
+
if (r.failed) return ($('hello-said').textContent = `Not sent: ${r.failed}`)
|
|
1400
|
+
const json = (x) => (typeof x === 'string' ? x : JSON.stringify(x, null, 2))
|
|
1401
|
+
const s = r.sent
|
|
1402
|
+
const g = r.received
|
|
1403
|
+
show(`${s.method} ${s.url}\n${Object.entries(s.headers).map(([k, v]) => `${k}: ${v}`).join('\n')}\n\n${json(s.body)}`,
|
|
1404
|
+
g.error ? g.error : `${g.type ? `content-type: ${g.type}\n\n` : ''}${json(g.body) || '(empty)'}`,
|
|
1405
|
+
g.error ? '· no answer' : `· ${g.status} · ${g.ms} ms`, g.ok ? 'ok' : 'bad')
|
|
1406
|
+
$('hello-said').textContent = helloSaid(r, 200)
|
|
1407
|
+
}
|
|
1408
|
+
const sendHello = (provider, model) => api('/api/hello', { provider, model }).catch((e) => ({ failed: e.message }))
|
|
1409
|
+
const helloSaid = (r, n) => {
|
|
1410
|
+
const g = r.received
|
|
1411
|
+
return r.failed ? `Not sent: ${r.failed}` : g.error ? `It didn't answer: ${g.error}` : !g.ok ? `The provider refused it (${g.status}).` : r.reply ? `It answered: ${r.reply.length > n ? r.reply.slice(0, n) + '…' : r.reply}` : 'It answered, but with no text in choices[0].message.content.'
|
|
1412
|
+
}
|
|
1413
|
+
const answered = (r) => Boolean(r?.received?.ok && r.reply)
|
|
1414
|
+
// Test on the ticked models: "Hello" to each, a provider's one after another (its rate limit), the
|
|
1415
|
+
// providers at once. Each row: what came back; clicked, what was sent and received. Closing the
|
|
1416
|
+
// dialog stops the ones not sent yet.
|
|
1417
|
+
let hellos = null
|
|
1418
|
+
async function helloAll(list) {
|
|
1419
|
+
const run = (hellos = { list: list.map((t) => ({ ...t, r: null })) })
|
|
1420
|
+
$('hellos-title').textContent = `Hello to ${list.length} model${list.length === 1 ? '' : 's'}`
|
|
1421
|
+
const draw = () => {
|
|
1422
|
+
if (run !== hellos) return
|
|
1423
|
+
const done = run.list.filter((t) => t.r)
|
|
1424
|
+
const ok = done.filter((t) => answered(t.r)).length
|
|
1425
|
+
$('hellos-said').textContent = `${done.length} of ${run.list.length} done${done.length ? ` · ${ok} answered, ${done.length - ok} didn't` : ''}${done.length < run.list.length ? ' · sending…' : ''}`
|
|
1426
|
+
$('hellos-ok').disabled = !ok
|
|
1427
|
+
put($('hellos-list'), run.list.map((t) => h('button', { type: 'button', className: !t.r ? '' : answered(t.r) ? 'ok' : 'bad', title: t.r ? 'see what was sent and what came back' : 'not answered yet', disabled: !t.r, onclick: () => hello(t.provider, t.model, t.r) },
|
|
1428
|
+
h('span', { className: 'id', textContent: keyOf(t) }),
|
|
1429
|
+
h('span', { className: 'said', textContent: t.r ? helloSaid(t.r, 120) : 'waiting…' }))))
|
|
1430
|
+
}
|
|
1431
|
+
draw()
|
|
1432
|
+
if (!$('hellos').open) $('hellos').showModal()
|
|
1433
|
+
const byProvider = Map.groupBy(run.list, (t) => t.provider)
|
|
1434
|
+
await Promise.all([...byProvider.values()].map(async (ts) => {
|
|
1435
|
+
for (const t of ts) {
|
|
1436
|
+
if (run !== hellos) return
|
|
1437
|
+
t.r = await sendHello(t.provider, t.model)
|
|
1438
|
+
draw()
|
|
1439
|
+
}
|
|
1440
|
+
}))
|
|
1441
|
+
}
|
|
1442
|
+
$('hellos-close').onclick = () => $('hellos').close()
|
|
1443
|
+
$('hellos').addEventListener('close', () => (hellos = null))
|
|
1444
|
+
// the ticks: the ones that answered, only
|
|
1445
|
+
$('hellos-ok').onclick = () => {
|
|
1446
|
+
PICKED_CAT.clear()
|
|
1447
|
+
for (const t of hellos?.list ?? []) if (answered(t.r)) pickedOf(t.provider).add(t.model)
|
|
1448
|
+
$('hellos').close()
|
|
1449
|
+
drawPicks()
|
|
1450
|
+
}
|
|
1451
|
+
$('hello-again').onclick = () => helloOf && hello(helloOf.provider, helloOf.model)
|
|
1452
|
+
$('hello-close').onclick = () => $('hello').close()
|
|
1336
1453
|
// every model added to a provider by name forgotten; the ones in your list leave it too
|
|
1337
1454
|
async function clearAdded(p) {
|
|
1338
1455
|
const inList = S.order.filter((t) => t.provider === p.name && p.added.includes(t.model))
|
|
@@ -1382,14 +1499,13 @@ function drawPicks() {
|
|
|
1382
1499
|
put($('picks'),
|
|
1383
1500
|
h('span', { className: 'n', textContent: `${all.length} selected` }),
|
|
1384
1501
|
h('button', { className: 'small ghost', textContent: 'Clear', onclick: () => (PICKED_CAT.clear(), drawPicks()) }),
|
|
1502
|
+
h('button', { className: 'small', textContent: `Test ${all.length}`, title: 'send each ticked model "Hello" and see which answer', onclick: () => helloAll(all) }),
|
|
1385
1503
|
toTake.length ? h('button', { className: 'small danger', textContent: `Remove ${toTake.length} from your list`, onclick: take }) : null,
|
|
1386
1504
|
toAdd.length ? h('button', { className: 'small primary', textContent: `Add ${toAdd.length} to your list`, onclick: () => (PICKED_CAT.clear(), saveOrder([...S.order, ...toAdd], `${toAdd.length} added: your list has ${S.order.length + toAdd.length} models`)) }) : null)
|
|
1387
1505
|
if (was !== $('picks').hidden) fillHeight() // the list gives it room
|
|
1388
1506
|
}
|
|
1389
1507
|
function buildCatalog(s) {
|
|
1390
1508
|
const q = modelQuery.trim()
|
|
1391
|
-
// a name no provider lists (anywhere): offered under each provider, to use it anyway
|
|
1392
|
-
const anyMatch = !q || s.providers.some((p) => (MODELS[p.name]?.list ?? []).some((id) => id.toLowerCase().includes(q.toLowerCase())))
|
|
1393
1509
|
SHOWN.clear()
|
|
1394
1510
|
put($('p-list'), s.providers.map((p) => {
|
|
1395
1511
|
if (!p.list) MODELS[p.name] = { list: [], details: {}, off: true } // its list switched off: never asked for
|
|
@@ -1398,23 +1514,37 @@ function buildCatalog(s) {
|
|
|
1398
1514
|
// and the models added by name (and any of yours it doesn't list), marked as yours
|
|
1399
1515
|
const listed = new Set(m.list ?? [])
|
|
1400
1516
|
const yours = [...new Set([...p.added, ...s.order.filter((t) => t.provider === p.name).map((t) => t.model)])].filter((id) => !listed.has(id))
|
|
1401
|
-
const ids = [...listed, ...yours].sort((a, b) => (catSort === 'price' && costOf(m.details?.[a]) - costOf(m.details?.[b])) || byName(a, b)).filter((id) => id.toLowerCase().includes(q.toLowerCase()))
|
|
1517
|
+
const ids = [...listed, ...yours].sort((a, b) => (catSort === 'price' && costOf(m.details?.[a]) - costOf(m.details?.[b])) || byName(a, b)).filter((id) => id.toLowerCase().includes(q.toLowerCase()) && (!freeOnly || isFree(m.details?.[id])))
|
|
1402
1518
|
const row = (id, own) =>
|
|
1403
1519
|
h('button', { className: 'mrow', 'data-provider': p.name, 'data-model': id, onclick: () => (placeOf(p.name, id) === undefined ? addModel(p.name, id) : removeMine(p.name, id)) },
|
|
1404
1520
|
h('span', { className: 'id', title: id }, id, canCell(m.details?.[id], S.order[placeOf(p.name, id)])),
|
|
1521
|
+
isFree(m.details?.[id]) ? h('span', { className: 'tag ok', textContent: 'free', title: `${p.name}'s model list says $0` }) : null,
|
|
1405
1522
|
own ? h('span', { className: 'tag', textContent: 'not in its list' }) : p.added.includes(id) ? h('span', { className: 'tag', textContent: 'added by you' }) : null,
|
|
1406
1523
|
rowTail(p.name, id))
|
|
1407
1524
|
// its tick box (to add or take off several at once), and one you added: ✕ forgets it (it leaves your list too)
|
|
1408
1525
|
const tick = (id) => h('input', { type: 'checkbox', className: 'pick', checked: pickedOf(p.name).has(id), 'data-model': id, 'aria-label': `select ${id}`, onchange: (e) => (e.target.checked ? pickedOf(p.name).add(id) : pickedOf(p.name).delete(id), drawPicks()) })
|
|
1409
|
-
const item = (id) => h('div', { className: 'mwrap' }, tick(id), row(id),
|
|
1526
|
+
const item = (id) => h('div', { className: 'mwrap' }, tick(id), row(id),
|
|
1527
|
+
h('button', { className: 'small try', textContent: 'Test', title: `send ${id} "Hello" and see what was sent and what came back`, onclick: () => hello(p.name, id) }),
|
|
1528
|
+
!p.added.includes(id) ? null :
|
|
1410
1529
|
h('button', { className: 'forget', textContent: '✕', title: 'forget this model', 'aria-label': `forget ${id}`, onclick: async () => {
|
|
1411
1530
|
if (placeOf(p.name, id) !== undefined && !confirm(`Forget ${id}? It leaves your list too.`)) return
|
|
1412
1531
|
if (placeOf(p.name, id) !== undefined) await saveOrder(S.order.filter((t) => t.provider !== p.name || t.model !== id), `${id} removed`)
|
|
1413
1532
|
act('/api/provider/added', { name: p.name, model: id, remove: true }, `forgot ${id}`)
|
|
1414
1533
|
} }))
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1534
|
+
// a model by its name, into your list; one it has already: said, and shown
|
|
1535
|
+
const addByName = (e) => {
|
|
1536
|
+
e.preventDefault()
|
|
1537
|
+
const box = e.target.elements.name
|
|
1538
|
+
const id = box.value.trim()
|
|
1539
|
+
if (!id) return
|
|
1540
|
+
const at = placeOf(p.name, id)
|
|
1541
|
+
if (at !== undefined) return showRow(p.name, id, `${id} is already in your list (#${at + 1})`)
|
|
1542
|
+
if (listed.has(id) || yours.includes(id)) return showRow(p.name, id, `${p.name} already has ${id}: click it to add it`)
|
|
1543
|
+
box.value = ''
|
|
1544
|
+
addModel(p.name, id)
|
|
1545
|
+
}
|
|
1546
|
+
if (q && !ids.length) return null // searching: only the providers with a match
|
|
1547
|
+
if (freeOnly && !ids.length) return null // free only: the providers with a free one
|
|
1418
1548
|
return h('section', { 'data-provider': p.name },
|
|
1419
1549
|
// the header folds it (a click on it, or its arrow; not on its buttons and switch)
|
|
1420
1550
|
h('div', { className: 'prov-h', onclick: (e) => !e.target.closest('button:not(.fold), input, label, a') && fold(p.name) },
|
|
@@ -1422,12 +1552,14 @@ function buildCatalog(s) {
|
|
|
1422
1552
|
ids.length ? h('input', { type: 'checkbox', className: 'pick-all', 'data-provider': p.name, 'aria-label': `select ${p.name}'s models`, title: `select all of ${p.name}'s models shown (or none)`, onchange: (e) => (ids.forEach((id) => (e.target.checked ? pickedOf(p.name).add(id) : pickedOf(p.name).delete(id))), drawPicks()) }) : null,
|
|
1423
1553
|
h('div', { className: 'who' },
|
|
1424
1554
|
h('span', { className: 'name', textContent: p.name }),
|
|
1425
|
-
m.off ? h('span', { className: 'tag', textContent: 'list off', title: "its list isn't loaded:
|
|
1555
|
+
m.off ? h('span', { className: 'tag', textContent: 'list off', title: "its list isn't loaded: add a model by its name, in the box on the right" }) : m.loading ? h('span', { className: 'tag', textContent: 'loading…' }) : m.error ? h('span', { className: 'tag err', textContent: "can't list its models", title: m.error }) : h('span', { className: 'tag ok', textContent: `${m.list.length} model${m.list.length === 1 ? '' : 's'}` }),
|
|
1426
1556
|
h('span', { className: 'tag inlist' }),
|
|
1427
1557
|
h('span', { className: 'meta mono', textContent: p.baseUrl, title: `${p.baseUrl} · key ${p.key || 'none'}` })),
|
|
1558
|
+
h('form', { className: 'by-name', onsubmit: addByName },
|
|
1559
|
+
h('input', { type: 'text', name: 'name', placeholder: 'model name', 'aria-label': `add a model to ${p.name} by its name`, title: `a model's exact name: added to your list, also one ${p.name} doesn't list` }),
|
|
1560
|
+
h('button', { className: 'small', textContent: 'Add' })),
|
|
1428
1561
|
h('button', { className: 'small', textContent: 'Edit', title: 'its address and key, its model list, delete it', onclick: () => openForm(p) })),
|
|
1429
1562
|
// what's listed first; loading and errors under it (the models already shown stay where they are)
|
|
1430
|
-
typed.length ? h('div', { className: 'rows' }, typed) : null,
|
|
1431
1563
|
(() => {
|
|
1432
1564
|
if (!ids.length) return null
|
|
1433
1565
|
SHOWN.set(p.name, ids)
|
|
@@ -1436,10 +1568,11 @@ function buildCatalog(s) {
|
|
|
1436
1568
|
box._v = virtual(box, { count: ids.length, height: CAT_ROW, row: (i) => item(ids[i]), scroller: $('p-list') })
|
|
1437
1569
|
return box
|
|
1438
1570
|
})(),
|
|
1439
|
-
m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: "Or
|
|
1440
|
-
m.off && !ids.length
|
|
1571
|
+
m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: "Or add a model by its name, in the box above." })) : null,
|
|
1572
|
+
m.off && !ids.length ? h('div', { className: 'cat-note', textContent: "Its list is off: add a model by its name, in the box above." }) : null,
|
|
1441
1573
|
m.loading ? h('div', { className: 'cat-note', textContent: 'Loading its models…' }) : null)
|
|
1442
1574
|
}))
|
|
1575
|
+
if (freeOnly && !$('p-list').querySelector('section')) put($('p-list'), h('div', { className: 'cat-note', textContent: "None of your providers lists a model at $0. (A provider whose model list has no prices can't say what's free: Free only leaves those out.)" }))
|
|
1443
1576
|
}
|
|
1444
1577
|
// --- Your models page: the list as a table; sorted by several columns at once (each a tie-break
|
|
1445
1578
|
// for the ones before it), the sort kept in this browser ---
|
|
@@ -1958,6 +2091,8 @@ function render(s) {
|
|
|
1958
2091
|
const p = s.prefs
|
|
1959
2092
|
catSort = p.catSort === 'price' ? 'price' : 'name'
|
|
1960
2093
|
$('m-sort').value = catSort
|
|
2094
|
+
freeOnly = p.freeOnly === true
|
|
2095
|
+
$('free-only').checked = freeOnly
|
|
1961
2096
|
FOLDED.clear()
|
|
1962
2097
|
for (const n of Array.isArray(p.folded) ? p.folded : []) FOLDED.add(n)
|
|
1963
2098
|
SORT = Array.isArray(p.sort) ? p.sort.filter((x) => COLS[x?.key]) : []
|
package/src/settings.js
CHANGED
|
@@ -102,10 +102,11 @@ const skillsDir = () => path.join(HOME, 'skills')
|
|
|
102
102
|
// what the provider said (names, sizes, prices; not what's known from models.dev or tests, which
|
|
103
103
|
// changes). Asked for again when its list is switched on or off, or the provider is changed.
|
|
104
104
|
const LISTS = path.join(HOME, 'model-lists.json')
|
|
105
|
+
const LIST_V = 2 // how a saved list was read (2: airforce's cents, Cloudflare's prices); another: read again
|
|
105
106
|
// The page's own choices (sorts, folded providers, the provider ranking): kept here, not in the
|
|
106
107
|
// browser, whose storage is per address (a new port each time)
|
|
107
108
|
const PREFS = path.join(HOME, 'settings-page.json')
|
|
108
|
-
const PREF_KEYS = ['catSort', 'folded', 'sort', 'rank']
|
|
109
|
+
const PREF_KEYS = ['catSort', 'freeOnly', 'folded', 'sort', 'rank']
|
|
109
110
|
function forgetList(...names) {
|
|
110
111
|
const all = readJson(LISTS, {})
|
|
111
112
|
if (!names.some((n) => n in all)) return
|
|
@@ -437,11 +438,11 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
437
438
|
if (!p) throw new Error(`no provider "${name}"`)
|
|
438
439
|
if (p.list === false) return { models: [], details: {}, off: true }
|
|
439
440
|
const lists = readJson(LISTS, {})
|
|
440
|
-
let saved = lists[name]?.baseUrl === p.baseUrl && lists[name].models?.length ? lists[name] : null // an empty one: asked again
|
|
441
|
+
let saved = lists[name]?.baseUrl === p.baseUrl && lists[name].models?.length && lists[name].v === LIST_V ? lists[name] : null // an empty one, or one read the old way: asked again
|
|
441
442
|
if (!saved) {
|
|
442
443
|
const entries = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).modelEntries()
|
|
443
444
|
const num = (...xs) => xs.map(Number).find((x) => x > 0)
|
|
444
|
-
saved = { baseUrl: p.baseUrl, at: Date.now(), models: entries.map((m) => String(m.id ?? m.name)), details: Object.fromEntries(entries.map((m) => [String(m.id ?? m.name), {
|
|
445
|
+
saved = { v: LIST_V, baseUrl: p.baseUrl, at: Date.now(), models: entries.map((m) => String(m.id ?? m.name)), details: Object.fromEntries(entries.map((m) => [String(m.id ?? m.name), {
|
|
445
446
|
name: m.name && m.name !== String(m.id ?? m.name) ? String(m.name) : undefined,
|
|
446
447
|
context: num(m.context_length, m.context_window, m.max_model_len, m.max_context_length, m.top_provider?.context_length),
|
|
447
448
|
owner: m.owned_by && m.owned_by !== 'system' ? String(m.owned_by) : undefined,
|
|
@@ -505,6 +506,28 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
505
506
|
// itself). In the requests log as a model test. Never throws: { ok, ms, reply } or { ok: false, error }.
|
|
506
507
|
'POST /api/try': ({ provider, model } = {}) => testOne({ provider, model: String(model ?? '') }),
|
|
507
508
|
// the tests' results, and the run going now (with the models it hasn't tested yet)
|
|
509
|
+
// Test, on a model's row: one "Hello" to it, and exactly what was sent and what came back (the
|
|
510
|
+
// key masked), for the page to show. Not streamed, so the answer is the provider's own JSON.
|
|
511
|
+
'POST /api/hello': async ({ provider, model }) => {
|
|
512
|
+
const c = clientFor({ provider, model })
|
|
513
|
+
if (!c || !model) throw new Error(`no provider "${provider}"`)
|
|
514
|
+
const url = `${c.baseUrl}/chat/completions`
|
|
515
|
+
const body = { model, messages: [{ role: 'user', content: 'Hello' }] }
|
|
516
|
+
const sent = { method: 'POST', url, headers: { 'content-type': 'application/json', ...(c.apiKey && { authorization: `Bearer ${mask(c.apiKey)}` }) }, body }
|
|
517
|
+
const t0 = Date.now()
|
|
518
|
+
try {
|
|
519
|
+
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(c.apiKey && { authorization: `Bearer ${c.apiKey}` }) }, body: JSON.stringify(body), signal: AbortSignal.timeout(90_000) })
|
|
520
|
+
const text = await res.text()
|
|
521
|
+
let json = null
|
|
522
|
+
try {
|
|
523
|
+
json = JSON.parse(text)
|
|
524
|
+
} catch {}
|
|
525
|
+
const reply = json?.choices?.[0]?.message?.content ?? null
|
|
526
|
+
return { sent, received: { status: res.status, ok: res.ok, ms: Date.now() - t0, type: res.headers.get('content-type'), body: json ?? text.slice(0, 20_000) }, reply }
|
|
527
|
+
} catch (e) {
|
|
528
|
+
return { sent, received: { error: e.name === 'TimeoutError' ? 'no answer within 90 seconds' : `couldn't send it: ${e.cause?.code || e.cause?.message || e.message}`, ms: Date.now() - t0 } }
|
|
529
|
+
}
|
|
530
|
+
},
|
|
508
531
|
'POST /api/tests': () => ({ results: tests, run: run && { ...runState(), pending: [...run.pending] } }),
|
|
509
532
|
// a run: all your models, or the ones given (only); one at a time if one is going already
|
|
510
533
|
'POST /api/tests/start': ({ only } = {}) => {
|
|
@@ -772,7 +795,11 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
772
795
|
// output } (either may be missing), or undefined. Not models.dev's: the same model costs differently
|
|
773
796
|
// at different providers. The forms lists use: pricing.prompt/completion (per token, OpenRouter's);
|
|
774
797
|
// pricing.input/output with a unit (per million when it says so; no unit: per token when it's that
|
|
775
|
-
// small); input_price/output_price (per token); pricepermilliontokens
|
|
798
|
+
// small); input_price/output_price (per token); pricepermilliontokens (in cents: api.airforce's 88
|
|
799
|
+
// is its own 880000 micro-dollars); Cloudflare's properties ("per M input tokens"); only a free
|
|
800
|
+
// tier (is_free, or a tier named free when nothing else is said: a list's "free tier" with a price
|
|
801
|
+
// is a plan that may use it, not a model that costs nothing).
|
|
802
|
+
const cfPrice = (m, unit) => (Array.isArray(m.properties) ? m.properties.find((p) => p?.property_id === 'price')?.value?.find?.((v) => unit.test(v?.unit ?? ''))?.price : undefined)
|
|
776
803
|
export function listedPrice(m) {
|
|
777
804
|
const num = (x) => (x === null || x === undefined || x === '' || !(Number(x) >= 0) ? undefined : Number(x)) // -1: varies (a router's "auto")
|
|
778
805
|
const pr = m.pricing && typeof m.pricing === 'object' && !Array.isArray(m.pricing) ? m.pricing : {}
|
|
@@ -781,7 +808,8 @@ export function listedPrice(m) {
|
|
|
781
808
|
[num(pr.prompt), num(pr.completion), () => 1e6],
|
|
782
809
|
[num(pr.input), num(pr.output), unit],
|
|
783
810
|
[num(m.input_price), num(m.output_price), () => 1e6],
|
|
784
|
-
[num(m.pricepermilliontokens), num(m.output_pricepermilliontokens), () =>
|
|
811
|
+
[num(m.pricepermilliontokens), num(m.output_pricepermilliontokens), () => 0.01],
|
|
812
|
+
[num(cfPrice(m, /^per M input tokens$/i)), num(cfPrice(m, /^per M output tokens$/i)), () => 1],
|
|
785
813
|
]) {
|
|
786
814
|
if (i === undefined && o === undefined) continue
|
|
787
815
|
const per = (x) => (x === undefined ? undefined : Number((x * k(x)).toPrecision(6)))
|