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/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: ...