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.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .mypy_cache/
10
+ .env
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-14)
4
+
5
+ - First release: `ChatOutrun` chat model (invoke, stream, async, tool calling, structured output) on the Outrun OpenAI-compatible API.
@@ -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
@@ -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,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,7 @@
1
+ from langchain_outrun import __all__
2
+
3
+ EXPECTED_ALL = ["ChatOutrun", "__version__"]
4
+
5
+
6
+ def test_all_imports() -> None:
7
+ assert sorted(EXPECTED_ALL) == sorted(__all__)
@@ -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
+ )