一次完整的 Leonardo 生成流程:创建任务 → 轮询状态 → 下载图片 → 写清单。 几个要点:
- 轮询间隔逐渐加长,并设置总超时;
- 只有
COMPLETE状态才读取图片(官方 FAQ:PENDING时generated_images是空的);nsfw: true的图片不下载;- 每张图片记录 sha256。 另外,v1 和 v2 的创建接口返回的结构不一样,代码对两种都做了兼容。
先看清成品
- 本地替身上:轮询 3 次后完成,等待间隔是 2 秒和 3 秒;下载 1 张图片,另一张因为 nsfw 被跳过;清单里记录了提示词和 sha256。失败状态、超时、内容审核拦截(400)、鉴权失败(401)都有测试。pytest 5 项通过。
- 用无效 Key 请求真实接口:
✗ HTTP 401 [access-denied] Authentication hook unauthorized this request,退出码为 1。
合规提醒:Leonardo API 需要单独购买 API 额度,和网页版订阅是分开的。API Key 只放在服务端环境变量里,不要放进前端代码,也不要共享账号或 Key。生成内容要遵守 Leonardo 的条款和内容政策:官方会默认拦截 NSFW 提示词,并在结果里用
nsfw字段标记图片。不要尝试绕过审核,也不要生成侵犯他人肖像权、商标或版权的内容。请求体里的public字段决定图片是否出现在社区公开流中,处理敏感素材时设为 false。本文不列价格。
运行说明:box 上没有 Leonardo API Key 和 API 额度,所以没有真正生成图片。真实运行过的部分是:用无效 Key 请求 v1 和 v2 接口,都返回 HTTP 401
{{"error":"Authentication hook unauthorized this request","path":"$","code":"access-denied"}}。成功路径用的是我自己写的本地替身;替身的格式依据的是官方文档中的示例(FAQ、NSFW 指南、webhook 指南、v2 OpenAPI 规范)。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
leo_run.py |
创建、轮询、下载、写清单 |
request.json |
v1 快速入门中的请求体(尺寸和数量我改小了) |
fake_leo.py |
本地替身 |
test_leo_run.py |
5 项测试 |
需要准备哪些环境?
Python 3.9 以上(只用标准库);测试需要 pip install pytest。真实运行需要 LEONARDO_API_KEY 和 API 额度。
关键事实
| 事实 | 来源 |
|---|---|
v1 创建:POST https://cloud.leonardo.ai/api/rest/v1/generations |
快速入门 |
查询:GET .../v1/generations/{id},结果在 generations_by_pk 中;状态有 PENDING、COMPLETE、FAILED |
FAQ、错误信息说明 |
图片:generated_images[].{id,url,nsfw} |
NSFW 指南 |
内容审核拦截:400 {"error":"content moderation filter: nude","path":"$","code":"unexpected"} |
NSFW 指南 |
| 通过 API 生成的图片不会过期 | FAQ |
文件结构
63-leonardo-ai-B-src/
├── leo_run.py
├── request.json
├── fake_leo.py
└── test_leo_run.py
第 1 步:主流程
"""Leonardo 生成任务:创建 → 轮询 → 下载 → 写清单。
- 创建:POST {BASE}/generations(v1 快速入门的请求体),响应里取 generationId
(v1 的响应是 {"sdGenerationJob": {"generationId": ...}},v2 参考页的响应是顶层 {"generationId": ...},两种都兼容)
- 轮询:GET {BASE}/generations/{id},看 generations_by_pk.status:PENDING 继续等,COMPLETE 或 FAILED 结束
(官方 FAQ:PENDING 时 generated_images 为空)。轮询间隔逐渐加长,并设总超时
- 下载:nsfw 为 true 的图片不下载,记入清单;每张图记录 sha256
- 官方更推荐用 webhook 代替轮询,见 63C"""
import hashlib, json, os, pathlib, sys, time, urllib.error, urllib.request
BASE = os.environ.get("LEONARDO_BASE", "https://cloud.leonardo.ai/api/rest/v1")
UA = "binbho-tutorial/1.0"
class LeoError(RuntimeError):
pass
def _req(method, url, key, body=None, timeout=60):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"authorization": f"Bearer {key}", "accept": "application/json", "content-type": "application/json", "User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
except urllib.error.HTTPError as e:
try:
err = json.loads(e.read())
except ValueError:
err = {}
raise LeoError(f"HTTP {e.code} [{err.get('code')}] {err.get('error')}") from None
def create(key, body, base=BASE):
r = _req("POST", f"{base}/generations", key, body)
gid = r.get("generationId") or (r.get("sdGenerationJob") or {}).get("generationId")
if not gid:
raise LeoError(f"响应里没有 generationId:{str(r)[:200]}")
return gid
def wait(key, gid, base=BASE, timeout=300, sleep=time.sleep, clock=time.monotonic):
start, delay = clock(), 2.0
while True:
g = _req("GET", f"{base}/generations/{gid}", key)["generations_by_pk"]
if g["status"] == "COMPLETE":
return g
if g["status"] == "FAILED":
raise LeoError(f"生成失败:{gid}")
if clock() - start + delay > timeout:
raise LeoError(f"等待超时({timeout}s),任务仍为 {g['status']},可稍后用 id {gid} 再查")
sleep(delay); delay = min(delay * 1.5, 15)
def download(g, outdir, opener=urllib.request.urlopen):
out = pathlib.Path(outdir); out.mkdir(parents=True, exist_ok=True)
manifest = {"generation_id": g["id"], "prompt": g.get("prompt"), "images": []}
for i, img in enumerate(g["generated_images"]):
item = {"id": img["id"], "url": img["url"], "nsfw": img.get("nsfw", False)}
if item["nsfw"]:
item["skipped"] = "nsfw 标记为 true,未下载"
else:
ext = pathlib.Path(img["url"].split("?")[0]).suffix or ".jpg"
p = out / f"{g['id']}_{i}{ext}"
with opener(urllib.request.Request(img["url"], headers={"User-Agent": UA}), timeout=60) as r:
data = r.read()
p.write_bytes(data)
item.update(file=p.name, sha256=hashlib.sha256(data).hexdigest(), bytes=len(data))
manifest["images"].append(item)
(out / f"{g['id']}.manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1), encoding="utf-8")
return manifest
if __name__ == "__main__":
key = os.environ["LEONARDO_API_KEY"]
body = json.load(open(sys.argv[1], encoding="utf-8"))
try:
gid = create(key, body); print("任务已创建:", gid)
m = download(wait(key, gid), sys.argv[2] if len(sys.argv) > 2 else "out")
print(f"下载 {sum('file' in x for x in m['images'])} 张,跳过 {sum('skipped' in x for x in m['images'])} 张")
except LeoError as e:
print("✗", e); sys.exit(1)
第 2 步:替身和测试
"""本地替身(我自己写的,不是 Leonardo 官方程序)。格式依据:
- 401 的错误体照搬真实接口:{"error":"Authentication hook unauthorized this request","path":"$","code":"access-denied"}
- 内容审核拦截(400)照搬官方 NSFW 指南:{"error":"content moderation filter: nude","path":"$","code":"unexpected"}
- generations_by_pk / status / generated_images[].nsfw 依据官方 FAQ 和 NSFW 指南"""
import json, threading, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
STATE = {"polls_until_done": 2, "polls": {}, "final": "COMPLETE", "nsfw_second": True, "v2_shape": False}
PNG = bytes.fromhex("89504e470d0a1a0a0000000d4948445200000001000000010806000000"
"1f15c4890000000d49444154789c6360f8cfc000000301010018dd8db40000000049454e44ae426082")
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _j(self, code, obj):
raw = json.dumps(obj).encode()
self.send_response(code); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(raw)
def _auth(self):
if self.headers.get("authorization") != "Bearer good":
self._j(401, {"error": "Authentication hook unauthorized this request", "path": "$", "code": "access-denied"}); return False
return True
def do_POST(self):
if not self._auth():
return
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if "nude" in body.get("prompt", ""):
return self._j(400, {"error": "content moderation filter: nude", "path": "$", "code": "unexpected"})
gid = "11111111-2222-4333-8444-555555555555"
STATE["polls"][gid] = 0; STATE["prompt"] = body.get("prompt")
self._j(200, {"generationId": gid, "apiCreditCost": 1} if STATE["v2_shape"] else {"sdGenerationJob": {"generationId": gid, "apiCreditCost": 1}})
def do_GET(self):
path = urllib.parse.urlsplit(self.path).path
if path.startswith("/img/"):
self.send_response(200); self.send_header("Content-Type", "image/png"); self.end_headers(); return self.wfile.write(PNG)
if not self._auth():
return
gid = path.rsplit("/", 1)[-1]
STATE["polls"][gid] += 1
done = STATE["polls"][gid] > STATE["polls_until_done"]
status = STATE["final"] if done else "PENDING"
host = f"http://{self.headers['Host']}"
imgs = [{"id": f"img{i}", "url": f"{host}/img/Leonardo_{i}.png", "nsfw": STATE["nsfw_second"] and i == 1, "likeCount": 0,
"generated_image_variation_generics": []} for i in range(2)] if status == "COMPLETE" else []
self._j(200, {"generations_by_pk": {"id": gid, "status": status, "prompt": STATE.get("prompt"), "generated_images": imgs}})
def start():
s = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=s.serve_forever, daemon=True).start()
return s, f"http://127.0.0.1:{s.server_address[1]}"
import json
import pytest
import fake_leo as F, leo_run as L
@pytest.fixture
def base():
F.STATE.update(polls_until_done=2, polls={}, final="COMPLETE", nsfw_second=True, v2_shape=False)
s, u = F.start(); yield u; s.shutdown()
BODY = json.load(open("request.json"))
def test_full_flow(base, tmp_path):
waits = []
gid = L.create("good", BODY, base)
g = L.wait("good", gid, base, sleep=waits.append)
m = L.download(g, tmp_path)
assert waits == [2.0, 3.0] and F.STATE["polls"][gid] == 3
assert m["images"][0]["file"].endswith("_0.png") and len(m["images"][0]["sha256"]) == 64
assert m["images"][1]["skipped"].startswith("nsfw") and "file" not in m["images"][1]
assert json.load(open(tmp_path / f"{gid}.manifest.json"))["prompt"] == BODY["prompt"]
def test_v2_response_shape(base):
F.STATE["v2_shape"] = True
assert L.create("good", BODY, base) == "11111111-2222-4333-8444-555555555555"
def test_failed(base):
F.STATE["final"] = "FAILED"
with pytest.raises(L.LeoError, match="生成失败"):
L.wait("good", L.create("good", BODY, base), base, sleep=lambda s: None)
def test_timeout(base):
F.STATE["polls_until_done"] = 99
t = iter(range(0, 1000, 10))
with pytest.raises(L.LeoError, match="等待超时"):
L.wait("good", L.create("good", BODY, base), base, timeout=30, sleep=lambda s: None, clock=lambda: next(t))
def test_moderation_and_auth(base):
with pytest.raises(L.LeoError, match=r"HTTP 400 \[unexpected\] content moderation filter"):
L.create("good", {**BODY, "prompt": "nude"}, base)
with pytest.raises(L.LeoError, match=r"HTTP 401 \[access-denied\]"):
L.create("bad", BODY, base)
第 3 步:运行
$ LEONARDO_API_KEY=invalid-key python leo_run.py request.json
✗ HTTP 401 [access-denied] Authentication hook unauthorized this request
$ python -m pytest -q
5 passed
调试示例
1. v1 和 v2 返回的 generationId 位置不同。 快速入门(v1)只说“返回一个 generationId”。从官方错误示例中的路径 $.selectionSet.sdGenerationJob... 可以看出,v1 的结果包在 sdGenerationJob 里;而 63A 抓取到的 v2 规范中,generationId 在响应的顶层。如果只按其中一种写法取值,换接口版本后就会拿到 None。create() 两种都会尝试,取不到时直接报错,test_v2_response_shape 覆盖了 v2 的情况。
2. 查询接口的文档链接已经失效。 官方 webhook 指南里链接的 /reference/getgenerationbyid 返回 404,仍然可以访问的是 /v1.0/reference/getgenerationbyid。v2 规范里只有 POST /generations,我没有找到 v2 的查询接口文档,所以轮询使用 v1 的 GET。用 v2 创建的任务能否用 v1 查询,我没有验证过(需要真实 Key),这是一个未确认的假设。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 返回 200,但图片列表为空 | 状态还是 PENDING | 继续轮询,或者改用 webhook(63C) |
| 400 content moderation filter | 提示词被审核拦截 | 修改提示词,不要尝试绕过 |
couldn't access image |
拿 generationId 当成了图片 ID | 图片 ID 在 generated_images[].id 中 |
| 一直轮询导致被限流 | 轮询太频繁 | 拉长轮询间隔,或者改用 webhook |
模板规则
- 轮询要有间隔递增和总超时。
- 只读取 COMPLETE 状态的结果。
- nsfw 图片不对外展示。
- 每张图片都记录 sha256 和提示词。
项目资源
| 用途 | 地址 |
|---|---|
| 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 |
| Get a Single Generation(v1.0) | https://docs.leonardo.ai/v1.0/reference/getgenerationbyid |
| NSFW 指南 | https://docs.leonardo.ai/docs/guide-to-handling-not-safe-for-work-image-generation-nsfw |
生成任务是异步的,要按状态来处理,不能拿到 ID 就马上读取结果。