@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/src/client.js ADDED
@@ -0,0 +1,433 @@
1
+ //@ts-check
2
+ /**
3
+ * The chat client: one `complete()` call against any OpenAI-compatible
4
+ * `/chat/completions` endpoint, streaming by default. The host injects
5
+ * `fetch` exactly like every other jarenjs boundary injects its
6
+ * environment, so the client runs identically in the browser, in Node
7
+ * and in tests against a scripted stub.
8
+ *
9
+ * The reply is normalized to `{ message: { role, content, toolCalls },
10
+ * finishReason, usage, model }` whether the server streamed deltas or
11
+ * answered in one JSON document.
12
+ */
13
+
14
+ import { AiError } from './errors.js';
15
+ import { resolveEndpoint } from './providers.js';
16
+ import { normalizeRetry, withRetry, isTransientFailure, httpFailure, transportFailure } from './retry.js';
17
+ import { createSseDecoder } from './sse.js';
18
+ import { normalizeCache, replayKey, verifyChatEntry, cloneJson, now } from './replay.js';
19
+
20
+ /**
21
+ * The reasoning text one streamed chunk carries: the OpenRouter/`o`-
22
+ * family `delta.reasoning` string, or the `reasoning_details` text
23
+ * entries some providers emit instead.
24
+ * @param {any} delta
25
+ * @returns {string}
26
+ */
27
+ export function reasoningOf(delta) {
28
+ if (typeof delta?.reasoning === 'string') return delta.reasoning;
29
+ if (Array.isArray(delta?.reasoning_details)) {
30
+ let text = '';
31
+ for (const detail of delta.reasoning_details) {
32
+ if (typeof detail?.text === 'string') text += detail.text;
33
+ }
34
+ return text;
35
+ }
36
+ return '';
37
+ }
38
+
39
+ /**
40
+ * Accumulates OpenAI streaming chunks (`choices[0].delta`) into one
41
+ * normalized assistant message. Tool-call fragments merge by `index`;
42
+ * argument strings concatenate across chunks; reasoning deltas
43
+ * accumulate into `message.reasoning` (absent when the model emitted
44
+ * none) so a reasoning-only turn is distinguishable from an empty one.
45
+ * @returns {{ push: (chunk: any) => string, result: () => any }}
46
+ * `push` returns the text delta this chunk contributed (may be '').
47
+ */
48
+ export function createStreamAccumulator() {
49
+ let role = 'assistant';
50
+ let content = '';
51
+ let reasoning = '';
52
+ /** @type {any[]} */
53
+ const toolCalls = [];
54
+ let finishReason = null;
55
+ let usage = null;
56
+ let model = null;
57
+
58
+ return {
59
+ push(chunk) {
60
+ if (chunk === null || typeof chunk !== 'object') return '';
61
+ if (typeof chunk.model === 'string') model = chunk.model;
62
+ if (chunk.usage != null) usage = chunk.usage;
63
+ const choice = chunk.choices?.[0];
64
+ if (choice == null) return '';
65
+ if (choice.finish_reason != null) finishReason = choice.finish_reason;
66
+ const delta = choice.delta ?? choice.message ?? {};
67
+ if (typeof delta.role === 'string') role = delta.role;
68
+ reasoning += reasoningOf(delta);
69
+ let text = '';
70
+ if (typeof delta.content === 'string') {
71
+ content += delta.content;
72
+ text = delta.content;
73
+ }
74
+ for (const fragment of delta.tool_calls ?? []) {
75
+ const at = fragment.index ?? toolCalls.length;
76
+ const slot = toolCalls[at] ?? (toolCalls[at] = { id: '', name: '', arguments: '' });
77
+ if (typeof fragment.id === 'string' && fragment.id !== '') slot.id = fragment.id;
78
+ if (typeof fragment.function?.name === 'string' && slot.name === '')
79
+ slot.name = fragment.function.name;
80
+ if (typeof fragment.function?.arguments === 'string')
81
+ slot.arguments += fragment.function.arguments;
82
+ }
83
+ return text;
84
+ },
85
+ result() {
86
+ const calls = toolCalls
87
+ .filter((call) => call != null)
88
+ .map((call, i) => ({ ...call, id: call.id === '' ? `call_${i}` : call.id }));
89
+ return {
90
+ message: {
91
+ role,
92
+ content,
93
+ toolCalls: calls.length > 0 ? calls : null,
94
+ ...(reasoning === '' ? {} : { reasoning }),
95
+ },
96
+ finishReason,
97
+ usage,
98
+ model,
99
+ };
100
+ },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * @param {any} payload - a complete (non-streamed) chat completion
106
+ * @returns {any} the normalized result
107
+ */
108
+ function fromCompletion(payload) {
109
+ const choice = payload?.choices?.[0];
110
+ if (choice == null || typeof choice !== 'object')
111
+ throw new AiError('AI0003', 'malformed completion: no choices in the response');
112
+ const message = choice.message ?? {};
113
+ const calls = (message.tool_calls ?? []).map((call, i) => ({
114
+ id: typeof call.id === 'string' && call.id !== '' ? call.id : `call_${i}`,
115
+ name: call.function?.name ?? '',
116
+ arguments: call.function?.arguments ?? '',
117
+ }));
118
+ const reasoning = reasoningOf(message);
119
+ return {
120
+ message: {
121
+ role: message.role ?? 'assistant',
122
+ content: typeof message.content === 'string' ? message.content : '',
123
+ toolCalls: calls.length > 0 ? calls : null,
124
+ ...(reasoning === '' ? {} : { reasoning }),
125
+ },
126
+ finishReason: choice.finish_reason ?? null,
127
+ usage: payload.usage ?? null,
128
+ model: payload.model ?? null,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * A reply that arrived as text rather than as a stream of events: one
134
+ * JSON completion document — a provider or proxy that ignores `stream`
135
+ * — or nothing this client can read. One implementation for every place
136
+ * that can happen (a nonstreaming request, a fetch without a readable
137
+ * body, and a stream that closed without a single event), so all answer
138
+ * the same way: the message, or `AI0003`. Never a silent empty message.
139
+ * @param {string} text
140
+ * @returns {any} the normalized result
141
+ */
142
+ function completionFromText(text) {
143
+ if (!/^\s*\{/.test(text))
144
+ throw new AiError('AI0003', `expected an SSE stream or a JSON completion, got: ${text.slice(0, 120)}`);
145
+ /** @type {any} */
146
+ let payload;
147
+ try {
148
+ payload = JSON.parse(text);
149
+ }
150
+ catch {
151
+ throw new AiError('AI0003', `malformed completion: ${text.slice(0, 120)}`);
152
+ }
153
+ return fromCompletion(payload);
154
+ }
155
+
156
+ /**
157
+ * @typedef {Object} ChatRequest
158
+ * @property {any[]} messages - OpenAI wire-shape messages
159
+ * @property {any[]} [tools] - OpenAI function-tool definitions
160
+ * @property {any} [toolChoice] - `tool_choice` passthrough
161
+ * @property {string} [model] - overrides the client's configured model
162
+ * @property {number} [temperature]
163
+ * @property {number} [maxTokens] - token ceiling for this reply, sent under
164
+ * the client's `maxTokensField`. Overrides the client default; when
165
+ * both are unset, the provider chooses the limit. With
166
+ * `max_completion_tokens`, reasoning tokens share this budget with
167
+ * visible output tokens.
168
+ * @property {boolean} [stream] - default true
169
+ * @property {{ name?: string, schema?: any, strict?: boolean, type?: 'json' }} [responseFormat]
170
+ * - structured output: `{ name, schema, strict? }` emits the OpenAI
171
+ * `response_format: { type: "json_schema", … }` wire shape (strict
172
+ * defaults to true); `{ type: 'json' }` emits `json_object` mode
173
+ * @property {{ effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high',
174
+ * enabled?: boolean, exclude?: boolean, max_tokens?: number }} [reasoning]
175
+ * - the provider-normalized thinking control, forwarded verbatim.
176
+ * `{ effort: 'none' }` (or `{ enabled: false }`) turns a hybrid
177
+ * thinking model OFF: it answers directly, which on a short task is
178
+ * dramatically cheaper and faster. `{ exclude: true }` only HIDES the
179
+ * thinking — the model still thinks and you still pay for it.
180
+ * Overrides the client-level default.
181
+ * @property {AbortSignal} [signal]
182
+ * @property {(text: string) => void} [onDelta] - streamed text callback
183
+ * @property {(text: string) => void} [onReasoning] - streamed reasoning
184
+ * callback (reasoning models emit thinking before/instead of content)
185
+ */
186
+
187
+ /**
188
+ * @param {{ provider?: string, baseUrl?: string, apiKey?: string,
189
+ * model?: string, headers?: Record<string, string>,
190
+ * fetch?: typeof fetch, maxTokens?: number,
191
+ * maxTokensField?: 'max_tokens' | 'max_completion_tokens',
192
+ * reasoning?: { effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high',
193
+ * enabled?: boolean, exclude?: boolean, max_tokens?: number },
194
+ * retry?: import('./retry.js').RetryOptions,
195
+ * cache?: import('./replay.js').ReplayCache }} [options]
196
+ * - `maxTokensField` selects the wire field for client and request
197
+ * `maxTokens` budgets (default `'max_tokens'`). Select
198
+ * `'max_completion_tokens'` for OpenAI Chat Completions, including
199
+ * reasoning models. The selection is explicit, not inferred from
200
+ * the URL or model; exactly one field is sent when a budget is set.
201
+ * - `reasoning` is the default thinking control for every request (see
202
+ * `ChatRequest.reasoning`); a per-request value overrides it.
203
+ * - `cache` is the replay seam: `{ get(key), set(key, value) }`, each
204
+ * sync or async. The client keys every request by its effective
205
+ * credential-free wire body after endpoint resolution and default
206
+ * application (`stream`, the signal and the callbacks never enter
207
+ * the key), answers a remembered reply with ZERO transport calls,
208
+ * marked `replayed: { ms }` — the wall time of the purchase — with
209
+ * `onDelta`/`onReasoning` fired once each with the whole text, and
210
+ * remembers a bought reply as `{ value, ms }`. The seam fails closed:
211
+ * an adapter that throws fails the call; a stored entry that does not
212
+ * verify is `AI0003`; a request that cannot be keyed (a function
213
+ * inside `tools`) is `AI0001` before any wire call. The key is the
214
+ * complete canonical request — an adapter wanting a fixed-width id
215
+ * hashes it cryptographically, never with a 32-bit hash.
216
+ * - `retry.attempts` is the TOTAL number of tries (default 3; 1
217
+ * disables retrying); backoff is exponential with full jitter,
218
+ * capped at `maxMs`. A provider `Retry-After` (seconds or HTTP-date)
219
+ * wins over the computed delay, capped at `maxMs` too: a provider
220
+ * asking for a minute gets the cap (8 000 ms by default), and the
221
+ * value it asked for rides the final error as `retryAfterMs` for
222
+ * the caller to honour. `random` and `sleep` exist for deterministic
223
+ * tests.
224
+ * @returns {{ endpoint: { provider: string, base: string, url: string,
225
+ * headers: Record<string, string>, model: string },
226
+ * complete: (request: ChatRequest) => Promise<any> }}
227
+ */
228
+ export function createChatClient(options = {}) {
229
+ const endpoint = resolveEndpoint(options);
230
+ const maxTokensField = options.maxTokensField ?? 'max_tokens';
231
+ if (maxTokensField !== 'max_tokens' && maxTokensField !== 'max_completion_tokens')
232
+ throw new AiError('AI0001', "maxTokensField must be 'max_tokens' or 'max_completion_tokens'");
233
+ const fetchFn = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
234
+ const retry = normalizeRetry(options.retry);
235
+ const cache = normalizeCache(options.cache);
236
+
237
+ /**
238
+ * The body one request POSTs, defaults applied. One construction for
239
+ * the wire and for the replay key, so the two cannot drift: what is
240
+ * keyed is exactly what would be sent.
241
+ * @param {ChatRequest} request
242
+ * @returns {any}
243
+ */
244
+ function requestBody(request) {
245
+ const { messages, tools, toolChoice } = request;
246
+ const model = request.model ?? endpoint.model;
247
+ const stream = request.stream ?? true;
248
+ /** @type {any} */
249
+ const body = { model, messages, stream };
250
+ if (Array.isArray(tools) && tools.length > 0) body.tools = tools;
251
+ if (toolChoice !== undefined) body.tool_choice = toolChoice;
252
+ if (typeof request.temperature === 'number') body.temperature = request.temperature;
253
+ // an unset ceiling is not "no ceiling": a provider substitutes the
254
+ // model's whole context window, and an aggregator that bills against
255
+ // a balance REFUSES the request when it cannot afford that worst case
256
+ // (OpenRouter answers 402 naming the number it wanted). A caller that
257
+ // knows its answer is a few thousand tokens should be able to say so.
258
+ const maxTokens = request.maxTokens ?? options.maxTokens;
259
+ if (typeof maxTokens === 'number') body[maxTokensField] = maxTokens;
260
+ // the thinking control rides through untouched — a hybrid model needs
261
+ // it to answer WITHOUT reasoning first, and a body that silently drops
262
+ // it is indistinguishable from a provider that ignores it
263
+ const reasoning = request.reasoning ?? options.reasoning;
264
+ if (reasoning !== undefined) body.reasoning = reasoning;
265
+ const format = request.responseFormat;
266
+ if (format !== undefined) {
267
+ body.response_format = format.type === 'json'
268
+ ? { type: 'json_object' }
269
+ : {
270
+ type: 'json_schema',
271
+ json_schema: {
272
+ name: format.name ?? 'result',
273
+ schema: format.schema,
274
+ strict: format.strict ?? true,
275
+ },
276
+ };
277
+ }
278
+ return body;
279
+ }
280
+
281
+ /**
282
+ * One request/response cycle. `state.delivered` flips as soon as a
283
+ * streamed delta reaches `onDelta` or `onReasoning` — the point of no
284
+ * return for the retry loop (the caller has observed output).
285
+ * @param {ChatRequest} request
286
+ * @param {{ delivered: boolean }} state
287
+ */
288
+ async function attemptOnce(request, state) {
289
+ const { signal, onDelta, onReasoning } = request;
290
+ const body = requestBody(request);
291
+ const stream = body.stream;
292
+
293
+ /** @type {any} */
294
+ let response;
295
+ try {
296
+ response = await fetchFn(endpoint.url, {
297
+ method: 'POST',
298
+ headers: endpoint.headers,
299
+ body: JSON.stringify(body),
300
+ signal,
301
+ });
302
+ }
303
+ catch (err) {
304
+ throw transportFailure(err, endpoint.url);
305
+ }
306
+ if (response.ok !== true) throw await httpFailure(response, endpoint.url);
307
+ if (!stream) return completionFromText(await response.text());
308
+
309
+ const decoder = createSseDecoder();
310
+ const accumulator = createStreamAccumulator();
311
+ /** @param {string} payload */
312
+ const handle = (payload) => {
313
+ if (payload === '[DONE]') return;
314
+ /** @type {any} */
315
+ let chunk;
316
+ try {
317
+ chunk = JSON.parse(payload);
318
+ }
319
+ catch {
320
+ throw new AiError('AI0003', `malformed stream chunk: ${payload.slice(0, 120)}`);
321
+ }
322
+ if (onReasoning !== undefined) {
323
+ const thinking = reasoningOf(chunk?.choices?.[0]?.delta ?? {});
324
+ if (thinking !== '') {
325
+ state.delivered = true;
326
+ onReasoning(thinking);
327
+ }
328
+ }
329
+ const text = accumulator.push(chunk);
330
+ if (text !== '' && onDelta !== undefined) {
331
+ state.delivered = true;
332
+ onDelta(text);
333
+ }
334
+ };
335
+
336
+ // the body's text is kept only until the first event arrives: a
337
+ // reply that closes without one was never a stream (see below), and
338
+ // must then be read whole as a document
339
+ let events = 0;
340
+ let raw = '';
341
+ /** @param {string} text */
342
+ const feed = (text) => {
343
+ if (events === 0) raw += text;
344
+ for (const payload of decoder.feed(text)) {
345
+ events += 1;
346
+ handle(payload);
347
+ }
348
+ if (events > 0) raw = '';
349
+ };
350
+ if (typeof response.body?.getReader === 'function') {
351
+ const reader = response.body.getReader();
352
+ const textDecoder = new TextDecoder();
353
+ let finished = false;
354
+ try {
355
+ for (;;) {
356
+ const { done, value } = await reader.read();
357
+ if (done) { finished = true; break; }
358
+ feed(textDecoder.decode(value, { stream: true }));
359
+ }
360
+ }
361
+ finally {
362
+ if (!finished) {
363
+ try { await reader.cancel(); }
364
+ catch { /* Preserve the read, decoding or callback failure. */ }
365
+ }
366
+ reader.releaseLock();
367
+ }
368
+ }
369
+ else {
370
+ // a host without a readable body (test stubs, exotic fetch shims)
371
+ // hands over the whole text at once
372
+ feed(await response.text());
373
+ }
374
+ for (const payload of decoder.end()) {
375
+ events += 1;
376
+ handle(payload);
377
+ }
378
+ // zero events means the reply was never a stream: a provider or proxy
379
+ // that ignores `stream` answers one JSON document, and anything else
380
+ // is malformed — either way a coded answer, never an empty message
381
+ if (events === 0) return completionFromText(raw);
382
+ return accumulator.result();
383
+ }
384
+
385
+ /** @param {ChatRequest} request */
386
+ async function complete(request) {
387
+ const { messages, signal } = request ?? {};
388
+ if (!Array.isArray(messages) || messages.length === 0)
389
+ throw new AiError('AI0001', 'complete() needs a non-empty messages array');
390
+ const model = request.model ?? endpoint.model;
391
+ if (model === '' || model == null)
392
+ throw new AiError('AI0001', 'no model configured — set one in the client options or the request');
393
+
394
+ // transient transport failures (network, 408, 429, 5xx) and a
395
+ // malformed 200 — no choices, a bad chunk — back off and try again
396
+ // through the shared policy; the one judgment that is this wire's
397
+ // own is the `!state.delivered` guard, which keeps a retry from ever
398
+ // re-sending after the caller has observed streamed output
399
+ const state = { delivered: false };
400
+ const buy = () => withRetry(retry, () => attemptOnce(request, state), {
401
+ signal,
402
+ retryable: (failure) => isTransientFailure(failure) && !state.delivered,
403
+ });
404
+ if (cache === null) return buy();
405
+
406
+ // the key is the body the wire would see, minus `stream`: a reply
407
+ // streamed or answered whole is the same reply, and the callbacks
408
+ // are fired on a replay so a streaming caller sees one path
409
+ const { stream: _stream, ...keyed } = requestBody(request);
410
+ const key = replayKey('chat', endpoint, keyed);
411
+ const hit = await cache.get(key);
412
+ if (hit !== undefined) {
413
+ const { value, ms } = verifyChatEntry(hit);
414
+ const result = cloneJson(value);
415
+ const reasoning = result.message.reasoning;
416
+ if (typeof reasoning === 'string' && reasoning !== '' && request.onReasoning !== undefined)
417
+ request.onReasoning(reasoning);
418
+ const content = result.message.content;
419
+ if (typeof content === 'string' && content !== '' && request.onDelta !== undefined)
420
+ request.onDelta(content);
421
+ return { ...result, replayed: { ms } };
422
+ }
423
+ const started = now();
424
+ const result = await buy();
425
+ // a `set` that throws fails the call AFTER the purchase — the reply
426
+ // was bought and is lost, which is the loud failure a broken cache
427
+ // deserves (fail closed; an adapter that wants otherwise catches)
428
+ await cache.set(key, { value: cloneJson(result), ms: now() - started });
429
+ return result;
430
+ }
431
+
432
+ return { endpoint, complete };
433
+ }
package/src/embed.d.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The OpenAI-compatible `/embeddings` client over the existing provider
3
+ * set. The endpoint is `${base}/embeddings` from the same resolved base
4
+ * the chat client uses, with the same auth and headers; the request is
5
+ * one non-streaming POST of `{ model, input }`.
6
+ *
7
+ * Retries follow the chat client exactly: transient transport failures
8
+ * (network, 408, 429, 5xx) and a malformed 200 back off with full
9
+ * jitter and try again, `Retry-After` wins up to `maxMs`, an abort ends
10
+ * everything at once. A structurally wrong reply — the wrong width for
11
+ * a fixed model — is therefore reported after `attempts` tries; a probe
12
+ * that wants a fast answer sets `retry: { attempts: 1 }`.
13
+ *
14
+ * @param {{ provider?: string, baseUrl?: string, apiKey?: string,
15
+ * model?: string, dims?: number, headers?: Record<string, string>,
16
+ * fetch?: typeof fetch, timeoutMs?: number,
17
+ * retry?: import('./retry.js').RetryOptions,
18
+ * cache?: import('./replay.js').ReplayCache }} [options]
19
+ * - `model` is required: it is half of every vector's identity.
20
+ * - `cache` is the replay seam (`createChatClient` documents the
21
+ * contract). Here it is PER TEXT: each input is keyed by the
22
+ * credential-free endpoint, the model and the text; the texts the
23
+ * adapter remembers come back from it, only the rest travel — in one
24
+ * wire call, in input order — and every bought vector is remembered
25
+ * as `{ vector: number[], ms }` (the wall time of the batch). A
26
+ * call whose every text is remembered makes no wire call at all, and
27
+ * its first replay settles `dims` exactly as a first reply would.
28
+ * - `dims` pins the other half up front; left out, the width of the
29
+ * first reply becomes the client's, and every later reply must
30
+ * match it. Give it when the identity must be known before the
31
+ * first call.
32
+ * - `timeoutMs` bounds each attempt, and a timed-out attempt retries
33
+ * like a network failure. Unset means no timeout, as `complete()`
34
+ * has none — a batch of long texts on a local runtime legitimately
35
+ * takes a while; `probeEmbeddings` sets 5 000 ms, as `probeProvider`
36
+ * does.
37
+ * - `retry` is the chat client's option, unchanged (see
38
+ * `createChatClient`).
39
+ * @returns {Embedder & { provider: string }} the seam, plus the
40
+ * resolved provider name; `dims` is the settled width
41
+ */
42
+ export function createEmbeddingClient(options?: {
43
+ provider?: string;
44
+ baseUrl?: string;
45
+ apiKey?: string;
46
+ model?: string;
47
+ dims?: number;
48
+ headers?: Record<string, string>;
49
+ fetch?: typeof fetch;
50
+ timeoutMs?: number;
51
+ retry?: import("./retry.js").RetryOptions;
52
+ cache?: import("./replay.js").ReplayCache;
53
+ }): Embedder & {
54
+ provider: string;
55
+ };
56
+ /**
57
+ * Probe the embeddings wire before relying on it: can this key/URL/model
58
+ * embed at all, and at what width? Embeds one word with exactly the
59
+ * auth an `embed()` call would use, in one attempt, within `timeoutMs`
60
+ * (default 5 000, as `probeProvider`). Never throws — the result object
61
+ * is the settings-UI contract, and the live proof that a provider
62
+ * really serves `/embeddings` beside `/chat/completions`.
63
+ * @param {{ provider?: string, baseUrl?: string, apiKey?: string,
64
+ * model?: string, dims?: number, headers?: Record<string, string>,
65
+ * fetch?: typeof fetch, timeoutMs?: number }} [options]
66
+ * @returns {Promise<{ ok: true, model: string, dims: number } |
67
+ * { ok: false, status?: number, error: string }>}
68
+ */
69
+ export function probeEmbeddings(options?: {
70
+ provider?: string;
71
+ baseUrl?: string;
72
+ apiKey?: string;
73
+ model?: string;
74
+ dims?: number;
75
+ headers?: Record<string, string>;
76
+ fetch?: typeof fetch;
77
+ timeoutMs?: number;
78
+ }): Promise<{
79
+ ok: true;
80
+ model: string;
81
+ dims: number;
82
+ } | {
83
+ ok: false;
84
+ status?: number;
85
+ error: string;
86
+ }>;
87
+ /**
88
+ * The deterministic reference embedder — demo-grade, for tests and
89
+ * offline demos. Each text becomes the bag of its case-folded character
90
+ * trigrams (the text padded with one space on each side), hashed with
91
+ * the suite's FNV-1a into `dims` buckets and l2-normalized: the same
92
+ * text yields the same vector on every host, forever, with no network,
93
+ * no weights and no dependency. It is LEXICAL, not semantic — two texts
94
+ * score high when they share letters, not when they mean the same
95
+ * thing — so it exercises retrieval mechanics (does the right memory
96
+ * reach the prompt?) without saying anything about embedding quality,
97
+ * which belongs to a real model behind the same seam.
98
+ * @param {{ dims?: number }} [options] - the width (default 64); the
99
+ * identity is `hash-trigram-<dims>`, so two widths never mix
100
+ * @returns {Embedder & { dims: number }}
101
+ */
102
+ export function createHashEmbedder(options?: {
103
+ dims?: number;
104
+ }): Embedder & {
105
+ dims: number;
106
+ };
107
+ /**
108
+ * The embedder seam: what every consumer of embeddings in this package
109
+ * takes, and what a host implements to bring its own.
110
+ */
111
+ export type Embedder = {
112
+ /**
113
+ * - one vector per input, in input order; rejects `AI0001` for
114
+ * anything but a non-empty array of strings
115
+ */
116
+ embed: (texts: string[], options?: {
117
+ signal?: AbortSignal;
118
+ }) => Promise<Float32Array[]>;
119
+ /**
120
+ * - the name half of a vector's identity
121
+ */
122
+ model: string;
123
+ /**
124
+ * - the width half; the wire client
125
+ * leaves it undefined until its first reply settles it, unless the
126
+ * caller configured it
127
+ */
128
+ dims: number | undefined;
129
+ };