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.
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ .venv-*/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .benchmarks/
12
+ .env
13
+ results.txt
@@ -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
@@ -0,0 +1,6 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.mark.compile
5
+ def test_placeholder() -> None:
6
+ """Used to compile integration tests without running them."""
@@ -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,7 @@
1
+ from langchain_keln import __all__
2
+
3
+ EXPECTED_ALL = ["ChatKeln", "__version__"]
4
+
5
+
6
+ def test_all_imports() -> None:
7
+ assert sorted(EXPECTED_ALL) == sorted(__all__)
@@ -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
+ )