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.
- dr_providers-0.1.0/.gitignore +8 -0
- dr_providers-0.1.0/CHANGELOG.md +15 -0
- dr_providers-0.1.0/LICENSE +21 -0
- dr_providers-0.1.0/PKG-INFO +121 -0
- dr_providers-0.1.0/README.md +96 -0
- dr_providers-0.1.0/pyproject.toml +122 -0
- dr_providers-0.1.0/src/dr_providers/__init__.py +34 -0
- dr_providers-0.1.0/src/dr_providers/cli.py +138 -0
- dr_providers-0.1.0/src/dr_providers/config.py +31 -0
- dr_providers-0.1.0/src/dr_providers/names.py +48 -0
- dr_providers-0.1.0/src/dr_providers/py.typed +0 -0
- dr_providers-0.1.0/src/dr_providers/query/__init__.py +29 -0
- dr_providers-0.1.0/src/dr_providers/query/errors.py +10 -0
- dr_providers-0.1.0/src/dr_providers/query/from_prompt.py +39 -0
- dr_providers-0.1.0/src/dr_providers/query/providers/__init__.py +3 -0
- dr_providers-0.1.0/src/dr_providers/query/providers/openrouter.py +26 -0
- dr_providers-0.1.0/src/dr_providers/query/reasoning.py +40 -0
- dr_providers-0.1.0/src/dr_providers/query/request.py +129 -0
- dr_providers-0.1.0/src/dr_providers/query/response.py +149 -0
- dr_providers-0.1.0/src/dr_providers/query/transport.py +111 -0
- dr_providers-0.1.0/src/dr_providers/query/transport_config.py +94 -0
|
@@ -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,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,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
|