Leonardo 的每次生成都会消耗 API 额度。请求体写错(比如宽度不是 8 的倍数、数量超过上限、
"false"写成了字符串)时,最好在本地就发现,而不是等服务器拒绝。 官方 v2 参考页(createGeneration)的 HTML 里嵌着一份完整的 OpenAPI 规范,涵盖 81 种 model 的请求结构。这篇写两个小工具:
fetch_spec.py:取出这份规范并记录 SHA-256;leo_validate.py:按请求中的model找到对应的结构,用 jsonschema 离线校验。
先看清成品(box 上真实运行)
- 抓取规范 v2.0.0,支持 81 个 model 值;两次抓取的 SHA-256 一致(
5ea798d5…f706),抓取时间是 2026-10-21 19:24(UTC+8)。bad.json报出 7 条问题:空提示词、public是字符串、mode写成小写、宽度 1020 不是 8 的倍数、数量 12 超过上限 8。typo.json中的lucid-orign被识别为未知 model,并给出了相近的lucid-origin。pytest 7 项通过。
合规提醒:Leonardo API 需要单独购买 API 额度,和网页版订阅是分开的。API Key 只放在服务端环境变量里,不要放进前端代码,也不要共享账号或 Key。生成内容要遵守 Leonardo 的条款和内容政策:官方会默认拦截 NSFW 提示词,并在结果里用
nsfw字段标记图片。不要尝试绕过审核,也不要生成侵犯他人肖像权、商标或版权的内容。请求体里的public字段决定图片是否出现在社区公开流中,处理敏感素材时设为 false。本文不列价格。
运行说明:这个项目完全离线,不需要 API Key,全部在 box 上真实运行过。规范的版权属于 Leonardo,所以 src 里不包含
openapi-v2.json,需要自己运行fetch_spec.py获取;没有这个文件时,测试会自动跳过。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
fetch_spec.py |
从官方文档页中提取 v2 规范,记录来源、时间和 SHA-256 |
leo_validate.py |
离线校验请求体,有错误时退出码为 1 |
requests/ |
正确、错误、拼写错误三个示例 |
test_validate.py |
7 项测试 |
需要准备哪些环境?
Python 3.9 以上;pip install jsonschema pytest。
关键事实
| 事实 | 来源 |
|---|---|
v2 接口:POST https://cloud.leonardo.ai/api/rest/v2/generations,请求体是 {"model": ..., "parameters": {...}, "public": ...} |
v2 规范中的 servers 和 paths |
v2 响应:顶层的 generationId 和 apiCreditCost |
v2 规范中的 responses |
| 以 lucid-origin 为例:prompt 1~2000 字,width 16~3840 且是 8 的倍数,quantity 1~8,mode 是 FAST 或 ULTRA | v2 规范 |
鉴权方式:Authorization: Bearer <api_key> |
v2 规范中的 securitySchemes |
文件结构
63-leonardo-ai-A-src/
├── fetch_spec.py
├── leo_validate.py
├── requests/{good,bad,typo}.json
└── test_validate.py
第 1 步:抓取规范
"""从 Leonardo 官方文档页(createGeneration 参考页)中取出内嵌的 v2 OpenAPI 规范,保存为 openapi-v2.json。
文档页是 HTML,规范以 JSON 形式嵌在页面里;这里找到含 "servers":[{"url":"https://cloud.leonardo.ai/api/rest/v2" 的那个对象并解析出来。
同时记录抓取时间和 SHA-256,便于以后发现规范是否变化。"""
import datetime, hashlib, html, json, re, sys, urllib.request
URL = "https://docs.leonardo.ai/reference/creategeneration"
MARK = re.compile(r'"servers"\s*:\s*\[\s*\{\s*"url"\s*:\s*"https://cloud\.leonardo\.ai/api/rest/v2"') # 允许空白,页面压缩方式变了也能找到
def extract(page):
s = html.unescape(page)
m = MARK.search(s)
i = m.start() if m else -1
if i < 0:
raise ValueError("页面里没有找到 v2 规范(文档结构可能变了)")
dec = json.JSONDecoder()
j = i
while (j := s.rfind("{", 0, j)) >= 0: # 向前找能完整解析且含 paths 的对象
try:
obj, _ = dec.raw_decode(s[j:])
if isinstance(obj, dict) and "paths" in obj and "openapi" in obj:
return obj
except ValueError:
pass
raise ValueError("找到了标记,但没能解析出完整的规范")
if __name__ == "__main__":
req = urllib.request.Request(URL, headers={"User-Agent": "binbho-tutorial/1.0"})
spec = extract(urllib.request.urlopen(req, timeout=60).read().decode("utf-8", "replace"))
raw = json.dumps(spec, ensure_ascii=False, sort_keys=True, indent=1)
open("openapi-v2.json", "w", encoding="utf-8").write(raw)
meta = {"source": URL, "fetched_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"sha256": hashlib.sha256(raw.encode()).hexdigest(), "version": spec["info"]["version"]}
json.dump(meta, open("openapi-v2.meta.json", "w"), indent=1)
print(meta)
第 2 步:校验器
"""在花费 API 额度之前,用官方 v2 OpenAPI 规范离线校验 POST /generations 的请求体。
- 先按请求里的 model 找到对应的请求模式(81 种之一)
- 再用 jsonschema 校验:必填项、取值范围、multipleOf 8、枚举值等
- 规范是 OpenAPI 3.0 的写法;nullable 字段先转成 JSON Schema 的写法再校验"""
import json, sys
import jsonschema
def load(path="openapi-v2.json"):
spec = json.load(open(path, encoding="utf-8"))
op = spec["paths"]["/generations"]["post"]["requestBody"]["content"]["application/json"]["schema"]
schemas = spec["components"]["schemas"]
by_model = {}
for ref in op.get("oneOf") or op.get("anyOf"):
name = ref["$ref"].rsplit("/", 1)[-1]
for m in schemas[name]["properties"]["model"].get("enum", []):
by_model[m] = name
return spec, by_model
def oas_to_jsonschema(node):
"""OpenAPI 3.0 的 nullable: true → JSON Schema 的 type: [..., "null"]。"""
if isinstance(node, dict):
node = {k: oas_to_jsonschema(v) for k, v in node.items()}
if node.pop("nullable", False) and isinstance(node.get("type"), str):
node["type"] = [node["type"], "null"]
return node
if isinstance(node, list):
return [oas_to_jsonschema(x) for x in node]
return node
def validate(spec, by_model, body):
m = body.get("model")
if m not in by_model:
near = sorted(x for x in by_model if m and m.split("-")[0] in x)[:6]
return [f"未知的 model {m!r};相近的有 {near}"]
schema = oas_to_jsonschema(spec["components"]["schemas"][by_model[m]])
schema = {**schema, "components": oas_to_jsonschema(spec["components"])} # 让 #/components/... 引用能解析
v = jsonschema.Draft7Validator(schema)
return [f"{'/'.join(map(str, e.absolute_path)) or '(根)'}: {e.message}" for e in sorted(v.iter_errors(body), key=str)]
if __name__ == "__main__":
spec, by_model = load()
print(f"规范 {spec['info']['version']}:支持 {len(by_model)} 个 model 值")
bad = 0
for f in sys.argv[1:]:
errs = validate(spec, by_model, json.load(open(f, encoding="utf-8")))
bad += bool(errs)
print(("✓ " if not errs else "✗ ") + f)
for e in errs:
print(" -", e)
sys.exit(1 if bad else 0)
第 3 步:示例请求
{"model": "lucid-origin", "parameters": {"prompt": "一只在窗台上晒太阳的橘猫,水彩风格", "width": 1024, "height": 768, "quantity": 2, "mode": "FAST"}, "public": false}
{"model": "lucid-origin", "parameters": {"prompt": "", "width": 1020, "height": 768, "quantity": 12, "mode": "fast"}, "public": "false"}
第 4 步:测试
import json, pathlib
import pytest
import fetch_spec, leo_validate as V
if not pathlib.Path("openapi-v2.json").exists():
pytest.skip("先运行 python fetch_spec.py 下载规范", allow_module_level=True)
SPEC, BY_MODEL = V.load()
def body(name):
return json.load(open(f"requests/{name}.json", encoding="utf-8"))
def test_model_index():
assert len(BY_MODEL) >= 80 and BY_MODEL["lucid-origin"] == "LucidOriginGenerationRequest"
def test_good():
assert V.validate(SPEC, BY_MODEL, body("good")) == []
def test_bad_lists_every_problem():
errs = "\n".join(V.validate(SPEC, BY_MODEL, body("bad")))
for s in ["prompt: '' should be non-empty", "1020 is not a multiple of 8", "12 is greater than the maximum of 8",
"'fast' is not one of ['FAST', 'ULTRA']", "'false' is not of type 'boolean'"]:
assert s in errs
def test_1000_is_fine(): # 调试示例 1:1000 = 8 × 125
b = body("good"); b["parameters"]["width"] = 1000
assert V.validate(SPEC, BY_MODEL, b) == []
def test_typo_suggests():
assert "lucid-origin" in V.validate(SPEC, BY_MODEL, body("typo"))[0]
def test_nullable_conversion():
assert V.oas_to_jsonschema({"type": "integer", "nullable": True}) == {"type": ["integer", "null"]}
def test_extract_from_html():
spec = {"openapi": "3.0.0", "servers": [{"url": "https://cloud.leonardo.ai/api/rest/v2"}], "paths": {}}
page = "<html><script>var x = " + json.dumps(spec).replace('"', """) + ";</script></html>"
assert fetch_spec.extract(page)["openapi"] == "3.0.0"
with pytest.raises(ValueError, match="没有找到"):
fetch_spec.extract("<html></html>")
第 5 步:运行
$ python fetch_spec.py
{'source': 'https://docs.leonardo.ai/reference/creategeneration', 'fetched_at': '2026-10-21T11:24:26+00:00', 'sha256': '5ea798d5daa75e4d135a806ba373fa6d6a7618cde830b14ee12e046b46d8f706', 'version': 'v2.0.0'}
$ python leo_validate.py requests/*.json
规范 v2.0.0:支持 81 个 model 值
✗ requests/bad.json
- parameters/prompt: '' should be non-empty
- public: 'false' is not of type 'boolean'
- parameters/mode: 'fast' does not match '^[A-Z][A-Z0-9_]*[A-Z0-9]$'
- parameters/mode: 'fast' is not one of ['FAST', 'ULTRA']
- parameters/mode: 'fast' is not valid under any of the given schemas
- parameters/width: 1020 is not a multiple of 8
- parameters/quantity: 12 is greater than the maximum of 8
✓ requests/good.json
✗ requests/typo.json
- 未知的 model 'lucid-orign';相近的有 ['lucid-origin', 'lucid-realism']
$ python -m pytest -q
7 passed
(fetched_at 是 UTC 时间,对应 2026-10-21 19:24,UTC+8。)
调试示例
1. 我以为 1000 不是 8 的倍数。 反例最初写的是 "width": 1000,我预期它会触发 multipleOf 8 的错误,但校验器没有报。算一下就明白了:1000 = 8 × 125。错的是我的反例,不是校验器。我把反例改成 1020,并加了一条 test_1000_is_fine 测试。
2. 页面格式稍有变化,提取就会失败。 单元测试用 json.dumps 构造假页面时,生成的 JSON 在冒号后面带空格,结果提取函数报“页面里没有找到 v2 规范”,因为我是用固定字符串匹配 "servers":[{"url":...。我改成允许任意空白的正则表达式。改完后重新抓取真实页面,结果和之前一致(SHA-256 相同)。
3. 文档链接失效了。 webhook 指南中“Get a Single Generation”的链接(/reference/getgenerationbyid)现在返回 404,新版参考文档只有 v2 的 createGeneration;旧的 v1 参考页在 /v1.0/reference/getgenerationbyid。所以本文以规范本身为准,并把来源和 SHA-256 记录下来,便于以后发现规范是否变化。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 校验通过,但服务器仍然返回 400 | 内容审核、额度或账号状态等问题,规范里查不出来 | 查看错误信息说明页 |
mode: 'fast' 报错 |
枚举值是大写 | 改成 FAST |
"false" 报错 |
布尔值写成了字符串 | 改成 false |
| 规范里的 SHA-256 变了 | 官方更新了规范 | 重新运行校验,再看哪些字段变了 |
模板规则
- 先在本地校验,再花额度。
- 记录规范的来源和 SHA-256。
- 不在仓库里分发官方规范文件。
- 默认设置
public: false。
项目资源
| 用途 | 地址 |
|---|---|
| Leonardo API 快速入门 | https://docs.leonardo.ai/docs/getting-started |
| API FAQ | https://docs.leonardo.ai/docs/api-faq |
| 错误信息说明 | https://docs.leonardo.ai/docs/api-error-messages |
| v2 createGeneration 参考 | https://docs.leonardo.ai/reference/creategeneration |
在本地把请求校验一遍,就不会为写错的参数浪费额度。