@startsimpli/llm 0.4.20 → 0.4.22
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/package.json
CHANGED
|
@@ -144,6 +144,21 @@ describe('adapting to what a model refuses', () => {
|
|
|
144
144
|
expect(sent).toHaveLength(3);
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
+
it('parallel first calls all succeed: a lesson learned by one request applies to the others in flight', async () => {
|
|
148
|
+
// Regression: crochet's assembly pipeline fires its connection calls in
|
|
149
|
+
// parallel. All of them sent 'none' before any answer; the first 400 taught
|
|
150
|
+
// the provider 'low', and the others saw that lesson as already learned and
|
|
151
|
+
// threw instead of retrying (gpt-6-astra: 12 of 12 connections failed).
|
|
152
|
+
const p = provider('gpt-6.1-sol');
|
|
153
|
+
const effortErr = { code: 'unsupported_value', param: 'reasoning_effort', message: "Unsupported value: 'reasoning_effort' does not support 'none' with this model. Supported values are: 'low', 'medium', 'high', and 'xhigh'." };
|
|
154
|
+
reject(effortErr);
|
|
155
|
+
reject(effortErr);
|
|
156
|
+
reject(effortErr);
|
|
157
|
+
const results = await Promise.all([1, 2, 3].map(() => p.generate('u', 's', { reasoningEffort: 'none' })));
|
|
158
|
+
expect(results.every((r) => r.content === '{"ok":true}')).toBe(true);
|
|
159
|
+
expect(sent.slice(3).every((b) => b.reasoning_effort === 'low')).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
147
162
|
it('adapts on the tool-calling path too', async () => {
|
|
148
163
|
const p = provider('gpt-6-sol');
|
|
149
164
|
reject({ code: 'unsupported_value', param: 'temperature', message: "Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported." });
|
|
@@ -197,3 +212,56 @@ describe('response provenance', () => {
|
|
|
197
212
|
expect(res.model).toBe('server-reported-model');
|
|
198
213
|
});
|
|
199
214
|
});
|
|
215
|
+
|
|
216
|
+
/** A fetch Response whose body streams OpenAI SSE chunks. */
|
|
217
|
+
function sse(chunks: object[], split = 7) {
|
|
218
|
+
const text = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n';
|
|
219
|
+
// Deliver in small arbitrary slices so the parser must buffer partial lines.
|
|
220
|
+
const parts: Uint8Array[] = [];
|
|
221
|
+
const enc = new TextEncoder();
|
|
222
|
+
for (let i = 0; i < text.length; i += split) parts.push(enc.encode(text.slice(i, i + split)));
|
|
223
|
+
const body = new ReadableStream<Uint8Array>({ start(c) { parts.forEach((p) => c.enqueue(p)); c.close(); } });
|
|
224
|
+
return { ok: true, status: 200, body } as unknown as Response;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
describe('generateStream', () => {
|
|
228
|
+
const delta = (content: string) => ({ model: 'gpt-6-astra', choices: [{ index: 0, delta: { content }, finish_reason: null }] });
|
|
229
|
+
|
|
230
|
+
it('calls onDelta with each piece of content in order and returns the whole response', async () => {
|
|
231
|
+
fetchMock.mockImplementationOnce(async (_u: unknown, init: RequestInit) => {
|
|
232
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
233
|
+
return sse([
|
|
234
|
+
delta('{"pie'), delta('ces":[{"name":"He'), delta('ad"}]}'),
|
|
235
|
+
{ model: 'gpt-6-astra', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
|
|
236
|
+
{ model: 'gpt-6-astra', choices: [], usage: { prompt_tokens: 9, completion_tokens: 5, total_tokens: 14 } },
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
const seen: string[] = [];
|
|
240
|
+
const res = await provider('gpt-6-astra').generateStream!('u', 's', (d) => seen.push(d));
|
|
241
|
+
expect(seen).toEqual(['{"pie', 'ces":[{"name":"He', 'ad"}]}']);
|
|
242
|
+
expect(res.content).toBe('{"pieces":[{"name":"Head"}]}');
|
|
243
|
+
expect(res.model).toBe('gpt-6-astra');
|
|
244
|
+
expect(res.provider).toBe('openai');
|
|
245
|
+
expect(res.finishReason).toBe('stop');
|
|
246
|
+
expect(res.usage).toEqual({ promptTokens: 9, completionTokens: 5, totalTokens: 14 });
|
|
247
|
+
const body = sent[0] as Sent & { stream?: boolean; stream_options?: unknown };
|
|
248
|
+
expect(body.stream).toBe(true);
|
|
249
|
+
expect(body.stream_options).toEqual({ include_usage: true });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('adapts to a refused parameter before the stream starts', async () => {
|
|
253
|
+
reject({ code: 'unsupported_value', param: 'temperature', message: "Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported." });
|
|
254
|
+
fetchMock.mockImplementationOnce(async (_u: unknown, init: RequestInit) => {
|
|
255
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
256
|
+
return sse([delta('{"ok":true}')]);
|
|
257
|
+
});
|
|
258
|
+
const res = await provider('gpt-6-sol').generateStream!('u', 's', () => {});
|
|
259
|
+
expect(res.content).toBe('{"ok":true}');
|
|
260
|
+
expect(sent[1]!.temperature).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('throws on an empty stream, like generate() does on empty content', async () => {
|
|
264
|
+
fetchMock.mockImplementationOnce(async () => sse([{ model: 'm', choices: [{ index: 0, delta: {}, finish_reason: 'length' }] }]));
|
|
265
|
+
await expect(provider('gpt-6-astra').generateStream!('u', 's', () => {})).rejects.toThrow(/Empty response/);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -35,6 +35,19 @@ export interface ILLMProvider {
|
|
|
35
35
|
options?: GenerateOptions
|
|
36
36
|
): Promise<LLMResponse>;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Optional STREAMING generate: same contract as generate(), and calls
|
|
40
|
+
* `onDelta` with each chunk of content as the model writes it, so a caller
|
|
41
|
+
* can show progress during a long generation. Callers should fall back to
|
|
42
|
+
* generate() when a provider doesn't implement it.
|
|
43
|
+
*/
|
|
44
|
+
generateStream?(
|
|
45
|
+
userPrompt: string,
|
|
46
|
+
systemPrompt: string,
|
|
47
|
+
onDelta: (text: string) => void,
|
|
48
|
+
options?: GenerateOptions
|
|
49
|
+
): Promise<LLMResponse>;
|
|
50
|
+
|
|
38
51
|
/**
|
|
39
52
|
* Optional NATIVE (structured) tool-calling. Providers backed by a real tools
|
|
40
53
|
* API (OpenAI functions, Anthropic tool blocks) implement this so the agent
|
package/src/providers/openai.ts
CHANGED
|
@@ -268,26 +268,133 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
268
268
|
sent: { effort?: ReasoningEffort; maxTokens: number },
|
|
269
269
|
signal: AbortSignal,
|
|
270
270
|
): Promise<T> {
|
|
271
|
+
const response = await this.postAdaptivelyRaw(model, build, sent, signal);
|
|
272
|
+
return (await response.json()) as T;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** postAdaptively, returning the successful Response unread (for streaming). */
|
|
276
|
+
private async postAdaptivelyRaw(
|
|
277
|
+
model: string,
|
|
278
|
+
build: (q: ModelQuirks) => object,
|
|
279
|
+
sent: { effort?: ReasoningEffort; maxTokens: number },
|
|
280
|
+
signal: AbortSignal,
|
|
281
|
+
): Promise<Response> {
|
|
271
282
|
const q = this.quirksFor(model);
|
|
272
283
|
for (let attempt = 0; ; attempt++) {
|
|
284
|
+
const body = JSON.stringify(build(q));
|
|
273
285
|
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
|
|
274
286
|
method: 'POST',
|
|
275
287
|
headers: {
|
|
276
288
|
'Content-Type': 'application/json',
|
|
277
289
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
278
290
|
},
|
|
279
|
-
body
|
|
291
|
+
body,
|
|
280
292
|
signal,
|
|
281
293
|
});
|
|
282
|
-
if (response.ok) return
|
|
294
|
+
if (response.ok) return response;
|
|
283
295
|
const errorData = (await response.json().catch(() => ({
|
|
284
296
|
error: { message: 'Unknown error' },
|
|
285
297
|
}))) as OpenAIErrorResponse;
|
|
286
|
-
if (response.status === 400 && attempt < MAX_ADAPTIVE_RETRIES
|
|
298
|
+
if (response.status === 400 && attempt < MAX_ADAPTIVE_RETRIES) {
|
|
299
|
+
const learned = learnFromError(q, errorData.error, sent);
|
|
300
|
+
// Calls to a new model often go out in parallel: by the time this one
|
|
301
|
+
// fails, another may already have learned the lesson, so retry whenever
|
|
302
|
+
// the request would now be built differently from the one just sent.
|
|
303
|
+
if (learned || JSON.stringify(build(q)) !== body) continue;
|
|
304
|
+
}
|
|
287
305
|
throw this.handleApiError(response.status, errorData);
|
|
288
306
|
}
|
|
289
307
|
}
|
|
290
308
|
|
|
309
|
+
async generateStream(
|
|
310
|
+
userPrompt: string,
|
|
311
|
+
systemPrompt: string,
|
|
312
|
+
onDelta: (text: string) => void,
|
|
313
|
+
options?: GenerateOptions
|
|
314
|
+
): Promise<LLMResponse> {
|
|
315
|
+
if (!this.isAvailable()) {
|
|
316
|
+
throw new LLMProviderError('OpenAI API key not configured', 'AUTHENTICATION_ERROR', false, undefined, this.name);
|
|
317
|
+
}
|
|
318
|
+
const model = options?.model ?? this.config.defaultModel;
|
|
319
|
+
const temperature = options?.temperature ?? this.config.defaultTemperature;
|
|
320
|
+
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
321
|
+
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
322
|
+
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
323
|
+
const base = {
|
|
324
|
+
model,
|
|
325
|
+
messages: [
|
|
326
|
+
{ role: 'system' as const, content: systemPrompt },
|
|
327
|
+
{ role: 'user' as const, content: userPrompt },
|
|
328
|
+
],
|
|
329
|
+
response_format: { type: 'json_object' as const },
|
|
330
|
+
stream: true,
|
|
331
|
+
stream_options: { include_usage: true },
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const controller = new AbortController();
|
|
335
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
336
|
+
try {
|
|
337
|
+
const response = await this.postAdaptivelyRaw(model, (q) => ({
|
|
338
|
+
...base,
|
|
339
|
+
...samplingParams(q, temperature, maxTokens, effort),
|
|
340
|
+
}), { effort, maxTokens }, controller.signal);
|
|
341
|
+
|
|
342
|
+
let content = '';
|
|
343
|
+
let reportedModel = model;
|
|
344
|
+
let finishReason: string | undefined;
|
|
345
|
+
let responseId: string | undefined;
|
|
346
|
+
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
347
|
+
const reader = response.body!.getReader();
|
|
348
|
+
const decoder = new TextDecoder();
|
|
349
|
+
let buffer = '';
|
|
350
|
+
for (;;) {
|
|
351
|
+
const { done, value } = await reader.read();
|
|
352
|
+
if (done) break;
|
|
353
|
+
buffer += decoder.decode(value, { stream: true });
|
|
354
|
+
let nl: number;
|
|
355
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
356
|
+
const line = buffer.slice(0, nl).trim();
|
|
357
|
+
buffer = buffer.slice(nl + 1);
|
|
358
|
+
if (!line.startsWith('data:')) continue;
|
|
359
|
+
const payload = line.slice(5).trim();
|
|
360
|
+
if (payload === '[DONE]') continue;
|
|
361
|
+
const chunk = JSON.parse(payload) as {
|
|
362
|
+
id?: string;
|
|
363
|
+
model?: string;
|
|
364
|
+
choices?: Array<{ delta?: { content?: string | null }; finish_reason?: string | null }>;
|
|
365
|
+
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
366
|
+
};
|
|
367
|
+
if (chunk.id) responseId = chunk.id;
|
|
368
|
+
if (chunk.model) reportedModel = chunk.model;
|
|
369
|
+
const choice = chunk.choices?.[0];
|
|
370
|
+
if (choice?.finish_reason) finishReason = choice.finish_reason;
|
|
371
|
+
const delta = choice?.delta?.content;
|
|
372
|
+
if (delta) {
|
|
373
|
+
content += delta;
|
|
374
|
+
onDelta(delta);
|
|
375
|
+
}
|
|
376
|
+
if (chunk.usage) {
|
|
377
|
+
usage.promptTokens = chunk.usage.prompt_tokens;
|
|
378
|
+
usage.completionTokens = chunk.usage.completion_tokens;
|
|
379
|
+
usage.totalTokens = chunk.usage.total_tokens;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
clearTimeout(timeoutId);
|
|
384
|
+
if (!content) {
|
|
385
|
+
throw new LLMProviderError('Empty response from OpenAI', 'UNKNOWN_ERROR', true, undefined, this.name);
|
|
386
|
+
}
|
|
387
|
+
return { content, usage, model: reportedModel, provider: this.name, responseId, finishReason };
|
|
388
|
+
} catch (error) {
|
|
389
|
+
clearTimeout(timeoutId);
|
|
390
|
+
if (error instanceof LLMProviderError) throw error;
|
|
391
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
392
|
+
throw new LLMProviderError(`Request timed out after ${timeout}ms`, 'TIMEOUT', true, undefined, this.name);
|
|
393
|
+
}
|
|
394
|
+
throw new LLMProviderError(error instanceof Error ? error.message : 'Unknown error', 'UNKNOWN_ERROR', true, undefined, this.name);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
291
398
|
isAvailable(): boolean {
|
|
292
399
|
return Boolean(this.config.apiKey);
|
|
293
400
|
}
|