opsai-cli 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.
opsai_cli/contracts.py ADDED
@@ -0,0 +1,203 @@
1
+ """Standalone API v1 snapshot (2026-10-31).
2
+
3
+ Mirrors api/routes/open_api/schemas.py and shared/button_guide.py.
4
+ Contract tests compare against those pure model definitions without importing
5
+ the backend. Never import backend modules in the distributed client.
6
+ """
7
+ from typing import Annotated, Literal
8
+ from urllib.parse import urlsplit
9
+ import ipaddress
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, AfterValidator, model_validator
12
+
13
+
14
+
15
+ class ButtonRegion(BaseModel):
16
+ model_config = ConfigDict(extra="forbid")
17
+ number: int = Field(ge=1, le=100)
18
+ x: float = Field(ge=0, le=1, allow_inf_nan=False)
19
+ y: float = Field(ge=0, le=1, allow_inf_nan=False)
20
+ text: str = Field(min_length=1, max_length=1000)
21
+
22
+ @model_validator(mode="after")
23
+ def nonblank(self):
24
+ if not self.text.strip():
25
+ raise ValueError("请填写每个标记的功能")
26
+ return self
27
+
28
+ class ButtonGuide(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+ original: str = Field(min_length=1, max_length=2048)
31
+ base: str | None = Field(default=None, max_length=2048)
32
+ annotated: str | None = Field(default=None, max_length=2048)
33
+ marker_size: float = Field(default=0.036, ge=0.000001, le=8, allow_inf_nan=False)
34
+ regions: list[ButtonRegion] = Field(default_factory=list, max_length=100)
35
+
36
+ @model_validator(mode="after")
37
+ def unique_numbers(self):
38
+ if len({r.number for r in self.regions}) != len(self.regions):
39
+ raise ValueError("功能标记序号不能重复")
40
+ if self.regions and not self.annotated:
41
+ raise ValueError("功能标记缺少已保存的标记图片")
42
+ return self
43
+
44
+
45
+
46
+ def public_url(value: str) -> str:
47
+ parsed = urlsplit(value)
48
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
49
+ raise ValueError("素材必须是公开的 HTTP/HTTPS URL")
50
+ host = parsed.hostname.lower().rstrip(".")
51
+ if host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
52
+ raise ValueError("素材 URL 不可指向内网")
53
+ try:
54
+ address = ipaddress.ip_address(host)
55
+ except ValueError:
56
+ pass
57
+ else:
58
+ if not address.is_global:
59
+ raise ValueError("素材 URL 不可指向内网")
60
+ return value
61
+
62
+
63
+ def nonblank(value: str) -> str:
64
+ if not value.strip():
65
+ raise ValueError("不能为空")
66
+ return value
67
+
68
+
69
+ Text = Annotated[str, Field(min_length=1, max_length=128), AfterValidator(nonblank)]
70
+ Id = Annotated[int, Field(strict=True, gt=0)]
71
+ Money = Annotated[float, Field(gt=0, allow_inf_nan=False)]
72
+ URL = Annotated[str, Field(max_length=2000), AfterValidator(public_url)]
73
+
74
+
75
+ class Contract(BaseModel):
76
+ model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
77
+
78
+
79
+ class Images(Contract):
80
+ main_img: URL
81
+ bg_img: list[URL] = Field(default_factory=list)
82
+ size_img: URL | None = None
83
+ sale_img: list[URL] = Field(default_factory=list)
84
+ scene_img: list[URL] = Field(default_factory=list)
85
+ compete_img: list[URL] = Field(default_factory=list)
86
+ family_img: URL | None = None
87
+
88
+
89
+ class Dimensions(Contract):
90
+ product_length: float | None = Field(None, ge=0)
91
+ product_width: float | None = Field(None, ge=0)
92
+ product_height: float | None = Field(None, ge=0)
93
+ product_weight: float | None = Field(None, ge=0)
94
+ package_length: float | None = Field(None, ge=0)
95
+ package_width: float | None = Field(None, ge=0)
96
+ package_height: float | None = Field(None, ge=0)
97
+ package_weight: float | None = Field(None, ge=0)
98
+ length_unit: Literal["毫米", "厘米", "米", "英寸"] = "毫米"
99
+ weight_unit: Literal["克", "千克", "磅"] = "克"
100
+
101
+
102
+ class Product(Contract):
103
+ sku: Annotated[str, Field(min_length=1, max_length=64)]
104
+ spu: Annotated[str, Field(min_length=1, max_length=64)] | None = None
105
+ title: Annotated[str, Field(min_length=1, max_length=255)]
106
+ description: str | None = None
107
+ keywords: list[str] = Field(default_factory=list)
108
+ sale_points: list[str] = Field(default_factory=list)
109
+ video_url: URL | None = None
110
+ price: float | None = Field(None, ge=0)
111
+ currency: Literal["RMB"] = "RMB"
112
+ inventory: int = Field(50, ge=0)
113
+ package_list: str | None = Field(None, max_length=255)
114
+ size: str | None = None
115
+ dimensions: Dimensions = Field(default_factory=Dimensions)
116
+ images: Images
117
+ button_guide: ButtonGuide | None = None
118
+
119
+ @model_validator(mode="after")
120
+ def validate_materials(self):
121
+ if not self.sku.strip() or not self.title.strip() or (self.spu is not None and not self.spu.strip()):
122
+ raise ValueError("SKU、SPU、标题不能为空白")
123
+ if len(self.images.main_img) > 512:
124
+ raise ValueError("主图 URL 长度不能超过 512")
125
+ if self.button_guide:
126
+ for value in (self.button_guide.original, self.button_guide.base, self.button_guide.annotated):
127
+ if value:
128
+ public_url(value)
129
+ return self
130
+
131
+
132
+ class SitePrice(Contract):
133
+ site: Annotated[str, Field(pattern=r"^[A-Z]{2}$")]
134
+ price: Money
135
+ pre_promotion_price: Money | None = None
136
+ promotional_price: Money | None = None
137
+ currency: Annotated[str, Field(pattern=r"^[A-Z]{3}$")] | None = None
138
+
139
+
140
+ class StorePrices(Contract):
141
+ store_id: Id
142
+ site_prices: list[SitePrice] = Field(min_length=1, max_length=100)
143
+
144
+ @model_validator(mode="after")
145
+ def unique_sites(self):
146
+ if len({row.site for row in self.site_prices}) != len(self.site_prices):
147
+ raise ValueError("同一店铺站点价格不可重复")
148
+ return self
149
+
150
+
151
+ class ListingSubmit(Contract):
152
+ request_id: Text
153
+ store_ids: list[Id] = Field(min_length=1, max_length=100)
154
+ prices: list[StorePrices] = Field(min_length=1, max_length=100)
155
+ products: list[Product] = Field(min_length=1, max_length=100)
156
+
157
+ @model_validator(mode="after")
158
+ def complete_prices(self):
159
+ if len(set(self.store_ids)) != len(self.store_ids):
160
+ raise ValueError("店铺不可重复")
161
+ if len({p.store_id for p in self.prices}) != len(self.prices) or {p.store_id for p in self.prices} != set(self.store_ids):
162
+ raise ValueError("prices 必须与 store_ids 一一对应")
163
+ if len({p.sku for p in self.products}) != len(self.products):
164
+ raise ValueError("商品 SKU 不可重复")
165
+ return self
166
+
167
+
168
+ class AvailableActions(Contract):
169
+ store_id: Id
170
+ listing_sku: Text
171
+ platform: Literal[9] = 9
172
+
173
+
174
+ class ListingPrices(Contract):
175
+ store_id: Id
176
+ listing_skus: list[Text] = Field(min_length=1, max_length=100)
177
+ action_id: Id
178
+ platform: Literal[9]
179
+
180
+
181
+ class ActivityItem(Contract):
182
+ listing_sku: Text
183
+ action_price: Money
184
+
185
+
186
+ class ActivitySubmit(Contract):
187
+ request_id: Text
188
+ store_id: Id
189
+ action_id: Id
190
+ items: list[ActivityItem] = Field(min_length=1, max_length=100)
191
+
192
+ @model_validator(mode="after")
193
+ def unique_skus(self):
194
+ if len({item.listing_sku for item in self.items}) != len(self.items):
195
+ raise ValueError("listing_sku 不可重复")
196
+ return self
197
+
198
+
199
+ class OptimizationSubmit(Contract):
200
+ request_id: Text
201
+ store_id: Id
202
+ mode: Literal["update", "create"]
203
+ listing_sku: Text
@@ -0,0 +1,71 @@
1
+ """Origin-bound OS credentials; no plaintext or plugin fallback."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ from opsai_cli.config import normalize_base_url, profile_name
9
+ from opsai_cli.errors import CliError
10
+
11
+
12
+ UNAVAILABLE = "System credential storage is unavailable or locked; use OPSAI_API_KEY in this process instead."
13
+
14
+
15
+ def validate_key(value: str) -> str:
16
+ if (not isinstance(value, str) or not value or len(value) > 72
17
+ or any(ord(c) < 33 or ord(c) > 126 for c in value)):
18
+ raise CliError("API Key must contain 1-72 printable ASCII characters without whitespace.")
19
+ return value
20
+
21
+
22
+ def system_backend():
23
+ # Select built-in OS implementations explicitly. Do not honor third-party
24
+ # plaintext, null, or chained fallback backends configured via keyring.
25
+ try:
26
+ if sys.platform == "win32":
27
+ from keyring.backends.Windows import WinVaultKeyring
28
+ backend = WinVaultKeyring()
29
+ elif sys.platform == "darwin":
30
+ from keyring.backends.macOS import Keyring
31
+ backend = Keyring()
32
+ elif sys.platform.startswith("linux"):
33
+ from keyring.backends.SecretService import Keyring
34
+ backend = Keyring()
35
+ else:
36
+ raise CliError(UNAVAILABLE)
37
+ if backend.priority <= 0:
38
+ raise CliError(UNAVAILABLE)
39
+ return backend
40
+ except Exception:
41
+ raise CliError(UNAVAILABLE) from None
42
+
43
+
44
+ def credential_account(profile: str, base_url: str) -> str:
45
+ identity = [profile_name(profile), normalize_base_url(base_url)]
46
+ return hashlib.sha256(json.dumps(identity, separators=(",", ":")).encode()).hexdigest()
47
+
48
+
49
+ class CredentialStore:
50
+ def __init__(self, backend_factory=None):
51
+ self.backend_factory = backend_factory or system_backend
52
+
53
+ def save(self, profile: str, base_url: str, key: str) -> None:
54
+ key = validate_key(key)
55
+ account = credential_account(profile, base_url)
56
+ try:
57
+ self.backend_factory().set_password("opsai-cli", account, key)
58
+ except Exception:
59
+ raise CliError(UNAVAILABLE) from None
60
+
61
+ def resolve(self, profile: str, base_url: str) -> str:
62
+ account = credential_account(profile, base_url)
63
+ if "OPSAI_API_KEY" in os.environ:
64
+ return validate_key(os.environ["OPSAI_API_KEY"])
65
+ try:
66
+ key = self.backend_factory().get_password("opsai-cli", account)
67
+ except Exception:
68
+ raise CliError(UNAVAILABLE) from None
69
+ if key is None:
70
+ raise CliError("No API Key for this profile and service address; run opsai auth configure or set OPSAI_API_KEY.")
71
+ return validate_key(key)
opsai_cli/errors.py ADDED
@@ -0,0 +1,11 @@
1
+ """Safe user-facing errors; never render raw transport or backend exceptions."""
2
+
3
+
4
+ class CliError(Exception):
5
+ def __init__(self, message: str, exit_code: int = 2, *, status: int | None = None,
6
+ retryable: bool = False, retry_after: float | None = None):
7
+ super().__init__(message)
8
+ self.exit_code = exit_code
9
+ self.status = status
10
+ self.retryable = retryable
11
+ self.retry_after = retry_after
opsai_cli/output.py ADDED
@@ -0,0 +1,62 @@
1
+ """Stable stdout results and stderr diagnostics, with credential redaction."""
2
+
3
+ import json
4
+ import re
5
+ import sys
6
+
7
+ from opsai_cli.errors import CliError
8
+
9
+
10
+ class Output:
11
+ def __init__(self, mode=None, secrets=()):
12
+ self.mode = mode or ("table" if sys.stdout.isatty() else "json")
13
+ self.secrets = tuple(s for s in secrets if s)
14
+
15
+ def clean(self, value):
16
+ if isinstance(value, str):
17
+ for secret in self.secrets:
18
+ value = value.replace(secret, "[REDACTED]")
19
+ return re.sub(r"(?i)bearer\s+[^\s\"']+", "Bearer [REDACTED]", value)
20
+ if isinstance(value, list):
21
+ return [self.clean(v) for v in value]
22
+ if isinstance(value, dict):
23
+ return {self.clean(str(k)): self.clean(v) for k, v in value.items()}
24
+ return value
25
+
26
+ def note(self, text):
27
+ print(self.clean(text), file=sys.stderr, flush=True)
28
+
29
+ def emit(self, value, event="result"):
30
+ value = self.clean(value)
31
+ try:
32
+ json.dumps(value, allow_nan=False)
33
+ except (ValueError, TypeError, RecursionError):
34
+ raise CliError("API returned non-serializable or non-finite result data.", 5) from None
35
+ if self.mode == "ndjson":
36
+ print(json.dumps({"event": event, "data": value}, ensure_ascii=True, allow_nan=False), flush=True)
37
+ elif self.mode == "json":
38
+ print(json.dumps(value, ensure_ascii=True, indent=2, allow_nan=False), flush=True)
39
+ else:
40
+ data = value.get("data", value) if isinstance(value, dict) else value
41
+ rows = data if isinstance(data, list) else [data]
42
+ if rows and all(isinstance(row, dict) for row in rows):
43
+ columns = list(dict.fromkeys(k for row in rows for k in row))
44
+ print("\t".join(columns))
45
+ for row in rows:
46
+ print("\t".join(json.dumps(row.get(k), ensure_ascii=True) for k in columns))
47
+ else:
48
+ print(json.dumps(data, ensure_ascii=True))
49
+ print("Business task times: Asia/Shanghai; platform timestamps retain their source timezone.")
50
+
51
+ def event(self, name, data):
52
+ if self.mode == "ndjson":
53
+ self.emit(data, name)
54
+ elif name == "log":
55
+ self.note(str(data.get("message", "")))
56
+
57
+ def error(self, exc):
58
+ error = {"source": "cli", "exit_code": exc.exit_code, "message": str(exc)}
59
+ if self.mode in {"json", "ndjson"}:
60
+ print(json.dumps(self.clean({"error": error}), ensure_ascii=True), file=sys.stderr, flush=True)
61
+ else:
62
+ self.note(f"opsai: {exc}")
opsai_cli/sse.py ADDED
@@ -0,0 +1,65 @@
1
+ """Bounded SSE framing over HTTPX's incremental UTF-8 line decoder."""
2
+
3
+ import json
4
+ import codecs
5
+
6
+ from opsai_cli.errors import CliError
7
+
8
+
9
+ def utf8_lines(chunks, check=lambda: None):
10
+ decoder = codecs.getincrementaldecoder("utf-8")("strict")
11
+ line, skip_lf = [], False
12
+ try:
13
+ for chunk in chunks:
14
+ check()
15
+ text = decoder.decode(chunk)
16
+ for char in text:
17
+ if skip_lf:
18
+ skip_lf = False
19
+ if char == "\n":
20
+ continue
21
+ if char in "\r\n":
22
+ yield "".join(line)
23
+ line = []
24
+ skip_lf = char == "\r"
25
+ else:
26
+ line.append(char)
27
+ if len(line) > 2 * 1024 * 1024:
28
+ raise CliError("SSE line exceeded the supported size.", 5)
29
+ tail = decoder.decode(b"", final=True)
30
+ if tail or line:
31
+ yield "".join(line) + tail
32
+ except UnicodeError:
33
+ raise CliError("SSE returned invalid UTF-8.", 5) from None
34
+
35
+
36
+ def parse_events(lines, check=lambda: None):
37
+ name, data, size = "message", [], 0
38
+ first = True
39
+ for line in lines:
40
+ check()
41
+ if first:
42
+ line = line.removeprefix("\ufeff")
43
+ first = False
44
+ size += len(line)
45
+ if size > 2 * 1024 * 1024:
46
+ raise CliError("SSE event exceeded the supported size.", 5)
47
+ if line == "":
48
+ if data:
49
+ try:
50
+ payload = json.loads("\n".join(data))
51
+ if not isinstance(payload, dict):
52
+ raise ValueError
53
+ except ValueError:
54
+ raise CliError("SSE returned invalid event JSON.", 5) from None
55
+ yield name, payload
56
+ name, data, size = "message", [], 0
57
+ elif not line.startswith(":"):
58
+ field, separator, value = line.partition(":")
59
+ if value.startswith(" "):
60
+ value = value[1:]
61
+ if field == "event":
62
+ name = value
63
+ elif field == "data":
64
+ data.append(value if separator else "")
65
+ # Unterminated events are not dispatched; no fabricated terminal result.
opsai_cli/tasks.py ADDED
@@ -0,0 +1,177 @@
1
+ """Bounded task orchestration. Never cancel a remote task on local exit."""
2
+
3
+ import math
4
+ import time
5
+
6
+ from opsai_cli.errors import CliError
7
+
8
+
9
+ ACTIVE = {"pending", "running", "paused"}
10
+ TERMINAL = {
11
+ "listing": {"completed", "failed", "cancelled"},
12
+ "activity": {"completed", "partial_failed", "failed", "cancelled"},
13
+ "optimization": {"succeeded", "failed", "cancelled"},
14
+ }
15
+
16
+
17
+ def finite_positive(value, name):
18
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or value <= 0:
19
+ raise CliError(f"{name} must be a finite positive number.")
20
+
21
+
22
+ def validate_options(request_timeout, wait_timeout, poll_interval, max_retries):
23
+ finite_positive(request_timeout, "--request-timeout")
24
+ finite_positive(wait_timeout, "--wait-timeout")
25
+ finite_positive(poll_interval, "--poll-interval")
26
+ if poll_interval < 1:
27
+ raise CliError("--poll-interval must be at least 1 second.")
28
+ if type(max_retries) is not int or not 0 <= max_retries <= 10:
29
+ raise CliError("--max-retries must be between 0 and 10.")
30
+
31
+
32
+ def task_data(envelope, expected_id=None):
33
+ data = envelope.get("data")
34
+ if (not isinstance(data, dict) or type(data.get("task_id")) is not int or data["task_id"] <= 0
35
+ or not isinstance(data.get("status"), str)
36
+ or (expected_id is not None and data["task_id"] != expected_id)):
37
+ raise CliError("API returned invalid task details.", 5)
38
+ return data
39
+
40
+
41
+ def outcome(family, data):
42
+ if data["status"] in {"failed", "partial_failed", "cancelled"}:
43
+ return 1
44
+ if data.get("error") or data.get("error_message"):
45
+ return 1
46
+ if family == "listing":
47
+ items = data.get("items")
48
+ return 0 if isinstance(items, list) and items and all(isinstance(r, dict) and r.get("status") in {"PUBLISHED", "SUBMITTED"} for r in items) else 1
49
+ if family == "activity":
50
+ items = data.get("items")
51
+ return 0 if isinstance(items, list) and items and len(items) == data.get("total") and data.get("failed_count") == 0 and all(isinstance(r, dict) and r.get("status") == "success" for r in items) else 1
52
+ results = data.get("results")
53
+ return 0 if isinstance(results, list) and results and data.get("success_count") == data.get("total") == 1 and all(isinstance(r, dict) and r.get("success") is True for r in results) else 1
54
+
55
+
56
+ class TaskSession:
57
+ def __init__(self, client, output, *, family, prefix, request_timeout=30,
58
+ wait_timeout=1800, poll_interval=5, max_retries=2,
59
+ waiting=False, clock=None, sleep=None):
60
+ validate_options(request_timeout, wait_timeout, poll_interval, max_retries)
61
+ self.client, self.output = client, output
62
+ self.family, self.prefix = family, prefix
63
+ self.clock, self.sleep = clock or time.monotonic, sleep or time.sleep
64
+ self.deadline = self.clock() + (wait_timeout if waiting else 120)
65
+ self.timeout_code = 6 if waiting else 5
66
+ self.request_timeout, self.interval, self.retries = request_timeout, poll_interval, max_retries
67
+ self.task_id = None
68
+ self.request_id = None
69
+
70
+ def remaining(self):
71
+ remaining = self.deadline - self.clock()
72
+ if remaining <= 0:
73
+ raise CliError("Local deadline expired; the server task may still be running.", self.timeout_code)
74
+ return remaining
75
+
76
+ def pause(self, seconds):
77
+ remaining = self.remaining()
78
+ if seconds >= remaining:
79
+ raise CliError("Local deadline would expire before the next permitted request; server work may continue.", self.timeout_code)
80
+ self.sleep(seconds)
81
+ self.remaining()
82
+
83
+ def retry_pause(self, error, attempt):
84
+ if not error.retryable or attempt >= self.retries:
85
+ raise error
86
+ delay = error.retry_after if error.retry_after is not None else min(2 ** attempt, 30)
87
+ if error.status == 429 and error.retry_after is None:
88
+ delay = 60
89
+ self.output.note(f"Transient API error; retry {attempt + 1}/{self.retries} after {delay:g}s.")
90
+ self.pause(delay)
91
+
92
+ def request(self, method, path, *, body=None, params=None, retry=True):
93
+ for attempt in range(self.retries + 1):
94
+ remaining = self.remaining()
95
+ try:
96
+ result = self.client.request_json(method, path, json=body, params=params,
97
+ timeout=min(self.request_timeout, remaining))
98
+ self.remaining()
99
+ return result
100
+ except CliError as exc:
101
+ if not retry:
102
+ raise
103
+ self.retry_pause(exc, attempt)
104
+
105
+ def accepted(self, data):
106
+ self.task_id = data["task_id"]
107
+ self.output.note(f"Task accepted: task_id={self.task_id}. Resume: opsai {self.family} task watch {self.task_id} (use the same --profile).")
108
+ self.output.event("accepted", data)
109
+
110
+ def watch(self):
111
+ while True:
112
+ envelope = self.request("GET", f"{self.prefix}/tasks/{self.task_id}")
113
+ data = task_data(envelope, self.task_id)
114
+ if data["status"] in TERMINAL[self.family]:
115
+ return envelope
116
+ if data["status"] not in ACTIVE:
117
+ self.output.note("Unknown task status; waiting until the configured deadline.")
118
+ self.output.event("status", data)
119
+ self.pause(self.interval)
120
+
121
+ def submit_stream(self, body):
122
+ # Reconnect/re-submit only while no accepted event has been received.
123
+ # The exact same normalized request body and request_id are reused.
124
+ for attempt in range(self.retries + 1):
125
+ try:
126
+ events = self.client.stream_events(self.prefix + "/tasks/stream", body,
127
+ timeout=min(max(30, self.request_timeout), self.remaining()), check=self.remaining)
128
+ try:
129
+ for name, data in events:
130
+ self.remaining()
131
+ if name == "accepted":
132
+ details = task_data({"data": data}, self.task_id)
133
+ self.accepted(details)
134
+ elif name == "result":
135
+ if self.task_id is None:
136
+ raise CliError("SSE result arrived before task acceptance.", 5)
137
+ details = task_data({"data": data}, self.task_id)
138
+ if details["status"] not in TERMINAL[self.family]:
139
+ raise CliError("SSE result was not a terminal task state.", 5)
140
+ return {"code": 0, "message": "Task finished", "data": details}
141
+ elif name == "error":
142
+ raise CliError("SSE status reading failed.", 5)
143
+ elif name == "log":
144
+ self.output.event(name, data)
145
+ finally:
146
+ events.close()
147
+ raise CliError("SSE ended without a final result.", 5, retryable=True)
148
+ except CliError as exc:
149
+ if exc.exit_code == 6:
150
+ raise
151
+ if self.task_id is not None:
152
+ self.output.note("SSE unavailable; switching to task polling. Logs may be incomplete.")
153
+ return self.watch()
154
+ self.retry_pause(exc, attempt)
155
+
156
+ def stop(self):
157
+ for attempt in range(self.retries + 1):
158
+ try:
159
+ envelope = self.request("POST", f"{self.prefix}/tasks/{self.task_id}/stop", retry=False)
160
+ except CliError as exc:
161
+ if exc.status in {408, 425, 429}:
162
+ self.retry_pause(exc, attempt)
163
+ continue
164
+ if not exc.retryable or (exc.status is not None and exc.status < 500):
165
+ raise
166
+ self.output.note("Stop response was not confirmed; querying task state without repeating the stop request.")
167
+ return self.watch()
168
+ data = task_data(envelope, self.task_id)
169
+ if data["status"] in TERMINAL[self.family]:
170
+ return envelope
171
+ return self.watch()
172
+
173
+ def recovery_note(self):
174
+ if self.task_id is not None:
175
+ self.output.note(f"Server work is not rolled back. Query: opsai {self.family} task get {self.task_id} (use the same --profile).")
176
+ elif self.request_id is not None:
177
+ self.output.note(f"Submission outcome may be unknown. Retry the original body with request_id={self.request_id}; do not generate a new request_id.")