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 目录。
210 lines
12 KiB
Markdown
210 lines
12 KiB
Markdown
# patbond-ai
|
||
|
||
Patbond 的 AI 生成服务(Python)。负责调用 AI 服务商 API 完成图片与视频生成,
|
||
是四仓中唯一直接持有 AI 服务商凭证的仓库。
|
||
|
||
架构与边界的完整决策见 **patbond-doc `docs/architecture/decisions.md` ADR-023**。
|
||
以下为要点,改动前请以 ADR 原文为准。
|
||
|
||
## 职责边界
|
||
|
||
- **只负责调用 AI 服务商 API。** 不访问数据库——生成任务状态的唯一事实来源是
|
||
Java 侧的 `creation.generation_jobs`。
|
||
- **不持有对象存储凭证。** 产物由 Java 下发预签名 PUT 地址,本服务直传后回报,
|
||
再由 Java 核实并建 `media.assets` 行(即客户端两步上传,本服务站在客户端位置)。
|
||
- **对上游同步或异步一律对外呈现为异步**:`submit` 立即返回任务 id,同步型
|
||
服务商(长连接等待返回)在本服务内部以后台任务承载。这样 Java 侧无长连接占线程,
|
||
且 `provider_request_id` 立即可得,超时不再产生「是否已扣费」的歧义。
|
||
|
||
## 与 Java 侧的接口
|
||
|
||
- 传输为 **REST(JSON)**,非 gRPC(理由见 ADR-023 §4:避免引入第二套无门禁契约体系)。
|
||
- 契约为本仓内独立的 **`openapi-internal.yaml`**,独立版本号,
|
||
**不进** patbond-api 的四模块快照锁,**也不写入**面向客户端的 `openapi.yaml`。
|
||
- 鉴权为 **服务身份 JWT**(独立密钥对,非用户 token 转发):本服务强制校验
|
||
`aud=patbond-ai` 与 `typ=service`。
|
||
- Java 主动轮询本服务查询进度;回调是将来的可选优化,当前不启用。
|
||
- 请求体**不含 `userId`**(数据最小化):完成工作只需 `jobId`、生成参数与预签名上传地址。
|
||
|
||
## 运行
|
||
|
||
```bash
|
||
cp .env.sample .env # 填入实际值;.env 已被 gitignore
|
||
python -m app # 默认 127.0.0.1:8000
|
||
```
|
||
|
||
**只监听回环**是默认:本服务只接受来自 `patbond-creation` 的内部调用,不该对外
|
||
可达(与项目「默认拒绝、需要外部访问时经 nginx 反代」的纪律一致)。
|
||
|
||
探活:`GET /health`(**在 `/internal` 之外,不需要服务票**)。它会报出实际装配了
|
||
哪些适配器——缺服务商凭证时适配器不装配,而那个症状(任务永远排队)离根因很远,
|
||
让探活直接说出来能省很多排查时间。
|
||
|
||
## 接口
|
||
|
||
| 端点 | 说明 |
|
||
| --- | --- |
|
||
| `POST /internal/generation/tasks` | 提交任务,返回**服务商**任务 ID |
|
||
| `GET /internal/generation/tasks/{provider_request_id}` | 查询任务 |
|
||
| `GET /health` | 探活,无需服务票 |
|
||
|
||
**权威契约是仓库根目录的 `openapi-internal.yaml`**(手写),不是 FastAPI 自动生成
|
||
的 `/openapi.json`(后者仅供调试,交互文档已关闭)。两者的一致性由
|
||
`tests/api/test_contract_drift.py` 守着——比对路径、方法、字段集、枚举取值与必填项,
|
||
**不比对字节**(那永远不会相同,只会制造一个天天红然后被 skip 绕过的门禁)。
|
||
|
||
### 三类失败必须分开
|
||
|
||
| 类别 | 形态 | 调用方该做什么 |
|
||
| --- | --- | --- |
|
||
| `service` | 非 2xx + `error.kind = "service"` | 我方 bug 或配置问题。除 `internal_error`/`provider_unavailable` 外不该重试 |
|
||
| `provider` | 非 2xx + `error.kind = "provider"` + 完整分类 | **一律以 `retry` 字段为准**,不要自己解读厂商码 |
|
||
| 任务失败 | **200** + `state = "failed"` + `failure` | 同上看 `retry` |
|
||
|
||
第三类刻意不走错误通道:查询一个失败的任务,**HTTP 调用是成功的**,失败的是任务。
|
||
做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
|
||
|
||
判别字段是 `error.kind`,**不是 HTTP 状态码**——状态码在两类之间会撞。
|
||
|
||
`provider` 信封与 `failure` 的分类字段**故意完全一致**,故调用方可复用同一段处置
|
||
逻辑,不必为「提交时被限流」与「任务失败于限流」写两套分支。
|
||
|
||
## 代码结构
|
||
|
||
分层,依赖**单向**向内(`api` → `assembly`/`config` → `providers` → `domain`),由
|
||
`tests/test_layering.py` 自动守着——靠纪律守不住的东西,靠测试才守得住。
|
||
|
||
```
|
||
app/
|
||
├── domain/generation.py 与服务商无关的领域模型
|
||
├── providers/
|
||
│ ├── base.py 生成能力抽象(新增供应商 = 实现它)
|
||
│ ├── registry.py provider_code → 适配器
|
||
│ ├── volcengine/
|
||
│ │ ├── client.py HTTP 传输与鉴权(不认识「视频」)
|
||
│ │ ├── errors.py 火山错误码 → 领域 Failure
|
||
│ │ ├── models.py 厂商请求/响应 DTO
|
||
│ │ ├── capabilities.py 模型能力与参数限制
|
||
│ │ ├── dimensions.py ratio+resolution → 像素对照表
|
||
│ │ └── video.py Seedance 视频业务
|
||
│ └── openai/
|
||
│ ├── client.py HTTP 传输与鉴权(JSON + multipart)
|
||
│ ├── errors.py OpenAI 错误 → 领域 Failure
|
||
│ ├── models.py 厂商请求/响应 DTO
|
||
│ ├── capabilities.py 尺寸算术约束与画质档
|
||
│ ├── tasks.py 同步→异步桥(本服务唯一的有状态组件)
|
||
│ └── image.py gpt-image-2 图片业务(文生图 + 图生图)
|
||
├── api/ HTTP 端点、服务票校验、错误信封
|
||
├── config.py 集中配置(全部旋钮从环境变量读)
|
||
└── assembly.py 装配根(唯一同时知道配置与具体供应商的地方)
|
||
```
|
||
|
||
**为什么这么切**:
|
||
|
||
- **`domain/` 对厂商与 httpx 零依赖。** 这是「换供应商不动上层」的支点。判断一个
|
||
概念该放哪:问「换一家供应商,它还在吗」——`Retry`/`UserFacing` 描述的是
|
||
Worker 与客户端该怎么做,与谁家无关,故在领域层;`expired` 状态、
|
||
`QuotaExceeded` 这类码是火山特有的,故在适配层。
|
||
- **`client.py` 与 `video.py` 分开。** client 只管鉴权、超时、错误解包,不认识
|
||
「视频」。以后接火山的**图片或语音**接口时新建兄弟模块复用同一个 client,
|
||
而不是再抄一遍鉴权与错误解包——这是拆它出来的全部理由。
|
||
- **两家的 `client.py` 刻意不共用。** 错误体形状不同(OpenAI 是
|
||
`{"error": {message, type, code, param}}`,火山是 `{"error": {code, message}}`),
|
||
且 OpenAI 的编辑端点走 multipart。抽一个共同基类会让两边都被对方的特例污染
|
||
——**同一家的不同能力之间才复用传输层,不同供应商之间不复用**。
|
||
- **适配层不读配置**,全部通过构造参数注入。否则单测得摆弄环境变量,且同一进程
|
||
内没法跑两套不同配置的实例。
|
||
|
||
### 新增一家供应商 / 一种能力
|
||
|
||
1. 实现 `app/providers/base.py` 的 `GenerationProvider`;
|
||
2. 在 `app/assembly.py` 的 `build_registry()` 里登记一行;
|
||
3. 目录表的 `provider_code` 与适配器的 `provider_code` 必须同值——Java 侧按
|
||
任务行冻结的 `provider_code_snapshot` 查适配器(**不按当前目录**,因为目录
|
||
可能被改指向,而在途任务必须仍由当初提交它的适配器轮询)。
|
||
|
||
上层与领域层**一行都不用改**。
|
||
|
||
## 已接入的服务商
|
||
|
||
| 服务商 | 能力 | provider_code | 状态 |
|
||
| --- | --- | --- | --- |
|
||
| 火山方舟 Seedance | 视频生成 | `volcengine-seedance` | 提交与查询已实现,产物转存待存储决策 |
|
||
| OpenAI gpt-image-2 | 图片生成(文生图 / 图生图) | `openai-gpt-image` | 同上;另见下方「同步型供应商的代价」 |
|
||
|
||
接口事实与错误码映射的完整取证见 patbond-doc
|
||
`docs/development/providers/volcengine-seedance.md` 与
|
||
`docs/development/providers/openai-gpt-image.md`。其中几条尤其要记住:
|
||
|
||
- **火山不支持幂等键**,「提交超时后不知道有没有扣费」只能由 `max_attempts` 兜住;
|
||
- **火山产物 URL 只有 24 小时有效期**,因此厂商 URL 不能当存储用,必须在时限内转存;
|
||
- **OpenAI 的两份文档都没有错误码表**,故 `providers/openai/errors.py` 的映射是
|
||
按 HTTP 状态码为主推断的,**未经文档证实**,拿到真实错误数据后须校正。
|
||
|
||
### 同步型供应商的代价
|
||
|
||
OpenAI 的图片接口是**同步**的(没有任务 ID,也没有查询端点),由
|
||
`providers/openai/tasks.py` 在本服务内部包成异步。两笔代价无法靠代码消除:
|
||
|
||
- **本服务从此持有状态。** 进程重启会丢掉所有在途图片任务(Java 侧轮询得到
|
||
`task_not_found`,按失败处理)。也因此 **`patbond-ai` 横向扩容前必须先解决
|
||
任务 ID 只在签发进程内有效的问题**——多副本 + 无粘性路由会让轮询落到不认识
|
||
该 ID 的副本上。视频那侧没有这个约束,火山的任务 ID 是全局的。
|
||
- **超时窗口无法靠重查弥补。** 超时那次调用可能已在服务商侧完成并计费,而图片
|
||
接口没有任务 ID 可供事后查询。只能由 `max_attempts` 兜住。
|
||
|
||
## 配置
|
||
|
||
全部旋钮见 `app/config.py`,常用项:
|
||
|
||
| 环境变量 | 默认值 | 说明 |
|
||
| --- | --- | --- |
|
||
| `ARK_API_KEY` | _(无)_ | 火山方舟密钥。**缺失时该适配器不装配**,服务仍可启动 |
|
||
| `ARK_BASE_URL` | 北京区官方地址 | |
|
||
| `ARK_TIMEOUT_SEC` | `10` | **单次 HTTP 往返**超时,不是生成耗时 |
|
||
| `ARK_VIDEO_MODEL` | _(无)_ | 仅作兜底;正常由 Java 侧传入任务行的模型快照 |
|
||
| `ARK_TASK_EXPIRY_SEC` | `3600` | 厂商 `execution_expires_after`,区间 `[3600, 259200]`。默认取下限——厂商默认 48 小时对交互式应用过长 |
|
||
| `OPENAI_API_KEY` | _(无)_ | OpenAI 密钥。**缺失时该适配器不装配**,服务仍可启动 |
|
||
| `OPENAI_TIMEOUT_SEC` | `180` | ⚠️ 与 `ARK_TIMEOUT_SEC` **语义不同**:图片接口同步,这里是**生成耗时的上限** |
|
||
| `OPENAI_IMAGE_MODEL` | _(无)_ | 仅作兜底;正常由 Java 侧传入任务行的模型快照 |
|
||
| `OPENAI_MAX_CONCURRENT` | `4` | 同时执行的同步调用数上限,也是「同时有多少钱在花」的上界 |
|
||
| `OPENAI_COMPLETED_TTL_SEC` | `900` | 已完成任务在内存注册表的保留时长。**须大于 Java 侧最长轮询间隔**,否则结果会在被取走前被清掉 |
|
||
| `OPENAI_MAX_INFLIGHT` | `256` | 注册表条目上限。满了拒绝新提交(503),**不挤掉在途任务** |
|
||
| `PATBOND_SERVICE_JWT_PUBLIC_KEY` | _(无)_ | 校验 Java 侧服务票的 RS256 公钥 |
|
||
| `PATBOND_SERVICE_JWT_AUDIENCE` | `patbond-ai` | **必须强制校验**,否则独立密钥对失去意义 |
|
||
|
||
非法值**响亮失败而不静默回落**:一次配置笔误变成难查的行为偏差是最糟的结果。
|
||
|
||
## 本地门禁
|
||
|
||
与 CI 同一套命令(`.gitea/workflows/ci.yml`)。**逐条分开跑并读各自的退出码**——
|
||
`ruff check . | tail -1; echo $?` 读到的是 `tail` 的退出码,会把一次真实的 lint
|
||
失败读成通过(本仓已因此红过一次):
|
||
|
||
```bash
|
||
sh scripts/check-secrets.sh --all # 凭证防泄漏全量自查
|
||
ruff format --check .
|
||
ruff check .
|
||
pytest -q
|
||
```
|
||
|
||
**每人每仓启用一次** pre-commit 防泄漏钩子:
|
||
|
||
```bash
|
||
git config core.hooksPath scripts/hooks
|
||
```
|
||
|
||
`scripts/check-secrets.sh` 是四仓同构副本,**调整规则时四仓同步提交**。
|
||
它覆盖 AI 服务商密钥形态(`sk-ant-`、`sk-`/`sk-proj-`/`sk-svcacct-`)与
|
||
`api_key`/`auth_token` 实值赋值——本仓正是这些规则的主要适用对象。
|
||
|
||
## 凭证纪律
|
||
|
||
真实密钥**只允许存在于被 gitignore 的文件中**(`.env`),占位样例进 `.env.sample`。
|
||
拦下真实凭证时的第一动作是**去服务商控制台轮换/禁用该密钥**,然后才是清理提交。
|
||
|
||
## 分支与发布
|
||
|
||
`dev` 直推 + CI 绿,`main` 经 PR 合并。本仓纳入四仓 tag 同步与发布回归
|
||
(ADR-023 §12)。发布流程见 patbond-doc `docs/development/releases.md`。
|