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.
- sovara_langchain-0.1.0/LICENSE +21 -0
- sovara_langchain-0.1.0/PKG-INFO +59 -0
- sovara_langchain-0.1.0/README.md +43 -0
- sovara_langchain-0.1.0/pyproject.toml +68 -0
- sovara_langchain-0.1.0/setup.cfg +4 -0
- sovara_langchain-0.1.0/src/sovara_langchain/__init__.py +48 -0
- sovara_langchain-0.1.0/src/sovara_langchain/_chat_model.py +346 -0
- sovara_langchain-0.1.0/src/sovara_langchain/_log.py +20 -0
- sovara_langchain-0.1.0/src/sovara_langchain/_messages.py +278 -0
- sovara_langchain-0.1.0/src/sovara_langchain/_middleware.py +143 -0
- sovara_langchain-0.1.0/src/sovara_langchain/_tool_patch.py +150 -0
- sovara_langchain-0.1.0/src/sovara_langchain.egg-info/PKG-INFO +59 -0
- sovara_langchain-0.1.0/src/sovara_langchain.egg-info/SOURCES.txt +16 -0
- sovara_langchain-0.1.0/src/sovara_langchain.egg-info/dependency_links.txt +1 -0
- sovara_langchain-0.1.0/src/sovara_langchain.egg-info/requires.txt +5 -0
- sovara_langchain-0.1.0/src/sovara_langchain.egg-info/top_level.txt +1 -0
- sovara_langchain-0.1.0/tests/test_sovara_langchain.py +53 -0
- sovara_langchain-0.1.0/tests/test_tool_patch.py +47 -0
|
@@ -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,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)
|