@startsimpli/llm 0.4.21 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "Generic LLM service infrastructure — provider abstraction, retry logic, JSON extraction",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -212,3 +212,56 @@ describe('response provenance', () => {
212
212
  expect(res.model).toBe('server-reported-model');
213
213
  });
214
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
@@ -268,6 +268,17 @@ 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++) {
273
284
  const body = JSON.stringify(build(q));
@@ -280,7 +291,7 @@ export class OpenAIProvider implements ILLMProvider {
280
291
  body,
281
292
  signal,
282
293
  });
283
- if (response.ok) return (await response.json()) as T;
294
+ if (response.ok) return response;
284
295
  const errorData = (await response.json().catch(() => ({
285
296
  error: { message: 'Unknown error' },
286
297
  }))) as OpenAIErrorResponse;
@@ -295,6 +306,95 @@ export class OpenAIProvider implements ILLMProvider {
295
306
  }
296
307
  }
297
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
+
298
398
  isAvailable(): boolean {
299
399
  return Boolean(this.config.apiKey);
300
400
  }