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.
- langchain_llama_cpp/__init__.py +27 -0
- langchain_llama_cpp/_wire.py +328 -0
- langchain_llama_cpp/chat_models.py +1350 -0
- langchain_llama_cpp/content_blocks.py +73 -0
- langchain_llama_cpp/exceptions.py +197 -0
- langchain_llama_cpp/py.typed +0 -0
- langchain_llama_cpp/utils.py +34 -0
- langchain_llama_cpp-0.1.0.dist-info/METADATA +248 -0
- langchain_llama_cpp-0.1.0.dist-info/RECORD +11 -0
- langchain_llama_cpp-0.1.0.dist-info/WHEEL +4 -0
- langchain_llama_cpp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|