batchwork-ai 0.2.2__tar.gz → 0.2.3__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 (62) hide show
  1. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/PKG-INFO +5 -1
  2. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/README.md +4 -0
  3. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/pyproject.toml +1 -1
  4. batchwork_ai-0.2.3/src/batchwork/_base_url.py +56 -0
  5. batchwork_ai-0.2.3/src/batchwork/_read_retry.py +89 -0
  6. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_config.py +5 -28
  7. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_contract.py +2 -1
  8. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_failures.py +11 -1
  9. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_lifecycle.py +6 -77
  10. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_submit_text.py +4 -13
  11. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/client.py +18 -2
  12. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/job.py +18 -6
  13. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/media.py +9 -1
  14. batchwork_ai-0.2.3/src/batchwork/providers/_capabilities.py +33 -0
  15. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/anthropic.py +3 -1
  16. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/google.py +2 -1
  17. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/mistral.py +32 -18
  18. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/openai_compatible.py +32 -21
  19. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/shared.py +34 -2
  20. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/xai.py +64 -7
  21. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/types.py +55 -2
  22. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/LICENSE +0 -0
  23. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/__init__.py +0 -0
  24. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_anthropic_serialization.py +0 -0
  25. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_bounded_json.py +0 -0
  26. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_compatible_media.py +0 -0
  27. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_google_schema.py +0 -0
  28. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_google_serialization.py +0 -0
  29. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_limits.py +0 -0
  30. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_network.py +0 -0
  31. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_provider_failure.py +0 -0
  32. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_serialization.py +0 -0
  33. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_text_validation.py +0 -0
  34. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_typing.py +0 -0
  35. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/body.py +0 -0
  36. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/__init__.py +0 -0
  37. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_commands.py +0 -0
  38. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_human.py +0 -0
  39. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_input.py +0 -0
  40. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_materialize.py +0 -0
  41. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_output.py +0 -0
  42. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_registry.py +0 -0
  43. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_state.py +0 -0
  44. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_volume.py +0 -0
  45. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/errors.py +0 -0
  46. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/__init__.py +0 -0
  47. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/_image.py +0 -0
  48. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/adapter.py +0 -0
  49. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/groq.py +0 -0
  50. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/ids.py +0 -0
  51. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/openai.py +0 -0
  52. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/together.py +0 -0
  53. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/py.typed +0 -0
  54. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/__init__.py +0 -0
  55. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/models.py +0 -0
  56. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/poller.py +0 -0
  57. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/signing.py +0 -0
  58. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/transport.py +0 -0
  59. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/stores/__init__.py +0 -0
  60. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/stores/base.py +0 -0
  61. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/stores/memory.py +0 -0
  62. {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/stores/redis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchwork-ai
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Unified async batch API for AI providers
5
5
  Keywords: ai,anthropic,batch,gemini,llm,openai
6
6
  Author: Ajanraj
@@ -174,6 +174,10 @@ The base package does not import or require `upstash-redis`.
174
174
  - [Public API](https://batchwork.ajanraj.com/docs/api)
175
175
  - [FAQ](https://batchwork.ajanraj.com/docs/faq)
176
176
 
177
+ ## Built with Codex and GPT-5.6
178
+
179
+ Batchwork was built entirely in the OpenAI Codex CLI with GPT-5.6 during [OpenAI Build Week](https://openai.devpost.com/) (July 2026) — the Python library, the `batchwork` CLI with its machine output contract and local job registry, the Agent Skill, the test suite, and the documentation site. Codex planned the design through a decision interview, implemented the seven provider adapters across three modalities, hardened security through repeated review loops, verified everything against live provider APIs, and drove the PyPI and documentation releases. The full collaboration story, plus a self-contained demo project for judges, lives in [`hackathon/`](hackathon/README.md).
180
+
177
181
  ## License
178
182
 
179
183
  [MIT](https://opensource.org/licenses/MIT) © [Ajanraj](https://github.com/ajanraj)
@@ -142,6 +142,10 @@ The base package does not import or require `upstash-redis`.
142
142
  - [Public API](https://batchwork.ajanraj.com/docs/api)
143
143
  - [FAQ](https://batchwork.ajanraj.com/docs/faq)
144
144
 
145
+ ## Built with Codex and GPT-5.6
146
+
147
+ Batchwork was built entirely in the OpenAI Codex CLI with GPT-5.6 during [OpenAI Build Week](https://openai.devpost.com/) (July 2026) — the Python library, the `batchwork` CLI with its machine output contract and local job registry, the Agent Skill, the test suite, and the documentation site. Codex planned the design through a decision interview, implemented the seven provider adapters across three modalities, hardened security through repeated review loops, verified everything against live provider APIs, and drove the PyPI and documentation releases. The full collaboration story, plus a self-contained demo project for judges, lives in [`hackathon/`](hackathon/README.md).
148
+
145
149
  ## License
146
150
 
147
151
  [MIT](https://opensource.org/licenses/MIT) © [Ajanraj](https://github.com/ajanraj)
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "batchwork-ai"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Unified async batch API for AI providers"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,56 @@
1
+ """Provider base URL validation and normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ipaddress import ip_address
6
+ from urllib.parse import urlsplit, urlunsplit
7
+
8
+
9
+ class BaseUrlError(ValueError):
10
+ """A provider base URL violates the outbound credential policy."""
11
+
12
+
13
+ def _policy_error(label: str) -> BaseUrlError:
14
+ return BaseUrlError(
15
+ f"{label} must be absolute HTTPS (HTTP allowed for loopback), without "
16
+ "userinfo, query, or fragment."
17
+ )
18
+
19
+
20
+ def normalize_base_url(value: str, label: str = "base URL") -> str:
21
+ """Validate a provider endpoint and remove trailing path slashes."""
22
+ raw_scheme, separator, remainder = value.partition("://")
23
+ authority = remainder
24
+ for delimiter in "/?#":
25
+ authority = authority.partition(delimiter)[0]
26
+ try:
27
+ parsed = urlsplit(value)
28
+ hostname = parsed.hostname
29
+ except ValueError as error:
30
+ raise _policy_error(label) from error
31
+ try:
32
+ _ = parsed.port
33
+ except ValueError as error:
34
+ raise BaseUrlError(f"{label} contains an invalid port.") from error
35
+
36
+ loopback = hostname == "localhost"
37
+ if hostname is not None:
38
+ try:
39
+ loopback = loopback or ip_address(hostname).is_loopback
40
+ except ValueError:
41
+ pass
42
+
43
+ if (
44
+ not separator
45
+ or raw_scheme.lower() != parsed.scheme
46
+ or not hostname
47
+ or any(character.isspace() for character in authority)
48
+ or parsed.username is not None
49
+ or parsed.password is not None
50
+ or "?" in value
51
+ or "#" in value
52
+ or (parsed.scheme != "https" and not (parsed.scheme == "http" and loopback))
53
+ ):
54
+ raise _policy_error(label)
55
+
56
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
@@ -0,0 +1,89 @@
1
+ """Provider-neutral retry policy for safe read operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+ import ssl
8
+ import time
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import TypeVar
11
+
12
+ import httpx
13
+
14
+ from ._provider_failure import ProviderFailureError, ProviderFailureKind
15
+
16
+ _READ_ATTEMPTS = 3
17
+ _RETRYABLE_HTTP_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
18
+ _MAX_RETRY_DELAY_SECONDS = 60.0
19
+ _ReadResult = TypeVar("_ReadResult")
20
+
21
+
22
+ class _ReadRetryDeadlineExceeded(TimeoutError):
23
+ """The local read-retry deadline expired before another attempt."""
24
+
25
+
26
+ def _cause_chain(error: BaseException) -> list[BaseException]:
27
+ causes: list[BaseException] = []
28
+ current: BaseException | None = error
29
+ while current is not None and current not in causes:
30
+ causes.append(current)
31
+ current = current.__cause__ or current.__context__
32
+ return causes
33
+
34
+
35
+ def _retryable_read_failure(error: BaseException) -> bool:
36
+ if not isinstance(error, ProviderFailureError):
37
+ return False
38
+ failure = error.failure
39
+ if failure.kind is ProviderFailureKind.UNAVAILABLE:
40
+ return failure.status_code in _RETRYABLE_HTTP_STATUSES
41
+ if failure.kind is not ProviderFailureKind.TRANSPORT:
42
+ return False
43
+ causes = _cause_chain(error)
44
+ if any(isinstance(cause, ssl.SSLError) for cause in causes):
45
+ return False
46
+ return any(
47
+ isinstance(
48
+ cause,
49
+ (
50
+ httpx.ConnectError,
51
+ httpx.ConnectTimeout,
52
+ httpx.ReadError,
53
+ httpx.ReadTimeout,
54
+ ),
55
+ )
56
+ for cause in causes
57
+ )
58
+
59
+
60
+ def _retry_delay(error: BaseException, retry_index: int) -> float:
61
+ if isinstance(error, ProviderFailureError):
62
+ retry_after = error.failure.retry_after_seconds
63
+ if retry_after is not None:
64
+ return min(float(retry_after), _MAX_RETRY_DELAY_SECONDS)
65
+ ceiling = min(0.25 * (2**retry_index), _MAX_RETRY_DELAY_SECONDS)
66
+ return random.uniform(ceiling / 2, ceiling)
67
+
68
+
69
+ async def _retry_read(
70
+ read: Callable[[], Awaitable[_ReadResult]],
71
+ *,
72
+ deadline: float | None = None,
73
+ ) -> _ReadResult:
74
+ for attempt in range(_READ_ATTEMPTS):
75
+ try:
76
+ return await read()
77
+ except Exception as error:
78
+ if attempt + 1 == _READ_ATTEMPTS or not _retryable_read_failure(error):
79
+ raise
80
+ delay = _retry_delay(error, attempt)
81
+ if deadline is not None:
82
+ remaining = deadline - time.monotonic()
83
+ if remaining <= 0:
84
+ raise _ReadRetryDeadlineExceeded from None
85
+ if delay >= remaining:
86
+ await asyncio.sleep(remaining)
87
+ raise _ReadRetryDeadlineExceeded from None
88
+ await asyncio.sleep(delay)
89
+ raise RuntimeError("batchwork: exhausted retry loop")
@@ -9,13 +9,13 @@ import sys
9
9
  import tomllib
10
10
  from collections.abc import Mapping
11
11
  from dataclasses import dataclass, field
12
- from ipaddress import ip_address
13
12
  from pathlib import Path
14
13
  from typing import Literal
15
- from urllib.parse import urlsplit, urlunsplit
16
14
 
17
15
  import click
18
16
 
17
+ from batchwork._base_url import BaseUrlError
18
+ from batchwork._base_url import normalize_base_url as normalize_core_base_url
19
19
  from batchwork.errors import BatchworkError
20
20
  from batchwork.types import BatchProvider, resolve_model
21
21
 
@@ -136,33 +136,10 @@ def registry_path(explicit: Path | None, environment: Mapping[str, str] = os.env
136
136
  def normalize_base_url(value: str | None, label: str = "base URL") -> str | None:
137
137
  if value is None:
138
138
  return None
139
- parsed = urlsplit(value)
140
- hostname = parsed.hostname
141
139
  try:
142
- _ = parsed.port
143
- except ValueError as error:
144
- raise ConfigError(f"{label} contains an invalid port.", code="endpoint_invalid") from error
145
- loopback = hostname == "localhost"
146
- if hostname is not None:
147
- try:
148
- loopback = loopback or ip_address(hostname).is_loopback
149
- except ValueError:
150
- pass
151
- if (
152
- not hostname
153
- or any(character.isspace() for character in parsed.netloc)
154
- or parsed.username is not None
155
- or parsed.password is not None
156
- or parsed.query
157
- or parsed.fragment
158
- or (parsed.scheme != "https" and not (parsed.scheme == "http" and loopback))
159
- ):
160
- raise ConfigError(
161
- f"{label} must be absolute HTTPS (HTTP allowed for loopback), without "
162
- "userinfo, query, or fragment.",
163
- code="endpoint_invalid",
164
- )
165
- return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
140
+ return normalize_core_base_url(value, label)
141
+ except BaseUrlError as error:
142
+ raise ConfigError(str(error), code="endpoint_invalid") from error
166
143
 
167
144
 
168
145
  def validate_environment_name(value: str, label: str) -> str:
@@ -731,7 +731,8 @@ def reference_document() -> str:
731
731
  lines = [
732
732
  "---",
733
733
  "title: CLI machine schema",
734
- "description: Generated schema-v1 envelopes and credential-free examples.",
734
+ 'description: "Generated schema-v1 envelopes and credential-free examples for '
735
+ 'machine-readable batchwork CLI stdout and stderr."',
735
736
  "---",
736
737
  "",
737
738
  "This page is generated from Batchwork's typed machine-output models. "
@@ -118,7 +118,11 @@ def provider_failure(
118
118
  if failure is None:
119
119
  return None
120
120
  code, category, exit_code, message = _provider_mapping(failure)
121
- retryable = category == "provider_availability" and not submission
121
+ retryable = (
122
+ category == "provider_availability"
123
+ and failure.kind is not ProviderFailureKind.PROTOCOL
124
+ and not submission
125
+ )
122
126
  submission_outcome = None
123
127
  recovery = None
124
128
  if submission:
@@ -138,6 +142,12 @@ def provider_failure(
138
142
  submission_outcome = "unknown"
139
143
  recovery = Recovery(action="inspect_provider_account")
140
144
  retryable = False
145
+ elif failure.kind is ProviderFailureKind.PROTOCOL:
146
+ message = (
147
+ "The provider returned an invalid lifecycle response. The remote job was not "
148
+ "modified and local state was not advanced. Check provider compatibility or "
149
+ "upgrade Batchwork before retrying."
150
+ )
141
151
  if (
142
152
  result_stream
143
153
  and category == "provider_availability"
@@ -4,19 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import inspect
7
- import random
8
7
  import sqlite3
9
- import ssl
10
8
  import time
11
9
  from collections.abc import Awaitable, Callable, Sequence
12
10
  from dataclasses import dataclass
13
11
  from datetime import UTC, datetime
14
12
  from pathlib import Path
15
- from typing import TypeVar
16
13
 
17
- import httpx
18
-
19
- from batchwork._provider_failure import ProviderFailureError, ProviderFailureKind
14
+ from batchwork._read_retry import (
15
+ _READ_ATTEMPTS,
16
+ _retry_delay,
17
+ _retry_read,
18
+ _retryable_read_failure,
19
+ )
20
20
  from batchwork.client import Batchwork
21
21
  from batchwork.types import (
22
22
  BatchProvider,
@@ -69,11 +69,6 @@ from ._submit_text import (
69
69
  resolve_route_descriptor,
70
70
  )
71
71
 
72
- _READ_ATTEMPTS = 3
73
- _RETRYABLE_HTTP_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
74
- _MAX_RETRY_DELAY_SECONDS = 60.0
75
- _ReadResult = TypeVar("_ReadResult")
76
-
77
72
 
78
73
  @dataclass(frozen=True, slots=True)
79
74
  class LifecycleOptions:
@@ -160,72 +155,6 @@ def active_signal_failure(*, interrupted: bool) -> LifecycleFailure | None:
160
155
  )
161
156
 
162
157
 
163
- def _cause_chain(error: BaseException) -> list[BaseException]:
164
- causes: list[BaseException] = []
165
- current: BaseException | None = error
166
- while current is not None and current not in causes:
167
- causes.append(current)
168
- current = current.__cause__ or current.__context__
169
- return causes
170
-
171
-
172
- def _retryable_read_failure(error: BaseException) -> bool:
173
- if not isinstance(error, ProviderFailureError):
174
- return False
175
- failure = error.failure
176
- if failure.kind is ProviderFailureKind.UNAVAILABLE:
177
- return failure.status_code in _RETRYABLE_HTTP_STATUSES
178
- if failure.kind is not ProviderFailureKind.TRANSPORT:
179
- return False
180
- causes = _cause_chain(error)
181
- if any(isinstance(cause, ssl.SSLError) for cause in causes):
182
- return False
183
- return any(
184
- isinstance(
185
- cause,
186
- (
187
- httpx.ConnectError,
188
- httpx.ConnectTimeout,
189
- httpx.ReadError,
190
- httpx.ReadTimeout,
191
- ),
192
- )
193
- for cause in causes
194
- )
195
-
196
-
197
- def _retry_delay(error: BaseException, retry_index: int) -> float:
198
- if isinstance(error, ProviderFailureError):
199
- retry_after = error.failure.retry_after_seconds
200
- if retry_after is not None:
201
- return min(float(retry_after), _MAX_RETRY_DELAY_SECONDS)
202
- ceiling = min(0.25 * (2**retry_index), _MAX_RETRY_DELAY_SECONDS)
203
- return random.uniform(ceiling / 2, ceiling)
204
-
205
-
206
- async def _retry_read(
207
- read: Callable[[], Awaitable[_ReadResult]],
208
- *,
209
- deadline: float | None = None,
210
- ) -> _ReadResult:
211
- for attempt in range(_READ_ATTEMPTS):
212
- try:
213
- return await read()
214
- except Exception as error:
215
- if attempt + 1 == _READ_ATTEMPTS or not _retryable_read_failure(error):
216
- raise
217
- delay = _retry_delay(error, attempt)
218
- if deadline is not None:
219
- remaining = deadline - time.monotonic()
220
- if remaining <= 0:
221
- raise TimeoutError from None
222
- if delay >= remaining:
223
- await asyncio.sleep(remaining)
224
- raise TimeoutError from None
225
- await asyncio.sleep(delay)
226
- raise RuntimeError("batchwork: exhausted retry loop")
227
-
228
-
229
158
  def duration_seconds(duration: str | None) -> float | None:
230
159
  if duration is None:
231
160
  return None
@@ -34,6 +34,7 @@ from batchwork.errors import (
34
34
  )
35
35
  from batchwork.job import BatchJob
36
36
  from batchwork.media import DefaultMediaResolver
37
+ from batchwork.providers._capabilities import supports_batch_metadata
37
38
  from batchwork.types import (
38
39
  BatchDefaults,
39
40
  BatchEmbeddingDefaults,
@@ -331,11 +332,12 @@ def _resolve_route(
331
332
 
332
333
 
333
334
  def resolve_registered_route(provider: BatchProvider, route: RegistryRoute) -> ResolvedRoute:
335
+ base_url = normalize_base_url(route.base_url, "registered base URL")
334
336
  api_key = _environment_value(route.api_key_env, "API key")
335
337
  resolved_headers = dict(route.headers)
336
338
  for name, variable in route.header_env.items():
337
339
  resolved_headers[name] = _environment_value(variable, f'Header "{name}"')
338
- return ResolvedRoute(api_key, route.base_url, resolved_headers, route)
340
+ return ResolvedRoute(api_key, base_url, resolved_headers, route)
339
341
 
340
342
 
341
343
  def _parse_json_object(document: str, label: str) -> dict[str, JsonValue]:
@@ -447,7 +449,6 @@ def _prepare_submission(
447
449
  *,
448
450
  modality: Modality,
449
451
  endpoint: str | None,
450
- metadata_providers: frozenset[BatchProvider],
451
452
  ) -> _PreparedSubmission:
452
453
  loaded = load_config(root.config)
453
454
  profile_name, profile = select_profile(loaded, root.profile)
@@ -455,7 +456,7 @@ def _prepare_submission(
455
456
  if selected_model is None:
456
457
  raise _usage(f"--model is required for submit {modality}.")
457
458
  spec = _model_spec(selected_model, endpoint)
458
- if options.batch_metadata and spec.provider not in metadata_providers:
459
+ if options.batch_metadata and not supports_batch_metadata(spec.provider):
459
460
  raise CliUsageError(
460
461
  f"Provider {spec.provider.value} does not support submission-level batch metadata.",
461
462
  code="unsupported_setting",
@@ -624,14 +625,6 @@ async def submit_text(
624
625
  options,
625
626
  modality="text",
626
627
  endpoint=options.endpoint,
627
- metadata_providers=frozenset(
628
- {
629
- BatchProvider.OPENAI,
630
- BatchProvider.GROQ,
631
- BatchProvider.MISTRAL,
632
- BatchProvider.TOGETHER,
633
- }
634
- ),
635
628
  )
636
629
  spec = prepared.spec
637
630
  route = prepared.route
@@ -732,7 +725,6 @@ async def submit_embeddings(
732
725
  options,
733
726
  modality="embeddings",
734
727
  endpoint=None,
735
- metadata_providers=frozenset({BatchProvider.OPENAI, BatchProvider.MISTRAL}),
736
728
  )
737
729
  spec = prepared.spec
738
730
  route = prepared.route
@@ -821,7 +813,6 @@ async def submit_images(
821
813
  options,
822
814
  modality="images",
823
815
  endpoint=None,
824
- metadata_providers=frozenset({BatchProvider.OPENAI}),
825
816
  )
826
817
  spec = prepared.spec
827
818
  route = prepared.route
@@ -19,6 +19,7 @@ from .body import (
19
19
  from .errors import BatchClosedError, BatchworkError, _LimitExceededError
20
20
  from .job import BatchJob
21
21
  from .media import DefaultMediaResolver, MediaResolver, MediaSource, ResolvedMedia
22
+ from .providers._capabilities import validate_batch_metadata
22
23
  from .providers.adapter import BatchAdapter
23
24
  from .types import (
24
25
  AssistantMessage,
@@ -233,6 +234,7 @@ class Batchwork:
233
234
  validate_upload: Callable[[int], None] | None,
234
235
  ) -> BatchJob:
235
236
  spec = resolve_model(model)
237
+ validate_batch_metadata(spec.provider, metadata)
236
238
  self._validate_requests(requests)
237
239
  limits = limits or BatchLimits()
238
240
  validate_request_count(requests, limits)
@@ -255,6 +257,7 @@ class Batchwork:
255
257
  defaults,
256
258
  limits,
257
259
  kind=spec.kind,
260
+ strict=True,
258
261
  )
259
262
  prepared = await self._resolve_request_media(
260
263
  spec,
@@ -265,7 +268,13 @@ class Batchwork:
265
268
  budget=media_budget,
266
269
  )
267
270
  built = build_text_bodies(
268
- spec.provider, spec.model_id, prepared, defaults, limits, kind=spec.kind
271
+ spec.provider,
272
+ spec.model_id,
273
+ prepared,
274
+ defaults,
275
+ limits,
276
+ kind=spec.kind,
277
+ strict=True,
269
278
  )
270
279
  if validate_upload is None:
271
280
  snapshot = await adapter.submit(
@@ -329,13 +338,19 @@ class Batchwork:
329
338
  validate_upload: Callable[[int], None] | None,
330
339
  ) -> BatchJob:
331
340
  spec = resolve_model(model)
341
+ validate_batch_metadata(spec.provider, metadata)
332
342
  self._validate_requests(requests)
333
343
  adapter, resolved_credentials = self._adapter_and_credentials(
334
344
  spec.provider, credentials, api_key=api_key, base_url=base_url, headers=headers
335
345
  )
336
346
  limits = limits or BatchLimits()
337
347
  built = build_embedding_bodies(
338
- spec.provider, spec.model_id, requests, limits, defaults=defaults
348
+ spec.provider,
349
+ spec.model_id,
350
+ requests,
351
+ limits,
352
+ defaults=defaults,
353
+ strict=True,
339
354
  )
340
355
  if validate_upload is None:
341
356
  snapshot = await adapter.submit(
@@ -399,6 +414,7 @@ class Batchwork:
399
414
  validate_upload: Callable[[int], None] | None,
400
415
  ) -> BatchJob:
401
416
  spec = resolve_model(model)
417
+ validate_batch_metadata(spec.provider, metadata)
402
418
  self._validate_requests(requests)
403
419
  adapter, resolved_credentials = self._adapter_and_credentials(
404
420
  spec.provider, credentials, api_key=api_key, base_url=base_url, headers=headers
@@ -4,10 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import inspect
7
+ import math
7
8
  import time
8
9
  from collections.abc import AsyncIterator, Awaitable, Callable
9
10
  from typing import Protocol, runtime_checkable
10
11
 
12
+ from ._read_retry import _ReadRetryDeadlineExceeded, _retry_read
11
13
  from .errors import BatchTimeoutError
12
14
  from .types import (
13
15
  BatchRequestCounts,
@@ -98,10 +100,12 @@ class BatchJob:
98
100
  timeout_seconds: float | None,
99
101
  on_poll: PollCallback | None,
100
102
  ) -> BatchSnapshot:
101
- if poll_interval < 0:
102
- raise ValueError("poll_interval must be non-negative")
103
- if timeout_seconds is not None and timeout_seconds < 0:
104
- raise ValueError("timeout must be non-negative")
103
+ if not math.isfinite(poll_interval) or poll_interval <= 0:
104
+ raise ValueError("poll_interval must be finite and greater than zero")
105
+ if timeout_seconds is not None and (
106
+ not math.isfinite(timeout_seconds) or timeout_seconds < 0
107
+ ):
108
+ raise ValueError("timeout must be finite and non-negative")
105
109
  deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
106
110
 
107
111
  while True:
@@ -110,14 +114,18 @@ class BatchJob:
110
114
  raise BatchTimeoutError(f'batchwork: timed out waiting for batch "{self.id}"')
111
115
 
112
116
  if remaining is None:
113
- snapshot = await self.poll()
117
+ snapshot = await _retry_read(self.poll)
114
118
  await _call(on_poll, snapshot)
115
119
  else:
116
120
  poll_timeout = asyncio.timeout(remaining)
117
121
  try:
118
122
  async with poll_timeout:
119
- snapshot = await self.poll()
123
+ snapshot = await _retry_read(self.poll, deadline=deadline)
120
124
  await _call(on_poll, snapshot)
125
+ except _ReadRetryDeadlineExceeded:
126
+ raise BatchTimeoutError(
127
+ f'batchwork: timed out waiting for batch "{self.id}"'
128
+ ) from None
121
129
  except TimeoutError:
122
130
  if not poll_timeout.expired():
123
131
  raise
@@ -136,6 +144,10 @@ class BatchJob:
136
144
 
137
145
  async def results(self) -> AsyncIterator[BatchResult]:
138
146
  self._ensure_open()
147
+ if is_terminal_status(self.status):
148
+ async for result in self._results_from_current_snapshot():
149
+ yield result
150
+ return
139
151
  async for result in self._adapter.results(self.id, self._credentials):
140
152
  self._ensure_open()
141
153
  yield result
@@ -531,7 +531,15 @@ class DefaultMediaResolver:
531
531
  f"batchwork: media download returned HTTP {response.status_code}"
532
532
  ) from error
533
533
  length = response.headers.get("content-length")
534
- if length is not None and int(length) > max_bytes:
534
+ try:
535
+ declared_length = int(length) if length is not None else None
536
+ except ValueError:
537
+ declared_length = None
538
+ if (
539
+ declared_length is not None
540
+ and declared_length >= 0
541
+ and declared_length > max_bytes
542
+ ):
535
543
  raise _MediaLimitExceededError(
536
544
  f"batchwork: media exceeds the {max_bytes} byte limit"
537
545
  )
@@ -0,0 +1,33 @@
1
+ """Shared provider capability checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from batchwork.errors import _UnsupportedSettingError
8
+ from batchwork.types import BatchProvider
9
+
10
+ BATCH_METADATA_PROVIDERS = frozenset(
11
+ {
12
+ BatchProvider.OPENAI,
13
+ BatchProvider.GROQ,
14
+ BatchProvider.MISTRAL,
15
+ BatchProvider.TOGETHER,
16
+ }
17
+ )
18
+
19
+
20
+ def supports_batch_metadata(provider: BatchProvider) -> bool:
21
+ """Return whether the provider accepts submission-level batch metadata."""
22
+
23
+ return provider in BATCH_METADATA_PROVIDERS
24
+
25
+
26
+ def validate_batch_metadata(provider: BatchProvider, metadata: Mapping[str, str] | None) -> None:
27
+ """Reject unsupported batch metadata before provider-side mutation."""
28
+
29
+ if metadata and not supports_batch_metadata(provider):
30
+ raise _UnsupportedSettingError(
31
+ f'batchwork: provider "{provider.value}" does not support '
32
+ "submission-level batch metadata."
33
+ )
@@ -19,6 +19,7 @@ from batchwork.types import (
19
19
  ProviderCredentials,
20
20
  )
21
21
 
22
+ from ._capabilities import validate_batch_metadata
22
23
  from .ids import simple_provider_id
23
24
  from .shared import (
24
25
  api_key,
@@ -116,7 +117,8 @@ class AnthropicAdapter:
116
117
  limits: BatchLimits | None = None,
117
118
  validate_upload: Callable[[int], None] | None = None,
118
119
  ) -> BatchSnapshot:
119
- del endpoint, model_id, metadata
120
+ validate_batch_metadata(self.id, metadata)
121
+ del endpoint, model_id
120
122
  requests = [
121
123
  {
122
124
  "custom_id": item.custom_id,
@@ -19,6 +19,7 @@ from batchwork.types import (
19
19
  ProviderCredentials,
20
20
  )
21
21
 
22
+ from ._capabilities import validate_batch_metadata
22
23
  from .ids import prefixed_provider_id
23
24
  from .shared import (
24
25
  api_key,
@@ -155,7 +156,7 @@ class GoogleAdapter:
155
156
  limits: BatchLimits | None = None,
156
157
  validate_upload: Callable[[int], None] | None = None,
157
158
  ) -> BatchSnapshot:
158
- del metadata
159
+ validate_batch_metadata(self.id, metadata)
159
160
  embedding = "embedcontent" in endpoint.lower()
160
161
  method = "asyncBatchEmbedContent" if embedding else "batchGenerateContent"
161
162
  requests: list[dict[str, object]] = []