heartcode 0.1.13 → 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 +19 -0
- package/package.json +1 -1
- package/src/settings.html +293 -166
- package/src/settings.js +33 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
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
|
+
|
|
16
|
+
## 0.1.14 (2026-10-30)
|
|
17
|
+
|
|
18
|
+
- Settings page, Providers & models: one full-width list. Tick models (or a whole provider, or
|
|
19
|
+
everything shown) and add them to your list or take them off it at once; a click on one still
|
|
20
|
+
adds or removes it. Each provider is one line; its model list switch, the models added by name
|
|
21
|
+
and Delete are in its Edit dialog. Your list is a button to the Your models page, which now has
|
|
22
|
+
what to do when a model fails.
|
|
23
|
+
|
|
5
24
|
## 0.1.13 (2026-10-30)
|
|
6
25
|
|
|
7
26
|
- Settings no longer go back by themselves: a save writes only what changed, so another HeartCode
|
package/package.json
CHANGED
package/src/settings.html
CHANGED
|
@@ -147,29 +147,30 @@
|
|
|
147
147
|
.preset span { color: var(--muted); font-size: 12px; }
|
|
148
148
|
.preset.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
149
149
|
|
|
150
|
-
/* Providers & models: every provider's models
|
|
151
|
-
|
|
152
|
-
.work { display:
|
|
153
|
-
.work > .card { padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
|
154
|
-
.cat-top { padding:
|
|
155
|
-
.cat-
|
|
156
|
-
.cat-
|
|
157
|
-
.cat-
|
|
158
|
-
.cat-
|
|
159
|
-
.cat-
|
|
160
|
-
.cat-
|
|
161
|
-
|
|
162
|
-
.
|
|
150
|
+
/* Providers & models: every provider's models in one list, full width; it fills the screen's height and scrolls inside */
|
|
151
|
+
.head-actions { display: flex; gap: 8px; align-items: center; }
|
|
152
|
+
.work { display: flex; flex-direction: column; }
|
|
153
|
+
.work > .card { flex: 1; padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
|
154
|
+
.cat-top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
155
|
+
.cat-top input[type=search] { flex: 1; min-width: 140px; }
|
|
156
|
+
.cat-top select { flex: none; width: auto; padding-right: 30px; }
|
|
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; }
|
|
159
|
+
.cat-top button.ghost:hover:not(:disabled) { color: var(--text); border-color: transparent; }
|
|
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; }
|
|
161
|
+
/* what to do with the ticked models: at the bottom of the list, only while some are ticked */
|
|
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; }
|
|
163
|
+
.picks .n { font-weight: 600; margin-right: auto; }
|
|
163
164
|
.prov-h { cursor: pointer; }
|
|
164
|
-
.prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s;
|
|
165
|
+
.prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s; }
|
|
165
166
|
.prov-h .fold:hover { color: var(--text); }
|
|
166
167
|
section.collapsed .prov-h .fold { transform: rotate(-90deg); }
|
|
167
168
|
section.collapsed > :not(.prov-h) { display: none; } /* folded: just its header */
|
|
168
169
|
.cat-list { flex: 1; overflow-y: auto; }
|
|
169
|
-
.prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px;
|
|
170
|
-
.prov-h .who { flex: 1; min-width:
|
|
170
|
+
.prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: var(--panel2); border-bottom: 1px solid var(--line); }
|
|
171
|
+
.prov-h .who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }
|
|
171
172
|
.prov-h .name { font-weight: 700; font-size: 15px; }
|
|
172
|
-
.prov-h .meta { font-size: 12px; color: var(--
|
|
173
|
+
.prov-h .meta { font-size: 12px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
173
174
|
.prov-h .meta .mono { font-size: 12px; }
|
|
174
175
|
.rows { display: flex; flex-direction: column; } /* one model per line, A to Z */
|
|
175
176
|
.mrow { break-inside: avoid; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 9px 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: none; cursor: pointer; min-width: 0; }
|
|
@@ -185,11 +186,25 @@
|
|
|
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); }
|
|
205
|
+
.mwrap { padding-left: 16px; align-items: center; }
|
|
206
|
+
.mwrap .pick + .mrow { padding-left: 12px; }
|
|
188
207
|
.mwrap .forget:hover { color: var(--err); }
|
|
189
|
-
.cat-top .listsw { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); white-space: nowrap; margin-right: 6px; }
|
|
190
|
-
.cat-top .listsw .sw { margin-top: 0; }
|
|
191
|
-
.prov-h .listsw { margin-top: 8px; width: fit-content; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
|
192
|
-
.prov-h .listsw .sw { margin-top: 0; }
|
|
193
208
|
.log-opts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px; }
|
|
194
209
|
.log-opts .opt { border-bottom: 0; padding-bottom: 0; }
|
|
195
210
|
@media (max-width: 860px) { .log-opts { grid-template-columns: 1fr; } }
|
|
@@ -219,8 +234,6 @@
|
|
|
219
234
|
.req-detail h3 { font-size: 14px; margin: 16px 0 4px; }
|
|
220
235
|
@media (max-width: 860px) { .reqs { grid-template-columns: 1fr; } .req-list { border-right: 0; max-height: 50vh; } }
|
|
221
236
|
.cat-note { padding: 12px 16px; color: var(--muted); font-size: 12.5px; }
|
|
222
|
-
.byname { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); }
|
|
223
|
-
.byname input { height: 32px; font-family: var(--mono); font-size: 12.5px; }
|
|
224
237
|
/* one line: what models.dev says (cut short, all of it on hover), its buttons on the right */
|
|
225
238
|
.olist .test { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
226
239
|
.olist .test .msg { flex: 1; min-width: 0; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.35; }
|
|
@@ -269,6 +282,11 @@
|
|
|
269
282
|
.mt { min-width: 1116px; }
|
|
270
283
|
.mt-h input[type=checkbox], .mtable input[type=checkbox] { width: 15px; height: 15px; margin: 0; accent-color: var(--brand); cursor: pointer; }
|
|
271
284
|
.mtable li.picked { background: var(--brand-soft); }
|
|
285
|
+
.mt-rules { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 28px; padding: 12px 18px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--muted); }
|
|
286
|
+
.mt-rules .rule { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
|
287
|
+
.mt-rules select { width: auto; height: 32px; padding: 4px 30px 4px 10px; }
|
|
288
|
+
.mt-rules input { width: 64px; height: 32px; }
|
|
289
|
+
.mt-rules .hint { flex-basis: 100%; margin: 0; }
|
|
272
290
|
.mt-outcomes { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px 16px; border-bottom: 1px solid var(--line); font-size: 12.5px; color: var(--muted); }
|
|
273
291
|
.mt-outcomes > span:first-child { margin-right: 4px; }
|
|
274
292
|
/* a test result: what it was and how many (a click selects them), and ↻ to test them again */
|
|
@@ -430,26 +448,24 @@
|
|
|
430
448
|
<!-- Providers & models -->
|
|
431
449
|
<section class="page" id="page-providers">
|
|
432
450
|
<div class="head">
|
|
433
|
-
<div><h1>Providers & models</h1><p class="lead">
|
|
434
|
-
<button class="primary" id="p-open">+ Add a provider</button>
|
|
451
|
+
<div><h1>Providers & models</h1><p class="lead">Click a model to add it to your list, or tick several and add them at once. HeartCode uses your list in order: #1, then the next when one fails.</p></div>
|
|
452
|
+
<div class="head-actions"><button id="p-mine" type="button"></button><button class="primary" id="p-open">+ Add a provider</button></div>
|
|
435
453
|
</div>
|
|
436
454
|
<div class="steps" id="steps"></div>
|
|
437
|
-
<div class="
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
</div>
|
|
447
|
-
</div>
|
|
448
|
-
<div class="job-slot" data-where="catalog"></div>
|
|
449
|
-
<div class="cat-list" id="p-list"></div>
|
|
450
|
-
</div>
|
|
451
|
-
<div class="card mine" id="inuse"></div>
|
|
455
|
+
<div class="work" id="p-work" hidden>
|
|
456
|
+
<div class="card catalog">
|
|
457
|
+
<div class="cat-top">
|
|
458
|
+
<input type="checkbox" id="pick-shown" aria-label="select every model shown">
|
|
459
|
+
<input type="search" id="m-search" placeholder="Search all models…">
|
|
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>
|
|
462
|
+
<button class="small ghost" id="fold-all" type="button">Collapse all</button>
|
|
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>
|
|
452
464
|
</div>
|
|
465
|
+
<div class="job-slot" data-where="catalog"></div>
|
|
466
|
+
<div class="cat-list" id="p-list"></div>
|
|
467
|
+
<div class="picks" id="picks" hidden></div>
|
|
468
|
+
</div>
|
|
453
469
|
</div>
|
|
454
470
|
</section>
|
|
455
471
|
|
|
@@ -607,7 +623,11 @@
|
|
|
607
623
|
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
608
624
|
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
609
625
|
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
610
|
-
<div
|
|
626
|
+
<div id="p-more" hidden>
|
|
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>
|
|
628
|
+
<div class="actions" id="p-added"></div>
|
|
629
|
+
</div>
|
|
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>
|
|
611
631
|
</dialog>
|
|
612
632
|
<dialog id="c-form" aria-labelledby="c-title">
|
|
613
633
|
<h2 id="c-title">Add a server</h2>
|
|
@@ -622,6 +642,21 @@
|
|
|
622
642
|
<div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
|
|
623
643
|
</dialog>
|
|
624
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>
|
|
625
660
|
<dialog id="pr" aria-labelledby="pr-title">
|
|
626
661
|
<form>
|
|
627
662
|
<h2 id="pr-title">Provider ranking</h2>
|
|
@@ -654,7 +689,7 @@ function toast(text, err = false) {
|
|
|
654
689
|
}
|
|
655
690
|
// What's running (a test, lists loading, models.dev), shown at the top of the panels it's about
|
|
656
691
|
// while it runs: job(id, { what, where, done, total, detail, stop }) to show or update it (no total:
|
|
657
|
-
// a bar that just moves), job(id) when it's over. where: the panels ('catalog', '
|
|
692
|
+
// a bar that just moves), job(id) when it's over. where: the panels ('catalog', 'table').
|
|
658
693
|
// Updated in place, so nothing jumps while it counts.
|
|
659
694
|
const JOBS = new Map()
|
|
660
695
|
function job(id, state) {
|
|
@@ -901,11 +936,25 @@ function openForm(p = null) {
|
|
|
901
936
|
$('p-key').placeholder = p?.key ? 'empty: keep the key it has' : 'paste it here (none for a server on this machine, like Ollama)'
|
|
902
937
|
$('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
|
|
903
938
|
$('p-save').textContent = p ? 'Save changes' : 'Add provider'
|
|
939
|
+
// editing: what used to crowd its header
|
|
940
|
+
$('p-more').hidden = $('p-delete').hidden = !p
|
|
941
|
+
$('presets').hidden = Boolean(p)
|
|
942
|
+
$('p-form').querySelector('.sub').hidden = Boolean(p)
|
|
943
|
+
if (p) {
|
|
944
|
+
$('p-list-on').checked = p.list
|
|
945
|
+
put($('p-added'), p.added.length ? [h('span', { className: 'muted', style: 'font-size:13px', textContent: `${p.added.length} model${p.added.length === 1 ? '' : 's'} you added by name` }), h('button', { className: 'small danger', textContent: 'Forget them', onclick: () => ($('p-form').close(), clearAdded(p)) })] : [])
|
|
946
|
+
}
|
|
904
947
|
drawPresets()
|
|
905
948
|
$('p-form').showModal()
|
|
906
949
|
;(p ? $('p-url') : $('p-name')).focus()
|
|
907
950
|
}
|
|
908
951
|
const closeForm = () => $('p-form').close()
|
|
952
|
+
$('p-delete').onclick = () => {
|
|
953
|
+
const p = editing
|
|
954
|
+
if (!p || !confirm(`Delete the provider "${p.name}"? Its models leave your list too.`)) return
|
|
955
|
+
closeForm()
|
|
956
|
+
act('/api/provider/delete', { name: p.name }, `deleted ${p.name}`)
|
|
957
|
+
}
|
|
909
958
|
$('p-form').onclose = () => (editing = null)
|
|
910
959
|
$('p-open').onclick = () => openForm()
|
|
911
960
|
$('p-cancel').onclick = closeForm
|
|
@@ -917,6 +966,7 @@ $('p-save').onclick = async () => {
|
|
|
917
966
|
if (!r) return
|
|
918
967
|
const added = r.providers.find((p) => p.name === name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''))
|
|
919
968
|
if (!added) return
|
|
969
|
+
if (was && $('p-list-on').checked !== was.list) await act('/api/provider/list', { name: added.name, on: $('p-list-on').checked }) // its list switched on or off
|
|
920
970
|
if (was) delete MODELS[was.name]
|
|
921
971
|
loadModels(added.name, true) // a new one, or a new URL or key: its models (again)
|
|
922
972
|
toast(was ? `saved ${added.name}` : `added ${added.name}: its models are loading`)
|
|
@@ -934,6 +984,14 @@ let modelQuery = ''
|
|
|
934
984
|
// storage isn't: the page's address has a new port each time)
|
|
935
985
|
const savePref = (key, value) => api('/api/prefs', { key, value }).catch((e) => toast(`not kept: ${e.message}`, true))
|
|
936
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
|
+
}
|
|
937
995
|
$('m-sort').onchange = (e) => {
|
|
938
996
|
catSort = e.target.value
|
|
939
997
|
savePref('catSort', catSort)
|
|
@@ -945,6 +1003,7 @@ const costOf = (d) => (d?.price ? (d.price.input ?? 0) + (d.price.output ?? 0) :
|
|
|
945
1003
|
let searchOpen = false
|
|
946
1004
|
const FOLDED = new Set()
|
|
947
1005
|
function applyFolds() {
|
|
1006
|
+
$('fold-all').textContent = allFolded() ? 'Expand all' : 'Collapse all'
|
|
948
1007
|
for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
|
|
949
1008
|
const folded = FOLDED.has(sec.dataset.provider) && !(searchOpen && modelQuery.trim())
|
|
950
1009
|
sec.classList.toggle('collapsed', folded)
|
|
@@ -959,17 +1018,14 @@ function settle() {
|
|
|
959
1018
|
}
|
|
960
1019
|
const folds = () => (applyFolds(), savePref('folded', [...FOLDED])) // a fold changed: shown and kept
|
|
961
1020
|
const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), folds())
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
$('
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
if (!confirm(`Add ${add.length} model${add.length === 1 ? '' : 's'} to your list${modelQuery.trim() ? ` (the ones matching "${modelQuery.trim()}")` : ''}?`)) return
|
|
971
|
-
saveOrder([...S.order, ...add], `${add.length} added: your list has ${S.order.length + add.length} models`)
|
|
972
|
-
}
|
|
1021
|
+
// one button: collapse them all, or (all collapsed) expand them all
|
|
1022
|
+
const allFolded = () => Boolean(S?.providers.length) && S.providers.every((p) => FOLDED.has(p.name))
|
|
1023
|
+
$('fold-all').onclick = () => (settle(), allFolded() ? FOLDED.clear() : S?.providers.forEach((p) => FOLDED.add(p.name)), folds())
|
|
1024
|
+
$('pick-shown').onchange = (e) => {
|
|
1025
|
+
for (const [provider, ids] of SHOWN) for (const id of ids) e.target.checked ? pickedOf(provider).add(id) : PICKED_CAT.get(provider)?.delete(id)
|
|
1026
|
+
drawPicks()
|
|
1027
|
+
}
|
|
1028
|
+
$('p-mine').onclick = () => (location.hash = 'models')
|
|
973
1029
|
// models.dev fetched again (your models' profiles too), then every listed provider's models with it
|
|
974
1030
|
$('dev-all').onclick = async () => {
|
|
975
1031
|
if (!(await lookUp())) return
|
|
@@ -980,21 +1036,8 @@ $('m-search').oninput = (e) => ((modelQuery = e.target.value), (searchOpen = tru
|
|
|
980
1036
|
// list and its own test), and what a click does. Shown on hover and on keyboard focus.
|
|
981
1037
|
const kTok = (n) => (n >= 1e6 ? `${Math.round(n / 1e5) / 10}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
|
|
982
1038
|
const usd = (x) => (x === 0 ? 'free' : `$${x < 0.01 ? x.toFixed(4) : x < 1 ? x.toFixed(3).replace(/0$/, '') : x.toFixed(2)}`)
|
|
983
|
-
//
|
|
984
|
-
// lines of text, its button on the right): where that comes from, and Edit (or Set it up, for one
|
|
985
|
-
// models.dev doesn't know). Replaced in place when it changes (streamed).
|
|
1039
|
+
// where what a model can do comes from (the Your models table)
|
|
986
1040
|
const FROM = { 'models.dev': 'from models.dev', you: 'set by you', tested: 'tested' }
|
|
987
|
-
function testLine(t) {
|
|
988
|
-
const x = t.tested
|
|
989
|
-
const edit = (label) => h('button', { className: 'small', textContent: label, onclick: (e) => (e.stopPropagation(), openSetup(t)) })
|
|
990
|
-
const [cls, text, action] =
|
|
991
|
-
!x ? ['none', 'looking it up in models.dev…', null]
|
|
992
|
-
: x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and Refresh from models.dev
|
|
993
|
-
: ['ok', `✓ ${testedText(x)} · ${Object.keys(x.set ?? {}).length ? 'yours' : FROM[x.source]}`, edit('Edit')]
|
|
994
|
-
const about = x?.source === 'assumed' ? "not in models.dev: Retry, or set up what it can do" : text
|
|
995
|
-
return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text, title: about }), action)
|
|
996
|
-
}
|
|
997
|
-
|
|
998
1041
|
// Test: one short message to the model, to see that it really answers (the key, access to it, its
|
|
999
1042
|
// name, the server). The result stays under it until the page is reloaded.
|
|
1000
1043
|
const TRIES = {} // "provider/model" → { going } | { ok, ms, reply, at } | { ok: false, status, error, at } (from the server)
|
|
@@ -1077,7 +1120,7 @@ function showRun(run) {
|
|
|
1077
1120
|
if (run?.over) toast(`${run.stopping ? `stopped after ${run.done} of ${run.total}: ` : ''}${run.good} of ${run.done} work${run.good === run.done ? '' : `; ${run.done - run.good} don't (Works? says why, on hover)`}`, run.good < run.done)
|
|
1078
1121
|
} else
|
|
1079
1122
|
job('test-all', {
|
|
1080
|
-
where: ['table'
|
|
1123
|
+
where: ['table'],
|
|
1081
1124
|
what: run.stopping ? 'Stopping the tests…' : `Testing ${run.some ? '' : 'your '}${run.total} model${run.total === 1 ? '' : 's'}${run.each > 1 ? ` · ${run.each} at a time per provider` : ''}`,
|
|
1082
1125
|
done: run.done, total: run.total, stopping: run.stopping,
|
|
1083
1126
|
detail: [h('span', { className: 'ok', textContent: `✓ ${run.good} work` }), ' · ', h('span', { className: 'bad', textContent: `✗ ${run.done - run.good} don't` }), run.busy.length ? ` · now: ${run.busy.join(', ')}` : '', Object.keys(run.paused ?? {}).length ? ` · paused (busy, 429): ${Object.entries(run.paused).map(([p, secs]) => `${p} ${secs}s`).join(', ')}` : ''],
|
|
@@ -1089,7 +1132,7 @@ function showRun(run) {
|
|
|
1089
1132
|
// models.dev looked up again: one model (its Retry), or all of yours (Refresh from models.dev)
|
|
1090
1133
|
const retry = (t) => h('button', { className: 'small', textContent: 'Retry', title: 'look it up in models.dev again', onclick: (e) => (e.stopPropagation(), lookUp(t)) })
|
|
1091
1134
|
async function lookUp(t) {
|
|
1092
|
-
job('models.dev', { where: ['catalog', '
|
|
1135
|
+
job('models.dev', { where: ['catalog', 'table'], what: t ? `Looking up ${t.model} in models.dev` : 'Asking models.dev about your models', detail: 'context, tool calls, thinking, images' })
|
|
1093
1136
|
const r = await act('/api/models-dev', t ? { provider: t.provider, model: t.model } : {})
|
|
1094
1137
|
job('models.dev')
|
|
1095
1138
|
if (r) toast(t ? (r.found ? `${t.model}: found in models.dev` : `${t.model}: still not in models.dev`) : `models.dev: ${r.found} of ${r.of} found`)
|
|
@@ -1179,77 +1222,27 @@ function drawProviders(s) {
|
|
|
1179
1222
|
// the steps: always on the page (nothing jumps when one is done), ticked off as you go
|
|
1180
1223
|
put($('steps'), [
|
|
1181
1224
|
['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
|
|
1182
|
-
['Choose
|
|
1225
|
+
['Choose models', 'click one, or tick several and add them', order.length ? 'done' : s.providers.length ? 'now' : ''],
|
|
1183
1226
|
['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
|
|
1184
1227
|
].map(([t, d, cls], i) => h('div', { className: 'step ' + cls }, h('span', { className: 'num', textContent: cls === 'done' ? '✓' : i + 1 }), h('div', {}, h('b', { textContent: t }), d))))
|
|
1228
|
+
$('steps').hidden = order.length > 0 // the first start only
|
|
1185
1229
|
$('p-work').hidden = none
|
|
1230
|
+
// your list, at the top: how many, and the way to its page (order, tests, what to do when one fails)
|
|
1231
|
+
$('p-mine').hidden = !order.length
|
|
1232
|
+
$('p-mine').textContent = `Your models (${order.length}) →`
|
|
1233
|
+
$('p-mine').title = order.length ? `#1: ${order[0].provider} · ${order[0].model}. Open the Your models page: the order, tests, and what happens when one fails` : ''
|
|
1186
1234
|
fillHeight()
|
|
1187
1235
|
|
|
1188
|
-
//
|
|
1189
|
-
const side = innerWidth > 860 ? 'on the left' : 'below' // where the models are (a phone: under this)
|
|
1190
|
-
const save = (list, done) => saveOrder(list, done)
|
|
1191
|
-
const move = (i, j) => {
|
|
1192
|
-
if (i === j) return
|
|
1193
|
-
const list = [...order]
|
|
1194
|
-
const [t] = list.splice(i, 1)
|
|
1195
|
-
list.splice(j, 0, t)
|
|
1196
|
-
return save(list, list[0] !== order[0] ? `HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1197
|
-
}
|
|
1198
|
-
// a row of your list (the last: where the next one goes)
|
|
1199
|
-
const mineRow = (i) => {
|
|
1200
|
-
if (i === order.length) return h('li', { className: 'next', textContent: `#${order.length + 1} · click a model ${side} to add it here (a fallback)` })
|
|
1201
|
-
const t = order[i]
|
|
1202
|
-
return h('li', {
|
|
1203
|
-
className: 'in' + (i === 0 ? ' use' : ''), tabIndex: 0, 'data-key': `${t.provider}/${t.model}`, title: order.length > 1 ? 'Drag to move it (or Alt+↑ / Alt+↓)' : '',
|
|
1204
|
-
onkeydown: (e) => e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && (e.preventDefault(), move(i, Math.max(0, Math.min(order.length - 1, i + (e.key === 'ArrowUp' ? -1 : 1))))),
|
|
1205
|
-
},
|
|
1206
|
-
h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true', style: order.length > 1 ? '' : 'visibility:hidden' }),
|
|
1207
|
-
h('span', { className: 'n', textContent: `#${i + 1}`, title: i === 0 ? 'in use: the one HeartCode uses' : 'a fallback: tried, in order, when the ones above fail' }),
|
|
1208
|
-
h('span', { className: 'm', textContent: t.model, title: t.model }),
|
|
1209
|
-
h('span', { className: 'p' + (i === 0 ? ' use' : ''), textContent: t.provider, title: `${t.provider}${i === 0 ? ': in use' : ''}` }),
|
|
1210
|
-
testLine(t),
|
|
1211
|
-
h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
|
|
1212
|
-
}
|
|
1213
|
-
const mine = order.length ? h('ol', { className: 'olist' }) : null
|
|
1214
|
-
const mineV = mine && virtual(mine, { count: order.length + 1, height: MINE_ROW, row: mineRow })
|
|
1215
|
-
if (mineV) mineV.items = order.length
|
|
1216
|
-
put($('inuse'),
|
|
1217
|
-
h('div', { className: 'mine-top' },
|
|
1218
|
-
h('div', { className: 'mine-title' },
|
|
1219
|
-
h('h2', { textContent: 'Your models' }),
|
|
1220
|
-
h('button', { className: 'small', textContent: 'Table', style: order.length ? '' : 'visibility:hidden', title: 'all of them in a table, to compare and sort', onclick: () => (location.hash = 'models') }),
|
|
1221
|
-
h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
|
|
1222
|
-
h('button', { className: 'small danger', textContent: 'Remove all', title: 'take every model off this list', style: order.length ? '' : 'visibility:hidden', onclick: () => confirm(`Take all ${order.length} model${order.length === 1 ? '' : 's'} off your list? HeartCode then has none to use until you choose one. (They stay under their providers.)`) && saveOrder([], 'all removed: no model chosen now') })),
|
|
1223
|
-
h('p', { className: 'sub', style: 'margin:0', textContent: order.length > 1 ? 'HeartCode uses #1. If it fails, it tries #2, then #3… and round again. Drag to change the order.' : order.length ? `HeartCode uses this one. Click more ${side} to add fallbacks: tried, in order, when one fails.` : `The models HeartCode uses, in the order it tries them. Click one ${side} to start.` })),
|
|
1224
|
-
jobSlot('mine'),
|
|
1225
|
-
order.length ? h('div', { className: 'ohead' }, h('span'), h('span', { textContent: '#' }), h('span', { textContent: 'Model' }), h('span', { textContent: 'Provider' }), h('span', { textContent: 'What it can do' }), h('span')) : null,
|
|
1226
|
-
order.length ? mine
|
|
1227
|
-
: h('div', { className: 'mine-empty' }, h('b', { textContent: 'No model chosen yet' }), `Click a model ${side}: it becomes #1, the one HeartCode uses.`),
|
|
1228
|
-
order.length > 1 ? h('div', { className: 'mine-rules' },
|
|
1229
|
-
h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
|
|
1230
|
-
h('div', { className: 'row', style: 'gap:8px' },
|
|
1231
|
-
h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
|
|
1232
|
-
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model (rate limits, outages)']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
|
|
1233
|
-
s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first', style: 'width:76px', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => save(order, 'saved', { retries: Number(e.target.value) }) }) : null),
|
|
1234
|
-
h('div', { className: 'hint', textContent: { failure: 'Any error moves on at once, to another provider\'s model first. It stops only when every model fails.', retries: 'A rate limit or outage: the same model is tried again (with a short wait) that many times first. Any other error moves on at once.' }[s.rotation.on] ?? 'Any error moves on at once, to another provider\'s model first.' }),
|
|
1235
|
-
h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
|
|
1236
|
-
h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
|
|
1237
|
-
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
|
|
1238
|
-
|
|
1239
|
-
mineV?.draw()
|
|
1240
|
-
sortable(mineV, move)
|
|
1241
|
-
|
|
1242
|
-
// left: every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
1236
|
+
// every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
1243
1237
|
// again only when what it lists changes (a provider, its models, the search); a change to your
|
|
1244
|
-
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
const
|
|
1248
|
-
put($('lists-all'), [sw({ checked: allOn, 'aria-label': "load every provider's model list", onchange: (e) => act('/api/provider/list', { on: e.target.checked }, `every provider's model list ${e.target.checked ? 'on' : 'off'}`) }), 'Load all lists'])
|
|
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])
|
|
1249
1242
|
if (shape !== catalogShape) (buildCatalog(s), applyFolds())
|
|
1250
1243
|
catalogShape = shape
|
|
1251
1244
|
markCatalog()
|
|
1252
|
-
$('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…`
|
|
1253
1246
|
$('n-providers').textContent = s.providers.length || ''
|
|
1254
1247
|
}
|
|
1255
1248
|
// Your models: pick one up (anywhere on it with a mouse, by its ⋮⋮ on a touch screen) and the
|
|
@@ -1258,7 +1251,6 @@ function drawProviders(s) {
|
|
|
1258
1251
|
// Drag a row of a long list (virtual) to move it: it follows the pointer, the others make room, and
|
|
1259
1252
|
// near the list's top or bottom edge it scrolls (faster nearer the edge). onMove(from, to) when
|
|
1260
1253
|
// dropped; the list is drawn again once the order is saved (put back if saving failed).
|
|
1261
|
-
const MINE_ROW = 50 // your models, on the right
|
|
1262
1254
|
const TABLE_ROW = 46 // the Your models page's table
|
|
1263
1255
|
function sortable(v, onMove) {
|
|
1264
1256
|
if (!v || v.items < 2) return
|
|
@@ -1384,6 +1376,80 @@ function canCell(d, mine) {
|
|
|
1384
1376
|
const text = [can, priceText(price)].filter(Boolean).join(' · ')
|
|
1385
1377
|
return text ? h('span', { className: 'can', textContent: text }) : null
|
|
1386
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()
|
|
1387
1453
|
// every model added to a provider by name forgotten; the ones in your list leave it too
|
|
1388
1454
|
async function clearAdded(p) {
|
|
1389
1455
|
const inList = S.order.filter((t) => t.provider === p.name && p.added.includes(t.model))
|
|
@@ -1392,12 +1458,55 @@ async function clearAdded(p) {
|
|
|
1392
1458
|
act('/api/provider/added', { name: p.name, all: true }, `${p.name}: the models you added are forgotten`)
|
|
1393
1459
|
}
|
|
1394
1460
|
const CAT_ROW = 56 // a model's row on the left: its name, and what it can do under it
|
|
1395
|
-
|
|
1461
|
+
// The models ticked (provider → Set of ids): kept while you search, so ones ticked for one search
|
|
1462
|
+
// and the next are added (or taken off) together. SHOWN: each provider's models shown now (the
|
|
1463
|
+
// search's matches).
|
|
1464
|
+
const PICKED_CAT = new Map()
|
|
1465
|
+
const SHOWN = new Map()
|
|
1466
|
+
const pickedOf = (name) => PICKED_CAT.get(name) ?? PICKED_CAT.set(name, new Set()).get(name)
|
|
1467
|
+
// The ticked models (every provider's) and what to do with them, on the bar over the list: all the
|
|
1468
|
+
// shown ones ticked (or none), added to your list, taken off it. The tick boxes drawn now (rows and
|
|
1469
|
+
// each provider's) follow.
|
|
1470
|
+
function drawPicks() {
|
|
1471
|
+
if (!S) return
|
|
1472
|
+
const all = [...PICKED_CAT].flatMap(([provider, ids]) => [...ids].map((model) => ({ provider, model })))
|
|
1473
|
+
const shown = [...SHOWN].flatMap(([provider, ids]) => ids.map((model) => ({ provider, model })))
|
|
1474
|
+
const isPicked = (t) => Boolean(PICKED_CAT.get(t.provider)?.has(t.model))
|
|
1475
|
+
const toAdd = all.filter((t) => placeOf(t.provider, t.model) === undefined)
|
|
1476
|
+
const toTake = all.filter((t) => placeOf(t.provider, t.model) !== undefined)
|
|
1477
|
+
for (const box of $('p-list').querySelectorAll('input.pick')) box.checked = Boolean(PICKED_CAT.get(box.closest('section').dataset.provider)?.has(box.dataset.model))
|
|
1478
|
+
for (const box of $('p-list').querySelectorAll('input.pick-all')) {
|
|
1479
|
+
const ids = SHOWN.get(box.dataset.provider) ?? []
|
|
1480
|
+
const n = ids.filter((id) => PICKED_CAT.get(box.dataset.provider)?.has(id)).length
|
|
1481
|
+
box.checked = n > 0 && n === ids.length
|
|
1482
|
+
box.indeterminate = n > 0 && n < ids.length
|
|
1483
|
+
}
|
|
1484
|
+
const top = $('pick-shown')
|
|
1485
|
+
const n = shown.filter(isPicked).length
|
|
1486
|
+
top.checked = n > 0 && n === shown.length
|
|
1487
|
+
top.indeterminate = n > 0 && n < shown.length
|
|
1488
|
+
top.disabled = !shown.length
|
|
1489
|
+
top.title = top.checked ? 'unselect every model shown' : `select every model shown (${shown.length}${modelQuery.trim() ? ': the matches' : ''})`
|
|
1490
|
+
const take = () => {
|
|
1491
|
+
const off = new Set(toTake.map(keyOf))
|
|
1492
|
+
const rest = S.order.filter((t) => !off.has(keyOf(t)))
|
|
1493
|
+
if (!confirm(`Take ${toTake.length} model${toTake.length === 1 ? '' : 's'} off your list?${!rest.length ? ' None is left then: HeartCode has no model to use until you choose one.' : keyOf(rest[0]) !== keyOf(S.order[0]) ? ` HeartCode then uses ${rest[0].provider} · ${rest[0].model}.` : ''}`)) return
|
|
1494
|
+
PICKED_CAT.clear()
|
|
1495
|
+
saveOrder(rest, `${toTake.length} removed`)
|
|
1496
|
+
}
|
|
1497
|
+
const was = $('picks').hidden
|
|
1498
|
+
$('picks').hidden = !all.length
|
|
1499
|
+
put($('picks'),
|
|
1500
|
+
h('span', { className: 'n', textContent: `${all.length} selected` }),
|
|
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) }),
|
|
1503
|
+
toTake.length ? h('button', { className: 'small danger', textContent: `Remove ${toTake.length} from your list`, onclick: take }) : null,
|
|
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)
|
|
1505
|
+
if (was !== $('picks').hidden) fillHeight() // the list gives it room
|
|
1506
|
+
}
|
|
1396
1507
|
function buildCatalog(s) {
|
|
1397
|
-
CATALOG = []
|
|
1398
1508
|
const q = modelQuery.trim()
|
|
1399
|
-
|
|
1400
|
-
const anyMatch = !q || s.providers.some((p) => (MODELS[p.name]?.list ?? []).some((id) => id.toLowerCase().includes(q.toLowerCase())))
|
|
1509
|
+
SHOWN.clear()
|
|
1401
1510
|
put($('p-list'), s.providers.map((p) => {
|
|
1402
1511
|
if (!p.list) MODELS[p.name] = { list: [], details: {}, off: true } // its list switched off: never asked for
|
|
1403
1512
|
else loadModels(p.name, MODELS[p.name]?.off)
|
|
@@ -1405,61 +1514,65 @@ function buildCatalog(s) {
|
|
|
1405
1514
|
// and the models added by name (and any of yours it doesn't list), marked as yours
|
|
1406
1515
|
const listed = new Set(m.list ?? [])
|
|
1407
1516
|
const yours = [...new Set([...p.added, ...s.order.filter((t) => t.provider === p.name).map((t) => t.model)])].filter((id) => !listed.has(id))
|
|
1408
|
-
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])))
|
|
1409
1518
|
const row = (id, own) =>
|
|
1410
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)) },
|
|
1411
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,
|
|
1412
1522
|
own ? h('span', { className: 'tag', textContent: 'not in its list' }) : p.added.includes(id) ? h('span', { className: 'tag', textContent: 'added by you' }) : null,
|
|
1413
1523
|
rowTail(p.name, id))
|
|
1414
|
-
// one you added: ✕ forgets it (it leaves your list too)
|
|
1415
|
-
const
|
|
1524
|
+
// its tick box (to add or take off several at once), and one you added: ✕ forgets it (it leaves your list too)
|
|
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()) })
|
|
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 :
|
|
1416
1529
|
h('button', { className: 'forget', textContent: '✕', title: 'forget this model', 'aria-label': `forget ${id}`, onclick: async () => {
|
|
1417
1530
|
if (placeOf(p.name, id) !== undefined && !confirm(`Forget ${id}? It leaves your list too.`)) return
|
|
1418
1531
|
if (placeOf(p.name, id) !== undefined) await saveOrder(S.order.filter((t) => t.provider !== p.name || t.model !== id), `${id} removed`)
|
|
1419
1532
|
act('/api/provider/added', { name: p.name, model: id, remove: true }, `forgot ${id}`)
|
|
1420
1533
|
} }))
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
const
|
|
1428
|
-
if (
|
|
1429
|
-
name.
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
act('/api/provider/added', { name: p.name, model: id }).then((r) => r && showRow(p.name, id, `${id} added to ${p.name}'s models: click it to use it`))
|
|
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)
|
|
1433
1545
|
}
|
|
1434
|
-
|
|
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
|
|
1435
1548
|
return h('section', { 'data-provider': p.name },
|
|
1436
1549
|
// the header folds it (a click on it, or its arrow; not on its buttons and switch)
|
|
1437
1550
|
h('div', { className: 'prov-h', onclick: (e) => !e.target.closest('button:not(.fold), input, label, a') && fold(p.name) },
|
|
1438
1551
|
h('button', { className: 'fold', type: 'button', textContent: '▾', 'aria-label': `fold ${p.name}`, 'aria-expanded': 'true' }),
|
|
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,
|
|
1439
1553
|
h('div', { className: 'who' },
|
|
1440
|
-
h('span', { className: 'name', textContent: p.name }),
|
|
1441
|
-
m.off ? h('span', { className: 'tag', textContent: 'list off' }) : 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'}` }),
|
|
1442
|
-
|
|
1443
|
-
h('
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
h('button', { className: 'small
|
|
1448
|
-
h('div', { className: 'byname' }, name, h('button', { className: 'small byname-go', textContent: 'Add', onclick: go }),
|
|
1449
|
-
p.added.length ? h('button', { className: 'small danger', textContent: `Clear added (${p.added.length})`, title: `forget the ${p.added.length} model${p.added.length === 1 ? '' : 's'} you added to ${p.name} by name`, onclick: () => clearAdded(p) }) : null),
|
|
1554
|
+
h('span', { className: 'name', textContent: p.name }),
|
|
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'}` }),
|
|
1556
|
+
h('span', { className: 'tag inlist' }),
|
|
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' })),
|
|
1561
|
+
h('button', { className: 'small', textContent: 'Edit', title: 'its address and key, its model list, delete it', onclick: () => openForm(p) })),
|
|
1450
1562
|
// what's listed first; loading and errors under it (the models already shown stay where they are)
|
|
1451
|
-
typed.length ? h('div', { className: 'rows' }, typed) : null,
|
|
1452
1563
|
(() => {
|
|
1453
1564
|
if (!ids.length) return null
|
|
1454
|
-
|
|
1565
|
+
SHOWN.set(p.name, ids)
|
|
1455
1566
|
const box = h('div', { className: 'rows' })
|
|
1456
1567
|
box._ids = ids
|
|
1457
1568
|
box._v = virtual(box, { count: ids.length, height: CAT_ROW, row: (i) => item(ids[i]), scroller: $('p-list') })
|
|
1458
1569
|
return box
|
|
1459
1570
|
})(),
|
|
1460
|
-
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:
|
|
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,
|
|
1461
1573
|
m.loading ? h('div', { className: 'cat-note', textContent: 'Loading its models…' }) : null)
|
|
1462
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.)" }))
|
|
1463
1576
|
}
|
|
1464
1577
|
// --- Your models page: the list as a table; sorted by several columns at once (each a tie-break
|
|
1465
1578
|
// for the ones before it), the sort kept in this browser ---
|
|
@@ -1609,6 +1722,18 @@ function drawMine(s) {
|
|
|
1609
1722
|
order.length > 1 ? h('button', { className: 'small primary', textContent: 'Save order', title: 'save the order shown as your model order (#1 first)', onclick: () => keep(view) }) : null,
|
|
1610
1723
|
order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
|
|
1611
1724
|
order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll() }) : null),
|
|
1725
|
+
// when the one in use fails, and what a new message starts with
|
|
1726
|
+
order.length > 1 ? h('div', { className: 'mt-rules' },
|
|
1727
|
+
h('span', { className: 'rule' },
|
|
1728
|
+
h('label', { for: 'r-on', textContent: 'When a model fails, go on to the next' }),
|
|
1729
|
+
h('select', { id: 'r-on', onchange: (e) => saveOrder(order, 'saved', { on: e.target.value }) },
|
|
1730
|
+
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
|
|
1731
|
+
s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first (rate limits, outages)', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => saveOrder(order, 'saved', { retries: Number(e.target.value) }) }) : null),
|
|
1732
|
+
h('span', { className: 'rule' },
|
|
1733
|
+
h('label', { for: 'r-next', textContent: 'A new message starts with' }),
|
|
1734
|
+
h('select', { id: 'r-next', onchange: (e) => saveOrder(order, 'saved', { next: e.target.value }) },
|
|
1735
|
+
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))),
|
|
1736
|
+
h('span', { className: 'hint', textContent: s.rotation.on === 'retries' ? 'A rate limit or outage: that model is tried again that many times first; any other error moves on at once.' : "Any error moves on at once, to another provider's model first." })) : null,
|
|
1612
1737
|
order.length
|
|
1613
1738
|
? h('div', { className: 'mt-scroll' }, h('div', { className: 'mt' },
|
|
1614
1739
|
jobSlot('table'),
|
|
@@ -1663,6 +1788,7 @@ function markCatalog() {
|
|
|
1663
1788
|
tag.hidden = !n
|
|
1664
1789
|
tag.textContent = `${n} in your list${S.order[0]?.provider === sec.dataset.provider ? ' · #1' : ''}`
|
|
1665
1790
|
}
|
|
1791
|
+
drawPicks()
|
|
1666
1792
|
}
|
|
1667
1793
|
|
|
1668
1794
|
function drawUsing(s) {
|
|
@@ -1965,6 +2091,8 @@ function render(s) {
|
|
|
1965
2091
|
const p = s.prefs
|
|
1966
2092
|
catSort = p.catSort === 'price' ? 'price' : 'name'
|
|
1967
2093
|
$('m-sort').value = catSort
|
|
2094
|
+
freeOnly = p.freeOnly === true
|
|
2095
|
+
$('free-only').checked = freeOnly
|
|
1968
2096
|
FOLDED.clear()
|
|
1969
2097
|
for (const n of Array.isArray(p.folded) ? p.folded : []) FOLDED.add(n)
|
|
1970
2098
|
SORT = Array.isArray(p.sort) ? p.sort.filter((x) => COLS[x?.key]) : []
|
|
@@ -2038,7 +2166,6 @@ if (token) {
|
|
|
2038
2166
|
const t = S.order[placeOf(...d.key.split(/\/(.*)/s))]
|
|
2039
2167
|
if (!t) continue
|
|
2040
2168
|
Object.assign(t, { tested: d.tested ?? t.tested, known: d.known ?? t.known })
|
|
2041
|
-
document.querySelector(`#inuse li[data-key="${CSS.escape(d.key)}"] .test`)?.replaceWith(testLine(t))
|
|
2042
2169
|
}
|
|
2043
2170
|
news.clear()
|
|
2044
2171
|
drawMine(S)
|
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)))
|