@aria-framework/ai 0.1.0
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/browser/ai-polish.js +199 -0
- package/error.js +78 -0
- package/facts.js +133 -0
- package/generate.js +42 -0
- package/index.js +181 -0
- package/package.json +24 -0
- package/polish.js +156 -0
- package/providers/anthropic.js +192 -0
- package/providers/openai-compatible.js +374 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenAI chat-completions shape — LM Studio, Ollama, vLLM, LiteLLM.
|
|
3
|
+
*
|
|
4
|
+
* ONE ADAPTER, MANY SERVERS. All of them expose `POST {baseUrl}/chat/completions` with the same
|
|
5
|
+
* request and response bodies, so the only thing that varies between them is the URL and whether an
|
|
6
|
+
* API key is wanted. That is why this file is not called "lmstudio": naming it after one server
|
|
7
|
+
* would invite a second copy the day somebody points it at Ollama.
|
|
8
|
+
*
|
|
9
|
+
* It is NOT the Anthropic shape, which is the whole reason the seam exists — see ../providers/
|
|
10
|
+
* anthropic.js for what differs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { AiError, fromFetchFailure, redact } = require('../error');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{baseUrl:string, model:string, apiKey?:string, timeoutMs:number, label?:string}} cfg
|
|
19
|
+
* @param {{system?:string, messages:Array<{role:string,content:string}>, maxTokens?:number,
|
|
20
|
+
* temperature?:number, schema?:object, signal?:AbortSignal}} opts
|
|
21
|
+
* @returns {Promise<{text:string, json:object|null, model:string, usage:object, ms:number}>}
|
|
22
|
+
*/
|
|
23
|
+
async function complete(cfg, opts) {
|
|
24
|
+
const label = cfg.label || 'The model server';
|
|
25
|
+
const url = apiRoot(cfg.baseUrl) + '/chat/completions';
|
|
26
|
+
|
|
27
|
+
// The system prompt is just a message with role 'system' here. Anthropic takes it as a separate
|
|
28
|
+
// top-level parameter — one of the several small shape differences that make an adapter necessary.
|
|
29
|
+
const messages = [];
|
|
30
|
+
if (opts.system) messages.push({ role: 'system', content: opts.system });
|
|
31
|
+
for (const m of opts.messages || []) messages.push({ role: m.role, content: m.content });
|
|
32
|
+
|
|
33
|
+
const body = {
|
|
34
|
+
model: cfg.model,
|
|
35
|
+
messages,
|
|
36
|
+
max_tokens: opts.maxTokens || 1024,
|
|
37
|
+
temperature: opts.temperature == null ? 0.2 : opts.temperature,
|
|
38
|
+
stream: false
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// SCHEMA-CONSTRAINED DECODING, not a prompt asking nicely for JSON. LM Studio implements OpenAI's
|
|
42
|
+
// structured-output shape, so the server refuses to emit anything that does not fit — which is
|
|
43
|
+
// what makes a 9B model dependable for the structured work (the relevance pass, the rewrite).
|
|
44
|
+
if (opts.schema) {
|
|
45
|
+
body.response_format = {
|
|
46
|
+
type: 'json_schema',
|
|
47
|
+
json_schema: { name: opts.schema.name || 'result', schema: opts.schema.schema || opts.schema, strict: true }
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const started = Date.now();
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs || 60000);
|
|
54
|
+
if (opts.signal) opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
55
|
+
|
|
56
|
+
let res;
|
|
57
|
+
try {
|
|
58
|
+
res = await fetch(url, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: Object.assign(
|
|
61
|
+
{ 'Content-Type': 'application/json' },
|
|
62
|
+
// LM Studio ignores it; a shared LiteLLM or a hosted vLLM will not.
|
|
63
|
+
cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {}
|
|
64
|
+
),
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
signal: controller.signal
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
throw fromFetchFailure(err, { label, url });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// THE TIMER STAYS ARMED UNTIL THE BODY IS READ. It used to be cleared in the fetch's `finally`,
|
|
74
|
+
// which disarmed the abort controller the moment HEADERS arrived — so a server that answered 200
|
|
75
|
+
// and then stalled mid-body left `res.json()` awaiting forever, with nothing left to interrupt it.
|
|
76
|
+
//
|
|
77
|
+
// That is not a hypothetical shape for this provider: LM Studio evicting or reloading a model
|
|
78
|
+
// mid-response does exactly that. And the cost is not one slow call. runner.tick() is guarded by
|
|
79
|
+
// a single-flight `running` flag cleared in its own `finally`; an await that never settles means
|
|
80
|
+
// that finally never runs, so `running` stays true and EVERY later tick returns early. One stalled
|
|
81
|
+
// response killed the entire AI job queue until the process was restarted.
|
|
82
|
+
//
|
|
83
|
+
// `httpError` reads the body too (res.text()), so it has to be inside this block as well.
|
|
84
|
+
let payload;
|
|
85
|
+
try {
|
|
86
|
+
if (!res.ok) throw await httpError(res, label, cfg.apiKey);
|
|
87
|
+
payload = await res.json();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// THE ABORT CHECK COMES FIRST, before the AiError short-circuit — the order is the fix.
|
|
90
|
+
// httpError reads the body too, and its own `.catch(() => '')` swallows an abort that fires
|
|
91
|
+
// mid-read; it then RETURNS a well-formed AiError (rate_limit, auth, …) built from a body it
|
|
92
|
+
// never received. With the instanceof check first, that bogus error won — a 429-then-stall was
|
|
93
|
+
// reported as "rate limiting this app" after the full deadline, and rate_limit being marked
|
|
94
|
+
// retryable meant withOneRetry re-sent it, reintroducing the doubled wait this file's other
|
|
95
|
+
// fix removed. An abort is the deadline; nothing constructed after it outranks it.
|
|
96
|
+
if (controller.signal.aborted || (err && err.name === 'AbortError')) {
|
|
97
|
+
// Stated as a timeout DIRECTLY, not routed through fromFetchFailure — that helper keys on
|
|
98
|
+
// err.name === 'AbortError', and the error in hand here is often httpError's AiError built
|
|
99
|
+
// from a swallowed abort, which it would misfile as 'unreachable'. The signal aborting IS
|
|
100
|
+
// the deadline; no inspection of the error object can outrank that fact.
|
|
101
|
+
throw new AiError('timeout', `${label} did not answer in time.`, { cause: err });
|
|
102
|
+
}
|
|
103
|
+
if (err instanceof AiError) throw err;
|
|
104
|
+
throw new AiError('bad_response', `${label} replied with something that is not JSON.`, { cause: err });
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// LM STUDIO ANSWERS 200 FOR AN ENDPOINT IT DOES NOT HAVE, with the failure in the body:
|
|
110
|
+
//
|
|
111
|
+
// POST /chat/completions -> 200 {"error":"Unexpected endpoint or method. (POST /chat/completions)"}
|
|
112
|
+
//
|
|
113
|
+
// `res.ok` is therefore useless as the only gate. Without this check the body has no `choices`,
|
|
114
|
+
// the code reads an undefined message, and the operator is told the model "returned an empty
|
|
115
|
+
// completion" — which sent this investigation after a thinking-model theory that was not the
|
|
116
|
+
// cause. Verified against the real server: the same request on /v1/chat/completions answers
|
|
117
|
+
// normally.
|
|
118
|
+
if (payload && payload.error && !payload.choices) {
|
|
119
|
+
const said = typeof payload.error === 'string'
|
|
120
|
+
? payload.error
|
|
121
|
+
: (payload.error.message || JSON.stringify(payload.error));
|
|
122
|
+
if (/unexpected endpoint/i.test(said)) {
|
|
123
|
+
throw new AiError('unconfigured',
|
|
124
|
+
`${label} does not serve ${url}. The address is probably missing its /v1 — ` +
|
|
125
|
+
'LM Studio answers 200 with an error body rather than a 404, so this looks like a working ' +
|
|
126
|
+
`server that says nothing. It reported: ${redact(said, cfg.apiKey)}`);
|
|
127
|
+
}
|
|
128
|
+
throw new AiError('bad_response', `${label} refused the request: ${redact(said, cfg.apiKey)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const choice = payload && payload.choices && payload.choices[0];
|
|
132
|
+
const message = (choice && choice.message) || {};
|
|
133
|
+
const finishReason = (choice && choice.finish_reason) || null;
|
|
134
|
+
|
|
135
|
+
// REASONING MODELS ANSWER IN TWO PARTS. Qwen3.5 thinks before it speaks, and a server may hand
|
|
136
|
+
// that thinking back separately (`reasoning_content`) or inline, wrapped in <think> tags. Neither
|
|
137
|
+
// is the answer, and both have to be recognised — otherwise a model that reasoned and then ran
|
|
138
|
+
// out of room looks identical to a broken server.
|
|
139
|
+
const reasoning = message.reasoning_content || message.reasoning || '';
|
|
140
|
+
const text = stripThinking(message.content || '');
|
|
141
|
+
|
|
142
|
+
if (!text) {
|
|
143
|
+
throw emptyCompletion({ label, finishReason, reasoning, maxTokens: body.max_tokens, usage: payload.usage });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// With a schema the content is still a STRING containing JSON — the server constrains the shape,
|
|
147
|
+
// it does not parse for you. Parsing here rather than at each call site means a malformed body is
|
|
148
|
+
// one error type instead of a surprise in a page render.
|
|
149
|
+
let json = null;
|
|
150
|
+
if (opts.schema) {
|
|
151
|
+
try {
|
|
152
|
+
json = JSON.parse(text);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// THE TRUNCATION CASE GETS ITS OWN SENTENCE, because it is not the model's fault and the
|
|
155
|
+
// generic message sends the investigation the wrong way. Seen live: the model wrote a good
|
|
156
|
+
// draft and then looped inside an unbounded array until max_tokens cut the JSON mid-bracket —
|
|
157
|
+
// finish_reason 'length', unparseable by construction. "Will not parse" reads as a model
|
|
158
|
+
// problem; "ran out of room" names the actual cause and its two fixes (a schema ceiling, or
|
|
159
|
+
// a bigger reply limit).
|
|
160
|
+
if (finishReason === 'length') {
|
|
161
|
+
throw new AiError('bad_response',
|
|
162
|
+
`${label} ran out of room mid-answer (${body.max_tokens} tokens) — the structured reply ` +
|
|
163
|
+
'was cut off before it was finished. If this repeats, the schema may be letting the ' +
|
|
164
|
+
'model ramble; a Regenerate usually succeeds.', { cause: err });
|
|
165
|
+
}
|
|
166
|
+
throw new AiError('bad_response',
|
|
167
|
+
`${label} was asked for structured output and returned text that will not parse.`, { cause: err });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
text,
|
|
173
|
+
json,
|
|
174
|
+
model: payload.model || cfg.model,
|
|
175
|
+
usage: normaliseUsage(payload.usage),
|
|
176
|
+
ms: Date.now() - started,
|
|
177
|
+
finishReason,
|
|
178
|
+
// A caller that cares (the summary's coverage line) can tell a complete answer from a cut-off
|
|
179
|
+
// one without re-reading the payload.
|
|
180
|
+
truncated: finishReason === 'length',
|
|
181
|
+
reasonedFor: reasoning ? reasoning.length : 0
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `<think>…</think>` removed.
|
|
187
|
+
*
|
|
188
|
+
* Served through an OpenAI-compatible endpoint, a thinking model often leaves its reasoning inline
|
|
189
|
+
* in `content` rather than in a field of its own. Rendering that to an agent as if it were the
|
|
190
|
+
* answer is worse than showing nothing.
|
|
191
|
+
*/
|
|
192
|
+
function stripThinking(content) {
|
|
193
|
+
return String(content || '')
|
|
194
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
195
|
+
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
|
|
196
|
+
.trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Why nothing came back — the message an operator can act on.
|
|
201
|
+
*
|
|
202
|
+
* "Returned an empty completion" is true and useless. The three causes are distinguishable from the
|
|
203
|
+
* payload, so they are distinguished: the budget ran out, the budget went on thinking, or the server
|
|
204
|
+
* genuinely said nothing.
|
|
205
|
+
*/
|
|
206
|
+
function emptyCompletion({ label, finishReason, reasoning, maxTokens, usage }) {
|
|
207
|
+
const spent = (usage && usage.completion_tokens) || 0;
|
|
208
|
+
if (finishReason === 'length' || (reasoning && !spentLeftRoom(spent, maxTokens))) {
|
|
209
|
+
return new AiError('bad_response',
|
|
210
|
+
`${label} ran out of room before it answered — it used all ${maxTokens} reply tokens` +
|
|
211
|
+
(reasoning ? ' on internal reasoning' : '') +
|
|
212
|
+
'. This model thinks before it replies, so raise the reply limit (1024 is a sensible floor).');
|
|
213
|
+
}
|
|
214
|
+
if (reasoning) {
|
|
215
|
+
return new AiError('bad_response',
|
|
216
|
+
`${label} returned only its internal reasoning and no answer. Raise the reply limit and try again.`);
|
|
217
|
+
}
|
|
218
|
+
return new AiError('bad_response',
|
|
219
|
+
`${label} returned an empty completion (finish reason: ${finishReason || 'none given'}). ` +
|
|
220
|
+
'Check the model is fully loaded on the server.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Did the completion stop well short of the ceiling? Then the ceiling was not the problem. */
|
|
224
|
+
function spentLeftRoom(spent, maxTokens) {
|
|
225
|
+
return maxTokens > 0 && spent > 0 && spent < maxTokens - 2;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function httpError(res, label, apiKey) {
|
|
229
|
+
// Redacted before it is used anywhere: a server is free to quote the key back in its error body.
|
|
230
|
+
const detail = await res.text().then((t) => redact(t.slice(0, 300), apiKey)).catch(() => '');
|
|
231
|
+
if (res.status === 401 || res.status === 403) {
|
|
232
|
+
return new AiError('auth', `${label} rejected the API key.`, { status: res.status });
|
|
233
|
+
}
|
|
234
|
+
if (res.status === 429) {
|
|
235
|
+
return new AiError('rate_limit', `${label} is rate limiting this app.`, { status: res.status });
|
|
236
|
+
}
|
|
237
|
+
if (res.status === 404) {
|
|
238
|
+
// The single most common local mistake: a model name that is not the one loaded.
|
|
239
|
+
return new AiError('unconfigured',
|
|
240
|
+
`${label} does not have that model loaded (404). Check the model name matches what is running.`,
|
|
241
|
+
{ status: res.status });
|
|
242
|
+
}
|
|
243
|
+
return new AiError('bad_response', `${label} returned ${res.status}. ${detail}`.trim(), { status: res.status });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The API root, with the `/v1` an operator reasonably leaves off.
|
|
248
|
+
*
|
|
249
|
+
* `http://192.168.250.249:1234` is a perfectly sensible thing to type — it is what LM Studio shows
|
|
250
|
+
* you — and it is not where the OpenAI-compatible endpoints live. A path that is already there is
|
|
251
|
+
* left alone, because other servers in this family mount elsewhere (LiteLLM behind a prefix, for
|
|
252
|
+
* one) and second-guessing an explicit path would break them.
|
|
253
|
+
*/
|
|
254
|
+
function apiRoot(baseUrl) {
|
|
255
|
+
const trimmed = String(baseUrl || '').replace(/\/+$/, '');
|
|
256
|
+
let path;
|
|
257
|
+
try {
|
|
258
|
+
path = new URL(trimmed).pathname.replace(/\/+$/, '');
|
|
259
|
+
} catch (err) {
|
|
260
|
+
return trimmed; // not a URL we can parse; leave it exactly as typed
|
|
261
|
+
}
|
|
262
|
+
return path === '' ? trimmed + '/v1' : trimmed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Both providers report tokens; they name the fields differently. One shape reaches the caller. */
|
|
266
|
+
function normaliseUsage(u) {
|
|
267
|
+
if (!u) return { prompt: 0, completion: 0, total: 0 };
|
|
268
|
+
const prompt = u.prompt_tokens || 0;
|
|
269
|
+
const completion = u.completion_tokens || 0;
|
|
270
|
+
return { prompt, completion, total: u.total_tokens || prompt + completion };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** What the server has loaded — used by the admin screen so nobody has to type a model name blind. */
|
|
274
|
+
async function listModels(cfg) {
|
|
275
|
+
const url = apiRoot(cfg.baseUrl) + '/models';
|
|
276
|
+
try {
|
|
277
|
+
const res = await fetch(url, {
|
|
278
|
+
headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
279
|
+
signal: AbortSignal.timeout(cfg.timeoutMs || 10000)
|
|
280
|
+
});
|
|
281
|
+
if (!res.ok) return [];
|
|
282
|
+
const payload = await res.json();
|
|
283
|
+
// The 200-with-an-error-body case needs nothing special here: an error body has no `data`, so
|
|
284
|
+
// it already yields no models. A guard for it was written and then deleted — a control proved
|
|
285
|
+
// it could not fail, which is the definition of code that is not doing anything.
|
|
286
|
+
//
|
|
287
|
+
// The admin screen says both possibilities out loud ("nothing loaded, or the address may be
|
|
288
|
+
// wrong") because from here the two are genuinely indistinguishable.
|
|
289
|
+
return (payload.data || []).map((m) => m.id).filter(Boolean);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
// A provider that cannot list models can still complete; this is a convenience, not a check.
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Embed one or more strings.
|
|
298
|
+
*
|
|
299
|
+
* SAME ENDPOINT FAMILY, DIFFERENT PATH — `/v1/embeddings`, and the same `apiRoot` correction that
|
|
300
|
+
* `/chat/completions` needs, because a base URL missing its `/v1` fails here in exactly the way it
|
|
301
|
+
* failed there: LM Studio answers 200 with an error body rather than a 404.
|
|
302
|
+
*
|
|
303
|
+
* ORDER IS THE CONTRACT. The response carries an `index` per vector and providers are not obliged
|
|
304
|
+
* to return them in order, so they are sorted rather than assumed — a silent re-ordering would pair
|
|
305
|
+
* every document with the wrong vector, and the result would still be 768 plausible floats.
|
|
306
|
+
*/
|
|
307
|
+
async function embed(cfg, texts) {
|
|
308
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
309
|
+
if (!list.length) return [];
|
|
310
|
+
const url = apiRoot(cfg.baseUrl) + '/embeddings';
|
|
311
|
+
const label = cfg.label || 'The provider';
|
|
312
|
+
|
|
313
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
314
|
+
if (cfg.apiKey) headers.Authorization = `Bearer ${cfg.apiKey}`;
|
|
315
|
+
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs || 60000);
|
|
318
|
+
let res;
|
|
319
|
+
try {
|
|
320
|
+
res = await fetch(url, {
|
|
321
|
+
method: 'POST', headers, signal: controller.signal,
|
|
322
|
+
body: JSON.stringify({ model: cfg.embeddingModel, input: list })
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
throw fromFetchFailure(err, { label, url });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The timer stays armed until the body is read — the same fix complete() got, which this
|
|
330
|
+
// function MISSED on the first pass. Three reviews in: complete() was cured in both adapters
|
|
331
|
+
// while embed(), in the same file, kept the old shape — and the runner awaits embed() inside
|
|
332
|
+
// tick(), so one embedding-model eviction mid-response wedged the whole AI queue exactly the
|
|
333
|
+
// way the complete() comment describes. The third copy of a pattern is the one that gets
|
|
334
|
+
// missed; if a fourth ever appears, extract the scaffolding first.
|
|
335
|
+
let raw;
|
|
336
|
+
try {
|
|
337
|
+
raw = await res.text();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (controller.signal.aborted || (err && err.name === 'AbortError')) {
|
|
340
|
+
throw fromFetchFailure(err, { label, url });
|
|
341
|
+
}
|
|
342
|
+
throw new AiError('bad_response', `${label} closed the connection mid-answer.`, { cause: err });
|
|
343
|
+
} finally {
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
}
|
|
346
|
+
let payload;
|
|
347
|
+
try { payload = JSON.parse(raw); } catch (err) {
|
|
348
|
+
throw new AiError('bad_response', `${label} returned something that is not JSON from ${url}.`);
|
|
349
|
+
}
|
|
350
|
+
if (!res.ok || (payload && payload.error && !payload.data)) {
|
|
351
|
+
const said = payload && payload.error
|
|
352
|
+
? (typeof payload.error === 'string' ? payload.error : payload.error.message || JSON.stringify(payload.error))
|
|
353
|
+
: `HTTP ${res.status}`;
|
|
354
|
+
if (/unexpected endpoint/i.test(String(said))) {
|
|
355
|
+
throw new AiError('unconfigured',
|
|
356
|
+
`${label} does not serve ${url}. The address is probably missing its /v1.`);
|
|
357
|
+
}
|
|
358
|
+
throw new AiError('bad_response', `${label} could not embed: ${redact(said, cfg.apiKey)}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const data = Array.isArray(payload.data) ? payload.data.slice() : [];
|
|
362
|
+
if (data.length !== list.length) {
|
|
363
|
+
throw new AiError('bad_response',
|
|
364
|
+
`${label} returned ${data.length} vectors for ${list.length} inputs — they cannot be paired up.`);
|
|
365
|
+
}
|
|
366
|
+
data.sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
367
|
+
return data.map((d) => {
|
|
368
|
+
const v = d && d.embedding;
|
|
369
|
+
if (!Array.isArray(v) || !v.length) throw new AiError('bad_response', `${label} returned an empty vector.`);
|
|
370
|
+
return v;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
module.exports = { complete, listModels, embed, apiRoot };
|