heartcode 0.0.0-stage → 0.1.1

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