ap-client 0.4.2.dev0__tar.gz → 0.5.0.dev1__tar.gz

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.
@@ -1,18 +1,18 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.4.2.dev0
3
+ Version: 0.5.0.dev1
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: irepo-artifact==0.1.2
6
+ Requires-Dist: irepo-artifact==0.2.0
7
7
  Requires-Dist: pyyaml>=6.0
8
8
  Requires-Dist: requests>=2.28.0
9
9
  Requires-Dist: rich>=13.0.0
10
10
  Requires-Dist: typer>=0.9.0
11
11
  Requires-Dist: websockets>=13.0
12
12
  Provides-Extra: all
13
- Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.2; extra == 'all'
13
+ Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.4; extra == 'all'
14
14
  Provides-Extra: dataset
15
- Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.2; extra == 'dataset'
15
+ Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.4; extra == 'dataset'
16
16
  Description-Content-Type: text/markdown
17
17
 
18
18
  A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.
@@ -63,7 +63,9 @@ def _fmt(output_format: Optional[str]) -> str:
63
63
  def _check_time_range(value: str) -> str:
64
64
  normalized = str(value or "").strip().lower()
65
65
  if not _TIME_RANGE_RE.fullmatch(normalized):
66
- raise typer.BadParameter(f"--time-range must be a duration like 30s/5m/1h, got {value!r}")
66
+ raise typer.BadParameter(
67
+ f"--time-range must be a duration like 30s/5m/1h, got {value!r}"
68
+ )
67
69
  return normalized
68
70
 
69
71
 
@@ -13,6 +13,7 @@ from typing import Any, Literal, Optional, Union
13
13
  from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
14
14
 
15
15
  import requests
16
+ from urllib3.util import Timeout as HTTPTimeout
16
17
 
17
18
  from .config import Config, ConfigurationError, get_config, has_cluster_header
18
19
  from .managed_ak import require_managed_ak_ack, validate_ak_id, validate_ak_selection
@@ -126,7 +127,6 @@ EXTRA_AGG_OPS = ("avg", "sum")
126
127
  _OTHERS_MODEL = {
127
128
  "name": "Others",
128
129
  "series_name": "",
129
- "is_default": False,
130
130
  "source_id": "",
131
131
  "series_id": "",
132
132
  }
@@ -171,7 +171,9 @@ def _merge_meta_tags(
171
171
  conflicts = [k for k, v in provided.items() if v and str(v).strip() and k in existing_keys]
172
172
  if conflicts:
173
173
  raise ValueError(
174
- "tags 中已包含 " + ", ".join(conflicts) + ",与 meta 参数冲突,请只保留一种方式"
174
+ "tags already contains "
175
+ + ", ".join(conflicts)
176
+ + "; it conflicts with the meta parameter, keep only one"
175
177
  )
176
178
  extra = [
177
179
  f"{key}:{str(value).strip()}"
@@ -343,6 +345,11 @@ def _split_paged(payload: Any) -> tuple[list[dict], dict]:
343
345
  )
344
346
 
345
347
 
348
+ # apiserver meta-tags 端点单页上限(handler.metaTagPaging 截断到 200;
349
+ # 缺省仅 20,目录查询必须显式带上并翻页)。
350
+ _META_TAG_PAGE_SIZE = 200
351
+
352
+
346
353
  class APIClient:
347
354
  """Agent Platform API client."""
348
355
 
@@ -401,6 +408,8 @@ class APIClient:
401
408
  params: Optional[dict] = None,
402
409
  json_body: Any = None,
403
410
  timeout: TimeoutType = None,
411
+ deadline: Optional[float] = None,
412
+ _return_response: bool = False,
404
413
  ) -> Any:
405
414
  """Send an HTTP request with X-Request-ID and optional verbose logging."""
406
415
  self._require_cluster_config()
@@ -412,6 +421,7 @@ class APIClient:
412
421
  verbose = self.config.verbose
413
422
  max_attempts = self._max_attempts_for(method)
414
423
  for attempt in range(1, max_attempts + 1):
424
+ attempt_timeout = _deadline_timeout(timeout, deadline)
415
425
  request_id = str(uuid.uuid4())
416
426
  headers = {"X-Request-ID": request_id}
417
427
  if verbose:
@@ -435,9 +445,11 @@ class APIClient:
435
445
  params=params,
436
446
  json=json_body,
437
447
  headers=headers,
438
- timeout=timeout,
448
+ timeout=attempt_timeout,
439
449
  )
440
450
  except requests.RequestException as exc:
451
+ if deadline is not None:
452
+ _manifest_budget_remaining(deadline)
441
453
  duration_ms = (time.perf_counter() - start) * 1000
442
454
  if verbose:
443
455
  self._log_exception(method, url, request_id, duration_ms, exc)
@@ -453,10 +465,12 @@ class APIClient:
453
465
  delay,
454
466
  error=f"{type(exc).__name__}: {exc}",
455
467
  )
456
- time.sleep(delay)
468
+ _deadline_sleep(delay, deadline)
457
469
  continue
458
470
  raise APIError(None, str(exc), request_id, None) from exc
459
471
  duration_ms = (time.perf_counter() - start) * 1000
472
+ if deadline is not None:
473
+ _manifest_budget_remaining(deadline)
460
474
  if verbose:
461
475
  self._log_response(method, url, request_id, resp, duration_ms)
462
476
  if not resp.ok:
@@ -472,10 +486,12 @@ class APIClient:
472
486
  delay,
473
487
  status=resp.status_code,
474
488
  )
475
- time.sleep(delay)
489
+ _deadline_sleep(delay, deadline)
476
490
  continue
477
491
  detail = _response_error_detail(resp)
478
492
  raise APIError(resp.status_code, detail, request_id, resp)
493
+ if _return_response:
494
+ return resp, request_id
479
495
  if not resp.content:
480
496
  return None
481
497
  try:
@@ -977,21 +993,103 @@ class APIClient:
977
993
  return item
978
994
  raise APIError(404, f"meta model {name!r} not found", "client-filter", None)
979
995
 
996
+ def _get_meta_tag_catalog(
997
+ self, path: str, params: Optional[dict] = None
998
+ ) -> tuple[list[dict], dict]:
999
+ """按 benchmarks 同口径自动翻页取全量目录记录,返回 ``(records, pagination)``。
1000
+
1001
+ apiserver ``meta-tags`` 端点分页参数为 ``page`` / ``page_size``(缺省
1002
+ 20、上限 200,见 handler.metaTagPaging);单页取用会把值目录静默截断
1003
+ (``meta-job-type`` 漏掉 ``Eval-Eval`` 时 ``default`` 会被算成空串),
1004
+ 故显式传上限并翻页。
1005
+ """
1006
+ records: list[dict] = []
1007
+ pagination: dict = {}
1008
+ page = 1
1009
+ # 与 list_benchmarks 同口径的 100 页上限,防分页异常导致死循环
1010
+ max_pages = 100
1011
+ while page <= max_pages:
1012
+ query = dict(params or {})
1013
+ query["page"] = page
1014
+ query["page_size"] = _META_TAG_PAGE_SIZE
1015
+ page_records, pagination = _split_paged(self._get_central(path, params=query))
1016
+ records.extend(page_records)
1017
+ if not page_records or not pagination:
1018
+ break
1019
+ try:
1020
+ total = int(pagination.get("total"))
1021
+ except (TypeError, ValueError):
1022
+ total = None
1023
+ if total is not None:
1024
+ if len(records) >= total:
1025
+ break
1026
+ elif len(page_records) < _META_TAG_PAGE_SIZE:
1027
+ break
1028
+ page += 1
1029
+ return records, pagination
1030
+
1031
+ def list_meta_tags(self) -> dict:
1032
+ """meta 标签键目录(apiserver GET /apis/v1/meta-tags/keys,返回 {items:[...]})。
1033
+
1034
+ apiserver 用 PagedSuccess envelope({code,data,pagination})返回,
1035
+ 按基准 benchmarks 客户端同口径经 _split_paged 拆包并自动翻页;
1036
+ 裸列表等旧形态兜底为空分页。
1037
+ """
1038
+ records, pagination = self._get_meta_tag_catalog("/meta-tags/keys")
1039
+ result = {"items": records}
1040
+ if pagination:
1041
+ result["pagination"] = pagination
1042
+ return result
1043
+
1044
+ def get_meta_tags(self, key: str) -> dict:
1045
+ """单个 meta 键的值目录(GET /apis/v1/meta-tags?key=,返回 {items:[...]})。
1046
+
1047
+ 与 ``list_meta_tags`` 同样自动翻页,避免值目录被单页默认大小截断。
1048
+ """
1049
+ records, pagination = self._get_meta_tag_catalog("/meta-tags", {"key": key})
1050
+ result = {"items": records}
1051
+ if pagination:
1052
+ result["pagination"] = pagination
1053
+ return result
1054
+
980
1055
  def list_meta_job_types(self) -> dict:
981
- """任务类型词表(本地常量,不走接口)。
1056
+ """任务类型词表(目录驱动:GET /apis/v1/meta-tags?key=meta-job-type)。
982
1057
 
983
- job-type 词表固定为 Eval-Eval(默认)+ Others(兜底手动输入),
984
- 不依赖服务端可用性。default 固定 ``Eval-Eval``(job-type 词表
985
- 不再走集群配置,``AP_META_DEFAULT_JOB_TYPE`` env 已删)。
1058
+ 仅取目录中 ``is_enabled`` 的值(缺失按启用解析)+ 末尾 ``Others``
1059
+ 兜底行;``default`` 为 ``Eval-Eval``(在目录中时,否则空串)。
1060
+ 目录请求失败或为空 → 回退本地常量(Eval-Eval + Others,fail-open),
1061
+ 并在 stderr 打一行告警,词表降级可见但不阻塞命令。
986
1062
  """
987
- default = "Eval-Eval"
1063
+ error: Exception | None = None
1064
+ try:
1065
+ items = self.get_meta_tags("meta-job-type").get("items") or []
1066
+ except Exception as exc: # fail-open:目录不可用不阻塞词表命令
1067
+ error = exc
1068
+ items = []
1069
+ if items:
1070
+ options = []
1071
+ for item in items:
1072
+ value = str(item.get("value") or "").strip()
1073
+ if value and item.get("is_enabled", True):
1074
+ options.append({"value": value})
1075
+ if options:
1076
+ default = "Eval-Eval" if any(o["value"] == "Eval-Eval" for o in options) else ""
1077
+ return {
1078
+ "meta_job_type": {
1079
+ "default": default,
1080
+ "options": [*options, {"value": "Others"}],
1081
+ }
1082
+ }
1083
+ _write_stderr(
1084
+ [
1085
+ "warning: meta-job-type catalog unavailable or empty, "
1086
+ f"falling back to local constants (reason={error or 'empty catalog'})"
1087
+ ]
1088
+ )
988
1089
  return {
989
1090
  "meta_job_type": {
990
- "default": default,
991
- "options": [
992
- {"value": default, "is_default": True},
993
- {"value": "Others", "is_default": False},
994
- ],
1091
+ "default": "Eval-Eval",
1092
+ "options": [{"value": "Eval-Eval"}, {"value": "Others"}],
995
1093
  }
996
1094
  }
997
1095
 
@@ -1185,6 +1283,7 @@ class APIClient:
1185
1283
  benchmark: Optional[str] = None,
1186
1284
  experiment: Optional[str] = None,
1187
1285
  ak_id: Optional[str] = None,
1286
+ ak_mode: str = "auto",
1188
1287
  instance_range: Optional[str] = None,
1189
1288
  profile: Optional[str] = None,
1190
1289
  ) -> dict:
@@ -1236,6 +1335,7 @@ class APIClient:
1236
1335
  benchmark=benchmark,
1237
1336
  experiment=experiment,
1238
1337
  ak_id=ak_id,
1338
+ ak_mode=ak_mode,
1239
1339
  )
1240
1340
 
1241
1341
  result = self._post("/jobs", body, timeout=timeout)
@@ -1297,6 +1397,7 @@ class APIClient:
1297
1397
  benchmark: Optional[str] = None,
1298
1398
  experiment: Optional[str] = None,
1299
1399
  ak_id: Optional[str] = None,
1400
+ ak_mode: str = "auto",
1300
1401
  instance_range: Optional[str] = None,
1301
1402
  profile: Optional[str] = None,
1302
1403
  ) -> dict:
@@ -1312,11 +1413,15 @@ class APIClient:
1312
1413
  if not template and not profile_id and profile is None:
1313
1414
  raise ValueError("Either template, profile or profile_id is required")
1314
1415
  validate_ak_selection(ak_id, params, params_list, overrides)
1416
+ if ak_mode not in {"auto", "manual", "synced"}:
1417
+ raise ValueError("ak_mode must be auto, manual or synced")
1315
1418
  body: dict = {}
1316
1419
  if template is not None:
1317
1420
  body["template"] = template
1318
1421
  if ak_id is not None:
1319
1422
  body["ak_id"] = ak_id
1423
+ if ak_mode != "auto":
1424
+ body["ak_mode"] = ak_mode
1320
1425
 
1321
1426
  if params is not None:
1322
1427
  body["params"] = params
@@ -2067,9 +2172,14 @@ class APIClient:
2067
2172
  return self._delete(f"/concurrency-policies/{quote(policy_id)}")
2068
2173
 
2069
2174
  # ==================== Logs and artifacts ====================
2070
- def cancel_group(self, group_id: str) -> dict:
2175
+ def preview_group_cancel(self, group_id: str) -> dict:
2176
+ """Preview the current Group cancel scope and obtain confirmation if required."""
2177
+ return self._post(f"/groups/{quote(group_id)}/cancel-preview", {})
2178
+
2179
+ def cancel_group(self, group_id: str, confirmation_token: Optional[str] = None) -> dict:
2071
2180
  """Cancel all unfinished jobs in a group."""
2072
- return self._post(f"/groups/{quote(group_id)}/cancel", {})
2181
+ body = {"confirmation_token": confirmation_token} if confirmation_token else {}
2182
+ return self._post(f"/groups/{quote(group_id)}/cancel", body)
2073
2183
 
2074
2184
  def get_job_logs(
2075
2185
  self,
@@ -2362,6 +2472,52 @@ class APIClient:
2362
2472
  params=params,
2363
2473
  )
2364
2474
 
2475
+ def wait_for_job_artifacts_manifest(self, job_id: str) -> dict:
2476
+ """Poll the new API within one per-Job budget; HTTP failures keep normal retries."""
2477
+ budget = self.config.artifact_manifest_wait_timeout
2478
+ if not math.isfinite(budget) or budget <= 0:
2479
+ raise ConfigurationError(
2480
+ "AP_ARTIFACT_MANIFEST_WAIT_TIMEOUT must be a finite positive number"
2481
+ )
2482
+ deadline = time.monotonic() + budget
2483
+ while True:
2484
+ response, request_id = self._request(
2485
+ "GET",
2486
+ f"/jobs/{quote(job_id, safe='')}/artifacts/manifest",
2487
+ deadline=deadline,
2488
+ _return_response=True,
2489
+ )
2490
+ try:
2491
+ payload = response.json()
2492
+ except ValueError as exc:
2493
+ raise APIError(
2494
+ response.status_code, "Invalid artifact manifest response", request_id, response
2495
+ ) from exc
2496
+ if not isinstance(payload, dict) or payload.get("job_id") != job_id:
2497
+ raise APIError(
2498
+ response.status_code, "Invalid artifact manifest response", request_id, response
2499
+ )
2500
+ if response.status_code == 200:
2501
+ if "manifest" not in payload or (
2502
+ payload["manifest"] is not None and not isinstance(payload["manifest"], list)
2503
+ ):
2504
+ raise APIError(200, "Invalid artifact manifest response", request_id, response)
2505
+ return payload
2506
+ if response.status_code != 202 or payload.get("status") != "preparing":
2507
+ raise APIError(
2508
+ response.status_code,
2509
+ "Invalid artifact preparation response",
2510
+ request_id,
2511
+ response,
2512
+ )
2513
+ try:
2514
+ delay = float(response.headers.get("Retry-After", "2"))
2515
+ except ValueError:
2516
+ delay = 2.0
2517
+ if not math.isfinite(delay) or delay <= 0:
2518
+ delay = 2.0
2519
+ _deadline_sleep(delay, deadline)
2520
+
2365
2521
  def get_artifact_file_download_urls(self, job_id: str, paths: list[str]) -> dict:
2366
2522
  """Get download URLs for multiple artifact files."""
2367
2523
  endpoint = f"/jobs/{quote(job_id, safe='')}/artifacts/files/download"
@@ -3275,3 +3431,34 @@ def _summarize_json_value(data: Any, limit: int) -> str:
3275
3431
  summary = {"_count": len(data), "_sample": sample}
3276
3432
  return _truncate_text(_safe_json(summary), limit)
3277
3433
  return _truncate_text(_safe_json(data), limit)
3434
+
3435
+
3436
+ def _manifest_budget_remaining(deadline: float) -> float:
3437
+ remaining = deadline - time.monotonic()
3438
+ if remaining <= 0:
3439
+ raise APIError(None, "Artifact manifest wait timed out", None, None)
3440
+ return remaining
3441
+
3442
+
3443
+ def _deadline_timeout(
3444
+ timeout: TimeoutType, deadline: Optional[float]
3445
+ ) -> Union[TimeoutType, HTTPTimeout]:
3446
+ if deadline is None:
3447
+ return timeout
3448
+ remaining = _manifest_budget_remaining(deadline)
3449
+
3450
+ def bounded(value):
3451
+ return min(value, remaining) if value is not None else remaining
3452
+
3453
+ if isinstance(timeout, tuple):
3454
+ connect, read = (bounded(value) for value in timeout)
3455
+ else:
3456
+ connect = read = bounded(timeout)
3457
+ # The connect phase must consume the same remaining budget as the read.
3458
+ return HTTPTimeout(total=remaining, connect=connect, read=read)
3459
+
3460
+
3461
+ def _deadline_sleep(delay: float, deadline: Optional[float]) -> None:
3462
+ if deadline is not None:
3463
+ delay = min(delay, _manifest_budget_remaining(deadline))
3464
+ time.sleep(delay)