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.
- agent_runtime_client/__init__.py +84 -0
- agent_runtime_client/client.py +319 -0
- agent_runtime_client/errors.py +51 -0
- agent_runtime_client/functions.py +92 -0
- agent_runtime_client/helpers.py +30 -0
- agent_runtime_client/models.py +131 -0
- agent_runtime_client/py.typed +0 -0
- agent_runtime_client/validation.py +493 -0
- agent_runtime_client-0.1.0.dist-info/METADATA +239 -0
- agent_runtime_client-0.1.0.dist-info/RECORD +13 -0
- agent_runtime_client-0.1.0.dist-info/WHEEL +5 -0
- agent_runtime_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_runtime_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|