@tangleai/models 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/package.json +92 -0
- package/src/check.d.ts +29 -0
- package/src/check.js +37 -0
- package/src/client.d.ts +185 -0
- package/src/client.js +433 -0
- package/src/embed.d.ts +129 -0
- package/src/embed.js +362 -0
- package/src/embedding-vector.d.ts +9 -0
- package/src/embedding-vector.js +30 -0
- package/src/errors.d.ts +22 -0
- package/src/errors.js +45 -0
- package/src/grammar.d.ts +29 -0
- package/src/grammar.js +35 -0
- package/src/index.d.ts +9 -0
- package/src/index.js +11 -0
- package/src/providers.d.ts +69 -0
- package/src/providers.js +156 -0
- package/src/replay.d.ts +76 -0
- package/src/replay.js +141 -0
- package/src/retry.d.ts +141 -0
- package/src/retry.js +182 -0
- package/src/routing.d.ts +32 -0
- package/src/routing.js +87 -0
- package/src/sse.d.ts +1 -0
- package/src/sse.js +12 -0
- package/src/structured.d.ts +89 -0
- package/src/structured.js +219 -0
package/src/providers.js
ADDED
|
@@ -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
|
+
}
|
package/src/replay.d.ts
ADDED
|
@@ -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';
|