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.
@@ -0,0 +1,147 @@
1
+ """Media conversion for chat-completion-compatible providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ from collections.abc import Mapping
8
+
9
+ from ._typing import is_string_mapping
10
+ from .errors import BatchworkError
11
+ from .types import BatchProvider
12
+
13
+
14
+ def _media_type(part: Mapping[str, object]) -> str:
15
+ value = part.get("media_type", part.get("mediaType"))
16
+ if isinstance(value, str) and value:
17
+ return value
18
+ raise BatchworkError("batchwork: file part requires a media type.")
19
+
20
+
21
+ def _file_source(part: Mapping[str, object], provider: BatchProvider) -> tuple[str, str]:
22
+ data = part.get("data")
23
+ if isinstance(data, bytes):
24
+ return "data", base64.b64encode(data).decode()
25
+ if isinstance(data, str):
26
+ return ("url", data) if data.startswith(("http://", "https://")) else ("data", data)
27
+ if not is_string_mapping(data):
28
+ raise BatchworkError(f"batchwork: {provider.value} file part data is invalid.")
29
+
30
+ kind = data.get("type")
31
+ if kind == "url":
32
+ url = data.get("url")
33
+ if url is not None:
34
+ return "url", str(url)
35
+ elif kind == "data":
36
+ value = data.get("data")
37
+ if isinstance(value, bytes):
38
+ return "data", base64.b64encode(value).decode()
39
+ if isinstance(value, str):
40
+ return "data", value
41
+ elif kind in {"reference", "text", "provider-file-id"}:
42
+ raise BatchworkError(
43
+ f"batchwork: {provider.value} does not support file data type {kind!r}."
44
+ )
45
+ raise BatchworkError(f"batchwork: {provider.value} file part data is invalid.")
46
+
47
+
48
+ def _data_url(media_type: str, data: str) -> str:
49
+ return f"data:{media_type};base64,{data}"
50
+
51
+
52
+ def _groq_file_content(part: Mapping[str, object]) -> dict[str, object]:
53
+ media_type = _media_type(part)
54
+ if media_type.split("/", 1)[0] != "image":
55
+ raise BatchworkError("batchwork: Groq supports only image file parts.")
56
+ kind, source = _file_source(part, BatchProvider.GROQ)
57
+ return {
58
+ "type": "image_url",
59
+ "image_url": {"url": source if kind == "url" else _data_url(media_type, source)},
60
+ }
61
+
62
+
63
+ def _mistral_file_content(part: Mapping[str, object]) -> dict[str, object]:
64
+ media_type = _media_type(part)
65
+ kind, source = _file_source(part, BatchProvider.MISTRAL)
66
+ value = source if kind == "url" else _data_url(media_type, source)
67
+ if media_type.split("/", 1)[0] == "image":
68
+ return {"type": "image_url", "image_url": value}
69
+ if media_type == "application/pdf":
70
+ return {"type": "document_url", "document_url": value}
71
+ raise BatchworkError("batchwork: Mistral supports only image and PDF file parts.")
72
+
73
+
74
+ def _decode_text(data: str) -> str:
75
+ try:
76
+ return base64.b64decode(data, validate=True).decode()
77
+ except (binascii.Error, UnicodeDecodeError, ValueError) as error:
78
+ raise BatchworkError(
79
+ "batchwork: Together text file data must be valid base64-encoded UTF-8."
80
+ ) from error
81
+
82
+
83
+ def _together_file_content(part: Mapping[str, object]) -> dict[str, object]:
84
+ media_type = _media_type(part)
85
+ top_level = media_type.split("/", 1)[0]
86
+ kind, source = _file_source(part, BatchProvider.TOGETHER)
87
+
88
+ if top_level == "image":
89
+ return {
90
+ "type": "image_url",
91
+ "image_url": {"url": source if kind == "url" else _data_url(media_type, source)},
92
+ }
93
+ if top_level == "audio":
94
+ if kind == "url":
95
+ raise BatchworkError("batchwork: Together does not support audio file URLs.")
96
+ audio_format = (
97
+ "wav"
98
+ if media_type == "audio/wav"
99
+ else "mp3"
100
+ if media_type in {"audio/mp3", "audio/mpeg"}
101
+ else None
102
+ )
103
+ if audio_format is None:
104
+ raise BatchworkError(
105
+ f"batchwork: Together does not support audio media type {media_type!r}."
106
+ )
107
+ return {
108
+ "type": "input_audio",
109
+ "input_audio": {"data": source, "format": audio_format},
110
+ }
111
+ if top_level == "application":
112
+ if kind == "url":
113
+ raise BatchworkError("batchwork: Together does not support PDF file URLs.")
114
+ if media_type != "application/pdf":
115
+ raise BatchworkError(
116
+ f"batchwork: Together does not support file media type {media_type!r}."
117
+ )
118
+ filename = part.get("filename")
119
+ return {
120
+ "type": "file",
121
+ "file": {
122
+ "filename": filename if isinstance(filename, str) else "document.pdf",
123
+ "file_data": _data_url("application/pdf", source),
124
+ },
125
+ }
126
+ if top_level == "text":
127
+ return {"type": "text", "text": source if kind == "url" else _decode_text(source)}
128
+ raise BatchworkError(f"batchwork: Together does not support file media type {media_type!r}.")
129
+
130
+
131
+ def compatible_file_content(
132
+ part: Mapping[str, object], provider: BatchProvider
133
+ ) -> dict[str, object]:
134
+ """Convert a file part to the provider's native chat content shape."""
135
+
136
+ if provider is BatchProvider.GROQ:
137
+ return _groq_file_content(part)
138
+ if provider is BatchProvider.MISTRAL:
139
+ return _mistral_file_content(part)
140
+ if provider is BatchProvider.TOGETHER:
141
+ return _together_file_content(part)
142
+ raise BatchworkError(
143
+ f"batchwork: provider {provider.value!r} does not use compatible media conversion."
144
+ )
145
+
146
+
147
+ __all__ = ["compatible_file_content"]
@@ -0,0 +1,134 @@
1
+ """Google OpenAPI schema conversion for function declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+
7
+ from ._typing import is_string_mapping
8
+
9
+
10
+ def _schema_sequence(value: object) -> list[object] | None:
11
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
12
+ return None
13
+ return list(value)
14
+
15
+
16
+ def _empty_object_schema(schema: Mapping[str, object]) -> bool:
17
+ properties = schema.get("properties")
18
+ no_properties = not isinstance(properties, Mapping) or not properties
19
+ additional_properties = schema.get("additionalProperties")
20
+ return (
21
+ schema.get("type") == "object" and no_properties and additional_properties in (None, False)
22
+ )
23
+
24
+
25
+ def google_openapi_schema(schema: object, *, _root: bool = True) -> dict[str, object] | None:
26
+ """Convert JSON Schema Draft 7 to Google's OpenAPI 3.0 subset."""
27
+
28
+ if schema is None:
29
+ return None
30
+ if isinstance(schema, bool):
31
+ return {"type": "boolean", "properties": {}}
32
+ if not is_string_mapping(schema):
33
+ return None
34
+ if _empty_object_schema(schema):
35
+ if _root:
36
+ return None
37
+ nested: dict[str, object] = {"type": "object"}
38
+ description = schema.get("description")
39
+ if description:
40
+ nested["description"] = description
41
+ return nested
42
+
43
+ result: dict[str, object] = {}
44
+ for key in ("description", "required", "format"):
45
+ value = schema.get(key)
46
+ if value:
47
+ result[key] = value
48
+
49
+ if "const" in schema:
50
+ result["enum"] = [schema["const"]]
51
+
52
+ raw_type = schema.get("type")
53
+ types = _schema_sequence(raw_type)
54
+ if types is not None:
55
+ has_null = "null" in types
56
+ non_null_types = [value for value in types if value != "null"]
57
+ if not non_null_types:
58
+ result["type"] = "null"
59
+ else:
60
+ result["anyOf"] = [{"type": value} for value in non_null_types]
61
+ if has_null:
62
+ result["nullable"] = True
63
+ elif raw_type:
64
+ result["type"] = raw_type
65
+
66
+ if "enum" in schema:
67
+ result["enum"] = schema["enum"]
68
+
69
+ properties = schema.get("properties")
70
+ if isinstance(properties, Mapping):
71
+ converted_properties: dict[str, object] = {}
72
+ for key, value in properties.items():
73
+ if isinstance(key, str) and isinstance(value, (Mapping, bool)):
74
+ converted = google_openapi_schema(value, _root=False)
75
+ if converted is not None:
76
+ converted_properties[key] = converted
77
+ result["properties"] = converted_properties
78
+
79
+ raw_items = schema.get("items")
80
+ items = _schema_sequence(raw_items)
81
+ if items is not None:
82
+ converted_items: list[object] = []
83
+ for item in items:
84
+ if isinstance(item, (Mapping, bool)):
85
+ converted = google_openapi_schema(item, _root=False)
86
+ if converted is not None:
87
+ converted_items.append(converted)
88
+ result["items"] = converted_items
89
+ elif isinstance(raw_items, (Mapping, bool)):
90
+ converted = google_openapi_schema(raw_items, _root=False)
91
+ if converted is not None:
92
+ result["items"] = converted
93
+
94
+ for keyword in ("allOf", "oneOf"):
95
+ branches = _schema_sequence(schema.get(keyword))
96
+ if branches is None:
97
+ continue
98
+ converted_branches: list[object] = []
99
+ for branch in branches:
100
+ if isinstance(branch, (Mapping, bool)):
101
+ converted = google_openapi_schema(branch, _root=False)
102
+ if converted is not None:
103
+ converted_branches.append(converted)
104
+ result[keyword] = converted_branches
105
+
106
+ any_of = _schema_sequence(schema.get("anyOf"))
107
+ if any_of is not None:
108
+ non_null = [
109
+ branch
110
+ for branch in any_of
111
+ if not (isinstance(branch, Mapping) and branch.get("type") == "null")
112
+ ]
113
+ nullable = len(non_null) != len(any_of)
114
+ converted_any_of = [
115
+ converted
116
+ for branch in non_null
117
+ if isinstance(branch, (Mapping, bool))
118
+ and (converted := google_openapi_schema(branch, _root=False)) is not None
119
+ ]
120
+ if nullable and len(converted_any_of) == 1:
121
+ result["nullable"] = True
122
+ result.update(converted_any_of[0])
123
+ else:
124
+ result["anyOf"] = converted_any_of
125
+ if nullable:
126
+ result["nullable"] = True
127
+
128
+ if "minLength" in schema:
129
+ result["minLength"] = schema["minLength"]
130
+
131
+ return result
132
+
133
+
134
+ __all__ = ["google_openapi_schema"]
@@ -0,0 +1,239 @@
1
+ """Private Google message and tool-result serialization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import mimetypes
7
+ from collections.abc import Mapping, Sequence
8
+
9
+ from ._serialization import _messages, _provider_options, _source, _wire
10
+ from ._typing import is_string_mapping
11
+ from .types import BatchProvider
12
+
13
+ SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
14
+
15
+
16
+ def _thought_signature(part: Mapping[str, object]) -> str | None:
17
+ value = _provider_options(part, BatchProvider.GOOGLE).get("thoughtSignature")
18
+ return str(value) if value is not None else None
19
+
20
+
21
+ def _with_signature(
22
+ converted: dict[str, object], part: Mapping[str, object], *, fallback: str | None = None
23
+ ) -> dict[str, object]:
24
+ signature = _thought_signature(part)
25
+ if signature is None:
26
+ signature = fallback
27
+ return {**converted, **({"thoughtSignature": signature} if signature is not None else {})}
28
+
29
+
30
+ def _tool_file(part: Mapping[str, object]) -> tuple[str, bool, str] | None:
31
+ kind = part.get("type")
32
+ media_type = part.get("media_type", part.get("mediaType"))
33
+ raw_data = part.get("data")
34
+ if kind == "file-data":
35
+ raw_data = {"type": "data", "data": raw_data}
36
+ elif kind == "file-url":
37
+ raw_data = {"type": "url", "url": part.get("url")}
38
+ url = part.get("url")
39
+ media_type = media_type or (mimetypes.guess_type(url)[0] if isinstance(url, str) else None)
40
+ elif kind == "image-data":
41
+ raw_data = {"type": "data", "data": raw_data}
42
+ elif kind == "image-url":
43
+ raw_data = {"type": "url", "url": part.get("url")}
44
+ media_type = "image"
45
+ elif kind != "file":
46
+ return None
47
+ if not isinstance(media_type, str):
48
+ media_type = "application/octet-stream"
49
+ source, inline = _source(raw_data, BatchProvider.GOOGLE)
50
+ return (source, inline, media_type) if source is not None else None
51
+
52
+
53
+ def _tool_content(
54
+ name: object, call_id: object, value: object, *, supports_parts: bool
55
+ ) -> list[object]:
56
+ if (
57
+ not isinstance(name, str)
58
+ or not isinstance(value, Sequence)
59
+ or isinstance(value, (str, bytes, bytearray))
60
+ ):
61
+ return []
62
+ if not supports_parts:
63
+ result: list[object] = []
64
+ for item in value:
65
+ if is_string_mapping(item) and item.get("type") == "text":
66
+ result.append(
67
+ {
68
+ "functionResponse": {
69
+ "id": call_id,
70
+ "name": name,
71
+ "response": {"name": name, "content": item.get("text", "")},
72
+ }
73
+ }
74
+ )
75
+ continue
76
+ file = _tool_file(item) if is_string_mapping(item) else None
77
+ if file is not None and file[1] and file[2].split("/", 1)[0] == "image":
78
+ result.extend(
79
+ [
80
+ {"inlineData": {"mimeType": file[2], "data": file[0]}},
81
+ {
82
+ "text": (
83
+ "Tool executed successfully and returned this image as a response"
84
+ )
85
+ },
86
+ ]
87
+ )
88
+ elif item is not None:
89
+ result.append({"text": json.dumps(item, separators=(",", ":"))})
90
+ return result
91
+ text: list[str] = []
92
+ response_parts: list[object] = []
93
+ for item in value:
94
+ if is_string_mapping(item) and item.get("type") == "text":
95
+ text.append(str(item.get("text", "")))
96
+ continue
97
+ file = _tool_file(item) if is_string_mapping(item) else None
98
+ if file is not None and file[1]:
99
+ response_parts.append({"inlineData": {"mimeType": file[2], "data": file[0]}})
100
+ elif item is not None:
101
+ text.append(json.dumps(item, separators=(",", ":")))
102
+ function_response: dict[str, object] = {
103
+ "id": call_id,
104
+ "name": name,
105
+ "response": {
106
+ "name": name,
107
+ "content": "\n".join(text) if text else "Tool executed successfully.",
108
+ },
109
+ }
110
+ if response_parts:
111
+ function_response["parts"] = response_parts
112
+ return [{"functionResponse": function_response}]
113
+
114
+
115
+ def _part(
116
+ part: object,
117
+ *,
118
+ assistant: bool,
119
+ gemini_3: bool,
120
+ supports_response_parts: bool,
121
+ ) -> object | list[object]:
122
+ if isinstance(part, str):
123
+ return {"text": part}
124
+ if not is_string_mapping(part):
125
+ return part
126
+ kind = part.get("type")
127
+ if kind == "text":
128
+ converted = {"text": part.get("text", "")}
129
+ return _with_signature(converted, part) if assistant else converted
130
+ if kind == "reasoning":
131
+ return _with_signature({"text": part.get("text", ""), "thought": True}, part)
132
+ if kind in {"image", "file", "reasoning-file"}:
133
+ raw_source = part.get("image") if kind == "image" else part.get("data")
134
+ source, inline = _source(raw_source, BatchProvider.GOOGLE)
135
+ media_type = part.get("media_type", part.get("mediaType", "application/octet-stream"))
136
+ if source is not None and inline:
137
+ converted = {"inlineData": {"mimeType": media_type, "data": source}}
138
+ if kind == "reasoning-file":
139
+ converted["thought"] = True
140
+ return _with_signature(converted, part) if assistant else converted
141
+ if source is not None:
142
+ converted = {"fileData": {"mimeType": media_type, "fileUri": source}}
143
+ if kind == "reasoning-file":
144
+ converted["thought"] = True
145
+ return _with_signature(converted, part) if assistant else converted
146
+ if kind == "tool-call":
147
+ converted = {
148
+ "functionCall": {
149
+ "id": part.get("tool_call_id", part.get("toolCallId")),
150
+ "name": part.get("tool_name", part.get("toolName")),
151
+ "args": part.get("input", {}),
152
+ }
153
+ }
154
+ fallback = SKIP_THOUGHT_SIGNATURE_VALIDATOR if gemini_3 else None
155
+ return _with_signature(converted, part, fallback=fallback)
156
+ if kind == "tool-result":
157
+ name = part.get("tool_name", part.get("toolName"))
158
+ output = part.get("output", part.get("content", {}))
159
+ if is_string_mapping(output) and output.get("type") == "content":
160
+ return _tool_content(
161
+ name,
162
+ part.get("tool_call_id", part.get("toolCallId")),
163
+ output.get("value"),
164
+ supports_parts=supports_response_parts,
165
+ )
166
+ if is_string_mapping(output):
167
+ content = (
168
+ output.get("reason") or "Tool call execution denied."
169
+ if output.get("type") == "execution-denied"
170
+ else output.get("value")
171
+ )
172
+ else:
173
+ content = output
174
+ return {
175
+ "functionResponse": {
176
+ "id": part.get("tool_call_id", part.get("toolCallId")),
177
+ "name": name,
178
+ "response": {"name": name, "content": content},
179
+ }
180
+ }
181
+ native = {key: value for key, value in part.items() if key != "provider_options"}
182
+ return _wire(part, BatchProvider.GOOGLE, native)
183
+
184
+
185
+ def google_messages(
186
+ item: Mapping[str, object], model_id: str
187
+ ) -> tuple[list[dict[str, object]], dict[str, object] | None]:
188
+ contents: list[dict[str, object]] = []
189
+ system_parts: list[dict[str, str]] = []
190
+ system = item.get("system")
191
+ if isinstance(system, str):
192
+ system_parts.append({"text": system})
193
+ normalized_model_id = model_id.lower()
194
+ gemini_3 = "gemini-3" in normalized_model_id
195
+ is_gemma = normalized_model_id.startswith("gemma-")
196
+ supports_response_parts = gemini_3
197
+ for message in _messages(item):
198
+ role = message.get("role")
199
+ if role == "system":
200
+ content = message.get("content")
201
+ if isinstance(content, str):
202
+ system_parts.append({"text": content})
203
+ continue
204
+ content = message.get("content")
205
+ source_parts = (
206
+ content
207
+ if isinstance(content, Sequence) and not isinstance(content, (str, bytes, bytearray))
208
+ else [content]
209
+ )
210
+ parts: list[object] = []
211
+ for part in source_parts:
212
+ converted = _part(
213
+ part,
214
+ assistant=role == "assistant",
215
+ gemini_3=gemini_3,
216
+ supports_response_parts=supports_response_parts,
217
+ )
218
+ parts.extend(converted if isinstance(converted, list) else [converted])
219
+ contents.append(
220
+ _wire(
221
+ message,
222
+ BatchProvider.GOOGLE,
223
+ {"role": "model" if role == "assistant" else "user", "parts": parts},
224
+ )
225
+ )
226
+ if is_gemma:
227
+ if system_parts and contents and contents[0].get("role") == "user":
228
+ system_text = "\n\n".join(part["text"] for part in system_parts)
229
+ raw_parts = contents[0].get("parts")
230
+ if isinstance(raw_parts, list):
231
+ prefixed_parts: list[object] = [{"text": f"{system_text}\n\n"}]
232
+ prefixed_parts.extend(raw_parts)
233
+ contents[0]["parts"] = prefixed_parts
234
+ return contents, None
235
+ system_instruction: dict[str, object] | None = {"parts": system_parts} if system_parts else None
236
+ return contents, system_instruction
237
+
238
+
239
+ __all__ = ["SKIP_THOUGHT_SIGNATURE_VALIDATOR", "google_messages"]
batchwork/_network.py ADDED
@@ -0,0 +1,79 @@
1
+ """Shared private network-address policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import ipaddress
7
+ import socket
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+
11
+
12
+ class AddressResolutionFailureReason(Enum):
13
+ LOOKUP = "lookup"
14
+ EMPTY = "empty"
15
+ INVALID = "invalid"
16
+ NON_GLOBAL = "non_global"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class AddressResolutionFailure:
21
+ reason: AddressResolutionFailureReason
22
+ address: object | None = None
23
+ cause: Exception | None = None
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ResolvedAddresses:
28
+ addresses: tuple[str, ...]
29
+
30
+
31
+ AddressResolution = ResolvedAddresses | AddressResolutionFailure
32
+
33
+
34
+ def validate_public_address(address: object) -> AddressResolutionFailure | None:
35
+ if not isinstance(address, str):
36
+ return AddressResolutionFailure(AddressResolutionFailureReason.INVALID, address)
37
+ try:
38
+ parsed = ipaddress.ip_address(address)
39
+ except ValueError as error:
40
+ return AddressResolutionFailure(AddressResolutionFailureReason.INVALID, address, error)
41
+ if not parsed.is_global:
42
+ return AddressResolutionFailure(AddressResolutionFailureReason.NON_GLOBAL, address)
43
+ return None
44
+
45
+
46
+ async def resolve_public_addresses(host: str, port: int) -> AddressResolution:
47
+ """Resolve one target and reject its complete address set on any unsafe record."""
48
+
49
+ try:
50
+ literal = ipaddress.ip_address(host)
51
+ except ValueError:
52
+ try:
53
+ records = await asyncio.get_running_loop().getaddrinfo(
54
+ host,
55
+ port,
56
+ family=socket.AF_UNSPEC,
57
+ type=socket.SOCK_STREAM,
58
+ )
59
+ except OSError as error:
60
+ return AddressResolutionFailure(AddressResolutionFailureReason.LOOKUP, cause=error)
61
+ addresses: list[str] = []
62
+ for record in records:
63
+ address = record[4][0]
64
+ if not isinstance(address, str):
65
+ return AddressResolutionFailure(AddressResolutionFailureReason.INVALID, address)
66
+ failure = validate_public_address(address)
67
+ if failure is not None:
68
+ return failure
69
+ if address not in addresses:
70
+ addresses.append(address)
71
+ else:
72
+ address = str(literal)
73
+ failure = validate_public_address(address)
74
+ if failure is not None:
75
+ return failure
76
+ addresses = [address]
77
+ if not addresses:
78
+ return AddressResolutionFailure(AddressResolutionFailureReason.EMPTY)
79
+ return ResolvedAddresses(tuple(addresses))