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.
- package/dist/ai-model.d.ts +20 -22
- package/dist/ai-model.d.ts.map +1 -1
- package/dist/ai-model.js +26 -23
- package/dist/ai-model.js.map +1 -1
- package/dist/client.d.ts +5 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +17 -9
- package/dist/client.js.map +1 -1
- package/dist/http.d.ts +2 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +1 -0
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/interfaces.d.ts +49 -11
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/interfaces.js +14 -0
- package/dist/interfaces.js.map +1 -1
- package/dist/providers/anthropic.d.ts +56 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +524 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/google.d.ts +5 -0
- package/dist/providers/google.d.ts.map +1 -1
- package/dist/providers/google.js +64 -8
- package/dist/providers/google.js.map +1 -1
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/ollama.d.ts.map +1 -1
- package/dist/providers/ollama.js +38 -11
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +9 -7
- package/dist/providers/openai.js.map +1 -1
- package/dist/router.d.ts +13 -33
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +33 -57
- package/dist/router.js.map +1 -1
- package/dist/stream-decoder.d.ts +29 -2
- package/dist/stream-decoder.d.ts.map +1 -1
- package/dist/stream-decoder.js +39 -11
- package/dist/stream-decoder.js.map +1 -1
- package/dist/structured-output.d.ts +107 -181
- package/dist/structured-output.d.ts.map +1 -1
- package/dist/structured-output.js +137 -192
- package/dist/structured-output.js.map +1 -1
- package/dist/zod-adapter.d.ts +44 -0
- package/dist/zod-adapter.d.ts.map +1 -0
- package/dist/zod-adapter.js +61 -0
- package/dist/zod-adapter.js.map +1 -0
- package/package.json +9 -1
- package/src/ai-model.ts +350 -0
- package/src/auditor.ts +213 -0
- package/src/client.ts +402 -0
- package/src/debug/debug-google-streaming.ts +97 -0
- package/src/debug/debug-tool-execution.ts +86 -0
- package/src/debug/test-lmstudio-tools.ts +155 -0
- package/src/demos/README.md +47 -0
- package/src/demos/basic/universal-llm-examples.ts +161 -0
- package/src/demos/mcp/astrid-memory-demo.ts +295 -0
- package/src/demos/mcp/astrid-persona-memory.ts +357 -0
- package/src/demos/mcp/mcp-mongodb-demo.ts +275 -0
- package/src/demos/mcp/simple-astrid-memory.ts +148 -0
- package/src/demos/mcp/simple-mcp-demo.ts +68 -0
- package/src/demos/mcp/working-mcp-demo.ts +62 -0
- package/src/demos/model-alias-demo.ts +0 -0
- package/src/demos/tools/RAG_MEMORY_INTEGRATION.md +267 -0
- package/src/demos/tools/astrid-memory-demo.ts +270 -0
- package/src/demos/tools/astrid-production-memory-clean.ts +785 -0
- package/src/demos/tools/astrid-production-memory.ts +558 -0
- package/src/demos/tools/basic-translation-test.ts +66 -0
- package/src/demos/tools/chromadb-similarity-tuning.ts +390 -0
- package/src/demos/tools/clean-multilingual-conversation.ts +209 -0
- package/src/demos/tools/clean-translation-test.ts +119 -0
- package/src/demos/tools/clean-universal-multilingual-test.ts +131 -0
- package/src/demos/tools/complete-rag-demo.ts +369 -0
- package/src/demos/tools/complete-tool-demo.ts +132 -0
- package/src/demos/tools/demo-tool-calling.ts +124 -0
- package/src/demos/tools/dynamic-language-switching-test.ts +251 -0
- package/src/demos/tools/hybrid-thinking-test.ts +154 -0
- package/src/demos/tools/memory-integration-test.ts +420 -0
- package/src/demos/tools/multilingual-memory-system.ts +802 -0
- package/src/demos/tools/ondemand-translation-demo.ts +655 -0
- package/src/demos/tools/production-tool-demo.ts +245 -0
- package/src/demos/tools/revolutionary-multilingual-test.ts +151 -0
- package/src/demos/tools/rigorous-language-analysis.ts +218 -0
- package/src/demos/tools/test-universal-memory-system.ts +126 -0
- package/src/demos/tools/translation-integration-guide.ts +346 -0
- package/src/demos/tools/universal-memory-system.ts +560 -0
- package/src/http.ts +247 -0
- package/src/index.ts +161 -0
- package/src/interfaces.ts +657 -0
- package/src/mcp.ts +345 -0
- package/src/providers/anthropic.ts +762 -0
- package/src/providers/google.ts +620 -0
- package/src/providers/index.ts +8 -0
- package/src/providers/ollama.ts +469 -0
- package/src/providers/openai.ts +392 -0
- package/src/router.ts +780 -0
- package/src/stream-decoder.ts +361 -0
- package/src/structured-output.ts +759 -0
- package/src/test-scripts/test-advanced-tools.ts +310 -0
- package/src/test-scripts/test-google-streaming-enhanced.ts +147 -0
- package/src/test-scripts/test-google-streaming.ts +63 -0
- package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -0
- package/src/test-scripts/test-mcp-config.ts +28 -0
- package/src/test-scripts/test-mcp-connection.ts +29 -0
- package/src/test-scripts/test-system-message-positions.ts +163 -0
- package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -0
- package/src/test-scripts/test-tool-calling.ts +231 -0
- package/src/tests/ai-model.test.ts +1614 -0
- package/src/tests/auditor.test.ts +224 -0
- package/src/tests/http.test.ts +200 -0
- package/src/tests/interfaces.test.ts +117 -0
- package/src/tests/providers/google.test.ts +660 -0
- package/src/tests/providers/ollama.test.ts +954 -0
- package/src/tests/providers/openai.test.ts +1122 -0
- package/src/tests/router.test.ts +254 -0
- package/src/tests/stream-decoder.test.ts +179 -0
- package/src/tests/structured-output.test.ts +1450 -0
- package/src/tests/tools.test.ts +175 -0
- package/src/tools.ts +246 -0
- 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
|
+
}
|