batchwork-ai 0.1.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.
- batchwork/__init__.py +261 -0
- batchwork/_anthropic_serialization.py +432 -0
- batchwork/_compatible_media.py +147 -0
- batchwork/_google_schema.py +134 -0
- batchwork/_google_serialization.py +239 -0
- batchwork/_network.py +79 -0
- batchwork/_serialization.py +1467 -0
- batchwork/_typing.py +15 -0
- batchwork/body.py +1170 -0
- batchwork/client.py +489 -0
- batchwork/errors.py +47 -0
- batchwork/job.py +145 -0
- batchwork/media.py +452 -0
- batchwork/providers/__init__.py +46 -0
- batchwork/providers/adapter.py +36 -0
- batchwork/providers/anthropic.py +246 -0
- batchwork/providers/google.py +309 -0
- batchwork/providers/groq.py +5 -0
- batchwork/providers/ids.py +22 -0
- batchwork/providers/mistral.py +153 -0
- batchwork/providers/openai.py +5 -0
- batchwork/providers/openai_compatible.py +373 -0
- batchwork/providers/shared.py +383 -0
- batchwork/providers/together.py +5 -0
- batchwork/providers/xai.py +217 -0
- batchwork/py.typed +1 -0
- batchwork/server/__init__.py +77 -0
- batchwork/server/models.py +93 -0
- batchwork/server/poller.py +321 -0
- batchwork/server/signing.py +262 -0
- batchwork/server/transport.py +232 -0
- batchwork/stores/__init__.py +14 -0
- batchwork/stores/base.py +24 -0
- batchwork/stores/memory.py +44 -0
- batchwork/stores/redis.py +110 -0
- batchwork/types.py +685 -0
- batchwork_ai-0.1.1.dist-info/METADATA +143 -0
- batchwork_ai-0.1.1.dist-info/RECORD +40 -0
- batchwork_ai-0.1.1.dist-info/WHEEL +4 -0
- batchwork_ai-0.1.1.dist-info/licenses/LICENSE +21 -0
batchwork/media.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Secure media resolution for providers that require inline data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import binascii
|
|
8
|
+
import mimetypes
|
|
9
|
+
import ssl
|
|
10
|
+
from collections.abc import AsyncIterable, AsyncIterator, Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol, TypeAlias
|
|
13
|
+
from urllib.parse import unquote_to_bytes, urljoin, urlsplit
|
|
14
|
+
|
|
15
|
+
import httpcore
|
|
16
|
+
import httpx
|
|
17
|
+
from httpcore._backends.anyio import AnyIOBackend
|
|
18
|
+
|
|
19
|
+
from ._network import (
|
|
20
|
+
AddressResolutionFailureReason,
|
|
21
|
+
ResolvedAddresses,
|
|
22
|
+
resolve_public_addresses,
|
|
23
|
+
validate_public_address,
|
|
24
|
+
)
|
|
25
|
+
from .errors import MediaResolutionError
|
|
26
|
+
from .types import (
|
|
27
|
+
MediaSource,
|
|
28
|
+
ProviderFileReference,
|
|
29
|
+
TaggedFileDataData,
|
|
30
|
+
TaggedFileDataReference,
|
|
31
|
+
TaggedFileDataText,
|
|
32
|
+
TaggedFileDataUrl,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_MAX_REDIRECTS = 5
|
|
36
|
+
SocketOption: TypeAlias = (
|
|
37
|
+
tuple[int, int, int] | tuple[int, int, bytes | bytearray] | tuple[int, int, None, int]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _socket_options(value: object) -> list[SocketOption] | None:
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
if not isinstance(value, Iterable) or isinstance(value, (str, bytes, bytearray)):
|
|
45
|
+
raise TypeError("batchwork: socket_options must be an iterable of socket option tuples")
|
|
46
|
+
result: list[SocketOption] = []
|
|
47
|
+
for option in value:
|
|
48
|
+
if not isinstance(option, tuple):
|
|
49
|
+
raise TypeError("batchwork: each socket option must be a tuple")
|
|
50
|
+
if len(option) == 3:
|
|
51
|
+
level, name, payload = option
|
|
52
|
+
if not isinstance(level, int) or not isinstance(name, int):
|
|
53
|
+
raise TypeError("batchwork: socket option identifiers must be integers")
|
|
54
|
+
if isinstance(payload, int):
|
|
55
|
+
result.append((level, name, payload))
|
|
56
|
+
elif isinstance(payload, (bytes, bytearray)):
|
|
57
|
+
result.append((level, name, payload))
|
|
58
|
+
else:
|
|
59
|
+
raise TypeError("batchwork: socket option payload has an unsupported type")
|
|
60
|
+
elif len(option) == 4:
|
|
61
|
+
level, name, payload, length = option
|
|
62
|
+
if (
|
|
63
|
+
not isinstance(level, int)
|
|
64
|
+
or not isinstance(name, int)
|
|
65
|
+
or payload is not None
|
|
66
|
+
or not isinstance(length, int)
|
|
67
|
+
):
|
|
68
|
+
raise TypeError("batchwork: socket option tuple has invalid values")
|
|
69
|
+
result.append((level, name, None, length))
|
|
70
|
+
else:
|
|
71
|
+
raise TypeError("batchwork: socket option tuple must contain three or four values")
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _timeout(value: object) -> float | None:
|
|
76
|
+
if value is None:
|
|
77
|
+
return None
|
|
78
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
79
|
+
return float(value)
|
|
80
|
+
raise TypeError("batchwork: network timeout must be numeric or None")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class ResolvedMedia:
|
|
85
|
+
data: bytes
|
|
86
|
+
media_type: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MediaResolver(Protocol):
|
|
90
|
+
async def resolve(
|
|
91
|
+
self,
|
|
92
|
+
source: MediaSource,
|
|
93
|
+
*,
|
|
94
|
+
media_type: str | None = None,
|
|
95
|
+
max_bytes: int,
|
|
96
|
+
) -> ResolvedMedia: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_address(address: str) -> None:
|
|
100
|
+
failure = validate_public_address(address)
|
|
101
|
+
if failure is None:
|
|
102
|
+
return
|
|
103
|
+
if failure.reason is AddressResolutionFailureReason.INVALID:
|
|
104
|
+
raise MediaResolutionError(
|
|
105
|
+
f'batchwork: DNS returned invalid address "{address}"'
|
|
106
|
+
) from failure.cause
|
|
107
|
+
if failure.reason is AddressResolutionFailureReason.NON_GLOBAL:
|
|
108
|
+
raise MediaResolutionError(
|
|
109
|
+
f'batchwork: refused media address "{address}" because it is not globally routable'
|
|
110
|
+
)
|
|
111
|
+
raise RuntimeError("batchwork: unexpected address validation failure")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _resolve_public_addresses(host: str, port: int) -> tuple[str, ...]:
|
|
115
|
+
result = await resolve_public_addresses(host, port)
|
|
116
|
+
if isinstance(result, ResolvedAddresses):
|
|
117
|
+
return result.addresses
|
|
118
|
+
if result.reason is AddressResolutionFailureReason.LOOKUP:
|
|
119
|
+
raise MediaResolutionError(
|
|
120
|
+
f'batchwork: failed to resolve media host "{host}"'
|
|
121
|
+
) from result.cause
|
|
122
|
+
if result.reason is AddressResolutionFailureReason.EMPTY:
|
|
123
|
+
raise MediaResolutionError(f'batchwork: media host "{host}" resolved to no addresses')
|
|
124
|
+
address = str(result.address)
|
|
125
|
+
_validate_address(address)
|
|
126
|
+
raise RuntimeError("batchwork: unexpected media address resolution failure")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _PinnedNetworkBackend(httpcore.AsyncNetworkBackend):
|
|
130
|
+
"""Resolve, validate, and pin every new TCP connection."""
|
|
131
|
+
|
|
132
|
+
def __init__(self) -> None:
|
|
133
|
+
self._backend = AnyIOBackend()
|
|
134
|
+
|
|
135
|
+
async def connect_tcp(
|
|
136
|
+
self,
|
|
137
|
+
host: str,
|
|
138
|
+
port: int,
|
|
139
|
+
*args: object,
|
|
140
|
+
local_address: str | None = None,
|
|
141
|
+
socket_options: Iterable[SocketOption] | None = None,
|
|
142
|
+
**options: object,
|
|
143
|
+
) -> httpcore.AsyncNetworkStream:
|
|
144
|
+
if len(args) > 3:
|
|
145
|
+
raise TypeError("batchwork: connect_tcp received too many positional arguments")
|
|
146
|
+
timeout_seconds = _timeout(args[0] if args else options.get("timeout"))
|
|
147
|
+
raw_local_address = args[1] if len(args) > 1 else local_address
|
|
148
|
+
if raw_local_address is not None and not isinstance(raw_local_address, str):
|
|
149
|
+
raise TypeError("batchwork: local_address must be a string or None")
|
|
150
|
+
resolved_socket_options = _socket_options(args[2] if len(args) > 2 else socket_options)
|
|
151
|
+
addresses = await _resolve_public_addresses(host, port)
|
|
152
|
+
last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None
|
|
153
|
+
for address in addresses:
|
|
154
|
+
try:
|
|
155
|
+
return await self._backend.connect_tcp(
|
|
156
|
+
address,
|
|
157
|
+
timeout=timeout_seconds,
|
|
158
|
+
port=port,
|
|
159
|
+
local_address=raw_local_address,
|
|
160
|
+
socket_options=resolved_socket_options,
|
|
161
|
+
)
|
|
162
|
+
except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
|
|
163
|
+
last_error = error
|
|
164
|
+
message = f'batchwork: failed to connect to media host "{host}" at any validated address'
|
|
165
|
+
if isinstance(last_error, httpcore.ConnectTimeout):
|
|
166
|
+
raise httpcore.ConnectTimeout(message) from last_error
|
|
167
|
+
if isinstance(last_error, httpcore.ConnectError):
|
|
168
|
+
raise httpcore.ConnectError(message) from last_error
|
|
169
|
+
raise MediaResolutionError(message)
|
|
170
|
+
|
|
171
|
+
async def connect_unix_socket(
|
|
172
|
+
self,
|
|
173
|
+
path: str,
|
|
174
|
+
*args: object,
|
|
175
|
+
socket_options: Iterable[SocketOption] | None = None,
|
|
176
|
+
**options: object,
|
|
177
|
+
) -> httpcore.AsyncNetworkStream:
|
|
178
|
+
del path, args, socket_options, options
|
|
179
|
+
raise MediaResolutionError("batchwork: Unix sockets are not valid media sources")
|
|
180
|
+
|
|
181
|
+
async def sleep(self, seconds: float) -> None:
|
|
182
|
+
await asyncio.sleep(seconds)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _CoreStream(httpx.AsyncByteStream):
|
|
186
|
+
def __init__(self, stream: AsyncIterable[object]) -> None:
|
|
187
|
+
self._stream = stream
|
|
188
|
+
|
|
189
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
190
|
+
async for chunk in self._stream:
|
|
191
|
+
if not isinstance(chunk, bytes):
|
|
192
|
+
raise MediaResolutionError("batchwork: media transport returned a non-byte chunk")
|
|
193
|
+
yield chunk
|
|
194
|
+
|
|
195
|
+
async def aclose(self) -> None:
|
|
196
|
+
close = getattr(self._stream, "aclose", None)
|
|
197
|
+
if close is not None:
|
|
198
|
+
await close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _PinnedTransport(httpx.AsyncBaseTransport):
|
|
202
|
+
def __init__(self) -> None:
|
|
203
|
+
self._pool = httpcore.AsyncConnectionPool(
|
|
204
|
+
ssl_context=ssl.create_default_context(),
|
|
205
|
+
network_backend=_PinnedNetworkBackend(),
|
|
206
|
+
max_keepalive_connections=0,
|
|
207
|
+
retries=0,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
211
|
+
response = await self._pool.handle_async_request(
|
|
212
|
+
httpcore.Request(
|
|
213
|
+
method=request.method,
|
|
214
|
+
url=httpcore.URL(
|
|
215
|
+
scheme=request.url.raw_scheme,
|
|
216
|
+
host=request.url.raw_host,
|
|
217
|
+
port=request.url.port,
|
|
218
|
+
target=request.url.raw_path,
|
|
219
|
+
),
|
|
220
|
+
headers=request.headers.raw,
|
|
221
|
+
content=request.stream,
|
|
222
|
+
extensions=request.extensions,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
stream = response.stream
|
|
226
|
+
if not isinstance(stream, AsyncIterable):
|
|
227
|
+
raise MediaResolutionError("batchwork: media transport returned an invalid stream")
|
|
228
|
+
return httpx.Response(
|
|
229
|
+
status_code=response.status,
|
|
230
|
+
headers=response.headers,
|
|
231
|
+
stream=_CoreStream(stream),
|
|
232
|
+
extensions=response.extensions,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def aclose(self) -> None:
|
|
236
|
+
await self._pool.aclose()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class _PinnedBorrowedTransport(httpx.AsyncBaseTransport):
|
|
240
|
+
"""Pin validated targets while preserving caller ownership."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
|
|
243
|
+
self._transport = transport
|
|
244
|
+
|
|
245
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
246
|
+
host = request.url.host
|
|
247
|
+
if host is None:
|
|
248
|
+
raise MediaResolutionError("batchwork: media URL must include a host")
|
|
249
|
+
authority = request.url.netloc.decode("ascii")
|
|
250
|
+
addresses = await _resolve_public_addresses(host, request.url.port or 443)
|
|
251
|
+
last_error: httpx.ConnectError | httpx.ConnectTimeout | None = None
|
|
252
|
+
for address in addresses:
|
|
253
|
+
pinned = httpx.Request(
|
|
254
|
+
request.method,
|
|
255
|
+
request.url.copy_with(host=address),
|
|
256
|
+
headers=request.headers,
|
|
257
|
+
stream=request.stream,
|
|
258
|
+
extensions={**request.extensions, "sni_hostname": host},
|
|
259
|
+
)
|
|
260
|
+
pinned.headers["host"] = authority
|
|
261
|
+
try:
|
|
262
|
+
return await self._transport.handle_async_request(pinned)
|
|
263
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as error:
|
|
264
|
+
last_error = error
|
|
265
|
+
message = f'batchwork: failed to connect to media host "{host}" at any validated address'
|
|
266
|
+
if isinstance(last_error, httpx.ConnectTimeout):
|
|
267
|
+
raise httpx.ConnectTimeout(message, request=request) from last_error
|
|
268
|
+
if isinstance(last_error, httpx.ConnectError):
|
|
269
|
+
raise httpx.ConnectError(message, request=request) from last_error
|
|
270
|
+
raise MediaResolutionError(message)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _sniff_media_type(data: bytes) -> str | None:
|
|
274
|
+
signatures = (
|
|
275
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
276
|
+
(b"\xff\xd8\xff", "image/jpeg"),
|
|
277
|
+
(b"GIF87a", "image/gif"),
|
|
278
|
+
(b"GIF89a", "image/gif"),
|
|
279
|
+
(b"RIFF", "image/webp"),
|
|
280
|
+
(b"%PDF-", "application/pdf"),
|
|
281
|
+
)
|
|
282
|
+
for signature, detected in signatures:
|
|
283
|
+
if data.startswith(signature):
|
|
284
|
+
if detected == "image/webp" and data[8:12] != b"WEBP":
|
|
285
|
+
continue
|
|
286
|
+
return detected
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _validate_size(data: bytes, max_bytes: int) -> None:
|
|
291
|
+
if len(data) > max_bytes:
|
|
292
|
+
raise MediaResolutionError(
|
|
293
|
+
f"batchwork: media is {len(data)} bytes, exceeding the {max_bytes} byte limit"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _decode_inline(value: str) -> tuple[bytes, str | None]:
|
|
298
|
+
if value.startswith("data:"):
|
|
299
|
+
header, separator, payload = value[5:].partition(",")
|
|
300
|
+
if not separator:
|
|
301
|
+
raise MediaResolutionError("batchwork: malformed data URL")
|
|
302
|
+
segments = header.split(";")
|
|
303
|
+
declared = segments[0] or None
|
|
304
|
+
try:
|
|
305
|
+
data = (
|
|
306
|
+
base64.b64decode(payload, validate=True)
|
|
307
|
+
if "base64" in segments
|
|
308
|
+
else unquote_to_bytes(payload)
|
|
309
|
+
)
|
|
310
|
+
except (binascii.Error, ValueError) as error:
|
|
311
|
+
raise MediaResolutionError("batchwork: malformed data URL payload") from error
|
|
312
|
+
return data, declared
|
|
313
|
+
try:
|
|
314
|
+
return base64.b64decode(value, validate=True), None
|
|
315
|
+
except (binascii.Error, ValueError) as error:
|
|
316
|
+
raise MediaResolutionError(
|
|
317
|
+
"batchwork: string media must be an HTTPS URL, data URL, or base64 payload"
|
|
318
|
+
) from error
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _validated_type(data: bytes, declared: str | None, fallback: str | None = None) -> str:
|
|
322
|
+
declared = declared.split(";", 1)[0].strip().lower() if declared else None
|
|
323
|
+
detected = _sniff_media_type(data)
|
|
324
|
+
declared_top_level = declared.split("/", 1)[0] if declared is not None else None
|
|
325
|
+
declared_is_partial = declared is not None and ("/" not in declared or declared.endswith("/*"))
|
|
326
|
+
types_match = detected == declared or (
|
|
327
|
+
declared_is_partial
|
|
328
|
+
and detected is not None
|
|
329
|
+
and detected.startswith(f"{declared_top_level}/")
|
|
330
|
+
)
|
|
331
|
+
if detected is not None and declared is not None and not types_match:
|
|
332
|
+
raise MediaResolutionError(
|
|
333
|
+
f'batchwork: media type "{declared}" does not match detected type "{detected}"'
|
|
334
|
+
)
|
|
335
|
+
selected = detected or declared or fallback
|
|
336
|
+
if selected is None or "/" not in selected:
|
|
337
|
+
raise MediaResolutionError("batchwork: media type is unknown; provide media_type")
|
|
338
|
+
return selected
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class DefaultMediaResolver:
|
|
342
|
+
"""Resolve inline data or HTTPS media with SSRF and rebinding protection."""
|
|
343
|
+
|
|
344
|
+
def __init__(
|
|
345
|
+
self,
|
|
346
|
+
*,
|
|
347
|
+
timeout: httpx.Timeout | float = 30.0,
|
|
348
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
349
|
+
) -> None:
|
|
350
|
+
self._timeout = timeout
|
|
351
|
+
self._transport = transport
|
|
352
|
+
|
|
353
|
+
async def resolve(
|
|
354
|
+
self,
|
|
355
|
+
source: MediaSource,
|
|
356
|
+
*,
|
|
357
|
+
media_type: str | None = None,
|
|
358
|
+
max_bytes: int,
|
|
359
|
+
) -> ResolvedMedia:
|
|
360
|
+
if isinstance(source, (ProviderFileReference, TaggedFileDataReference)):
|
|
361
|
+
raise MediaResolutionError("batchwork: provider file references cannot be downloaded")
|
|
362
|
+
if isinstance(source, TaggedFileDataData):
|
|
363
|
+
raw_data = source.data
|
|
364
|
+
if isinstance(raw_data, bytes):
|
|
365
|
+
_validate_size(raw_data, max_bytes)
|
|
366
|
+
return ResolvedMedia(raw_data, _validated_type(raw_data, media_type))
|
|
367
|
+
data, declared = _decode_inline(raw_data)
|
|
368
|
+
_validate_size(data, max_bytes)
|
|
369
|
+
return ResolvedMedia(data, _validated_type(data, media_type or declared))
|
|
370
|
+
elif isinstance(source, TaggedFileDataUrl):
|
|
371
|
+
source = source.url
|
|
372
|
+
elif isinstance(source, TaggedFileDataText):
|
|
373
|
+
source = source.text.encode()
|
|
374
|
+
if isinstance(source, bytes):
|
|
375
|
+
_validate_size(source, max_bytes)
|
|
376
|
+
return ResolvedMedia(source, _validated_type(source, media_type))
|
|
377
|
+
|
|
378
|
+
value = str(source)
|
|
379
|
+
parsed = urlsplit(value)
|
|
380
|
+
if parsed.scheme and parsed.scheme != "https":
|
|
381
|
+
if parsed.scheme != "data":
|
|
382
|
+
raise MediaResolutionError("batchwork: remote media URLs must use HTTPS")
|
|
383
|
+
if parsed.scheme != "https":
|
|
384
|
+
data, declared = _decode_inline(value)
|
|
385
|
+
_validate_size(data, max_bytes)
|
|
386
|
+
return ResolvedMedia(data, _validated_type(data, media_type or declared))
|
|
387
|
+
if parsed.username is not None or parsed.password is not None:
|
|
388
|
+
raise MediaResolutionError("batchwork: remote media URLs must not include credentials")
|
|
389
|
+
return await self._download(value, media_type=media_type, max_bytes=max_bytes)
|
|
390
|
+
|
|
391
|
+
async def _download(self, url: str, *, media_type: str | None, max_bytes: int) -> ResolvedMedia:
|
|
392
|
+
transport = (
|
|
393
|
+
_PinnedTransport()
|
|
394
|
+
if self._transport is None
|
|
395
|
+
else _PinnedBorrowedTransport(self._transport)
|
|
396
|
+
)
|
|
397
|
+
async with httpx.AsyncClient(
|
|
398
|
+
transport=transport,
|
|
399
|
+
timeout=self._timeout,
|
|
400
|
+
follow_redirects=False,
|
|
401
|
+
headers={"accept-encoding": "identity"},
|
|
402
|
+
) as client:
|
|
403
|
+
current = url
|
|
404
|
+
for redirects in range(_MAX_REDIRECTS + 1):
|
|
405
|
+
try:
|
|
406
|
+
async with client.stream("GET", current) as response:
|
|
407
|
+
if response.status_code in {301, 302, 303, 307, 308}:
|
|
408
|
+
location = response.headers.get("location")
|
|
409
|
+
if location is None:
|
|
410
|
+
raise MediaResolutionError(
|
|
411
|
+
"batchwork: media redirect omitted Location"
|
|
412
|
+
)
|
|
413
|
+
if redirects == _MAX_REDIRECTS:
|
|
414
|
+
raise MediaResolutionError("batchwork: too many media redirects")
|
|
415
|
+
current = urljoin(current, location)
|
|
416
|
+
redirected = urlsplit(current)
|
|
417
|
+
if redirected.scheme != "https":
|
|
418
|
+
raise MediaResolutionError(
|
|
419
|
+
"batchwork: media redirects must remain on HTTPS"
|
|
420
|
+
)
|
|
421
|
+
if redirected.username is not None or redirected.password is not None:
|
|
422
|
+
raise MediaResolutionError(
|
|
423
|
+
"batchwork: remote media URLs must not include credentials"
|
|
424
|
+
)
|
|
425
|
+
continue
|
|
426
|
+
try:
|
|
427
|
+
response.raise_for_status()
|
|
428
|
+
except httpx.HTTPStatusError as error:
|
|
429
|
+
raise MediaResolutionError(
|
|
430
|
+
f"batchwork: media download returned HTTP {response.status_code}"
|
|
431
|
+
) from error
|
|
432
|
+
length = response.headers.get("content-length")
|
|
433
|
+
if length is not None and int(length) > max_bytes:
|
|
434
|
+
raise MediaResolutionError(
|
|
435
|
+
f"batchwork: media exceeds the {max_bytes} byte limit"
|
|
436
|
+
)
|
|
437
|
+
chunks: list[bytes] = []
|
|
438
|
+
size = 0
|
|
439
|
+
async for chunk in response.aiter_bytes():
|
|
440
|
+
size += len(chunk)
|
|
441
|
+
if size > max_bytes:
|
|
442
|
+
raise MediaResolutionError(
|
|
443
|
+
f"batchwork: media exceeds the {max_bytes} byte limit"
|
|
444
|
+
)
|
|
445
|
+
chunks.append(chunk)
|
|
446
|
+
data = b"".join(chunks)
|
|
447
|
+
fallback, _ = mimetypes.guess_type(current)
|
|
448
|
+
declared = media_type or response.headers.get("content-type") or fallback
|
|
449
|
+
return ResolvedMedia(data, _validated_type(data, declared))
|
|
450
|
+
except httpx.HTTPError as error:
|
|
451
|
+
raise MediaResolutionError("batchwork: media download failed") from error
|
|
452
|
+
raise AssertionError("unreachable")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Provider adapter registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from batchwork.errors import UnsupportedProviderError
|
|
8
|
+
from batchwork.types import BatchProvider
|
|
9
|
+
|
|
10
|
+
from .adapter import BatchAdapter
|
|
11
|
+
from .anthropic import AnthropicAdapter
|
|
12
|
+
from .google import GoogleAdapter
|
|
13
|
+
from .mistral import MistralAdapter
|
|
14
|
+
from .openai_compatible import groq_adapter, openai_adapter, together_adapter
|
|
15
|
+
from .xai import XAIAdapter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_adapter(
|
|
19
|
+
provider: BatchProvider | str,
|
|
20
|
+
*,
|
|
21
|
+
http_client: httpx.AsyncClient | None = None,
|
|
22
|
+
) -> BatchAdapter:
|
|
23
|
+
"""Create a provider adapter bound to the caller-owned HTTP client."""
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
resolved = provider if isinstance(provider, BatchProvider) else BatchProvider(provider)
|
|
27
|
+
except ValueError as error:
|
|
28
|
+
raise UnsupportedProviderError(str(provider)) from error
|
|
29
|
+
if resolved is BatchProvider.OPENAI:
|
|
30
|
+
return openai_adapter(http_client)
|
|
31
|
+
if resolved is BatchProvider.ANTHROPIC:
|
|
32
|
+
return AnthropicAdapter(http_client)
|
|
33
|
+
if resolved is BatchProvider.GOOGLE:
|
|
34
|
+
return GoogleAdapter(http_client)
|
|
35
|
+
if resolved is BatchProvider.GROQ:
|
|
36
|
+
return groq_adapter(http_client)
|
|
37
|
+
if resolved is BatchProvider.MISTRAL:
|
|
38
|
+
return MistralAdapter(http_client)
|
|
39
|
+
if resolved is BatchProvider.TOGETHER:
|
|
40
|
+
return together_adapter(http_client)
|
|
41
|
+
if resolved is BatchProvider.XAI:
|
|
42
|
+
return XAIAdapter(http_client)
|
|
43
|
+
raise UnsupportedProviderError(resolved.value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["BatchAdapter", "get_adapter"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Provider adapter contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from batchwork.body import BuiltRequest
|
|
9
|
+
from batchwork.types import (
|
|
10
|
+
BatchLimits,
|
|
11
|
+
BatchProvider,
|
|
12
|
+
BatchResult,
|
|
13
|
+
BatchSnapshot,
|
|
14
|
+
ProviderCredentials,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BatchAdapter(Protocol):
|
|
19
|
+
id: BatchProvider
|
|
20
|
+
|
|
21
|
+
async def submit(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
built: Sequence[BuiltRequest],
|
|
25
|
+
credentials: ProviderCredentials,
|
|
26
|
+
endpoint: str,
|
|
27
|
+
model_id: str,
|
|
28
|
+
metadata: Mapping[str, str] | None = None,
|
|
29
|
+
limits: BatchLimits | None = None,
|
|
30
|
+
) -> BatchSnapshot: ...
|
|
31
|
+
|
|
32
|
+
async def retrieve(self, id: str, credentials: ProviderCredentials) -> BatchSnapshot: ...
|
|
33
|
+
|
|
34
|
+
def results(self, id: str, credentials: ProviderCredentials) -> AsyncIterator[BatchResult]: ...
|
|
35
|
+
|
|
36
|
+
async def cancel(self, id: str, credentials: ProviderCredentials) -> None: ...
|