langchain-llamay 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_llamay-0.1.0/.gitignore +7 -0
- langchain_llamay-0.1.0/LICENSE +21 -0
- langchain_llamay-0.1.0/PKG-INFO +103 -0
- langchain_llamay-0.1.0/README.md +84 -0
- langchain_llamay-0.1.0/langchain_llamay/__init__.py +13 -0
- langchain_llamay-0.1.0/langchain_llamay/_base.py +14 -0
- langchain_llamay-0.1.0/langchain_llamay/_client.py +52 -0
- langchain_llamay-0.1.0/langchain_llamay/chat_models.py +131 -0
- langchain_llamay-0.1.0/langchain_llamay/embeddings.py +26 -0
- langchain_llamay-0.1.0/langchain_llamay/py.typed +0 -0
- langchain_llamay-0.1.0/pyproject.toml +39 -0
- langchain_llamay-0.1.0/tests/__init__.py +0 -0
- langchain_llamay-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_llamay-0.1.0/tests/integration_tests/test_chat_models.py +31 -0
- langchain_llamay-0.1.0/tests/integration_tests/test_embeddings.py +13 -0
- langchain_llamay-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_llamay-0.1.0/tests/unit_tests/test_chat_models.py +69 -0
- langchain_llamay-0.1.0/tests/unit_tests/test_embeddings.py +18 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kosa8 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,103 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-llamay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain integration for llamay, a local LLM runtime
|
|
5
|
+
Project-URL: Homepage, https://llamay.com
|
|
6
|
+
Project-URL: Documentation, https://github.com/kosa8ai/langchain-llamay#readme
|
|
7
|
+
Project-URL: Source, https://github.com/kosa8ai/langchain-llamay
|
|
8
|
+
Author: Kosa8 Labs
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: langchain-core<2.0.0,>=1.0.0
|
|
17
|
+
Requires-Dist: langchain-ollama<2.0.0,>=1.0.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# langchain-llamay
|
|
21
|
+
|
|
22
|
+
LangChain integration for [llamay](https://llamay.com), a local LLM runtime for
|
|
23
|
+
macOS, Windows and Linux.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -U langchain-llamay
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Start a server
|
|
30
|
+
|
|
31
|
+
Download llamay from [llamay.com/download](https://llamay.com/download/), then:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
llamay serve -m qwen2.5:7b
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
llamay downloads a model it does not have yet, including GGUF repositories on
|
|
38
|
+
Hugging Face (`hf.co/<owner>/<repo>:<quant>`), and listens on
|
|
39
|
+
`http://localhost:11435`. Set `LLAMAY_HOST` to reach a server elsewhere.
|
|
40
|
+
|
|
41
|
+
## Chat
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from langchain_llamay import ChatLlamay
|
|
45
|
+
|
|
46
|
+
llm = ChatLlamay(model="qwen2.5:7b")
|
|
47
|
+
print(llm.invoke("Why is the sky blue?").content)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Tool calling, structured output and streaming work as they do in
|
|
51
|
+
`langchain-ollama`, which this package builds on: llamay serves Ollama's API.
|
|
52
|
+
|
|
53
|
+
One thing Ollama lacks does work here: `tool_choice`. llamay constrains the
|
|
54
|
+
answer to a call when one is required, so even a small model calls the tool it
|
|
55
|
+
is told to.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
def get_weather(city: str) -> str:
|
|
59
|
+
"""Get the weather for a city."""
|
|
60
|
+
return "sunny"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
llm.bind_tools([get_weather], tool_choice="get_weather").invoke("Hello!").tool_calls
|
|
64
|
+
# [{'name': 'get_weather', 'args': {'city': ...}, ...}]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`tool_choice` takes `"auto"`, `"none"`, `"any"` or `True` (call one of the
|
|
68
|
+
tools), a tool's name, or an OpenAI or Anthropic tool_choice object.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pydantic import BaseModel
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class City(BaseModel):
|
|
75
|
+
name: str
|
|
76
|
+
country: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
llm.with_structured_output(City).invoke("Name a city in Japan.")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Embeddings
|
|
83
|
+
|
|
84
|
+
Start the server with an encoder beside the chat model:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
llamay serve -m qwen2.5:7b -embed all-minilm:latest
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from langchain_llamay import LlamayEmbeddings
|
|
92
|
+
|
|
93
|
+
embeddings = LlamayEmbeddings(model="all-minilm:latest")
|
|
94
|
+
vector = embeddings.embed_query("hello")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Tests
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv run --group test pytest tests/unit_tests
|
|
101
|
+
# with `llamay serve -m qwen2.5:0.5b -embed all-minilm:latest` running:
|
|
102
|
+
uv run --group test pytest tests/integration_tests
|
|
103
|
+
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# langchain-llamay
|
|
2
|
+
|
|
3
|
+
LangChain integration for [llamay](https://llamay.com), a local LLM runtime for
|
|
4
|
+
macOS, Windows and Linux.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install -U langchain-llamay
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Start a server
|
|
11
|
+
|
|
12
|
+
Download llamay from [llamay.com/download](https://llamay.com/download/), then:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
llamay serve -m qwen2.5:7b
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
llamay downloads a model it does not have yet, including GGUF repositories on
|
|
19
|
+
Hugging Face (`hf.co/<owner>/<repo>:<quant>`), and listens on
|
|
20
|
+
`http://localhost:11435`. Set `LLAMAY_HOST` to reach a server elsewhere.
|
|
21
|
+
|
|
22
|
+
## Chat
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from langchain_llamay import ChatLlamay
|
|
26
|
+
|
|
27
|
+
llm = ChatLlamay(model="qwen2.5:7b")
|
|
28
|
+
print(llm.invoke("Why is the sky blue?").content)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Tool calling, structured output and streaming work as they do in
|
|
32
|
+
`langchain-ollama`, which this package builds on: llamay serves Ollama's API.
|
|
33
|
+
|
|
34
|
+
One thing Ollama lacks does work here: `tool_choice`. llamay constrains the
|
|
35
|
+
answer to a call when one is required, so even a small model calls the tool it
|
|
36
|
+
is told to.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
def get_weather(city: str) -> str:
|
|
40
|
+
"""Get the weather for a city."""
|
|
41
|
+
return "sunny"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
llm.bind_tools([get_weather], tool_choice="get_weather").invoke("Hello!").tool_calls
|
|
45
|
+
# [{'name': 'get_weather', 'args': {'city': ...}, ...}]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`tool_choice` takes `"auto"`, `"none"`, `"any"` or `True` (call one of the
|
|
49
|
+
tools), a tool's name, or an OpenAI or Anthropic tool_choice object.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from pydantic import BaseModel
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class City(BaseModel):
|
|
56
|
+
name: str
|
|
57
|
+
country: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
llm.with_structured_output(City).invoke("Name a city in Japan.")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Embeddings
|
|
64
|
+
|
|
65
|
+
Start the server with an encoder beside the chat model:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
llamay serve -m qwen2.5:7b -embed all-minilm:latest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from langchain_llamay import LlamayEmbeddings
|
|
73
|
+
|
|
74
|
+
embeddings = LlamayEmbeddings(model="all-minilm:latest")
|
|
75
|
+
vector = embeddings.embed_query("hello")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Tests
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run --group test pytest tests/unit_tests
|
|
82
|
+
# with `llamay serve -m qwen2.5:0.5b -embed all-minilm:latest` running:
|
|
83
|
+
uv run --group test pytest tests/integration_tests
|
|
84
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""LangChain integration for llamay, a local LLM runtime."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
from langchain_llamay.chat_models import ChatLlamay
|
|
6
|
+
from langchain_llamay.embeddings import LlamayEmbeddings
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = metadata.version(__package__)
|
|
10
|
+
except metadata.PackageNotFoundError:
|
|
11
|
+
__version__ = ""
|
|
12
|
+
|
|
13
|
+
__all__ = ["ChatLlamay", "LlamayEmbeddings", "__version__"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Where a llamay server is, when nobody says."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
DEFAULT_BASE_URL = "http://localhost:11435"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def default_base_url() -> str:
|
|
9
|
+
"""LLAMAY_HOST, or llamay's own port on this machine.
|
|
10
|
+
|
|
11
|
+
llamay listens on 11435, beside Ollama's 11434, so the two can run at once;
|
|
12
|
+
pointing at 11434 by default would quietly talk to Ollama instead.
|
|
13
|
+
"""
|
|
14
|
+
return os.environ.get("LLAMAY_HOST") or DEFAULT_BASE_URL
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The ollama client, able to send llamay's tool_choice.
|
|
2
|
+
|
|
3
|
+
Ollama's /api/chat has no tool_choice, so the ollama client has no argument for
|
|
4
|
+
it. llamay's does: it reads the field in OpenAI's and Anthropic's shapes and
|
|
5
|
+
constrains generation to a call, which is how a small local model can be made
|
|
6
|
+
to call a tool at all. These subclasses add the field to the request body.
|
|
7
|
+
|
|
8
|
+
The choice travels in a context variable rather than on the client, because one
|
|
9
|
+
client serves every call a chat model makes, concurrent ones included. The body
|
|
10
|
+
is built inside chat() before it returns -- for a stream too, whose generator
|
|
11
|
+
only reads the response -- so the variable is still set when _request runs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from contextvars import ContextVar
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ollama import AsyncClient, Client
|
|
20
|
+
|
|
21
|
+
_tool_choice: ContextVar[Any] = ContextVar("llamay_tool_choice", default=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _with_choice(kwargs: dict[str, Any], args: tuple[Any, ...]) -> dict[str, Any]:
|
|
25
|
+
choice = _tool_choice.get()
|
|
26
|
+
if choice is not None and len(args) >= 2 and args[1] == "/api/chat" and "json" in kwargs:
|
|
27
|
+
kwargs = {**kwargs, "json": {**kwargs["json"], "tool_choice": choice}}
|
|
28
|
+
return kwargs
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LlamayClient(Client):
|
|
32
|
+
def chat(self, *args: Any, tool_choice: Any = None, **kwargs: Any) -> Any:
|
|
33
|
+
token = _tool_choice.set(tool_choice)
|
|
34
|
+
try:
|
|
35
|
+
return super().chat(*args, **kwargs)
|
|
36
|
+
finally:
|
|
37
|
+
_tool_choice.reset(token)
|
|
38
|
+
|
|
39
|
+
def _request(self, cls: Any, *args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
return super()._request(cls, *args, **_with_choice(kwargs, args))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LlamayAsyncClient(AsyncClient):
|
|
44
|
+
async def chat(self, *args: Any, tool_choice: Any = None, **kwargs: Any) -> Any:
|
|
45
|
+
token = _tool_choice.set(tool_choice)
|
|
46
|
+
try:
|
|
47
|
+
return await super().chat(*args, **kwargs)
|
|
48
|
+
finally:
|
|
49
|
+
_tool_choice.reset(token)
|
|
50
|
+
|
|
51
|
+
async def _request(self, cls: Any, *args: Any, **kwargs: Any) -> Any:
|
|
52
|
+
return await super()._request(cls, *args, **_with_choice(kwargs, args))
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""llamay chat models."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from langchain_core.language_models import LangSmithParams, LanguageModelInput
|
|
7
|
+
from langchain_core.messages import AIMessage, BaseMessage
|
|
8
|
+
from langchain_core.runnables import Runnable
|
|
9
|
+
from langchain_core.tools import BaseTool
|
|
10
|
+
from langchain_core.utils.function_calling import convert_to_openai_tool
|
|
11
|
+
from langchain_ollama import ChatOllama
|
|
12
|
+
from pydantic import Field, model_validator
|
|
13
|
+
from typing_extensions import Self
|
|
14
|
+
|
|
15
|
+
from langchain_llamay._base import default_base_url
|
|
16
|
+
from langchain_llamay._client import LlamayAsyncClient, LlamayClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _llamay_tool_choice(choice: dict | str | bool | None) -> dict | str | None:
|
|
20
|
+
"""LangChain's tool_choice in a form llamay reads.
|
|
21
|
+
|
|
22
|
+
llamay takes OpenAI's strings and object and Anthropic's object. LangChain
|
|
23
|
+
adds True for "call something" and a bare tool name for "call this one".
|
|
24
|
+
"""
|
|
25
|
+
if choice is None or choice is False:
|
|
26
|
+
return None
|
|
27
|
+
if choice is True:
|
|
28
|
+
return "required"
|
|
29
|
+
if isinstance(choice, dict):
|
|
30
|
+
return choice
|
|
31
|
+
if choice in ("auto", "none", "any", "required"):
|
|
32
|
+
return choice
|
|
33
|
+
return {"type": "function", "function": {"name": choice}}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ChatLlamay(ChatOllama):
|
|
37
|
+
"""Chat with a model served by `llamay serve`.
|
|
38
|
+
|
|
39
|
+
llamay serves Ollama's API, so this is `ChatOllama` pointed at llamay, with
|
|
40
|
+
one addition Ollama does not have: `tool_choice` is honoured. llamay
|
|
41
|
+
constrains generation to a call when one is required, so even a small
|
|
42
|
+
local model calls the tool it is told to.
|
|
43
|
+
|
|
44
|
+
Setup:
|
|
45
|
+
Install llamay from https://llamay.com/download/ and start a server:
|
|
46
|
+
|
|
47
|
+
.. code-block:: bash
|
|
48
|
+
|
|
49
|
+
llamay serve -m qwen2.5:7b
|
|
50
|
+
pip install -U langchain-llamay
|
|
51
|
+
|
|
52
|
+
Instantiate:
|
|
53
|
+
.. code-block:: python
|
|
54
|
+
|
|
55
|
+
from langchain_llamay import ChatLlamay
|
|
56
|
+
|
|
57
|
+
llm = ChatLlamay(model="qwen2.5:7b")
|
|
58
|
+
llm.invoke("Why is the sky blue?")
|
|
59
|
+
|
|
60
|
+
Tool calling:
|
|
61
|
+
.. code-block:: python
|
|
62
|
+
|
|
63
|
+
llm.bind_tools([get_weather], tool_choice="get_weather").invoke("Hi")
|
|
64
|
+
|
|
65
|
+
Key init args:
|
|
66
|
+
model: str
|
|
67
|
+
Any name `llamay models` lists, or a Hugging Face reference such as
|
|
68
|
+
``hf.co/bartowski/Qwen2.5-7B-Instruct-GGUF:Q4_K_M``.
|
|
69
|
+
base_url: str | None
|
|
70
|
+
The server. Defaults to ``LLAMAY_HOST``, else
|
|
71
|
+
``http://localhost:11435``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
base_url: str | None = Field(default_factory=default_base_url)
|
|
75
|
+
|
|
76
|
+
@model_validator(mode="after")
|
|
77
|
+
def _llamay_clients(self) -> Self:
|
|
78
|
+
# ChatOllama has built its clients by now, with every setting it was
|
|
79
|
+
# given; giving them the subclass that can send tool_choice keeps all
|
|
80
|
+
# of that rather than rebuilding it here.
|
|
81
|
+
if self._client is not None:
|
|
82
|
+
self._client.__class__ = LlamayClient
|
|
83
|
+
if self._async_client is not None:
|
|
84
|
+
self._async_client.__class__ = LlamayAsyncClient
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def _llm_type(self) -> str:
|
|
89
|
+
return "chat-llamay"
|
|
90
|
+
|
|
91
|
+
def _get_ls_params(self, stop: list[str] | None = None, **kwargs: Any) -> LangSmithParams:
|
|
92
|
+
params = super()._get_ls_params(stop=stop, **kwargs)
|
|
93
|
+
params["ls_provider"] = "llamay"
|
|
94
|
+
# A per-call model= is the model that answered; ChatOllama reports the
|
|
95
|
+
# constructor's, which files the trace under the wrong model.
|
|
96
|
+
params["ls_model_name"] = kwargs.get("model", self.model)
|
|
97
|
+
return params
|
|
98
|
+
|
|
99
|
+
def bind_tools(
|
|
100
|
+
self,
|
|
101
|
+
tools: Sequence[dict[str, Any] | type | Callable | BaseTool],
|
|
102
|
+
*,
|
|
103
|
+
tool_choice: dict | str | Literal["auto", "any"] | bool | None = None, # noqa: PYI051
|
|
104
|
+
**kwargs: Any,
|
|
105
|
+
) -> Runnable[LanguageModelInput, AIMessage]:
|
|
106
|
+
"""Bind tools, and optionally which of them the model must call.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
tools: Tool definitions, in any form ``convert_to_openai_tool`` reads.
|
|
110
|
+
tool_choice: ``"auto"``, ``"none"``, ``"any"`` or ``True`` (call one
|
|
111
|
+
of them), a tool's name (call that one), or an OpenAI or
|
|
112
|
+
Anthropic tool_choice object.
|
|
113
|
+
kwargs: Passed to ``self.bind``.
|
|
114
|
+
"""
|
|
115
|
+
choice = _llamay_tool_choice(tool_choice)
|
|
116
|
+
if choice is not None:
|
|
117
|
+
kwargs["tool_choice"] = choice
|
|
118
|
+
# Not super().bind_tools: its own tool_choice parameter would take the
|
|
119
|
+
# choice and drop it, which is what this method exists to stop.
|
|
120
|
+
return self.bind(tools=[convert_to_openai_tool(t) for t in tools], **kwargs)
|
|
121
|
+
|
|
122
|
+
def _chat_params(
|
|
123
|
+
self, messages: list[BaseMessage], stop: list[str] | None = None, **kwargs: Any
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
# Kept out of ChatOllama's params, which go to the ollama client as
|
|
126
|
+
# they are, and put back for LlamayClient.chat to send.
|
|
127
|
+
choice = kwargs.pop("tool_choice", None)
|
|
128
|
+
params = super()._chat_params(messages, stop, **kwargs)
|
|
129
|
+
if choice is not None:
|
|
130
|
+
params["tool_choice"] = choice
|
|
131
|
+
return params
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""llamay embeddings."""
|
|
2
|
+
|
|
3
|
+
from langchain_ollama import OllamaEmbeddings
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from langchain_llamay._base import default_base_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LlamayEmbeddings(OllamaEmbeddings):
|
|
10
|
+
"""Embeddings from the encoder a llamay server was started with.
|
|
11
|
+
|
|
12
|
+
llamay serves embeddings from an encoder loaded beside the chat model:
|
|
13
|
+
|
|
14
|
+
.. code-block:: bash
|
|
15
|
+
|
|
16
|
+
llamay serve -m qwen2.5:7b -embed all-minilm:latest
|
|
17
|
+
|
|
18
|
+
.. code-block:: python
|
|
19
|
+
|
|
20
|
+
from langchain_llamay import LlamayEmbeddings
|
|
21
|
+
|
|
22
|
+
embeddings = LlamayEmbeddings(model="all-minilm:latest")
|
|
23
|
+
embeddings.embed_query("hello")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
base_url: str | None = Field(default_factory=default_base_url)
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-llamay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain integration for llamay, a local LLM runtime"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Kosa8 Labs" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"langchain-core>=1.0.0,<2.0.0",
|
|
16
|
+
"langchain-ollama>=1.0.0,<2.0.0",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://llamay.com"
|
|
27
|
+
Documentation = "https://github.com/kosa8ai/langchain-llamay#readme"
|
|
28
|
+
Source = "https://github.com/kosa8ai/langchain-llamay"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
test = ["pytest>=8,<9", "pytest-asyncio>=0.23", "langchain-tests>=1.0.0,<2.0.0"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
36
|
+
markers = ["compile: placeholder test that only checks the package imports"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["langchain_llamay"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Standard tests against a live `llamay serve -m qwen2.5:0.5b -embed all-minilm:latest`."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from langchain_tests.integration_tests import ChatModelIntegrationTests
|
|
6
|
+
|
|
7
|
+
from langchain_llamay import ChatLlamay
|
|
8
|
+
|
|
9
|
+
MODEL = os.environ.get("LLAMAY_TEST_MODEL", "qwen2.5:0.5b")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestChatLlamayIntegration(ChatModelIntegrationTests):
|
|
13
|
+
@property
|
|
14
|
+
def chat_model_class(self) -> type[ChatLlamay]:
|
|
15
|
+
return ChatLlamay
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def chat_model_params(self) -> dict:
|
|
19
|
+
return {"model": MODEL, "temperature": 0}
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def has_tool_calling(self) -> bool:
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def has_tool_choice(self) -> bool:
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def supports_image_inputs(self) -> bool:
|
|
31
|
+
return False
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from langchain_tests.integration_tests import EmbeddingsIntegrationTests
|
|
2
|
+
|
|
3
|
+
from langchain_llamay import LlamayEmbeddings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestLlamayEmbeddingsIntegration(EmbeddingsIntegrationTests):
|
|
7
|
+
@property
|
|
8
|
+
def embeddings_class(self) -> type[LlamayEmbeddings]:
|
|
9
|
+
return LlamayEmbeddings
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def embedding_model_params(self) -> dict:
|
|
13
|
+
return {"model": "all-minilm:latest"}
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from langchain_tests.unit_tests import ChatModelUnitTests
|
|
2
|
+
|
|
3
|
+
from langchain_llamay import ChatLlamay
|
|
4
|
+
from langchain_llamay._base import DEFAULT_BASE_URL
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestChatLlamayUnit(ChatModelUnitTests):
|
|
8
|
+
@property
|
|
9
|
+
def chat_model_class(self) -> type[ChatLlamay]:
|
|
10
|
+
return ChatLlamay
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def chat_model_params(self) -> dict:
|
|
14
|
+
return {"model": "qwen2.5:0.5b"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_defaults_to_llamays_port(monkeypatch) -> None:
|
|
18
|
+
monkeypatch.delenv("LLAMAY_HOST", raising=False)
|
|
19
|
+
assert ChatLlamay(model="qwen2.5:0.5b").base_url == DEFAULT_BASE_URL == "http://localhost:11435"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_reads_llamay_host(monkeypatch) -> None:
|
|
23
|
+
monkeypatch.setenv("LLAMAY_HOST", "http://gpu-box:11435")
|
|
24
|
+
assert ChatLlamay(model="qwen2.5:0.5b").base_url == "http://gpu-box:11435"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_an_explicit_base_url_wins(monkeypatch) -> None:
|
|
28
|
+
monkeypatch.setenv("LLAMAY_HOST", "http://gpu-box:11435")
|
|
29
|
+
assert ChatLlamay(model="m", base_url="http://other:1").base_url == "http://other:1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_traces_as_llamay() -> None:
|
|
33
|
+
llm = ChatLlamay(model="qwen2.5:0.5b")
|
|
34
|
+
assert llm._llm_type == "chat-llamay"
|
|
35
|
+
assert llm._get_ls_params()["ls_provider"] == "llamay"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_tool_choice_reaches_the_request(monkeypatch) -> None:
|
|
39
|
+
# ChatOllama drops tool_choice; ChatLlamay sends it, in the shape llamay
|
|
40
|
+
# reads, on the same /api/chat request as the tools.
|
|
41
|
+
sent: list[dict] = []
|
|
42
|
+
|
|
43
|
+
def fake_request(self, cls, *args, **kwargs):
|
|
44
|
+
sent.append(kwargs.get("json", {}))
|
|
45
|
+
return cls(model="m", message={"role": "assistant", "content": ""}, done=True)
|
|
46
|
+
|
|
47
|
+
import ollama
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr(ollama.Client, "_request", fake_request)
|
|
50
|
+
|
|
51
|
+
def get_weather(city: str) -> str:
|
|
52
|
+
"""Get the weather for a city."""
|
|
53
|
+
return "sunny"
|
|
54
|
+
|
|
55
|
+
llm = ChatLlamay(model="qwen2.5:0.5b")
|
|
56
|
+
for choice, want in [
|
|
57
|
+
("get_weather", {"type": "function", "function": {"name": "get_weather"}}),
|
|
58
|
+
("any", "any"),
|
|
59
|
+
(True, "required"),
|
|
60
|
+
("auto", "auto"),
|
|
61
|
+
]:
|
|
62
|
+
sent.clear()
|
|
63
|
+
llm.bind_tools([get_weather], tool_choice=choice).invoke("hi", stream=False)
|
|
64
|
+
assert sent and sent[-1].get("tool_choice") == want, (choice, sent)
|
|
65
|
+
assert sent[-1]["tools"][0]["function"]["name"] == "get_weather"
|
|
66
|
+
|
|
67
|
+
sent.clear()
|
|
68
|
+
llm.bind_tools([get_weather]).invoke("hi", stream=False)
|
|
69
|
+
assert "tool_choice" not in sent[-1]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from langchain_tests.unit_tests import EmbeddingsUnitTests
|
|
2
|
+
|
|
3
|
+
from langchain_llamay import LlamayEmbeddings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestLlamayEmbeddingsUnit(EmbeddingsUnitTests):
|
|
7
|
+
@property
|
|
8
|
+
def embeddings_class(self) -> type[LlamayEmbeddings]:
|
|
9
|
+
return LlamayEmbeddings
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def embedding_model_params(self) -> dict:
|
|
13
|
+
return {"model": "all-minilm:latest"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_embeddings_default_to_llamays_port(monkeypatch) -> None:
|
|
17
|
+
monkeypatch.delenv("LLAMAY_HOST", raising=False)
|
|
18
|
+
assert LlamayEmbeddings(model="all-minilm:latest").base_url == "http://localhost:11435"
|