universal-llm-client 4.5.1 → 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.
- package/CHANGELOG.md +6 -0
- package/dist/providers/ollama.js +11 -0
- package/dist/providers/openai.js +20 -12
- package/dist/thinking.d.ts +12 -0
- package/dist/thinking.js +34 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [4.5.2] - 2026-06-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`chat_template_kwargs` no longer sent to hosted OpenAI-compatible gateways** — the unified `thinking` flag mapped to vLLM's `chat_template_kwargs.enable_thinking` for *every* OpenAI-compatible endpoint except official `api.openai.com`. Hosted gateways that expose an OpenAI-compatible surface but validate the request body strictly — **Cerebras**, Groq, Fireworks, Together, Mistral, DeepSeek, OpenRouter, xAI — reject the unknown field with `HTTP 400 ("wrong_api_format")`, which broke every call that set `thinking` (including `thinking: false`). The OpenAI-compatible provider now gates this vLLM/SGLang extension behind a `supportsChatTemplateKwargs(url)` strict-host check (`src/thinking.ts`), so it is only sent to endpoints not on the strict-host list (i.e. self-hosted vLLM/Qwen). Self-hosted servers (localhost / custom hosts) are unaffected. Verified live against Cerebras (`gemma-4-31b`).
|
|
13
|
+
|
|
8
14
|
## [4.5.1] - 2026-06-14
|
|
9
15
|
|
|
10
16
|
### Added
|
package/dist/providers/ollama.js
CHANGED
|
@@ -120,6 +120,17 @@ export class OllamaClient extends BaseLLMClient {
|
|
|
120
120
|
}
|
|
121
121
|
// Ollama `think` is on/off (no levels); default on for thinking models.
|
|
122
122
|
body['think'] = resolveThinking(options?.thinking, this.options.thinking)?.enabled ?? true;
|
|
123
|
+
// Handle structured output via format parameter — same as chat(). Without
|
|
124
|
+
// this, streaming structured output is unconstrained and the model can emit
|
|
125
|
+
// malformed JSON that only fails at the final parse.
|
|
126
|
+
const schemaOptions = this.extractSchemaOptions(options);
|
|
127
|
+
if (schemaOptions) {
|
|
128
|
+
body['format'] = this.buildFormatParameter(schemaOptions);
|
|
129
|
+
}
|
|
130
|
+
else if (options?.responseFormat) {
|
|
131
|
+
// Legacy json_object mode - map to Ollama's "json" format
|
|
132
|
+
body['format'] = 'json';
|
|
133
|
+
}
|
|
123
134
|
const start = Date.now();
|
|
124
135
|
this.auditor.record({
|
|
125
136
|
timestamp: start,
|
package/dist/providers/openai.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
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
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>.';
|
|
@@ -409,10 +409,18 @@ export class OpenAICompatibleClient extends BaseLLMClient {
|
|
|
409
409
|
provider: 'openai',
|
|
410
410
|
model: this.options.model,
|
|
411
411
|
});
|
|
412
|
-
// In gemma-native mode
|
|
413
|
-
//
|
|
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.
|
|
414
415
|
const decoderEvents = [];
|
|
415
|
-
const
|
|
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) : () => { });
|
|
416
424
|
// Track accumulated tool calls across chunks
|
|
417
425
|
const toolCallAccum = new Map();
|
|
418
426
|
let activeBody = body;
|
|
@@ -451,7 +459,7 @@ export class OpenAICompatibleClient extends BaseLLMClient {
|
|
|
451
459
|
}
|
|
452
460
|
if (delta.content) {
|
|
453
461
|
decoder.push(delta.content);
|
|
454
|
-
if (
|
|
462
|
+
if (yieldDecoderEvents) {
|
|
455
463
|
while (decoderEvents.length)
|
|
456
464
|
yield decoderEvents.shift();
|
|
457
465
|
}
|
|
@@ -512,7 +520,7 @@ export class OpenAICompatibleClient extends BaseLLMClient {
|
|
|
512
520
|
}
|
|
513
521
|
}
|
|
514
522
|
decoder.flush();
|
|
515
|
-
if (
|
|
523
|
+
if (yieldDecoderEvents) {
|
|
516
524
|
while (decoderEvents.length)
|
|
517
525
|
yield decoderEvents.shift();
|
|
518
526
|
}
|
|
@@ -653,16 +661,16 @@ export class OpenAICompatibleClient extends BaseLLMClient {
|
|
|
653
661
|
// A user-supplied value (via parameters) always wins.
|
|
654
662
|
const thinking = resolveThinking(options?.thinking, this.options.thinking);
|
|
655
663
|
if (thinking) {
|
|
656
|
-
const isOfficialOpenAI = (this.options.url ?? '').includes('api.openai.com');
|
|
657
664
|
if (isOpenAIReasoningModel(this.options.model)) {
|
|
658
665
|
if (params['reasoning_effort'] === undefined) {
|
|
659
666
|
params['reasoning_effort'] = thinking.enabled ? (thinking.level ?? 'medium') : 'minimal';
|
|
660
667
|
}
|
|
661
668
|
}
|
|
662
|
-
else if (
|
|
663
|
-
// `chat_template_kwargs` is a vLLM/Qwen extension.
|
|
664
|
-
//
|
|
665
|
-
//
|
|
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.
|
|
666
674
|
const existing = params['chat_template_kwargs'] ?? {};
|
|
667
675
|
params['chat_template_kwargs'] = { enable_thinking: thinking.enabled, ...existing };
|
|
668
676
|
}
|
package/dist/thinking.d.ts
CHANGED
|
@@ -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).
|
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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-llm-client",
|
|
3
|
-
"version": "4.5.
|
|
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",
|