@tangleai/models 0.21.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/package.json +92 -0
- package/src/check.d.ts +29 -0
- package/src/check.js +37 -0
- package/src/client.d.ts +185 -0
- package/src/client.js +433 -0
- package/src/embed.d.ts +129 -0
- package/src/embed.js +362 -0
- package/src/embedding-vector.d.ts +9 -0
- package/src/embedding-vector.js +30 -0
- package/src/errors.d.ts +22 -0
- package/src/errors.js +45 -0
- package/src/grammar.d.ts +29 -0
- package/src/grammar.js +35 -0
- package/src/index.d.ts +9 -0
- package/src/index.js +11 -0
- package/src/providers.d.ts +69 -0
- package/src/providers.js +156 -0
- package/src/replay.d.ts +76 -0
- package/src/replay.js +141 -0
- package/src/retry.d.ts +141 -0
- package/src/retry.js +182 -0
- package/src/routing.d.ts +32 -0
- package/src/routing.js +87 -0
- package/src/sse.d.ts +1 -0
- package/src/sse.js +12 -0
- package/src/structured.d.ts +89 -0
- package/src/structured.js +219 -0
package/src/embed.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Embeddings: the `/embeddings` wire of the same OpenAI-compatible
|
|
4
|
+
* provider family the chat client speaks, and the seam every consumer
|
|
5
|
+
* of an embedding in this package is written against —
|
|
6
|
+
*
|
|
7
|
+
* { embed(texts, { signal }) → Promise<Float32Array[]>, model, dims }
|
|
8
|
+
*
|
|
9
|
+
* — one vector per input, in input order, from a named model at a
|
|
10
|
+
* fixed width. `model` and `dims` are a vector's identity: vectors from
|
|
11
|
+
* different models are pairwise meaningless and compare into plausible
|
|
12
|
+
* garbage, so the identity travels with every embedding and a consumer
|
|
13
|
+
* refuses to mix two. Two implementations ship here.
|
|
14
|
+
* `createEmbeddingClient` is the wire — OpenRouter, Ollama, LM Studio or
|
|
15
|
+
* any OpenAI-compatible base, resolved exactly as the chat client
|
|
16
|
+
* resolves it. `createHashEmbedder` is the deterministic reference:
|
|
17
|
+
* hashed character trigrams, dependency-free and network-free — what
|
|
18
|
+
* the tests and the offline demos run on, and demo-grade by design.
|
|
19
|
+
* Anything heavier (a local transformer runtime, a native embedding
|
|
20
|
+
* library) is the host's, wired through the same three members. This
|
|
21
|
+
* package ships no model weights, no tokenizer, no download and no
|
|
22
|
+
* opinion on which embedding model is good.
|
|
23
|
+
*
|
|
24
|
+
* The one invariant the wire client exists to own: a reply is
|
|
25
|
+
* reassembled by each item's `index`, and refused unless exactly one
|
|
26
|
+
* non-empty, finite vector of the expected width arrives per input. An
|
|
27
|
+
* embedding attached to the wrong text is worse than an error, and a
|
|
28
|
+
* client that trusts reply order gets exactly that from a provider
|
|
29
|
+
* that answers a batch out of order.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { fnv1a } from '@jarenjs/core/string';
|
|
33
|
+
import { l2Normalize } from '@jarenjs/core/vector';
|
|
34
|
+
|
|
35
|
+
import { AiError } from './errors.js';
|
|
36
|
+
import { verifyEmbeddingComponents } from './embedding-vector.js';
|
|
37
|
+
import { resolveEndpoint } from './providers.js';
|
|
38
|
+
import { normalizeRetry, withRetry, isTransientFailure, httpFailure, transportFailure, abortError } from './retry.js';
|
|
39
|
+
import { normalizeCache, replayKey, verifyEmbeddingEntry, now } from './replay.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The embedder seam: what every consumer of embeddings in this package
|
|
43
|
+
* takes, and what a host implements to bring its own.
|
|
44
|
+
* @typedef {Object} Embedder
|
|
45
|
+
* @property {(texts: string[], options?: { signal?: AbortSignal }) => Promise<Float32Array[]>} embed
|
|
46
|
+
* - one vector per input, in input order; rejects `AI0001` for
|
|
47
|
+
* anything but a non-empty array of strings
|
|
48
|
+
* @property {string} model - the name half of a vector's identity
|
|
49
|
+
* @property {number | undefined} dims - the width half; the wire client
|
|
50
|
+
* leaves it undefined until its first reply settles it, unless the
|
|
51
|
+
* caller configured it
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {unknown} texts
|
|
56
|
+
* @returns {asserts texts is string[]}
|
|
57
|
+
*/
|
|
58
|
+
function assertTexts(texts) {
|
|
59
|
+
if (!Array.isArray(texts) || texts.length === 0 || !texts.every((text) => typeof text === 'string'))
|
|
60
|
+
throw new AiError('AI0001', 'embed() needs a non-empty array of strings');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The OpenAI-compatible `/embeddings` client over the existing provider
|
|
65
|
+
* set. The endpoint is `${base}/embeddings` from the same resolved base
|
|
66
|
+
* the chat client uses, with the same auth and headers; the request is
|
|
67
|
+
* one non-streaming POST of `{ model, input }`.
|
|
68
|
+
*
|
|
69
|
+
* Retries follow the chat client exactly: transient transport failures
|
|
70
|
+
* (network, 408, 429, 5xx) and a malformed 200 back off with full
|
|
71
|
+
* jitter and try again, `Retry-After` wins up to `maxMs`, an abort ends
|
|
72
|
+
* everything at once. A structurally wrong reply — the wrong width for
|
|
73
|
+
* a fixed model — is therefore reported after `attempts` tries; a probe
|
|
74
|
+
* that wants a fast answer sets `retry: { attempts: 1 }`.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ provider?: string, baseUrl?: string, apiKey?: string,
|
|
77
|
+
* model?: string, dims?: number, headers?: Record<string, string>,
|
|
78
|
+
* fetch?: typeof fetch, timeoutMs?: number,
|
|
79
|
+
* retry?: import('./retry.js').RetryOptions,
|
|
80
|
+
* cache?: import('./replay.js').ReplayCache }} [options]
|
|
81
|
+
* - `model` is required: it is half of every vector's identity.
|
|
82
|
+
* - `cache` is the replay seam (`createChatClient` documents the
|
|
83
|
+
* contract). Here it is PER TEXT: each input is keyed by the
|
|
84
|
+
* credential-free endpoint, the model and the text; the texts the
|
|
85
|
+
* adapter remembers come back from it, only the rest travel — in one
|
|
86
|
+
* wire call, in input order — and every bought vector is remembered
|
|
87
|
+
* as `{ vector: number[], ms }` (the wall time of the batch). A
|
|
88
|
+
* call whose every text is remembered makes no wire call at all, and
|
|
89
|
+
* its first replay settles `dims` exactly as a first reply would.
|
|
90
|
+
* - `dims` pins the other half up front; left out, the width of the
|
|
91
|
+
* first reply becomes the client's, and every later reply must
|
|
92
|
+
* match it. Give it when the identity must be known before the
|
|
93
|
+
* first call.
|
|
94
|
+
* - `timeoutMs` bounds each attempt, and a timed-out attempt retries
|
|
95
|
+
* like a network failure. Unset means no timeout, as `complete()`
|
|
96
|
+
* has none — a batch of long texts on a local runtime legitimately
|
|
97
|
+
* takes a while; `probeEmbeddings` sets 5 000 ms, as `probeProvider`
|
|
98
|
+
* does.
|
|
99
|
+
* - `retry` is the chat client's option, unchanged (see
|
|
100
|
+
* `createChatClient`).
|
|
101
|
+
* @returns {Embedder & { provider: string }} the seam, plus the
|
|
102
|
+
* resolved provider name; `dims` is the settled width
|
|
103
|
+
*/
|
|
104
|
+
export function createEmbeddingClient(options = {}) {
|
|
105
|
+
const endpoint = resolveEndpoint(options);
|
|
106
|
+
const model = endpoint.model;
|
|
107
|
+
if (typeof model !== 'string' || model === '')
|
|
108
|
+
throw new AiError('AI0001', 'createEmbeddingClient needs a model — it is half of every vector\'s identity');
|
|
109
|
+
if (options.dims !== undefined && !(Number.isInteger(options.dims) && options.dims > 0))
|
|
110
|
+
throw new AiError('AI0001', `dims must be a positive integer, got ${String(options.dims)}`);
|
|
111
|
+
const timeoutMs = options.timeoutMs;
|
|
112
|
+
if (timeoutMs !== undefined && !(Number.isFinite(timeoutMs) && timeoutMs > 0))
|
|
113
|
+
throw new AiError('AI0001', `timeoutMs must be a positive number, got ${String(timeoutMs)}`);
|
|
114
|
+
const url = `${endpoint.base}/embeddings`;
|
|
115
|
+
const fetchFn = options.fetch ?? ((u, init) => globalThis.fetch(u, init));
|
|
116
|
+
const retry = normalizeRetry(options.retry);
|
|
117
|
+
const cache = normalizeCache(options.cache);
|
|
118
|
+
/** @type {number | undefined} */
|
|
119
|
+
let dims = options.dims;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One request/response cycle.
|
|
123
|
+
* @param {string[]} texts
|
|
124
|
+
* @param {AbortSignal | undefined} signal
|
|
125
|
+
* @returns {Promise<Float32Array[]>}
|
|
126
|
+
*/
|
|
127
|
+
async function attemptOnce(texts, signal) {
|
|
128
|
+
// the attempt's own deadline rides beside the caller's signal; which
|
|
129
|
+
// of the two fired decides whether the failure is a retryable
|
|
130
|
+
// timeout or the caller's abort
|
|
131
|
+
/** @type {AbortController | null} */
|
|
132
|
+
let deadline = null;
|
|
133
|
+
/** @type {any} */
|
|
134
|
+
let timer;
|
|
135
|
+
let requestSignal = signal;
|
|
136
|
+
if (timeoutMs !== undefined) {
|
|
137
|
+
deadline = new AbortController();
|
|
138
|
+
timer = setTimeout(() => /** @type {AbortController} */ (deadline).abort(), timeoutMs);
|
|
139
|
+
requestSignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
|
|
140
|
+
}
|
|
141
|
+
/** @type {string} */
|
|
142
|
+
let text;
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetchFn(url, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: endpoint.headers,
|
|
147
|
+
body: JSON.stringify({ model, input: texts }),
|
|
148
|
+
signal: requestSignal,
|
|
149
|
+
});
|
|
150
|
+
if (response.ok !== true) throw await httpFailure(response, url);
|
|
151
|
+
text = await response.text();
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (err instanceof AiError) throw err;
|
|
155
|
+
if (deadline !== null && deadline.signal.aborted && signal?.aborted !== true)
|
|
156
|
+
throw new AiError('AI0002', `no answer from ${url} within ${timeoutMs} ms`, { status: 0, cause: err });
|
|
157
|
+
throw transportFailure(err, url);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
}
|
|
162
|
+
/** @type {any} */
|
|
163
|
+
let payload;
|
|
164
|
+
try {
|
|
165
|
+
payload = JSON.parse(text);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
throw new AiError('AI0003', `malformed embeddings reply: ${text.slice(0, 120)}`);
|
|
169
|
+
}
|
|
170
|
+
const vectors = reassemble(payload, texts.length, dims);
|
|
171
|
+
// the first reply settles the width; from here every reply must match
|
|
172
|
+
if (dims === undefined) dims = vectors[0].length;
|
|
173
|
+
return vectors;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string[]} texts
|
|
178
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
179
|
+
* @returns {Promise<Float32Array[]>}
|
|
180
|
+
*/
|
|
181
|
+
async function embed(texts, options = {}) {
|
|
182
|
+
assertTexts(texts);
|
|
183
|
+
const { signal } = options;
|
|
184
|
+
/** @param {string[]} batch */
|
|
185
|
+
const buy = (batch) => withRetry(retry, () => attemptOnce(batch, signal), { signal, retryable: isTransientFailure });
|
|
186
|
+
if (cache === null) return buy(texts);
|
|
187
|
+
|
|
188
|
+
// per text: what the adapter remembers is placed, what it does not
|
|
189
|
+
// is bought in one call and remembered; a remembered vector's width
|
|
190
|
+
// is held to the settled one, and settles it when nothing has
|
|
191
|
+
const keys = texts.map((text) => replayKey('embeddings', endpoint, { model, input: text }));
|
|
192
|
+
/** @type {Float32Array[]} */
|
|
193
|
+
const out = new Array(texts.length);
|
|
194
|
+
/** @type {number[]} */
|
|
195
|
+
const missing = [];
|
|
196
|
+
for (let i = 0; i < texts.length; i++) {
|
|
197
|
+
const hit = await cache.get(keys[i]);
|
|
198
|
+
if (hit === undefined) {
|
|
199
|
+
missing.push(i);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const vector = verifyEmbeddingEntry(hit, dims);
|
|
203
|
+
if (dims === undefined) dims = vector.length;
|
|
204
|
+
out[i] = vector;
|
|
205
|
+
}
|
|
206
|
+
if (missing.length > 0) {
|
|
207
|
+
const started = now();
|
|
208
|
+
const vectors = await buy(missing.map((i) => texts[i]));
|
|
209
|
+
const ms = now() - started;
|
|
210
|
+
for (let j = 0; j < missing.length; j++) {
|
|
211
|
+
out[missing[j]] = vectors[j];
|
|
212
|
+
await cache.set(keys[missing[j]], { vector: Array.from(vectors[j]), ms });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
embed,
|
|
220
|
+
model,
|
|
221
|
+
provider: endpoint.provider,
|
|
222
|
+
get dims() {
|
|
223
|
+
return dims;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The verification: `data[]` reassembled by each item's `index`, every
|
|
230
|
+
* input filled exactly once, every vector a non-empty array of finite
|
|
231
|
+
* numbers, every width the expected one — or `AI0003` naming the input
|
|
232
|
+
* that failed. Nothing is guessed: an item without a usable index is
|
|
233
|
+
* refused rather than placed by position, and a vector of the wrong
|
|
234
|
+
* width is refused rather than cut or padded.
|
|
235
|
+
* @param {any} payload
|
|
236
|
+
* @param {number} count - the number of inputs sent
|
|
237
|
+
* @param {number | undefined} dims - the width every vector must have,
|
|
238
|
+
* or undefined to let input 0's vector settle it for this reply
|
|
239
|
+
* @returns {Float32Array[]}
|
|
240
|
+
*/
|
|
241
|
+
function reassemble(payload, count, dims) {
|
|
242
|
+
const data = payload?.data;
|
|
243
|
+
if (!Array.isArray(data))
|
|
244
|
+
throw new AiError('AI0003', 'malformed embeddings reply: no data[] in the response');
|
|
245
|
+
if (data.length !== count)
|
|
246
|
+
throw new AiError('AI0003', `embeddings reply carried ${data.length} items for ${count} inputs`);
|
|
247
|
+
/** @type {any[]} */
|
|
248
|
+
const slots = new Array(count);
|
|
249
|
+
for (let i = 0; i < count; i++) {
|
|
250
|
+
const item = data[i];
|
|
251
|
+
const index = item?.index;
|
|
252
|
+
if (!Number.isInteger(index) || index < 0 || index >= count)
|
|
253
|
+
throw new AiError('AI0003',
|
|
254
|
+
`embeddings reply item ${i} carries no usable index for ${count} inputs (got ${JSON.stringify(index)})`);
|
|
255
|
+
if (slots[index] !== undefined)
|
|
256
|
+
throw new AiError('AI0003', `input ${index} received two embeddings`);
|
|
257
|
+
slots[index] = item.embedding;
|
|
258
|
+
}
|
|
259
|
+
// `count` items over `count` distinct indices: every slot is filled
|
|
260
|
+
const out = new Array(count);
|
|
261
|
+
for (let i = 0; i < count; i++) {
|
|
262
|
+
const embedding = slots[i];
|
|
263
|
+
if (!Array.isArray(embedding) || embedding.length === 0)
|
|
264
|
+
throw new AiError('AI0003', `input ${i} received an empty embedding`);
|
|
265
|
+
if (dims === undefined) dims = embedding.length;
|
|
266
|
+
if (embedding.length !== dims)
|
|
267
|
+
throw new AiError('AI0003', `input ${i} received ${embedding.length} dimensions, expected ${dims}`);
|
|
268
|
+
out[i] = verifyEmbeddingComponents(embedding, `input ${i} received`);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Probe the embeddings wire before relying on it: can this key/URL/model
|
|
275
|
+
* embed at all, and at what width? Embeds one word with exactly the
|
|
276
|
+
* auth an `embed()` call would use, in one attempt, within `timeoutMs`
|
|
277
|
+
* (default 5 000, as `probeProvider`). Never throws — the result object
|
|
278
|
+
* is the settings-UI contract, and the live proof that a provider
|
|
279
|
+
* really serves `/embeddings` beside `/chat/completions`.
|
|
280
|
+
* @param {{ provider?: string, baseUrl?: string, apiKey?: string,
|
|
281
|
+
* model?: string, dims?: number, headers?: Record<string, string>,
|
|
282
|
+
* fetch?: typeof fetch, timeoutMs?: number }} [options]
|
|
283
|
+
* @returns {Promise<{ ok: true, model: string, dims: number } |
|
|
284
|
+
* { ok: false, status?: number, error: string }>}
|
|
285
|
+
*/
|
|
286
|
+
export async function probeEmbeddings(options = {}) {
|
|
287
|
+
/** @type {ReturnType<typeof createEmbeddingClient>} */
|
|
288
|
+
let client;
|
|
289
|
+
try {
|
|
290
|
+
// never through a cache: a probe's job is to prove the wire answers today
|
|
291
|
+
client = createEmbeddingClient({
|
|
292
|
+
...options,
|
|
293
|
+
cache: undefined,
|
|
294
|
+
retry: { attempts: 1 },
|
|
295
|
+
timeoutMs: options.timeoutMs ?? 5000,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
return { ok: false, error: /** @type {Error} */ (err).message };
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const [vector] = await client.embed(['probe']);
|
|
303
|
+
return { ok: true, model: client.model, dims: vector.length };
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
const status = err instanceof AiError && typeof err.status === 'number' && err.status > 0
|
|
307
|
+
? err.status
|
|
308
|
+
: undefined;
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
...(status === undefined ? {} : { status }),
|
|
312
|
+
error: /** @type {any} */ (err)?.message ?? String(err),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The deterministic reference embedder — demo-grade, for tests and
|
|
319
|
+
* offline demos. Each text becomes the bag of its case-folded character
|
|
320
|
+
* trigrams (the text padded with one space on each side), hashed with
|
|
321
|
+
* the suite's FNV-1a into `dims` buckets and l2-normalized: the same
|
|
322
|
+
* text yields the same vector on every host, forever, with no network,
|
|
323
|
+
* no weights and no dependency. It is LEXICAL, not semantic — two texts
|
|
324
|
+
* score high when they share letters, not when they mean the same
|
|
325
|
+
* thing — so it exercises retrieval mechanics (does the right memory
|
|
326
|
+
* reach the prompt?) without saying anything about embedding quality,
|
|
327
|
+
* which belongs to a real model behind the same seam.
|
|
328
|
+
* @param {{ dims?: number }} [options] - the width (default 64); the
|
|
329
|
+
* identity is `hash-trigram-<dims>`, so two widths never mix
|
|
330
|
+
* @returns {Embedder & { dims: number }}
|
|
331
|
+
*/
|
|
332
|
+
export function createHashEmbedder(options = {}) {
|
|
333
|
+
const dims = options.dims ?? 64;
|
|
334
|
+
if (!(Number.isInteger(dims) && dims > 0))
|
|
335
|
+
throw new AiError('AI0001', `createHashEmbedder needs a positive integer dims, got ${String(dims)}`);
|
|
336
|
+
const model = `hash-trigram-${dims}`;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @param {string} text
|
|
340
|
+
* @returns {Float32Array}
|
|
341
|
+
*/
|
|
342
|
+
function trigramVector(text) {
|
|
343
|
+
const counts = new Float32Array(dims);
|
|
344
|
+
const padded = ` ${text.toLowerCase()} `;
|
|
345
|
+
for (let i = 0; i + 3 <= padded.length; i++)
|
|
346
|
+
counts[fnv1a(padded.slice(i, i + 3)) % dims] += 1;
|
|
347
|
+
// counts is finite and non-empty, so this is never null; a text
|
|
348
|
+
// without a single trigram stays the zero vector, which scores 0
|
|
349
|
+
// against everything rather than NaN
|
|
350
|
+
return /** @type {Float32Array} */ (l2Normalize(counts));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
model,
|
|
355
|
+
dims,
|
|
356
|
+
async embed(texts, options = {}) {
|
|
357
|
+
assertTexts(texts);
|
|
358
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
359
|
+
return texts.map(trigramVector);
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify embedding components into a fresh Float32Array, refusing
|
|
3
|
+
* non-numbers, non-finite values and finite numbers that overflow
|
|
4
|
+
* Float32. The source array is never changed.
|
|
5
|
+
* @param {any[]} components - an array whose shape and width were checked
|
|
6
|
+
* @param {string} subject - the subject and verb of a refusal
|
|
7
|
+
* @returns {Float32Array}
|
|
8
|
+
*/
|
|
9
|
+
export function verifyEmbeddingComponents(components: any[], subject: string): Float32Array;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The embeddings wire and replay share one AI0003 conversion policy:
|
|
4
|
+
* each component must remain finite in the Float32Array the client
|
|
5
|
+
* returns. Shape and width are checked by the caller, which also
|
|
6
|
+
* supplies the subject naming the input or stored entry that failed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AiError } from './errors.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Verify embedding components into a fresh Float32Array, refusing
|
|
13
|
+
* non-numbers, non-finite values and finite numbers that overflow
|
|
14
|
+
* Float32. The source array is never changed.
|
|
15
|
+
* @param {any[]} components - an array whose shape and width were checked
|
|
16
|
+
* @param {string} subject - the subject and verb of a refusal
|
|
17
|
+
* @returns {Float32Array}
|
|
18
|
+
*/
|
|
19
|
+
export function verifyEmbeddingComponents(components, subject) {
|
|
20
|
+
const vector = new Float32Array(components.length);
|
|
21
|
+
for (let i = 0; i < components.length; i++) {
|
|
22
|
+
const x = components[i];
|
|
23
|
+
if (typeof x !== 'number' || !Number.isFinite(x))
|
|
24
|
+
throw new AiError('AI0003', `${subject} a component that is not a finite number at ${i}`);
|
|
25
|
+
vector[i] = x;
|
|
26
|
+
if (!Number.isFinite(vector[i]))
|
|
27
|
+
throw new AiError('AI0003', `${subject} a component outside the finite Float32 range at ${i}`);
|
|
28
|
+
}
|
|
29
|
+
return vector;
|
|
30
|
+
}
|
package/src/errors.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class AiError extends CodedError {
|
|
2
|
+
/**
|
|
3
|
+
* @param {string} code - stable error code ('AI0001' | 'AI0002' | 'AI0003')
|
|
4
|
+
* @param {string} reason - The bare reason; `message` is composed as
|
|
5
|
+
* `${code}: ${reason}` per the coded contract (transport errors
|
|
6
|
+
* have no document, so there is never a location).
|
|
7
|
+
* @param {{ status?: number, attempts?: number, retryAfterMs?: number,
|
|
8
|
+
* cause?: unknown }} [meta] - transport metadata: the HTTP status
|
|
9
|
+
* (`0` for a network failure before any response), how many tries
|
|
10
|
+
* the client made, and the provider's `Retry-After` in ms
|
|
11
|
+
*/
|
|
12
|
+
constructor(code: string, reason: string, meta?: {
|
|
13
|
+
status?: number;
|
|
14
|
+
attempts?: number;
|
|
15
|
+
retryAfterMs?: number;
|
|
16
|
+
cause?: unknown;
|
|
17
|
+
});
|
|
18
|
+
status: number;
|
|
19
|
+
attempts: number;
|
|
20
|
+
retryAfterMs: number;
|
|
21
|
+
}
|
|
22
|
+
import { CodedError } from '@jarenjs/core/errors';
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The model transport error type. Every failure the package itself raises
|
|
4
|
+
* carries a stable code, like the rest of the suite:
|
|
5
|
+
*
|
|
6
|
+
* AI0001 — invalid configuration or request (caller error)
|
|
7
|
+
* AI0002 — the provider answered with an HTTP error status
|
|
8
|
+
* AI0003 — the provider answered with a malformed payload
|
|
9
|
+
*
|
|
10
|
+
* Tool execution and the agent loop never throw for content-level
|
|
11
|
+
* problems (an unknown tool, invalid tool input, a tool that throws) —
|
|
12
|
+
* those come back as `{ error }` results the model can read and
|
|
13
|
+
* recover from. AiError is reserved for the transport and for misuse.
|
|
14
|
+
*
|
|
15
|
+
* Refinement (`refine.js`) rejects with its own `AI01xx` codes, enumerated
|
|
16
|
+
* there. They are the same idea one step further out: never thrown, they
|
|
17
|
+
* travel as records with a pointer, because their reader is a model
|
|
18
|
+
* repairing its own proposal.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { CodedError } from '@jarenjs/core/errors';
|
|
22
|
+
|
|
23
|
+
export class AiError extends CodedError {
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} code - stable error code ('AI0001' | 'AI0002' | 'AI0003')
|
|
26
|
+
* @param {string} reason - The bare reason; `message` is composed as
|
|
27
|
+
* `${code}: ${reason}` per the coded contract (transport errors
|
|
28
|
+
* have no document, so there is never a location).
|
|
29
|
+
* @param {{ status?: number, attempts?: number, retryAfterMs?: number,
|
|
30
|
+
* cause?: unknown }} [meta] - transport metadata: the HTTP status
|
|
31
|
+
* (`0` for a network failure before any response), how many tries
|
|
32
|
+
* the client made, and the provider's `Retry-After` in ms
|
|
33
|
+
*/
|
|
34
|
+
constructor(code, reason, meta) {
|
|
35
|
+
super('AiError', code, reason, undefined,
|
|
36
|
+
meta !== undefined && meta.cause !== undefined
|
|
37
|
+
? { cause: meta.cause }
|
|
38
|
+
: undefined);
|
|
39
|
+
if (meta !== undefined) {
|
|
40
|
+
if (meta.status !== undefined) this.status = meta.status;
|
|
41
|
+
if (meta.attempts !== undefined) this.attempts = meta.attempts;
|
|
42
|
+
if (meta.retryAfterMs !== undefined) this.retryAfterMs = meta.retryAfterMs;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/grammar.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @param {{ client: any, grammar: 'query'|'jslt'|'app'|'fsm'|'dag'|'statechart'|'workflow'|'model', profile: any,
|
|
2
|
+
* schema: any, refs?: any[], compile: (document: any) => any, gate?: any,
|
|
3
|
+
* maxRepairs?: number, stream?: boolean, onAttempt?: any,
|
|
4
|
+
* selectModel?: any, limits?: any, onRoute?: any }} options */
|
|
5
|
+
export function createGrammarAuthor(options: {
|
|
6
|
+
client: any;
|
|
7
|
+
grammar: "query" | "jslt" | "app" | "fsm" | "dag" | "statechart" | "workflow" | "model";
|
|
8
|
+
profile: any;
|
|
9
|
+
schema: any;
|
|
10
|
+
refs?: any[];
|
|
11
|
+
compile: (document: any) => any;
|
|
12
|
+
gate?: any;
|
|
13
|
+
maxRepairs?: number;
|
|
14
|
+
stream?: boolean;
|
|
15
|
+
onAttempt?: any;
|
|
16
|
+
selectModel?: any;
|
|
17
|
+
limits?: any;
|
|
18
|
+
onRoute?: any;
|
|
19
|
+
}): {
|
|
20
|
+
author: (question: any, hooks?: {}) => Promise<{
|
|
21
|
+
value: any;
|
|
22
|
+
raw: string;
|
|
23
|
+
attempts: number;
|
|
24
|
+
} | {
|
|
25
|
+
errors: any[];
|
|
26
|
+
raw: string;
|
|
27
|
+
attempts: number;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
package/src/grammar.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** One profile decoder and mandatory full-grammar gate for any injected compiler. */
|
|
3
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
4
|
+
import { createStructuredOutput } from './structured.js';
|
|
5
|
+
import { checkOutcome } from '@jarenjs/core/check';
|
|
6
|
+
import { createRoutedClient } from './routing.js';
|
|
7
|
+
|
|
8
|
+
/** @param {{ client: any, grammar: 'query'|'jslt'|'app'|'fsm'|'dag'|'statechart'|'workflow'|'model', profile: any,
|
|
9
|
+
* schema: any, refs?: any[], compile: (document: any) => any, gate?: any,
|
|
10
|
+
* maxRepairs?: number, stream?: boolean, onAttempt?: any,
|
|
11
|
+
* selectModel?: any, limits?: any, onRoute?: any }} options */
|
|
12
|
+
export function createGrammarAuthor(options) {
|
|
13
|
+
if (!['query', 'jslt', 'app', 'fsm', 'dag', 'statechart', 'workflow', 'model'].includes(options.grammar))
|
|
14
|
+
throw new TypeError('unknown authored grammar');
|
|
15
|
+
if (!options.profile || !options.schema || typeof options.compile !== 'function')
|
|
16
|
+
throw new TypeError('grammar author needs a derived profile, full schema and compiler');
|
|
17
|
+
const validator = new JarenValidator({ skipErrors: false, collectErrors: true });
|
|
18
|
+
for (const ref of options.refs ?? []) validator.addSchema(ref);
|
|
19
|
+
const full = validator.compile(options.schema);
|
|
20
|
+
const generate = createStructuredOutput({ client: createRoutedClient(options, { purpose: 'author', grammar: options.grammar }), schema: options.profile,
|
|
21
|
+
name: `jaren_${options.grammar}`, strict: false, refs: options.refs,
|
|
22
|
+
maxRepairs: options.maxRepairs, stream: options.stream ?? true, onAttempt: options.onAttempt,
|
|
23
|
+
gate: [(document) => {
|
|
24
|
+
const shape = checkOutcome(full(document));
|
|
25
|
+
if (!shape.valid) return shape;
|
|
26
|
+
try { options.compile(document); return true; }
|
|
27
|
+
catch (error) { return { valid: false, errors: [{ code: error.code ?? 'AI0200',
|
|
28
|
+
docPath: error.docPath ?? '', message: error.reason ?? error.message }] }; }
|
|
29
|
+
}, ...[].concat(options.gate ?? [])],
|
|
30
|
+
});
|
|
31
|
+
return { author: (question, hooks = {}) => generate.generate([
|
|
32
|
+
{ role: 'system', content: `Author a ${options.grammar} document. Return only JSON. The full grammar and compiler validate every response.` },
|
|
33
|
+
{ role: 'user', content: question },
|
|
34
|
+
], hooks) };
|
|
35
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { AiError } from "./errors.js";
|
|
2
|
+
export { createSseDecoder } from "./sse.js";
|
|
3
|
+
export { createStructuredOutput } from "./structured.js";
|
|
4
|
+
export { createGrammarAuthor } from "./grammar.js";
|
|
5
|
+
export { invalidInput } from "./check.js";
|
|
6
|
+
export { PROVIDERS, resolveEndpoint, probeProvider } from "./providers.js";
|
|
7
|
+
export { createChatClient, createStreamAccumulator } from "./client.js";
|
|
8
|
+
export { createEmbeddingClient, probeEmbeddings, createHashEmbedder } from "./embed.js";
|
|
9
|
+
export { createRoutedClient, MODEL_PURPOSES } from "./routing.js";
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** models: public AI mechanisms over injected Jaren foundations. */
|
|
3
|
+
export { AiError } from './errors.js';
|
|
4
|
+
export { PROVIDERS, resolveEndpoint, probeProvider } from './providers.js';
|
|
5
|
+
export { createSseDecoder } from './sse.js';
|
|
6
|
+
export { createChatClient, createStreamAccumulator } from './client.js';
|
|
7
|
+
export { createEmbeddingClient, probeEmbeddings, createHashEmbedder } from './embed.js';
|
|
8
|
+
export { createStructuredOutput } from './structured.js';
|
|
9
|
+
export { createGrammarAuthor } from './grammar.js';
|
|
10
|
+
export { createRoutedClient, MODEL_PURPOSES } from './routing.js';
|
|
11
|
+
export { invalidInput } from './check.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a provider configuration into a concrete chat endpoint.
|
|
3
|
+
* @param {{ provider?: string, baseUrl?: string, apiKey?: string,
|
|
4
|
+
* model?: string, headers?: Record<string, string> }} [options]
|
|
5
|
+
* @returns {{ provider: string, base: string, url: string,
|
|
6
|
+
* headers: Record<string, string>, model: string }} `base` is the
|
|
7
|
+
* normalized base URL every endpoint of this wire family hangs off;
|
|
8
|
+
* `url` is `${base}/chat/completions`
|
|
9
|
+
*/
|
|
10
|
+
export function resolveEndpoint(options?: {
|
|
11
|
+
provider?: string;
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
model?: string;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
}): {
|
|
17
|
+
provider: string;
|
|
18
|
+
base: string;
|
|
19
|
+
url: string;
|
|
20
|
+
headers: Record<string, string>;
|
|
21
|
+
model: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Probe a provider before the first turn: can this key/URL answer, and
|
|
25
|
+
* which models does it offer? GETs the OpenAI-compatible `/models`
|
|
26
|
+
* listing (OpenRouter, Ollama and LM Studio all serve it) with the
|
|
27
|
+
* same resolved auth the chat call would use. Never throws — the
|
|
28
|
+
* result object is the settings-UI contract.
|
|
29
|
+
* @param {{ provider?: string, baseUrl?: string, apiKey?: string,
|
|
30
|
+
* headers?: Record<string, string>, fetch?: typeof fetch,
|
|
31
|
+
* timeoutMs?: number }} [options]
|
|
32
|
+
* @returns {Promise<{ ok: true, models: string[] } |
|
|
33
|
+
* { ok: false, status?: number, error: string }>}
|
|
34
|
+
*/
|
|
35
|
+
export function probeProvider(options?: {
|
|
36
|
+
provider?: string;
|
|
37
|
+
baseUrl?: string;
|
|
38
|
+
apiKey?: string;
|
|
39
|
+
headers?: Record<string, string>;
|
|
40
|
+
fetch?: typeof fetch;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
ok: true;
|
|
44
|
+
models: string[];
|
|
45
|
+
} | {
|
|
46
|
+
ok: false;
|
|
47
|
+
status?: number;
|
|
48
|
+
error: string;
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* The built-in providers. `custom` accepts any OpenAI-compatible base
|
|
52
|
+
* URL (the caller must supply one). The local runtimes get `/v1`
|
|
53
|
+
* appended automatically when the URL carries no path — pasting
|
|
54
|
+
* `http://localhost:11434` just works.
|
|
55
|
+
*
|
|
56
|
+
* `structured` names the strongest structured-output tier the provider
|
|
57
|
+
* reliably speaks on this wire: `'json_schema'` (schema-constrained
|
|
58
|
+
* decoding), `'json'` (JSON mode without a schema), or `null` (assume
|
|
59
|
+
* nothing — the schema travels in the prompt). Either way the caller
|
|
60
|
+
* validates locally; the tier only decides how much the server helps.
|
|
61
|
+
* @type {Record<string, { label: string, baseUrl: string | null,
|
|
62
|
+
* local: boolean, structured: 'json_schema' | 'json' | null }>}
|
|
63
|
+
*/
|
|
64
|
+
export const PROVIDERS: Record<string, {
|
|
65
|
+
label: string;
|
|
66
|
+
baseUrl: string | null;
|
|
67
|
+
local: boolean;
|
|
68
|
+
structured: "json_schema" | "json" | null;
|
|
69
|
+
}>;
|