batchwork-ai 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- batchwork/__init__.py +261 -0
- batchwork/_anthropic_serialization.py +432 -0
- batchwork/_compatible_media.py +147 -0
- batchwork/_google_schema.py +134 -0
- batchwork/_google_serialization.py +239 -0
- batchwork/_network.py +79 -0
- batchwork/_serialization.py +1467 -0
- batchwork/_typing.py +15 -0
- batchwork/body.py +1170 -0
- batchwork/client.py +489 -0
- batchwork/errors.py +47 -0
- batchwork/job.py +145 -0
- batchwork/media.py +452 -0
- batchwork/providers/__init__.py +46 -0
- batchwork/providers/adapter.py +36 -0
- batchwork/providers/anthropic.py +246 -0
- batchwork/providers/google.py +309 -0
- batchwork/providers/groq.py +5 -0
- batchwork/providers/ids.py +22 -0
- batchwork/providers/mistral.py +153 -0
- batchwork/providers/openai.py +5 -0
- batchwork/providers/openai_compatible.py +373 -0
- batchwork/providers/shared.py +383 -0
- batchwork/providers/together.py +5 -0
- batchwork/providers/xai.py +217 -0
- batchwork/py.typed +1 -0
- batchwork/server/__init__.py +77 -0
- batchwork/server/models.py +93 -0
- batchwork/server/poller.py +321 -0
- batchwork/server/signing.py +262 -0
- batchwork/server/transport.py +232 -0
- batchwork/stores/__init__.py +14 -0
- batchwork/stores/base.py +24 -0
- batchwork/stores/memory.py +44 -0
- batchwork/stores/redis.py +110 -0
- batchwork/types.py +685 -0
- batchwork_ai-0.1.1.dist-info/METADATA +143 -0
- batchwork_ai-0.1.1.dist-info/RECORD +40 -0
- batchwork_ai-0.1.1.dist-info/WHEEL +4 -0
- batchwork_ai-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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))
|