admatrix-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ __pycache__/
5
+ *.py[cod]
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -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,125 @@
1
+ # ad-matrix Python client and `yc` CLI
2
+
3
+ This package is the generic client for the model catalog exposed by ad-matrix.
4
+ It contains no model IDs or Provider-specific behavior: models, JSON Schemas,
5
+ selection metadata, and generation results are discovered at runtime.
6
+
7
+ ## Installation
8
+
9
+ Install the CLI as an isolated tool:
10
+
11
+ ```bash
12
+ uv tool install admatrix-client
13
+ # or: pipx install admatrix-client
14
+ ```
15
+
16
+ For one-off use without a persistent installation:
17
+
18
+ ```bash
19
+ uvx --from admatrix-client yc --help
20
+ ```
21
+
22
+ Python integrations can install the same package with their normal package
23
+ manager and import `admatrix_client`.
24
+
25
+ The command-line client is installed as `yc`. Set the service URL explicitly:
26
+
27
+ ```bash
28
+ export YC_BASE_URL=https://your-ad-matrix-host
29
+ yc auth login
30
+ yc models list
31
+ yc generate --model MODEL_ID --input @payload.json --wait
32
+ ```
33
+
34
+ `yc` requires HTTPS so API Keys are never sent over plaintext transport.
35
+ Plain HTTP is accepted only for `localhost` and loopback addresses during
36
+ local development.
37
+
38
+ `--json` can appear before or after a command. It always writes one JSON
39
+ document to stdout, including for validation, authentication, rate-limit, and
40
+ service errors.
41
+
42
+ For automation, `YC_API_KEY` takes precedence over the stored credential.
43
+ matrix-agent injects its current runtime JWT through `RuntimeJWTAuth` and uses
44
+ the same `AdMatrixClient`; it does not need an API Key.
45
+
46
+ The plaintext API Key is never printed by `yc`. Login prefers the operating
47
+ system keychain and prompts through a hidden terminal input; it never accepts
48
+ the Key as a command-line argument. The fallback credential file must be
49
+ owner-only (`0600`) or the client refuses to load it. When a fallback file
50
+ exists, it remains authoritative until the next successful login stores the
51
+ new Key in the system keychain and removes the file.
52
+
53
+ Local input validation follows the JSON Schema draft declared by ad-matrix.
54
+ API failures preserve request IDs and structured rate-limit metadata such as
55
+ `limit_type` and `retry_after_seconds`.
56
+
57
+ ## Commands
58
+
59
+ ```bash
60
+ yc auth login|logout|status
61
+ yc models list
62
+ yc models schema MODEL_ID
63
+ yc upload ./reference.png
64
+ yc score --model MODEL_ID --input @payload.json
65
+ yc generate --model MODEL_ID --input @payload.json --request-num 2 --wait
66
+ yc generations get GENERATION_ID
67
+ yc generations wait GENERATION_ID
68
+ ```
69
+
70
+ Any string value inside the input JSON that starts with `@` and resolves to a
71
+ local file is uploaded first and replaced with its public URL. Relative paths
72
+ are resolved from the payload file:
73
+
74
+ ```json
75
+ {
76
+ "prompt": "一只猫在雨夜的霓虹街道上奔跑",
77
+ "references": [{ "image_url": "@reference.png" }]
78
+ }
79
+ ```
80
+
81
+ Upload sessions are accounted per 亿创 user, not per API Key, so creating
82
+ multiple Keys does not multiply the allowance. The current defaults are 30
83
+ signed sessions per minute and 10 GiB of signed bytes per UTC day; deployments
84
+ may override them. A signed URL is valid for 10 minutes, and its required
85
+ `x-obs-expires` header makes the temporary object expire after 7 days. The
86
+ `yc upload --json` response includes `expires_at` and the latest server-side
87
+ deletion boundary in `delete_after`.
88
+
89
+ When `--wait` is used, successful managed resource files are downloaded to
90
+ `yc-output/<generation_id>/` by default, or to the directory supplied through
91
+ `--output`. Existing files are never overwritten. A partial generation keeps
92
+ its successful downloads and the complete failed-output list, then exits with
93
+ code 7. The terminal `score` is the final settled score; the score returned by
94
+ the initial generate call is only the estimate/reservation.
95
+
96
+ The CLI creates one idempotency key for each generate command. A lost response
97
+ or retryable gateway failure is retried once with that same key, so the retry
98
+ does not create a second task or charge.
99
+ Keys are user-global across all model generation routes, so one key must never
100
+ be reused for a different model or a different generation request.
101
+
102
+ ## Exit codes
103
+
104
+ | Code | Meaning |
105
+ | --- | --- |
106
+ | 0 | Success |
107
+ | 2 | Command argument, JSON, or model Schema error |
108
+ | 3 | Missing or invalid authentication |
109
+ | 4 | Insufficient score |
110
+ | 5 | User/model rate or concurrency limit |
111
+ | 6 | Network, timeout, or service failure |
112
+ | 7 | Generation terminal failure or partial failure |
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ uv sync
118
+ uv run pytest
119
+ uv run ruff check .
120
+ uv build
121
+ ```
122
+
123
+ Release artifacts are published to PyPI so matrix-agent and external tools
124
+ resolve the same immutable package source and hashes. Do not use a sibling
125
+ filesystem dependency or copy the HTTP client into a consuming repository.
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "admatrix-client"
3
+ version = "0.1.0"
4
+ description = "Generic Python client and yc CLI for the ad-matrix model API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "httpx>=0.27,<1",
9
+ "jsonschema>=4.23,<5",
10
+ "keyring>=25,<26",
11
+ ]
12
+
13
+ [project.scripts]
14
+ yc = "yc_cli.main:run"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/admatrix_client", "src/yc_cli"]
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pytest>=9.0.3,<10",
26
+ "ruff>=0.12,<1",
27
+ ]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+
32
+ [tool.ruff]
33
+ line-length = 100
34
+ target-version = "py311"
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "B", "UP"]
@@ -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