heartcode 0.1.7 → 0.1.9
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 +5 -1
- package/package.json +1 -1
- package/prompts/agent.md +5 -4
- package/src/agent.js +5 -2
- package/src/client.js +40 -6
- package/src/config.js +5 -2
- package/src/settings.html +508 -148
- package/src/settings.js +223 -33
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
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].models?.length ? lists[name] : null // an empty one: asked again
|
|
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:
|
|
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:
|
|
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':
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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`)
|