@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.
- package/dist/index.d.mts +353 -0
- package/dist/index.d.ts +353 -0
- package/dist/index.js +851 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +835 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +40 -0
- package/src/__tests__/json-extraction.test.ts +45 -0
- package/src/__tests__/llm-service.test.ts +108 -0
- package/src/__tests__/mock-provider.test.ts +91 -0
- package/src/__tests__/retry.test.ts +48 -0
- package/src/errors.ts +31 -0
- package/src/index.ts +37 -0
- package/src/providers/anthropic.ts +273 -0
- package/src/providers/interface.ts +44 -0
- package/src/providers/mock.ts +134 -0
- package/src/providers/openai.ts +273 -0
- package/src/service.ts +366 -0
- package/src/types.ts +107 -0
- package/src/utils/http-status.ts +25 -0
- package/src/utils/json-extraction.ts +60 -0
- package/src/utils/retry.ts +122 -0
|
@@ -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
|
+
}
|