heartcode 0.1.6 → 0.1.8
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/SKILL.md +24 -10
- package/bin/heartcode.js +70 -30
- package/package.json +1 -1
- package/prompts/agent.md +15 -30
- package/prompts/guided.md +6 -6
- package/src/acp.js +3 -3
- package/src/agent.js +168 -68
- package/src/client.js +162 -81
- package/src/config.js +5 -2
- package/src/context.js +2 -2
- package/src/director.js +8 -5
- package/src/edit.js +5 -6
- package/src/format.js +18 -9
- package/src/guided.js +27 -55
- package/src/headless.js +18 -11
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +6 -3
- package/src/settings.html +508 -148
- package/src/settings.js +242 -35
- package/src/tools.js +138 -41
- package/src/usage.js +9 -4
package/src/settings.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from 'node:fs'
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import crypto from 'node:crypto'
|
|
10
10
|
import os from 'node:os'
|
|
11
|
-
import { HOME, saveConfig, readJson, writeJson } from './config.js'
|
|
11
|
+
import { HOME, saveConfig, readJson, writeJson, byName } from './config.js'
|
|
12
12
|
import { DEFAULT_SERVERS } from './mcp.js'
|
|
13
13
|
import { sections, BUILTIN_PROMPTS } from './prompts.js'
|
|
14
14
|
import { Client } from './client.js'
|
|
@@ -78,11 +78,37 @@ const ROTATION = { on: ['failure', 'retries', 'any'], next: ['first', 'stay'] }
|
|
|
78
78
|
const rotationOf = (cfg) => ({ on: 'failure', retries: 2, next: 'first', ...cfg.rotation, then: (cfg.rotation?.then ?? []).filter((t) => cfg.providers[t.provider]) })
|
|
79
79
|
|
|
80
80
|
const mask = (k) => (k ? (k.length > 12 ? `${k.slice(0, 4)}…${k.slice(-4)}` : '…') : '')
|
|
81
|
+
async function schemeFor(address) {
|
|
82
|
+
try {
|
|
83
|
+
await fetch(`https://${address.replace(/\/+$/, '')}/models`, { redirect: 'manual', signal: AbortSignal.timeout(8000) })
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// https can't connect at all (refused, or a server that only speaks http): http, if that answers
|
|
86
|
+
if (e.name !== 'TimeoutError') {
|
|
87
|
+
try {
|
|
88
|
+
await fetch(`http://${address.replace(/\/+$/, '')}/models`, { redirect: 'manual', signal: AbortSignal.timeout(8000) })
|
|
89
|
+
return `http://${address}`
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return `https://${address}` // it answered on https, or is slow: never the key in the clear on a guess
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
const safeName = (n) => String(n ?? '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
82
97
|
const skillsDir = () => path.join(HOME, 'skills')
|
|
83
98
|
|
|
84
99
|
// cfg: the running HeartCode's config (changed in place, and saved). apply(what): after a change,
|
|
85
100
|
// to bring it into the running sessions ('model', 'setting:<name>', 'theme', 'mcp', 'skill', 'keys').
|
|
101
|
+
// Each provider's model list, kept after it's loaded: { name: { baseUrl, at, models, details } },
|
|
102
|
+
// what the provider said (names, sizes, prices; not what's known from models.dev or tests, which
|
|
103
|
+
// changes). Asked for again when its list is switched on or off, or the provider is changed.
|
|
104
|
+
const LISTS = path.join(HOME, 'model-lists.json')
|
|
105
|
+
function forgetList(...names) {
|
|
106
|
+
const all = readJson(LISTS, {})
|
|
107
|
+
if (!names.some((n) => n in all)) return
|
|
108
|
+
for (const n of names) delete all[n]
|
|
109
|
+
writeJson(LISTS, all)
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
87
113
|
const token = crypto.randomBytes(24).toString('hex')
|
|
88
114
|
const changed = (what) => {
|
|
@@ -168,8 +194,139 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
168
194
|
return clip(`${e.status ? `${e.status}: ` : ''}${said}`, 300)
|
|
169
195
|
}
|
|
170
196
|
|
|
197
|
+
// Model tests: one short message to a model, to see that it really answers (key, access, name, the
|
|
198
|
+
// server itself), in the requests log as a model test. Run here, not in the page: closing the page
|
|
199
|
+
// doesn't stop them. Each result is kept in ~/.heart/model-tests.json ("provider/model" → { ok, ms,
|
|
200
|
+
// reply | error, status, at }) and sent to the page as it comes.
|
|
201
|
+
const TESTS_FILE = path.join(HOME, 'model-tests.json')
|
|
202
|
+
const tests = readJson(TESTS_FILE, {})
|
|
203
|
+
let saving = null
|
|
204
|
+
const saveTests = () => (saving ??= setTimeout(() => ((saving = null), writeJson(TESTS_FILE, tests)), 1000)) // at most once a second
|
|
205
|
+
// a model taken off your list: its test result goes too (added again, it's not tested)
|
|
206
|
+
const forgetTests = () => {
|
|
207
|
+
const mine = new Set(state().order.map((t) => `${t.provider}/${t.model}`))
|
|
208
|
+
let gone = 0
|
|
209
|
+
for (const key of Object.keys(tests)) if (!mine.has(key)) (delete tests[key]), gone++
|
|
210
|
+
if (gone) saveTests()
|
|
211
|
+
}
|
|
212
|
+
// Never throws: { ok, ms, reply } or { ok: false, ms, status, error }; { stopped } when stop
|
|
213
|
+
// (a run's Stop) cancels it.
|
|
214
|
+
async function tryOne({ provider, model }, stop) {
|
|
215
|
+
const c = clientFor({ provider, model })
|
|
216
|
+
if (!c || !model) return { ok: false, error: `no provider "${provider}"` }
|
|
217
|
+
c.purpose = 'test'
|
|
218
|
+
const t0 = Date.now()
|
|
219
|
+
try {
|
|
220
|
+
const signal = stop ? AbortSignal.any([AbortSignal.timeout(90_000), stop]) : AbortSignal.timeout(90_000)
|
|
221
|
+
const r = await c.chat({ messages: [{ role: 'user', content: 'Reply with just the word OK.' }], stream: true }, { signal })
|
|
222
|
+
const reply = (r.content || '').trim()
|
|
223
|
+
if (!reply && !r.toolCalls.length) return { ok: false, ms: Date.now() - t0, error: `it answered with nothing${r.usage?.completion_tokens ? ` (${r.usage.completion_tokens} tokens it said it wrote)` : ''}` }
|
|
224
|
+
return { ok: true, ms: Date.now() - t0, reply: reply.slice(0, 80) }
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (stop?.aborted) return { stopped: true }
|
|
227
|
+
return { ok: false, ms: Date.now() - t0, status: e.status ?? null, error: e.name === 'TimeoutError' ? 'no answer within 90 seconds' : whyFailed(e), ...(e.retryAfter > 0 && { retryAfter: Number(e.retryAfter) }) }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// result: kept and sent to the page (given: a test already made, as a run decided to end it)
|
|
231
|
+
async function testOne(t, given) {
|
|
232
|
+
const key = `${t.provider}/${t.model}`
|
|
233
|
+
const result = { ...(given ?? (await tryOne(t))), at: Date.now() }
|
|
234
|
+
tests[key] = result
|
|
235
|
+
saveTests()
|
|
236
|
+
emit('tested', { key, result })
|
|
237
|
+
return result
|
|
238
|
+
}
|
|
239
|
+
// The run going now: every provider at once, and from each a few models at a time: one per 100
|
|
240
|
+
// models being tested (100: one at a time, 200: two…). A provider that says it's busy (429)
|
|
241
|
+
// isn't a result about the model: that provider goes down to one at a time and pauses (as long
|
|
242
|
+
// as it asks, else 15s, 30s… up to 2 minutes), and the model is tried again later; it's kept as
|
|
243
|
+
// a 429 only after the provider refused it RATE_TRIES times. A model taken off your list while
|
|
244
|
+
// it runs isn't tested. Stop: each provider finishes the one it's on.
|
|
245
|
+
const RATE_TRIES = 4
|
|
246
|
+
let run = null
|
|
247
|
+
const runState = () => ({
|
|
248
|
+
total: run.total, done: run.done, good: run.good, each: run.each, busy: [...run.busy.keys()], stopping: run.stopping, some: run.some, started: run.started,
|
|
249
|
+
paused: Object.fromEntries([...run.providers].filter(([, p]) => p.until > Date.now()).map(([name, p]) => [name, Math.ceil((p.until - Date.now()) / 1000)])),
|
|
250
|
+
})
|
|
251
|
+
// your models now ("provider/model"), to skip the ones taken off while a run goes
|
|
252
|
+
let mineOf = { then: undefined, model: undefined, keys: null }
|
|
253
|
+
const inMine = (key) => {
|
|
254
|
+
if (mineOf.then !== cfg.rotation?.then || mineOf.model !== cfg.model || mineOf.provider !== cfg.provider)
|
|
255
|
+
mineOf = { then: cfg.rotation?.then, model: cfg.model, provider: cfg.provider, keys: new Set(state().order.map((t) => `${t.provider}/${t.model}`)) }
|
|
256
|
+
return mineOf.keys.has(key)
|
|
257
|
+
}
|
|
258
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
259
|
+
async function startRun(list, some) {
|
|
260
|
+
const each = Math.max(1, Math.ceil(list.length / 100))
|
|
261
|
+
run = { total: list.length, done: 0, good: 0, each, busy: new Map(), pending: new Set(list.map((t) => `${t.provider}/${t.model}`)), stopping: false, some, started: Date.now(), providers: new Map(), cancel: new AbortController() }
|
|
262
|
+
emit('testrun', runState())
|
|
263
|
+
for (const t of list) {
|
|
264
|
+
if (!run.providers.has(t.provider)) run.providers.set(t.provider, { queue: [], cap: each, until: 0, pausedAt: 0, backoff: 15_000, refused: new Map() })
|
|
265
|
+
run.providers.get(t.provider).queue.push(t)
|
|
266
|
+
}
|
|
267
|
+
const ticker = setInterval(() => run && Object.keys(runState().paused).length && emit('testrun', runState()), 1000) // the pauses counting down
|
|
268
|
+
// a provider's worker n: its next model, while there's one and n is under its cap (a busy
|
|
269
|
+
// provider's cap goes down to one: the others stop; the last one left never does)
|
|
270
|
+
const worker = async (provider, n) => {
|
|
271
|
+
const p = run.providers.get(provider)
|
|
272
|
+
while (!run.stopping && p.queue.length && (n < p.cap || p.live === 1)) {
|
|
273
|
+
if (p.until > Date.now()) {
|
|
274
|
+
await wait(Math.min(200, p.until - Date.now()))
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
const t = p.queue.shift()
|
|
278
|
+
const key = `${t.provider}/${t.model}`
|
|
279
|
+
if (!inMine(key)) {
|
|
280
|
+
run.pending.delete(key)
|
|
281
|
+
run.total--
|
|
282
|
+
emit('testrun', runState())
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
run.busy.set(provider, (run.busy.get(provider) ?? 0) + 1)
|
|
286
|
+
const sent = Date.now()
|
|
287
|
+
const r = await tryOne(t, run.cancel.signal)
|
|
288
|
+
if (r.stopped) break // Stop: not a result (its last one stays)
|
|
289
|
+
run.busy.get(provider) > 1 ? run.busy.set(provider, run.busy.get(provider) - 1) : run.busy.delete(provider)
|
|
290
|
+
if (r.status === 429) {
|
|
291
|
+
const times = (p.refused.get(key) ?? 0) + 1
|
|
292
|
+
p.refused.set(key, times)
|
|
293
|
+
p.cap = 1
|
|
294
|
+
// a pause, longer each time; not for refusals of requests sent before the last pause began
|
|
295
|
+
// (several at once are one burst)
|
|
296
|
+
if (!(sent < p.pausedAt)) {
|
|
297
|
+
p.pausedAt = Date.now()
|
|
298
|
+
p.until = Date.now() + Math.min(120_000, r.retryAfter > 0 ? r.retryAfter * 1000 : p.backoff)
|
|
299
|
+
p.backoff = Math.min(120_000, p.backoff * 2)
|
|
300
|
+
}
|
|
301
|
+
log('test', `${provider} is busy (429): one at a time, pausing ${Math.round((p.until - Date.now()) / 1000)}s${times < RATE_TRIES ? `; ${t.model} again later` : ''}`)
|
|
302
|
+
if (times < RATE_TRIES) {
|
|
303
|
+
p.queue.push(t)
|
|
304
|
+
emit('testrun', runState())
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
r.error = `the provider kept saying it's busy (429), ${times} times with pauses between: try it again later`
|
|
308
|
+
} else if (r.ok) p.backoff = 15_000
|
|
309
|
+
await testOne(t, r)
|
|
310
|
+
run.pending.delete(key)
|
|
311
|
+
run.done++
|
|
312
|
+
if (r.ok) run.good++
|
|
313
|
+
emit('testrun', runState())
|
|
314
|
+
}
|
|
315
|
+
p.live--
|
|
316
|
+
}
|
|
317
|
+
for (const p of run.providers.values()) p.live = Math.min(each, p.queue.length)
|
|
318
|
+
try {
|
|
319
|
+
await Promise.all([...run.providers].flatMap(([provider, p]) => Array.from({ length: p.live }, (_, n) => worker(provider, n))))
|
|
320
|
+
} finally {
|
|
321
|
+
clearInterval(ticker)
|
|
322
|
+
const over = { ...runState(), over: true }
|
|
323
|
+
run = null
|
|
324
|
+
emit('testrun', over)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
171
328
|
const state = () => ({
|
|
172
|
-
providers: Object.entries(cfg.providers)
|
|
329
|
+
providers: Object.entries(byName(cfg.providers))
|
|
173
330
|
.filter(([n]) => n !== cfg.adhoc)
|
|
174
331
|
.map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, added: p.models ?? [] })),
|
|
175
332
|
provider: cfg.provider ?? null,
|
|
@@ -191,11 +348,13 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
191
348
|
'GET /api/state': () => state(),
|
|
192
349
|
'GET /api/log-size': () => logSize(),
|
|
193
350
|
'POST /api/clear-logs': () => ({ gone: clearLogs(), size: logSize() }),
|
|
194
|
-
'POST /api/provider': ({ name, baseUrl, apiKey, keepKey, was }) => {
|
|
351
|
+
'POST /api/provider': async ({ name, baseUrl, apiKey, keepKey, was }) => {
|
|
195
352
|
name = safeName(name)
|
|
196
353
|
if (!name) throw new Error('a name is needed')
|
|
197
354
|
baseUrl = String(baseUrl ?? '').trim()
|
|
198
|
-
|
|
355
|
+
// an address without http(s)://: whichever of the two the server answers on (any answer, even
|
|
356
|
+
// a 401, means it's there), https first
|
|
357
|
+
if (!/^https?:\/\//i.test(baseUrl)) baseUrl = await schemeFor(baseUrl)
|
|
199
358
|
try {
|
|
200
359
|
if (!new URL(baseUrl).hostname) throw new Error()
|
|
201
360
|
} catch {
|
|
@@ -211,12 +370,15 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
211
370
|
}
|
|
212
371
|
cfg.providers[name] = { ...old, baseUrl, apiKey: key } // its list switch and added models stay
|
|
213
372
|
if (!key) delete cfg.providers[name].apiKey
|
|
373
|
+
forgetList(name, ...(was ? [was] : [])) // a new URL or key: its models asked for again
|
|
214
374
|
save('model')
|
|
375
|
+
forgetTests()
|
|
215
376
|
return state()
|
|
216
377
|
},
|
|
217
378
|
'POST /api/provider/delete': ({ name }) => {
|
|
218
379
|
if (!cfg.providers[name]) throw new Error(`no provider "${name}"`)
|
|
219
380
|
delete cfg.providers[name]
|
|
381
|
+
forgetList(name)
|
|
220
382
|
if (cfg.rotation?.then) cfg.rotation.then = cfg.rotation.then.filter((t) => t.provider !== name)
|
|
221
383
|
if (cfg.provider === name) {
|
|
222
384
|
const [first, ...rest] = cfg.rotation?.then ?? [] // the next in the list becomes #1
|
|
@@ -224,23 +386,34 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
224
386
|
if (cfg.rotation) cfg.rotation.then = rest
|
|
225
387
|
}
|
|
226
388
|
save('model')
|
|
389
|
+
forgetTests()
|
|
227
390
|
return state()
|
|
228
391
|
},
|
|
229
392
|
// a provider's models, and what's known about each (for the page's tooltip): what the provider
|
|
230
393
|
// says (name, context, owner, date, description, price), HeartCode's price list, its own test
|
|
231
394
|
// Its model list switched off: HeartCode never asks it for its models; only the ones added by name show.
|
|
395
|
+
// one provider's list on or off, or every provider's (no name)
|
|
232
396
|
'POST /api/provider/list': ({ name, on }) => {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
397
|
+
if (name !== undefined && !cfg.providers[name]) throw new Error(`no provider "${name}"`)
|
|
398
|
+
for (const [n, p] of Object.entries(cfg.providers)) {
|
|
399
|
+
if (name !== undefined && n !== name) continue
|
|
400
|
+
if (on) delete p.list
|
|
401
|
+
else p.list = false
|
|
402
|
+
}
|
|
403
|
+
forgetList(...(name !== undefined ? [name] : Object.keys(cfg.providers))) // switched: loaded again
|
|
237
404
|
save('provider')
|
|
238
405
|
return state()
|
|
239
406
|
},
|
|
240
407
|
// A model added by its name (one the provider doesn't list, or its list is off): remembered.
|
|
241
|
-
|
|
408
|
+
// all: every model added by name forgotten at once
|
|
409
|
+
'POST /api/provider/added': ({ name, model, remove, all }) => {
|
|
242
410
|
const p = cfg.providers[name]
|
|
243
411
|
if (!p) throw new Error(`no provider "${name}"`)
|
|
412
|
+
if (all) {
|
|
413
|
+
delete p.models
|
|
414
|
+
save('provider')
|
|
415
|
+
return state()
|
|
416
|
+
}
|
|
244
417
|
model = String(model ?? '').trim()
|
|
245
418
|
if (!model) throw new Error('a model is needed')
|
|
246
419
|
const rest = (p.models ?? []).filter((m) => m !== model)
|
|
@@ -253,26 +426,32 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
253
426
|
const p = cfg.providers[name]
|
|
254
427
|
if (!p) throw new Error(`no provider "${name}"`)
|
|
255
428
|
if (p.list === false) return { models: [], details: {}, off: true }
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const price = priceOf(name, p.baseUrl, id) ?? (listed && Number.isFinite(listed.input) ? listed : null)
|
|
264
|
-
const profile = loadProfile(new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey, model: id }))
|
|
265
|
-
return [id, {
|
|
266
|
-
name: m.name && m.name !== id ? String(m.name) : undefined,
|
|
429
|
+
const lists = readJson(LISTS, {})
|
|
430
|
+
let saved = lists[name]?.baseUrl === p.baseUrl ? lists[name] : null
|
|
431
|
+
if (!saved) {
|
|
432
|
+
const entries = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).modelEntries()
|
|
433
|
+
const num = (...xs) => xs.map(Number).find((x) => x > 0)
|
|
434
|
+
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), {
|
|
435
|
+
name: m.name && m.name !== String(m.id ?? m.name) ? String(m.name) : undefined,
|
|
267
436
|
context: num(m.context_length, m.context_window, m.max_model_len, m.max_context_length, m.top_provider?.context_length),
|
|
268
437
|
owner: m.owned_by && m.owned_by !== 'system' ? String(m.owned_by) : undefined,
|
|
269
438
|
created: num(m.created) && Number(m.created) > 946684800 ? Number(m.created) * 1000 : undefined, // seconds; 2000 or later
|
|
270
439
|
description: m.description ? String(m.description).slice(0, 400) : undefined,
|
|
271
|
-
price:
|
|
440
|
+
price: listedPrice(m),
|
|
441
|
+
}])) }
|
|
442
|
+
writeJson(LISTS, { ...readJson(LISTS, {}), [name]: saved })
|
|
443
|
+
}
|
|
444
|
+
await loadPrices().catch(() => null)
|
|
445
|
+
// and what's known now: its test or setup, and models.dev
|
|
446
|
+
const details = Object.fromEntries(saved.models.map((id) => {
|
|
447
|
+
const profile = loadProfile(new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey, model: id }))
|
|
448
|
+
return [id, {
|
|
449
|
+
...saved.details[id],
|
|
272
450
|
tested: profile ? { mode: strategy(profile).actions, context: profile.context, thinking: Boolean(profile.thinking), vision: strategy(profile).vision } : undefined,
|
|
451
|
+
known: limitsOf(name, p.baseUrl, id) ?? undefined,
|
|
273
452
|
}]
|
|
274
453
|
}))
|
|
275
|
-
return { models:
|
|
454
|
+
return { models: saved.models, details, at: saved.at }
|
|
276
455
|
},
|
|
277
456
|
'POST /api/model': ({ provider, model }) => {
|
|
278
457
|
if (!cfg.providers[provider]) throw new Error(`no provider "${provider}"`)
|
|
@@ -281,6 +460,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
281
460
|
if (cfg.rotation?.then) cfg.rotation.then = cfg.rotation.then.filter((t) => t.provider !== provider || t.model !== cfg.model) // #1 now, not also further down
|
|
282
461
|
save('model')
|
|
283
462
|
test([{ provider, model: cfg.model }])
|
|
463
|
+
forgetTests()
|
|
284
464
|
return state()
|
|
285
465
|
},
|
|
286
466
|
// the model list, in order (#1 is the model), and when to go on to the next
|
|
@@ -305,6 +485,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
305
485
|
saveConfig(cfg)
|
|
306
486
|
changed(changed1 ? 'model' : 'setting:rotation')
|
|
307
487
|
test(clean)
|
|
488
|
+
forgetTests()
|
|
308
489
|
return state()
|
|
309
490
|
},
|
|
310
491
|
// models.dev again (one model, or all of yours): its list fetched anew, and each model's profile
|
|
@@ -312,19 +493,22 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
312
493
|
// for it stay above). A running HeartCode takes it at once.
|
|
313
494
|
// Test: one short message to the model, to see that it answers (key, access, name, the server
|
|
314
495
|
// itself). In the requests log as a model test. Never throws: { ok, ms, reply } or { ok: false, error }.
|
|
315
|
-
'POST /api/try':
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
496
|
+
'POST /api/try': ({ provider, model } = {}) => testOne({ provider, model: String(model ?? '') }),
|
|
497
|
+
// the tests' results, and the run going now (with the models it hasn't tested yet)
|
|
498
|
+
'POST /api/tests': () => ({ results: tests, run: run && { ...runState(), pending: [...run.pending] } }),
|
|
499
|
+
// a run: all your models, or the ones given (only); one at a time if one is going already
|
|
500
|
+
'POST /api/tests/start': ({ only } = {}) => {
|
|
501
|
+
if (run) throw new Error('a test is running: wait for it, or stop it')
|
|
502
|
+
const order = state().order
|
|
503
|
+
const mine = new Set(order.map((t) => `${t.provider}/${t.model}`))
|
|
504
|
+
const list = Array.isArray(only) ? only.map((t) => ({ provider: String(t?.provider ?? ''), model: String(t?.model ?? '') })).filter((t) => mine.has(`${t.provider}/${t.model}`)) : order.map(({ provider, model }) => ({ provider, model }))
|
|
505
|
+
if (!list.length) throw new Error('no model to test')
|
|
506
|
+
startRun(list, Array.isArray(only))
|
|
507
|
+
return { run: { ...runState(), pending: [...run.pending] } }
|
|
508
|
+
},
|
|
509
|
+
'POST /api/tests/stop': () => {
|
|
510
|
+
if (run && !run.stopping) (run.stopping = true), run.cancel.abort(), emit('testrun', runState()) // the ones on their way: cancelled
|
|
511
|
+
return {}
|
|
328
512
|
},
|
|
329
513
|
'POST /api/models-dev': async ({ provider, model } = {}) => {
|
|
330
514
|
await loadPrices({ force: true })
|
|
@@ -534,6 +718,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
534
718
|
})
|
|
535
719
|
return new Promise((resolve, reject) => {
|
|
536
720
|
server.on('error', reject)
|
|
721
|
+
forgetTests() // results of models no longer yours, left from before
|
|
537
722
|
server.listen(port, '127.0.0.1', () => {
|
|
538
723
|
const url = `http://127.0.0.1:${server.address().port}/?t=${token}`
|
|
539
724
|
log('web', `settings page at 127.0.0.1:${server.address().port}`)
|
|
@@ -548,6 +733,28 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
548
733
|
})
|
|
549
734
|
}
|
|
550
735
|
|
|
736
|
+
// What a model costs at this provider, per million tokens, as its own model list says: { input,
|
|
737
|
+
// output } (either may be missing), or undefined. Not models.dev's: the same model costs differently
|
|
738
|
+
// at different providers. The forms lists use: pricing.prompt/completion (per token, OpenRouter's);
|
|
739
|
+
// pricing.input/output with a unit (per million when it says so; no unit: per token when it's that
|
|
740
|
+
// small); input_price/output_price (per token); pricepermilliontokens; only a free tier.
|
|
741
|
+
export function listedPrice(m) {
|
|
742
|
+
const num = (x) => (x === null || x === undefined || x === '' || !(Number(x) >= 0) ? undefined : Number(x)) // -1: varies (a router's "auto")
|
|
743
|
+
const pr = m.pricing && typeof m.pricing === 'object' && !Array.isArray(m.pricing) ? m.pricing : {}
|
|
744
|
+
const unit = (x) => (pr.unit ? (/1m|million/i.test(pr.unit) ? 1 : 1e6) : x < 0.001 ? 1e6 : 1)
|
|
745
|
+
for (const [i, o, k] of [
|
|
746
|
+
[num(pr.prompt), num(pr.completion), () => 1e6],
|
|
747
|
+
[num(pr.input), num(pr.output), unit],
|
|
748
|
+
[num(m.input_price), num(m.output_price), () => 1e6],
|
|
749
|
+
[num(m.pricepermilliontokens), num(m.output_pricepermilliontokens), () => 1],
|
|
750
|
+
]) {
|
|
751
|
+
if (i === undefined && o === undefined) continue
|
|
752
|
+
const per = (x) => (x === undefined ? undefined : Number((x * k(x)).toPrecision(6)))
|
|
753
|
+
return { input: per(i), output: per(o) }
|
|
754
|
+
}
|
|
755
|
+
if (m.is_free === true || m.tier === 'free' || m.access_tier === 'free') return { input: 0, output: 0 }
|
|
756
|
+
}
|
|
757
|
+
|
|
551
758
|
function removeSkill(name) {
|
|
552
759
|
const dir = path.join(skillsDir(), safeName(name))
|
|
553
760
|
const file = path.join(skillsDir(), `${safeName(name)}.md`)
|