dr-providers 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,8 @@
1
+ .worktrees
2
+ .DS_Store
3
+ *.swp
4
+ .cache/
5
+ .ruff_cache/
6
+ .venv/
7
+ dist/
8
+ **/__pycache__/
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## 0.1.0
6
+
7
+ Initial release.
8
+
9
+ - OpenRouter provider via `OpenRouterProvider` and generic `ApiProvider` transport
10
+ - Unified `LlmRequest` with `prepare()`, `endpoint()`, `headers()`, and `json_payload()`
11
+ - Minimal chat-completions response parsing into `LlmResponse`
12
+ - Typed config models: `ReasoningSpec`, `SamplingControls`, `LlmConfig`
13
+ - Module layout: `errors`, `transport_config`, `providers/openrouter`, `from_prompt`
14
+ - Public API exported from top-level `dr_providers` package
15
+ - Optional CLI extra: `pip install dr-providers[cli]` provides `query-provider`
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 danielle rothermel
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,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: dr-providers
3
+ Version: 0.1.0
4
+ Summary: OpenRouter LLM query client with typed requests and responses.
5
+ Project-URL: Repository, https://github.com/danielle-rothermel/dr-providers
6
+ Project-URL: Issues, https://github.com/danielle-rothermel/dr-providers/issues
7
+ Author-email: Danielle Rothermel <danielle.rothermel@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.13
17
+ Requires-Dist: httpx>=0.28.1
18
+ Requires-Dist: pydantic>=2.13.4
19
+ Requires-Dist: tenacity>=9.1.4
20
+ Provides-Extra: cli
21
+ Requires-Dist: typer>=0.26.7; extra == 'cli'
22
+ Provides-Extra: notebooks
23
+ Requires-Dist: marimo[recommended]>=0.23.10; extra == 'notebooks'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # dr-providers
27
+
28
+ OpenRouter LLM query client with typed requests and responses. Requires Python 3.13+.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install dr-providers
34
+ ```
35
+
36
+ Or with [uv](https://docs.astral.sh/uv/):
37
+
38
+ ```bash
39
+ uv add dr-providers
40
+ ```
41
+
42
+ ### Optional CLI
43
+
44
+ ```bash
45
+ pip install "dr-providers[cli]"
46
+ query-provider --help
47
+ ```
48
+
49
+ ## Authentication
50
+
51
+ Set your OpenRouter API key:
52
+
53
+ ```bash
54
+ export OPENROUTER_API_KEY="sk-or-..."
55
+ ```
56
+
57
+ ## Library usage
58
+
59
+ ```python
60
+ from dr_providers import (
61
+ LlmRequest,
62
+ Message,
63
+ MessageRole,
64
+ OpenRouterProvider,
65
+ ProviderName,
66
+ )
67
+
68
+ with OpenRouterProvider() as provider:
69
+ response = provider.generate(
70
+ LlmRequest(
71
+ provider=ProviderName.OPENROUTER,
72
+ model="openai/gpt-4o-mini",
73
+ messages=[
74
+ Message(role=MessageRole.USER, content="Say hello in one word."),
75
+ ],
76
+ )
77
+ )
78
+ print(response.text)
79
+ ```
80
+
81
+ ### Prompt helper
82
+
83
+ For scripts and demos, `query_from_prompt` builds a request from plain strings:
84
+
85
+ ```python
86
+ from dr_providers import OpenRouterProvider, ProviderName
87
+ from dr_providers.query.from_prompt import query_from_prompt
88
+
89
+ with OpenRouterProvider() as provider:
90
+ response = query_from_prompt(
91
+ provider,
92
+ ProviderName.OPENROUTER,
93
+ model="openai/gpt-4o-mini",
94
+ prompt="Say hello in one word.",
95
+ )
96
+ ```
97
+
98
+ This helper is not re-exported from the top-level package.
99
+
100
+ ## Public API
101
+
102
+ Import stable symbols from the top-level package:
103
+
104
+ ```python
105
+ from dr_providers import LlmRequest, OpenRouterProvider, ReasoningSpec
106
+ ```
107
+
108
+ See `dr_providers.__all__` for the full list.
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ uv sync
114
+ scripts/pre-check.sh
115
+ ```
116
+
117
+ Run the CLI from the repo without installing:
118
+
119
+ ```bash
120
+ uv run python scripts/query_provider.py --model openai/gpt-4o-mini -m "hi"
121
+ ```
@@ -0,0 +1,96 @@
1
+ # dr-providers
2
+
3
+ OpenRouter LLM query client with typed requests and responses. Requires Python 3.13+.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install dr-providers
9
+ ```
10
+
11
+ Or with [uv](https://docs.astral.sh/uv/):
12
+
13
+ ```bash
14
+ uv add dr-providers
15
+ ```
16
+
17
+ ### Optional CLI
18
+
19
+ ```bash
20
+ pip install "dr-providers[cli]"
21
+ query-provider --help
22
+ ```
23
+
24
+ ## Authentication
25
+
26
+ Set your OpenRouter API key:
27
+
28
+ ```bash
29
+ export OPENROUTER_API_KEY="sk-or-..."
30
+ ```
31
+
32
+ ## Library usage
33
+
34
+ ```python
35
+ from dr_providers import (
36
+ LlmRequest,
37
+ Message,
38
+ MessageRole,
39
+ OpenRouterProvider,
40
+ ProviderName,
41
+ )
42
+
43
+ with OpenRouterProvider() as provider:
44
+ response = provider.generate(
45
+ LlmRequest(
46
+ provider=ProviderName.OPENROUTER,
47
+ model="openai/gpt-4o-mini",
48
+ messages=[
49
+ Message(role=MessageRole.USER, content="Say hello in one word."),
50
+ ],
51
+ )
52
+ )
53
+ print(response.text)
54
+ ```
55
+
56
+ ### Prompt helper
57
+
58
+ For scripts and demos, `query_from_prompt` builds a request from plain strings:
59
+
60
+ ```python
61
+ from dr_providers import OpenRouterProvider, ProviderName
62
+ from dr_providers.query.from_prompt import query_from_prompt
63
+
64
+ with OpenRouterProvider() as provider:
65
+ response = query_from_prompt(
66
+ provider,
67
+ ProviderName.OPENROUTER,
68
+ model="openai/gpt-4o-mini",
69
+ prompt="Say hello in one word.",
70
+ )
71
+ ```
72
+
73
+ This helper is not re-exported from the top-level package.
74
+
75
+ ## Public API
76
+
77
+ Import stable symbols from the top-level package:
78
+
79
+ ```python
80
+ from dr_providers import LlmRequest, OpenRouterProvider, ReasoningSpec
81
+ ```
82
+
83
+ See `dr_providers.__all__` for the full list.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ uv sync
89
+ scripts/pre-check.sh
90
+ ```
91
+
92
+ Run the CLI from the repo without installing:
93
+
94
+ ```bash
95
+ uv run python scripts/query_provider.py --model openai/gpt-4o-mini -m "hi"
96
+ ```
@@ -0,0 +1,122 @@
1
+ [project]
2
+ name = "dr-providers"
3
+ version = "0.1.0"
4
+ description = "OpenRouter LLM query client with typed requests and responses."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Danielle Rothermel", email = "danielle.rothermel@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.13"
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "httpx>=0.28.1",
21
+ "pydantic>=2.13.4",
22
+ "tenacity>=9.1.4",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ cli = ["typer>=0.26.7"]
27
+ notebooks = ["marimo[recommended]>=0.23.10"]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/danielle-rothermel/dr-providers"
31
+ Issues = "https://github.com/danielle-rothermel/dr-providers/issues"
32
+
33
+ [project.scripts]
34
+ query-provider = "dr_providers.cli:main"
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=9.1.1",
43
+ "ruff>=0.15.18",
44
+ "ty>=0.0.51",
45
+ "typer>=0.26.7",
46
+ ]
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ exclude = [
50
+ "/.env",
51
+ "/.env.example",
52
+ "/.gitignore",
53
+ "/.python-version",
54
+ "/uv.lock",
55
+ "/scripts",
56
+ "/nbs",
57
+ "/.cache",
58
+ "/.github",
59
+ "/tests",
60
+ "/dist",
61
+ "/AGENTS.md",
62
+ "/CLAUDE.md",
63
+ ]
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
67
+
68
+ [tool.ruff]
69
+ include = ["scripts/**/*.py", "src/**/*.py", "tests/**/*.py"]
70
+ line-length = 79
71
+
72
+ [tool.ruff.lint]
73
+ select = [
74
+ "A",
75
+ "ARG",
76
+ "ASYNC",
77
+ "B",
78
+ "BLE",
79
+ "C4",
80
+ "DTZ",
81
+ "E",
82
+ "F",
83
+ "FA",
84
+ "FBT",
85
+ "FLY",
86
+ "FURB",
87
+ "G",
88
+ "I",
89
+ "ICN",
90
+ "ISC",
91
+ "LOG",
92
+ "N",
93
+ "NPY",
94
+ "PD",
95
+ "PERF",
96
+ "PIE",
97
+ "PL",
98
+ "PTH",
99
+ "PT",
100
+ "RET",
101
+ "RSE",
102
+ "RUF",
103
+ "S",
104
+ "SIM",
105
+ "SLOT",
106
+ "T10",
107
+ "TC",
108
+ "TID",
109
+ "TRY",
110
+ "UP",
111
+ "W",
112
+ "YTT",
113
+ ]
114
+ ignore = ["PLR1711", "S101", "TRY003"]
115
+
116
+ [tool.ruff.lint.per-file-ignores]
117
+ "nbs/**/*.py" = ["E501", "F841", "RET503", "B018"]
118
+ "tests/**/*.py" = ["PLR2004", "PLC0415"]
119
+
120
+ [tool.ty.src]
121
+ include = ["scripts", "src", "tests"]
122
+ exclude = ["nbs"]
@@ -0,0 +1,34 @@
1
+ from dr_providers.query import (
2
+ ApiProvider,
3
+ LlmConfig,
4
+ LlmRequest,
5
+ LlmResponse,
6
+ Message,
7
+ MessageRole,
8
+ OpenRouterProvider,
9
+ ProviderError,
10
+ ProviderName,
11
+ ProviderSemanticError,
12
+ ProviderTransportError,
13
+ ReasoningSpec,
14
+ RequestControls,
15
+ SamplingControls,
16
+ )
17
+
18
+ __all__ = [
19
+ "ApiProvider",
20
+ "LlmConfig",
21
+ "LlmRequest",
22
+ "LlmResponse",
23
+ "Message",
24
+ "MessageRole",
25
+ "OpenRouterProvider",
26
+ "ProviderError",
27
+ "ProviderName",
28
+ "ProviderSemanticError",
29
+ "ProviderTransportError",
30
+ "ReasoningSpec",
31
+ "RequestControls",
32
+ "SamplingControls",
33
+ ]
34
+ __version__ = "0.1.0"
@@ -0,0 +1,138 @@
1
+ """CLI for querying OpenRouter with model, reasoning, and sampling options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from dr_providers import (
8
+ LlmResponse,
9
+ OpenRouterProvider,
10
+ ProviderName,
11
+ ReasoningSpec,
12
+ SamplingControls,
13
+ )
14
+ from dr_providers.names import EffortLevel
15
+ from dr_providers.query.from_prompt import query_from_prompt
16
+
17
+
18
+ def _parse_effort(value: str) -> EffortLevel:
19
+ normalized = value.lower()
20
+ try:
21
+ return EffortLevel(normalized)
22
+ except ValueError:
23
+ allowed = ", ".join(level.value for level in EffortLevel)
24
+ raise typer.BadParameter(
25
+ f"Invalid effort {value!r}. Expected one of: {allowed}"
26
+ ) from None
27
+
28
+
29
+ def _build_reasoning(
30
+ *,
31
+ effort: str | None,
32
+ reasoning_enabled: bool | None,
33
+ ) -> ReasoningSpec | None:
34
+ if effort is not None and reasoning_enabled is not None:
35
+ raise typer.BadParameter(
36
+ "Use --effort for effort-style models or "
37
+ "--reasoning-enabled/--reasoning-disabled for toggle-style "
38
+ "models, not both."
39
+ )
40
+ if effort is not None:
41
+ return ReasoningSpec(effort=_parse_effort(effort))
42
+ if reasoning_enabled is not None:
43
+ return ReasoningSpec(enabled=reasoning_enabled)
44
+ return None
45
+
46
+
47
+ def _build_sampling(
48
+ *,
49
+ temperature: float | None,
50
+ top_p: float | None,
51
+ ) -> SamplingControls | None:
52
+ if temperature is None and top_p is None:
53
+ return None
54
+ return SamplingControls(temperature=temperature, top_p=top_p)
55
+
56
+
57
+ def _query_provider( # noqa: PLR0913
58
+ *,
59
+ effort: str | None,
60
+ reasoning_enabled: bool | None,
61
+ temperature: float | None,
62
+ top_p: float | None,
63
+ max_tokens: int | None,
64
+ model: str,
65
+ message: str,
66
+ ) -> LlmResponse:
67
+ reasoning = _build_reasoning(
68
+ effort=effort,
69
+ reasoning_enabled=reasoning_enabled,
70
+ )
71
+ sampling = _build_sampling(temperature=temperature, top_p=top_p)
72
+ with OpenRouterProvider() as provider:
73
+ return query_from_prompt(
74
+ provider=provider,
75
+ provider_name=ProviderName.OPENROUTER,
76
+ model=model,
77
+ prompt=message,
78
+ reasoning=reasoning,
79
+ sampling=sampling,
80
+ max_tokens=max_tokens,
81
+ )
82
+
83
+
84
+ def query( # noqa: PLR0913
85
+ model: str = typer.Option(..., "--model", help="OpenRouter model id."),
86
+ message: str = typer.Option(
87
+ ...,
88
+ "--message",
89
+ "-m",
90
+ help="User message to send.",
91
+ ),
92
+ effort: str | None = typer.Option(
93
+ None,
94
+ "--effort",
95
+ "-e",
96
+ help="Reasoning effort (low, medium, high) for effort-style models.",
97
+ ),
98
+ reasoning_enabled: bool | None = typer.Option( # noqa: FBT001
99
+ None,
100
+ "--reasoning-enabled/--reasoning-disabled",
101
+ help="Enable or disable reasoning for toggle-style models.",
102
+ ),
103
+ max_tokens: int | None = typer.Option(
104
+ None,
105
+ "--max-tokens",
106
+ help="Completion token limit. Omit to use provider defaults.",
107
+ ),
108
+ temperature: float | None = typer.Option(
109
+ None,
110
+ "--temperature",
111
+ "--temp",
112
+ help="Sampling temperature.",
113
+ ),
114
+ top_p: float | None = typer.Option(
115
+ None,
116
+ "--top-p",
117
+ help="Sampling top-p.",
118
+ ),
119
+ ) -> None:
120
+ response = _query_provider(
121
+ model=model,
122
+ message=message,
123
+ effort=effort,
124
+ reasoning_enabled=reasoning_enabled,
125
+ temperature=temperature,
126
+ top_p=top_p,
127
+ max_tokens=max_tokens,
128
+ )
129
+ typer.echo(response.text)
130
+ typer.echo(f"({response.latency_ms} ms)", err=True)
131
+
132
+
133
+ def main() -> None:
134
+ typer.run(query)
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from dr_providers.names import EffortLevel, ProviderName # noqa: TC001
6
+
7
+
8
+ class ReasoningSpec(BaseModel):
9
+ model_config = ConfigDict(frozen=True, extra="forbid")
10
+ enabled: bool | None = None
11
+ effort: EffortLevel | None = None
12
+
13
+
14
+ class SamplingControls(BaseModel):
15
+ model_config = ConfigDict(frozen=True, extra="forbid")
16
+
17
+ temperature: float | None = None
18
+ top_p: float | None = None
19
+
20
+ def is_empty(self) -> bool:
21
+ return self.temperature is None and self.top_p is None
22
+
23
+
24
+ class LlmConfig(BaseModel):
25
+ model_config = ConfigDict(frozen=True, extra="forbid")
26
+
27
+ provider: ProviderName
28
+ model: str
29
+ max_tokens: int | None = None
30
+ reasoning: ReasoningSpec | None = None
31
+ sampling: SamplingControls | None = None
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Self
5
+
6
+
7
+ class ProviderName(StrEnum):
8
+ _api_base_url: str
9
+
10
+ def __new__(cls, value: str, api_base_url: str) -> Self:
11
+ obj = str.__new__(cls, value)
12
+ obj._value_ = value
13
+ obj._api_base_url = api_base_url
14
+ return obj
15
+
16
+ OPENROUTER = "openrouter", "https://openrouter.ai/api/v1"
17
+
18
+ def api_key_env(self) -> str:
19
+ return f"{self.name}_API_KEY"
20
+
21
+ @property
22
+ def api_base_url(self) -> str:
23
+ return self._api_base_url
24
+
25
+
26
+ class EffortLevel(StrEnum):
27
+ LOW = "low"
28
+ MEDIUM = "medium"
29
+ HIGH = "high"
30
+
31
+
32
+ class ControlRequestStyle(StrEnum):
33
+ NONE = "none"
34
+ ENABLED_FLAG = "enabled_flag"
35
+ EFFORT = "effort"
36
+
37
+
38
+ class MessageRole(StrEnum):
39
+ SYSTEM = "system"
40
+ USER = "user"
41
+ ASSISTANT = "assistant"
42
+
43
+
44
+ # TODO: remove this
45
+ class OpenRouterReasoningKey(StrEnum):
46
+ REASONING = "reasoning"
47
+ ENABLED = "enabled"
48
+ EFFORT = "effort"
File without changes
@@ -0,0 +1,29 @@
1
+ from dr_providers.config import LlmConfig, ReasoningSpec, SamplingControls
2
+ from dr_providers.names import MessageRole, ProviderName
3
+ from dr_providers.query.errors import (
4
+ ProviderError,
5
+ ProviderSemanticError,
6
+ ProviderTransportError,
7
+ )
8
+ from dr_providers.query.providers import OpenRouterProvider
9
+ from dr_providers.query.reasoning import RequestControls
10
+ from dr_providers.query.request import LlmRequest, Message
11
+ from dr_providers.query.response import LlmResponse
12
+ from dr_providers.query.transport import ApiProvider
13
+
14
+ __all__ = [
15
+ "ApiProvider",
16
+ "LlmConfig",
17
+ "LlmRequest",
18
+ "LlmResponse",
19
+ "Message",
20
+ "MessageRole",
21
+ "OpenRouterProvider",
22
+ "ProviderError",
23
+ "ProviderName",
24
+ "ProviderSemanticError",
25
+ "ProviderTransportError",
26
+ "ReasoningSpec",
27
+ "RequestControls",
28
+ "SamplingControls",
29
+ ]
@@ -0,0 +1,10 @@
1
+ class ProviderError(Exception):
2
+ pass
3
+
4
+
5
+ class ProviderTransportError(ProviderError):
6
+ pass
7
+
8
+
9
+ class ProviderSemanticError(ProviderError):
10
+ pass
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from dr_providers.names import MessageRole, ProviderName
6
+ from dr_providers.query.request import LlmRequest, Message
7
+
8
+ if TYPE_CHECKING:
9
+ from dr_providers.config import ReasoningSpec, SamplingControls
10
+ from dr_providers.query.response import LlmResponse
11
+ from dr_providers.query.transport import ApiProvider
12
+
13
+
14
+ def query_from_prompt( # noqa: PLR0913
15
+ provider: ApiProvider,
16
+ provider_name: ProviderName,
17
+ model: str,
18
+ prompt: str,
19
+ *,
20
+ system: str | None = None,
21
+ max_tokens: int | None = None,
22
+ reasoning: ReasoningSpec | None = None,
23
+ sampling: SamplingControls | None = None,
24
+ metadata: dict[str, Any] | None = None,
25
+ ) -> LlmResponse:
26
+ messages: list[Message] = []
27
+ if system is not None:
28
+ messages.append(Message(role=MessageRole.SYSTEM, content=system))
29
+ messages.append(Message(role=MessageRole.USER, content=prompt))
30
+ request = LlmRequest(
31
+ provider=provider_name,
32
+ model=model,
33
+ messages=messages,
34
+ max_tokens=max_tokens,
35
+ reasoning=reasoning,
36
+ sampling=sampling,
37
+ metadata=metadata or {},
38
+ )
39
+ return provider.generate(request)
@@ -0,0 +1,3 @@
1
+ from dr_providers.query.providers.openrouter import OpenRouterProvider
2
+
3
+ __all__ = ["OpenRouterProvider"]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ import httpx
7
+
8
+ from dr_providers.names import ProviderName
9
+ from dr_providers.query.transport import ApiProvider
10
+ from dr_providers.query.transport_config import ProviderConfig
11
+
12
+
13
+ class OpenRouterProvider(ApiProvider):
14
+ def __init__(
15
+ self,
16
+ *,
17
+ client: httpx.Client | None = None,
18
+ ) -> None:
19
+ super().__init__(
20
+ config=ProviderConfig(
21
+ name=ProviderName.OPENROUTER,
22
+ base_url=ProviderName.OPENROUTER.api_base_url,
23
+ api_key_env=ProviderName.OPENROUTER.api_key_env(),
24
+ ),
25
+ client=client,
26
+ )
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from dr_providers.config import ReasoningSpec # noqa: TC001
8
+
9
+
10
+ class ReasoningWarning(BaseModel):
11
+ model_config = ConfigDict(frozen=True)
12
+
13
+ message: str
14
+ provider: str | None = None
15
+ details: dict[str, Any] = Field(default_factory=dict)
16
+
17
+
18
+ def _reasoning_extra_body(spec: ReasoningSpec) -> dict[str, Any]:
19
+ if spec.enabled is not None:
20
+ payload: dict[str, Any] = {"enabled": spec.enabled}
21
+ elif spec.effort is not None:
22
+ payload = {"effort": spec.effort}
23
+ else:
24
+ payload = {"reasoning": True}
25
+ return {"reasoning": payload}
26
+
27
+
28
+ class RequestControls(BaseModel):
29
+ model_config = ConfigDict(frozen=True)
30
+
31
+ extra_body: dict[str, Any] = Field(default_factory=dict)
32
+ warnings: list[ReasoningWarning] = Field(default_factory=list)
33
+
34
+ @classmethod
35
+ def from_reasoning(
36
+ cls, reasoning: ReasoningSpec | None
37
+ ) -> RequestControls:
38
+ if reasoning is None:
39
+ return cls()
40
+ return cls(extra_body=_reasoning_extra_body(reasoning))
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from uuid import uuid4
5
+
6
+ from pydantic import (
7
+ BaseModel,
8
+ ConfigDict,
9
+ Field,
10
+ )
11
+
12
+ from dr_providers.config import ReasoningSpec, SamplingControls # noqa: TC001
13
+ from dr_providers.names import MessageRole, ProviderName # noqa: TC001
14
+ from dr_providers.query.reasoning import ReasoningWarning, RequestControls
15
+ from dr_providers.query.transport_config import ProviderConfig, resolve_api_key
16
+
17
+
18
+ class Message(BaseModel):
19
+ model_config = ConfigDict(frozen=True, extra="forbid")
20
+
21
+ role: MessageRole
22
+ content: str
23
+
24
+
25
+ class LlmRequest(BaseModel):
26
+ model_config = ConfigDict(frozen=True, extra="forbid")
27
+
28
+ provider: ProviderName
29
+ model: str
30
+ messages: list[Message]
31
+ max_tokens: int | None = None
32
+ reasoning: ReasoningSpec | None = None
33
+ sampling: SamplingControls | None = None
34
+ metadata: dict[str, Any] = Field(default_factory=dict)
35
+ base_url: str | None = Field(default=None, exclude=True)
36
+ chat_path: str | None = Field(default=None, exclude=True)
37
+ api_key: str | None = Field(default=None, exclude=True, repr=False)
38
+ idempotency_key: str | None = Field(default=None, exclude=True)
39
+ extra_body: dict[str, Any] = Field(default_factory=dict, exclude=True)
40
+ warnings: list[ReasoningWarning] = Field(
41
+ default_factory=list, exclude=True
42
+ )
43
+
44
+ @property
45
+ def has_sampling_controls(self) -> bool:
46
+ return self.sampling is not None and not self.sampling.is_empty()
47
+
48
+ @property
49
+ def sampling_temperature(self) -> float | None:
50
+ return self.sampling.temperature if self.sampling is not None else None
51
+
52
+ @property
53
+ def sampling_top_p(self) -> float | None:
54
+ return self.sampling.top_p if self.sampling is not None else None
55
+
56
+ def prepare(
57
+ self,
58
+ config: ProviderConfig,
59
+ *,
60
+ controls: RequestControls | None = None,
61
+ ) -> LlmRequest:
62
+ request_controls = controls or RequestControls.from_reasoning(
63
+ self.reasoning
64
+ )
65
+ return self.model_copy(
66
+ update={
67
+ "base_url": config.base_url,
68
+ "chat_path": config.chat_path,
69
+ "api_key": resolve_api_key(config, label=self.provider),
70
+ "idempotency_key": self._resolve_idempotency_key(),
71
+ "extra_body": request_controls.extra_body,
72
+ "warnings": request_controls.warnings,
73
+ }
74
+ )
75
+
76
+ def _resolve_idempotency_key(self) -> str:
77
+ raw_idempotency_key = self.metadata.get("idempotency_key")
78
+ if isinstance(raw_idempotency_key, str) and raw_idempotency_key:
79
+ return raw_idempotency_key
80
+ return uuid4().hex
81
+
82
+ def _require_prepared(self) -> None:
83
+ if self.api_key is None or self.base_url is None:
84
+ raise ValueError("LlmRequest is not prepared for transport")
85
+
86
+ def endpoint(self) -> str:
87
+ self._require_prepared()
88
+ base_url = self.base_url
89
+ if base_url is None:
90
+ raise ValueError("LlmRequest is not prepared for transport")
91
+ if self.chat_path is None:
92
+ return base_url
93
+ return base_url.rstrip("/") + self.chat_path
94
+
95
+ def headers(self) -> dict[str, str]:
96
+ self._require_prepared()
97
+ return {
98
+ "Authorization": f"Bearer {self.api_key}",
99
+ "Content-Type": "application/json",
100
+ "Idempotency-Key": self.idempotency_key or "",
101
+ }
102
+
103
+ def json_payload(self) -> dict[str, Any]:
104
+ self._require_prepared()
105
+ payload: dict[str, Any] = {
106
+ "model": self.model,
107
+ "messages": [
108
+ {"role": message.role, "content": message.content}
109
+ for message in self.messages
110
+ ],
111
+ }
112
+ if self.sampling_temperature is not None:
113
+ payload["temperature"] = self.sampling_temperature
114
+ if self.sampling_top_p is not None:
115
+ payload["top_p"] = self.sampling_top_p
116
+ if self.max_tokens is not None:
117
+ # TODO: will this break things?
118
+ payload["max_tokens"] = self.max_tokens
119
+ payload["max_completion_tokens"] = self.max_tokens
120
+
121
+ overlapping_keys = sorted(set(self.extra_body) & set(payload))
122
+ if overlapping_keys:
123
+ conflicts = ", ".join(overlapping_keys)
124
+ raise ValueError(
125
+ "extra_body conflicts with "
126
+ f"validated payload keys: {conflicts}"
127
+ )
128
+ payload.update(self.extra_body)
129
+ return payload
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from enum import IntEnum
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from dr_providers.query.errors import (
10
+ ProviderSemanticError,
11
+ ProviderTransportError,
12
+ )
13
+ from dr_providers.query.reasoning import ReasoningWarning # noqa: TC001
14
+
15
+ if TYPE_CHECKING:
16
+ import httpx
17
+
18
+ from dr_providers.query.request import LlmRequest
19
+
20
+
21
+ class HttpStatusCode(IntEnum):
22
+ TRANSIENT_ERROR = 500
23
+ TIMEOUT = 408
24
+ TOO_MANY_REQUESTS = 429
25
+ BAD_REQUEST = 400
26
+
27
+
28
+ def validate_http_response(
29
+ *,
30
+ provider_label: str,
31
+ status_code: int,
32
+ response_text_preview: str,
33
+ json_error: str | None,
34
+ response_shape_error: str | None,
35
+ ) -> None:
36
+ if status_code >= HttpStatusCode.TRANSIENT_ERROR or status_code in {
37
+ HttpStatusCode.TIMEOUT,
38
+ HttpStatusCode.TOO_MANY_REQUESTS,
39
+ }:
40
+ raise ProviderTransportError(
41
+ f"{provider_label} transient error status={status_code} "
42
+ f"body={response_text_preview}"
43
+ )
44
+ if status_code >= HttpStatusCode.BAD_REQUEST:
45
+ raise ProviderSemanticError(
46
+ f"{provider_label} rejected request status={status_code} "
47
+ f"body={response_text_preview}"
48
+ )
49
+ if json_error is not None:
50
+ raise ProviderTransportError(
51
+ f"{provider_label} invalid JSON response: {json_error}"
52
+ )
53
+ if response_shape_error is not None:
54
+ raise ProviderSemanticError(
55
+ f"{provider_label} response shape invalid: {response_shape_error}"
56
+ )
57
+
58
+
59
+ class CallError(BaseModel):
60
+ model_config = ConfigDict(frozen=True)
61
+
62
+ error_type: str
63
+ message: str
64
+ retryable: bool = False
65
+ raw_json: dict[str, Any] | None = None
66
+
67
+
68
+ class LlmResponse(BaseModel):
69
+ model_config = ConfigDict(frozen=True, extra="forbid")
70
+
71
+ raw_json: dict[str, Any] = Field(default_factory=dict)
72
+ provider: str
73
+ model: str
74
+ latency_ms: int
75
+ text: str
76
+ finish_reason: str | None = None
77
+ warnings: list[Any] = Field(default_factory=list)
78
+
79
+
80
+ def _parse_response_json(
81
+ response: httpx.Response,
82
+ ) -> tuple[dict[str, Any] | None, str | None, str | None]:
83
+ try:
84
+ body_raw = response.json()
85
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
86
+ return None, str(exc), None
87
+ if not isinstance(body_raw, dict):
88
+ return None, None, "expected JSON object"
89
+ return body_raw, None, None
90
+
91
+
92
+ def _extract_completion_fields(
93
+ body: dict[str, Any], *, provider_label: str
94
+ ) -> tuple[str, str | None]:
95
+ choices = body.get("choices")
96
+ if not isinstance(choices, list) or not choices:
97
+ raise ProviderSemanticError(
98
+ f"{provider_label} response missing choices"
99
+ )
100
+ choice = choices[0]
101
+ if not isinstance(choice, dict):
102
+ raise ProviderSemanticError(
103
+ f"{provider_label} response choice invalid"
104
+ )
105
+ finish_reason = choice.get("finish_reason")
106
+ finish_reason_str = (
107
+ finish_reason if isinstance(finish_reason, str) else None
108
+ )
109
+ message = choice.get("message")
110
+ if not isinstance(message, dict):
111
+ return "", finish_reason_str
112
+ content = message.get("content")
113
+ text = content if isinstance(content, str) else ""
114
+ return text, finish_reason_str
115
+
116
+
117
+ def llm_response_from_http(
118
+ response: httpx.Response,
119
+ request: LlmRequest,
120
+ *,
121
+ latency_ms: int,
122
+ warnings: list[ReasoningWarning],
123
+ ) -> LlmResponse:
124
+ provider_label = str(request.provider)
125
+ response_text_preview = response.text[:500]
126
+ body_raw, json_error, shape_error = _parse_response_json(response)
127
+ validate_http_response(
128
+ provider_label=provider_label,
129
+ status_code=response.status_code,
130
+ response_text_preview=response_text_preview,
131
+ json_error=json_error,
132
+ response_shape_error=shape_error,
133
+ )
134
+ if body_raw is None:
135
+ raise ProviderSemanticError(
136
+ f"{provider_label} response shape invalid: missing body"
137
+ )
138
+ text, finish_reason = _extract_completion_fields(
139
+ body_raw, provider_label=provider_label
140
+ )
141
+ return LlmResponse(
142
+ text=text,
143
+ finish_reason=finish_reason,
144
+ latency_ms=latency_ms,
145
+ raw_json=body_raw,
146
+ provider=provider_label,
147
+ model=request.model,
148
+ warnings=warnings,
149
+ )
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from abc import ABC, abstractmethod
5
+ from typing import TYPE_CHECKING, Self
6
+
7
+ import httpx
8
+ from tenacity import (
9
+ retry,
10
+ retry_if_exception_type,
11
+ stop_after_attempt,
12
+ wait_exponential_jitter,
13
+ )
14
+
15
+ from dr_providers.query.errors import ProviderTransportError
16
+ from dr_providers.query.reasoning import RequestControls
17
+ from dr_providers.query.response import llm_response_from_http
18
+
19
+ if TYPE_CHECKING:
20
+ from dr_providers.query.request import LlmRequest
21
+ from dr_providers.query.response import LlmResponse
22
+ from dr_providers.query.transport_config import (
23
+ ProviderAvailabilityStatus,
24
+ ProviderConfig,
25
+ )
26
+
27
+
28
+ class ProviderTransport(ABC):
29
+ _config: ProviderConfig
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ return self._config.name
34
+
35
+ @property
36
+ def config(self) -> ProviderConfig:
37
+ return self._config
38
+
39
+ def availability_status(self) -> ProviderAvailabilityStatus:
40
+ return self._config.availability_status()
41
+
42
+ def is_available(self) -> bool:
43
+ return self.availability_status().available
44
+
45
+ @abstractmethod
46
+ def generate(self, request: LlmRequest) -> LlmResponse:
47
+ raise NotImplementedError
48
+
49
+ def close(self) -> None:
50
+ return None
51
+
52
+
53
+ class ApiProvider(ProviderTransport):
54
+ _config: ProviderConfig
55
+
56
+ def __init__(
57
+ self,
58
+ *,
59
+ config: ProviderConfig,
60
+ client: httpx.Client | None = None,
61
+ ) -> None:
62
+ self._config = config
63
+ self._owns_client = client is None
64
+ self._client = client or httpx.Client(timeout=config.timeout_seconds)
65
+
66
+ @property
67
+ def config(self) -> ProviderConfig:
68
+ return self._config
69
+
70
+ def close(self) -> None:
71
+ if self._owns_client:
72
+ self._client.close()
73
+
74
+ def __enter__(self) -> Self:
75
+ return self
76
+
77
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
78
+ self.close()
79
+
80
+ @retry(
81
+ retry=retry_if_exception_type(
82
+ (httpx.TimeoutException, httpx.TransportError)
83
+ ),
84
+ wait=wait_exponential_jitter(initial=0.5, max=8),
85
+ stop=stop_after_attempt(3),
86
+ reraise=True,
87
+ )
88
+ def _post_with_retry(self, prepared: LlmRequest) -> httpx.Response:
89
+ return self._client.post(
90
+ prepared.endpoint(),
91
+ headers=prepared.headers(),
92
+ json=prepared.json_payload(),
93
+ )
94
+
95
+ def generate(self, request: LlmRequest) -> LlmResponse:
96
+ controls = RequestControls.from_reasoning(request.reasoning)
97
+ prepared = request.prepare(self._config, controls=controls)
98
+ started = time.perf_counter()
99
+ try:
100
+ response = self._post_with_retry(prepared)
101
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
102
+ raise ProviderTransportError(
103
+ f"{self.name} HTTP request failed: {exc}"
104
+ ) from exc
105
+ latency_ms = int((time.perf_counter() - started) * 1000)
106
+ return llm_response_from_http(
107
+ response,
108
+ request,
109
+ latency_ms=latency_ms,
110
+ warnings=prepared.warnings,
111
+ )
@@ -0,0 +1,94 @@
1
+ import os
2
+ import shutil
3
+ from typing import Self
4
+
5
+ from pydantic import (
6
+ BaseModel,
7
+ ConfigDict,
8
+ Field,
9
+ field_validator,
10
+ model_validator,
11
+ )
12
+
13
+ from dr_providers.query.errors import ProviderSemanticError
14
+
15
+
16
+ class ProviderAvailabilityStatus(BaseModel):
17
+ model_config = ConfigDict(frozen=True)
18
+
19
+ provider: str
20
+ available: bool
21
+ missing_env_vars: tuple[str, ...] = Field(default_factory=tuple)
22
+ missing_executables: tuple[str, ...] = Field(default_factory=tuple)
23
+ supports_structured_output: bool = False
24
+
25
+
26
+ class ProviderConfig(BaseModel):
27
+ model_config = ConfigDict(frozen=True)
28
+
29
+ name: str
30
+ supports_structured_output: bool = True
31
+ required_env_vars: list[str] = Field(default_factory=list)
32
+ required_executables: list[str] = Field(default_factory=list)
33
+ timeout_seconds: float = 120.0
34
+ base_url: str
35
+ api_key_env: str
36
+ api_key: str | None = None
37
+ chat_path: str | None = "/chat/completions"
38
+
39
+ @field_validator("chat_path")
40
+ @classmethod
41
+ def _ensure_leading_slash(cls, v: str | None) -> str | None:
42
+ if v is None:
43
+ return None
44
+ if not v.startswith("/"):
45
+ return f"/{v}"
46
+ return v
47
+
48
+ @model_validator(mode="after")
49
+ def _compute_api_env_requirements(self) -> Self:
50
+ if not self.api_key and self.api_key_env not in self.required_env_vars:
51
+ object.__setattr__(
52
+ self,
53
+ "required_env_vars",
54
+ [*self.required_env_vars, self.api_key_env],
55
+ )
56
+ return self
57
+
58
+ def missing_env_vars(self) -> tuple[str, ...]:
59
+ return tuple(
60
+ env_var
61
+ for env_var in self.required_env_vars
62
+ if not os.getenv(env_var)
63
+ )
64
+
65
+ def missing_executables(self) -> tuple[str, ...]:
66
+ return tuple(
67
+ executable
68
+ for executable in self.required_executables
69
+ if shutil.which(executable) is None
70
+ )
71
+
72
+ def availability_status(self) -> ProviderAvailabilityStatus:
73
+ missing_env = self.missing_env_vars()
74
+ missing_exec = self.missing_executables()
75
+ return ProviderAvailabilityStatus(
76
+ provider=self.name,
77
+ available=not missing_env and not missing_exec,
78
+ missing_env_vars=missing_env,
79
+ missing_executables=missing_exec,
80
+ supports_structured_output=self.supports_structured_output,
81
+ )
82
+
83
+
84
+ def resolve_api_key(
85
+ config: ProviderConfig, *, label: str | None = None
86
+ ) -> str:
87
+ key = config.api_key or os.getenv(config.api_key_env)
88
+ if not key:
89
+ provider_label = label or config.name
90
+ raise ProviderSemanticError(
91
+ f"Missing API key for {provider_label}. "
92
+ f"Set {config.api_key_env} or pass config.api_key"
93
+ )
94
+ return key