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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "A coding CLI for any local or OpenAI-compatible model, including small models without tool calling.",
5
5
  "keywords": [
6
6
  "ai",
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
- <select id="m-sort" aria-label="sort the models"><option value="name">Sort by name</option><option value="price">Sort by price</option></select>
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 (type one's exact name in the search).</span></div><span class="sw"><input type="checkbox" id="p-list-on"><i></i></span></label>
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
- 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])
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, or type a model name…`
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), !p.added.includes(id) ? null :
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
- const typed = !anyMatch ? [row(q, true)] : []
1416
- if (typed[0]) typed[0].dataset.typed = '1' // a name no provider lists: not part of Add all
1417
- if (q && anyMatch && !ids.length) return null // searching: only the providers with a match
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: type a model's exact name in the search to add it" }) : 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'}` }),
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 type a model's exact name in the search, and click it." })) : null,
1440
- m.off && !ids.length && !typed.length ? h('div', { className: 'cat-note', textContent: "Its list is off: type a model's exact name in the search, and click it to add it." }) : null,
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; only a free tier.
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), () => 1],
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)))