@acedatacloud/skills 2026.628.3 → 2026.628.5

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.
package/AGENTS.md CHANGED
@@ -31,7 +31,7 @@ Skills are located in the `skills/` directory (also mirrored to `.agents/skills/
31
31
  - **face-transform** — Face analysis, beautification, age/gender transform, swap, cartoon
32
32
  - **short-url** — Create and manage short URLs
33
33
  - **onepage-pdf** — Convert an HTML page into one tall single-page PDF (local; no API token; needs Python + pymupdf + Chrome/Edge)
34
- - **acedatacloud-api** — API usage guideauthentication, SDKs, error handling
34
+ - **acedatacloud** — Manage your AceDataCloud account balance, usage/spend, API keys, services, orders, announcements
35
35
 
36
36
  ## Authentication
37
37
 
package/README.md CHANGED
@@ -50,7 +50,7 @@ Compatible with **30+ AI coding agents** via the [agentskills.io](https://agents
50
50
  | [face-transform](skills/face-transform/) | Face analysis, beautification, age/gender transform, swap, cartoon |
51
51
  | [short-url](skills/short-url/) | Create and manage short URLs |
52
52
  | [onepage-pdf](skills/onepage-pdf/) | Convert an HTML page into one tall single-page PDF — no pagination breaks (local, no token) |
53
- | [acedatacloud-api](skills/acedatacloud-api/) | API usage guideauthentication, SDKs, error handling |
53
+ | [acedatacloud](skills/acedatacloud/) | Manage your AceDataCloud account balance, usage/spend, API keys, services, orders, announcements |
54
54
 
55
55
  ### Connectors
56
56
 
@@ -207,6 +207,7 @@ Skills provide **knowledge** (when to use, parameters, gotchas). MCP servers pro
207
207
  | nano-banana-image | [mcp-nano-banana](https://pypi.org/project/mcp-nano-banana/) | `pip install mcp-nano-banana` | `https://nano-banana.mcp.acedata.cloud/mcp` |
208
208
  | short-url | [mcp-shorturl](https://pypi.org/project/mcp-shorturl/) | `pip install mcp-shorturl` | `https://short-url.mcp.acedata.cloud/mcp` |
209
209
  | wan-video | [mcp-wan](https://pypi.org/project/mcp-wan/) | `pip install mcp-wan` | `https://wan.mcp.acedata.cloud/mcp` |
210
+ | acedatacloud | [mcp-acedatacloud](https://pypi.org/project/mcp-acedatacloud/) | `pip install mcp-acedatacloud` | `https://acedatacloud.mcp.acedata.cloud/mcp` |
210
211
 
211
212
  **Using hosted MCP endpoints** (no local install needed):
212
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.628.3",
3
+ "version": "2026.628.5",
4
4
  "description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -18,6 +18,7 @@ Each AceDataCloud service has a corresponding MCP server that provides tool-use
18
18
  | nano-banana-image | `pip install mcp-nano-banana` | `https://nano-banana.mcp.acedata.cloud/mcp` |
19
19
  | short-url | `pip install mcp-shorturl` | `https://short-url.mcp.acedata.cloud/mcp` |
20
20
  | wan-video | `pip install mcp-wan` | `https://wan.mcp.acedata.cloud/mcp` |
21
+ | acedatacloud | `pip install mcp-acedatacloud` | `https://acedatacloud.mcp.acedata.cloud/mcp` |
21
22
 
22
23
  ## Configuration Example
23
24
 
@@ -0,0 +1,244 @@
1
+ ---
2
+ name: acedatacloud
3
+ description: |
4
+ Manage your AceDataCloud account through the management API
5
+ (platform.acedata.cloud). Use when the user wants to check their balance /
6
+ remaining credits, look up API call (usage) records and spend, list or create
7
+ or delete API keys (credentials), list subscribed services, list/create/pay
8
+ recharge orders, manage platform tokens, list available models, or (admins)
9
+ publish an announcement. This is the self-service "console" API — distinct
10
+ from the data-generation APIs (image/video/music/search).
11
+ license: Apache-2.0
12
+ metadata:
13
+ author: acedatacloud
14
+ version: "1.0"
15
+ connections: [acedatacloud]
16
+ compatibility: Requires ACEDATACLOUD_PLATFORM_TOKEN (a platform or user token). Auto-injected when the AceDataCloud connector is installed; otherwise set it in .env. Optionally pair with mcp-acedatacloud for tool-use.
17
+ ---
18
+
19
+ # AceDataCloud Platform Management
20
+
21
+ Programmatically manage your AceDataCloud account: balances, usage records, API
22
+ keys, services, orders, platform tokens, models, and announcements.
23
+
24
+ This is the **management / console** API at `https://platform.acedata.cloud/api/v1`
25
+ — the same surface the web console uses. It is **different** from the
26
+ data-generation API at `api.acedata.cloud` (image / video / music / search generation).
27
+
28
+ ## Setup — use a PLATFORM token, not a service token
29
+
30
+ The management API is authenticated with a **platform token** (or a logged-in
31
+ user token), **not** the per-service API token used for `api.acedata.cloud`.
32
+
33
+ 1. Create one at [platform.acedata.cloud/console/platform-tokens](https://platform.acedata.cloud/console/platform-tokens)
34
+ (or `POST /api/v1/platform-tokens/`). It starts with `platform-` and never expires.
35
+ 2. Provide it one of two ways:
36
+ - **Connector (recommended in studio / chat):** install the **AceDataCloud**
37
+ connector at [auth.acedata.cloud/user/connections](https://auth.acedata.cloud/user/connections)
38
+ and paste the token once — the runtime injects `ACEDATACLOUD_PLATFORM_TOKEN`
39
+ into the sandbox automatically (this skill declares `connections: [acedatacloud]`).
40
+ - **Local `.env`:**
41
+
42
+ ```bash
43
+ ACEDATACLOUD_PLATFORM_TOKEN=platform-v1-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
44
+ ```
45
+
46
+ ```bash
47
+ curl -H "authorization: Bearer $ACEDATACLOUD_PLATFORM_TOKEN" \
48
+ https://platform.acedata.cloud/api/v1/applications/
49
+ ```
50
+
51
+ > A normal token only ever sees **its own** account data. A superuser token sees
52
+ > every user's data and is required for admin operations (announcements).
53
+
54
+ ## CLI (preferred)
55
+
56
+ The skill ships [`scripts/acedatacloud.py`](scripts/acedatacloud.py) — a self-contained
57
+ CLI (stdlib only) for the most common operations.
58
+
59
+ ```bash
60
+ ADC=$SKILL_DIR/scripts/acedatacloud.py
61
+
62
+ # Read
63
+ python3 $ADC balance # remaining credits per subscription
64
+ python3 $ADC services --search suno # list/search subscribed services
65
+ python3 $ADC usage --days 7 # recent API call records
66
+ python3 $ADC usage-summary --days 30 # spend aggregated by day + API
67
+ python3 $ADC keys # list API keys (credentials)
68
+ python3 $ADC orders --state Finished # recharge orders
69
+ python3 $ADC tokens # platform tokens
70
+ python3 $ADC models # available chat models
71
+
72
+ # Safe write (require --yes to actually execute)
73
+ python3 $ADC create-key --application <app-id> --name "ci" --yes
74
+ python3 $ADC delete-key --id <credential-id> --yes
75
+ python3 $ADC create-order --application <app-id> --package <package-id> --yes
76
+ python3 $ADC pay-order --id <order-id> --pay-way Stripe --yes
77
+ python3 $ADC create-token --yes
78
+ python3 $ADC delete-token --id <token-id> --yes
79
+
80
+ # Admin (superuser token only)
81
+ python3 $ADC send-announcement --title "..." --content "..." --yes
82
+ ```
83
+
84
+ Every command accepts `--json` for machine-readable output and `--token` to
85
+ override the env token. Without `--yes`, write commands print a dry-run preview
86
+ and exit without calling the API.
87
+
88
+ ## Pagination
89
+
90
+ List endpoints return `{ "count": <total>, "items": [ ... ] }` and accept
91
+ `?limit=` and `?offset=`. (Note: some older docs say `results`; the live field
92
+ is **`items`**.)
93
+
94
+ ## Endpoints reference
95
+
96
+ ### Balance / subscriptions — `GET /applications/`
97
+
98
+ An *Application* is your subscription to one Service; its `remaining_amount` is
99
+ your balance (in **Credits**) for that service.
100
+
101
+ ```bash
102
+ curl -H "authorization: Bearer $ACEDATACLOUD_PLATFORM_TOKEN" \
103
+ "https://platform.acedata.cloud/api/v1/applications/?limit=1"
104
+ ```
105
+
106
+ ```json
107
+ {
108
+ "count": 3,
109
+ "items": [
110
+ {
111
+ "id": "e9f625f2-cbd5-4254-8264-cfadbd180428",
112
+ "service_id": "f2b646d8-3cfd-46ef-969a-1ea9eebde329",
113
+ "remaining_amount": 100.9,
114
+ "used_amount": 1.1,
115
+ "paid": false,
116
+ "scope": "Global",
117
+ "allow_consume_global": true,
118
+ "expired_at": null,
119
+ "type": "Usage"
120
+ }
121
+ ]
122
+ }
123
+ ```
124
+
125
+ Filters: `service_id`, `scope` (`Individual`/`Global`), `user_id` (superuser only).
126
+
127
+ ### Usage records — `GET /usage/apis/`
128
+
129
+ Per-request call records (status code, latency, credits deducted).
130
+
131
+ Filters: `api_id`, `application_id`, `status_code`, `created_at_from`,
132
+ `created_at_to`, `user_id` (superuser only).
133
+
134
+ ```json
135
+ {
136
+ "count": 128,
137
+ "items": [
138
+ {
139
+ "id": "c124684b-7188-4c3e-ad53-3fba5150b944",
140
+ "api": { "title": "Suno Audios Generation API" },
141
+ "status_code": 200,
142
+ "original_amount": 0.55,
143
+ "deducted_amount": 0.55,
144
+ "trace_id": "…",
145
+ "elapsed": 12.3,
146
+ "created_at": "2026-06-28T09:15:44Z"
147
+ }
148
+ ]
149
+ }
150
+ ```
151
+
152
+ - **Spend aggregate** — `GET /usage/apis/aggregate/?created_at_from=&created_at_to=`
153
+ → `{ "items": [{ "date", "api_id", "amount" }], "total": <credits>, "apis": { "<id>": { "title" } } }`
154
+ - **Status-code filter values** — `GET /usage/apis/status-codes/` → `{ "items": [200, 400, 502] }`
155
+ - **CSV export** — `GET /usage/apis/export/?created_at_from=&created_at_to=` (downloads `usages.csv`)
156
+
157
+ ### Services — `GET /services/`
158
+
159
+ ```json
160
+ { "count": 215, "items": [ { "id": "…", "alias": "suno", "title": "Suno 音乐生成",
161
+ "unit": "Credit", "free_amount": 0.0, "applied_count": 121733,
162
+ "packages": [ { "id": "…", "type": "Usage", "price": 13.0, "amount": 100.0 } ] } ] }
163
+ ```
164
+
165
+ ### API keys / credentials — `GET /credentials/`
166
+
167
+ ```json
168
+ { "count": 2, "items": [ { "id": "dae4899f-…", "name": "ci", "type": "Token",
169
+ "token": "<secret>", "limited_amount": null, "used_amount": 1.1,
170
+ "host": null, "created_at": "2026-06-28T09:15:44Z" } ] }
171
+ ```
172
+
173
+ - **Create** — `POST /credentials/` `{ "application_id": "<app>", "name": "ci", "limited_amount": 100, "expired_at": "2026-12-31T00:00:00Z" }` → returns the credential incl. the new `token`. Save it; it is shown in full only at creation.
174
+ - **Delete** — `DELETE /credentials/{id}` → `204 No Content`
175
+ - Filters: `application_id`, `host`, `granted` (`true`/`false`), `user_id` (superuser).
176
+ - To "rotate" a key, delete it and create a new one.
177
+
178
+ ### Orders (recharge) — `GET /orders/`
179
+
180
+ ```json
181
+ { "count": 12, "items": [ { "id": "17c10c9d-…", "application_id": "…",
182
+ "package_id": "…", "description": "100 Credits", "price": 13.0,
183
+ "state": "Finished", "pay_way": "Stripe", "pay_url": "<url>",
184
+ "created_at": "2026-06-28T09:17:25Z" } ] }
185
+ ```
186
+
187
+ - `state` ∈ `Pending` `Paid` `Finished` `Expired` `Failed` `Refunded`
188
+ - `pay_way` ∈ `WechatPay` `AliPay` `Stripe` `X402` `PayPal` `Reward`
189
+ - **Create** — `POST /orders/` `{ "application_id": "<app>", "package_id": "<package>" }`
190
+ - **Pay** — `POST /orders/{id}/pay/` `{ "pay_way": "Stripe" }` → returns the order with a `pay_url` to open
191
+ - **Refresh status** — `POST /orders/{id}/refresh/` (re-checks the PSP and updates `state`)
192
+ - Filters: `state`, `pay_way`, `created_at_from`, `created_at_to`, `user_id` (superuser).
193
+
194
+ ### Platform tokens — `GET /platform-tokens/`
195
+
196
+ ```json
197
+ { "count": 2, "items": [ { "id": "efdccba4-…", "token": "<secret>",
198
+ "expiration": null, "used_at": null, "created_at": "2026-06-28T06:29:02Z" } ] }
199
+ ```
200
+
201
+ - **Create** — `POST /platform-tokens/` → returns the new token (starts with `platform-`)
202
+ - **Delete** — `DELETE /platform-tokens/{id}/` → `204 No Content`
203
+
204
+ ### Models — `GET /models/`
205
+
206
+ OpenAI-style (no pagination): `{ "object": "list", "data": [ { "id": "gpt-4.1",
207
+ "label": "GPT-4.1", "owned_by": "openai", "type": "chat", "capabilities": ["vision"] } ] }`
208
+
209
+ ### Announcements — `GET /announcements/`
210
+
211
+ Public read: `{ "count": 20, "items": [ { "id", "title", "content",
212
+ "translation_key", "published", "publish_at", "rank", "tags", "is_read" } ] }`
213
+
214
+ **Admin only (superuser token):**
215
+
216
+ - **Publish** — `POST /announcements/admin/` `{ "title": "...", "content": "...", "rank": 5, "tags": ["product"], "published": true }`
217
+ (`content` is Markdown; a zh-cn Translation row is created and other locales are auto-translated by a CronJob)
218
+ - **Edit / delete** — `PUT` / `DELETE /announcements/admin/{id}`
219
+ - **AI polish** — `POST /announcements/admin/polish/` `{ "title", "content" }`
220
+ - **AI translate** — `POST /announcements/admin/translate/` `{ "translation_key" }`
221
+
222
+ ## Write-operation safety
223
+
224
+ Creating/deleting keys, creating/paying orders, deleting platform tokens, and
225
+ publishing announcements are **irreversible or money-related**. Always:
226
+
227
+ 1. Confirm the exact target (`application_id`, `order_id`, `credential_id`) with
228
+ the user before executing.
229
+ 2. With the CLI, writes are dry-run unless `--yes` is passed.
230
+ 3. Never print a full `token` value into shared logs — it grants account access.
231
+
232
+ ## Gotchas
233
+
234
+ - Use the **platform token** (`ACEDATACLOUD_PLATFORM_TOKEN`), not the
235
+ `api.acedata.cloud` service token — the latter returns 401 here.
236
+ - `remaining_amount` / `used_amount` / `amount` are in **Credits**, not USD.
237
+ Convert with a service's package: `USD = Credits × (package.price / package.amount)`.
238
+ - Newly created credential/platform tokens are returned in full **only once** —
239
+ store them immediately.
240
+ - Credential "rotate" is delete + recreate; there is no in-place rotate endpoint.
241
+ - Announcement endpoints under `/admin/` require a **superuser** token; a normal
242
+ token gets `403`.
243
+
244
+ > **MCP:** `pip install mcp-acedatacloud` | See [all MCP servers](../_shared/mcp-servers.md). The MCP exposes these as tools (`platform_get_balance`, `platform_list_usage`, `platform_create_credential`, `platform_create_announcement`, …) with the same write-confirmation guard.
@@ -0,0 +1,467 @@
1
+ #!/usr/bin/env python3
2
+ """AceDataCloud platform management CLI (self-contained, stdlib only).
3
+
4
+ Talks to the management API at https://platform.acedata.cloud/api/v1 using a
5
+ platform token (ACEDATACLOUD_PLATFORM_TOKEN). Read commands are always safe;
6
+ write commands are dry-run unless --yes is passed.
7
+
8
+ Examples:
9
+ python3 platform.py balance
10
+ python3 platform.py usage-summary --days 30
11
+ python3 platform.py keys
12
+ python3 platform.py create-key --application <app-id> --name ci --yes
13
+ python3 platform.py send-announcement --title T --content C --yes
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as dt
19
+ import json
20
+ import os
21
+ import sys
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+
26
+ DEFAULT_BASE_URL = os.environ.get("PLATFORM_API_BASE_URL", "https://platform.acedata.cloud")
27
+ SECRET_KEYS = {"token", "password", "pay_url", "pay_id"}
28
+
29
+
30
+ def _die(msg: str, code: int = 1) -> None:
31
+ print(f"error: {msg}", file=sys.stderr)
32
+ sys.exit(code)
33
+
34
+
35
+ def _token(args: argparse.Namespace) -> str:
36
+ token = args.token or os.environ.get("ACEDATACLOUD_PLATFORM_TOKEN", "")
37
+ if not token:
38
+ _die(
39
+ "no platform token. Set ACEDATACLOUD_PLATFORM_TOKEN or pass --token.\n"
40
+ "Create one at https://platform.acedata.cloud/console/platform-tokens"
41
+ )
42
+ return token
43
+
44
+
45
+ def _request(args: argparse.Namespace, method: str, path: str,
46
+ params: dict | None = None, body: dict | None = None) -> tuple[int, object]:
47
+ base = args.base_url.rstrip("/")
48
+ url = f"{base}/api/v1{path}"
49
+ if params:
50
+ clean = {k: v for k, v in params.items() if v is not None}
51
+ if clean:
52
+ url += "?" + urllib.parse.urlencode(clean, doseq=True)
53
+ data = json.dumps(body).encode() if body is not None else None
54
+ req = urllib.request.Request(url, data=data, method=method, headers={
55
+ "accept": "application/json",
56
+ "authorization": f"Bearer {_token(args)}",
57
+ "content-type": "application/json",
58
+ })
59
+ try:
60
+ with urllib.request.urlopen(req, timeout=args.timeout) as resp:
61
+ raw = resp.read().decode("utf-8")
62
+ return resp.status, (json.loads(raw) if raw.strip() else None)
63
+ except urllib.error.HTTPError as e:
64
+ raw = e.read().decode("utf-8", "replace")
65
+ try:
66
+ return e.code, json.loads(raw)
67
+ except Exception:
68
+ return e.code, raw
69
+ except urllib.error.URLError as e:
70
+ _die(f"network error: {e.reason}")
71
+ return 0, None # unreachable
72
+
73
+
74
+ def _get_all(args: argparse.Namespace, path: str, params: dict | None = None,
75
+ page: int = 200, cap: int = 2000) -> tuple[int, list]:
76
+ """Fetch every page of a {count, items} list endpoint (capped)."""
77
+ base = dict(params or {})
78
+ out: list = []
79
+ offset = 0
80
+ total = 0
81
+ while len(out) < cap:
82
+ base.update({"limit": page, "offset": offset})
83
+ status, payload = _request(args, "GET", path, base)
84
+ _check_ok(status, payload)
85
+ items = payload.get("items", []) if isinstance(payload, dict) else []
86
+ total = payload.get("count", 0) if isinstance(payload, dict) else 0
87
+ out.extend(items)
88
+ offset += page
89
+ if not items or offset >= total:
90
+ break
91
+ return total, out
92
+
93
+
94
+ def _mask(obj: object, reveal: bool) -> object:
95
+ if reveal:
96
+ return obj
97
+ if isinstance(obj, dict):
98
+ out = {}
99
+ for k, v in obj.items():
100
+ if k in SECRET_KEYS and isinstance(v, str) and v:
101
+ out[k] = v[:10] + "…" + f"({len(v)} chars)" if len(v) > 10 else "***"
102
+ else:
103
+ out[k] = _mask(v, reveal)
104
+ return out
105
+ if isinstance(obj, list):
106
+ return [_mask(x, reveal) for x in obj]
107
+ return obj
108
+
109
+
110
+ def _emit(args: argparse.Namespace, payload: object, rows: list[tuple] | None = None,
111
+ headers: tuple | None = None, force_reveal: bool = False) -> None:
112
+ reveal = force_reveal or getattr(args, "reveal", False)
113
+ if args.json:
114
+ print(json.dumps(_mask(payload, reveal), ensure_ascii=False, indent=2))
115
+ return
116
+ if rows is not None and headers is not None:
117
+ widths = [len(h) for h in headers]
118
+ for r in rows:
119
+ for i, cell in enumerate(r):
120
+ widths[i] = max(widths[i], len(str(cell)))
121
+ line = " ".join(str(h).ljust(widths[i]) for i, h in enumerate(headers))
122
+ print(line)
123
+ print(" ".join("-" * widths[i] for i in range(len(headers))))
124
+ for r in rows:
125
+ print(" ".join(str(c).ljust(widths[i]) for i, c in enumerate(r)))
126
+ else:
127
+ print(json.dumps(_mask(payload, reveal), ensure_ascii=False, indent=2))
128
+
129
+
130
+ def _check_ok(status: int, payload: object) -> None:
131
+ if status >= 400:
132
+ msg = payload
133
+ if isinstance(payload, dict):
134
+ err = payload.get("error")
135
+ msg = (err.get("message") if isinstance(err, dict) else err) or payload.get("detail") or payload
136
+ _die(f"HTTP {status}: {msg}", code=2)
137
+
138
+
139
+ def _since(days: int | None) -> str | None:
140
+ if not days:
141
+ return None
142
+ return (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
143
+
144
+
145
+ # ---- read commands -------------------------------------------------------
146
+
147
+ def cmd_balance(args):
148
+ status, payload = _request(args, "GET", "/applications/", {"limit": args.limit})
149
+ _check_ok(status, payload)
150
+ items = payload.get("items", []) if isinstance(payload, dict) else []
151
+ rows = [(
152
+ it.get("service_id", "")[:8],
153
+ it.get("remaining_amount"),
154
+ it.get("used_amount"),
155
+ it.get("scope"),
156
+ it.get("id", "")[:8],
157
+ ) for it in items]
158
+ _emit(args, payload, rows, ("service", "remaining", "used", "scope", "app_id"))
159
+
160
+
161
+ def cmd_services(args):
162
+ if args.search:
163
+ total, items = _get_all(args, "/services/")
164
+ s = args.search.lower()
165
+ items = [it for it in items if s in (it.get("alias") or "").lower() or s in (it.get("title") or "").lower()]
166
+ payload = {"count": len(items), "items": items}
167
+ if not args.json:
168
+ print(f"# {len(items)} match '{args.search}' / {total} total services\n")
169
+ else:
170
+ status, payload = _request(args, "GET", "/services/", {"limit": args.limit})
171
+ _check_ok(status, payload)
172
+ items = payload.get("items", []) if isinstance(payload, dict) else []
173
+ if not args.json:
174
+ print(f"# {len(items)} shown / {payload.get('count')} total services\n")
175
+ rows = [(it.get("alias"), (it.get("title") or "")[:28], it.get("unit"), it.get("applied_count"), it.get("id", "")[:8]) for it in items]
176
+ _emit(args, payload, rows, ("alias", "title", "unit", "applied", "id"))
177
+
178
+
179
+ def cmd_usage(args):
180
+ params = {"limit": args.limit, "status_code": args.status_code,
181
+ "api_id": args.api, "created_at_from": _since(args.days)}
182
+ status, payload = _request(args, "GET", "/usage/apis/", params)
183
+ _check_ok(status, payload)
184
+ items = payload.get("items", []) if isinstance(payload, dict) else []
185
+ rows = [(
186
+ ((it.get("api") or {}).get("title") or "")[:30],
187
+ it.get("status_code"),
188
+ it.get("deducted_amount"),
189
+ it.get("elapsed"),
190
+ (it.get("created_at") or "")[:19],
191
+ ) for it in items]
192
+ _emit(args, payload, rows, ("api", "status", "credits", "elapsed", "created_at"))
193
+
194
+
195
+ def cmd_usage_summary(args):
196
+ params = {"created_at_from": _since(args.days or 30), "api_id": args.api}
197
+ status, payload = _request(args, "GET", "/usage/apis/aggregate/", params)
198
+ _check_ok(status, payload)
199
+ apis = payload.get("apis", {}) if isinstance(payload, dict) else {}
200
+ by_api: dict[str, float] = {}
201
+ for it in (payload.get("items", []) if isinstance(payload, dict) else []):
202
+ by_api[it.get("api_id")] = by_api.get(it.get("api_id"), 0.0) + (it.get("amount") or 0.0)
203
+ rows = sorted(((apis.get(k, {}).get("title", k or "?")[:36], round(v, 4)) for k, v in by_api.items()),
204
+ key=lambda r: -r[1])
205
+ if not args.json:
206
+ print(f"# total spend: {payload.get('total')} Credits over last {args.days or 30} days\n")
207
+ _emit(args, payload, rows, ("api", "credits"))
208
+
209
+
210
+ def cmd_keys(args):
211
+ status, payload = _request(args, "GET", "/credentials/", {"limit": args.limit, "application_id": args.application})
212
+ _check_ok(status, payload)
213
+ items = payload.get("items", []) if isinstance(payload, dict) else []
214
+ rows = [(
215
+ it.get("id", "")[:8],
216
+ it.get("name") or "-",
217
+ it.get("type"),
218
+ it.get("limited_amount"),
219
+ it.get("used_amount"),
220
+ (it.get("created_at") or "")[:10],
221
+ ) for it in items]
222
+ _emit(args, payload, rows, ("id", "name", "type", "limit", "used", "created"))
223
+
224
+
225
+ def cmd_orders(args):
226
+ status, payload = _request(args, "GET", "/orders/", {"limit": args.limit, "state": args.state, "pay_way": args.pay_way})
227
+ _check_ok(status, payload)
228
+ items = payload.get("items", []) if isinstance(payload, dict) else []
229
+ rows = [(
230
+ it.get("id", "")[:8],
231
+ (it.get("description") or "")[:26],
232
+ it.get("price"),
233
+ it.get("state"),
234
+ it.get("pay_way"),
235
+ (it.get("created_at") or "")[:10],
236
+ ) for it in items]
237
+ _emit(args, payload, rows, ("id", "description", "price", "state", "pay_way", "created"))
238
+
239
+
240
+ def cmd_tokens(args):
241
+ status, payload = _request(args, "GET", "/platform-tokens/", {"limit": args.limit})
242
+ _check_ok(status, payload)
243
+ items = payload.get("items", []) if isinstance(payload, dict) else []
244
+ rows = [(it.get("id", "")[:8], (it.get("used_at") or "never")[:19], (it.get("created_at") or "")[:19]) for it in items]
245
+ _emit(args, payload, rows, ("id", "last_used", "created"))
246
+
247
+
248
+ def cmd_models(args):
249
+ status, payload = _request(args, "GET", "/models/")
250
+ _check_ok(status, payload)
251
+ data = payload.get("data", []) if isinstance(payload, dict) else []
252
+ rows = [(m.get("id"), (m.get("label") or "")[:24], m.get("owned_by"), m.get("type"), ",".join(m.get("capabilities") or [])) for m in data]
253
+ _emit(args, payload, rows, ("id", "label", "owned_by", "type", "capabilities"))
254
+
255
+
256
+ def cmd_announcements(args):
257
+ status, payload = _request(args, "GET", "/announcements/", {"limit": args.limit})
258
+ _check_ok(status, payload)
259
+ items = payload.get("items", []) if isinstance(payload, dict) else []
260
+ rows = [(it.get("id", "")[:8], (it.get("title") or "")[:40], it.get("rank"), (it.get("publish_at") or "")[:10]) for it in items]
261
+ _emit(args, payload, rows, ("id", "title", "rank", "publish_at"))
262
+
263
+
264
+ # ---- write commands (gated by --yes) -------------------------------------
265
+
266
+ def _guard(args, action: str, detail: dict) -> bool:
267
+ if args.yes:
268
+ return True
269
+ print("DRY RUN — pass --yes to execute.")
270
+ print(f"action: {action}")
271
+ print(f"target: {json.dumps(detail, ensure_ascii=False)}")
272
+ return False
273
+
274
+
275
+ def cmd_create_key(args):
276
+ body = {"application_id": args.application}
277
+ if args.name:
278
+ body["name"] = args.name
279
+ if args.limited_amount is not None:
280
+ body["limited_amount"] = args.limited_amount
281
+ if args.expired_at:
282
+ body["expired_at"] = args.expired_at
283
+ if not _guard(args, "POST /credentials/", body):
284
+ return
285
+ status, payload = _request(args, "POST", "/credentials/", body=body)
286
+ _check_ok(status, payload)
287
+ print("# new key created — store the token now, it is shown in full only once")
288
+ _emit(args, payload, force_reveal=True)
289
+
290
+
291
+ def cmd_delete_key(args):
292
+ if not _guard(args, f"DELETE /credentials/{args.id}", {"id": args.id}):
293
+ return
294
+ status, payload = _request(args, "DELETE", f"/credentials/{args.id}")
295
+ _check_ok(status, payload)
296
+ print(f"deleted credential {args.id} (HTTP {status})")
297
+
298
+
299
+ def cmd_create_order(args):
300
+ body = {"application_id": args.application, "package_id": args.package}
301
+ if not _guard(args, "POST /orders/", body):
302
+ return
303
+ status, payload = _request(args, "POST", "/orders/", body=body)
304
+ _check_ok(status, payload)
305
+ _emit(args, payload)
306
+
307
+
308
+ def cmd_pay_order(args):
309
+ body = {"pay_way": args.pay_way}
310
+ if not _guard(args, f"POST /orders/{args.id}/pay/", body):
311
+ return
312
+ status, payload = _request(args, "POST", f"/orders/{args.id}/pay/", body=body)
313
+ _check_ok(status, payload)
314
+ if isinstance(payload, dict) and payload.get("pay_url"):
315
+ print(f"pay_url: {payload['pay_url']}")
316
+ _emit(args, payload)
317
+
318
+
319
+ def cmd_create_token(args):
320
+ if not _guard(args, "POST /platform-tokens/", {}):
321
+ return
322
+ status, payload = _request(args, "POST", "/platform-tokens/", body={})
323
+ _check_ok(status, payload)
324
+ print("# new platform token — store it now, it is shown in full only once")
325
+ _emit(args, payload, force_reveal=True)
326
+
327
+
328
+ def cmd_delete_token(args):
329
+ if not _guard(args, f"DELETE /platform-tokens/{args.id}/", {"id": args.id}):
330
+ return
331
+ status, payload = _request(args, "DELETE", f"/platform-tokens/{args.id}/")
332
+ _check_ok(status, payload)
333
+ print(f"deleted platform token {args.id} (HTTP {status})")
334
+
335
+
336
+ def cmd_send_announcement(args):
337
+ body = {"title": args.title, "content": args.content, "published": not args.draft}
338
+ if args.rank is not None:
339
+ body["rank"] = args.rank
340
+ if args.tags:
341
+ body["tags"] = args.tags.split(",")
342
+ if not _guard(args, "POST /announcements/admin/ (superuser only)", body):
343
+ return
344
+ status, payload = _request(args, "POST", "/announcements/admin/", body=body)
345
+ _check_ok(status, payload)
346
+ _emit(args, payload)
347
+
348
+
349
+ def build_parser() -> argparse.ArgumentParser:
350
+ # Global options use SUPPRESS defaults so a value parsed BEFORE the
351
+ # subcommand is not clobbered by the subparser re-injecting its own
352
+ # default. Without SUPPRESS, argparse overwrites `--json foo` back to
353
+ # False. Real defaults are applied in main() via _GLOBAL_DEFAULTS, so the
354
+ # options work in both positions (e.g. `--json keys` and `keys --json`).
355
+ common = argparse.ArgumentParser(add_help=False)
356
+ common.add_argument("--token", default=argparse.SUPPRESS,
357
+ help="platform token (default: $ACEDATACLOUD_PLATFORM_TOKEN)")
358
+ common.add_argument("--base-url", default=argparse.SUPPRESS, help="API base URL")
359
+ common.add_argument("--timeout", type=float, default=argparse.SUPPRESS,
360
+ help="request timeout in seconds (default: 30)")
361
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
362
+ help="raw JSON output")
363
+ common.add_argument("--reveal", action="store_true", default=argparse.SUPPRESS,
364
+ help="do not mask secrets in output")
365
+
366
+ p = argparse.ArgumentParser(description="AceDataCloud platform management CLI", parents=[common])
367
+ sub = p.add_subparsers(dest="command", required=True)
368
+
369
+ def add(name, fn, help_):
370
+ sp = sub.add_parser(name, help=help_, parents=[common])
371
+ sp.set_defaults(func=fn)
372
+ return sp
373
+
374
+ sp = add("balance", cmd_balance, "remaining credits per subscription")
375
+ sp.add_argument("--limit", type=int, default=50)
376
+
377
+ sp = add("services", cmd_services, "list/search subscribed services")
378
+ sp.add_argument("--limit", type=int, default=100)
379
+ sp.add_argument("--search", help="filter by alias/title substring")
380
+
381
+ sp = add("usage", cmd_usage, "recent API call records")
382
+ sp.add_argument("--limit", type=int, default=20)
383
+ sp.add_argument("--days", type=int, help="only records newer than N days")
384
+ sp.add_argument("--status-code", type=int)
385
+ sp.add_argument("--api", help="filter by api_id")
386
+
387
+ sp = add("usage-summary", cmd_usage_summary, "spend aggregated by API")
388
+ sp.add_argument("--days", type=int, default=30)
389
+ sp.add_argument("--api", help="filter by api_id")
390
+
391
+ sp = add("keys", cmd_keys, "list API keys (credentials)")
392
+ sp.add_argument("--limit", type=int, default=50)
393
+ sp.add_argument("--application", help="filter by application_id")
394
+
395
+ sp = add("orders", cmd_orders, "list recharge orders")
396
+ sp.add_argument("--limit", type=int, default=20)
397
+ sp.add_argument("--state")
398
+ sp.add_argument("--pay-way", dest="pay_way")
399
+
400
+ sp = add("tokens", cmd_tokens, "list platform tokens")
401
+ sp.add_argument("--limit", type=int, default=50)
402
+
403
+ add("models", cmd_models, "list available chat models")
404
+
405
+ sp = add("announcements", cmd_announcements, "list announcements")
406
+ sp.add_argument("--limit", type=int, default=20)
407
+
408
+ sp = add("create-key", cmd_create_key, "create an API key (needs --yes)")
409
+ sp.add_argument("--application", required=True)
410
+ sp.add_argument("--name")
411
+ sp.add_argument("--limited-amount", dest="limited_amount", type=float)
412
+ sp.add_argument("--expired-at", dest="expired_at")
413
+ sp.add_argument("--yes", action="store_true")
414
+
415
+ sp = add("delete-key", cmd_delete_key, "delete an API key (needs --yes)")
416
+ sp.add_argument("--id", required=True)
417
+ sp.add_argument("--yes", action="store_true")
418
+
419
+ sp = add("create-order", cmd_create_order, "create a recharge order (needs --yes)")
420
+ sp.add_argument("--application", required=True)
421
+ sp.add_argument("--package", required=True)
422
+ sp.add_argument("--yes", action="store_true")
423
+
424
+ sp = add("pay-order", cmd_pay_order, "create a pay link for an order (needs --yes)")
425
+ sp.add_argument("--id", required=True)
426
+ sp.add_argument("--pay-way", dest="pay_way", default="Stripe")
427
+ sp.add_argument("--yes", action="store_true")
428
+
429
+ sp = add("create-token", cmd_create_token, "create a platform token (needs --yes)")
430
+ sp.add_argument("--yes", action="store_true")
431
+
432
+ sp = add("delete-token", cmd_delete_token, "delete a platform token (needs --yes)")
433
+ sp.add_argument("--id", required=True)
434
+ sp.add_argument("--yes", action="store_true")
435
+
436
+ sp = add("send-announcement", cmd_send_announcement, "publish an announcement (superuser; needs --yes)")
437
+ sp.add_argument("--title", required=True)
438
+ sp.add_argument("--content", required=True)
439
+ sp.add_argument("--rank", type=int)
440
+ sp.add_argument("--tags", help="comma-separated")
441
+ sp.add_argument("--draft", action="store_true", help="create unpublished")
442
+ sp.add_argument("--yes", action="store_true")
443
+
444
+ return p
445
+
446
+
447
+ _GLOBAL_DEFAULTS = {
448
+ "token": None,
449
+ "base_url": DEFAULT_BASE_URL,
450
+ "timeout": 30.0,
451
+ "json": False,
452
+ "reveal": False,
453
+ }
454
+
455
+
456
+ def main() -> None:
457
+ args = build_parser().parse_args()
458
+ # Apply defaults for any global option not supplied in either position
459
+ # (they use argparse.SUPPRESS so unset attrs are simply absent).
460
+ for attr, default in _GLOBAL_DEFAULTS.items():
461
+ if not hasattr(args, attr):
462
+ setattr(args, attr, default)
463
+ args.func(args)
464
+
465
+
466
+ if __name__ == "__main__":
467
+ main()
@@ -257,9 +257,29 @@ ZH_UPLOADED_IMAGES = "https://zhuanlan.zhihu.com/api/uploaded_images"
257
257
  ZH_IMAGES = "https://api.zhihu.com/images"
258
258
  OSS_ENDPOINT = "https://zhihu-pics-upload.zhimg.com"
259
259
  OSS_BUCKET = "zhihu-pics"
260
- _ZHIMG = "zhimg.com" # a src already on Zhihu's CDN needs no re-upload
260
+ # A src already on Zhihu's own CDN needs no re-upload. uploaded_images hands back
261
+ # a pic-private.zhihu.com URL for draft-scoped images, so skip those too.
262
+ _ZHIMG_HOSTS = ("zhimg.com", "pic-private.zhihu.com")
261
263
  _IMG_SRC_RE = re.compile(r'(<img[^>]*?\ssrc=["\'])([^"\']+)(["\'])', re.IGNORECASE)
262
264
  _MD_IMG_RE = re.compile(r'(!\[[^\]]*\]\()(\s*<?)([^)\s>]+)(>?\s*\))')
265
+ # uploaded_images returns a DRAFT-SCOPED signed pic-private URL whose auth_key
266
+ # expires; reduce any Zhihu image URL to the durable public form Zhihu serves
267
+ # after publish (picx.zhimg.com/v2-<hash>.<ext>).
268
+ _ZH_IMG_HASH = re.compile(r'(v2-[0-9a-f]+)(?:~[^."?\']*)?\.(png|jpe?g|gif|webp)', re.IGNORECASE)
269
+
270
+
271
+ def _on_zhihu_cdn(src: str) -> bool:
272
+ # Match on the parsed host (not a substring) so an external host that merely
273
+ # contains "zhimg.com" (e.g. my-zhimg.com) isn't wrongly treated as hosted.
274
+ host = (urllib.parse.urlsplit(src).hostname or "") if src else ""
275
+ return any(host == h or host.endswith("." + h) for h in _ZHIMG_HOSTS)
276
+
277
+
278
+ def _canonical_zhimg(url):
279
+ if not url:
280
+ return url
281
+ m = _ZH_IMG_HASH.search(url)
282
+ return f"https://picx.zhimg.com/{m.group(1)}.{m.group(2).lower()}" if m else url
263
283
 
264
284
 
265
285
  def _raw(method, url, jar, *, headers=None, data=None, timeout=60):
@@ -312,7 +332,7 @@ def _upload_image_url(jar, src: str):
312
332
  except json.JSONDecodeError:
313
333
  return None
314
334
  if isinstance(d, dict) and d.get("src"):
315
- return d["src"]
335
+ return _canonical_zhimg(d["src"])
316
336
  return None
317
337
 
318
338
 
@@ -380,13 +400,13 @@ def _upload_image_binary(jar, data: bytes):
380
400
  upload_token = token_data.get("upload_token") or {}
381
401
  if upload_file.get("state") == 1: # already on Zhihu — just resolve its hash
382
402
  object_key = _wait_image_ready(jar, upload_file.get("image_id", ""))
383
- return f"https://pic4.zhimg.com/{object_key}" if object_key else None
403
+ return _canonical_zhimg(f"https://pic4.zhimg.com/{object_key}") if object_key else None
384
404
  object_key = upload_file.get("object_key", "")
385
405
  if not object_key or not _oss_put(object_key, data, upload_token):
386
406
  return None
387
407
  if data[:6] in (b"GIF87a", b"GIF89a"):
388
408
  object_key += ".gif"
389
- return f"https://pic4.zhimg.com/{object_key}"
409
+ return _canonical_zhimg(f"https://pic4.zhimg.com/{object_key}")
390
410
 
391
411
 
392
412
  def _download_image(src: str):
@@ -400,7 +420,7 @@ def _download_image(src: str):
400
420
  def count_images(content: str) -> int:
401
421
  srcs = {m.group(2) for m in _IMG_SRC_RE.finditer(content or "")}
402
422
  srcs |= {m.group(3) for m in _MD_IMG_RE.finditer(content or "")}
403
- return sum(1 for s in srcs if _ZHIMG not in s)
423
+ return sum(1 for s in srcs if not _on_zhihu_cdn(s))
404
424
 
405
425
 
406
426
  def rehost_images(jar, content: str):
@@ -410,7 +430,7 @@ def rehost_images(jar, content: str):
410
430
  stats = {"found": 0, "rehosted": 0, "failed": 0}
411
431
 
412
432
  def resolve(src: str) -> str:
413
- if not src or _ZHIMG in src:
433
+ if not src or _on_zhihu_cdn(src):
414
434
  return src
415
435
  if src in cache:
416
436
  return cache[src]
@@ -1,130 +0,0 @@
1
- ---
2
- name: acedatacloud-api
3
- description: Guide for using AceDataCloud APIs. Use when authenticating, making API calls, managing credentials, understanding billing, or integrating AceDataCloud services into applications. Covers setup, authentication, request patterns, error handling, and SDK integration.
4
- license: Apache-2.0
5
- metadata:
6
- author: acedatacloud
7
- version: "1.0"
8
- compatibility: Requires an AceDataCloud account at platform.acedata.cloud.
9
- ---
10
-
11
- # AceDataCloud API Usage Guide
12
-
13
- Complete guide for using AceDataCloud's AI-powered data services API.
14
-
15
- ## Getting Started
16
-
17
- ### 1. Create an Account
18
-
19
- Register at [platform.acedata.cloud](https://platform.acedata.cloud).
20
-
21
- ### 2. Subscribe to a Service
22
-
23
- Browse available services and click **Get** to subscribe. Most services include free quota.
24
-
25
- ### 3. Create API Credentials
26
-
27
- Go to your service's **Credentials** page and create an API Token.
28
-
29
- > **Full details:** See [authentication](../_shared/authentication.md) for token types and usage.
30
-
31
- ## SDK Integration (OpenAI-Compatible)
32
-
33
- For chat completion services, use the standard OpenAI SDK:
34
-
35
- ```python
36
- from openai import OpenAI
37
-
38
- client = OpenAI(
39
- api_key="YOUR_API_TOKEN",
40
- base_url="https://api.acedata.cloud/v1"
41
- )
42
-
43
- response = client.chat.completions.create(
44
- model="claude-sonnet-4-20250514",
45
- messages=[{"role": "user", "content": "Hello!"}]
46
- )
47
- ```
48
-
49
- ```javascript
50
- import OpenAI from "openai";
51
-
52
- const client = new OpenAI({
53
- apiKey: "YOUR_API_TOKEN",
54
- baseURL: "https://api.acedata.cloud/v1"
55
- });
56
-
57
- const response = await client.chat.completions.create({
58
- model: "gpt-4.1",
59
- messages: [{ role: "user", content: "Hello!" }]
60
- });
61
- ```
62
-
63
- ## Request Patterns
64
-
65
- ### Synchronous APIs
66
-
67
- Some APIs return results immediately (e.g., face transform, search):
68
-
69
- ```bash
70
- curl -X POST https://api.acedata.cloud/face/analyze \
71
- -H "Authorization: Bearer $TOKEN" \
72
- -H "Content-Type: application/json" \
73
- -d '{"image_url": "https://example.com/photo.jpg"}'
74
- ```
75
-
76
- ### Async Task APIs
77
-
78
- Most generation APIs (images, video, music) are asynchronous.
79
-
80
- > **Full details:** See [async task polling](../_shared/async-tasks.md) for the submit-and-poll pattern.
81
-
82
- ## Error Handling
83
-
84
- | HTTP Code | Meaning | Action |
85
- |-----------|---------|--------|
86
- | 400 | Bad request | Check request parameters |
87
- | 401 | Unauthorized | Check API token |
88
- | 403 | Forbidden | Content filtered or insufficient permissions |
89
- | 429 | Rate limited | Wait and retry with backoff |
90
- | 500 | Server error | Retry or contact support |
91
-
92
- Error response format:
93
-
94
- ```json
95
- {
96
- "error": {
97
- "code": "token_mismatched",
98
- "message": "Invalid or expired token"
99
- }
100
- }
101
- ```
102
-
103
- ## Billing
104
-
105
- - Each API call deducts from your **subscription balance** (remaining_amount)
106
- - Cost varies by service, model, and usage (tokens, requests, data size)
107
- - Check balance at [platform.acedata.cloud](https://platform.acedata.cloud)
108
- - Most services offer free trial quota
109
-
110
- ## Service Categories
111
-
112
- | Category | Services | Base Path |
113
- |----------|----------|-----------|
114
- | **AI Chat** | GPT, Claude, Gemini, Kimi, Grok | `/v1/chat/completions` |
115
- | **Image Gen** | Flux, Seedream, NanoBanana | `/flux/*`, `/seedream/*`, etc. |
116
- | **Video Gen** | Luma, Sora, Veo, Kling, Hailuo, Seedance, Wan | `/luma/*`, `/sora/*`, etc. |
117
- | **Music Gen** | Suno, Producer, Fish Audio | `/suno/*`, `/producer/*`, `/fish/*` |
118
- | **Search** | Google Search (web/images/news/maps) | `/serp/*` |
119
- | **Face** | Analyze, beautify, swap, cartoon, age | `/face/*` |
120
- | **Utility** | Short URL, QR Art, Headshots | `/short-url`, `/qrart/*`, `/headshots/*` |
121
-
122
- ## Gotchas
123
-
124
- - Tokens are **service-scoped** by default — create a global token if you need cross-service access
125
- - Async APIs return a `task_id` — always use `callback_url` to get the task_id immediately, then poll for results
126
- - Avoid `wait: true` — it blocks for the full generation duration and will time out for video/music tasks
127
- - Rate limits vary by service tier — upgrade your plan if hitting limits
128
- - All timestamps are in UTC
129
-
130
- > **MCP:** See [MCP servers](../_shared/mcp-servers.md) for tool-use integration with AI agents.