@startsimpli/llm 0.1.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.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * OpenAI Provider Implementation
3
+ *
4
+ * Uses native fetch to minimize dependencies.
5
+ * Supports GPT-5.x, GPT-4o models, and custom fine-tuned models.
6
+ *
7
+ * To use a custom fine-tuned model:
8
+ * 1. Set OPENAI_MODEL or LLM_MODEL to your fine-tuned model ID (e.g., "ft:gpt-4o-2024-08-06:org:name:id")
9
+ * 2. Fine-tuned models will use the configured temperature setting (not forced to 1.0 like base gpt-5)
10
+ */
11
+
12
+ import type { ILLMProvider } from './interface';
13
+ import type { LLMResponse, GenerateOptions, ProviderConfig } from '../types';
14
+ import { LLM_DEFAULTS } from '../types';
15
+ import { LLMProviderError } from '../errors';
16
+ import type { LLMErrorCode } from '../errors';
17
+
18
+ interface OpenAIMessage {
19
+ role: 'system' | 'user' | 'assistant';
20
+ content: string;
21
+ }
22
+
23
+ interface OpenAIRequest {
24
+ model: string;
25
+ messages: OpenAIMessage[];
26
+ temperature?: number;
27
+ max_tokens?: number;
28
+ response_format?: { type: 'json_object' | 'text' };
29
+ }
30
+
31
+ interface OpenAIResponse {
32
+ id: string;
33
+ object: string;
34
+ created: number;
35
+ model: string;
36
+ choices: Array<{
37
+ index: number;
38
+ message: { role: string; content: string };
39
+ finish_reason: string;
40
+ }>;
41
+ usage: {
42
+ prompt_tokens: number;
43
+ completion_tokens: number;
44
+ total_tokens: number;
45
+ };
46
+ }
47
+
48
+ interface OpenAIErrorResponse {
49
+ error: {
50
+ message: string;
51
+ type: string;
52
+ code?: string;
53
+ param?: string;
54
+ };
55
+ }
56
+
57
+ const OPENAI_MODELS = [
58
+ 'gpt-5.2',
59
+ 'gpt-5.1',
60
+ 'gpt-5',
61
+ 'gpt-4o',
62
+ 'gpt-4o-mini',
63
+ ] as const;
64
+
65
+ const MODEL_COSTS: Record<string, { input: number; output: number }> = {
66
+ 'gpt-5.2': { input: 0.005, output: 0.015 },
67
+ 'gpt-5.1': { input: 0.005, output: 0.015 },
68
+ 'gpt-5': { input: 0.005, output: 0.015 },
69
+ 'gpt-4o': { input: 0.0025, output: 0.01 },
70
+ 'gpt-4o-mini': { input: 0.00015, output: 0.0006 },
71
+ };
72
+
73
+ const DEFAULTS = {
74
+ baseUrl: 'https://api.openai.com/v1',
75
+ model: LLM_DEFAULTS.openaiModel,
76
+ temperature: LLM_DEFAULTS.temperature,
77
+ maxTokens: LLM_DEFAULTS.maxTokens,
78
+ timeoutMs: LLM_DEFAULTS.timeoutMs,
79
+ };
80
+
81
+ export class OpenAIProvider implements ILLMProvider {
82
+ readonly name = 'openai';
83
+ readonly availableModels = OPENAI_MODELS;
84
+ readonly defaultModel: string;
85
+
86
+ private readonly config: Required<ProviderConfig>;
87
+
88
+ constructor(config: ProviderConfig) {
89
+ this.config = {
90
+ apiKey: config.apiKey,
91
+ baseUrl: config.baseUrl ?? DEFAULTS.baseUrl,
92
+ defaultModel: config.defaultModel ?? DEFAULTS.model,
93
+ defaultTemperature: config.defaultTemperature ?? DEFAULTS.temperature,
94
+ defaultMaxTokens: config.defaultMaxTokens ?? DEFAULTS.maxTokens,
95
+ timeoutMs: config.timeoutMs ?? DEFAULTS.timeoutMs,
96
+ };
97
+ this.defaultModel = this.config.defaultModel;
98
+ }
99
+
100
+ isAvailable(): boolean {
101
+ return Boolean(this.config.apiKey);
102
+ }
103
+
104
+ async generate(
105
+ userPrompt: string,
106
+ systemPrompt: string,
107
+ options?: GenerateOptions
108
+ ): Promise<LLMResponse> {
109
+ if (!this.isAvailable()) {
110
+ throw new LLMProviderError(
111
+ 'OpenAI API key not configured',
112
+ 'AUTHENTICATION_ERROR',
113
+ false,
114
+ undefined,
115
+ this.name
116
+ );
117
+ }
118
+
119
+ const model = options?.model ?? this.config.defaultModel;
120
+ // Base gpt-5 models require temperature=1.0, but fine-tuned models use configured temperature
121
+ const isBaseGpt5 = model === 'gpt-5' || model === 'gpt-5.1' || model === 'gpt-5.2';
122
+ const isFineTuned = model.startsWith('ft:');
123
+ const temperature = isBaseGpt5 && !isFineTuned
124
+ ? 1.0
125
+ : (options?.temperature ?? this.config.defaultTemperature);
126
+ const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
127
+ const timeout = options?.timeout ?? this.config.timeoutMs;
128
+
129
+ const requestBody: OpenAIRequest = {
130
+ model,
131
+ messages: [
132
+ { role: 'system', content: systemPrompt },
133
+ { role: 'user', content: userPrompt },
134
+ ],
135
+ temperature,
136
+ max_tokens: maxTokens,
137
+ response_format: { type: 'json_object' },
138
+ };
139
+
140
+ const controller = new AbortController();
141
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
142
+
143
+ try {
144
+ const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
145
+ method: 'POST',
146
+ headers: {
147
+ 'Content-Type': 'application/json',
148
+ Authorization: `Bearer ${this.config.apiKey}`,
149
+ },
150
+ body: JSON.stringify(requestBody),
151
+ signal: controller.signal,
152
+ });
153
+
154
+ clearTimeout(timeoutId);
155
+
156
+ if (!response.ok) {
157
+ const errorData = (await response.json().catch(() => ({
158
+ error: { message: 'Unknown error' },
159
+ }))) as OpenAIErrorResponse;
160
+ throw this.handleApiError(response.status, errorData);
161
+ }
162
+
163
+ const data = (await response.json()) as OpenAIResponse;
164
+
165
+ if (!data.choices?.[0]?.message?.content) {
166
+ throw new LLMProviderError(
167
+ 'Empty response from OpenAI',
168
+ 'UNKNOWN_ERROR',
169
+ true,
170
+ undefined,
171
+ this.name
172
+ );
173
+ }
174
+
175
+ return {
176
+ content: data.choices[0].message.content,
177
+ usage: {
178
+ promptTokens: data.usage.prompt_tokens,
179
+ completionTokens: data.usage.completion_tokens,
180
+ totalTokens: data.usage.total_tokens,
181
+ },
182
+ model: data.model,
183
+ responseId: data.id,
184
+ finishReason: data.choices[0].finish_reason,
185
+ };
186
+ } catch (error) {
187
+ clearTimeout(timeoutId);
188
+
189
+ if (error instanceof LLMProviderError) {
190
+ throw error;
191
+ }
192
+
193
+ if (error instanceof Error && error.name === 'AbortError') {
194
+ throw new LLMProviderError(
195
+ `Request timed out after ${timeout}ms`,
196
+ 'TIMEOUT',
197
+ true,
198
+ undefined,
199
+ this.name
200
+ );
201
+ }
202
+
203
+ throw new LLMProviderError(
204
+ `OpenAI request failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
205
+ 'UNKNOWN_ERROR',
206
+ true,
207
+ undefined,
208
+ this.name
209
+ );
210
+ }
211
+ }
212
+
213
+ estimateCost(
214
+ promptTokens: number,
215
+ completionTokens: number,
216
+ model?: string
217
+ ): number | undefined {
218
+ const costs = MODEL_COSTS[model ?? this.config.defaultModel];
219
+ if (!costs) return undefined;
220
+ return (promptTokens / 1000) * costs.input + (completionTokens / 1000) * costs.output;
221
+ }
222
+
223
+ private handleApiError(
224
+ statusCode: number,
225
+ errorResponse: OpenAIErrorResponse
226
+ ): LLMProviderError {
227
+ const message = errorResponse.error?.message ?? 'Unknown OpenAI error';
228
+ const errorType = errorResponse.error?.type ?? '';
229
+ const errorCode = errorResponse.error?.code ?? '';
230
+
231
+ let code: LLMErrorCode;
232
+ let retryable = false;
233
+
234
+ if (statusCode === 401) {
235
+ code = 'AUTHENTICATION_ERROR';
236
+ } else if (statusCode === 429) {
237
+ code = 'RATE_LIMITED';
238
+ retryable = true;
239
+ } else if (statusCode === 400) {
240
+ if (errorCode === 'context_length_exceeded') {
241
+ code = 'CONTEXT_LENGTH_EXCEEDED';
242
+ } else if (errorType === 'invalid_request_error') {
243
+ code = 'INVALID_REQUEST';
244
+ } else {
245
+ code = 'UNKNOWN_ERROR';
246
+ }
247
+ } else if (statusCode === 403) {
248
+ code = errorCode === 'content_filter' ? 'CONTENT_FILTERED' : 'AUTHENTICATION_ERROR';
249
+ } else if (statusCode >= 500) {
250
+ code = 'SERVICE_UNAVAILABLE';
251
+ retryable = true;
252
+ } else {
253
+ code = 'UNKNOWN_ERROR';
254
+ }
255
+
256
+ return new LLMProviderError(message, code, retryable, statusCode, this.name);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Create an OpenAI provider from environment variables.
262
+ * Returns null if OPENAI_API_KEY is not set.
263
+ */
264
+ export function createOpenAIProvider(): OpenAIProvider | null {
265
+ const apiKey = process.env.OPENAI_API_KEY;
266
+ if (!apiKey) return null;
267
+
268
+ return new OpenAIProvider({
269
+ apiKey,
270
+ baseUrl: process.env.OPENAI_BASE_URL,
271
+ defaultModel: process.env.OPENAI_MODEL,
272
+ });
273
+ }
package/src/service.ts ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Generic LLM Service
3
+ *
4
+ * Orchestrates provider selection, generation, JSON extraction,
5
+ * schema validation, and retry logic. Parameterized over <TOutput>
6
+ * so domain-specific schemas (Pattern, Recipe, etc.) stay in their apps.
7
+ */
8
+
9
+ import type { ZodSchema } from 'zod';
10
+ import { ZodError } from 'zod';
11
+ import type { ILLMProvider } from './providers/interface';
12
+ import type {
13
+ LLMResponse,
14
+ GenerateOptions,
15
+ LLMServiceConfig,
16
+ ValidationResult,
17
+ } from './types';
18
+ import { LLM_DEFAULTS } from './types';
19
+ import { LLMProviderError } from './errors';
20
+ import { extractJson } from './utils/json-extraction';
21
+ import {
22
+ buildSchemaErrorCorrectionPrompt,
23
+ buildParseErrorCorrectionPrompt,
24
+ buildGenericRetryPrompt,
25
+ } from './utils/retry';
26
+ import { OpenAIProvider, createOpenAIProvider } from './providers/openai';
27
+ import { AnthropicProvider, createAnthropicProvider } from './providers/anthropic';
28
+ import { MockLLMProvider } from './providers/mock';
29
+
30
+ /**
31
+ * Metadata about a generation attempt (tokens, model, retries).
32
+ */
33
+ export interface GenerationMetadata {
34
+ model: string;
35
+ promptTokens: number;
36
+ completionTokens: number;
37
+ validationAttempts: number;
38
+ }
39
+
40
+ /**
41
+ * Result of a validated generation. The caller checks `success` to narrow the union.
42
+ */
43
+ export type GenerationResult<TOutput> =
44
+ | {
45
+ success: true;
46
+ data: TOutput;
47
+ metadata: GenerationMetadata;
48
+ }
49
+ | {
50
+ success: false;
51
+ error: { code: string; message: string; retryable: boolean };
52
+ metadata?: GenerationMetadata;
53
+ };
54
+
55
+ const DEFAULT_CONFIG: Required<LLMServiceConfig> = {
56
+ maxRetries: 2,
57
+ preferredProvider: 'openai',
58
+ defaultModel: '',
59
+ defaultTemperature: LLM_DEFAULTS.temperature,
60
+ defaultMaxTokens: LLM_DEFAULTS.maxTokens,
61
+ };
62
+
63
+ /**
64
+ * Generic LLM service. Consumers supply:
65
+ * - A Zod schema for output validation
66
+ * - Prompt builders for their domain
67
+ *
68
+ * The service handles provider selection, retries, JSON extraction, and validation.
69
+ */
70
+ export class LLMService<TOutput> {
71
+ private readonly config: Required<LLMServiceConfig>;
72
+ private readonly providers: Map<string, ILLMProvider> = new Map();
73
+
74
+ constructor(config?: LLMServiceConfig) {
75
+ this.config = { ...DEFAULT_CONFIG, ...config };
76
+ this.initializeProviders();
77
+ }
78
+
79
+ private initializeProviders(): void {
80
+ // Mock provider for testing
81
+ if (process.env.LLM_PROVIDER === 'mock') {
82
+ this.providers.set('mock', new MockLLMProvider());
83
+ return;
84
+ }
85
+
86
+ const openai = createOpenAIProvider();
87
+ if (openai) this.providers.set('openai', openai);
88
+
89
+ const anthropic = createAnthropicProvider();
90
+ if (anthropic) this.providers.set('anthropic', anthropic);
91
+ }
92
+
93
+ private getProvider(): ILLMProvider {
94
+ // Try preferred first
95
+ const preferred = this.providers.get(this.config.preferredProvider);
96
+ if (preferred?.isAvailable()) return preferred;
97
+
98
+ // Fallback to any available
99
+ for (const provider of this.providers.values()) {
100
+ if (provider.isAvailable()) return provider;
101
+ }
102
+
103
+ throw new LLMProviderError(
104
+ 'No LLM providers available. Please configure OPENAI_API_KEY or ANTHROPIC_API_KEY.',
105
+ 'AUTHENTICATION_ERROR',
106
+ false
107
+ );
108
+ }
109
+
110
+ /** Check if any LLM provider is available. */
111
+ isAvailable(): boolean {
112
+ for (const provider of this.providers.values()) {
113
+ if (provider.isAvailable()) return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /** Get list of available provider names. */
119
+ getAvailableProviders(): string[] {
120
+ const available: string[] = [];
121
+ for (const [name, provider] of this.providers.entries()) {
122
+ if (provider.isAvailable()) available.push(name);
123
+ }
124
+ return available;
125
+ }
126
+
127
+ /**
128
+ * Simple chat-style generation with no schema validation.
129
+ * Returns the raw LLM response.
130
+ */
131
+ async chat(
132
+ userPrompt: string,
133
+ systemPrompt: string,
134
+ options?: GenerateOptions
135
+ ): Promise<LLMResponse> {
136
+ const provider = this.getProvider();
137
+ const generateOptions: GenerateOptions = {
138
+ temperature: options?.temperature ?? this.config.defaultTemperature,
139
+ maxTokens: options?.maxTokens ?? this.config.defaultMaxTokens,
140
+ ...options,
141
+ };
142
+
143
+ return provider.generate(userPrompt, systemPrompt, generateOptions);
144
+ }
145
+
146
+ /**
147
+ * Generate structured output validated against a Zod schema.
148
+ *
149
+ * @param userPrompt - User's request
150
+ * @param systemPrompt - System instructions
151
+ * @param schema - Zod schema to validate the parsed JSON against
152
+ * @param options - Generation options
153
+ */
154
+ async generate(
155
+ userPrompt: string,
156
+ systemPrompt: string,
157
+ schema: ZodSchema<TOutput>,
158
+ options?: GenerateOptions
159
+ ): Promise<GenerationResult<TOutput>> {
160
+ const provider = this.getProvider();
161
+
162
+ const genOptions: GenerateOptions = {
163
+ model: this.config.defaultModel || undefined,
164
+ temperature: this.config.defaultTemperature,
165
+ maxTokens: this.config.defaultMaxTokens,
166
+ ...options,
167
+ };
168
+
169
+ let lastError: Error | undefined;
170
+ let lastResponse: LLMResponse | undefined;
171
+ let validationAttempts = 0;
172
+
173
+ // Initial generation
174
+ try {
175
+ lastResponse = await provider.generate(userPrompt, systemPrompt, genOptions);
176
+ validationAttempts++;
177
+
178
+ const extraction = extractJson(lastResponse.content);
179
+ if (!extraction.success) {
180
+ // JSON parse error — try correction
181
+ const result = await this.retryWithCorrection(
182
+ provider,
183
+ userPrompt,
184
+ systemPrompt,
185
+ lastResponse,
186
+ 'parse',
187
+ extraction.error!,
188
+ schema,
189
+ genOptions,
190
+ validationAttempts
191
+ );
192
+ if (result.data) {
193
+ return this.buildSuccess(result.data, result.response ?? lastResponse, result.attempts);
194
+ }
195
+ lastError = new Error(extraction.error);
196
+ validationAttempts = result.attempts;
197
+ } else {
198
+ // Validate against schema
199
+ const validation = this.validate(extraction.json, schema);
200
+ if (validation.valid && validation.data) {
201
+ return this.buildSuccess(validation.data, lastResponse, validationAttempts);
202
+ }
203
+
204
+ // Schema validation error — try correction
205
+ const result = await this.retryWithCorrection(
206
+ provider,
207
+ userPrompt,
208
+ systemPrompt,
209
+ lastResponse,
210
+ 'schema',
211
+ validation.zodError!,
212
+ schema,
213
+ genOptions,
214
+ validationAttempts
215
+ );
216
+ if (result.data) {
217
+ return this.buildSuccess(result.data, result.response ?? lastResponse, result.attempts);
218
+ }
219
+ lastError = validation.zodError;
220
+ validationAttempts = result.attempts;
221
+ }
222
+ } catch (error) {
223
+ lastError = error instanceof Error ? error : new Error(String(error));
224
+ }
225
+
226
+ // All retries exhausted
227
+ return this.buildError(lastError ?? new Error('Unknown error'), lastResponse, validationAttempts);
228
+ }
229
+
230
+ /**
231
+ * Register an additional provider instance (useful for custom/third-party providers).
232
+ */
233
+ registerProvider(name: string, provider: ILLMProvider): void {
234
+ this.providers.set(name, provider);
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Private helpers
239
+ // ---------------------------------------------------------------------------
240
+
241
+ private async retryWithCorrection(
242
+ provider: ILLMProvider,
243
+ originalPrompt: string,
244
+ systemPrompt: string,
245
+ previousResponse: LLMResponse,
246
+ errorType: 'parse' | 'schema',
247
+ error: string | ZodError,
248
+ schema: ZodSchema<TOutput>,
249
+ options: GenerateOptions,
250
+ currentAttempts: number
251
+ ): Promise<{ data?: TOutput; response?: LLMResponse; attempts: number }> {
252
+ let attempts = currentAttempts;
253
+
254
+ for (let retry = 0; retry < this.config.maxRetries; retry++) {
255
+ attempts++;
256
+
257
+ try {
258
+ const correctionPrompt =
259
+ errorType === 'parse'
260
+ ? buildParseErrorCorrectionPrompt(previousResponse.content, error as string)
261
+ : buildSchemaErrorCorrectionPrompt(previousResponse.content, error as ZodError);
262
+
263
+ const response = await provider.generate(correctionPrompt, systemPrompt, options);
264
+ const extraction = extractJson(response.content);
265
+
266
+ if (!extraction.success) {
267
+ // Still failing — try generic retry on last attempt
268
+ if (retry === this.config.maxRetries - 1) {
269
+ const genericPrompt = buildGenericRetryPrompt(originalPrompt, attempts);
270
+ const finalResponse = await provider.generate(genericPrompt, systemPrompt, options);
271
+ attempts++;
272
+
273
+ const finalExtraction = extractJson(finalResponse.content);
274
+ if (finalExtraction.success) {
275
+ const validation = this.validate(finalExtraction.json, schema);
276
+ if (validation.valid && validation.data) {
277
+ return { data: validation.data, response: finalResponse, attempts };
278
+ }
279
+ }
280
+ }
281
+ continue;
282
+ }
283
+
284
+ const validation = this.validate(extraction.json, schema);
285
+ if (validation.valid && validation.data) {
286
+ return { data: validation.data, response, attempts };
287
+ }
288
+
289
+ // Update error for next retry
290
+ if (validation.zodError) {
291
+ previousResponse = response;
292
+ error = validation.zodError;
293
+ errorType = 'schema';
294
+ }
295
+ } catch {
296
+ // Provider error during retry — continue to next attempt
297
+ }
298
+ }
299
+
300
+ return { attempts };
301
+ }
302
+
303
+ private validate(data: unknown, schema: ZodSchema<TOutput>): ValidationResult<TOutput> {
304
+ const result = schema.safeParse(data);
305
+ if (result.success) {
306
+ return { valid: true, data: result.data };
307
+ }
308
+ return {
309
+ valid: false,
310
+ zodError: result.error,
311
+ errors: result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`),
312
+ };
313
+ }
314
+
315
+ private buildSuccess(
316
+ data: TOutput,
317
+ response: LLMResponse,
318
+ validationAttempts: number
319
+ ): GenerationResult<TOutput> {
320
+ return {
321
+ success: true,
322
+ data,
323
+ metadata: {
324
+ model: response.model,
325
+ promptTokens: response.usage.promptTokens,
326
+ completionTokens: response.usage.completionTokens,
327
+ validationAttempts,
328
+ },
329
+ };
330
+ }
331
+
332
+ private buildError(
333
+ error: Error,
334
+ response?: LLMResponse,
335
+ validationAttempts?: number
336
+ ): GenerationResult<TOutput> {
337
+ const isProviderError = error instanceof LLMProviderError;
338
+
339
+ let code = 'GENERATION_FAILED';
340
+ let retryable = false;
341
+
342
+ if (isProviderError) {
343
+ code = error.code;
344
+ retryable = error.retryable;
345
+ } else if (error instanceof ZodError) {
346
+ code = 'VALIDATION_FAILED';
347
+ retryable = true;
348
+ }
349
+
350
+ const result: GenerationResult<TOutput> = {
351
+ success: false,
352
+ error: { code, message: error.message, retryable },
353
+ };
354
+
355
+ if (response) {
356
+ result.metadata = {
357
+ model: response.model,
358
+ promptTokens: response.usage.promptTokens,
359
+ completionTokens: response.usage.completionTokens,
360
+ validationAttempts: validationAttempts ?? 1,
361
+ };
362
+ }
363
+
364
+ return result;
365
+ }
366
+ }