sovara-langchain 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sovara Labs
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,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: sovara-langchain
3
+ Version: 0.1.0
4
+ Summary: Sovara recording and lessons for LangChain and LangGraph
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://docs.sovara-labs.com/integrations/langchain
7
+ Project-URL: Repository, https://github.com/SovaraLabs/sovara-langchain/tree/main/python
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: sovara<0.11,>=0.10
12
+ Requires-Dist: langchain-core<2,>=1
13
+ Provides-Extra: agent
14
+ Requires-Dist: langchain<2,>=1.1; extra == "agent"
15
+ Dynamic: license-file
16
+
17
+ # Sovara for LangChain (Python)
18
+
19
+ Record LangChain and LangGraph model and tool calls in Sovara, and add relevant lessons to each
20
+ model request.
21
+
22
+ ```bash
23
+ uv add "sovara-langchain[agent]"
24
+ # or: pip install "sovara-langchain[agent]"
25
+ ```
26
+
27
+ Omit `[agent]` if you only use `wrap_chat_model`.
28
+
29
+ ## Use
30
+
31
+ Inside a Sovara trace, pick one per model:
32
+
33
+ ```python
34
+ import sovara_langchain
35
+ from sovara_langchain import SovaraMiddleware, wrap_chat_model
36
+
37
+ # create_agent: add the middleware last. It records model and tool calls.
38
+ agent = create_agent(model, tools, middleware=[*other_middleware, SovaraMiddleware()])
39
+
40
+ # Anything else (LangGraph nodes, create_react_agent, with_structured_output): wrap the model,
41
+ # and call instrument_tools() once at startup to record tool calls too.
42
+ sovara_langchain.instrument_tools()
43
+ model = wrap_chat_model(ChatOpenAI(model="gpt-5.4")).bind_tools(tools)
44
+ ```
45
+
46
+ - `instrument_tools()` also records tools called directly, without a model-generated tool call.
47
+ - Lessons are one temporary user message at the end of the request. The agent's messages and
48
+ graph state are never changed.
49
+ - Outside a trace, calls pass through unrecorded. If Sovara is unavailable, calls still run.
50
+ - Last in the list, the middleware records the model, prompt and messages that other middleware
51
+ settled on, and each retry by other middleware as its own step.
52
+ - Don't use the middleware and the wrapper for the same model.
53
+ - Model calls are recorded as conversations, so **Rerun** is not available for them.
54
+
55
+ Requires `langchain-core` 1.x (`langchain` 1.1+ for the middleware).
56
+
57
+ ## License
58
+
59
+ MIT.
@@ -0,0 +1,43 @@
1
+ # Sovara for LangChain (Python)
2
+
3
+ Record LangChain and LangGraph model and tool calls in Sovara, and add relevant lessons to each
4
+ model request.
5
+
6
+ ```bash
7
+ uv add "sovara-langchain[agent]"
8
+ # or: pip install "sovara-langchain[agent]"
9
+ ```
10
+
11
+ Omit `[agent]` if you only use `wrap_chat_model`.
12
+
13
+ ## Use
14
+
15
+ Inside a Sovara trace, pick one per model:
16
+
17
+ ```python
18
+ import sovara_langchain
19
+ from sovara_langchain import SovaraMiddleware, wrap_chat_model
20
+
21
+ # create_agent: add the middleware last. It records model and tool calls.
22
+ agent = create_agent(model, tools, middleware=[*other_middleware, SovaraMiddleware()])
23
+
24
+ # Anything else (LangGraph nodes, create_react_agent, with_structured_output): wrap the model,
25
+ # and call instrument_tools() once at startup to record tool calls too.
26
+ sovara_langchain.instrument_tools()
27
+ model = wrap_chat_model(ChatOpenAI(model="gpt-5.4")).bind_tools(tools)
28
+ ```
29
+
30
+ - `instrument_tools()` also records tools called directly, without a model-generated tool call.
31
+ - Lessons are one temporary user message at the end of the request. The agent's messages and
32
+ graph state are never changed.
33
+ - Outside a trace, calls pass through unrecorded. If Sovara is unavailable, calls still run.
34
+ - Last in the list, the middleware records the model, prompt and messages that other middleware
35
+ settled on, and each retry by other middleware as its own step.
36
+ - Don't use the middleware and the wrapper for the same model.
37
+ - Model calls are recorded as conversations, so **Rerun** is not available for them.
38
+
39
+ Requires `langchain-core` 1.x (`langchain` 1.1+ for the middleware).
40
+
41
+ ## License
42
+
43
+ MIT.
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sovara-langchain"
7
+ version = "0.1.0"
8
+ description = "Sovara recording and lessons for LangChain and LangGraph"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "sovara>=0.10,<0.11",
14
+ "langchain-core>=1,<2",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ # SovaraMiddleware for create_agent.
19
+ agent = ["langchain>=1.1,<2"]
20
+
21
+ [project.urls]
22
+ Documentation = "https://docs.sovara-labs.com/integrations/langchain"
23
+ Repository = "https://github.com/SovaraLabs/sovara-langchain/tree/main/python"
24
+
25
+ [tool.setuptools]
26
+ package-dir = {"" = "src"}
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+ include = ["sovara_langchain*"]
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "pytest",
35
+ "ruff==0.17.2",
36
+ "httpx",
37
+ "langchain>=1.1,<2",
38
+ "langchain-anthropic",
39
+ "langchain-openai",
40
+ "langgraph",
41
+ # Newer releases reject the httpx client the provider tests inject.
42
+ "anthropic==0.88.0",
43
+ ]
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py310"
48
+
49
+ [tool.ruff.lint]
50
+ select = [
51
+ "E4", "E7", "E9", "F", "I", "UP", "B",
52
+ "D100", "D101", "D102", "D103", "D104", "D106", "D107",
53
+ "D200", "D205", "D400", "D401", "D419",
54
+ "ERA001", "PGH004", "RUF100",
55
+ ]
56
+
57
+ [tool.ruff.lint.per-file-ignores]
58
+ "tests/**/*.py" = ["D1"]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
62
+ # End-to-end tests need a running Sovara test server; run them with `-m e2e`.
63
+ addopts = "-m 'not e2e'"
64
+ markers = ["e2e: runs against a real Sovara exec server (see tests/e2e/support.py)"]
65
+
66
+ [tool.ruff.lint.isort]
67
+ # Sovara's own packages sort together, as in the sovara repository.
68
+ known-first-party = ["sovara", "sovara_common", "sovara_langchain", "tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,48 @@
1
+ """Record LangChain and LangGraph model and tool calls, with lessons per model call.
2
+
3
+ Use one of these per model, not both:
4
+
5
+ - ``SovaraMiddleware`` for ``create_agent`` (needs ``langchain>=1.1``); it also records tool
6
+ calls.
7
+ - ``wrap_chat_model(model)`` for any other use of a chat model, such as custom LangGraph
8
+ nodes, ``with_structured_output`` or prebuilt agents (needs only ``langchain-core``).
9
+
10
+ Call ``instrument_tools()`` once at startup to also record tool calls that the middleware does not
11
+ own, such as the tools of a LangGraph agent built on a wrapped model. Each export loads on first
12
+ access, so the wrapper does not import ``langchain``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from importlib import import_module
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from sovara_langchain._chat_model import SovaraChatModel, wrap_chat_model
22
+ from sovara_langchain._middleware import SovaraMiddleware
23
+
24
+ __all__ = ["SovaraChatModel", "SovaraMiddleware", "instrument_tools", "wrap_chat_model"]
25
+
26
+ _MODULES = {
27
+ "SovaraChatModel": "sovara_langchain._chat_model",
28
+ "wrap_chat_model": "sovara_langchain._chat_model",
29
+ "SovaraMiddleware": "sovara_langchain._middleware",
30
+ }
31
+
32
+
33
+ def instrument_tools() -> None:
34
+ """Record LangChain tool calls inside Sovara traces; idempotent.
35
+
36
+ Patches ``langchain_core`` tool execution. Tool calls the middleware already records are
37
+ skipped, so using both never records a tool twice.
38
+ """
39
+ from sovara_langchain._tool_patch import langchain_patch
40
+
41
+ langchain_patch()
42
+
43
+
44
+ def __getattr__(name: str) -> Any:
45
+ module = _MODULES.get(name)
46
+ if module is None:
47
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
48
+ return getattr(import_module(module), name)
@@ -0,0 +1,346 @@
1
+ """Wrap a LangChain chat model so every call it makes is recorded with lessons.
2
+
3
+ ``wrap_chat_model(model)`` returns a chat model that records each ``invoke``, ``stream``,
4
+ ``batch`` and async call, including calls through ``bind_tools`` and
5
+ ``with_structured_output``. Lessons go into one temporary user message on that request
6
+ only; the caller's messages and graph state are never changed. Generation, tool binding,
7
+ structured output and parsing stay with the wrapped model.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
13
+ from typing import Any
14
+
15
+ from langchain_core.language_models.chat_models import BaseChatModel
16
+ from langchain_core.messages import BaseMessage
17
+ from langchain_core.outputs import ChatGenerationChunk, ChatResult
18
+ from langchain_core.runnables import (
19
+ Runnable,
20
+ RunnableBinding,
21
+ RunnableParallel,
22
+ RunnableSequence,
23
+ RunnableWithFallbacks,
24
+ )
25
+ from langchain_core.runnables.passthrough import RunnableAssign
26
+ from pydantic import PrivateAttr
27
+
28
+ from sovara import ConversationOutput, Execution, SovaraConfigError, Step
29
+ from sovara import integration as instrumentation
30
+ from sovara_langchain import _messages as convert
31
+
32
+ __all__ = ["SovaraChatModel", "wrap_chat_model"]
33
+
34
+
35
+ def _prepare(
36
+ model: BaseChatModel, messages: list[BaseMessage], kwargs: dict[str, Any]
37
+ ) -> tuple[Step | None, list[BaseMessage], Execution | None]:
38
+ call = convert.llm_call(model, messages, kwargs.get("tools") or ())
39
+ step = instrumentation.begin(call, lessons=True) if call is not None else None
40
+ return _with_lessons(step, messages, kwargs)
41
+
42
+
43
+ async def _aprepare(
44
+ model: BaseChatModel, messages: list[BaseMessage], kwargs: dict[str, Any]
45
+ ) -> tuple[Step | None, list[BaseMessage], Execution | None]:
46
+ call = convert.llm_call(model, messages, kwargs.get("tools") or ())
47
+ step = await instrumentation.abegin(call, lessons=True) if call is not None else None
48
+ return _with_lessons(step, messages, kwargs)
49
+
50
+
51
+ def _with_lessons(
52
+ step: Step | None, messages: list[BaseMessage], kwargs: dict[str, Any]
53
+ ) -> tuple[Step | None, list[BaseMessage], Execution | None]:
54
+ context = step.lesson_context if step is not None else None
55
+ if context is None:
56
+ return step, messages, None
57
+ sent = convert.with_lessons(messages, context.text)
58
+ execution = Execution(
59
+ input=convert.conversation(sent, kwargs.get("tools") or ()),
60
+ injected_retrieval_id=context.retrieval_id,
61
+ )
62
+ return step, sent, execution
63
+
64
+
65
+ def _result_output(result: ChatResult) -> ConversationOutput:
66
+ if not result.generations:
67
+ return convert.safe_output(None)
68
+ generation = result.generations[0]
69
+ return convert.safe_output(generation.message, generation.generation_info) # type: ignore[arg-type]
70
+
71
+
72
+ def _chunks_output(chunks: ChatGenerationChunk | None) -> ConversationOutput:
73
+ if chunks is None:
74
+ return convert.safe_output(None)
75
+ return convert.safe_output(chunks.message, chunks.generation_info) # type: ignore[arg-type]
76
+
77
+
78
+ def _rebind(node: Any, old: Any, new: Any) -> tuple[Any, bool]:
79
+ """Return ``node`` with the runnable ``old`` replaced by ``new``, and whether it was found."""
80
+ if node is old:
81
+ return new, True
82
+ if isinstance(node, RunnableBinding):
83
+ bound, found = _rebind(node.bound, old, new)
84
+ return (node.model_copy(update={"bound": bound}), True) if found else (node, False)
85
+ if isinstance(node, RunnableSequence):
86
+ first, in_first = _rebind(node.first, old, new)
87
+ middle = [_rebind(step, old, new) for step in node.middle]
88
+ last, in_last = _rebind(node.last, old, new)
89
+ found = in_first or in_last or any(hit for _, hit in middle)
90
+ update = {"first": first, "middle": [step for step, _ in middle], "last": last}
91
+ return (node.model_copy(update=update), True) if found else (node, False)
92
+ if isinstance(node, RunnableParallel):
93
+ steps = {key: _rebind(step, old, new) for key, step in node.steps__.items()}
94
+ if not any(hit for _, hit in steps.values()):
95
+ return node, False
96
+ return node.model_copy(update={"steps__": {k: s for k, (s, _) in steps.items()}}), True
97
+ if isinstance(node, RunnableWithFallbacks):
98
+ runnable, in_runnable = _rebind(node.runnable, old, new)
99
+ fallbacks = [_rebind(step, old, new) for step in node.fallbacks]
100
+ if not in_runnable and not any(hit for _, hit in fallbacks):
101
+ return node, False
102
+ update = {"runnable": runnable, "fallbacks": [step for step, _ in fallbacks]}
103
+ return node.model_copy(update=update), True
104
+ if isinstance(node, RunnableAssign):
105
+ mapper, found = _rebind(node.mapper, old, new)
106
+ return (node.model_copy(update={"mapper": mapper}), True) if found else (node, False)
107
+ return node, False
108
+
109
+
110
+ class SovaraChatModel(BaseChatModel):
111
+ """A chat model that records each call of the wrapped model as a Sovara step.
112
+
113
+ Create it with ``wrap_chat_model``. Calls go to the wrapped model's own generation and
114
+ streaming methods; outside a recording they pass through unchanged.
115
+
116
+ Public attributes the wrapper does not define, such as ``temperature`` or ``model_name``,
117
+ are read from and set on the wrapped model. Chat-model settings (callbacks, tags, cache and
118
+ the other ``BaseChatModel`` fields) are copied when wrapping; setting one later sets it on
119
+ both models.
120
+ """
121
+
122
+ # Private, because providers such as ChatAnthropic use a public ``model`` field.
123
+ _model: BaseChatModel = PrivateAttr()
124
+
125
+ def __init__(self, model: BaseChatModel, **settings: Any) -> None:
126
+ """Wrap ``model``; ``settings`` are its ``BaseChatModel`` fields."""
127
+ super().__init__(**settings)
128
+ self._model = model
129
+
130
+ def __getattr__(self, name: str) -> Any:
131
+ try:
132
+ return super().__getattr__(name) # type: ignore[misc]
133
+ except AttributeError:
134
+ if name.startswith("_"):
135
+ raise
136
+ private = self.__pydantic_private__ or {}
137
+ if "_model" not in private:
138
+ raise
139
+ return getattr(private["_model"], name)
140
+
141
+ def __setattr__(self, name: str, value: Any) -> None:
142
+ if name.startswith("_"):
143
+ super().__setattr__(name, value)
144
+ elif name in BaseChatModel.model_fields:
145
+ super().__setattr__(name, value)
146
+ setattr(self._model, name, value)
147
+ else:
148
+ setattr(self._model, name, value)
149
+
150
+ @property
151
+ def wrapped_model(self) -> BaseChatModel:
152
+ """Return the model whose calls this wrapper records."""
153
+ return self._model
154
+
155
+ @property
156
+ def _llm_type(self) -> str:
157
+ return self._model._llm_type
158
+
159
+ @property
160
+ def _identifying_params(self) -> Mapping[str, Any]:
161
+ return self._model._identifying_params
162
+
163
+ def _get_ls_params(self, stop: list[str] | None = None, **kwargs: Any) -> Any:
164
+ return self._model._get_ls_params(stop=stop, **kwargs)
165
+
166
+ def _should_stream(self, *, async_api: bool, run_manager: Any = None, **kwargs: Any) -> bool:
167
+ return self._model._should_stream(async_api=async_api, run_manager=run_manager, **kwargs)
168
+
169
+ def _generate(
170
+ self,
171
+ messages: list[BaseMessage],
172
+ stop: list[str] | None = None,
173
+ run_manager: Any = None,
174
+ **kwargs: Any,
175
+ ) -> ChatResult:
176
+ step, sent, execution = _prepare(self._model, messages, kwargs)
177
+ if step is None:
178
+ return self._model._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
179
+ try:
180
+ with step.activate():
181
+ result = self._model._generate(sent, stop=stop, run_manager=run_manager, **kwargs)
182
+ except BaseException as exc:
183
+ instrumentation.report_raised(step, exc, classify=convert.raised, execution=execution)
184
+ raise
185
+ instrumentation.report(step, "finish", _result_output(result), execution=execution)
186
+ return result
187
+
188
+ async def _agenerate(
189
+ self,
190
+ messages: list[BaseMessage],
191
+ stop: list[str] | None = None,
192
+ run_manager: Any = None,
193
+ **kwargs: Any,
194
+ ) -> ChatResult:
195
+ step, sent, execution = await _aprepare(self._model, messages, kwargs)
196
+ if step is None:
197
+ return await self._model._agenerate(
198
+ messages, stop=stop, run_manager=run_manager, **kwargs
199
+ )
200
+ try:
201
+ with step.activate():
202
+ result = await self._model._agenerate(
203
+ sent, stop=stop, run_manager=run_manager, **kwargs
204
+ )
205
+ except BaseException as exc:
206
+ await instrumentation.areport_raised(
207
+ step, exc, classify=convert.raised, execution=execution
208
+ )
209
+ raise
210
+ await instrumentation.areport(step, "finish", _result_output(result), execution=execution)
211
+ return result
212
+
213
+ def _stream(
214
+ self,
215
+ messages: list[BaseMessage],
216
+ stop: list[str] | None = None,
217
+ run_manager: Any = None,
218
+ **kwargs: Any,
219
+ ) -> Iterator[ChatGenerationChunk]:
220
+ step, sent, execution = _prepare(self._model, messages, kwargs)
221
+ if step is None:
222
+ yield from self._model._stream(messages, stop=stop, run_manager=run_manager, **kwargs)
223
+ return
224
+ chunks: ChatGenerationChunk | None = None
225
+ iterator = self._model._stream(sent, stop=stop, run_manager=run_manager, **kwargs)
226
+ try:
227
+ while True:
228
+ # Activation covers each pull from the model, never the consumer's code.
229
+ with step.activate():
230
+ chunk = next(iterator, None)
231
+ if chunk is None:
232
+ break
233
+ chunks = chunk if chunks is None else chunks + chunk
234
+ yield chunk
235
+ except BaseException as exc:
236
+ ending, args = (
237
+ ("cancel", ("Closed",)) if isinstance(exc, GeneratorExit) else (convert.raised(exc))
238
+ )
239
+ instrumentation.report(
240
+ step,
241
+ ending,
242
+ *args,
243
+ output=_chunks_output(chunks),
244
+ execution=execution,
245
+ propagating=True,
246
+ )
247
+ close = getattr(iterator, "close", None)
248
+ if close is not None:
249
+ close()
250
+ raise
251
+ instrumentation.report(step, "finish", _chunks_output(chunks), execution=execution)
252
+
253
+ async def _astream(
254
+ self,
255
+ messages: list[BaseMessage],
256
+ stop: list[str] | None = None,
257
+ run_manager: Any = None,
258
+ **kwargs: Any,
259
+ ) -> AsyncIterator[ChatGenerationChunk]:
260
+ step, sent, execution = await _aprepare(self._model, messages, kwargs)
261
+ if step is None:
262
+ async for chunk in self._model._astream(
263
+ messages, stop=stop, run_manager=run_manager, **kwargs
264
+ ):
265
+ yield chunk
266
+ return
267
+ chunks: ChatGenerationChunk | None = None
268
+ iterator = self._model._astream(sent, stop=stop, run_manager=run_manager, **kwargs)
269
+ try:
270
+ while True:
271
+ with step.activate():
272
+ chunk = await anext(iterator, None)
273
+ if chunk is None:
274
+ break
275
+ chunks = chunk if chunks is None else chunks + chunk
276
+ yield chunk
277
+ except BaseException as exc:
278
+ ending, args = (
279
+ ("cancel", ("Closed",)) if isinstance(exc, GeneratorExit) else (convert.raised(exc))
280
+ )
281
+ partial = _chunks_output(chunks)
282
+ # A closed async generator may still await; a cancelled task must not.
283
+ if isinstance(exc, (Exception, GeneratorExit)):
284
+ await instrumentation.areport(
285
+ step, ending, *args, output=partial, execution=execution, propagating=True
286
+ )
287
+ else:
288
+ instrumentation.report_in_background(
289
+ step, ending, *args, output=partial, execution=execution
290
+ )
291
+ aclose = getattr(iterator, "aclose", None)
292
+ if aclose is not None:
293
+ await aclose()
294
+ raise
295
+ await instrumentation.areport(step, "finish", _chunks_output(chunks), execution=execution)
296
+
297
+ def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> Runnable:
298
+ """Bind tools with the wrapped model's own implementation, keeping calls recorded.
299
+
300
+ Raises:
301
+ SovaraConfigError: The wrapped model did not return a binding of itself, so its
302
+ calls could not be recorded.
303
+ """
304
+ native = self._model.bind_tools(tools, **kwargs)
305
+ if not (isinstance(native, RunnableBinding) and native.bound is self._model):
306
+ raise SovaraConfigError(
307
+ f"{type(self._model).__name__}.bind_tools did not return a binding of the "
308
+ "model, so Sovara cannot record its calls."
309
+ )
310
+ return native.model_copy(update={"bound": self})
311
+
312
+ def with_structured_output(self, schema: Any, **kwargs: Any) -> Runnable:
313
+ """Use the wrapped model's own structured output, keeping its calls recorded.
314
+
315
+ Parsing, ``include_raw`` and errors behave exactly as for the wrapped model.
316
+
317
+ Raises:
318
+ SovaraConfigError: The wrapped model's runnable does not call the model in a form
319
+ Sovara can redirect.
320
+ """
321
+ native = self._model.with_structured_output(schema, **kwargs)
322
+ rebound, found = _rebind(native, self._model, self)
323
+ if not found:
324
+ raise SovaraConfigError(
325
+ f"{type(self._model).__name__}.with_structured_output returned a runnable "
326
+ "Sovara cannot record."
327
+ )
328
+ return rebound
329
+
330
+
331
+ def wrap_chat_model(model: BaseChatModel) -> SovaraChatModel:
332
+ """Return a chat model that records every call of ``model``; wrapping twice is a no-op.
333
+
334
+ The wrapper takes the wrapped model's callbacks, cache, rate limiter and other chat-model
335
+ settings, since calls go to its generation methods directly. Other attributes are
336
+ forwarded to ``model``.
337
+
338
+ Raises:
339
+ SovaraConfigError: ``model`` is not a LangChain chat model.
340
+ """
341
+ if isinstance(model, SovaraChatModel):
342
+ return model
343
+ if not isinstance(model, BaseChatModel):
344
+ raise SovaraConfigError("wrap_chat_model() needs a LangChain BaseChatModel.")
345
+ settings = {name: getattr(model, name) for name in BaseChatModel.model_fields}
346
+ return SovaraChatModel(model=model, **settings)
@@ -0,0 +1,20 @@
1
+ """Logging for the LangChain integration: metadata only, never payloads or tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+
8
+ logger = logging.getLogger("sovara_langchain")
9
+
10
+ _warned: set[str] = set()
11
+ _warned_lock = threading.Lock()
12
+
13
+
14
+ def warn_once(key: str, message: str) -> None:
15
+ """Log a warning the first time ``key`` is seen in this process."""
16
+ with _warned_lock:
17
+ if key in _warned:
18
+ return
19
+ _warned.add(key)
20
+ logger.warning(message)