universal-llm-client 4.3.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.
Files changed (151) hide show
  1. package/CHANGELOG.md +27 -24
  2. package/README.md +60 -11
  3. package/dist/ai-model.d.ts +12 -1
  4. package/dist/ai-model.d.ts.map +1 -1
  5. package/dist/ai-model.js +36 -1
  6. package/dist/ai-model.js.map +1 -1
  7. package/dist/auditor.js.map +1 -1
  8. package/dist/client.js.map +1 -1
  9. package/dist/gemma-channel.d.ts +14 -0
  10. package/dist/gemma-channel.d.ts.map +1 -0
  11. package/dist/gemma-channel.js +38 -0
  12. package/dist/gemma-channel.js.map +1 -0
  13. package/dist/gemma-diffusion.d.ts +49 -0
  14. package/dist/gemma-diffusion.d.ts.map +1 -0
  15. package/dist/gemma-diffusion.js +147 -0
  16. package/dist/gemma-diffusion.js.map +1 -0
  17. package/dist/http.d.ts +4 -0
  18. package/dist/http.d.ts.map +1 -1
  19. package/dist/http.js +14 -1
  20. package/dist/http.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/interfaces.d.ts +163 -7
  26. package/dist/interfaces.d.ts.map +1 -1
  27. package/dist/interfaces.js.map +1 -1
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/providers/anthropic.d.ts.map +1 -1
  30. package/dist/providers/anthropic.js +28 -3
  31. package/dist/providers/anthropic.js.map +1 -1
  32. package/dist/providers/google.d.ts +22 -1
  33. package/dist/providers/google.d.ts.map +1 -1
  34. package/dist/providers/google.js +223 -13
  35. package/dist/providers/google.js.map +1 -1
  36. package/dist/providers/index.js.map +1 -1
  37. package/dist/providers/ollama.d.ts +2 -0
  38. package/dist/providers/ollama.d.ts.map +1 -1
  39. package/dist/providers/ollama.js +59 -30
  40. package/dist/providers/ollama.js.map +1 -1
  41. package/dist/providers/openai.d.ts +14 -0
  42. package/dist/providers/openai.d.ts.map +1 -1
  43. package/dist/providers/openai.js +200 -22
  44. package/dist/providers/openai.js.map +1 -1
  45. package/dist/router.d.ts +2 -0
  46. package/dist/router.d.ts.map +1 -1
  47. package/dist/router.js +4 -0
  48. package/dist/router.js.map +1 -1
  49. package/dist/stream-decoder.d.ts +12 -0
  50. package/dist/stream-decoder.d.ts.map +1 -1
  51. package/dist/stream-decoder.js +182 -5
  52. package/dist/stream-decoder.js.map +1 -1
  53. package/dist/structured-output.js.map +1 -1
  54. package/dist/thinking.d.ts +36 -0
  55. package/dist/thinking.d.ts.map +1 -0
  56. package/dist/thinking.js +52 -0
  57. package/dist/thinking.js.map +1 -0
  58. package/dist/tools.js.map +1 -1
  59. package/dist/zod-adapter.js.map +1 -1
  60. package/package.json +4 -1
  61. package/src/ai-model.ts +400 -0
  62. package/src/auditor.ts +213 -0
  63. package/src/client.ts +402 -0
  64. package/src/debug/debug-google-streaming.ts +97 -0
  65. package/src/debug/debug-tool-execution.ts +86 -0
  66. package/src/debug/test-lmstudio-tools.ts +155 -0
  67. package/src/demos/README.md +47 -0
  68. package/src/demos/basic/universal-llm-examples.ts +161 -0
  69. package/src/demos/diffusion-gemma/.env +29 -0
  70. package/src/demos/diffusion-gemma/.env.example +27 -0
  71. package/src/demos/diffusion-gemma/CLAUDE.md +95 -0
  72. package/src/demos/diffusion-gemma/README.md +59 -0
  73. package/src/demos/diffusion-gemma/canvas.ts +1606 -0
  74. package/src/demos/diffusion-gemma/docker-compose.yml +29 -0
  75. package/src/demos/diffusion-gemma/probe-stream.ts +51 -0
  76. package/src/demos/diffusion-gemma/probe-tools.ts +55 -0
  77. package/src/demos/diffusion-gemma/server.ts +1205 -0
  78. package/src/demos/diffusion-gemma/start-vllm.sh +98 -0
  79. package/src/demos/mcp/astrid-memory-demo.ts +295 -0
  80. package/src/demos/mcp/astrid-persona-memory.ts +357 -0
  81. package/src/demos/mcp/mcp-mongodb-demo.ts +275 -0
  82. package/src/demos/mcp/simple-astrid-memory.ts +148 -0
  83. package/src/demos/mcp/simple-mcp-demo.ts +68 -0
  84. package/src/demos/mcp/working-mcp-demo.ts +62 -0
  85. package/src/demos/model-alias-demo.ts +0 -0
  86. package/src/demos/tools/RAG_MEMORY_INTEGRATION.md +267 -0
  87. package/src/demos/tools/astrid-memory-demo.ts +270 -0
  88. package/src/demos/tools/astrid-production-memory-clean.ts +785 -0
  89. package/src/demos/tools/astrid-production-memory.ts +558 -0
  90. package/src/demos/tools/basic-translation-test.ts +66 -0
  91. package/src/demos/tools/chromadb-similarity-tuning.ts +390 -0
  92. package/src/demos/tools/clean-multilingual-conversation.ts +209 -0
  93. package/src/demos/tools/clean-translation-test.ts +119 -0
  94. package/src/demos/tools/clean-universal-multilingual-test.ts +131 -0
  95. package/src/demos/tools/complete-rag-demo.ts +369 -0
  96. package/src/demos/tools/complete-tool-demo.ts +132 -0
  97. package/src/demos/tools/demo-tool-calling.ts +124 -0
  98. package/src/demos/tools/dynamic-language-switching-test.ts +251 -0
  99. package/src/demos/tools/hybrid-thinking-test.ts +154 -0
  100. package/src/demos/tools/memory-integration-test.ts +420 -0
  101. package/src/demos/tools/multilingual-memory-system.ts +802 -0
  102. package/src/demos/tools/ondemand-translation-demo.ts +655 -0
  103. package/src/demos/tools/production-tool-demo.ts +245 -0
  104. package/src/demos/tools/revolutionary-multilingual-test.ts +151 -0
  105. package/src/demos/tools/rigorous-language-analysis.ts +218 -0
  106. package/src/demos/tools/test-universal-memory-system.ts +126 -0
  107. package/src/demos/tools/translation-integration-guide.ts +346 -0
  108. package/src/demos/tools/universal-memory-system.ts +560 -0
  109. package/src/gemma-channel.ts +47 -0
  110. package/src/gemma-diffusion.ts +167 -0
  111. package/src/http.ts +261 -0
  112. package/src/index.ts +180 -0
  113. package/src/interfaces.ts +843 -0
  114. package/src/mcp.ts +345 -0
  115. package/src/providers/anthropic.ts +796 -0
  116. package/src/providers/google.ts +840 -0
  117. package/src/providers/index.ts +8 -0
  118. package/src/providers/ollama.ts +503 -0
  119. package/src/providers/openai.ts +587 -0
  120. package/src/router.ts +785 -0
  121. package/src/stream-decoder.ts +535 -0
  122. package/src/structured-output.ts +759 -0
  123. package/src/test-scripts/test-advanced-tools.ts +310 -0
  124. package/src/test-scripts/test-google-deep-research.ts +33 -0
  125. package/src/test-scripts/test-google-streaming-enhanced.ts +147 -0
  126. package/src/test-scripts/test-google-streaming.ts +63 -0
  127. package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -0
  128. package/src/test-scripts/test-google-thinking.ts +46 -0
  129. package/src/test-scripts/test-mcp-config.ts +28 -0
  130. package/src/test-scripts/test-mcp-connection.ts +29 -0
  131. package/src/test-scripts/test-system-message-positions.ts +163 -0
  132. package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -0
  133. package/src/test-scripts/test-tool-calling.ts +231 -0
  134. package/src/test-scripts/test-vllm-qwen36.ts +256 -0
  135. package/src/tests/ai-model.test.ts +1614 -0
  136. package/src/tests/auditor.test.ts +224 -0
  137. package/src/tests/gemma-diffusion.test.ts +115 -0
  138. package/src/tests/http.test.ts +200 -0
  139. package/src/tests/interfaces.test.ts +117 -0
  140. package/src/tests/providers/anthropic.test.ts +118 -0
  141. package/src/tests/providers/google.test.ts +841 -0
  142. package/src/tests/providers/ollama.test.ts +1034 -0
  143. package/src/tests/providers/openai.test.ts +1511 -0
  144. package/src/tests/router.test.ts +254 -0
  145. package/src/tests/stream-decoder.test.ts +263 -0
  146. package/src/tests/structured-output.test.ts +1450 -0
  147. package/src/tests/thinking.test.ts +65 -0
  148. package/src/tests/tools.test.ts +175 -0
  149. package/src/thinking.ts +73 -0
  150. package/src/tools.ts +246 -0
  151. package/src/zod-adapter.ts +72 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Test tool calling functionality with free local models
3
+ */
4
+
5
+ import { AIModelFactory, ToolBuilder } from '../index';
6
+
7
+ async function testToolCallingLocal() {
8
+ console.log('🛠️ Testing Universal LLM Client Tool Calling with Local Models\n');
9
+
10
+ // Create models for testing (using models with good tool calling support)
11
+ const models = {
12
+ // Test with Ollama - qwen3:8b has excellent tool calling support
13
+ ollama: AIModelFactory.createOllamaChatModelWithTools('qwen3:8b'),
14
+ // Test with LM Studio - qwen/qwen3-8b for tool calling
15
+ lmstudio: AIModelFactory.createOpenAIChatModelWithTools('qwen/qwen3-8b', 'http://localhost:1234/v1')
16
+ };
17
+
18
+ console.log('📋 Test 1: Basic Calculator Tool\n');
19
+
20
+ for (const [provider, model] of Object.entries(models)) {
21
+ console.log(`\n🔧 Testing ${provider} (checking if server is available):`);
22
+
23
+ try {
24
+ await model.ensureReady();
25
+
26
+ const response = await model.chat([
27
+ { role: 'user', content: 'What is 25 * 4 + 10? Please use the calculator tool to solve this mathematically.' }
28
+ ], {}, {
29
+ tool_choice: 'auto'
30
+ });
31
+
32
+ console.log(`Response: ${response.content}`);
33
+
34
+ if (response.tool_calls) {
35
+ console.log(`🔨 Tool calls made:`, response.tool_calls.length);
36
+ for (const toolCall of response.tool_calls) {
37
+ console.log(` - ${toolCall.function.name}: ${toolCall.function.arguments}`);
38
+ }
39
+ } else {
40
+ console.log('ℹ️ No tool calls made - model may have calculated directly');
41
+ }
42
+ } catch (error) {
43
+ if ((error as Error).message.includes('ECONNREFUSED') || (error as Error).message.includes('fetch failed')) {
44
+ console.error(`❌ ${provider} server not running - skipping tests for this provider`);
45
+ } else {
46
+ console.error(`❌ Error with ${provider}:`, (error as Error).message);
47
+ }
48
+ }
49
+ }
50
+
51
+ // Test automatic tool execution with calculator
52
+ console.log('\n\n📋 Test 2: Automatic Tool Execution\n');
53
+
54
+ const ollamaModel = models.ollama;
55
+
56
+ try {
57
+ console.log('🔧 Testing automatic tool execution with Ollama:');
58
+
59
+ const response = await ollamaModel.chatWithTools([
60
+ {
61
+ role: 'user',
62
+ content: 'Calculate 15 * 8 + 32, then tell me what time it is right now'
63
+ }
64
+ ]);
65
+
66
+ console.log('✅ Final response:', response.content);
67
+ } catch (error) {
68
+ console.error('❌ Error:', (error as Error).message);
69
+ }
70
+
71
+ // Test multiple tools
72
+ console.log('\n\n📋 Test 3: Multiple Tool Usage\n');
73
+
74
+ try {
75
+ console.log('🔧 Testing multiple tools with Ollama:');
76
+
77
+ // Register additional useful tools
78
+ const randomNumberTool = ToolBuilder.createTool<{ min: number; max: number; count?: number }>(
79
+ 'generate_random_numbers',
80
+ 'Generate random numbers within a range',
81
+ {
82
+ properties: {
83
+ min: { type: 'number', description: 'Minimum value' },
84
+ max: { type: 'number', description: 'Maximum value' },
85
+ count: { type: 'number', description: 'How many numbers to generate', default: 1 }
86
+ },
87
+ required: ['min', 'max']
88
+ },
89
+ (args) => {
90
+ const count = args.count || 1;
91
+ const numbers: any[] = [];
92
+ for (let i = 0; i < count; i++) {
93
+ numbers.push(Math.floor(Math.random() * (args.max - args.min + 1)) + args.min);
94
+ }
95
+ return { numbers, count: numbers.length };
96
+ }
97
+ );
98
+
99
+ const textTool = ToolBuilder.createTool<{ text: string; operation: 'uppercase' | 'lowercase' | 'reverse' }>(
100
+ 'text_transform',
101
+ 'Transform text in various ways',
102
+ {
103
+ properties: {
104
+ text: { type: 'string', description: 'Text to transform' },
105
+ operation: {
106
+ type: 'string',
107
+ enum: ['uppercase', 'lowercase', 'reverse'],
108
+ description: 'Type of transformation'
109
+ }
110
+ },
111
+ required: ['text', 'operation']
112
+ },
113
+ (args) => {
114
+ let result = args.text;
115
+ switch (args.operation) {
116
+ case 'uppercase':
117
+ result = args.text.toUpperCase();
118
+ break;
119
+ case 'lowercase':
120
+ result = args.text.toLowerCase();
121
+ break;
122
+ case 'reverse':
123
+ result = args.text.split('').reverse().join('');
124
+ break;
125
+ }
126
+ return { original: args.text, transformed: result, operation: args.operation };
127
+ }
128
+ );
129
+
130
+ ollamaModel.registerTools([randomNumberTool, textTool]);
131
+
132
+ const response = await ollamaModel.chatWithTools([
133
+ {
134
+ role: 'user',
135
+ content: 'First calculate 100 / 4, then generate 3 random numbers between 1 and 10, and finally convert the text "hello world" to uppercase'
136
+ }
137
+ ]);
138
+
139
+ console.log('✅ Final response:', response.content);
140
+ } catch (error) {
141
+ console.error('❌ Error:', (error as Error).message);
142
+ }
143
+
144
+ // Test custom tool
145
+ console.log('\n\n📋 Test 4: Custom Tool Registration\n');
146
+
147
+ try {
148
+ // Register a custom tool
149
+ const customTool = ToolBuilder.createTool<{ city: string; country?: string }>(
150
+ 'get_city_info',
151
+ 'Get information about a city',
152
+ {
153
+ properties: {
154
+ city: { type: 'string', description: 'Name of the city' },
155
+ country: { type: 'string', description: 'Country the city is in' }
156
+ },
157
+ required: ['city']
158
+ },
159
+ (args) => ({
160
+ city: args.city,
161
+ country: args.country || 'Unknown',
162
+ population: Math.floor(Math.random() * 10000000) + 100000,
163
+ weather: 'Sunny',
164
+ timezone: 'UTC+0',
165
+ founded: Math.floor(Math.random() * 2000) + 1
166
+ })
167
+ );
168
+
169
+ ollamaModel.registerTool(
170
+ customTool.name,
171
+ customTool.description,
172
+ customTool.parameters,
173
+ customTool.handler
174
+ );
175
+
176
+ console.log('🔧 Testing custom tool:');
177
+
178
+ const response = await ollamaModel.chatWithTools([
179
+ {
180
+ role: 'user',
181
+ content: 'Can you get information about Paris, France?'
182
+ }
183
+ ]);
184
+
185
+ console.log('✅ Custom tool response:', response.content);
186
+ } catch (error) {
187
+ console.error('❌ Error:', (error as Error).message);
188
+ }
189
+
190
+ // Test tool choice options
191
+ console.log('\n\n📋 Test 5: Tool Choice Control\n');
192
+
193
+ try {
194
+ console.log('🔧 Testing forced tool usage:');
195
+
196
+ const response = await ollamaModel.chat([
197
+ { role: 'user', content: 'Tell me about the weather today' }
198
+ ], {}, {
199
+ tool_choice: { type: 'function', function: { name: 'get_current_time' } }
200
+ });
201
+
202
+ console.log('✅ Forced tool response:', response.content);
203
+ if (response.tool_calls) {
204
+ console.log('🔨 Tool used:', response.tool_calls[0]?.function.name);
205
+ }
206
+ } catch (error) {
207
+ console.error('❌ Error:', (error as Error).message);
208
+ }
209
+
210
+ // Clean up
211
+ Object.values(models).forEach(model => model.dispose());
212
+
213
+ console.log('\n✅ Tool calling tests completed!');
214
+ }
215
+
216
+ // Add error handling for the main test
217
+ async function runTests() {
218
+ try {
219
+ await testToolCallingLocal();
220
+ } catch (error) {
221
+ console.error('💥 Test suite failed:', (error as Error).message);
222
+ console.error(error);
223
+ }
224
+ }
225
+
226
+ // Run the tests
227
+ if (require.main === module) {
228
+ runTests();
229
+ }
230
+
231
+ export { testToolCallingLocal };
@@ -0,0 +1,256 @@
1
+ /**
2
+ * vLLM + Qwen3.6 (NVFP4) compatibility test for universal-llm-client.
3
+ *
4
+ * Exercises the OpenAI-compatible provider against a local vLLM server running
5
+ * nvidia/Qwen3.6-35B-A3B-NVFP4, with special attention to REASONING handling.
6
+ *
7
+ * Run (server must be up on :8000):
8
+ * bun run src/test-scripts/test-vllm-qwen36.ts
9
+ *
10
+ * Env overrides:
11
+ * VLLM_URL (default http://localhost:8000)
12
+ * VLLM_MODEL (default qwen3.6-nvfp4)
13
+ */
14
+
15
+ import { AIModel } from '../index.js';
16
+ import type { DecodedEvent } from '../stream-decoder.js';
17
+ import type { LLMChatResponse } from '../interfaces.js';
18
+
19
+ const URL = process.env.VLLM_URL ?? 'http://localhost:8000';
20
+ const MODEL = process.env.VLLM_MODEL ?? 'qwen3.6-nvfp4';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // tiny test harness
24
+ // ---------------------------------------------------------------------------
25
+ type Status = 'PASS' | 'FAIL' | 'PARTIAL';
26
+ const results: { name: string; status: Status; note: string }[] = [];
27
+ function record(name: string, status: Status, note = '') {
28
+ results.push({ name, status, note });
29
+ const icon = status === 'PASS' ? '✅' : status === 'PARTIAL' ? '🟡' : '❌';
30
+ console.log(`\n${icon} ${name} — ${status}${note ? `\n ${note}` : ''}`);
31
+ }
32
+ function section(title: string) {
33
+ console.log(`\n${'━'.repeat(70)}\n${title}\n${'━'.repeat(70)}`);
34
+ }
35
+
36
+ /** Drain a chatStream generator, collecting events AND the final return value. */
37
+ async function drainStream(
38
+ gen: AsyncGenerator<DecodedEvent, LLMChatResponse | void, unknown>,
39
+ ): Promise<{ events: DecodedEvent[]; result: LLMChatResponse | void }> {
40
+ const events: DecodedEvent[] = [];
41
+ let result: LLMChatResponse | void;
42
+ while (true) {
43
+ const { value, done } = await gen.next();
44
+ if (done) { result = value as LLMChatResponse | void; break; }
45
+ events.push(value);
46
+ }
47
+ return { events, result };
48
+ }
49
+
50
+ /** Raw OpenAI call straight to vLLM — ground truth for what the server emits. */
51
+ async function rawChat(body: Record<string, unknown>): Promise<any> {
52
+ const res = await fetch(`${URL}/v1/chat/completions`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ model: MODEL, ...body }),
56
+ });
57
+ return res.json();
58
+ }
59
+
60
+ const REASON_PROMPT =
61
+ 'A farmer has 17 sheep. All but 9 run away. Then he buys 5 more. How many sheep does he have? Think it through, then give the number.';
62
+
63
+ // ---------------------------------------------------------------------------
64
+
65
+ async function main() {
66
+ console.log(`vLLM compatibility test\n url = ${URL}\n model = ${MODEL}`);
67
+
68
+ const model = new AIModel({
69
+ model: MODEL,
70
+ thinking: true, // intent flag (no-op for the openai provider, but documents intent)
71
+ timeout: 120_000,
72
+ providers: [{ type: 'openai', url: URL, apiKey: 'EMPTY' }],
73
+ });
74
+
75
+ // ----- 1. Connectivity / model discovery --------------------------------
76
+ section('1. Connectivity & model discovery');
77
+ try {
78
+ const models = await model.getModels();
79
+ console.log(' /v1/models ->', models);
80
+ if (models.includes(MODEL)) record('Model discovery', 'PASS', `served model "${MODEL}" is listed`);
81
+ else record('Model discovery', 'PARTIAL', `server reachable but "${MODEL}" not in ${JSON.stringify(models)}`);
82
+ } catch (e) {
83
+ record('Model discovery', 'FAIL', `cannot reach server: ${(e as Error).message}`);
84
+ console.log('\nAborting — server unreachable.');
85
+ printSummary();
86
+ return;
87
+ }
88
+
89
+ // ----- 2. Basic chat (non-streaming) ------------------------------------
90
+ section('2. Basic chat (non-streaming)');
91
+ try {
92
+ const r = await model.chat(
93
+ [{ role: 'user', content: 'In one short sentence, what is the capital of Japan?' }],
94
+ { temperature: 0, maxTokens: 256 },
95
+ );
96
+ const content = r.message.content?.trim() ?? '';
97
+ console.log(' content :', JSON.stringify(content));
98
+ console.log(' usage :', JSON.stringify(r.usage));
99
+ if (r.usage?.tokensPerSecond) {
100
+ console.log(` stats : ${r.usage.tokensPerSecond.toFixed(1)} tok/s over ${r.usage.durationMs}ms wall-clock`);
101
+ }
102
+ if (content.toLowerCase().includes('tokyo')) record('Basic chat', 'PASS', 'correct, clean answer (reasoning stripped server-side)');
103
+ else if (content.length > 0) record('Basic chat', 'PARTIAL', 'got content but expected "Tokyo"');
104
+ else record('Basic chat', 'FAIL', 'empty content (model spent budget reasoning — see reasoning section)');
105
+ } catch (e) {
106
+ record('Basic chat', 'FAIL', (e as Error).message);
107
+ }
108
+
109
+ // ----- 3. Streaming -----------------------------------------------------
110
+ section('3. Streaming (chatStream)');
111
+ try {
112
+ const { events, result } = await drainStream(
113
+ model.chatStream(
114
+ [{ role: 'user', content: 'List three colors, comma separated.' }],
115
+ // Generous budget: Qwen3.6 thinks first, so a small cap is spent
116
+ // entirely on reasoning before any answer tokens are produced.
117
+ { temperature: 0, maxTokens: 1024 },
118
+ ),
119
+ );
120
+ const textEvents = events.filter(e => e.type === 'text').length;
121
+ const thinkingEvents = events.filter(e => e.type === 'thinking').length;
122
+ const finalContent = (result && 'message' in result ? result.message.content : '')?.trim() ?? '';
123
+ console.log(` events: ${events.length} (text=${textEvents}, thinking=${thinkingEvents})`);
124
+ console.log(' final content:', JSON.stringify(finalContent));
125
+ if (textEvents > 0 && finalContent.length > 0) record('Streaming', 'PASS', `${textEvents} text deltas streamed, final content assembled`);
126
+ else record('Streaming', 'PARTIAL', 'stream completed but content was empty');
127
+ } catch (e) {
128
+ record('Streaming', 'FAIL', (e as Error).message);
129
+ }
130
+
131
+ // ----- 4. REASONING (the focus) -----------------------------------------
132
+ section('4. Reasoning exposure (Qwen3.6 thinking)');
133
+ try {
134
+ // 4a. Ground truth: what does vLLM actually send?
135
+ const raw = await rawChat({
136
+ messages: [{ role: 'user', content: REASON_PROMPT }],
137
+ max_tokens: 800,
138
+ temperature: 0,
139
+ });
140
+ const rawMsg = raw?.choices?.[0]?.message ?? {};
141
+ const serverReasoning: string = rawMsg.reasoning ?? rawMsg.reasoning_content ?? '';
142
+ const serverContent: string = rawMsg.content ?? '';
143
+ console.log(` [server raw] reasoning field: ${serverReasoning.length} chars; content field: ${serverContent.length} chars`);
144
+ if (serverReasoning) console.log(` [server raw] reasoning preview: ${JSON.stringify(serverReasoning.slice(0, 120))}…`);
145
+ console.log(` [server raw] content : ${JSON.stringify(serverContent.slice(0, 120))}`);
146
+
147
+ // 4b. What the client surfaces (non-streaming)
148
+ const r = await model.chat([{ role: 'user', content: REASON_PROMPT }], { temperature: 0, maxTokens: 800 });
149
+ const clientReasoning = r.reasoning ?? '';
150
+ const clientContent = r.message.content ?? '';
151
+ console.log(` [client chat] .reasoning: ${clientReasoning.length} chars; .content: ${clientContent.length} chars`);
152
+
153
+ // 4c. What the client surfaces (streaming — uses StandardChatDecoder <think> parser)
154
+ const { result } = await drainStream(model.chatStream([{ role: 'user', content: REASON_PROMPT }], { temperature: 0, maxTokens: 800 }));
155
+ const streamReasoning = (result && 'reasoning' in result ? result.reasoning : '') ?? '';
156
+ console.log(` [client stream] .reasoning: ${streamReasoning.length} chars`);
157
+
158
+ const serverHasReasoning = serverReasoning.length > 50;
159
+ const clientExposes = clientReasoning.length > 0 || streamReasoning.length > 0;
160
+ const contentClean = !clientContent.includes('<think>');
161
+
162
+ if (serverHasReasoning && clientExposes) {
163
+ record('Reasoning exposure', 'PASS', 'client surfaces the reasoning trace via .reasoning');
164
+ } else if (serverHasReasoning && !clientExposes && contentClean) {
165
+ record('Reasoning exposure', 'PARTIAL',
166
+ 'Server emits reasoning in a separate `reasoning` field; client returns CLEAN answers but does NOT expose the trace ' +
167
+ '(the openai provider reads `content`/`delta.content` only, never `reasoning`/`reasoning_content`). ' +
168
+ 'Fix: read `message.reasoning`/`delta.reasoning` in providers/openai.ts.');
169
+ } else if (clientContent.includes('<think>')) {
170
+ record('Reasoning exposure', 'PARTIAL', 'Reasoning leaks into content as <think> tags (run server WITHOUT --reasoning-parser, then streaming separates it).');
171
+ } else {
172
+ record('Reasoning exposure', 'FAIL', 'No reasoning surfaced anywhere.');
173
+ }
174
+ } catch (e) {
175
+ record('Reasoning exposure', 'FAIL', (e as Error).message);
176
+ }
177
+
178
+ // ----- 5. Tool calling --------------------------------------------------
179
+ section('5. Tool calling (chatWithTools)');
180
+ try {
181
+ let toolHit = false;
182
+ model.registerTool(
183
+ 'multiply',
184
+ 'Multiply two integers and return the product',
185
+ { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] },
186
+ async (args: any) => { toolHit = true; return { product: args.a * args.b }; },
187
+ );
188
+ const r = await model.chatWithTools(
189
+ [{ role: 'user', content: 'Use the multiply tool to compute 17 times 23, then state the result.' }],
190
+ { temperature: 0, maxTokens: 1024, maxIterations: 3 },
191
+ );
192
+ const trace = r.toolExecutions ?? [];
193
+ const content = r.message.content ?? '';
194
+ console.log(' toolExecutions:', JSON.stringify(trace));
195
+ console.log(' content :', JSON.stringify(content.slice(0, 160)));
196
+ if (toolHit && content.includes('391')) record('Tool calling', 'PASS', `tool executed (${trace.length} trace entr${trace.length === 1 ? 'y' : 'ies'}), answer 391 returned`);
197
+ else if (toolHit) record('Tool calling', 'PARTIAL', 'tool fired but final answer missing 391');
198
+ else record('Tool calling', 'PARTIAL', 'tool NOT invoked — vLLM likely needs `--enable-auto-tool-choice --tool-call-parser hermes`');
199
+ } catch (e) {
200
+ record('Tool calling', 'FAIL', (e as Error).message);
201
+ }
202
+
203
+ // ----- 6. Structured output (JSON schema / guided decoding) --------------
204
+ section('6. Structured output (response_format json_schema)');
205
+ try {
206
+ const r = await model.chat(
207
+ [{ role: 'user', content: 'Give the capital and population (millions, integer) of France.' }],
208
+ {
209
+ temperature: 0,
210
+ maxTokens: 1024,
211
+ // Unified thinking flag — now wired through the openai provider to
212
+ // vLLM's chat_template_kwargs.enable_thinking. Disable thinking so
213
+ // guided decoding emits the object directly.
214
+ thinking: false,
215
+ jsonSchema: {
216
+ type: 'object',
217
+ properties: { capital: { type: 'string' }, population_millions: { type: 'number' } },
218
+ required: ['capital', 'population_millions'],
219
+ additionalProperties: false,
220
+ },
221
+ name: 'CountryFact',
222
+ } as any,
223
+ );
224
+ const content = r.message.content ?? '';
225
+ const structured = (r as any).structured;
226
+ console.log(' content :', JSON.stringify(content.slice(0, 200)));
227
+ console.log(' structured:', JSON.stringify(structured));
228
+ let parsed: any = structured;
229
+ if (!parsed) { try { parsed = JSON.parse(content); } catch { /* ignore */ } }
230
+ if (parsed && typeof parsed.capital === 'string' && typeof parsed.population_millions === 'number') {
231
+ record('Structured output', 'PASS', `valid JSON: capital=${parsed.capital}`);
232
+ } else if (parsed) {
233
+ record('Structured output', 'PARTIAL', 'JSON parsed but schema fields missing/mistyped');
234
+ } else {
235
+ record('Structured output', 'FAIL', 'response was not valid JSON');
236
+ }
237
+ } catch (e) {
238
+ record('Structured output', 'FAIL', (e as Error).message);
239
+ }
240
+
241
+ await model.dispose();
242
+ printSummary();
243
+ }
244
+
245
+ function printSummary() {
246
+ section('SUMMARY');
247
+ const pad = Math.max(...results.map(r => r.name.length));
248
+ for (const r of results) {
249
+ const icon = r.status === 'PASS' ? '✅' : r.status === 'PARTIAL' ? '🟡' : '❌';
250
+ console.log(`${icon} ${r.name.padEnd(pad)} ${r.status}`);
251
+ }
252
+ const pass = results.filter(r => r.status === 'PASS').length;
253
+ console.log(`\n${pass}/${results.length} PASS`);
254
+ }
255
+
256
+ main().catch(e => { console.error('FATAL', e); process.exit(1); });