ap-client 0.4.1__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.
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.4.1
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
@@ -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
@@ -1186,6 +1186,7 @@ class APIClient:
1186
1186
  experiment: Optional[str] = None,
1187
1187
  ak_id: Optional[str] = None,
1188
1188
  instance_range: Optional[str] = None,
1189
+ profile: Optional[str] = None,
1189
1190
  ) -> dict:
1190
1191
  """Create a job."""
1191
1192
  body = self.build_create_job_body(
@@ -1216,6 +1217,7 @@ class APIClient:
1216
1217
  credential_type=credential_type,
1217
1218
  account_pool=account_pool,
1218
1219
  eval_config=eval_config,
1220
+ profile=profile,
1219
1221
  profile_id=profile_id,
1220
1222
  profile_version=profile_version,
1221
1223
  instances=instances,
@@ -1296,16 +1298,19 @@ class APIClient:
1296
1298
  experiment: Optional[str] = None,
1297
1299
  ak_id: Optional[str] = None,
1298
1300
  instance_range: Optional[str] = None,
1301
+ profile: Optional[str] = None,
1299
1302
  ) -> dict:
1300
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")
1301
1306
  _validate_credential_source_selection(
1302
1307
  account_pool=account_pool,
1303
1308
  credential_type=credential_type,
1304
1309
  params=params,
1305
1310
  params_list=params_list,
1306
1311
  )
1307
- if not template and not profile_id:
1308
- 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")
1309
1314
  validate_ak_selection(ak_id, params, params_list, overrides)
1310
1315
  body: dict = {}
1311
1316
  if template is not None:
@@ -1383,6 +1388,8 @@ class APIClient:
1383
1388
  body["eval_config"] = eval_config
1384
1389
 
1385
1390
  # Profile mode fields
1391
+ if profile is not None:
1392
+ body["profile"] = profile
1386
1393
  if profile_id is not None:
1387
1394
  body["profile_id"] = profile_id
1388
1395
  if profile_version is not None:
@@ -2248,8 +2255,14 @@ class APIClient:
2248
2255
  next_token: Optional[str] = None,
2249
2256
  pagination: Optional[str] = None,
2250
2257
  include_total: bool = True,
2258
+ download_mode: Optional[Literal["oss", "proxy", "both"]] = None,
2251
2259
  ) -> dict:
2252
- """Get one page of artifact download links for a group."""
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
+ """
2253
2266
  params: dict[str, object] = {"skip": skip, "limit": limit}
2254
2267
  if include_post_process:
2255
2268
  params["include_post_process"] = True
@@ -2259,19 +2272,28 @@ class APIClient:
2259
2272
  params["pagination"] = pagination
2260
2273
  if not include_total:
2261
2274
  params["include_total"] = False
2275
+ if download_mode is not None:
2276
+ params["download_mode"] = download_mode
2262
2277
  endpoint = f"/groups/{quote(group_id)}/artifacts"
2263
2278
  response = self._get(endpoint, params=params)
2264
2279
  _ensure_next_token_honored(response, next_token=next_token, endpoint=endpoint)
2265
2280
  return response
2266
2281
 
2267
- def get_group_artifacts(self, group_id: str, include_post_process: bool = False) -> dict:
2268
- """Get artifact download links for a group."""
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."""
2269
2290
  next_token: str | None = None
2270
2291
  skip = 0
2271
2292
  mode: str | None = None
2272
2293
  artifacts: list[dict] = []
2273
2294
  last_page: dict | None = None
2274
2295
 
2296
+ download_options = {"download_mode": download_mode} if download_mode is not None else {}
2275
2297
  while True:
2276
2298
  page = self.get_group_artifacts_page(
2277
2299
  group_id,
@@ -2281,6 +2303,7 @@ class APIClient:
2281
2303
  next_token=next_token,
2282
2304
  pagination="cursor" if mode in {None, "cursor"} else None,
2283
2305
  include_total=False,
2306
+ **download_options,
2284
2307
  )
2285
2308
  last_page = page
2286
2309
  page_artifacts = page.get("artifacts") or []
@@ -2314,9 +2337,17 @@ class APIClient:
2314
2337
  )
2315
2338
  return result
2316
2339
 
2317
- def get_job_artifacts(self, job_ids: list) -> list:
2318
- """Get artifact download links for one or more jobs."""
2319
- return self._post("/jobs/artifacts", {"job_ids": job_ids})
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)
2320
2351
 
2321
2352
  def get_job_artifacts_manifest(
2322
2353
  self,
@@ -26,7 +26,7 @@ from ap_client.api import (
26
26
  )
27
27
  from ap_client.config import ENV_VAR_SPECS, ConfigurationError, _parse_bool, normalize_output_format
28
28
  from ap_client.dataset_commands import register as _register_dataset_commands
29
- from ap_client.exporter import export_group, export_job
29
+ from ap_client.exporter import export_group, export_job, export_jobs
30
30
  from ap_client.fs_commands import register as _register_fs_commands
31
31
  from ap_client.instance_commands import register as _register_instance_commands
32
32
  from ap_client.managed_ak import (
@@ -265,7 +265,7 @@ def _build_job_create_retry_command(
265
265
  args.append(template)
266
266
  if ak_id is not None:
267
267
  args.extend(["--ak-id", ak_id])
268
- if profile:
268
+ if profile is not None:
269
269
  args.extend(["--profile", profile])
270
270
  if resource_profile_id:
271
271
  args.extend(["--resource-profile", resource_profile_id])
@@ -565,6 +565,27 @@ def _require_resource_profile_ack(result: dict, resource_profile_id: Optional[st
565
565
  raise typer.Exit(1)
566
566
 
567
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
+
568
589
  def _select_instance_ids(
569
590
  instance_ids: list[str],
570
591
  instance_range: Optional[str],
@@ -3032,7 +3053,7 @@ def job_create(
3032
3053
  profile: Optional[str] = typer.Option(
3033
3054
  None,
3034
3055
  "--profile",
3035
- help="Profile ID or name; triggers profile mode (server-side dataset expansion)",
3056
+ help="Profile ID or name with optional :latest, :verified, :release (no fallback), or :vN; resolved server-side",
3036
3057
  ),
3037
3058
  resource_profile_id: Optional[str] = typer.Option(
3038
3059
  None,
@@ -3242,39 +3263,19 @@ def job_create(
3242
3263
  raise typer.Exit(1)
3243
3264
 
3244
3265
  # Reject --trials when not in profile mode (profile-only override)
3245
- if trials is not None and not profile:
3266
+ if trials is not None and profile is None:
3246
3267
  _emit_error("--trials is only supported with --profile")
3247
3268
  raise typer.Exit(1)
3248
3269
 
3249
3270
  # Reject --eval-config when not in profile mode (use 'ap group create/update --eval-config' instead)
3250
- if eval_config is not None and not profile:
3271
+ if eval_config is not None and profile is None:
3251
3272
  _emit_error(
3252
3273
  "--eval-config is only supported with --profile. Use 'ap group create --eval-config' or 'ap group update --eval-config' instead."
3253
3274
  )
3254
3275
  raise typer.Exit(1)
3255
3276
 
3256
3277
  # Profile mode: early branch
3257
- if profile:
3258
- # Parse profile:version syntax (e.g. "sweagent-swe:v3" or "sweagent-swe:3")
3259
- # Also supports :verified suffix (e.g. "sweagent-swe:verified") which stays
3260
- # in profile_id and is interpreted server-side.
3261
- profile_version_req: Optional[int] = None
3262
- if ":" in profile:
3263
- _, suffix = profile.rsplit(":", 1)
3264
- if suffix.lower() == "verified":
3265
- # Keep :verified in the profile string — server parses it from profile_id
3266
- pass
3267
- else:
3268
- profile, version_str = profile.rsplit(":", 1)
3269
- version_str = version_str.lstrip("v")
3270
- try:
3271
- profile_version_req = int(version_str)
3272
- except ValueError:
3273
- _emit_error(
3274
- f"Invalid profile version: '{version_str}' (expected integer or 'verified', e.g. --profile myprofile:v3 or --profile myprofile:verified)"
3275
- )
3276
- raise typer.Exit(1)
3277
-
3278
+ if profile is not None:
3278
3279
  # Mutual exclusion checks
3279
3280
  if params_list_input:
3280
3281
  _emit_error(
@@ -3300,7 +3301,7 @@ def job_create(
3300
3301
  if not suite_name and not group_id:
3301
3302
  from datetime import datetime as _dt
3302
3303
 
3303
- 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')}"
3304
3305
 
3305
3306
  # Parse IDs only; Profile range selection happens on the server.
3306
3307
  instances_list: Optional[list[str]] = None
@@ -3338,10 +3339,9 @@ def job_create(
3338
3339
  credential_type=credential_type,
3339
3340
  account_pool=account_pool,
3340
3341
  eval_config=eval_config_dict,
3341
- profile_id=profile,
3342
+ profile=profile,
3342
3343
  resource_profile_id=resource_profile_id,
3343
3344
  **({"ak_id": ak_id} if ak_id is not None else {}),
3344
- profile_version=profile_version_req,
3345
3345
  instances=instances_list,
3346
3346
  instance_range=instance_range,
3347
3347
  dataset=dataset,
@@ -3375,6 +3375,7 @@ def job_create(
3375
3375
  except ManagedAKProtocolError as exc:
3376
3376
  _emit_error(str(exc))
3377
3377
  raise typer.Exit(1) from None
3378
+ _require_profile_ack(result, profile)
3378
3379
  _print_formatted(result, "json" if output_format == "plain" else output_format)
3379
3380
  return
3380
3381
 
@@ -3455,10 +3456,9 @@ def job_create(
3455
3456
  credential_type=credential_type,
3456
3457
  account_pool=account_pool,
3457
3458
  eval_config=eval_config_dict,
3458
- profile_id=profile,
3459
+ profile=profile,
3459
3460
  resource_profile_id=resource_profile_id,
3460
3461
  **({"ak_id": ak_id} if ak_id is not None else {}),
3461
- profile_version=profile_version_req,
3462
3462
  instances=instances_list,
3463
3463
  instance_range=instance_range,
3464
3464
  dataset=dataset,
@@ -3481,6 +3481,8 @@ def job_create(
3481
3481
  emit_profile_retry_hint_once()
3482
3482
  raise
3483
3483
 
3484
+ _require_profile_ack(result, profile)
3485
+
3484
3486
  # Use idempotency_key returned by server (may differ when auto-generated server-side)
3485
3487
  effective_idem_key = result.get("idempotency_key") or submission_idempotency_key
3486
3488
 
@@ -3493,6 +3495,8 @@ def job_create(
3493
3495
  print(f" profile: {profile}")
3494
3496
  if ak_id is not None:
3495
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']}")
3496
3500
  if resource_profile_id is not None:
3497
3501
  print(f" resource_profile_id: {resource_profile_id}")
3498
3502
  print(f" group_id: {result.get('group_id')}")
@@ -4667,10 +4671,47 @@ def job_artifacts(
4667
4671
  _print_formatted(results, output_format)
4668
4672
 
4669
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
+
4670
4702
  @job_app.command("export")
4671
4703
  def job_export(
4672
- job_id: str = typer.Argument(..., help="Job ID"),
4704
+ job_id: str = typer.Argument(..., help="Job ID or comma-separated Job IDs"),
4673
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
+ ),
4674
4715
  logs: bool = typer.Option(False, "--logs", help="Include container logs"),
4675
4716
  events: bool = typer.Option(False, "--events", help="Include events"),
4676
4717
  no_extract_artifacts: bool = typer.Option(
@@ -4680,17 +4721,50 @@ def job_export(
4680
4721
  ),
4681
4722
  ):
4682
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")
4683
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 = {}
4684
4754
  dest = export_job(
4685
4755
  client,
4686
4756
  job_id,
4687
4757
  output,
4758
+ result_callback=lambda jid, status, channel: result.update(status=status, channel=channel),
4759
+ timeout=timeout,
4688
4760
  include_logs=logs,
4689
4761
  include_events=events,
4690
4762
  extract_artifacts=not no_extract_artifacts,
4691
4763
  )
4692
4764
  print(f"[green]Job exported:[/] {job_id}")
4693
4765
  print(f" path: {dest}")
4766
+ if result.get("channel") in {"oss", "proxy"} and result.get("status") == "downloaded":
4767
+ print(f" channel: {result['channel']}")
4694
4768
 
4695
4769
 
4696
4770
  @job_app.command("wait")
@@ -6138,6 +6212,12 @@ def group_export(
6138
6212
  workers: int = typer.Option(
6139
6213
  4, "--workers", "-w", min=1, help="Number of concurrent export workers"
6140
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
+ ),
6141
6221
  logs: bool = typer.Option(False, "--logs", help="Include container logs"),
6142
6222
  events: bool = typer.Option(False, "--events", help="Include events"),
6143
6223
  no_extract_artifacts: bool = typer.Option(
@@ -6198,12 +6278,15 @@ def group_export(
6198
6278
  current_stage=current_stage,
6199
6279
  )
6200
6280
 
6281
+ report, channels = _export_channel_reporter(progress.console.print)
6201
6282
  dest, summary = export_group(
6202
6283
  client,
6203
6284
  group_id,
6204
6285
  output,
6205
6286
  progress_callback=_on_progress,
6206
6287
  workers=workers,
6288
+ result_callback=report,
6289
+ timeout=timeout,
6207
6290
  include_logs=logs,
6208
6291
  include_events=events,
6209
6292
  extract_artifacts=not no_extract_artifacts,
@@ -6216,6 +6299,7 @@ def group_export(
6216
6299
  f" [green]{summary.downloaded}[/] jobs downloaded, "
6217
6300
  f"[yellow]{summary.skipped}[/] jobs skipped (no artifact)"
6218
6301
  )
6302
+ print(f"channels: oss={channels['oss']} proxy={channels['proxy']}")
6219
6303
  if summary.skipped_job_names:
6220
6304
  print(f" Skipped: {', '.join(summary.skipped_job_names)}")
6221
6305