universal-llm-client 4.2.0 → 4.5.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/CHANGELOG.md +142 -103
- package/LICENSE +21 -21
- package/README.md +640 -591
- package/dist/ai-model.d.ts +12 -1
- package/dist/ai-model.d.ts.map +1 -1
- package/dist/ai-model.js +36 -1
- package/dist/ai-model.js.map +1 -1
- package/dist/gemma-channel.d.ts +14 -0
- package/dist/gemma-channel.d.ts.map +1 -0
- package/dist/gemma-channel.js +38 -0
- package/dist/gemma-channel.js.map +1 -0
- package/dist/gemma-diffusion.d.ts +49 -0
- package/dist/gemma-diffusion.d.ts.map +1 -0
- package/dist/gemma-diffusion.js +147 -0
- package/dist/gemma-diffusion.js.map +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +14 -1
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces.d.ts +183 -7
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/interfaces.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +28 -3
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/google.d.ts +22 -1
- package/dist/providers/google.d.ts.map +1 -1
- package/dist/providers/google.js +225 -13
- package/dist/providers/google.js.map +1 -1
- package/dist/providers/ollama.d.ts +2 -0
- package/dist/providers/ollama.d.ts.map +1 -1
- package/dist/providers/ollama.js +59 -30
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts +14 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +200 -22
- package/dist/providers/openai.js.map +1 -1
- package/dist/router.d.ts +2 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -0
- package/dist/router.js.map +1 -1
- package/dist/stream-decoder.d.ts +12 -0
- package/dist/stream-decoder.d.ts.map +1 -1
- package/dist/stream-decoder.js +182 -5
- package/dist/stream-decoder.js.map +1 -1
- package/dist/thinking.d.ts +36 -0
- package/dist/thinking.d.ts.map +1 -0
- package/dist/thinking.js +52 -0
- package/dist/thinking.js.map +1 -0
- package/package.json +118 -116
- package/src/ai-model.ts +400 -350
- package/src/auditor.ts +213 -213
- package/src/client.ts +402 -402
- package/src/debug/debug-google-streaming.ts +1 -1
- package/src/demos/basic/universal-llm-examples.ts +3 -3
- package/src/demos/diffusion-gemma/.env +29 -0
- package/src/demos/diffusion-gemma/.env.example +27 -0
- package/src/demos/diffusion-gemma/CLAUDE.md +95 -0
- package/src/demos/diffusion-gemma/README.md +59 -0
- package/src/demos/diffusion-gemma/canvas.ts +1606 -0
- package/src/demos/diffusion-gemma/docker-compose.yml +29 -0
- package/src/demos/diffusion-gemma/probe-stream.ts +51 -0
- package/src/demos/diffusion-gemma/probe-tools.ts +55 -0
- package/src/demos/diffusion-gemma/server.ts +1205 -0
- package/src/demos/diffusion-gemma/start-vllm.sh +98 -0
- package/src/gemma-channel.ts +47 -0
- package/src/gemma-diffusion.ts +167 -0
- package/src/http.ts +261 -247
- package/src/index.ts +180 -161
- package/src/interfaces.ts +843 -657
- package/src/mcp.ts +345 -345
- package/src/providers/anthropic.ts +796 -762
- package/src/providers/google.ts +840 -620
- package/src/providers/index.ts +8 -8
- package/src/providers/ollama.ts +503 -469
- package/src/providers/openai.ts +587 -392
- package/src/router.ts +785 -780
- package/src/stream-decoder.ts +535 -361
- package/src/structured-output.ts +759 -759
- package/src/test-scripts/test-google-deep-research.ts +33 -0
- package/src/test-scripts/test-google-streaming-enhanced.ts +147 -147
- package/src/test-scripts/test-google-streaming.ts +1 -1
- package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -189
- package/src/test-scripts/test-google-thinking.ts +46 -0
- package/src/test-scripts/test-system-message-positions.ts +163 -163
- package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -83
- package/src/test-scripts/test-vllm-qwen36.ts +256 -0
- package/src/tests/ai-model.test.ts +1614 -1614
- package/src/tests/auditor.test.ts +224 -224
- package/src/tests/gemma-diffusion.test.ts +115 -0
- package/src/tests/http.test.ts +200 -200
- package/src/tests/interfaces.test.ts +117 -117
- package/src/tests/providers/anthropic.test.ts +118 -0
- package/src/tests/providers/google.test.ts +841 -660
- package/src/tests/providers/ollama.test.ts +1034 -954
- package/src/tests/providers/openai.test.ts +1511 -1122
- package/src/tests/router.test.ts +254 -254
- package/src/tests/stream-decoder.test.ts +263 -179
- package/src/tests/structured-output.test.ts +1450 -1450
- package/src/tests/thinking.test.ts +65 -0
- package/src/tests/tools.test.ts +175 -175
- package/src/thinking.ts +73 -0
- package/src/tools.ts +246 -246
- package/src/zod-adapter.ts +72 -72
package/src/client.ts
CHANGED
|
@@ -1,402 +1,402 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Universal LLM Client v3 — Base LLM Client
|
|
3
|
-
*
|
|
4
|
-
* Abstract base class for all LLM providers.
|
|
5
|
-
* Handles tool registration, execution, and the autonomous
|
|
6
|
-
* multi-turn tool execution loop.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type {
|
|
10
|
-
LLMClientOptions,
|
|
11
|
-
LLMChatMessage,
|
|
12
|
-
LLMChatResponse,
|
|
13
|
-
LLMToolDefinition,
|
|
14
|
-
LLMToolCall,
|
|
15
|
-
LLMFunction,
|
|
16
|
-
ToolRegistry,
|
|
17
|
-
ToolHandler,
|
|
18
|
-
ToolExecutionResult,
|
|
19
|
-
ChatOptions,
|
|
20
|
-
ModelMetadata,
|
|
21
|
-
} from './interfaces.js';
|
|
22
|
-
import {
|
|
23
|
-
StructuredOutputError,
|
|
24
|
-
type StructuredOutputOptions,
|
|
25
|
-
type SchemaConfig,
|
|
26
|
-
} from './structured-output.js';
|
|
27
|
-
import type { DecodedEvent } from './stream-decoder.js';
|
|
28
|
-
import type { Auditor } from './auditor.js';
|
|
29
|
-
import { NoopAuditor } from './auditor.js';
|
|
30
|
-
|
|
31
|
-
// ============================================================================
|
|
32
|
-
// Abstract Base Client
|
|
33
|
-
// ============================================================================
|
|
34
|
-
|
|
35
|
-
export abstract class BaseLLMClient {
|
|
36
|
-
protected options: LLMClientOptions;
|
|
37
|
-
protected toolRegistry: ToolRegistry = {};
|
|
38
|
-
protected auditor: Auditor;
|
|
39
|
-
protected debug: boolean;
|
|
40
|
-
|
|
41
|
-
constructor(options: LLMClientOptions, auditor?: Auditor) {
|
|
42
|
-
this.options = options;
|
|
43
|
-
this.auditor = auditor ?? new NoopAuditor();
|
|
44
|
-
this.debug = options.debug ?? false;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// ========================================================================
|
|
48
|
-
// Abstract Methods (implemented by providers)
|
|
49
|
-
// ========================================================================
|
|
50
|
-
|
|
51
|
-
/** Send a chat request and get a response */
|
|
52
|
-
abstract chat(
|
|
53
|
-
messages: LLMChatMessage[],
|
|
54
|
-
options?: ChatOptions,
|
|
55
|
-
): Promise<LLMChatResponse>;
|
|
56
|
-
|
|
57
|
-
/** Stream a chat response as decoded events */
|
|
58
|
-
abstract chatStream(
|
|
59
|
-
messages: LLMChatMessage[],
|
|
60
|
-
options?: ChatOptions,
|
|
61
|
-
): AsyncGenerator<DecodedEvent, LLMChatResponse | void, unknown>;
|
|
62
|
-
|
|
63
|
-
/** Get available models */
|
|
64
|
-
abstract getModels(): Promise<string[]>;
|
|
65
|
-
|
|
66
|
-
/** Generate embeddings for text */
|
|
67
|
-
abstract embed(text: string): Promise<number[]>;
|
|
68
|
-
|
|
69
|
-
/** Generate embeddings for multiple texts */
|
|
70
|
-
async embedArray(texts: string[]): Promise<number[][]> {
|
|
71
|
-
return Promise.all(texts.map(t => this.embed(t)));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Get metadata about a model (context length, architecture, etc.)
|
|
76
|
-
* Override per-provider for accurate data.
|
|
77
|
-
*/
|
|
78
|
-
async getModelInfo(_modelName?: string): Promise<ModelMetadata> {
|
|
79
|
-
return { contextLength: 8192 }; // Conservative default
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ========================================================================
|
|
83
|
-
// Tool Registration
|
|
84
|
-
// ========================================================================
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Sanitize tool name for LLM compatibility.
|
|
88
|
-
* LLM APIs require function names matching [a-zA-Z0-9_-].
|
|
89
|
-
* Module-prefixed names like "@core/computer:list_windows" are cleaned.
|
|
90
|
-
*/
|
|
91
|
-
private sanitizeToolName(name: string): string {
|
|
92
|
-
return name
|
|
93
|
-
.replace(/^@[^:]+:/, '') // Strip module prefix
|
|
94
|
-
.replace(/[^a-zA-Z0-9_-]/g, '_') // Replace illegal chars
|
|
95
|
-
.replace(/_+/g, '_') // Collapse
|
|
96
|
-
.replace(/^_|_$/g, ''); // Trim
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Register a tool/function callable by the model */
|
|
100
|
-
registerTool(
|
|
101
|
-
name: string,
|
|
102
|
-
description: string,
|
|
103
|
-
parameters: LLMFunction['parameters'],
|
|
104
|
-
handler: ToolHandler,
|
|
105
|
-
): void {
|
|
106
|
-
const safeName = this.sanitizeToolName(name);
|
|
107
|
-
this.toolRegistry[name] = {
|
|
108
|
-
definition: { name: safeName, description, parameters },
|
|
109
|
-
handler,
|
|
110
|
-
};
|
|
111
|
-
// Index by sanitized name for reverse lookup
|
|
112
|
-
if (safeName !== name && !this.toolRegistry[safeName]) {
|
|
113
|
-
this.toolRegistry[safeName] = this.toolRegistry[name]!;
|
|
114
|
-
}
|
|
115
|
-
this.debugLog(`Registered tool: ${name} (LLM name: ${safeName})`);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Register multiple tools at once */
|
|
119
|
-
registerTools(
|
|
120
|
-
tools: Array<{
|
|
121
|
-
name: string;
|
|
122
|
-
description: string;
|
|
123
|
-
parameters: LLMFunction['parameters'];
|
|
124
|
-
handler: ToolHandler;
|
|
125
|
-
}>,
|
|
126
|
-
): void {
|
|
127
|
-
for (const tool of tools) {
|
|
128
|
-
this.registerTool(tool.name, tool.description, tool.parameters, tool.handler);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Get all registered tool definitions (deduplicated by sanitized name) */
|
|
133
|
-
getToolDefinitions(): LLMToolDefinition[] {
|
|
134
|
-
const seen = new Set<string>();
|
|
135
|
-
const defs: LLMToolDefinition[] = [];
|
|
136
|
-
for (const { definition } of Object.values(this.toolRegistry)) {
|
|
137
|
-
if (seen.has(definition.name)) continue;
|
|
138
|
-
seen.add(definition.name);
|
|
139
|
-
defs.push({ type: 'function' as const, function: definition });
|
|
140
|
-
}
|
|
141
|
-
return defs;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ========================================================================
|
|
145
|
-
// Tool Execution
|
|
146
|
-
// ========================================================================
|
|
147
|
-
|
|
148
|
-
/** Execute a single tool call with fuzzy name matching */
|
|
149
|
-
async executeTool(toolCall: LLMToolCall): Promise<ToolExecutionResult> {
|
|
150
|
-
const toolName = toolCall.function.name;
|
|
151
|
-
const start = Date.now();
|
|
152
|
-
let tool = this.toolRegistry[toolName];
|
|
153
|
-
|
|
154
|
-
// Fuzzy lookup: try suffix match (LLM stripped module prefix)
|
|
155
|
-
if (!tool) {
|
|
156
|
-
const entries = Object.entries(this.toolRegistry);
|
|
157
|
-
const bySuffix = entries.find(([k]) => k.endsWith(`:${toolName}`));
|
|
158
|
-
if (bySuffix) {
|
|
159
|
-
tool = bySuffix[1];
|
|
160
|
-
this.debugLog(`Fuzzy tool match: "${toolName}" → "${bySuffix[0]}"`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Try prefix match: if only one tool in that module, use it
|
|
164
|
-
if (!tool) {
|
|
165
|
-
const byPrefix = entries.filter(([k]) => k.startsWith(`${toolName}:`));
|
|
166
|
-
if (byPrefix.length === 1) {
|
|
167
|
-
tool = byPrefix[0]![1];
|
|
168
|
-
this.debugLog(`Fuzzy tool match (single): "${toolName}" → "${byPrefix[0]![0]}"`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (!tool) {
|
|
174
|
-
const result: ToolExecutionResult = {
|
|
175
|
-
tool_call_id: toolCall.id,
|
|
176
|
-
output: null,
|
|
177
|
-
error: `Unknown tool: ${toolName}`,
|
|
178
|
-
duration: Date.now() - start,
|
|
179
|
-
};
|
|
180
|
-
this.auditor.record({
|
|
181
|
-
timestamp: Date.now(),
|
|
182
|
-
type: 'tool_result',
|
|
183
|
-
toolExecution: result,
|
|
184
|
-
error: result.error,
|
|
185
|
-
});
|
|
186
|
-
return result;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
this.auditor.record({
|
|
190
|
-
timestamp: Date.now(),
|
|
191
|
-
type: 'tool_call',
|
|
192
|
-
metadata: { toolName, arguments: toolCall.function.arguments },
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
const args = JSON.parse(toolCall.function.arguments);
|
|
197
|
-
const output = await tool.handler(args);
|
|
198
|
-
const result: ToolExecutionResult = {
|
|
199
|
-
tool_call_id: toolCall.id,
|
|
200
|
-
output,
|
|
201
|
-
duration: Date.now() - start,
|
|
202
|
-
};
|
|
203
|
-
this.auditor.record({
|
|
204
|
-
timestamp: Date.now(),
|
|
205
|
-
type: 'tool_result',
|
|
206
|
-
toolExecution: result,
|
|
207
|
-
});
|
|
208
|
-
return result;
|
|
209
|
-
} catch (error) {
|
|
210
|
-
const result: ToolExecutionResult = {
|
|
211
|
-
tool_call_id: toolCall.id,
|
|
212
|
-
output: null,
|
|
213
|
-
error: error instanceof Error ? error.message : String(error),
|
|
214
|
-
duration: Date.now() - start,
|
|
215
|
-
};
|
|
216
|
-
this.auditor.record({
|
|
217
|
-
timestamp: Date.now(),
|
|
218
|
-
type: 'tool_result',
|
|
219
|
-
toolExecution: result,
|
|
220
|
-
error: result.error,
|
|
221
|
-
});
|
|
222
|
-
return result;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Execute multiple tool calls in parallel */
|
|
227
|
-
async executeTools(toolCalls: LLMToolCall[]): Promise<ToolExecutionResult[]> {
|
|
228
|
-
return Promise.all(toolCalls.map(tc => this.executeTool(tc)));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ========================================================================
|
|
232
|
-
// Chat with Tools (multi-turn autonomous loop)
|
|
233
|
-
// ========================================================================
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Chat with automatic tool execution.
|
|
237
|
-
* Continues until the model stops calling tools or max iterations reached.
|
|
238
|
-
* Returns the complete execution trace in `toolExecutions`.
|
|
239
|
-
*/
|
|
240
|
-
async chatWithTools(
|
|
241
|
-
messages: LLMChatMessage[],
|
|
242
|
-
options?: ChatOptions & { maxIterations?: number },
|
|
243
|
-
): Promise<LLMChatResponse> {
|
|
244
|
-
const maxIterations = options?.maxIterations ?? 10;
|
|
245
|
-
const conversationMessages = [...messages];
|
|
246
|
-
const allToolExecutions: ToolExecutionResult[] = [];
|
|
247
|
-
let iterations = 0;
|
|
248
|
-
|
|
249
|
-
while (iterations < maxIterations) {
|
|
250
|
-
const response = await this.chat(conversationMessages, {
|
|
251
|
-
...options,
|
|
252
|
-
tools: this.getToolDefinitions(),
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// If no tool calls, return with full trace
|
|
256
|
-
if (!response.message.tool_calls?.length) {
|
|
257
|
-
return {
|
|
258
|
-
...response,
|
|
259
|
-
toolExecutions: allToolExecutions.length > 0 ? allToolExecutions : undefined,
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Add assistant message with tool calls
|
|
264
|
-
conversationMessages.push(response.message);
|
|
265
|
-
|
|
266
|
-
// Execute tools in parallel
|
|
267
|
-
const toolResults = await this.executeTools(response.message.tool_calls);
|
|
268
|
-
allToolExecutions.push(...toolResults);
|
|
269
|
-
|
|
270
|
-
// Add tool results as messages
|
|
271
|
-
for (const result of toolResults) {
|
|
272
|
-
conversationMessages.push({
|
|
273
|
-
role: 'tool',
|
|
274
|
-
content: typeof result.output === 'string'
|
|
275
|
-
? result.output
|
|
276
|
-
: JSON.stringify(result.output),
|
|
277
|
-
tool_call_id: result.tool_call_id,
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
iterations++;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Max iterations — final call without tools
|
|
285
|
-
const finalResponse = await this.chat(conversationMessages);
|
|
286
|
-
return {
|
|
287
|
-
...finalResponse,
|
|
288
|
-
toolExecutions: allToolExecutions,
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ========================================================================
|
|
293
|
-
// Helpers
|
|
294
|
-
// ========================================================================
|
|
295
|
-
|
|
296
|
-
/** Set the model name at runtime */
|
|
297
|
-
setModel(modelName: string): void {
|
|
298
|
-
this.options.model = modelName;
|
|
299
|
-
this.debugLog(`Model switched to: ${modelName}`);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Get the current model name */
|
|
303
|
-
get model(): string {
|
|
304
|
-
return this.options.model;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** Get the base URL */
|
|
308
|
-
get url(): string {
|
|
309
|
-
return this.options.url;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/** Set the auditor instance */
|
|
313
|
-
setAuditor(auditor: Auditor): void {
|
|
314
|
-
this.auditor = auditor;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
protected debugLog(message: string, data?: unknown): void {
|
|
318
|
-
if (this.debug) {
|
|
319
|
-
console.log(`[LLM:${this.options.model}] ${message}`, data ?? '');
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Generate a unique ID for tool calls when the provider doesn't provide one.
|
|
325
|
-
*/
|
|
326
|
-
protected generateToolCallId(): string {
|
|
327
|
-
return `call_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ========================================================================
|
|
331
|
-
// Structured Output Helpers (shared across all providers)
|
|
332
|
-
// ========================================================================
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Extract schema options from ChatOptions.
|
|
336
|
-
* Returns null if no schema is provided.
|
|
337
|
-
* Returns a SchemaConfig if a schema was found.
|
|
338
|
-
*/
|
|
339
|
-
protected extractSchemaOptions(options?: ChatOptions): (StructuredOutputOptions<unknown> & { schemaConfig: SchemaConfig<unknown> }) | null {
|
|
340
|
-
if (!options) return null;
|
|
341
|
-
|
|
342
|
-
if (options.schema) {
|
|
343
|
-
return {
|
|
344
|
-
schemaConfig: options.schema,
|
|
345
|
-
name: options.schemaName,
|
|
346
|
-
description: options.schemaDescription,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (options.jsonSchema) {
|
|
351
|
-
// Raw JSON Schema without validation
|
|
352
|
-
const config: SchemaConfig<unknown> = {
|
|
353
|
-
jsonSchema: options.jsonSchema,
|
|
354
|
-
};
|
|
355
|
-
return {
|
|
356
|
-
schemaConfig: config,
|
|
357
|
-
name: options.schemaName,
|
|
358
|
-
description: options.schemaDescription,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return null;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Validate structured response using a SchemaConfig.
|
|
367
|
-
* Throws StructuredOutputError on failure.
|
|
368
|
-
*/
|
|
369
|
-
protected validateStructuredResponse(content: string, config: SchemaConfig<unknown>): void {
|
|
370
|
-
if (!content) {
|
|
371
|
-
throw new StructuredOutputError(
|
|
372
|
-
'Empty response from LLM',
|
|
373
|
-
{ rawOutput: content },
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
let parsed: unknown;
|
|
378
|
-
try {
|
|
379
|
-
parsed = JSON.parse(content);
|
|
380
|
-
} catch (error) {
|
|
381
|
-
const syntaxError = error instanceof SyntaxError
|
|
382
|
-
? error
|
|
383
|
-
: new SyntaxError(String(error));
|
|
384
|
-
throw new StructuredOutputError(
|
|
385
|
-
`Failed to parse JSON: ${syntaxError.message}`,
|
|
386
|
-
{ rawOutput: content, cause: syntaxError },
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (config.validate) {
|
|
391
|
-
try {
|
|
392
|
-
config.validate(parsed);
|
|
393
|
-
} catch (error) {
|
|
394
|
-
const validationError = error instanceof Error ? error : new Error(String(error));
|
|
395
|
-
throw new StructuredOutputError(
|
|
396
|
-
`Validation failed: ${validationError.message}`,
|
|
397
|
-
{ rawOutput: content, cause: validationError },
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Universal LLM Client v3 — Base LLM Client
|
|
3
|
+
*
|
|
4
|
+
* Abstract base class for all LLM providers.
|
|
5
|
+
* Handles tool registration, execution, and the autonomous
|
|
6
|
+
* multi-turn tool execution loop.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
LLMClientOptions,
|
|
11
|
+
LLMChatMessage,
|
|
12
|
+
LLMChatResponse,
|
|
13
|
+
LLMToolDefinition,
|
|
14
|
+
LLMToolCall,
|
|
15
|
+
LLMFunction,
|
|
16
|
+
ToolRegistry,
|
|
17
|
+
ToolHandler,
|
|
18
|
+
ToolExecutionResult,
|
|
19
|
+
ChatOptions,
|
|
20
|
+
ModelMetadata,
|
|
21
|
+
} from './interfaces.js';
|
|
22
|
+
import {
|
|
23
|
+
StructuredOutputError,
|
|
24
|
+
type StructuredOutputOptions,
|
|
25
|
+
type SchemaConfig,
|
|
26
|
+
} from './structured-output.js';
|
|
27
|
+
import type { DecodedEvent } from './stream-decoder.js';
|
|
28
|
+
import type { Auditor } from './auditor.js';
|
|
29
|
+
import { NoopAuditor } from './auditor.js';
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Abstract Base Client
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
export abstract class BaseLLMClient {
|
|
36
|
+
protected options: LLMClientOptions;
|
|
37
|
+
protected toolRegistry: ToolRegistry = {};
|
|
38
|
+
protected auditor: Auditor;
|
|
39
|
+
protected debug: boolean;
|
|
40
|
+
|
|
41
|
+
constructor(options: LLMClientOptions, auditor?: Auditor) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.auditor = auditor ?? new NoopAuditor();
|
|
44
|
+
this.debug = options.debug ?? false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ========================================================================
|
|
48
|
+
// Abstract Methods (implemented by providers)
|
|
49
|
+
// ========================================================================
|
|
50
|
+
|
|
51
|
+
/** Send a chat request and get a response */
|
|
52
|
+
abstract chat(
|
|
53
|
+
messages: LLMChatMessage[],
|
|
54
|
+
options?: ChatOptions,
|
|
55
|
+
): Promise<LLMChatResponse>;
|
|
56
|
+
|
|
57
|
+
/** Stream a chat response as decoded events */
|
|
58
|
+
abstract chatStream(
|
|
59
|
+
messages: LLMChatMessage[],
|
|
60
|
+
options?: ChatOptions,
|
|
61
|
+
): AsyncGenerator<DecodedEvent, LLMChatResponse | void, unknown>;
|
|
62
|
+
|
|
63
|
+
/** Get available models */
|
|
64
|
+
abstract getModels(): Promise<string[]>;
|
|
65
|
+
|
|
66
|
+
/** Generate embeddings for text */
|
|
67
|
+
abstract embed(text: string): Promise<number[]>;
|
|
68
|
+
|
|
69
|
+
/** Generate embeddings for multiple texts */
|
|
70
|
+
async embedArray(texts: string[]): Promise<number[][]> {
|
|
71
|
+
return Promise.all(texts.map(t => this.embed(t)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get metadata about a model (context length, architecture, etc.)
|
|
76
|
+
* Override per-provider for accurate data.
|
|
77
|
+
*/
|
|
78
|
+
async getModelInfo(_modelName?: string): Promise<ModelMetadata> {
|
|
79
|
+
return { contextLength: 8192 }; // Conservative default
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ========================================================================
|
|
83
|
+
// Tool Registration
|
|
84
|
+
// ========================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sanitize tool name for LLM compatibility.
|
|
88
|
+
* LLM APIs require function names matching [a-zA-Z0-9_-].
|
|
89
|
+
* Module-prefixed names like "@core/computer:list_windows" are cleaned.
|
|
90
|
+
*/
|
|
91
|
+
private sanitizeToolName(name: string): string {
|
|
92
|
+
return name
|
|
93
|
+
.replace(/^@[^:]+:/, '') // Strip module prefix
|
|
94
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_') // Replace illegal chars
|
|
95
|
+
.replace(/_+/g, '_') // Collapse
|
|
96
|
+
.replace(/^_|_$/g, ''); // Trim
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Register a tool/function callable by the model */
|
|
100
|
+
registerTool(
|
|
101
|
+
name: string,
|
|
102
|
+
description: string,
|
|
103
|
+
parameters: LLMFunction['parameters'],
|
|
104
|
+
handler: ToolHandler,
|
|
105
|
+
): void {
|
|
106
|
+
const safeName = this.sanitizeToolName(name);
|
|
107
|
+
this.toolRegistry[name] = {
|
|
108
|
+
definition: { name: safeName, description, parameters },
|
|
109
|
+
handler,
|
|
110
|
+
};
|
|
111
|
+
// Index by sanitized name for reverse lookup
|
|
112
|
+
if (safeName !== name && !this.toolRegistry[safeName]) {
|
|
113
|
+
this.toolRegistry[safeName] = this.toolRegistry[name]!;
|
|
114
|
+
}
|
|
115
|
+
this.debugLog(`Registered tool: ${name} (LLM name: ${safeName})`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Register multiple tools at once */
|
|
119
|
+
registerTools(
|
|
120
|
+
tools: Array<{
|
|
121
|
+
name: string;
|
|
122
|
+
description: string;
|
|
123
|
+
parameters: LLMFunction['parameters'];
|
|
124
|
+
handler: ToolHandler;
|
|
125
|
+
}>,
|
|
126
|
+
): void {
|
|
127
|
+
for (const tool of tools) {
|
|
128
|
+
this.registerTool(tool.name, tool.description, tool.parameters, tool.handler);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get all registered tool definitions (deduplicated by sanitized name) */
|
|
133
|
+
getToolDefinitions(): LLMToolDefinition[] {
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
const defs: LLMToolDefinition[] = [];
|
|
136
|
+
for (const { definition } of Object.values(this.toolRegistry)) {
|
|
137
|
+
if (seen.has(definition.name)) continue;
|
|
138
|
+
seen.add(definition.name);
|
|
139
|
+
defs.push({ type: 'function' as const, function: definition });
|
|
140
|
+
}
|
|
141
|
+
return defs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ========================================================================
|
|
145
|
+
// Tool Execution
|
|
146
|
+
// ========================================================================
|
|
147
|
+
|
|
148
|
+
/** Execute a single tool call with fuzzy name matching */
|
|
149
|
+
async executeTool(toolCall: LLMToolCall): Promise<ToolExecutionResult> {
|
|
150
|
+
const toolName = toolCall.function.name;
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
let tool = this.toolRegistry[toolName];
|
|
153
|
+
|
|
154
|
+
// Fuzzy lookup: try suffix match (LLM stripped module prefix)
|
|
155
|
+
if (!tool) {
|
|
156
|
+
const entries = Object.entries(this.toolRegistry);
|
|
157
|
+
const bySuffix = entries.find(([k]) => k.endsWith(`:${toolName}`));
|
|
158
|
+
if (bySuffix) {
|
|
159
|
+
tool = bySuffix[1];
|
|
160
|
+
this.debugLog(`Fuzzy tool match: "${toolName}" → "${bySuffix[0]}"`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Try prefix match: if only one tool in that module, use it
|
|
164
|
+
if (!tool) {
|
|
165
|
+
const byPrefix = entries.filter(([k]) => k.startsWith(`${toolName}:`));
|
|
166
|
+
if (byPrefix.length === 1) {
|
|
167
|
+
tool = byPrefix[0]![1];
|
|
168
|
+
this.debugLog(`Fuzzy tool match (single): "${toolName}" → "${byPrefix[0]![0]}"`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!tool) {
|
|
174
|
+
const result: ToolExecutionResult = {
|
|
175
|
+
tool_call_id: toolCall.id,
|
|
176
|
+
output: null,
|
|
177
|
+
error: `Unknown tool: ${toolName}`,
|
|
178
|
+
duration: Date.now() - start,
|
|
179
|
+
};
|
|
180
|
+
this.auditor.record({
|
|
181
|
+
timestamp: Date.now(),
|
|
182
|
+
type: 'tool_result',
|
|
183
|
+
toolExecution: result,
|
|
184
|
+
error: result.error,
|
|
185
|
+
});
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.auditor.record({
|
|
190
|
+
timestamp: Date.now(),
|
|
191
|
+
type: 'tool_call',
|
|
192
|
+
metadata: { toolName, arguments: toolCall.function.arguments },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const args = JSON.parse(toolCall.function.arguments);
|
|
197
|
+
const output = await tool.handler(args);
|
|
198
|
+
const result: ToolExecutionResult = {
|
|
199
|
+
tool_call_id: toolCall.id,
|
|
200
|
+
output,
|
|
201
|
+
duration: Date.now() - start,
|
|
202
|
+
};
|
|
203
|
+
this.auditor.record({
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
type: 'tool_result',
|
|
206
|
+
toolExecution: result,
|
|
207
|
+
});
|
|
208
|
+
return result;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const result: ToolExecutionResult = {
|
|
211
|
+
tool_call_id: toolCall.id,
|
|
212
|
+
output: null,
|
|
213
|
+
error: error instanceof Error ? error.message : String(error),
|
|
214
|
+
duration: Date.now() - start,
|
|
215
|
+
};
|
|
216
|
+
this.auditor.record({
|
|
217
|
+
timestamp: Date.now(),
|
|
218
|
+
type: 'tool_result',
|
|
219
|
+
toolExecution: result,
|
|
220
|
+
error: result.error,
|
|
221
|
+
});
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Execute multiple tool calls in parallel */
|
|
227
|
+
async executeTools(toolCalls: LLMToolCall[]): Promise<ToolExecutionResult[]> {
|
|
228
|
+
return Promise.all(toolCalls.map(tc => this.executeTool(tc)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ========================================================================
|
|
232
|
+
// Chat with Tools (multi-turn autonomous loop)
|
|
233
|
+
// ========================================================================
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Chat with automatic tool execution.
|
|
237
|
+
* Continues until the model stops calling tools or max iterations reached.
|
|
238
|
+
* Returns the complete execution trace in `toolExecutions`.
|
|
239
|
+
*/
|
|
240
|
+
async chatWithTools(
|
|
241
|
+
messages: LLMChatMessage[],
|
|
242
|
+
options?: ChatOptions & { maxIterations?: number },
|
|
243
|
+
): Promise<LLMChatResponse> {
|
|
244
|
+
const maxIterations = options?.maxIterations ?? 10;
|
|
245
|
+
const conversationMessages = [...messages];
|
|
246
|
+
const allToolExecutions: ToolExecutionResult[] = [];
|
|
247
|
+
let iterations = 0;
|
|
248
|
+
|
|
249
|
+
while (iterations < maxIterations) {
|
|
250
|
+
const response = await this.chat(conversationMessages, {
|
|
251
|
+
...options,
|
|
252
|
+
tools: this.getToolDefinitions(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// If no tool calls, return with full trace
|
|
256
|
+
if (!response.message.tool_calls?.length) {
|
|
257
|
+
return {
|
|
258
|
+
...response,
|
|
259
|
+
toolExecutions: allToolExecutions.length > 0 ? allToolExecutions : undefined,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Add assistant message with tool calls
|
|
264
|
+
conversationMessages.push(response.message);
|
|
265
|
+
|
|
266
|
+
// Execute tools in parallel
|
|
267
|
+
const toolResults = await this.executeTools(response.message.tool_calls);
|
|
268
|
+
allToolExecutions.push(...toolResults);
|
|
269
|
+
|
|
270
|
+
// Add tool results as messages
|
|
271
|
+
for (const result of toolResults) {
|
|
272
|
+
conversationMessages.push({
|
|
273
|
+
role: 'tool',
|
|
274
|
+
content: typeof result.output === 'string'
|
|
275
|
+
? result.output
|
|
276
|
+
: JSON.stringify(result.output),
|
|
277
|
+
tool_call_id: result.tool_call_id,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
iterations++;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Max iterations — final call without tools
|
|
285
|
+
const finalResponse = await this.chat(conversationMessages);
|
|
286
|
+
return {
|
|
287
|
+
...finalResponse,
|
|
288
|
+
toolExecutions: allToolExecutions,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ========================================================================
|
|
293
|
+
// Helpers
|
|
294
|
+
// ========================================================================
|
|
295
|
+
|
|
296
|
+
/** Set the model name at runtime */
|
|
297
|
+
setModel(modelName: string): void {
|
|
298
|
+
this.options.model = modelName;
|
|
299
|
+
this.debugLog(`Model switched to: ${modelName}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Get the current model name */
|
|
303
|
+
get model(): string {
|
|
304
|
+
return this.options.model;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Get the base URL */
|
|
308
|
+
get url(): string {
|
|
309
|
+
return this.options.url;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Set the auditor instance */
|
|
313
|
+
setAuditor(auditor: Auditor): void {
|
|
314
|
+
this.auditor = auditor;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
protected debugLog(message: string, data?: unknown): void {
|
|
318
|
+
if (this.debug) {
|
|
319
|
+
console.log(`[LLM:${this.options.model}] ${message}`, data ?? '');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Generate a unique ID for tool calls when the provider doesn't provide one.
|
|
325
|
+
*/
|
|
326
|
+
protected generateToolCallId(): string {
|
|
327
|
+
return `call_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ========================================================================
|
|
331
|
+
// Structured Output Helpers (shared across all providers)
|
|
332
|
+
// ========================================================================
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Extract schema options from ChatOptions.
|
|
336
|
+
* Returns null if no schema is provided.
|
|
337
|
+
* Returns a SchemaConfig if a schema was found.
|
|
338
|
+
*/
|
|
339
|
+
protected extractSchemaOptions(options?: ChatOptions): (StructuredOutputOptions<unknown> & { schemaConfig: SchemaConfig<unknown> }) | null {
|
|
340
|
+
if (!options) return null;
|
|
341
|
+
|
|
342
|
+
if (options.schema) {
|
|
343
|
+
return {
|
|
344
|
+
schemaConfig: options.schema,
|
|
345
|
+
name: options.schemaName,
|
|
346
|
+
description: options.schemaDescription,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (options.jsonSchema) {
|
|
351
|
+
// Raw JSON Schema without validation
|
|
352
|
+
const config: SchemaConfig<unknown> = {
|
|
353
|
+
jsonSchema: options.jsonSchema,
|
|
354
|
+
};
|
|
355
|
+
return {
|
|
356
|
+
schemaConfig: config,
|
|
357
|
+
name: options.schemaName,
|
|
358
|
+
description: options.schemaDescription,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Validate structured response using a SchemaConfig.
|
|
367
|
+
* Throws StructuredOutputError on failure.
|
|
368
|
+
*/
|
|
369
|
+
protected validateStructuredResponse(content: string, config: SchemaConfig<unknown>): void {
|
|
370
|
+
if (!content) {
|
|
371
|
+
throw new StructuredOutputError(
|
|
372
|
+
'Empty response from LLM',
|
|
373
|
+
{ rawOutput: content },
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let parsed: unknown;
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(content);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
const syntaxError = error instanceof SyntaxError
|
|
382
|
+
? error
|
|
383
|
+
: new SyntaxError(String(error));
|
|
384
|
+
throw new StructuredOutputError(
|
|
385
|
+
`Failed to parse JSON: ${syntaxError.message}`,
|
|
386
|
+
{ rawOutput: content, cause: syntaxError },
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (config.validate) {
|
|
391
|
+
try {
|
|
392
|
+
config.validate(parsed);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
const validationError = error instanceof Error ? error : new Error(String(error));
|
|
395
|
+
throw new StructuredOutputError(
|
|
396
|
+
`Validation failed: ${validationError.message}`,
|
|
397
|
+
{ rawOutput: content, cause: validationError },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|