@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/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
+ }
@@ -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
+ }
@@ -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
+ }>;