agent-runtime-client 0.1.0__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,84 @@
1
+ """Python client for agent-runtime: generate_object() / generate_text() over the REST API, plus the admin API.
2
+
3
+ Import name ``agent_runtime_client``, distribution ``agent-runtime-client``. Zero dependencies, Python 3.9+.
4
+ """
5
+ from .client import DEFAULT_BASE_URL, DEFAULT_MAX_ATTEMPTS, AgentRuntimeClient, Transport, create_client, resolve_base_url, urllib_transport
6
+ from .errors import AgentRuntimeError, is_inference_failure, is_invalid_format
7
+ from .functions import generate_object, generate_text, inference, text
8
+ from .helpers import encode_file, encode_image, user_message
9
+ from .models import (
10
+ Attachment,
11
+ AttachmentKind,
12
+ AttachmentMeta,
13
+ FormatModel,
14
+ Health,
15
+ InferenceError,
16
+ InferenceErrorCode,
17
+ InferenceFailure,
18
+ InferenceMode,
19
+ InferenceRecord,
20
+ InferenceResponse,
21
+ InferenceStatus,
22
+ InferenceSuccess,
23
+ InferenceSummary,
24
+ ListInferencesResponse,
25
+ RequestFile,
26
+ RequestMessage,
27
+ RequestRole,
28
+ RunInfo,
29
+ StoredRequest,
30
+ TextResponse,
31
+ TextSuccess,
32
+ )
33
+ from .validation import ValidationResult, assert_answer, resolve_ref, type_of, validate_answer, validate_tool_arguments, validate_value
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "AgentRuntimeClient",
39
+ "AgentRuntimeError",
40
+ "Attachment",
41
+ "AttachmentKind",
42
+ "AttachmentMeta",
43
+ "DEFAULT_BASE_URL",
44
+ "DEFAULT_MAX_ATTEMPTS",
45
+ "FormatModel",
46
+ "Health",
47
+ "InferenceError",
48
+ "InferenceErrorCode",
49
+ "InferenceFailure",
50
+ "InferenceMode",
51
+ "InferenceRecord",
52
+ "InferenceResponse",
53
+ "InferenceStatus",
54
+ "InferenceSuccess",
55
+ "InferenceSummary",
56
+ "ListInferencesResponse",
57
+ "RequestFile",
58
+ "RequestMessage",
59
+ "RequestRole",
60
+ "RunInfo",
61
+ "StoredRequest",
62
+ "TextResponse",
63
+ "TextSuccess",
64
+ "Transport",
65
+ "ValidationResult",
66
+ "assert_answer",
67
+ "create_client",
68
+ "encode_file",
69
+ "encode_image",
70
+ "generate_object",
71
+ "generate_text",
72
+ "inference",
73
+ "is_inference_failure",
74
+ "is_invalid_format",
75
+ "resolve_base_url",
76
+ "resolve_ref",
77
+ "text",
78
+ "type_of",
79
+ "urllib_transport",
80
+ "user_message",
81
+ "validate_answer",
82
+ "validate_tool_arguments",
83
+ "validate_value",
84
+ ]
@@ -0,0 +1,319 @@
1
+ """Typed client for agent-runtime over the standard library."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import socket
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
11
+
12
+ from .errors import AgentRuntimeError, is_inference_failure, is_invalid_format
13
+ from .models import (
14
+ Attachment,
15
+ FormatModel,
16
+ Health,
17
+ InferenceFailure,
18
+ InferenceRecord,
19
+ InferenceResponse,
20
+ ListInferencesResponse,
21
+ RequestFile,
22
+ RequestMessage,
23
+ TextResponse,
24
+ )
25
+ from .validation import validate_tool_arguments
26
+
27
+ DEFAULT_BASE_URL = "http://localhost:10050"
28
+ """Default server address; the ``AGENT_RUNTIME_URL`` environment variable overrides it."""
29
+
30
+ DEFAULT_MAX_ATTEMPTS = 3
31
+ """Inferences ``generate_object`` / ``inference`` run before giving up on an answer the client-side validator rejects."""
32
+
33
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], Optional[float]], Tuple[int, Dict[str, str], bytes]]
34
+ """``transport(method, url, headers, body, timeout) -> (status, headers, body)``; the default uses ``urllib``."""
35
+
36
+ _UNSET: Any = object()
37
+
38
+
39
+ def resolve_base_url(base_url: Optional[str] = None) -> str:
40
+ return base_url or os.environ.get("AGENT_RUNTIME_URL") or DEFAULT_BASE_URL
41
+
42
+
43
+ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: Optional[float]) -> Tuple[int, Dict[str, str], bytes]:
44
+ """Default transport. ``timeout=None`` waits as long as the server takes: an inference can run for minutes."""
45
+ request = urllib.request.Request(url, data=body, method=method, headers=headers)
46
+ try:
47
+ with urllib.request.urlopen(request, timeout=timeout) as response:
48
+ return response.status, {key.lower(): value for key, value in response.headers.items()}, response.read()
49
+ except urllib.error.HTTPError as error:
50
+ return error.code, {key.lower(): value for key, value in error.headers.items()}, error.read()
51
+
52
+
53
+ class AgentRuntimeClient:
54
+ """Client for agent-runtime.
55
+
56
+ Every method raises :class:`AgentRuntimeError` on transport or HTTP errors. ``inference()`` and
57
+ ``text()`` return the typed failure instead of raising when the agent itself failed (HTTP 500
58
+ with an InferenceFailure body); ``generate_object()`` and ``generate_text()`` raise for it.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ base_url: Optional[str] = None,
64
+ *,
65
+ headers: Optional[Dict[str, str]] = None,
66
+ timeout: Optional[float] = None,
67
+ validate_answer: bool = True,
68
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
69
+ transport: Optional[Transport] = None,
70
+ ) -> None:
71
+ """
72
+ :param base_url: server address, default ``AGENT_RUNTIME_URL`` or ``http://localhost:10050``.
73
+ :param headers: headers added to every request, e.g. a reverse-proxy token.
74
+ :param timeout: seconds to wait for a response; ``None`` (default) waits as long as the inference takes.
75
+ :param validate_answer: check every successful answer against ``format`` with the server's validator and
76
+ run the inference again when it does not match (default True).
77
+ :param max_attempts: inferences to run before failing with ``schema_mismatch`` when the client-side
78
+ validation keeps rejecting (default 3).
79
+ :param transport: replacement for the HTTP layer, mostly for tests; see :data:`Transport`.
80
+ """
81
+ self.base_url = resolve_base_url(base_url).rstrip("/")
82
+ self.headers = dict(headers or {})
83
+ self.timeout = timeout
84
+ self.validate_answer = validate_answer
85
+ self.max_attempts = max(1, int(max_attempts))
86
+ self.transport = transport or urllib_transport
87
+
88
+ # ----------------------------------------------------------- inference
89
+
90
+ def inference(
91
+ self,
92
+ messages: List[RequestMessage],
93
+ format: FormatModel,
94
+ *,
95
+ files: Optional[List[RequestFile]] = None,
96
+ model: Optional[str] = None,
97
+ instructions: Optional[str] = None,
98
+ headers: Optional[Dict[str, str]] = None,
99
+ timeout: Optional[float] = _UNSET,
100
+ ) -> InferenceResponse:
101
+ """POST /api/v1/inference. Returns the full response, success or failure; ``status`` discriminates.
102
+
103
+ With ``validate_answer`` (default) a successful answer is checked against ``format`` locally; a
104
+ mismatch runs the inference again, up to ``max_attempts`` times in total, and then returns a
105
+ failure with code ``schema_mismatch`` whose ``details`` list every rejected attempt.
106
+ """
107
+ body = _body(messages, files, model, instructions, format)
108
+ details: List[str] = []
109
+ attempts = turns = duration_ms = 0
110
+ last: Optional[Dict[str, Any]] = None
111
+ for _ in range(self.max_attempts):
112
+ response = self._run_agent("/api/v1/inference", body, headers, timeout)
113
+ last = response
114
+ attempts += int(response.get("attempts") or 0)
115
+ turns += int(response.get("turns") or 0)
116
+ duration_ms += int(response.get("durationMs") or 0)
117
+ if response.get("status") != "success" or not self.validate_answer:
118
+ return response # type: ignore[return-value]
119
+ result = validate_tool_arguments(response.get("data"), format)
120
+ if result.success:
121
+ return response # type: ignore[return-value]
122
+ details.append(f"inference {response.get('id')}: {result.error}")
123
+ assert last is not None
124
+ last_reason = details[-1].split(": ", 1)[1] if details else ""
125
+ failure: InferenceFailure = {
126
+ "id": last.get("id", ""),
127
+ "model": last.get("model", ""),
128
+ "attempts": attempts,
129
+ "turns": turns,
130
+ "durationMs": duration_ms,
131
+ "status": "error",
132
+ "error": {
133
+ "code": "schema_mismatch",
134
+ "message": f"Answer rejected by the client-side validator in {self.max_attempts} inference(s): {last_reason}",
135
+ "details": details,
136
+ },
137
+ }
138
+ return failure
139
+
140
+ def generate_object(
141
+ self,
142
+ messages: List[RequestMessage],
143
+ format: FormatModel,
144
+ *,
145
+ files: Optional[List[RequestFile]] = None,
146
+ model: Optional[str] = None,
147
+ instructions: Optional[str] = None,
148
+ headers: Optional[Dict[str, str]] = None,
149
+ timeout: Optional[float] = _UNSET,
150
+ ) -> Dict[str, Any]:
151
+ """The json-inference ``generateObject`` equivalent: returns the answer matching ``format``,
152
+ raises :class:`AgentRuntimeError` when the agent failed."""
153
+ response = self.inference(messages, format, files=files, model=model, instructions=instructions, headers=headers, timeout=timeout)
154
+ if response["status"] == "error":
155
+ error = response["error"]
156
+ raise AgentRuntimeError(f"{error['code']}: {error['message']}", 500, response, response)
157
+ return response["data"]
158
+
159
+ def text(
160
+ self,
161
+ messages: List[RequestMessage],
162
+ *,
163
+ files: Optional[List[RequestFile]] = None,
164
+ model: Optional[str] = None,
165
+ instructions: Optional[str] = None,
166
+ headers: Optional[Dict[str, str]] = None,
167
+ timeout: Optional[float] = _UNSET,
168
+ ) -> TextResponse:
169
+ """POST /api/v1/text. Returns the full response, success or failure."""
170
+ return self._run_agent("/api/v1/text", _body(messages, files, model, instructions), headers, timeout) # type: ignore[return-value]
171
+
172
+ def generate_text(
173
+ self,
174
+ messages: List[RequestMessage],
175
+ *,
176
+ files: Optional[List[RequestFile]] = None,
177
+ model: Optional[str] = None,
178
+ instructions: Optional[str] = None,
179
+ headers: Optional[Dict[str, str]] = None,
180
+ timeout: Optional[float] = _UNSET,
181
+ ) -> str:
182
+ """The json-inference ``generateText`` equivalent: returns the final message, raises when the agent failed."""
183
+ response = self.text(messages, files=files, model=model, instructions=instructions, headers=headers, timeout=timeout)
184
+ if response["status"] == "error":
185
+ error = response["error"]
186
+ raise AgentRuntimeError(f"{error['code']}: {error['message']}", 500, response, response)
187
+ return response["text"]
188
+
189
+ # --------------------------------------------------------------- admin
190
+
191
+ def health(self) -> Health:
192
+ return self._json("GET", "/api/v1/health")
193
+
194
+ def list_inferences(
195
+ self,
196
+ *,
197
+ limit: Optional[int] = None,
198
+ offset: Optional[int] = None,
199
+ status: Optional[str] = None,
200
+ mode: Optional[str] = None,
201
+ q: Optional[str] = None,
202
+ ) -> ListInferencesResponse:
203
+ """Lists inferences, newest first. ``q`` is a case-insensitive substring search over request, result, error, id and model."""
204
+ query: Dict[str, str] = {}
205
+ if limit is not None:
206
+ query["limit"] = str(limit)
207
+ if offset is not None:
208
+ query["offset"] = str(offset)
209
+ if status:
210
+ query["status"] = status
211
+ if mode:
212
+ query["mode"] = mode
213
+ if q:
214
+ query["q"] = q
215
+ suffix = f"?{urllib.parse.urlencode(query)}" if query else ""
216
+ return self._json("GET", f"/api/v1/inferences{suffix}")
217
+
218
+ def get_inference(self, inference_id: str) -> InferenceRecord:
219
+ """Request, transcript, result or error and attachment metadata of one run."""
220
+ return self._json("GET", f"/api/v1/inferences/{urllib.parse.quote(inference_id, safe='')}")
221
+
222
+ def delete_inference(self, inference_id: str) -> Dict[str, bool]:
223
+ return self._json("DELETE", f"/api/v1/inferences/{urllib.parse.quote(inference_id, safe='')}")
224
+
225
+ def get_attachment(self, inference_id: str, attachment_id: str) -> Attachment:
226
+ """Downloads an attachment (request file, image, tool image or produced file) with its metadata."""
227
+ record = self.get_inference(inference_id)
228
+ meta = next((item for item in record["attachments"] if item["id"] == attachment_id), None)
229
+ if meta is None:
230
+ raise AgentRuntimeError(f"Attachment {attachment_id} not found in inference {inference_id}", 404, None)
231
+ path = f"/api/v1/inferences/{urllib.parse.quote(inference_id, safe='')}/attachments/{urllib.parse.quote(attachment_id, safe='')}"
232
+ status, _, payload = self._request("GET", path, None, None, _UNSET, accept="*/*")
233
+ if status >= 400:
234
+ raise AgentRuntimeError(f"HTTP {status} while downloading attachment", status, payload.decode("utf-8", "replace"))
235
+ attachment: Attachment = {**meta, "data": payload} # type: ignore[typeddict-item]
236
+ return attachment
237
+
238
+ # ------------------------------------------------------------ internals
239
+
240
+ def _run_agent(self, path: str, body: Dict[str, Any], headers: Optional[Dict[str, str]], timeout: Optional[float]) -> Dict[str, Any]:
241
+ status, _, raw = self._request("POST", path, body, headers, timeout)
242
+ payload = _parse_body(raw)
243
+ if status < 400:
244
+ return payload
245
+ if status == 500 and is_inference_failure(payload):
246
+ return payload
247
+ if is_invalid_format(payload):
248
+ # 400: the schema itself was rejected before any inference ran
249
+ raise AgentRuntimeError(f"{payload['error']['code']}: {payload['error']['message']}", status, payload, None, payload["error"]["code"])
250
+ raise AgentRuntimeError(_describe(status, payload), status, payload)
251
+
252
+ def _json(self, method: str, path: str) -> Any:
253
+ status, _, raw = self._request(method, path, None, None, _UNSET)
254
+ payload = _parse_body(raw)
255
+ if status >= 400:
256
+ raise AgentRuntimeError(_describe(status, payload), status, payload)
257
+ return payload
258
+
259
+ def _request(
260
+ self,
261
+ method: str,
262
+ path: str,
263
+ body: Any,
264
+ headers: Optional[Dict[str, str]],
265
+ timeout: Optional[float],
266
+ accept: str = "application/json",
267
+ ) -> Tuple[int, Dict[str, str], bytes]:
268
+ merged = {"accept": accept, **self.headers, **(headers or {})}
269
+ data: Optional[bytes] = None
270
+ if body is not None:
271
+ merged["content-type"] = "application/json"
272
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
273
+ url = f"{self.base_url}{path}"
274
+ try:
275
+ return self.transport(method, url, merged, data, self.timeout if timeout is _UNSET else timeout)
276
+ except AgentRuntimeError:
277
+ raise
278
+ except (urllib.error.URLError, socket.timeout, OSError, ValueError) as error:
279
+ reason = getattr(error, "reason", None) or error
280
+ raise AgentRuntimeError(f"Request to {url} failed: {reason}", 0, None) from error
281
+
282
+
283
+ def _body(
284
+ messages: List[RequestMessage],
285
+ files: Optional[List[RequestFile]],
286
+ model: Optional[str],
287
+ instructions: Optional[str],
288
+ format: Optional[FormatModel] = None,
289
+ ) -> Dict[str, Any]:
290
+ body: Dict[str, Any] = {"messages": list(messages)}
291
+ if files:
292
+ body["files"] = list(files)
293
+ if format is not None:
294
+ body["format"] = format
295
+ if model:
296
+ body["model"] = model
297
+ if instructions:
298
+ body["instructions"] = instructions
299
+ return body
300
+
301
+
302
+ def _parse_body(raw: bytes) -> Any:
303
+ if not raw:
304
+ return None
305
+ text = raw.decode("utf-8", "replace")
306
+ try:
307
+ return json.loads(text)
308
+ except ValueError:
309
+ return text
310
+
311
+
312
+ def _describe(status: int, payload: Any) -> str:
313
+ if isinstance(payload, dict) and isinstance(payload.get("message"), str):
314
+ return f"HTTP {status}: {payload['message']}"
315
+ return f"HTTP {status}"
316
+
317
+
318
+ def create_client(base_url: Optional[str] = None, **options: Any) -> AgentRuntimeClient:
319
+ return AgentRuntimeClient(base_url, **options)
@@ -0,0 +1,51 @@
1
+ """Errors raised by the client."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ class AgentRuntimeError(Exception):
8
+ """Raised for any transport or HTTP failure.
9
+
10
+ When the server answered with an InferenceFailure body (HTTP 500 from
11
+ /inference or /text) the typed failure is in ``failure`` and ``code`` is the
12
+ server's InferenceErrorCode. ``status`` is 0 for network failures.
13
+ """
14
+
15
+ def __init__(self, message: str, status: int, body: Any, failure: Optional[Dict[str, Any]] = None, code: Optional[str] = None) -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.body = body
19
+ self.failure = failure
20
+ self._explicit_code = code
21
+
22
+ @property
23
+ def code(self) -> Optional[str]:
24
+ """InferenceErrorCode when the server returned a typed failure or rejected the schema (``invalid_format``), otherwise None."""
25
+ if self._explicit_code is not None:
26
+ return self._explicit_code
27
+ if self.failure is not None:
28
+ return self.failure.get("error", {}).get("code")
29
+ return None
30
+
31
+ @property
32
+ def details(self) -> List[str]:
33
+ """Per-attempt details when the server returned a typed failure."""
34
+ if self.failure is None:
35
+ return []
36
+ return list(self.failure.get("error", {}).get("details") or [])
37
+
38
+ @property
39
+ def inference_id(self) -> Optional[str]:
40
+ """Inference id (present for typed failures) for looking the run up in the admin UI."""
41
+ return self.failure.get("id") if self.failure is not None else None
42
+
43
+
44
+ def is_inference_failure(value: Any) -> bool:
45
+ """True for a server body of the shape ``{"status": "error", "error": {"code": ...}}``."""
46
+ return isinstance(value, dict) and value.get("status") == "error" and isinstance((value.get("error") or {}).get("code"), str)
47
+
48
+
49
+ def is_invalid_format(value: Any) -> bool:
50
+ """True for the 400 body returned when ``format`` is not a valid JSON Schema."""
51
+ return is_inference_failure(value) and value["error"]["code"] == "invalid_format"
@@ -0,0 +1,92 @@
1
+ """Standalone ``generate_object`` / ``generate_text`` functions in the json-inference style: each call creates a client."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from .client import AgentRuntimeClient, Transport
7
+ from .models import FormatModel, InferenceResponse, RequestFile, RequestMessage, TextResponse
8
+
9
+
10
+ def _client(base_url: Optional[str], headers: Optional[Dict[str, str]], timeout: Optional[float], validate_answer: bool, max_attempts: int, transport: Optional[Transport]) -> AgentRuntimeClient:
11
+ return AgentRuntimeClient(base_url, headers=headers, timeout=timeout, validate_answer=validate_answer, max_attempts=max_attempts, transport=transport)
12
+
13
+
14
+ def generate_object(
15
+ messages: List[RequestMessage],
16
+ format: FormatModel,
17
+ *,
18
+ files: Optional[List[RequestFile]] = None,
19
+ model: Optional[str] = None,
20
+ instructions: Optional[str] = None,
21
+ base_url: Optional[str] = None,
22
+ headers: Optional[Dict[str, str]] = None,
23
+ timeout: Optional[float] = None,
24
+ validate_answer: bool = True,
25
+ max_attempts: int = 3,
26
+ transport: Optional[Transport] = None,
27
+ ) -> Dict[str, Any]:
28
+ """Runs the agent on agent-runtime and returns the answer matching ``format``.
29
+
30
+ ``base_url`` defaults to ``AGENT_RUNTIME_URL`` or ``http://localhost:10050``. Raises
31
+ :class:`~agent_runtime_client.AgentRuntimeError` when the agent failed.
32
+
33
+ >>> entry = generate_object(
34
+ ... [{"role": "user", "content": "..."}],
35
+ ... {"type": "object", "required": ["position"], "properties": {"position": {"type": "string", "enum": ["long", "short"]}}},
36
+ ... )
37
+ >>> entry["position"]
38
+ 'long'
39
+ """
40
+ return _client(base_url, headers, timeout, validate_answer, max_attempts, transport).generate_object(
41
+ messages, format, files=files, model=model, instructions=instructions
42
+ )
43
+
44
+
45
+ def inference(
46
+ messages: List[RequestMessage],
47
+ format: FormatModel,
48
+ *,
49
+ files: Optional[List[RequestFile]] = None,
50
+ model: Optional[str] = None,
51
+ instructions: Optional[str] = None,
52
+ base_url: Optional[str] = None,
53
+ headers: Optional[Dict[str, str]] = None,
54
+ timeout: Optional[float] = None,
55
+ validate_answer: bool = True,
56
+ max_attempts: int = 3,
57
+ transport: Optional[Transport] = None,
58
+ ) -> InferenceResponse:
59
+ """Same request as :func:`generate_object`, but returns the full response (``status`` is ``"success"`` or ``"error"``) instead of raising."""
60
+ return _client(base_url, headers, timeout, validate_answer, max_attempts, transport).inference(
61
+ messages, format, files=files, model=model, instructions=instructions
62
+ )
63
+
64
+
65
+ def generate_text(
66
+ messages: List[RequestMessage],
67
+ *,
68
+ files: Optional[List[RequestFile]] = None,
69
+ model: Optional[str] = None,
70
+ instructions: Optional[str] = None,
71
+ base_url: Optional[str] = None,
72
+ headers: Optional[Dict[str, str]] = None,
73
+ timeout: Optional[float] = None,
74
+ transport: Optional[Transport] = None,
75
+ ) -> str:
76
+ """Runs the agent and returns its final message as plain text. Raises :class:`~agent_runtime_client.AgentRuntimeError` when the agent failed."""
77
+ return _client(base_url, headers, timeout, True, 1, transport).generate_text(messages, files=files, model=model, instructions=instructions)
78
+
79
+
80
+ def text(
81
+ messages: List[RequestMessage],
82
+ *,
83
+ files: Optional[List[RequestFile]] = None,
84
+ model: Optional[str] = None,
85
+ instructions: Optional[str] = None,
86
+ base_url: Optional[str] = None,
87
+ headers: Optional[Dict[str, str]] = None,
88
+ timeout: Optional[float] = None,
89
+ transport: Optional[Transport] = None,
90
+ ) -> TextResponse:
91
+ """Same request as :func:`generate_text`, but returns the full response instead of raising."""
92
+ return _client(base_url, headers, timeout, True, 1, transport).text(messages, files=files, model=model, instructions=instructions)
@@ -0,0 +1,30 @@
1
+ """Request helpers: base64 encoding of files and images."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ from typing import List, Optional, Union
6
+
7
+ from .models import RequestFile, RequestMessage
8
+
9
+
10
+ def _to_base64(data: Union[bytes, bytearray, memoryview, str]) -> str:
11
+ raw = data.encode("utf-8") if isinstance(data, str) else bytes(data)
12
+ return base64.b64encode(raw).decode("ascii")
13
+
14
+
15
+ def encode_file(name: str, data: Union[bytes, bytearray, memoryview, str]) -> RequestFile:
16
+ """Builds a request file from raw bytes or a UTF-8 string."""
17
+ return {"name": name, "content": _to_base64(data)}
18
+
19
+
20
+ def encode_image(data: Union[bytes, bytearray, memoryview]) -> str:
21
+ """Encodes image bytes (jpeg, png, gif, webp, bmp) as the base64 string expected in ``messages[].images``."""
22
+ return _to_base64(data)
23
+
24
+
25
+ def user_message(content: str, images: Optional[List[Union[bytes, bytearray, memoryview]]] = None) -> RequestMessage:
26
+ """Shorthand for a user message with optional image attachments."""
27
+ message: RequestMessage = {"role": "user", "content": content}
28
+ if images:
29
+ message["images"] = [encode_image(image) for image in images]
30
+ return message