@sprqvntrs/llm 3.13.0
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/LICENSE +21 -0
- package/README.md +595 -0
- package/index.ts +53 -0
- package/package.json +56 -0
- package/src/clients/anthropic-client.ts +636 -0
- package/src/clients/openai-client.ts +548 -0
- package/src/clients/openrouter-client.ts +802 -0
- package/src/helpers.ts +185 -0
- package/src/llm.ts +107 -0
- package/src/model-types.ts +61 -0
- package/src/models.ts +98 -0
- package/src/pricing-data.json +1651 -0
- package/src/pricing.ts +36 -0
- package/src/types/client-interface.ts +222 -0
- package/src/utils/debug.ts +120 -0
- package/src/utils/errors.ts +508 -0
- package/src/utils/normalize-null-strings.ts +103 -0
- package/src/utils/resolve-refs.ts +91 -0
- package/src/utils/strip-json-artifacts.ts +98 -0
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sprqvntrs/llm",
|
|
3
|
+
"version": "3.13.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./index.ts",
|
|
6
|
+
"types": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/SPRQVNTRS/platform.git",
|
|
14
|
+
"directory": "packages/llm"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/**/*",
|
|
18
|
+
"index.ts",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test:unit": "vitest run",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:anthropic": "tsx tests/anthropic-client.test.ts",
|
|
25
|
+
"test:openrouter": "tsx tests/openrouter-client.test.ts",
|
|
26
|
+
"test:openai": "tsx tests/openai-client.test.ts",
|
|
27
|
+
"test:truncation": "tsx tests/output-truncated-error.test.ts",
|
|
28
|
+
"sync-pricing": "tsx scripts/sync-pricing.ts",
|
|
29
|
+
"test:live": "tsx tests/anthropic-client.test.ts && tsx tests/openrouter-client.test.ts && tsx tests/openai-client.test.ts"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@anthropic-ai/sdk": "^0.91.1",
|
|
33
|
+
"@openrouter/sdk": "^0.12.22",
|
|
34
|
+
"openai": "^6.35.0",
|
|
35
|
+
"qs": "^6.12.1"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"zod": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/flat-cache": "^2.0.2",
|
|
42
|
+
"@types/node": "^22.0.0",
|
|
43
|
+
"@types/qs": "^6.9.15",
|
|
44
|
+
"dotenv": "^17.2.3",
|
|
45
|
+
"tsx": "^4.20.6",
|
|
46
|
+
"typescript": "^5.6.0",
|
|
47
|
+
"vitest": "^3.2.4"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/SPRQVNTRS/platform/tree/main/packages/llm#readme",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/SPRQVNTRS/platform/issues"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { z } from 'zod/v4';
|
|
3
|
+
import type {
|
|
4
|
+
LlmClientInterface,
|
|
5
|
+
BaseLlmClientConfig,
|
|
6
|
+
StreamChunk,
|
|
7
|
+
LlmTokenUsage,
|
|
8
|
+
ReasoningEffortLevel,
|
|
9
|
+
} from '../types/client-interface';
|
|
10
|
+
import { DEFAULT_MODELS, ANTHROPIC_MAX_TOKENS, WEB_SEARCH_TOOLS, DEFAULT_SYSTEM_PROMPT } from '../models';
|
|
11
|
+
import { calculateUsageCost } from '../pricing';
|
|
12
|
+
import { OpenAIClient } from './openai-client';
|
|
13
|
+
import type { AnthropicModel } from '../model-types';
|
|
14
|
+
import { DebugLogger } from '../utils/debug';
|
|
15
|
+
import {
|
|
16
|
+
generateRequestId,
|
|
17
|
+
wrapSdkError,
|
|
18
|
+
isRetryableError,
|
|
19
|
+
type LlmErrorContext,
|
|
20
|
+
} from '../utils/errors';
|
|
21
|
+
|
|
22
|
+
export interface AnthropicClientConfig extends Omit<BaseLlmClientConfig, 'model'> {
|
|
23
|
+
/**
|
|
24
|
+
* The Anthropic model to use
|
|
25
|
+
*/
|
|
26
|
+
model: AnthropicModel;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Optional OpenAI API key for structured formatting (auto-detected from env if not provided)
|
|
30
|
+
*/
|
|
31
|
+
openaiApiKey?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Client for interacting with Anthropic's API.
|
|
36
|
+
* Implements the unified LlmClientInterface.
|
|
37
|
+
* Uses OpenAI for structured output formatting when needed.
|
|
38
|
+
*/
|
|
39
|
+
export class AnthropicClient implements LlmClientInterface {
|
|
40
|
+
private client: Anthropic;
|
|
41
|
+
private model: AnthropicModel;
|
|
42
|
+
private formatterClient?: OpenAIClient;
|
|
43
|
+
private logger: DebugLogger;
|
|
44
|
+
private timeout: number;
|
|
45
|
+
private maxRetries: number;
|
|
46
|
+
private _lastUsage: LlmTokenUsage | null = null;
|
|
47
|
+
|
|
48
|
+
get lastUsage(): LlmTokenUsage | null {
|
|
49
|
+
return this._lastUsage;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a new AnthropicClient instance
|
|
54
|
+
*
|
|
55
|
+
* @param config Configuration options
|
|
56
|
+
* @throws Error if the Anthropic API key is not configured
|
|
57
|
+
*/
|
|
58
|
+
constructor(config: AnthropicClientConfig) {
|
|
59
|
+
// Use config timeout or default to 120 seconds (2 minutes)
|
|
60
|
+
this.timeout = config.timeout ?? 120000;
|
|
61
|
+
this.maxRetries = config.maxRetries ?? 2;
|
|
62
|
+
|
|
63
|
+
this.client = new Anthropic({
|
|
64
|
+
apiKey: config.apiKey,
|
|
65
|
+
timeout: this.timeout,
|
|
66
|
+
maxRetries: this.maxRetries,
|
|
67
|
+
});
|
|
68
|
+
this.model = config.model;
|
|
69
|
+
this.logger = new DebugLogger('AnthropicClient', { enabled: config.debug });
|
|
70
|
+
|
|
71
|
+
// Auto-detect OpenAI API key from environment if not explicitly provided
|
|
72
|
+
const openaiKey = config.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
73
|
+
|
|
74
|
+
// Initialize OpenAI formatter if API key is available
|
|
75
|
+
if (openaiKey) {
|
|
76
|
+
this.formatterClient = new OpenAIClient({
|
|
77
|
+
apiKey: openaiKey,
|
|
78
|
+
model: DEFAULT_MODELS.STRUCTURED_FORMATTER.model,
|
|
79
|
+
debug: config.debug,
|
|
80
|
+
timeout: this.timeout,
|
|
81
|
+
maxRetries: this.maxRetries,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Validate configuration on instantiation
|
|
86
|
+
this.validateConfiguration();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Set the OpenAI formatter client for structured output
|
|
91
|
+
*
|
|
92
|
+
* @param openaiApiKey OpenAI API key
|
|
93
|
+
*/
|
|
94
|
+
setFormatterClient(openaiApiKey: string, debug?: boolean): void {
|
|
95
|
+
this.formatterClient = new OpenAIClient({
|
|
96
|
+
apiKey: openaiApiKey,
|
|
97
|
+
model: DEFAULT_MODELS.STRUCTURED_FORMATTER.model,
|
|
98
|
+
debug,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validates that the client is properly configured
|
|
104
|
+
* @returns true if valid, throws an error with details if not
|
|
105
|
+
*/
|
|
106
|
+
validateConfiguration(): boolean {
|
|
107
|
+
if (!this.client.apiKey) {
|
|
108
|
+
throw new Error('Anthropic API key is not configured');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Warn if OpenAI formatter is not available (but don't throw - fallback exists)
|
|
112
|
+
if (!this.formatterClient) {
|
|
113
|
+
this.logger.log(
|
|
114
|
+
'⚠️ OpenAI API key not found. Anthropic will attempt direct JSON generation (less reliable). ' +
|
|
115
|
+
'Set OPENAI_API_KEY environment variable for better structured output.',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates a response from Anthropic's API and returns the text content
|
|
124
|
+
*
|
|
125
|
+
* @param prompt The prompt to send to the model
|
|
126
|
+
* @param options Optional configuration
|
|
127
|
+
* @param options.timeout Request timeout in milliseconds (overrides client default)
|
|
128
|
+
* @returns The text content as a string
|
|
129
|
+
*/
|
|
130
|
+
async createResponse(prompt: string, options?: { timeout?: number }): Promise<string> {
|
|
131
|
+
const response = await this.createRawResponse(prompt, options);
|
|
132
|
+
return this.extractContentFromResponse(response);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Creates a raw response from Anthropic's API and returns the full response object
|
|
137
|
+
* Use this when you need access to metadata like usage stats, finish reason, etc.
|
|
138
|
+
*
|
|
139
|
+
* @param prompt The prompt to send to the model
|
|
140
|
+
* @param options Optional configuration
|
|
141
|
+
* @param options.timeout Request timeout in milliseconds (overrides client default)
|
|
142
|
+
* @returns The raw message response from Anthropic
|
|
143
|
+
*/
|
|
144
|
+
async createRawResponse(prompt: string, options?: { timeout?: number }): Promise<unknown> {
|
|
145
|
+
const effectiveTimeout = options?.timeout ?? this.timeout;
|
|
146
|
+
|
|
147
|
+
// Create a client with the effective timeout if different from instance timeout
|
|
148
|
+
const client = effectiveTimeout !== this.timeout
|
|
149
|
+
? new Anthropic({
|
|
150
|
+
apiKey: this.client.apiKey,
|
|
151
|
+
timeout: effectiveTimeout,
|
|
152
|
+
maxRetries: this.maxRetries,
|
|
153
|
+
})
|
|
154
|
+
: this.client;
|
|
155
|
+
|
|
156
|
+
const response = await client.messages.create({
|
|
157
|
+
model: this.model,
|
|
158
|
+
max_tokens: ANTHROPIC_MAX_TOKENS,
|
|
159
|
+
system: DEFAULT_SYSTEM_PROMPT,
|
|
160
|
+
messages: [
|
|
161
|
+
{
|
|
162
|
+
role: 'user',
|
|
163
|
+
content: prompt,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
return response;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Extracts text content from a raw Anthropic response object
|
|
172
|
+
* Anthropic's messages API returns responses with content blocks
|
|
173
|
+
*
|
|
174
|
+
* @param response The raw response from Anthropic
|
|
175
|
+
* @returns The text content as a string
|
|
176
|
+
* @throws Error if there is no content in the response
|
|
177
|
+
*/
|
|
178
|
+
private extractContentFromResponse(response: unknown): string {
|
|
179
|
+
const typedResponse = response as Anthropic.Message;
|
|
180
|
+
const textBlock = typedResponse.content.find((block) => block.type === 'text');
|
|
181
|
+
if (!textBlock || textBlock.type !== 'text') {
|
|
182
|
+
throw new Error('No text content in Anthropic response');
|
|
183
|
+
}
|
|
184
|
+
return textBlock.text;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get the underlying Anthropic SDK client
|
|
189
|
+
* This allows access to all native Anthropic SDK methods
|
|
190
|
+
*/
|
|
191
|
+
get sdk(): Anthropic {
|
|
192
|
+
return this.client;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get the model being used
|
|
197
|
+
*/
|
|
198
|
+
get currentModel(): string {
|
|
199
|
+
return this.model;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Creates a streaming response from Anthropic's API
|
|
204
|
+
* Returns an async iterator that yields chunks of text as they arrive
|
|
205
|
+
*
|
|
206
|
+
* @param prompt The prompt to send to the model
|
|
207
|
+
* @param options Optional configuration
|
|
208
|
+
* @param options.timeout Request timeout in milliseconds (overrides client default)
|
|
209
|
+
* @returns An async iterable of stream chunks
|
|
210
|
+
*/
|
|
211
|
+
async *createStreamingResponse(prompt: string, options?: { timeout?: number }): AsyncIterable<StreamChunk> {
|
|
212
|
+
const requestId = generateRequestId();
|
|
213
|
+
const startTime = Date.now();
|
|
214
|
+
const effectiveTimeout = options?.timeout ?? this.timeout;
|
|
215
|
+
|
|
216
|
+
this.logger.log('createStreamingResponse called', {
|
|
217
|
+
modelUsed: this.model,
|
|
218
|
+
requestId,
|
|
219
|
+
timeout: effectiveTimeout,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
// Create a client with the effective timeout if different from instance timeout
|
|
224
|
+
const client = effectiveTimeout !== this.timeout
|
|
225
|
+
? new Anthropic({
|
|
226
|
+
apiKey: this.client.apiKey,
|
|
227
|
+
timeout: effectiveTimeout,
|
|
228
|
+
maxRetries: this.maxRetries,
|
|
229
|
+
})
|
|
230
|
+
: this.client;
|
|
231
|
+
|
|
232
|
+
const stream = client.messages.stream({
|
|
233
|
+
model: this.model,
|
|
234
|
+
max_tokens: ANTHROPIC_MAX_TOKENS,
|
|
235
|
+
system: DEFAULT_SYSTEM_PROMPT,
|
|
236
|
+
messages: [
|
|
237
|
+
{
|
|
238
|
+
role: 'user',
|
|
239
|
+
content: prompt,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
let accumulatedText = '';
|
|
245
|
+
|
|
246
|
+
for await (const chunk of stream) {
|
|
247
|
+
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
|
248
|
+
const text = chunk.delta.text;
|
|
249
|
+
accumulatedText += text;
|
|
250
|
+
|
|
251
|
+
yield {
|
|
252
|
+
text,
|
|
253
|
+
isComplete: false,
|
|
254
|
+
accumulatedText,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Get final message with usage data
|
|
260
|
+
const finalMessage = await stream.finalMessage();
|
|
261
|
+
let usage: StreamChunk['usage'] | undefined;
|
|
262
|
+
|
|
263
|
+
if (finalMessage.usage) {
|
|
264
|
+
usage = {
|
|
265
|
+
promptTokens: finalMessage.usage.input_tokens,
|
|
266
|
+
completionTokens: finalMessage.usage.output_tokens,
|
|
267
|
+
totalTokens: finalMessage.usage.input_tokens + finalMessage.usage.output_tokens,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Final chunk with usage
|
|
272
|
+
const elapsedMs = Date.now() - startTime;
|
|
273
|
+
this.logger.log(`Streaming completed in ${elapsedMs}ms`);
|
|
274
|
+
if (usage) {
|
|
275
|
+
this.logger.logUsage(usage);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
yield {
|
|
279
|
+
text: '',
|
|
280
|
+
isComplete: true,
|
|
281
|
+
accumulatedText,
|
|
282
|
+
usage,
|
|
283
|
+
};
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const elapsedMs = Date.now() - startTime;
|
|
286
|
+
const context: LlmErrorContext = {
|
|
287
|
+
clientType: 'anthropic',
|
|
288
|
+
model: this.model,
|
|
289
|
+
elapsedMs,
|
|
290
|
+
timeoutMs: effectiveTimeout,
|
|
291
|
+
operation: 'createStreamingResponse',
|
|
292
|
+
requestId,
|
|
293
|
+
metadata: {
|
|
294
|
+
promptSize: prompt.length,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const wrappedError = wrapSdkError(error, context);
|
|
299
|
+
this.logger.logError('Streaming failed', wrappedError, wrappedError.context);
|
|
300
|
+
throw wrappedError;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Transforms reasoning effort level into Anthropic's thinking mode configuration
|
|
306
|
+
*
|
|
307
|
+
* @param reasoningEffort The normalized reasoning effort level
|
|
308
|
+
* @returns Thinking mode object with appropriate budget_tokens, or undefined if not needed
|
|
309
|
+
*
|
|
310
|
+
* Budget guidelines based on Anthropic's documentation:
|
|
311
|
+
* - none: extended thinking disabled (no thinking block sent)
|
|
312
|
+
* - low: 1,024 tokens (minimum threshold for basic reasoning)
|
|
313
|
+
* - medium: 8,192 tokens (moderate complexity tasks)
|
|
314
|
+
* - high: 16,384 tokens (complex tasks requiring comprehensive reasoning)
|
|
315
|
+
*
|
|
316
|
+
* @see https://docs.claude.com/en/docs/build-with-claude/extended-thinking
|
|
317
|
+
*/
|
|
318
|
+
private getThinkingConfig(
|
|
319
|
+
reasoningEffort?: ReasoningEffortLevel,
|
|
320
|
+
): { type: 'enabled'; budget_tokens: number } | undefined {
|
|
321
|
+
// Anthropic has no "off" switch — extended thinking is opt-in, so both an
|
|
322
|
+
// omitted effort and an explicit 'none' mean "send no thinking block".
|
|
323
|
+
if (!reasoningEffort || reasoningEffort === 'none') {
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const budgetMap = {
|
|
328
|
+
low: 1024, // Minimum budget for basic reasoning
|
|
329
|
+
medium: 8192, // Moderate complexity tasks
|
|
330
|
+
high: 16384, // Complex tasks with comprehensive reasoning
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
type: 'enabled',
|
|
335
|
+
budget_tokens: budgetMap[reasoningEffort],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Creates a structured response using Anthropic for generation and OpenAI for formatting
|
|
341
|
+
*
|
|
342
|
+
* Since Anthropic doesn't support structured outputs, this method:
|
|
343
|
+
* 1. Generates content with Anthropic (with optional extended thinking)
|
|
344
|
+
* 2. Passes it to OpenAI's createStructuredResponse for formatting and validation
|
|
345
|
+
*
|
|
346
|
+
* @param options Configuration options
|
|
347
|
+
* @param options.prompt The prompt to send to the model
|
|
348
|
+
* @param options.schema The Zod schema to validate the response against
|
|
349
|
+
* @param options.formatGuidance Optional guidance for formatting the response
|
|
350
|
+
* @param options.reasoningEffort Normalized reasoning effort level ('none' | 'low' | 'medium' | 'high') -
|
|
351
|
+
* 'low' | 'medium' | 'high' enable extended thinking mode; 'none' (or omitted) leaves it off
|
|
352
|
+
* @param options.maxAttempts Maximum number of retry attempts (default: 1, no retries)
|
|
353
|
+
* @param options.logExecutionTime Whether to log execution time warnings (default: false)
|
|
354
|
+
* @param options.responseInstructions Additional instructions to append to the prompt (deprecated, use formatGuidance)
|
|
355
|
+
* @param options.useWebSearch Whether to enable web search for this request (default: false)
|
|
356
|
+
* @param options.stream Whether to use streaming for the generation phase (default: true)
|
|
357
|
+
* @param options.timeout Request timeout in milliseconds (overrides client default)
|
|
358
|
+
* @returns The structured and validated response according to the provided schema
|
|
359
|
+
* @throws Error if no OpenAI formatter is configured
|
|
360
|
+
*/
|
|
361
|
+
async createStructuredResponse<T extends z.ZodType>({
|
|
362
|
+
prompt,
|
|
363
|
+
schema,
|
|
364
|
+
formatGuidance,
|
|
365
|
+
reasoningEffort,
|
|
366
|
+
maxAttempts = 3,
|
|
367
|
+
logExecutionTime = false,
|
|
368
|
+
responseInstructions,
|
|
369
|
+
useWebSearch = false,
|
|
370
|
+
stream = true,
|
|
371
|
+
timeout,
|
|
372
|
+
}: {
|
|
373
|
+
prompt: string;
|
|
374
|
+
schema: T;
|
|
375
|
+
formatGuidance?: string;
|
|
376
|
+
reasoningEffort?: ReasoningEffortLevel;
|
|
377
|
+
maxAttempts?: number;
|
|
378
|
+
logExecutionTime?: boolean;
|
|
379
|
+
responseInstructions?: string;
|
|
380
|
+
useWebSearch?: boolean;
|
|
381
|
+
stream?: boolean;
|
|
382
|
+
timeout?: number;
|
|
383
|
+
}): Promise<z.infer<T>> {
|
|
384
|
+
if (!this.formatterClient) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
'OpenAI formatter not configured. Anthropic does not support structured outputs natively. ' +
|
|
387
|
+
'Please provide an OpenAI API key when creating the AnthropicClient, or set OPENAI_API_KEY environment variable.',
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Handle deprecated responseInstructions parameter
|
|
392
|
+
const effectiveFormatGuidance =
|
|
393
|
+
responseInstructions ?
|
|
394
|
+
formatGuidance ? `${formatGuidance}\n\n${responseInstructions}`
|
|
395
|
+
: responseInstructions
|
|
396
|
+
: formatGuidance;
|
|
397
|
+
|
|
398
|
+
const effectiveTimeout = timeout ?? this.timeout;
|
|
399
|
+
this._lastUsage = null;
|
|
400
|
+
const requestId = generateRequestId();
|
|
401
|
+
let attempts = 0;
|
|
402
|
+
let lastError: unknown;
|
|
403
|
+
|
|
404
|
+
// Build tools array if web search is enabled
|
|
405
|
+
const tools = useWebSearch ? [WEB_SEARCH_TOOLS.ANTHROPIC] : undefined;
|
|
406
|
+
|
|
407
|
+
// Get thinking configuration based on reasoning effort
|
|
408
|
+
const thinking = this.getThinkingConfig(reasoningEffort);
|
|
409
|
+
|
|
410
|
+
// Calculate max_tokens: must be greater than thinking budget
|
|
411
|
+
// If thinking is enabled, we need max_tokens > budget_tokens
|
|
412
|
+
// Otherwise, use the default ANTHROPIC_MAX_TOKENS
|
|
413
|
+
const maxTokens = thinking ? Math.max(ANTHROPIC_MAX_TOKENS, thinking.budget_tokens + 4096) : ANTHROPIC_MAX_TOKENS;
|
|
414
|
+
|
|
415
|
+
// Create a client with the effective timeout if different from instance timeout
|
|
416
|
+
const client = effectiveTimeout !== this.timeout
|
|
417
|
+
? new Anthropic({
|
|
418
|
+
apiKey: this.client.apiKey,
|
|
419
|
+
timeout: effectiveTimeout,
|
|
420
|
+
maxRetries: this.maxRetries,
|
|
421
|
+
})
|
|
422
|
+
: this.client;
|
|
423
|
+
|
|
424
|
+
while (attempts < maxAttempts) {
|
|
425
|
+
attempts++;
|
|
426
|
+
const startTime = Date.now();
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
this.logger.log('createStructuredResponse called', {
|
|
430
|
+
modelUsed: this.model,
|
|
431
|
+
reasoningEffort,
|
|
432
|
+
stream,
|
|
433
|
+
attempt: `${attempts}/${maxAttempts}`,
|
|
434
|
+
requestId,
|
|
435
|
+
timeout: effectiveTimeout,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
let textContent: string;
|
|
439
|
+
|
|
440
|
+
if (stream) {
|
|
441
|
+
// Step 1: Generate content with Anthropic using streaming for observability
|
|
442
|
+
this.logger.log('Using streaming generation for observability');
|
|
443
|
+
let accumulatedText = '';
|
|
444
|
+
let lastLoggedLength = 0;
|
|
445
|
+
|
|
446
|
+
const messageStream = client.messages.stream({
|
|
447
|
+
model: this.model,
|
|
448
|
+
max_tokens: maxTokens,
|
|
449
|
+
system: DEFAULT_SYSTEM_PROMPT,
|
|
450
|
+
messages: [
|
|
451
|
+
{
|
|
452
|
+
role: 'user',
|
|
453
|
+
content: prompt,
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
...(tools && { tools }),
|
|
457
|
+
...(thinking && { thinking }),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
for await (const chunk of messageStream) {
|
|
461
|
+
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
|
462
|
+
const text = chunk.delta.text;
|
|
463
|
+
accumulatedText += text;
|
|
464
|
+
|
|
465
|
+
// Log progress every 100 characters for observability
|
|
466
|
+
if (accumulatedText.length - lastLoggedLength >= 100) {
|
|
467
|
+
this.logger.log(`Streaming progress: ${accumulatedText.length} chars`);
|
|
468
|
+
lastLoggedLength = accumulatedText.length;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
this.logger.log(`Streaming completed, total: ${accumulatedText.length} chars`);
|
|
474
|
+
textContent = accumulatedText;
|
|
475
|
+
|
|
476
|
+
// Capture usage from the stream's final message
|
|
477
|
+
const finalMessage = await messageStream.finalMessage();
|
|
478
|
+
if (finalMessage.usage) {
|
|
479
|
+
const promptTokens = finalMessage.usage.input_tokens ?? 0;
|
|
480
|
+
const completionTokens = finalMessage.usage.output_tokens ?? 0;
|
|
481
|
+
const cachedTokens: number | undefined = (finalMessage.usage as any).cache_read_input_tokens ?? undefined;
|
|
482
|
+
this._lastUsage = {
|
|
483
|
+
promptTokens,
|
|
484
|
+
completionTokens,
|
|
485
|
+
totalTokens: promptTokens + completionTokens,
|
|
486
|
+
cachedTokens,
|
|
487
|
+
model: this.model,
|
|
488
|
+
cost: calculateUsageCost(this.model, promptTokens, completionTokens, cachedTokens),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
// Non-streaming path
|
|
493
|
+
const anthropicResponse = await client.messages.create({
|
|
494
|
+
model: this.model,
|
|
495
|
+
max_tokens: maxTokens,
|
|
496
|
+
system: DEFAULT_SYSTEM_PROMPT,
|
|
497
|
+
messages: [
|
|
498
|
+
{
|
|
499
|
+
role: 'user',
|
|
500
|
+
content: prompt,
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
...(tools && { tools }),
|
|
504
|
+
...(thinking && { thinking }),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Extract text content - when thinking mode is enabled, response may have multiple content blocks
|
|
508
|
+
// Find the text block (skip thinking blocks)
|
|
509
|
+
const textBlock = anthropicResponse.content.find((block) => block.type === 'text');
|
|
510
|
+
if (!textBlock || textBlock.type !== 'text') {
|
|
511
|
+
// Log the actual response structure for debugging
|
|
512
|
+
this.logger.logError('Unexpected response structure', undefined, {
|
|
513
|
+
contentBlocks: anthropicResponse.content.map((block) => ({ type: block.type })),
|
|
514
|
+
});
|
|
515
|
+
throw new Error('Expected text response from Anthropic');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
textContent = textBlock.text;
|
|
519
|
+
|
|
520
|
+
// Capture usage from the non-streaming response
|
|
521
|
+
if (anthropicResponse.usage) {
|
|
522
|
+
const promptTokens = anthropicResponse.usage.input_tokens ?? 0;
|
|
523
|
+
const completionTokens = anthropicResponse.usage.output_tokens ?? 0;
|
|
524
|
+
const cachedTokens: number | undefined = (anthropicResponse.usage as any).cache_read_input_tokens ?? undefined;
|
|
525
|
+
this._lastUsage = {
|
|
526
|
+
promptTokens,
|
|
527
|
+
completionTokens,
|
|
528
|
+
totalTokens: promptTokens + completionTokens,
|
|
529
|
+
cachedTokens,
|
|
530
|
+
model: this.model,
|
|
531
|
+
cost: calculateUsageCost(this.model, promptTokens, completionTokens, cachedTokens),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
this.logger.logResponsePreview(textContent);
|
|
537
|
+
|
|
538
|
+
// Step 2: Use OpenAI to format the response into the required schema
|
|
539
|
+
const formattedResponse = await this.formatterClient.createStructuredResponse({
|
|
540
|
+
prompt: textContent,
|
|
541
|
+
schema,
|
|
542
|
+
formatGuidance: effectiveFormatGuidance,
|
|
543
|
+
reasoningEffort: 'low', // Formatting doesn't need high reasoning
|
|
544
|
+
maxAttempts, // Pass through retry logic to formatter
|
|
545
|
+
logExecutionTime: false, // We'll log our own execution time
|
|
546
|
+
stream: false, // Don't stream the formatting step, only the generation
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Log execution time
|
|
550
|
+
const executionTime = Date.now() - startTime;
|
|
551
|
+
if (logExecutionTime || this.logger.isEnabled()) {
|
|
552
|
+
this.logger.logExecutionTime('createStructuredResponse', executionTime);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return formattedResponse;
|
|
556
|
+
} catch (error) {
|
|
557
|
+
lastError = error;
|
|
558
|
+
const elapsedMs = Date.now() - startTime;
|
|
559
|
+
|
|
560
|
+
const context: LlmErrorContext = {
|
|
561
|
+
clientType: 'anthropic',
|
|
562
|
+
model: this.model,
|
|
563
|
+
elapsedMs,
|
|
564
|
+
timeoutMs: effectiveTimeout,
|
|
565
|
+
operation: 'createStructuredResponse',
|
|
566
|
+
requestId,
|
|
567
|
+
metadata: {
|
|
568
|
+
promptSize: prompt.length,
|
|
569
|
+
schemaComplexity: typeof schema,
|
|
570
|
+
attempt: attempts,
|
|
571
|
+
maxAttempts,
|
|
572
|
+
reasoningEffort,
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const wrappedError = wrapSdkError(error, context);
|
|
577
|
+
const retryable = isRetryableError(wrappedError);
|
|
578
|
+
|
|
579
|
+
this.logger.log(`Failed attempt ${attempts}/${maxAttempts}`, {
|
|
580
|
+
error: wrappedError.message,
|
|
581
|
+
errorType: wrappedError.name,
|
|
582
|
+
retryable,
|
|
583
|
+
elapsedMs,
|
|
584
|
+
...(wrappedError.context.metadata?.errorCode && { errorCode: wrappedError.context.metadata.errorCode }),
|
|
585
|
+
...(wrappedError.context.metadata?.providerRequestId && { providerRequestId: wrappedError.context.metadata.providerRequestId }),
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
if (!retryable || attempts === maxAttempts) {
|
|
589
|
+
this.logger.logError(
|
|
590
|
+
`createStructuredResponse failed${retryable ? ' after all attempts' : ' (non-retryable)'}`,
|
|
591
|
+
wrappedError,
|
|
592
|
+
wrappedError.context,
|
|
593
|
+
);
|
|
594
|
+
throw wrappedError;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Wait before retrying (exponential backoff)
|
|
598
|
+
const backoffMs = Math.min(1000 * Math.pow(2, attempts - 1), 10000);
|
|
599
|
+
this.logger.log(`Retrying after ${backoffMs}ms backoff (attempt ${attempts + 1}/${maxAttempts})...`);
|
|
600
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
throw lastError || new Error('Failed to get structured response from LLM');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Process a batch of items with parallel processing
|
|
609
|
+
*/
|
|
610
|
+
async processBatchWithLLM<T, R>({
|
|
611
|
+
items,
|
|
612
|
+
processFn,
|
|
613
|
+
batchSize = 5,
|
|
614
|
+
}: {
|
|
615
|
+
items: T[];
|
|
616
|
+
processFn: (batch: T[]) => Promise<R[]>;
|
|
617
|
+
batchSize?: number;
|
|
618
|
+
}): Promise<R[]> {
|
|
619
|
+
const batches: T[][] = [];
|
|
620
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
621
|
+
batches.push(items.slice(i, i + batchSize));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const results = await Promise.all(batches.map(processFn));
|
|
625
|
+
return results.flat();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Generates an embedding vector for the provided text
|
|
630
|
+
* Note: Anthropic doesn't provide embeddings API yet
|
|
631
|
+
* This would need to be implemented using a different service
|
|
632
|
+
*/
|
|
633
|
+
async generateEmbedding(_value: string): Promise<number[]> {
|
|
634
|
+
throw new Error('Anthropic does not support embeddings. Use OpenAI for embeddings.');
|
|
635
|
+
}
|
|
636
|
+
}
|