ap-client 0.1.4.dev0__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.
ap_client/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Agent Platform API Client"""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .api import APIClient, get_client
6
+ from .config import Config, get_config
7
+
8
+ try:
9
+ __version__ = version("ap-client")
10
+ except PackageNotFoundError:
11
+ __version__ = "0.1.4"
12
+
13
+ __all__ = ["APIClient", "Config", "__version__", "get_client", "get_config"]
ap_client/api.py ADDED
@@ -0,0 +1,516 @@
1
+ """API client."""
2
+
3
+ import json as _json
4
+ import platform
5
+ import sys
6
+ import time
7
+ import uuid
8
+ from typing import Any, Optional
9
+ from urllib.parse import quote
10
+
11
+ import requests
12
+
13
+ from .config import Config, get_config
14
+
15
+ _GROUP_ARTIFACTS_PAGE_SIZE = 500
16
+ _VERBOSE_SAMPLE_KEYS = 5
17
+ _VERBOSE_SAMPLE_ITEMS = 3
18
+ _VERBOSE_VALUE_PREVIEW = 120
19
+
20
+
21
+ class APIClient:
22
+ """Agent Platform API client."""
23
+
24
+ def __init__(self, config: Config):
25
+ self.config = config
26
+ self.session = requests.Session()
27
+ headers = dict(config.headers)
28
+ user_agent = _build_user_agent(headers.get("User-Agent"))
29
+ headers["User-Agent"] = user_agent
30
+ self.session.headers.update(
31
+ {
32
+ "Content-Type": "application/json",
33
+ **headers,
34
+ }
35
+ )
36
+
37
+ def _url(self, path: str) -> str:
38
+ """Build the full URL."""
39
+ if (
40
+ path.startswith("/")
41
+ and "/api" not in self.config.base_url
42
+ and "nlb-" not in self.config.base_url
43
+ and "localhost" not in self.config.base_url
44
+ ):
45
+ return f"{self.config.base_url}/api{path}"
46
+ return f"{self.config.base_url}{path}"
47
+
48
+ def _request(
49
+ self,
50
+ method: str,
51
+ path: str,
52
+ *,
53
+ params: Optional[dict] = None,
54
+ json_body: Any = None,
55
+ timeout: float = 30,
56
+ ) -> Any:
57
+ """Send an HTTP request with X-Request-ID and optional verbose logging."""
58
+ url = self._url(path)
59
+ request_id = str(uuid.uuid4())
60
+ headers = {"X-Request-ID": request_id}
61
+ verbose = self.config.verbose
62
+ if verbose:
63
+ self._log_request(method, url, request_id, params, json_body)
64
+ start = time.perf_counter()
65
+ try:
66
+ resp = self.session.request(
67
+ method,
68
+ url,
69
+ params=params,
70
+ json=json_body,
71
+ headers=headers,
72
+ timeout=timeout,
73
+ )
74
+ except Exception as exc:
75
+ if verbose:
76
+ duration_ms = (time.perf_counter() - start) * 1000
77
+ self._log_exception(method, url, request_id, duration_ms, exc)
78
+ raise
79
+ duration_ms = (time.perf_counter() - start) * 1000
80
+ if verbose:
81
+ self._log_response(method, url, request_id, resp, duration_ms)
82
+ if not resp.ok:
83
+ try:
84
+ err_data = resp.json()
85
+ detail = err_data.get("detail", resp.text)
86
+ except Exception:
87
+ detail = resp.text
88
+ raise Exception(
89
+ f"API Error {resp.status_code}: {detail} (request_id={request_id})"
90
+ )
91
+ if not resp.content:
92
+ return None
93
+ return resp.json()
94
+
95
+ def _get(self, path: str, params: Optional[dict] = None) -> Any:
96
+ """Send a GET request."""
97
+ return self._request("GET", path, params=params)
98
+
99
+ def _post(self, path: str, data: Any, timeout: float = 30) -> Any:
100
+ """Send a POST request."""
101
+ return self._request("POST", path, json_body=data, timeout=timeout)
102
+
103
+ def _delete(self, path: str) -> Any:
104
+ """Send a DELETE request."""
105
+ return self._request("DELETE", path)
106
+
107
+ # ==================== Verbose logging ====================
108
+
109
+ def _log_request(
110
+ self,
111
+ method: str,
112
+ url: str,
113
+ request_id: str,
114
+ params: Optional[dict],
115
+ body: Any,
116
+ ) -> None:
117
+ lines = [f">> {method} {url}", f" request_id={request_id}"]
118
+ if params:
119
+ lines.append(f" params={_safe_json(params)}")
120
+ if body is not None:
121
+ encoded = _safe_json(body).encode("utf-8", errors="replace")
122
+ lines.append(f" body_size={len(encoded)}")
123
+ preview = self._summarize_body(body, encoded)
124
+ if preview:
125
+ lines.append(f" body={preview}")
126
+ _write_stderr(lines)
127
+
128
+ def _log_response(
129
+ self,
130
+ method: str,
131
+ url: str,
132
+ request_id: str,
133
+ resp: requests.Response,
134
+ duration_ms: float,
135
+ ) -> None:
136
+ raw = resp.content or b""
137
+ lines = [
138
+ f"<< {method} {url}",
139
+ f" request_id={request_id}",
140
+ f" status={resp.status_code}",
141
+ f" duration_ms={duration_ms:.1f}",
142
+ f" body_size={len(raw)}",
143
+ ]
144
+ preview = self._summarize_response(resp, raw)
145
+ if preview:
146
+ lines.append(f" body={preview}")
147
+ _write_stderr(lines)
148
+
149
+ def _log_exception(
150
+ self,
151
+ method: str,
152
+ url: str,
153
+ request_id: str,
154
+ duration_ms: float,
155
+ exc: Exception,
156
+ ) -> None:
157
+ _write_stderr(
158
+ [
159
+ f"!! {method} {url}",
160
+ f" request_id={request_id}",
161
+ f" duration_ms={duration_ms:.1f}",
162
+ f" error={type(exc).__name__}: {exc}",
163
+ ]
164
+ )
165
+
166
+ def _summarize_body(self, body: Any, encoded: bytes) -> str:
167
+ if self.config.verbose_full_body:
168
+ return encoded.decode("utf-8", errors="replace")
169
+ limit = self.config.verbose_body_limit
170
+ if len(encoded) <= limit:
171
+ return encoded.decode("utf-8", errors="replace")
172
+ return _summarize_json_value(body, limit)
173
+
174
+ def _summarize_response(self, resp: requests.Response, raw: bytes) -> str:
175
+ if not raw:
176
+ return ""
177
+ if self.config.verbose_full_body:
178
+ return _safe_decode(raw)
179
+ limit = self.config.verbose_body_limit
180
+ content_type = (resp.headers.get("Content-Type") or "").lower()
181
+ if "json" in content_type:
182
+ try:
183
+ data = resp.json()
184
+ except Exception:
185
+ return _truncate_text(_safe_decode(raw), limit)
186
+ encoded = _safe_json(data)
187
+ if len(encoded.encode("utf-8", errors="replace")) <= limit:
188
+ return encoded
189
+ return _summarize_json_value(data, limit)
190
+ return _truncate_text(_safe_decode(raw), limit)
191
+
192
+ # ==================== Template operations ====================
193
+
194
+ def list_templates(self) -> list:
195
+ """List all templates."""
196
+ params = {}
197
+ if self.config.agenthub_ref:
198
+ params["agenthub_revision"] = self.config.agenthub_ref
199
+ return self._get("/templates", params=params)
200
+
201
+ def get_template(self, name: str) -> dict:
202
+ """Get template details."""
203
+ params = {}
204
+ if self.config.agenthub_ref:
205
+ params["agenthub_revision"] = self.config.agenthub_ref
206
+ return self._get(f"/templates/{quote(name)}", params=params)
207
+
208
+ # ==================== Dataset operations ====================
209
+
210
+ def list_datasets(self, search: Optional[str] = None) -> dict:
211
+ """List all datasets."""
212
+ params = {"limit": 500}
213
+ if search:
214
+ params["search"] = search
215
+ return self._get("/datasets", params=params)
216
+
217
+ def list_dataset_versions(self, dataset: str) -> list:
218
+ """List dataset versions."""
219
+ return self._get(f"/datasets/{quote(dataset)}/versions")
220
+
221
+ def list_dataset_instances(self, dataset_version: str, limit: int = 10000) -> dict:
222
+ """List dataset instances."""
223
+ return self._get(f"/datasets/{quote(dataset_version)}/instances", params={"limit": limit})
224
+
225
+ # ==================== Job operations ====================
226
+
227
+ def create_group(
228
+ self,
229
+ name: Optional[str] = None,
230
+ max_concurrency: Optional[int] = None,
231
+ eval_config: Optional[dict] = None,
232
+ ) -> dict:
233
+ """Create a group."""
234
+ body: dict = {}
235
+ if name:
236
+ body["name"] = name
237
+ if max_concurrency is not None:
238
+ body["max_concurrency"] = max_concurrency
239
+ if eval_config is not None:
240
+ body["eval_config"] = eval_config
241
+ return self._post("/groups", body)
242
+
243
+ def create_job(
244
+ self,
245
+ template: str,
246
+ params: Optional[dict] = None,
247
+ params_list: Optional[list] = None,
248
+ suite_name: Optional[str] = None,
249
+ tags: Optional[list] = None,
250
+ overrides: Optional[dict] = None,
251
+ max_concurrency: Optional[int] = None,
252
+ group_id: Optional[str] = None,
253
+ enable_otel_tracing: Optional[bool] = None,
254
+ timeout: float = 30,
255
+ ) -> dict:
256
+ """Create a job."""
257
+ body: dict = {"template": template}
258
+
259
+ if params is not None:
260
+ body["params"] = params
261
+ if params_list is not None:
262
+ body["params_list"] = params_list
263
+ if suite_name:
264
+ body["suite_name"] = suite_name
265
+ if self.config.agenthub_ref:
266
+ body["agenthub_revision"] = self.config.agenthub_ref
267
+ if tags:
268
+ body["tags"] = tags
269
+ if overrides:
270
+ body["overrides"] = overrides
271
+ if max_concurrency is not None:
272
+ body["max_concurrency"] = max_concurrency
273
+ if group_id is not None:
274
+ body["group_id"] = group_id
275
+ if enable_otel_tracing is not None:
276
+ body["enable_otel_tracing"] = enable_otel_tracing
277
+
278
+ return self._post("/jobs", body, timeout=timeout)
279
+
280
+ def get_job(self, job_id: str) -> dict:
281
+ """Get job status."""
282
+ return self._get(f"/jobs/{quote(job_id)}")
283
+
284
+ def list_jobs(
285
+ self,
286
+ template: Optional[str] = None,
287
+ group_id: Optional[str] = None,
288
+ status: Optional[str] = None,
289
+ job_id: Optional[str] = None,
290
+ instance_id: Optional[str] = None,
291
+ finished_after: Optional[str] = None,
292
+ finished_before: Optional[str] = None,
293
+ created_after: Optional[str] = None,
294
+ created_before: Optional[str] = None,
295
+ skip: int = 0,
296
+ limit: int = 100,
297
+ ) -> dict:
298
+ """List jobs."""
299
+ params = {"skip": skip, "limit": limit}
300
+ if template:
301
+ params["template"] = template
302
+ if group_id:
303
+ params["group_id"] = group_id
304
+ if status:
305
+ params["status"] = status
306
+ if job_id:
307
+ params["job_id"] = job_id
308
+ if instance_id:
309
+ params["instance_id"] = instance_id
310
+ if finished_after:
311
+ params["finished_after"] = finished_after
312
+ if finished_before:
313
+ params["finished_before"] = finished_before
314
+ if created_after:
315
+ params["created_after"] = created_after
316
+ if created_before:
317
+ params["created_before"] = created_before
318
+ return self._get("/jobs", params=params)
319
+
320
+ # ==================== Group operations ====================
321
+
322
+ def get_group(self, group_id: str) -> dict:
323
+ """Get group details."""
324
+ return self._get(f"/groups/{quote(group_id)}")
325
+
326
+ def list_groups(self, skip: int = 0, limit: int = 100) -> dict:
327
+ """List groups."""
328
+ return self._get("/groups", params={"skip": skip, "limit": limit})
329
+
330
+ def list_group_jobs(
331
+ self,
332
+ group_id: str,
333
+ tag: Optional[list[str]] = None,
334
+ skip: int = 0,
335
+ limit: int = 100,
336
+ ) -> dict:
337
+ """List jobs in a group."""
338
+ params: dict = {"skip": skip, "limit": limit}
339
+ if tag:
340
+ params["tag"] = tag
341
+ return self._get(f"/groups/{quote(group_id)}/jobs", params=params)
342
+
343
+ def get_group_stats(self, group_id: str) -> dict:
344
+ """Get group progress."""
345
+ return self._get(f"/groups/{quote(group_id)}/stats")
346
+
347
+ def get_group_eval(self, group_id: str) -> dict:
348
+ """Get aggregated evaluation results for a group."""
349
+ return self._get(f"/groups/{quote(group_id)}/eval")
350
+
351
+ # ==================== Logs and artifacts ====================
352
+ def cancel_group(self, group_id: str) -> dict:
353
+ """Cancel all unfinished jobs in a group."""
354
+ return self._post(f"/groups/{quote(group_id)}/cancel", {})
355
+
356
+ def get_job_logs(
357
+ self, job_id: str, container: Optional[str] = None, tail: Optional[int] = None
358
+ ) -> dict:
359
+ """Get job logs."""
360
+ params = {}
361
+ if container:
362
+ params["container"] = container
363
+ if tail:
364
+ params["tail"] = tail
365
+ return self._get(f"/jobs/{quote(job_id)}/logs", params=params or None)
366
+
367
+ def get_job_events(self, job_id: str, container: Optional[str] = None) -> dict:
368
+ """Get job events."""
369
+ params = {}
370
+ if container:
371
+ params["container"] = container
372
+ return self._get(f"/jobs/{quote(job_id)}/events", params=params or None)
373
+
374
+ def get_job_metrics(self, job_id: str) -> dict:
375
+ """Get job metrics."""
376
+ return self._get(f"/metrics/{quote(job_id)}")
377
+
378
+ def cancel_job(self, job_id: str) -> dict:
379
+ """Cancel a job."""
380
+ return self._delete(f"/jobs/{quote(job_id)}")
381
+
382
+ def get_group_artifacts_page(
383
+ self,
384
+ group_id: str,
385
+ *,
386
+ skip: int = 0,
387
+ limit: int = _GROUP_ARTIFACTS_PAGE_SIZE,
388
+ ) -> dict:
389
+ """Get one page of artifact download links for a group."""
390
+ return self._get(
391
+ f"/groups/{quote(group_id)}/artifacts",
392
+ params={"skip": skip, "limit": limit},
393
+ )
394
+
395
+ def get_group_artifacts(self, group_id: str) -> dict:
396
+ """Get artifact download links for a group."""
397
+ skip = 0
398
+ artifacts: list[dict] = []
399
+ last_page: dict | None = None
400
+
401
+ while True:
402
+ page = self.get_group_artifacts_page(
403
+ group_id,
404
+ skip=skip,
405
+ limit=_GROUP_ARTIFACTS_PAGE_SIZE,
406
+ )
407
+ last_page = page
408
+ page_artifacts = page.get("artifacts") or []
409
+ artifacts.extend(page_artifacts)
410
+
411
+ total = page.get("total")
412
+ if total is not None and len(artifacts) >= total:
413
+ break
414
+ if len(page_artifacts) < _GROUP_ARTIFACTS_PAGE_SIZE:
415
+ break
416
+
417
+ skip += len(page_artifacts)
418
+
419
+ return {
420
+ **last_page,
421
+ "skip": 0,
422
+ "limit": len(artifacts),
423
+ "artifacts": artifacts,
424
+ }
425
+
426
+ def get_job_artifacts(self, job_ids: list) -> list:
427
+ """Get artifact download links for one or more jobs."""
428
+ return self._post("/jobs/artifacts", {"job_ids": job_ids})
429
+
430
+ def render_template(self, name: str, params: dict) -> dict:
431
+ """Preview the rendered template output."""
432
+ return self._post(f"/templates/{quote(name)}/render", {"params": params})
433
+
434
+
435
+ _verbose_override: Optional[bool] = None
436
+
437
+
438
+ def set_verbose_override(value: Optional[bool]) -> None:
439
+ """Set a process-wide verbose override consulted by get_client()."""
440
+ global _verbose_override
441
+ _verbose_override = value
442
+
443
+
444
+ def get_client() -> APIClient:
445
+ """Get an API client."""
446
+ return APIClient(get_config(verbose=_verbose_override))
447
+
448
+
449
+ def _build_user_agent(existing: Optional[str] = None) -> str:
450
+ """Build client User-Agent string."""
451
+ client_ua = (
452
+ f"ap-client/{_client_version()} "
453
+ f"python/{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} "
454
+ f"{platform.system().lower()}/{platform.machine().lower()}"
455
+ )
456
+ if existing:
457
+ return f"{existing} {client_ua}"
458
+ return client_ua
459
+
460
+
461
+ def _client_version() -> str:
462
+ from . import __version__
463
+
464
+ return __version__
465
+
466
+
467
+ def _write_stderr(lines: list[str]) -> None:
468
+ sys.stderr.write("\n".join(lines) + "\n")
469
+ sys.stderr.flush()
470
+
471
+
472
+ def _safe_json(value: Any) -> str:
473
+ try:
474
+ return _json.dumps(value, ensure_ascii=False, default=str)
475
+ except Exception:
476
+ return str(value)
477
+
478
+
479
+ def _safe_decode(raw: bytes) -> str:
480
+ try:
481
+ return raw.decode("utf-8", errors="replace")
482
+ except Exception:
483
+ return f"<{len(raw)} bytes binary>"
484
+
485
+
486
+ def _truncate_text(text: str, limit: int) -> str:
487
+ if len(text) <= limit:
488
+ return text
489
+ return text[:limit] + f"...<truncated {len(text) - limit} chars>"
490
+
491
+
492
+ def _shorten_value(value: Any) -> Any:
493
+ if isinstance(value, str):
494
+ if len(value) > _VERBOSE_VALUE_PREVIEW:
495
+ return value[:_VERBOSE_VALUE_PREVIEW] + "..."
496
+ return value
497
+ if isinstance(value, dict):
498
+ return f"<dict {len(value)} keys>"
499
+ if isinstance(value, list):
500
+ return f"<list {len(value)} items>"
501
+ return value
502
+
503
+
504
+ def _summarize_json_value(data: Any, limit: int) -> str:
505
+ if isinstance(data, dict):
506
+ keys = list(data.keys())
507
+ sample = {k: _shorten_value(data[k]) for k in keys[:_VERBOSE_SAMPLE_KEYS]}
508
+ summary = {"_keys": keys, "_sample": sample}
509
+ if len(keys) > _VERBOSE_SAMPLE_KEYS:
510
+ summary["_truncated_keys"] = len(keys) - _VERBOSE_SAMPLE_KEYS
511
+ return _truncate_text(_safe_json(summary), limit)
512
+ if isinstance(data, list):
513
+ sample = [_shorten_value(item) for item in data[:_VERBOSE_SAMPLE_ITEMS]]
514
+ summary = {"_count": len(data), "_sample": sample}
515
+ return _truncate_text(_safe_json(summary), limit)
516
+ return _truncate_text(_safe_json(data), limit)