langchain-keln 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_keln-0.1.0/.gitignore +13 -0
- langchain_keln-0.1.0/CHANGELOG.md +10 -0
- langchain_keln-0.1.0/LICENSE +21 -0
- langchain_keln-0.1.0/PKG-INFO +146 -0
- langchain_keln-0.1.0/README.md +120 -0
- langchain_keln-0.1.0/langchain_keln/__init__.py +12 -0
- langchain_keln-0.1.0/langchain_keln/chat_models.py +257 -0
- langchain_keln-0.1.0/langchain_keln/py.typed +0 -0
- langchain_keln-0.1.0/pyproject.toml +81 -0
- langchain_keln-0.1.0/tests/__init__.py +0 -0
- langchain_keln-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_keln-0.1.0/tests/integration_tests/test_compile.py +6 -0
- langchain_keln-0.1.0/tests/integration_tests/test_standard.py +52 -0
- langchain_keln-0.1.0/tests/integration_tests/test_tool_loop.py +89 -0
- langchain_keln-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_keln-0.1.0/tests/unit_tests/test_chat_models.py +249 -0
- langchain_keln-0.1.0/tests/unit_tests/test_imports.py +7 -0
- langchain_keln-0.1.0/tests/unit_tests/test_standard.py +32 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- First release.
|
|
6
|
+
- `ChatKeln` chat model for Keln, based on `langchain-openai`'s `BaseChatOpenAI`.
|
|
7
|
+
- Reads `KELN_API_KEY` and `KELN_API_BASE`. Defaults to `https://api.keln.ai/v1` and `zai-org/glm-5.3`.
|
|
8
|
+
- Reads the reasoning trace from `reasoning_content` in `invoke` and `stream`, and sends it back on assistant messages.
|
|
9
|
+
- Uses `/v1/chat/completions` with the `system` role and `max_tokens`, and leaves out `store`.
|
|
10
|
+
- Passes LangChain's standard unit tests.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keln
|
|
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,146 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-keln
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain integration for Keln, an inference platform for open models
|
|
5
|
+
Project-URL: Homepage, https://keln.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.keln.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/kris-holy/langchain-keln
|
|
8
|
+
Project-URL: Issues, https://github.com/kris-holy/langchain-keln/issues
|
|
9
|
+
Author-email: kris-holy <kris@keln.ai>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: keln,langchain,llm,open models,openai-compatible
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: <4.0,>=3.10
|
|
23
|
+
Requires-Dist: langchain-core<2.0.0,>=1.0.0
|
|
24
|
+
Requires-Dist: langchain-openai<2.0.0,>=1.0.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# langchain-keln
|
|
28
|
+
|
|
29
|
+
LangChain chat model for [Keln](https://keln.ai), an inference platform for open models.
|
|
30
|
+
|
|
31
|
+
`ChatKeln` works like `ChatOpenAI`: `invoke`, `stream`, async calls, tool calling, structured output and agents. It also handles 2 Keln details for you. It reads the reasoning trace that reasoning models return, and it sends that trace back on assistant messages in a tool call loop.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -U langchain-keln
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Get an API key
|
|
40
|
+
|
|
41
|
+
1. Sign up at [app.keln.ai/signup](https://app.keln.ai/signup).
|
|
42
|
+
2. Go to **Keys** → **Create key**.
|
|
43
|
+
3. Set the key as an environment variable:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export KELN_API_KEY="your-api-key"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
On Windows PowerShell:
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
$env:KELN_API_KEY = "your-api-key"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Chat
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from langchain_keln import ChatKeln
|
|
59
|
+
|
|
60
|
+
llm = ChatKeln(model="zai-org/glm-5.3", reasoning_effort="low")
|
|
61
|
+
|
|
62
|
+
print(llm.invoke("Explain prompt caching in 1 sentence.").content)
|
|
63
|
+
|
|
64
|
+
for chunk in llm.stream("Write a haiku about open models."):
|
|
65
|
+
print(chunk.text, end="", flush=True)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The model list with context lengths and reasoning levels is at [docs.keln.ai/models-pricing](https://docs.keln.ai/models-pricing/). The same list is available as JSON at `GET https://api.keln.ai/v1/models`, with no key needed.
|
|
69
|
+
|
|
70
|
+
## Tool calling and structured output
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from langchain_core.tools import tool
|
|
74
|
+
from pydantic import BaseModel
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@tool
|
|
78
|
+
def get_weather(city: str) -> str:
|
|
79
|
+
"""Return the current weather for a city."""
|
|
80
|
+
return "sunny, 21 C"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
llm_with_tools = llm.bind_tools([get_weather])
|
|
84
|
+
print(llm_with_tools.invoke("What is the weather in Paris?").tool_calls)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Summary(BaseModel):
|
|
88
|
+
title: str
|
|
89
|
+
bullets: list[str]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
print(
|
|
93
|
+
llm.with_structured_output(Summary).invoke(
|
|
94
|
+
"Summarize what an inference platform does."
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Agents
|
|
100
|
+
|
|
101
|
+
With the `langchain` package installed (`pip install -U langchain`):
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from langchain.agents import create_agent
|
|
105
|
+
|
|
106
|
+
agent = create_agent(ChatKeln(model="moonshotai/kimi-k3"), tools=[get_weather])
|
|
107
|
+
agent.invoke(
|
|
108
|
+
{"messages": [{"role": "user", "content": "What is the weather in Paris?"}]}
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Reasoning
|
|
113
|
+
|
|
114
|
+
Most Keln models can think before they answer. Set the level with `reasoning_effort`, for example `"none"`, `"low"`, `"high"` or `"max"`. Each model lists the levels it accepts in `/v1/models`. If you leave it out, each model uses the default level listed in `/v1/models`.
|
|
115
|
+
|
|
116
|
+
The trace is in `message.additional_kwargs["reasoning_content"]`, in `invoke` and in `stream`. When you pass the conversation back, `ChatKeln` sends each assistant message's trace with it, so the model keeps its train of thought across tool calls.
|
|
117
|
+
|
|
118
|
+
## Configuration
|
|
119
|
+
|
|
120
|
+
| Argument | Env var | Default |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `api_key` | `KELN_API_KEY` | required |
|
|
123
|
+
| `base_url` | `KELN_API_BASE` | `https://api.keln.ai/v1` |
|
|
124
|
+
| `model` | | `zai-org/glm-5.3` |
|
|
125
|
+
|
|
126
|
+
Every other `ChatOpenAI` argument (`temperature`, `max_tokens`, `timeout`, `max_retries`, `model_kwargs` and others) works the same way. `max_tokens` covers reasoning and answer together.
|
|
127
|
+
|
|
128
|
+
`ChatKeln` uses the `/v1/chat/completions` endpoint. It sends the `system` role and `max_tokens`, and leaves out the `store` field.
|
|
129
|
+
|
|
130
|
+
## Docs
|
|
131
|
+
|
|
132
|
+
- Keln docs: [docs.keln.ai](https://docs.keln.ai)
|
|
133
|
+
- Models and prices: [docs.keln.ai/models-pricing](https://docs.keln.ai/models-pricing/)
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pip install -e . --group test --group lint --group typing
|
|
139
|
+
pytest tests/unit_tests --disable-socket --allow-unix-socket
|
|
140
|
+
KELN_API_KEY=... pytest tests/integration_tests
|
|
141
|
+
ruff check . && ruff format --check . && mypy langchain_keln tests
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT, Keln.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# langchain-keln
|
|
2
|
+
|
|
3
|
+
LangChain chat model for [Keln](https://keln.ai), an inference platform for open models.
|
|
4
|
+
|
|
5
|
+
`ChatKeln` works like `ChatOpenAI`: `invoke`, `stream`, async calls, tool calling, structured output and agents. It also handles 2 Keln details for you. It reads the reasoning trace that reasoning models return, and it sends that trace back on assistant messages in a tool call loop.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -U langchain-keln
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Get an API key
|
|
14
|
+
|
|
15
|
+
1. Sign up at [app.keln.ai/signup](https://app.keln.ai/signup).
|
|
16
|
+
2. Go to **Keys** → **Create key**.
|
|
17
|
+
3. Set the key as an environment variable:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export KELN_API_KEY="your-api-key"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
On Windows PowerShell:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
$env:KELN_API_KEY = "your-api-key"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Chat
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from langchain_keln import ChatKeln
|
|
33
|
+
|
|
34
|
+
llm = ChatKeln(model="zai-org/glm-5.3", reasoning_effort="low")
|
|
35
|
+
|
|
36
|
+
print(llm.invoke("Explain prompt caching in 1 sentence.").content)
|
|
37
|
+
|
|
38
|
+
for chunk in llm.stream("Write a haiku about open models."):
|
|
39
|
+
print(chunk.text, end="", flush=True)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The model list with context lengths and reasoning levels is at [docs.keln.ai/models-pricing](https://docs.keln.ai/models-pricing/). The same list is available as JSON at `GET https://api.keln.ai/v1/models`, with no key needed.
|
|
43
|
+
|
|
44
|
+
## Tool calling and structured output
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from langchain_core.tools import tool
|
|
48
|
+
from pydantic import BaseModel
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@tool
|
|
52
|
+
def get_weather(city: str) -> str:
|
|
53
|
+
"""Return the current weather for a city."""
|
|
54
|
+
return "sunny, 21 C"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
llm_with_tools = llm.bind_tools([get_weather])
|
|
58
|
+
print(llm_with_tools.invoke("What is the weather in Paris?").tool_calls)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Summary(BaseModel):
|
|
62
|
+
title: str
|
|
63
|
+
bullets: list[str]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
print(
|
|
67
|
+
llm.with_structured_output(Summary).invoke(
|
|
68
|
+
"Summarize what an inference platform does."
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Agents
|
|
74
|
+
|
|
75
|
+
With the `langchain` package installed (`pip install -U langchain`):
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from langchain.agents import create_agent
|
|
79
|
+
|
|
80
|
+
agent = create_agent(ChatKeln(model="moonshotai/kimi-k3"), tools=[get_weather])
|
|
81
|
+
agent.invoke(
|
|
82
|
+
{"messages": [{"role": "user", "content": "What is the weather in Paris?"}]}
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Reasoning
|
|
87
|
+
|
|
88
|
+
Most Keln models can think before they answer. Set the level with `reasoning_effort`, for example `"none"`, `"low"`, `"high"` or `"max"`. Each model lists the levels it accepts in `/v1/models`. If you leave it out, each model uses the default level listed in `/v1/models`.
|
|
89
|
+
|
|
90
|
+
The trace is in `message.additional_kwargs["reasoning_content"]`, in `invoke` and in `stream`. When you pass the conversation back, `ChatKeln` sends each assistant message's trace with it, so the model keeps its train of thought across tool calls.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
| Argument | Env var | Default |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `api_key` | `KELN_API_KEY` | required |
|
|
97
|
+
| `base_url` | `KELN_API_BASE` | `https://api.keln.ai/v1` |
|
|
98
|
+
| `model` | | `zai-org/glm-5.3` |
|
|
99
|
+
|
|
100
|
+
Every other `ChatOpenAI` argument (`temperature`, `max_tokens`, `timeout`, `max_retries`, `model_kwargs` and others) works the same way. `max_tokens` covers reasoning and answer together.
|
|
101
|
+
|
|
102
|
+
`ChatKeln` uses the `/v1/chat/completions` endpoint. It sends the `system` role and `max_tokens`, and leaves out the `store` field.
|
|
103
|
+
|
|
104
|
+
## Docs
|
|
105
|
+
|
|
106
|
+
- Keln docs: [docs.keln.ai](https://docs.keln.ai)
|
|
107
|
+
- Models and prices: [docs.keln.ai/models-pricing](https://docs.keln.ai/models-pricing/)
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install -e . --group test --group lint --group typing
|
|
113
|
+
pytest tests/unit_tests --disable-socket --allow-unix-socket
|
|
114
|
+
KELN_API_KEY=... pytest tests/integration_tests
|
|
115
|
+
ruff check . && ruff format --check . && mypy langchain_keln tests
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT, Keln.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""LangChain integration for Keln."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
from langchain_keln.chat_models import ChatKeln
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = metadata.version(__package__ or "langchain-keln")
|
|
9
|
+
except metadata.PackageNotFoundError:
|
|
10
|
+
__version__ = ""
|
|
11
|
+
|
|
12
|
+
__all__ = ["ChatKeln", "__version__"]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Keln chat models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import openai
|
|
8
|
+
from langchain_core.language_models import LangSmithParams, LanguageModelInput
|
|
9
|
+
from langchain_core.messages import AIMessage, AIMessageChunk
|
|
10
|
+
from langchain_core.outputs import ChatGenerationChunk, ChatResult
|
|
11
|
+
from langchain_core.utils import from_env, secret_from_env
|
|
12
|
+
from langchain_openai.chat_models.base import BaseChatOpenAI
|
|
13
|
+
from pydantic import ConfigDict, Field, SecretStr, model_validator
|
|
14
|
+
from typing_extensions import Self
|
|
15
|
+
|
|
16
|
+
DEFAULT_API_BASE = "https://api.keln.ai/v1"
|
|
17
|
+
DEFAULT_MODEL = "zai-org/glm-5.3"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _reasoning_from_message(message: AIMessage) -> str | None:
|
|
21
|
+
"""Return the reasoning trace stored on an assistant message, if any."""
|
|
22
|
+
reasoning = message.additional_kwargs.get("reasoning_content")
|
|
23
|
+
if isinstance(reasoning, str) and reasoning:
|
|
24
|
+
return reasoning
|
|
25
|
+
# `output_version="v1"` keeps the trace in standard reasoning content blocks.
|
|
26
|
+
if isinstance(message.content, list):
|
|
27
|
+
parts = [
|
|
28
|
+
block["reasoning"]
|
|
29
|
+
for block in message.content
|
|
30
|
+
if isinstance(block, dict)
|
|
31
|
+
and block.get("type") == "reasoning"
|
|
32
|
+
and isinstance(block.get("reasoning"), str)
|
|
33
|
+
]
|
|
34
|
+
if parts:
|
|
35
|
+
return "".join(parts)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChatKeln(BaseChatOpenAI):
|
|
40
|
+
"""Chat models on Keln, an inference platform for open models.
|
|
41
|
+
|
|
42
|
+
Keln serves open models through an OpenAI-compatible API. The live model
|
|
43
|
+
list is at `GET https://api.keln.ai/v1/models` (no key needed) and on
|
|
44
|
+
https://docs.keln.ai/models-pricing/.
|
|
45
|
+
|
|
46
|
+
Setup:
|
|
47
|
+
Install `langchain-keln` and set the `KELN_API_KEY` environment variable.
|
|
48
|
+
To create a key, sign up at https://app.keln.ai/signup, then go to
|
|
49
|
+
**Keys** and select **Create key**.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -U langchain-keln
|
|
53
|
+
export KELN_API_KEY="your-api-key"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Key init args (completion params):
|
|
57
|
+
model:
|
|
58
|
+
Keln model id. Defaults to `"zai-org/glm-5.3"`.
|
|
59
|
+
temperature:
|
|
60
|
+
Sampling temperature.
|
|
61
|
+
max_tokens:
|
|
62
|
+
Max number of tokens to generate, reasoning included.
|
|
63
|
+
reasoning_effort:
|
|
64
|
+
Reasoning level, for example `"none"`, `"low"`, `"high"` or `"max"`.
|
|
65
|
+
The levels each model accepts are listed in `/v1/models`. If you leave
|
|
66
|
+
it out, each model uses the default level listed in `/v1/models`.
|
|
67
|
+
|
|
68
|
+
Key init args (client params):
|
|
69
|
+
api_key:
|
|
70
|
+
Keln API key. Read from `KELN_API_KEY` if not passed.
|
|
71
|
+
base_url:
|
|
72
|
+
API base URL. Read from `KELN_API_BASE`, defaults to
|
|
73
|
+
`https://api.keln.ai/v1`.
|
|
74
|
+
timeout:
|
|
75
|
+
Timeout for requests.
|
|
76
|
+
max_retries:
|
|
77
|
+
Max number of retries.
|
|
78
|
+
|
|
79
|
+
Instantiate:
|
|
80
|
+
```python
|
|
81
|
+
from langchain_keln import ChatKeln
|
|
82
|
+
|
|
83
|
+
model = ChatKeln(model="zai-org/glm-5.3", reasoning_effort="low")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Invoke:
|
|
87
|
+
```python
|
|
88
|
+
model.invoke("Explain prompt caching in 1 sentence.")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Stream:
|
|
92
|
+
```python
|
|
93
|
+
for chunk in model.stream("Write a haiku about open models."):
|
|
94
|
+
print(chunk.text, end="")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Tool calling and structured output work as with `ChatOpenAI`
|
|
98
|
+
(`bind_tools`, `with_structured_output`).
|
|
99
|
+
|
|
100
|
+
Reasoning:
|
|
101
|
+
Reasoning models return their trace in
|
|
102
|
+
`message.additional_kwargs["reasoning_content"]`, in `invoke` and in
|
|
103
|
+
`stream`. `ChatKeln` sends that trace back on assistant messages, so
|
|
104
|
+
reasoning models keep their train of thought across a tool call loop.
|
|
105
|
+
|
|
106
|
+
`ChatKeln` uses the `/v1/chat/completions` endpoint. It sends the `system`
|
|
107
|
+
role and `max_tokens`, and leaves out the `store` field.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
model_name: str = Field(default=DEFAULT_MODEL, alias="model")
|
|
111
|
+
"""Keln model id."""
|
|
112
|
+
api_key: SecretStr | None = Field(
|
|
113
|
+
default_factory=secret_from_env("KELN_API_KEY", default=None),
|
|
114
|
+
)
|
|
115
|
+
"""Keln API key. Read from `KELN_API_KEY` if not provided."""
|
|
116
|
+
api_base: str = Field(
|
|
117
|
+
alias="base_url",
|
|
118
|
+
default_factory=from_env("KELN_API_BASE", default=DEFAULT_API_BASE),
|
|
119
|
+
)
|
|
120
|
+
"""Keln API base URL. Read from `KELN_API_BASE` if not provided."""
|
|
121
|
+
stream_usage: bool | None = True
|
|
122
|
+
"""Include token usage in streamed responses."""
|
|
123
|
+
|
|
124
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def _llm_type(self) -> str:
|
|
128
|
+
"""Return type of chat model."""
|
|
129
|
+
return "chat-keln"
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def lc_secrets(self) -> dict[str, str]:
|
|
133
|
+
"""A map of constructor argument names to secret ids."""
|
|
134
|
+
return {"api_key": "KELN_API_KEY"}
|
|
135
|
+
|
|
136
|
+
def _get_ls_params(
|
|
137
|
+
self,
|
|
138
|
+
stop: list[str] | None = None,
|
|
139
|
+
**kwargs: Any,
|
|
140
|
+
) -> LangSmithParams:
|
|
141
|
+
ls_params = super()._get_ls_params(stop=stop, **kwargs)
|
|
142
|
+
ls_params["ls_provider"] = "keln"
|
|
143
|
+
return ls_params
|
|
144
|
+
|
|
145
|
+
@model_validator(mode="after")
|
|
146
|
+
def validate_environment(self) -> Self:
|
|
147
|
+
"""Check the API key and build the OpenAI clients pointed at Keln."""
|
|
148
|
+
if self.api_base == DEFAULT_API_BASE and not (
|
|
149
|
+
self.api_key and self.api_key.get_secret_value()
|
|
150
|
+
):
|
|
151
|
+
msg = (
|
|
152
|
+
"KELN_API_KEY must be set, or pass api_key=... "
|
|
153
|
+
"Create a key at https://app.keln.ai under Keys."
|
|
154
|
+
)
|
|
155
|
+
raise ValueError(msg)
|
|
156
|
+
client_params: dict = {
|
|
157
|
+
k: v
|
|
158
|
+
for k, v in {
|
|
159
|
+
"api_key": self.api_key.get_secret_value() if self.api_key else None,
|
|
160
|
+
"base_url": self.api_base,
|
|
161
|
+
"timeout": self.request_timeout,
|
|
162
|
+
"max_retries": self.max_retries,
|
|
163
|
+
"default_headers": self.default_headers,
|
|
164
|
+
"default_query": self.default_query,
|
|
165
|
+
}.items()
|
|
166
|
+
if v is not None
|
|
167
|
+
}
|
|
168
|
+
if not (self.client or None):
|
|
169
|
+
sync_specific: dict = {"http_client": self.http_client}
|
|
170
|
+
self.root_client = openai.OpenAI(**client_params, **sync_specific)
|
|
171
|
+
self.client = self.root_client.chat.completions
|
|
172
|
+
if not (self.async_client or None):
|
|
173
|
+
async_specific: dict = {"http_client": self.http_async_client}
|
|
174
|
+
self.root_async_client = openai.AsyncOpenAI(
|
|
175
|
+
**client_params, **async_specific
|
|
176
|
+
)
|
|
177
|
+
self.async_client = self.root_async_client.chat.completions
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def _use_responses_api(self, payload: dict) -> bool:
|
|
181
|
+
# Keln requests go to /v1/chat/completions.
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
def _get_request_payload(
|
|
185
|
+
self,
|
|
186
|
+
input_: LanguageModelInput,
|
|
187
|
+
*,
|
|
188
|
+
stop: list[str] | None = None,
|
|
189
|
+
**kwargs: Any,
|
|
190
|
+
) -> dict:
|
|
191
|
+
messages = self._convert_input(input_).to_messages()
|
|
192
|
+
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
|
193
|
+
# Keln keeps no copy of requests, so `store` is left out.
|
|
194
|
+
payload.pop("store", None)
|
|
195
|
+
message_dicts = payload.get("messages", [])
|
|
196
|
+
for message, message_dict in zip(messages, message_dicts, strict=False):
|
|
197
|
+
if message_dict.get("role") == "developer":
|
|
198
|
+
message_dict["role"] = "system"
|
|
199
|
+
if isinstance(message, AIMessage) and message_dict.get("role") == (
|
|
200
|
+
"assistant"
|
|
201
|
+
):
|
|
202
|
+
reasoning = _reasoning_from_message(message)
|
|
203
|
+
if reasoning:
|
|
204
|
+
message_dict["reasoning_content"] = reasoning
|
|
205
|
+
return payload
|
|
206
|
+
|
|
207
|
+
def _create_chat_result(
|
|
208
|
+
self,
|
|
209
|
+
response: dict | openai.BaseModel,
|
|
210
|
+
generation_info: dict | None = None,
|
|
211
|
+
) -> ChatResult:
|
|
212
|
+
result = super()._create_chat_result(response, generation_info)
|
|
213
|
+
response_dict = (
|
|
214
|
+
response
|
|
215
|
+
if isinstance(response, dict)
|
|
216
|
+
else response.model_dump(
|
|
217
|
+
exclude={"choices": {"__all__": {"message": {"parsed"}}}},
|
|
218
|
+
warnings=False,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
for generation in result.generations:
|
|
222
|
+
generation.message.response_metadata["model_provider"] = "keln"
|
|
223
|
+
choices = response_dict.get("choices") or []
|
|
224
|
+
for generation, choice in zip(result.generations, choices, strict=False):
|
|
225
|
+
message = choice.get("message") or {}
|
|
226
|
+
reasoning = message.get("reasoning_content") or message.get("reasoning")
|
|
227
|
+
if isinstance(reasoning, str) and reasoning:
|
|
228
|
+
generation.message.additional_kwargs["reasoning_content"] = reasoning
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
def _convert_chunk_to_generation_chunk(
|
|
232
|
+
self,
|
|
233
|
+
chunk: dict,
|
|
234
|
+
default_chunk_class: type,
|
|
235
|
+
base_generation_info: dict | None,
|
|
236
|
+
) -> ChatGenerationChunk | None:
|
|
237
|
+
generation_chunk = super()._convert_chunk_to_generation_chunk(
|
|
238
|
+
chunk, default_chunk_class, base_generation_info
|
|
239
|
+
)
|
|
240
|
+
if not generation_chunk or not isinstance(
|
|
241
|
+
generation_chunk.message, AIMessageChunk
|
|
242
|
+
):
|
|
243
|
+
return generation_chunk
|
|
244
|
+
if choices := chunk.get("choices"):
|
|
245
|
+
generation_chunk.message.response_metadata = {
|
|
246
|
+
**generation_chunk.message.response_metadata,
|
|
247
|
+
"model_provider": "keln",
|
|
248
|
+
}
|
|
249
|
+
delta = choices[0].get("delta") or {}
|
|
250
|
+
reasoning = delta.get("reasoning_content")
|
|
251
|
+
if reasoning is None:
|
|
252
|
+
reasoning = delta.get("reasoning")
|
|
253
|
+
if isinstance(reasoning, str) and reasoning:
|
|
254
|
+
generation_chunk.message.additional_kwargs["reasoning_content"] = (
|
|
255
|
+
reasoning
|
|
256
|
+
)
|
|
257
|
+
return generation_chunk
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-keln"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain integration for Keln, an inference platform for open models"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "kris-holy", email = "kris@keln.ai" }]
|
|
13
|
+
requires-python = ">=3.10,<4.0"
|
|
14
|
+
keywords = ["keln", "langchain", "llm", "open models", "openai-compatible"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"langchain-core>=1.0.0,<2.0.0",
|
|
28
|
+
"langchain-openai>=1.0.0,<2.0.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://keln.ai"
|
|
33
|
+
Documentation = "https://docs.keln.ai"
|
|
34
|
+
Repository = "https://github.com/kris-holy/langchain-keln"
|
|
35
|
+
Issues = "https://github.com/kris-holy/langchain-keln/issues"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
test = [
|
|
39
|
+
"pytest>=8",
|
|
40
|
+
"pytest-asyncio>=0.23",
|
|
41
|
+
"pytest-socket>=0.7",
|
|
42
|
+
"langchain-tests>=1.0.0,<2.0.0",
|
|
43
|
+
]
|
|
44
|
+
lint = ["ruff>=0.15"]
|
|
45
|
+
typing = ["mypy>=1.10"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
include = [
|
|
49
|
+
"langchain_keln",
|
|
50
|
+
"tests",
|
|
51
|
+
"README.md",
|
|
52
|
+
"CHANGELOG.md",
|
|
53
|
+
"LICENSE",
|
|
54
|
+
"pyproject.toml",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["langchain_keln"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
target-version = "py310"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "B", "UP", "D", "T201"]
|
|
65
|
+
ignore = ["D100", "D104", "D105", "D107", "D203", "D213"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.per-file-ignores]
|
|
68
|
+
"tests/**" = ["D"]
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
strict = true
|
|
72
|
+
disallow_any_generics = false
|
|
73
|
+
warn_return_any = false
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
addopts = "--strict-markers --strict-config --durations=5"
|
|
77
|
+
markers = [
|
|
78
|
+
"compile: mark placeholder test used to compile integration tests without running them",
|
|
79
|
+
]
|
|
80
|
+
asyncio_mode = "auto"
|
|
81
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Standard LangChain integration tests for ChatKeln.
|
|
2
|
+
|
|
3
|
+
These tests call the live Keln API and run only when `KELN_API_KEY` is set.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from langchain_core.language_models import BaseChatModel
|
|
10
|
+
from langchain_tests.integration_tests import ChatModelIntegrationTests
|
|
11
|
+
|
|
12
|
+
from langchain_keln import ChatKeln
|
|
13
|
+
|
|
14
|
+
pytestmark = pytest.mark.skipif(
|
|
15
|
+
not os.environ.get("KELN_API_KEY"), reason="KELN_API_KEY is not set"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
MODEL_NAME = "zai-org/glm-5.3"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestChatKelnIntegration(ChatModelIntegrationTests):
|
|
22
|
+
@property
|
|
23
|
+
def chat_model_class(self) -> type[BaseChatModel]:
|
|
24
|
+
return ChatKeln
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def chat_model_params(self) -> dict:
|
|
28
|
+
# The output cap covers reasoning and answer together, so leave room
|
|
29
|
+
# for both.
|
|
30
|
+
return {
|
|
31
|
+
"model": MODEL_NAME,
|
|
32
|
+
"reasoning_effort": "low",
|
|
33
|
+
"max_tokens": 4096,
|
|
34
|
+
"timeout": 120,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def model_override_value(self) -> str:
|
|
39
|
+
return "moonshotai/kimi-k3"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def supports_json_mode(self) -> bool:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def supports_image_inputs(self) -> bool:
|
|
47
|
+
# zai-org/glm-5.3 takes text input only.
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def returns_usage_metadata(self) -> bool:
|
|
52
|
+
return True
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Streamed multi-turn tool loop on Keln reasoning models.
|
|
2
|
+
|
|
3
|
+
Checks that each streamed turn returns a reasoning trace, that `ChatKeln` sends
|
|
4
|
+
the trace back on the assistant message, and that the loop ends with an answer.
|
|
5
|
+
Runs only when `KELN_API_KEY` is set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from langchain_core.messages import (
|
|
14
|
+
AIMessageChunk,
|
|
15
|
+
BaseMessage,
|
|
16
|
+
HumanMessage,
|
|
17
|
+
SystemMessage,
|
|
18
|
+
ToolMessage,
|
|
19
|
+
)
|
|
20
|
+
from langchain_core.runnables import Runnable
|
|
21
|
+
from langchain_core.tools import tool
|
|
22
|
+
|
|
23
|
+
from langchain_keln import ChatKeln
|
|
24
|
+
|
|
25
|
+
pytestmark = pytest.mark.skipif(
|
|
26
|
+
not os.environ.get("KELN_API_KEY"), reason="KELN_API_KEY is not set"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
MODELS = ["zai-org/glm-5.3", "moonshotai/kimi-k3"]
|
|
30
|
+
MAX_TURNS = 6
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@tool
|
|
34
|
+
def get_weather(city: str) -> str:
|
|
35
|
+
"""Return the current weather for a city."""
|
|
36
|
+
weather = {"paris": "sunny, 21 C", "tokyo": "rain, 16 C"}
|
|
37
|
+
return weather.get(city.strip().lower(), "cloudy, 18 C")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _stream_turn(model: Runnable, messages: list[BaseMessage]) -> AIMessageChunk:
|
|
41
|
+
merged: AIMessageChunk | None = None
|
|
42
|
+
for chunk in model.stream(messages):
|
|
43
|
+
assert isinstance(chunk, AIMessageChunk)
|
|
44
|
+
merged = chunk if merged is None else merged + chunk
|
|
45
|
+
assert merged is not None, "the stream returned no chunks"
|
|
46
|
+
return merged
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("model_name", MODELS)
|
|
50
|
+
def test_streamed_tool_loop_sends_reasoning_back(model_name: str) -> None:
|
|
51
|
+
llm = ChatKeln(model=model_name, max_tokens=4096, timeout=180)
|
|
52
|
+
bound = llm.bind_tools([get_weather])
|
|
53
|
+
messages: list[BaseMessage] = [
|
|
54
|
+
SystemMessage(
|
|
55
|
+
"Use the get_weather tool for every city the user names, 1 call per "
|
|
56
|
+
"city, then answer in 1 short sentence."
|
|
57
|
+
),
|
|
58
|
+
HumanMessage("What is the weather in Paris and in Tokyo right now?"),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
tool_rounds = 0
|
|
62
|
+
reasoning_turns = 0
|
|
63
|
+
final: AIMessageChunk | None = None
|
|
64
|
+
for _ in range(MAX_TURNS):
|
|
65
|
+
reply = _stream_turn(bound, messages)
|
|
66
|
+
if reply.additional_kwargs.get("reasoning_content"):
|
|
67
|
+
reasoning_turns += 1
|
|
68
|
+
messages.append(reply)
|
|
69
|
+
if not reply.tool_calls:
|
|
70
|
+
final = reply
|
|
71
|
+
break
|
|
72
|
+
tool_rounds += 1
|
|
73
|
+
for call in reply.tool_calls:
|
|
74
|
+
assert call["name"] == "get_weather"
|
|
75
|
+
result = get_weather.invoke(call["args"])
|
|
76
|
+
messages.append(ToolMessage(result, tool_call_id=call["id"]))
|
|
77
|
+
|
|
78
|
+
# The next request must carry this turn's reasoning trace.
|
|
79
|
+
payload = llm._get_request_payload(messages)
|
|
80
|
+
sent = [m for m in payload["messages"] if m["role"] == "assistant"][-1]
|
|
81
|
+
trace = reply.additional_kwargs.get("reasoning_content")
|
|
82
|
+
if trace:
|
|
83
|
+
assert sent["reasoning_content"] == trace
|
|
84
|
+
|
|
85
|
+
assert tool_rounds >= 1, "the model made no tool call"
|
|
86
|
+
assert reasoning_turns >= 1, "the stream returned no reasoning_content"
|
|
87
|
+
assert final is not None, f"no final answer after {MAX_TURNS} turns"
|
|
88
|
+
assert final.text.strip(), "the final answer is empty"
|
|
89
|
+
assert final.usage_metadata is not None
|
|
File without changes
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Unit tests for ChatKeln behaviour specific to Keln."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from langchain_core.messages import (
|
|
9
|
+
AIMessage,
|
|
10
|
+
AIMessageChunk,
|
|
11
|
+
HumanMessage,
|
|
12
|
+
SystemMessage,
|
|
13
|
+
ToolMessage,
|
|
14
|
+
)
|
|
15
|
+
from pydantic import SecretStr
|
|
16
|
+
|
|
17
|
+
from langchain_keln import ChatKeln
|
|
18
|
+
from langchain_keln.chat_models import DEFAULT_API_BASE, DEFAULT_MODEL
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _model(**kwargs: Any) -> ChatKeln:
|
|
22
|
+
return ChatKeln(api_key=SecretStr("test-key"), **kwargs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_defaults() -> None:
|
|
26
|
+
model = _model()
|
|
27
|
+
assert model.model_name == DEFAULT_MODEL == "zai-org/glm-5.3"
|
|
28
|
+
assert model.api_base == DEFAULT_API_BASE == "https://api.keln.ai/v1"
|
|
29
|
+
assert model._llm_type == "chat-keln"
|
|
30
|
+
assert model.lc_secrets == {"api_key": "KELN_API_KEY"}
|
|
31
|
+
assert str(model.root_client.base_url).rstrip("/") == DEFAULT_API_BASE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
35
|
+
monkeypatch.setenv("KELN_API_KEY", "env-key")
|
|
36
|
+
model = ChatKeln()
|
|
37
|
+
assert model.api_key is not None
|
|
38
|
+
assert model.api_key.get_secret_value() == "env-key"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
42
|
+
monkeypatch.delenv("KELN_API_KEY", raising=False)
|
|
43
|
+
monkeypatch.delenv("KELN_API_BASE", raising=False)
|
|
44
|
+
with pytest.raises(ValueError, match="KELN_API_KEY"):
|
|
45
|
+
ChatKeln()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_api_key_hidden_in_repr() -> None:
|
|
49
|
+
model = _model()
|
|
50
|
+
assert "test-key" not in repr(model)
|
|
51
|
+
assert "test-key" not in str(model)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_ls_params() -> None:
|
|
55
|
+
params = _model()._get_ls_params()
|
|
56
|
+
assert params["ls_provider"] == "keln"
|
|
57
|
+
assert params["ls_model_name"] == DEFAULT_MODEL
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_payload_uses_chat_completions_shape() -> None:
|
|
61
|
+
model = _model(max_tokens=64, reasoning_effort="low")
|
|
62
|
+
payload = model._get_request_payload(
|
|
63
|
+
[SystemMessage("Be brief."), HumanMessage("Hi")]
|
|
64
|
+
)
|
|
65
|
+
assert payload["model"] == DEFAULT_MODEL
|
|
66
|
+
assert payload["max_tokens"] == 64
|
|
67
|
+
assert "max_completion_tokens" not in payload
|
|
68
|
+
assert payload["reasoning_effort"] == "low"
|
|
69
|
+
assert payload["messages"][0] == {"role": "system", "content": "Be brief."}
|
|
70
|
+
assert "input" not in payload
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_payload_omits_store() -> None:
|
|
74
|
+
model = _model(store=True)
|
|
75
|
+
payload = model._get_request_payload([HumanMessage("Hi")])
|
|
76
|
+
assert "store" not in payload
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_developer_role_becomes_system() -> None:
|
|
80
|
+
message = SystemMessage(
|
|
81
|
+
"Be brief.", additional_kwargs={"__openai_role__": "developer"}
|
|
82
|
+
)
|
|
83
|
+
payload = _model()._get_request_payload([message, HumanMessage("Hi")])
|
|
84
|
+
assert payload["messages"][0]["role"] == "system"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_responses_api_not_used() -> None:
|
|
88
|
+
model = _model(use_responses_api=True, reasoning={"effort": "low"})
|
|
89
|
+
payload = model._get_request_payload([HumanMessage("Hi")])
|
|
90
|
+
assert "messages" in payload
|
|
91
|
+
assert "input" not in payload
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_reasoning_content_sent_back_on_assistant_messages() -> None:
|
|
95
|
+
tool_call = {
|
|
96
|
+
"name": "get_weather",
|
|
97
|
+
"args": {"city": "Paris"},
|
|
98
|
+
"id": "call_1",
|
|
99
|
+
"type": "tool_call",
|
|
100
|
+
}
|
|
101
|
+
messages = [
|
|
102
|
+
HumanMessage("Weather in Paris?"),
|
|
103
|
+
AIMessage(
|
|
104
|
+
"",
|
|
105
|
+
tool_calls=[tool_call],
|
|
106
|
+
additional_kwargs={"reasoning_content": "Call the weather tool."},
|
|
107
|
+
),
|
|
108
|
+
ToolMessage("Sunny, 21 C", tool_call_id="call_1"),
|
|
109
|
+
AIMessage("It is sunny."),
|
|
110
|
+
]
|
|
111
|
+
payload = _model()._get_request_payload(messages)
|
|
112
|
+
sent = payload["messages"]
|
|
113
|
+
assert sent[1]["role"] == "assistant"
|
|
114
|
+
assert sent[1]["reasoning_content"] == "Call the weather tool."
|
|
115
|
+
assert sent[1]["tool_calls"][0]["id"] == "call_1"
|
|
116
|
+
assert "reasoning_content" not in sent[0]
|
|
117
|
+
assert "reasoning_content" not in sent[2]
|
|
118
|
+
assert "reasoning_content" not in sent[3]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_reasoning_content_from_v1_content_blocks() -> None:
|
|
122
|
+
message = AIMessage(
|
|
123
|
+
content=[
|
|
124
|
+
{"type": "reasoning", "reasoning": "Think first."},
|
|
125
|
+
{"type": "text", "text": "Answer."},
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
payload = _model()._get_request_payload([HumanMessage("Hi"), message])
|
|
129
|
+
assert payload["messages"][1]["reasoning_content"] == "Think first."
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _completion(message: dict[str, Any]) -> dict[str, Any]:
|
|
133
|
+
return {
|
|
134
|
+
"id": "chatcmpl-1",
|
|
135
|
+
"object": "chat.completion",
|
|
136
|
+
"created": 0,
|
|
137
|
+
"model": DEFAULT_MODEL,
|
|
138
|
+
"choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
|
|
139
|
+
"usage": {"prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_reasoning_content_read_from_response() -> None:
|
|
144
|
+
response = _completion(
|
|
145
|
+
{
|
|
146
|
+
"role": "assistant",
|
|
147
|
+
"content": "391",
|
|
148
|
+
"reasoning_content": "17 x 23",
|
|
149
|
+
"reasoning": "17 x 23",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
result = _model()._create_chat_result(response)
|
|
153
|
+
message = result.generations[0].message
|
|
154
|
+
assert message.content == "391"
|
|
155
|
+
assert message.additional_kwargs["reasoning_content"] == "17 x 23"
|
|
156
|
+
assert message.response_metadata["model_provider"] == "keln"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_reasoning_mirror_read_from_response() -> None:
|
|
160
|
+
response = _completion(
|
|
161
|
+
{"role": "assistant", "content": "391", "reasoning": "17 x 23"}
|
|
162
|
+
)
|
|
163
|
+
result = _model()._create_chat_result(response)
|
|
164
|
+
assert result.generations[0].message.additional_kwargs["reasoning_content"] == (
|
|
165
|
+
"17 x 23"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_no_reasoning_key_when_absent() -> None:
|
|
170
|
+
response = _completion({"role": "assistant", "content": "Hi"})
|
|
171
|
+
result = _model()._create_chat_result(response)
|
|
172
|
+
assert "reasoning_content" not in result.generations[0].message.additional_kwargs
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _chunk(delta: dict[str, Any]) -> dict[str, Any]:
|
|
176
|
+
return {
|
|
177
|
+
"id": "chatcmpl-1",
|
|
178
|
+
"object": "chat.completion.chunk",
|
|
179
|
+
"created": 0,
|
|
180
|
+
"model": DEFAULT_MODEL,
|
|
181
|
+
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_reasoning_content_read_from_stream() -> None:
|
|
186
|
+
model = _model()
|
|
187
|
+
first = model._convert_chunk_to_generation_chunk(
|
|
188
|
+
_chunk({"role": "assistant", "reasoning_content": "Let me ", "reasoning": ""}),
|
|
189
|
+
AIMessageChunk,
|
|
190
|
+
None,
|
|
191
|
+
)
|
|
192
|
+
second = model._convert_chunk_to_generation_chunk(
|
|
193
|
+
_chunk({"reasoning": "think"}), AIMessageChunk, None
|
|
194
|
+
)
|
|
195
|
+
third = model._convert_chunk_to_generation_chunk(
|
|
196
|
+
_chunk({"content": "391"}), AIMessageChunk, None
|
|
197
|
+
)
|
|
198
|
+
assert first is not None and second is not None and third is not None
|
|
199
|
+
merged = first.message + second.message + third.message
|
|
200
|
+
assert merged.additional_kwargs["reasoning_content"] == "Let me think"
|
|
201
|
+
assert merged.content == "391"
|
|
202
|
+
assert merged.response_metadata["model_provider"] == "keln"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_streamed_reasoning_round_trip() -> None:
|
|
206
|
+
model = _model()
|
|
207
|
+
chunks = [
|
|
208
|
+
model._convert_chunk_to_generation_chunk(
|
|
209
|
+
_chunk({"role": "assistant", "reasoning_content": "Use the "}),
|
|
210
|
+
AIMessageChunk,
|
|
211
|
+
None,
|
|
212
|
+
),
|
|
213
|
+
model._convert_chunk_to_generation_chunk(
|
|
214
|
+
_chunk({"reasoning_content": "tool."}), AIMessageChunk, None
|
|
215
|
+
),
|
|
216
|
+
model._convert_chunk_to_generation_chunk(
|
|
217
|
+
_chunk(
|
|
218
|
+
{
|
|
219
|
+
"tool_calls": [
|
|
220
|
+
{
|
|
221
|
+
"index": 0,
|
|
222
|
+
"id": "call_1",
|
|
223
|
+
"type": "function",
|
|
224
|
+
"function": {
|
|
225
|
+
"name": "get_weather",
|
|
226
|
+
"arguments": '{"city": "Paris"}',
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
),
|
|
232
|
+
AIMessageChunk,
|
|
233
|
+
None,
|
|
234
|
+
),
|
|
235
|
+
]
|
|
236
|
+
merged = chunks[0].message # type: ignore[union-attr]
|
|
237
|
+
for chunk in chunks[1:]:
|
|
238
|
+
merged = merged + chunk.message # type: ignore[union-attr]
|
|
239
|
+
assert isinstance(merged, AIMessageChunk)
|
|
240
|
+
payload = model._get_request_payload(
|
|
241
|
+
[
|
|
242
|
+
HumanMessage("Weather in Paris?"),
|
|
243
|
+
merged,
|
|
244
|
+
ToolMessage("Sunny", tool_call_id="call_1"),
|
|
245
|
+
]
|
|
246
|
+
)
|
|
247
|
+
assistant = payload["messages"][1]
|
|
248
|
+
assert assistant["reasoning_content"] == "Use the tool."
|
|
249
|
+
assert assistant["tool_calls"][0]["function"]["name"] == "get_weather"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Standard LangChain unit tests for ChatKeln."""
|
|
2
|
+
|
|
3
|
+
from langchain_core.language_models import BaseChatModel
|
|
4
|
+
from langchain_tests.unit_tests import ChatModelUnitTests
|
|
5
|
+
|
|
6
|
+
from langchain_keln import ChatKeln
|
|
7
|
+
|
|
8
|
+
MODEL_NAME = "zai-org/glm-5.3"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestChatKelnUnit(ChatModelUnitTests):
|
|
12
|
+
@property
|
|
13
|
+
def chat_model_class(self) -> type[BaseChatModel]:
|
|
14
|
+
return ChatKeln
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def chat_model_params(self) -> dict:
|
|
18
|
+
return {"model": MODEL_NAME, "api_key": "test-key"}
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def init_from_env_params(self) -> tuple[dict, dict, dict]:
|
|
22
|
+
return (
|
|
23
|
+
{
|
|
24
|
+
"KELN_API_KEY": "api_key",
|
|
25
|
+
"KELN_API_BASE": "https://base.example.com/v1",
|
|
26
|
+
},
|
|
27
|
+
{"model": MODEL_NAME},
|
|
28
|
+
{
|
|
29
|
+
"api_key": "api_key",
|
|
30
|
+
"api_base": "https://base.example.com/v1",
|
|
31
|
+
},
|
|
32
|
+
)
|