universal-llm-client 4.5.0 → 4.5.2

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 (174) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -0
  3. package/dist/ai-model.d.ts +0 -1
  4. package/dist/ai-model.js +0 -1
  5. package/dist/auditor.d.ts +0 -1
  6. package/dist/auditor.js +0 -1
  7. package/dist/client.d.ts +0 -1
  8. package/dist/client.js +0 -1
  9. package/dist/gemma-channel.d.ts +0 -1
  10. package/dist/gemma-channel.js +0 -1
  11. package/dist/gemma-diffusion.d.ts +0 -1
  12. package/dist/gemma-diffusion.js +0 -1
  13. package/dist/http.d.ts +0 -1
  14. package/dist/http.js +0 -1
  15. package/dist/index.d.ts +0 -1
  16. package/dist/index.js +0 -1
  17. package/dist/interfaces.d.ts +0 -1
  18. package/dist/interfaces.js +0 -1
  19. package/dist/mcp.d.ts +0 -1
  20. package/dist/mcp.js +0 -1
  21. package/dist/providers/anthropic.d.ts +0 -1
  22. package/dist/providers/anthropic.js +0 -1
  23. package/dist/providers/google.d.ts +0 -1
  24. package/dist/providers/google.js +0 -1
  25. package/dist/providers/index.d.ts +0 -1
  26. package/dist/providers/index.js +0 -1
  27. package/dist/providers/ollama.d.ts +0 -1
  28. package/dist/providers/ollama.js +11 -1
  29. package/dist/providers/openai.d.ts +2 -1
  30. package/dist/providers/openai.js +322 -85
  31. package/dist/router.d.ts +0 -1
  32. package/dist/router.js +0 -1
  33. package/dist/stream-decoder.d.ts +0 -1
  34. package/dist/stream-decoder.js +0 -1
  35. package/dist/structured-output.d.ts +0 -1
  36. package/dist/structured-output.js +0 -1
  37. package/dist/thinking.d.ts +12 -1
  38. package/dist/thinking.js +34 -1
  39. package/dist/tools.d.ts +0 -1
  40. package/dist/tools.js +0 -1
  41. package/dist/zod-adapter.d.ts +0 -1
  42. package/dist/zod-adapter.js +0 -1
  43. package/package.json +1 -2
  44. package/dist/ai-model.d.ts.map +0 -1
  45. package/dist/ai-model.js.map +0 -1
  46. package/dist/auditor.d.ts.map +0 -1
  47. package/dist/auditor.js.map +0 -1
  48. package/dist/client.d.ts.map +0 -1
  49. package/dist/client.js.map +0 -1
  50. package/dist/gemma-channel.d.ts.map +0 -1
  51. package/dist/gemma-channel.js.map +0 -1
  52. package/dist/gemma-diffusion.d.ts.map +0 -1
  53. package/dist/gemma-diffusion.js.map +0 -1
  54. package/dist/http.d.ts.map +0 -1
  55. package/dist/http.js.map +0 -1
  56. package/dist/index.d.ts.map +0 -1
  57. package/dist/index.js.map +0 -1
  58. package/dist/interfaces.d.ts.map +0 -1
  59. package/dist/interfaces.js.map +0 -1
  60. package/dist/mcp.d.ts.map +0 -1
  61. package/dist/mcp.js.map +0 -1
  62. package/dist/providers/anthropic.d.ts.map +0 -1
  63. package/dist/providers/anthropic.js.map +0 -1
  64. package/dist/providers/google.d.ts.map +0 -1
  65. package/dist/providers/google.js.map +0 -1
  66. package/dist/providers/index.d.ts.map +0 -1
  67. package/dist/providers/index.js.map +0 -1
  68. package/dist/providers/ollama.d.ts.map +0 -1
  69. package/dist/providers/ollama.js.map +0 -1
  70. package/dist/providers/openai.d.ts.map +0 -1
  71. package/dist/providers/openai.js.map +0 -1
  72. package/dist/router.d.ts.map +0 -1
  73. package/dist/router.js.map +0 -1
  74. package/dist/stream-decoder.d.ts.map +0 -1
  75. package/dist/stream-decoder.js.map +0 -1
  76. package/dist/structured-output.d.ts.map +0 -1
  77. package/dist/structured-output.js.map +0 -1
  78. package/dist/thinking.d.ts.map +0 -1
  79. package/dist/thinking.js.map +0 -1
  80. package/dist/tools.d.ts.map +0 -1
  81. package/dist/tools.js.map +0 -1
  82. package/dist/zod-adapter.d.ts.map +0 -1
  83. package/dist/zod-adapter.js.map +0 -1
  84. package/src/ai-model.ts +0 -400
  85. package/src/auditor.ts +0 -213
  86. package/src/client.ts +0 -402
  87. package/src/debug/debug-google-streaming.ts +0 -97
  88. package/src/debug/debug-tool-execution.ts +0 -86
  89. package/src/debug/test-lmstudio-tools.ts +0 -155
  90. package/src/demos/README.md +0 -47
  91. package/src/demos/basic/universal-llm-examples.ts +0 -161
  92. package/src/demos/diffusion-gemma/.env +0 -29
  93. package/src/demos/diffusion-gemma/.env.example +0 -27
  94. package/src/demos/diffusion-gemma/CLAUDE.md +0 -95
  95. package/src/demos/diffusion-gemma/README.md +0 -59
  96. package/src/demos/diffusion-gemma/canvas.ts +0 -1606
  97. package/src/demos/diffusion-gemma/docker-compose.yml +0 -29
  98. package/src/demos/diffusion-gemma/probe-stream.ts +0 -51
  99. package/src/demos/diffusion-gemma/probe-tools.ts +0 -55
  100. package/src/demos/diffusion-gemma/server.ts +0 -1205
  101. package/src/demos/diffusion-gemma/start-vllm.sh +0 -98
  102. package/src/demos/mcp/astrid-memory-demo.ts +0 -295
  103. package/src/demos/mcp/astrid-persona-memory.ts +0 -357
  104. package/src/demos/mcp/mcp-mongodb-demo.ts +0 -275
  105. package/src/demos/mcp/simple-astrid-memory.ts +0 -148
  106. package/src/demos/mcp/simple-mcp-demo.ts +0 -68
  107. package/src/demos/mcp/working-mcp-demo.ts +0 -62
  108. package/src/demos/model-alias-demo.ts +0 -0
  109. package/src/demos/tools/RAG_MEMORY_INTEGRATION.md +0 -267
  110. package/src/demos/tools/astrid-memory-demo.ts +0 -270
  111. package/src/demos/tools/astrid-production-memory-clean.ts +0 -785
  112. package/src/demos/tools/astrid-production-memory.ts +0 -558
  113. package/src/demos/tools/basic-translation-test.ts +0 -66
  114. package/src/demos/tools/chromadb-similarity-tuning.ts +0 -390
  115. package/src/demos/tools/clean-multilingual-conversation.ts +0 -209
  116. package/src/demos/tools/clean-translation-test.ts +0 -119
  117. package/src/demos/tools/clean-universal-multilingual-test.ts +0 -131
  118. package/src/demos/tools/complete-rag-demo.ts +0 -369
  119. package/src/demos/tools/complete-tool-demo.ts +0 -132
  120. package/src/demos/tools/demo-tool-calling.ts +0 -124
  121. package/src/demos/tools/dynamic-language-switching-test.ts +0 -251
  122. package/src/demos/tools/hybrid-thinking-test.ts +0 -154
  123. package/src/demos/tools/memory-integration-test.ts +0 -420
  124. package/src/demos/tools/multilingual-memory-system.ts +0 -802
  125. package/src/demos/tools/ondemand-translation-demo.ts +0 -655
  126. package/src/demos/tools/production-tool-demo.ts +0 -245
  127. package/src/demos/tools/revolutionary-multilingual-test.ts +0 -151
  128. package/src/demos/tools/rigorous-language-analysis.ts +0 -218
  129. package/src/demos/tools/test-universal-memory-system.ts +0 -126
  130. package/src/demos/tools/translation-integration-guide.ts +0 -346
  131. package/src/demos/tools/universal-memory-system.ts +0 -560
  132. package/src/gemma-channel.ts +0 -47
  133. package/src/gemma-diffusion.ts +0 -167
  134. package/src/http.ts +0 -261
  135. package/src/index.ts +0 -180
  136. package/src/interfaces.ts +0 -843
  137. package/src/mcp.ts +0 -345
  138. package/src/providers/anthropic.ts +0 -796
  139. package/src/providers/google.ts +0 -840
  140. package/src/providers/index.ts +0 -8
  141. package/src/providers/ollama.ts +0 -503
  142. package/src/providers/openai.ts +0 -587
  143. package/src/router.ts +0 -785
  144. package/src/stream-decoder.ts +0 -535
  145. package/src/structured-output.ts +0 -759
  146. package/src/test-scripts/test-advanced-tools.ts +0 -310
  147. package/src/test-scripts/test-google-deep-research.ts +0 -33
  148. package/src/test-scripts/test-google-streaming-enhanced.ts +0 -147
  149. package/src/test-scripts/test-google-streaming.ts +0 -63
  150. package/src/test-scripts/test-google-system-prompt-comprehensive.ts +0 -189
  151. package/src/test-scripts/test-google-thinking.ts +0 -46
  152. package/src/test-scripts/test-mcp-config.ts +0 -28
  153. package/src/test-scripts/test-mcp-connection.ts +0 -29
  154. package/src/test-scripts/test-system-message-positions.ts +0 -163
  155. package/src/test-scripts/test-system-prompt-improvement-demo.ts +0 -83
  156. package/src/test-scripts/test-tool-calling.ts +0 -231
  157. package/src/test-scripts/test-vllm-qwen36.ts +0 -256
  158. package/src/tests/ai-model.test.ts +0 -1614
  159. package/src/tests/auditor.test.ts +0 -224
  160. package/src/tests/gemma-diffusion.test.ts +0 -115
  161. package/src/tests/http.test.ts +0 -200
  162. package/src/tests/interfaces.test.ts +0 -117
  163. package/src/tests/providers/anthropic.test.ts +0 -118
  164. package/src/tests/providers/google.test.ts +0 -841
  165. package/src/tests/providers/ollama.test.ts +0 -1034
  166. package/src/tests/providers/openai.test.ts +0 -1511
  167. package/src/tests/router.test.ts +0 -254
  168. package/src/tests/stream-decoder.test.ts +0 -263
  169. package/src/tests/structured-output.test.ts +0 -1450
  170. package/src/tests/thinking.test.ts +0 -65
  171. package/src/tests/tools.test.ts +0 -175
  172. package/src/thinking.ts +0 -73
  173. package/src/tools.ts +0 -246
  174. package/src/zod-adapter.ts +0 -72
@@ -5,12 +5,206 @@
5
5
  * Works with: OpenAI, OpenRouter, LM Studio, LlamaCpp, vLLM, Groq, Together.
6
6
  */
7
7
  import { BaseLLMClient } from '../client.js';
8
- import { resolveThinking, isOpenAIReasoningModel } from '../thinking.js';
8
+ import { resolveThinking, isOpenAIReasoningModel, supportsChatTemplateKwargs } from '../thinking.js';
9
9
  import { httpRequest, httpStream, parseSSE, buildHeaders } from '../http.js';
10
- import { StandardChatDecoder } from '../stream-decoder.js';
10
+ import { createDecoder, StandardChatDecoder } from '../stream-decoder.js';
11
11
  import { normalizeJsonSchema, getJsonSchemaFromConfig, } from '../structured-output.js';
12
12
  import { isGemmaDiffusionModel, parseGemmaDiffusionOutput } from '../gemma-diffusion.js';
13
+ const VLLM_AUTO_TOOL_CHOICE_HINT = 'vLLM rejected automatic tool choice. Retrying with text-level tool calling. To use native tool_calls, start vLLM with --enable-auto-tool-choice and --tool-call-parser <parser>.';
14
+ function normalizeMessagesForOpenAICompat(messages) {
15
+ let sawNonSystem = false;
16
+ return messages.map(message => {
17
+ if (message.role !== 'system') {
18
+ sawNonSystem = true;
19
+ return {
20
+ ...message,
21
+ content: message.content ?? '',
22
+ };
23
+ }
24
+ if (!sawNonSystem) {
25
+ return {
26
+ ...message,
27
+ content: message.content ?? '',
28
+ };
29
+ }
30
+ return {
31
+ ...message,
32
+ role: 'user',
33
+ content: `[SYSTEM MESSAGE]\n${stringifyMessageContent(message.content)}`,
34
+ };
35
+ });
36
+ }
37
+ function stringifyMessageContent(content) {
38
+ if (typeof content === 'string')
39
+ return content;
40
+ return content
41
+ .map(part => {
42
+ if (part.type === 'text')
43
+ return part.text;
44
+ if (part.type === 'image_url')
45
+ return `[Image: ${part.image_url.url}]`;
46
+ if (part.type === 'audio')
47
+ return `[Audio: ${part.audio.mimeType}]`;
48
+ return '';
49
+ })
50
+ .filter(Boolean)
51
+ .join('\n');
52
+ }
53
+ function hasToolDefinitions(body) {
54
+ const tools = body['tools'];
55
+ return Array.isArray(tools) && tools.length > 0;
56
+ }
57
+ function isVllmAutoToolChoiceError(value) {
58
+ const text = value instanceof Error
59
+ ? value.message
60
+ : typeof value === 'string'
61
+ ? value
62
+ : JSON.stringify(value ?? '');
63
+ const normalized = text.toLowerCase();
64
+ return (normalized.includes('auto')
65
+ && normalized.includes('tool choice requires --enable-auto-tool-choice')
66
+ && normalized.includes('--tool-call-parser'));
67
+ }
68
+ async function requestWithVllmToolFallback(url, request, tools, onFallback) {
69
+ try {
70
+ return await httpRequest(url, {
71
+ method: 'POST',
72
+ headers: request.headers,
73
+ body: request.body,
74
+ timeout: request.timeout,
75
+ });
76
+ }
77
+ catch (error) {
78
+ if (tools?.length
79
+ && hasToolDefinitions(request.body)
80
+ && isVllmAutoToolChoiceError(error)) {
81
+ onFallback();
82
+ return httpRequest(url, {
83
+ method: 'POST',
84
+ headers: request.headers,
85
+ body: withoutNativeTools(request.body, tools),
86
+ timeout: request.timeout,
87
+ });
88
+ }
89
+ throw error;
90
+ }
91
+ }
92
+ function parseJsonObject(text) {
93
+ try {
94
+ const parsed = JSON.parse(text);
95
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
96
+ return parsed;
97
+ }
98
+ }
99
+ catch {
100
+ /* not JSON */
101
+ }
102
+ return null;
103
+ }
104
+ function parseTextToolCallBody(content) {
105
+ const body = content.trim();
106
+ if (!body)
107
+ return [];
108
+ try {
109
+ const parsed = JSON.parse(body);
110
+ const rawCalls = Array.isArray(parsed) ? parsed : [parsed];
111
+ const calls = [];
112
+ for (const rawCall of rawCalls) {
113
+ if (!rawCall || typeof rawCall !== 'object')
114
+ continue;
115
+ const record = rawCall;
116
+ const name = record['name'];
117
+ if (typeof name !== 'string' || !name)
118
+ continue;
119
+ const args = record['arguments'] ?? record['parameters'] ?? record['args'] ?? {};
120
+ calls.push({
121
+ name,
122
+ arguments: typeof args === 'string' ? JSON.stringify(parseJsonObject(args) ?? {}) : JSON.stringify(args ?? {}),
123
+ });
124
+ }
125
+ if (calls.length > 0)
126
+ return calls;
127
+ }
128
+ catch {
129
+ /* not structured JSON */
130
+ }
131
+ const functionCallMatch = /^([@A-Za-z_][@A-Za-z0-9_.:-]*)\s*\(([\s\S]*)\)\s*$/u.exec(body);
132
+ if (functionCallMatch) {
133
+ const rawArgs = functionCallMatch[2].trim();
134
+ const args = rawArgs ? parseJsonObject(rawArgs) : {};
135
+ if (args) {
136
+ return [{ name: functionCallMatch[1], arguments: JSON.stringify(args) }];
137
+ }
138
+ }
139
+ const calls = [];
140
+ const funcPattern = /<function=([@A-Za-z_][@A-Za-z0-9_.:-]*)>([\s\S]*?)<\/function>/g;
141
+ let fMatch;
142
+ while ((fMatch = funcPattern.exec(body)) !== null) {
143
+ const args = {};
144
+ const paramPattern = /<parameter=([A-Za-z_][A-Za-z0-9_-]*)>([\s\S]*?)<\/parameter>/g;
145
+ let pMatch;
146
+ while ((pMatch = paramPattern.exec(fMatch[2] ?? '')) !== null) {
147
+ args[pMatch[1]] = pMatch[2].trim();
148
+ }
149
+ calls.push({ name: fMatch[1], arguments: JSON.stringify(args) });
150
+ }
151
+ return calls;
152
+ }
153
+ function recoverToolCallsFromText(content, knownToolNames, generateId) {
154
+ if (!content || content.length < 10)
155
+ return null;
156
+ const calls = [];
157
+ let cleanContent = content;
158
+ const isKnownTool = (name) => knownToolNames.has(name);
159
+ const toolCallPattern = /<tool_call>([\s\S]*?)<\/tool_call>/g;
160
+ let tcMatch;
161
+ while ((tcMatch = toolCallPattern.exec(content)) !== null) {
162
+ const parsedCalls = parseTextToolCallBody(tcMatch[1]);
163
+ let matched = false;
164
+ for (const parsed of parsedCalls) {
165
+ if (!isKnownTool(parsed.name))
166
+ continue;
167
+ matched = true;
168
+ calls.push({
169
+ id: generateId(),
170
+ type: 'function',
171
+ function: { name: parsed.name, arguments: parsed.arguments },
172
+ });
173
+ }
174
+ if (matched)
175
+ cleanContent = cleanContent.replace(tcMatch[0], '');
176
+ }
177
+ if (calls.length === 0)
178
+ return null;
179
+ return { calls, cleanContent: cleanContent.trim() };
180
+ }
181
+ function toolFallbackInstruction(tools) {
182
+ const toolLines = tools.map(tool => {
183
+ const fn = tool.function;
184
+ return `- ${fn.name}: ${fn.description}\n parameters JSON schema: ${JSON.stringify(fn.parameters)}`;
185
+ });
186
+ return {
187
+ role: 'system',
188
+ content: 'The server does not support native OpenAI tool parsing for this request. '
189
+ + 'Use this text tool protocol instead.\n\n'
190
+ + 'When you need a tool, respond with exactly one or more tool calls and no prose:\n'
191
+ + '<tool_call>tool_name({"argument":"value"})</tool_call>\n\n'
192
+ + 'After tool results are provided, answer the user normally. Available tools:\n'
193
+ + toolLines.join('\n'),
194
+ };
195
+ }
196
+ function withTextToolFallbackMessages(messages, tools) {
197
+ return [toolFallbackInstruction(tools), ...messages];
198
+ }
199
+ function withoutNativeTools(body, tools) {
200
+ const fallbackBody = { ...body };
201
+ delete fallbackBody['tools'];
202
+ delete fallbackBody['tool_choice'];
203
+ fallbackBody['messages'] = withTextToolFallbackMessages(body['messages'] ?? [], tools);
204
+ return fallbackBody;
205
+ }
13
206
  export class OpenAICompatibleClient extends BaseLLMClient {
207
+ warnedVllmToolFallback = false;
14
208
  /**
15
209
  * DiffusionGemma on trimmed vLLM builds has no server-side reasoning or
16
210
  * tool-call parser — the native channel protocol is handled client-side
@@ -60,13 +254,19 @@ export class OpenAICompatibleClient extends BaseLLMClient {
60
254
  }
61
255
  super({ ...options, url: base }, auditor);
62
256
  }
257
+ warnVllmToolFallback() {
258
+ if (this.warnedVllmToolFallback)
259
+ return;
260
+ this.warnedVllmToolFallback = true;
261
+ console.warn(`[OpenAI] ${VLLM_AUTO_TOOL_CHOICE_HINT}`);
262
+ }
63
263
  // ========================================================================
64
264
  // Chat
65
265
  // ========================================================================
66
266
  async chat(messages, options) {
67
267
  // Structured output and tools can now be used together.\n // The provider sends both response_format and tools in the request.\n // The Router handles skipping validation when the response contains tool calls.
68
268
  const url = this.buildUrl('/chat/completions');
69
- const tools = options?.tools ?? (Object.keys(this.toolRegistry).length > 0 ? this.getToolDefinitions() : undefined);
269
+ const tools = options?.tools;
70
270
  const body = {
71
271
  model: this.options.model,
72
272
  messages: this.convertMessages(messages),
@@ -100,12 +300,11 @@ export class OpenAICompatibleClient extends BaseLLMClient {
100
300
  provider: 'openai',
101
301
  model: this.options.model,
102
302
  });
103
- const response = await httpRequest(url, {
104
- method: 'POST',
303
+ const response = await requestWithVllmToolFallback(url, {
105
304
  headers: buildHeaders(this.options),
106
305
  body,
107
306
  timeout: this.options.timeout ?? 30000,
108
- });
307
+ }, tools, () => this.warnVllmToolFallback());
109
308
  const data = response.data;
110
309
  const choice = data.choices[0];
111
310
  if (!choice) {
@@ -152,6 +351,14 @@ export class OpenAICompatibleClient extends BaseLLMClient {
152
351
  }));
153
352
  }
154
353
  }
354
+ if (!toolCalls?.length && tools?.length && content) {
355
+ const knownToolNames = new Set(tools.map(tool => tool.function.name));
356
+ const recovered = recoverToolCallsFromText(content, knownToolNames, () => this.generateToolCallId());
357
+ if (recovered) {
358
+ toolCalls = recovered.calls;
359
+ content = recovered.cleanContent;
360
+ }
361
+ }
155
362
  const result = {
156
363
  message: {
157
364
  role: 'assistant',
@@ -177,7 +384,7 @@ export class OpenAICompatibleClient extends BaseLLMClient {
177
384
  // ========================================================================
178
385
  async *chatStream(messages, options) {
179
386
  const url = this.buildUrl('/chat/completions');
180
- const tools = options?.tools ?? (Object.keys(this.toolRegistry).length > 0 ? this.getToolDefinitions() : undefined);
387
+ const tools = options?.tools;
181
388
  const body = {
182
389
  model: this.options.model,
183
390
  messages: this.convertMessages(messages),
@@ -202,91 +409,118 @@ export class OpenAICompatibleClient extends BaseLLMClient {
202
409
  provider: 'openai',
203
410
  model: this.options.model,
204
411
  });
205
- // In gemma-native mode the decoder classifies thought-channel content,
206
- // so we yield ITS events (thinking vs text) instead of the raw deltas.
412
+ // In gemma-native mode, or when a caller selects an explicit decoder,
413
+ // the decoder classifies content into typed events instead of exposing
414
+ // raw deltas with protocol tags still attached.
207
415
  const decoderEvents = [];
208
- const decoder = new StandardChatDecoder(this.gemmaNative ? e => decoderEvents.push(e) : () => { });
416
+ const decoderOption = options?.decoder;
417
+ const decoderInstanceProvided = Boolean(decoderOption && typeof decoderOption === 'object');
418
+ const yieldDecoderEvents = !decoderInstanceProvided && (this.gemmaNative || typeof decoderOption === 'string');
419
+ const decoder = typeof decoderOption === 'string'
420
+ ? createDecoder(decoderOption, e => decoderEvents.push(e))
421
+ : decoderInstanceProvided
422
+ ? decoderOption
423
+ : new StandardChatDecoder(this.gemmaNative ? e => decoderEvents.push(e) : () => { });
209
424
  // Track accumulated tool calls across chunks
210
425
  const toolCallAccum = new Map();
211
- const stream = httpStream(url, {
212
- method: 'POST',
213
- headers: buildHeaders(this.options),
214
- body,
215
- timeout: this.options.timeout ?? 120000,
216
- });
426
+ let activeBody = body;
427
+ let retriedWithTextTools = false;
217
428
  let usage;
218
429
  // Accumulates reasoning deltas from servers that stream a dedicated
219
430
  // `reasoning` / `reasoning_content` field (vLLM, DeepSeek-R1, etc.).
220
431
  let reasoningBuffer = '';
221
- for await (const { data } of parseSSE(stream)) {
432
+ while (true) {
433
+ const stream = httpStream(url, {
434
+ method: 'POST',
435
+ headers: buildHeaders(this.options),
436
+ body: activeBody,
437
+ timeout: this.options.timeout ?? 120000,
438
+ });
222
439
  try {
223
- const parsed = JSON.parse(data);
224
- if (parsed.usage) {
225
- usage = {
226
- inputTokens: parsed.usage.prompt_tokens,
227
- outputTokens: parsed.usage.completion_tokens,
228
- totalTokens: parsed.usage.total_tokens,
229
- cachedTokens: parsed.usage.prompt_tokens_details?.cached_tokens,
230
- };
231
- }
232
- const delta = parsed.choices?.[0]?.delta;
233
- if (!delta)
234
- continue;
235
- // Surface server-side reasoning deltas as thinking events.
236
- const reasoningDelta = delta.reasoning ?? delta.reasoning_content;
237
- if (reasoningDelta) {
238
- reasoningBuffer += reasoningDelta;
239
- yield { type: 'thinking', content: reasoningDelta };
240
- }
241
- if (delta.content) {
242
- decoder.push(delta.content);
243
- if (this.gemmaNative) {
244
- while (decoderEvents.length)
245
- yield decoderEvents.shift();
246
- }
247
- else {
248
- yield { type: 'text', content: delta.content };
249
- }
250
- }
251
- // Accumulate streamed tool calls
252
- if (delta.tool_calls) {
253
- for (const tc of delta.tool_calls) {
254
- const existing = toolCallAccum.get(tc.index);
255
- if (!existing) {
256
- toolCallAccum.set(tc.index, {
257
- id: tc.id || this.generateToolCallId(),
258
- type: 'function',
259
- function: {
260
- name: tc.function?.name || '',
261
- arguments: tc.function?.arguments || '',
262
- },
263
- });
440
+ for await (const { data } of parseSSE(stream)) {
441
+ try {
442
+ const parsed = JSON.parse(data);
443
+ if (parsed.usage) {
444
+ usage = {
445
+ inputTokens: parsed.usage.prompt_tokens,
446
+ outputTokens: parsed.usage.completion_tokens,
447
+ totalTokens: parsed.usage.total_tokens,
448
+ cachedTokens: parsed.usage.prompt_tokens_details?.cached_tokens,
449
+ };
264
450
  }
265
- else {
266
- if (tc.function?.arguments) {
267
- existing.function.arguments += tc.function.arguments;
451
+ const delta = parsed.choices?.[0]?.delta;
452
+ if (!delta)
453
+ continue;
454
+ // Surface server-side reasoning deltas as thinking events.
455
+ const reasoningDelta = delta.reasoning ?? delta.reasoning_content;
456
+ if (reasoningDelta) {
457
+ reasoningBuffer += reasoningDelta;
458
+ yield { type: 'thinking', content: reasoningDelta };
459
+ }
460
+ if (delta.content) {
461
+ decoder.push(delta.content);
462
+ if (yieldDecoderEvents) {
463
+ while (decoderEvents.length)
464
+ yield decoderEvents.shift();
465
+ }
466
+ else {
467
+ yield { type: 'text', content: delta.content };
268
468
  }
269
- if (tc.function?.name) {
270
- existing.function.name += tc.function.name;
469
+ }
470
+ // Accumulate streamed tool calls
471
+ if (delta.tool_calls) {
472
+ for (const tc of delta.tool_calls) {
473
+ const existing = toolCallAccum.get(tc.index);
474
+ if (!existing) {
475
+ toolCallAccum.set(tc.index, {
476
+ id: tc.id || this.generateToolCallId(),
477
+ type: 'function',
478
+ function: {
479
+ name: tc.function?.name || '',
480
+ arguments: tc.function?.arguments || '',
481
+ },
482
+ });
483
+ }
484
+ else {
485
+ if (tc.function?.arguments) {
486
+ existing.function.arguments += tc.function.arguments;
487
+ }
488
+ if (tc.function?.name) {
489
+ existing.function.name += tc.function.name;
490
+ }
491
+ }
492
+ }
493
+ }
494
+ // Emit tool calls when stream finishes
495
+ if (parsed.choices?.[0]?.finish_reason === 'tool_calls' || parsed.choices?.[0]?.finish_reason === 'stop') {
496
+ if (toolCallAccum.size > 0) {
497
+ const calls = Array.from(toolCallAccum.values())
498
+ .map(tc => this.normalizeToolCall(tc));
499
+ yield { type: 'tool_call', calls };
271
500
  }
272
501
  }
273
502
  }
274
- }
275
- // Emit tool calls when stream finishes
276
- if (parsed.choices?.[0]?.finish_reason === 'tool_calls' || parsed.choices?.[0]?.finish_reason === 'stop') {
277
- if (toolCallAccum.size > 0) {
278
- const calls = Array.from(toolCallAccum.values())
279
- .map(tc => this.normalizeToolCall(tc));
280
- yield { type: 'tool_call', calls };
503
+ catch {
504
+ // Skip unparseable SSE data
281
505
  }
282
506
  }
507
+ break;
283
508
  }
284
- catch {
285
- // Skip unparseable SSE data
509
+ catch (error) {
510
+ if (!retriedWithTextTools
511
+ && tools?.length
512
+ && hasToolDefinitions(activeBody)
513
+ && isVllmAutoToolChoiceError(error)) {
514
+ this.warnVllmToolFallback();
515
+ activeBody = withoutNativeTools(activeBody, tools);
516
+ retriedWithTextTools = true;
517
+ continue;
518
+ }
519
+ throw error;
286
520
  }
287
521
  }
288
522
  decoder.flush();
289
- if (this.gemmaNative) {
523
+ if (yieldDecoderEvents) {
290
524
  while (decoderEvents.length)
291
525
  yield decoderEvents.shift();
292
526
  }
@@ -332,6 +566,15 @@ export class OpenAICompatibleClient extends BaseLLMClient {
332
566
  yield { type: 'tool_call', calls: finalToolCalls };
333
567
  }
334
568
  }
569
+ if (!finalToolCalls?.length && tools?.length && cleanContent) {
570
+ const knownToolNames = new Set(tools.map(tool => tool.function.name));
571
+ const recovered = recoverToolCallsFromText(cleanContent, knownToolNames, () => this.generateToolCallId());
572
+ if (recovered) {
573
+ finalToolCalls = recovered.calls;
574
+ cleanContent = recovered.cleanContent;
575
+ yield { type: 'tool_call', calls: finalToolCalls };
576
+ }
577
+ }
335
578
  return {
336
579
  message: {
337
580
  role: 'assistant',
@@ -400,12 +643,7 @@ export class OpenAICompatibleClient extends BaseLLMClient {
400
643
  // Internals
401
644
  // ========================================================================
402
645
  convertMessages(messages) {
403
- // OpenAI format is our canonical format, minimal conversion needed
404
- return messages.map(msg => ({
405
- ...msg,
406
- // Ensure content is never null/undefined
407
- content: msg.content ?? '',
408
- }));
646
+ return normalizeMessagesForOpenAICompat(messages);
409
647
  }
410
648
  buildRequestParams(options) {
411
649
  const params = {
@@ -423,16 +661,16 @@ export class OpenAICompatibleClient extends BaseLLMClient {
423
661
  // A user-supplied value (via parameters) always wins.
424
662
  const thinking = resolveThinking(options?.thinking, this.options.thinking);
425
663
  if (thinking) {
426
- const isOfficialOpenAI = (this.options.url ?? '').includes('api.openai.com');
427
664
  if (isOpenAIReasoningModel(this.options.model)) {
428
665
  if (params['reasoning_effort'] === undefined) {
429
666
  params['reasoning_effort'] = thinking.enabled ? (thinking.level ?? 'medium') : 'minimal';
430
667
  }
431
668
  }
432
- else if (!isOfficialOpenAI) {
433
- // `chat_template_kwargs` is a vLLM/Qwen extension. Official OpenAI
434
- // rejects unknown body fields (and gpt-4o has no thinking toggle),
435
- // so only send it to self-hosted / compatible gateways.
669
+ else if (supportsChatTemplateKwargs(this.options.url)) {
670
+ // `chat_template_kwargs` is a self-hosted vLLM/Qwen extension.
671
+ // Official OpenAI and hosted OpenAI-compatible gateways (Cerebras,
672
+ // Groq, Fireworks, …) reject unknown body fields with HTTP 400,
673
+ // so only send it to endpoints not on the strict-host list.
436
674
  const existing = params['chat_template_kwargs'] ?? {};
437
675
  params['chat_template_kwargs'] = { enable_thinking: thinking.enabled, ...existing };
438
676
  }
@@ -478,4 +716,3 @@ export class OpenAICompatibleClient extends BaseLLMClient {
478
716
  };
479
717
  }
480
718
  }
481
- //# sourceMappingURL=openai.js.map
package/dist/router.d.ts CHANGED
@@ -136,4 +136,3 @@ export declare class Router {
136
136
  private recordSuccess;
137
137
  private recordFailure;
138
138
  }
139
- //# sourceMappingURL=router.d.ts.map
package/dist/router.js CHANGED
@@ -580,4 +580,3 @@ export class Router {
580
580
  }
581
581
  }
582
582
  }
583
- //# sourceMappingURL=router.js.map
@@ -148,4 +148,3 @@ export declare function getRegisteredDecoders(): string[];
148
148
  * @throws Error if the decoder type is not registered
149
149
  */
150
150
  export declare function createDecoder(type: DecoderType | string, callback: DecoderCallback, options?: DecoderOptions): StreamDecoder;
151
- //# sourceMappingURL=stream-decoder.d.ts.map
@@ -440,4 +440,3 @@ export function createDecoder(type, callback, options) {
440
440
  }
441
441
  return factory(callback, options);
442
442
  }
443
- //# sourceMappingURL=stream-decoder.js.map
@@ -390,4 +390,3 @@ export interface StreamingStructuredResult<T> {
390
390
  /** The validated partial or complete object */
391
391
  value: T;
392
392
  }
393
- //# sourceMappingURL=structured-output.d.ts.map
@@ -447,4 +447,3 @@ export class StreamingJsonParser {
447
447
  return undefined;
448
448
  }
449
449
  }
450
- //# sourceMappingURL=structured-output.js.map
@@ -23,6 +23,18 @@ export interface ResolvedThinking {
23
23
  export declare function resolveThinking(perCall: boolean | ThinkingLevel | undefined, config: boolean | ThinkingLevel | undefined): ResolvedThinking | undefined;
24
24
  /** Heuristic: OpenAI reasoning models use `reasoning_effort` (o-series, GPT-5). */
25
25
  export declare function isOpenAIReasoningModel(model: string): boolean;
26
+ /**
27
+ * Whether an OpenAI-compatible endpoint accepts the vLLM/SGLang
28
+ * `chat_template_kwargs` extension (used to toggle `enable_thinking`).
29
+ *
30
+ * This is a self-hosted-server feature. Commercial hosted gateways that expose
31
+ * an OpenAI-compatible surface (OpenAI, Cerebras, Groq, Fireworks, Together,
32
+ * Mistral, DeepSeek, OpenRouter, …) reject unknown body fields, so we only send
33
+ * it to endpoints not on the strict list (assumed self-hosted vLLM/Qwen). When
34
+ * the URL is unknown/empty we default to permissive (self-hosted) behavior to
35
+ * preserve the prior contract for local servers.
36
+ */
37
+ export declare function supportsChatTemplateKwargs(url: string | undefined): boolean;
26
38
  /**
27
39
  * Gemini 2.5 `thinkingBudget` for a level. 0 disables, -1 is dynamic, and the
28
40
  * Flash range is 0–24576. A bare `true` (no level) maps to dynamic (-1).
@@ -33,4 +45,3 @@ export declare function geminiThinkingBudget(level: ThinkingLevel | undefined):
33
45
  * API minimum) and < `maxTokens` (the API requires headroom for the answer).
34
46
  */
35
47
  export declare function anthropicThinkingBudget(level: ThinkingLevel | undefined, maxTokens: number): number;
36
- //# sourceMappingURL=thinking.d.ts.map
package/dist/thinking.js CHANGED
@@ -24,6 +24,40 @@ export function resolveThinking(perCall, config) {
24
24
  export function isOpenAIReasoningModel(model) {
25
25
  return /^(o\d|gpt-5)/i.test(model);
26
26
  }
27
+ /**
28
+ * Hosted, OpenAI-*compatible* gateways that validate the request body strictly
29
+ * and reject unknown fields with HTTP 400 — just like official OpenAI. The
30
+ * `chat_template_kwargs` knob (below) is a self-hosted vLLM/SGLang extension and
31
+ * must NOT be sent to these. Matched as case-insensitive host substrings.
32
+ */
33
+ const STRICT_OPENAI_COMPAT_HOSTS = [
34
+ 'api.openai.com',
35
+ 'api.cerebras.ai',
36
+ 'api.groq.com',
37
+ 'api.fireworks.ai',
38
+ 'api.together.xyz',
39
+ 'api.together.ai',
40
+ 'api.mistral.ai',
41
+ 'api.deepseek.com',
42
+ 'api.perplexity.ai',
43
+ 'openrouter.ai',
44
+ 'api.x.ai',
45
+ ];
46
+ /**
47
+ * Whether an OpenAI-compatible endpoint accepts the vLLM/SGLang
48
+ * `chat_template_kwargs` extension (used to toggle `enable_thinking`).
49
+ *
50
+ * This is a self-hosted-server feature. Commercial hosted gateways that expose
51
+ * an OpenAI-compatible surface (OpenAI, Cerebras, Groq, Fireworks, Together,
52
+ * Mistral, DeepSeek, OpenRouter, …) reject unknown body fields, so we only send
53
+ * it to endpoints not on the strict list (assumed self-hosted vLLM/Qwen). When
54
+ * the URL is unknown/empty we default to permissive (self-hosted) behavior to
55
+ * preserve the prior contract for local servers.
56
+ */
57
+ export function supportsChatTemplateKwargs(url) {
58
+ const u = (url ?? '').toLowerCase();
59
+ return !STRICT_OPENAI_COMPAT_HOSTS.some((host) => u.includes(host));
60
+ }
27
61
  /**
28
62
  * Gemini 2.5 `thinkingBudget` for a level. 0 disables, -1 is dynamic, and the
29
63
  * Flash range is 0–24576. A bare `true` (no level) maps to dynamic (-1).
@@ -49,4 +83,3 @@ export function anthropicThinkingBudget(level, maxTokens) {
49
83
  : 2048; // bare `true`
50
84
  return Math.max(1024, Math.min(base, maxTokens - 1024));
51
85
  }
52
- //# sourceMappingURL=thinking.js.map
package/dist/tools.d.ts CHANGED
@@ -75,4 +75,3 @@ export declare function createRandomNumberTool(): {
75
75
  parameters: LLMFunction['parameters'];
76
76
  handler: ToolHandler;
77
77
  };
78
- //# sourceMappingURL=tools.d.ts.map
package/dist/tools.js CHANGED
@@ -204,4 +204,3 @@ export function createRandomNumberTool() {
204
204
  },
205
205
  };
206
206
  }
207
- //# sourceMappingURL=tools.js.map
@@ -41,4 +41,3 @@ export declare function fromZod<T>(schema: z.ZodType<T>, options?: {
41
41
  }): SchemaConfig<T>;
42
42
  export { z } from 'zod';
43
43
  export type { SchemaConfig } from './structured-output.js';
44
- //# sourceMappingURL=zod-adapter.d.ts.map
@@ -58,4 +58,3 @@ export function fromZod(schema, options) {
58
58
  }
59
59
  // Re-export z for convenience (users importing from /zod likely want it)
60
60
  export { z } from 'zod';
61
- //# sourceMappingURL=zod-adapter.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-llm-client",
3
- "version": "4.5.0",
3
+ "version": "4.5.2",
4
4
  "type": "module",
5
5
  "description": "A universal LLM client with transparent provider failover, streaming tool execution, pluggable reasoning, and native observability.",
6
6
  "main": "./dist/index.js",
@@ -41,7 +41,6 @@
41
41
  },
42
42
  "files": [
43
43
  "dist",
44
- "src",
45
44
  "README.md",
46
45
  "CHANGELOG.md",
47
46
  "LICENSE"
@@ -1 +0,0 @@
1
- {"version":3,"file":"ai-model.d.ts","sourceRoot":"","sources":["../src/ai-model.ts"],"names":[],"mappings":"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"}