langchain-llama-cpp 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- langchain_llama_cpp-0.1.0/PKG-INFO +248 -0
- langchain_llama_cpp-0.1.0/README.md +222 -0
- langchain_llama_cpp-0.1.0/pyproject.toml +112 -0
- langchain_llama_cpp-0.1.0/pyproject.toml.orig +102 -0
- langchain_llama_cpp-0.1.0/src/langchain_llama_cpp/__init__.py +27 -0
- langchain_llama_cpp-0.1.0/src/langchain_llama_cpp/_wire.py +328 -0
- langchain_llama_cpp-0.1.0/src/langchain_llama_cpp/chat_models.py +1350 -0
- langchain_llama_cpp-0.1.0/src/langchain_llama_cpp/content_blocks.py +73 -0
- langchain_llama_cpp-0.1.0/src/langchain_llama_cpp/exceptions.py +197 -0
- langchain_llama_cpp-0.1.0/src/langchain_llama_cpp/py.typed +0 -0
- langchain_llama_cpp-0.1.0/src/langchain_llama_cpp/utils.py +34 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bharathan Mudaliar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: langchain-llama-cpp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An integration package connecting llama.cpp's llama-server and LangChain, with reasoning, timings, structured output and tool calling
|
|
5
|
+
Keywords: langchain,llama.cpp,llama-server,llama-swap,llm,local-llm,chat-model
|
|
6
|
+
Author: Bharathan Mudaliar
|
|
7
|
+
Author-email: Bharathan Mudaliar <bharathanmudaliar@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: httpx>=0.27,<1
|
|
21
|
+
Requires-Dist: langchain-core>=1.6,<2
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Project-URL: Repository, https://github.com/bharat029/langchain-llama-cpp
|
|
24
|
+
Project-URL: Issues, https://github.com/bharat029/langchain-llama-cpp/issues
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# langchain-llama-cpp
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/langchain-llama-cpp/)
|
|
30
|
+
[](https://opensource.org/licenses/MIT)
|
|
31
|
+
|
|
32
|
+
A LangChain chat model, `ChatLlama`, for llama.cpp's `llama-server`. It's built
|
|
33
|
+
on `langchain-core` and `httpx` only and talks to `/v1/chat/completions`
|
|
34
|
+
directly, so nothing llama-server sends back is lost on the way.
|
|
35
|
+
|
|
36
|
+
**Why not `ChatOpenAI`?** llama-server speaks the OpenAI format, but with extras
|
|
37
|
+
that `ChatOpenAI` drops or gets wrong:
|
|
38
|
+
|
|
39
|
+
- it drops `reasoning_content` (the model's thinking) and llama-server's `timings`;
|
|
40
|
+
- it sends a named `tool_choice` as an object, which llama-server silently turns
|
|
41
|
+
into `"auto"`;
|
|
42
|
+
- it can't send llama.cpp's own sampling fields (`top_k`, `min_p`, `samplers`,
|
|
43
|
+
DRY, XTC, ...) as typed parameters.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install langchain-llama-cpp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
or `uv add langchain-llama-cpp`. Python 3.10+.
|
|
52
|
+
|
|
53
|
+
## Tested setup
|
|
54
|
+
|
|
55
|
+
- **llama-swap** in front of llama-server. `model` is the llama-swap entry to
|
|
56
|
+
use. A bare llama-server (no llama-swap) is not tested yet.
|
|
57
|
+
- llama-server build **b11429**, with gemma 4, Qwen3.6 and gpt-oss, on
|
|
58
|
+
langchain-core 1.6.
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from langchain_llama_cpp import ChatLlama
|
|
64
|
+
|
|
65
|
+
llm = ChatLlama(model="gemma", max_tokens=2000) # base_url from LLAMA_BASE_URL
|
|
66
|
+
msg = llm.invoke("What is 17 * 23? Answer with the number only.")
|
|
67
|
+
|
|
68
|
+
msg.content # '391'
|
|
69
|
+
msg.additional_kwargs["reasoning_content"] # 'The user wants to know the product...'
|
|
70
|
+
msg.usage_metadata # {'input_tokens': 32, 'output_tokens': 223, ...}
|
|
71
|
+
msg.response_metadata["timings"] # {'prompt_n': 25, 'predicted_n': 223, ...}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Set `max_tokens` with room for thinking. Reasoning length varies from run to
|
|
75
|
+
run: gemma answered this in 223 tokens once, and in another run used all of
|
|
76
|
+
`max_tokens=300` and ended mid-thought, with an empty `content` and
|
|
77
|
+
`finish_reason: "length"`.
|
|
78
|
+
|
|
79
|
+
`base_url` defaults to the `LLAMA_BASE_URL` environment variable, e.g.
|
|
80
|
+
`http://localhost:8080`. Pass `base_url=` to override it.
|
|
81
|
+
|
|
82
|
+
Reasoning is stored where ChatDeepSeek and ChatOllama put it,
|
|
83
|
+
`additional_kwargs["reasoning_content"]`, so `msg.content_blocks` shows it as a
|
|
84
|
+
standard `{"type": "reasoning"}` block. `langchain_llama_cpp.utils.pretty_print(msg)`
|
|
85
|
+
prints a message with its reasoning.
|
|
86
|
+
|
|
87
|
+
## Usage
|
|
88
|
+
|
|
89
|
+
### Thinking
|
|
90
|
+
|
|
91
|
+
The switch lives in the chat template, and its name differs per model family:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
ChatLlama(model="gemma", chat_template_kwargs={"enable_thinking": False}) # gemma, qwen
|
|
95
|
+
ChatLlama(model="gpt-oss", chat_template_kwargs={"reasoning_effort": "low"}) # gpt-oss
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Thinking tokens count towards `max_tokens` (see Quick start).
|
|
99
|
+
|
|
100
|
+
When you send a conversation back, an `AIMessage`'s reasoning goes back too, and
|
|
101
|
+
the model's chat template decides whether to show it to the model.
|
|
102
|
+
|
|
103
|
+
### Streaming and async
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
for chunk in llm.stream("Count to five."):
|
|
107
|
+
print(chunk.content, end="")
|
|
108
|
+
|
|
109
|
+
msg = await llm.ainvoke("Hello!")
|
|
110
|
+
async for chunk in llm.astream("Hello!"):
|
|
111
|
+
...
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Reasoning arrives in `chunk.additional_kwargs["reasoning_content"]`. Usage and
|
|
115
|
+
timings come in the last chunk (`stream_usage=True`, the default).
|
|
116
|
+
|
|
117
|
+
### Tool calling
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from langchain_core.tools import tool
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@tool
|
|
124
|
+
def get_weather(city: str) -> str:
|
|
125
|
+
"""Get the current weather in a city."""
|
|
126
|
+
return f"Sunny in {city}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
llm_with_tools = llm.bind_tools([get_weather])
|
|
130
|
+
msg = llm_with_tools.invoke("What's the weather in Paris?")
|
|
131
|
+
msg.tool_calls # [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': '...', ...}]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
llama-server accepts only the strings `"auto"`, `"none"` and `"required"` as
|
|
135
|
+
`tool_choice`. A tool's name (or `"any"`, or `True`) is sent as `"required"`:
|
|
136
|
+
the model must call a tool but may pick a different one, because the server
|
|
137
|
+
can't force a particular tool.
|
|
138
|
+
|
|
139
|
+
### Structured output
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from pydantic import BaseModel, Field
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Person(BaseModel):
|
|
146
|
+
name: str
|
|
147
|
+
age: int = Field(description="Age in years")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
structured = llm.with_structured_output(Person)
|
|
151
|
+
structured.invoke("Alice is 30.") # Person(name='Alice', age=30)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Three methods:
|
|
155
|
+
|
|
156
|
+
| `method` | What's sent | The model sees the schema? |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `"function_calling"` (default) | the schema as the only tool, `tool_choice: "required"` | yes, through the chat template |
|
|
159
|
+
| `"json_schema"` | `response_format: {"type": "json_schema", ...}` | no: it's enforced by the grammar only |
|
|
160
|
+
| `"json_mode"` | `response_format: {"type": "json_object"}` | no |
|
|
161
|
+
|
|
162
|
+
`function_calling` is the default because with `json_schema` the model never
|
|
163
|
+
sees the schema's field descriptions: asked for an age described as "in months",
|
|
164
|
+
it fills in years. Pass `include_format_instructions=True` with `json_schema` or
|
|
165
|
+
`json_mode` to add the schema to the system message.
|
|
166
|
+
|
|
167
|
+
### Request parameters
|
|
168
|
+
|
|
169
|
+
Every field `/v1/chat/completions` accepts in b11429 is a typed parameter:
|
|
170
|
+
`temperature`, `top_k`, `top_p`, `min_p`, `samplers`, `repeat_penalty`, the DRY
|
|
171
|
+
and XTC samplers, `grammar`, `json_schema`, `reasoning_budget_tokens`,
|
|
172
|
+
`cache_prompt`, `id_slot`, `lora` and more. Set them on the model or per call:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
llm = ChatLlama(model="gemma", temperature=0.2, top_k=40)
|
|
176
|
+
llm.invoke("Hi", max_tokens=500)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
They all default to `None`, which leaves them out of the request so the
|
|
180
|
+
server's per-model defaults apply. To see those defaults:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
llm.get_server_defaults()
|
|
184
|
+
# {'temperature': 1.0, 'top_k': 64, 'top_p': 0.95, 'max_tokens': -1, ..., 'n_ctx': 131072}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Errors and retries
|
|
188
|
+
|
|
189
|
+
Errors are subclasses of both the httpx exception and LangChain's `ModelError`
|
|
190
|
+
classes, so either `except` works:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from langchain_llama_cpp import LlamaContextOverflowError
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
llm.invoke(very_long_prompt)
|
|
197
|
+
except LlamaContextOverflowError as error:
|
|
198
|
+
print(error.n_prompt_tokens, error.n_ctx)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
There are also `LlamaModelNotFoundError` (an unknown llama-swap entry),
|
|
202
|
+
`LlamaInvalidRequestError`, `LlamaRateLimitError`, `LlamaAPIError` (5xx),
|
|
203
|
+
`LlamaConnectionError` and `LlamaTimeoutError`.
|
|
204
|
+
|
|
205
|
+
Connection errors, timeouts, 429 and 5xx are retried `max_retries` times
|
|
206
|
+
(default 2) with exponential backoff, obeying `Retry-After`. `timeout` is in
|
|
207
|
+
seconds and defaults to `None` (no timeout), since local generation can be slow.
|
|
208
|
+
|
|
209
|
+
## Known limitations
|
|
210
|
+
|
|
211
|
+
- **Forcing a tool with Qwen3.5/3.6.** With `tool_choice="required"` (also used
|
|
212
|
+
by `with_structured_output`'s default method), llama-server's grammar for these
|
|
213
|
+
models lets the model write text before the call and won't let it stop
|
|
214
|
+
without one. A model that doesn't want a tool keeps writing until
|
|
215
|
+
`max_tokens`, and there's no tool call. This is
|
|
216
|
+
[ggml-org/llama.cpp#27217](https://github.com/ggml-org/llama.cpp/issues/27217);
|
|
217
|
+
keep `max_tokens` set.
|
|
218
|
+
- **A named `tool_choice`** is sent as `"required"` (see Tool calling).
|
|
219
|
+
- **JSON schema bounds on numbers** (`minimum`, `maximum`, `multipleOf` on
|
|
220
|
+
`number`) aren't enforced by llama-server's grammar. Integer bounds are.
|
|
221
|
+
- **Bare llama-server** (without llama-swap) isn't tested; `get_server_defaults`
|
|
222
|
+
needs llama-swap's `/upstream/<model>/props`.
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
Bug reports and pull requests are welcome on
|
|
227
|
+
[GitHub](https://github.com/bharat029/langchain-llama-cpp/issues).
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
uv sync
|
|
231
|
+
uv run pytest tests/unit_tests # offline
|
|
232
|
+
uv run ruff check && uv run ruff format --check && uv run pyright
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The integration tests (LangChain's standard `langchain-tests` suite, on gemma,
|
|
236
|
+
qwen and gpt-oss) need a llama-swap server. Copy `.env.example` to `.env` and
|
|
237
|
+
set `LLAMA_BASE_URL`, then:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
uv run --env-file .env pytest tests/integration_tests
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The JSON llama-server sends back is described in
|
|
244
|
+
`src/langchain_llama_cpp/_wire.py`.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# langchain-llama-cpp
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/langchain-llama-cpp/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
A LangChain chat model, `ChatLlama`, for llama.cpp's `llama-server`. It's built
|
|
7
|
+
on `langchain-core` and `httpx` only and talks to `/v1/chat/completions`
|
|
8
|
+
directly, so nothing llama-server sends back is lost on the way.
|
|
9
|
+
|
|
10
|
+
**Why not `ChatOpenAI`?** llama-server speaks the OpenAI format, but with extras
|
|
11
|
+
that `ChatOpenAI` drops or gets wrong:
|
|
12
|
+
|
|
13
|
+
- it drops `reasoning_content` (the model's thinking) and llama-server's `timings`;
|
|
14
|
+
- it sends a named `tool_choice` as an object, which llama-server silently turns
|
|
15
|
+
into `"auto"`;
|
|
16
|
+
- it can't send llama.cpp's own sampling fields (`top_k`, `min_p`, `samplers`,
|
|
17
|
+
DRY, XTC, ...) as typed parameters.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install langchain-llama-cpp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
or `uv add langchain-llama-cpp`. Python 3.10+.
|
|
26
|
+
|
|
27
|
+
## Tested setup
|
|
28
|
+
|
|
29
|
+
- **llama-swap** in front of llama-server. `model` is the llama-swap entry to
|
|
30
|
+
use. A bare llama-server (no llama-swap) is not tested yet.
|
|
31
|
+
- llama-server build **b11429**, with gemma 4, Qwen3.6 and gpt-oss, on
|
|
32
|
+
langchain-core 1.6.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from langchain_llama_cpp import ChatLlama
|
|
38
|
+
|
|
39
|
+
llm = ChatLlama(model="gemma", max_tokens=2000) # base_url from LLAMA_BASE_URL
|
|
40
|
+
msg = llm.invoke("What is 17 * 23? Answer with the number only.")
|
|
41
|
+
|
|
42
|
+
msg.content # '391'
|
|
43
|
+
msg.additional_kwargs["reasoning_content"] # 'The user wants to know the product...'
|
|
44
|
+
msg.usage_metadata # {'input_tokens': 32, 'output_tokens': 223, ...}
|
|
45
|
+
msg.response_metadata["timings"] # {'prompt_n': 25, 'predicted_n': 223, ...}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Set `max_tokens` with room for thinking. Reasoning length varies from run to
|
|
49
|
+
run: gemma answered this in 223 tokens once, and in another run used all of
|
|
50
|
+
`max_tokens=300` and ended mid-thought, with an empty `content` and
|
|
51
|
+
`finish_reason: "length"`.
|
|
52
|
+
|
|
53
|
+
`base_url` defaults to the `LLAMA_BASE_URL` environment variable, e.g.
|
|
54
|
+
`http://localhost:8080`. Pass `base_url=` to override it.
|
|
55
|
+
|
|
56
|
+
Reasoning is stored where ChatDeepSeek and ChatOllama put it,
|
|
57
|
+
`additional_kwargs["reasoning_content"]`, so `msg.content_blocks` shows it as a
|
|
58
|
+
standard `{"type": "reasoning"}` block. `langchain_llama_cpp.utils.pretty_print(msg)`
|
|
59
|
+
prints a message with its reasoning.
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
### Thinking
|
|
64
|
+
|
|
65
|
+
The switch lives in the chat template, and its name differs per model family:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
ChatLlama(model="gemma", chat_template_kwargs={"enable_thinking": False}) # gemma, qwen
|
|
69
|
+
ChatLlama(model="gpt-oss", chat_template_kwargs={"reasoning_effort": "low"}) # gpt-oss
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Thinking tokens count towards `max_tokens` (see Quick start).
|
|
73
|
+
|
|
74
|
+
When you send a conversation back, an `AIMessage`'s reasoning goes back too, and
|
|
75
|
+
the model's chat template decides whether to show it to the model.
|
|
76
|
+
|
|
77
|
+
### Streaming and async
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
for chunk in llm.stream("Count to five."):
|
|
81
|
+
print(chunk.content, end="")
|
|
82
|
+
|
|
83
|
+
msg = await llm.ainvoke("Hello!")
|
|
84
|
+
async for chunk in llm.astream("Hello!"):
|
|
85
|
+
...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Reasoning arrives in `chunk.additional_kwargs["reasoning_content"]`. Usage and
|
|
89
|
+
timings come in the last chunk (`stream_usage=True`, the default).
|
|
90
|
+
|
|
91
|
+
### Tool calling
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from langchain_core.tools import tool
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@tool
|
|
98
|
+
def get_weather(city: str) -> str:
|
|
99
|
+
"""Get the current weather in a city."""
|
|
100
|
+
return f"Sunny in {city}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
llm_with_tools = llm.bind_tools([get_weather])
|
|
104
|
+
msg = llm_with_tools.invoke("What's the weather in Paris?")
|
|
105
|
+
msg.tool_calls # [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': '...', ...}]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
llama-server accepts only the strings `"auto"`, `"none"` and `"required"` as
|
|
109
|
+
`tool_choice`. A tool's name (or `"any"`, or `True`) is sent as `"required"`:
|
|
110
|
+
the model must call a tool but may pick a different one, because the server
|
|
111
|
+
can't force a particular tool.
|
|
112
|
+
|
|
113
|
+
### Structured output
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from pydantic import BaseModel, Field
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Person(BaseModel):
|
|
120
|
+
name: str
|
|
121
|
+
age: int = Field(description="Age in years")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
structured = llm.with_structured_output(Person)
|
|
125
|
+
structured.invoke("Alice is 30.") # Person(name='Alice', age=30)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Three methods:
|
|
129
|
+
|
|
130
|
+
| `method` | What's sent | The model sees the schema? |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `"function_calling"` (default) | the schema as the only tool, `tool_choice: "required"` | yes, through the chat template |
|
|
133
|
+
| `"json_schema"` | `response_format: {"type": "json_schema", ...}` | no: it's enforced by the grammar only |
|
|
134
|
+
| `"json_mode"` | `response_format: {"type": "json_object"}` | no |
|
|
135
|
+
|
|
136
|
+
`function_calling` is the default because with `json_schema` the model never
|
|
137
|
+
sees the schema's field descriptions: asked for an age described as "in months",
|
|
138
|
+
it fills in years. Pass `include_format_instructions=True` with `json_schema` or
|
|
139
|
+
`json_mode` to add the schema to the system message.
|
|
140
|
+
|
|
141
|
+
### Request parameters
|
|
142
|
+
|
|
143
|
+
Every field `/v1/chat/completions` accepts in b11429 is a typed parameter:
|
|
144
|
+
`temperature`, `top_k`, `top_p`, `min_p`, `samplers`, `repeat_penalty`, the DRY
|
|
145
|
+
and XTC samplers, `grammar`, `json_schema`, `reasoning_budget_tokens`,
|
|
146
|
+
`cache_prompt`, `id_slot`, `lora` and more. Set them on the model or per call:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
llm = ChatLlama(model="gemma", temperature=0.2, top_k=40)
|
|
150
|
+
llm.invoke("Hi", max_tokens=500)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
They all default to `None`, which leaves them out of the request so the
|
|
154
|
+
server's per-model defaults apply. To see those defaults:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
llm.get_server_defaults()
|
|
158
|
+
# {'temperature': 1.0, 'top_k': 64, 'top_p': 0.95, 'max_tokens': -1, ..., 'n_ctx': 131072}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Errors and retries
|
|
162
|
+
|
|
163
|
+
Errors are subclasses of both the httpx exception and LangChain's `ModelError`
|
|
164
|
+
classes, so either `except` works:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from langchain_llama_cpp import LlamaContextOverflowError
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
llm.invoke(very_long_prompt)
|
|
171
|
+
except LlamaContextOverflowError as error:
|
|
172
|
+
print(error.n_prompt_tokens, error.n_ctx)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
There are also `LlamaModelNotFoundError` (an unknown llama-swap entry),
|
|
176
|
+
`LlamaInvalidRequestError`, `LlamaRateLimitError`, `LlamaAPIError` (5xx),
|
|
177
|
+
`LlamaConnectionError` and `LlamaTimeoutError`.
|
|
178
|
+
|
|
179
|
+
Connection errors, timeouts, 429 and 5xx are retried `max_retries` times
|
|
180
|
+
(default 2) with exponential backoff, obeying `Retry-After`. `timeout` is in
|
|
181
|
+
seconds and defaults to `None` (no timeout), since local generation can be slow.
|
|
182
|
+
|
|
183
|
+
## Known limitations
|
|
184
|
+
|
|
185
|
+
- **Forcing a tool with Qwen3.5/3.6.** With `tool_choice="required"` (also used
|
|
186
|
+
by `with_structured_output`'s default method), llama-server's grammar for these
|
|
187
|
+
models lets the model write text before the call and won't let it stop
|
|
188
|
+
without one. A model that doesn't want a tool keeps writing until
|
|
189
|
+
`max_tokens`, and there's no tool call. This is
|
|
190
|
+
[ggml-org/llama.cpp#27217](https://github.com/ggml-org/llama.cpp/issues/27217);
|
|
191
|
+
keep `max_tokens` set.
|
|
192
|
+
- **A named `tool_choice`** is sent as `"required"` (see Tool calling).
|
|
193
|
+
- **JSON schema bounds on numbers** (`minimum`, `maximum`, `multipleOf` on
|
|
194
|
+
`number`) aren't enforced by llama-server's grammar. Integer bounds are.
|
|
195
|
+
- **Bare llama-server** (without llama-swap) isn't tested; `get_server_defaults`
|
|
196
|
+
needs llama-swap's `/upstream/<model>/props`.
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
Bug reports and pull requests are welcome on
|
|
201
|
+
[GitHub](https://github.com/bharat029/langchain-llama-cpp/issues).
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
uv sync
|
|
205
|
+
uv run pytest tests/unit_tests # offline
|
|
206
|
+
uv run ruff check && uv run ruff format --check && uv run pyright
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The integration tests (LangChain's standard `langchain-tests` suite, on gemma,
|
|
210
|
+
qwen and gpt-oss) need a llama-swap server. Copy `.env.example` to `.env` and
|
|
211
|
+
set `LLAMA_BASE_URL`, then:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
uv run --env-file .env pytest tests/integration_tests
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The JSON llama-server sends back is described in
|
|
218
|
+
`src/langchain_llama_cpp/_wire.py`.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "langchain-llama-cpp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An integration package connecting llama.cpp's llama-server and LangChain, with reasoning, timings, structured output and tool calling"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = [
|
|
10
|
+
"langchain",
|
|
11
|
+
"llama.cpp",
|
|
12
|
+
"llama-server",
|
|
13
|
+
"llama-swap",
|
|
14
|
+
"llm",
|
|
15
|
+
"local-llm",
|
|
16
|
+
"chat-model",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"httpx>=0.27,<1",
|
|
32
|
+
"langchain-core>=1.6,<2",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[[project.authors]]
|
|
36
|
+
name = "Bharathan Mudaliar"
|
|
37
|
+
email = "bharathanmudaliar@gmail.com"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Repository = "https://github.com/bharat029/langchain-llama-cpp"
|
|
41
|
+
Issues = "https://github.com/bharat029/langchain-llama-cpp/issues"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
dev = [
|
|
49
|
+
"langchain-tests>=1.1.9",
|
|
50
|
+
"pyright>=1.1.414",
|
|
51
|
+
"pytest>=9.1.1",
|
|
52
|
+
"pytest-asyncio>=1.4.0",
|
|
53
|
+
"ruff>=0.17.0",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.format]
|
|
57
|
+
docstring-code-format = true
|
|
58
|
+
docstring-code-line-length = 100
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["ALL"]
|
|
62
|
+
ignore = [
|
|
63
|
+
"CPY001",
|
|
64
|
+
"COM812",
|
|
65
|
+
"ISC001",
|
|
66
|
+
"ANN401",
|
|
67
|
+
"TC002",
|
|
68
|
+
"TC003",
|
|
69
|
+
"RUF012",
|
|
70
|
+
"PLR0913",
|
|
71
|
+
"C901",
|
|
72
|
+
"PLR0912",
|
|
73
|
+
"PLR0915",
|
|
74
|
+
"PERF203",
|
|
75
|
+
"FIX002",
|
|
76
|
+
"TD002",
|
|
77
|
+
"TD003",
|
|
78
|
+
]
|
|
79
|
+
unfixable = ["B028"]
|
|
80
|
+
|
|
81
|
+
[tool.ruff.lint.pydocstyle]
|
|
82
|
+
convention = "google"
|
|
83
|
+
ignore-var-parameters = true
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
86
|
+
ban-relative-imports = "all"
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
89
|
+
"tests/**/*.py" = [
|
|
90
|
+
"D",
|
|
91
|
+
"INP001",
|
|
92
|
+
"S101",
|
|
93
|
+
"PLR2004",
|
|
94
|
+
"SLF001",
|
|
95
|
+
"ARG001",
|
|
96
|
+
"PT011",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
[tool.pytest.ini_options]
|
|
100
|
+
asyncio_mode = "auto"
|
|
101
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
102
|
+
|
|
103
|
+
[tool.pyright]
|
|
104
|
+
include = [
|
|
105
|
+
"src",
|
|
106
|
+
"tests",
|
|
107
|
+
]
|
|
108
|
+
pythonVersion = "3.10"
|
|
109
|
+
venvPath = "."
|
|
110
|
+
venv = ".venv"
|
|
111
|
+
typeCheckingMode = "standard"
|
|
112
|
+
strict = ["src"]
|