@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.
@@ -0,0 +1,156 @@
1
+ //@ts-check
2
+ /**
3
+ * Provider endpoint resolution for OpenAI-compatible chat APIs.
4
+ *
5
+ * The bring-your-own-key reality of browser-side AI is three shapes:
6
+ * a cloud aggregator key (OpenRouter) or a local runtime URL (Ollama,
7
+ * LM Studio). All of them — and every other OpenAI-compatible server —
8
+ * speak the same `/chat/completions` wire format, so one small client
9
+ * covers the lot; the only per-provider knowledge needed is the base
10
+ * URL convention, which lives here.
11
+ *
12
+ * No key ever leaves the caller's hands: resolution just turns
13
+ * `{ provider, baseUrl, apiKey, model }` into a base, the chat URL and
14
+ * headers. The base is resolved ONCE and returned, so every sibling
15
+ * endpoint of the family (`/models`, and whatever else hangs off the
16
+ * same base) is composed from it — never re-derived from the chat URL
17
+ * by string surgery.
18
+ */
19
+
20
+ import { AiError } from './errors.js';
21
+
22
+ /**
23
+ * The built-in providers. `custom` accepts any OpenAI-compatible base
24
+ * URL (the caller must supply one). The local runtimes get `/v1`
25
+ * appended automatically when the URL carries no path — pasting
26
+ * `http://localhost:11434` just works.
27
+ *
28
+ * `structured` names the strongest structured-output tier the provider
29
+ * reliably speaks on this wire: `'json_schema'` (schema-constrained
30
+ * decoding), `'json'` (JSON mode without a schema), or `null` (assume
31
+ * nothing — the schema travels in the prompt). Either way the caller
32
+ * validates locally; the tier only decides how much the server helps.
33
+ * @type {Record<string, { label: string, baseUrl: string | null,
34
+ * local: boolean, structured: 'json_schema' | 'json' | null }>}
35
+ */
36
+ export const PROVIDERS = {
37
+ openrouter: { label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', local: false, structured: 'json_schema' },
38
+ ollama: { label: 'Ollama', baseUrl: 'http://localhost:11434/v1', local: true, structured: 'json' },
39
+ lmstudio: { label: 'LM Studio', baseUrl: 'http://localhost:1234/v1', local: true, structured: 'json_schema' },
40
+ custom: { label: 'OpenAI-compatible', baseUrl: null, local: false, structured: null },
41
+ };
42
+
43
+ /**
44
+ * Resolve a provider configuration into a concrete chat endpoint.
45
+ * @param {{ provider?: string, baseUrl?: string, apiKey?: string,
46
+ * model?: string, headers?: Record<string, string> }} [options]
47
+ * @returns {{ provider: string, base: string, url: string,
48
+ * headers: Record<string, string>, model: string }} `base` is the
49
+ * normalized base URL every endpoint of this wire family hangs off;
50
+ * `url` is `${base}/chat/completions`
51
+ */
52
+ export function resolveEndpoint(options = {}) {
53
+ const provider = options.provider
54
+ ?? (typeof options.baseUrl === 'string' && options.baseUrl.trim() !== '' ? 'custom' : 'openrouter');
55
+ const preset = PROVIDERS[provider];
56
+ if (preset === undefined)
57
+ throw new AiError('AI0001', `unknown provider '${provider}' (${Object.keys(PROVIDERS).join(', ')})`);
58
+
59
+ const configured = (options.baseUrl ?? '').trim();
60
+ const raw = configured === '' ? preset.baseUrl : configured;
61
+ if (raw === null || raw === '')
62
+ throw new AiError('AI0001', `provider '${provider}' needs a baseUrl`);
63
+
64
+ const base = normalizeBaseUrl(raw, preset.local);
65
+ /** @type {Record<string, string>} */
66
+ const headers = { 'content-type': 'application/json' };
67
+ const apiKey = (options.apiKey ?? '').trim();
68
+ if (apiKey !== '') headers.authorization = `Bearer ${apiKey}`;
69
+ Object.assign(headers, options.headers);
70
+
71
+ return { provider, base, url: `${base}/chat/completions`, headers, model: options.model ?? '' };
72
+ }
73
+
74
+ /**
75
+ * Probe a provider before the first turn: can this key/URL answer, and
76
+ * which models does it offer? GETs the OpenAI-compatible `/models`
77
+ * listing (OpenRouter, Ollama and LM Studio all serve it) with the
78
+ * same resolved auth the chat call would use. Never throws — the
79
+ * result object is the settings-UI contract.
80
+ * @param {{ provider?: string, baseUrl?: string, apiKey?: string,
81
+ * headers?: Record<string, string>, fetch?: typeof fetch,
82
+ * timeoutMs?: number }} [options]
83
+ * @returns {Promise<{ ok: true, models: string[] } |
84
+ * { ok: false, status?: number, error: string }>}
85
+ */
86
+ export async function probeProvider(options = {}) {
87
+ /** @type {ReturnType<typeof resolveEndpoint>} */
88
+ let endpoint;
89
+ try {
90
+ endpoint = resolveEndpoint(options);
91
+ }
92
+ catch (err) {
93
+ return { ok: false, error: /** @type {Error} */ (err).message };
94
+ }
95
+ const url = `${endpoint.base}/models`;
96
+ const fetchFn = options.fetch ?? ((u, init) => globalThis.fetch(u, init));
97
+ const timeoutMs = options.timeoutMs ?? 5000;
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
100
+ try {
101
+ const response = await fetchFn(url, {
102
+ method: 'GET',
103
+ headers: endpoint.headers,
104
+ signal: controller.signal,
105
+ });
106
+ if (response.ok !== true)
107
+ return { ok: false, status: response.status, error: `HTTP ${response.status} from ${url}` };
108
+ const payload = await response.json();
109
+ const models = Array.isArray(payload?.data)
110
+ ? payload.data.map((m) => m?.id).filter((id) => typeof id === 'string')
111
+ : [];
112
+ return { ok: true, models };
113
+ }
114
+ catch (err) {
115
+ return {
116
+ ok: false,
117
+ error: controller.signal.aborted
118
+ ? `no answer from ${url} within ${timeoutMs} ms`
119
+ : /** @type {any} */ (err)?.message ?? String(err),
120
+ };
121
+ }
122
+ finally {
123
+ clearTimeout(timer);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Forgiving base-URL normalization, on the parts of ONE parse: trailing
129
+ * slashes and a pasted `/chat/completions` suffix (in any case) are
130
+ * stripped from the path; local runtimes with a bare origin get their
131
+ * `/v1` prefix. A query string or fragment is refused rather than
132
+ * carried: this wire family composes endpoints by appending a path, and
133
+ * `…?api-version=x/chat/completions` is a URL nobody meant.
134
+ * @param {string} raw
135
+ * @param {boolean} local
136
+ * @returns {string}
137
+ */
138
+ function normalizeBaseUrl(raw, local) {
139
+ /** @type {URL} */
140
+ let url;
141
+ try {
142
+ url = new URL(raw);
143
+ }
144
+ catch {
145
+ throw new AiError('AI0001', `invalid baseUrl '${raw}'`);
146
+ }
147
+ if (url.search !== '' || url.hash !== '')
148
+ throw new AiError('AI0001', `baseUrl '${raw}' must not carry a query string or fragment`);
149
+ let path = url.pathname.replace(/\/+$/, '');
150
+ if (/\/chat\/completions$/i.test(path))
151
+ path = path.slice(0, -'/chat/completions'.length).replace(/\/+$/, '');
152
+ if (local && path === '') path = '/v1';
153
+ // with no search and no hash, `href` is everything before the path —
154
+ // origin, plus any credentials the caller pasted — followed by the path
155
+ return url.href.slice(0, url.href.length - url.pathname.length) + path;
156
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The replay cache seam a host implements.
3
+ * @typedef {Object} ReplayCache
4
+ * @property {(key: string) => any} get - the stored value, or `undefined`
5
+ * for a miss; may answer a promise
6
+ * @property {(key: string, value: any) => any} set - remember a value
7
+ * under a key; may answer a promise. The value is JSON-only.
8
+ */
9
+ /**
10
+ * The `cache` option, checked once at client construction: absent means
11
+ * no cache; present means both members are functions, or `AI0001`.
12
+ * @param {unknown} cache
13
+ * @returns {ReplayCache | null}
14
+ */
15
+ export function normalizeCache(cache: unknown): ReplayCache | null;
16
+ /**
17
+ * The key one request has under one endpoint: the canonical
18
+ * serialization of the wire, the credential-free endpoint identity and
19
+ * the effective request. The same request keys the same string on
20
+ * every host.
21
+ * @param {'chat' | 'embeddings'} wire
22
+ * @param {{ provider: string, base: string }} endpoint
23
+ * @param {any} request - the body the client would POST, `stream` removed
24
+ * @returns {string}
25
+ * @throws {AiError} `AI0001` when the request cannot be keyed injectively
26
+ */
27
+ export function replayKey(wire: "chat" | "embeddings", endpoint: {
28
+ provider: string;
29
+ base: string;
30
+ }, request: any): string;
31
+ /**
32
+ * A stored chat entry, verified: `{ value, ms }` with `value` a
33
+ * normalized result carrying a `message` object and `ms` a finite
34
+ * number — or `AI0003`.
35
+ * @param {any} entry
36
+ * @returns {{ value: any, ms: number }}
37
+ */
38
+ export function verifyChatEntry(entry: any): {
39
+ value: any;
40
+ ms: number;
41
+ };
42
+ /**
43
+ * A stored embedding entry, verified into a fresh vector: `{ vector,
44
+ * ms }` with `vector` a non-empty array of finite numbers at the settled
45
+ * width (any positive width when none is settled yet — the first replay
46
+ * settles it exactly as a first wire reply would) — or `AI0003`.
47
+ * @param {any} entry
48
+ * @param {number | undefined} dims - the settled width, if any
49
+ * @returns {Float32Array}
50
+ */
51
+ export function verifyEmbeddingEntry(entry: any, dims: number | undefined): Float32Array;
52
+ /**
53
+ * A JSON-only copy: what is stored, and what a replay answers, so that a
54
+ * caller mutating its result never mutates the adapter's entry.
55
+ * @template T
56
+ * @param {T} value
57
+ * @returns {T}
58
+ */
59
+ export function cloneJson<T>(value: T): T;
60
+ /** Milliseconds now, for the wall time of a purchase. */
61
+ export function now(): number;
62
+ /**
63
+ * The replay cache seam a host implements.
64
+ */
65
+ export type ReplayCache = {
66
+ /**
67
+ * - the stored value, or `undefined`
68
+ * for a miss; may answer a promise
69
+ */
70
+ get: (key: string) => any;
71
+ /**
72
+ * - remember a value
73
+ * under a key; may answer a promise. The value is JSON-only.
74
+ */
75
+ set: (key: string, value: any) => any;
76
+ };
package/src/replay.js ADDED
@@ -0,0 +1,141 @@
1
+ //@ts-check
2
+ /**
3
+ * The replay seam behind both wire clients: what a remembered reply is
4
+ * keyed by, and what a remembered entry must look like before it is
5
+ * served. This module owns the two decisions a host must not be left
6
+ * to make twice —
7
+ *
8
+ * - **the key is the effective wire request, canonicalized.** It is
9
+ * built by the client after endpoint resolution and default
10
+ * application, from `{ wire, provider, base, request }` where
11
+ * `request` is the body the client would POST (minus `stream`, which
12
+ * does not change an answer). Never the headers (they carry the
13
+ * credential), never the signal, never a callback. Because the key
14
+ * is the body rather than an allow-list of request members, an
15
+ * option added to the body later enters the key by construction — it
16
+ * cannot alias an older key. The serialization is `semanticKey`:
17
+ * injective over plain data, so two requests share a key exactly when
18
+ * they are the same request, and a request that cannot be keyed
19
+ * injectively (a function inside `tools`, a cycle) is refused up
20
+ * front rather than folded onto someone else's entry;
21
+ *
22
+ * - **a stored entry is verified, not trusted.** A chat entry must carry
23
+ * a normalized result and the wall time of the purchase; an embedding
24
+ * entry must carry a vector of finite numbers at the settled width.
25
+ * Anything else is `AI0003`, never served — a cache that answers
26
+ * garbage is worse than a cache that is down.
27
+ *
28
+ * The client hands the adapter the complete canonical string. It is
29
+ * long (a whole conversation is in it) and that is the point: it is
30
+ * collision-free. An adapter that needs a fixed-width storage id hashes
31
+ * it — with a cryptographic hash, because a 32-bit hash over prompts
32
+ * that differ by one token would sooner or later serve one prompt's
33
+ * answer for another's.
34
+ *
35
+ * The adapter contract is two members, each sync or async:
36
+ * `get(key) → value | undefined` and `set(key, value)`. The seam FAILS
37
+ * CLOSED: an adapter that throws fails the call. An adapter that wants
38
+ * to fail open — keep buying while its storage is broken — catches its
39
+ * own errors and answers `undefined`; the client will not guess which
40
+ * it wanted.
41
+ */
42
+
43
+ import { semanticKey } from '@jarenjs/core/object';
44
+
45
+ import { AiError } from './errors.js';
46
+ import { verifyEmbeddingComponents } from './embedding-vector.js';
47
+
48
+ /**
49
+ * The replay cache seam a host implements.
50
+ * @typedef {Object} ReplayCache
51
+ * @property {(key: string) => any} get - the stored value, or `undefined`
52
+ * for a miss; may answer a promise
53
+ * @property {(key: string, value: any) => any} set - remember a value
54
+ * under a key; may answer a promise. The value is JSON-only.
55
+ */
56
+
57
+ /**
58
+ * The `cache` option, checked once at client construction: absent means
59
+ * no cache; present means both members are functions, or `AI0001`.
60
+ * @param {unknown} cache
61
+ * @returns {ReplayCache | null}
62
+ */
63
+ export function normalizeCache(cache) {
64
+ if (cache === undefined || cache === null) return null;
65
+ const candidate = /** @type {any} */ (cache);
66
+ if (typeof candidate.get !== 'function' || typeof candidate.set !== 'function')
67
+ throw new AiError('AI0001', 'cache needs { get(key), set(key, value) } — both functions, sync or async');
68
+ return candidate;
69
+ }
70
+
71
+ /**
72
+ * The key one request has under one endpoint: the canonical
73
+ * serialization of the wire, the credential-free endpoint identity and
74
+ * the effective request. The same request keys the same string on
75
+ * every host.
76
+ * @param {'chat' | 'embeddings'} wire
77
+ * @param {{ provider: string, base: string }} endpoint
78
+ * @param {any} request - the body the client would POST, `stream` removed
79
+ * @returns {string}
80
+ * @throws {AiError} `AI0001` when the request cannot be keyed injectively
81
+ */
82
+ export function replayKey(wire, endpoint, request) {
83
+ try {
84
+ return semanticKey({ wire, provider: endpoint.provider, base: endpoint.base, request });
85
+ }
86
+ catch (err) {
87
+ throw new AiError('AI0001',
88
+ `the ${wire} request is not cacheable: ${/** @type {Error} */ (err).message}`);
89
+ }
90
+ }
91
+
92
+ /** @param {any} value */
93
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
94
+
95
+ /**
96
+ * A stored chat entry, verified: `{ value, ms }` with `value` a
97
+ * normalized result carrying a `message` object and `ms` a finite
98
+ * number — or `AI0003`.
99
+ * @param {any} entry
100
+ * @returns {{ value: any, ms: number }}
101
+ */
102
+ export function verifyChatEntry(entry) {
103
+ if (!isRecord(entry) || !isRecord(entry.value) || !isRecord(entry.value.message)
104
+ || typeof entry.ms !== 'number' || !Number.isFinite(entry.ms))
105
+ throw new AiError('AI0003', 'malformed replay entry for the chat wire: expected { value: { message, … }, ms }');
106
+ return entry;
107
+ }
108
+
109
+ /**
110
+ * A stored embedding entry, verified into a fresh vector: `{ vector,
111
+ * ms }` with `vector` a non-empty array of finite numbers at the settled
112
+ * width (any positive width when none is settled yet — the first replay
113
+ * settles it exactly as a first wire reply would) — or `AI0003`.
114
+ * @param {any} entry
115
+ * @param {number | undefined} dims - the settled width, if any
116
+ * @returns {Float32Array}
117
+ */
118
+ export function verifyEmbeddingEntry(entry, dims) {
119
+ const vector = isRecord(entry) ? entry.vector : undefined;
120
+ if (!Array.isArray(vector) || vector.length === 0)
121
+ throw new AiError('AI0003', 'malformed replay entry for the embeddings wire: expected { vector: number[], ms }');
122
+ if (dims !== undefined && vector.length !== dims)
123
+ throw new AiError('AI0003', `replay entry carries ${vector.length} dimensions, expected ${dims}`);
124
+ return verifyEmbeddingComponents(vector, 'replay entry carries');
125
+ }
126
+
127
+ /**
128
+ * A JSON-only copy: what is stored, and what a replay answers, so that a
129
+ * caller mutating its result never mutates the adapter's entry.
130
+ * @template T
131
+ * @param {T} value
132
+ * @returns {T}
133
+ */
134
+ export function cloneJson(value) {
135
+ return JSON.parse(JSON.stringify(value));
136
+ }
137
+
138
+ /** Milliseconds now, for the wall time of a purchase. */
139
+ export function now() {
140
+ return globalThis.performance.now();
141
+ }
package/src/retry.d.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The `retry` option of every client.
3
+ * @typedef {Object} RetryOptions
4
+ * @property {number} [attempts] - the TOTAL number of tries (default 3;
5
+ * 1 disables retrying)
6
+ * @property {number} [baseMs] - the first backoff (default 500); each
7
+ * later one doubles, with full jitter
8
+ * @property {number} [maxMs] - the ceiling on any single wait (default
9
+ * 8 000) — a provider `Retry-After` included: a provider asking for a
10
+ * minute gets the cap, and the value it asked for rides the final
11
+ * error as `retryAfterMs` for the caller to honour
12
+ * @property {() => number} [random] - the jitter source, for
13
+ * deterministic tests
14
+ * @property {(ms: number, signal?: AbortSignal) => Promise<void>} [sleep]
15
+ * - the wait itself, for deterministic tests; the default is a timer
16
+ * that rejects with the abort reason the moment `signal` aborts
17
+ */
18
+ /**
19
+ * The option with its defaults filled in.
20
+ * @typedef {Object} RetryPolicy
21
+ * @property {number} attempts
22
+ * @property {number} baseMs
23
+ * @property {number} maxMs
24
+ * @property {() => number} random
25
+ * @property {(ms: number, signal?: AbortSignal) => Promise<void>} sleep
26
+ */
27
+ /**
28
+ * @param {RetryOptions | undefined} retry
29
+ * @returns {RetryPolicy}
30
+ */
31
+ export function normalizeRetry(retry: RetryOptions | undefined): RetryPolicy;
32
+ /**
33
+ * Whether a failure is the transient kind. A transport error with a
34
+ * retryable status is (a network failure before any response counts
35
+ * as status 0); so is a malformed 200 — a reply that carried none of
36
+ * what the wire promised is a provider hiccup, common on busy cheap
37
+ * tiers, and safe to retry precisely because nothing was delivered.
38
+ * Anything else — a caller error, a 401, a 404 — is final on the first
39
+ * try.
40
+ * @param {unknown} err
41
+ * @returns {boolean}
42
+ */
43
+ export function isTransientFailure(err: unknown): boolean;
44
+ /**
45
+ * The wait before the next try: exponential backoff with full jitter,
46
+ * capped at `maxMs` — unless the provider named a `Retry-After`, which
47
+ * wins up to the same cap.
48
+ * @param {RetryPolicy} policy
49
+ * @param {number} attempt - the try that just failed, counted from 1
50
+ * @param {number | undefined} retryAfter - the provider's ask, in ms
51
+ * @returns {number} milliseconds
52
+ */
53
+ export function retryDelay(policy: RetryPolicy, attempt: number, retryAfter: number | undefined): number;
54
+ /**
55
+ * Run `once` until it settles. A failure `retryable` accepts backs off
56
+ * and tries again while tries remain; anything else is thrown as it
57
+ * came, and a coded transport failure (`AI0002`, `AI0003`) carries the
58
+ * number of tries as `attempts`. The wait honours `signal`: an abort
59
+ * during backoff rejects with the abort reason, exactly like an abort
60
+ * during the request — nothing is ever retried past an abort.
61
+ * @template T
62
+ * @param {RetryPolicy} policy
63
+ * @param {() => Promise<T>} once - one request/response cycle
64
+ * @param {{ signal?: AbortSignal, retryable: (failure: AiError) => boolean }} options
65
+ * - `retryable` is the wire's own judgment over a coded failure (the
66
+ * chat client, for one, stops retrying once a streamed delta has
67
+ * reached the caller); it is never asked about an uncoded error
68
+ * @returns {Promise<T>}
69
+ */
70
+ export function withRetry<T>(policy: RetryPolicy, once: () => Promise<T>, options: {
71
+ signal?: AbortSignal;
72
+ retryable: (failure: AiError) => boolean;
73
+ }): Promise<T>;
74
+ /**
75
+ * The `AI0002` for a response that is not ok: the status, a short
76
+ * excerpt of the body, and the provider's `Retry-After` in ms.
77
+ * @param {any} response
78
+ * @param {string} url
79
+ * @returns {Promise<AiError>}
80
+ */
81
+ export function httpFailure(response: any, url: string): Promise<AiError>;
82
+ /**
83
+ * What to throw when `fetch` itself threw: an abort exactly as it came
84
+ * (the caller's own signal, never retried, never rewrapped); anything
85
+ * else the `AI0002` of a failure before any response, status 0.
86
+ * @param {any} err
87
+ * @param {string} url
88
+ * @returns {any}
89
+ */
90
+ export function transportFailure(err: any, url: string): any;
91
+ /**
92
+ * Parse a `Retry-After` header (delta-seconds or HTTP-date) into ms.
93
+ * @param {any} response
94
+ * @returns {number | undefined}
95
+ */
96
+ export function retryAfterMs(response: any): number | undefined;
97
+ export { abortError };
98
+ /**
99
+ * The `retry` option of every client.
100
+ */
101
+ export type RetryOptions = {
102
+ /**
103
+ * - the TOTAL number of tries (default 3;
104
+ * 1 disables retrying)
105
+ */
106
+ attempts?: number;
107
+ /**
108
+ * - the first backoff (default 500); each
109
+ * later one doubles, with full jitter
110
+ */
111
+ baseMs?: number;
112
+ /**
113
+ * - the ceiling on any single wait (default
114
+ * 8 000) — a provider `Retry-After` included: a provider asking for a
115
+ * minute gets the cap, and the value it asked for rides the final
116
+ * error as `retryAfterMs` for the caller to honour
117
+ */
118
+ maxMs?: number;
119
+ /**
120
+ * - the jitter source, for
121
+ * deterministic tests
122
+ */
123
+ random?: () => number;
124
+ /**
125
+ * - the wait itself, for deterministic tests; the default is a timer
126
+ * that rejects with the abort reason the moment `signal` aborts
127
+ */
128
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
129
+ };
130
+ /**
131
+ * The option with its defaults filled in.
132
+ */
133
+ export type RetryPolicy = {
134
+ attempts: number;
135
+ baseMs: number;
136
+ maxMs: number;
137
+ random: () => number;
138
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
139
+ };
140
+ import { AiError } from './errors.js';
141
+ import { abortError } from '@jarenjs/core/retry';