8275a04903
CI / ai-gates (push) Successful in 38s
新增 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 目录。
413 lines
18 KiB
YAML
413 lines
18 KiB
YAML
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
|