ownta-mcp 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,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: ownta-mcp
3
+ Version: 0.1.0
4
+ Summary: OWNTA MCP server (stdio): image generation, image ops, and read-only skill/library tools
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: mcp<2,>=1.9
8
+ Requires-Dist: httpx>=0.27
9
+
10
+ # ownta-mcp
11
+
12
+ OWNTA 的 MCP 服务端(stdio):把出图、图像处理、技能与百科只读封装成 Agent 可直接调用的 8 个工具。
13
+ 不需要克隆本仓库,装好包 + 一把 Key 就能用。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ pip install ownta-mcp
19
+ # 或者
20
+ uv tool install ownta-mcp
21
+ ```
22
+
23
+ 需要 Python >= 3.10。装完会得到命令 `ownta-mcp`(也可以 `python -m mcp_server`)。
24
+
25
+ ## 1. 拿一把 Key
26
+
27
+ 登录 <https://ownta.ai> → 设置 → API Key → 新建。
28
+
29
+ 默认 scopes 是五个值:`img.gen`(出图)、`vid.gen`、`llm.chat`、`img.ops`(图像处理)、
30
+ `lib.read`。只让 Agent 出图就把 `img.ops` 去掉;Key 建完不能改 scopes,要改只能重建。
31
+ 存量 Key 会在服务端启动迁移时自动补上 `img.ops` / `lib.read`,不需要重建。
32
+
33
+ Key 等同账户权限(能出图、能扣点),不要提交进仓库或贴进公开对话。
34
+
35
+ ## 2. 配到客户端
36
+
37
+ 通用形状(Claude Desktop / Cursor 都是 `mcpServers` 这一层):
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "ownta": {
43
+ "command": "ownta-mcp",
44
+ "env": {
45
+ "OWNTA_GW_API_KEY": "sk-ownta-...",
46
+ "OWNTA_GW_BASE_URL": "https://ai.ownta.ai"
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ - Claude Desktop:`%APPDATA%\Claude\claude_desktop_config.json`(Windows)/
54
+ `~/Library/Application Support/Claude/claude_desktop_config.json`(macOS)
55
+ - Cursor:`~/.cursor/mcp.json`
56
+ - VS Code:`.vscode/mcp.json`(键名是 `servers`,其余字段相同)
57
+
58
+ Windows 上客户端找不到 `ownta-mcp` 时,把 `command` 写成 pip 的 Scripts 全路径,
59
+ 例如 `C:\Users\<you>\AppData\Local\Programs\Python\Python312\Scripts\ownta-mcp.exe`。
60
+
61
+ `OWNTA_GW_BASE_URL` 可省略,默认就是 `https://ai.ownta.ai`。
62
+
63
+ ## 3. 先跑这一句
64
+
65
+ > 用 ownta 的 generate_image 画一只在雨林里的蓝顶亚马逊鹦鹉,1024x1024,然后轮询到出图,把图片 URL 给我。
66
+
67
+ Agent 应该先拿到 `job_id`,再调 `query_job` 取 `url`。第一次调用会扣点;
68
+ 余额不足会收到 `insufficient_credits`。
69
+
70
+ ## 工具
71
+
72
+ | 工具 | scope | 计费 | 说明 |
73
+ |---|---|---|---|
74
+ | `generate_image` | `img.gen` | 按计价表扣点 | 提交出图(异步,立即回 `job_id`);支持参考图、透明底、DTF/DTG |
75
+ | `generate_brazil_pattern` | `img.gen` | 按计价表扣点 | 巴西 T 恤印花一步提交(10 款技能,透明底 DTF) |
76
+ | `query_job` | — | 免费 | 轮询任务状态与成品 URL,可反复调 |
77
+ | `list_skills` | — | 免费 | 平台模板 + 你自己的「我的技能」 |
78
+ | `get_skill` | — | 免费 | 取单个技能的提示词全文与图位清单 |
79
+ | `apply_op` | `img.ops` | 按档 0~16 点(明细见下) | 13 档图像处理;`compress` 兼做等比下采(**没有 `resize`**) |
80
+ | `browse_library` | — | 免费 | 百科知识库目录与检索(10 个域,含 nature 题材 / style 风格层) |
81
+ | `read_library_entry` | — | 免费 | 读百科正文或 references/ 附件,支持分段续读 |
82
+
83
+ `apply_op` 的 13 档按点数分四组(价目真值在网关 `Price` 表):
84
+
85
+ - **0 点**:`compress` / `crop` / `watermark` / `print_text` / `whitebg`
86
+ - **2 点**:`matting` / `kit`  **4 点**:`seamless`  **5 点**:`upscale`  **6 点**:`seamless_pro`
87
+ - **8 点**:`print_extract`  **10 点**:`inpaint_swap`  **16 点**:`outpaint`
88
+
89
+ `apply_op` 的 `src` 只收本站 CDN 对象(`image/` `uploads/` `processed/` 前缀),
90
+ 不收外链与 base64;`generate_image` 的参考图反过来只收 data URL / 裸 base64,不收 http URL。
91
+
92
+ ## 错误码
93
+
94
+ 回包 `ok=false` 时按 `code` 分支(原样透传网关的 `detail.reason`):
95
+
96
+ | code | 含义 | 怎么办 |
97
+ |---|---|---|
98
+ | `insufficient_credits` | 余额不足 | 充值,或换一把有余额账号的 Key |
99
+ | `scope_forbidden` | 这把 Key 没有该动作的 scope | `apply_op` 需要 `img.ops`,新建一把带它的 Key |
100
+ | `rate_limited` | 触发 RPM / 突发 / 日封顶 | 按回包 `retry_after` 退避 |
101
+ | `content_blocked` | 内容审核拒了 | 改写提示词 |
102
+ | `invalid_request` | 参数或来源不合规 | 按 `message` 改;`src` 必须是本站对象 |
103
+
104
+ ## 已知边界
105
+
106
+ - 只有 stdio,没有远程 HTTP 端点:Agent 必须跑在能起子进程的客户端里。
107
+ - 一次任务不等待出图;`generate_image` 返回后必须用 `query_job` 轮询。
108
+ - stdout 是 JSON-RPC 通道,本包日志只写 stderr;不要用 `print` 调试。
109
+
110
+ ## 从源码安装(开发)
111
+
112
+ ```bash
113
+ cd gateway/mcp_server
114
+ pip install -e .
115
+ ```
116
+
117
+ 版本:0.1.0
@@ -0,0 +1,108 @@
1
+ # ownta-mcp
2
+
3
+ OWNTA 的 MCP 服务端(stdio):把出图、图像处理、技能与百科只读封装成 Agent 可直接调用的 8 个工具。
4
+ 不需要克隆本仓库,装好包 + 一把 Key 就能用。
5
+
6
+ ## 安装
7
+
8
+ ```bash
9
+ pip install ownta-mcp
10
+ # 或者
11
+ uv tool install ownta-mcp
12
+ ```
13
+
14
+ 需要 Python >= 3.10。装完会得到命令 `ownta-mcp`(也可以 `python -m mcp_server`)。
15
+
16
+ ## 1. 拿一把 Key
17
+
18
+ 登录 <https://ownta.ai> → 设置 → API Key → 新建。
19
+
20
+ 默认 scopes 是五个值:`img.gen`(出图)、`vid.gen`、`llm.chat`、`img.ops`(图像处理)、
21
+ `lib.read`。只让 Agent 出图就把 `img.ops` 去掉;Key 建完不能改 scopes,要改只能重建。
22
+ 存量 Key 会在服务端启动迁移时自动补上 `img.ops` / `lib.read`,不需要重建。
23
+
24
+ Key 等同账户权限(能出图、能扣点),不要提交进仓库或贴进公开对话。
25
+
26
+ ## 2. 配到客户端
27
+
28
+ 通用形状(Claude Desktop / Cursor 都是 `mcpServers` 这一层):
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "ownta": {
34
+ "command": "ownta-mcp",
35
+ "env": {
36
+ "OWNTA_GW_API_KEY": "sk-ownta-...",
37
+ "OWNTA_GW_BASE_URL": "https://ai.ownta.ai"
38
+ }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ - Claude Desktop:`%APPDATA%\Claude\claude_desktop_config.json`(Windows)/
45
+ `~/Library/Application Support/Claude/claude_desktop_config.json`(macOS)
46
+ - Cursor:`~/.cursor/mcp.json`
47
+ - VS Code:`.vscode/mcp.json`(键名是 `servers`,其余字段相同)
48
+
49
+ Windows 上客户端找不到 `ownta-mcp` 时,把 `command` 写成 pip 的 Scripts 全路径,
50
+ 例如 `C:\Users\<you>\AppData\Local\Programs\Python\Python312\Scripts\ownta-mcp.exe`。
51
+
52
+ `OWNTA_GW_BASE_URL` 可省略,默认就是 `https://ai.ownta.ai`。
53
+
54
+ ## 3. 先跑这一句
55
+
56
+ > 用 ownta 的 generate_image 画一只在雨林里的蓝顶亚马逊鹦鹉,1024x1024,然后轮询到出图,把图片 URL 给我。
57
+
58
+ Agent 应该先拿到 `job_id`,再调 `query_job` 取 `url`。第一次调用会扣点;
59
+ 余额不足会收到 `insufficient_credits`。
60
+
61
+ ## 工具
62
+
63
+ | 工具 | scope | 计费 | 说明 |
64
+ |---|---|---|---|
65
+ | `generate_image` | `img.gen` | 按计价表扣点 | 提交出图(异步,立即回 `job_id`);支持参考图、透明底、DTF/DTG |
66
+ | `generate_brazil_pattern` | `img.gen` | 按计价表扣点 | 巴西 T 恤印花一步提交(10 款技能,透明底 DTF) |
67
+ | `query_job` | — | 免费 | 轮询任务状态与成品 URL,可反复调 |
68
+ | `list_skills` | — | 免费 | 平台模板 + 你自己的「我的技能」 |
69
+ | `get_skill` | — | 免费 | 取单个技能的提示词全文与图位清单 |
70
+ | `apply_op` | `img.ops` | 按档 0~16 点(明细见下) | 13 档图像处理;`compress` 兼做等比下采(**没有 `resize`**) |
71
+ | `browse_library` | — | 免费 | 百科知识库目录与检索(10 个域,含 nature 题材 / style 风格层) |
72
+ | `read_library_entry` | — | 免费 | 读百科正文或 references/ 附件,支持分段续读 |
73
+
74
+ `apply_op` 的 13 档按点数分四组(价目真值在网关 `Price` 表):
75
+
76
+ - **0 点**:`compress` / `crop` / `watermark` / `print_text` / `whitebg`
77
+ - **2 点**:`matting` / `kit`  **4 点**:`seamless`  **5 点**:`upscale`  **6 点**:`seamless_pro`
78
+ - **8 点**:`print_extract`  **10 点**:`inpaint_swap`  **16 点**:`outpaint`
79
+
80
+ `apply_op` 的 `src` 只收本站 CDN 对象(`image/` `uploads/` `processed/` 前缀),
81
+ 不收外链与 base64;`generate_image` 的参考图反过来只收 data URL / 裸 base64,不收 http URL。
82
+
83
+ ## 错误码
84
+
85
+ 回包 `ok=false` 时按 `code` 分支(原样透传网关的 `detail.reason`):
86
+
87
+ | code | 含义 | 怎么办 |
88
+ |---|---|---|
89
+ | `insufficient_credits` | 余额不足 | 充值,或换一把有余额账号的 Key |
90
+ | `scope_forbidden` | 这把 Key 没有该动作的 scope | `apply_op` 需要 `img.ops`,新建一把带它的 Key |
91
+ | `rate_limited` | 触发 RPM / 突发 / 日封顶 | 按回包 `retry_after` 退避 |
92
+ | `content_blocked` | 内容审核拒了 | 改写提示词 |
93
+ | `invalid_request` | 参数或来源不合规 | 按 `message` 改;`src` 必须是本站对象 |
94
+
95
+ ## 已知边界
96
+
97
+ - 只有 stdio,没有远程 HTTP 端点:Agent 必须跑在能起子进程的客户端里。
98
+ - 一次任务不等待出图;`generate_image` 返回后必须用 `query_job` 轮询。
99
+ - stdout 是 JSON-RPC 通道,本包日志只写 stderr;不要用 `print` 调试。
100
+
101
+ ## 从源码安装(开发)
102
+
103
+ ```bash
104
+ cd gateway/mcp_server
105
+ pip install -e .
106
+ ```
107
+
108
+ 版本:0.1.0
@@ -0,0 +1,8 @@
1
+ """MCP 首刀的服务端包(独立进程,stdio transport)。
2
+
3
+ 边界(new-plans 定案):
4
+ - **不 import 网关 app**,只经 HTTP 调 `/v1/generate` 与 `/v1/jobs/{id}` —— 鉴权、计费、
5
+ 幂等、内容审核的唯一真源都留在网关,MCP 只是一层协议适配,崩了不拖生成主链。
6
+ - `client.py` / `tools.py` 不依赖 `mcp` SDK(纯 httpx + 纯函数),可在无 SDK 的环境跑单测;
7
+ 只有 `server.py` 需要 `mcp`(`requirements-mcp.txt`),生产镜像不必装。
8
+ """
@@ -0,0 +1,5 @@
1
+ """`python -m mcp_server` 的入口:等价于 console script `ownta-mcp`。"""
2
+ from mcp_server.server import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,202 @@
1
+ """MCP → 网关的 HTTP 客户端(唯一耦合面)。
2
+
3
+ 为什么不是「import main 再调函数」:MCP 是**另一个进程**(new-plans 定案),复用网关的
4
+ `require_auth`(JWT 与 sk-ownta- 平台 Key 同一套)、计费与 Idempotency-Key 语义,最省事
5
+ 且不产生第二份真源的方式就是走 HTTP。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+ DEFAULT_BASE_URL = "https://ai.ownta.ai"
16
+
17
+ # 网关自己的 detail.reason 优先;没带 reason 的旧路径按状态码兜底成同一套机器码,
18
+ # 让 Agent 侧只认一组 code(insufficient_credits / rate_limited / …)就够分支。
19
+ _REASON_BY_STATUS = {
20
+ 402: "insufficient_credits",
21
+ 403: "scope_forbidden",
22
+ 422: "invalid_request",
23
+ 429: "rate_limited",
24
+ 451: "content_blocked",
25
+ }
26
+
27
+
28
+ class GatewayError(RuntimeError):
29
+ """一次失败的网关调用。`code` 给 Agent 分支,`message` 给人看。"""
30
+
31
+ def __init__(self, status: int, code: str, message: str,
32
+ retry_after: int | None = None):
33
+ super().__init__(f"[{status} {code}] {message}")
34
+ self.status = status
35
+ self.code = code
36
+ self.message = message
37
+ self.retry_after = retry_after
38
+
39
+ def as_dict(self) -> dict:
40
+ out = {"ok": False, "status": self.status, "code": self.code, "message": self.message}
41
+ if self.retry_after is not None:
42
+ out["retry_after"] = self.retry_after
43
+ return out
44
+
45
+
46
+ def _error_from(res: httpx.Response) -> GatewayError:
47
+ detail: dict = {}
48
+ plain = "" # FastAPI 的 detail 常是字符串,别再丢进 JSON 文本
49
+ try:
50
+ body = res.json()
51
+ except ValueError: # 非 JSON(网关/边缘 5xx 的 HTML 页)
52
+ body = None
53
+ if isinstance(body, dict):
54
+ d = body.get("detail")
55
+ if isinstance(d, dict):
56
+ detail = d
57
+ else:
58
+ detail = body
59
+ plain = d if isinstance(d, str) else ""
60
+ elif isinstance(body, str):
61
+ plain = body
62
+ code = str(detail.get("reason") or detail.get("code")
63
+ or _REASON_BY_STATUS.get(res.status_code, "upstream_error"))
64
+ msg = str(detail.get("message") or detail.get("error") or plain
65
+ or (res.text or "").strip()[:300] or res.reason_phrase)
66
+ ra = res.headers.get("Retry-After") or ""
67
+ return GatewayError(res.status_code, code, msg, int(ra) if ra.isdigit() else None)
68
+
69
+
70
+ class GatewayClient:
71
+ """一个 Key 一个实例。构造即校验 Key 存在(缺了直接退出,别让工具调用到一半才炸)。"""
72
+
73
+ def __init__(self, base_url: str | None = None, api_key: str | None = None,
74
+ timeout: float = 60.0, transport: httpx.BaseTransport | None = None):
75
+ self.base_url = (base_url or os.getenv("OWNTA_GW_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
76
+ self.api_key = (api_key if api_key is not None else os.getenv("OWNTA_GW_API_KEY", "")).strip()
77
+ if not self.api_key:
78
+ raise SystemExit(
79
+ "缺少 OWNTA_GW_API_KEY(sk-ownta-…)。请写进 MCP 客户端配置的 env,"
80
+ "不要写进仓库。"
81
+ )
82
+ self._timeout = timeout
83
+ self._transport = transport # 单测注入 httpx.MockTransport;生产为 None
84
+
85
+ def _send(self, method: str, path: str, *, payload: Any = None,
86
+ idem: str = "") -> dict:
87
+ headers = {"Accept": "application/json",
88
+ "Authorization": f"Bearer {self.api_key}",
89
+ # 来源标记(2026-11-02 百科读数):读百科的主体有两个 —— 站内的
90
+ # 浏览器和跑在 IDE 里的 MCP 客户端,两者都带 Bearer、在鉴权层同形状,
91
+ # 只能自带身份才能分开。没有这一行,「858 条有没有人用」永远分不清
92
+ # 是自己人在读还是用户在读(评审要的正是可证伪的那一句)。
93
+ # 只用于读数归类,不参与任何权限判定 —— 网关不拿它当凭据。
94
+ "X-Ownta-Via": "mcp"}
95
+ if idem:
96
+ headers["Idempotency-Key"] = idem
97
+ try:
98
+ with httpx.Client(timeout=self._timeout, transport=self._transport) as c:
99
+ res = c.request(method, f"{self.base_url}{path}", json=payload, headers=headers)
100
+ except httpx.HTTPError as e:
101
+ # 连不上/超时是「不知道网关收没收到」,不能当业务失败重试 —— 语义交给调用方,
102
+ # 这里只标记 network_error(status=0),不猜。
103
+ raise GatewayError(0, "network_error", f"{method} {path} 失败:{e}") from e
104
+ if res.status_code >= 400:
105
+ raise _error_from(res)
106
+ return res.json()
107
+
108
+ # ---- 首刀两个端点 ----
109
+ def generate(self, payload: dict, idem: str = "") -> dict:
110
+ """POST /v1/generate(202 → {job_id,status,poll,sse})。"""
111
+ return self._send("POST", "/v1/generate", payload=payload, idem=idem)
112
+
113
+ def job(self, job_id: str) -> dict:
114
+ """GET /v1/jobs/{id}(JobStatus:status/output_urls/credits_charged/error/…)。"""
115
+ return self._send("GET", f"/v1/jobs/{job_id}")
116
+
117
+ # ---- skills 发现(只读,不需要幂等键) ----
118
+ def skills(self) -> dict:
119
+ """GET /v1/skills → {skills:[{id,name,icon,kind,ratio,prompt,batch?,requires?,…}]}。
120
+
121
+ 该端点在网关是公开的(未登录可查),但这里仍带 Bearer —— 没理由为一次
122
+ 只读调用新开一条无鉴权通道,鉴权层的唯一真源留在网关。
123
+ """
124
+ return self._send("GET", "/v1/skills")
125
+
126
+ def skills_mine(self) -> dict:
127
+ """GET /v1/skills/mine → {skills:[…], limit}(只看本人的私有技能)。
128
+
129
+ 与 `skills()` 的差别在**鉴权面**:那个是平台公开模板,这个由网关按 Key
130
+ 解析 uid 过滤,看不到别人的行。guest Key 会 403 `guest_not_allowed` ——
131
+ 那是「访客不存在私有技能」,不是故障。
132
+ """
133
+ return self._send("GET", "/v1/skills/mine")
134
+
135
+ # ---- 百科层(2026-10-31 新增):与 /v1/skills 是两回事 ----
136
+ def skill_library(self, *, domain: str = "", sub: str = "", q: str = "",
137
+ offset: int = 0, limit: int = 30) -> dict:
138
+ """GET /v1/skill-library → {tree, page, errors[, matched_tree]}。
139
+
140
+ 别和 `skills()` 混:那边是**出图模板**(kind 只有 image/video,能直接喂
141
+ generate_image);这边是**百科知识层**(方法论正文,跑不了图)。两个端点
142
+ 都只读,鉴权口径相同。
143
+
144
+ `q` 非空时网关做全字段检索(**含正文**)并多回 `matched_tree`;那个键也是
145
+ 「网关是不是新版」的探针 —— 旧网关会把 q 当未知参数忽略、照回全集第一页,
146
+ 调用方看到缺 `matched_tree` 就该走本地回退。中文必须 quote,否则非法 URL。
147
+
148
+ 网关侧 `paging.bounds` 会把非法 limit/offset 夹回默认值而不抛 500,所以这里
149
+ 不重复校验上界;`page.has_more` 由网关算,客户端不要自己推。
150
+ """
151
+ params = []
152
+ if domain:
153
+ params.append(f"domain={quote(domain, safe='')}")
154
+ if sub:
155
+ params.append(f"sub={quote(sub, safe='')}")
156
+ if q:
157
+ params.append(f"q={quote(str(q), safe='')}")
158
+ params.append(f"offset={int(offset)}")
159
+ params.append(f"limit={int(limit)}")
160
+ return self._send("GET", "/v1/skill-library?" + "&".join(params))
161
+
162
+ def skill_library_body(self, *, path: str, file: str = "", offset: int = 0,
163
+ limit: int = 20000) -> dict:
164
+ """GET /v1/skill-library/body → {path, body, offset, total, has_more[, file][, files]}。
165
+
166
+ `path` 是 `domain/sub/slug`。越界或格式不符网关回 404(`read_body` 的白名单
167
+ 正则挡 `..` 与绝对路径),不会读到库外文件。拼 URL 前仍要 quote:Agent 传进
168
+ 中文或空格时,不编码得到的是非法 URL,报错信息会指向网关而不是参数。
169
+
170
+ `offset` 与 `limit` 都是**字符**数,与回包的 `total` 同口径;`has_more` 是
171
+ 「后面还有」,不是「本次可能被截断」。
172
+
173
+ `file` 非空时改读该条目的附件(`references/**` 下的纯文本),同样支持
174
+ `offset` 续读。**不带 file 时网关会多回 `files` 清单**(有附件才有这个键),
175
+ 那是「正文说的 references/x.md 到底在不在」的唯一可靠依据。
176
+ """
177
+ url = (f"/v1/skill-library/body?path={quote(str(path), safe='')}"
178
+ f"&offset={int(offset)}&limit={int(limit)}")
179
+ if file:
180
+ url += f"&file={quote(str(file), safe='')}"
181
+ return self._send("GET", url)
182
+
183
+ def brand_kits(self) -> dict:
184
+ """GET /v1/brand-kits → {kits:[…], enabled, block_max}(只看本人,永远不公开)。
185
+
186
+ 只给一处用:技能声明 `requires=["brand_kit"]` 时判断「有没有可注入的默认合同」。
187
+ 注入只认 `is_default`(brand.default_kit),所以这里必须拿整张列表而不是总数。
188
+ """
189
+ return self._send("GET", "/v1/brand-kits")
190
+
191
+ # ---- 处理档(P1 交付链):唯一耦合面上的极薄转发 ----
192
+ def op_apply(self, op: str, src: str, params: dict | None = None) -> dict:
193
+ """POST /v1/image/ops/apply(201 → {job_id,op,url,key,content_type,ext,size,credits,…})。
194
+
195
+ 这里**不做任何校验**:op 白名单、params 规格、源图归属(只认自家 CDN 前缀)
196
+ 全在网关 `imageops.validate` / `own_key`,钱也走网关那条 reserve→settle。
197
+ 转发层多写一行,就多一套会漂移的真值。
198
+ 注意回包的 `size` 是**字节数**,不是像素。
199
+ """
200
+ return self._send("POST", "/v1/image/ops/apply",
201
+ payload={"op": str(op or ""), "src": str(src or ""),
202
+ "params": params if isinstance(params, dict) else {}})