设计实践

Leonardo AI:Leonardo 的每次生成都会消耗 API 额度

Leonardo 的每次生成都会消耗 API 额度。请求体写错(比如宽度不是 8 的倍数、数量超过上限、"false" 写成了字符串)时,最好在本地就发现,而不是等服务器拒绝。 官方 v2 参考页(createGeneration)的 HTML 里嵌着一份完整的 OpenAPI 规范,涵盖 81 种 model 的请求结构。这篇写两个小工具: - fetch

BINBHO · 图文教程

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('"', "&quot;") + ";</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

在本地把请求校验一遍,就不会为写错的参数浪费额度。