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 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
@@ -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,
@@ -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 the decoder classifies thought-channel content,
413
- // 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.
414
415
  const decoderEvents = [];
415
- 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) : () => { });
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 (this.gemmaNative) {
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 (this.gemmaNative) {
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 (!isOfficialOpenAI) {
663
- // `chat_template_kwargs` is a vLLM/Qwen extension. Official OpenAI
664
- // rejects unknown body fields (and gpt-4o has no thinking toggle),
665
- // 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.
666
674
  const existing = params['chat_template_kwargs'] ?? {};
667
675
  params['chat_template_kwargs'] = { enable_thinking: thinking.enabled, ...existing };
668
676
  }
@@ -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.1",
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",