heartcode 0.0.0-stage → 0.1.0

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/client.js ADDED
@@ -0,0 +1,545 @@
1
+ // OpenAI-compatible chat client: plain fetch, SSE streaming, <think> stripping.
2
+ import { setTimeout as sleep } from 'node:timers/promises'
3
+ import { log, logRequest, requestLog } from './log.js'
4
+
5
+ export const est = (text) => Math.ceil(String(text ?? '').length / 3.5)
6
+ // An error that may go away by itself: a rate limit, a server error or overload, a timeout, no
7
+ // connection (no status). Anything else (a wrong model name, key or request) won't.
8
+ export const outage = (e) => !e.status || [408, 409, 429].includes(e.status) || e.status >= 500
9
+ // A request's messages, estimated: an image's data isn't text (about 800 tokens, not its base64)
10
+ export function estMessages(messages) {
11
+ let images = 0
12
+ const text = JSON.stringify(messages ?? [], (k, v) => (k === 'url' && typeof v === 'string' && v.startsWith('data:') ? (images++, '') : v))
13
+ return est(text) + images * 800
14
+ }
15
+
16
+ // "Request too large" (the context window, or a provider's per-request or per-minute input cap):
17
+ // the same request can never work, so it isn't retried; the caller sends less. Returns the
18
+ // limit in tokens (from the message; else 3/4 of what was sent, but not below what is known to
19
+ // work), or null for other errors.
20
+ export function inputLimit(e, sent, known = 0) {
21
+ const big =
22
+ e.status === 413 ||
23
+ (e.status >= 400 && e.status < 500 && /too large|too long|reduce the length|reduce your message|context length|context window|context size|too many tokens|exceeds? the (maximum|available|context)|maximum (number of )?tokens/i.test(e.message))
24
+ if (!big) return null
25
+ const n = Number(LIMIT_IN_MESSAGE.map((re) => re.exec(e.message)?.[1]).find(Boolean))
26
+ return n > 0 ? n : Math.max(Math.floor(sent * 0.75), known)
27
+ }
28
+
29
+ // The limit when the error states it: { tokens, rate } (rate: a per-minute cap on your account,
30
+ // not the model's window), or null (no number in it: nothing is learned from a guess).
31
+ export function limitSaid(e) {
32
+ if (!inputLimit(e, 0)) return null
33
+ const n = Number(LIMIT_IN_MESSAGE.map((re) => re.exec(e.message)?.[1]).find(Boolean))
34
+ return n > 0 ? { tokens: n, rate: /per minute|tokens per min|\bTPM\b|rate.?limit/i.test(e.message) } : null
35
+ }
36
+
37
+ // How servers say the limit: OpenAI and vLLM, Mistral, llama.cpp, Anthropic-style, Gemini,
38
+ // "context window of N", Groq's per-request cap ("Limit N").
39
+ const LIMIT_IN_MESSAGE = [
40
+ /maximum context length is (\d+)/i,
41
+ /(\d+) maximum context length/i,
42
+ /available context size \((\d+)/i,
43
+ /> ?(\d+) maximum/i,
44
+ /allowed \((\d+)\)/i,
45
+ /context (?:window|length|size) (?:of|is) (\d+)/i,
46
+ /\bLimit (\d+)/,
47
+ ]
48
+
49
+ // A model's window from a /models entry, whatever the provider calls it. OpenRouter/Together:
50
+ // context_length (and top_provider.context_length) · vLLM: max_model_len · Groq: context_window ·
51
+ // Mistral: max_context_length · LiteLLM: max_input_tokens · Gemini: inputTokenLimit.
52
+ export function windowOf(m) {
53
+ if (!m) return undefined
54
+ return m.context_length ?? m.top_provider?.context_length ?? m.max_model_len ?? m.context_window ?? m.max_context_length ?? m.max_input_tokens ?? m.inputTokenLimit ?? m.context_size ?? m.n_ctx ?? m.meta?.n_ctx_train
55
+ }
56
+
57
+ export function normalizeBaseUrl(url) {
58
+ const u = new URL(url)
59
+ if (u.pathname === '/' || u.pathname === '') u.pathname = '/v1'
60
+ return u.toString().replace(/\/+$/, '')
61
+ }
62
+
63
+ export function isLocal(url) {
64
+ const h = new URL(url).hostname
65
+ return /^(localhost|127\.|0\.0\.0\.0|\[::1\]|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)
66
+ }
67
+
68
+ // Removes reasoning blocks some models put inside the normal content.
69
+ export function stripThink(text) {
70
+ let t = String(text ?? '').replace(/<think>[\s\S]*?<\/think>/g, '')
71
+ const open = t.indexOf('<think>')
72
+ if (open !== -1) t = t.slice(0, open)
73
+ const close = t.indexOf('</think>')
74
+ if (close !== -1) t = t.slice(close + 8)
75
+ return t.trimStart()
76
+ }
77
+
78
+ // What can be shown so far while streaming: holds back a half-received "<think>".
79
+ function visible(full) {
80
+ let v = stripThink(full)
81
+ for (let k = 7; k > 0; k--) {
82
+ if (v.endsWith('<think>'.slice(0, k))) return v.slice(0, -k)
83
+ }
84
+ return v
85
+ }
86
+
87
+ // The text inside <think> blocks so far (an unclosed one counts), holding back a half-received "</think>".
88
+ export function thinkOf(full) {
89
+ let out = ''
90
+ let i = 0
91
+ for (;;) {
92
+ const open = full.indexOf('<think>', i)
93
+ if (open === -1) return out
94
+ const close = full.indexOf('</think>', open + 7)
95
+ if (close === -1) {
96
+ let rest = full.slice(open + 7)
97
+ for (let k = 8; k > 0; k--) {
98
+ if (rest.endsWith('</think>'.slice(0, k))) {
99
+ rest = rest.slice(0, -k)
100
+ break
101
+ }
102
+ }
103
+ return out + rest
104
+ }
105
+ out += full.slice(open + 7, close)
106
+ i = close + 8
107
+ }
108
+ }
109
+
110
+ export class Client {
111
+ constructor({ baseUrl, apiKey, model, provider, timeout = 600_000 } = {}) {
112
+ this.provider = provider // its name in config.json, for the requests log
113
+ this.baseUrl = normalizeBaseUrl(baseUrl)
114
+ this.apiKey = apiKey
115
+ this.model = model
116
+ this.timeout = timeout
117
+ this.quirks = {}
118
+ this.retryBase = 1000 // first wait before retrying a failed request (then ×2 each time)
119
+ this.tokenRatio = 1 // the server's token count / est(): calibrated from usage (context.js)
120
+ this.knownGood = 0 // the biggest request known to work, in tokens
121
+ this.onUsage = null // (prompt tokens the server counted, estimate of what was sent): learns the window
122
+ this.speed = { tokens: 0, ms: 0 } // generated tokens and time, for the average tok/s
123
+ this.rotation = null // setRotation
124
+ }
125
+
126
+ // Models to go on to when one fails (the settings page's numbered list), in order and round
127
+ // again: list [{ provider, baseUrl, apiKey, model }] (#1 is this client's own), on: when to move
128
+ // on ('failure': rate limits, server errors, timeouts, no connection; 'retries': after `retries`
129
+ // failed tries of the same one; 'any': any error), next: what a new message starts with ('first' or
130
+ // 'stay' on the one that worked). onSwitch(target): a different model answered than before.
131
+ setRotation(list, { on = 'failure', retries = 2, next = 'first' } = {}) {
132
+ if (!list || list.length < 2) {
133
+ if (this.rotation) this.#use(this.rotation.list[0])
134
+ this.rotation = null
135
+ return
136
+ }
137
+ const was = this.rotation
138
+ list = list.map((t) => ({ ...t, quirks: was?.list.find((x) => x.baseUrl === t.baseUrl && x.model === t.model)?.quirks ?? {} }))
139
+ if (!was) list[0].quirks = this.quirks
140
+ this.rotation = { list, on, retries, next, at: 0, good: list[0] }
141
+ this.#use(list[0])
142
+ }
143
+ get target() {
144
+ return this.rotation?.list[this.rotation.at] ?? null
145
+ }
146
+ // A new message: back to #1 (unless it stays on the one that worked)
147
+ newMessage() {
148
+ const r = this.rotation
149
+ if (r && r.next === 'first' && r.at) this.#use(r.list[(r.at = 0)])
150
+ }
151
+ #use(t) {
152
+ Object.assign(this, { baseUrl: normalizeBaseUrl(t.baseUrl), apiKey: t.apiKey, model: t.model, provider: t.provider })
153
+ this.quirks = t.quirks ?? (t.quirks = {})
154
+ }
155
+ // After a failure: go on to the next model? (then it's tried at once)
156
+ #rotate(e, tries) {
157
+ const r = this.rotation
158
+ if (!r) return false
159
+ if (!(r.on === 'any' || (r.on === 'failure' && outage(e)) || (r.on === 'retries' && tries >= r.retries))) return false
160
+ r.at = (r.at + 1) % r.list.length
161
+ log('rotate', `${e.message.slice(0, 200)} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
162
+ this.#use(r.list[r.at])
163
+ return true
164
+ }
165
+
166
+ // The requests log (src/log.js): one request to the model, how it ended, and where it is in your
167
+ // list of models (#2 of 3: a fallback).
168
+ #record(req, sent, { error, reply, firstAt } = {}) {
169
+ if (error) error.recorded = true
170
+ if (!requestLog.on) return
171
+ const r = this.rotation
172
+ logRequest({
173
+ ok: !error,
174
+ stopped: error?.name === 'AbortError',
175
+ purpose: this.purpose ?? 'chat',
176
+ provider: this.provider ?? null,
177
+ model: this.model,
178
+ place: r ? r.at + 1 : 1,
179
+ of: r ? r.list.length : 1,
180
+ url: `${this.baseUrl}/chat/completions`,
181
+ status: error ? (error.status ?? null) : 200,
182
+ error: error?.message,
183
+ errorBody: error?.body,
184
+ ms: Date.now() - sent,
185
+ firstTokenMs: firstAt ? firstAt - sent : undefined,
186
+ usage: reply?.usage,
187
+ finishReason: reply?.finishReason,
188
+ request: req,
189
+ reply: reply && { content: reply.content, reasoning: reply.reasoning, toolCalls: reply.toolCalls },
190
+ })
191
+ }
192
+
193
+ get tps() {
194
+ return this.speed.ms ? Math.round(this.speed.tokens / (this.speed.ms / 1000)) : 0
195
+ }
196
+
197
+ async #fetch(url, init = {}, { signal, timeout = this.timeout } = {}) {
198
+ const limit = AbortSignal.timeout(timeout)
199
+ const headers = { 'content-type': 'application/json', ...(this.apiKey && { authorization: `Bearer ${this.apiKey}` }) }
200
+ const t0 = Date.now()
201
+ log('http', `→ ${init.method ?? 'GET'} ${url}`, init.body ?? '')
202
+ let res
203
+ try {
204
+ res = await fetch(url, { ...init, headers, signal: signal ? AbortSignal.any([signal, limit]) : limit })
205
+ } catch (e) {
206
+ log('http', `✗ ${url} after ${Date.now() - t0}ms: ${e.name} ${e.cause?.code ?? ''} ${e.message}`)
207
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') throw e
208
+ throw new Error(`cannot reach ${url} (${e.cause?.code || e.cause?.message || e.message})`)
209
+ }
210
+ log('http', `← ${res.status} ${url} in ${Date.now() - t0}ms`)
211
+ if (!res.ok) {
212
+ let body = await res.text().catch(() => '')
213
+ log('http', `error body: ${body.slice(0, 5000)}`)
214
+ const raw = body.slice(0, 20_000)
215
+ if (/^\s*</.test(body)) body = /<title>([^<]*)/i.exec(body)?.[1]?.trim() ?? 'an HTML error page' // proxies and gateways
216
+ const err = new Error(`${res.status} from ${url}: ${body.slice(0, 300)}`)
217
+ err.status = res.status
218
+ err.body = raw // all of it, for the requests log
219
+ err.retryAfter = res.headers.get('retry-after') ?? /"retryDelay":\s*"(\d+)s"/.exec(body)?.[1] // Gemini says it in the body
220
+ throw err
221
+ }
222
+ return res
223
+ }
224
+
225
+ async #json(url, init, timeout = 5000) {
226
+ return (await this.#fetch(url, init, { timeout })).json()
227
+ }
228
+
229
+ async listModels(timeout = 10_000) {
230
+ return (await this.modelEntries(timeout)).map((m) => m.id ?? m.name)
231
+ }
232
+ // The server's own entries for its models ({ id, owned_by, context_length, pricing… }, as it sends them).
233
+ async modelEntries(timeout = 10_000) {
234
+ const data = await this.#json(this.baseUrl + '/models', undefined, timeout)
235
+ return (data.data ?? data.models ?? []).filter((m) => m && (m.id ?? m.name))
236
+ }
237
+
238
+ // Best effort: asks the server how big the loaded context window is.
239
+ // What the server is, and the window it says: { size, server }. server: llama.cpp, lmstudio,
240
+ // ollama, ollama-cloud, vllm, or null (a provider or proxy that doesn't say what it is). Only a
241
+ // server that is one of these is treated as a model server on this machine or network; its
242
+ // address (localhost, 192.168…) says nothing (a proxy there may serve Gemini).
243
+ async contextSize() {
244
+ const root = this.baseUrl.replace(/\/v1$/, '')
245
+ const host = new URL(root).hostname
246
+ const n = (x) => (Number(x) > 0 ? Number(x) : undefined)
247
+ const tries = [
248
+ async () => {
249
+ const p = await this.#json(root + '/props')
250
+ if (p.default_generation_settings || 'n_ctx' in p) return { size: n(p.default_generation_settings?.n_ctx ?? p.n_ctx), server: 'llama.cpp' }
251
+ },
252
+ async () => {
253
+ const m = await this.#json(`${root}/api/v0/models/${encodeURIComponent(this.model)}`)
254
+ return { size: n(m.loaded_context_length ?? m.max_context_length), server: 'lmstudio' }
255
+ },
256
+ async () => {
257
+ // Ollama (it answers /api/version), or Ollama's cloud (ollama.com): the window of the model
258
+ // as loaded now, else its num_ctx, else on this machine Ollama's default and in the cloud
259
+ // the model's own length
260
+ const cloud = host === 'ollama.com' || host.endsWith('.ollama.com')
261
+ if (!cloud) await this.#json(root + '/api/version')
262
+ const ps = cloud ? null : await this.#json(root + '/api/ps').catch(() => null)
263
+ const loaded = ps?.models?.find((m) => m.name === this.model || m.model === this.model)?.context_length
264
+ const s = await this.#json(root + '/api/show', { method: 'POST', body: JSON.stringify({ model: this.model }) }).catch(() => null)
265
+ const set = /num_ctx\s+(\d+)/.exec(s?.parameters ?? '')?.[1]
266
+ const trained = Object.entries(s?.model_info ?? {}).find(([k]) => k.endsWith('.context_length'))?.[1]
267
+ return { size: n(loaded) ?? n(set) ?? (cloud ? n(trained) : s ? 4096 : undefined), server: cloud ? 'ollama-cloud' : 'ollama' }
268
+ },
269
+ async () => {
270
+ const d = await this.#json(this.baseUrl + '/models')
271
+ const m = (d.data ?? d.models ?? []).find((x) => x.id === this.model || x.name === this.model)
272
+ return { size: n(windowOf(m)), server: m?.owned_by === 'vllm' || m?.max_model_len ? 'vllm' : null }
273
+ },
274
+ async () => ({ size: n(windowOf(await this.#json(`${this.baseUrl}/models/${encodeURIComponent(this.model)}`))), server: null }),
275
+ ]
276
+ let server = null
277
+ for (const t of tries) {
278
+ try {
279
+ const r = await t()
280
+ if (r?.server) server ??= r.server
281
+ if (r?.size) return { size: r.size, server: server ?? r.server ?? null }
282
+ } catch {}
283
+ }
284
+ return { size: undefined, server }
285
+ }
286
+
287
+ // The settings page's Measure: one request far bigger than any window (about 2.2 million
288
+ // tokens), which servers refuse with their limit in the error ("maximum context length is
289
+ // 131072"). Refused requests aren't charged. If it's accepted (a window that big), it's stopped
290
+ // at once. Returns { limit } | { over } (it took that many) | { error }. Not logged: it's 4 MB.
291
+ async measureContext({ signal, tokens = 2_200_000 } = {}) {
292
+ const headers = { 'content-type': 'application/json', ...(this.apiKey && { authorization: `Bearer ${this.apiKey}` }) }
293
+ const body = JSON.stringify({ model: this.model, messages: [{ role: 'user', content: 'a '.repeat(tokens) }], stream: true })
294
+ const stop = new AbortController()
295
+ let res
296
+ try {
297
+ res = await fetch(this.baseUrl + '/chat/completions', { method: 'POST', headers, body, signal: AbortSignal.any([stop.signal, AbortSignal.timeout(180_000), ...(signal ? [signal] : [])]) })
298
+ } catch (e) {
299
+ return { error: `couldn't send it: ${e.cause?.code || e.cause?.message || e.message}` }
300
+ }
301
+ if (res.ok) {
302
+ stop.abort()
303
+ return { over: tokens }
304
+ }
305
+ const text = await res.text().catch(() => '')
306
+ const e = Object.assign(new Error(`${res.status} ${text.slice(0, 4000)}`), { status: res.status })
307
+ const said = limitSaid(e)
308
+ log('measure', `${this.model}: ${res.status}`, text.slice(0, 1000))
309
+ if (said && !said.rate) return { limit: said.tokens }
310
+ const why = /^\s*</.test(text) ? (/<title>([^<]*)/i.exec(text)?.[1]?.trim() ?? 'an HTML error page') : (() => {
311
+ try {
312
+ const j = JSON.parse(text)
313
+ return j.error?.message ?? j.error ?? j.message ?? text
314
+ } catch {
315
+ return text
316
+ }
317
+ })()
318
+ return { error: `${res.status}: ${String(why).slice(0, 300)}${said?.rate ? ' (a per-minute cap on your account, not the window)' : ''}` }
319
+ }
320
+
321
+ // Returns { content, reasoning, toolCalls, usage, finishReason, thinking }.
322
+ // While streaming, onToken gets answer text and onReasoning gets thinking text.
323
+ // With this.onRetry set (during tasks), any failure is retried after 1s, 2s, 4s, … (capped at
324
+ // 60s, or the server's Retry-After) until it works or the signal aborts. onRetry(error, ms,
325
+ // attempt) reports each wait; onRetry(null) reports the recovery.
326
+ async chat(body, opts = {}) {
327
+ // "too large": never retried as is; the limit is learned and the caller sends less
328
+ const tooLarge = (e) => {
329
+ const limit = inputLimit(e, Math.round(estMessages(body.messages) * this.tokenRatio), this.knownGood)
330
+ if (!limit) return false
331
+ e.inputLimit = limit // this request is sent smaller
332
+ // remembered only when the server said the number (a guess isn't); a per-minute cap apart
333
+ const said = limitSaid(e)
334
+ log('limit', `request too large; ${said ? `the server says ${said.tokens}${said.rate ? ' per minute' : ''}` : `no limit given, trying ~${limit}`}`)
335
+ if (said) this.onInputLimit?.(said.tokens, { rate: said.rate })
336
+ return true
337
+ }
338
+ if (!this.onRetry) {
339
+ try {
340
+ return await this.#timed(body, opts)
341
+ } catch (e) {
342
+ tooLarge(e)
343
+ throw e
344
+ }
345
+ }
346
+ let tries = 0 // of the model being tried now
347
+ let round = 0 // models tried since the last wait (a whole round of them failing: a short pause)
348
+ let hard = true // every failure in this round was one that waiting can't fix (see outage)
349
+ for (let attempt = 0; ; attempt++) {
350
+ try {
351
+ const r = await this.#timed(body, { ...opts, once: true })
352
+ if (attempt) this.onRetry(null)
353
+ const rot = this.rotation
354
+ if (rot && rot.good !== rot.list[rot.at]) this.onSwitch?.((rot.good = rot.list[rot.at]))
355
+ return r
356
+ } catch (e) {
357
+ if (e.name === 'AbortError' || opts.signal?.aborted) throw e
358
+ if (tooLarge(e)) throw e // the caller shrinks the conversation and sends again
359
+ if (this.retryIf && !this.retryIf(e, attempt)) throw e
360
+ // A wrong model name, key or request (404, 401, 400…): the same request fails the same way
361
+ // every time, so it isn't sent again. It stops here, unless the model list moves on at any
362
+ // error (the settings page); and a whole round of those stops too, rather than going round.
363
+ if (!outage(e) && this.rotation?.on !== 'any') throw e
364
+ hard &&= !outage(e)
365
+ tries++
366
+ if (this.#rotate(e, tries)) {
367
+ tries = 0
368
+ // ponytail: a 1s pause after a whole round fails (no connection at all: not thousands of
369
+ // requests a minute); the user chose "round again at once"
370
+ if (++round >= this.rotation.list.length) {
371
+ if (hard) throw e // every model refused it outright: going round again won't change that
372
+ hard = true
373
+ round = 0
374
+ this.onRetry(e, 1000, attempt + 1, this.target)
375
+ await sleep(1000, null, { signal: opts.signal })
376
+ } else this.onRetry(e, 0, attempt + 1, this.target)
377
+ continue
378
+ }
379
+ const wait = Math.min(Number(e.retryAfter) * 1000 || this.retryBase * 2 ** (tries - 1), 60_000)
380
+ log('retry', `attempt ${attempt + 1} failed, waiting ${wait}ms: ${e.message}`)
381
+ this.onRetry(e, wait, attempt + 1)
382
+ await sleep(wait, null, { signal: opts.signal })
383
+ }
384
+ }
385
+ }
386
+
387
+ // inFlight: when the request being waited on now was sent (null between requests), for the spinner.
388
+ async #timed(body, opts) {
389
+ this.inFlight = Date.now()
390
+ try {
391
+ return await this.#chat(body, opts)
392
+ } catch (e) {
393
+ if (!e.recorded && this.last) this.#record(this.last.req, this.last.sent, { error: e }) // failed while the reply streamed in
394
+ throw e
395
+ } finally {
396
+ this.inFlight = null
397
+ }
398
+ }
399
+
400
+ // One request. Without `once`, rate limits and server errors get three quiet retries.
401
+ async #chat(body, { onToken, onReasoning, onToolDelta, signal, once } = {}) {
402
+ const req = { model: this.model, ...body }
403
+ if (this.quirks.noTemperature) delete req.temperature
404
+ if (this.effort && !this.quirks.noEffort) req.reasoning_effort = this.effort
405
+ if (req.stream && !this.quirks.noStreamUsage) req.stream_options = { include_usage: true } // token counts for the status bar
406
+ let res
407
+ let sent
408
+ let firstAt
409
+ try {
410
+ for (let attempt = 0; ; attempt++) {
411
+ sent = Date.now()
412
+ this.last = { req, sent }
413
+ try {
414
+ res = await this.#fetch(this.baseUrl + '/chat/completions', { method: 'POST', body: JSON.stringify(req) }, { signal })
415
+ break
416
+ } catch (e) {
417
+ this.#record(req, sent, { error: e })
418
+ // Rate limits and server errors, the same set the OpenAI SDKs retry: wait (Retry-After, else 2s, 4s, 8s).
419
+ if (once || !([408, 409, 429].includes(e.status) || e.status >= 500) || attempt === 3 || signal?.aborted) throw e
420
+ const wait = Math.min(Number(e.retryAfter) * 1000 || 2000 * 2 ** attempt, 30_000)
421
+ log('retry', `quiet retry ${attempt + 1} in ${wait}ms: ${e.message}`)
422
+ await sleep(wait, null, { signal })
423
+ }
424
+ }
425
+ } catch (e) {
426
+ // Some APIs reject parameters older servers need; learn once and retry.
427
+ if (e.status === 400 && /temperature/.test(e.message) && 'temperature' in body && !this.quirks.noTemperature) {
428
+ this.quirks.noTemperature = true
429
+ log('quirk', 'server rejects temperature')
430
+ return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
431
+ }
432
+ for (const [quirk, param] of [['noEffort', 'reasoning_effort'], ['noStreamUsage', 'stream_options']]) {
433
+ if (e.status === 400 && param in req && new RegExp(param).test(e.message) && !this.quirks[quirk]) {
434
+ this.quirks[quirk] = true
435
+ log('quirk', `server rejects ${param}; dropping it`)
436
+ return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
437
+ }
438
+ }
439
+ throw e
440
+ }
441
+ // Tokens the last request used (prompt + reply): what the status bar shows against the context size.
442
+ // Speed counts from the first streamed token (or the request, if not streaming) to the end.
443
+ const count = (r) => {
444
+ if (r.usage?.prompt_tokens) this.onUsage?.(r.usage.prompt_tokens, estMessages(req.messages) + est(JSON.stringify(req.tools ?? '')))
445
+ this.lastTokens = r.usage?.total_tokens || estMessages(req.messages) + est(r.content + r.reasoning)
446
+ const out = r.usage?.completion_tokens || est(r.content + r.reasoning + r.toolCalls.map((t) => t.function.arguments).join(''))
447
+ this.onSpend?.(r.usage?.prompt_tokens || r.usage?.completion_tokens ? r.usage : { prompt_tokens: estMessages(req.messages), completion_tokens: out, estimated: true }) // usage and cost (usage.js)
448
+ const ms = Date.now() - (firstAt ?? sent)
449
+ if (out > 0 && ms > 0) {
450
+ this.speed.tokens += out
451
+ this.speed.ms += ms
452
+ }
453
+ log('reply', `${this.model} in ${Date.now() - sent}ms`, { content: r.content, reasoning: r.reasoning, toolCalls: r.toolCalls, usage: r.usage, finishReason: r.finishReason })
454
+ this.#record(req, sent, { reply: r, firstAt })
455
+ return r
456
+ }
457
+ if (!body.stream) {
458
+ const data = await res.json()
459
+ const choice = data.choices?.[0] ?? {}
460
+ const msg = choice.message ?? {}
461
+ return count(done(msg.content ?? '', msg.reasoning_content ?? msg.reasoning ?? '', msg.tool_calls ?? [], data.usage, choice.finish_reason))
462
+ }
463
+
464
+ const reader = res.body.getReader()
465
+ const dec = new TextDecoder()
466
+ let buf = ''
467
+ let content = ''
468
+ let reasoning = ''
469
+ let emitted = ''
470
+ let thought = ''
471
+ let usage
472
+ let finishReason
473
+ const calls = []
474
+ const emit = (v) => {
475
+ if (onToken && v.startsWith(emitted) && v.length > emitted.length) {
476
+ onToken(v.slice(emitted.length))
477
+ emitted = v
478
+ }
479
+ }
480
+ for (;;) {
481
+ const { value, done: end } = await reader.read()
482
+ firstAt ??= Date.now()
483
+ if (end) break
484
+ buf += dec.decode(value, { stream: true })
485
+ let nl
486
+ while ((nl = buf.indexOf('\n')) !== -1) {
487
+ const line = buf.slice(0, nl).trim()
488
+ buf = buf.slice(nl + 1)
489
+ if (!line.startsWith('data:')) continue
490
+ const payload = line.slice(5).trim()
491
+ if (payload === '[DONE]') continue
492
+ let chunk
493
+ try {
494
+ chunk = JSON.parse(payload)
495
+ } catch {
496
+ continue
497
+ }
498
+ if (chunk.error) throw new Error(`server error: ${chunk.error.message ?? JSON.stringify(chunk.error)}`)
499
+ if (chunk.usage) usage = chunk.usage
500
+ const ch = chunk.choices?.[0]
501
+ if (!ch) continue
502
+ if (ch.finish_reason) finishReason = ch.finish_reason
503
+ const d = ch.delta ?? {}
504
+ const r = d.reasoning_content || d.reasoning
505
+ if (r) {
506
+ reasoning += r
507
+ onReasoning?.(r)
508
+ }
509
+ for (const tc of d.tool_calls ?? []) {
510
+ const i = tc.index ?? (tc.id ? calls.length : Math.max(calls.length - 1, 0))
511
+ const call = (calls[i] ??= { id: '', type: 'function', function: { name: '', arguments: '' } })
512
+ if (tc.id) call.id = tc.id
513
+ if (tc.function?.name) call.function.name ||= tc.function.name
514
+ if (tc.function?.arguments) call.function.arguments += tc.function.arguments
515
+ onToolDelta?.(call.function.name, call.function.arguments)
516
+ }
517
+ if (d.content) {
518
+ content += d.content
519
+ const t = thinkOf(content)
520
+ if (onReasoning && t.length > thought.length && t.startsWith(thought)) {
521
+ onReasoning(t.slice(thought.length))
522
+ thought = t
523
+ }
524
+ emit(visible(content))
525
+ }
526
+ }
527
+ }
528
+ const result = done(content, reasoning, calls.filter(Boolean), usage, finishReason)
529
+ emit(result.content)
530
+ return count(result)
531
+ }
532
+ }
533
+
534
+ function done(raw, reasoning, toolCalls, usage, finishReason) {
535
+ const calls = toolCalls.filter((c) => c?.function?.name)
536
+ calls.forEach((c, i) => (c.id ||= `call_${Date.now()}_${i}`))
537
+ return {
538
+ content: stripThink(raw),
539
+ reasoning: (reasoning || thinkOf(raw)).trim(),
540
+ toolCalls: calls,
541
+ usage,
542
+ finishReason,
543
+ thinking: Boolean(reasoning) || /<\/?think>/.test(raw),
544
+ }
545
+ }
package/src/config.js ADDED
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ export const HOME = process.env.HEART_HOME || path.join(os.homedir(), '.heart')
6
+ export const CONFIG_FILE = path.join(HOME, 'config.json')
7
+
8
+ const SETTINGS = ['context', 'effort', 'permissions', 'autoCompact', 'director', 'theme', 'format', 'lsp', 'voice', 'rotation', 'log', 'logRequests', 'logDetail', 'logDays']
9
+ const LOCAL_NAMES = { 11434: 'ollama', 1234: 'lmstudio', 8080: 'llamacpp' }
10
+
11
+ export function readJson(file, fallback) {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
14
+ } catch {
15
+ return fallback
16
+ }
17
+ }
18
+
19
+ // A config file the user writes (hooks.json, mcp.json): one that doesn't parse is skipped, but
20
+ // said (takeConfigErrors, shown before the next message), never silently ignored.
21
+ const configErrors = new Map() // file → { message, stamp, shown }
22
+ export function readConfig(file, fallback) {
23
+ if (!file || !fs.existsSync(file)) return fallback
24
+ const text = fs.readFileSync(file, 'utf8')
25
+ try {
26
+ return JSON.parse(text)
27
+ } catch (e) {
28
+ const stamp = fs.statSync(file).mtimeMs
29
+ if (configErrors.get(file)?.stamp !== stamp) configErrors.set(file, { message: `${file} isn't valid JSON (${e.message}); it's skipped until it's fixed`, stamp, shown: false })
30
+ return fallback
31
+ }
32
+ }
33
+ export function takeConfigErrors() {
34
+ const out = [...configErrors.values()].filter((e) => !e.shown)
35
+ for (const e of out) e.shown = true
36
+ return out.map((e) => e.message)
37
+ }
38
+
39
+ export function writeJson(file, data) {
40
+ fs.mkdirSync(path.dirname(file), { recursive: true })
41
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
42
+ }
43
+
44
+ // A short name for a URL: https://api.groq.com/openai/v1 → groq, https://proxy.dev/groq → groq.
45
+ export function providerName(url) {
46
+ const u = new URL(url)
47
+ if (/^(localhost|127\.|0\.0\.0\.0)/.test(u.hostname)) return LOCAL_NAMES[u.port] ?? `local-${u.port || 80}`
48
+ const seg = u.pathname.split('/').filter((s) => s && !/^(v\d+\w*|api|openai)$/i.test(s)).pop()
49
+ if (seg) return seg.toLowerCase()
50
+ const parts = u.hostname.split('.').filter((p) => !['api', 'www', 'integrate', 'generativelanguage'].includes(p))
51
+ return (parts.length > 1 ? parts.at(-2) : parts[0]) || u.hostname
52
+ }
53
+
54
+ function fromFile() {
55
+ const file = readJson(CONFIG_FILE, {})
56
+ const cfg = { ...file, providers: { ...file.providers }, adhoc: null }
57
+ if (file.baseUrl && !file.providers) {
58
+ // config from before providers existed
59
+ const name = providerName(file.baseUrl)
60
+ cfg.providers[name] = { baseUrl: file.baseUrl, apiKey: file.apiKey }
61
+ cfg.provider = name
62
+ }
63
+ delete cfg.baseUrl
64
+ delete cfg.apiKey
65
+ return cfg
66
+ }
67
+
68
+ // cfg: { providers: {name: {baseUrl, apiKey}}, provider, model, context, adhoc }
69
+ // flags > env > ~/.heart/config.json. A URL from flags or env becomes a provider for this run
70
+ // only (cfg.adhoc), unless saved.
71
+ // The model name that means your list: #1, then the fallbacks when one fails (also: no model given).
72
+ // Any other model given for a run (-m, HEART_MODEL, a URL of its own) is that model alone: fixed.
73
+ export const DEFAULT_MODEL = 'default'
74
+
75
+ export function loadConfig(flags = {}) {
76
+ const file = fromFile()
77
+ const cfg = { providers: file.providers, provider: file.provider, model: file.model, adhoc: null }
78
+ for (const k of SETTINGS) cfg[k] = flags[k] ?? file[k]
79
+ if (cfg.context) cfg.context = Number(cfg.context)
80
+
81
+ const baseUrl = flags.baseUrl || process.env.HEART_BASE_URL || process.env.OPENAI_BASE_URL
82
+ const apiKey = flags.apiKey || process.env.HEART_API_KEY || process.env.OPENAI_API_KEY
83
+ if (baseUrl) {
84
+ const name = providerName(baseUrl)
85
+ cfg.providers[name] = { baseUrl, apiKey }
86
+ if (cfg.provider !== name) cfg.model = undefined
87
+ cfg.provider = name
88
+ cfg.adhoc = name
89
+ cfg.fixed = true
90
+ } else if (apiKey && cfg.providers[cfg.provider] && !cfg.providers[cfg.provider].apiKey) {
91
+ cfg.providers[cfg.provider].apiKey = apiKey
92
+ }
93
+ const model = flags.model || process.env.HEART_MODEL
94
+ if (model && model !== DEFAULT_MODEL) Object.assign(cfg, { model, fixed: true })
95
+ return cfg
96
+ }
97
+
98
+ export function saveConfig(cfg) {
99
+ const providers = { ...cfg.providers }
100
+ if (cfg.adhoc) delete providers[cfg.adhoc]
101
+ const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
102
+ const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }
103
+ for (const k of SETTINGS) if (cfg[k] !== undefined) out[k] = cfg[k]
104
+ writeJson(CONFIG_FILE, out)
105
+ }