Files
patbond-ai/openapi-internal.yaml
lixi 8275a04903
CI / ai-gates (push) Successful in 38s
feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
新增 app/providers/openai/:client 传输(JSON + multipart)、errors 错误映射、
models 厂商 DTO、capabilities 尺寸算术约束、tasks 同步→异步桥、image 图片业务。
装配与配置同步接入,缺 OPENAI_API_KEY 时跳过装配、服务仍可启动。

与火山视频的三处根本差异及其处置:
- 接口同步(无任务 ID、无查询端点)→ SyncTaskRegistry 在本服务内部吸收,
  对外仍是 ADR-023 §4 的两段式提交/轮询。代价:本服务从此持有状态,进程重启
  丢在途任务,且横向扩容前须先解决任务 ID 只在签发进程内有效的问题。
- 产物是 base64 字节而非 URL → 无过期问题,但字节只在内存里。asset_url 放
  data URI 仅为满足领域不变式,绝不能原样写进 media.assets.external_url。
- 图生图走 multipart → 先下载参考图再上传;下载失败与超限按参数错误同步拒绝,
  不变成注定失败的后台任务。

两处需要记住的判断:
- 尺寸上限「3840x2160」文档未说清是逐边还是按像素。逐边理解会禁掉所有高于
  2160 的竖图,而 1024x1536 是文档明示的标准竖图,故必然过严。采用最长边
  ≤3840 且总像素 ≤3840×2160,已在 capabilities.py 与取证页标为推断。
- 两份文档都没有错误码表,故 errors.py 以 HTTP 状态码为主、type/code 为辅,
  顶部标注「未经文档证实」。429 无线索时按限流而非配额耗尽(猜成配额会把可
  重试的限流判死);400 无线索时归服务端错误而非内容被拒(错报会让用户去改
  一个其实没问题的提示词)。

一处死锁已修并回归钉住:SyncTaskRegistry 原用 add_done_callback 记录完成时刻,
而快速完成的任务在注册时已 done,回调便同步地在持有非可重入锁的线程里执行。
改为首次被观察到时惰性记录。

测试 166 → 271(+105)。四道门禁逐条读退出码,全绿。
产物转存仍阻塞于存储决策(ADR-023 §7),图片模型因此未播种进 V7 目录。
2026-09-21 12:10:07 +08:00

413 lines
18 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
openapi: 3.1.0
info:
title: patbond-ai 内部接口
version: 0.1.0
description: |
`patbond-creation`(Java)调用 `patbond-ai`(Python)执行 AI 生成的内部契约。
**这份契约不对客户端暴露**,与 `patbond-doc/docs/api/openapi.yaml` 是两份独立
文档、独立版本号(ADR-023 §4)。刻意不合并:内部适配端点若进入公开契约,会污染
其路径/操作计数,也会让「对客户端的承诺」与「对内部实现的约定」混为一谈。
本文档也**不进** patbond-api 的四模块快照锁。
## 字段命名为 snake_case
与项目对客户端的 camelCase 契约不同。这是有意的:本服务是 Python,snake_case
使 JSON 与领域 dataclass 字段逐一对应、无需转换层;Java 侧只需在这一个 Feign
客户端上配置命名策略。**用 camelCase 反而要在两端都加转换。**
## 两类失败必须分开(本契约的核心设计)
| 类别 | 含义 | 形态 |
| --- | --- | --- |
| **service** | Java↔Python 之间的问题:鉴权失败、请求体非法、任务不存在、本服务内部错误、适配器未装配 | 非 2xx + `error.kind = "service"` |
| **provider** | Python↔AI 服务商之间的问题:限流、配额耗尽、内容被拒、厂商内部错误 | 非 2xx + `error.kind = "provider"`,**并携带完整分类** |
| **任务失败** | HTTP 调用本身成功,但生成任务最终失败 | **200** + `state = "failed"` + `failure` |
第三类刻意不走错误通道:查询一个失败的任务,**HTTP 请求是成功的**,失败的是
任务。把它做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
判别字段是 `error.kind`,**不是 HTTP 状态码**。Java 侧据此分流:
`service` 类属我方 bug 或配置问题(除 `internal_error`/`provider_unavailable`
外均不可重试);`provider` 类一律以 `retry` 字段为准,不要自己解读厂商码。
## 一处与服务商形态有关的说明
**服务商可能是同步的。** 火山的视频接口本身异步(提交返回任务 ID);OpenAI 的
图片接口是**同步**的——一次请求等到底,没有任务 ID 也没有查询接口。该差异由本
服务在内部吸收(ADR-023 §4),故本契约对两者形状完全一致。
代价如实登记:同步型供应商的在途任务只能记在本服务内存里,**进程重启会丢失**,
之后查询返回 `task_not_found`。那次调用可能已经完成并计费,而同步接口没有任务
ID 可供事后查询,故该窗口**无法靠"再查一次"弥补**,只能由调用方的
`max_attempts` 兜住。并发在途任务达上限时提交返回 `provider_unavailable`
(503,可重试)而非 `invalid_request`。
## 鉴权
所有端点要求服务身份 JWT:`Authorization: Bearer <token>`。本服务强制校验
`aud = patbond-ai` 与 `typ = service`(ADR-023 §5)。用户 token 一律拒绝——
该密钥对与用户 token 的密钥对是独立的两套。
servers:
- url: http://127.0.0.1:8000
description: 本机开发
- url: http://patbond-ai:8000
description: 容器网络内
security:
- serviceJwt: []
paths:
/internal/generation/tasks:
post:
summary: 提交生成任务
operationId: submitGenerationTask
description: |
把任务交给上游服务商,返回**服务商的任务 ID**。
**不阻塞等待生成完成。** 但会阻塞一次到服务商的短往返——服务商的提交接口
本身是异步的(只入队即返回任务 ID),所以这一跳很快。
调用方**必须在上游产生任何计费动作之前**把返回的 `provider_request_id`
落库:火山不支持幂等键,这是提交超时后唯一能判断「是否已提交」的依据。
**一个无法消除的窗口**:若本服务到服务商的调用超时,调用方会收到
`provider` 类错误且拿不到 ID,而服务商**可能已经创建了任务**。无幂等键可用,
该窗口只能由 `max_attempts` 兜住(ADR-023 §4 已如实登记)。服务商支持
回调,将来启用回调可作为孤儿任务的回收通道。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitRequest'
responses:
'200':
description: 已提交
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitResponse'
'400':
$ref: '#/components/responses/ServiceError'
'401':
$ref: '#/components/responses/ServiceError'
'502':
$ref: '#/components/responses/ProviderError'
'503':
$ref: '#/components/responses/ServiceError'
/internal/generation/tasks/{provider_request_id}:
get:
summary: 查询生成任务
operationId: getGenerationTask
description: |
路径参数是**服务商的任务 ID**,不是我方 `job_id`。
这是刻意的:本服务不持有任何状态,唯一能用来问服务商的就是这个 ID。若用
`job_id`,本服务就得存一份 `job_id → 厂商 ID` 的映射,「无状态」不再成立,
且会多出一个「Java 有 ID 但查不到任何东西」的中间态。
注意火山**只保留最近 7 天**的任务记录,更早的任务会以 `provider` 类错误
返回。
parameters:
- name: provider_request_id
in: path
required: true
schema:
type: string
example: cgt-20260920-abc123
responses:
'200':
description: |
任务当前状态。**任务失败也是 200** —— HTTP 调用成功了,失败的是任务。
content:
application/json:
schema:
$ref: '#/components/schemas/TaskResponse'
'401':
$ref: '#/components/responses/ServiceError'
'404':
$ref: '#/components/responses/ServiceError'
'502':
$ref: '#/components/responses/ProviderError'
'503':
$ref: '#/components/responses/ServiceError'
components:
securitySchemes:
serviceJwt:
type: http
scheme: bearer
bearerFormat: JWT
description: |
由 `patbond-creation` 用**独立于用户 token 的**密钥对签发。claim 约定:
`iss=patbond-api`、`aud=patbond-ai`、`sub=service:creation-worker`、
`typ=service`、短 `exp`(5 分钟)、带 `jti`。
responses:
ServiceError:
description: Java↔Python 之间的问题
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceErrorEnvelope'
ProviderError:
description: Python↔服务商之间的问题,携带完整分类
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderErrorEnvelope'
schemas:
SubmitRequest:
type: object
required: [job_id, media_kind, model]
description: |
**刻意不含 `user_id`**(数据最小化,ADR-023 §5):本服务干活只需要任务参数。
需要给服务商做滥用检测时传的是 `safety_identifier`——一个哈希,不是明文 ID。
properties:
job_id:
type: string
format: uuid
description: |
我方任务 ID。**本服务不用它查任何东西**,仅用于日志关联,使一条报错能
对应回 `creation.generation_jobs` 的行。
media_kind:
type: string
enum: [image, video]
description: 决定路由到哪个适配器。
model:
type: string
description: |
服务商侧的模型标识,取自任务行冻结的 `provider_model_snapshot`。
**本服务不自行选模型**:选型是目录的职责,且任务行里的快照必须与实际
调用的模型一致,否则事后无法对账。
example: doubao-seedance-2-5-260628
prompt:
type: [string, 'null']
maxLength: 10000
description: 与 `input_url` **至少要有一个**,都为空则本地即拒。
input_url:
type: [string, 'null']
description: |
参考图/首帧的**公网可达** URL。给了即图生视频,不给即文生视频
(参考图选填,决策 A2)。
ratio:
type: [string, 'null']
description: |
缺省 `adaptive`。**会按模型限制本地预校验**:Seedance 2.5 在图生视频时
只支持 `adaptive`,指定具体比例会被本服务直接拒(省掉一次往返)。
example: adaptive
resolution:
type: [string, 'null']
description: |
缺省 `720p`。按模型预校验:2.5 无 4k,2.0 系列有。**视频路径专用**。
example: 720p
width_px:
type: [integer, 'null']
description: |
**图片路径专用**的像素宽。与 `height_px` 必须同时给,否则交由模型自选
(半个尺寸不如不给)。
两家服务商的尺寸表达方式不同:火山用「分辨率档 + 宽高比」
(`resolution` + `ratio`),OpenAI 用 `WIDTHxHEIGHT` 像素串。契约同时
保留两种而**不强行统一**——任何统一都会在某一侧失真:把 720p+16:9 折成
像素会丢掉「档位」语义,把像素折成比例会丢掉精确值。
OpenAI 侧的算术约束会在本服务**本地预校验**:宽高都须被 16 整除、
宽高比在 1:3 ~ 3:1 之间、不超过 3840x2160(超过 2560x1440 属服务商
标注的实验性范围)。违规请求以 `invalid_request` 拒绝,不花一次往返。
height_px:
type: [integer, 'null']
description: 见 `width_px`。
duration_sec:
type: [integer, 'null']
description: |
缺省 5。按模型预校验:2.5 为 `[4,30]`,2.0 系列为 `[4,15]`;
`-1` 表示由模型自选,不参与范围校验。
generate_audio:
type: boolean
default: true
watermark:
type: boolean
default: false
safety_identifier:
type: [string, 'null']
maxLength: 64
description: |
终端用户标识,供服务商做滥用检测。**必须是哈希值,不可传明文用户 ID**。
callback_url:
type: [string, 'null']
description: |
预留,当前不启用(ADR-023 §4:轮询为保证,回调为优化)。留此字段使
将来启用无需改契约。
upload_target:
type: [string, 'null']
description: |
产物的预签名 PUT 地址。**当前不启用**——转存阻塞于对象存储决策
(ADR-023 §7)。启用后本服务会把产物直传该地址,从而无需持有任何
对象存储凭证。
SubmitResponse:
type: object
required: [provider_request_id]
properties:
provider_request_id:
type: string
description: 服务商任务 ID。调用方须在上游计费前落库。
example: cgt-20260920-abc123
TaskResponse:
type: object
required: [provider_request_id, state, progress]
properties:
provider_request_id:
type: string
state:
type: string
enum: [pending, running, succeeded, failed, cancelled]
description: |
**刻意比服务商的状态集更小。** 火山有 queued/running/cancelled/
succeeded/failed/expired 六个:`queued` 映射为 `pending`,`expired`
归入 `failed`(它是超时,属失败的一种,重试策略由 `failure` 表达,
不需要独立状态)。
progress:
type: integer
minimum: 0
maximum: 100
description: |
火山不返回百分比,故成功前恒 0、成功即 100。**这不是缺陷**:客户端
契约本就不承诺平滑进度(ADR-023 §10),一个诚实的 0 不该被读成「卡住」。
asset_url:
type: [string, 'null']
description: |
产物 URL。**可能是有时效的服务商地址**——火山为 24 小时。调用方必须在
时效内转存,**不可长期保存此值**(存进 `media.assets.external_url`
会在 24 小时后变死链)。
asset_url_expires_in_sec:
type: [integer, 'null']
description: |
`asset_url` 的剩余有效期。与 URL 一起给出,使调用方没有理由把它当
永久地址。火山为 86400。
cover_url:
type: [string, 'null']
description: |
封面图 URL。火山的尾帧图(png、尺寸与视频一致、无水印)可免费充当此
用途,省掉抽帧。
ratio:
type: [string, 'null']
resolution:
type: [string, 'null']
width_px:
type: [integer, 'null']
description: |
由 (模型族, resolution, ratio) 查静态对照表得到,**不需要下载产物或
解码**。`ratio` 为 `adaptive` 或模型族未知时为 `null`——留空而不猜,
一个错的尺寸会让客户端按错误比例渲染且看不出是错的。
height_px:
type: [integer, 'null']
duration_ms:
type: [integer, 'null']
description: |
有 `frames`+`fps` 时按帧精确换算;只有服务商的整数秒时退回它,而那是
「帧数/24 向下取整」的**约数**(最多可能短 1 秒)。两者都缺时为 null,
不编 0。
output_format:
type: [string, 'null']
example: mp4
billing_tokens:
type: [integer, 'null']
description: 计费对账依据(火山为 `usage.completion_tokens`)。
failure:
oneOf:
- $ref: '#/components/schemas/Failure'
- type: 'null'
description: |
仅 `state = failed` 时非空。**`failed` 必然携带它**——否则调用方无法
决定重试策略,只能盲目重试或盲目放弃,两者都错。
Failure:
type: object
required: [retry, user_facing, provider_code]
properties:
retry:
type: string
enum: [never, backoff, quota]
description: |
**Worker 据此决定重试,不必解读服务商码。**
`never`:同样输入必然同样结果(参数错、内容被拒、鉴权失败)。
`backoff`:限流、突发流量保护、服务过载,退避几秒到几十秒。
`quota`:额度耗尽,要等到下一个计费周期或充值——不该把重试预算花在
这上面,应直接终态失败并让用户看到出口。
user_facing:
type: string
enum:
[quota_exhausted, rate_limited, content_rejected, server_error, upstream_unavailable]
description: |
映射到客户端呈现。`quota_exhausted` 与 `rate_limited` **必须分开**:
前者等的是明天或充值(弹 sheet 给出口),后者等的是几秒(弹 SnackBar
可自动重试)。合成一类必然有一半场景的文案和出口是错的(ADR-023 §9)。
provider_code:
type: string
description: |
原始服务商错误码,**原样保留、绝不丢弃**——排查时往往是唯一线索。
上层不应据此分支判断(那是适配层的职责),只记录与展示给开发者。
example: OutputVideoSensitiveContentDetected
message:
type: string
description: 服务商原始错误描述,仅供排查与开发者可见的日志。
ServiceErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [kind, code, message]
properties:
kind:
type: string
const: service
code:
type: string
enum:
- unauthenticated # 服务票缺失/过期/aud 或 typ 不符
- invalid_request # 请求体不合契约,或参数超出模型限制
- task_not_found # 路径里的服务商任务 ID 不存在
- provider_unavailable # 适配器未装配(如缺凭证)或 provider_code 未知
- internal_error # 本服务 bug
message:
type: string
ProviderErrorEnvelope:
type: object
required: [error]
description: |
服务商调用失败。**与任务失败(200 + state=failed)是两回事**:这里是调用
没成功,那里是调用成功但任务最终失败。
properties:
error:
type: object
required: [kind, retry, user_facing, provider_code, message]
description: |
字段与 `Failure` 一致,故 Java 侧可复用同一段分类处置逻辑,无需为
「提交时被限流」和「任务失败于限流」写两套分支。
properties:
kind:
type: string
const: provider
retry:
type: string
enum: [never, backoff, quota]
user_facing:
type: string
enum:
[quota_exhausted, rate_limited, content_rejected, server_error, upstream_unavailable]
provider_code:
type: string
message:
type: string