@christianirack/brainllm-runtime 0.6.1 → 0.8.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/brain-data.js +5 -2
- package/brain-env.js +8 -6
- package/brain-form.js +46 -36
- package/brain-inspect.js +19 -6
- package/brain-island.js +2 -1
- package/brain-lazy.js +2 -0
- package/brain-links.js +37 -0
- package/brain-live.js +21 -5
- package/brain-observe.js +120 -0
- package/brain-router.js +224 -178
- package/brain-schema.js +41 -9
- package/brain-trace.js +269 -0
- package/brain-vitals.js +60 -0
- package/package.json +1 -1
- package/server/llm.mjs +199 -0
- package/server/native-sockets.mjs +13 -10
- package/server/server.mjs +663 -105
- package/shim/dom.js +42 -7
- package/sw/brain-sw.js +17 -9
- package/ui/error-state.brain +1 -1
package/brain-schema.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// BRAINLLM schema mini-language (INTERNALS §14, §15): shared by form() (brain-form.js), api() validation
|
|
2
2
|
// (brain-data.js) and server.mjs, so a page that only fetches data does not load the form module.
|
|
3
|
-
// Per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists, value for num
|
|
4
|
-
// list of T, or a nested schema object.
|
|
3
|
+
// Per field: `str num bool email url date tel file` + `!` required + `<=N >=N` (length for str and lists, value for num,
|
|
4
|
+
// bytes for file) + `[T]` list of T, or a nested schema object. `file(image/*,.pdf)` names the accepted types.
|
|
5
5
|
const RX_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
6
6
|
const G = globalThis
|
|
7
7
|
|
|
@@ -9,7 +9,8 @@ const G = globalThis
|
|
|
9
9
|
// i18n/<locale>.json; plural objects allowed for keys with `{n}`), else the English text. The table is read directly
|
|
10
10
|
// (globalThis.__brainDict, filled by brain-i18n.js `add`) so a page that only validates does not load brain-i18n.js.
|
|
11
11
|
// Keys: form.required form.list form.min_items form.max_items form.item form.number form.min form.max form.email
|
|
12
|
-
// form.url form.date form.text form.boolean form.min_chars form.max_chars.
|
|
12
|
+
// form.url form.date form.text form.boolean form.min_chars form.max_chars form.tel form.file form.file_type
|
|
13
|
+
// form.file_min form.file_max.
|
|
13
14
|
export function msg(key, en, vars) {
|
|
14
15
|
const D = G.__brainDict
|
|
15
16
|
if (D) {
|
|
@@ -29,14 +30,21 @@ export function msg(key, en, vars) {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
// 'str!<=80' → { t: 'str', req: true, min, max, list: false }
|
|
32
|
-
const TYPES = new Set(['str', 'num', 'bool', 'email', 'url', 'date'])
|
|
33
|
+
const TYPES = new Set(['str', 'num', 'bool', 'email', 'url', 'date', 'tel', 'file'])
|
|
34
|
+
const ACCEPT = /^(?:\.[a-z0-9]+|[a-z]+\/(?:\*|[a-z0-9.+-]+))$/
|
|
33
35
|
function scalarRule(s) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const syntax = '(use str num bool email url date tel file, file(image/*,.pdf), ! required, <=N >=N, [T])'
|
|
37
|
+
if (typeof s !== 'string' || s.length > 256) throw Error(`form: invalid rule ${syntax}`)
|
|
38
|
+
const m = /^(?:([a-z]+)(?:\(([^()]*)\))?(!)?|\[([a-z]+)(?:\(([^()]*)\))?(!)?\](!)?)((?:[<>]=-?\d+(?:\.\d+)?)*)$/.exec(s)
|
|
39
|
+
if (!m || m[6] && m[7]) throw Error(`form: invalid rule '${s}' ${syntax}`)
|
|
40
|
+
const r = { t: m[1] || m[4], req: !!(m[3] || m[6] || m[7]), list: !!m[4], min: null, max: null }
|
|
38
41
|
if (!TYPES.has(r.t)) throw Error(`form: unknown type '${r.t}'`)
|
|
39
|
-
|
|
42
|
+
const accept = m[2] ?? m[5]
|
|
43
|
+
if (accept != null) {
|
|
44
|
+
r.accept = accept.split(',').map(a => a.trim().toLowerCase())
|
|
45
|
+
if (r.t !== 'file' || !r.accept.every(a => ACCEPT.test(a))) throw Error(`form: invalid rule '${s}' ${syntax}`)
|
|
46
|
+
}
|
|
47
|
+
for (const [, op, n] of m[8].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) {
|
|
40
48
|
const key = op === '<' ? 'max' : 'min', value = Number(n)
|
|
41
49
|
if (r[key] != null || !Number.isFinite(value) || (!r.list && r.t === 'bool') || ((r.list || r.t !== 'num') && (!Number.isSafeInteger(value) || value < 0))) throw Error(`form: invalid bounds in rule '${s}'`)
|
|
42
50
|
r[key] = value
|
|
@@ -73,6 +81,24 @@ function calendarDate(s) {
|
|
|
73
81
|
return year > 0 && month > 0 && month <= 12 && day > 0 && day <= days[month - 1]
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
// A phone as people write it: optional leading +, digits with single separators, 7 to 15 digits (E.164's maximum).
|
|
85
|
+
function validTel(s) {
|
|
86
|
+
const t = s.trim()
|
|
87
|
+
if (!/^\+?\(?\d(?:[ .()-]{0,2}\d)*$/.test(t)) return false
|
|
88
|
+
const digits = t.replace(/\D/g, '').length
|
|
89
|
+
return digits >= 7 && digits <= 15
|
|
90
|
+
}
|
|
91
|
+
// The browser's File/Blob shape. The type and name come from the client: the server has to verify the content again.
|
|
92
|
+
const isFile = v => !!v && typeof v === 'object' && typeof v.size === 'number' && typeof v.type === 'string'
|
|
93
|
+
const accepted = (r, f) => r.accept.some(a => a[0] === '.' ? String(f.name || '').toLowerCase().endsWith(a) : a.endsWith('/*') ? f.type.toLowerCase().startsWith(a.slice(0, -1)) : f.type.toLowerCase() === a)
|
|
94
|
+
function checkFile(r, f) {
|
|
95
|
+
if (!isFile(f)) return msg('form.file', 'Choose a file')
|
|
96
|
+
if (r.accept && !accepted(r, f)) return msg('form.file_type', 'This file type is not allowed')
|
|
97
|
+
if (r.min != null && f.size < r.min) return msg('form.file_min', `The file must be at least ${r.min} bytes`, { n: r.min })
|
|
98
|
+
if (r.max != null && f.size > r.max) return msg('form.file_max', `The file must be at most ${r.max} bytes`, { n: r.max })
|
|
99
|
+
return ''
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
77
103
|
|
|
78
104
|
// Compiler-only validators for proven unbounded scalars. Separate entry points let
|
|
@@ -106,6 +132,7 @@ export function check(r, v) {
|
|
|
106
132
|
}
|
|
107
133
|
return ''
|
|
108
134
|
}
|
|
135
|
+
if (r.t === 'file' && !empty(v)) return checkFile(r, v)
|
|
109
136
|
if (Array.isArray(v)) return msg(r.t === 'num' ? 'form.number' : r.t === 'bool' ? 'form.boolean' : 'form.text', r.t === 'num' ? 'Enter a number' : r.t === 'bool' ? 'Choose true or false' : 'Enter text')
|
|
110
137
|
if (empty(v)) return r.req ? msg('form.required', 'Required') : ''
|
|
111
138
|
if (r.t === 'bool') return typeof v !== 'boolean' ? msg('form.boolean', 'Choose true or false') : r.req && !v ? msg('form.required', 'Required') : ''
|
|
@@ -132,9 +159,14 @@ export function check(r, v) {
|
|
|
132
159
|
case 'date':
|
|
133
160
|
if (typeof v !== 'string' || !calendarDate(s)) return msg('form.date', 'Enter a valid date')
|
|
134
161
|
break
|
|
162
|
+
case 'tel':
|
|
163
|
+
if (typeof v !== 'string' || !validTel(s)) return msg('form.tel', 'Enter a valid phone number')
|
|
164
|
+
break
|
|
135
165
|
case 'str':
|
|
136
166
|
if (typeof v !== 'string') return msg('form.text', 'Enter text')
|
|
137
167
|
break
|
|
168
|
+
case 'file':
|
|
169
|
+
return msg('form.file', 'Choose a file')
|
|
138
170
|
default:
|
|
139
171
|
throw Error(`form: unknown type '${r.t}'`)
|
|
140
172
|
}
|
package/brain-trace.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// `brain trace`, the browser half (INTERNALS §49). Development only: the dev client (crates/brain-serve/src/client.js)
|
|
2
|
+
// imports it and nothing a production build ships does. It records navigation, prefetch, fetch and RPC with payload and
|
|
3
|
+
// response, console warnings, CSP and CORS failures, sockets and leak findings, and sends them over the HMR socket.
|
|
4
|
+
// Values are redacted by the store before they reach disk. A same-origin request carries `x-brain-trace`, the id the
|
|
5
|
+
// server and the backend then use, so one id follows it through every layer.
|
|
6
|
+
// A new module records without touching any of this: `globalThis.__brainTrace('mymodule', 'mykind', { title, level, data })`.
|
|
7
|
+
const G = globalThis
|
|
8
|
+
const BODY = 65536
|
|
9
|
+
const uuid = () => (G.crypto?.randomUUID ? G.crypto.randomUUID() : '00000000-0000-4000-8000-000000000000'.replace(/0/g, () => (Math.random() * 16 | 0).toString(16)))
|
|
10
|
+
const lasting = () => {
|
|
11
|
+
try {
|
|
12
|
+
const known = G.sessionStorage.getItem('brain:trace')
|
|
13
|
+
if (known) return known
|
|
14
|
+
const made = uuid()
|
|
15
|
+
G.sessionStorage.setItem('brain:trace', made)
|
|
16
|
+
return made
|
|
17
|
+
} catch { return uuid() }
|
|
18
|
+
}
|
|
19
|
+
export const ids = { session: lasting(), tab: uuid() }
|
|
20
|
+
|
|
21
|
+
let queue = [], send = null, timer = null
|
|
22
|
+
const flush = () => {
|
|
23
|
+
timer = null
|
|
24
|
+
if (!queue.length || !send) return
|
|
25
|
+
const events = queue.slice(0, 200)
|
|
26
|
+
if (send(JSON.stringify({ t: 'brain:trace', ...ids, events }))) queue = queue.slice(events.length)
|
|
27
|
+
if (queue.length) timer = setTimeout(flush, 250)
|
|
28
|
+
}
|
|
29
|
+
export function note(module, kind, fields = {}) {
|
|
30
|
+
if (queue.length >= 1000) queue.shift()
|
|
31
|
+
queue.push({ ts: Date.now(), module, kind, route: G.location?.pathname, ...fields })
|
|
32
|
+
timer ??= setTimeout(flush, 100)
|
|
33
|
+
}
|
|
34
|
+
export function attach(sender) {
|
|
35
|
+
send = sender
|
|
36
|
+
timer ??= setTimeout(flush, 0)
|
|
37
|
+
}
|
|
38
|
+
G.__brainTrace = (module, kind, fields) => note(String(module), String(kind), fields)
|
|
39
|
+
|
|
40
|
+
// ---------- locations: a generated line of a dev module back to its .brain line (the module's inline source map) ----------
|
|
41
|
+
const RUNTIME = '/@brain/'
|
|
42
|
+
const nativeFetch = G.fetch?.bind(G)
|
|
43
|
+
let failedId = null
|
|
44
|
+
// the id of the last failed call, for the error report that follows it (brain-err cannot see the request)
|
|
45
|
+
export const lastFailed = () => failedId
|
|
46
|
+
const MAPS = new Map()
|
|
47
|
+
function mapOf(url) {
|
|
48
|
+
if (!MAPS.has(url)) {
|
|
49
|
+
if (MAPS.size > 64) MAPS.clear()
|
|
50
|
+
MAPS.set(url, (async () => {
|
|
51
|
+
const text = await (await nativeFetch(url)).text()
|
|
52
|
+
const found = /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(text)
|
|
53
|
+
if (!found) return null
|
|
54
|
+
const { decode } = await import('./brain-err.js')
|
|
55
|
+
return decode(JSON.parse(atob(found[1])).mappings)
|
|
56
|
+
})().catch(() => null))
|
|
57
|
+
}
|
|
58
|
+
return MAPS.get(url)
|
|
59
|
+
}
|
|
60
|
+
export async function mapping(url, line) {
|
|
61
|
+
try {
|
|
62
|
+
const u = new URL(url, G.location.href)
|
|
63
|
+
if (u.origin !== G.location.origin || u.pathname.includes(RUNTIME)) return null
|
|
64
|
+
u.search = ''
|
|
65
|
+
const lines = await mapOf(u.href)
|
|
66
|
+
const original = lines && lines[line - 1]
|
|
67
|
+
return { file: u.pathname.replace(/^\//, ''), line: original || null, precision: original ? 'source-line' : 'module' }
|
|
68
|
+
} catch { return null }
|
|
69
|
+
}
|
|
70
|
+
// The first frame outside the runtime: who made this call.
|
|
71
|
+
const callSite = () => {
|
|
72
|
+
for (const row of String(new Error().stack).split('\n').slice(2)) {
|
|
73
|
+
const found = /((?:https?):\/\/[^\s)]+):(\d+):\d+/.exec(row)
|
|
74
|
+
if (found && !found[1].includes(RUNTIME)) return { url: found[1], line: +found[2] }
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
const located = async (site, fields) => {
|
|
79
|
+
const where = site && await mapping(site.url, site.line)
|
|
80
|
+
return where ? { file: where.file, line: where.line, ...fields } : fields
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- fetch, RPC and prefetch ----------
|
|
84
|
+
// source modules are not responses anyone debugs, and their literals are not data
|
|
85
|
+
const textual = type => /json|text\/(?!event-stream|javascript)|xml|form-urlencoded/i.test(type || '')
|
|
86
|
+
async function bodyOf(response) {
|
|
87
|
+
const type = response.headers.get('content-type')
|
|
88
|
+
if (!textual(type)) return type ? `[${type}]` : null
|
|
89
|
+
const reader = response.clone().body?.getReader()
|
|
90
|
+
if (!reader) return null
|
|
91
|
+
const decoder = new TextDecoder()
|
|
92
|
+
let text = ''
|
|
93
|
+
try {
|
|
94
|
+
while (text.length < BODY) {
|
|
95
|
+
const { done, value } = await reader.read()
|
|
96
|
+
if (done) return text + decoder.decode()
|
|
97
|
+
text += decoder.decode(value, { stream: true })
|
|
98
|
+
}
|
|
99
|
+
return text.slice(0, BODY) + '…[truncated by the browser]'
|
|
100
|
+
} finally { reader.cancel().catch(() => {}) }
|
|
101
|
+
}
|
|
102
|
+
const payloadOf = (input, init) => {
|
|
103
|
+
const body = init?.body
|
|
104
|
+
if (typeof body === 'string') return body.slice(0, BODY)
|
|
105
|
+
if (body instanceof G.URLSearchParams) return String(body).slice(0, BODY)
|
|
106
|
+
if (body != null) return `[${body.constructor?.name || typeof body}]`
|
|
107
|
+
return input instanceof G.Request && input.method !== 'GET' && input.method !== 'HEAD' ? '[Request body]' : null
|
|
108
|
+
}
|
|
109
|
+
const kindOf = (url, init, headers) =>
|
|
110
|
+
url.pathname.includes('/_r/') ? 'rpc'
|
|
111
|
+
: init?.priority === 'low' || headers.has('x-brain-prefetch') ? 'prefetch'
|
|
112
|
+
: /text\/html/.test(headers.get('accept') || '') ? 'page' : 'fetch'
|
|
113
|
+
function wrapFetch() {
|
|
114
|
+
if (!nativeFetch || G.fetch.$trace) return
|
|
115
|
+
const wrapped = function (input, init) {
|
|
116
|
+
let url
|
|
117
|
+
try { url = new URL(input instanceof G.Request ? input.url : String(input), G.location.href) } catch { return nativeFetch(input, init) }
|
|
118
|
+
if (url.pathname.includes(RUNTIME) || !/^https?:$/.test(url.protocol) || /\.(?:js|mjs|brain|jsbrain|cssbrain)$/.test(url.pathname)) return nativeFetch(input, init)
|
|
119
|
+
const same = url.origin === G.location.origin, id = uuid(), site = callSite(), started = performance.now()
|
|
120
|
+
const headers = new Headers(init?.headers ?? (input instanceof G.Request ? input.headers : undefined))
|
|
121
|
+
const kind = kindOf(url, init, headers), method = (init?.method || (input instanceof G.Request ? input.method : 'GET')).toUpperCase()
|
|
122
|
+
const payload = payloadOf(input, init)
|
|
123
|
+
if (same) headers.set('x-brain-trace', id)
|
|
124
|
+
const route = same ? url.pathname + url.search : url.href
|
|
125
|
+
return nativeFetch(input, same ? { ...init, headers } : init).then(response => {
|
|
126
|
+
const elapsed = +(performance.now() - started).toFixed(1)
|
|
127
|
+
if (same && response.status >= 400) failedId = id
|
|
128
|
+
bodyOf(response).catch(() => null).then(async text => {
|
|
129
|
+
const fields = { request_id: same ? id : null, route, status: response.status, duration_ms: elapsed, data: { method, payload, response: text } }
|
|
130
|
+
note('browser', kind, response.status >= 400 ? await located(site, fields) : fields)
|
|
131
|
+
})
|
|
132
|
+
return response
|
|
133
|
+
}, async error => {
|
|
134
|
+
const elapsed = +(performance.now() - started).toFixed(1), aborted = error?.name === 'AbortError'
|
|
135
|
+
const fields = { request_id: same ? id : null, route, status: aborted ? null : 0, duration_ms: elapsed, level: aborted ? 'info' : 'error', title: aborted ? 'Request aborted' : same ? String(error?.message || error) : `Cross-origin request to ${url.origin} failed: ${error?.message || error}`, data: { method, payload, aborted } }
|
|
136
|
+
note('browser', aborted ? 'abort' : same ? kind : 'cors', aborted ? fields : await located(site, fields))
|
|
137
|
+
throw error
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
wrapped.$trace = 1
|
|
141
|
+
G.fetch = wrapped
|
|
142
|
+
}
|
|
143
|
+
// Uploads with a progress callback use XMLHttpRequest (brain-data).
|
|
144
|
+
function wrapXhr() {
|
|
145
|
+
const X = G.XMLHttpRequest?.prototype
|
|
146
|
+
if (!X || X.$trace) return
|
|
147
|
+
X.$trace = 1
|
|
148
|
+
const open = X.open, sendRequest = X.send
|
|
149
|
+
X.open = function (method, url, ...rest) {
|
|
150
|
+
this.$traced = { method: String(method).toUpperCase(), url: new URL(String(url), G.location.href) }
|
|
151
|
+
return open.call(this, method, url, ...rest)
|
|
152
|
+
}
|
|
153
|
+
X.send = function (body) {
|
|
154
|
+
const info = this.$traced, started = performance.now(), id = uuid()
|
|
155
|
+
if (info) {
|
|
156
|
+
const same = info.url.origin === G.location.origin
|
|
157
|
+
if (same) this.setRequestHeader('x-brain-trace', id)
|
|
158
|
+
this.addEventListener('loadend', () => note('browser', 'xhr', { request_id: same ? id : null, route: same ? info.url.pathname + info.url.search : info.url.href, status: this.status, duration_ms: +(performance.now() - started).toFixed(1), data: { method: info.method, payload: typeof body === 'string' ? body.slice(0, BODY) : body == null ? null : `[${body.constructor?.name}]` } }))
|
|
159
|
+
}
|
|
160
|
+
return sendRequest.call(this, body)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------- navigation ----------
|
|
165
|
+
function watchNavigation() {
|
|
166
|
+
const entry = G.performance?.getEntriesByType?.('navigation')?.[0]
|
|
167
|
+
const server = entry?.serverTiming?.find(item => item.name === 'rid')?.description
|
|
168
|
+
// the document itself: the server's id comes back in Server-Timing, the only header a page can read about its own load
|
|
169
|
+
const first = () => {
|
|
170
|
+
const done = G.performance?.getEntriesByType?.('navigation')?.[0]
|
|
171
|
+
note('browser', 'navigation', { request_id: server || null, route: G.location.pathname + G.location.search, status: done?.responseStatus || null, duration_ms: done?.loadEventEnd ? +done.loadEventEnd.toFixed(1) : null, data: { type: done?.type || 'navigate' } })
|
|
172
|
+
}
|
|
173
|
+
if (G.document.readyState === 'complete') first()
|
|
174
|
+
else G.addEventListener('load', () => setTimeout(first), { once: true })
|
|
175
|
+
G.addEventListener('brain:navigate', () => note('browser', 'navigation', { route: G.location.pathname + G.location.search, data: { type: 'client' } }))
|
|
176
|
+
G.addEventListener('popstate', () => note('browser', 'navigation', { data: { type: 'history' } }))
|
|
177
|
+
// <link rel="prefetch"> and speculation never pass through fetch()
|
|
178
|
+
try {
|
|
179
|
+
new PerformanceObserver(list => {
|
|
180
|
+
for (const item of list.getEntries()) {
|
|
181
|
+
if (item.initiatorType !== 'link' || !G.document.querySelector(`link[rel="prefetch"][href="${new URL(item.name).pathname}"]`)) continue
|
|
182
|
+
note('browser', 'prefetch', { route: new URL(item.name).pathname, duration_ms: +item.duration.toFixed(1), status: item.responseStatus || null, data: { initiator: 'link' } })
|
|
183
|
+
}
|
|
184
|
+
}).observe({ type: 'resource', buffered: true })
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------- console, CSP ----------
|
|
189
|
+
function watchConsole() {
|
|
190
|
+
for (const [method, level] of [['warn', 'warn'], ['error', 'error']]) {
|
|
191
|
+
const original = G.console?.[method]
|
|
192
|
+
if (!original) continue
|
|
193
|
+
G.console[method] = function (...args) {
|
|
194
|
+
// brain-err prints the errors it records: those are already `browser/error` events
|
|
195
|
+
if (!(args[0] instanceof Error) && !String(args[0]).startsWith('BRAIN_ERROR')) {
|
|
196
|
+
const site = callSite(), title = args.map(a => (typeof a === 'string' ? a : (() => { try { return JSON.stringify(a) } catch { return String(a) } })())).join(' ').slice(0, 1024)
|
|
197
|
+
located(site, { level, title }).then(fields => note('browser', 'console', fields))
|
|
198
|
+
}
|
|
199
|
+
return original.apply(this, args)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function watchCsp() {
|
|
204
|
+
G.document.addEventListener('securitypolicyviolation', async event => {
|
|
205
|
+
const { violatedDirective, effectiveDirective, blockedURI, sourceFile, lineNumber, columnNumber, disposition, sample } = event
|
|
206
|
+
const where = sourceFile && lineNumber ? await mapping(sourceFile, lineNumber) : null
|
|
207
|
+
note('browser', 'csp', {
|
|
208
|
+
level: 'error', title: `CSP ${effectiveDirective || violatedDirective} refused ${blockedURI || 'inline code'}`,
|
|
209
|
+
// a call made through the runtime's own wrappers says nothing about the app: the event keeps its route
|
|
210
|
+
file: where?.file ?? (sourceFile && !sourceFile.includes(RUNTIME) ? new URL(sourceFile, G.location.href).pathname : null), line: where ? where.line : sourceFile?.includes(RUNTIME) ? null : lineNumber || null,
|
|
211
|
+
data: { violation: { directive: effectiveDirective || violatedDirective, blocked: blockedURI, disposition, sample, sourceFile, line: lineNumber, column: columnNumber, precision: where?.precision || 'generated' } },
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------- sockets ----------
|
|
217
|
+
function wrapSockets() {
|
|
218
|
+
const C = G.WebSocket
|
|
219
|
+
if (!C || C.$trace) return
|
|
220
|
+
const W = new Proxy(C, {
|
|
221
|
+
construct(target, args, next) {
|
|
222
|
+
const socket = Reflect.construct(target, args, next), url = String(args[0])
|
|
223
|
+
if (url.includes(RUNTIME)) return socket
|
|
224
|
+
const route = (() => { try { return new URL(url).pathname } catch { return url } })(), opened = performance.now()
|
|
225
|
+
let received = 0, sent = 0
|
|
226
|
+
socket.addEventListener('message', () => received++)
|
|
227
|
+
const write = socket.send
|
|
228
|
+
socket.send = function (...rest) { sent++; return write.apply(this, rest) }
|
|
229
|
+
socket.addEventListener('open', () => note('browser', 'socket-open', { route, data: { url } }))
|
|
230
|
+
socket.addEventListener('close', event => {
|
|
231
|
+
const clean = event.wasClean || event.code === 1000 || event.code === 1001
|
|
232
|
+
note('browser', clean ? 'socket-close' : 'socket', { route, level: clean ? 'info' : 'error', title: clean ? null : `Socket ${route} closed with code ${event.code}`, duration_ms: +(performance.now() - opened).toFixed(1), data: { url, code: event.code, reason: event.reason, received, sent } })
|
|
233
|
+
})
|
|
234
|
+
return socket
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
W.$trace = 1
|
|
238
|
+
G.WebSocket = W
|
|
239
|
+
const S = G.EventSource
|
|
240
|
+
if (!S) return
|
|
241
|
+
G.EventSource = new Proxy(S, {
|
|
242
|
+
construct(target, args, next) {
|
|
243
|
+
const source = Reflect.construct(target, args, next), route = String(args[0])
|
|
244
|
+
source.addEventListener('open', () => note('browser', 'socket-open', { route, data: { transport: 'sse' } }))
|
|
245
|
+
// a stream the browser will not retry is the failure; the ordinary reconnect is not
|
|
246
|
+
source.addEventListener('error', () => { if (source.readyState === 2) note('browser', 'socket', { route, level: 'error', title: `Event stream ${route} failed`, data: { transport: 'sse' } }) })
|
|
247
|
+
return source
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// brain-inspect finds the leaks and already sends them to the same socket; `inspected` reads its batch for this store.
|
|
253
|
+
export function inspected(batch) {
|
|
254
|
+
if (!batch.includes('"leak"')) return
|
|
255
|
+
try {
|
|
256
|
+
for (const record of JSON.parse(batch).d || []) {
|
|
257
|
+
if (record.k === 'leak') note('memory', 'leak', { level: 'warn', title: record.msg, code: record.code, fix: record.fix, component: record.name, file: record.file, line: record.line, data: { fx: record.fx } })
|
|
258
|
+
}
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (G.document) {
|
|
263
|
+
wrapFetch()
|
|
264
|
+
wrapXhr()
|
|
265
|
+
wrapSockets()
|
|
266
|
+
watchConsole()
|
|
267
|
+
watchCsp()
|
|
268
|
+
watchNavigation()
|
|
269
|
+
}
|
package/brain-vitals.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Core Web Vitals, opt-in: `vitals(report)` measures in this page and `reportVitals(url)` sends the final values.
|
|
2
|
+
// Nothing is emitted unless an app imports this module. Metrics a browser lacks are never reported, so read them as
|
|
3
|
+
// optional: LCP and INP do not exist in every engine. Thresholds are the published web.dev good/poor limits.
|
|
4
|
+
const LIMITS = { LCP: [2500, 4000], CLS: [0.1, 0.25], INP: [200, 500], FCP: [1800, 3000], TTFB: [800, 1800] }
|
|
5
|
+
const rate = (name, value) => (value <= LIMITS[name][0] ? 'good' : value <= LIMITS[name][1] ? 'needs-improvement' : 'poor')
|
|
6
|
+
|
|
7
|
+
export function vitals(report, env = globalThis) {
|
|
8
|
+
const PO = env.PerformanceObserver
|
|
9
|
+
const types = PO?.supportedEntryTypes ?? []
|
|
10
|
+
const stops = []
|
|
11
|
+
const send = (name, value) => report({ name, value: name === 'CLS' ? +value.toFixed(4) : Math.round(value), rating: rate(name, value) })
|
|
12
|
+
const watch = (type, options, read) => {
|
|
13
|
+
if (!types.includes(type)) return
|
|
14
|
+
const po = new PO(list => read(list.getEntries()))
|
|
15
|
+
po.observe({ type, buffered: true, ...options })
|
|
16
|
+
stops.push(() => po.disconnect())
|
|
17
|
+
}
|
|
18
|
+
const nav = env.performance?.getEntriesByType?.('navigation')?.[0]
|
|
19
|
+
if (nav?.responseStart > 0) send('TTFB', nav.responseStart)
|
|
20
|
+
watch('paint', {}, list => { for (const e of list) if (e.name === 'first-contentful-paint') send('FCP', e.startTime) })
|
|
21
|
+
watch('largest-contentful-paint', {}, list => { const last = list[list.length - 1]; if (last) send('LCP', last.startTime) })
|
|
22
|
+
// CLS: the worst session window (gap under 1 s, at most 5 s long), without shifts caused by recent input.
|
|
23
|
+
let worst = 0, sum = 0, first = 0, previous = 0
|
|
24
|
+
watch('layout-shift', {}, list => {
|
|
25
|
+
for (const e of list) {
|
|
26
|
+
if (e.hadRecentInput) continue
|
|
27
|
+
if (sum && e.startTime - previous < 1000 && e.startTime - first < 5000) sum += e.value
|
|
28
|
+
else { sum = e.value; first = e.startTime }
|
|
29
|
+
previous = e.startTime
|
|
30
|
+
if (sum > worst) { worst = sum; send('CLS', worst) }
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
// INP: the slowest interaction, one in 50 discarded as an outlier, as the metric's definition says.
|
|
34
|
+
const slow = new Map()
|
|
35
|
+
let inp = -1
|
|
36
|
+
watch('event', { durationThreshold: 40 }, list => {
|
|
37
|
+
for (const e of list) if (e.interactionId) slow.set(e.interactionId, Math.max(slow.get(e.interactionId) ?? 0, e.duration))
|
|
38
|
+
const all = [...slow.values()].sort((a, b) => b - a)
|
|
39
|
+
const value = all[Math.min(all.length - 1, Math.floor(all.length / 50))]
|
|
40
|
+
if (value !== undefined && value !== inp) send('INP', (inp = value))
|
|
41
|
+
if (slow.size > 500) for (const key of [...slow.keys()].slice(0, 250)) slow.delete(key)
|
|
42
|
+
})
|
|
43
|
+
return () => { for (const stop of stops) stop() }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// One beacon each time the page is hidden, with what changed since the last one; the query string is never sent.
|
|
47
|
+
export function reportVitals(url, env = globalThis) {
|
|
48
|
+
if (typeof url !== 'string' || !url.startsWith('/')) throw new TypeError('reportVitals(url): pass a same-origin path, such as "/api/vitals"')
|
|
49
|
+
const doc = env.document
|
|
50
|
+
const fresh = new Map()
|
|
51
|
+
const stop = vitals(metric => fresh.set(metric.name, metric), env)
|
|
52
|
+
const flush = () => {
|
|
53
|
+
if (doc.visibilityState !== 'hidden' || !fresh.size) return
|
|
54
|
+
const body = JSON.stringify({ path: env.location.pathname, metrics: [...fresh.values()] })
|
|
55
|
+
if (env.navigator.sendBeacon?.(url, new Blob([body], { type: 'application/json' }))) fresh.clear()
|
|
56
|
+
}
|
|
57
|
+
doc.addEventListener('visibilitychange', flush)
|
|
58
|
+
env.addEventListener('pagehide', flush)
|
|
59
|
+
return () => { stop(); doc.removeEventListener('visibilitychange', flush); env.removeEventListener('pagehide', flush) }
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/server/llm.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Server LLM module (INTERNALS §51): `ctx.llm`, one provider-neutral call for Anthropic and OpenAI. Zero dependencies.
|
|
2
|
+
// Provider contracts reviewed 2026-10-31 against the official documentation:
|
|
3
|
+
// - Anthropic: platform.claude.com/docs/en/api/messages, /api/versioning, /api/errors, /build-with-claude/streaming.
|
|
4
|
+
// API version 2026-10-21 rejects temperature, top_p and top_k with a 400, so the neutral API has no sampling options.
|
|
5
|
+
// - OpenAI: developers.openai.com/api/reference/resources/responses/methods/create, /api/docs/guides/streaming-responses
|
|
6
|
+
// and the event schemas of github.com/openai/openai-openapi (Responses API, stored off with `store: false`).
|
|
7
|
+
// Keys come only from the server environment. An error never carries a key; provider text stays in `detail`, redacted.
|
|
8
|
+
const LLM_PROVIDERS = {
|
|
9
|
+
anthropic: { key: 'ANTHROPIC_API_KEY', model: 'ANTHROPIC_MODEL', base: 'ANTHROPIC_BASE_URL', origin: 'https://api.anthropic.com' },
|
|
10
|
+
openai: { key: 'OPENAI_API_KEY', model: 'OPENAI_MODEL', base: 'OPENAI_BASE_URL', origin: 'https://api.openai.com' },
|
|
11
|
+
}
|
|
12
|
+
const LLM_ANTHROPIC_VERSION = '2026-10-21'
|
|
13
|
+
const LLM_DEFAULT_TIMEOUT = 60000, LLM_MAX_TIMEOUT = 600000, LLM_DEFAULT_MAX_TOKENS = 1024
|
|
14
|
+
const LLM_MAX_BODY = 32 * 1024 * 1024, LLM_MAX_LINE = 4 * 1024 * 1024, LLM_MAX_DETAIL = 500
|
|
15
|
+
|
|
16
|
+
export class LlmError extends Error {
|
|
17
|
+
constructor(code, status, message, fix, extra = {}) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = 'LlmError'
|
|
20
|
+
this.code = code
|
|
21
|
+
this.status = status
|
|
22
|
+
this.fix = fix
|
|
23
|
+
Object.assign(this, extra)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const llmConfig = (message, fix) => new LlmError('B0921', 503, message, fix)
|
|
27
|
+
const llmFailure = (status, message, extra) => new LlmError('B0922', status, message, 'Inspect err.provider, err.providerStatus and err.detail on the server', extra)
|
|
28
|
+
|
|
29
|
+
function llmResolve(env, o) {
|
|
30
|
+
if (!o || typeof o !== 'object') throw llmConfig('ctx.llm needs an options object', 'Pass { messages: [{ role: "user", content: "…" }] } or { prompt: "…" }')
|
|
31
|
+
const requested = o.provider ?? env.BRAIN_LLM_PROVIDER
|
|
32
|
+
if (requested != null && requested !== '' && !Object.hasOwn(LLM_PROVIDERS, requested)) throw llmConfig(`Unknown LLM provider "${String(requested).slice(0, 40)}"`, 'Use "anthropic" or "openai"')
|
|
33
|
+
const present = Object.keys(LLM_PROVIDERS).filter(name => env[LLM_PROVIDERS[name].key])
|
|
34
|
+
const provider = requested || (present.length === 1 ? present[0] : null)
|
|
35
|
+
if (!provider) throw llmConfig(present.length ? 'Both LLM provider keys are set and none is selected' : 'No LLM provider key is set', present.length ? 'Pass { provider } or set BRAIN_LLM_PROVIDER in .brain_env' : 'Set ANTHROPIC_API_KEY or OPENAI_API_KEY in .brain_env (never with PUBLIC_)')
|
|
36
|
+
const p = LLM_PROVIDERS[provider], key = env[p.key]
|
|
37
|
+
if (!key) throw llmConfig(`${p.key} is not set`, `Set ${p.key} in .brain_env (never with PUBLIC_)`)
|
|
38
|
+
const model = o.model ?? env[p.model]
|
|
39
|
+
if (typeof model !== 'string' || !model) throw llmConfig(`No model selected for ${provider}`, `Pass { model } or set ${p.model} in .brain_env; BRAINLLM pins no model`)
|
|
40
|
+
let base
|
|
41
|
+
try { base = new URL(env[p.base] || p.origin) } catch { throw llmConfig(`${p.base} is not a URL`, `Set ${p.base} to an https address`) }
|
|
42
|
+
const loopback = base.hostname === '127.0.0.1' || base.hostname === 'localhost' || base.hostname === '[::1]'
|
|
43
|
+
if (base.protocol !== 'https:' && !(base.protocol === 'http:' && loopback)) throw llmConfig(`${p.base} must use https`, 'Only a loopback address may use plain http')
|
|
44
|
+
if (base.username || base.password) throw llmConfig(`${p.base} must not carry credentials`, `Keep the key in ${p.key}`)
|
|
45
|
+
const messages = o.messages ?? (typeof o.prompt === 'string' ? [{ role: 'user', content: o.prompt }] : null)
|
|
46
|
+
if (!Array.isArray(messages) || !messages.length) throw new LlmError('B0921', 400, 'ctx.llm needs messages or prompt', 'Pass { messages: [{ role: "user", content: "…" }] }')
|
|
47
|
+
for (const m of messages) if (!m || (m.role !== 'user' && m.role !== 'assistant') || typeof m.content !== 'string') throw new LlmError('B0921', 400, 'Each message needs role "user" or "assistant" and text content', 'The system prompt goes in { system }')
|
|
48
|
+
if (o.system != null && typeof o.system !== 'string') throw new LlmError('B0921', 400, 'system must be text', 'Pass { system: "…" }')
|
|
49
|
+
const maxTokens = o.maxTokens ?? LLM_DEFAULT_MAX_TOKENS
|
|
50
|
+
if (!Number.isSafeInteger(maxTokens) || maxTokens < 1) throw new LlmError('B0921', 400, 'maxTokens must be a positive integer', `Omit it for ${LLM_DEFAULT_MAX_TOKENS}`)
|
|
51
|
+
const timeoutMs = o.timeoutMs ?? LLM_DEFAULT_TIMEOUT
|
|
52
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > LLM_MAX_TIMEOUT) throw new LlmError('B0921', 400, `timeoutMs must be between 1 and ${LLM_MAX_TIMEOUT}`, `Omit it for ${LLM_DEFAULT_TIMEOUT}`)
|
|
53
|
+
return { provider, key, model, root: base.href.replace(/\/+$/, ''), messages, system: o.system ?? null, maxTokens, timeoutMs, signal: o.signal }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function llmRequest(r, stream) {
|
|
57
|
+
if (r.provider === 'anthropic') return {
|
|
58
|
+
url: r.root + '/v1/messages',
|
|
59
|
+
headers: { 'x-api-key': r.key, 'anthropic-version': LLM_ANTHROPIC_VERSION, 'content-type': 'application/json' },
|
|
60
|
+
body: { model: r.model, max_tokens: r.maxTokens, messages: r.messages, ...(r.system ? { system: r.system } : {}), ...(stream ? { stream: true } : {}) },
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
url: r.root + '/v1/responses',
|
|
64
|
+
headers: { authorization: 'Bearer ' + r.key, 'content-type': 'application/json' },
|
|
65
|
+
body: { model: r.model, max_output_tokens: r.maxTokens, input: r.messages, store: false, ...(r.system ? { instructions: r.system } : {}), ...(stream ? { stream: true } : {}) },
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const llmStop = reason => ({ end_turn: 'end', stop_sequence: 'end', completed: 'end', max_tokens: 'length', max_output_tokens: 'length', content_filter: 'filter', refusal: 'filter' })[reason] || 'other'
|
|
70
|
+
const llmRedact = (text, key) => String(text ?? '').split(key).join('[key]').slice(0, LLM_MAX_DETAIL)
|
|
71
|
+
|
|
72
|
+
function llmParsed(r, json, requestId) {
|
|
73
|
+
if (r.provider === 'anthropic') {
|
|
74
|
+
if (!Array.isArray(json?.content)) throw llmFailure(502, 'The model provider sent an unexpected answer', { provider: r.provider })
|
|
75
|
+
return { provider: r.provider, model: json.model ?? r.model, text: json.content.filter(b => b?.type === 'text').map(b => b.text).join(''), stopReason: llmStop(json.stop_reason), usage: { inputTokens: json.usage?.input_tokens ?? null, outputTokens: json.usage?.output_tokens ?? null }, requestId }
|
|
76
|
+
}
|
|
77
|
+
if (json?.status === 'failed') throw llmFailure(502, 'The model provider could not complete the request', { provider: r.provider, detail: llmRedact(json.error?.message, r.key) })
|
|
78
|
+
if (!Array.isArray(json?.output)) throw llmFailure(502, 'The model provider sent an unexpected answer', { provider: r.provider })
|
|
79
|
+
const text = json.output.filter(i => i?.type === 'message').flatMap(i => i.content || []).filter(c => c?.type === 'output_text').map(c => c.text).join('')
|
|
80
|
+
return { provider: r.provider, model: json.model ?? r.model, text, stopReason: llmStop(json.incomplete_details?.reason ?? json.status), usage: { inputTokens: json.usage?.input_tokens ?? null, outputTokens: json.usage?.output_tokens ?? null }, requestId }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function llmStatusError(r, res) {
|
|
84
|
+
let detail = ''
|
|
85
|
+
try { const raw = await res.text(); try { const j = JSON.parse(raw); detail = j?.error?.message ?? raw } catch { detail = raw } } catch {}
|
|
86
|
+
const providerStatus = res.status, extra = { provider: r.provider, providerStatus, detail: llmRedact(detail, r.key) }
|
|
87
|
+
if (providerStatus === 429) return llmFailure(429, 'The model provider is rate limiting requests; try again shortly', { ...extra, retryAfter: res.headers.get('retry-after') })
|
|
88
|
+
if (providerStatus === 401 || providerStatus === 403) return new LlmError('B0921', 503, `The model provider refused the key in ${LLM_PROVIDERS[r.provider].key}`, 'Check the key and its permissions', extra)
|
|
89
|
+
return llmFailure(providerStatus === 504 || providerStatus === 408 ? 504 : 502, 'The model provider could not complete the request', extra)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function llmFetch(r, stream, fallback) {
|
|
93
|
+
const deadline = AbortSignal.timeout(r.timeoutMs)
|
|
94
|
+
const signal = AbortSignal.any([deadline, ...(fallback ? [fallback] : []), ...(r.signal ? [r.signal] : [])])
|
|
95
|
+
const q = llmRequest(r, stream)
|
|
96
|
+
const failed = e => deadline.aborted ? llmFailure(504, 'The model provider did not answer in time', { provider: r.provider }) : signal.aborted ? llmFailure(499, 'The LLM call was cancelled', { provider: r.provider, cancelled: true }) : e instanceof LlmError ? e : llmFailure(502, 'The model provider could not be reached', { provider: r.provider })
|
|
97
|
+
let res
|
|
98
|
+
try { res = await fetch(q.url, { method: 'POST', headers: q.headers, body: JSON.stringify(q.body), signal, redirect: 'error' }) } catch (e) { throw failed(e) }
|
|
99
|
+
return { res, failed, requestId: res.headers.get('request-id') ?? res.headers.get('x-request-id') }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function llmReadAll(res) {
|
|
103
|
+
const reader = res.body.getReader(), parts = []
|
|
104
|
+
let total = 0
|
|
105
|
+
for (;;) {
|
|
106
|
+
const { done, value } = await reader.read()
|
|
107
|
+
if (done) break
|
|
108
|
+
total += value.length
|
|
109
|
+
if (total > LLM_MAX_BODY) { await reader.cancel(); throw llmFailure(502, 'The model provider answer is too large') }
|
|
110
|
+
parts.push(value)
|
|
111
|
+
}
|
|
112
|
+
return Buffer.concat(parts).toString('utf8')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// One `data:` payload per server-sent event; the event name is repeated inside each payload by both providers.
|
|
116
|
+
async function* llmEvents(res) {
|
|
117
|
+
const decoder = new TextDecoder(), reader = res.body.getReader()
|
|
118
|
+
let buffer = '', data = []
|
|
119
|
+
try {
|
|
120
|
+
for (;;) {
|
|
121
|
+
const { done, value } = await reader.read()
|
|
122
|
+
if (done) return
|
|
123
|
+
buffer += decoder.decode(value, { stream: true })
|
|
124
|
+
if (buffer.length > LLM_MAX_LINE) throw llmFailure(502, 'The model provider sent an oversized event')
|
|
125
|
+
for (let at; (at = buffer.indexOf('\n')) >= 0;) {
|
|
126
|
+
const line = buffer.slice(0, at).replace(/\r$/, '')
|
|
127
|
+
buffer = buffer.slice(at + 1)
|
|
128
|
+
if (line === '') { if (data.length) { const raw = data.join('\n'); data = []; let e; try { e = JSON.parse(raw) } catch { throw llmFailure(502, 'The model provider sent an unreadable event') } yield e } }
|
|
129
|
+
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart())
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} finally { await reader.cancel().catch(() => {}) }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function llmStream(r, parent) {
|
|
136
|
+
let settle, reject
|
|
137
|
+
const result = new Promise((a, b) => { settle = a; reject = b })
|
|
138
|
+
result.catch(() => {}) // reading `.result` is optional; the iterator throws the same failure
|
|
139
|
+
const run = async function* () {
|
|
140
|
+
let opened
|
|
141
|
+
try { opened = await llmFetch(r, true, parent) } catch (e) { reject(e); throw e }
|
|
142
|
+
const { res, failed, requestId } = opened
|
|
143
|
+
const out = { provider: r.provider, model: r.model, text: '', stopReason: 'other', usage: { inputTokens: null, outputTokens: null }, requestId }
|
|
144
|
+
let finished = false
|
|
145
|
+
try {
|
|
146
|
+
if (!res.ok) throw await llmStatusError(r, res)
|
|
147
|
+
for await (const e of llmEvents(res)) {
|
|
148
|
+
let piece = ''
|
|
149
|
+
if (r.provider === 'anthropic') {
|
|
150
|
+
if (e.type === 'error') throw e.error?.type === 'rate_limit_error' ? llmFailure(429, 'The model provider is rate limiting requests; try again shortly', { provider: r.provider, detail: llmRedact(e.error?.message, r.key) }) : llmFailure(502, 'The model provider failed during the answer', { provider: r.provider, detail: llmRedact(e.error?.message, r.key) })
|
|
151
|
+
if (e.type === 'message_start') { out.model = e.message?.model ?? out.model; out.usage.inputTokens = e.message?.usage?.input_tokens ?? null; out.usage.outputTokens = e.message?.usage?.output_tokens ?? null }
|
|
152
|
+
else if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta') piece = e.delta.text
|
|
153
|
+
else if (e.type === 'message_delta') { out.stopReason = llmStop(e.delta?.stop_reason); if (e.usage?.output_tokens != null) out.usage.outputTokens = e.usage.output_tokens }
|
|
154
|
+
else if (e.type === 'message_stop') finished = true
|
|
155
|
+
} else {
|
|
156
|
+
if (e.type === 'error') throw llmFailure(502, 'The model provider failed during the answer', { provider: r.provider, detail: llmRedact(e.message, r.key) })
|
|
157
|
+
if (e.type === 'response.failed') throw llmFailure(502, 'The model provider could not complete the request', { provider: r.provider, detail: llmRedact(e.response?.error?.message, r.key) })
|
|
158
|
+
if (e.type === 'response.output_text.delta') piece = e.delta
|
|
159
|
+
else if (e.type === 'response.completed' || e.type === 'response.incomplete') {
|
|
160
|
+
finished = true
|
|
161
|
+
out.model = e.response?.model ?? out.model
|
|
162
|
+
out.stopReason = llmStop(e.response?.incomplete_details?.reason ?? e.response?.status)
|
|
163
|
+
out.usage = { inputTokens: e.response?.usage?.input_tokens ?? null, outputTokens: e.response?.usage?.output_tokens ?? null }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (piece) { out.text += piece; yield piece }
|
|
167
|
+
}
|
|
168
|
+
if (!finished) throw llmFailure(502, 'The model provider ended the answer early', { provider: r.provider })
|
|
169
|
+
settle(out)
|
|
170
|
+
} catch (e) { const f = failed(e); reject(f); throw f }
|
|
171
|
+
}
|
|
172
|
+
const iterator = run()
|
|
173
|
+
// Leaving the loop early closes the provider connection; `.result` then rejects as cancelled.
|
|
174
|
+
const stop = iterator.return.bind(iterator)
|
|
175
|
+
return {
|
|
176
|
+
result,
|
|
177
|
+
[Symbol.asyncIterator]() { return this },
|
|
178
|
+
next: () => iterator.next(),
|
|
179
|
+
return: value => { reject(llmFailure(499, 'The LLM stream was closed before its end', { provider: r.provider, cancelled: true })); return stop(value) },
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ctx.llm for one request: `signal` is ctx.signal, so a closed client or a passed deadline stops the provider call.
|
|
184
|
+
export function llmClient(env, signal) {
|
|
185
|
+
return {
|
|
186
|
+
async complete(o) {
|
|
187
|
+
const r = llmResolve(env, o)
|
|
188
|
+
const { res, failed, requestId } = await llmFetch(r, false, signal)
|
|
189
|
+
try {
|
|
190
|
+
if (!res.ok) throw await llmStatusError(r, res)
|
|
191
|
+
let json
|
|
192
|
+
try { json = JSON.parse(await llmReadAll(res)) } catch (e) { if (e instanceof LlmError) throw e; if (e instanceof SyntaxError) throw llmFailure(502, 'The model provider sent an unreadable answer', { provider: r.provider }); throw e }
|
|
193
|
+
return llmParsed(r, json, requestId)
|
|
194
|
+
} catch (e) { throw failed(e) }
|
|
195
|
+
},
|
|
196
|
+
// Validates now, connects on the first read. `for await (const text of s)`, then `await s.result`.
|
|
197
|
+
stream(o) { return llmStream(llmResolve(env, o), signal) },
|
|
198
|
+
}
|
|
199
|
+
}
|