@tangleai/models 0.21.1 → 0.24.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 +26 -0
- package/README.md +2 -1
- package/package.json +3 -3
- package/src/check.d.ts +9 -10
- package/src/check.js +14 -18
- package/src/client.d.ts +59 -102
- package/src/client.js +307 -351
- package/src/embed.d.ts +54 -36
- package/src/embed.js +202 -287
- package/src/embedding-vector.d.ts +9 -4
- package/src/embedding-vector.js +13 -17
- package/src/errors.d.ts +26 -9
- package/src/errors.js +22 -24
- package/src/grammar.d.ts +6 -7
- package/src/grammar.js +39 -30
- package/src/index.d.ts +10 -9
- package/src/index.js +9 -10
- package/src/providers.d.ts +41 -31
- package/src/providers.js +79 -99
- package/src/replay.d.ts +52 -37
- package/src/replay.js +31 -59
- package/src/retry.d.ts +45 -86
- package/src/retry.js +50 -105
- package/src/routing.d.ts +5 -9
- package/src/routing.js +91 -79
- package/src/sse.d.ts +10 -1
- package/src/sse.js +0 -2
- package/src/structured.d.ts +19 -17
- package/src/structured.js +95 -124
package/src/embed.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//@ts-check
|
|
2
1
|
/**
|
|
3
2
|
* Embeddings: the `/embeddings` wire of the same OpenAI-compatible
|
|
4
3
|
* provider family the chat client speaks, and the seam every consumer
|
|
@@ -28,203 +27,133 @@
|
|
|
28
27
|
* client that trusts reply order gets exactly that from a provider
|
|
29
28
|
* that answers a batch out of order.
|
|
30
29
|
*/
|
|
31
|
-
|
|
32
30
|
import { fnv1a } from '@jarenjs/core/string';
|
|
33
31
|
import { l2Normalize } from '@jarenjs/core/vector';
|
|
34
|
-
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
import { AiError } from "./errors.js";
|
|
33
|
+
import { verifyEmbeddingComponents } from "./embedding-vector.js";
|
|
34
|
+
import { resolveEndpoint } from "./providers.js";
|
|
35
|
+
import { normalizeRetry, withRetry, isTransientFailure, httpFailure, transportFailure, abortError } from "./retry.js";
|
|
36
|
+
import { normalizeCache, replayKey, verifyEmbeddingEntry, now } from "./replay.js";
|
|
37
|
+
/** The embedder seam: what every consumer of embeddings in this package
|
|
38
|
+
* takes, and what a host implements to bring its own. */
|
|
41
39
|
/**
|
|
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
40
|
*/
|
|
58
41
|
function assertTexts(texts) {
|
|
59
|
-
|
|
60
|
-
|
|
42
|
+
if (!Array.isArray(texts) || texts.length === 0 || !texts.every((text) => typeof text === 'string'))
|
|
43
|
+
throw new AiError('AI0001', 'embed() needs a non-empty array of strings');
|
|
61
44
|
}
|
|
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
45
|
export function createEmbeddingClient(options = {}) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
46
|
+
const endpoint = resolveEndpoint(options);
|
|
47
|
+
const model = endpoint.model;
|
|
48
|
+
if (typeof model !== 'string' || model === '')
|
|
49
|
+
throw new AiError('AI0001', 'createEmbeddingClient needs a model — it is half of every vector\'s identity');
|
|
50
|
+
if (options.dims !== undefined && !(Number.isInteger(options.dims) && options.dims > 0))
|
|
51
|
+
throw new AiError('AI0001', `dims must be a positive integer, got ${String(options.dims)}`);
|
|
52
|
+
const timeoutMs = options.timeoutMs;
|
|
53
|
+
if (timeoutMs !== undefined && !(Number.isFinite(timeoutMs) && timeoutMs > 0))
|
|
54
|
+
throw new AiError('AI0001', `timeoutMs must be a positive number, got ${String(timeoutMs)}`);
|
|
55
|
+
const url = `${endpoint.base}/embeddings`;
|
|
56
|
+
const fetchFn = options.fetch ?? ((u, init) => globalThis.fetch(u, init));
|
|
57
|
+
const retry = normalizeRetry(options.retry);
|
|
58
|
+
const cache = normalizeCache(options.cache);
|
|
59
|
+
let dims = options.dims;
|
|
60
|
+
/**
|
|
61
|
+
* One request/response cycle.
|
|
62
|
+
*/
|
|
63
|
+
async function attemptOnce(texts, signal) {
|
|
64
|
+
// the attempt's own deadline rides beside the caller's signal; which
|
|
65
|
+
// of the two fired decides whether the failure is a retryable
|
|
66
|
+
// timeout or the caller's abort
|
|
67
|
+
let deadline = null;
|
|
68
|
+
let timer;
|
|
69
|
+
let requestSignal = signal;
|
|
70
|
+
if (timeoutMs !== undefined) {
|
|
71
|
+
deadline = new AbortController();
|
|
72
|
+
timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
73
|
+
requestSignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
|
|
74
|
+
}
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetchFn(url, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: endpoint.headers,
|
|
80
|
+
body: JSON.stringify({ model, input: texts }),
|
|
81
|
+
signal: requestSignal,
|
|
82
|
+
});
|
|
83
|
+
if (response.ok !== true)
|
|
84
|
+
throw await httpFailure(response, url);
|
|
85
|
+
text = await response.text();
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof AiError)
|
|
89
|
+
throw err;
|
|
90
|
+
if (deadline !== null && deadline.signal.aborted && signal?.aborted !== true)
|
|
91
|
+
throw new AiError('AI0002', `no answer from ${url} within ${timeoutMs} ms`, { status: 0, cause: err });
|
|
92
|
+
throw transportFailure(err, url);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
let payload;
|
|
98
|
+
try {
|
|
99
|
+
payload = JSON.parse(text);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new AiError('AI0003', `malformed embeddings reply: ${text.slice(0, 120)}`);
|
|
103
|
+
}
|
|
104
|
+
const vectors = reassemble(payload, texts.length, dims);
|
|
105
|
+
// the first reply settles the width; from here every reply must match
|
|
106
|
+
if (dims === undefined)
|
|
107
|
+
dims = vectors[0].length;
|
|
108
|
+
return vectors;
|
|
152
109
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
110
|
+
/**
|
|
111
|
+
* @param [options]
|
|
112
|
+
*/
|
|
113
|
+
async function embed(texts, options = {}) {
|
|
114
|
+
assertTexts(texts);
|
|
115
|
+
const { signal } = options;
|
|
116
|
+
/** @param batch */
|
|
117
|
+
const buy = (batch) => withRetry(retry, () => attemptOnce(batch, signal), { signal, retryable: isTransientFailure });
|
|
118
|
+
if (cache === null)
|
|
119
|
+
return buy(texts);
|
|
120
|
+
// per text: what the adapter remembers is placed, what it does not
|
|
121
|
+
// is bought in one call and remembered; a remembered vector's width
|
|
122
|
+
// is held to the settled one, and settles it when nothing has
|
|
123
|
+
const keys = texts.map((text) => replayKey('embeddings', endpoint, { model, input: text }));
|
|
124
|
+
const out = new Array(texts.length);
|
|
125
|
+
const missing = [];
|
|
126
|
+
for (let i = 0; i < texts.length; i++) {
|
|
127
|
+
const hit = await cache.get(keys[i]);
|
|
128
|
+
if (hit === undefined) {
|
|
129
|
+
missing.push(i);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const vector = verifyEmbeddingEntry(hit, dims);
|
|
133
|
+
if (dims === undefined)
|
|
134
|
+
dims = vector.length;
|
|
135
|
+
out[i] = vector;
|
|
136
|
+
}
|
|
137
|
+
if (missing.length > 0) {
|
|
138
|
+
const started = now();
|
|
139
|
+
const vectors = await buy(missing.map((i) => texts[i]));
|
|
140
|
+
const ms = now() - started;
|
|
141
|
+
for (let j = 0; j < missing.length; j++) {
|
|
142
|
+
out[missing[j]] = vectors[j];
|
|
143
|
+
await cache.set(keys[missing[j]], { vector: Array.from(vectors[j]), ms });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
158
147
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
};
|
|
148
|
+
return {
|
|
149
|
+
embed,
|
|
150
|
+
model,
|
|
151
|
+
provider: endpoint.provider,
|
|
152
|
+
get dims() {
|
|
153
|
+
return dims;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
226
156
|
}
|
|
227
|
-
|
|
228
157
|
/**
|
|
229
158
|
* The verification: `data[]` reassembled by each item's `index`, every
|
|
230
159
|
* input filled exactly once, every vector a non-empty array of finite
|
|
@@ -232,44 +161,40 @@ export function createEmbeddingClient(options = {}) {
|
|
|
232
161
|
* that failed. Nothing is guessed: an item without a usable index is
|
|
233
162
|
* refused rather than placed by position, and a vector of the wrong
|
|
234
163
|
* width is refused rather than cut or padded.
|
|
235
|
-
* @param
|
|
236
|
-
* @param
|
|
237
|
-
* @param {number | undefined} dims - the width every vector must have,
|
|
164
|
+
* @param count - the number of inputs sent
|
|
165
|
+
* @param dims - the width every vector must have,
|
|
238
166
|
* or undefined to let input 0's vector settle it for this reply
|
|
239
|
-
* @returns {Float32Array[]}
|
|
240
167
|
*/
|
|
241
168
|
function reassemble(payload, count, dims) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return out;
|
|
169
|
+
const data = payload?.data;
|
|
170
|
+
if (!Array.isArray(data))
|
|
171
|
+
throw new AiError('AI0003', 'malformed embeddings reply: no data[] in the response');
|
|
172
|
+
if (data.length !== count)
|
|
173
|
+
throw new AiError('AI0003', `embeddings reply carried ${data.length} items for ${count} inputs`);
|
|
174
|
+
const slots = new Array(count);
|
|
175
|
+
for (let i = 0; i < count; i++) {
|
|
176
|
+
const item = data[i];
|
|
177
|
+
const index = item?.index;
|
|
178
|
+
if (!Number.isInteger(index) || index < 0 || index >= count)
|
|
179
|
+
throw new AiError('AI0003', `embeddings reply item ${i} carries no usable index for ${count} inputs (got ${JSON.stringify(index)})`);
|
|
180
|
+
if (slots[index] !== undefined)
|
|
181
|
+
throw new AiError('AI0003', `input ${index} received two embeddings`);
|
|
182
|
+
slots[index] = item.embedding;
|
|
183
|
+
}
|
|
184
|
+
// `count` items over `count` distinct indices: every slot is filled
|
|
185
|
+
const out = new Array(count);
|
|
186
|
+
for (let i = 0; i < count; i++) {
|
|
187
|
+
const embedding = slots[i];
|
|
188
|
+
if (!Array.isArray(embedding) || embedding.length === 0)
|
|
189
|
+
throw new AiError('AI0003', `input ${i} received an empty embedding`);
|
|
190
|
+
if (dims === undefined)
|
|
191
|
+
dims = embedding.length;
|
|
192
|
+
if (embedding.length !== dims)
|
|
193
|
+
throw new AiError('AI0003', `input ${i} received ${embedding.length} dimensions, expected ${dims}`);
|
|
194
|
+
out[i] = verifyEmbeddingComponents(embedding, `input ${i} received`);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
271
197
|
}
|
|
272
|
-
|
|
273
198
|
/**
|
|
274
199
|
* Probe the embeddings wire before relying on it: can this key/URL/model
|
|
275
200
|
* embed at all, and at what width? Embeds one word with exactly the
|
|
@@ -277,43 +202,37 @@ function reassemble(payload, count, dims) {
|
|
|
277
202
|
* (default 5 000, as `probeProvider`). Never throws — the result object
|
|
278
203
|
* is the settings-UI contract, and the live proof that a provider
|
|
279
204
|
* really serves `/embeddings` beside `/chat/completions`.
|
|
280
|
-
* @param
|
|
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 }>}
|
|
205
|
+
* @param [options]
|
|
285
206
|
*/
|
|
286
207
|
export async function probeEmbeddings(options = {}) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
}
|
|
208
|
+
let client;
|
|
209
|
+
try {
|
|
210
|
+
// never through a cache: a probe's job is to prove the wire answers today
|
|
211
|
+
client = createEmbeddingClient({
|
|
212
|
+
...options,
|
|
213
|
+
cache: undefined,
|
|
214
|
+
retry: { attempts: 1 },
|
|
215
|
+
timeoutMs: options.timeoutMs ?? 5000,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return { ok: false, error: err.message };
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const [vector] = await client.embed(['probe']);
|
|
223
|
+
return { ok: true, model: client.model, dims: vector.length };
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const status = err instanceof AiError && typeof err.status === 'number' && err.status > 0
|
|
227
|
+
? err.status
|
|
228
|
+
: undefined;
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
...(status === undefined ? {} : { status }),
|
|
232
|
+
error: err?.message ?? String(err),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
315
235
|
}
|
|
316
|
-
|
|
317
236
|
/**
|
|
318
237
|
* The deterministic reference embedder — demo-grade, for tests and
|
|
319
238
|
* offline demos. Each text becomes the bag of its case-folded character
|
|
@@ -325,38 +244,34 @@ export async function probeEmbeddings(options = {}) {
|
|
|
325
244
|
* thing — so it exercises retrieval mechanics (does the right memory
|
|
326
245
|
* reach the prompt?) without saying anything about embedding quality,
|
|
327
246
|
* which belongs to a real model behind the same seam.
|
|
328
|
-
* @param
|
|
247
|
+
* @param [options] - the width (default 64); the
|
|
329
248
|
* identity is `hash-trigram-<dims>`, so two widths never mix
|
|
330
|
-
* @returns {Embedder & { dims: number }}
|
|
331
249
|
*/
|
|
332
250
|
export function createHashEmbedder(options = {}) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return texts.map(trigramVector);
|
|
360
|
-
},
|
|
361
|
-
};
|
|
251
|
+
const dims = options.dims ?? 64;
|
|
252
|
+
if (!(Number.isInteger(dims) && dims > 0))
|
|
253
|
+
throw new AiError('AI0001', `createHashEmbedder needs a positive integer dims, got ${String(dims)}`);
|
|
254
|
+
const model = `hash-trigram-${dims}`;
|
|
255
|
+
/**
|
|
256
|
+
*/
|
|
257
|
+
function trigramVector(text) {
|
|
258
|
+
const counts = new Float32Array(dims);
|
|
259
|
+
const padded = ` ${text.toLowerCase()} `;
|
|
260
|
+
for (let i = 0; i + 3 <= padded.length; i++)
|
|
261
|
+
counts[fnv1a(padded.slice(i, i + 3)) % dims] += 1;
|
|
262
|
+
// counts is finite and non-empty, so this is never null; a text
|
|
263
|
+
// without a single trigram stays the zero vector, which scores 0
|
|
264
|
+
// against everything rather than NaN
|
|
265
|
+
return l2Normalize(counts);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
model,
|
|
269
|
+
dims,
|
|
270
|
+
async embed(texts, options = {}) {
|
|
271
|
+
assertTexts(texts);
|
|
272
|
+
if (options.signal?.aborted)
|
|
273
|
+
throw abortError(options.signal);
|
|
274
|
+
return texts.map(trigramVector);
|
|
275
|
+
},
|
|
276
|
+
};
|
|
362
277
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The embeddings wire and replay share one AI0003 conversion policy:
|
|
3
|
+
* each component must remain finite in the Float32Array the client
|
|
4
|
+
* returns. Shape and width are checked by the caller, which also
|
|
5
|
+
* supplies the subject naming the input or stored entry that failed.
|
|
6
|
+
*/
|
|
1
7
|
/**
|
|
2
8
|
* Verify embedding components into a fresh Float32Array, refusing
|
|
3
9
|
* non-numbers, non-finite values and finite numbers that overflow
|
|
4
10
|
* Float32. The source array is never changed.
|
|
5
|
-
* @param
|
|
6
|
-
* @param
|
|
7
|
-
* @returns {Float32Array}
|
|
11
|
+
* @param components - an array whose shape and width were checked
|
|
12
|
+
* @param subject - the subject and verb of a refusal
|
|
8
13
|
*/
|
|
9
|
-
export function verifyEmbeddingComponents(components: any[], subject: string): Float32Array;
|
|
14
|
+
export declare function verifyEmbeddingComponents(components: any[], subject: string): Float32Array;
|
package/src/embedding-vector.js
CHANGED
|
@@ -1,30 +1,26 @@
|
|
|
1
|
-
//@ts-check
|
|
2
1
|
/**
|
|
3
2
|
* The embeddings wire and replay share one AI0003 conversion policy:
|
|
4
3
|
* each component must remain finite in the Float32Array the client
|
|
5
4
|
* returns. Shape and width are checked by the caller, which also
|
|
6
5
|
* supplies the subject naming the input or stored entry that failed.
|
|
7
6
|
*/
|
|
8
|
-
|
|
9
|
-
import { AiError } from './errors.js';
|
|
10
|
-
|
|
7
|
+
import { AiError } from "./errors.js";
|
|
11
8
|
/**
|
|
12
9
|
* Verify embedding components into a fresh Float32Array, refusing
|
|
13
10
|
* non-numbers, non-finite values and finite numbers that overflow
|
|
14
11
|
* Float32. The source array is never changed.
|
|
15
|
-
* @param
|
|
16
|
-
* @param
|
|
17
|
-
* @returns {Float32Array}
|
|
12
|
+
* @param components - an array whose shape and width were checked
|
|
13
|
+
* @param subject - the subject and verb of a refusal
|
|
18
14
|
*/
|
|
19
15
|
export function verifyEmbeddingComponents(components, subject) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
const vector = new Float32Array(components.length);
|
|
17
|
+
for (let i = 0; i < components.length; i++) {
|
|
18
|
+
const x = components[i];
|
|
19
|
+
if (typeof x !== 'number' || !Number.isFinite(x))
|
|
20
|
+
throw new AiError('AI0003', `${subject} a component that is not a finite number at ${i}`);
|
|
21
|
+
vector[i] = x;
|
|
22
|
+
if (!Number.isFinite(vector[i]))
|
|
23
|
+
throw new AiError('AI0003', `${subject} a component outside the finite Float32 range at ${i}`);
|
|
24
|
+
}
|
|
25
|
+
return vector;
|
|
30
26
|
}
|
package/src/errors.d.ts
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The model transport error type. Every failure the package itself raises
|
|
3
|
+
* carries a stable code, like the rest of the suite:
|
|
4
|
+
*
|
|
5
|
+
* AI0001 — invalid configuration or request (caller error)
|
|
6
|
+
* AI0002 — the provider answered with an HTTP error status
|
|
7
|
+
* AI0003 — the provider answered with a malformed payload
|
|
8
|
+
*
|
|
9
|
+
* Tool execution and the agent loop never throw for content-level
|
|
10
|
+
* problems (an unknown tool, invalid tool input, a tool that throws) —
|
|
11
|
+
* those come back as `{ error }` results the model can read and
|
|
12
|
+
* recover from. AiError is reserved for the transport and for misuse.
|
|
13
|
+
*
|
|
14
|
+
* Refinement (`refine.ts`) rejects with its own `AI01xx` codes, enumerated
|
|
15
|
+
* there. They are the same idea one step further out: never thrown, they
|
|
16
|
+
* travel as records with a pointer, because their reader is a model
|
|
17
|
+
* repairing its own proposal.
|
|
18
|
+
*/
|
|
19
|
+
import { CodedError } from '@jarenjs/core/errors';
|
|
20
|
+
export declare class AiError extends CodedError {
|
|
21
|
+
status?: number;
|
|
22
|
+
attempts?: number;
|
|
23
|
+
retryAfterMs?: number;
|
|
2
24
|
/**
|
|
3
|
-
* @param
|
|
4
|
-
* @param
|
|
25
|
+
* @param code - stable error code ('AI0001' | 'AI0002' | 'AI0003')
|
|
26
|
+
* @param reason - The bare reason; `message` is composed as
|
|
5
27
|
* `${code}: ${reason}` per the coded contract (transport errors
|
|
6
28
|
* have no document, so there is never a location).
|
|
7
|
-
* @param
|
|
8
|
-
* cause?: unknown }} [meta] - transport metadata: the HTTP status
|
|
29
|
+
* @param [meta] - transport metadata: the HTTP status
|
|
9
30
|
* (`0` for a network failure before any response), how many tries
|
|
10
31
|
* the client made, and the provider's `Retry-After` in ms
|
|
11
32
|
*/
|
|
@@ -15,8 +36,4 @@ export class AiError extends CodedError {
|
|
|
15
36
|
retryAfterMs?: number;
|
|
16
37
|
cause?: unknown;
|
|
17
38
|
});
|
|
18
|
-
status: number;
|
|
19
|
-
attempts: number;
|
|
20
|
-
retryAfterMs: number;
|
|
21
39
|
}
|
|
22
|
-
import { CodedError } from '@jarenjs/core/errors';
|