admatrix-client 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.
@@ -0,0 +1,27 @@
1
+ from .auth import APIKeyAuth, AuthProvider, RuntimeJWTAuth
2
+ from .client import AdMatrixClient
3
+ from .errors import (
4
+ AdMatrixAPIError,
5
+ AdMatrixError,
6
+ AdMatrixTimeoutError,
7
+ AdMatrixTransportError,
8
+ AdMatrixValidationError,
9
+ )
10
+ from .types import Generation, Model, ModelSchema, Score, UploadedAsset
11
+
12
+ __all__ = [
13
+ "APIKeyAuth",
14
+ "AdMatrixAPIError",
15
+ "AdMatrixClient",
16
+ "AdMatrixError",
17
+ "AdMatrixTimeoutError",
18
+ "AdMatrixTransportError",
19
+ "AdMatrixValidationError",
20
+ "AuthProvider",
21
+ "Generation",
22
+ "Model",
23
+ "ModelSchema",
24
+ "RuntimeJWTAuth",
25
+ "Score",
26
+ "UploadedAsset",
27
+ ]
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import json
5
+ import os
6
+ import stat
7
+ import tempfile
8
+ from collections.abc import Callable, Mapping
9
+ from pathlib import Path
10
+ from typing import Protocol
11
+ from urllib.parse import urlparse
12
+
13
+ import keyring
14
+
15
+
16
+ class AuthProvider(Protocol):
17
+ def headers(self) -> Mapping[str, str]: ...
18
+
19
+
20
+ class APIKeyAuth:
21
+ def __init__(self, api_key: str) -> None:
22
+ if not api_key:
23
+ raise ValueError("api_key is required")
24
+ self._api_key = api_key
25
+
26
+ def headers(self) -> Mapping[str, str]:
27
+ return {"X-API-KEY": self._api_key}
28
+
29
+
30
+ class RuntimeJWTAuth:
31
+ def __init__(self, token_supplier: Callable[[], str]) -> None:
32
+ self._token_supplier = token_supplier
33
+
34
+ def headers(self) -> Mapping[str, str]:
35
+ token = self._token_supplier()
36
+ if not token:
37
+ raise ValueError("runtime JWT supplier returned an empty token")
38
+ return {"Authorization": f"Bearer {token}"}
39
+
40
+
41
+ def validate_api_key_transport(base_url: str) -> None:
42
+ """Reject plaintext API-Key transport except on loopback hosts."""
43
+ parsed = urlparse(base_url)
44
+ hostname = parsed.hostname
45
+ if parsed.scheme == "https" and hostname:
46
+ return
47
+ if parsed.scheme == "http" and hostname:
48
+ is_loopback = hostname == "localhost" or hostname.endswith(".localhost")
49
+ if not is_loopback:
50
+ try:
51
+ is_loopback = ipaddress.ip_address(hostname).is_loopback
52
+ except ValueError:
53
+ is_loopback = False
54
+ if is_loopback:
55
+ return
56
+ raise ValueError(
57
+ "API Key endpoints must use HTTPS; only localhost and loopback addresses may use HTTP"
58
+ )
59
+
60
+
61
+ class KeyringBackend(Protocol):
62
+ def get_password(self, service: str, username: str) -> str | None: ...
63
+
64
+ def set_password(self, service: str, username: str, value: str) -> None: ...
65
+
66
+ def delete_password(self, service: str, username: str) -> None: ...
67
+
68
+
69
+ class CredentialStore:
70
+ service = "yc-admatrix"
71
+ username = "api-key"
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ path: Path | None = None,
77
+ keyring_backend: KeyringBackend | None = None,
78
+ ) -> None:
79
+ configured_path = os.getenv("YC_CREDENTIAL_FILE")
80
+ self.path = path or (
81
+ Path(configured_path)
82
+ if configured_path
83
+ else Path.home() / ".config" / "yc" / "credentials.json"
84
+ )
85
+ self._keyring = keyring_backend or keyring
86
+
87
+ def save(self, api_key: str) -> str:
88
+ if not api_key:
89
+ raise ValueError("API Key cannot be empty")
90
+ try:
91
+ self._keyring.set_password(self.service, self.username, api_key)
92
+ self._delete_file()
93
+ return "keyring"
94
+ except Exception:
95
+ try:
96
+ existing = self._keyring.get_password(self.service, self.username)
97
+ except Exception:
98
+ existing = None
99
+ if existing == api_key:
100
+ self._delete_file()
101
+ return "keyring"
102
+ if existing:
103
+ try:
104
+ self._keyring.delete_password(self.service, self.username)
105
+ except Exception as delete_error:
106
+ raise OSError(
107
+ "cannot replace the existing keyring API Key; "
108
+ "the old credential is still configured"
109
+ ) from delete_error
110
+ try:
111
+ remaining = self._keyring.get_password(self.service, self.username)
112
+ except Exception as verify_error:
113
+ raise OSError(
114
+ "cannot verify removal of the existing keyring API Key"
115
+ ) from verify_error
116
+ if remaining:
117
+ raise OSError(
118
+ "the existing keyring API Key could not be removed"
119
+ ) from None
120
+ self._save_file(api_key)
121
+ return "file"
122
+
123
+ def load(self) -> str | None:
124
+ # A file only remains after keyring storage failed. Keep that fallback
125
+ # authoritative so a temporarily unavailable keyring cannot later
126
+ # resurrect an older credential.
127
+ file_value = self._load_file()
128
+ if file_value:
129
+ return file_value
130
+ try:
131
+ value = self._keyring.get_password(self.service, self.username)
132
+ if value:
133
+ return value
134
+ except Exception:
135
+ pass
136
+ return None
137
+
138
+ def source(self) -> str | None:
139
+ if self._load_file():
140
+ return "file"
141
+ try:
142
+ if self._keyring.get_password(self.service, self.username):
143
+ return "keyring"
144
+ except Exception:
145
+ pass
146
+ return None
147
+
148
+ def delete(self) -> None:
149
+ keyring_error: Exception | None = None
150
+ try:
151
+ self._keyring.delete_password(self.service, self.username)
152
+ except Exception as error:
153
+ try:
154
+ remaining = self._keyring.get_password(self.service, self.username)
155
+ except Exception:
156
+ keyring_error = error
157
+ else:
158
+ if remaining:
159
+ keyring_error = error
160
+ self._delete_file()
161
+ if keyring_error is not None:
162
+ raise OSError(
163
+ "cannot remove or verify removal of the keyring API Key"
164
+ ) from keyring_error
165
+
166
+ def _save_file(self, api_key: str) -> None:
167
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
168
+ descriptor, temporary_name = tempfile.mkstemp(
169
+ prefix=f".{self.path.name}.",
170
+ suffix=".tmp",
171
+ dir=self.path.parent,
172
+ )
173
+ temporary = Path(temporary_name)
174
+ try:
175
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
176
+ descriptor = -1
177
+ json.dump({"api_key": api_key}, handle)
178
+ handle.write("\n")
179
+ os.chmod(temporary, 0o600)
180
+ os.replace(temporary, self.path)
181
+ os.chmod(self.path, 0o600)
182
+ finally:
183
+ if descriptor >= 0:
184
+ os.close(descriptor)
185
+ if temporary.exists():
186
+ temporary.unlink()
187
+
188
+ def _load_file(self) -> str | None:
189
+ try:
190
+ file_stat = self.path.lstat()
191
+ except FileNotFoundError:
192
+ return None
193
+ if os.name == "posix":
194
+ mode = stat.S_IMODE(file_stat.st_mode)
195
+ if stat.S_ISLNK(file_stat.st_mode) or mode & 0o077:
196
+ raise PermissionError(
197
+ f"credential file {self.path} must be an owner-only 0600 file"
198
+ )
199
+ if hasattr(os, "getuid") and file_stat.st_uid != os.getuid():
200
+ raise PermissionError(
201
+ f"credential file {self.path} must be owned by the current user"
202
+ )
203
+ try:
204
+ payload = json.loads(self.path.read_text(encoding="utf-8"))
205
+ except (OSError, ValueError, TypeError):
206
+ return None
207
+ value = payload.get("api_key")
208
+ return value if isinstance(value, str) and value else None
209
+
210
+ def _delete_file(self) -> None:
211
+ try:
212
+ self.path.unlink()
213
+ except FileNotFoundError:
214
+ pass
215
+
216
+
217
+ def resolve_api_key(store: CredentialStore | None = None) -> tuple[str | None, str | None]:
218
+ environment = os.getenv("YC_API_KEY")
219
+ if environment:
220
+ return environment, "environment"
221
+ credential_store = store or CredentialStore()
222
+ value = credential_store.load()
223
+ return value, credential_store.source() if value else None
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ import httpx
10
+ import jsonschema
11
+
12
+ from .auth import APIKeyAuth, AuthProvider, validate_api_key_transport
13
+ from .errors import (
14
+ AdMatrixAPIError,
15
+ AdMatrixTimeoutError,
16
+ AdMatrixTransportError,
17
+ AdMatrixValidationError,
18
+ )
19
+ from .types import Generation, JsonObject, Model, ModelSchema, Score, UploadedAsset
20
+
21
+ _TERMINAL_GENERATION_STATUSES = {
22
+ "completed",
23
+ "partial",
24
+ "failed",
25
+ "error",
26
+ "cancelled",
27
+ "canceled",
28
+ }
29
+
30
+ _UPLOAD_CONTENT_TYPES_BY_SUFFIX = {
31
+ ".glb": "model/gltf-binary",
32
+ ".gltf": "model/gltf+json",
33
+ ".obj": "model/obj",
34
+ ".fbx": "application/vnd.autodesk.fbx",
35
+ ".stl": "model/stl",
36
+ ".usdz": "model/vnd.usdz+zip",
37
+ ".3mf": "model/3mf",
38
+ ".zip": "application/zip",
39
+ }
40
+
41
+
42
+ class AdMatrixClient:
43
+ def __init__(
44
+ self,
45
+ base_url: str,
46
+ auth: AuthProvider,
47
+ *,
48
+ http_client: httpx.Client | None = None,
49
+ timeout: float = 30,
50
+ ) -> None:
51
+ if not base_url:
52
+ raise ValueError("base_url is required")
53
+ if isinstance(auth, APIKeyAuth):
54
+ validate_api_key_transport(base_url)
55
+ self.base_url = base_url.rstrip("/")
56
+ self.auth = auth
57
+ self._http = http_client or httpx.Client(timeout=timeout)
58
+ self._owns_http = http_client is None
59
+
60
+ def close(self) -> None:
61
+ if self._owns_http:
62
+ self._http.close()
63
+
64
+ def __enter__(self) -> AdMatrixClient:
65
+ return self
66
+
67
+ def __exit__(self, *_args: object) -> None:
68
+ self.close()
69
+
70
+ def list_models(self) -> list[Model]:
71
+ data = self._request("GET", "/api/matrix/aimake/v1/models")
72
+ if not isinstance(data, list):
73
+ raise AdMatrixTransportError("model list response is not an array")
74
+ return [Model.from_dict(self._as_object(item, "model")) for item in data]
75
+
76
+ def get_model_schema(self, model_id: str) -> ModelSchema:
77
+ path_id = self._model_path(model_id)
78
+ data = self._request("GET", f"/api/matrix/aimake/v1/models/{path_id}/schema")
79
+ return ModelSchema.from_dict(self._as_object(data, "model schema"))
80
+
81
+ def create_upload(self, path: Path | str) -> UploadedAsset:
82
+ source = Path(path)
83
+ size = source.stat().st_size
84
+ content_type = _UPLOAD_CONTENT_TYPES_BY_SUFFIX.get(source.suffix.lower())
85
+ if content_type is None:
86
+ content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
87
+ session_data = self._request(
88
+ "POST",
89
+ "/api/matrix/aimake/v1/uploads",
90
+ json={
91
+ "file_name": source.name,
92
+ "content_type": content_type,
93
+ "size_bytes": size,
94
+ },
95
+ )
96
+ session = self._as_object(session_data, "upload session")
97
+ method = str(session.get("method", "PUT")).upper()
98
+ upload_url = str(session.get("upload_url", ""))
99
+ signed_headers = {
100
+ str(key): str(value) for key, value in dict(session.get("headers") or {}).items()
101
+ }
102
+ if method != "PUT" or not upload_url:
103
+ raise AdMatrixTransportError("upload session is missing a valid PUT URL")
104
+
105
+ try:
106
+ with source.open("rb") as content:
107
+ response = self._http.request(
108
+ method,
109
+ upload_url,
110
+ headers=signed_headers,
111
+ content=content,
112
+ )
113
+ except (OSError, httpx.HTTPError) as error:
114
+ raise AdMatrixTransportError(f"signed upload failed: {error}") from error
115
+ if response.status_code < 200 or response.status_code >= 300:
116
+ raise self._api_error(response, fallback="signed upload failed")
117
+ return UploadedAsset.from_dict(session)
118
+
119
+ def preview_score(
120
+ self,
121
+ model_id: str,
122
+ input: JsonObject,
123
+ request_num: int = 1,
124
+ *,
125
+ validate: bool = True,
126
+ ) -> Score:
127
+ if validate:
128
+ self._validate_model_input(model_id, input)
129
+ path_id = self._model_path(model_id)
130
+ data = self._request(
131
+ "POST",
132
+ f"/api/matrix/aimake/v1/models/{path_id}/score",
133
+ json={"input": input, "request_num": request_num},
134
+ )
135
+ score = Score.from_dict(self._as_object(data, "score"))
136
+ if score.score < 0 or score.total_score < 0:
137
+ raise AdMatrixValidationError(
138
+ model_id,
139
+ "server returned invalid negative score values",
140
+ )
141
+ return score
142
+
143
+ def generate(
144
+ self,
145
+ model_id: str,
146
+ input: JsonObject,
147
+ request_num: int = 1,
148
+ *,
149
+ idempotency_key: str | None = None,
150
+ ga_info: JsonObject | None = None,
151
+ validate: bool = True,
152
+ ) -> Generation:
153
+ if validate:
154
+ self._validate_model_input(model_id, input)
155
+
156
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
157
+ path_id = self._model_path(model_id)
158
+ request: JsonObject = {"input": input, "request_num": request_num}
159
+ if ga_info:
160
+ request["ga_info"] = ga_info
161
+ data = self._request_generation(
162
+ f"/api/matrix/aimake/v1/models/{path_id}/generate",
163
+ request,
164
+ headers,
165
+ retry_safe=bool(idempotency_key),
166
+ )
167
+ return Generation.from_dict(self._as_object(data, "generation"))
168
+
169
+ def _validate_model_input(self, model_id: str, input: JsonObject) -> None:
170
+ schema = self.get_model_schema(model_id)
171
+ try:
172
+ validator_class = jsonschema.validators.validator_for(schema.input)
173
+ validator_class.check_schema(schema.input)
174
+ validator_class(schema.input).validate(input)
175
+ except jsonschema.ValidationError as error:
176
+ path = ".".join(str(part) for part in error.absolute_path)
177
+ location = f" at {path}" if path else ""
178
+ raise AdMatrixValidationError(model_id, f"{error.message}{location}") from None
179
+ except jsonschema.SchemaError as error:
180
+ detail = f"server returned invalid schema: {error.message}"
181
+ raise AdMatrixValidationError(model_id, detail) from None
182
+
183
+ def get_generation(self, generation_id: str) -> Generation:
184
+ path_id = quote(generation_id, safe="-._~")
185
+ data = self._request("GET", f"/api/matrix/aimake/v1/generations/{path_id}")
186
+ return Generation.from_dict(self._as_object(data, "generation"))
187
+
188
+ def wait_generation(
189
+ self,
190
+ generation_id: str,
191
+ *,
192
+ poll_interval: float = 2,
193
+ timeout: float = 1800,
194
+ ) -> Generation:
195
+ deadline = time.monotonic() + timeout
196
+ while True:
197
+ generation = self.get_generation(generation_id)
198
+ if generation.status.lower() in _TERMINAL_GENERATION_STATUSES:
199
+ return generation
200
+ if time.monotonic() >= deadline:
201
+ raise AdMatrixTimeoutError(generation_id, timeout)
202
+ if poll_interval > 0:
203
+ time.sleep(min(poll_interval, max(0, deadline - time.monotonic())))
204
+
205
+ def download(self, url: str, destination: Path | str) -> Path:
206
+ target = Path(destination).expanduser().resolve()
207
+ target.parent.mkdir(parents=True, exist_ok=True)
208
+ created = False
209
+ try:
210
+ with self._http.stream("GET", url) as response:
211
+ if response.status_code < 200 or response.status_code >= 300:
212
+ response.read()
213
+ raise self._api_error(response, fallback="result download failed")
214
+ with target.open("xb") as output:
215
+ created = True
216
+ for chunk in response.iter_bytes():
217
+ output.write(chunk)
218
+ except AdMatrixAPIError:
219
+ raise
220
+ except (OSError, httpx.HTTPError) as error:
221
+ if created:
222
+ target.unlink(missing_ok=True)
223
+ raise AdMatrixTransportError(f"result download failed: {error}") from error
224
+ return target
225
+
226
+ def _request(
227
+ self,
228
+ method: str,
229
+ path: str,
230
+ *,
231
+ headers: dict[str, str] | None = None,
232
+ json: JsonObject | None = None,
233
+ ) -> Any:
234
+ request_headers = dict(self.auth.headers())
235
+ if headers:
236
+ request_headers.update(headers)
237
+ try:
238
+ response = self._http.request(
239
+ method,
240
+ f"{self.base_url}{path}",
241
+ headers=request_headers,
242
+ json=json,
243
+ )
244
+ except httpx.HTTPError as error:
245
+ raise AdMatrixTransportError(f"request failed: {error}") from error
246
+
247
+ try:
248
+ payload = response.json()
249
+ except ValueError:
250
+ if response.status_code < 200 or response.status_code >= 300:
251
+ raise self._api_error(response, fallback="non-JSON error response") from None
252
+ raise AdMatrixTransportError("ad-matrix returned a non-JSON response") from None
253
+
254
+ if not isinstance(payload, dict):
255
+ raise AdMatrixTransportError("ad-matrix response envelope is not an object")
256
+ code = payload.get("code")
257
+ if response.status_code < 200 or response.status_code >= 300 or code != 0:
258
+ raise self._api_error(response, payload=payload)
259
+ return payload.get("data")
260
+
261
+ def _request_generation(
262
+ self,
263
+ path: str,
264
+ request: JsonObject,
265
+ headers: dict[str, str] | None,
266
+ *,
267
+ retry_safe: bool,
268
+ ) -> Any:
269
+ attempts = 2 if retry_safe else 1
270
+ for attempt in range(attempts):
271
+ try:
272
+ return self._request("POST", path, headers=headers, json=request)
273
+ except AdMatrixTransportError:
274
+ if attempt + 1 >= attempts:
275
+ raise
276
+ except AdMatrixAPIError as error:
277
+ if attempt + 1 >= attempts or error.status_code not in {502, 503, 504}:
278
+ raise
279
+ raise AssertionError("unreachable generation retry state")
280
+
281
+ @staticmethod
282
+ def _as_object(value: Any, label: str) -> JsonObject:
283
+ if not isinstance(value, dict):
284
+ raise AdMatrixTransportError(f"{label} response is not an object")
285
+ return value
286
+
287
+ @staticmethod
288
+ def _model_path(model_id: str) -> str:
289
+ segments = model_id.split("/")
290
+ if not model_id or any(segment in {"", ".", ".."} for segment in segments):
291
+ raise ValueError("model_id is invalid")
292
+ return quote(model_id, safe="/-._~")
293
+
294
+ @staticmethod
295
+ def _api_error(
296
+ response: httpx.Response,
297
+ *,
298
+ payload: JsonObject | None = None,
299
+ fallback: str = "request failed",
300
+ ) -> AdMatrixAPIError:
301
+ if payload is None:
302
+ try:
303
+ decoded = response.json()
304
+ payload = decoded if isinstance(decoded, dict) else {}
305
+ except ValueError:
306
+ payload = {}
307
+ detail = payload.get("detail") or payload.get("msg") or payload.get("error") or fallback
308
+ return AdMatrixAPIError(
309
+ status_code=response.status_code,
310
+ code=payload.get("code"),
311
+ detail=str(detail),
312
+ request_id=response.headers.get("X-Request-Id"),
313
+ data=payload.get("data"),
314
+ retry_after=response.headers.get("Retry-After"),
315
+ )
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class AdMatrixError(Exception):
7
+ """Base error for safe, user-facing client failures."""
8
+
9
+
10
+ class AdMatrixTransportError(AdMatrixError):
11
+ def __init__(self, message: str) -> None:
12
+ super().__init__(message)
13
+
14
+
15
+ class AdMatrixAPIError(AdMatrixError):
16
+ def __init__(
17
+ self,
18
+ *,
19
+ status_code: int,
20
+ code: int | str | None,
21
+ detail: str,
22
+ request_id: str | None = None,
23
+ data: Any = None,
24
+ retry_after: str | None = None,
25
+ ) -> None:
26
+ self.status_code = status_code
27
+ self.code = code
28
+ self.detail = detail
29
+ self.request_id = request_id
30
+ self.data = data
31
+ self.limit_type = (
32
+ str(data.get("limit_type"))
33
+ if isinstance(data, dict) and data.get("limit_type")
34
+ else None
35
+ )
36
+ retry_value = data.get("retry_after_seconds") if isinstance(data, dict) else None
37
+ if retry_value is None:
38
+ retry_value = retry_after
39
+ try:
40
+ self.retry_after_seconds = int(retry_value) if retry_value is not None else None
41
+ except (TypeError, ValueError):
42
+ self.retry_after_seconds = None
43
+ request_suffix = f" (request_id={request_id})" if request_id else ""
44
+ super().__init__(f"ad-matrix API error {status_code}/{code}: {detail}{request_suffix}")
45
+
46
+
47
+ class AdMatrixValidationError(AdMatrixError):
48
+ def __init__(self, model_id: str, detail: str) -> None:
49
+ self.model_id = model_id
50
+ self.detail = detail
51
+ super().__init__(f"input does not match schema for {model_id}: {detail}")
52
+
53
+
54
+ class AdMatrixTimeoutError(AdMatrixError):
55
+ def __init__(self, generation_id: str, timeout: float) -> None:
56
+ self.generation_id = generation_id
57
+ self.timeout = timeout
58
+ super().__init__(f"generation {generation_id} did not finish within {timeout:g}s")
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ JsonObject = dict[str, Any]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Model:
11
+ id: str
12
+ display_name: str
13
+ description: str
14
+ series_slug: str
15
+ series_display: str
16
+ material_type_slug: str
17
+ schema_version: str
18
+ selection_profile: JsonObject
19
+ _data: JsonObject = field(repr=False)
20
+
21
+ @classmethod
22
+ def from_dict(cls, data: JsonObject) -> Model:
23
+ return cls(
24
+ id=str(data.get("id", "")),
25
+ display_name=str(data.get("display_name", "")),
26
+ description=str(data.get("description", "")),
27
+ series_slug=str(data.get("series_slug", "")),
28
+ series_display=str(data.get("series_display", "")),
29
+ material_type_slug=str(data.get("material_type_slug", "")),
30
+ schema_version=str(data.get("schema_version", "")),
31
+ selection_profile=dict(data.get("selection_profile") or {}),
32
+ _data=dict(data),
33
+ )
34
+
35
+ def to_dict(self) -> JsonObject:
36
+ return dict(self._data)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ModelSchema:
41
+ input: JsonObject
42
+ output: JsonObject
43
+ schema_version: str
44
+ selection_profile: JsonObject
45
+ _data: JsonObject = field(repr=False)
46
+
47
+ @classmethod
48
+ def from_dict(cls, data: JsonObject) -> ModelSchema:
49
+ return cls(
50
+ input=dict(data.get("input") or {}),
51
+ output=dict(data.get("output") or {}),
52
+ schema_version=str(data.get("schema_version", "")),
53
+ selection_profile=dict(data.get("selection_profile") or {}),
54
+ _data=dict(data),
55
+ )
56
+
57
+ def to_dict(self) -> JsonObject:
58
+ return dict(self._data)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Score:
63
+ score: int
64
+ total_score: int
65
+ request_num: int
66
+ _data: JsonObject = field(repr=False)
67
+
68
+ @classmethod
69
+ def from_dict(cls, data: JsonObject) -> Score:
70
+ return cls(
71
+ score=int(data.get("score", 0)),
72
+ total_score=int(data.get("total_score", 0)),
73
+ request_num=int(data.get("request_num", 1)),
74
+ _data=dict(data),
75
+ )
76
+
77
+ def to_dict(self) -> JsonObject:
78
+ return dict(self._data)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Generation:
83
+ id: str
84
+ model_id: str
85
+ status: str
86
+ request_num: int
87
+ score: int | None
88
+ outputs: list[JsonObject]
89
+ _data: JsonObject = field(repr=False)
90
+
91
+ @classmethod
92
+ def from_dict(cls, data: JsonObject) -> Generation:
93
+ return cls(
94
+ id=str(data.get("id") or data.get("gen_id") or ""),
95
+ model_id=str(data.get("model_id", "")),
96
+ status=str(data.get("status", "")),
97
+ request_num=int(data.get("request_num", 1)),
98
+ score=int(data["score"]) if data.get("score") is not None else None,
99
+ outputs=[dict(item) for item in data.get("outputs") or []],
100
+ _data=dict(data),
101
+ )
102
+
103
+ def to_dict(self) -> JsonObject:
104
+ return dict(self._data)
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class UploadedAsset:
109
+ public_url: str
110
+ object_key: str
111
+ expires_at: str
112
+ delete_after: str
113
+ _data: JsonObject = field(repr=False)
114
+
115
+ @classmethod
116
+ def from_dict(cls, data: JsonObject) -> UploadedAsset:
117
+ return cls(
118
+ public_url=str(data.get("public_url", "")),
119
+ object_key=str(data.get("object_key", "")),
120
+ expires_at=str(data.get("expires_at", "")),
121
+ delete_after=str(data.get("delete_after", "")),
122
+ _data=dict(data),
123
+ )
124
+
125
+ def to_dict(self) -> JsonObject:
126
+ return {
127
+ "public_url": self.public_url,
128
+ "object_key": self.object_key,
129
+ "expires_at": self.expires_at,
130
+ "delete_after": self.delete_after,
131
+ }
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: admatrix-client
3
+ Version: 0.1.0
4
+ Summary: Generic Python client and yc CLI for the ad-matrix model API
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx<1,>=0.27
7
+ Requires-Dist: jsonschema<5,>=4.23
8
+ Requires-Dist: keyring<26,>=25
9
+ Description-Content-Type: text/markdown
10
+
11
+ # ad-matrix Python client and `yc` CLI
12
+
13
+ This package is the generic client for the model catalog exposed by ad-matrix.
14
+ It contains no model IDs or Provider-specific behavior: models, JSON Schemas,
15
+ selection metadata, and generation results are discovered at runtime.
16
+
17
+ ## Installation
18
+
19
+ Install the CLI as an isolated tool:
20
+
21
+ ```bash
22
+ uv tool install admatrix-client
23
+ # or: pipx install admatrix-client
24
+ ```
25
+
26
+ For one-off use without a persistent installation:
27
+
28
+ ```bash
29
+ uvx --from admatrix-client yc --help
30
+ ```
31
+
32
+ Python integrations can install the same package with their normal package
33
+ manager and import `admatrix_client`.
34
+
35
+ The command-line client is installed as `yc`. Set the service URL explicitly:
36
+
37
+ ```bash
38
+ export YC_BASE_URL=https://your-ad-matrix-host
39
+ yc auth login
40
+ yc models list
41
+ yc generate --model MODEL_ID --input @payload.json --wait
42
+ ```
43
+
44
+ `yc` requires HTTPS so API Keys are never sent over plaintext transport.
45
+ Plain HTTP is accepted only for `localhost` and loopback addresses during
46
+ local development.
47
+
48
+ `--json` can appear before or after a command. It always writes one JSON
49
+ document to stdout, including for validation, authentication, rate-limit, and
50
+ service errors.
51
+
52
+ For automation, `YC_API_KEY` takes precedence over the stored credential.
53
+ matrix-agent injects its current runtime JWT through `RuntimeJWTAuth` and uses
54
+ the same `AdMatrixClient`; it does not need an API Key.
55
+
56
+ The plaintext API Key is never printed by `yc`. Login prefers the operating
57
+ system keychain and prompts through a hidden terminal input; it never accepts
58
+ the Key as a command-line argument. The fallback credential file must be
59
+ owner-only (`0600`) or the client refuses to load it. When a fallback file
60
+ exists, it remains authoritative until the next successful login stores the
61
+ new Key in the system keychain and removes the file.
62
+
63
+ Local input validation follows the JSON Schema draft declared by ad-matrix.
64
+ API failures preserve request IDs and structured rate-limit metadata such as
65
+ `limit_type` and `retry_after_seconds`.
66
+
67
+ ## Commands
68
+
69
+ ```bash
70
+ yc auth login|logout|status
71
+ yc models list
72
+ yc models schema MODEL_ID
73
+ yc upload ./reference.png
74
+ yc score --model MODEL_ID --input @payload.json
75
+ yc generate --model MODEL_ID --input @payload.json --request-num 2 --wait
76
+ yc generations get GENERATION_ID
77
+ yc generations wait GENERATION_ID
78
+ ```
79
+
80
+ Any string value inside the input JSON that starts with `@` and resolves to a
81
+ local file is uploaded first and replaced with its public URL. Relative paths
82
+ are resolved from the payload file:
83
+
84
+ ```json
85
+ {
86
+ "prompt": "一只猫在雨夜的霓虹街道上奔跑",
87
+ "references": [{ "image_url": "@reference.png" }]
88
+ }
89
+ ```
90
+
91
+ Upload sessions are accounted per 亿创 user, not per API Key, so creating
92
+ multiple Keys does not multiply the allowance. The current defaults are 30
93
+ signed sessions per minute and 10 GiB of signed bytes per UTC day; deployments
94
+ may override them. A signed URL is valid for 10 minutes, and its required
95
+ `x-obs-expires` header makes the temporary object expire after 7 days. The
96
+ `yc upload --json` response includes `expires_at` and the latest server-side
97
+ deletion boundary in `delete_after`.
98
+
99
+ When `--wait` is used, successful managed resource files are downloaded to
100
+ `yc-output/<generation_id>/` by default, or to the directory supplied through
101
+ `--output`. Existing files are never overwritten. A partial generation keeps
102
+ its successful downloads and the complete failed-output list, then exits with
103
+ code 7. The terminal `score` is the final settled score; the score returned by
104
+ the initial generate call is only the estimate/reservation.
105
+
106
+ The CLI creates one idempotency key for each generate command. A lost response
107
+ or retryable gateway failure is retried once with that same key, so the retry
108
+ does not create a second task or charge.
109
+ Keys are user-global across all model generation routes, so one key must never
110
+ be reused for a different model or a different generation request.
111
+
112
+ ## Exit codes
113
+
114
+ | Code | Meaning |
115
+ | --- | --- |
116
+ | 0 | Success |
117
+ | 2 | Command argument, JSON, or model Schema error |
118
+ | 3 | Missing or invalid authentication |
119
+ | 4 | Insufficient score |
120
+ | 5 | User/model rate or concurrency limit |
121
+ | 6 | Network, timeout, or service failure |
122
+ | 7 | Generation terminal failure or partial failure |
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ uv sync
128
+ uv run pytest
129
+ uv run ruff check .
130
+ uv build
131
+ ```
132
+
133
+ Release artifacts are published to PyPI so matrix-agent and external tools
134
+ resolve the same immutable package source and hashes. Do not use a sibling
135
+ filesystem dependency or copy the HTTP client into a consuming repository.
@@ -0,0 +1,11 @@
1
+ admatrix_client/__init__.py,sha256=TefzGXP5U5qEZZHbcZdTYJxFs1L9FbKsw8CnmGZ2ldc,629
2
+ admatrix_client/auth.py,sha256=qh3mS77OEkpVHgg0LAS2l7r6BWyfbbvI6gEe4lafl28,7642
3
+ admatrix_client/client.py,sha256=qPHHMThXE_RjmN5TokYhBnzNB-tdbDXP3rSxZW4TF38,11541
4
+ admatrix_client/errors.py,sha256=0HTdJeXpkdfSGQocRtoHB-dqcQJtf_W_c3uHIwqv7lg,1936
5
+ admatrix_client/types.py,sha256=VgH8bEQv84K5rbz8d_oRjZhptrdH7RSsCw_kPdYEReM,3772
6
+ yc_cli/__init__.py,sha256=KEAXzI_9PVfw0PzcboRw5tIhS-1MfsSEb5vJOfTNEx4,45
7
+ yc_cli/main.py,sha256=ohqVOkIasfzId1yy4O4h7cZSb3b8SEueNrkBQgKJw2E,16185
8
+ admatrix_client-0.1.0.dist-info/METADATA,sha256=oRzzNuGdLm0TFM1FOH8miu7pwWOAhS3bfb54hHmc2Bk,4861
9
+ admatrix_client-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ admatrix_client-0.1.0.dist-info/entry_points.txt,sha256=yKlUmmLb85KAeM4vC5KfPBXuD2DmzR9LOZkKjQmvQCs,39
11
+ admatrix_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ yc = yc_cli.main:run
yc_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Command-line interface package for yc."""
yc_cli/main.py ADDED
@@ -0,0 +1,454 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import os
7
+ import sys
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import unquote, urlparse
12
+
13
+ from admatrix_client import (
14
+ AdMatrixAPIError,
15
+ AdMatrixClient,
16
+ AdMatrixError,
17
+ AdMatrixTimeoutError,
18
+ AdMatrixTransportError,
19
+ AdMatrixValidationError,
20
+ APIKeyAuth,
21
+ )
22
+ from admatrix_client.auth import CredentialStore, resolve_api_key, validate_api_key_transport
23
+
24
+
25
+ class CLIError(Exception):
26
+ def __init__(self, message: str, exit_code: int = 2) -> None:
27
+ self.exit_code = exit_code
28
+ super().__init__(message)
29
+
30
+
31
+ EXIT_SUCCESS = 0
32
+ EXIT_USAGE = 2
33
+ EXIT_AUTH = 3
34
+ EXIT_SCORE = 4
35
+ EXIT_RATE_LIMIT = 5
36
+ EXIT_SERVICE = 6
37
+ EXIT_GENERATION_FAILED = 7
38
+
39
+
40
+ def build_parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(prog="yc", description="亿创模型命令行客户端")
42
+ _add_common_options(parser)
43
+ commands = parser.add_subparsers(dest="command", required=True)
44
+
45
+ auth = commands.add_parser("auth", help="管理 API Key")
46
+ _add_common_options(auth, suppress_defaults=True)
47
+ auth_commands = auth.add_subparsers(dest="auth_command", required=True)
48
+ auth_login = auth_commands.add_parser("login", help="安全提示并保存 API Key")
49
+ _add_common_options(auth_login, suppress_defaults=True)
50
+ auth_logout = auth_commands.add_parser("logout", help="删除本地 API Key")
51
+ _add_common_options(auth_logout, suppress_defaults=True)
52
+ auth_status = auth_commands.add_parser("status", help="查看鉴权状态")
53
+ _add_common_options(auth_status, suppress_defaults=True)
54
+
55
+ models = commands.add_parser("models", help="查看服务端模型目录")
56
+ _add_common_options(models, suppress_defaults=True)
57
+ model_commands = models.add_subparsers(dest="models_command", required=True)
58
+ models_list = model_commands.add_parser("list")
59
+ _add_common_options(models_list, suppress_defaults=True)
60
+ schema = model_commands.add_parser("schema")
61
+ _add_common_options(schema, suppress_defaults=True)
62
+ schema.add_argument("model_id")
63
+
64
+ upload = commands.add_parser("upload", help="上传本地素材")
65
+ _add_common_options(upload, suppress_defaults=True)
66
+ upload.add_argument("path", type=Path)
67
+
68
+ score = commands.add_parser("score", help="预估积分")
69
+ _add_common_options(score, suppress_defaults=True)
70
+ _add_generation_input(score)
71
+
72
+ generate = commands.add_parser("generate", help="提交生成任务")
73
+ _add_common_options(generate, suppress_defaults=True)
74
+ _add_generation_input(generate)
75
+ generate.add_argument("--idempotency-key")
76
+ generate.add_argument("--wait", action="store_true")
77
+ generate.add_argument("--output", type=Path)
78
+ generate.add_argument("--timeout", type=float, default=1800)
79
+ generate.add_argument("--poll-interval", type=float, default=2)
80
+
81
+ generations = commands.add_parser("generations", help="查看生成任务")
82
+ _add_common_options(generations, suppress_defaults=True)
83
+ generation_commands = generations.add_subparsers(dest="generations_command", required=True)
84
+ get_command = generation_commands.add_parser("get")
85
+ _add_common_options(get_command, suppress_defaults=True)
86
+ get_command.add_argument("generation_id")
87
+ wait_command = generation_commands.add_parser("wait")
88
+ _add_common_options(wait_command, suppress_defaults=True)
89
+ wait_command.add_argument("generation_id")
90
+ wait_command.add_argument("--output", type=Path)
91
+ wait_command.add_argument("--timeout", type=float, default=1800)
92
+ wait_command.add_argument("--poll-interval", type=float, default=2)
93
+ return parser
94
+
95
+
96
+ def _add_common_options(
97
+ parser: argparse.ArgumentParser,
98
+ *,
99
+ suppress_defaults: bool = False,
100
+ ) -> None:
101
+ default_base_url: Any = argparse.SUPPRESS if suppress_defaults else os.getenv("YC_BASE_URL")
102
+ default_json: Any = argparse.SUPPRESS if suppress_defaults else False
103
+ parser.add_argument("--base-url", default=default_base_url)
104
+ parser.add_argument(
105
+ "--json",
106
+ action="store_true",
107
+ dest="json_output",
108
+ default=default_json,
109
+ )
110
+
111
+
112
+ def _add_generation_input(parser: argparse.ArgumentParser) -> None:
113
+ parser.add_argument("--model", required=True)
114
+ parser.add_argument("--input", required=True, dest="input_value")
115
+ parser.add_argument("--request-num", type=int, default=1)
116
+
117
+
118
+ def main(argv: list[str] | None = None) -> int:
119
+ parser = build_parser()
120
+ raw_args = list(sys.argv[1:] if argv is None else argv)
121
+ try:
122
+ args = parser.parse_args(raw_args)
123
+ except SystemExit as error:
124
+ if error.code and "--json" in raw_args:
125
+ _emit(
126
+ {
127
+ "error": {
128
+ "type": "ArgumentError",
129
+ "message": "命令参数错误",
130
+ "exit_code": EXIT_USAGE,
131
+ }
132
+ },
133
+ True,
134
+ )
135
+ return EXIT_USAGE
136
+ raise
137
+ try:
138
+ if args.command == "auth":
139
+ return _handle_auth(args)
140
+
141
+ client = _build_client(args)
142
+ try:
143
+ result = _dispatch(client, args)
144
+ finally:
145
+ client.close()
146
+ _emit(result, args.json_output)
147
+ return _result_exit_code(args, result)
148
+ except (CLIError, AdMatrixError, OSError, ValueError, json.JSONDecodeError) as error:
149
+ exit_code = _error_exit_code(error)
150
+ if args.json_output:
151
+ _emit(_error_payload(error, exit_code), True)
152
+ else:
153
+ print(str(error), file=sys.stderr)
154
+ return exit_code
155
+
156
+
157
+ def run() -> None:
158
+ raise SystemExit(main())
159
+
160
+
161
+ def _build_client(args: argparse.Namespace) -> AdMatrixClient:
162
+ if not args.base_url:
163
+ raise CLIError("未配置服务地址,请设置 YC_BASE_URL 或传 --base-url", EXIT_USAGE)
164
+ _validate_cli_base_url(args.base_url)
165
+ api_key, _source = resolve_api_key()
166
+ if not api_key:
167
+ raise CLIError("未配置 API Key,请先运行 yc auth login 或设置 YC_API_KEY", EXIT_AUTH)
168
+ return AdMatrixClient(args.base_url, APIKeyAuth(api_key))
169
+
170
+
171
+ def _validate_cli_base_url(base_url: str) -> None:
172
+ try:
173
+ validate_api_key_transport(base_url)
174
+ except ValueError as error:
175
+ raise CLIError(
176
+ "服务地址必须使用 HTTPS;仅 localhost 和回环地址允许 HTTP",
177
+ EXIT_USAGE,
178
+ ) from error
179
+
180
+
181
+ def _handle_auth(args: argparse.Namespace) -> int:
182
+ store = CredentialStore()
183
+ if args.auth_command == "login":
184
+ api_key = getpass.getpass("API Key: ")
185
+ try:
186
+ source = store.save(api_key)
187
+ except OSError as error:
188
+ raise CLIError(str(error), EXIT_AUTH) from error
189
+ _emit({"configured": True, "source": source}, args.json_output)
190
+ return 0
191
+ if args.auth_command == "logout":
192
+ try:
193
+ store.delete()
194
+ except OSError as error:
195
+ raise CLIError(str(error), EXIT_AUTH) from error
196
+ api_key, source = resolve_api_key(store)
197
+ _emit(
198
+ {"configured": bool(api_key), "source": source},
199
+ args.json_output,
200
+ )
201
+ return EXIT_AUTH if api_key else EXIT_SUCCESS
202
+ api_key, source = resolve_api_key(store)
203
+ _emit({"configured": bool(api_key), "source": source}, args.json_output)
204
+ return EXIT_SUCCESS if api_key else EXIT_AUTH
205
+
206
+
207
+ def _dispatch(client: AdMatrixClient, args: argparse.Namespace) -> Any:
208
+ if args.command == "models":
209
+ if args.models_command == "list":
210
+ return [model.to_dict() for model in client.list_models()]
211
+ return client.get_model_schema(args.model_id).to_dict()
212
+ if args.command == "upload":
213
+ return client.create_upload(args.path).to_dict()
214
+ if args.command == "score":
215
+ payload = _load_and_upload_input(client, args.input_value)
216
+ return client.preview_score(args.model, payload, args.request_num).to_dict()
217
+ if args.command == "generate":
218
+ if args.output is not None and not args.wait:
219
+ raise CLIError("--output 只能与 --wait 一起使用")
220
+ payload = _load_and_upload_input(client, args.input_value)
221
+ idempotency_key = args.idempotency_key or str(uuid.uuid4())
222
+ generation = client.generate(
223
+ args.model,
224
+ payload,
225
+ args.request_num,
226
+ idempotency_key=idempotency_key,
227
+ )
228
+ if args.wait:
229
+ generation = client.wait_generation(
230
+ generation.id,
231
+ timeout=args.timeout,
232
+ poll_interval=args.poll_interval,
233
+ )
234
+ return _download_generation_outputs(client, generation, args.output)
235
+ return generation.to_dict()
236
+ if args.command == "generations":
237
+ if args.generations_command == "get":
238
+ return client.get_generation(args.generation_id).to_dict()
239
+ generation = client.wait_generation(
240
+ args.generation_id,
241
+ timeout=args.timeout,
242
+ poll_interval=args.poll_interval,
243
+ )
244
+ return _download_generation_outputs(client, generation, args.output)
245
+ raise CLIError("未知命令")
246
+
247
+
248
+ def _load_input(value: str) -> dict[str, Any]:
249
+ if value == "-":
250
+ raw = sys.stdin.read()
251
+ elif value.startswith("@"):
252
+ raw = Path(value[1:]).read_text(encoding="utf-8")
253
+ else:
254
+ raw = value
255
+ payload = json.loads(raw)
256
+ if not isinstance(payload, dict):
257
+ raise CLIError("--input 必须是 JSON 对象")
258
+ return payload
259
+
260
+
261
+ def _load_and_upload_input(client: AdMatrixClient, value: str) -> dict[str, Any]:
262
+ payload = _load_input(value)
263
+ base_dir = (
264
+ Path(value[1:]).expanduser().resolve().parent if value.startswith("@") else Path.cwd()
265
+ )
266
+ resolved = _upload_local_references(client, payload, base_dir)
267
+ if not isinstance(resolved, dict):
268
+ raise CLIError("--input 必须是 JSON 对象")
269
+ return resolved
270
+
271
+
272
+ def _upload_local_references(client: AdMatrixClient, value: Any, base_dir: Path) -> Any:
273
+ if isinstance(value, dict):
274
+ return {
275
+ key: _upload_local_references(client, item, base_dir) for key, item in value.items()
276
+ }
277
+ if isinstance(value, list):
278
+ return [_upload_local_references(client, item, base_dir) for item in value]
279
+ if not isinstance(value, str) or not value.startswith("@") or len(value) == 1:
280
+ return value
281
+
282
+ path = Path(value[1:]).expanduser()
283
+ if not path.is_absolute():
284
+ path = base_dir / path
285
+ if not path.is_file():
286
+ return value
287
+ return client.create_upload(path).public_url
288
+
289
+
290
+ def _download_generation_outputs(
291
+ client: AdMatrixClient,
292
+ generation: Any,
293
+ output: Path | None,
294
+ ) -> dict[str, Any]:
295
+ result = generation.to_dict()
296
+ output_dir = (
297
+ output.expanduser().resolve()
298
+ if output is not None
299
+ else (Path.cwd() / "yc-output" / generation.id).resolve()
300
+ )
301
+ downloads: list[dict[str, Any]] = []
302
+ seen_urls: set[str] = set()
303
+
304
+ for item in generation.outputs:
305
+ if item.get("item_status") != "succeeded":
306
+ continue
307
+ output_index = int(item.get("index", 0))
308
+ for remote_url in _collect_output_urls(item):
309
+ if remote_url in seen_urls:
310
+ continue
311
+ seen_urls.add(remote_url)
312
+ output_dir.mkdir(parents=True, exist_ok=True)
313
+ destination = _unique_download_path(output_dir, remote_url, output_index)
314
+ local_path = client.download(remote_url, destination)
315
+ downloads.append(
316
+ {
317
+ "output_index": output_index,
318
+ "remote_url": remote_url,
319
+ "local_path": str(local_path.resolve()),
320
+ }
321
+ )
322
+
323
+ result["downloads"] = downloads
324
+ return result
325
+
326
+
327
+ def _collect_output_urls(item: dict[str, Any]) -> list[str]:
328
+ resources = item.get("resources")
329
+ resource_urls: list[str] = []
330
+ if isinstance(resources, list):
331
+ for resource in resources:
332
+ if not isinstance(resource, dict):
333
+ continue
334
+ resource_urls.extend(_collect_http_urls(resource.get("url")))
335
+ return resource_urls
336
+
337
+
338
+ def _collect_http_urls(value: Any) -> list[str]:
339
+ if isinstance(value, dict):
340
+ urls: list[str] = []
341
+ for item in value.values():
342
+ urls.extend(_collect_http_urls(item))
343
+ return urls
344
+ if isinstance(value, list):
345
+ urls = []
346
+ for item in value:
347
+ urls.extend(_collect_http_urls(item))
348
+ return urls
349
+ if isinstance(value, str):
350
+ parsed = urlparse(value)
351
+ if parsed.scheme in {"http", "https"} and parsed.netloc:
352
+ return [value]
353
+ return []
354
+
355
+
356
+ def _unique_download_path(output_dir: Path, remote_url: str, output_index: int) -> Path:
357
+ raw_name = unquote(Path(urlparse(remote_url).path).name)
358
+ name = Path(raw_name).name if raw_name else f"output-{output_index}"
359
+ candidate = output_dir / name
360
+ if not candidate.exists():
361
+ return candidate
362
+
363
+ suffix = candidate.suffix
364
+ stem = candidate.stem or f"output-{output_index}"
365
+ candidate = output_dir / f"{stem}-{output_index}{suffix}"
366
+ counter = 2
367
+ while candidate.exists():
368
+ candidate = output_dir / f"{stem}-{output_index}-{counter}{suffix}"
369
+ counter += 1
370
+ return candidate
371
+
372
+
373
+ def _emit(value: Any, json_output: bool) -> None:
374
+ if json_output:
375
+ print(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
376
+ return
377
+ if isinstance(value, dict) and set(value) <= {"configured", "source"}:
378
+ if value.get("configured"):
379
+ print(f"已配置(来源:{value.get('source')})")
380
+ else:
381
+ print("未配置")
382
+ return
383
+ print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
384
+
385
+
386
+ def _result_exit_code(args: argparse.Namespace, value: Any) -> int:
387
+ is_generation_result = args.command == "generations" or (
388
+ args.command == "generate" and bool(args.wait)
389
+ )
390
+ if not is_generation_result or not isinstance(value, dict):
391
+ return EXIT_SUCCESS
392
+
393
+ status = str(value.get("status", "")).lower()
394
+ if status in {"partial", "failed", "error", "cancelled", "canceled"}:
395
+ return EXIT_GENERATION_FAILED
396
+ if status != "completed":
397
+ return EXIT_SUCCESS
398
+
399
+ outputs = value.get("outputs")
400
+ if not isinstance(outputs, list) or not outputs:
401
+ return EXIT_GENERATION_FAILED
402
+ if any(
403
+ not isinstance(output, dict) or output.get("item_status") != "succeeded"
404
+ for output in outputs
405
+ ):
406
+ return EXIT_GENERATION_FAILED
407
+ return EXIT_SUCCESS
408
+
409
+
410
+ def _error_exit_code(error: Exception) -> int:
411
+ if isinstance(error, CLIError):
412
+ return error.exit_code
413
+ if isinstance(error, AdMatrixValidationError):
414
+ return EXIT_USAGE
415
+ if isinstance(error, AdMatrixAPIError):
416
+ if error.status_code in {401, 403}:
417
+ return EXIT_AUTH
418
+ if error.status_code == 402:
419
+ return EXIT_SCORE
420
+ if error.status_code == 429:
421
+ return EXIT_RATE_LIMIT
422
+ if error.status_code >= 500:
423
+ return EXIT_SERVICE
424
+ return EXIT_USAGE
425
+ if isinstance(error, (AdMatrixTimeoutError, AdMatrixTransportError)):
426
+ return EXIT_SERVICE
427
+ if isinstance(error, AdMatrixError):
428
+ return EXIT_SERVICE
429
+ return EXIT_USAGE
430
+
431
+
432
+ def _error_payload(error: Exception, exit_code: int) -> dict[str, Any]:
433
+ detail: dict[str, Any] = {
434
+ "type": type(error).__name__,
435
+ "message": str(error),
436
+ "exit_code": exit_code,
437
+ }
438
+ if isinstance(error, AdMatrixAPIError):
439
+ detail.update(
440
+ {
441
+ "status_code": error.status_code,
442
+ "code": error.code,
443
+ "request_id": error.request_id,
444
+ "limit_type": error.limit_type,
445
+ "retry_after_seconds": error.retry_after_seconds,
446
+ }
447
+ )
448
+ elif isinstance(error, AdMatrixValidationError):
449
+ detail["model_id"] = error.model_id
450
+ return {"error": detail}
451
+
452
+
453
+ if __name__ == "__main__":
454
+ run()