@stacksjs/ai 0.70.53 → 0.70.55

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,430 @@
1
+ /**
2
+ * Anthropic Claude API Driver
3
+ *
4
+ * Direct API integration with Anthropic's Claude models.
5
+ * Supports chat completions and streaming.
6
+ */
7
+
8
+ import type { AIDriver, AIDriverConfig, AIMessage, AIResult, ChatCompletionOptions, ClaudeAPIResponse, ClaudeStreamEvent } from '../../types'
9
+ import { fetchWithRetry } from '../../utils/retry'
10
+ import { recordUsage } from '../../utils/usage'
11
+ import { normalizeMessagesForProvider } from '../../utils/vision'
12
+
13
+ export interface AnthropicDriverConfig extends AIDriverConfig {
14
+ apiKey: string
15
+ model?: string
16
+ maxTokens?: number
17
+ anthropicVersion?: string
18
+ }
19
+
20
+ const DEFAULT_MODEL = 'claude-sonnet-4-20250514'
21
+ const DEFAULT_MAX_TOKENS = 4096
22
+ const DEFAULT_VERSION = '2023-06-01'
23
+ const BASE_URL = 'https://api.anthropic.com/v1'
24
+
25
+ let globalConfig: AnthropicDriverConfig | null = null
26
+
27
+ /**
28
+ * Configure Anthropic globally
29
+ */
30
+ export function configure(config: AnthropicDriverConfig): void {
31
+ globalConfig = config
32
+ }
33
+
34
+ function getConfig(config?: Partial<AnthropicDriverConfig>): AnthropicDriverConfig {
35
+ const merged = { ...globalConfig, ...config }
36
+ if (!merged.apiKey) {
37
+ merged.apiKey = process.env.ANTHROPIC_API_KEY || ''
38
+ }
39
+ return merged as AnthropicDriverConfig
40
+ }
41
+
42
+ export function createAnthropicDriver(config: AnthropicDriverConfig): AIDriver {
43
+ const {
44
+ apiKey,
45
+ model = DEFAULT_MODEL,
46
+ maxTokens = DEFAULT_MAX_TOKENS,
47
+ anthropicVersion = DEFAULT_VERSION,
48
+ } = config
49
+
50
+ return {
51
+ name: 'Claude API',
52
+
53
+ async process(command: string, systemPrompt: string, history: AIMessage[]): Promise<string> {
54
+ if (!apiKey) {
55
+ throw new Error('Anthropic API key not set. Configure your API key in settings.')
56
+ }
57
+
58
+ // Retry-aware fetch (stacksjs/stacks#1878 A-5). Honors 429
59
+ // Retry-After + exponential backoff on 5xx (overloaded
60
+ // capacity is the common Anthropic blocker).
61
+ const response = await fetchWithRetry(`${BASE_URL}/messages`, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Content-Type': 'application/json',
65
+ 'x-api-key': apiKey,
66
+ 'anthropic-version': anthropicVersion,
67
+ },
68
+ body: JSON.stringify({
69
+ model,
70
+ max_tokens: maxTokens,
71
+ system: systemPrompt,
72
+ messages: [...history, { role: 'user', content: command }],
73
+ }),
74
+ })
75
+
76
+ if (!response.ok) {
77
+ const error = await response.text()
78
+ throw new Error(`Claude API error: ${error}`)
79
+ }
80
+
81
+ const data = (await response.json()) as ClaudeAPIResponse
82
+ if (!data.content || data.content.length === 0) {
83
+ throw new Error('Claude API returned empty content')
84
+ }
85
+ return data.content[0].text
86
+ },
87
+
88
+ async *stream(command: string, systemPrompt: string, history: AIMessage[]): AsyncGenerator<string> {
89
+ if (!apiKey) {
90
+ throw new Error('Anthropic API key not set. Configure your API key in settings.')
91
+ }
92
+
93
+ const response = await fetch(`${BASE_URL}/messages`, {
94
+ method: 'POST',
95
+ headers: {
96
+ 'Content-Type': 'application/json',
97
+ 'x-api-key': apiKey,
98
+ 'anthropic-version': anthropicVersion,
99
+ },
100
+ body: JSON.stringify({
101
+ model,
102
+ max_tokens: maxTokens,
103
+ system: systemPrompt,
104
+ stream: true,
105
+ messages: [...history, { role: 'user', content: command }],
106
+ }),
107
+ })
108
+
109
+ if (!response.ok) {
110
+ const error = await response.text()
111
+ throw new Error(`Claude API error: ${error}`)
112
+ }
113
+
114
+ const reader = response.body?.getReader()
115
+ if (!reader) throw new Error('No response body')
116
+
117
+ const decoder = new TextDecoder()
118
+ let buffer = ''
119
+
120
+ // Mid-stream error visibility (stacksjs/stacks#1878 A-2).
121
+ // Anthropic surfaces stream errors as `event: error` /
122
+ // `data: { type: 'error', error: { type, message } }`.
123
+ // The pre-fix code dropped these silently — the consumer
124
+ // saw a truncated response and assumed success. Now: throw
125
+ // so the caller knows the stream was cut short.
126
+ const handlePayload = function* (data: string) {
127
+ if (data === '[DONE]') return
128
+ let event: ClaudeStreamEvent & { type: string, error?: { type?: string, message?: string } }
129
+ try {
130
+ event = JSON.parse(data) as any
131
+ }
132
+ catch {
133
+ return
134
+ }
135
+ if (event.type === 'error') {
136
+ const msg = event.error?.message ?? JSON.stringify(event.error ?? event)
137
+ throw new Error(`[anthropic/stream] mid-stream error: ${msg}`)
138
+ }
139
+ if (event.type === 'content_block_delta' && event.delta?.text) {
140
+ yield event.delta.text
141
+ }
142
+ }
143
+
144
+ while (true) {
145
+ const { done, value } = await reader.read()
146
+ if (done) break
147
+
148
+ buffer += decoder.decode(value, { stream: true })
149
+ const lines = buffer.split('\n')
150
+ buffer = lines.pop() || ''
151
+
152
+ for (const line of lines) {
153
+ if (line.startsWith('data: ')) {
154
+ yield * handlePayload(line.slice(6))
155
+ }
156
+ }
157
+ }
158
+
159
+ // Process any remaining data in the buffer
160
+ if (buffer.startsWith('data: ')) {
161
+ yield * handlePayload(buffer.slice(6))
162
+ }
163
+ },
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Chat completion with full options. Supports tools + structured
169
+ * output via `responseFormat` (stacksjs/stacks#1878 A-1).
170
+ */
171
+ export async function chat(
172
+ messages: AIMessage[],
173
+ options: ChatCompletionOptions & { system?: string } = {},
174
+ ): Promise<AIResult> {
175
+ const config = getConfig()
176
+ const {
177
+ model = DEFAULT_MODEL,
178
+ maxTokens = DEFAULT_MAX_TOKENS,
179
+ temperature,
180
+ topP,
181
+ stop,
182
+ system,
183
+ tools,
184
+ toolChoice,
185
+ responseFormat,
186
+ } = options
187
+
188
+ // Build the request body. Tools and tool_choice map directly to
189
+ // Claude's Messages API. responseFormat is mapped to the
190
+ // tools-as-json pattern (Claude 3.5+ doesn't have a first-class
191
+ // JSON-mode parameter; the standard idiom is "define a tool whose
192
+ // input is the JSON shape and force the model to call it").
193
+ // Normalize content arrays into Anthropic's wire format
194
+ // (stacksjs/stacks#1878 A-3). Apps that authored their messages
195
+ // with OpenAI-style `image_url` blocks (or use cross-driver
196
+ // helpers) get the right shape on the wire.
197
+ const normalizedMessages = normalizeMessagesForProvider(messages, 'anthropic')
198
+
199
+ // Track wall-clock duration for usage reporters (#1878 A-6).
200
+ const startedAt = Date.now()
201
+
202
+ const body: Record<string, unknown> = {
203
+ model,
204
+ max_tokens: maxTokens,
205
+ temperature,
206
+ top_p: topP,
207
+ stop_sequences: stop ? (Array.isArray(stop) ? stop : [stop]) : undefined,
208
+ system,
209
+ messages: normalizedMessages,
210
+ }
211
+
212
+ if (tools && tools.length > 0) {
213
+ body.tools = tools.map(t => ({
214
+ name: t.name,
215
+ description: t.description,
216
+ input_schema: t.parameters ?? { type: 'object', properties: {} },
217
+ }))
218
+ if (toolChoice !== undefined)
219
+ body.tool_choice = mapAnthropicToolChoice(toolChoice)
220
+ }
221
+
222
+ // responseFormat → tools-as-json shape. If callers provide both
223
+ // explicit tools AND responseFormat, the structured-output tool
224
+ // is appended and forced via tool_choice. Caller's explicit
225
+ // tool_choice wins.
226
+ if (responseFormat && responseFormat.type !== 'text') {
227
+ const outputTool = buildAnthropicJsonTool(responseFormat)
228
+ const existing = Array.isArray(body.tools) ? body.tools as unknown[] : []
229
+ body.tools = [...existing, outputTool]
230
+ if (toolChoice === undefined) {
231
+ body.tool_choice = { type: 'tool', name: outputTool.name }
232
+ }
233
+ }
234
+
235
+ const response = await fetch(`${BASE_URL}/messages`, {
236
+ method: 'POST',
237
+ headers: {
238
+ 'Content-Type': 'application/json',
239
+ 'x-api-key': config.apiKey,
240
+ 'anthropic-version': config.anthropicVersion || DEFAULT_VERSION,
241
+ },
242
+ body: JSON.stringify(body),
243
+ })
244
+
245
+ if (!response.ok) {
246
+ const error = await response.text()
247
+ throw new Error(`Claude API error: ${error}`)
248
+ }
249
+
250
+ const data = (await response.json()) as any
251
+
252
+ if (!data.content || data.content.length === 0) {
253
+ throw new Error('Claude API returned empty content')
254
+ }
255
+
256
+ // Extract the content. For tool-use-as-json, the response is a
257
+ // tool_use block whose `input` field holds the structured object;
258
+ // we JSON.stringify it for the `content` string field so callers
259
+ // can JSON.parse back out. For freeform, the first text block.
260
+ const block = data.content.find((b: { type: string }) => b.type === 'tool_use')
261
+ ?? data.content.find((b: { type: string }) => b.type === 'text')
262
+ ?? data.content[0]
263
+ const content = block?.type === 'tool_use'
264
+ ? JSON.stringify(block.input)
265
+ : (block?.text ?? '')
266
+
267
+ const result: AIResult = {
268
+ content,
269
+ model: data.model,
270
+ usage: {
271
+ promptTokens: data.usage?.input_tokens || 0,
272
+ completionTokens: data.usage?.output_tokens || 0,
273
+ totalTokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
274
+ },
275
+ finishReason: data.stop_reason,
276
+ }
277
+
278
+ // Fire registered usage reporters (#1878 A-6).
279
+ recordUsage({
280
+ provider: 'anthropic',
281
+ model: data.model,
282
+ promptTokens: result.usage!.promptTokens,
283
+ completionTokens: result.usage!.completionTokens,
284
+ totalTokens: result.usage!.totalTokens,
285
+ durationMs: Date.now() - startedAt,
286
+ timestamp: Date.now(),
287
+ })
288
+
289
+ return result
290
+ }
291
+
292
+ /**
293
+ * Map the cross-driver `toolChoice` shape to Anthropic's
294
+ * Messages-API representation.
295
+ */
296
+ function mapAnthropicToolChoice(choice: NonNullable<ChatCompletionOptions['toolChoice']>): Record<string, unknown> {
297
+ if (choice === 'auto') return { type: 'auto' }
298
+ if (choice === 'required') return { type: 'any' }
299
+ if (choice === 'none') return { type: 'auto', disable_parallel_tool_use: true }
300
+ return { type: 'tool', name: choice.name }
301
+ }
302
+
303
+ /**
304
+ * Build a synthetic tool that captures the requested JSON shape,
305
+ * used to coerce structured output via the tool-use idiom.
306
+ */
307
+ function buildAnthropicJsonTool(format: NonNullable<ChatCompletionOptions['responseFormat']>): { name: string, description: string, input_schema: Record<string, unknown> } {
308
+ if (format.type === 'json_schema') {
309
+ return {
310
+ name: format.json_schema.name,
311
+ description: `Returns the result as JSON matching the '${format.json_schema.name}' schema.`,
312
+ input_schema: format.json_schema.schema,
313
+ }
314
+ }
315
+ // json_object — no schema, just "object with any keys"
316
+ return {
317
+ name: 'structured_output',
318
+ description: 'Returns the result as a JSON object.',
319
+ input_schema: { type: 'object', additionalProperties: true },
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Stream chat completion
325
+ */
326
+ export async function* streamChat(
327
+ messages: AIMessage[],
328
+ options: ChatCompletionOptions & { system?: string } = {},
329
+ ): AsyncGenerator<string> {
330
+ const config = getConfig()
331
+ const {
332
+ model = DEFAULT_MODEL,
333
+ maxTokens = DEFAULT_MAX_TOKENS,
334
+ temperature,
335
+ topP,
336
+ stop,
337
+ system,
338
+ } = options
339
+
340
+ const response = await fetch(`${BASE_URL}/messages`, {
341
+ method: 'POST',
342
+ headers: {
343
+ 'Content-Type': 'application/json',
344
+ 'x-api-key': config.apiKey,
345
+ 'anthropic-version': config.anthropicVersion || DEFAULT_VERSION,
346
+ },
347
+ body: JSON.stringify({
348
+ model,
349
+ max_tokens: maxTokens,
350
+ temperature,
351
+ top_p: topP,
352
+ stop_sequences: stop ? (Array.isArray(stop) ? stop : [stop]) : undefined,
353
+ system,
354
+ stream: true,
355
+ messages: normalizeMessagesForProvider(messages, 'anthropic'),
356
+ }),
357
+ })
358
+
359
+ if (!response.ok) {
360
+ const error = await response.text()
361
+ throw new Error(`Claude API error: ${error}`)
362
+ }
363
+
364
+ const reader = response.body?.getReader()
365
+ if (!reader) throw new Error('No response body')
366
+
367
+ const decoder = new TextDecoder()
368
+ let buffer = ''
369
+
370
+ while (true) {
371
+ const { done, value } = await reader.read()
372
+ if (done) break
373
+
374
+ buffer += decoder.decode(value, { stream: true })
375
+ const lines = buffer.split('\n')
376
+ buffer = lines.pop() || ''
377
+
378
+ for (const line of lines) {
379
+ if (line.startsWith('data: ')) {
380
+ const data = line.slice(6)
381
+ if (data === '[DONE]') continue
382
+
383
+ try {
384
+ const event = JSON.parse(data) as ClaudeStreamEvent
385
+ if (event.type === 'content_block_delta' && event.delta?.text) {
386
+ yield event.delta.text
387
+ }
388
+ }
389
+ catch {
390
+ // Skip invalid JSON
391
+ }
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Simple prompt helper
399
+ */
400
+ export async function prompt(
401
+ text: string,
402
+ options: ChatCompletionOptions & { system?: string } = {},
403
+ ): Promise<string> {
404
+ const result = await chat([{ role: 'user', content: text }], options)
405
+ return result.content
406
+ }
407
+
408
+ /**
409
+ * Count tokens (approximate)
410
+ * Note: This is a rough estimate. For accurate counts, use the tokenizer.
411
+ */
412
+ export function estimateTokens(text: string): number {
413
+ // Rough estimate: ~4 characters per token for English text
414
+ return Math.ceil(text.length / 4)
415
+ }
416
+
417
+ export const anthropicDriver: { create: typeof createAnthropicDriver } = {
418
+ create: createAnthropicDriver,
419
+ }
420
+
421
+ export const anthropic = {
422
+ configure,
423
+ chat,
424
+ streamChat,
425
+ prompt,
426
+ estimateTokens,
427
+ createDriver: createAnthropicDriver,
428
+ }
429
+
430
+ export default anthropic