@startsimpli/llm 0.4.21 → 0.4.23
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 +1 -1
- package/src/__tests__/anthropic-provider.test.ts +11 -0
- package/src/__tests__/openai-provider.test.ts +68 -0
- package/src/index.ts +1 -0
- package/src/providers/anthropic.ts +17 -2
- package/src/providers/interface.ts +13 -0
- package/src/providers/openai.ts +117 -3
- package/src/types.ts +11 -0
package/package.json
CHANGED
|
@@ -169,3 +169,14 @@ describe('AnthropicProvider — recovering from a parameter the model rejects',
|
|
|
169
169
|
expect(sent).toHaveLength(1);
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
describe('images', () => {
|
|
174
|
+
it('sends images as base64 image blocks before the text', async () => {
|
|
175
|
+
await provider('claude-opus-5-5').generate('what is this?', 's', { images: [{ data: 'AAAA', mediaType: 'image/png' }] });
|
|
176
|
+
const body = sent[0] as unknown as { messages: Array<{ content: Array<Record<string, unknown>> }> };
|
|
177
|
+
const content = body.messages[0]!.content;
|
|
178
|
+
expect(content[0]).toEqual({ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } });
|
|
179
|
+
expect(content[1]!.type).toBe('text');
|
|
180
|
+
expect(String(content[1]!.text)).toContain('what is this?');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -212,3 +212,71 @@ 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
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('images', () => {
|
|
270
|
+
it('sends images as image_url parts after the text, and plain text without them', async () => {
|
|
271
|
+
const p = provider('gpt-6-astra');
|
|
272
|
+
await p.generate('what is this?', 's', { images: [{ data: 'AAAA', mediaType: 'image/png' }] });
|
|
273
|
+
await p.generate('plain', 's');
|
|
274
|
+
const withImage = sent[0] as unknown as { messages: Array<{ role: string; content: unknown }> };
|
|
275
|
+
expect(withImage.messages[1]!.content).toEqual([
|
|
276
|
+
{ type: 'text', text: 'what is this?' },
|
|
277
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
|
|
278
|
+
]);
|
|
279
|
+
const plain = sent[1] as unknown as { messages: Array<{ role: string; content: unknown }> };
|
|
280
|
+
expect(plain.messages[1]!.content).toBe('plain');
|
|
281
|
+
});
|
|
282
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -11,9 +11,13 @@ import { LLM_DEFAULTS } from '../types';
|
|
|
11
11
|
import { LLMProviderError } from '../errors';
|
|
12
12
|
import type { LLMErrorCode } from '../errors';
|
|
13
13
|
|
|
14
|
+
type AnthropicContentBlock =
|
|
15
|
+
| { type: 'text'; text: string }
|
|
16
|
+
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
|
|
17
|
+
|
|
14
18
|
interface AnthropicMessage {
|
|
15
19
|
role: 'user' | 'assistant';
|
|
16
|
-
content: string;
|
|
20
|
+
content: string | AnthropicContentBlock[];
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
interface AnthropicRequest {
|
|
@@ -199,7 +203,18 @@ Please respond with a valid JSON object only. Do not include any text before or
|
|
|
199
203
|
const requestBody: AnthropicRequest = {
|
|
200
204
|
model,
|
|
201
205
|
max_tokens: maxTokens,
|
|
202
|
-
messages: [{
|
|
206
|
+
messages: [{
|
|
207
|
+
role: 'user',
|
|
208
|
+
content: options?.images?.length
|
|
209
|
+
? [
|
|
210
|
+
...options.images.map((img) => ({
|
|
211
|
+
type: 'image' as const,
|
|
212
|
+
source: { type: 'base64' as const, media_type: img.mediaType, data: img.data },
|
|
213
|
+
})),
|
|
214
|
+
{ type: 'text' as const, text: jsonWrappedPrompt },
|
|
215
|
+
]
|
|
216
|
+
: jsonWrappedPrompt,
|
|
217
|
+
}],
|
|
203
218
|
system: systemPrompt,
|
|
204
219
|
};
|
|
205
220
|
// Only for models known to take it. The Claude 5 family answers a request
|
|
@@ -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
|
@@ -18,14 +18,28 @@ import type {
|
|
|
18
18
|
NativeChatMessage,
|
|
19
19
|
NativeToolResponse,
|
|
20
20
|
ReasoningEffort,
|
|
21
|
+
LLMImage,
|
|
21
22
|
} from '../types';
|
|
22
23
|
import { LLM_DEFAULTS } from '../types';
|
|
23
24
|
import { LLMProviderError } from '../errors';
|
|
24
25
|
import type { LLMErrorCode } from '../errors';
|
|
25
26
|
|
|
27
|
+
type OpenAIContentPart =
|
|
28
|
+
| { type: 'text'; text: string }
|
|
29
|
+
| { type: 'image_url'; image_url: { url: string } };
|
|
30
|
+
|
|
26
31
|
interface OpenAIMessage {
|
|
27
32
|
role: 'system' | 'user' | 'assistant';
|
|
28
|
-
content: string;
|
|
33
|
+
content: string | OpenAIContentPart[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The user message: plain text, or text plus images for vision models. */
|
|
37
|
+
function userContent(prompt: string, images?: LLMImage[]): string | OpenAIContentPart[] {
|
|
38
|
+
if (!images?.length) return prompt;
|
|
39
|
+
return [
|
|
40
|
+
{ type: 'text', text: prompt },
|
|
41
|
+
...images.map((img) => ({ type: 'image_url' as const, image_url: { url: `data:${img.mediaType};base64,${img.data}` } })),
|
|
42
|
+
];
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
interface OpenAIRequest {
|
|
@@ -268,6 +282,17 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
268
282
|
sent: { effort?: ReasoningEffort; maxTokens: number },
|
|
269
283
|
signal: AbortSignal,
|
|
270
284
|
): Promise<T> {
|
|
285
|
+
const response = await this.postAdaptivelyRaw(model, build, sent, signal);
|
|
286
|
+
return (await response.json()) as T;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** postAdaptively, returning the successful Response unread (for streaming). */
|
|
290
|
+
private async postAdaptivelyRaw(
|
|
291
|
+
model: string,
|
|
292
|
+
build: (q: ModelQuirks) => object,
|
|
293
|
+
sent: { effort?: ReasoningEffort; maxTokens: number },
|
|
294
|
+
signal: AbortSignal,
|
|
295
|
+
): Promise<Response> {
|
|
271
296
|
const q = this.quirksFor(model);
|
|
272
297
|
for (let attempt = 0; ; attempt++) {
|
|
273
298
|
const body = JSON.stringify(build(q));
|
|
@@ -280,7 +305,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
280
305
|
body,
|
|
281
306
|
signal,
|
|
282
307
|
});
|
|
283
|
-
if (response.ok) return
|
|
308
|
+
if (response.ok) return response;
|
|
284
309
|
const errorData = (await response.json().catch(() => ({
|
|
285
310
|
error: { message: 'Unknown error' },
|
|
286
311
|
}))) as OpenAIErrorResponse;
|
|
@@ -295,6 +320,95 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
295
320
|
}
|
|
296
321
|
}
|
|
297
322
|
|
|
323
|
+
async generateStream(
|
|
324
|
+
userPrompt: string,
|
|
325
|
+
systemPrompt: string,
|
|
326
|
+
onDelta: (text: string) => void,
|
|
327
|
+
options?: GenerateOptions
|
|
328
|
+
): Promise<LLMResponse> {
|
|
329
|
+
if (!this.isAvailable()) {
|
|
330
|
+
throw new LLMProviderError('OpenAI API key not configured', 'AUTHENTICATION_ERROR', false, undefined, this.name);
|
|
331
|
+
}
|
|
332
|
+
const model = options?.model ?? this.config.defaultModel;
|
|
333
|
+
const temperature = options?.temperature ?? this.config.defaultTemperature;
|
|
334
|
+
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
335
|
+
const effort = options?.reasoningEffort ?? this.config.defaultReasoningEffort;
|
|
336
|
+
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
337
|
+
const base = {
|
|
338
|
+
model,
|
|
339
|
+
messages: [
|
|
340
|
+
{ role: 'system' as const, content: systemPrompt },
|
|
341
|
+
{ role: 'user' as const, content: userContent(userPrompt, options?.images) },
|
|
342
|
+
],
|
|
343
|
+
response_format: { type: 'json_object' as const },
|
|
344
|
+
stream: true,
|
|
345
|
+
stream_options: { include_usage: true },
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const controller = new AbortController();
|
|
349
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
350
|
+
try {
|
|
351
|
+
const response = await this.postAdaptivelyRaw(model, (q) => ({
|
|
352
|
+
...base,
|
|
353
|
+
...samplingParams(q, temperature, maxTokens, effort),
|
|
354
|
+
}), { effort, maxTokens }, controller.signal);
|
|
355
|
+
|
|
356
|
+
let content = '';
|
|
357
|
+
let reportedModel = model;
|
|
358
|
+
let finishReason: string | undefined;
|
|
359
|
+
let responseId: string | undefined;
|
|
360
|
+
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
361
|
+
const reader = response.body!.getReader();
|
|
362
|
+
const decoder = new TextDecoder();
|
|
363
|
+
let buffer = '';
|
|
364
|
+
for (;;) {
|
|
365
|
+
const { done, value } = await reader.read();
|
|
366
|
+
if (done) break;
|
|
367
|
+
buffer += decoder.decode(value, { stream: true });
|
|
368
|
+
let nl: number;
|
|
369
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
370
|
+
const line = buffer.slice(0, nl).trim();
|
|
371
|
+
buffer = buffer.slice(nl + 1);
|
|
372
|
+
if (!line.startsWith('data:')) continue;
|
|
373
|
+
const payload = line.slice(5).trim();
|
|
374
|
+
if (payload === '[DONE]') continue;
|
|
375
|
+
const chunk = JSON.parse(payload) as {
|
|
376
|
+
id?: string;
|
|
377
|
+
model?: string;
|
|
378
|
+
choices?: Array<{ delta?: { content?: string | null }; finish_reason?: string | null }>;
|
|
379
|
+
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
380
|
+
};
|
|
381
|
+
if (chunk.id) responseId = chunk.id;
|
|
382
|
+
if (chunk.model) reportedModel = chunk.model;
|
|
383
|
+
const choice = chunk.choices?.[0];
|
|
384
|
+
if (choice?.finish_reason) finishReason = choice.finish_reason;
|
|
385
|
+
const delta = choice?.delta?.content;
|
|
386
|
+
if (delta) {
|
|
387
|
+
content += delta;
|
|
388
|
+
onDelta(delta);
|
|
389
|
+
}
|
|
390
|
+
if (chunk.usage) {
|
|
391
|
+
usage.promptTokens = chunk.usage.prompt_tokens;
|
|
392
|
+
usage.completionTokens = chunk.usage.completion_tokens;
|
|
393
|
+
usage.totalTokens = chunk.usage.total_tokens;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
clearTimeout(timeoutId);
|
|
398
|
+
if (!content) {
|
|
399
|
+
throw new LLMProviderError('Empty response from OpenAI', 'UNKNOWN_ERROR', true, undefined, this.name);
|
|
400
|
+
}
|
|
401
|
+
return { content, usage, model: reportedModel, provider: this.name, responseId, finishReason };
|
|
402
|
+
} catch (error) {
|
|
403
|
+
clearTimeout(timeoutId);
|
|
404
|
+
if (error instanceof LLMProviderError) throw error;
|
|
405
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
406
|
+
throw new LLMProviderError(`Request timed out after ${timeout}ms`, 'TIMEOUT', true, undefined, this.name);
|
|
407
|
+
}
|
|
408
|
+
throw new LLMProviderError(error instanceof Error ? error.message : 'Unknown error', 'UNKNOWN_ERROR', true, undefined, this.name);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
298
412
|
isAvailable(): boolean {
|
|
299
413
|
return Boolean(this.config.apiKey);
|
|
300
414
|
}
|
|
@@ -324,7 +438,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
324
438
|
model,
|
|
325
439
|
messages: [
|
|
326
440
|
{ role: 'system' as const, content: systemPrompt },
|
|
327
|
-
{ role: 'user' as const, content: userPrompt },
|
|
441
|
+
{ role: 'user' as const, content: userContent(userPrompt, options?.images) },
|
|
328
442
|
],
|
|
329
443
|
response_format: { type: 'json_object' as const },
|
|
330
444
|
};
|
package/src/types.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface LLMResponse {
|
|
|
30
30
|
/**
|
|
31
31
|
* Options passed through to the provider for a single generation call.
|
|
32
32
|
*/
|
|
33
|
+
/** An image attached to a prompt: base64 data (no data: prefix) and its media type. */
|
|
34
|
+
export interface LLMImage {
|
|
35
|
+
data: string;
|
|
36
|
+
mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
export interface GenerateOptions {
|
|
34
40
|
/** Model to use (provider-specific) */
|
|
35
41
|
model?: string;
|
|
@@ -45,6 +51,11 @@ export interface GenerateOptions {
|
|
|
45
51
|
* provider adds headroom for the reasoning on top.
|
|
46
52
|
*/
|
|
47
53
|
reasoningEffort?: ReasoningEffort;
|
|
54
|
+
/**
|
|
55
|
+
* Images sent with the user prompt, for vision-capable models (e.g. to have
|
|
56
|
+
* a model look at a rendering of what it designed).
|
|
57
|
+
*/
|
|
58
|
+
images?: LLMImage[];
|
|
48
59
|
/** Arbitrary extra data the caller wants to thread through (e.g. domain preferences) */
|
|
49
60
|
extra?: Record<string, unknown>;
|
|
50
61
|
}
|