heartcode 0.1.7 → 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/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'
@@ -98,6 +98,17 @@ const skillsDir = () => path.join(HOME, 'skills')
98
98
 
99
99
  // cfg: the running HeartCode's config (changed in place, and saved). apply(what): after a change,
100
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
+
101
112
  export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
102
113
  const token = crypto.randomBytes(24).toString('hex')
103
114
  const changed = (what) => {
@@ -183,8 +194,139 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
183
194
  return clip(`${e.status ? `${e.status}: ` : ''}${said}`, 300)
184
195
  }
185
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
+
186
328
  const state = () => ({
187
- providers: Object.entries(cfg.providers)
329
+ providers: Object.entries(byName(cfg.providers))
188
330
  .filter(([n]) => n !== cfg.adhoc)
189
331
  .map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, added: p.models ?? [] })),
190
332
  provider: cfg.provider ?? null,
@@ -228,12 +370,15 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
228
370
  }
229
371
  cfg.providers[name] = { ...old, baseUrl, apiKey: key } // its list switch and added models stay
230
372
  if (!key) delete cfg.providers[name].apiKey
373
+ forgetList(name, ...(was ? [was] : [])) // a new URL or key: its models asked for again
231
374
  save('model')
375
+ forgetTests()
232
376
  return state()
233
377
  },
234
378
  'POST /api/provider/delete': ({ name }) => {
235
379
  if (!cfg.providers[name]) throw new Error(`no provider "${name}"`)
236
380
  delete cfg.providers[name]
381
+ forgetList(name)
237
382
  if (cfg.rotation?.then) cfg.rotation.then = cfg.rotation.then.filter((t) => t.provider !== name)
238
383
  if (cfg.provider === name) {
239
384
  const [first, ...rest] = cfg.rotation?.then ?? [] // the next in the list becomes #1
@@ -241,23 +386,34 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
241
386
  if (cfg.rotation) cfg.rotation.then = rest
242
387
  }
243
388
  save('model')
389
+ forgetTests()
244
390
  return state()
245
391
  },
246
392
  // a provider's models, and what's known about each (for the page's tooltip): what the provider
247
393
  // says (name, context, owner, date, description, price), HeartCode's price list, its own test
248
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)
249
396
  'POST /api/provider/list': ({ name, on }) => {
250
- const p = cfg.providers[name]
251
- if (!p) throw new Error(`no provider "${name}"`)
252
- if (on) delete p.list
253
- else p.list = false
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
254
404
  save('provider')
255
405
  return state()
256
406
  },
257
407
  // A model added by its name (one the provider doesn't list, or its list is off): remembered.
258
- 'POST /api/provider/added': ({ name, model, remove }) => {
408
+ // all: every model added by name forgotten at once
409
+ 'POST /api/provider/added': ({ name, model, remove, all }) => {
259
410
  const p = cfg.providers[name]
260
411
  if (!p) throw new Error(`no provider "${name}"`)
412
+ if (all) {
413
+ delete p.models
414
+ save('provider')
415
+ return state()
416
+ }
261
417
  model = String(model ?? '').trim()
262
418
  if (!model) throw new Error('a model is needed')
263
419
  const rest = (p.models ?? []).filter((m) => m !== model)
@@ -270,26 +426,32 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
270
426
  const p = cfg.providers[name]
271
427
  if (!p) throw new Error(`no provider "${name}"`)
272
428
  if (p.list === false) return { models: [], details: {}, off: true }
273
- const entries = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).modelEntries()
274
- await loadPrices().catch(() => null)
275
- const num = (...xs) => xs.map(Number).find((x) => x > 0)
276
- const details = Object.fromEntries(entries.map((m) => {
277
- const id = String(m.id ?? m.name)
278
- const perMillion = (x) => Number((Number(x) * 1e6).toPrecision(6)) // OpenRouter: per token
279
- const listed = m.pricing && { input: perMillion(m.pricing.prompt), output: perMillion(m.pricing.completion) }
280
- const price = priceOf(name, p.baseUrl, id) ?? (listed && Number.isFinite(listed.input) ? listed : null)
281
- const profile = loadProfile(new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey, model: id }))
282
- return [id, {
283
- 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,
284
436
  context: num(m.context_length, m.context_window, m.max_model_len, m.max_context_length, m.top_provider?.context_length),
285
437
  owner: m.owned_by && m.owned_by !== 'system' ? String(m.owned_by) : undefined,
286
438
  created: num(m.created) && Number(m.created) > 946684800 ? Number(m.created) * 1000 : undefined, // seconds; 2000 or later
287
439
  description: m.description ? String(m.description).slice(0, 400) : undefined,
288
- price: price ? { input: price.input, output: price.output } : undefined,
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],
289
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,
290
452
  }]
291
453
  }))
292
- return { models: entries.map((m) => String(m.id ?? m.name)), details }
454
+ return { models: saved.models, details, at: saved.at }
293
455
  },
294
456
  'POST /api/model': ({ provider, model }) => {
295
457
  if (!cfg.providers[provider]) throw new Error(`no provider "${provider}"`)
@@ -298,6 +460,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
298
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
299
461
  save('model')
300
462
  test([{ provider, model: cfg.model }])
463
+ forgetTests()
301
464
  return state()
302
465
  },
303
466
  // the model list, in order (#1 is the model), and when to go on to the next
@@ -322,6 +485,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
322
485
  saveConfig(cfg)
323
486
  changed(changed1 ? 'model' : 'setting:rotation')
324
487
  test(clean)
488
+ forgetTests()
325
489
  return state()
326
490
  },
327
491
  // models.dev again (one model, or all of yours): its list fetched anew, and each model's profile
@@ -329,19 +493,22 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
329
493
  // for it stay above). A running HeartCode takes it at once.
330
494
  // Test: one short message to the model, to see that it answers (key, access, name, the server
331
495
  // itself). In the requests log as a model test. Never throws: { ok, ms, reply } or { ok: false, error }.
332
- 'POST /api/try': async ({ provider, model } = {}) => {
333
- const c = clientFor({ provider, model: String(model ?? '') })
334
- if (!c || !model) return { ok: false, error: `no provider "${provider}"` }
335
- c.purpose = 'test'
336
- const t0 = Date.now()
337
- try {
338
- const r = await c.chat({ messages: [{ role: 'user', content: 'Reply with just the word OK.' }], stream: true }, { signal: AbortSignal.timeout(90_000) })
339
- const reply = (r.content || '').trim()
340
- 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)` : ''}` }
341
- return { ok: true, ms: Date.now() - t0, reply: reply.slice(0, 80) }
342
- } catch (e) {
343
- return { ok: false, ms: Date.now() - t0, status: e.status ?? null, error: e.name === 'TimeoutError' ? 'no answer within 90 seconds' : whyFailed(e) }
344
- }
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 {}
345
512
  },
346
513
  'POST /api/models-dev': async ({ provider, model } = {}) => {
347
514
  await loadPrices({ force: true })
@@ -551,6 +718,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
551
718
  })
552
719
  return new Promise((resolve, reject) => {
553
720
  server.on('error', reject)
721
+ forgetTests() // results of models no longer yours, left from before
554
722
  server.listen(port, '127.0.0.1', () => {
555
723
  const url = `http://127.0.0.1:${server.address().port}/?t=${token}`
556
724
  log('web', `settings page at 127.0.0.1:${server.address().port}`)
@@ -565,6 +733,28 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
565
733
  })
566
734
  }
567
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
+
568
758
  function removeSkill(name) {
569
759
  const dir = path.join(skillsDir(), safeName(name))
570
760
  const file = path.join(skillsDir(), `${safeName(name)}.md`)