扣子可以把你自己的 HTTP 服务导入为插件,方式是提供一份 OpenAPI 文档。导入失败时,提示往往不够具体。 这篇写一个小服务(文本统计、长度单位换算)和它的
openapi.yaml,再写一个检查器,分两层:
- 第 1 层:通用 OpenAPI 校验;
- 第 2 层:逐条照搬开源 Coze Studio v0.5.1 源码
openapi.go中Validate()的额外规则,比如只能有一个 server、必须有 summary、响应只能是 200 + JSON 对象、path 参数不能是数组。
先看清成品(box 上真实运行)
openapi.yaml通过检查;反例bad.yaml报出 6 条错误。- Coze Studio 自带的 18 个官方插件文档全部通过检查(用来反向验证规则移植得对不对)。
- 服务的实际响应用 jsonschema 对照文档中的 schema 校验通过。pytest 6 项通过。
合规提醒:扣子中国站(coze.cn)和国际站(coze.com)是两套独立的账号体系,个人访问令牌(PAT)也不能互用。请使用你所在地区对应的站点,不要用代理绕过地区限制,不要共享账号或 Token。Token 只放在环境变量里,不要写进代码或仓库。发给智能体和工作流的数据会由字节跳动的服务处理,涉及个人信息或公司数据时,先确认合规要求。本文不列价格和额度,以官网为准。
运行说明:box 上没有扣子账号,没有把插件真正导入 coze.cn,也没有把服务部署到公网。检查规则来自开源版源码,扣子云端的实现没有公开,可能有出入,最终以导入时的提示为准。 自部署 Coze Studio 也因为 minio 镜像拉不到而没有成功(上游 issue #2775)。服务、检查器和测试都在 box 上真实运行过。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
plugin_server.py |
标准库服务,用 X-Api-Key 鉴权(对应扣子插件的 API Key 授权方式) |
openapi.yaml |
可以导入的 OpenAPI 3.0.1 文档 |
coze_plugin_lint.py |
两层检查器 |
examples/bad.yaml |
反例 |
test_plugin.py |
6 项测试 |
需要准备哪些环境?
Python 3.9 以上;pip install pyyaml jsonschema openapi-spec-validator pytest(box 上 openapi-spec-validator 的版本是 0.9.0)。如果要测试官方插件,还需要 git clone --branch v0.5.1 https://github.com/coze-dev/coze-studio /tmp/coze-studio。
Coze Studio v0.5.1 的插件文档规则(源码原文)
| 规则 | 报错原文 |
|---|---|
info.title 和 info.description 必填 |
the description of info is required |
| servers 有且只有 1 个,必须有 host,长度不超过 512 | server is required and only one server is allowed |
| 每个操作必须有 operationId 和 summary | summary is required |
| 请求体只能有 1 种类型:json、problem+json、form-urlencoded、x-yaml、yaml,并且必须是 object | invalid media type... |
| 参数不能放在 body 里,不能是 object;path 参数不能是 array | the type of parameter 'ids' cannot be 'array' |
响应只能是 200(可以再加一个 default),类型是 application/json,内容是 object |
response only supports '200' status |
文件结构
59-coze-C-src/
├── plugin_server.py
├── openapi.yaml
├── coze_plugin_lint.py
├── examples/bad.yaml
└── test_plugin.py
第 1 步:服务
"""一个可以做成扣子“基于已有服务创建插件”的小服务(标准库,教学用)。
POST /v1/text/stats {"text": "..."} -> 字符数 / 汉字数 / 行数
GET /v1/convert/length?value=1&from=m&to=cm -> 长度单位换算
请求头 X-Api-Key 校验(在扣子里对应“服务 Token / API Key”授权,密钥放环境变量)。"""
import json, os, re, sys, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
KEY = os.environ.get("PLUGIN_API_KEY", "dev-key")
UNITS = {"mm": 0.001, "cm": 0.01, "m": 1.0, "km": 1000.0, "in": 0.0254, "ft": 0.3048}
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _json(self, code, obj):
raw = json.dumps(obj, ensure_ascii=False).encode()
self.send_response(code); self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
def _auth(self):
if self.headers.get("X-Api-Key") != KEY:
self._json(401, {"code": 401, "msg": "invalid api key"}); return False
return True
def do_POST(self):
if not self._auth():
return
if urllib.parse.urlsplit(self.path).path != "/v1/text/stats":
return self._json(404, {"code": 404, "msg": "not found"})
try:
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
text = body["text"]
assert isinstance(text, str)
except Exception:
return self._json(400, {"code": 400, "msg": "text (string) is required"})
if len(text) > 100_000:
return self._json(413, {"code": 413, "msg": "text too long (max 100000)"})
self._json(200, {"code": 0, "msg": "ok", "data": {
"chars": len(text), "han": len(re.findall(r"[\u4e00-\u9fff]", text)),
"lines": text.count("\n") + 1 if text else 0}})
def do_GET(self):
if not self._auth():
return
u = urllib.parse.urlsplit(self.path)
if u.path != "/v1/convert/length":
return self._json(404, {"code": 404, "msg": "not found"})
q = dict(urllib.parse.parse_qsl(u.query))
try:
v, f, t = float(q["value"]), q["from"], q["to"]
r = v * UNITS[f] / UNITS[t]
except (KeyError, ValueError):
return self._json(400, {"code": 400, "msg": f"need value/from/to; units: {','.join(UNITS)}"})
self._json(200, {"code": 0, "msg": "ok", "data": {"result": round(r, 6), "unit": t}})
def make(port=0):
return ThreadingHTTPServer(("127.0.0.1", port), H)
if __name__ == "__main__":
s = make(int(sys.argv[1]) if len(sys.argv) > 1 else 8787)
print(f"listening on http://127.0.0.1:{s.server_address[1]}"); s.serve_forever()
第 2 步:OpenAPI 文档
openapi: 3.0.1
info:
title: 文本小工具
description: 统计文本的字符数、汉字数、行数,以及常用长度单位换算。
version: v1
servers:
- url: https://plugin.example.com
paths:
/v1/text/stats:
post:
operationId: text_stats
summary: 统计一段文本的字符数、汉字数和行数
requestBody:
content:
application/json:
schema:
type: object
required: [text]
properties:
text:
type: string
description: 要统计的文本,最多 100000 字符
responses:
"200":
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/StatsResp"
/v1/convert/length:
get:
operationId: convert_length
summary: 长度单位换算(mm/cm/m/km/in/ft)
parameters:
- {name: value, in: query, required: true, description: 数值, schema: {type: number}}
- {name: from, in: query, required: true, description: 原单位, schema: {type: string, enum: [mm, cm, m, km, in, ft]}}
- {name: to, in: query, required: true, description: 目标单位, schema: {type: string, enum: [mm, cm, m, km, in, ft]}}
responses:
"200":
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/ConvertResp"
components:
schemas:
StatsResp:
type: object
required: [code, msg, data]
properties:
code: {type: integer}
msg: {type: string}
data:
type: object
required: [chars, han, lines]
properties:
chars: {type: integer, description: 字符数}
han: {type: integer, description: 汉字数}
lines: {type: integer, description: 行数}
ConvertResp:
type: object
required: [code, msg, data]
properties:
code: {type: integer}
msg: {type: string}
data:
type: object
required: [result, unit]
properties:
result: {type: number}
unit: {type: string}
反例:
openapi: 3.0.1
info: {title: 坏例子, version: v1}
servers:
- url: https://a.example.com
- url: https://b.example.com
paths:
/items/{ids}:
post:
operationId: add_items
parameters:
- {name: ids, in: path, required: true, schema: {type: array, items: {type: string}}}
requestBody:
content:
text/plain:
schema: {type: string}
responses:
"201":
description: created
content:
application/json:
schema: {type: object}
第 3 步:检查器
"""检查 OpenAPI 文档能否被扣子插件导入。
第 1 层:openapi-spec-validator(通用 OpenAPI 3.0 校验)
第 2 层:照搬 coze-studio v0.5.1 backend/crossdomain/plugin/model/openapi.go 中 Validate() 的额外规则。
注意:这是开源版的规则,扣子云端(coze.cn/coze.com)的实现未公开,可能有出入。"""
import sys, urllib.parse
import yaml
from openapi_spec_validator import validate
from openapi_spec_validator.readers import read_from_filename
REQ_TYPES = ["application/json", "application/problem+json", "application/x-www-form-urlencoded",
"application/x-yaml", "application/yaml"]
METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
def deref(doc, node):
while isinstance(node, dict) and "$ref" in node:
cur = doc
for part in node["$ref"].lstrip("#/").split("/"):
cur = cur[part]
node = cur
return node
def check_op(doc, where, op):
errs = []
E = lambda m: errs.append(f"{where}: {m}")
if not op.get("operationId"):
E("operationID is required")
if not op.get("summary"):
E("summary is required")
rb = op.get("requestBody")
if rb is not None:
content = (deref(doc, rb) or {}).get("content") or {}
if not content:
E("request body is required")
elif len(content) != 1:
E("request body only supports one media type")
else:
mt = next((content[c] for c in REQ_TYPES if c in content), None)
if mt is None:
E(f"invalid media type, request body only the following types: [{', '.join(REQ_TYPES)}]")
elif not mt.get("schema"):
E("request body schema is required")
elif deref(doc, mt["schema"]).get("type") != "object":
E("request body only supports 'object' type")
for p in op.get("parameters") or []:
p = deref(doc, p)
sc = deref(doc, p.get("schema")) if p.get("schema") else None
if not sc:
E("parameter schema is required"); continue
t = sc.get("type", "")
if p.get("in") == "body":
E(f"the location of parameter '{p.get('name')}' cannot be 'body'")
if not t:
E(f"the type of parameter '{p.get('name')}' is required")
if t == "object":
E(f"the type of parameter '{p.get('name')}' cannot be 'object'")
if p.get("in") == "path" and t == "array":
E(f"the type of parameter '{p.get('name')}' cannot be 'array'")
resps = op.get("responses") or {}
if resps:
keys = set(map(str, resps))
if not (keys == {"200"} or keys == {"200", "default"}):
E("response only supports '200' status")
else:
r = deref(doc, resps.get("200", resps.get(200)))
content = r.get("content") or {}
if len(content) != 1 or "application/json" not in content:
E("response only supports 'application/json' media type")
elif not content["application/json"].get("schema"):
E("the media type schema of response is required")
elif deref(doc, content["application/json"]["schema"]).get("type") != "object":
E("response body only supports 'object' type")
return errs
def lint(path):
try:
spec, _ = read_from_filename(path)
validate(spec)
except Exception as e:
msg = (str(e).strip().splitlines() or [type(e).__name__])[0] # 有的异常 str() 为空
return [f"OpenAPI 校验失败:{msg}"]
doc = yaml.safe_load(open(path, encoding="utf-8"))
errs = []
info = doc.get("info") or {}
if not info.get("title"):
errs.append("the title of info is required")
if not info.get("description"):
errs.append("the description of info is required")
servers = doc.get("servers") or []
if len(servers) != 1:
errs.append("server is required and only one server is allowed")
else:
u = servers[0].get("url", "")
if not urllib.parse.urlsplit(u).hostname:
errs.append(f"invalid server url '{u}'")
if len(u) > 512:
errs.append(f"server url '{u}' is too long")
for p, item in (doc.get("paths") or {}).items():
for m, op in item.items():
if m in METHODS:
errs += check_op(doc, f"{m.upper()} {p}", op)
return errs
if __name__ == "__main__":
bad = 0
for f in sys.argv[1:]:
errs = lint(f)
bad += bool(errs)
print(f"{'✗' if errs else '✓'} {f}")
for e in errs:
print(f" - {e}")
sys.exit(1 if bad else 0)
第 4 步:测试
import glob, json, threading, urllib.error, urllib.request
import jsonschema, pytest, yaml
import coze_plugin_lint as L, plugin_server
SPEC = yaml.safe_load(open("openapi.yaml", encoding="utf-8"))
@pytest.fixture(scope="module")
def base():
s = plugin_server.make(); threading.Thread(target=s.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{s.server_address[1]}"; s.shutdown()
def call(base, path, body=None, key="dev-key"):
req = urllib.request.Request(base + path, data=json.dumps(body).encode() if body is not None else None,
headers={"X-Api-Key": key, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, json.load(e)
def schema_for(path, method):
s = SPEC["paths"][path][method]["responses"]["200"]["content"]["application/json"]["schema"]
return {**s, "components": SPEC["components"]}
def test_spec_passes_lint():
assert L.lint("openapi.yaml") == []
def test_bad_example_errors():
errs = "\n".join(L.lint("examples/bad.yaml"))
for m in ["description of info", "only one server", "summary is required", "invalid media type",
"cannot be 'array'", "only supports '200'"]:
assert m in errs
def test_official_plugins_pass():
files = [f for f in glob.glob("/tmp/coze-studio/backend/conf/plugin/pluginproduct/*.yaml")
if not f.endswith("plugin_meta.yaml")]
if not files:
pytest.skip("未克隆 coze-studio")
assert all(L.lint(f) == [] for f in files), [f for f in files if L.lint(f)]
def test_stats_matches_schema(base):
code, r = call(base, "/v1/text/stats", {"text": "你好 world\n第二行"})
assert code == 200 and r["data"] == {"chars": 12, "han": 5, "lines": 2}
jsonschema.validate(r, schema_for("/v1/text/stats", "post"))
def test_convert_matches_schema(base):
code, r = call(base, "/v1/convert/length?value=3&from=ft&to=m")
assert code == 200 and r["data"]["result"] == 0.9144
jsonschema.validate(r, schema_for("/v1/convert/length", "get"))
def test_errors(base):
assert call(base, "/v1/text/stats", {"text": 1})[0] == 400
assert call(base, "/v1/convert/length?value=1&from=m&to=li")[0] == 400
assert call(base, "/v1/text/stats", {"text": "x"}, key="nope")[0] == 401
第 5 步:运行
$ python coze_plugin_lint.py openapi.yaml examples/bad.yaml
✓ openapi.yaml
✗ examples/bad.yaml
- the description of info is required
- server is required and only one server is allowed
- POST /items/{ids}: summary is required
- POST /items/{ids}: invalid media type, request body only the following types: [application/json, application/problem+json, application/x-www-form-urlencoded, application/x-yaml, application/yaml]
- POST /items/{ids}: the type of parameter 'ids' cannot be 'array'
- POST /items/{ids}: response only supports '200' status
$ python -m pytest -q
6 passed
要在扣子中导入,需要先把服务部署到一个公网 HTTPS 地址,并把 servers[0].url 改成这个地址,然后在扣子里选择基于已有服务创建插件,导入 openapi.yaml,授权方式选 API Key(Header X-Api-Key)。这一步我没有执行。
调试示例
1. 检查器自己崩溃了。 我第一次用检查器跑官方插件目录时,得到的不是检查结果,而是 IndexError: list index out of range。原因是 openapi-spec-validator 抛出的 ValidatorDetectError 的 str() 是空字符串,而我写的是 str(e).splitlines()[0]。改成在消息为空时使用异常类名之后,报错正常显示为 OpenAPI 校验失败:ValidatorDetectError。
2. 有一个“官方插件”没有通过检查。 修好检查器之后,只有 plugin_meta.yaml 不通过,原因是它没有 openapi: 版本字段。看过文件内容后发现,它是插件的索引清单,本身不是 OpenAPI 文档。另外 5 个飞书插件的 openapi: 字段不在文件开头,但能正常识别。所以测试中排除了 plugin_meta.yaml,其余 18 个全部通过,这也说明移植的规则没有误伤官方文档。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 导入时提示只能有一个 server | 写了测试和生产两个 server | 每个环境单独导出一份文档 |
| 返回 201 的接口导入失败 | 只支持 200 | 改文档,必要时也改服务 |
| 列表参数放在 path 里 | path 参数不能是数组 | 改成 query 参数,或者放进请求体 |
| 插件能导入,但调用失败 | 服务地址不是公网 HTTPS,或者 Key 填错 | 部署后检查地址和 Key |
模板规则
- 每个操作都写 operationId 和中文 summary(模型靠它来选工具)。
- 响应统一使用 200 + JSON 对象。
- 用测试保证服务的实际响应符合文档。
- 密钥只通过扣子的授权配置传入。
项目资源
| 用途 | 地址 |
|---|---|
| 扣子开发者文档(中国站) | https://www.coze.cn/open/docs/developer_guides |
| 官方 Python SDK cozepy | https://github.com/coze-dev/coze-py |
| Coze Studio 开源版 | https://github.com/coze-dev/coze-studio |
| 插件文档校验源码 openapi.go(v0.5.1) | https://github.com/coze-dev/coze-studio/blob/v0.5.1/backend/crossdomain/plugin/model/openapi.go |
| 官方插件示例目录 | https://github.com/coze-dev/coze-studio/tree/v0.5.1/backend/conf/plugin/pluginproduct |
| minio 镜像拉取问题 #2775 | https://github.com/coze-dev/coze-studio/issues/2775 |
| openapi-spec-validator | https://github.com/python-openapi/openapi-spec-validator |
| OpenAPI 3.0.3 规范 | https://spec.openapis.org/oas/v3.0.3.html |
插件文档写对了,导入时就不用反复试错,模型也更容易选对工具。