jharness-models 0.2.1__py3-none-any.whl
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/models/__init__.py +7 -0
- jharness/models/_http.py +570 -0
- jharness/models/_json.py +43 -0
- jharness/models/_profiles.py +42 -0
- jharness/models/_stream.py +136 -0
- jharness/models/anthropic/__init__.py +13 -0
- jharness/models/anthropic/errors.py +12 -0
- jharness/models/anthropic/messages_api/__init__.py +3 -0
- jharness/models/anthropic/messages_api/client.py +203 -0
- jharness/models/anthropic/messages_api/codec.py +301 -0
- jharness/models/anthropic/messages_api/messages.py +416 -0
- jharness/models/anthropic/messages_api/stream.py +414 -0
- jharness/models/anthropic/messages_api/tools.py +99 -0
- jharness/models/anthropic/profiles.py +119 -0
- jharness/models/deepseek/__init__.py +11 -0
- jharness/models/deepseek/profiles.py +91 -0
- jharness/models/openai/__init__.py +13 -0
- jharness/models/openai/chat_completions/__init__.py +3 -0
- jharness/models/openai/chat_completions/client.py +163 -0
- jharness/models/openai/chat_completions/codec.py +273 -0
- jharness/models/openai/chat_completions/messages.py +212 -0
- jharness/models/openai/chat_completions/stream.py +286 -0
- jharness/models/openai/chat_completions/tools.py +123 -0
- jharness/models/openai/errors.py +12 -0
- jharness/models/openai/profiles.py +94 -0
- jharness/models/py.typed +1 -0
- jharness_models-0.2.1.dist-info/METADATA +36 -0
- jharness_models-0.2.1.dist-info/RECORD +30 -0
- jharness_models-0.2.1.dist-info/WHEEL +4 -0
- jharness_models-0.2.1.dist-info/licenses/LICENSE +21 -0
jharness/models/_http.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
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 aclosing, asynccontextmanager
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from time import time
|
|
10
|
+
from typing import Any, Generic, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from jharness.kernel import (
|
|
15
|
+
DeltaSink,
|
|
16
|
+
ModelDelta,
|
|
17
|
+
ModelError,
|
|
18
|
+
ModelErrorInfo,
|
|
19
|
+
ModelResponse,
|
|
20
|
+
RunContext,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429, 500, 502, 503, 504})
|
|
24
|
+
_CLIENT_OPTION_NAMES = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"client",
|
|
27
|
+
"headers",
|
|
28
|
+
"max_sse_event_bytes",
|
|
29
|
+
"max_sse_line_bytes",
|
|
30
|
+
"profile",
|
|
31
|
+
"timeout",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
_DEFAULT_HTTP_TIMEOUT_SECONDS = 60.0
|
|
35
|
+
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0
|
|
36
|
+
_DEFAULT_MAX_SSE_LINE_BYTES = 256 * 1024
|
|
37
|
+
_DEFAULT_MAX_SSE_EVENT_BYTES = 1024 * 1024
|
|
38
|
+
|
|
39
|
+
ProfileT = TypeVar("ProfileT")
|
|
40
|
+
|
|
41
|
+
PayloadFactory = Callable[[], Mapping[str, object]]
|
|
42
|
+
HeadersFactory = Callable[[Mapping[str, object]], Mapping[str, str]]
|
|
43
|
+
ResponseDecoder = Callable[[Mapping[str, object]], ModelResponse]
|
|
44
|
+
FrameDecoder = Callable[[str | None, str], tuple[bool, Sequence[ModelDelta]]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class ModelClientConfig(Generic[ProfileT]):
|
|
49
|
+
"""Validated transport configuration shared by concrete provider clients."""
|
|
50
|
+
|
|
51
|
+
base_url: str
|
|
52
|
+
api_key: str = field(repr=False)
|
|
53
|
+
model: str
|
|
54
|
+
profile: ProfileT
|
|
55
|
+
timeout: float | httpx.Timeout | None
|
|
56
|
+
max_sse_line_bytes: int
|
|
57
|
+
max_sse_event_bytes: int
|
|
58
|
+
headers: Mapping[str, str]
|
|
59
|
+
client: httpx.AsyncClient | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def model_client_config(
|
|
63
|
+
*,
|
|
64
|
+
base_url: str,
|
|
65
|
+
api_key: str,
|
|
66
|
+
model: str,
|
|
67
|
+
options: Mapping[str, Any],
|
|
68
|
+
default_profile: ProfileT,
|
|
69
|
+
constructor_name: str,
|
|
70
|
+
) -> ModelClientConfig[ProfileT]:
|
|
71
|
+
"""Normalize the common constructor surface for one provider client."""
|
|
72
|
+
|
|
73
|
+
unexpected = set(options).difference(_CLIENT_OPTION_NAMES)
|
|
74
|
+
if unexpected:
|
|
75
|
+
option = min(unexpected)
|
|
76
|
+
raise TypeError(f"{constructor_name}() got an unexpected keyword argument {option!r}")
|
|
77
|
+
if not base_url:
|
|
78
|
+
raise ValueError("base_url must not be empty")
|
|
79
|
+
if not api_key:
|
|
80
|
+
raise ValueError("api_key must not be empty")
|
|
81
|
+
if not model:
|
|
82
|
+
raise ValueError("model must not be empty")
|
|
83
|
+
profile = cast(ProfileT | None, options.get("profile")) or default_profile
|
|
84
|
+
timeout = cast(
|
|
85
|
+
float | httpx.Timeout | None,
|
|
86
|
+
(
|
|
87
|
+
options["timeout"]
|
|
88
|
+
if "timeout" in options
|
|
89
|
+
else httpx.Timeout(
|
|
90
|
+
_DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
91
|
+
connect=_DEFAULT_CONNECT_TIMEOUT_SECONDS,
|
|
92
|
+
)
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
max_sse_line_bytes = _positive_int_option(
|
|
96
|
+
options.get("max_sse_line_bytes", _DEFAULT_MAX_SSE_LINE_BYTES),
|
|
97
|
+
"max_sse_line_bytes",
|
|
98
|
+
)
|
|
99
|
+
max_sse_event_bytes = _positive_int_option(
|
|
100
|
+
options.get("max_sse_event_bytes", _DEFAULT_MAX_SSE_EVENT_BYTES),
|
|
101
|
+
"max_sse_event_bytes",
|
|
102
|
+
)
|
|
103
|
+
if max_sse_event_bytes < max_sse_line_bytes:
|
|
104
|
+
raise ValueError("max_sse_event_bytes must be >= max_sse_line_bytes")
|
|
105
|
+
return ModelClientConfig(
|
|
106
|
+
base_url.rstrip("/"),
|
|
107
|
+
api_key,
|
|
108
|
+
model,
|
|
109
|
+
profile,
|
|
110
|
+
timeout,
|
|
111
|
+
max_sse_line_bytes,
|
|
112
|
+
max_sse_event_bytes,
|
|
113
|
+
dict(cast(Mapping[str, str] | None, options.get("headers")) or {}),
|
|
114
|
+
cast(httpx.AsyncClient | None, options.get("client")),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _positive_int_option(value: object, label: str) -> int:
|
|
119
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
120
|
+
raise ValueError(f"{label} must be a positive integer")
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class ModelErrorPolicy:
|
|
126
|
+
"""Model-specific details needed by the shared transport error boundary."""
|
|
127
|
+
|
|
128
|
+
provider: str
|
|
129
|
+
codec_error: type[ValueError]
|
|
130
|
+
request_id_headers: tuple[str, ...]
|
|
131
|
+
error_code_keys: tuple[str, ...]
|
|
132
|
+
additional_retryable_status_codes: frozenset[int] = frozenset()
|
|
133
|
+
retryable_error_codes: frozenset[str] = frozenset()
|
|
134
|
+
body_request_id_key: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class ServerSentEvent:
|
|
139
|
+
"""One decoded SSE frame."""
|
|
140
|
+
|
|
141
|
+
event: str | None
|
|
142
|
+
data: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def invoke_json_model(
|
|
146
|
+
*,
|
|
147
|
+
client: httpx.AsyncClient | None,
|
|
148
|
+
timeout: float | httpx.Timeout | None,
|
|
149
|
+
context: RunContext,
|
|
150
|
+
url: str,
|
|
151
|
+
payload: PayloadFactory,
|
|
152
|
+
headers: HeadersFactory,
|
|
153
|
+
decode: ResponseDecoder,
|
|
154
|
+
errors: ModelErrorPolicy,
|
|
155
|
+
response_shape_error: str,
|
|
156
|
+
) -> ModelResponse:
|
|
157
|
+
"""Execute one JSON model request through the shared provider error boundary."""
|
|
158
|
+
|
|
159
|
+
response: httpx.Response | None = None
|
|
160
|
+
try:
|
|
161
|
+
body = payload()
|
|
162
|
+
async with managed_async_client(client, timeout) as http:
|
|
163
|
+
response = await http.post(
|
|
164
|
+
url,
|
|
165
|
+
headers=headers(body),
|
|
166
|
+
json=body,
|
|
167
|
+
timeout=_effective_timeout(timeout, context),
|
|
168
|
+
)
|
|
169
|
+
await ensure_success_response(response, errors)
|
|
170
|
+
value: object = response.json()
|
|
171
|
+
if not isinstance(value, Mapping):
|
|
172
|
+
raise errors.codec_error(response_shape_error)
|
|
173
|
+
decoded = cast(Mapping[str, object], value)
|
|
174
|
+
if "error" in decoded:
|
|
175
|
+
raise ModelError(
|
|
176
|
+
_body_error_info(
|
|
177
|
+
decoded,
|
|
178
|
+
errors,
|
|
179
|
+
status_code=response.status_code,
|
|
180
|
+
response_text="provider response error",
|
|
181
|
+
request_id=response_request_id(response, errors.request_id_headers),
|
|
182
|
+
metadata=response_error_metadata(response),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return decode(decoded)
|
|
186
|
+
except (ModelError, httpx.HTTPError, ValueError) as exc:
|
|
187
|
+
raise _model_error(exc, response, errors) from exc
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def invoke_sse_model(
|
|
191
|
+
*,
|
|
192
|
+
client: httpx.AsyncClient | None,
|
|
193
|
+
timeout: float | httpx.Timeout | None,
|
|
194
|
+
context: RunContext,
|
|
195
|
+
url: str,
|
|
196
|
+
payload: PayloadFactory,
|
|
197
|
+
headers: HeadersFactory,
|
|
198
|
+
decode_frame: FrameDecoder,
|
|
199
|
+
completed_response: Callable[[], ModelResponse],
|
|
200
|
+
emit_delta: DeltaSink | None,
|
|
201
|
+
errors: ModelErrorPolicy,
|
|
202
|
+
incomplete_error: str,
|
|
203
|
+
max_sse_line_bytes: int = _DEFAULT_MAX_SSE_LINE_BYTES,
|
|
204
|
+
max_sse_event_bytes: int = _DEFAULT_MAX_SSE_EVENT_BYTES,
|
|
205
|
+
) -> ModelResponse:
|
|
206
|
+
"""Execute one SSE model request and return its provider-assembled response."""
|
|
207
|
+
|
|
208
|
+
steps = _decoded_sse_steps(
|
|
209
|
+
client=client,
|
|
210
|
+
timeout=timeout,
|
|
211
|
+
context=context,
|
|
212
|
+
url=url,
|
|
213
|
+
payload=payload,
|
|
214
|
+
headers=headers,
|
|
215
|
+
decode_frame=decode_frame,
|
|
216
|
+
completed_response=completed_response,
|
|
217
|
+
errors=errors,
|
|
218
|
+
incomplete_error=incomplete_error,
|
|
219
|
+
max_sse_line_bytes=max_sse_line_bytes,
|
|
220
|
+
max_sse_event_bytes=max_sse_event_bytes,
|
|
221
|
+
)
|
|
222
|
+
async with aclosing(steps):
|
|
223
|
+
async for deltas, completed in steps:
|
|
224
|
+
if emit_delta is not None:
|
|
225
|
+
for delta in deltas:
|
|
226
|
+
await emit_delta(delta)
|
|
227
|
+
if completed is not None:
|
|
228
|
+
return completed
|
|
229
|
+
raise RuntimeError("provider stream ended without a result")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def _decoded_sse_steps(
|
|
233
|
+
*,
|
|
234
|
+
client: httpx.AsyncClient | None,
|
|
235
|
+
timeout: float | httpx.Timeout | None,
|
|
236
|
+
context: RunContext,
|
|
237
|
+
url: str,
|
|
238
|
+
payload: PayloadFactory,
|
|
239
|
+
headers: HeadersFactory,
|
|
240
|
+
decode_frame: FrameDecoder,
|
|
241
|
+
completed_response: Callable[[], ModelResponse],
|
|
242
|
+
errors: ModelErrorPolicy,
|
|
243
|
+
incomplete_error: str,
|
|
244
|
+
max_sse_line_bytes: int,
|
|
245
|
+
max_sse_event_bytes: int,
|
|
246
|
+
) -> AsyncGenerator[tuple[Sequence[ModelDelta], ModelResponse | None]]:
|
|
247
|
+
"""Own provider resources while yielding outside the host sink boundary."""
|
|
248
|
+
|
|
249
|
+
response: httpx.Response | None = None
|
|
250
|
+
try:
|
|
251
|
+
body = payload()
|
|
252
|
+
async with (
|
|
253
|
+
managed_async_client(client, timeout) as http,
|
|
254
|
+
http.stream(
|
|
255
|
+
"POST",
|
|
256
|
+
url,
|
|
257
|
+
headers=headers(body),
|
|
258
|
+
json=body,
|
|
259
|
+
timeout=_effective_timeout(timeout, context),
|
|
260
|
+
) as response,
|
|
261
|
+
):
|
|
262
|
+
await ensure_success_response(response, errors)
|
|
263
|
+
async for frame in iter_server_sent_events(
|
|
264
|
+
response,
|
|
265
|
+
max_line_bytes=max_sse_line_bytes,
|
|
266
|
+
max_event_bytes=max_sse_event_bytes,
|
|
267
|
+
error=errors.codec_error,
|
|
268
|
+
):
|
|
269
|
+
done, deltas = decode_frame(frame.event, frame.data)
|
|
270
|
+
completed = completed_response() if done else None
|
|
271
|
+
yield deltas, completed
|
|
272
|
+
if done:
|
|
273
|
+
return
|
|
274
|
+
raise errors.codec_error(incomplete_error)
|
|
275
|
+
except (ModelError, httpx.HTTPError, ValueError) as exc:
|
|
276
|
+
raise _model_error(exc, response, errors) from exc
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@asynccontextmanager
|
|
280
|
+
async def managed_async_client(
|
|
281
|
+
client: httpx.AsyncClient | None,
|
|
282
|
+
timeout: float | httpx.Timeout | None,
|
|
283
|
+
) -> AsyncGenerator[httpx.AsyncClient]:
|
|
284
|
+
"""Reuse an injected client or own a short-lived provider client."""
|
|
285
|
+
|
|
286
|
+
if client is not None:
|
|
287
|
+
yield client
|
|
288
|
+
return
|
|
289
|
+
async with httpx.AsyncClient(timeout=timeout) as owned_client:
|
|
290
|
+
yield owned_client
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _effective_timeout(
|
|
294
|
+
configured: float | httpx.Timeout | None,
|
|
295
|
+
context: RunContext,
|
|
296
|
+
) -> float | httpx.Timeout | None:
|
|
297
|
+
deadline = context.deadline
|
|
298
|
+
if deadline is None:
|
|
299
|
+
return configured
|
|
300
|
+
remaining = deadline - time()
|
|
301
|
+
if remaining <= 0:
|
|
302
|
+
raise httpx.ReadTimeout("run deadline expired before provider request")
|
|
303
|
+
if configured is None:
|
|
304
|
+
return remaining
|
|
305
|
+
if isinstance(configured, httpx.Timeout):
|
|
306
|
+
return httpx.Timeout(
|
|
307
|
+
connect=_clamp_timeout_phase(configured.connect, remaining),
|
|
308
|
+
read=_clamp_timeout_phase(configured.read, remaining),
|
|
309
|
+
write=_clamp_timeout_phase(configured.write, remaining),
|
|
310
|
+
pool=_clamp_timeout_phase(configured.pool, remaining),
|
|
311
|
+
)
|
|
312
|
+
return min(float(configured), remaining)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _clamp_timeout_phase(value: float | None, remaining: float) -> float:
|
|
316
|
+
return remaining if value is None else min(value, remaining)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def ensure_success_response(
|
|
320
|
+
response: httpx.Response,
|
|
321
|
+
policy: ModelErrorPolicy,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Accept only 2xx responses and preserve the complete HTTP error envelope."""
|
|
324
|
+
|
|
325
|
+
if 200 <= response.status_code < 300:
|
|
326
|
+
return
|
|
327
|
+
try:
|
|
328
|
+
_ = response.text
|
|
329
|
+
except httpx.ResponseNotRead:
|
|
330
|
+
await response.aread()
|
|
331
|
+
try:
|
|
332
|
+
body = response.json()
|
|
333
|
+
except ValueError:
|
|
334
|
+
body = None
|
|
335
|
+
raise ModelError(
|
|
336
|
+
_body_error_info(
|
|
337
|
+
body,
|
|
338
|
+
policy,
|
|
339
|
+
status_code=response.status_code,
|
|
340
|
+
response_text=response.text or response.reason_phrase or "provider error",
|
|
341
|
+
request_id=response_request_id(response, policy.request_id_headers),
|
|
342
|
+
metadata=response_error_metadata(response),
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def iter_server_sent_events(
|
|
348
|
+
response: httpx.Response,
|
|
349
|
+
*,
|
|
350
|
+
max_line_bytes: int = _DEFAULT_MAX_SSE_LINE_BYTES,
|
|
351
|
+
max_event_bytes: int = _DEFAULT_MAX_SSE_EVENT_BYTES,
|
|
352
|
+
error: type[ValueError] = ValueError,
|
|
353
|
+
) -> AsyncIterator[ServerSentEvent]:
|
|
354
|
+
"""Parse an HTTP response body into SSE frames according to field boundaries."""
|
|
355
|
+
|
|
356
|
+
event_name: str | None = None
|
|
357
|
+
data_lines: list[str] = []
|
|
358
|
+
event_bytes = 0
|
|
359
|
+
async for line, line_bytes in _iter_sse_lines(
|
|
360
|
+
response,
|
|
361
|
+
max_line_bytes=max_line_bytes,
|
|
362
|
+
error=error,
|
|
363
|
+
):
|
|
364
|
+
if line == "":
|
|
365
|
+
if data_lines:
|
|
366
|
+
yield ServerSentEvent(event=event_name, data="\n".join(data_lines))
|
|
367
|
+
event_name = None
|
|
368
|
+
data_lines = []
|
|
369
|
+
event_bytes = 0
|
|
370
|
+
continue
|
|
371
|
+
event_bytes += line_bytes + 1
|
|
372
|
+
if event_bytes > max_event_bytes:
|
|
373
|
+
raise error(f"SSE event exceeds the configured {max_event_bytes}-byte limit")
|
|
374
|
+
if line.startswith(":"):
|
|
375
|
+
continue
|
|
376
|
+
field, separator, raw_value = line.partition(":")
|
|
377
|
+
if not separator:
|
|
378
|
+
raw_value = ""
|
|
379
|
+
value = raw_value[1:] if raw_value.startswith(" ") else raw_value
|
|
380
|
+
if field == "event":
|
|
381
|
+
event_name = value
|
|
382
|
+
elif field == "data":
|
|
383
|
+
data_lines.append(value)
|
|
384
|
+
if data_lines:
|
|
385
|
+
yield ServerSentEvent(event=event_name, data="\n".join(data_lines))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _iter_sse_lines(
|
|
389
|
+
response: httpx.Response,
|
|
390
|
+
*,
|
|
391
|
+
max_line_bytes: int,
|
|
392
|
+
error: type[ValueError],
|
|
393
|
+
) -> AsyncIterator[tuple[str, int]]:
|
|
394
|
+
pending = bytearray()
|
|
395
|
+
skip_lf = False
|
|
396
|
+
async for chunk in response.aiter_bytes():
|
|
397
|
+
for item in chunk:
|
|
398
|
+
if skip_lf:
|
|
399
|
+
skip_lf = False
|
|
400
|
+
if item == 0x0A:
|
|
401
|
+
continue
|
|
402
|
+
if item == 0x0D:
|
|
403
|
+
yield _decode_sse_line(pending, error), len(pending)
|
|
404
|
+
pending.clear()
|
|
405
|
+
skip_lf = True
|
|
406
|
+
continue
|
|
407
|
+
if item == 0x0A:
|
|
408
|
+
yield _decode_sse_line(pending, error), len(pending)
|
|
409
|
+
pending.clear()
|
|
410
|
+
continue
|
|
411
|
+
if len(pending) >= max_line_bytes:
|
|
412
|
+
raise error(f"SSE line exceeds the configured {max_line_bytes}-byte limit")
|
|
413
|
+
pending.append(item)
|
|
414
|
+
if pending:
|
|
415
|
+
yield _decode_sse_line(pending, error), len(pending)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _decode_sse_line(value: bytearray, error: type[ValueError]) -> str:
|
|
419
|
+
try:
|
|
420
|
+
return value.decode("utf-8", errors="strict")
|
|
421
|
+
except UnicodeDecodeError as exc:
|
|
422
|
+
raise error("SSE stream contains invalid UTF-8") from exc
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def response_error_metadata(response: httpx.Response) -> dict[str, str]:
|
|
426
|
+
"""Return portable HTTP context that is useful beyond provider error bodies."""
|
|
427
|
+
|
|
428
|
+
metadata: dict[str, str] = {}
|
|
429
|
+
location = response.headers.get("location")
|
|
430
|
+
if location:
|
|
431
|
+
metadata["location"] = location
|
|
432
|
+
retry_after = response.headers.get("retry-after")
|
|
433
|
+
if retry_after:
|
|
434
|
+
metadata["retry_after"] = retry_after
|
|
435
|
+
return metadata
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def response_request_id(
|
|
439
|
+
response: httpx.Response,
|
|
440
|
+
header_names: Sequence[str],
|
|
441
|
+
) -> str | None:
|
|
442
|
+
"""Return the first non-empty request identifier from provider header names."""
|
|
443
|
+
|
|
444
|
+
for header_name in header_names:
|
|
445
|
+
request_id = response.headers.get(header_name)
|
|
446
|
+
if request_id:
|
|
447
|
+
return request_id
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def stream_body_error(
|
|
452
|
+
body: Mapping[str, object],
|
|
453
|
+
policy: ModelErrorPolicy,
|
|
454
|
+
) -> ModelError:
|
|
455
|
+
"""Build the standard model error for an error carried by an SSE payload."""
|
|
456
|
+
|
|
457
|
+
return ModelError(
|
|
458
|
+
_body_error_info(
|
|
459
|
+
body,
|
|
460
|
+
policy,
|
|
461
|
+
status_code=None,
|
|
462
|
+
response_text="provider stream error",
|
|
463
|
+
request_id=None,
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def decode_json_object(
|
|
469
|
+
data: str,
|
|
470
|
+
error: type[ValueError],
|
|
471
|
+
error_message: str,
|
|
472
|
+
) -> Mapping[str, object]:
|
|
473
|
+
parsed: object = json.loads(data)
|
|
474
|
+
if not isinstance(parsed, Mapping):
|
|
475
|
+
raise error(error_message)
|
|
476
|
+
return cast(Mapping[str, object], parsed)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _model_error(
|
|
480
|
+
exc: Exception,
|
|
481
|
+
response: httpx.Response | None,
|
|
482
|
+
policy: ModelErrorPolicy,
|
|
483
|
+
) -> ModelError:
|
|
484
|
+
semantic_model_error = isinstance(exc, ModelError)
|
|
485
|
+
if semantic_model_error:
|
|
486
|
+
info = exc.info
|
|
487
|
+
else:
|
|
488
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
489
|
+
code, retryable = "timeout", True
|
|
490
|
+
elif isinstance(exc, httpx.NetworkError | httpx.RemoteProtocolError | httpx.ProxyError):
|
|
491
|
+
code, retryable = exc.__class__.__name__, True
|
|
492
|
+
elif isinstance(exc, httpx.HTTPError):
|
|
493
|
+
code, retryable = exc.__class__.__name__, False
|
|
494
|
+
elif isinstance(exc, policy.codec_error):
|
|
495
|
+
code, retryable = "codec_error", False
|
|
496
|
+
elif isinstance(exc, json.JSONDecodeError):
|
|
497
|
+
code, retryable = "invalid_json", False
|
|
498
|
+
else:
|
|
499
|
+
code, retryable = exc.__class__.__name__, False
|
|
500
|
+
info = ModelErrorInfo(
|
|
501
|
+
message=str(exc) or exc.__class__.__name__,
|
|
502
|
+
provider=policy.provider,
|
|
503
|
+
code=code,
|
|
504
|
+
retryable=retryable,
|
|
505
|
+
)
|
|
506
|
+
if response is not None:
|
|
507
|
+
metadata = dict(info.metadata)
|
|
508
|
+
metadata.update(response_error_metadata(response))
|
|
509
|
+
response_status = response.status_code
|
|
510
|
+
status_code = info.status_code
|
|
511
|
+
if status_code is None and (not semantic_model_error or not 200 <= response_status < 300):
|
|
512
|
+
status_code = response_status
|
|
513
|
+
info = ModelErrorInfo(
|
|
514
|
+
message=info.message,
|
|
515
|
+
provider=info.provider,
|
|
516
|
+
code=info.code,
|
|
517
|
+
status_code=status_code,
|
|
518
|
+
retryable=info.retryable,
|
|
519
|
+
request_id=(
|
|
520
|
+
response_request_id(response, policy.request_id_headers) or info.request_id
|
|
521
|
+
),
|
|
522
|
+
metadata=metadata,
|
|
523
|
+
)
|
|
524
|
+
return ModelError(info)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _body_error_info(
|
|
528
|
+
error_body: object,
|
|
529
|
+
policy: ModelErrorPolicy,
|
|
530
|
+
*,
|
|
531
|
+
status_code: int | None,
|
|
532
|
+
response_text: str,
|
|
533
|
+
request_id: str | None,
|
|
534
|
+
metadata: Mapping[str, object] | None = None,
|
|
535
|
+
) -> ModelErrorInfo:
|
|
536
|
+
message = response_text
|
|
537
|
+
code: str | None = None
|
|
538
|
+
body_request_id: str | None = None
|
|
539
|
+
if isinstance(error_body, Mapping):
|
|
540
|
+
error_mapping = cast(Mapping[str, object], error_body)
|
|
541
|
+
if policy.body_request_id_key is not None:
|
|
542
|
+
raw_request_id = error_mapping.get(policy.body_request_id_key)
|
|
543
|
+
if isinstance(raw_request_id, str) and raw_request_id:
|
|
544
|
+
body_request_id = raw_request_id
|
|
545
|
+
error_value = error_mapping.get("error")
|
|
546
|
+
if isinstance(error_value, Mapping):
|
|
547
|
+
nested = cast(Mapping[str, object], error_value)
|
|
548
|
+
raw_message = nested.get("message")
|
|
549
|
+
if isinstance(raw_message, str) and raw_message:
|
|
550
|
+
message = raw_message
|
|
551
|
+
for key in policy.error_code_keys:
|
|
552
|
+
item = nested.get(key)
|
|
553
|
+
if isinstance(item, str) and item:
|
|
554
|
+
code = item
|
|
555
|
+
break
|
|
556
|
+
elif isinstance(error_value, str) and error_value:
|
|
557
|
+
message = error_value
|
|
558
|
+
return ModelErrorInfo(
|
|
559
|
+
message=message,
|
|
560
|
+
provider=policy.provider,
|
|
561
|
+
code=code or "provider_error",
|
|
562
|
+
status_code=status_code,
|
|
563
|
+
retryable=(
|
|
564
|
+
status_code in _RETRYABLE_STATUS_CODES
|
|
565
|
+
or status_code in policy.additional_retryable_status_codes
|
|
566
|
+
or code in policy.retryable_error_codes
|
|
567
|
+
),
|
|
568
|
+
request_id=request_id or body_request_id,
|
|
569
|
+
metadata={} if metadata is None else metadata,
|
|
570
|
+
)
|
jharness/models/_json.py
ADDED
|
@@ -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
|