langchain-llama-cpp 0.1.0__py3-none-any.whl

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,1350 @@
1
+ """ChatLlama: a LangChain chat model for llama.cpp's llama-server behind llama-swap."""
2
+
3
+ import asyncio
4
+ import json
5
+ import random
6
+ import time
7
+ import weakref
8
+ from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
9
+ from operator import itemgetter
10
+ from typing import Any, Literal, cast
11
+
12
+ import httpx
13
+ from langchain_core.callbacks import (
14
+ AsyncCallbackManagerForLLMRun,
15
+ CallbackManagerForLLMRun,
16
+ )
17
+ from langchain_core.exceptions import ModelError, OutputParserException
18
+ from langchain_core.language_models import (
19
+ BaseChatModel,
20
+ LangSmithParams,
21
+ LanguageModelInput,
22
+ )
23
+ from langchain_core.messages import (
24
+ AIMessage,
25
+ AIMessageChunk,
26
+ BaseMessage,
27
+ InvalidToolCall,
28
+ SystemMessage,
29
+ ToolCall,
30
+ convert_to_openai_messages,
31
+ )
32
+ from langchain_core.messages.ai import (
33
+ InputTokenDetails,
34
+ OutputTokenDetails,
35
+ UsageMetadata,
36
+ )
37
+ from langchain_core.messages.tool import ToolCallChunk
38
+ from langchain_core.messages.tool import invalid_tool_call as create_invalid_tool_call
39
+ from langchain_core.messages.tool import tool_call_chunk as create_tool_call_chunk
40
+ from langchain_core.output_parsers import (
41
+ JsonOutputKeyToolsParser,
42
+ JsonOutputParser,
43
+ PydanticOutputParser,
44
+ PydanticToolsParser,
45
+ )
46
+ from langchain_core.output_parsers.format_instructions import (
47
+ JSON_FORMAT_INSTRUCTIONS,
48
+ )
49
+ from langchain_core.output_parsers.openai_tools import (
50
+ make_invalid_tool_call,
51
+ parse_tool_call,
52
+ )
53
+ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
54
+ from langchain_core.runnables import (
55
+ Runnable,
56
+ RunnableLambda,
57
+ RunnableMap,
58
+ RunnablePassthrough,
59
+ )
60
+ from langchain_core.tools import BaseTool
61
+ from langchain_core.utils import from_env
62
+ from langchain_core.utils.function_calling import (
63
+ convert_to_json_schema,
64
+ convert_to_openai_function,
65
+ convert_to_openai_tool,
66
+ )
67
+ from langchain_core.utils.pydantic import is_basemodel_subclass
68
+ from pydantic import BaseModel, Field, PrivateAttr
69
+
70
+ from langchain_llama_cpp._wire import (
71
+ LlamaChunk,
72
+ LlamaCompletionTokensDetails,
73
+ LlamaDelta,
74
+ LlamaFunction,
75
+ LlamaMessage,
76
+ LlamaPromptTokensDetails,
77
+ LlamaResponse,
78
+ LlamaUsage,
79
+ )
80
+ from langchain_llama_cpp.content_blocks import MODEL_PROVIDER
81
+ from langchain_llama_cpp.exceptions import (
82
+ error_from_cut_off_stream,
83
+ error_from_response,
84
+ error_from_stream_event,
85
+ error_from_transport,
86
+ )
87
+
88
+ # --- Constants ------------------------------------------------------------
89
+
90
+ CHAT_COMPLETIONS = "/v1/chat/completions"
91
+
92
+ # Backoff between retries, as in the openai and groq SDKs.
93
+ INITIAL_RETRY_DELAY = 0.5
94
+ MAX_RETRY_DELAY = 8.0
95
+ MAX_RETRY_AFTER = 60.0
96
+
97
+ # ChatLlama fields that configure the client rather than the request.
98
+ _CLIENT_FIELDS = frozenset(
99
+ {
100
+ "model",
101
+ "base_url",
102
+ "timeout",
103
+ "max_retries",
104
+ "streaming",
105
+ "stream_usage",
106
+ "http_client",
107
+ "http_async_client",
108
+ }
109
+ )
110
+
111
+ _AsyncClients = weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, httpx.AsyncClient]
112
+
113
+ # What `_chunk_from_line` returns for `data: [DONE]`, the end of a finished stream.
114
+ _DONE = ChatGenerationChunk(message=AIMessageChunk(content=""))
115
+
116
+
117
+ # --- The chat model -------------------------------------------------------
118
+
119
+
120
+ class ChatLlama(BaseChatModel):
121
+ """Chat model for llama.cpp's `llama-server`, reached through llama-swap.
122
+
123
+ Key init args — completion params:
124
+ model: str
125
+ llama-swap entry to use, e.g. `"gemma"`. Required.
126
+ max_tokens: int | None
127
+ Maximum tokens to generate. Thinking tokens count too.
128
+ temperature: float | None
129
+ Randomness (0 = greedy).
130
+ top_k, top_p, min_p: int | float | None
131
+ Sampling cut-offs.
132
+ stop: list[str] | None
133
+ Stop strings.
134
+ chat_template_kwargs: dict | None
135
+ Variables for the chat template; the thinking switch lives here and
136
+ differs per family: `{"enable_thinking": False}` (gemma, qwen),
137
+ `{"reasoning_effort": "low"}` (gpt-oss).
138
+ seed: int | None
139
+ Random seed.
140
+
141
+ Key init args — client params:
142
+ base_url: str
143
+ Server root, e.g. `http://localhost:8080`. Defaults to the
144
+ `LLAMA_BASE_URL` environment variable.
145
+ timeout: float | httpx.Timeout | None
146
+ Request timeout in seconds. `None` (the default) means none.
147
+ max_retries: int
148
+ Retries for connection errors, timeouts, 429 and 5xx (default 2).
149
+ streaming: bool
150
+ Route `invoke` through streaming.
151
+ stream_usage: bool
152
+ Ask for the final usage chunk when streaming (default on).
153
+ http_client, http_async_client: httpx.Client | httpx.AsyncClient | None
154
+ Your own httpx clients.
155
+
156
+ Every request field `/v1/chat/completions` accepts has its own typed field,
157
+ documented on the field itself. All default to `None`, which leaves the
158
+ field out of the request so the server's per-model default applies (see
159
+ `get_server_defaults`).
160
+
161
+ Example:
162
+ ```python
163
+ from langchain_llama_cpp import ChatLlama
164
+
165
+ llm = ChatLlama(model="gemma", max_tokens=300) # base_url from LLAMA_BASE_URL
166
+ msg = llm.invoke("What is 17 * 23?")
167
+ msg.content # "391"
168
+ msg.additional_kwargs["reasoning_content"] # the model's thinking, if any
169
+ msg.response_metadata["timings"] # llama-server's own timings
170
+ ```
171
+ """
172
+
173
+ model: str
174
+ """llama-swap entry to use, e.g. `"gemma"`."""
175
+
176
+ base_url: str = Field(
177
+ default_factory=from_env(
178
+ "LLAMA_BASE_URL",
179
+ error_message="Pass base_url= or set LLAMA_BASE_URL.",
180
+ )
181
+ )
182
+ """Server root, e.g. `http://localhost:8080`. Defaults to `LLAMA_BASE_URL`."""
183
+
184
+ # --- Request fields -----------------------------------------------------
185
+ # Every field accepted by /v1/chat/completions in llama.cpp b11429
186
+ # (tools/server/server-common.cpp oaicompat_chat_params_parse,
187
+ # server-schema.cpp make_llama_cmpl_schema, server-context.cpp id_slot).
188
+ # Each is sent under its own name; None (the default) leaves it out, so the
189
+ # server's per-model default applies (see `get_server_defaults`).
190
+
191
+ # Length and stopping
192
+ max_tokens: int | None = None
193
+ """Maximum tokens to generate (-1 = no limit). Thinking tokens count too."""
194
+
195
+ stop: list[str] | None = None
196
+ """Stop strings. Generation ends at the first one, which is not returned."""
197
+
198
+ ignore_eos: bool | None = None
199
+ """Ignore the end-of-sequence token and keep generating."""
200
+
201
+ t_max_predict_ms: int | None = None
202
+ """Stop generating after this many ms from the first token, at a newline."""
203
+
204
+ n_indent: int | None = None
205
+ """Minimum line indentation, in spaces, for generated text (code completion)."""
206
+
207
+ n: int | None = None
208
+ """Number of completions to generate (1 to the server's slot count)."""
209
+
210
+ # Sampling
211
+ temperature: float | None = None
212
+ """Randomness (0 = greedy)."""
213
+
214
+ dynatemp_range: float | None = None
215
+ """Dynamic temperature: final value in `temperature ± range` (0 = off)."""
216
+
217
+ dynatemp_exponent: float | None = None
218
+ """Dynamic temperature: how entropy maps to temperature."""
219
+
220
+ top_k: int | None = None
221
+ """Keep only the k most likely tokens (0 = off)."""
222
+
223
+ top_p: float | None = None
224
+ """Keep the smallest set of tokens with this total probability (1.0 = off)."""
225
+
226
+ min_p: float | None = None
227
+ """Drop tokens below this fraction of the top token's probability (0 = off)."""
228
+
229
+ typical_p: float | None = None
230
+ """Locally typical sampling (1.0 = off)."""
231
+
232
+ top_n_sigma: float | None = None
233
+ """Keep tokens within n standard deviations of the top logit (< 0 = off)."""
234
+
235
+ xtc_probability: float | None = None
236
+ """Chance that the XTC sampler removes top tokens (0 = off)."""
237
+
238
+ xtc_threshold: float | None = None
239
+ """Minimum probability for XTC removal (> 0.5 turns XTC off)."""
240
+
241
+ mirostat: Literal[0, 1, 2] | None = None
242
+ """Mirostat sampling: 0 = off, 1 = Mirostat, 2 = Mirostat 2.0."""
243
+
244
+ mirostat_tau: float | None = None
245
+ """Mirostat target entropy (tau)."""
246
+
247
+ mirostat_eta: float | None = None
248
+ """Mirostat learning rate (eta)."""
249
+
250
+ adaptive_target: float | None = None
251
+ """Adaptive sampling target entropy, 0.0 to 1.0 (negative = off)."""
252
+
253
+ adaptive_decay: float | None = None
254
+ """Adaptive sampling EMA decay, 0.0 to 0.99."""
255
+
256
+ min_keep: int | None = None
257
+ """Make every sampler keep at least this many tokens."""
258
+
259
+ samplers: list[str] | str | None = None
260
+ """Sampler order: names (`["top_k", "min_p", ...]`) or a string of sampler chars."""
261
+
262
+ seed: int | None = None
263
+ """Random seed (-1 = random)."""
264
+
265
+ backend_sampling: bool | None = None
266
+ """Sample on the backend instead of in llama.cpp's sampler."""
267
+
268
+ # Repetition penalties
269
+ repeat_penalty: float | None = None
270
+ """Penalty for repeated tokens (1.0 = off)."""
271
+
272
+ repeat_last_n: int | None = None
273
+ """How many recent tokens the repeat penalty looks at (0 = off)."""
274
+
275
+ frequency_penalty: float | None = None
276
+ """Penalty that grows with how often a token appeared (0 = off)."""
277
+
278
+ presence_penalty: float | None = None
279
+ """Penalty for any token that already appeared (0 = off)."""
280
+
281
+ dry_multiplier: float | None = None
282
+ """DRY repetition penalty multiplier (0 = off)."""
283
+
284
+ dry_base: float | None = None
285
+ """DRY penalty base (>= 1.0; smaller values fall back to the server default)."""
286
+
287
+ dry_allowed_length: int | None = None
288
+ """Repeats longer than this get the DRY penalty."""
289
+
290
+ dry_penalty_last_n: int | None = None
291
+ """How many tokens DRY scans for repeats (0 = off)."""
292
+
293
+ dry_sequence_breakers: list[str] | None = None
294
+ """Strings that reset DRY's repeat matching (non-empty list)."""
295
+
296
+ logit_bias: dict[str, float | bool] | list[list[Any]] | None = None
297
+ """Token bias: `{"token_or_id": bias}` or `[[token_or_id, bias], ...]`.
298
+
299
+ A bias of `False` bans the token.
300
+ """
301
+
302
+ # Constrained output (with_structured_output builds on these)
303
+ grammar: str | None = None
304
+ """A GBNF grammar the output must follow. Can't be combined with tools."""
305
+
306
+ json_schema: dict[str, Any] | None = None
307
+ """A JSON schema the output must follow (llama.cpp's own field)."""
308
+
309
+ # Chat template and reasoning
310
+ chat_template_kwargs: dict[str, Any] | None = None
311
+ """Variables for the chat template, e.g. `{"enable_thinking": False}`."""
312
+
313
+ reasoning_format: str | None = None
314
+ """Where thinking goes: `"auto"`/`"deepseek"` → `reasoning_content`.
315
+
316
+ `"none"` leaves it in the content text; `"deepseek-legacy"` does both.
317
+ """
318
+
319
+ reasoning_effort: str | None = None
320
+ """OpenAI-style effort (`"low"`, `"medium"`, `"high"`); `"none"` turns thinking off.
321
+
322
+ Passed to the template as `reasoning_effort` (gpt-oss).
323
+ """
324
+
325
+ reasoning_budget_tokens: int | None = None
326
+ """Cap on thinking tokens (-1 = no cap); then the end-of-thinking tag is forced."""
327
+
328
+ reasoning_budget_message: str | None = None
329
+ """Text inserted before the forced end-of-thinking tag."""
330
+
331
+ reasoning_control: bool | None = None
332
+ """Create the reasoning-budget sampler so thinking can be ended at runtime."""
333
+
334
+ add_generation_prompt: bool | None = None
335
+ """Whether the template appends the assistant turn's opening (default true)."""
336
+
337
+ continue_final_message: bool | str | None = None
338
+ """Continue the last assistant message instead of starting a new turn."""
339
+
340
+ echo: bool | None = None
341
+ """Echo the input tokens in the output."""
342
+
343
+ # Probabilities and debugging output
344
+ logprobs: bool | None = None
345
+ """Return token probabilities (llama.cpp's format, not exactly OpenAI's)."""
346
+
347
+ top_logprobs: int | None = None
348
+ """How many top tokens to return per position (needs `logprobs`; default 20)."""
349
+
350
+ n_probs: int | None = None
351
+ """llama.cpp's native form of `top_logprobs` (no `logprobs` flag needed)."""
352
+
353
+ post_sampling_probs: bool | None = None
354
+ """Report probabilities after the sampler chain instead of before."""
355
+
356
+ return_tokens: bool | None = None
357
+ """Also return the generated token ids."""
358
+
359
+ timings_per_token: bool | None = None
360
+ """Include timing information in every response chunk."""
361
+
362
+ response_fields: list[str] | None = None
363
+ """Only return these reply fields. Can remove fields ChatLlama reads."""
364
+
365
+ # Server resources and caching
366
+ cache_prompt: bool | None = None
367
+ """Reuse the KV cache for a shared prompt prefix (server default: on)."""
368
+
369
+ n_cache_reuse: int | None = None
370
+ """Minimum chunk size to reuse from the cache via KV shifting."""
371
+
372
+ n_keep: int | None = None
373
+ """Prompt tokens to keep when the context is shifted (-1 = all)."""
374
+
375
+ n_discard: int | None = None
376
+ """Tokens after `n_keep` that a context shift may drop (0 = half)."""
377
+
378
+ id_slot: int | None = None
379
+ """Run on this server slot (-1 = any free slot)."""
380
+
381
+ lora: list[dict[str, Any]] | None = None
382
+ """LoRA adapters for this request: `[{"id": 0, "scale": 1.0}, ...]`."""
383
+
384
+ # Streaming only (used from milestone 2)
385
+ return_progress: bool | None = None
386
+ """Send prompt-processing progress events while streaming."""
387
+
388
+ sse_ping_interval: int | None = None
389
+ """Seconds between keep-alive pings on a silent stream (-1 = off)."""
390
+
391
+ # --- Client settings (not sent) ----------------------------------------
392
+
393
+ timeout: float | httpx.Timeout | None = None
394
+ """Request timeout in seconds. `None` (the default) means no timeout."""
395
+
396
+ max_retries: int = Field(default=2, ge=0)
397
+ """Retries for connection errors, timeouts, 429 and 5xx. 0 turns them off."""
398
+
399
+ streaming: bool = False
400
+ """Route `invoke` through streaming (core reads this field, as for ChatOpenAI)."""
401
+
402
+ stream_usage: bool = True
403
+ """Ask for the final usage chunk (`stream_options.include_usage`) when streaming.
404
+
405
+ llama-server supports it, so it's on by default; the chunk also carries
406
+ `timings`. Can be overridden per call with `stream_usage=`.
407
+ """
408
+
409
+ http_client: httpx.Client | None = Field(default=None, exclude=True)
410
+ """Your own `httpx.Client`. If given, `timeout` is not applied to it."""
411
+
412
+ http_async_client: httpx.AsyncClient | None = Field(default=None, exclude=True)
413
+ """Your own `httpx.AsyncClient` for async calls. `timeout` isn't applied to it."""
414
+
415
+ _client: httpx.Client | None = PrivateAttr(default=None)
416
+
417
+ # An AsyncClient's pooled connections belong to the event loop that opened
418
+ # them, so there is one client per loop (langchain-ai/langchain#35783 is
419
+ # this bug in ChatOpenAI). Threads running their own loops don't evict each
420
+ # other; clients of closed loops are dropped in `_get_async_client`.
421
+ _async_clients: _AsyncClients = PrivateAttr(default_factory=_AsyncClients)
422
+
423
+ # --- Properties -------------------------------------------------------
424
+
425
+ @property
426
+ def _llm_type(self) -> str:
427
+ return "llama-cpp-chat"
428
+
429
+ @property
430
+ def _params(self) -> dict[str, Any]:
431
+ """Request fields set on the model; fields left at `None` are left out."""
432
+ return _without_none(**{name: getattr(self, name) for name in _REQUEST_FIELDS})
433
+
434
+ @property
435
+ def _identifying_params(self) -> dict[str, Any]:
436
+ return {"model": self.model, "base_url": self.base_url, **self._params}
437
+
438
+ # --- Public methods ---------------------------------------------------
439
+
440
+ def get_server_defaults(self) -> dict[str, Any]:
441
+ """Fetch the sampling defaults the server uses for this model.
442
+
443
+ These are what applies to every field you leave at `None`. Reads
444
+ `GET /upstream/<model>/props` on each call (which loads the model in
445
+ llama-swap). Nothing is stored on the model or sent with requests.
446
+
447
+ Returns:
448
+ `default_generation_settings.params` from `/props`, plus `n_ctx`.
449
+ Floats are rounded to 7 significant digits, since the server stores
450
+ them as 32-bit (it reports `top_p` 0.95 as 0.949999988079071).
451
+
452
+ Example:
453
+ ```python
454
+ ChatLlama(model="gemma").get_server_defaults()
455
+ # {"temperature": 1.0, "top_k": 64, "top_p": 0.95, "max_tokens": -1, ...}
456
+ ```
457
+ """
458
+ response = self._send("GET", f"/upstream/{self.model}/props")
459
+ settings: dict[str, Any] = response.json()["default_generation_settings"]
460
+
461
+ # Undo float32 noise: 0.949999988079071 -> 0.95.
462
+ defaults = {
463
+ key: float(f"{value:.7g}") if isinstance(value, float) else value
464
+ for key, value in settings.get("params", {}).items()
465
+ }
466
+ defaults["n_ctx"] = settings.get("n_ctx")
467
+ return defaults
468
+
469
+ def bind_tools(
470
+ self,
471
+ tools: Sequence[dict[str, Any] | type | Callable[..., Any] | BaseTool],
472
+ *,
473
+ tool_choice: dict[str, Any] | str | bool | None = None,
474
+ strict: bool | None = None,
475
+ parallel_tool_calls: bool | None = None,
476
+ **kwargs: Any,
477
+ ) -> Runnable[LanguageModelInput, AIMessage]:
478
+ """Bind tools to the model, sent as OpenAI-format `tools`.
479
+
480
+ Args:
481
+ tools: Anything `convert_to_openai_tool` accepts: `@tool` functions,
482
+ `BaseTool`s, Pydantic classes, `TypedDict`s, plain functions or
483
+ OpenAI tool dicts.
484
+ tool_choice: llama-server accepts only the strings `"auto"`,
485
+ `"none"` and `"required"`, which are sent as they are.
486
+ `"any"` and `True` become `"required"`. A bound tool's name, or
487
+ `{"type": "function", "function": {"name": ...}}`, also becomes
488
+ `"required"`: the server can't force one particular tool (it
489
+ turns the object form into `"auto"`), so the model must call a
490
+ tool but may pick another one. `None`, `False` and `""` leave
491
+ it out.
492
+
493
+ How strictly `"required"` holds depends on the chat handler
494
+ llama-server picks for the model's template. For Qwen3.5/3.6
495
+ (the handler llama.cpp names "qwen3-coder") the grammar lets
496
+ the model write text before the call and forbids ending without
497
+ one, so a model that doesn't want a tool keeps writing until
498
+ `max_tokens`, with no tool call (llama.cpp issue #27217, open
499
+ as of b11429). Keep `max_tokens` set when forcing tools.
500
+ strict: Sent as `strict` on each tool when set. llama-server
501
+ ignores it: its grammar always enforces the schema.
502
+ parallel_tool_calls: Sent when set.
503
+ **kwargs: Passed through to the request, like `bind`.
504
+
505
+ Returns:
506
+ A runnable that takes the model's input and returns an `AIMessage`.
507
+
508
+ Raises:
509
+ ValueError: If `tool_choice` names a tool that isn't bound, or isn't
510
+ a value described above.
511
+ """
512
+ formatted_tools = [
513
+ convert_to_openai_tool(tool, strict=strict) for tool in tools
514
+ ]
515
+
516
+ if tool_choice: # None, False and "" leave it out, as in ChatOpenAI
517
+ tool_names = [
518
+ tool["function"]["name"]
519
+ for tool in formatted_tools
520
+ if "function" in tool
521
+ ]
522
+
523
+ # llama-server takes only these strings. It turns the object form
524
+ # into "auto" (b11429), so a named tool becomes "required".
525
+ if tool_choice is True or tool_choice == "any":
526
+ tool_choice = "required"
527
+ elif not (
528
+ isinstance(tool_choice, str)
529
+ and tool_choice in {"auto", "none", "required"}
530
+ ):
531
+ name: object = tool_choice
532
+ if isinstance(tool_choice, dict):
533
+ function: object = tool_choice.get("function")
534
+ if isinstance(function, dict):
535
+ name = cast("dict[str, Any]", function).get("name")
536
+ if not (isinstance(name, str) and name in tool_names):
537
+ msg = (
538
+ "tool_choice must be 'auto', 'none', 'required', 'any', "
539
+ "True, or the name of a bound tool "
540
+ f"({', '.join(tool_names)}); got {tool_choice!r}"
541
+ )
542
+ raise ValueError(msg)
543
+ tool_choice = "required"
544
+ kwargs["tool_choice"] = tool_choice
545
+
546
+ if parallel_tool_calls is not None:
547
+ kwargs["parallel_tool_calls"] = parallel_tool_calls
548
+ return self.bind(tools=formatted_tools, **kwargs)
549
+
550
+ def with_structured_output(
551
+ self,
552
+ schema: dict[str, Any] | type | None = None,
553
+ *,
554
+ method: Literal[
555
+ "function_calling", "json_schema", "json_mode"
556
+ ] = "function_calling",
557
+ include_raw: bool = False,
558
+ strict: bool | None = None,
559
+ include_format_instructions: bool = False,
560
+ **kwargs: Any,
561
+ ) -> Runnable[LanguageModelInput, dict[str, Any] | BaseModel]:
562
+ """Return a runnable whose output is parsed into `schema`.
563
+
564
+ Args:
565
+ schema: A Pydantic class, a `TypedDict`, a JSON schema or an OpenAI
566
+ tool/function dict. Optional only for `method="json_mode"`.
567
+ method: How the server is asked for the structure:
568
+
569
+ - `"function_calling"` (default): the schema is the only tool,
570
+ sent with `tool_choice: "required"` and
571
+ `parallel_tool_calls: false`. The chat template shows the
572
+ schema (with its descriptions) to the model, and the
573
+ server's grammar constrains the arguments. With Qwen3.5/3.6,
574
+ a prompt the model doesn't answer with the tool runs to
575
+ `max_tokens` (see `bind_tools`).
576
+ - `"json_schema"`: `response_format: {"type": "json_schema"}`.
577
+ Grammar only: the model never sees the schema or its
578
+ descriptions unless `include_format_instructions=True`.
579
+ - `"json_mode"`: `response_format: {"type": "json_object"}`,
580
+ any JSON object.
581
+ include_raw: Return `{"raw", "parsed", "parsing_error"}` instead of
582
+ raising on a parsing error.
583
+ strict: Sent as `strict` in the tool or `json_schema` when set.
584
+ llama-server ignores it: its grammar always enforces the schema.
585
+ include_format_instructions: For `json_schema` and `json_mode`, add
586
+ core's format instructions for the schema to the system message
587
+ (after a leading system message's content, else as a new first
588
+ system message). Your messages are not modified.
589
+ **kwargs: Passed through to the request, like `bind`.
590
+
591
+ Returns:
592
+ A runnable that takes the model's input and returns an instance of
593
+ the Pydantic class, a dict, or the `include_raw` dict.
594
+
595
+ Raises:
596
+ ValueError: If `schema` is missing where it's needed, `method` is
597
+ unknown, or `include_format_instructions` is combined with
598
+ `function_calling`.
599
+ """
600
+ if method not in {"function_calling", "json_schema", "json_mode"}:
601
+ msg = (
602
+ "method must be 'function_calling', 'json_schema' or 'json_mode', "
603
+ f"got {method!r}"
604
+ )
605
+ raise ValueError(msg)
606
+ if include_format_instructions and method == "function_calling":
607
+ msg = (
608
+ "include_format_instructions applies to json_schema and json_mode; "
609
+ "with function_calling the chat template already shows the schema "
610
+ "to the model."
611
+ )
612
+ raise ValueError(msg)
613
+ if schema is None and (method != "json_mode" or include_format_instructions):
614
+ msg = f"schema is required for method={method!r}"
615
+ if include_format_instructions:
616
+ msg += " with include_format_instructions=True"
617
+ raise ValueError(msg)
618
+ if method == "function_calling" and "tools" in kwargs:
619
+ msg = "with_structured_output binds the schema as the only tool"
620
+ raise ValueError(msg)
621
+
622
+ pydantic_schema = (
623
+ cast("type[BaseModel]", schema)
624
+ if isinstance(schema, type) and is_basemodel_subclass(schema)
625
+ else None
626
+ )
627
+
628
+ ls_kwargs: dict[str, Any] = {"method": method}
629
+ if method != "json_mode":
630
+ ls_kwargs["strict"] = strict
631
+ ls_format = {"kwargs": ls_kwargs, "schema": schema}
632
+
633
+ llm: Runnable[LanguageModelInput, BaseMessage]
634
+ output_parser: Runnable[Any, Any]
635
+ if schema is not None and method == "function_calling":
636
+ given = _given_json_schema(schema)
637
+ tool = (
638
+ _tool_from_json_schema(given, strict=strict)
639
+ if given is not None
640
+ else convert_to_openai_tool(schema, strict=strict)
641
+ )
642
+
643
+ # The caller's kwargs win over our defaults, as in ChatOpenAI.
644
+ bind_kwargs: dict[str, Any] = {
645
+ "tool_choice": "required",
646
+ "parallel_tool_calls": False,
647
+ "ls_structured_output_format": ls_format,
648
+ **kwargs,
649
+ }
650
+ llm = self.bind_tools([tool], **bind_kwargs)
651
+
652
+ output_parser = (
653
+ PydanticToolsParser(tools=[pydantic_schema], first_tool_only=True)
654
+ if pydantic_schema
655
+ else JsonOutputKeyToolsParser(
656
+ key_name=tool["function"]["name"], first_tool_only=True
657
+ )
658
+ )
659
+ else:
660
+ response_format = (
661
+ _json_schema_response_format(schema, strict=strict)
662
+ if schema is not None and method == "json_schema"
663
+ else {"type": "json_object"}
664
+ )
665
+
666
+ llm = self.bind(
667
+ **{
668
+ "response_format": response_format,
669
+ "ls_structured_output_format": ls_format,
670
+ **kwargs,
671
+ }
672
+ )
673
+
674
+ if schema is not None and include_format_instructions:
675
+ instructions = _format_instructions(schema)
676
+
677
+ def add_format_instructions(
678
+ model_input: LanguageModelInput,
679
+ ) -> list[BaseMessage]:
680
+ """Append to a leading system message, or insert one first.
681
+
682
+ Returns a new list; the caller's messages are left untouched.
683
+ """
684
+ messages = self._convert_input(model_input).to_messages()
685
+
686
+ if not (messages and isinstance(messages[0], SystemMessage)):
687
+ return [SystemMessage(content=instructions), *messages]
688
+
689
+ system = messages[0]
690
+ content: str | list[str | dict[str, Any]]
691
+ if isinstance(system.content, str):
692
+ content = f"{system.content}\n\n{instructions}"
693
+ else:
694
+ text = {"type": "text", "text": instructions}
695
+ content = [*system.content, text]
696
+
697
+ update = {"content": content}
698
+ return [system.model_copy(update=update), *messages[1:]]
699
+
700
+ llm = RunnableLambda(add_format_instructions) | llm
701
+
702
+ output_parser = (
703
+ PydanticOutputParser(pydantic_object=pydantic_schema)
704
+ if pydantic_schema
705
+ else JsonOutputParser()
706
+ )
707
+
708
+ if include_raw:
709
+ parser_assign = RunnablePassthrough.assign(
710
+ parsed=itemgetter("raw") | output_parser, parsing_error=lambda _: None
711
+ )
712
+ parser_none = RunnablePassthrough.assign(parsed=lambda _: None)
713
+ parser_with_fallback = parser_assign.with_fallbacks(
714
+ [parser_none], exception_key="parsing_error"
715
+ )
716
+ return RunnableMap(raw=llm) | parser_with_fallback
717
+ return llm | output_parser
718
+
719
+ # --- LangChain core hooks ---------------------------------------------
720
+
721
+ def _get_ls_params(
722
+ self, stop: list[str] | None = None, **kwargs: Any
723
+ ) -> LangSmithParams:
724
+ # Core fills the model name, temperature and max_tokens (per-call first).
725
+ params = super()._get_ls_params(stop=stop or self.stop, **kwargs)
726
+ params["ls_provider"] = MODEL_PROVIDER
727
+ return params
728
+
729
+ def _combine_llm_outputs(
730
+ self, llm_outputs: list[dict[str, Any] | None]
731
+ ) -> dict[str, Any]:
732
+ """Sum `token_usage` across calls, as ChatOpenAI does (used by `generate`)."""
733
+ token_usage: dict[str, Any] = {}
734
+ system_fingerprint = None
735
+ for output in llm_outputs:
736
+ if output is None:
737
+ continue
738
+ _add_usage(token_usage, output.get("token_usage") or {})
739
+ system_fingerprint = system_fingerprint or output.get("system_fingerprint")
740
+
741
+ combined: dict[str, Any] = {
742
+ "token_usage": token_usage,
743
+ "model_name": self.model,
744
+ }
745
+ if system_fingerprint:
746
+ combined["system_fingerprint"] = system_fingerprint
747
+ return combined
748
+
749
+ def _generate(
750
+ self,
751
+ messages: list[BaseMessage],
752
+ stop: list[str] | None = None,
753
+ run_manager: CallbackManagerForLLMRun | None = None, # noqa: ARG002
754
+ **kwargs: Any,
755
+ ) -> ChatResult:
756
+ payload = self._build_payload(messages, stop, **kwargs)
757
+ response = self._send("POST", CHAT_COMPLETIONS, payload)
758
+ data: LlamaResponse = response.json()
759
+ return _create_chat_result(data)
760
+
761
+ async def _agenerate(
762
+ self,
763
+ messages: list[BaseMessage],
764
+ stop: list[str] | None = None,
765
+ run_manager: AsyncCallbackManagerForLLMRun | None = None, # noqa: ARG002
766
+ **kwargs: Any,
767
+ ) -> ChatResult:
768
+ payload = self._build_payload(messages, stop, **kwargs)
769
+ response = await self._asend("POST", CHAT_COMPLETIONS, payload)
770
+ data: LlamaResponse = response.json()
771
+ return _create_chat_result(data)
772
+
773
+ def _stream(
774
+ self,
775
+ messages: list[BaseMessage],
776
+ stop: list[str] | None = None,
777
+ run_manager: CallbackManagerForLLMRun | None = None, # noqa: ARG002
778
+ **kwargs: Any,
779
+ ) -> Iterator[ChatGenerationChunk]:
780
+ # Core calls run_manager.on_llm_new_token for each chunk itself.
781
+ payload = self._build_payload(messages, stop, streaming=True, **kwargs)
782
+ response = self._send("POST", CHAT_COMPLETIONS, payload, stream=True)
783
+
784
+ try:
785
+ for line in response.iter_lines():
786
+ chunk = _chunk_from_line(line, response)
787
+ if chunk is _DONE:
788
+ return
789
+ if chunk is not None:
790
+ yield chunk
791
+ except httpx.TransportError as exc: # the connection broke mid-stream
792
+ raise error_from_transport(exc) from exc
793
+ finally:
794
+ response.close() # also when the caller stops early
795
+
796
+ # Only [DONE] returns early: the stream was cut off.
797
+ raise error_from_cut_off_stream(response)
798
+
799
+ async def _astream(
800
+ self,
801
+ messages: list[BaseMessage],
802
+ stop: list[str] | None = None,
803
+ run_manager: AsyncCallbackManagerForLLMRun | None = None, # noqa: ARG002
804
+ **kwargs: Any,
805
+ ) -> AsyncIterator[ChatGenerationChunk]:
806
+ payload = self._build_payload(messages, stop, streaming=True, **kwargs)
807
+ response = await self._asend("POST", CHAT_COMPLETIONS, payload, stream=True)
808
+
809
+ try:
810
+ async for line in response.aiter_lines():
811
+ chunk = _chunk_from_line(line, response)
812
+ if chunk is _DONE:
813
+ return
814
+ if chunk is not None:
815
+ yield chunk
816
+ except httpx.TransportError as exc:
817
+ raise error_from_transport(exc) from exc
818
+ finally:
819
+ await response.aclose()
820
+
821
+ # Only [DONE] returns early: the stream was cut off.
822
+ raise error_from_cut_off_stream(response)
823
+
824
+ # --- Request building -------------------------------------------------
825
+
826
+ def _build_payload(
827
+ self,
828
+ messages: list[BaseMessage],
829
+ stop: list[str] | None,
830
+ *,
831
+ streaming: bool = False,
832
+ **kwargs: Any,
833
+ ) -> dict[str, Any]:
834
+ stream_usage = kwargs.pop("stream_usage", self.stream_usage)
835
+ # Core leaves `invoke(..., stream=True)` in kwargs; `streaming` decides.
836
+ kwargs.pop("stream", None)
837
+
838
+ payload: dict[str, Any] = {
839
+ "model": self.model,
840
+ "messages": [_convert_message(message) for message in messages],
841
+ **self._params,
842
+ **kwargs,
843
+ }
844
+
845
+ if stop is not None:
846
+ payload["stop"] = stop
847
+ if streaming:
848
+ # Chunks would interleave completions; ChatOpenAI refuses this too.
849
+ # `n_cmpl` is llama-server's alias for `n`.
850
+ if max(payload.get("n") or 1, payload.get("n_cmpl") or 1) > 1:
851
+ msg = "n must be 1 when streaming."
852
+ raise ValueError(msg)
853
+
854
+ payload["stream"] = True
855
+ if stream_usage:
856
+ payload["stream_options"] = {"include_usage": True}
857
+
858
+ return payload
859
+
860
+ def _build_request(
861
+ self, method: str, path: str, payload: dict[str, Any] | None
862
+ ) -> httpx.Request:
863
+ url = self.base_url.rstrip("/") + path
864
+ return httpx.Request(method, url, json=payload)
865
+
866
+ # --- HTTP -------------------------------------------------------------
867
+
868
+ def _get_client(self) -> httpx.Client:
869
+ if self.http_client is not None:
870
+ return self.http_client
871
+ if self._client is None:
872
+ # httpx's own default is a 5 s timeout, so always pass ours.
873
+ self._client = httpx.Client(timeout=self.timeout)
874
+ return self._client
875
+
876
+ def _get_async_client(self) -> httpx.AsyncClient:
877
+ if self.http_async_client is not None:
878
+ return self.http_async_client
879
+
880
+ loop = asyncio.get_running_loop()
881
+ client = self._async_clients.get(loop)
882
+ if client is None:
883
+ # Pooled connections keep their loop alive, so a weak key never
884
+ # dies on its own: drop clients of loops that have closed (each
885
+ # asyncio.run). They can't be aclose()d without their loop.
886
+ for old_loop in [old for old in self._async_clients if old.is_closed()]:
887
+ del self._async_clients[old_loop]
888
+
889
+ client = httpx.AsyncClient(timeout=self.timeout)
890
+ self._async_clients[loop] = client
891
+ return client
892
+
893
+ def _send(
894
+ self,
895
+ method: str,
896
+ path: str,
897
+ payload: dict[str, Any] | None = None,
898
+ *,
899
+ stream: bool = False,
900
+ ) -> httpx.Response:
901
+ """Send a request to `path`, retrying on errors core marks `is_retryable`.
902
+
903
+ With `stream=True` this returns once the headers arrive, so retries only
904
+ cover the request itself; a failure mid-stream is raised, not retried.
905
+ """
906
+ client = self._get_client()
907
+ request = self._build_request(method, path, payload)
908
+ attempt = 0
909
+
910
+ while True:
911
+ try:
912
+ response = client.send(request, stream=stream)
913
+ except httpx.TransportError as exc:
914
+ delay = self._retry_delay_or_raise(
915
+ attempt, error_from_transport(exc), cause=exc
916
+ )
917
+ else:
918
+ if response.is_success:
919
+ return response
920
+
921
+ response.read() # a streamed error body isn't read yet
922
+ response.close()
923
+ delay = self._retry_delay_or_raise(
924
+ attempt, error_from_response(response)
925
+ )
926
+
927
+ time.sleep(delay)
928
+ attempt += 1
929
+
930
+ async def _asend(
931
+ self,
932
+ method: str,
933
+ path: str,
934
+ payload: dict[str, Any] | None = None,
935
+ *,
936
+ stream: bool = False,
937
+ ) -> httpx.Response:
938
+ """Async twin of `_send`."""
939
+ client = self._get_async_client()
940
+ request = self._build_request(method, path, payload)
941
+ attempt = 0
942
+
943
+ while True:
944
+ try:
945
+ response = await client.send(request, stream=stream)
946
+ except httpx.TransportError as exc:
947
+ delay = self._retry_delay_or_raise(
948
+ attempt, error_from_transport(exc), cause=exc
949
+ )
950
+ else:
951
+ if response.is_success:
952
+ return response
953
+
954
+ await response.aread()
955
+ await response.aclose()
956
+ delay = self._retry_delay_or_raise(
957
+ attempt, error_from_response(response)
958
+ )
959
+
960
+ await asyncio.sleep(delay)
961
+ attempt += 1
962
+
963
+ def _retry_delay_or_raise(
964
+ self, attempt: int, error: Exception, cause: BaseException | None = None
965
+ ) -> float:
966
+ """Seconds to wait before retrying `error`, or raise it if it's final.
967
+
968
+ Retried: the errors core marks `is_retryable` (connection, timeout, 429
969
+ and 5xx). A numeric `Retry-After` up to `MAX_RETRY_AFTER` wins over the
970
+ exponential backoff.
971
+ """
972
+ retryable = isinstance(error, ModelError) and error.is_retryable
973
+ if not retryable or attempt >= self.max_retries:
974
+ raise error from cause
975
+
976
+ if isinstance(error, httpx.HTTPStatusError):
977
+ try:
978
+ retry_after = float(error.response.headers.get("retry-after", ""))
979
+ except ValueError: # missing, or an HTTP date
980
+ retry_after = None
981
+ if retry_after is not None and 0 <= retry_after <= MAX_RETRY_AFTER:
982
+ return retry_after
983
+
984
+ delay = min(INITIAL_RETRY_DELAY * 2**attempt, MAX_RETRY_DELAY)
985
+ return delay * (1 - 0.25 * random.random()) # noqa: S311 (jitter, not crypto)
986
+
987
+
988
+ # --- Public functions -----------------------------------------------------
989
+
990
+
991
+ def request_field_names() -> list[str]:
992
+ """Names of ChatLlama's request fields, in declaration order.
993
+
994
+ Everything ChatLlama declares itself, minus the client settings. `model` is
995
+ sent too, but always (it's required), so it isn't listed here.
996
+ """
997
+ inherited = BaseChatModel.model_fields
998
+ return [
999
+ name
1000
+ for name in ChatLlama.model_fields
1001
+ if name not in inherited and name not in _CLIENT_FIELDS
1002
+ ]
1003
+
1004
+
1005
+ _REQUEST_FIELDS = tuple(request_field_names()) # fixed once the class exists
1006
+
1007
+
1008
+ # --- Requests -------------------------------------------------------------
1009
+
1010
+
1011
+ def _convert_message(message: BaseMessage) -> dict[str, Any]:
1012
+ """Convert a message to the OpenAI shape llama-server expects."""
1013
+ converted = convert_to_openai_messages(message)
1014
+
1015
+ if isinstance(message, AIMessage):
1016
+ # Core leaves v1 reasoning blocks in `content`; send text as content and
1017
+ # reasoning as `reasoning_content` (M1-c: the chat template decides).
1018
+ converted["content"] = message.text
1019
+
1020
+ if isinstance(message.content, str):
1021
+ # No blocks to look through; this is where core's best-effort
1022
+ # parsing finds reasoning for string content.
1023
+ kwarg = message.additional_kwargs.get("reasoning_content")
1024
+ reasoning = kwarg if isinstance(kwarg, str) else ""
1025
+ else:
1026
+ reasoning = "".join(
1027
+ block.get("reasoning", "")
1028
+ for block in message.content_blocks
1029
+ if block["type"] == "reasoning"
1030
+ )
1031
+
1032
+ if reasoning:
1033
+ converted["reasoning_content"] = reasoning
1034
+
1035
+ return converted
1036
+
1037
+
1038
+ def _without_none(**items: Any) -> dict[str, Any]:
1039
+ """`items` minus the ones that are None."""
1040
+ return {key: value for key, value in items.items() if value is not None}
1041
+
1042
+
1043
+ # --- Tools and structured output ------------------------------------------
1044
+
1045
+
1046
+ def _json_schema_response_format(
1047
+ schema: dict[str, Any] | type, *, strict: bool | None
1048
+ ) -> dict[str, Any]:
1049
+ """`response_format` for `json_schema`: `{name, description?, schema, strict?}`."""
1050
+ if (given := _given_json_schema(schema)) is not None:
1051
+ json_schema = dict(given)
1052
+ else:
1053
+ function = convert_to_openai_function(schema)
1054
+ json_schema = {"name": function["name"]}
1055
+ if description := function.get("description"):
1056
+ json_schema["description"] = description
1057
+ json_schema["schema"] = function["parameters"]
1058
+
1059
+ if strict is not None:
1060
+ json_schema["strict"] = strict
1061
+ return {"type": "json_schema", "json_schema": json_schema}
1062
+
1063
+
1064
+ def _tool_from_json_schema(
1065
+ given: dict[str, Any], *, strict: bool | None
1066
+ ) -> dict[str, Any]:
1067
+ """An OpenAI tool from a `{name, description?, schema, strict?}` dict.
1068
+
1069
+ `convert_to_openai_tool` doesn't know this shape: it would keep the name
1070
+ and drop the schema, leaving a tool without parameters.
1071
+ """
1072
+ function: dict[str, Any] = {"name": given["name"]}
1073
+ if description := given.get("description"):
1074
+ function["description"] = description
1075
+ function["parameters"] = given["schema"]
1076
+
1077
+ if strict is None:
1078
+ strict = given.get("strict")
1079
+ if strict is not None:
1080
+ function["strict"] = strict
1081
+
1082
+ return {"type": "function", "function": function}
1083
+
1084
+
1085
+ def _given_json_schema(schema: dict[str, Any] | type) -> dict[str, Any] | None:
1086
+ """The `{name, schema, ...}` part of a schema already in OpenAI's shape.
1087
+
1088
+ Accepts the two shapes ChatOpenAI does: a whole `response_format`
1089
+ (`{"type": "json_schema", "json_schema": {...}}`) and its inner
1090
+ `{"name": ..., "schema": ...}`. Anything else returns None.
1091
+
1092
+ Raises:
1093
+ ValueError: If a `response_format` has no `json_schema.schema` object.
1094
+ """
1095
+ if not isinstance(schema, dict):
1096
+ return None
1097
+
1098
+ if schema.get("type") == "json_schema":
1099
+ inner: object = schema.get("json_schema")
1100
+ if not (
1101
+ isinstance(inner, dict)
1102
+ and "name" in inner
1103
+ and isinstance(cast("dict[str, Any]", inner).get("schema"), dict)
1104
+ ):
1105
+ msg = (
1106
+ "a json_schema response_format needs "
1107
+ '{"json_schema": {"name": ..., "schema": {...}}}'
1108
+ )
1109
+ raise ValueError(msg)
1110
+ return cast("dict[str, Any]", inner)
1111
+
1112
+ if "name" in schema and isinstance(schema.get("schema"), dict):
1113
+ return schema
1114
+ return None
1115
+
1116
+
1117
+ def _format_instructions(schema: dict[str, Any] | type) -> str:
1118
+ """Core's format instructions for `schema`.
1119
+
1120
+ Pydantic classes get `PydanticOutputParser.get_format_instructions()`. Other
1121
+ schemas get the text `JsonOutputParser.get_format_instructions()` builds,
1122
+ which it only does for Pydantic classes itself: the schema minus `title`
1123
+ and `type`, in `JSON_FORMAT_INSTRUCTIONS`.
1124
+ """
1125
+ if isinstance(schema, type) and is_basemodel_subclass(schema):
1126
+ return PydanticOutputParser(pydantic_object=schema).get_format_instructions()
1127
+
1128
+ if (given := _given_json_schema(schema)) is not None:
1129
+ reduced = dict(given["schema"])
1130
+ else:
1131
+ reduced = convert_to_json_schema(schema)
1132
+
1133
+ reduced.pop("title", None)
1134
+ reduced.pop("type", None)
1135
+ return JSON_FORMAT_INSTRUCTIONS.format(
1136
+ schema=json.dumps(reduced, ensure_ascii=False)
1137
+ )
1138
+
1139
+
1140
+ # --- Replies --------------------------------------------------------------
1141
+
1142
+
1143
+ def _create_chat_result(data: LlamaResponse) -> ChatResult:
1144
+ usage = data.get("usage")
1145
+ response_metadata = _without_none(
1146
+ model_name=data.get("model"),
1147
+ system_fingerprint=data.get("system_fingerprint"),
1148
+ id=data.get("id"),
1149
+ timings=data.get("timings"),
1150
+ model_provider=MODEL_PROVIDER,
1151
+ )
1152
+
1153
+ generations: list[ChatGeneration] = []
1154
+ for choice in data.get("choices") or []:
1155
+ message = _convert_response_message(choice.get("message") or LlamaMessage())
1156
+ message.response_metadata = dict(response_metadata)
1157
+ if usage:
1158
+ message.usage_metadata = _create_usage_metadata(usage)
1159
+
1160
+ generation_info: dict[str, Any] = {"finish_reason": choice.get("finish_reason")}
1161
+ if logprobs := choice.get("logprobs"):
1162
+ generation_info["logprobs"] = logprobs
1163
+ generations.append(
1164
+ ChatGeneration(message=message, generation_info=generation_info)
1165
+ )
1166
+
1167
+ llm_output = {
1168
+ "token_usage": usage,
1169
+ "model_name": data.get("model"),
1170
+ "system_fingerprint": data.get("system_fingerprint"),
1171
+ "id": data.get("id"),
1172
+ }
1173
+ return ChatResult(generations=generations, llm_output=llm_output)
1174
+
1175
+
1176
+ def _convert_response_message(message: LlamaMessage) -> AIMessage:
1177
+ additional_kwargs: dict[str, Any] = {}
1178
+ if reasoning := message.get("reasoning_content"):
1179
+ additional_kwargs["reasoning_content"] = reasoning
1180
+
1181
+ tool_calls: list[ToolCall] = []
1182
+ invalid_tool_calls: list[InvalidToolCall] = []
1183
+ for raw in message.get("tool_calls") or []:
1184
+ if not isinstance(raw.get("function"), dict): # parse_tool_call drops these
1185
+ invalid_tool_calls.append(
1186
+ create_invalid_tool_call(
1187
+ name=None,
1188
+ args=None,
1189
+ id=raw.get("id"),
1190
+ error="tool call has no 'function' object",
1191
+ )
1192
+ )
1193
+ continue
1194
+
1195
+ raw_dict = dict(raw) # core's parsers take a plain dict
1196
+ try:
1197
+ parsed = parse_tool_call(raw_dict, return_id=True)
1198
+ except OutputParserException as exc: # arguments aren't valid JSON
1199
+ invalid_tool_calls.append(make_invalid_tool_call(raw_dict, str(exc)))
1200
+ continue
1201
+ if parsed is not None: # with return_id, already a complete ToolCall
1202
+ tool_calls.append(cast("ToolCall", parsed))
1203
+
1204
+ content = message.get("content") or ""
1205
+ return AIMessage(
1206
+ content=content if isinstance(content, str) else [dict(p) for p in content],
1207
+ additional_kwargs=additional_kwargs,
1208
+ tool_calls=tool_calls,
1209
+ invalid_tool_calls=invalid_tool_calls,
1210
+ )
1211
+
1212
+
1213
+ def _create_usage_metadata(usage: LlamaUsage) -> UsageMetadata:
1214
+ """Map llama-server's `usage` the way ChatOpenAI does."""
1215
+ input_tokens = usage.get("prompt_tokens") or 0
1216
+ output_tokens = usage.get("completion_tokens") or 0
1217
+ total_tokens = usage.get("total_tokens")
1218
+ prompt_details = usage.get("prompt_tokens_details") or LlamaPromptTokensDetails()
1219
+ completion_details = (
1220
+ usage.get("completion_tokens_details") or LlamaCompletionTokensDetails()
1221
+ )
1222
+
1223
+ cache_read = prompt_details.get("cached_tokens")
1224
+ reasoning = completion_details.get("reasoning_tokens")
1225
+
1226
+ input_details = InputTokenDetails()
1227
+ if cache_read is not None:
1228
+ input_details["cache_read"] = cache_read
1229
+
1230
+ output_details = OutputTokenDetails()
1231
+ if reasoning is not None:
1232
+ output_details["reasoning"] = reasoning
1233
+
1234
+ return UsageMetadata(
1235
+ input_tokens=input_tokens,
1236
+ output_tokens=output_tokens,
1237
+ total_tokens=(
1238
+ total_tokens if total_tokens is not None else input_tokens + output_tokens
1239
+ ),
1240
+ input_token_details=input_details,
1241
+ output_token_details=output_details,
1242
+ )
1243
+
1244
+
1245
+ def _add_usage(total: dict[str, Any], usage: Mapping[str, Any]) -> None:
1246
+ """Add `usage` into `total`, recursing into nested detail dicts."""
1247
+ for key, value in usage.items():
1248
+ if value is None:
1249
+ continue
1250
+ if isinstance(value, Mapping):
1251
+ _add_usage(total.setdefault(key, {}), value) # pyright: ignore[reportUnknownArgumentType]
1252
+ elif isinstance(value, int | float):
1253
+ total[key] = total.get(key, 0) + value
1254
+
1255
+
1256
+ # --- Streams --------------------------------------------------------------
1257
+
1258
+
1259
+ def _chunk_from_line(line: str, response: httpx.Response) -> ChatGenerationChunk | None:
1260
+ """The chunk for one SSE line: None to skip the line, `_DONE` at the end.
1261
+
1262
+ Lines other than `data:` (blank separators, `:` keep-alive pings) are
1263
+ skipped. An `error` event (a failure mid-stream) is raised, and so is data
1264
+ that isn't a JSON object, as an API error.
1265
+ """
1266
+ if not line.startswith("data:"):
1267
+ return None
1268
+ data = line[len("data:") :].strip()
1269
+ if data == "[DONE]":
1270
+ return _DONE
1271
+
1272
+ try:
1273
+ event: object = json.loads(data)
1274
+ except ValueError as exc:
1275
+ detail = {"message": f"invalid JSON in stream event: {data[:200]!r}"}
1276
+ raise error_from_stream_event(detail, response) from exc
1277
+
1278
+ if not isinstance(event, dict):
1279
+ detail = {"message": f"stream event isn't a JSON object: {data[:200]!r}"}
1280
+ raise error_from_stream_event(detail, response)
1281
+
1282
+ if "error" in event:
1283
+ raise error_from_stream_event(cast("dict[str, Any]", event)["error"], response)
1284
+ return _convert_chunk(cast("LlamaChunk", event))
1285
+
1286
+
1287
+ def _convert_chunk(event: LlamaChunk) -> ChatGenerationChunk | None:
1288
+ """Convert one streamed `chat.completion.chunk` to a LangChain chunk.
1289
+
1290
+ Core merges chunks by concatenating string metadata (except `model_provider`
1291
+ and `id`), so `finish_reason`, `model_name` and `system_fingerprint` go on
1292
+ the finish chunk only.
1293
+ """
1294
+ choices = event.get("choices") or []
1295
+ if not choices:
1296
+ # The last chunk with `stream_options.include_usage`: usage and timings.
1297
+ usage = event.get("usage")
1298
+ timings = event.get("timings")
1299
+ if usage is None and timings is None:
1300
+ return None
1301
+
1302
+ response_metadata: dict[str, Any] = {"model_provider": MODEL_PROVIDER}
1303
+ if timings is not None:
1304
+ response_metadata["timings"] = timings
1305
+
1306
+ message = AIMessageChunk(
1307
+ content="",
1308
+ usage_metadata=_create_usage_metadata(usage) if usage else None,
1309
+ response_metadata=response_metadata,
1310
+ )
1311
+ return ChatGenerationChunk(message=message)
1312
+
1313
+ choice = choices[0]
1314
+ delta = choice.get("delta") or LlamaDelta()
1315
+
1316
+ additional_kwargs: dict[str, Any] = {}
1317
+ if reasoning := delta.get("reasoning_content"):
1318
+ additional_kwargs["reasoning_content"] = reasoning
1319
+
1320
+ tool_call_chunks: list[ToolCallChunk] = []
1321
+ for raw in delta.get("tool_calls") or []:
1322
+ # The id and name arrive in the first fragment, the arguments in pieces.
1323
+ function = raw.get("function") or LlamaFunction()
1324
+ tool_call_chunks.append(
1325
+ create_tool_call_chunk(
1326
+ name=function.get("name"),
1327
+ args=function.get("arguments"),
1328
+ id=raw.get("id"),
1329
+ index=raw.get("index"),
1330
+ )
1331
+ )
1332
+
1333
+ message = AIMessageChunk(
1334
+ content=delta.get("content") or "",
1335
+ additional_kwargs=additional_kwargs,
1336
+ tool_call_chunks=tool_call_chunks,
1337
+ response_metadata={"model_provider": MODEL_PROVIDER},
1338
+ )
1339
+
1340
+ generation_info: dict[str, Any] = {}
1341
+ if finish_reason := choice.get("finish_reason"):
1342
+ generation_info = _without_none(
1343
+ finish_reason=finish_reason,
1344
+ model_name=event.get("model"),
1345
+ system_fingerprint=event.get("system_fingerprint"),
1346
+ id=event.get("id"),
1347
+ )
1348
+ if logprobs := choice.get("logprobs"):
1349
+ generation_info["logprobs"] = logprobs
1350
+ return ChatGenerationChunk(message=message, generation_info=generation_info or None)