universal-llm-client 4.0.0 → 4.2.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.
Files changed (127) hide show
  1. package/dist/ai-model.d.ts +20 -22
  2. package/dist/ai-model.d.ts.map +1 -1
  3. package/dist/ai-model.js +26 -23
  4. package/dist/ai-model.js.map +1 -1
  5. package/dist/client.d.ts +5 -5
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +17 -9
  8. package/dist/client.js.map +1 -1
  9. package/dist/http.d.ts +2 -0
  10. package/dist/http.d.ts.map +1 -1
  11. package/dist/http.js +1 -0
  12. package/dist/http.js.map +1 -1
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/interfaces.d.ts +49 -11
  18. package/dist/interfaces.d.ts.map +1 -1
  19. package/dist/interfaces.js +14 -0
  20. package/dist/interfaces.js.map +1 -1
  21. package/dist/providers/anthropic.d.ts +56 -0
  22. package/dist/providers/anthropic.d.ts.map +1 -0
  23. package/dist/providers/anthropic.js +524 -0
  24. package/dist/providers/anthropic.js.map +1 -0
  25. package/dist/providers/google.d.ts +5 -0
  26. package/dist/providers/google.d.ts.map +1 -1
  27. package/dist/providers/google.js +64 -8
  28. package/dist/providers/google.js.map +1 -1
  29. package/dist/providers/index.d.ts +1 -0
  30. package/dist/providers/index.d.ts.map +1 -1
  31. package/dist/providers/index.js +1 -0
  32. package/dist/providers/index.js.map +1 -1
  33. package/dist/providers/ollama.d.ts.map +1 -1
  34. package/dist/providers/ollama.js +38 -11
  35. package/dist/providers/ollama.js.map +1 -1
  36. package/dist/providers/openai.d.ts.map +1 -1
  37. package/dist/providers/openai.js +9 -7
  38. package/dist/providers/openai.js.map +1 -1
  39. package/dist/router.d.ts +13 -33
  40. package/dist/router.d.ts.map +1 -1
  41. package/dist/router.js +33 -57
  42. package/dist/router.js.map +1 -1
  43. package/dist/stream-decoder.d.ts +29 -2
  44. package/dist/stream-decoder.d.ts.map +1 -1
  45. package/dist/stream-decoder.js +39 -11
  46. package/dist/stream-decoder.js.map +1 -1
  47. package/dist/structured-output.d.ts +107 -181
  48. package/dist/structured-output.d.ts.map +1 -1
  49. package/dist/structured-output.js +137 -192
  50. package/dist/structured-output.js.map +1 -1
  51. package/dist/zod-adapter.d.ts +44 -0
  52. package/dist/zod-adapter.d.ts.map +1 -0
  53. package/dist/zod-adapter.js +61 -0
  54. package/dist/zod-adapter.js.map +1 -0
  55. package/package.json +9 -1
  56. package/src/ai-model.ts +350 -0
  57. package/src/auditor.ts +213 -0
  58. package/src/client.ts +402 -0
  59. package/src/debug/debug-google-streaming.ts +97 -0
  60. package/src/debug/debug-tool-execution.ts +86 -0
  61. package/src/debug/test-lmstudio-tools.ts +155 -0
  62. package/src/demos/README.md +47 -0
  63. package/src/demos/basic/universal-llm-examples.ts +161 -0
  64. package/src/demos/mcp/astrid-memory-demo.ts +295 -0
  65. package/src/demos/mcp/astrid-persona-memory.ts +357 -0
  66. package/src/demos/mcp/mcp-mongodb-demo.ts +275 -0
  67. package/src/demos/mcp/simple-astrid-memory.ts +148 -0
  68. package/src/demos/mcp/simple-mcp-demo.ts +68 -0
  69. package/src/demos/mcp/working-mcp-demo.ts +62 -0
  70. package/src/demos/model-alias-demo.ts +0 -0
  71. package/src/demos/tools/RAG_MEMORY_INTEGRATION.md +267 -0
  72. package/src/demos/tools/astrid-memory-demo.ts +270 -0
  73. package/src/demos/tools/astrid-production-memory-clean.ts +785 -0
  74. package/src/demos/tools/astrid-production-memory.ts +558 -0
  75. package/src/demos/tools/basic-translation-test.ts +66 -0
  76. package/src/demos/tools/chromadb-similarity-tuning.ts +390 -0
  77. package/src/demos/tools/clean-multilingual-conversation.ts +209 -0
  78. package/src/demos/tools/clean-translation-test.ts +119 -0
  79. package/src/demos/tools/clean-universal-multilingual-test.ts +131 -0
  80. package/src/demos/tools/complete-rag-demo.ts +369 -0
  81. package/src/demos/tools/complete-tool-demo.ts +132 -0
  82. package/src/demos/tools/demo-tool-calling.ts +124 -0
  83. package/src/demos/tools/dynamic-language-switching-test.ts +251 -0
  84. package/src/demos/tools/hybrid-thinking-test.ts +154 -0
  85. package/src/demos/tools/memory-integration-test.ts +420 -0
  86. package/src/demos/tools/multilingual-memory-system.ts +802 -0
  87. package/src/demos/tools/ondemand-translation-demo.ts +655 -0
  88. package/src/demos/tools/production-tool-demo.ts +245 -0
  89. package/src/demos/tools/revolutionary-multilingual-test.ts +151 -0
  90. package/src/demos/tools/rigorous-language-analysis.ts +218 -0
  91. package/src/demos/tools/test-universal-memory-system.ts +126 -0
  92. package/src/demos/tools/translation-integration-guide.ts +346 -0
  93. package/src/demos/tools/universal-memory-system.ts +560 -0
  94. package/src/http.ts +247 -0
  95. package/src/index.ts +161 -0
  96. package/src/interfaces.ts +657 -0
  97. package/src/mcp.ts +345 -0
  98. package/src/providers/anthropic.ts +762 -0
  99. package/src/providers/google.ts +620 -0
  100. package/src/providers/index.ts +8 -0
  101. package/src/providers/ollama.ts +469 -0
  102. package/src/providers/openai.ts +392 -0
  103. package/src/router.ts +780 -0
  104. package/src/stream-decoder.ts +361 -0
  105. package/src/structured-output.ts +759 -0
  106. package/src/test-scripts/test-advanced-tools.ts +310 -0
  107. package/src/test-scripts/test-google-streaming-enhanced.ts +147 -0
  108. package/src/test-scripts/test-google-streaming.ts +63 -0
  109. package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -0
  110. package/src/test-scripts/test-mcp-config.ts +28 -0
  111. package/src/test-scripts/test-mcp-connection.ts +29 -0
  112. package/src/test-scripts/test-system-message-positions.ts +163 -0
  113. package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -0
  114. package/src/test-scripts/test-tool-calling.ts +231 -0
  115. package/src/tests/ai-model.test.ts +1614 -0
  116. package/src/tests/auditor.test.ts +224 -0
  117. package/src/tests/http.test.ts +200 -0
  118. package/src/tests/interfaces.test.ts +117 -0
  119. package/src/tests/providers/google.test.ts +660 -0
  120. package/src/tests/providers/ollama.test.ts +954 -0
  121. package/src/tests/providers/openai.test.ts +1122 -0
  122. package/src/tests/router.test.ts +254 -0
  123. package/src/tests/stream-decoder.test.ts +179 -0
  124. package/src/tests/structured-output.test.ts +1450 -0
  125. package/src/tests/tools.test.ts +175 -0
  126. package/src/tools.ts +246 -0
  127. package/src/zod-adapter.ts +72 -0
@@ -0,0 +1,762 @@
1
+ /**
2
+ * Universal LLM Client v3 — Anthropic Messages API Provider
3
+ *
4
+ * Implements BaseLLMClient for Anthropic's Messages API (Claude).
5
+ * Uses the custom Anthropic protocol — NOT OpenAI-compatible.
6
+ *
7
+ * Key differences from OpenAI:
8
+ * - Endpoint: POST /v1/messages (not /chat/completions)
9
+ * - Auth: x-api-key header (not Authorization: Bearer)
10
+ * - System prompt: top-level `system` field, not a message
11
+ * - Messages: content is always an array of content blocks
12
+ * - Tool calls: `tool_use` content blocks (not tool_calls array)
13
+ * - Tool results: `tool_result` content blocks in user messages
14
+ * - Streaming: content_block_start/delta/stop events with typed deltas
15
+ */
16
+
17
+ import { BaseLLMClient } from '../client.js';
18
+ import { httpRequest, httpStream, parseSSE } from '../http.js';
19
+ import { StandardChatDecoder } from '../stream-decoder.js';
20
+ import type {
21
+ LLMClientOptions,
22
+ LLMChatMessage,
23
+ LLMChatResponse,
24
+ LLMToolCall,
25
+ LLMToolDefinition,
26
+ ChatOptions,
27
+ TokenUsageInfo,
28
+ ModelMetadata,
29
+ LLMContentPart,
30
+ LLMMessageContent,
31
+ } from '../interfaces.js';
32
+ import type { DecodedEvent } from '../stream-decoder.js';
33
+ import type { Auditor } from '../auditor.js';
34
+
35
+ // ============================================================================
36
+ // Anthropic-Specific Types
37
+ // ============================================================================
38
+
39
+ /** Anthropic content block types */
40
+ interface AnthropicTextBlock {
41
+ readonly type: 'text';
42
+ readonly text: string;
43
+ }
44
+
45
+ interface AnthropicImageBlock {
46
+ readonly type: 'image';
47
+ readonly source: {
48
+ readonly type: 'base64' | 'url';
49
+ readonly media_type?: string;
50
+ readonly data?: string;
51
+ readonly url?: string;
52
+ };
53
+ }
54
+
55
+ interface AnthropicToolUseBlock {
56
+ readonly type: 'tool_use';
57
+ readonly id: string;
58
+ readonly name: string;
59
+ readonly input: Record<string, unknown>;
60
+ }
61
+
62
+ interface AnthropicToolResultBlock {
63
+ readonly type: 'tool_result';
64
+ readonly tool_call_id: string;
65
+ readonly content: string | AnthropicTextBlock[];
66
+ }
67
+
68
+ interface AnthropicThinkingBlock {
69
+ readonly type: 'thinking';
70
+ readonly thinking: string;
71
+ readonly signature: string;
72
+ }
73
+
74
+ type AnthropicContentBlock =
75
+ | AnthropicTextBlock
76
+ | AnthropicImageBlock
77
+ | AnthropicToolUseBlock
78
+ | AnthropicToolResultBlock
79
+ | AnthropicThinkingBlock;
80
+
81
+ /** Anthropic message format */
82
+ interface AnthropicMessage {
83
+ readonly role: 'user' | 'assistant';
84
+ readonly content: string | AnthropicContentBlock[];
85
+ }
86
+
87
+ /** Anthropic tool definition (uses input_schema, not parameters) */
88
+ interface AnthropicToolDef {
89
+ readonly name: string;
90
+ readonly description: string;
91
+ readonly input_schema: {
92
+ readonly type: 'object';
93
+ readonly properties?: Record<string, unknown>;
94
+ readonly required?: string[];
95
+ };
96
+ }
97
+
98
+ /** Anthropic request body */
99
+ interface AnthropicRequest {
100
+ readonly model: string;
101
+ readonly messages: AnthropicMessage[];
102
+ readonly max_tokens: number;
103
+ readonly system?: string;
104
+ readonly tools?: AnthropicToolDef[];
105
+ readonly tool_choice?: { readonly type: 'auto' | 'any' | 'tool'; readonly name?: string };
106
+ readonly stream?: boolean;
107
+ readonly temperature?: number;
108
+ }
109
+
110
+ /** Anthropic non-streaming response */
111
+ interface AnthropicResponse {
112
+ readonly id: string;
113
+ readonly type: 'message';
114
+ readonly role: 'assistant';
115
+ readonly content: AnthropicContentBlock[];
116
+ readonly model: string;
117
+ readonly stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence' | 'tool_use' | null;
118
+ readonly usage: {
119
+ readonly input_tokens: number;
120
+ readonly output_tokens: number;
121
+ };
122
+ }
123
+
124
+ /** Anthropic model from models list */
125
+ interface AnthropicModelInfo {
126
+ readonly id: string;
127
+ readonly display_name: string;
128
+ readonly created_at: string;
129
+ readonly type: 'model';
130
+ }
131
+
132
+ // ============================================================================
133
+ // Streaming Event Types
134
+ // ============================================================================
135
+
136
+ interface StreamMessageStart {
137
+ readonly type: 'message_start';
138
+ readonly message: AnthropicResponse;
139
+ }
140
+
141
+ interface StreamContentBlockStart {
142
+ readonly type: 'content_block_start';
143
+ readonly index: number;
144
+ readonly content_block: AnthropicContentBlock;
145
+ }
146
+
147
+ interface StreamContentBlockDelta {
148
+ readonly type: 'content_block_delta';
149
+ readonly index: number;
150
+ readonly delta:
151
+ | { readonly type: 'text_delta'; readonly text: string }
152
+ | { readonly type: 'input_json_delta'; readonly partial_json: string }
153
+ | { readonly type: 'thinking_delta'; readonly thinking: string }
154
+ | { readonly type: 'signature_delta'; readonly signature: string };
155
+ }
156
+
157
+ interface StreamContentBlockStop {
158
+ readonly type: 'content_block_stop';
159
+ readonly index: number;
160
+ }
161
+
162
+ interface StreamMessageDelta {
163
+ readonly type: 'message_delta';
164
+ readonly delta: {
165
+ readonly stop_reason: string | null;
166
+ readonly stop_sequence?: string | null;
167
+ };
168
+ readonly usage: {
169
+ readonly output_tokens: number;
170
+ };
171
+ }
172
+
173
+ interface StreamMessageStop {
174
+ readonly type: 'message_stop';
175
+ }
176
+
177
+ type AnthropicStreamEvent =
178
+ | StreamMessageStart
179
+ | StreamContentBlockStart
180
+ | StreamContentBlockDelta
181
+ | StreamContentBlockStop
182
+ | StreamMessageDelta
183
+ | StreamMessageStop
184
+ | { readonly type: 'ping' }
185
+ | { readonly type: 'error'; readonly error: { readonly type: string; readonly message: string } };
186
+
187
+ // ============================================================================
188
+ // Anthropic Client
189
+ // ============================================================================
190
+
191
+ export class AnthropicClient extends BaseLLMClient {
192
+ private readonly baseUrl: string;
193
+
194
+ constructor(options: LLMClientOptions, auditor?: Auditor) {
195
+ const url = (options.url || 'https://api.anthropic.com').replace(/\/+$/, '');
196
+ super({ ...options, url }, auditor);
197
+ this.baseUrl = url;
198
+ }
199
+
200
+ // ========================================================================
201
+ // Headers
202
+ // ========================================================================
203
+
204
+ private buildAnthropicHeaders(): Record<string, string> {
205
+ const headers: Record<string, string> = {
206
+ 'Content-Type': 'application/json',
207
+ 'anthropic-version': '2023-06-01',
208
+ };
209
+ if (this.options.apiKey) {
210
+ headers['x-api-key'] = this.options.apiKey;
211
+ }
212
+ return headers;
213
+ }
214
+
215
+ // ========================================================================
216
+ // Chat (non-streaming)
217
+ // ========================================================================
218
+
219
+ override async chat(
220
+ messages: LLMChatMessage[],
221
+ options?: ChatOptions,
222
+ ): Promise<LLMChatResponse> {
223
+ const url = `${this.baseUrl}/v1/messages`;
224
+ const body = this.buildRequestBody(messages, options, false);
225
+
226
+ const start = Date.now();
227
+ this.auditor.record({
228
+ timestamp: start,
229
+ type: 'request',
230
+ provider: 'anthropic',
231
+ model: this.options.model,
232
+ });
233
+
234
+ const response = await httpRequest<AnthropicResponse>(url, {
235
+ method: 'POST',
236
+ headers: this.buildAnthropicHeaders(),
237
+ body,
238
+ timeout: this.options.timeout ?? 60000,
239
+ });
240
+
241
+ const data = response.data;
242
+ const result = this.parseAnthropicResponse(data);
243
+
244
+ this.auditor.record({
245
+ timestamp: Date.now(),
246
+ type: 'response',
247
+ provider: 'anthropic',
248
+ model: this.options.model,
249
+ duration: Date.now() - start,
250
+ usage: result.usage,
251
+ });
252
+
253
+ return result;
254
+ }
255
+
256
+ // ========================================================================
257
+ // Streaming
258
+ // ========================================================================
259
+
260
+ override async *chatStream(
261
+ messages: LLMChatMessage[],
262
+ options?: ChatOptions,
263
+ ): AsyncGenerator<DecodedEvent, LLMChatResponse | void, unknown> {
264
+ const url = `${this.baseUrl}/v1/messages`;
265
+ const body = this.buildRequestBody(messages, options, true);
266
+
267
+ const start = Date.now();
268
+ this.auditor.record({
269
+ timestamp: start,
270
+ type: 'stream_start',
271
+ provider: 'anthropic',
272
+ model: this.options.model,
273
+ });
274
+
275
+ const decoder = new StandardChatDecoder(() => {});
276
+
277
+ // Track content blocks as they stream in
278
+ const contentBlocks: Map<number, {
279
+ type: string;
280
+ text: string;
281
+ toolId?: string;
282
+ toolName?: string;
283
+ inputJson?: string;
284
+ thinking?: string;
285
+ signature?: string;
286
+ }> = new Map();
287
+
288
+ let usage: TokenUsageInfo | undefined;
289
+ let inputTokens = 0;
290
+
291
+ const stream = httpStream(url, {
292
+ method: 'POST',
293
+ headers: this.buildAnthropicHeaders(),
294
+ body,
295
+ timeout: this.options.timeout ?? 120000,
296
+ });
297
+
298
+ for await (const { data } of parseSSE(stream)) {
299
+ try {
300
+ const event = JSON.parse(data) as AnthropicStreamEvent;
301
+
302
+ switch (event.type) {
303
+ case 'message_start': {
304
+ inputTokens = event.message.usage?.input_tokens ?? 0;
305
+ break;
306
+ }
307
+
308
+ case 'content_block_start': {
309
+ const block = event.content_block;
310
+ if (block.type === 'text') {
311
+ contentBlocks.set(event.index, { type: 'text', text: '' });
312
+ } else if (block.type === 'tool_use') {
313
+ contentBlocks.set(event.index, {
314
+ type: 'tool_use',
315
+ text: '',
316
+ toolId: block.id,
317
+ toolName: block.name,
318
+ inputJson: '',
319
+ });
320
+ } else if (block.type === 'thinking') {
321
+ contentBlocks.set(event.index, { type: 'thinking', text: '', thinking: '' });
322
+ }
323
+ break;
324
+ }
325
+
326
+ case 'content_block_delta': {
327
+ const block = contentBlocks.get(event.index);
328
+ if (!block) break;
329
+
330
+ if (event.delta.type === 'text_delta') {
331
+ block.text += event.delta.text;
332
+ decoder.push(event.delta.text);
333
+ yield { type: 'text', content: event.delta.text };
334
+ } else if (event.delta.type === 'input_json_delta') {
335
+ block.inputJson = (block.inputJson ?? '') + event.delta.partial_json;
336
+ } else if (event.delta.type === 'thinking_delta') {
337
+ block.thinking = (block.thinking ?? '') + event.delta.thinking;
338
+ decoder.pushReasoning(event.delta.thinking);
339
+ yield { type: 'thinking', content: event.delta.thinking };
340
+ } else if (event.delta.type === 'signature_delta') {
341
+ block.signature = event.delta.signature;
342
+ }
343
+ break;
344
+ }
345
+
346
+ case 'content_block_stop': {
347
+ const block = contentBlocks.get(event.index);
348
+ if (block?.type === 'tool_use' && block.toolId && block.toolName) {
349
+ // Parse accumulated JSON and emit tool call
350
+ const toolCall: LLMToolCall = {
351
+ id: block.toolId,
352
+ type: 'function',
353
+ function: {
354
+ name: block.toolName,
355
+ arguments: block.inputJson ?? '{}',
356
+ },
357
+ };
358
+ yield { type: 'tool_call', calls: [toolCall] };
359
+ }
360
+ break;
361
+ }
362
+
363
+ case 'message_delta': {
364
+ const outputTokens = event.usage?.output_tokens ?? 0;
365
+ usage = {
366
+ inputTokens,
367
+ outputTokens,
368
+ totalTokens: inputTokens + outputTokens,
369
+ };
370
+ break;
371
+ }
372
+
373
+ case 'error': {
374
+ throw new Error(`Anthropic stream error: ${event.error.type} — ${event.error.message}`);
375
+ }
376
+ }
377
+ } catch (e) {
378
+ if (e instanceof Error && e.message.startsWith('Anthropic stream error')) {
379
+ throw e;
380
+ }
381
+ // Skip unparseable SSE data
382
+ }
383
+ }
384
+
385
+ decoder.flush();
386
+
387
+ this.auditor.record({
388
+ timestamp: Date.now(),
389
+ type: 'stream_end',
390
+ provider: 'anthropic',
391
+ model: this.options.model,
392
+ duration: Date.now() - start,
393
+ usage,
394
+ });
395
+
396
+ // Build final tool calls from accumulated content blocks
397
+ const toolCalls: LLMToolCall[] = [];
398
+ for (const block of contentBlocks.values()) {
399
+ if (block.type === 'tool_use' && block.toolId && block.toolName) {
400
+ toolCalls.push({
401
+ id: block.toolId,
402
+ type: 'function',
403
+ function: {
404
+ name: block.toolName,
405
+ arguments: block.inputJson ?? '{}',
406
+ },
407
+ });
408
+ }
409
+ }
410
+
411
+ return {
412
+ message: {
413
+ role: 'assistant',
414
+ content: decoder.getCleanContent(),
415
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
416
+ },
417
+ reasoning: decoder.getReasoning(),
418
+ usage,
419
+ provider: 'anthropic',
420
+ };
421
+ }
422
+
423
+ // ========================================================================
424
+ // Embeddings (not supported by Anthropic)
425
+ // ========================================================================
426
+
427
+ override async embed(_text: string): Promise<number[]> {
428
+ throw new Error('Anthropic does not support embeddings. Use a different provider.');
429
+ }
430
+
431
+ // ========================================================================
432
+ // Model Discovery
433
+ // ========================================================================
434
+
435
+ override async getModels(): Promise<string[]> {
436
+ const url = `${this.baseUrl}/v1/models`;
437
+ try {
438
+ const response = await httpRequest<{
439
+ data: AnthropicModelInfo[];
440
+ }>(url, {
441
+ headers: this.buildAnthropicHeaders(),
442
+ timeout: 5000,
443
+ });
444
+ return response.data.data.map(m => m.id);
445
+ } catch {
446
+ // Fallback: return well-known Claude models
447
+ return [
448
+ 'claude-sonnet-4-20250514',
449
+ 'claude-haiku-4-20250514',
450
+ 'claude-opus-4-20250514',
451
+ ];
452
+ }
453
+ }
454
+
455
+ override async getModelInfo(_modelName?: string): Promise<ModelMetadata> {
456
+ // Claude models support large context windows
457
+ const model = _modelName ?? this.options.model;
458
+
459
+ // Claude 4 models have 200K context
460
+ if (model.includes('claude-4') || model.includes('claude-opus') ||
461
+ model.includes('claude-sonnet') || model.includes('claude-haiku')) {
462
+ return {
463
+ model,
464
+ contextLength: 200_000,
465
+ capabilities: ['tools', 'vision', 'thinking'],
466
+ };
467
+ }
468
+
469
+ return {
470
+ model,
471
+ contextLength: 200_000,
472
+ capabilities: ['tools', 'vision'],
473
+ };
474
+ }
475
+
476
+ // ========================================================================
477
+ // Internal: Request Building
478
+ // ========================================================================
479
+
480
+ private buildRequestBody(
481
+ messages: LLMChatMessage[],
482
+ options: ChatOptions | undefined,
483
+ stream: boolean,
484
+ ): AnthropicRequest {
485
+ // Extract system prompt from messages
486
+ const systemMessages = messages.filter(m => m.role === 'system');
487
+ const nonSystemMessages = messages.filter(m => m.role !== 'system');
488
+
489
+ const systemPrompt = systemMessages.length > 0
490
+ ? systemMessages
491
+ .map(m => typeof m.content === 'string' ? m.content : this.extractText(m.content))
492
+ .join('\n\n')
493
+ : undefined;
494
+
495
+ // Convert tools from OpenAI format to Anthropic format
496
+ const tools = options?.tools ?? (
497
+ Object.keys(this.toolRegistry).length > 0 ? this.getToolDefinitions() : undefined
498
+ );
499
+ const anthropicTools = tools?.map(t => this.convertToolDef(t));
500
+
501
+ // Map tool_choice
502
+ let toolChoice: AnthropicRequest['tool_choice'];
503
+ if (options?.toolChoice === 'required') {
504
+ toolChoice = { type: 'any' };
505
+ } else if (options?.toolChoice === 'none') {
506
+ toolChoice = { type: 'auto' }; // Anthropic doesn't have 'none', closest is 'auto'
507
+ } else if (options?.toolChoice === 'auto') {
508
+ toolChoice = { type: 'auto' };
509
+ }
510
+
511
+ const body: AnthropicRequest = {
512
+ model: this.options.model,
513
+ messages: this.convertMessages(nonSystemMessages),
514
+ max_tokens: options?.maxTokens ?? 4096,
515
+ ...(systemPrompt && { system: systemPrompt }),
516
+ ...(anthropicTools?.length && { tools: anthropicTools }),
517
+ ...(toolChoice && { tool_choice: toolChoice }),
518
+ ...(stream && { stream: true }),
519
+ ...(options?.temperature !== undefined && { temperature: options.temperature }),
520
+ };
521
+
522
+ return body;
523
+ }
524
+
525
+ // ========================================================================
526
+ // Internal: Message Conversion
527
+ // ========================================================================
528
+
529
+ /**
530
+ * Convert our canonical LLMChatMessage[] to Anthropic's message format.
531
+ * Key conversions:
532
+ * - 'tool' role messages → merged into preceding user message as tool_result blocks
533
+ * - assistant messages with tool_calls → assistant message with tool_use blocks
534
+ * - multimodal content → Anthropic image blocks
535
+ */
536
+ private convertMessages(messages: LLMChatMessage[]): AnthropicMessage[] {
537
+ const result: AnthropicMessage[] = [];
538
+
539
+ for (let i = 0; i < messages.length; i++) {
540
+ const msg = messages[i]!;
541
+
542
+ if (msg.role === 'assistant') {
543
+ // Build content blocks for assistant
544
+ const blocks: AnthropicContentBlock[] = [];
545
+
546
+ // Add text content if present
547
+ const text = typeof msg.content === 'string'
548
+ ? msg.content
549
+ : this.extractText(msg.content);
550
+ if (text) {
551
+ blocks.push({ type: 'text', text });
552
+ }
553
+
554
+ // Convert tool_calls to tool_use blocks
555
+ if (msg.tool_calls) {
556
+ for (const tc of msg.tool_calls) {
557
+ let input: Record<string, unknown> = {};
558
+ try {
559
+ input = JSON.parse(tc.function.arguments);
560
+ } catch {
561
+ // Keep empty object if parse fails
562
+ }
563
+ blocks.push({
564
+ type: 'tool_use',
565
+ id: tc.id,
566
+ name: tc.function.name,
567
+ input,
568
+ });
569
+ }
570
+ }
571
+
572
+ if (blocks.length > 0) {
573
+ result.push({ role: 'assistant', content: blocks });
574
+ }
575
+ } else if (msg.role === 'tool') {
576
+ // Anthropic needs tool results inside user messages
577
+ const toolResultBlock: AnthropicToolResultBlock = {
578
+ type: 'tool_result',
579
+ tool_call_id: msg.tool_call_id ?? '',
580
+ content: typeof msg.content === 'string'
581
+ ? msg.content
582
+ : this.extractText(msg.content),
583
+ };
584
+
585
+ // Collect consecutive tool results
586
+ const toolResults: AnthropicContentBlock[] = [toolResultBlock];
587
+ while (i + 1 < messages.length && messages[i + 1]!.role === 'tool') {
588
+ i++;
589
+ const nextMsg = messages[i]!;
590
+ toolResults.push({
591
+ type: 'tool_result',
592
+ tool_call_id: nextMsg.tool_call_id ?? '',
593
+ content: typeof nextMsg.content === 'string'
594
+ ? nextMsg.content
595
+ : this.extractText(nextMsg.content),
596
+ });
597
+ }
598
+
599
+ result.push({ role: 'user', content: toolResults });
600
+ } else if (msg.role === 'user') {
601
+ const blocks = this.convertUserContent(msg.content);
602
+ result.push({ role: 'user', content: blocks });
603
+ }
604
+ }
605
+
606
+ // Anthropic requires alternating user/assistant messages.
607
+ // Merge consecutive same-role messages if needed.
608
+ return this.ensureAlternating(result);
609
+ }
610
+
611
+ /**
612
+ * Convert user message content (string or multimodal) to Anthropic blocks.
613
+ */
614
+ private convertUserContent(content: LLMMessageContent): AnthropicContentBlock[] {
615
+ if (typeof content === 'string') {
616
+ return [{ type: 'text', text: content }];
617
+ }
618
+
619
+ const blocks: AnthropicContentBlock[] = [];
620
+ for (const part of content as LLMContentPart[]) {
621
+ if (part.type === 'text') {
622
+ blocks.push({ type: 'text', text: part.text });
623
+ } else if (part.type === 'audio') {
624
+ // Anthropic does not yet support audio input — skip silently
625
+ this.debugLog('[Anthropic] Audio content dropped — not supported');
626
+ } else if (part.type === 'image_url') {
627
+ const url = part.image_url.url;
628
+ if (url.startsWith('data:')) {
629
+ // Extract base64 data from data URI
630
+ const match = url.match(/^data:([^;]+);base64,(.+)$/);
631
+ if (match) {
632
+ blocks.push({
633
+ type: 'image',
634
+ source: {
635
+ type: 'base64',
636
+ media_type: match[1],
637
+ data: match[2],
638
+ },
639
+ });
640
+ }
641
+ } else {
642
+ // URL-based image
643
+ blocks.push({
644
+ type: 'image',
645
+ source: {
646
+ type: 'url',
647
+ url,
648
+ },
649
+ });
650
+ }
651
+ }
652
+ }
653
+ return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }];
654
+ }
655
+
656
+ /**
657
+ * Ensure messages alternate between user and assistant roles.
658
+ * Anthropic requires strict alternation. Merge consecutive same-role messages.
659
+ */
660
+ private ensureAlternating(messages: AnthropicMessage[]): AnthropicMessage[] {
661
+ if (messages.length <= 1) return messages;
662
+
663
+ const merged: AnthropicMessage[] = [messages[0]!];
664
+
665
+ for (let i = 1; i < messages.length; i++) {
666
+ const current = messages[i]!;
667
+ const last = merged[merged.length - 1]!;
668
+
669
+ if (current.role === last.role) {
670
+ // Merge content arrays
671
+ const lastContent = Array.isArray(last.content)
672
+ ? last.content
673
+ : [{ type: 'text' as const, text: last.content }];
674
+ const currentContent = Array.isArray(current.content)
675
+ ? current.content
676
+ : [{ type: 'text' as const, text: current.content }];
677
+
678
+ merged[merged.length - 1] = {
679
+ role: current.role,
680
+ content: [...lastContent, ...currentContent],
681
+ };
682
+ } else {
683
+ merged.push(current);
684
+ }
685
+ }
686
+
687
+ return merged;
688
+ }
689
+
690
+ // ========================================================================
691
+ // Internal: Response Parsing
692
+ // ========================================================================
693
+
694
+ /**
695
+ * Parse Anthropic's response format into our canonical LLMChatResponse.
696
+ */
697
+ private parseAnthropicResponse(data: AnthropicResponse): LLMChatResponse {
698
+ let textContent = '';
699
+ let reasoning: string | undefined;
700
+ const toolCalls: LLMToolCall[] = [];
701
+
702
+ for (const block of data.content) {
703
+ if (block.type === 'text') {
704
+ textContent += block.text;
705
+ } else if (block.type === 'tool_use') {
706
+ toolCalls.push({
707
+ id: block.id,
708
+ type: 'function',
709
+ function: {
710
+ name: block.name,
711
+ arguments: JSON.stringify(block.input),
712
+ },
713
+ });
714
+ } else if (block.type === 'thinking') {
715
+ reasoning = (reasoning ?? '') + block.thinking;
716
+ }
717
+ }
718
+
719
+ const usage: TokenUsageInfo = {
720
+ inputTokens: data.usage.input_tokens,
721
+ outputTokens: data.usage.output_tokens,
722
+ totalTokens: data.usage.input_tokens + data.usage.output_tokens,
723
+ };
724
+
725
+ return {
726
+ message: {
727
+ role: 'assistant',
728
+ content: textContent,
729
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
730
+ },
731
+ reasoning,
732
+ usage,
733
+ provider: 'anthropic',
734
+ };
735
+ }
736
+
737
+ // ========================================================================
738
+ // Internal: Helpers
739
+ // ========================================================================
740
+
741
+ /** Convert OpenAI-format tool definition to Anthropic format */
742
+ private convertToolDef(tool: LLMToolDefinition): AnthropicToolDef {
743
+ return {
744
+ name: tool.function.name,
745
+ description: tool.function.description,
746
+ input_schema: {
747
+ type: 'object',
748
+ properties: tool.function.parameters.properties,
749
+ required: tool.function.parameters.required,
750
+ },
751
+ };
752
+ }
753
+
754
+ /** Extract text from multimodal content */
755
+ private extractText(content: LLMMessageContent): string {
756
+ if (typeof content === 'string') return content;
757
+ return (content as LLMContentPart[])
758
+ .filter((p): p is { type: 'text'; text: string } => p.type === 'text')
759
+ .map(p => p.text)
760
+ .join('');
761
+ }
762
+ }