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.
@@ -0,0 +1,11 @@
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
11
+ node_modules/
@@ -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,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()