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.
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/PKG-INFO +5 -1
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/README.md +4 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/pyproject.toml +1 -1
- batchwork_ai-0.2.3/src/batchwork/_base_url.py +56 -0
- batchwork_ai-0.2.3/src/batchwork/_read_retry.py +89 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_config.py +5 -28
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_contract.py +2 -1
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_failures.py +11 -1
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_lifecycle.py +6 -77
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_submit_text.py +4 -13
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/client.py +18 -2
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/job.py +18 -6
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/media.py +9 -1
- batchwork_ai-0.2.3/src/batchwork/providers/_capabilities.py +33 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/anthropic.py +3 -1
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/google.py +2 -1
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/mistral.py +32 -18
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/openai_compatible.py +32 -21
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/shared.py +34 -2
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/xai.py +64 -7
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/types.py +55 -2
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/LICENSE +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/__init__.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_anthropic_serialization.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_bounded_json.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_compatible_media.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_google_schema.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_google_serialization.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_limits.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_network.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_provider_failure.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_serialization.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_text_validation.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/_typing.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/body.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/__init__.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_commands.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_human.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_input.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_materialize.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_output.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_registry.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_state.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/cli/_volume.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/errors.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/__init__.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/_image.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/adapter.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/groq.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/ids.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/openai.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/providers/together.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/py.typed +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/__init__.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/models.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/poller.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/signing.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/server/transport.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/stores/__init__.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/stores/base.py +0 -0
- {batchwork_ai-0.2.2 → batchwork_ai-0.2.3}/src/batchwork/stores/memory.py +0 -0
- {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.
|
|
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)
|
|
@@ -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
|
-
|
|
143
|
-
except
|
|
144
|
-
raise ConfigError(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
102
|
-
raise ValueError("poll_interval must be
|
|
103
|
-
if timeout_seconds is not None and
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]] = []
|