langchain-llama-cpp 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-llama-cpp
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: An integration package connecting llama.cpp's llama-server and LangChain, with reasoning, timings, structured output and tool calling
5
5
  Keywords: langchain,llama.cpp,llama-server,llama-swap,llm,local-llm,chat-model
6
6
  Author: Bharathan Mudaliar
@@ -54,8 +54,12 @@ or `uv add langchain-llama-cpp`. Python 3.10+.
54
54
 
55
55
  - **llama-swap** in front of llama-server. `model` is the llama-swap entry to
56
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.
57
+ - Live integration tests: gemma 4, thinking off and on. Last full run on
58
+ llama.cpp v0.9.0 (build b12110) with langchain-core 1.7.7 (2026-11-10).
59
+ Qwen3.6 and gpt-oss were tested on llama-server b11429 with langchain-core
60
+ 1.6, and are no longer in the suite.
61
+ - Request fields and reply shapes follow the llama.cpp source of the latest
62
+ release, **v0.9.0** (build b12110).
59
63
 
60
64
  ## Quick start
61
65
 
@@ -164,9 +168,35 @@ sees the schema's field descriptions: asked for an age described as "in months",
164
168
  it fills in years. Pass `include_format_instructions=True` with `json_schema` or
165
169
  `json_mode` to add the schema to the system message.
166
170
 
171
+ ### Model profile
172
+
173
+ At construction, `profile` is filled from the server's `/props`
174
+ (`/upstream/<model>/props` behind llama-swap, which starts the entry; `/props`
175
+ on a plain llama-server):
176
+
177
+ ```python
178
+ ChatLlama(model="gemma").profile
179
+ # {'text_inputs': True, 'max_input_tokens': 262144, 'image_inputs': False,
180
+ # 'audio_inputs': False, 'video_inputs': False, 'tool_calling': True}
181
+ ```
182
+
183
+ `max_input_tokens` is the per-slot context window (`n_ctx`), which LangChain and
184
+ Deep Agents use to size things such as summarization. It's fetched once per
185
+ `(base_url, model)` per process. If the server can't be reached, `profile` stays
186
+ `None`. Pass `profile=` to set your own, or `fetch_profile=False` to skip the
187
+ request. The request uses a separate client, not your `http_client`, which is
188
+ used only for chat requests.
189
+
190
+ `structured_output` is deliberately not set, so `create_agent` uses
191
+ `ToolStrategy` for a `response_format`. llama-server can't combine `tools` with
192
+ a `json_schema` `response_format`. The response-format grammar replaces the
193
+ tool-call grammar, so the model answers in JSON straight away and never calls a
194
+ tool. With Qwen-style templates in v0.9.0 the request fails instead, with
195
+ "failed to parse grammar".
196
+
167
197
  ### Request parameters
168
198
 
169
- Every field `/v1/chat/completions` accepts in b11429 is a typed parameter:
199
+ Every field `/v1/chat/completions` accepts in v0.9.0 is a typed parameter:
170
200
  `temperature`, `top_k`, `top_p`, `min_p`, `samplers`, `repeat_penalty`, the DRY
171
201
  and XTC samplers, `grammar`, `json_schema`, `reasoning_budget_tokens`,
172
202
  `cache_prompt`, `id_slot`, `lora` and more. Set them on the model or per call:
@@ -213,13 +243,14 @@ seconds and defaults to `None` (no timeout), since local generation can be slow.
213
243
  models lets the model write text before the call and won't let it stop
214
244
  without one. A model that doesn't want a tool keeps writing until
215
245
  `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.
246
+ [ggml-org/llama.cpp#27217](https://github.com/ggml-org/llama.cpp/issues/27217)
247
+ (closed as stale without a fix); keep `max_tokens` set.
218
248
  - **A named `tool_choice`** is sent as `"required"` (see Tool calling).
219
249
  - **JSON schema bounds on numbers** (`minimum`, `maximum`, `multipleOf` on
220
250
  `number`) aren't enforced by llama-server's grammar. Integer bounds are.
221
251
  - **Bare llama-server** (without llama-swap) isn't tested; `get_server_defaults`
222
- needs llama-swap's `/upstream/<model>/props`.
252
+ needs llama-swap's `/upstream/<model>/props` (the profile falls back to `/props`).
253
+ - **Tools and `response_format` together** give no tool calls (see Model profile).
223
254
 
224
255
  ## Development
225
256
 
@@ -232,9 +263,9 @@ uv run pytest tests/unit_tests # offline
232
263
  uv run ruff check && uv run ruff format --check && uv run pyright
233
264
  ```
234
265
 
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:
266
+ The integration tests (LangChain's standard `langchain-tests` suite, on Gemma 4
267
+ with thinking off and on) need a llama-swap server with a `gemma` entry. Copy
268
+ `.env.example` to `.env` and set `LLAMA_BASE_URL`, then:
238
269
 
239
270
  ```bash
240
271
  uv run --env-file .env pytest tests/integration_tests
@@ -28,8 +28,12 @@ or `uv add langchain-llama-cpp`. Python 3.10+.
28
28
 
29
29
  - **llama-swap** in front of llama-server. `model` is the llama-swap entry to
30
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.
31
+ - Live integration tests: gemma 4, thinking off and on. Last full run on
32
+ llama.cpp v0.9.0 (build b12110) with langchain-core 1.7.7 (2026-11-10).
33
+ Qwen3.6 and gpt-oss were tested on llama-server b11429 with langchain-core
34
+ 1.6, and are no longer in the suite.
35
+ - Request fields and reply shapes follow the llama.cpp source of the latest
36
+ release, **v0.9.0** (build b12110).
33
37
 
34
38
  ## Quick start
35
39
 
@@ -138,9 +142,35 @@ sees the schema's field descriptions: asked for an age described as "in months",
138
142
  it fills in years. Pass `include_format_instructions=True` with `json_schema` or
139
143
  `json_mode` to add the schema to the system message.
140
144
 
145
+ ### Model profile
146
+
147
+ At construction, `profile` is filled from the server's `/props`
148
+ (`/upstream/<model>/props` behind llama-swap, which starts the entry; `/props`
149
+ on a plain llama-server):
150
+
151
+ ```python
152
+ ChatLlama(model="gemma").profile
153
+ # {'text_inputs': True, 'max_input_tokens': 262144, 'image_inputs': False,
154
+ # 'audio_inputs': False, 'video_inputs': False, 'tool_calling': True}
155
+ ```
156
+
157
+ `max_input_tokens` is the per-slot context window (`n_ctx`), which LangChain and
158
+ Deep Agents use to size things such as summarization. It's fetched once per
159
+ `(base_url, model)` per process. If the server can't be reached, `profile` stays
160
+ `None`. Pass `profile=` to set your own, or `fetch_profile=False` to skip the
161
+ request. The request uses a separate client, not your `http_client`, which is
162
+ used only for chat requests.
163
+
164
+ `structured_output` is deliberately not set, so `create_agent` uses
165
+ `ToolStrategy` for a `response_format`. llama-server can't combine `tools` with
166
+ a `json_schema` `response_format`. The response-format grammar replaces the
167
+ tool-call grammar, so the model answers in JSON straight away and never calls a
168
+ tool. With Qwen-style templates in v0.9.0 the request fails instead, with
169
+ "failed to parse grammar".
170
+
141
171
  ### Request parameters
142
172
 
143
- Every field `/v1/chat/completions` accepts in b11429 is a typed parameter:
173
+ Every field `/v1/chat/completions` accepts in v0.9.0 is a typed parameter:
144
174
  `temperature`, `top_k`, `top_p`, `min_p`, `samplers`, `repeat_penalty`, the DRY
145
175
  and XTC samplers, `grammar`, `json_schema`, `reasoning_budget_tokens`,
146
176
  `cache_prompt`, `id_slot`, `lora` and more. Set them on the model or per call:
@@ -187,13 +217,14 @@ seconds and defaults to `None` (no timeout), since local generation can be slow.
187
217
  models lets the model write text before the call and won't let it stop
188
218
  without one. A model that doesn't want a tool keeps writing until
189
219
  `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.
220
+ [ggml-org/llama.cpp#27217](https://github.com/ggml-org/llama.cpp/issues/27217)
221
+ (closed as stale without a fix); keep `max_tokens` set.
192
222
  - **A named `tool_choice`** is sent as `"required"` (see Tool calling).
193
223
  - **JSON schema bounds on numbers** (`minimum`, `maximum`, `multipleOf` on
194
224
  `number`) aren't enforced by llama-server's grammar. Integer bounds are.
195
225
  - **Bare llama-server** (without llama-swap) isn't tested; `get_server_defaults`
196
- needs llama-swap's `/upstream/<model>/props`.
226
+ needs llama-swap's `/upstream/<model>/props` (the profile falls back to `/props`).
227
+ - **Tools and `response_format` together** give no tool calls (see Model profile).
197
228
 
198
229
  ## Development
199
230
 
@@ -206,9 +237,9 @@ uv run pytest tests/unit_tests # offline
206
237
  uv run ruff check && uv run ruff format --check && uv run pyright
207
238
  ```
208
239
 
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:
240
+ The integration tests (LangChain's standard `langchain-tests` suite, on Gemma 4
241
+ with thinking off and on) need a llama-swap server with a `gemma` entry. Copy
242
+ `.env.example` to `.env` and set `LLAMA_BASE_URL`, then:
212
243
 
213
244
  ```bash
214
245
  uv run --env-file .env pytest tests/integration_tests
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "langchain-llama-cpp"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "An integration package connecting llama.cpp's llama-server and LangChain, with reasoning, timings, structured output and tool calling"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "langchain-llama-cpp"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "An integration package connecting llama.cpp's llama-server and LangChain, with reasoning, timings, structured output and tool calling"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,4 +1,4 @@
1
- """The JSON llama-server sends back from `/v1/chat/completions` (b11429).
1
+ """The JSON llama-server sends back from `/v1/chat/completions` (v0.9.0).
2
2
 
3
3
  Types for the type checker only: replies are plain dicts from `json.loads`,
4
4
  never validated, so a server that sends something else isn't rejected here.
@@ -6,7 +6,7 @@ Every class is `total=False` because the code reads each key with `.get()`
6
6
  and copes when it's missing; the docstrings say which keys the server always
7
7
  sends.
8
8
 
9
- Sources (llama.cpp b11429): `tools/server/server-task.cpp`
9
+ Sources (llama.cpp v0.9.0, build b12110): `tools/server/server-task.cpp`
10
10
  (`to_json_oaicompat_chat`, `to_json_oaicompat_chat_stream`,
11
11
  `server_task_result_cmpl_partial::to_json_oaicompat_chat`,
12
12
  `probs_vector_to_json`, `result_prompt_progress::to_json`),
@@ -55,7 +55,7 @@ the stream ends without `[DONE]`.
55
55
  from typing import Literal, TypedDict
56
56
 
57
57
  FinishReason = Literal["stop", "length", "tool_calls"]
58
- """`length` when `max_tokens` or the context window ran out (b11429 sends
58
+ """`length` when `max_tokens` or the context window ran out (v0.9.0 sends
59
59
  `length` for every stop that isn't an end-of-sequence token or a stop string)."""
60
60
 
61
61
 
@@ -101,6 +101,9 @@ class LlamaContentPart(TypedDict, total=False):
101
101
  type: str
102
102
  text: str
103
103
 
104
+ name: str
105
+ """In place of `text` on a `tool_reference` part."""
106
+
104
107
 
105
108
  class LlamaMessage(TypedDict, total=False):
106
109
  """`choices[].message` in a reply. Always `role` and `content`."""
@@ -190,7 +193,7 @@ class LlamaPromptTokensDetails(TypedDict, total=False):
190
193
  class LlamaCompletionTokensDetails(TypedDict, total=False):
191
194
  """`usage.completion_tokens_details`, as OpenAI sends it.
192
195
 
193
- b11429 never sends it (reasoning tokens are counted in
196
+ v0.9.0 never sends it (reasoning tokens are counted in
194
197
  `completion_tokens` but not reported apart); read in case a build does.
195
198
  """
196
199
 
@@ -198,7 +201,7 @@ class LlamaCompletionTokensDetails(TypedDict, total=False):
198
201
 
199
202
 
200
203
  class LlamaUsage(TypedDict, total=False):
201
- """`usage`. b11429 always sends the four keys below except the last."""
204
+ """`usage`. v0.9.0 always sends the four keys below except the last."""
202
205
 
203
206
  prompt_tokens: int
204
207
 
@@ -20,6 +20,7 @@ from langchain_core.language_models import (
20
20
  LangSmithParams,
21
21
  LanguageModelInput,
22
22
  )
23
+ from langchain_core.language_models.model_profile import ModelProfile
23
24
  from langchain_core.messages import (
24
25
  AIMessage,
25
26
  AIMessageChunk,
@@ -94,6 +95,10 @@ INITIAL_RETRY_DELAY = 0.5
94
95
  MAX_RETRY_DELAY = 8.0
95
96
  MAX_RETRY_AFTER = 60.0
96
97
 
98
+ # The `/props` fetch behind `profile`. Behind llama-swap it starts the entry,
99
+ # which can take tens of seconds for a large model; connecting must not.
100
+ PROFILE_TIMEOUT = httpx.Timeout(120.0, connect=10.0)
101
+
97
102
  # ChatLlama fields that configure the client rather than the request.
98
103
  _CLIENT_FIELDS = frozenset(
99
104
  {
@@ -103,11 +108,16 @@ _CLIENT_FIELDS = frozenset(
103
108
  "max_retries",
104
109
  "streaming",
105
110
  "stream_usage",
111
+ "fetch_profile",
106
112
  "http_client",
107
113
  "http_async_client",
108
114
  }
109
115
  )
110
116
 
117
+ # Profiles fetched from `/props`, per (base_url, model), for the process.
118
+ # Only successes are kept, so a server that was down is asked again.
119
+ _PROFILE_CACHE: dict[tuple[str, str], ModelProfile] = {}
120
+
111
121
  _AsyncClients = weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, httpx.AsyncClient]
112
122
 
113
123
  # What `_chunk_from_line` returns for `data: [DONE]`, the end of a finished stream.
@@ -182,7 +192,7 @@ class ChatLlama(BaseChatModel):
182
192
  """Server root, e.g. `http://localhost:8080`. Defaults to `LLAMA_BASE_URL`."""
183
193
 
184
194
  # --- Request fields -----------------------------------------------------
185
- # Every field accepted by /v1/chat/completions in llama.cpp b11429
195
+ # Every field accepted by /v1/chat/completions in llama.cpp v0.9.0 (b12110)
186
196
  # (tools/server/server-common.cpp oaicompat_chat_params_parse,
187
197
  # server-schema.cpp make_llama_cmpl_schema, server-context.cpp id_slot).
188
198
  # Each is sent under its own name; None (the default) leaves it out, so the
@@ -338,7 +348,11 @@ class ChatLlama(BaseChatModel):
338
348
  """Continue the last assistant message instead of starting a new turn."""
339
349
 
340
350
  echo: bool | None = None
341
- """Echo the input tokens in the output."""
351
+ """With `continue_final_message`, include the continued text in the reply.
352
+
353
+ By default the reply holds only the newly generated text. (In b11429 and
354
+ older builds it meant echoing the input tokens.)
355
+ """
342
356
 
343
357
  # Probabilities and debugging output
344
358
  logprobs: bool | None = None
@@ -406,8 +420,23 @@ class ChatLlama(BaseChatModel):
406
420
  `timings`. Can be overridden per call with `stream_usage=`.
407
421
  """
408
422
 
423
+ fetch_profile: bool = True
424
+ """Fill `profile` from the server's `/props` at construction (default on).
425
+
426
+ Reads `GET /upstream/<model>/props` (llama-swap; this starts the entry),
427
+ falling back to `GET /props` (a plain llama-server) on a 404. Gives
428
+ `max_input_tokens` (the per-slot `n_ctx`), the input modalities and
429
+ `tool_calling`. Fetched once per `(base_url, model)` per process. Any failure
430
+ leaves `profile` at `None`. A `profile=` you pass is used as it is, with no
431
+ fetch. Turn it off for offline use. `/props` is fetched with a separate
432
+ client, not `http_client`.
433
+ """
434
+
409
435
  http_client: httpx.Client | None = Field(default=None, exclude=True)
410
- """Your own `httpx.Client`. If given, `timeout` is not applied to it."""
436
+ """Your own `httpx.Client` for chat requests (not the `/props` profile fetch).
437
+
438
+ If given, `timeout` is not applied to it.
439
+ """
411
440
 
412
441
  http_async_client: httpx.AsyncClient | None = Field(default=None, exclude=True)
413
442
  """Your own `httpx.AsyncClient` for async calls. `timeout` isn't applied to it."""
@@ -495,8 +524,9 @@ class ChatLlama(BaseChatModel):
495
524
  (the handler llama.cpp names "qwen3-coder") the grammar lets
496
525
  the model write text before the call and forbids ending without
497
526
  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.
527
+ `max_tokens`, with no tool call (llama.cpp issue #27217, closed
528
+ as stale without a fix; unchanged as of v0.9.0). Keep
529
+ `max_tokens` set when forcing tools.
500
530
  strict: Sent as `strict` on each tool when set. llama-server
501
531
  ignores it: its grammar always enforces the schema.
502
532
  parallel_tool_calls: Sent when set.
@@ -521,7 +551,7 @@ class ChatLlama(BaseChatModel):
521
551
  ]
522
552
 
523
553
  # llama-server takes only these strings. It turns the object form
524
- # into "auto" (b11429), so a named tool becomes "required".
554
+ # into "auto" (v0.9.0), so a named tool becomes "required".
525
555
  if tool_choice is True or tool_choice == "any":
526
556
  tool_choice = "required"
527
557
  elif not (
@@ -718,6 +748,52 @@ class ChatLlama(BaseChatModel):
718
748
 
719
749
  # --- LangChain core hooks ---------------------------------------------
720
750
 
751
+ def _resolve_model_profile(self) -> ModelProfile | None:
752
+ """The profile from the server's `/props`, or None (see `fetch_profile`).
753
+
754
+ Core calls this at construction when no `profile` was passed, and turns
755
+ any exception into None; failures return None here already.
756
+ """
757
+ if not self.fetch_profile:
758
+ return None
759
+
760
+ base_url = self.base_url.rstrip("/")
761
+ key = (base_url, self.model)
762
+ if (cached := _PROFILE_CACHE.get(key)) is None:
763
+ props = self._fetch_props(base_url)
764
+ profile = _profile_from_props(props) if props is not None else None
765
+ if profile is None:
766
+ return None
767
+ _PROFILE_CACHE[key] = cached = profile
768
+ return ModelProfile(**cached) # a copy: the caller may change it
769
+
770
+ def _fetch_props(self, base_url: str) -> dict[str, Any] | None:
771
+ """`/props` of this model's server: llama-swap's path, then llama-server's.
772
+
773
+ One attempt each, no retries, with `PROFILE_TIMEOUT`. Always on a
774
+ short-lived client of its own, never `http_client`: that client's event
775
+ hooks (e.g. a recorder of chat requests) shouldn't see this GET.
776
+ ChatLlama sends no auth headers, so nothing from it is needed.
777
+ """
778
+ paths = (f"/upstream/{self.model}/props", "/props")
779
+ client = _props_client()
780
+ try:
781
+ for path in paths:
782
+ response = client.get(base_url + path, timeout=PROFILE_TIMEOUT)
783
+ if response.status_code == 404: # noqa: PLR2004
784
+ continue # not llama-swap: try a plain llama-server
785
+ if not response.is_success:
786
+ return None
787
+ props: object = response.json()
788
+ return (
789
+ cast("dict[str, Any]", props) if isinstance(props, dict) else None
790
+ )
791
+ except (httpx.HTTPError, ValueError): # unreachable, timed out, not JSON
792
+ return None
793
+ finally:
794
+ client.close()
795
+ return None
796
+
721
797
  def _get_ls_params(
722
798
  self, stop: list[str] | None = None, **kwargs: Any
723
799
  ) -> LangSmithParams:
@@ -1040,6 +1116,60 @@ def _without_none(**items: Any) -> dict[str, Any]:
1040
1116
  return {key: value for key, value in items.items() if value is not None}
1041
1117
 
1042
1118
 
1119
+ # --- Profile --------------------------------------------------------------
1120
+
1121
+
1122
+ def _props_client() -> httpx.Client:
1123
+ """A new client for the `/props` fetch (a function so tests can swap it)."""
1124
+ return httpx.Client()
1125
+
1126
+
1127
+ def _profile_from_props(props: Mapping[str, Any]) -> ModelProfile | None:
1128
+ """A `ModelProfile` from llama-server's `/props`, or None if it isn't one.
1129
+
1130
+ Fields as of llama.cpp v0.9.0 (`server-context.cpp` `get_res_props`):
1131
+
1132
+ - `default_generation_settings.n_ctx`: the per-slot context window, which
1133
+ holds the prompt and the reply → `max_input_tokens`.
1134
+ - `modalities.{vision, audio, video}` → `image_inputs`, `audio_inputs`,
1135
+ `video_inputs`.
1136
+ - `chat_template_caps.supports_tool_calls` → `tool_calling`. Without it the
1137
+ server's generic parser doesn't parse tool calls at all
1138
+ (`chat-auto-parser-generator.cpp`).
1139
+
1140
+ `structured_output` is left out on purpose: with `tools` in the request, a
1141
+ `response_format` grammar replaces the tool-call grammar in every chat
1142
+ handler, so an agent's model could no longer call tools.
1143
+ """
1144
+ settings: object = props.get("default_generation_settings")
1145
+ if not isinstance(settings, dict):
1146
+ return None # a 200 from something that isn't llama-server
1147
+
1148
+ profile = ModelProfile(text_inputs=True)
1149
+
1150
+ n_ctx: object = cast("dict[str, Any]", settings).get("n_ctx")
1151
+ if isinstance(n_ctx, int) and not isinstance(n_ctx, bool) and n_ctx > 0:
1152
+ profile["max_input_tokens"] = n_ctx
1153
+
1154
+ modalities: object = props.get("modalities")
1155
+ if isinstance(modalities, dict):
1156
+ modality = cast("dict[str, Any]", modalities)
1157
+ if isinstance(vision := modality.get("vision"), bool):
1158
+ profile["image_inputs"] = vision
1159
+ if isinstance(audio := modality.get("audio"), bool):
1160
+ profile["audio_inputs"] = audio
1161
+ if isinstance(video := modality.get("video"), bool):
1162
+ profile["video_inputs"] = video
1163
+
1164
+ caps: object = props.get("chat_template_caps")
1165
+ if isinstance(caps, dict):
1166
+ tool_calls = cast("dict[str, Any]", caps).get("supports_tool_calls")
1167
+ if isinstance(tool_calls, bool):
1168
+ profile["tool_calling"] = tool_calls
1169
+
1170
+ return profile
1171
+
1172
+
1043
1173
  # --- Tools and structured output ------------------------------------------
1044
1174
 
1045
1175