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/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
- if (!/^https?:\/\//i.test(baseUrl)) baseUrl = (/^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.)/.test(baseUrl) ? 'http://' : 'https://') + baseUrl
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
- const p = cfg.providers[name]
234
- if (!p) throw new Error(`no provider "${name}"`)
235
- if (on) delete p.list
236
- 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
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
- '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 }) => {
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 entries = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).modelEntries()
257
- await loadPrices().catch(() => null)
258
- const num = (...xs) => xs.map(Number).find((x) => x > 0)
259
- const details = Object.fromEntries(entries.map((m) => {
260
- const id = String(m.id ?? m.name)
261
- const perMillion = (x) => Number((Number(x) * 1e6).toPrecision(6)) // OpenRouter: per token
262
- const listed = m.pricing && { input: perMillion(m.pricing.prompt), output: perMillion(m.pricing.completion) }
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: 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],
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: entries.map((m) => String(m.id ?? m.name)), details }
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': async ({ provider, model } = {}) => {
316
- const c = clientFor({ provider, model: String(model ?? '') })
317
- if (!c || !model) return { ok: false, error: `no provider "${provider}"` }
318
- c.purpose = 'test'
319
- const t0 = Date.now()
320
- try {
321
- const r = await c.chat({ messages: [{ role: 'user', content: 'Reply with just the word OK.' }], stream: true }, { signal: AbortSignal.timeout(90_000) })
322
- const reply = (r.content || '').trim()
323
- 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)` : ''}` }
324
- return { ok: true, ms: Date.now() - t0, reply: reply.slice(0, 80) }
325
- } catch (e) {
326
- return { ok: false, ms: Date.now() - t0, status: e.status ?? null, error: e.name === 'TimeoutError' ? 'no answer within 90 seconds' : whyFailed(e) }
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`)