ap-client 0.4.0.dev0__tar.gz → 0.4.2.dev0__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.
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/PKG-INFO +2 -1
- ap_client-0.4.2.dev0/ap_client/akpool_commands.py +179 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/api.py +76 -9
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/cli.py +118 -32
- ap_client-0.4.2.dev0/ap_client/exporter.py +728 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/profile_commands.py +19 -2
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/pyproject.toml +2 -1
- ap_client-0.4.0.dev0/ap_client/exporter.py +0 -934
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/__init__.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/config.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/dataset_commands.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/fs_commands.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/instance_commands.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/irepo_sdk.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/managed_ak.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/split_publish.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/tbb.py +0 -0
- {ap_client-0.4.0.dev0 → ap_client-0.4.2.dev0}/ap_client/waiter.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ap-client
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2.dev0
|
|
4
4
|
Summary: Agent Platform API Client & CLI
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: irepo-artifact==0.1.2
|
|
6
7
|
Requires-Dist: pyyaml>=6.0
|
|
7
8
|
Requires-Dist: requests>=2.28.0
|
|
8
9
|
Requires-Dist: rich>=13.0.0
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""``ap akpool`` 命令族 —— Account Pool(托管 AK)观测操作。
|
|
2
|
+
|
|
3
|
+
* ``akpool rpm get`` → apiserver ``GET /apis/v1/model-api-keys/rpm-usage``
|
|
4
|
+
(中心侧,走 ``APIClient._get_central``),按 AK + 模型查询当前 RPM 用量与上限。
|
|
5
|
+
|
|
6
|
+
AK 的两种指定方式互斥且必填其一:``--ak-id``(uak-xxx,仅平台托管 AK)或
|
|
7
|
+
``--masked-ak``(掩码后 6 位,未托管 AK 走这条路)。服务端同口径(都缺或都给 →
|
|
8
|
+
400),CLI 侧先拦一道,不发请求。``--time-range`` 是 Go duration 风格
|
|
9
|
+
(``30s``/``5m``/``1h``);显式 ``--from``/``--to``(RFC3339)时覆盖
|
|
10
|
+
``--time-range``。
|
|
11
|
+
|
|
12
|
+
响应是嵌套结构(无信封):``usage.current_rpm``/``usage.sampled_at``、
|
|
13
|
+
``rpm.limit``/``rpm.limit_state``/``rpm.synced_at``、``usage_status``、
|
|
14
|
+
``time_range``、``ak_id``/``masked_api_key``/``model_id``。json/yaml 原样透传;
|
|
15
|
+
plain/table 摊平成单行 key=value 展示。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from ap_client import get_client
|
|
25
|
+
|
|
26
|
+
__all__ = ["akpool_app", "register"]
|
|
27
|
+
|
|
28
|
+
akpool_app = typer.Typer(help="Account Pool operations (managed API keys)")
|
|
29
|
+
rpm_app = typer.Typer(help="Account Pool RPM usage operations")
|
|
30
|
+
|
|
31
|
+
akpool_app.add_typer(rpm_app, name="rpm")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register(app: typer.Typer) -> None:
|
|
35
|
+
"""把 ``akpool`` 挂到 ``ap`` 根命令上。"""
|
|
36
|
+
app.add_typer(akpool_app, name="akpool")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_FORMAT_HELP = "Output format: plain/table/json/yaml (default: AP_FORMAT or command default)"
|
|
40
|
+
_TIME_RANGE_HELP = "Lookback window for the RPM reading (Go duration, e.g. 30s/5m/1h)"
|
|
41
|
+
_ISO_TIME_HELP = "RFC3339 timestamp, e.g. 2026-09-21T12:00:00+08:00"
|
|
42
|
+
|
|
43
|
+
_TIME_RANGE_RE = re.compile(r"^\d+[smh]$")
|
|
44
|
+
|
|
45
|
+
#: plain/table 输出的字段顺序,取值自嵌套响应(见 _flatten_rpm)。
|
|
46
|
+
_RPM_KEYS = (
|
|
47
|
+
"current_rpm",
|
|
48
|
+
"rpm_limit",
|
|
49
|
+
"model",
|
|
50
|
+
"sampled_at",
|
|
51
|
+
"time_range",
|
|
52
|
+
"ak_id",
|
|
53
|
+
"usage_status",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _fmt(output_format: Optional[str]) -> str:
|
|
58
|
+
from .cli import _normalize_output_format
|
|
59
|
+
|
|
60
|
+
return _normalize_output_format(output_format, keep_table=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_time_range(value: str) -> str:
|
|
64
|
+
normalized = str(value or "").strip().lower()
|
|
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}")
|
|
67
|
+
return normalized
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _flatten_rpm(payload: dict) -> dict:
|
|
71
|
+
"""把嵌套的 rpm-usage 响应摊平成 plain/table 展示用的扁平 dict。
|
|
72
|
+
|
|
73
|
+
null 值渲染为 ``-``(如未托管 AK 的 ``ak_id``、未知限额的 ``rpm.limit``)。
|
|
74
|
+
"""
|
|
75
|
+
rpm = payload.get("rpm")
|
|
76
|
+
usage = payload.get("usage")
|
|
77
|
+
flat = {
|
|
78
|
+
"current_rpm": usage.get("current_rpm") if isinstance(usage, dict) else None,
|
|
79
|
+
"rpm_limit": rpm.get("limit") if isinstance(rpm, dict) else None,
|
|
80
|
+
"model": payload.get("model_id"),
|
|
81
|
+
"sampled_at": usage.get("sampled_at") if isinstance(usage, dict) else None,
|
|
82
|
+
"time_range": payload.get("time_range"),
|
|
83
|
+
"ak_id": payload.get("ak_id"),
|
|
84
|
+
"usage_status": payload.get("usage_status"),
|
|
85
|
+
}
|
|
86
|
+
return {key: (value if value is not None else "-") for key, value in flat.items()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _emit_rpm(payload: Any, output_format: str) -> None:
|
|
90
|
+
from .cli import _print_formatted, _print_rich_table
|
|
91
|
+
|
|
92
|
+
if not isinstance(payload, dict):
|
|
93
|
+
payload = {"value": payload}
|
|
94
|
+
if output_format in ("json", "yaml"):
|
|
95
|
+
_print_formatted(payload, output_format)
|
|
96
|
+
return
|
|
97
|
+
flat = _flatten_rpm(payload)
|
|
98
|
+
keys = [key for key in _RPM_KEYS if key in flat]
|
|
99
|
+
keys += [key for key in flat if key not in keys]
|
|
100
|
+
if output_format == "table":
|
|
101
|
+
_print_rich_table([flat], [(key, key) for key in keys])
|
|
102
|
+
return
|
|
103
|
+
_print_rpm_plain(payload)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _print_rpm_plain(payload: dict) -> None:
|
|
107
|
+
"""plain 输出:标题行(usage_status) + 对齐 key/value,与 _print_job_get_plain 同风格。
|
|
108
|
+
|
|
109
|
+
current_rpm 在限额已知时附利用率百分比;rpm_limit 未知/不限时展示 limit_state。
|
|
110
|
+
ak_id / masked_api_key 为 null(未托管 AK)时整行省略。
|
|
111
|
+
"""
|
|
112
|
+
from .cli import _print_key_values
|
|
113
|
+
|
|
114
|
+
rpm = payload.get("rpm")
|
|
115
|
+
usage = payload.get("usage")
|
|
116
|
+
rpm = rpm if isinstance(rpm, dict) else {}
|
|
117
|
+
usage = usage if isinstance(usage, dict) else {}
|
|
118
|
+
|
|
119
|
+
current = usage.get("current_rpm")
|
|
120
|
+
limit = rpm.get("limit")
|
|
121
|
+
limit_state = rpm.get("limit_state") or "unknown"
|
|
122
|
+
|
|
123
|
+
if isinstance(current, (int, float)) and isinstance(limit, (int, float)) and limit > 0:
|
|
124
|
+
current_text = f"{current} ({current / limit * 100:.1f}% of limit)"
|
|
125
|
+
else:
|
|
126
|
+
current_text = current
|
|
127
|
+
limit_text = f"{limit} ({limit_state})" if limit is not None else limit_state
|
|
128
|
+
|
|
129
|
+
typer.echo(f"AK RPM Usage ({payload.get('usage_status') or 'unknown'})")
|
|
130
|
+
_print_key_values(
|
|
131
|
+
[
|
|
132
|
+
("current_rpm", current_text),
|
|
133
|
+
("rpm_limit", limit_text),
|
|
134
|
+
("model", payload.get("model_id")),
|
|
135
|
+
("ak_id", payload.get("ak_id")),
|
|
136
|
+
("masked_api_key", payload.get("masked_api_key")),
|
|
137
|
+
("sampled_at", usage.get("sampled_at")),
|
|
138
|
+
("time_range", payload.get("time_range")),
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@rpm_app.command("get")
|
|
144
|
+
def rpm_get(
|
|
145
|
+
ak_id: Optional[str] = typer.Option(
|
|
146
|
+
None, "--ak-id", help="Managed AK ID (uak-xxx); mutually exclusive with --masked-ak"
|
|
147
|
+
),
|
|
148
|
+
masked_ak: Optional[str] = typer.Option(
|
|
149
|
+
None, "--masked-ak", help="Last 6 characters of the AK; mutually exclusive with --ak-id"
|
|
150
|
+
),
|
|
151
|
+
model: str = typer.Option(..., "--model", help="Model the RPM reading applies to"),
|
|
152
|
+
time_range: str = typer.Option("5m", "--time-range", help=_TIME_RANGE_HELP),
|
|
153
|
+
from_time: Optional[str] = typer.Option(
|
|
154
|
+
None, "--from", help=f"Window start ({_ISO_TIME_HELP}); overrides --time-range"
|
|
155
|
+
),
|
|
156
|
+
to_time: Optional[str] = typer.Option(
|
|
157
|
+
None, "--to", help=f"Window end ({_ISO_TIME_HELP}); overrides --time-range"
|
|
158
|
+
),
|
|
159
|
+
output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
|
|
160
|
+
):
|
|
161
|
+
"""Show the current RPM usage and limit of a managed AK for one model."""
|
|
162
|
+
resolved_ak_id = (ak_id or "").strip()
|
|
163
|
+
resolved_masked_ak = (masked_ak or "").strip()
|
|
164
|
+
if bool(resolved_ak_id) == bool(resolved_masked_ak):
|
|
165
|
+
raise typer.BadParameter("exactly one of --ak-id or --masked-ak is required")
|
|
166
|
+
if not model.strip():
|
|
167
|
+
raise typer.BadParameter("--model must not be empty")
|
|
168
|
+
normalized_range = _check_time_range(time_range)
|
|
169
|
+
output_format = _fmt(output_format)
|
|
170
|
+
|
|
171
|
+
result = get_client().get_akpool_rpm(
|
|
172
|
+
model=model.strip(),
|
|
173
|
+
ak_id=resolved_ak_id or None,
|
|
174
|
+
masked_ak=resolved_masked_ak or None,
|
|
175
|
+
time_range=normalized_range,
|
|
176
|
+
from_time=from_time,
|
|
177
|
+
to_time=to_time,
|
|
178
|
+
)
|
|
179
|
+
_emit_rpm(result, output_format)
|
|
@@ -9,7 +9,7 @@ import sys
|
|
|
9
9
|
import time
|
|
10
10
|
import uuid
|
|
11
11
|
from email.utils import parsedate_to_datetime
|
|
12
|
-
from typing import Any, Optional, Union
|
|
12
|
+
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
|
|
@@ -995,6 +995,42 @@ class APIClient:
|
|
|
995
995
|
}
|
|
996
996
|
}
|
|
997
997
|
|
|
998
|
+
# ==================== Account Pool operations ====================
|
|
999
|
+
|
|
1000
|
+
def get_akpool_rpm(
|
|
1001
|
+
self,
|
|
1002
|
+
*,
|
|
1003
|
+
model: str,
|
|
1004
|
+
ak_id: Optional[str] = None,
|
|
1005
|
+
masked_ak: Optional[str] = None,
|
|
1006
|
+
time_range: Optional[str] = None,
|
|
1007
|
+
from_time: Optional[str] = None,
|
|
1008
|
+
to_time: Optional[str] = None,
|
|
1009
|
+
timeout: TimeoutType = None,
|
|
1010
|
+
) -> dict:
|
|
1011
|
+
"""查询托管 AK 在指定模型上的当前 RPM 用量与上限(GET /apis/v1/model-api-keys/rpm-usage)。
|
|
1012
|
+
|
|
1013
|
+
``ak_id``(uak-xxx) 与 ``masked_ak``(掩码后 6 位) 二选一,都缺或都给
|
|
1014
|
+
服务端返回 400;``from_time``/``to_time``(RFC3339) 显式指定时覆盖
|
|
1015
|
+
``time_range``(Go duration,如 5m/1h,服务端默认 5m)。
|
|
1016
|
+
响应为嵌套结构:``usage.current_rpm``/``usage.sampled_at``、
|
|
1017
|
+
``rpm.limit``/``rpm.limit_state``/``rpm.synced_at``、``usage_status``;
|
|
1018
|
+
查未托管 AK 时 ``ak_id`` 为 null。
|
|
1019
|
+
"""
|
|
1020
|
+
validate_ak_id(ak_id)
|
|
1021
|
+
params: dict = {"model": model}
|
|
1022
|
+
if ak_id is not None:
|
|
1023
|
+
params["ak_id"] = ak_id
|
|
1024
|
+
if masked_ak is not None:
|
|
1025
|
+
params["masked_ak"] = masked_ak
|
|
1026
|
+
if time_range is not None:
|
|
1027
|
+
params["time_range"] = time_range
|
|
1028
|
+
if from_time is not None:
|
|
1029
|
+
params["from"] = from_time
|
|
1030
|
+
if to_time is not None:
|
|
1031
|
+
params["to"] = to_time
|
|
1032
|
+
return self._get_central("/model-api-keys/rpm-usage", params=params, timeout=timeout)
|
|
1033
|
+
|
|
998
1034
|
# ==================== Job operations ====================
|
|
999
1035
|
|
|
1000
1036
|
def create_group(
|
|
@@ -1150,6 +1186,7 @@ class APIClient:
|
|
|
1150
1186
|
experiment: Optional[str] = None,
|
|
1151
1187
|
ak_id: Optional[str] = None,
|
|
1152
1188
|
instance_range: Optional[str] = None,
|
|
1189
|
+
profile: Optional[str] = None,
|
|
1153
1190
|
) -> dict:
|
|
1154
1191
|
"""Create a job."""
|
|
1155
1192
|
body = self.build_create_job_body(
|
|
@@ -1180,6 +1217,7 @@ class APIClient:
|
|
|
1180
1217
|
credential_type=credential_type,
|
|
1181
1218
|
account_pool=account_pool,
|
|
1182
1219
|
eval_config=eval_config,
|
|
1220
|
+
profile=profile,
|
|
1183
1221
|
profile_id=profile_id,
|
|
1184
1222
|
profile_version=profile_version,
|
|
1185
1223
|
instances=instances,
|
|
@@ -1260,16 +1298,19 @@ class APIClient:
|
|
|
1260
1298
|
experiment: Optional[str] = None,
|
|
1261
1299
|
ak_id: Optional[str] = None,
|
|
1262
1300
|
instance_range: Optional[str] = None,
|
|
1301
|
+
profile: Optional[str] = None,
|
|
1263
1302
|
) -> dict:
|
|
1264
1303
|
"""Build the /jobs request body for job creation."""
|
|
1304
|
+
if profile is not None and (profile_id is not None or profile_version is not None):
|
|
1305
|
+
raise ValueError("profile cannot be combined with profile_id or profile_version")
|
|
1265
1306
|
_validate_credential_source_selection(
|
|
1266
1307
|
account_pool=account_pool,
|
|
1267
1308
|
credential_type=credential_type,
|
|
1268
1309
|
params=params,
|
|
1269
1310
|
params_list=params_list,
|
|
1270
1311
|
)
|
|
1271
|
-
if not template and not profile_id:
|
|
1272
|
-
raise ValueError("Either template or profile_id is required")
|
|
1312
|
+
if not template and not profile_id and profile is None:
|
|
1313
|
+
raise ValueError("Either template, profile or profile_id is required")
|
|
1273
1314
|
validate_ak_selection(ak_id, params, params_list, overrides)
|
|
1274
1315
|
body: dict = {}
|
|
1275
1316
|
if template is not None:
|
|
@@ -1347,6 +1388,8 @@ class APIClient:
|
|
|
1347
1388
|
body["eval_config"] = eval_config
|
|
1348
1389
|
|
|
1349
1390
|
# Profile mode fields
|
|
1391
|
+
if profile is not None:
|
|
1392
|
+
body["profile"] = profile
|
|
1350
1393
|
if profile_id is not None:
|
|
1351
1394
|
body["profile_id"] = profile_id
|
|
1352
1395
|
if profile_version is not None:
|
|
@@ -2212,8 +2255,14 @@ class APIClient:
|
|
|
2212
2255
|
next_token: Optional[str] = None,
|
|
2213
2256
|
pagination: Optional[str] = None,
|
|
2214
2257
|
include_total: bool = True,
|
|
2258
|
+
download_mode: Optional[Literal["oss", "proxy", "both"]] = None,
|
|
2215
2259
|
) -> dict:
|
|
2216
|
-
"""Get one page of
|
|
2260
|
+
"""Get one page of archive links.
|
|
2261
|
+
|
|
2262
|
+
Omit download_mode for the server default and legacy response. Explicit
|
|
2263
|
+
oss/proxy/both modes add a downloads mapping to each artifact; both can
|
|
2264
|
+
return only one successful provider. This method does not download files.
|
|
2265
|
+
"""
|
|
2217
2266
|
params: dict[str, object] = {"skip": skip, "limit": limit}
|
|
2218
2267
|
if include_post_process:
|
|
2219
2268
|
params["include_post_process"] = True
|
|
@@ -2223,19 +2272,28 @@ class APIClient:
|
|
|
2223
2272
|
params["pagination"] = pagination
|
|
2224
2273
|
if not include_total:
|
|
2225
2274
|
params["include_total"] = False
|
|
2275
|
+
if download_mode is not None:
|
|
2276
|
+
params["download_mode"] = download_mode
|
|
2226
2277
|
endpoint = f"/groups/{quote(group_id)}/artifacts"
|
|
2227
2278
|
response = self._get(endpoint, params=params)
|
|
2228
2279
|
_ensure_next_token_honored(response, next_token=next_token, endpoint=endpoint)
|
|
2229
2280
|
return response
|
|
2230
2281
|
|
|
2231
|
-
def get_group_artifacts(
|
|
2232
|
-
|
|
2282
|
+
def get_group_artifacts(
|
|
2283
|
+
self,
|
|
2284
|
+
group_id: str,
|
|
2285
|
+
include_post_process: bool = False,
|
|
2286
|
+
*,
|
|
2287
|
+
download_mode: Optional[Literal["oss", "proxy", "both"]] = None,
|
|
2288
|
+
) -> dict:
|
|
2289
|
+
"""Collect all archive-link pages, preserving download_mode on every page."""
|
|
2233
2290
|
next_token: str | None = None
|
|
2234
2291
|
skip = 0
|
|
2235
2292
|
mode: str | None = None
|
|
2236
2293
|
artifacts: list[dict] = []
|
|
2237
2294
|
last_page: dict | None = None
|
|
2238
2295
|
|
|
2296
|
+
download_options = {"download_mode": download_mode} if download_mode is not None else {}
|
|
2239
2297
|
while True:
|
|
2240
2298
|
page = self.get_group_artifacts_page(
|
|
2241
2299
|
group_id,
|
|
@@ -2245,6 +2303,7 @@ class APIClient:
|
|
|
2245
2303
|
next_token=next_token,
|
|
2246
2304
|
pagination="cursor" if mode in {None, "cursor"} else None,
|
|
2247
2305
|
include_total=False,
|
|
2306
|
+
**download_options,
|
|
2248
2307
|
)
|
|
2249
2308
|
last_page = page
|
|
2250
2309
|
page_artifacts = page.get("artifacts") or []
|
|
@@ -2278,9 +2337,17 @@ class APIClient:
|
|
|
2278
2337
|
)
|
|
2279
2338
|
return result
|
|
2280
2339
|
|
|
2281
|
-
def get_job_artifacts(
|
|
2282
|
-
|
|
2283
|
-
|
|
2340
|
+
def get_job_artifacts(
|
|
2341
|
+
self,
|
|
2342
|
+
job_ids: list,
|
|
2343
|
+
*,
|
|
2344
|
+
download_mode: Optional[Literal["oss", "proxy", "both"]] = None,
|
|
2345
|
+
) -> list:
|
|
2346
|
+
"""Get archive links; explicit modes also return typed ``downloads`` entries."""
|
|
2347
|
+
body = {"job_ids": job_ids}
|
|
2348
|
+
if download_mode is not None:
|
|
2349
|
+
body["download_mode"] = download_mode
|
|
2350
|
+
return self._post("/jobs/artifacts", body)
|
|
2284
2351
|
|
|
2285
2352
|
def get_job_artifacts_manifest(
|
|
2286
2353
|
self,
|
|
@@ -13,6 +13,7 @@ from urllib.parse import quote
|
|
|
13
13
|
|
|
14
14
|
import typer
|
|
15
15
|
from ap_client import __version__, get_client, get_config, tbb
|
|
16
|
+
from ap_client.akpool_commands import register as _register_akpool_commands
|
|
16
17
|
from ap_client.api import (
|
|
17
18
|
_OTHERS_MODEL,
|
|
18
19
|
APIError,
|
|
@@ -25,7 +26,7 @@ from ap_client.api import (
|
|
|
25
26
|
)
|
|
26
27
|
from ap_client.config import ENV_VAR_SPECS, ConfigurationError, _parse_bool, normalize_output_format
|
|
27
28
|
from ap_client.dataset_commands import register as _register_dataset_commands
|
|
28
|
-
from ap_client.exporter import export_group, export_job
|
|
29
|
+
from ap_client.exporter import export_group, export_job, export_jobs
|
|
29
30
|
from ap_client.fs_commands import register as _register_fs_commands
|
|
30
31
|
from ap_client.instance_commands import register as _register_instance_commands
|
|
31
32
|
from ap_client.managed_ak import (
|
|
@@ -160,6 +161,7 @@ app.add_typer(checkpoint_app, name="checkpoint")
|
|
|
160
161
|
_register_dataset_commands(dataset_app)
|
|
161
162
|
_register_instance_commands(app)
|
|
162
163
|
_register_fs_commands(app)
|
|
164
|
+
_register_akpool_commands(app)
|
|
163
165
|
|
|
164
166
|
_PAI_RUNTIME_ENV_TAGS: tuple[tuple[str, str], ...] = (
|
|
165
167
|
("DLC_JOB_ID", "dlc_job_id"),
|
|
@@ -263,7 +265,7 @@ def _build_job_create_retry_command(
|
|
|
263
265
|
args.append(template)
|
|
264
266
|
if ak_id is not None:
|
|
265
267
|
args.extend(["--ak-id", ak_id])
|
|
266
|
-
if profile:
|
|
268
|
+
if profile is not None:
|
|
267
269
|
args.extend(["--profile", profile])
|
|
268
270
|
if resource_profile_id:
|
|
269
271
|
args.extend(["--resource-profile", resource_profile_id])
|
|
@@ -563,6 +565,27 @@ def _require_resource_profile_ack(result: dict, resource_profile_id: Optional[st
|
|
|
563
565
|
raise typer.Exit(1)
|
|
564
566
|
|
|
565
567
|
|
|
568
|
+
def _require_profile_ack(result: dict, profile: str) -> None:
|
|
569
|
+
"""Detect servers that silently ignored the Profile reference (even in dry-run)."""
|
|
570
|
+
profile_id = result.get("profile_id")
|
|
571
|
+
version = result.get("profile_version")
|
|
572
|
+
if isinstance(profile_id, str) and profile_id.strip() and type(version) is int and version > 0:
|
|
573
|
+
return
|
|
574
|
+
identifiers = {key: result[key] for key in ("job_id", "group_id") if result.get(key)}
|
|
575
|
+
jobs = result.get("jobs")
|
|
576
|
+
if isinstance(jobs, list):
|
|
577
|
+
identifiers["job_ids"] = [
|
|
578
|
+
job["job_id"] for job in jobs if isinstance(job, dict) and job.get("job_id")
|
|
579
|
+
]
|
|
580
|
+
_emit_error(
|
|
581
|
+
f"server did not return a resolved profile_id and profile_version for {profile!r}. "
|
|
582
|
+
"Deploy an AP server supporting profile references before using this CLI. "
|
|
583
|
+
"Jobs may already have been created, even with --dry-run; check the returned IDs "
|
|
584
|
+
f"before retrying: {json.dumps(identifiers)}. No legacy-field retry was sent."
|
|
585
|
+
)
|
|
586
|
+
raise typer.Exit(1)
|
|
587
|
+
|
|
588
|
+
|
|
566
589
|
def _select_instance_ids(
|
|
567
590
|
instance_ids: list[str],
|
|
568
591
|
instance_range: Optional[str],
|
|
@@ -3030,7 +3053,7 @@ def job_create(
|
|
|
3030
3053
|
profile: Optional[str] = typer.Option(
|
|
3031
3054
|
None,
|
|
3032
3055
|
"--profile",
|
|
3033
|
-
help="Profile ID or name
|
|
3056
|
+
help="Profile ID or name with optional :latest, :verified, :release (no fallback), or :vN; resolved server-side",
|
|
3034
3057
|
),
|
|
3035
3058
|
resource_profile_id: Optional[str] = typer.Option(
|
|
3036
3059
|
None,
|
|
@@ -3240,39 +3263,19 @@ def job_create(
|
|
|
3240
3263
|
raise typer.Exit(1)
|
|
3241
3264
|
|
|
3242
3265
|
# Reject --trials when not in profile mode (profile-only override)
|
|
3243
|
-
if trials is not None and
|
|
3266
|
+
if trials is not None and profile is None:
|
|
3244
3267
|
_emit_error("--trials is only supported with --profile")
|
|
3245
3268
|
raise typer.Exit(1)
|
|
3246
3269
|
|
|
3247
3270
|
# Reject --eval-config when not in profile mode (use 'ap group create/update --eval-config' instead)
|
|
3248
|
-
if eval_config is not None and
|
|
3271
|
+
if eval_config is not None and profile is None:
|
|
3249
3272
|
_emit_error(
|
|
3250
3273
|
"--eval-config is only supported with --profile. Use 'ap group create --eval-config' or 'ap group update --eval-config' instead."
|
|
3251
3274
|
)
|
|
3252
3275
|
raise typer.Exit(1)
|
|
3253
3276
|
|
|
3254
3277
|
# Profile mode: early branch
|
|
3255
|
-
if profile:
|
|
3256
|
-
# Parse profile:version syntax (e.g. "sweagent-swe:v3" or "sweagent-swe:3")
|
|
3257
|
-
# Also supports :verified suffix (e.g. "sweagent-swe:verified") which stays
|
|
3258
|
-
# in profile_id and is interpreted server-side.
|
|
3259
|
-
profile_version_req: Optional[int] = None
|
|
3260
|
-
if ":" in profile:
|
|
3261
|
-
_, suffix = profile.rsplit(":", 1)
|
|
3262
|
-
if suffix.lower() == "verified":
|
|
3263
|
-
# Keep :verified in the profile string — server parses it from profile_id
|
|
3264
|
-
pass
|
|
3265
|
-
else:
|
|
3266
|
-
profile, version_str = profile.rsplit(":", 1)
|
|
3267
|
-
version_str = version_str.lstrip("v")
|
|
3268
|
-
try:
|
|
3269
|
-
profile_version_req = int(version_str)
|
|
3270
|
-
except ValueError:
|
|
3271
|
-
_emit_error(
|
|
3272
|
-
f"Invalid profile version: '{version_str}' (expected integer or 'verified', e.g. --profile myprofile:v3 or --profile myprofile:verified)"
|
|
3273
|
-
)
|
|
3274
|
-
raise typer.Exit(1)
|
|
3275
|
-
|
|
3278
|
+
if profile is not None:
|
|
3276
3279
|
# Mutual exclusion checks
|
|
3277
3280
|
if params_list_input:
|
|
3278
3281
|
_emit_error(
|
|
@@ -3298,7 +3301,7 @@ def job_create(
|
|
|
3298
3301
|
if not suite_name and not group_id:
|
|
3299
3302
|
from datetime import datetime as _dt
|
|
3300
3303
|
|
|
3301
|
-
suite_name = f"{template or 'profile'}-sp-{profile}-{_dt.now().strftime('%m%d-%H%M')}"
|
|
3304
|
+
suite_name = f"{template or 'profile'}-sp-{profile.replace(':', '-')}-{_dt.now().strftime('%m%d-%H%M')}"
|
|
3302
3305
|
|
|
3303
3306
|
# Parse IDs only; Profile range selection happens on the server.
|
|
3304
3307
|
instances_list: Optional[list[str]] = None
|
|
@@ -3336,10 +3339,9 @@ def job_create(
|
|
|
3336
3339
|
credential_type=credential_type,
|
|
3337
3340
|
account_pool=account_pool,
|
|
3338
3341
|
eval_config=eval_config_dict,
|
|
3339
|
-
|
|
3342
|
+
profile=profile,
|
|
3340
3343
|
resource_profile_id=resource_profile_id,
|
|
3341
3344
|
**({"ak_id": ak_id} if ak_id is not None else {}),
|
|
3342
|
-
profile_version=profile_version_req,
|
|
3343
3345
|
instances=instances_list,
|
|
3344
3346
|
instance_range=instance_range,
|
|
3345
3347
|
dataset=dataset,
|
|
@@ -3373,6 +3375,7 @@ def job_create(
|
|
|
3373
3375
|
except ManagedAKProtocolError as exc:
|
|
3374
3376
|
_emit_error(str(exc))
|
|
3375
3377
|
raise typer.Exit(1) from None
|
|
3378
|
+
_require_profile_ack(result, profile)
|
|
3376
3379
|
_print_formatted(result, "json" if output_format == "plain" else output_format)
|
|
3377
3380
|
return
|
|
3378
3381
|
|
|
@@ -3453,10 +3456,9 @@ def job_create(
|
|
|
3453
3456
|
credential_type=credential_type,
|
|
3454
3457
|
account_pool=account_pool,
|
|
3455
3458
|
eval_config=eval_config_dict,
|
|
3456
|
-
|
|
3459
|
+
profile=profile,
|
|
3457
3460
|
resource_profile_id=resource_profile_id,
|
|
3458
3461
|
**({"ak_id": ak_id} if ak_id is not None else {}),
|
|
3459
|
-
profile_version=profile_version_req,
|
|
3460
3462
|
instances=instances_list,
|
|
3461
3463
|
instance_range=instance_range,
|
|
3462
3464
|
dataset=dataset,
|
|
@@ -3479,6 +3481,8 @@ def job_create(
|
|
|
3479
3481
|
emit_profile_retry_hint_once()
|
|
3480
3482
|
raise
|
|
3481
3483
|
|
|
3484
|
+
_require_profile_ack(result, profile)
|
|
3485
|
+
|
|
3482
3486
|
# Use idempotency_key returned by server (may differ when auto-generated server-side)
|
|
3483
3487
|
effective_idem_key = result.get("idempotency_key") or submission_idempotency_key
|
|
3484
3488
|
|
|
@@ -3491,6 +3495,8 @@ def job_create(
|
|
|
3491
3495
|
print(f" profile: {profile}")
|
|
3492
3496
|
if ak_id is not None:
|
|
3493
3497
|
print(f" ak_ids: {response_ak_ids(result)}")
|
|
3498
|
+
print(f" profile_id: {result['profile_id']}")
|
|
3499
|
+
print(f" profile_version: {result['profile_version']}")
|
|
3494
3500
|
if resource_profile_id is not None:
|
|
3495
3501
|
print(f" resource_profile_id: {resource_profile_id}")
|
|
3496
3502
|
print(f" group_id: {result.get('group_id')}")
|
|
@@ -4665,10 +4671,47 @@ def job_artifacts(
|
|
|
4665
4671
|
_print_formatted(results, output_format)
|
|
4666
4672
|
|
|
4667
4673
|
|
|
4674
|
+
def _validate_export_timeout(value: float) -> float:
|
|
4675
|
+
import math
|
|
4676
|
+
|
|
4677
|
+
if not math.isfinite(value) or value <= 0:
|
|
4678
|
+
raise typer.BadParameter("Timeout must be a finite number greater than zero")
|
|
4679
|
+
return value
|
|
4680
|
+
|
|
4681
|
+
|
|
4682
|
+
def _export_channel_reporter(emit):
|
|
4683
|
+
"""Report completed Jobs, including callbacks from Group worker threads."""
|
|
4684
|
+
from threading import Lock
|
|
4685
|
+
|
|
4686
|
+
from rich.text import Text
|
|
4687
|
+
|
|
4688
|
+
counts = {"oss": 0, "proxy": 0}
|
|
4689
|
+
lock = Lock()
|
|
4690
|
+
|
|
4691
|
+
def report(job_id, status, channel):
|
|
4692
|
+
with lock:
|
|
4693
|
+
suffix = ""
|
|
4694
|
+
if status == "downloaded" and channel in counts:
|
|
4695
|
+
counts[channel] += 1
|
|
4696
|
+
suffix = f" channel={channel}"
|
|
4697
|
+
emit(Text(f"{job_id} {status}{suffix}"))
|
|
4698
|
+
|
|
4699
|
+
return report, counts
|
|
4700
|
+
|
|
4701
|
+
|
|
4668
4702
|
@job_app.command("export")
|
|
4669
4703
|
def job_export(
|
|
4670
|
-
job_id: str = typer.Argument(..., help="Job ID"),
|
|
4704
|
+
job_id: str = typer.Argument(..., help="Job ID or comma-separated Job IDs"),
|
|
4671
4705
|
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Export directory"),
|
|
4706
|
+
workers: int = typer.Option(
|
|
4707
|
+
1, "--workers", "-w", min=1, help="Number of concurrent export workers"
|
|
4708
|
+
),
|
|
4709
|
+
timeout: float = typer.Option(
|
|
4710
|
+
300,
|
|
4711
|
+
"--timeout",
|
|
4712
|
+
callback=_validate_export_timeout,
|
|
4713
|
+
help="SDK request timeout in seconds, not the total export duration",
|
|
4714
|
+
),
|
|
4672
4715
|
logs: bool = typer.Option(False, "--logs", help="Include container logs"),
|
|
4673
4716
|
events: bool = typer.Option(False, "--events", help="Include events"),
|
|
4674
4717
|
no_extract_artifacts: bool = typer.Option(
|
|
@@ -4678,17 +4721,50 @@ def job_export(
|
|
|
4678
4721
|
),
|
|
4679
4722
|
):
|
|
4680
4723
|
"""Export job artifacts to a local directory. Use --logs/--events to include logs and events."""
|
|
4724
|
+
ids = list(dict.fromkeys(value.strip() for value in job_id.split(",")))
|
|
4725
|
+
if not all(ids):
|
|
4726
|
+
raise typer.BadParameter("Job IDs must not be empty", param_hint="job_id")
|
|
4681
4727
|
client = get_client()
|
|
4728
|
+
if len(ids) > 1:
|
|
4729
|
+
report, channels = _export_channel_reporter(print)
|
|
4730
|
+
dest, summary = export_jobs(
|
|
4731
|
+
client,
|
|
4732
|
+
ids,
|
|
4733
|
+
output,
|
|
4734
|
+
workers=workers,
|
|
4735
|
+
result_callback=report,
|
|
4736
|
+
timeout=timeout,
|
|
4737
|
+
include_logs=logs,
|
|
4738
|
+
include_events=events,
|
|
4739
|
+
extract_artifacts=not no_extract_artifacts,
|
|
4740
|
+
)
|
|
4741
|
+
print(
|
|
4742
|
+
f"Jobs exported: downloaded={summary.downloaded} "
|
|
4743
|
+
f"skipped={summary.skipped} failed={len(summary.failures)}"
|
|
4744
|
+
)
|
|
4745
|
+
print(f"channels: oss={channels['oss']} proxy={channels['proxy']}")
|
|
4746
|
+
print(f" path: {dest}")
|
|
4747
|
+
for failed_id, error in summary.failures.items():
|
|
4748
|
+
print(f"[red]Failed job {failed_id}:[/] {error}")
|
|
4749
|
+
if summary.failures:
|
|
4750
|
+
raise typer.Exit(code=1)
|
|
4751
|
+
return
|
|
4752
|
+
job_id = ids[0]
|
|
4753
|
+
result = {}
|
|
4682
4754
|
dest = export_job(
|
|
4683
4755
|
client,
|
|
4684
4756
|
job_id,
|
|
4685
4757
|
output,
|
|
4758
|
+
result_callback=lambda jid, status, channel: result.update(status=status, channel=channel),
|
|
4759
|
+
timeout=timeout,
|
|
4686
4760
|
include_logs=logs,
|
|
4687
4761
|
include_events=events,
|
|
4688
4762
|
extract_artifacts=not no_extract_artifacts,
|
|
4689
4763
|
)
|
|
4690
4764
|
print(f"[green]Job exported:[/] {job_id}")
|
|
4691
4765
|
print(f" path: {dest}")
|
|
4766
|
+
if result.get("channel") in {"oss", "proxy"} and result.get("status") == "downloaded":
|
|
4767
|
+
print(f" channel: {result['channel']}")
|
|
4692
4768
|
|
|
4693
4769
|
|
|
4694
4770
|
@job_app.command("wait")
|
|
@@ -6136,6 +6212,12 @@ def group_export(
|
|
|
6136
6212
|
workers: int = typer.Option(
|
|
6137
6213
|
4, "--workers", "-w", min=1, help="Number of concurrent export workers"
|
|
6138
6214
|
),
|
|
6215
|
+
timeout: float = typer.Option(
|
|
6216
|
+
300,
|
|
6217
|
+
"--timeout",
|
|
6218
|
+
callback=_validate_export_timeout,
|
|
6219
|
+
help="SDK request timeout in seconds, not the total export duration",
|
|
6220
|
+
),
|
|
6139
6221
|
logs: bool = typer.Option(False, "--logs", help="Include container logs"),
|
|
6140
6222
|
events: bool = typer.Option(False, "--events", help="Include events"),
|
|
6141
6223
|
no_extract_artifacts: bool = typer.Option(
|
|
@@ -6196,12 +6278,15 @@ def group_export(
|
|
|
6196
6278
|
current_stage=current_stage,
|
|
6197
6279
|
)
|
|
6198
6280
|
|
|
6281
|
+
report, channels = _export_channel_reporter(progress.console.print)
|
|
6199
6282
|
dest, summary = export_group(
|
|
6200
6283
|
client,
|
|
6201
6284
|
group_id,
|
|
6202
6285
|
output,
|
|
6203
6286
|
progress_callback=_on_progress,
|
|
6204
6287
|
workers=workers,
|
|
6288
|
+
result_callback=report,
|
|
6289
|
+
timeout=timeout,
|
|
6205
6290
|
include_logs=logs,
|
|
6206
6291
|
include_events=events,
|
|
6207
6292
|
extract_artifacts=not no_extract_artifacts,
|
|
@@ -6214,6 +6299,7 @@ def group_export(
|
|
|
6214
6299
|
f" [green]{summary.downloaded}[/] jobs downloaded, "
|
|
6215
6300
|
f"[yellow]{summary.skipped}[/] jobs skipped (no artifact)"
|
|
6216
6301
|
)
|
|
6302
|
+
print(f"channels: oss={channels['oss']} proxy={channels['proxy']}")
|
|
6217
6303
|
if summary.skipped_job_names:
|
|
6218
6304
|
print(f" Skipped: {', '.join(summary.skipped_job_names)}")
|
|
6219
6305
|
|