jharness-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.
- jharness_providers-0.1.0/.gitignore +45 -0
- jharness_providers-0.1.0/LICENSE +21 -0
- jharness_providers-0.1.0/PKG-INFO +48 -0
- jharness_providers-0.1.0/README.md +21 -0
- jharness_providers-0.1.0/pyproject.toml +42 -0
- jharness_providers-0.1.0/src/jharness/providers/__init__.py +7 -0
- jharness_providers-0.1.0/src/jharness/providers/_http.py +371 -0
- jharness_providers-0.1.0/src/jharness/providers/_json.py +43 -0
- jharness_providers-0.1.0/src/jharness/providers/_profiles.py +42 -0
- jharness_providers-0.1.0/src/jharness/providers/_stream.py +136 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/__init__.py +13 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/errors.py +12 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/__init__.py +3 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/client.py +193 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/codec.py +301 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/messages.py +416 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/stream.py +414 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/tools.py +99 -0
- jharness_providers-0.1.0/src/jharness/providers/anthropic/profiles.py +119 -0
- jharness_providers-0.1.0/src/jharness/providers/deepseek/__init__.py +11 -0
- jharness_providers-0.1.0/src/jharness/providers/deepseek/profiles.py +91 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/__init__.py +13 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/__init__.py +3 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/client.py +155 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/codec.py +273 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/messages.py +212 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/stream.py +286 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/tools.py +123 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/errors.py +12 -0
- jharness_providers-0.1.0/src/jharness/providers/openai/profiles.py +94 -0
- jharness_providers-0.1.0/src/jharness/providers/py.typed +1 -0
- jharness_providers-0.1.0/tests/test_anthropic_provider.py +367 -0
- jharness_providers-0.1.0/tests/test_http_errors.py +186 -0
- jharness_providers-0.1.0/tests/test_message_codecs.py +267 -0
- jharness_providers-0.1.0/tests/test_openai_provider.py +277 -0
- jharness_providers-0.1.0/tests/test_profiles.py +134 -0
- jharness_providers-0.1.0/tests/test_provider_codec_edges.py +427 -0
- jharness_providers-0.1.0/tests/test_provider_stream_edges.py +414 -0
- jharness_providers-0.1.0/tests/test_stream_accumulator.py +34 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# OS/editor noise
|
|
2
|
+
.DS_Store
|
|
3
|
+
Thumbs.db
|
|
4
|
+
*.swp
|
|
5
|
+
*.swo
|
|
6
|
+
|
|
7
|
+
# Local AI assistant/workspace state
|
|
8
|
+
.codex/
|
|
9
|
+
.claude/
|
|
10
|
+
.gemini/
|
|
11
|
+
.aider*
|
|
12
|
+
|
|
13
|
+
# Pinned specification snapshot generated by scripts/sync_spec.py
|
|
14
|
+
.jharness-spec/
|
|
15
|
+
|
|
16
|
+
# Python
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.py[cod]
|
|
19
|
+
.python-version
|
|
20
|
+
.venv/
|
|
21
|
+
venv/
|
|
22
|
+
ENV/
|
|
23
|
+
|
|
24
|
+
# Python tooling caches
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.ruff_cache/
|
|
27
|
+
.pyright/
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
htmlcov/
|
|
31
|
+
|
|
32
|
+
# Packaging/build artifacts
|
|
33
|
+
dist/
|
|
34
|
+
build/
|
|
35
|
+
*.egg-info/
|
|
36
|
+
|
|
37
|
+
# Node/TypeScript SDK artifacts
|
|
38
|
+
node_modules/
|
|
39
|
+
coverage/
|
|
40
|
+
.turbo/
|
|
41
|
+
.next/
|
|
42
|
+
*.tsbuildinfo
|
|
43
|
+
|
|
44
|
+
# Logs
|
|
45
|
+
*.log
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
+MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JHarness contributors
|
|
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,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jharness-providers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Concrete model provider adapters for the JHarness runtime
|
|
5
|
+
Project-URL: Documentation, https://github.com/Ezio2000/jharness-python/tree/main/docs
|
|
6
|
+
Project-URL: Homepage, https://github.com/Ezio2000/jharness-python
|
|
7
|
+
Project-URL: Issues, https://github.com/Ezio2000/jharness-python/issues
|
|
8
|
+
Project-URL: Repository, https://github.com/Ezio2000/jharness-python.git
|
|
9
|
+
Author: JHarness contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,anthropic,deepseek,model,openai
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: httpx>=0.27.0
|
|
25
|
+
Requires-Dist: jharness-kernel<0.2.0,>=0.1.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# jharness-providers
|
|
29
|
+
|
|
30
|
+
`jharness-providers` contains optional concrete implementations of the
|
|
31
|
+
provider-neutral `jharness.kernel.Model` protocol. Public adapters are imported from
|
|
32
|
+
provider namespaces:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv add jharness-providers
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from jharness.providers.anthropic import AnthropicModel
|
|
40
|
+
from jharness.providers.deepseek import deepseek_anthropic_profile
|
|
41
|
+
from jharness.providers.openai import OpenAIChatCompletionsModel
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Provider transports, profiles, and codecs do not define runtime semantics.
|
|
45
|
+
See the
|
|
46
|
+
[provider documentation](https://github.com/Ezio2000/jharness-python/blob/main/docs/model-providers.md)
|
|
47
|
+
and
|
|
48
|
+
[Python package boundaries](https://github.com/Ezio2000/jharness-python/blob/main/docs/python-package-boundaries.md).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# jharness-providers
|
|
2
|
+
|
|
3
|
+
`jharness-providers` contains optional concrete implementations of the
|
|
4
|
+
provider-neutral `jharness.kernel.Model` protocol. Public adapters are imported from
|
|
5
|
+
provider namespaces:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add jharness-providers
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from jharness.providers.anthropic import AnthropicModel
|
|
13
|
+
from jharness.providers.deepseek import deepseek_anthropic_profile
|
|
14
|
+
from jharness.providers.openai import OpenAIChatCompletionsModel
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Provider transports, profiles, and codecs do not define runtime semantics.
|
|
18
|
+
See the
|
|
19
|
+
[provider documentation](https://github.com/Ezio2000/jharness-python/blob/main/docs/model-providers.md)
|
|
20
|
+
and
|
|
21
|
+
[Python package boundaries](https://github.com/Ezio2000/jharness-python/blob/main/docs/python-package-boundaries.md).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jharness-providers"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Concrete model provider adapters for the JHarness runtime"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "JHarness contributors" }]
|
|
10
|
+
keywords = ["ai", "anthropic", "deepseek", "model", "openai"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.27.0",
|
|
25
|
+
"jharness-kernel>=0.1.0,<0.2.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Documentation = "https://github.com/Ezio2000/jharness-python/tree/main/docs"
|
|
30
|
+
Homepage = "https://github.com/Ezio2000/jharness-python"
|
|
31
|
+
Issues = "https://github.com/Ezio2000/jharness-python/issues"
|
|
32
|
+
Repository = "https://github.com/Ezio2000/jharness-python.git"
|
|
33
|
+
|
|
34
|
+
[tool.uv.sources]
|
|
35
|
+
jharness-kernel = { workspace = true }
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/jharness"]
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Shared HTTP transport primitives for provider adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, Sequence
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Generic, TypeVar, cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from jharness.kernel import DeltaSink, ModelDelta, ModelError, ModelErrorInfo, ModelResponse
|
|
14
|
+
|
|
15
|
+
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429, 500, 502, 503, 504})
|
|
16
|
+
_CLIENT_OPTION_NAMES = frozenset({"client", "headers", "profile", "timeout"})
|
|
17
|
+
|
|
18
|
+
ProfileT = TypeVar("ProfileT")
|
|
19
|
+
|
|
20
|
+
PayloadFactory = Callable[[], Mapping[str, object]]
|
|
21
|
+
HeadersFactory = Callable[[Mapping[str, object]], Mapping[str, str]]
|
|
22
|
+
ResponseDecoder = Callable[[Mapping[str, object]], ModelResponse]
|
|
23
|
+
FrameDecoder = Callable[[str | None, str], tuple[bool, Sequence[ModelDelta]]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ProviderClientConfig(Generic[ProfileT]):
|
|
28
|
+
"""Validated transport configuration shared by concrete provider clients."""
|
|
29
|
+
|
|
30
|
+
base_url: str
|
|
31
|
+
api_key: str
|
|
32
|
+
model: str
|
|
33
|
+
profile: ProfileT
|
|
34
|
+
timeout: float | httpx.Timeout | None
|
|
35
|
+
headers: Mapping[str, str]
|
|
36
|
+
client: httpx.AsyncClient | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def provider_client_config(
|
|
40
|
+
*,
|
|
41
|
+
base_url: str,
|
|
42
|
+
api_key: str,
|
|
43
|
+
model: str,
|
|
44
|
+
options: Mapping[str, Any],
|
|
45
|
+
default_profile: ProfileT,
|
|
46
|
+
constructor_name: str,
|
|
47
|
+
) -> ProviderClientConfig[ProfileT]:
|
|
48
|
+
"""Normalize the common constructor surface for one provider client."""
|
|
49
|
+
|
|
50
|
+
unexpected = set(options).difference(_CLIENT_OPTION_NAMES)
|
|
51
|
+
if unexpected:
|
|
52
|
+
option = min(unexpected)
|
|
53
|
+
raise TypeError(f"{constructor_name}() got an unexpected keyword argument {option!r}")
|
|
54
|
+
if not base_url:
|
|
55
|
+
raise ValueError("base_url must not be empty")
|
|
56
|
+
if not api_key:
|
|
57
|
+
raise ValueError("api_key must not be empty")
|
|
58
|
+
if not model:
|
|
59
|
+
raise ValueError("model must not be empty")
|
|
60
|
+
profile = cast(ProfileT | None, options.get("profile")) or default_profile
|
|
61
|
+
return ProviderClientConfig(
|
|
62
|
+
base_url.rstrip("/"),
|
|
63
|
+
api_key,
|
|
64
|
+
model,
|
|
65
|
+
profile,
|
|
66
|
+
cast(float | httpx.Timeout | None, options.get("timeout")),
|
|
67
|
+
dict(cast(Mapping[str, str] | None, options.get("headers")) or {}),
|
|
68
|
+
cast(httpx.AsyncClient | None, options.get("client")),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class ProviderErrorPolicy:
|
|
74
|
+
"""Provider-specific details needed by the shared transport error boundary."""
|
|
75
|
+
|
|
76
|
+
provider: str
|
|
77
|
+
codec_error: type[ValueError]
|
|
78
|
+
request_id_headers: tuple[str, ...]
|
|
79
|
+
error_code_keys: tuple[str, ...]
|
|
80
|
+
additional_retryable_status_codes: frozenset[int] = frozenset()
|
|
81
|
+
body_request_id_key: str | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class ServerSentEvent:
|
|
86
|
+
"""One decoded SSE frame."""
|
|
87
|
+
|
|
88
|
+
event: str | None
|
|
89
|
+
data: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def invoke_json_model(
|
|
93
|
+
*,
|
|
94
|
+
client: httpx.AsyncClient | None,
|
|
95
|
+
timeout: float | httpx.Timeout | None,
|
|
96
|
+
url: str,
|
|
97
|
+
payload: PayloadFactory,
|
|
98
|
+
headers: HeadersFactory,
|
|
99
|
+
decode: ResponseDecoder,
|
|
100
|
+
errors: ProviderErrorPolicy,
|
|
101
|
+
response_shape_error: str,
|
|
102
|
+
) -> ModelResponse:
|
|
103
|
+
"""Execute one JSON model request through the shared provider error boundary."""
|
|
104
|
+
|
|
105
|
+
response: httpx.Response | None = None
|
|
106
|
+
try:
|
|
107
|
+
body = payload()
|
|
108
|
+
async with managed_async_client(client, timeout) as http:
|
|
109
|
+
response = await http.post(url, headers=headers(body), json=body)
|
|
110
|
+
await ensure_success_response(response, errors)
|
|
111
|
+
value: object = response.json()
|
|
112
|
+
if not isinstance(value, Mapping):
|
|
113
|
+
raise errors.codec_error(response_shape_error)
|
|
114
|
+
decoded = cast(Mapping[str, object], value)
|
|
115
|
+
if "error" in decoded:
|
|
116
|
+
raise ModelError(
|
|
117
|
+
_body_error_info(
|
|
118
|
+
decoded,
|
|
119
|
+
errors,
|
|
120
|
+
status_code=response.status_code,
|
|
121
|
+
response_text="provider response error",
|
|
122
|
+
request_id=response_request_id(response, errors.request_id_headers),
|
|
123
|
+
metadata=response_error_metadata(response),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return decode(decoded)
|
|
127
|
+
except (ModelError, httpx.HTTPError, ValueError) as exc:
|
|
128
|
+
raise _model_error(exc, response, errors) from exc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def invoke_sse_model(
|
|
132
|
+
*,
|
|
133
|
+
client: httpx.AsyncClient | None,
|
|
134
|
+
timeout: float | httpx.Timeout | None,
|
|
135
|
+
url: str,
|
|
136
|
+
payload: PayloadFactory,
|
|
137
|
+
headers: HeadersFactory,
|
|
138
|
+
decode_frame: FrameDecoder,
|
|
139
|
+
completed_response: Callable[[], ModelResponse],
|
|
140
|
+
emit_delta: DeltaSink | None,
|
|
141
|
+
errors: ProviderErrorPolicy,
|
|
142
|
+
incomplete_error: str,
|
|
143
|
+
) -> ModelResponse:
|
|
144
|
+
"""Execute one SSE model request and return its provider-assembled response."""
|
|
145
|
+
|
|
146
|
+
response: httpx.Response | None = None
|
|
147
|
+
try:
|
|
148
|
+
body = payload()
|
|
149
|
+
saw_done = False
|
|
150
|
+
async with (
|
|
151
|
+
managed_async_client(client, timeout) as http,
|
|
152
|
+
http.stream("POST", url, headers=headers(body), json=body) as response,
|
|
153
|
+
):
|
|
154
|
+
await ensure_success_response(response, errors)
|
|
155
|
+
async for frame in iter_server_sent_events(response):
|
|
156
|
+
done, deltas = decode_frame(frame.event, frame.data)
|
|
157
|
+
if emit_delta is not None:
|
|
158
|
+
for delta in deltas:
|
|
159
|
+
await emit_delta(delta)
|
|
160
|
+
if done:
|
|
161
|
+
saw_done = True
|
|
162
|
+
break
|
|
163
|
+
if not saw_done:
|
|
164
|
+
raise errors.codec_error(incomplete_error)
|
|
165
|
+
return completed_response()
|
|
166
|
+
except (ModelError, httpx.HTTPError, ValueError) as exc:
|
|
167
|
+
raise _model_error(exc, response, errors) from exc
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@asynccontextmanager
|
|
171
|
+
async def managed_async_client(
|
|
172
|
+
client: httpx.AsyncClient | None,
|
|
173
|
+
timeout: float | httpx.Timeout | None,
|
|
174
|
+
) -> AsyncGenerator[httpx.AsyncClient]:
|
|
175
|
+
"""Reuse an injected client or own a short-lived provider client."""
|
|
176
|
+
|
|
177
|
+
if client is not None:
|
|
178
|
+
yield client
|
|
179
|
+
return
|
|
180
|
+
async with httpx.AsyncClient(timeout=timeout) as owned_client:
|
|
181
|
+
yield owned_client
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def ensure_success_response(
|
|
185
|
+
response: httpx.Response,
|
|
186
|
+
policy: ProviderErrorPolicy,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Accept only 2xx responses and preserve the complete HTTP error envelope."""
|
|
189
|
+
|
|
190
|
+
if 200 <= response.status_code < 300:
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
_ = response.text
|
|
194
|
+
except httpx.ResponseNotRead:
|
|
195
|
+
await response.aread()
|
|
196
|
+
try:
|
|
197
|
+
body = response.json()
|
|
198
|
+
except ValueError:
|
|
199
|
+
body = None
|
|
200
|
+
raise ModelError(
|
|
201
|
+
_body_error_info(
|
|
202
|
+
body,
|
|
203
|
+
policy,
|
|
204
|
+
status_code=response.status_code,
|
|
205
|
+
response_text=response.text or response.reason_phrase or "provider error",
|
|
206
|
+
request_id=response_request_id(response, policy.request_id_headers),
|
|
207
|
+
metadata=response_error_metadata(response),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def iter_server_sent_events(response: httpx.Response) -> AsyncIterator[ServerSentEvent]:
|
|
213
|
+
"""Parse an HTTP response body into SSE frames according to field boundaries."""
|
|
214
|
+
|
|
215
|
+
event_name: str | None = None
|
|
216
|
+
data_lines: list[str] = []
|
|
217
|
+
async for line in response.aiter_lines():
|
|
218
|
+
if line == "":
|
|
219
|
+
if data_lines:
|
|
220
|
+
yield ServerSentEvent(event=event_name, data="\n".join(data_lines))
|
|
221
|
+
event_name = None
|
|
222
|
+
data_lines = []
|
|
223
|
+
continue
|
|
224
|
+
if line.startswith(":"):
|
|
225
|
+
continue
|
|
226
|
+
field, separator, raw_value = line.partition(":")
|
|
227
|
+
if not separator:
|
|
228
|
+
raw_value = ""
|
|
229
|
+
value = raw_value[1:] if raw_value.startswith(" ") else raw_value
|
|
230
|
+
if field == "event":
|
|
231
|
+
event_name = value
|
|
232
|
+
elif field == "data":
|
|
233
|
+
data_lines.append(value)
|
|
234
|
+
if data_lines:
|
|
235
|
+
yield ServerSentEvent(event=event_name, data="\n".join(data_lines))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def response_error_metadata(response: httpx.Response) -> dict[str, str]:
|
|
239
|
+
"""Return portable HTTP context that is useful beyond provider error bodies."""
|
|
240
|
+
|
|
241
|
+
location = response.headers.get("location")
|
|
242
|
+
return {} if not location else {"location": location}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def response_request_id(
|
|
246
|
+
response: httpx.Response,
|
|
247
|
+
header_names: Sequence[str],
|
|
248
|
+
) -> str | None:
|
|
249
|
+
"""Return the first non-empty request identifier from provider header names."""
|
|
250
|
+
|
|
251
|
+
for header_name in header_names:
|
|
252
|
+
request_id = response.headers.get(header_name)
|
|
253
|
+
if request_id:
|
|
254
|
+
return request_id
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def stream_body_error(
|
|
259
|
+
body: Mapping[str, object],
|
|
260
|
+
policy: ProviderErrorPolicy,
|
|
261
|
+
) -> ModelError:
|
|
262
|
+
"""Build the standard model error for an error carried by an SSE payload."""
|
|
263
|
+
|
|
264
|
+
return ModelError(
|
|
265
|
+
_body_error_info(
|
|
266
|
+
body,
|
|
267
|
+
policy,
|
|
268
|
+
status_code=None,
|
|
269
|
+
response_text="provider stream error",
|
|
270
|
+
request_id=None,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def decode_json_object(
|
|
276
|
+
data: str,
|
|
277
|
+
error: type[ValueError],
|
|
278
|
+
error_message: str,
|
|
279
|
+
) -> Mapping[str, object]:
|
|
280
|
+
parsed: object = json.loads(data)
|
|
281
|
+
if not isinstance(parsed, Mapping):
|
|
282
|
+
raise error(error_message)
|
|
283
|
+
return cast(Mapping[str, object], parsed)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _model_error(
|
|
287
|
+
exc: Exception,
|
|
288
|
+
response: httpx.Response | None,
|
|
289
|
+
policy: ProviderErrorPolicy,
|
|
290
|
+
) -> ModelError:
|
|
291
|
+
if isinstance(exc, ModelError):
|
|
292
|
+
info = exc.info
|
|
293
|
+
else:
|
|
294
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
295
|
+
code, retryable = "timeout", True
|
|
296
|
+
elif isinstance(exc, httpx.NetworkError | httpx.RemoteProtocolError | httpx.ProxyError):
|
|
297
|
+
code, retryable = exc.__class__.__name__, True
|
|
298
|
+
elif isinstance(exc, httpx.HTTPError):
|
|
299
|
+
code, retryable = exc.__class__.__name__, False
|
|
300
|
+
elif isinstance(exc, policy.codec_error):
|
|
301
|
+
code, retryable = "codec_error", False
|
|
302
|
+
elif isinstance(exc, json.JSONDecodeError):
|
|
303
|
+
code, retryable = "invalid_json", False
|
|
304
|
+
else:
|
|
305
|
+
code, retryable = exc.__class__.__name__, False
|
|
306
|
+
info = ModelErrorInfo(
|
|
307
|
+
message=str(exc) or exc.__class__.__name__,
|
|
308
|
+
provider=policy.provider,
|
|
309
|
+
code=code,
|
|
310
|
+
retryable=retryable,
|
|
311
|
+
)
|
|
312
|
+
if response is not None:
|
|
313
|
+
metadata = dict(info.metadata)
|
|
314
|
+
metadata.update(response_error_metadata(response))
|
|
315
|
+
info = ModelErrorInfo(
|
|
316
|
+
message=info.message,
|
|
317
|
+
provider=info.provider,
|
|
318
|
+
code=info.code,
|
|
319
|
+
status_code=response.status_code,
|
|
320
|
+
retryable=info.retryable,
|
|
321
|
+
request_id=(
|
|
322
|
+
response_request_id(response, policy.request_id_headers) or info.request_id
|
|
323
|
+
),
|
|
324
|
+
metadata=metadata,
|
|
325
|
+
)
|
|
326
|
+
return ModelError(info)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _body_error_info(
|
|
330
|
+
error_body: object,
|
|
331
|
+
policy: ProviderErrorPolicy,
|
|
332
|
+
*,
|
|
333
|
+
status_code: int | None,
|
|
334
|
+
response_text: str,
|
|
335
|
+
request_id: str | None,
|
|
336
|
+
metadata: Mapping[str, object] | None = None,
|
|
337
|
+
) -> ModelErrorInfo:
|
|
338
|
+
message = response_text
|
|
339
|
+
code: str | None = None
|
|
340
|
+
body_request_id: str | None = None
|
|
341
|
+
if isinstance(error_body, Mapping):
|
|
342
|
+
error_mapping = cast(Mapping[str, object], error_body)
|
|
343
|
+
if policy.body_request_id_key is not None:
|
|
344
|
+
raw_request_id = error_mapping.get(policy.body_request_id_key)
|
|
345
|
+
if isinstance(raw_request_id, str) and raw_request_id:
|
|
346
|
+
body_request_id = raw_request_id
|
|
347
|
+
error_value = error_mapping.get("error")
|
|
348
|
+
if isinstance(error_value, Mapping):
|
|
349
|
+
nested = cast(Mapping[str, object], error_value)
|
|
350
|
+
raw_message = nested.get("message")
|
|
351
|
+
if isinstance(raw_message, str) and raw_message:
|
|
352
|
+
message = raw_message
|
|
353
|
+
for key in policy.error_code_keys:
|
|
354
|
+
item = nested.get(key)
|
|
355
|
+
if isinstance(item, str) and item:
|
|
356
|
+
code = item
|
|
357
|
+
break
|
|
358
|
+
elif isinstance(error_value, str) and error_value:
|
|
359
|
+
message = error_value
|
|
360
|
+
return ModelErrorInfo(
|
|
361
|
+
message=message,
|
|
362
|
+
provider=policy.provider,
|
|
363
|
+
code=code or "provider_error",
|
|
364
|
+
status_code=status_code,
|
|
365
|
+
retryable=(
|
|
366
|
+
status_code in _RETRYABLE_STATUS_CODES
|
|
367
|
+
or status_code in policy.additional_retryable_status_codes
|
|
368
|
+
),
|
|
369
|
+
request_id=request_id or body_request_id,
|
|
370
|
+
metadata={} if metadata is None else metadata,
|
|
371
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Small scalar guards shared by provider wire codecs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, NoReturn, cast
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class JsonValues:
|
|
12
|
+
"""Validate common JSON scalar shapes with a provider-local error type."""
|
|
13
|
+
|
|
14
|
+
error_type: type[ValueError]
|
|
15
|
+
|
|
16
|
+
def mapping(self, value: object, label: str) -> Mapping[str, Any]:
|
|
17
|
+
if not isinstance(value, Mapping):
|
|
18
|
+
self._raise(f"{label} must be an object")
|
|
19
|
+
return cast(Mapping[str, Any], value)
|
|
20
|
+
|
|
21
|
+
def required_string(self, value: object, label: str) -> str:
|
|
22
|
+
if not isinstance(value, str):
|
|
23
|
+
self._raise(f"{label} must be a string")
|
|
24
|
+
if not value:
|
|
25
|
+
self._raise(f"{label} must not be empty")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
def optional_string(self, value: object) -> str | None:
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
if not isinstance(value, str):
|
|
32
|
+
self._raise("expected string or null")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
def optional_integer(self, value: object) -> int | None:
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
39
|
+
self._raise("expected integer or null")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
def _raise(self, message: str) -> NoReturn:
|
|
43
|
+
raise self.error_type(message)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Small validation vocabulary shared by provider profile values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def required_string(value: object, label: str) -> str:
|
|
11
|
+
if not isinstance(value, str) or not value:
|
|
12
|
+
raise ValueError(f"{label} must be a non-empty string")
|
|
13
|
+
return value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def copy_json_mapping(value: object, label: str) -> dict[str, Any]:
|
|
17
|
+
if not isinstance(value, Mapping):
|
|
18
|
+
raise TypeError(f"{label} must be a mapping")
|
|
19
|
+
result: dict[str, Any] = {}
|
|
20
|
+
for key, item in cast(Mapping[object, object], value).items():
|
|
21
|
+
if not isinstance(key, str) or not key:
|
|
22
|
+
raise ValueError(f"{label} keys must be non-empty strings")
|
|
23
|
+
result[key] = deepcopy(item)
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def copy_string_mapping(
|
|
28
|
+
value: object,
|
|
29
|
+
label: str,
|
|
30
|
+
*,
|
|
31
|
+
entry_description: str = "non-empty strings",
|
|
32
|
+
) -> dict[str, str]:
|
|
33
|
+
if not isinstance(value, Mapping):
|
|
34
|
+
raise TypeError(f"{label} must be a mapping")
|
|
35
|
+
result: dict[str, str] = {}
|
|
36
|
+
for key, item in cast(Mapping[object, object], value).items():
|
|
37
|
+
if not isinstance(key, str) or not key:
|
|
38
|
+
raise ValueError(f"{label} keys must be {entry_description}")
|
|
39
|
+
if not isinstance(item, str) or not item:
|
|
40
|
+
raise ValueError(f"{label} values must be {entry_description}")
|
|
41
|
+
result[key] = item
|
|
42
|
+
return result
|