xinfer-ai 0.12.1

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.
@@ -0,0 +1,474 @@
1
+ # Guided Decoding
2
+
3
+ This document describes the current guided decoding implementation in `xinfer`.
4
+
5
+ It focuses on:
6
+ - the live request-to-sampling workflow
7
+ - the supported request surfaces
8
+ - how reasoning effort is applied
9
+ - practical usage and validation commands
10
+
11
+ ## Current Model
12
+
13
+ Guided decoding is request-scoped.
14
+
15
+ The core engine does not invent grammars on its own. A request either:
16
+ - supplies a constraint grammar
17
+ - gets that constraint grammar composed with a reasoning prefix
18
+ - or runs unconstrained when neither exists
19
+
20
+ The final grammar is stored in `SamplingParams.grammar` and consumed by the runner.
21
+
22
+ ## End-to-End Workflow
23
+
24
+ ### 1. Request parsing
25
+
26
+ OpenAI-compatible requests can provide a grammar through:
27
+ - `structured_outputs`
28
+ - `response_format`
29
+ - legacy `constraint` plus `constraint_type`
30
+
31
+ Relevant code:
32
+ - `src/server/mod.rs`
33
+ - `src/server/server.rs`
34
+
35
+ The server converts those request fields into `TopLevelGrammar`.
36
+
37
+ Only one constraint source may be set at a time.
38
+
39
+ ### 2. Grammar composition
40
+
41
+ The server composes:
42
+ - request constraint grammar
43
+ - optional reasoning grammar prefix
44
+
45
+ The result is a single `TopLevelGrammar` assigned to `SamplingParams.grammar`.
46
+
47
+ If no client-supplied constraint grammar exists, `params.grammar` stays `None`.
48
+
49
+ ### 3. Sampling in runner
50
+
51
+ The runner uses the standard llguidance loop:
52
+
53
+ 1. build or reuse `GuidanceState` from `SamplingParams.grammar`
54
+ 2. call `compute_mask_or_eos()`
55
+ 3. mask logits
56
+ 4. apply penalties on masked logits
57
+ 5. sample once
58
+ 6. `commit_token()` into matcher state
59
+
60
+ Failures are fail-soft per sequence:
61
+ - on matcher creation failure, mask failure, or commit failure, guidance is disabled for that sequence
62
+ - normal generation continues for the affected sequence
63
+
64
+ Relevant code:
65
+ - `src/core/runner.rs`
66
+ - `src/utils/guidance.rs`
67
+
68
+ ## Supported Request Surfaces
69
+
70
+ ### OpenAI-compatible request fields
71
+
72
+ `structured_outputs`
73
+ - `choice`
74
+ - `regex`
75
+ - `json`
76
+ - `grammar`
77
+ - `structural_tag`
78
+
79
+ `response_format`
80
+ - `json_schema`
81
+ - `json_object`
82
+
83
+ Legacy fields
84
+ - `constraint`
85
+ - `constraint_type = regex | lark | json_schema | json`
86
+
87
+ If a request provides none of the above, guided decoding is not enabled.
88
+
89
+ ### Claude server
90
+
91
+ Current state:
92
+ - Claude does not expose the same client-supplied grammar request surface as the OpenAI endpoint
93
+ - Claude reasoning is still driven by explicit thinking behavior, not by `reasoning_effort` grammar composition
94
+ - Claude requests therefore do not currently enable guided decoding
95
+
96
+ ## Reasoning Effort
97
+
98
+ Reasoning effort is separate from ordinary structured outputs.
99
+
100
+ ### Current state
101
+
102
+ The OpenAI path maps `reasoning_effort` into grammar composition when a request constraint exists.
103
+
104
+ Accepted values come from `ReasoningEffort::from_str`:
105
+ - `none`
106
+ - `low`
107
+ - `medium`
108
+ - `normal`
109
+ - `high`
110
+ - `chain_of_thought`
111
+ - `cot`
112
+ - `cove`
113
+
114
+ Non-Python builds also support:
115
+ - `custom:<template>`
116
+
117
+ Relevant code:
118
+ - `src/utils/reasoning.rs`
119
+ - `src/server/server.rs`
120
+ - `src/utils/guidance.rs`
121
+
122
+ ### Important behavior
123
+
124
+ Reasoning effort:
125
+ - does not enable chat-template thinking by itself
126
+ - only affects grammar composition
127
+ - is ignored when no request constraint grammar is present
128
+ - only works when reasoning start/end tokens are available
129
+
130
+ If the tokenizer does not expose reasoning markers, the system logs a warning and falls back to the base grammar.
131
+
132
+ ### Why this is separate
133
+
134
+ Keep these concerns separate:
135
+ - `thinking`: prompt/template behavior
136
+ - `reasoning_effort`: guided-decoding grammar behavior
137
+
138
+ Server default:
139
+ - when a request omits `thinking` / `enable_thinking`, the Rust CLI defaults to thinking enabled
140
+ - start the Rust server with `--disable-reasoning` to change only that default fallback
141
+ - explicit request values still win over the CLI default
142
+ - `reasoning_effort` may still set `thinking=true` when guided decoding normalization is active
143
+
144
+ That separation avoids changing prompt rendering for request types that do not support OpenAI-style reasoning controls.
145
+
146
+ ## Use Cases
147
+
148
+ Present guided decoding from the simplest use case to the most constrained one.
149
+
150
+ ### Running the server
151
+ ```shell
152
+ xinfer --m Qwen/Qwen3.5-35B-A3B-FP8/ --ui-server --d 0
153
+ ```
154
+
155
+ ### Quick map
156
+
157
+ | Goal | Request surface | Typical type |
158
+ |------|-----------------|--------------|
159
+ | Pick one label | `structured_outputs` | `choice` |
160
+ | Enforce text pattern | `structured_outputs` or `constraint` | `regex` |
161
+ | Enforce full object schema | `structured_outputs` or `response_format` | `json` / `json_schema` |
162
+ | Enforce custom grammar | `structured_outputs` or `constraint` | `grammar` / `lark` |
163
+ | Constrain tagged structured output payload | `structured_outputs` | `structural_tag` |
164
+ | Add a reasoning prefix | `reasoning_effort` | `low`, `medium`, `high`, etc. |
165
+
166
+ ### 1. Constrain the answer to a fixed set
167
+
168
+ Use this when the model must pick exactly one label.
169
+
170
+ ```bash
171
+ curl -sXPOST localhost:8000/v1/chat/completions \
172
+ -H 'Content-Type: application/json' \
173
+ -d '{
174
+ "messages": [{"role":"user","content":"Classify this sentiment"}],
175
+ "structured_outputs": {
176
+ "choice": ["positive", "negative", "neutral"]
177
+ },
178
+ "max_tokens": 50
179
+ }'
180
+ ```
181
+
182
+ Expected behavior:
183
+ - output is one of the listed strings
184
+ - server log shows `source=structured_outputs type=choice`
185
+
186
+ ### 2. Constrain text with regex
187
+
188
+ Use this when the output is text, but must follow a narrow textual pattern.
189
+
190
+ `structured_outputs.regex` example:
191
+
192
+ ```bash
193
+ curl -sXPOST localhost:8000/v1/chat/completions \
194
+ -H 'Content-Type: application/json' \
195
+ -d '{
196
+ "messages": [{"role":"user","content":"Return a 3-digit code"}],
197
+ "structured_outputs": {
198
+ "regex": "^[0-9]{3}$"
199
+ },
200
+ "max_tokens": 10
201
+ }'
202
+ ```
203
+
204
+ Expected behavior:
205
+ - output matches the regex
206
+ - server log shows `source=structured_outputs type=regex`
207
+
208
+ ### 3. Constrain to a JSON object with a schema
209
+
210
+ Use this when the model must produce machine-parseable JSON.
211
+
212
+ Exact example:
213
+
214
+ ```bash
215
+ curl -sXPOST localhost:8000/v1/chat/completions \
216
+ -H 'Content-Type: application/json' \
217
+ -d '{
218
+ "messages": [{"role":"user","content":"Generate a user profile"}],
219
+ "structured_outputs": {
220
+ "json": {
221
+ "type": "object",
222
+ "properties": {
223
+ "name": {"type": "string"},
224
+ "age": {"type": "integer", "minimum": 0, "maximum": 150},
225
+ "email": {"type": "string", "pattern": "^[a-z]+@[a-z]+\\.[a-z]+$"}
226
+ },
227
+ "required": ["name", "age", "email"],
228
+ "additionalProperties": false
229
+ }
230
+ },
231
+ "max_tokens": 500
232
+ }'
233
+ ```
234
+
235
+ Expected behavior:
236
+ - output is valid JSON
237
+ - required fields are present
238
+ - extra properties are rejected by grammar
239
+
240
+ ### 4. Use OpenAI-style `response_format`
241
+
242
+ Use this when compatibility with clients that already emit `response_format` matters.
243
+
244
+ ```bash
245
+ curl -sXPOST localhost:8000/v1/chat/completions \
246
+ -H 'Content-Type: application/json' \
247
+ -d '{
248
+ "messages": [{"role":"user","content":"Return a JSON object"}],
249
+ "response_format": {
250
+ "type": "json_schema",
251
+ "json_schema": {
252
+ "schema": {
253
+ "type": "object",
254
+ "properties": {
255
+ "answer": {"type": "string"}
256
+ },
257
+ "required": ["answer"],
258
+ "additionalProperties": false
259
+ }
260
+ }
261
+ }
262
+ }'
263
+ ```
264
+
265
+ Expected behavior:
266
+ - output follows the supplied schema
267
+ - server log shows `source=response_format type=json_schema`
268
+
269
+ `json_object` example:
270
+
271
+ ```bash
272
+ curl -sXPOST localhost:8000/v1/chat/completions \
273
+ -H 'Content-Type: application/json' \
274
+ -d '{
275
+ "messages": [{"role":"user","content":"Return any JSON object"}],
276
+ "response_format": {
277
+ "type": "json_object"
278
+ },
279
+ "max_tokens": 200
280
+ }'
281
+ ```
282
+
283
+ Expected behavior:
284
+ - output is a valid JSON object
285
+ - no custom property schema is enforced beyond object shape
286
+
287
+ ### 5. Constrain with custom grammar
288
+
289
+ Use this when the output is not simple JSON schema and needs a formal grammar.
290
+
291
+ Regex example:
292
+
293
+ ```bash
294
+ curl -sXPOST localhost:8000/v1/chat/completions \
295
+ -H 'Content-Type: application/json' \
296
+ -d '{
297
+ "messages": [{"role":"user","content":"Return a 3-digit code"}],
298
+ "constraint": "^[0-9]{3}$",
299
+ "constraint_type": "regex",
300
+ "max_tokens": 10
301
+ }'
302
+ ```
303
+
304
+ Lark example:
305
+
306
+ ```bash
307
+ curl -sXPOST localhost:8000/v1/chat/completions \
308
+ -H 'Content-Type: application/json' \
309
+ -d '{
310
+ "messages": [{"role":"user","content":"Return yes or no"}],
311
+ "constraint": "start: \"yes\" | \"no\"",
312
+ "constraint_type": "lark",
313
+ "max_tokens": 10
314
+ }'
315
+ ```
316
+
317
+ Expected behavior:
318
+ - output satisfies the supplied grammar exactly
319
+ - server log shows `source=constraint type=regex` or `type=lark`
320
+
321
+ `structured_outputs.grammar` example:
322
+
323
+ ```bash
324
+ curl -sXPOST localhost:8000/v1/chat/completions \
325
+ -H 'Content-Type: application/json' \
326
+ -d '{
327
+ "messages": [{"role":"user","content":"Return yes or no"}],
328
+ "structured_outputs": {
329
+ "grammar": "start: \"yes\" | \"no\""
330
+ },
331
+ "max_tokens": 10
332
+ }'
333
+ ```
334
+
335
+ Expected behavior:
336
+ - output follows the supplied Lark grammar
337
+ - server log shows `source=structured_outputs type=grammar`
338
+
339
+ ### 6. Constrain structural-tag output
340
+
341
+ Use this when a request wants tagged structured content instead of plain JSON.
342
+
343
+ Example:
344
+
345
+ ```bash
346
+ curl -sXPOST localhost:8000/v1/chat/completions \
347
+ -H 'Content-Type: application/json' \
348
+ -d '{
349
+ "messages": [{"role":"user","content":"Emit a search tool call"}],
350
+ "structured_outputs": {
351
+ "structural_tag": {
352
+ "tag": "tool_call",
353
+ "schema": {
354
+ "type": "object",
355
+ "properties": {
356
+ "name": {"type": "string", "enum": ["search"]},
357
+ "arguments": {
358
+ "type": "object",
359
+ "properties": {
360
+ "query": {"type": "string"}
361
+ },
362
+ "required": ["query"],
363
+ "additionalProperties": false
364
+ }
365
+ },
366
+ "required": ["name", "arguments"],
367
+ "additionalProperties": false
368
+ }
369
+ }
370
+ },
371
+ "max_tokens": 200
372
+ }'
373
+ ```
374
+
375
+ Expected behavior:
376
+ - output follows the tagged structural envelope
377
+ - schema rules still constrain the payload
378
+
379
+ ### 7. Add reasoning effort on top of a constraint
380
+
381
+ Use this when you want the output constrained, but still want the model to emit a reasoning block first when the tokenizer supports reasoning markers.
382
+
383
+ ```bash
384
+ curl -sXPOST localhost:8000/v1/chat/completions \
385
+ -H 'Content-Type: application/json' \
386
+ -d '{
387
+ "messages": [{"role":"user","content":"Classify this sentiment and think first: I am happy!"}],
388
+ "structured_outputs": {
389
+ "choice": ["positive", "negative", "neutral"]
390
+ },
391
+ "reasoning_effort": "low",
392
+ "max_tokens": 1000
393
+ }'
394
+ ```
395
+
396
+ Expected behavior:
397
+ - the base answer remains grammar-constrained
398
+ - reasoning grammar is prefixed only when reasoning tokens are available
399
+ - if reasoning tokens are missing, the server logs a warning and falls back
400
+
401
+
402
+ ### Conflict rejection
403
+
404
+ Run:
405
+
406
+ ```bash
407
+ curl -sXPOST localhost:8000/v1/chat/completions \
408
+ -H 'Content-Type: application/json' \
409
+ -d '{
410
+ "messages": [{"role":"user","content":"hi"}],
411
+ "structured_outputs": {"choice": ["a", "b"]},
412
+ "response_format": {
413
+ "type": "json_schema",
414
+ "json_schema": {"schema": {"type": "object"}}
415
+ }
416
+ }'
417
+ ```
418
+
419
+ Check:
420
+ - request is rejected
421
+ - error says only one of `structured_outputs`, `response_format`, or `constraint` may be set
422
+
423
+ ### Unsupported `response_format` rejection
424
+
425
+ Run:
426
+
427
+ ```bash
428
+ curl -sXPOST localhost:8000/v1/chat/completions \
429
+ -H 'Content-Type: application/json' \
430
+ -d '{
431
+ "messages": [{"role":"user","content":"hi"}],
432
+ "response_format": {
433
+ "type": "xml"
434
+ }
435
+ }'
436
+ ```
437
+
438
+ Check:
439
+ - request is rejected
440
+ - error mentions only `json_schema` and `json_object` are supported
441
+
442
+ ### Empty `structured_outputs` rejection
443
+
444
+ Run:
445
+
446
+ ```bash
447
+ curl -sXPOST localhost:8000/v1/chat/completions \
448
+ -H 'Content-Type: application/json' \
449
+ -d '{
450
+ "messages": [{"role":"user","content":"hi"}],
451
+ "structured_outputs": {}
452
+ }'
453
+ ```
454
+
455
+ Check:
456
+ - request is rejected
457
+ - error says one of `choice`, `regex`, `json`, `grammar`, or `structural_tag` must be set
458
+
459
+ ### Reasoning effort fallback
460
+
461
+ Run the reasoning-effort example above on a model without reasoning tokens.
462
+
463
+ Check:
464
+ - request still completes
465
+ - base constraint still applies
466
+ - warning indicates reasoning tokens were not found
467
+
468
+
469
+ ## Known Boundaries
470
+
471
+ - Guided decoding is only active when `SamplingParams.grammar` is present.
472
+ - OpenAI currently has the richest client-facing grammar surface.
473
+ - Claude does not currently expose request-level guided decoding.
474
+ - No request-level grammar means no guided decoding.
@@ -0,0 +1,69 @@
1
+ # Kilo Code + xInfer (OpenAI-compatible endpoint)
2
+
3
+ This guide connects Kilo Code directly to xInfer using the built-in OpenAI-compatible `/v1/chat_completions` API.
4
+
5
+ ```
6
+ Kilo Code -> xInfer (OpenAI-compatible)
7
+ ```
8
+
9
+ ## 1) Start xInfer on port 8000
10
+
11
+ ```bash
12
+ # Rust
13
+ xinfer --m Qwen/Qwen3.5-35B-A3B-FP8 --server --d 0
14
+
15
+ # Different model
16
+ xinfer --m Qwen/Qwen3.5-27B-FP8 --d 0 --server
17
+
18
+ # Python
19
+ python3 -m xinfer.server --m Qwen/Qwen3-Coder-Next-FP8 --d 0,1
20
+ ```
21
+
22
+ ## 2) Configure Kilo Code
23
+
24
+ Install Kilo Code (CLI)
25
+
26
+ ```shell
27
+ npm install -g @kilocode/cli
28
+ ```
29
+
30
+ Export config into `~/.config/kilo/config.json`
31
+
32
+
33
+ ```json
34
+ {
35
+ "$schema": "https://opencode.ai/config.json",
36
+ "provider": {
37
+ "xinfer": {
38
+ "npm": "@ai-sdk/openai-compatible",
39
+ "name": "xInfer Local",
40
+ "options": {
41
+ "baseURL": "http://localhost:8000/v1"
42
+ },
43
+ "models": {
44
+ "qwen3-coder": {
45
+ "name": "Qwen3 Coder"
46
+ }
47
+ }
48
+ }
49
+ },
50
+ "model": "xinfer/qwen3-coder"
51
+ }
52
+ ```
53
+
54
+ ## 3) Run Kilo Code
55
+
56
+ run Kilo Code (CLI)
57
+
58
+ ```shell
59
+ kilo
60
+ ```
61
+
62
+ ### Trouble shooting
63
+
64
+ 1. Use the chat logger to monitor detailed interactions between Kilo Code and xInfer.
65
+
66
+ ```shell
67
+ # Log into files (in folder ./log)
68
+ export XINFER_CHAT_LOGGER=1
69
+ ```
@@ -0,0 +1,155 @@
1
+ # MCP Integration and Tool Calling
2
+
3
+ xInfer supports **Model Context Protocol (MCP)** integration, allowing you to easily expose tools from MCP servers to your LLM.
4
+
5
+ **Important:** xInfer follows the standard OpenAI tool calling specification. This means the server **only handles tool definitions and prompt injection**. It does **not** execute tools internally. When a model calls a tool, the generation stops, and the tool call details are returned to the client. The client is responsible for executing the tool and submitting the results back to the server.
6
+
7
+ ## Overview
8
+
9
+ ### What is MCP?
10
+
11
+ The **Model Context Protocol (MCP)** is a standardized protocol for connecting LLMs to external tools and services. xInfer supports:
12
+
13
+ - **Stdio transport**: Connect to local MCP servers via command-line processes
14
+ - **HTTP/SSE transport**: Connect to remote MCP servers via HTTP
15
+ - **Multi-server support**: Configure multiple MCP servers with automatic tool name prefixing
16
+ - **Automatic tool injection**: Tool definitions from configured MCP servers are automatically injected into the model's system prompt
17
+
18
+ ---
19
+
20
+ ## Tool Calling Workflow
21
+
22
+ 1. **Configuration**: You configure MCP servers in xInfer (via CLI or `mcp.json`).
23
+ 2. **Injection**: xInfer fetches tool definitions from these servers and appends them to the system prompt of your request.
24
+ 3. **Generation**: The model generates a tool call.
25
+ 4. **Completion**: The stream (or request) finishes with `finish_reason="tool_calls"`.
26
+ 5. **Execution (Client-side)**: Your client code receives the tool call, executes the corresponding function (which you must implement or bridge to an MCP client), and sends the result back in a new request.
27
+
28
+ ### Example Flow
29
+
30
+ ```python
31
+ import openai
32
+
33
+ client = openai.OpenAI(base_url="http://localhost:8000/v1", api_key="empty")
34
+
35
+ # Request triggers tool injection from configured MCP servers
36
+ response = client.chat.completions.create(
37
+ model="default",
38
+ messages=[{"role": "user", "content": "List files in current directory"}],
39
+ stream=True
40
+ )
41
+
42
+ # ... Handle stream ...
43
+ # If model calls a tool, response will contain tool_calls
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Configuration Options
49
+
50
+ ### CLI Options
51
+
52
+ | Option | Description |
53
+ |--------|-------------|
54
+ | `--mcp-command` | Path to single MCP server executable |
55
+ | `--mcp-args` | Comma-separated arguments for MCP server |
56
+ | `--mcp-config` | Path to JSON config file for multiple servers |
57
+
58
+ ### Config File Format
59
+
60
+ #### Local Servers (Stdio Transport)
61
+
62
+ ```json
63
+ {
64
+ "mcpServers": {
65
+ "server_name": {
66
+ "command": "path/to/executable",
67
+ "args": ["arg1", "arg2"],
68
+ "env": {
69
+ "ENV_VAR": "value"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ #### Remote Servers (HTTP/SSE Transport)
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "server_name": {
82
+ "url": "https://mcp.example.com/api/",
83
+ "headers": {
84
+ "Authorization": "Bearer YOUR_TOKEN",
85
+ "Accept": "text/event-stream"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ > **Note:** Each server must have either `command` (local) or `url` (remote), not both. You can mix local and remote servers in the same config file.
93
+
94
+ ### Tool Name Prefixing
95
+
96
+ When using multiple MCP servers, tool names are prefixed with the server name to avoid conflicts:
97
+
98
+ - Server `filesystem` with tool `list_directory` → `filesystem_list_directory`
99
+ - Server `github` with tool `search_repos` → `github_search_repos`
100
+
101
+ ---
102
+
103
+ ## Tool Call Parsing Architecture
104
+
105
+ xInfer uses a dual-strategy approach for detecting tool calls in streaming responses.
106
+
107
+ ### Detection Strategy
108
+
109
+ 1. **Token ID-based detection** (preferred): For models with dedicated tool call tokens
110
+ - Uses special token IDs defined in the tokenizer
111
+ - More robust and prevents false positives from text that looks like tool calls
112
+
113
+ 2. **Text-based detection** (fallback): For models without special tokens
114
+ - Uses pattern matching for start/end tag strings
115
+ - Applied when token IDs are not available
116
+
117
+ ### Model Support Matrix
118
+
119
+ | Model Family | Detection Method | Start Token | End Token |
120
+ |--------------|-----------------|-------------|-----------|
121
+ | Qwen 2.5/3 | Token ID + Text | `<tool_call>` (151657) | `</tool_call>` (151658) |
122
+ | Llama 3/3.1 | Token ID + Text | `<\|python_tag\|>` (128010) | `<\|eom_id\|>` (128008) |
123
+ | Mistral | Token ID + Text | `[TOOL_CALLS]` (9) | `]` |
124
+ | Gemma | Text only | `<start_function_call>` | `<end_function_call>` |
125
+ | Phi4, GLM4 | Text only | `<tool_call>` | `</tool_call>` |
126
+
127
+ ### Buffering Logic
128
+
129
+ The streaming tool parser uses a state machine:
130
+
131
+ 1. **Normal State**: Tokens stream through. Check for start trigger.
132
+ 2. **Buffering State**: On start detection, accumulate tokens silently.
133
+ 3. **End Detection**: When end token/text is detected:
134
+ - Parse buffer into structured tool calls
135
+ - If parsing succeeds: return `finish_reason: "tool_calls"`
136
+ - If parsing fails: flush buffered content as normal text
137
+
138
+ ### Content Filtering
139
+
140
+ Tool calls are NOT detected inside:
141
+ - **Reasoning blocks** (`<think>...</think>`, `<|think|>...<|/think|>`)
142
+ - **Code blocks** (` ``` `)
143
+
144
+ This prevents false positives from examples or documentation.
145
+
146
+ ---
147
+
148
+ ## Behavior Matrix
149
+
150
+ | Has Request Tools | MCP Configured | Behavior |
151
+ |-------------------|----------------|----------|
152
+ | ❌ | ❌ | Normal generation |
153
+ | ✅ | ❌ | User tools used. Stream finishes at tool call. |
154
+ | ❌ | ✅ | MCP tools injected. Stream finishes at tool call. |
155
+ | ✅ | ✅ | Both User and MCP tools available. Stream finishes at tool call. |