@tangleai/models 0.21.1 → 0.24.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/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 { 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
-
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
- 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;
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 text;
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 {{ push: (chunk: any) => string, result: () => any }}
41
+ * @returns
46
42
  * `push` returns the text delta this chunk contributed (may be '').
47
43
  */
48
44
  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 }),
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
- finishReason,
97
- usage,
98
- model,
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 {any} payload - a complete (non-streamed) chat completion
106
- * @returns {any} the normalized result
105
+ * @param payload - a complete (non-streamed) chat completion
106
+ * @returns the normalized result
107
107
  */
108
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
- };
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
- * @param {string} text
140
- * @returns {any} the normalized result
138
+ * @returns the normalized result
141
139
  */
142
140
  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);
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
- * @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]
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
- 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);
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
- 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);
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
- 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 }));
254
+ catch (err) {
255
+ throw transportFailure(err, endpoint.url);
359
256
  }
360
- }
361
- finally {
362
- if (!finished) {
363
- try { await reader.cancel(); }
364
- catch { /* Preserve the read, decoding or callback failure. */ }
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
- 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);
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
- // 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 } };
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
- 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 };
388
+ return { endpoint, complete };
433
389
  }