@tangleai/models 0.21.1 → 0.25.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/CHANGELOG.md +30 -0
- package/README.md +2 -1
- package/package.json +3 -3
- package/src/check.d.ts +9 -10
- package/src/check.js +14 -18
- package/src/client.d.ts +59 -102
- package/src/client.js +307 -351
- package/src/embed.d.ts +54 -36
- package/src/embed.js +202 -287
- package/src/embedding-vector.d.ts +9 -4
- package/src/embedding-vector.js +13 -17
- package/src/errors.d.ts +26 -9
- package/src/errors.js +22 -24
- package/src/grammar.d.ts +6 -7
- package/src/grammar.js +39 -30
- package/src/index.d.ts +10 -9
- package/src/index.js +9 -10
- package/src/providers.d.ts +41 -31
- package/src/providers.js +79 -99
- package/src/replay.d.ts +52 -37
- package/src/replay.js +31 -59
- package/src/retry.d.ts +45 -86
- package/src/retry.js +50 -105
- package/src/routing.d.ts +5 -9
- package/src/routing.js +91 -79
- package/src/sse.d.ts +10 -1
- package/src/sse.js +0 -2
- package/src/structured.d.ts +19 -17
- package/src/structured.js +95 -124
package/src/client.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//@ts-check
|
|
2
1
|
/**
|
|
3
2
|
* The chat client: one `complete()` call against any OpenAI-compatible
|
|
4
3
|
* `/chat/completions` endpoint, streaming by default. The host injects
|
|
@@ -10,125 +9,125 @@
|
|
|
10
9
|
* finishReason, usage, model }` whether the server streamed deltas or
|
|
11
10
|
* answered in one JSON document.
|
|
12
11
|
*/
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { normalizeCache, replayKey, verifyChatEntry, cloneJson, now } from './replay.js';
|
|
19
|
-
|
|
12
|
+
import { AiError } from "./errors.js";
|
|
13
|
+
import { resolveEndpoint } from "./providers.js";
|
|
14
|
+
import { normalizeRetry, withRetry, isTransientFailure, httpFailure, transportFailure } from "./retry.js";
|
|
15
|
+
import { createSseDecoder } from "./sse.js";
|
|
16
|
+
import { normalizeCache, replayKey, verifyChatEntry, cloneJson, now } from "./replay.js";
|
|
20
17
|
/**
|
|
21
18
|
* The reasoning text one streamed chunk carries: the OpenRouter/`o`-
|
|
22
19
|
* family `delta.reasoning` string, or the `reasoning_details` text
|
|
23
20
|
* entries some providers emit instead.
|
|
24
|
-
* @param {any} delta
|
|
25
|
-
* @returns {string}
|
|
26
21
|
*/
|
|
27
22
|
export function reasoningOf(delta) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
if (typeof delta?.reasoning === 'string')
|
|
24
|
+
return delta.reasoning;
|
|
25
|
+
if (Array.isArray(delta?.reasoning_details)) {
|
|
26
|
+
let text = '';
|
|
27
|
+
for (const detail of delta.reasoning_details) {
|
|
28
|
+
if (typeof detail?.text === 'string')
|
|
29
|
+
text += detail.text;
|
|
30
|
+
}
|
|
31
|
+
return text;
|
|
33
32
|
}
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
return '';
|
|
33
|
+
return '';
|
|
37
34
|
}
|
|
38
|
-
|
|
39
35
|
/**
|
|
40
36
|
* Accumulates OpenAI streaming chunks (`choices[0].delta`) into one
|
|
41
37
|
* normalized assistant message. Tool-call fragments merge by `index`;
|
|
42
38
|
* argument strings concatenate across chunks; reasoning deltas
|
|
43
39
|
* accumulate into `message.reasoning` (absent when the model emitted
|
|
44
40
|
* none) so a reasoning-only turn is distinguishable from an empty one.
|
|
45
|
-
* @returns
|
|
41
|
+
* @returns
|
|
46
42
|
* `push` returns the text delta this chunk contributed (may be '').
|
|
47
43
|
*/
|
|
48
44
|
export function createStreamAccumulator() {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
message: {
|
|
91
|
-
role,
|
|
92
|
-
content,
|
|
93
|
-
toolCalls: calls.length > 0 ? calls : null,
|
|
94
|
-
...(reasoning === '' ? {} : { reasoning }),
|
|
45
|
+
let role = 'assistant';
|
|
46
|
+
let content = '';
|
|
47
|
+
let reasoning = '';
|
|
48
|
+
const toolCalls = [];
|
|
49
|
+
let finishReason = null;
|
|
50
|
+
let usage = null;
|
|
51
|
+
let model = null;
|
|
52
|
+
return {
|
|
53
|
+
push(chunk) {
|
|
54
|
+
if (chunk === null || typeof chunk !== 'object')
|
|
55
|
+
return '';
|
|
56
|
+
if (typeof chunk.model === 'string')
|
|
57
|
+
model = chunk.model;
|
|
58
|
+
if (chunk.usage != null)
|
|
59
|
+
usage = chunk.usage;
|
|
60
|
+
const choice = chunk.choices?.[0];
|
|
61
|
+
if (choice == null)
|
|
62
|
+
return '';
|
|
63
|
+
if (choice.finish_reason != null)
|
|
64
|
+
finishReason = choice.finish_reason;
|
|
65
|
+
const delta = choice.delta ?? choice.message ?? {};
|
|
66
|
+
if (typeof delta.role === 'string')
|
|
67
|
+
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 !== '')
|
|
78
|
+
slot.id = fragment.id;
|
|
79
|
+
if (typeof fragment.function?.name === 'string' && slot.name === '')
|
|
80
|
+
slot.name = fragment.function.name;
|
|
81
|
+
if (typeof fragment.function?.arguments === 'string')
|
|
82
|
+
slot.arguments += fragment.function.arguments;
|
|
83
|
+
}
|
|
84
|
+
return text;
|
|
95
85
|
},
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
86
|
+
result() {
|
|
87
|
+
const calls = toolCalls
|
|
88
|
+
.filter((call) => call != null)
|
|
89
|
+
.map((call, i) => ({ ...call, id: call.id === '' ? `call_${i}` : call.id }));
|
|
90
|
+
return {
|
|
91
|
+
message: {
|
|
92
|
+
role,
|
|
93
|
+
content,
|
|
94
|
+
toolCalls: calls.length > 0 ? calls : null,
|
|
95
|
+
...(reasoning === '' ? {} : { reasoning }),
|
|
96
|
+
},
|
|
97
|
+
finishReason,
|
|
98
|
+
usage,
|
|
99
|
+
model,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
102
103
|
}
|
|
103
|
-
|
|
104
104
|
/**
|
|
105
|
-
* @param
|
|
106
|
-
* @returns
|
|
105
|
+
* @param payload - a complete (non-streamed) chat completion
|
|
106
|
+
* @returns the normalized result
|
|
107
107
|
*/
|
|
108
108
|
function fromCompletion(payload) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
130
|
}
|
|
131
|
-
|
|
132
131
|
/**
|
|
133
132
|
* A reply that arrived as text rather than as a stream of events: one
|
|
134
133
|
* JSON completion document — a provider or proxy that ignores `stream`
|
|
@@ -136,63 +135,22 @@ function fromCompletion(payload) {
|
|
|
136
135
|
* that can happen (a nonstreaming request, a fetch without a readable
|
|
137
136
|
* body, and a stream that closed without a single event), so all answer
|
|
138
137
|
* the same way: the message, or `AI0003`. Never a silent empty message.
|
|
139
|
-
* @
|
|
140
|
-
* @returns {any} the normalized result
|
|
138
|
+
* @returns the normalized result
|
|
141
139
|
*/
|
|
142
140
|
function completionFromText(text) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return fromCompletion(payload);
|
|
141
|
+
if (!/^\s*\{/.test(text))
|
|
142
|
+
throw new AiError('AI0003', `expected an SSE stream or a JSON completion, got: ${text.slice(0, 120)}`);
|
|
143
|
+
let payload;
|
|
144
|
+
try {
|
|
145
|
+
payload = JSON.parse(text);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new AiError('AI0003', `malformed completion: ${text.slice(0, 120)}`);
|
|
149
|
+
}
|
|
150
|
+
return fromCompletion(payload);
|
|
154
151
|
}
|
|
155
|
-
|
|
156
152
|
/**
|
|
157
|
-
* @
|
|
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]
|
|
153
|
+
* @param [options]
|
|
196
154
|
* - `maxTokensField` selects the wire field for client and request
|
|
197
155
|
* `maxTokens` budgets (default `'max_tokens'`). Select
|
|
198
156
|
* `'max_completion_tokens'` for OpenAI Chat Completions, including
|
|
@@ -221,213 +179,211 @@ function completionFromText(text) {
|
|
|
221
179
|
* value it asked for rides the final error as `retryAfterMs` for
|
|
222
180
|
* the caller to honour. `random` and `sleep` exist for deterministic
|
|
223
181
|
* tests.
|
|
224
|
-
* @returns {{ endpoint: { provider: string, base: string, url: string,
|
|
225
|
-
* headers: Record<string, string>, model: string },
|
|
226
|
-
* complete: (request: ChatRequest) => Promise<any> }}
|
|
227
182
|
*/
|
|
228
183
|
export function createChatClient(options = {}) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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);
|
|
184
|
+
const endpoint = resolveEndpoint(options);
|
|
185
|
+
const maxTokensField = options.maxTokensField ?? 'max_tokens';
|
|
186
|
+
if (maxTokensField !== 'max_tokens' && maxTokensField !== 'max_completion_tokens')
|
|
187
|
+
throw new AiError('AI0001', "maxTokensField must be 'max_tokens' or 'max_completion_tokens'");
|
|
188
|
+
const fetchFn = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
189
|
+
const retry = normalizeRetry(options.retry);
|
|
190
|
+
const cache = normalizeCache(options.cache);
|
|
191
|
+
/**
|
|
192
|
+
* The body one request POSTs, defaults applied. One construction for
|
|
193
|
+
* the wire and for the replay key, so the two cannot drift: what is
|
|
194
|
+
* keyed is exactly what would be sent.
|
|
195
|
+
*/
|
|
196
|
+
function requestBody(request) {
|
|
197
|
+
const { messages, tools, toolChoice } = request;
|
|
198
|
+
const model = request.model ?? endpoint.model;
|
|
199
|
+
const stream = request.stream ?? true;
|
|
200
|
+
const body = { model, messages, stream };
|
|
201
|
+
if (Array.isArray(tools) && tools.length > 0)
|
|
202
|
+
body.tools = tools;
|
|
203
|
+
if (toolChoice !== undefined)
|
|
204
|
+
body.tool_choice = toolChoice;
|
|
205
|
+
if (typeof request.temperature === 'number')
|
|
206
|
+
body.temperature = request.temperature;
|
|
207
|
+
// an unset ceiling is not "no ceiling": a provider substitutes the
|
|
208
|
+
// model's whole context window, and an aggregator that bills against
|
|
209
|
+
// a balance REFUSES the request when it cannot afford that worst case
|
|
210
|
+
// (OpenRouter answers 402 naming the number it wanted). A caller that
|
|
211
|
+
// knows its answer is a few thousand tokens should be able to say so.
|
|
212
|
+
const maxTokens = request.maxTokens ?? options.maxTokens;
|
|
213
|
+
if (typeof maxTokens === 'number')
|
|
214
|
+
body[maxTokensField] = maxTokens;
|
|
215
|
+
// the thinking control rides through untouched — a hybrid model needs
|
|
216
|
+
// it to answer WITHOUT reasoning first, and a body that silently drops
|
|
217
|
+
// it is indistinguishable from a provider that ignores it
|
|
218
|
+
const reasoning = request.reasoning ?? options.reasoning;
|
|
219
|
+
if (reasoning !== undefined)
|
|
220
|
+
body.reasoning = reasoning;
|
|
221
|
+
const format = request.responseFormat;
|
|
222
|
+
if (format !== undefined) {
|
|
223
|
+
body.response_format = format.type === 'json'
|
|
224
|
+
? { type: 'json_object' }
|
|
225
|
+
: {
|
|
226
|
+
type: 'json_schema',
|
|
227
|
+
json_schema: {
|
|
228
|
+
name: format.name ?? 'result',
|
|
229
|
+
schema: format.schema,
|
|
230
|
+
strict: format.strict ?? true,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return body;
|
|
305
235
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const thinking = reasoningOf(chunk?.choices?.[0]?.delta ?? {});
|
|
324
|
-
if (thinking !== '') {
|
|
325
|
-
state.delivered = true;
|
|
326
|
-
onReasoning(thinking);
|
|
236
|
+
/**
|
|
237
|
+
* One request/response cycle. `state.delivered` flips as soon as a
|
|
238
|
+
* streamed delta reaches `onDelta` or `onReasoning` — the point of no
|
|
239
|
+
* return for the retry loop (the caller has observed output).
|
|
240
|
+
*/
|
|
241
|
+
async function attemptOnce(request, state) {
|
|
242
|
+
const { signal, onDelta, onReasoning } = request;
|
|
243
|
+
const body = requestBody(request);
|
|
244
|
+
const stream = body.stream;
|
|
245
|
+
let response;
|
|
246
|
+
try {
|
|
247
|
+
response = await fetchFn(endpoint.url, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: endpoint.headers,
|
|
250
|
+
body: JSON.stringify(body),
|
|
251
|
+
signal,
|
|
252
|
+
});
|
|
327
253
|
}
|
|
328
|
-
|
|
329
|
-
|
|
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 }));
|
|
254
|
+
catch (err) {
|
|
255
|
+
throw transportFailure(err, endpoint.url);
|
|
359
256
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (!
|
|
363
|
-
|
|
364
|
-
|
|
257
|
+
if (response.ok !== true)
|
|
258
|
+
throw await httpFailure(response, endpoint.url);
|
|
259
|
+
if (!stream)
|
|
260
|
+
return completionFromText(await response.text());
|
|
261
|
+
const decoder = createSseDecoder();
|
|
262
|
+
const accumulator = createStreamAccumulator();
|
|
263
|
+
/** @param payload */
|
|
264
|
+
const handle = (payload) => {
|
|
265
|
+
if (payload === '[DONE]')
|
|
266
|
+
return;
|
|
267
|
+
let chunk;
|
|
268
|
+
try {
|
|
269
|
+
chunk = JSON.parse(payload);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
throw new AiError('AI0003', `malformed stream chunk: ${payload.slice(0, 120)}`);
|
|
273
|
+
}
|
|
274
|
+
if (onReasoning !== undefined) {
|
|
275
|
+
const thinking = reasoningOf(chunk?.choices?.[0]?.delta ?? {});
|
|
276
|
+
if (thinking !== '') {
|
|
277
|
+
state.delivered = true;
|
|
278
|
+
onReasoning(thinking);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const text = accumulator.push(chunk);
|
|
282
|
+
if (text !== '' && onDelta !== undefined) {
|
|
283
|
+
state.delivered = true;
|
|
284
|
+
onDelta(text);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
// the body's text is kept only until the first event arrives: a
|
|
288
|
+
// reply that closes without one was never a stream (see below), and
|
|
289
|
+
// must then be read whole as a document
|
|
290
|
+
let events = 0;
|
|
291
|
+
let raw = '';
|
|
292
|
+
/** @param text */
|
|
293
|
+
const feed = (text) => {
|
|
294
|
+
if (events === 0)
|
|
295
|
+
raw += text;
|
|
296
|
+
for (const payload of decoder.feed(text)) {
|
|
297
|
+
events += 1;
|
|
298
|
+
handle(payload);
|
|
299
|
+
}
|
|
300
|
+
if (events > 0)
|
|
301
|
+
raw = '';
|
|
302
|
+
};
|
|
303
|
+
if (typeof response.body?.getReader === 'function') {
|
|
304
|
+
const reader = response.body.getReader();
|
|
305
|
+
const textDecoder = new TextDecoder();
|
|
306
|
+
let finished = false;
|
|
307
|
+
try {
|
|
308
|
+
for (;;) {
|
|
309
|
+
const { done, value } = await reader.read();
|
|
310
|
+
if (done) {
|
|
311
|
+
finished = true;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
feed(textDecoder.decode(value, { stream: true }));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
if (!finished) {
|
|
319
|
+
try {
|
|
320
|
+
await reader.cancel();
|
|
321
|
+
}
|
|
322
|
+
catch { /* Preserve the read, decoding or callback failure. */ }
|
|
323
|
+
}
|
|
324
|
+
reader.releaseLock();
|
|
325
|
+
}
|
|
365
326
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
327
|
+
else {
|
|
328
|
+
// a host without a readable body (test stubs, exotic fetch shims)
|
|
329
|
+
// hands over the whole text at once
|
|
330
|
+
feed(await response.text());
|
|
331
|
+
}
|
|
332
|
+
for (const payload of decoder.end()) {
|
|
333
|
+
events += 1;
|
|
334
|
+
handle(payload);
|
|
335
|
+
}
|
|
336
|
+
// zero events means the reply was never a stream: a provider or proxy
|
|
337
|
+
// that ignores `stream` answers one JSON document, and anything else
|
|
338
|
+
// is malformed — either way a coded answer, never an empty message
|
|
339
|
+
if (events === 0)
|
|
340
|
+
return completionFromText(raw);
|
|
341
|
+
return accumulator.result();
|
|
377
342
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
343
|
+
/** @param request */
|
|
344
|
+
async function complete(request) {
|
|
345
|
+
const { messages, signal } = request ?? {};
|
|
346
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
347
|
+
throw new AiError('AI0001', 'complete() needs a non-empty messages array');
|
|
348
|
+
const model = request.model ?? endpoint.model;
|
|
349
|
+
if (model === '' || model == null)
|
|
350
|
+
throw new AiError('AI0001', 'no model configured — set one in the client options or the request');
|
|
351
|
+
// transient transport failures (network, 408, 429, 5xx) and a
|
|
352
|
+
// malformed 200 — no choices, a bad chunk — back off and try again
|
|
353
|
+
// through the shared policy; the one judgment that is this wire's
|
|
354
|
+
// own is the `!state.delivered` guard, which keeps a retry from ever
|
|
355
|
+
// re-sending after the caller has observed streamed output
|
|
356
|
+
const state = { delivered: false };
|
|
357
|
+
const buy = () => withRetry(retry, () => attemptOnce(request, state), {
|
|
358
|
+
signal,
|
|
359
|
+
retryable: (failure) => isTransientFailure(failure) && !state.delivered,
|
|
360
|
+
});
|
|
361
|
+
if (cache === null)
|
|
362
|
+
return buy();
|
|
363
|
+
// the key is the body the wire would see, minus `stream`: a reply
|
|
364
|
+
// streamed or answered whole is the same reply, and the callbacks
|
|
365
|
+
// are fired on a replay so a streaming caller sees one path
|
|
366
|
+
const { stream: _stream, ...keyed } = requestBody(request);
|
|
367
|
+
const key = replayKey('chat', endpoint, keyed);
|
|
368
|
+
const hit = await cache.get(key);
|
|
369
|
+
if (hit !== undefined) {
|
|
370
|
+
const { value, ms } = verifyChatEntry(hit);
|
|
371
|
+
const result = cloneJson(value);
|
|
372
|
+
const reasoning = result.message.reasoning;
|
|
373
|
+
if (typeof reasoning === 'string' && reasoning !== '' && request.onReasoning !== undefined)
|
|
374
|
+
request.onReasoning(reasoning);
|
|
375
|
+
const content = result.message.content;
|
|
376
|
+
if (typeof content === 'string' && content !== '' && request.onDelta !== undefined)
|
|
377
|
+
request.onDelta(content);
|
|
378
|
+
return { ...result, replayed: { ms } };
|
|
379
|
+
}
|
|
380
|
+
const started = now();
|
|
381
|
+
const result = await buy();
|
|
382
|
+
// a `set` that throws fails the call AFTER the purchase — the reply
|
|
383
|
+
// was bought and is lost, which is the loud failure a broken cache
|
|
384
|
+
// deserves (fail closed; an adapter that wants otherwise catches)
|
|
385
|
+
await cache.set(key, { value: cloneJson(result), ms: now() - started });
|
|
386
|
+
return result;
|
|
422
387
|
}
|
|
423
|
-
|
|
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 };
|
|
388
|
+
return { endpoint, complete };
|
|
433
389
|
}
|