langchain-outrun 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_outrun-0.1.0/.gitignore +10 -0
- langchain_outrun-0.1.0/CHANGELOG.md +5 -0
- langchain_outrun-0.1.0/LICENSE +21 -0
- langchain_outrun-0.1.0/PKG-INFO +130 -0
- langchain_outrun-0.1.0/README.md +103 -0
- langchain_outrun-0.1.0/langchain_outrun/__init__.py +12 -0
- langchain_outrun-0.1.0/langchain_outrun/chat_models.py +181 -0
- langchain_outrun-0.1.0/langchain_outrun/py.typed +0 -0
- langchain_outrun-0.1.0/pyproject.toml +58 -0
- langchain_outrun-0.1.0/tests/__init__.py +0 -0
- langchain_outrun-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_outrun-0.1.0/tests/integration_tests/test_compile.py +6 -0
- langchain_outrun-0.1.0/tests/integration_tests/test_standard.py +32 -0
- langchain_outrun-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_outrun-0.1.0/tests/unit_tests/test_chat_models.py +197 -0
- langchain_outrun-0.1.0/tests/unit_tests/test_imports.py +7 -0
- langchain_outrun-0.1.0/tests/unit_tests/test_standard.py +31 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Outrun LLC
|
|
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,130 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-outrun
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain integration for Outrun, a prepaid OpenAI-compatible API for GPT, Claude, Gemini, DeepSeek and more
|
|
5
|
+
Project-URL: Homepage, https://outrun.to
|
|
6
|
+
Project-URL: Documentation, https://github.com/OutrunAIAPI/langchain-outrun#readme
|
|
7
|
+
Project-URL: Pricing, https://outrun.to/pricing
|
|
8
|
+
Project-URL: Repository, https://github.com/OutrunAIAPI/langchain-outrun
|
|
9
|
+
Project-URL: Issues, https://github.com/OutrunAIAPI/langchain-outrun/issues
|
|
10
|
+
Author-email: Outrun LLC <support@outrun.to>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: chat,langchain,llm,openai-compatible,outrun
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: <4.0,>=3.10
|
|
24
|
+
Requires-Dist: langchain-core<2.0.0,>=1.0.0
|
|
25
|
+
Requires-Dist: langchain-openai<2.0.0,>=1.0.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# langchain-outrun
|
|
29
|
+
|
|
30
|
+
LangChain integration for [Outrun](https://outrun.to), a prepaid, OpenAI-compatible API. One Outrun key reaches GPT, Claude, Gemini, Grok, DeepSeek, GLM, Kimi, Qwen and MiniMax models.
|
|
31
|
+
|
|
32
|
+
`ChatOutrun` is a LangChain chat model built on `langchain-openai`, with the Outrun base URL and key set for you.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -U langchain-outrun
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Create a key at https://outrun.to/key, then:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export OUTRUN_API_KEY="your-api-key"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Chat model
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from langchain_outrun import ChatOutrun
|
|
50
|
+
|
|
51
|
+
model = ChatOutrun(model="gpt-5.6-terra", temperature=0)
|
|
52
|
+
print(model.invoke("Explain prompt caching in one sentence.").content)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Pick any model id from https://outrun.to/pricing (for example `claude-haiku-5-5`, `gemini-3.8-flash` or `deepseek-v4.1-flash`). The same list is available as JSON, without a key, at `https://api.outrun.to/pricing`.
|
|
56
|
+
|
|
57
|
+
### Streaming
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
for chunk in model.stream("Write a haiku about rain."):
|
|
61
|
+
print(chunk.text, end="", flush=True)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Token usage is included in streamed responses (`usage_metadata` on the last chunk).
|
|
65
|
+
|
|
66
|
+
### Tool calling
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pydantic import BaseModel, Field
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class GetWeather(BaseModel):
|
|
73
|
+
"""Get the current weather for a city."""
|
|
74
|
+
|
|
75
|
+
city: str = Field(description="City name")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
model_with_tools = model.bind_tools([GetWeather])
|
|
79
|
+
msg = model_with_tools.invoke("What's the weather in Paris?")
|
|
80
|
+
print(msg.tool_calls)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Structured output
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
class Answer(BaseModel):
|
|
87
|
+
answer: str
|
|
88
|
+
confidence: float
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
structured = model.with_structured_output(Answer)
|
|
92
|
+
print(structured.invoke("Is the sky blue? Answer briefly."))
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tool calling and structured output depend on the model you pick.
|
|
96
|
+
|
|
97
|
+
### Async
|
|
98
|
+
|
|
99
|
+
`ainvoke`, `astream` and `abatch` work as with any LangChain chat model.
|
|
100
|
+
|
|
101
|
+
## Settings
|
|
102
|
+
|
|
103
|
+
| Argument | Environment variable | Default |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `api_key` | `OUTRUN_API_KEY` | none (required) |
|
|
106
|
+
| `base_url` | `OUTRUN_API_BASE` | `https://api.outrun.to/v1` |
|
|
107
|
+
| `model` | | none (required) |
|
|
108
|
+
|
|
109
|
+
Other `ChatOpenAI` arguments such as `temperature`, `max_tokens`, `timeout`, `max_retries` and `default_headers` work the same way.
|
|
110
|
+
|
|
111
|
+
- Requests go to `/v1/chat/completions`. Pass `use_responses_api=True` to use `/v1/responses` instead (GPT models only).
|
|
112
|
+
- `ChatOutrun` never reads `OPENAI_API_KEY`, so an OpenAI key is never sent to Outrun.
|
|
113
|
+
- Each request carries an `X-Outrun-SDK: langchain/<version>` header.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install -e . pytest pytest-asyncio pytest-socket "langchain-tests>=1,<2"
|
|
119
|
+
pytest tests/unit_tests --disable-socket --allow-unix-socket
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`tests/integration_tests` call the live API (billed to your balance) and run only when `OUTRUN_API_KEY` is set.
|
|
123
|
+
|
|
124
|
+
## Links
|
|
125
|
+
|
|
126
|
+
- Outrun docs: https://outrun.to/docs/
|
|
127
|
+
- Plain OpenAI SDK wrapper: [`outrun-ai`](https://pypi.org/project/outrun-ai/)
|
|
128
|
+
- Support: support@outrun.to
|
|
129
|
+
|
|
130
|
+
License: MIT.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# langchain-outrun
|
|
2
|
+
|
|
3
|
+
LangChain integration for [Outrun](https://outrun.to), a prepaid, OpenAI-compatible API. One Outrun key reaches GPT, Claude, Gemini, Grok, DeepSeek, GLM, Kimi, Qwen and MiniMax models.
|
|
4
|
+
|
|
5
|
+
`ChatOutrun` is a LangChain chat model built on `langchain-openai`, with the Outrun base URL and key set for you.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -U langchain-outrun
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Create a key at https://outrun.to/key, then:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export OUTRUN_API_KEY="your-api-key"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Chat model
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from langchain_outrun import ChatOutrun
|
|
23
|
+
|
|
24
|
+
model = ChatOutrun(model="gpt-5.6-terra", temperature=0)
|
|
25
|
+
print(model.invoke("Explain prompt caching in one sentence.").content)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Pick any model id from https://outrun.to/pricing (for example `claude-haiku-5-5`, `gemini-3.8-flash` or `deepseek-v4.1-flash`). The same list is available as JSON, without a key, at `https://api.outrun.to/pricing`.
|
|
29
|
+
|
|
30
|
+
### Streaming
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
for chunk in model.stream("Write a haiku about rain."):
|
|
34
|
+
print(chunk.text, end="", flush=True)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Token usage is included in streamed responses (`usage_metadata` on the last chunk).
|
|
38
|
+
|
|
39
|
+
### Tool calling
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from pydantic import BaseModel, Field
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GetWeather(BaseModel):
|
|
46
|
+
"""Get the current weather for a city."""
|
|
47
|
+
|
|
48
|
+
city: str = Field(description="City name")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
model_with_tools = model.bind_tools([GetWeather])
|
|
52
|
+
msg = model_with_tools.invoke("What's the weather in Paris?")
|
|
53
|
+
print(msg.tool_calls)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Structured output
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
class Answer(BaseModel):
|
|
60
|
+
answer: str
|
|
61
|
+
confidence: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
structured = model.with_structured_output(Answer)
|
|
65
|
+
print(structured.invoke("Is the sky blue? Answer briefly."))
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Tool calling and structured output depend on the model you pick.
|
|
69
|
+
|
|
70
|
+
### Async
|
|
71
|
+
|
|
72
|
+
`ainvoke`, `astream` and `abatch` work as with any LangChain chat model.
|
|
73
|
+
|
|
74
|
+
## Settings
|
|
75
|
+
|
|
76
|
+
| Argument | Environment variable | Default |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `api_key` | `OUTRUN_API_KEY` | none (required) |
|
|
79
|
+
| `base_url` | `OUTRUN_API_BASE` | `https://api.outrun.to/v1` |
|
|
80
|
+
| `model` | | none (required) |
|
|
81
|
+
|
|
82
|
+
Other `ChatOpenAI` arguments such as `temperature`, `max_tokens`, `timeout`, `max_retries` and `default_headers` work the same way.
|
|
83
|
+
|
|
84
|
+
- Requests go to `/v1/chat/completions`. Pass `use_responses_api=True` to use `/v1/responses` instead (GPT models only).
|
|
85
|
+
- `ChatOutrun` never reads `OPENAI_API_KEY`, so an OpenAI key is never sent to Outrun.
|
|
86
|
+
- Each request carries an `X-Outrun-SDK: langchain/<version>` header.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install -e . pytest pytest-asyncio pytest-socket "langchain-tests>=1,<2"
|
|
92
|
+
pytest tests/unit_tests --disable-socket --allow-unix-socket
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`tests/integration_tests` call the live API (billed to your balance) and run only when `OUTRUN_API_KEY` is set.
|
|
96
|
+
|
|
97
|
+
## Links
|
|
98
|
+
|
|
99
|
+
- Outrun docs: https://outrun.to/docs/
|
|
100
|
+
- Plain OpenAI SDK wrapper: [`outrun-ai`](https://pypi.org/project/outrun-ai/)
|
|
101
|
+
- Support: support@outrun.to
|
|
102
|
+
|
|
103
|
+
License: MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""LangChain integration for Outrun."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = metadata.version(__package__ or "langchain-outrun")
|
|
7
|
+
except metadata.PackageNotFoundError:
|
|
8
|
+
__version__ = ""
|
|
9
|
+
|
|
10
|
+
from langchain_outrun.chat_models import ChatOutrun # noqa: E402
|
|
11
|
+
|
|
12
|
+
__all__ = ["ChatOutrun", "__version__"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Outrun 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.utils import from_env, secret_from_env
|
|
10
|
+
from langchain_openai.chat_models.base import BaseChatOpenAI
|
|
11
|
+
from pydantic import ConfigDict, Field, SecretStr, model_validator
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
DEFAULT_API_BASE = "https://api.outrun.to/v1"
|
|
15
|
+
SDK_HEADER = "X-Outrun-SDK"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sdk_header_value() -> str:
|
|
19
|
+
from langchain_outrun import __version__
|
|
20
|
+
|
|
21
|
+
return f"langchain/{__version__ or 'dev'}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChatOutrun(BaseChatOpenAI):
|
|
25
|
+
"""Chat models on Outrun, a prepaid OpenAI-compatible API.
|
|
26
|
+
|
|
27
|
+
One Outrun key reaches GPT, Claude, Gemini, Grok, DeepSeek, GLM, Kimi, Qwen
|
|
28
|
+
and MiniMax models. The model list with prices is at
|
|
29
|
+
https://outrun.to/pricing (JSON: `GET https://api.outrun.to/pricing`, no key
|
|
30
|
+
needed).
|
|
31
|
+
|
|
32
|
+
Setup:
|
|
33
|
+
Install `langchain-outrun` and set the `OUTRUN_API_KEY` environment
|
|
34
|
+
variable. Create a key at https://outrun.to/key.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -U langchain-outrun
|
|
38
|
+
export OUTRUN_API_KEY="your-api-key"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Key init args (completion params):
|
|
42
|
+
model:
|
|
43
|
+
Outrun model id, for example `"gpt-5.6-terra"` or
|
|
44
|
+
`"deepseek-v4.1-flash"`. Required.
|
|
45
|
+
temperature:
|
|
46
|
+
Sampling temperature.
|
|
47
|
+
max_tokens:
|
|
48
|
+
Max number of tokens to generate.
|
|
49
|
+
|
|
50
|
+
Key init args (client params):
|
|
51
|
+
api_key:
|
|
52
|
+
Outrun API key. Read from `OUTRUN_API_KEY` if not passed.
|
|
53
|
+
base_url:
|
|
54
|
+
API base URL. Read from `OUTRUN_API_BASE`, defaults to
|
|
55
|
+
`https://api.outrun.to/v1`.
|
|
56
|
+
timeout:
|
|
57
|
+
Timeout for requests.
|
|
58
|
+
max_retries:
|
|
59
|
+
Max number of retries.
|
|
60
|
+
|
|
61
|
+
Instantiate:
|
|
62
|
+
```python
|
|
63
|
+
from langchain_outrun import ChatOutrun
|
|
64
|
+
|
|
65
|
+
model = ChatOutrun(model="gpt-5.6-terra", temperature=0)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Invoke:
|
|
69
|
+
```python
|
|
70
|
+
model.invoke("Explain prompt caching in one sentence.")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Stream:
|
|
74
|
+
```python
|
|
75
|
+
for chunk in model.stream("Write a haiku about rain."):
|
|
76
|
+
print(chunk.text, end="")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Tool calling and structured output use the same methods as `ChatOpenAI`
|
|
80
|
+
(`bind_tools`, `with_structured_output`). Support depends on the model.
|
|
81
|
+
|
|
82
|
+
`ChatOutrun` sends requests to `/v1/chat/completions`. Pass
|
|
83
|
+
`use_responses_api=True` to use `/v1/responses` instead (GPT models only).
|
|
84
|
+
It never reads `OPENAI_API_KEY`, so an OpenAI key is never sent to Outrun.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
model_name: str = Field(alias="model")
|
|
88
|
+
"""Outrun model id."""
|
|
89
|
+
api_key: SecretStr | None = Field(
|
|
90
|
+
default_factory=secret_from_env("OUTRUN_API_KEY", default=None),
|
|
91
|
+
)
|
|
92
|
+
"""Outrun API key. Read from `OUTRUN_API_KEY` if not provided."""
|
|
93
|
+
api_base: str = Field(
|
|
94
|
+
alias="base_url",
|
|
95
|
+
default_factory=from_env("OUTRUN_API_BASE", default=DEFAULT_API_BASE),
|
|
96
|
+
)
|
|
97
|
+
"""Outrun API base URL. Read from `OUTRUN_API_BASE` if not provided."""
|
|
98
|
+
stream_usage: bool | None = True
|
|
99
|
+
"""Include token usage in streamed responses."""
|
|
100
|
+
|
|
101
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def _llm_type(self) -> str:
|
|
105
|
+
"""Return type of chat model."""
|
|
106
|
+
return "chat-outrun"
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def lc_secrets(self) -> dict[str, str]:
|
|
110
|
+
"""A map of constructor argument names to secret ids."""
|
|
111
|
+
return {"api_key": "OUTRUN_API_KEY"}
|
|
112
|
+
|
|
113
|
+
def _get_ls_params(
|
|
114
|
+
self,
|
|
115
|
+
stop: list[str] | None = None,
|
|
116
|
+
**kwargs: Any,
|
|
117
|
+
) -> LangSmithParams:
|
|
118
|
+
ls_params = super()._get_ls_params(stop=stop, **kwargs)
|
|
119
|
+
ls_params["ls_provider"] = "outrun"
|
|
120
|
+
return ls_params
|
|
121
|
+
|
|
122
|
+
@model_validator(mode="after")
|
|
123
|
+
def validate_environment(self) -> Self:
|
|
124
|
+
"""Check the API key and build OpenAI clients pointed at Outrun."""
|
|
125
|
+
if self.n is not None and self.n < 1:
|
|
126
|
+
msg = "n must be at least 1."
|
|
127
|
+
raise ValueError(msg)
|
|
128
|
+
if self.n is not None and self.n > 1 and self.streaming:
|
|
129
|
+
msg = "n must be 1 when streaming."
|
|
130
|
+
raise ValueError(msg)
|
|
131
|
+
# Always require an Outrun key: with no key, the OpenAI client would fall
|
|
132
|
+
# back to OPENAI_API_KEY and send it to this base URL.
|
|
133
|
+
if not (self.api_key and self.api_key.get_secret_value()):
|
|
134
|
+
msg = (
|
|
135
|
+
"OUTRUN_API_KEY must be set, or pass api_key=... "
|
|
136
|
+
"Create a key at https://outrun.to/key"
|
|
137
|
+
)
|
|
138
|
+
raise ValueError(msg)
|
|
139
|
+
headers = {SDK_HEADER: _sdk_header_value(), **(self.default_headers or {})}
|
|
140
|
+
client_params: dict = {
|
|
141
|
+
k: v
|
|
142
|
+
for k, v in {
|
|
143
|
+
"api_key": self.api_key.get_secret_value(),
|
|
144
|
+
"base_url": self.api_base,
|
|
145
|
+
"timeout": self.request_timeout,
|
|
146
|
+
"max_retries": self.max_retries,
|
|
147
|
+
"default_headers": headers,
|
|
148
|
+
"default_query": self.default_query,
|
|
149
|
+
}.items()
|
|
150
|
+
if v is not None
|
|
151
|
+
}
|
|
152
|
+
if not self.client:
|
|
153
|
+
sync_specific: dict = {"http_client": self.http_client}
|
|
154
|
+
self.root_client = openai.OpenAI(**client_params, **sync_specific)
|
|
155
|
+
self.client = self.root_client.chat.completions
|
|
156
|
+
if not self.async_client:
|
|
157
|
+
async_specific: dict = {"http_client": self.http_async_client}
|
|
158
|
+
self.root_async_client = openai.AsyncOpenAI(
|
|
159
|
+
**client_params, **async_specific
|
|
160
|
+
)
|
|
161
|
+
self.async_client = self.root_async_client.chat.completions
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def _use_responses_api(self, payload: dict) -> bool:
|
|
165
|
+
# Chat completions work for every Outrun model; /v1/responses only for
|
|
166
|
+
# GPT models, so use it only when asked.
|
|
167
|
+
return self.use_responses_api is True
|
|
168
|
+
|
|
169
|
+
def _get_request_payload(
|
|
170
|
+
self,
|
|
171
|
+
input_: LanguageModelInput,
|
|
172
|
+
*,
|
|
173
|
+
stop: list[str] | None = None,
|
|
174
|
+
**kwargs: Any,
|
|
175
|
+
) -> dict:
|
|
176
|
+
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
|
177
|
+
# Not every model behind Outrun accepts the `developer` role.
|
|
178
|
+
for message in payload.get("messages", []):
|
|
179
|
+
if message.get("role") == "developer":
|
|
180
|
+
message["role"] = "system"
|
|
181
|
+
return payload
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-outrun"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain integration for Outrun, a prepaid OpenAI-compatible API for GPT, Claude, Gemini, DeepSeek and more"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Outrun LLC", email = "support@outrun.to" }]
|
|
13
|
+
requires-python = ">=3.10,<4.0"
|
|
14
|
+
keywords = ["outrun", "langchain", "llm", "openai-compatible", "chat"]
|
|
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
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
"Typing :: Typed",
|
|
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://outrun.to"
|
|
33
|
+
Documentation = "https://github.com/OutrunAIAPI/langchain-outrun#readme"
|
|
34
|
+
Pricing = "https://outrun.to/pricing"
|
|
35
|
+
Repository = "https://github.com/OutrunAIAPI/langchain-outrun"
|
|
36
|
+
Issues = "https://github.com/OutrunAIAPI/langchain-outrun/issues"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
test = [
|
|
40
|
+
"pytest>=8",
|
|
41
|
+
"pytest-asyncio>=0.23",
|
|
42
|
+
"pytest-socket>=0.7",
|
|
43
|
+
"langchain-tests>=1.0.0,<2.0.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["langchain_outrun", "tests", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["langchain_outrun"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
addopts = "--strict-markers --strict-config --durations=5"
|
|
54
|
+
markers = [
|
|
55
|
+
"compile: mark placeholder test used to compile integration tests without running them",
|
|
56
|
+
]
|
|
57
|
+
asyncio_mode = "auto"
|
|
58
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Standard LangChain integration tests for ChatOutrun.
|
|
2
|
+
|
|
3
|
+
These tests call the live Outrun API, which bills the key's balance. They run
|
|
4
|
+
only when `OUTRUN_API_KEY` is set.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from langchain_core.language_models import BaseChatModel
|
|
11
|
+
from langchain_outrun import ChatOutrun
|
|
12
|
+
from langchain_tests.integration_tests import ChatModelIntegrationTests
|
|
13
|
+
|
|
14
|
+
pytestmark = pytest.mark.skipif(
|
|
15
|
+
not os.environ.get("OUTRUN_API_KEY"), reason="OUTRUN_API_KEY is not set"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
MODEL_NAME = os.environ.get("OUTRUN_TEST_MODEL", "gpt-5.6-terra")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestChatOutrunIntegration(ChatModelIntegrationTests):
|
|
22
|
+
@property
|
|
23
|
+
def chat_model_class(self) -> type[BaseChatModel]:
|
|
24
|
+
return ChatOutrun
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def chat_model_params(self) -> dict:
|
|
28
|
+
return {"model": MODEL_NAME, "max_tokens": 1024, "timeout": 120}
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def returns_usage_metadata(self) -> bool:
|
|
32
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Unit tests for ChatOutrun. Requests go to an in-memory mock, never the network."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
try: # openai 3.x ships its HTTP layer as httpx2
|
|
6
|
+
import httpx2 as httpx
|
|
7
|
+
except ImportError: # openai 1.x / 2.x
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
11
|
+
from langchain_outrun import ChatOutrun
|
|
12
|
+
from langchain_outrun.chat_models import DEFAULT_API_BASE
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
MODEL = "gpt-5.6-terra"
|
|
16
|
+
|
|
17
|
+
COMPLETION = {
|
|
18
|
+
"id": "c1",
|
|
19
|
+
"object": "chat.completion",
|
|
20
|
+
"created": 1,
|
|
21
|
+
"model": MODEL,
|
|
22
|
+
"choices": [
|
|
23
|
+
{
|
|
24
|
+
"index": 0,
|
|
25
|
+
"finish_reason": "stop",
|
|
26
|
+
"message": {"role": "assistant", "content": "hello there"},
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _chunk(delta: dict, finish: str | None = None, usage: dict | None = None) -> str:
|
|
34
|
+
body = {
|
|
35
|
+
"id": "c",
|
|
36
|
+
"object": "chat.completion.chunk",
|
|
37
|
+
"created": 1,
|
|
38
|
+
"model": MODEL,
|
|
39
|
+
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
|
40
|
+
}
|
|
41
|
+
if usage is not None:
|
|
42
|
+
body["choices"] = []
|
|
43
|
+
body["usage"] = usage
|
|
44
|
+
return f"data: {json.dumps(body)}\n\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
SSE = (
|
|
48
|
+
_chunk({"role": "assistant", "content": "Hel"})
|
|
49
|
+
+ _chunk({"content": "lo"})
|
|
50
|
+
+ _chunk({}, finish="stop")
|
|
51
|
+
+ _chunk({}, usage={"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5})
|
|
52
|
+
+ "data: [DONE]\n\n"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _handler(seen: list):
|
|
57
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
58
|
+
seen.append(request)
|
|
59
|
+
body = json.loads(request.content or b"{}")
|
|
60
|
+
if body.get("stream"):
|
|
61
|
+
return httpx.Response(
|
|
62
|
+
200, headers={"content-type": "text/event-stream"}, content=SSE
|
|
63
|
+
)
|
|
64
|
+
return httpx.Response(200, json=COMPLETION)
|
|
65
|
+
|
|
66
|
+
return handler
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _model(seen: list, **kwargs) -> ChatOutrun:
|
|
70
|
+
kwargs.setdefault("model", MODEL)
|
|
71
|
+
kwargs.setdefault("api_key", "test-key")
|
|
72
|
+
return ChatOutrun(
|
|
73
|
+
http_client=httpx.Client(transport=httpx.MockTransport(_handler(seen))),
|
|
74
|
+
http_async_client=httpx.AsyncClient(
|
|
75
|
+
transport=httpx.MockTransport(_handler(seen))
|
|
76
|
+
),
|
|
77
|
+
**kwargs,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture(autouse=True)
|
|
82
|
+
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
83
|
+
for name in ("OUTRUN_API_KEY", "OUTRUN_API_BASE", "OPENAI_API_KEY", "OPENAI_BASE_URL"):
|
|
84
|
+
monkeypatch.delenv(name, raising=False)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_defaults() -> None:
|
|
88
|
+
model = ChatOutrun(model=MODEL, api_key="k")
|
|
89
|
+
assert model.api_base == DEFAULT_API_BASE
|
|
90
|
+
assert str(model.root_client.base_url) == "https://api.outrun.to/v1/"
|
|
91
|
+
assert model._llm_type == "chat-outrun"
|
|
92
|
+
assert model._get_ls_params()["ls_provider"] == "outrun"
|
|
93
|
+
assert model.stream_usage is True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_model_is_required() -> None:
|
|
97
|
+
with pytest.raises(ValueError):
|
|
98
|
+
ChatOutrun(api_key="k") # type: ignore[call-arg]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_missing_key_raises_even_with_openai_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
102
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-not-be-used")
|
|
103
|
+
with pytest.raises(ValueError, match="OUTRUN_API_KEY"):
|
|
104
|
+
ChatOutrun(model=MODEL)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_custom_base_url_still_needs_outrun_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
108
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-not-be-used")
|
|
109
|
+
with pytest.raises(ValueError, match="OUTRUN_API_KEY"):
|
|
110
|
+
ChatOutrun(model=MODEL, base_url="http://localhost:8080/v1")
|
|
111
|
+
model = ChatOutrun(model=MODEL, base_url="http://localhost:8080/v1", api_key="k")
|
|
112
|
+
assert str(model.root_client.base_url) == "http://localhost:8080/v1/"
|
|
113
|
+
assert model.root_client.api_key == "k"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_invoke_sends_outrun_key_never_openai_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
117
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-not-be-used")
|
|
118
|
+
monkeypatch.setenv("OUTRUN_API_KEY", "outrun-env-key")
|
|
119
|
+
seen: list = []
|
|
120
|
+
model = ChatOutrun(
|
|
121
|
+
model=MODEL,
|
|
122
|
+
http_client=httpx.Client(transport=httpx.MockTransport(_handler(seen))),
|
|
123
|
+
)
|
|
124
|
+
result = model.invoke("hi")
|
|
125
|
+
assert result.content == "hello there"
|
|
126
|
+
assert result.usage_metadata["total_tokens"] == 5
|
|
127
|
+
request = seen[0]
|
|
128
|
+
assert str(request.url) == "https://api.outrun.to/v1/chat/completions"
|
|
129
|
+
assert request.headers["authorization"] == "Bearer outrun-env-key"
|
|
130
|
+
assert request.headers["x-outrun-sdk"].startswith("langchain/")
|
|
131
|
+
body = json.loads(request.content)
|
|
132
|
+
assert body["model"] == MODEL
|
|
133
|
+
assert body["messages"] == [{"role": "user", "content": "hi"}]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_key_not_in_repr() -> None:
|
|
137
|
+
model = ChatOutrun(model=MODEL, api_key="secret-value-123")
|
|
138
|
+
assert "secret-value-123" not in repr(model)
|
|
139
|
+
assert "secret-value-123" not in str(model)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_user_headers_are_kept() -> None:
|
|
143
|
+
seen: list = []
|
|
144
|
+
_model(seen, default_headers={"X-Extra": "1"}).invoke("hi")
|
|
145
|
+
assert seen[0].headers["x-extra"] == "1"
|
|
146
|
+
assert seen[0].headers["x-outrun-sdk"].startswith("langchain/")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_stream_requests_usage_and_yields_text() -> None:
|
|
150
|
+
seen: list = []
|
|
151
|
+
chunks = list(_model(seen).stream("hi"))
|
|
152
|
+
assert "".join(c.text for c in chunks) == "Hello"
|
|
153
|
+
body = json.loads(seen[0].content)
|
|
154
|
+
assert body["stream"] is True
|
|
155
|
+
assert body["stream_options"] == {"include_usage": True}
|
|
156
|
+
total = sum(chunks[1:], chunks[0])
|
|
157
|
+
assert total.usage_metadata["total_tokens"] == 5
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def test_async_invoke() -> None:
|
|
161
|
+
seen: list = []
|
|
162
|
+
result = await _model(seen).ainvoke("hi")
|
|
163
|
+
assert result.content == "hello there"
|
|
164
|
+
assert str(seen[0].url) == "https://api.outrun.to/v1/chat/completions"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_chat_completions_even_for_models_the_parent_would_route_elsewhere() -> None:
|
|
168
|
+
class Weather(BaseModel):
|
|
169
|
+
"""Get the weather for a city."""
|
|
170
|
+
|
|
171
|
+
city: str
|
|
172
|
+
|
|
173
|
+
seen: list = []
|
|
174
|
+
model = _model(seen, model="gpt-6-astra").bind_tools([Weather])
|
|
175
|
+
model.invoke("weather in Paris?")
|
|
176
|
+
assert str(seen[0].url) == "https://api.outrun.to/v1/chat/completions"
|
|
177
|
+
body = json.loads(seen[0].content)
|
|
178
|
+
assert body["tools"][0]["function"]["name"] == "Weather"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_responses_api_only_when_asked() -> None:
|
|
182
|
+
assert ChatOutrun(model="gpt-6-astra", api_key="k")._use_responses_api({}) is False
|
|
183
|
+
assert (
|
|
184
|
+
ChatOutrun(model="gpt-6-astra", api_key="k", use_responses_api=True)._use_responses_api({})
|
|
185
|
+
is True
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_developer_role_sent_as_system() -> None:
|
|
190
|
+
model = ChatOutrun(model=MODEL, api_key="k")
|
|
191
|
+
payload = model._get_request_payload(
|
|
192
|
+
[
|
|
193
|
+
SystemMessage("be brief", additional_kwargs={"__openai_role__": "developer"}),
|
|
194
|
+
HumanMessage("hi"),
|
|
195
|
+
]
|
|
196
|
+
)
|
|
197
|
+
assert [m["role"] for m in payload["messages"]] == ["system", "user"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Standard LangChain unit tests for ChatOutrun."""
|
|
2
|
+
|
|
3
|
+
from langchain_core.language_models import BaseChatModel
|
|
4
|
+
from langchain_outrun import ChatOutrun
|
|
5
|
+
from langchain_tests.unit_tests import ChatModelUnitTests
|
|
6
|
+
|
|
7
|
+
MODEL_NAME = "gpt-5.6-terra"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestChatOutrunUnit(ChatModelUnitTests):
|
|
11
|
+
@property
|
|
12
|
+
def chat_model_class(self) -> type[BaseChatModel]:
|
|
13
|
+
return ChatOutrun
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def chat_model_params(self) -> dict:
|
|
17
|
+
return {"model": MODEL_NAME, "api_key": "test-key"}
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def init_from_env_params(self) -> tuple[dict, dict, dict]:
|
|
21
|
+
return (
|
|
22
|
+
{
|
|
23
|
+
"OUTRUN_API_KEY": "api_key",
|
|
24
|
+
"OUTRUN_API_BASE": "https://base.example.com/v1",
|
|
25
|
+
},
|
|
26
|
+
{"model": MODEL_NAME},
|
|
27
|
+
{
|
|
28
|
+
"api_key": "api_key",
|
|
29
|
+
"api_base": "https://base.example.com/v1",
|
|
30
|
+
},
|
|
31
|
+
)
|