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