@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
+ * Anthropic Provider Implementation
3
+ *
4
+ * Uses native fetch to minimize dependencies.
5
+ * Supports Claude 4 and Claude 3.x models.
6
+ */
7
+
8
+ import type { ILLMProvider } from './interface';
9
+ import type { LLMResponse, GenerateOptions, ProviderConfig } from '../types';
10
+ import { LLM_DEFAULTS } from '../types';
11
+ import { LLMProviderError } from '../errors';
12
+ import type { LLMErrorCode } from '../errors';
13
+
14
+ interface AnthropicMessage {
15
+ role: 'user' | 'assistant';
16
+ content: string;
17
+ }
18
+
19
+ interface AnthropicRequest {
20
+ model: string;
21
+ max_tokens: number;
22
+ messages: AnthropicMessage[];
23
+ system?: string;
24
+ temperature?: number;
25
+ }
26
+
27
+ interface AnthropicResponse {
28
+ id: string;
29
+ type: 'message';
30
+ role: 'assistant';
31
+ content: Array<{ type: 'text'; text: string }>;
32
+ model: string;
33
+ stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence' | null;
34
+ usage: {
35
+ input_tokens: number;
36
+ output_tokens: number;
37
+ };
38
+ }
39
+
40
+ interface AnthropicErrorResponse {
41
+ type: 'error';
42
+ error: {
43
+ type: string;
44
+ message: string;
45
+ };
46
+ }
47
+
48
+ const ANTHROPIC_MODELS = [
49
+ 'claude-opus-4-6',
50
+ 'claude-opus-4-20250514',
51
+ 'claude-sonnet-4-6',
52
+ 'claude-sonnet-4-20250514',
53
+ 'claude-3-5-sonnet-20241022',
54
+ 'claude-3-5-haiku-20241022',
55
+ 'claude-3-opus-20240229',
56
+ 'claude-3-sonnet-20240229',
57
+ 'claude-3-haiku-20240307',
58
+ ] as const;
59
+
60
+ const MODEL_COSTS: Record<string, { input: number; output: number }> = {
61
+ 'claude-opus-4-6': { input: 0.015, output: 0.075 },
62
+ 'claude-opus-4-20250514': { input: 0.015, output: 0.075 },
63
+ 'claude-sonnet-4-6': { input: 0.003, output: 0.015 },
64
+ 'claude-sonnet-4-20250514': { input: 0.003, output: 0.015 },
65
+ 'claude-3-5-sonnet-20241022': { input: 0.003, output: 0.015 },
66
+ 'claude-3-5-haiku-20241022': { input: 0.0008, output: 0.004 },
67
+ 'claude-3-opus-20240229': { input: 0.015, output: 0.075 },
68
+ 'claude-3-sonnet-20240229': { input: 0.003, output: 0.015 },
69
+ 'claude-3-haiku-20240307': { input: 0.00025, output: 0.00125 },
70
+ };
71
+
72
+ const DEFAULTS = {
73
+ baseUrl: 'https://api.anthropic.com/v1',
74
+ model: LLM_DEFAULTS.anthropicModel,
75
+ temperature: LLM_DEFAULTS.temperature,
76
+ maxTokens: LLM_DEFAULTS.maxTokens,
77
+ timeoutMs: LLM_DEFAULTS.timeoutMs,
78
+ apiVersion: '2023-06-01',
79
+ };
80
+
81
+ export class AnthropicProvider implements ILLMProvider {
82
+ readonly name = 'anthropic';
83
+ readonly availableModels = ANTHROPIC_MODELS;
84
+ readonly defaultModel: string;
85
+
86
+ private readonly config: Required<ProviderConfig>;
87
+ private readonly apiVersion: string;
88
+
89
+ constructor(config: ProviderConfig, apiVersion?: string) {
90
+ this.config = {
91
+ apiKey: config.apiKey,
92
+ baseUrl: config.baseUrl ?? DEFAULTS.baseUrl,
93
+ defaultModel: config.defaultModel ?? DEFAULTS.model,
94
+ defaultTemperature: config.defaultTemperature ?? DEFAULTS.temperature,
95
+ defaultMaxTokens: config.defaultMaxTokens ?? DEFAULTS.maxTokens,
96
+ timeoutMs: config.timeoutMs ?? DEFAULTS.timeoutMs,
97
+ };
98
+ this.defaultModel = this.config.defaultModel;
99
+ this.apiVersion = apiVersion ?? DEFAULTS.apiVersion;
100
+ }
101
+
102
+ isAvailable(): boolean {
103
+ return Boolean(this.config.apiKey);
104
+ }
105
+
106
+ async generate(
107
+ userPrompt: string,
108
+ systemPrompt: string,
109
+ options?: GenerateOptions
110
+ ): Promise<LLMResponse> {
111
+ if (!this.isAvailable()) {
112
+ throw new LLMProviderError(
113
+ 'Anthropic API key not configured',
114
+ 'AUTHENTICATION_ERROR',
115
+ false,
116
+ undefined,
117
+ this.name
118
+ );
119
+ }
120
+
121
+ const model = options?.model ?? this.config.defaultModel;
122
+ const temperature = options?.temperature ?? this.config.defaultTemperature;
123
+ const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
124
+ const timeout = options?.timeout ?? this.config.timeoutMs;
125
+
126
+ // Wrap user prompt to request JSON output
127
+ const jsonWrappedPrompt = `${userPrompt}
128
+
129
+ Please respond with a valid JSON object only. Do not include any text before or after the JSON.`;
130
+
131
+ const requestBody: AnthropicRequest = {
132
+ model,
133
+ max_tokens: maxTokens,
134
+ messages: [{ role: 'user', content: jsonWrappedPrompt }],
135
+ system: systemPrompt,
136
+ temperature,
137
+ };
138
+
139
+ const controller = new AbortController();
140
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
141
+
142
+ try {
143
+ const response = await fetch(`${this.config.baseUrl}/messages`, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ 'x-api-key': this.config.apiKey,
148
+ 'anthropic-version': this.apiVersion,
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: { type: 'unknown', message: 'Unknown error' },
159
+ }))) as AnthropicErrorResponse;
160
+ throw this.handleApiError(response.status, errorData);
161
+ }
162
+
163
+ const data = (await response.json()) as AnthropicResponse;
164
+
165
+ const textContent = data.content.find((c) => c.type === 'text');
166
+ if (!textContent?.text) {
167
+ throw new LLMProviderError(
168
+ 'Empty response from Anthropic',
169
+ 'UNKNOWN_ERROR',
170
+ true,
171
+ undefined,
172
+ this.name
173
+ );
174
+ }
175
+
176
+ return {
177
+ content: textContent.text,
178
+ usage: {
179
+ promptTokens: data.usage.input_tokens,
180
+ completionTokens: data.usage.output_tokens,
181
+ totalTokens: data.usage.input_tokens + data.usage.output_tokens,
182
+ },
183
+ model: data.model,
184
+ responseId: data.id,
185
+ finishReason: data.stop_reason ?? undefined,
186
+ };
187
+ } catch (error) {
188
+ clearTimeout(timeoutId);
189
+
190
+ if (error instanceof LLMProviderError) {
191
+ throw error;
192
+ }
193
+
194
+ if (error instanceof Error && error.name === 'AbortError') {
195
+ throw new LLMProviderError(
196
+ `Request timed out after ${timeout}ms`,
197
+ 'TIMEOUT',
198
+ true,
199
+ undefined,
200
+ this.name
201
+ );
202
+ }
203
+
204
+ throw new LLMProviderError(
205
+ `Anthropic request failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
206
+ 'UNKNOWN_ERROR',
207
+ true,
208
+ undefined,
209
+ this.name
210
+ );
211
+ }
212
+ }
213
+
214
+ estimateCost(
215
+ promptTokens: number,
216
+ completionTokens: number,
217
+ model?: string
218
+ ): number | undefined {
219
+ const costs = MODEL_COSTS[model ?? this.config.defaultModel];
220
+ if (!costs) return undefined;
221
+ return (promptTokens / 1000) * costs.input + (completionTokens / 1000) * costs.output;
222
+ }
223
+
224
+ private handleApiError(
225
+ statusCode: number,
226
+ errorResponse: AnthropicErrorResponse
227
+ ): LLMProviderError {
228
+ const message = errorResponse.error?.message ?? 'Unknown Anthropic error';
229
+ const errorType = errorResponse.error?.type ?? '';
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 (errorType === 'invalid_request_error') {
241
+ code = (message.includes('token') || message.includes('length'))
242
+ ? 'CONTEXT_LENGTH_EXCEEDED'
243
+ : 'INVALID_REQUEST';
244
+ } else {
245
+ code = 'UNKNOWN_ERROR';
246
+ }
247
+ } else if (statusCode === 403) {
248
+ code = 'AUTHENTICATION_ERROR';
249
+ } else if (statusCode === 529 || 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 Anthropic provider from environment variables.
262
+ * Returns null if ANTHROPIC_API_KEY is not set.
263
+ */
264
+ export function createAnthropicProvider(): AnthropicProvider | null {
265
+ const apiKey = process.env.ANTHROPIC_API_KEY;
266
+ if (!apiKey) return null;
267
+
268
+ return new AnthropicProvider({
269
+ apiKey,
270
+ baseUrl: process.env.ANTHROPIC_BASE_URL,
271
+ defaultModel: process.env.ANTHROPIC_MODEL,
272
+ });
273
+ }
@@ -0,0 +1,44 @@
1
+ import type { LLMResponse, GenerateOptions } from '../types';
2
+
3
+ /**
4
+ * Contract that every LLM provider must implement.
5
+ *
6
+ * Generic over nothing — the provider just takes string prompts and returns
7
+ * string content. Domain-specific parsing/validation lives in the service layer.
8
+ */
9
+ export interface ILLMProvider {
10
+ /** Unique identifier for the provider (e.g. 'openai', 'anthropic') */
11
+ readonly name: string;
12
+
13
+ /** List of available models */
14
+ readonly availableModels: readonly string[];
15
+
16
+ /** Default model for this provider */
17
+ readonly defaultModel: string;
18
+
19
+ /**
20
+ * Send a prompt pair to the LLM and return the raw response.
21
+ *
22
+ * @param userPrompt - The user's message / generation request
23
+ * @param systemPrompt - System-level instructions
24
+ * @param options - Generation options (model, temperature, etc.)
25
+ */
26
+ generate(
27
+ userPrompt: string,
28
+ systemPrompt: string,
29
+ options?: GenerateOptions
30
+ ): Promise<LLMResponse>;
31
+
32
+ /** Check if the provider is properly configured and available */
33
+ isAvailable(): boolean;
34
+
35
+ /**
36
+ * Estimate cost for a request (in USD).
37
+ * Returns undefined if cost cannot be estimated.
38
+ */
39
+ estimateCost?(
40
+ promptTokens: number,
41
+ completionTokens: number,
42
+ model?: string
43
+ ): number | undefined;
44
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Mock LLM Provider for Testing
3
+ *
4
+ * Provides deterministic responses for testing.
5
+ * Can simulate errors, delays, and custom responses.
6
+ */
7
+
8
+ import type { ILLMProvider } from './interface';
9
+ import type { LLMResponse, GenerateOptions } from '../types';
10
+ import { LLMProviderError } from '../errors';
11
+
12
+ export interface MockBehavior {
13
+ /** Delay before responding (ms) */
14
+ delay?: number;
15
+ /** Simulate an error */
16
+ error?: {
17
+ code: 'RATE_LIMITED' | 'SERVICE_UNAVAILABLE' | 'CONTENT_FILTERED' | 'TIMEOUT';
18
+ message: string;
19
+ };
20
+ /** Return invalid JSON (for validation retry testing) */
21
+ returnInvalidJson?: boolean;
22
+ /** Custom response content string */
23
+ customResponse?: string;
24
+ }
25
+
26
+ // Global mock behavior that can be set in tests
27
+ let mockBehavior: MockBehavior = {};
28
+
29
+ /**
30
+ * Set mock behavior for testing.
31
+ */
32
+ export function setMockBehavior(behavior: MockBehavior) {
33
+ mockBehavior = behavior;
34
+ }
35
+
36
+ /**
37
+ * Reset mock behavior to defaults.
38
+ */
39
+ export function resetMockBehavior() {
40
+ mockBehavior = {};
41
+ }
42
+
43
+ /**
44
+ * Default valid JSON response for when no custom response is configured.
45
+ * Returns a minimal valid JSON object — domain-specific schemas should override via customResponse.
46
+ */
47
+ function getDefaultResponse(): string {
48
+ return JSON.stringify({ mock: true, timestamp: new Date().toISOString() });
49
+ }
50
+
51
+ /**
52
+ * Default invalid JSON response for testing validation retries.
53
+ */
54
+ function getInvalidResponse(): string {
55
+ return JSON.stringify({ incomplete: true });
56
+ }
57
+
58
+ export class MockLLMProvider implements ILLMProvider {
59
+ readonly name = 'mock';
60
+ readonly availableModels = ['mock-model-1', 'mock-model-2'] as const;
61
+ readonly defaultModel = 'mock-model-1';
62
+
63
+ private callCount = 0;
64
+
65
+ async generate(
66
+ _userPrompt: string,
67
+ _systemPrompt: string,
68
+ _options?: GenerateOptions
69
+ ): Promise<LLMResponse> {
70
+ this.callCount++;
71
+
72
+ if (mockBehavior.error) {
73
+ throw new LLMProviderError(
74
+ mockBehavior.error.message,
75
+ mockBehavior.error.code,
76
+ mockBehavior.error.code === 'RATE_LIMITED',
77
+ mockBehavior.error.code === 'RATE_LIMITED' ? 429 : 503,
78
+ 'mock'
79
+ );
80
+ }
81
+
82
+ if (mockBehavior.delay) {
83
+ await new Promise((resolve) => setTimeout(resolve, mockBehavior.delay));
84
+ }
85
+
86
+ if (mockBehavior.customResponse) {
87
+ return {
88
+ content: mockBehavior.customResponse,
89
+ usage: { promptTokens: 100, completionTokens: 500, totalTokens: 600 },
90
+ model: this.defaultModel,
91
+ responseId: `mock-${this.callCount}`,
92
+ finishReason: 'stop',
93
+ };
94
+ }
95
+
96
+ if (mockBehavior.returnInvalidJson) {
97
+ return {
98
+ content: getInvalidResponse(),
99
+ usage: { promptTokens: 100, completionTokens: 200, totalTokens: 300 },
100
+ model: this.defaultModel,
101
+ responseId: `mock-${this.callCount}`,
102
+ finishReason: 'stop',
103
+ };
104
+ }
105
+
106
+ return {
107
+ content: getDefaultResponse(),
108
+ usage: { promptTokens: 150, completionTokens: 800, totalTokens: 950 },
109
+ model: this.defaultModel,
110
+ responseId: `mock-${this.callCount}`,
111
+ finishReason: 'stop',
112
+ };
113
+ }
114
+
115
+ isAvailable(): boolean {
116
+ return true;
117
+ }
118
+
119
+ estimateCost(
120
+ promptTokens: number,
121
+ completionTokens: number,
122
+ _model?: string
123
+ ): number {
124
+ return (promptTokens + completionTokens) * 0.00001;
125
+ }
126
+
127
+ getCallCount(): number {
128
+ return this.callCount;
129
+ }
130
+
131
+ resetCallCount(): void {
132
+ this.callCount = 0;
133
+ }
134
+ }