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.
- admatrix_client/__init__.py +27 -0
- admatrix_client/auth.py +223 -0
- admatrix_client/client.py +315 -0
- admatrix_client/errors.py +58 -0
- admatrix_client/types.py +131 -0
- admatrix_client-0.1.0.dist-info/METADATA +135 -0
- admatrix_client-0.1.0.dist-info/RECORD +11 -0
- admatrix_client-0.1.0.dist-info/WHEEL +4 -0
- admatrix_client-0.1.0.dist-info/entry_points.txt +2 -0
- yc_cli/__init__.py +1 -0
- yc_cli/main.py +454 -0
|
@@ -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
|
+
]
|
admatrix_client/auth.py
ADDED
|
@@ -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")
|
admatrix_client/types.py
ADDED
|
@@ -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,,
|
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()
|