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.
Files changed (39) hide show
  1. jharness_providers-0.1.0/.gitignore +45 -0
  2. jharness_providers-0.1.0/LICENSE +21 -0
  3. jharness_providers-0.1.0/PKG-INFO +48 -0
  4. jharness_providers-0.1.0/README.md +21 -0
  5. jharness_providers-0.1.0/pyproject.toml +42 -0
  6. jharness_providers-0.1.0/src/jharness/providers/__init__.py +7 -0
  7. jharness_providers-0.1.0/src/jharness/providers/_http.py +371 -0
  8. jharness_providers-0.1.0/src/jharness/providers/_json.py +43 -0
  9. jharness_providers-0.1.0/src/jharness/providers/_profiles.py +42 -0
  10. jharness_providers-0.1.0/src/jharness/providers/_stream.py +136 -0
  11. jharness_providers-0.1.0/src/jharness/providers/anthropic/__init__.py +13 -0
  12. jharness_providers-0.1.0/src/jharness/providers/anthropic/errors.py +12 -0
  13. jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/__init__.py +3 -0
  14. jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/client.py +193 -0
  15. jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/codec.py +301 -0
  16. jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/messages.py +416 -0
  17. jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/stream.py +414 -0
  18. jharness_providers-0.1.0/src/jharness/providers/anthropic/messages_api/tools.py +99 -0
  19. jharness_providers-0.1.0/src/jharness/providers/anthropic/profiles.py +119 -0
  20. jharness_providers-0.1.0/src/jharness/providers/deepseek/__init__.py +11 -0
  21. jharness_providers-0.1.0/src/jharness/providers/deepseek/profiles.py +91 -0
  22. jharness_providers-0.1.0/src/jharness/providers/openai/__init__.py +13 -0
  23. jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/__init__.py +3 -0
  24. jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/client.py +155 -0
  25. jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/codec.py +273 -0
  26. jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/messages.py +212 -0
  27. jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/stream.py +286 -0
  28. jharness_providers-0.1.0/src/jharness/providers/openai/chat_completions/tools.py +123 -0
  29. jharness_providers-0.1.0/src/jharness/providers/openai/errors.py +12 -0
  30. jharness_providers-0.1.0/src/jharness/providers/openai/profiles.py +94 -0
  31. jharness_providers-0.1.0/src/jharness/providers/py.typed +1 -0
  32. jharness_providers-0.1.0/tests/test_anthropic_provider.py +367 -0
  33. jharness_providers-0.1.0/tests/test_http_errors.py +186 -0
  34. jharness_providers-0.1.0/tests/test_message_codecs.py +267 -0
  35. jharness_providers-0.1.0/tests/test_openai_provider.py +277 -0
  36. jharness_providers-0.1.0/tests/test_profiles.py +134 -0
  37. jharness_providers-0.1.0/tests/test_provider_codec_edges.py +427 -0
  38. jharness_providers-0.1.0/tests/test_provider_stream_edges.py +414 -0
  39. 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,7 @@
1
+ """Concrete model provider adapters.
2
+
3
+ Import provider-specific adapters from their provider namespace, for example
4
+ `jharness.providers.openai`.
5
+ """
6
+
7
+ __all__: list[str] = []
@@ -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