outrun-ai 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.
- outrun_ai-0.1.0/.gitignore +11 -0
- outrun_ai-0.1.0/LICENSE +21 -0
- outrun_ai-0.1.0/PKG-INFO +98 -0
- outrun_ai-0.1.0/README.md +72 -0
- outrun_ai-0.1.0/pyproject.toml +43 -0
- outrun_ai-0.1.0/src/outrun_ai/__init__.py +13 -0
- outrun_ai-0.1.0/src/outrun_ai/_client.py +82 -0
- outrun_ai-0.1.0/src/outrun_ai/_prices.py +61 -0
- outrun_ai-0.1.0/src/outrun_ai/py.typed +0 -0
- outrun_ai-0.1.0/tests/test_client.py +102 -0
- outrun_ai-0.1.0/tests/test_prices.py +96 -0
outrun_ai-0.1.0/LICENSE
ADDED
|
@@ -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.
|
outrun_ai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: outrun-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Outrun API (OpenAI-compatible), built on the official OpenAI SDK.
|
|
5
|
+
Project-URL: Homepage, https://outrun.to
|
|
6
|
+
Project-URL: Documentation, https://outrun.to/docs/
|
|
7
|
+
Project-URL: Pricing, https://outrun.to/pricing
|
|
8
|
+
Project-URL: Repository, https://github.com/OutrunAIAPI/outrun-sdk
|
|
9
|
+
Project-URL: Issues, https://github.com/OutrunAIAPI/outrun-sdk/issues
|
|
10
|
+
Author-email: Outrun LLC <support@outrun.to>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,claude,deepseek,gemini,gpt,llm,openai-compatible,outrun,sdk
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: openai>=1.55.3
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# outrun-ai (Python)
|
|
28
|
+
|
|
29
|
+
Python client for the [Outrun API](https://outrun.to). Outrun is a prepaid, OpenAI-compatible API: one key for GPT, Claude, Gemini, Grok, DeepSeek, GLM, Kimi, Qwen and MiniMax models. This package is a thin wrapper over the official `openai` package with the base URL and API key set for you, so everything the OpenAI SDK does still works.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install outrun-ai
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
Create a key at https://outrun.to/key, then:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export OUTRUN_API_KEY="your-key"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from outrun_ai import Outrun
|
|
47
|
+
|
|
48
|
+
client = Outrun() # reads OUTRUN_API_KEY
|
|
49
|
+
print(client.ask("gpt-5.6-terra", "Say hello in five words."))
|
|
50
|
+
print(client.ask("deepseek-v4.1-flash", "Name three prime numbers.", system="Be brief."))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
You can also pass `Outrun(api_key="...", base_url="...")`. The default base URL is `https://api.outrun.to/v1`. If no key is found, the client raises `OutrunError`.
|
|
54
|
+
|
|
55
|
+
## Streaming
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
for text in client.ask_stream("deepseek-v4.1-flash", "Write a haiku about rain."):
|
|
59
|
+
print(text, end="", flush=True)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Model ids and prices
|
|
63
|
+
|
|
64
|
+
`prices()` reads the public price list. It needs no key and costs nothing.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from outrun_ai import prices
|
|
68
|
+
|
|
69
|
+
for m in prices(kind="text"):
|
|
70
|
+
tier = m["tiers"][0]
|
|
71
|
+
print(m["id"], tier["prices"].get("input"), tier["prices"].get("output"), "USD per", tier["unit"])
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each item has `id` (the model name to send), `name`, `family`, `kind` (`text`, `image`, `video` or `audio`), `unit`, `tiers` and `paused`. Paused models are left out unless you pass `include_paused=True`. The same list is at https://outrun.to/pricing.
|
|
75
|
+
|
|
76
|
+
## Everything else
|
|
77
|
+
|
|
78
|
+
`Outrun` subclasses `openai.OpenAI`, so the usual calls work unchanged:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
client.chat.completions.create(
|
|
82
|
+
model="deepseek-v4.1-flash",
|
|
83
|
+
messages=[{"role": "user", "content": "Hi"}],
|
|
84
|
+
)
|
|
85
|
+
client.models.list()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`AsyncOutrun` is the async version (`await client.ask(...)`, `async for t in client.ask_stream(...)`).
|
|
89
|
+
|
|
90
|
+
Each API request carries an `X-Outrun-SDK: python/<version>` header. Nothing else is added.
|
|
91
|
+
|
|
92
|
+
## Links
|
|
93
|
+
|
|
94
|
+
- Docs: https://outrun.to/docs/
|
|
95
|
+
- LangChain: `pip install langchain-outrun` ([source](https://github.com/OutrunAIAPI/langchain-outrun))
|
|
96
|
+
- Support: support@outrun.to
|
|
97
|
+
|
|
98
|
+
License: MIT.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# outrun-ai (Python)
|
|
2
|
+
|
|
3
|
+
Python client for the [Outrun API](https://outrun.to). Outrun is a prepaid, OpenAI-compatible API: one key for GPT, Claude, Gemini, Grok, DeepSeek, GLM, Kimi, Qwen and MiniMax models. This package is a thin wrapper over the official `openai` package with the base URL and API key set for you, so everything the OpenAI SDK does still works.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install outrun-ai
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
Create a key at https://outrun.to/key, then:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export OUTRUN_API_KEY="your-key"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from outrun_ai import Outrun
|
|
21
|
+
|
|
22
|
+
client = Outrun() # reads OUTRUN_API_KEY
|
|
23
|
+
print(client.ask("gpt-5.6-terra", "Say hello in five words."))
|
|
24
|
+
print(client.ask("deepseek-v4.1-flash", "Name three prime numbers.", system="Be brief."))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
You can also pass `Outrun(api_key="...", base_url="...")`. The default base URL is `https://api.outrun.to/v1`. If no key is found, the client raises `OutrunError`.
|
|
28
|
+
|
|
29
|
+
## Streaming
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
for text in client.ask_stream("deepseek-v4.1-flash", "Write a haiku about rain."):
|
|
33
|
+
print(text, end="", flush=True)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Model ids and prices
|
|
37
|
+
|
|
38
|
+
`prices()` reads the public price list. It needs no key and costs nothing.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from outrun_ai import prices
|
|
42
|
+
|
|
43
|
+
for m in prices(kind="text"):
|
|
44
|
+
tier = m["tiers"][0]
|
|
45
|
+
print(m["id"], tier["prices"].get("input"), tier["prices"].get("output"), "USD per", tier["unit"])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each item has `id` (the model name to send), `name`, `family`, `kind` (`text`, `image`, `video` or `audio`), `unit`, `tiers` and `paused`. Paused models are left out unless you pass `include_paused=True`. The same list is at https://outrun.to/pricing.
|
|
49
|
+
|
|
50
|
+
## Everything else
|
|
51
|
+
|
|
52
|
+
`Outrun` subclasses `openai.OpenAI`, so the usual calls work unchanged:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
client.chat.completions.create(
|
|
56
|
+
model="deepseek-v4.1-flash",
|
|
57
|
+
messages=[{"role": "user", "content": "Hi"}],
|
|
58
|
+
)
|
|
59
|
+
client.models.list()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`AsyncOutrun` is the async version (`await client.ask(...)`, `async for t in client.ask_stream(...)`).
|
|
63
|
+
|
|
64
|
+
Each API request carries an `X-Outrun-SDK: python/<version>` header. Nothing else is added.
|
|
65
|
+
|
|
66
|
+
## Links
|
|
67
|
+
|
|
68
|
+
- Docs: https://outrun.to/docs/
|
|
69
|
+
- LangChain: `pip install langchain-outrun` ([source](https://github.com/OutrunAIAPI/langchain-outrun))
|
|
70
|
+
- Support: support@outrun.to
|
|
71
|
+
|
|
72
|
+
License: MIT.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "outrun-ai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the Outrun API (OpenAI-compatible), built on the official OpenAI SDK."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Outrun LLC", email = "support@outrun.to" }]
|
|
14
|
+
keywords = ["outrun", "llm", "api", "openai-compatible", "sdk", "gpt", "claude", "gemini", "deepseek"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["openai>=1.55.3"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://outrun.to"
|
|
30
|
+
Documentation = "https://outrun.to/docs/"
|
|
31
|
+
Pricing = "https://outrun.to/pricing"
|
|
32
|
+
Repository = "https://github.com/OutrunAIAPI/outrun-sdk"
|
|
33
|
+
Issues = "https://github.com/OutrunAIAPI/outrun-sdk/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/outrun_ai"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["src/outrun_ai", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
asyncio_mode = "auto"
|
|
43
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Outrun SDK: a thin wrapper over the OpenAI Python SDK, pointed at the Outrun API."""
|
|
2
|
+
from ._client import DEFAULT_BASE_URL, AsyncOutrun, Outrun, OutrunError, __version__
|
|
3
|
+
from ._prices import PRICING_URL, prices
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Outrun",
|
|
7
|
+
"AsyncOutrun",
|
|
8
|
+
"OutrunError",
|
|
9
|
+
"prices",
|
|
10
|
+
"DEFAULT_BASE_URL",
|
|
11
|
+
"PRICING_URL",
|
|
12
|
+
"__version__",
|
|
13
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, AsyncIterator, Iterator, Optional
|
|
5
|
+
|
|
6
|
+
import openai
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.outrun.to/v1"
|
|
10
|
+
_HEADER = "X-Outrun-SDK"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OutrunError(openai.OpenAIError):
|
|
14
|
+
"""Raised when the client is misconfigured (for example, no API key)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve(api_key: Optional[str], base_url: Optional[str], kwargs: dict) -> tuple:
|
|
18
|
+
key = api_key or os.environ.get("OUTRUN_API_KEY")
|
|
19
|
+
if not key:
|
|
20
|
+
raise OutrunError(
|
|
21
|
+
"No API key. Pass api_key=... or set the OUTRUN_API_KEY environment variable. "
|
|
22
|
+
"Get a key at https://outrun.to/key"
|
|
23
|
+
)
|
|
24
|
+
headers = {_HEADER: f"python/{__version__}"}
|
|
25
|
+
headers.update(kwargs.pop("default_headers", None) or {})
|
|
26
|
+
return key, base_url or DEFAULT_BASE_URL, headers
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _messages(prompt: str, system: Optional[str]) -> list:
|
|
30
|
+
msgs = []
|
|
31
|
+
if system:
|
|
32
|
+
msgs.append({"role": "system", "content": system})
|
|
33
|
+
msgs.append({"role": "user", "content": prompt})
|
|
34
|
+
return msgs
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Outrun(openai.OpenAI):
|
|
38
|
+
"""OpenAI client defaulting to the Outrun API. Everything on openai.OpenAI still works."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, **kwargs: Any):
|
|
41
|
+
key, url, headers = _resolve(api_key, base_url, kwargs)
|
|
42
|
+
super().__init__(api_key=key, base_url=url, default_headers=headers, **kwargs)
|
|
43
|
+
|
|
44
|
+
def ask(self, model: str, prompt: str, *, system: Optional[str] = None, **kwargs: Any) -> str:
|
|
45
|
+
"""Send one prompt, return the reply text."""
|
|
46
|
+
r = self.chat.completions.create(model=model, messages=_messages(prompt, system), **kwargs)
|
|
47
|
+
return r.choices[0].message.content or ""
|
|
48
|
+
|
|
49
|
+
def ask_stream(
|
|
50
|
+
self, model: str, prompt: str, *, system: Optional[str] = None, **kwargs: Any
|
|
51
|
+
) -> Iterator[str]:
|
|
52
|
+
"""Send one prompt, yield reply text as it arrives."""
|
|
53
|
+
stream = self.chat.completions.create(
|
|
54
|
+
model=model, messages=_messages(prompt, system), stream=True, **kwargs
|
|
55
|
+
)
|
|
56
|
+
for chunk in stream:
|
|
57
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
58
|
+
yield chunk.choices[0].delta.content
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AsyncOutrun(openai.AsyncOpenAI):
|
|
62
|
+
"""Async version of Outrun."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, **kwargs: Any):
|
|
65
|
+
key, url, headers = _resolve(api_key, base_url, kwargs)
|
|
66
|
+
super().__init__(api_key=key, base_url=url, default_headers=headers, **kwargs)
|
|
67
|
+
|
|
68
|
+
async def ask(self, model: str, prompt: str, *, system: Optional[str] = None, **kwargs: Any) -> str:
|
|
69
|
+
r = await self.chat.completions.create(
|
|
70
|
+
model=model, messages=_messages(prompt, system), **kwargs
|
|
71
|
+
)
|
|
72
|
+
return r.choices[0].message.content or ""
|
|
73
|
+
|
|
74
|
+
async def ask_stream(
|
|
75
|
+
self, model: str, prompt: str, *, system: Optional[str] = None, **kwargs: Any
|
|
76
|
+
) -> AsyncIterator[str]:
|
|
77
|
+
stream = await self.chat.completions.create(
|
|
78
|
+
model=model, messages=_messages(prompt, system), stream=True, **kwargs
|
|
79
|
+
)
|
|
80
|
+
async for chunk in stream:
|
|
81
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
82
|
+
yield chunk.choices[0].delta.content
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ._client import OutrunError, __version__
|
|
8
|
+
|
|
9
|
+
PRICING_URL = "https://api.outrun.to/pricing"
|
|
10
|
+
# The feed's firewall blocks the default "Python-urllib" agent, so send our own.
|
|
11
|
+
_USER_AGENT = f"Mozilla/5.0 (compatible; outrun-ai-python/{__version__}; +https://outrun.to)"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fetch(url: str, timeout: float) -> Dict[str, Any]:
|
|
15
|
+
req = urllib.request.Request(
|
|
16
|
+
url, headers={"User-Agent": _USER_AGENT, "Accept": "application/json"}
|
|
17
|
+
)
|
|
18
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (fixed https URL)
|
|
19
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def prices(
|
|
23
|
+
kind: Optional[str] = None,
|
|
24
|
+
*,
|
|
25
|
+
include_paused: bool = False,
|
|
26
|
+
timeout: float = 15.0,
|
|
27
|
+
url: str = PRICING_URL,
|
|
28
|
+
) -> List[Dict[str, Any]]:
|
|
29
|
+
"""Return the public Outrun price list. No API key is needed and nothing is billed.
|
|
30
|
+
|
|
31
|
+
Each item is a dict with ``id`` (the model name to send), ``name``, ``family``,
|
|
32
|
+
``kind`` (``"text"``, ``"image"``, ``"video"`` or ``"audio"``), ``unit`` and
|
|
33
|
+
``tiers`` (a list of ``{"label", "unit", "prices": {"input", "output", ...}}``,
|
|
34
|
+
prices in USD per ``unit``), plus ``paused`` (bool).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
kind: Only return models of this kind, for example ``"text"``.
|
|
38
|
+
include_paused: Also return models that are paused right now.
|
|
39
|
+
timeout: Seconds to wait for the feed.
|
|
40
|
+
url: Feed URL. Only change this for testing.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
feed = _fetch(url, timeout)
|
|
44
|
+
except Exception as exc: # network, HTTP or JSON errors
|
|
45
|
+
raise OutrunError(f"Could not read the Outrun price list from {url}: {exc}") from exc
|
|
46
|
+
if not isinstance(feed, dict) or not isinstance(feed.get("models"), list):
|
|
47
|
+
raise OutrunError(f"Unexpected price list format from {url}")
|
|
48
|
+
|
|
49
|
+
paused = set(feed.get("paused") or [])
|
|
50
|
+
out = []
|
|
51
|
+
for model in feed["models"]:
|
|
52
|
+
if not isinstance(model, dict) or "id" not in model:
|
|
53
|
+
continue
|
|
54
|
+
item = dict(model)
|
|
55
|
+
item["paused"] = item["id"] in paused
|
|
56
|
+
if item["paused"] and not include_paused:
|
|
57
|
+
continue
|
|
58
|
+
if kind is not None and item.get("kind") != kind:
|
|
59
|
+
continue
|
|
60
|
+
out.append(item)
|
|
61
|
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import httpx2 as httpx # openai 3.x
|
|
5
|
+
except ImportError: # openai 1.x / 2.x
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
from outrun_ai import AsyncOutrun, Outrun, OutrunError
|
|
9
|
+
|
|
10
|
+
COMPLETION = {
|
|
11
|
+
"id": "c1", "object": "chat.completion", "created": 1, "model": "m",
|
|
12
|
+
"choices": [{"index": 0, "finish_reason": "stop",
|
|
13
|
+
"message": {"role": "assistant", "content": "hello there"}}],
|
|
14
|
+
}
|
|
15
|
+
SSE = (
|
|
16
|
+
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"}}]}\n\n'
|
|
17
|
+
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"lo"}}]}\n\n'
|
|
18
|
+
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'
|
|
19
|
+
"data: [DONE]\n\n"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_handler(seen):
|
|
24
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
25
|
+
seen.append(request)
|
|
26
|
+
body = json.loads(request.content or b"{}")
|
|
27
|
+
if body.get("stream"):
|
|
28
|
+
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=SSE)
|
|
29
|
+
return httpx.Response(200, json=COMPLETION)
|
|
30
|
+
return handler
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(autouse=True)
|
|
34
|
+
def clean_env(monkeypatch):
|
|
35
|
+
monkeypatch.delenv("OUTRUN_API_KEY", raising=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def client(seen, **kw):
|
|
39
|
+
kw.setdefault("api_key", "test-key")
|
|
40
|
+
return Outrun(http_client=httpx.Client(transport=httpx.MockTransport(make_handler(seen))), **kw)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_default_base_url():
|
|
44
|
+
assert str(Outrun(api_key="k").base_url) == "https://api.outrun.to/v1/"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_custom_base_url():
|
|
48
|
+
assert str(Outrun(api_key="k", base_url="http://localhost:1/v1").base_url) == "http://localhost:1/v1/"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_env_key(monkeypatch):
|
|
52
|
+
monkeypatch.setenv("OUTRUN_API_KEY", "env-key")
|
|
53
|
+
assert Outrun().api_key == "env-key"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_missing_key():
|
|
57
|
+
with pytest.raises(OutrunError):
|
|
58
|
+
Outrun()
|
|
59
|
+
with pytest.raises(OutrunError):
|
|
60
|
+
AsyncOutrun()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_ask_returns_content_and_sends_system():
|
|
64
|
+
seen = []
|
|
65
|
+
c = client(seen)
|
|
66
|
+
assert c.ask("some-model", "hi", system="be brief") == "hello there"
|
|
67
|
+
body = json.loads(seen[0].content)
|
|
68
|
+
assert body["model"] == "some-model"
|
|
69
|
+
assert body["messages"] == [
|
|
70
|
+
{"role": "system", "content": "be brief"}, {"role": "user", "content": "hi"}]
|
|
71
|
+
assert str(seen[0].url) == "https://api.outrun.to/v1/chat/completions"
|
|
72
|
+
assert seen[0].headers["authorization"] == "Bearer test-key"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_ask_stream_yields_chunks():
|
|
76
|
+
seen = []
|
|
77
|
+
assert list(client(seen).ask_stream("m", "hi")) == ["Hel", "lo"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_sdk_header_present_and_overridable():
|
|
81
|
+
seen = []
|
|
82
|
+
client(seen).ask("m", "hi")
|
|
83
|
+
assert seen[0].headers["x-outrun-sdk"] == "python/0.1.0"
|
|
84
|
+
seen2 = []
|
|
85
|
+
client(seen2, default_headers={"X-Extra": "1"}).ask("m", "hi")
|
|
86
|
+
assert seen2[0].headers["x-extra"] == "1"
|
|
87
|
+
assert seen2[0].headers["x-outrun-sdk"] == "python/0.1.0"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_openai_chat_attribute_not_shadowed():
|
|
91
|
+
c = Outrun(api_key="k")
|
|
92
|
+
assert hasattr(c.chat, "completions")
|
|
93
|
+
assert hasattr(c, "models") and hasattr(c, "images")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def test_async_ask_and_stream():
|
|
97
|
+
seen = []
|
|
98
|
+
c = AsyncOutrun(api_key="k", http_client=httpx.AsyncClient(
|
|
99
|
+
transport=httpx.MockTransport(make_handler(seen))))
|
|
100
|
+
assert await c.ask("m", "hi") == "hello there"
|
|
101
|
+
assert [t async for t in c.ask_stream("m", "hi")] == ["Hel", "lo"]
|
|
102
|
+
assert seen[0].headers["x-outrun-sdk"] == "python/0.1.0"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import urllib.error
|
|
4
|
+
|
|
5
|
+
import outrun_ai
|
|
6
|
+
import pytest
|
|
7
|
+
from outrun_ai import OutrunError, _prices, prices
|
|
8
|
+
|
|
9
|
+
FEED = {
|
|
10
|
+
"currency": "USD",
|
|
11
|
+
"generated_at": 1,
|
|
12
|
+
"paused": ["paused-model"],
|
|
13
|
+
"models": [
|
|
14
|
+
{"id": "text-a", "name": "Text A", "family": "f", "kind": "text", "unit": "1M tokens",
|
|
15
|
+
"tiers": [{"label": "all", "unit": "1M tokens", "prices": {"input": 0.1, "output": 0.4}}]},
|
|
16
|
+
{"id": "img-b", "name": "Image B", "family": "image", "kind": "image", "unit": "image",
|
|
17
|
+
"tiers": [{"label": "all", "unit": "image", "prices": {"output": 0.02}}]},
|
|
18
|
+
{"id": "paused-model", "name": "Paused", "family": "f", "kind": "text", "unit": "1M tokens",
|
|
19
|
+
"tiers": []},
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FakeResponse(io.BytesIO):
|
|
25
|
+
def __enter__(self):
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, *exc):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def seen(monkeypatch):
|
|
34
|
+
"""Replace urlopen so no test touches the network; record each request."""
|
|
35
|
+
calls = []
|
|
36
|
+
|
|
37
|
+
def fake_urlopen(req, timeout=None):
|
|
38
|
+
calls.append((req, timeout))
|
|
39
|
+
return FakeResponse(json.dumps(FEED).encode())
|
|
40
|
+
|
|
41
|
+
monkeypatch.setattr(_prices.urllib.request, "urlopen", fake_urlopen)
|
|
42
|
+
return calls
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_exported():
|
|
46
|
+
assert outrun_ai.prices is prices
|
|
47
|
+
assert outrun_ai.PRICING_URL == "https://api.outrun.to/pricing"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_returns_active_models_with_paused_flag(seen):
|
|
51
|
+
out = prices()
|
|
52
|
+
assert [m["id"] for m in out] == ["text-a", "img-b"]
|
|
53
|
+
assert all(m["paused"] is False for m in out)
|
|
54
|
+
assert out[0]["tiers"][0]["prices"]["output"] == 0.4
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_kind_filter_and_include_paused(seen):
|
|
58
|
+
assert [m["id"] for m in prices("text")] == ["text-a"]
|
|
59
|
+
both = prices(kind="text", include_paused=True)
|
|
60
|
+
assert [(m["id"], m["paused"]) for m in both] == [("text-a", False), ("paused-model", True)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_request_url_user_agent_and_timeout(seen):
|
|
64
|
+
prices(timeout=3)
|
|
65
|
+
req, timeout = seen[0]
|
|
66
|
+
assert req.full_url == "https://api.outrun.to/pricing"
|
|
67
|
+
assert timeout == 3
|
|
68
|
+
ua = req.get_header("User-agent")
|
|
69
|
+
assert ua.startswith("Mozilla/5.0") and "outrun-ai-python/" in ua
|
|
70
|
+
assert "Python-urllib" not in ua
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_does_not_mutate_or_need_a_key(seen, monkeypatch):
|
|
74
|
+
monkeypatch.delenv("OUTRUN_API_KEY", raising=False)
|
|
75
|
+
prices()
|
|
76
|
+
req, _ = seen[0]
|
|
77
|
+
assert req.get_header("Authorization") is None
|
|
78
|
+
assert "paused" not in FEED["models"][0]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_network_error_becomes_outrun_error(monkeypatch):
|
|
82
|
+
def boom(req, timeout=None):
|
|
83
|
+
raise urllib.error.HTTPError(req.full_url, 403, "Forbidden", None, None)
|
|
84
|
+
|
|
85
|
+
monkeypatch.setattr(_prices.urllib.request, "urlopen", boom)
|
|
86
|
+
with pytest.raises(OutrunError, match="price list"):
|
|
87
|
+
prices()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_bad_format_becomes_outrun_error(monkeypatch):
|
|
91
|
+
monkeypatch.setattr(
|
|
92
|
+
_prices.urllib.request, "urlopen",
|
|
93
|
+
lambda req, timeout=None: FakeResponse(b'{"unexpected": true}'),
|
|
94
|
+
)
|
|
95
|
+
with pytest.raises(OutrunError, match="format"):
|
|
96
|
+
prices()
|