Files
lixi 541376e9be
CI / ai-gates (push) Successful in 55s
fix(ai): 按官方 Error codes 页重写 OpenAI 错误映射
拿到 doc_temp/gpt错误码.md 后核对,**此前猜的六个键里错了五个**:
rate_limit_exceeded / moderation_blocked / content_policy_violation /
invalid_api_key / image_generation_user_error 在官方文档里都不存在。真实的是
type rate_limit_error + code slow_down、type service_unavailable_error + code
server_is_overloaded、四个 billing 码。唯一对的 insufficient_quota 也是宽 type
而非 code。这段历史留在 errors.py 顶部,作为凭记忆列错误码表代价的记录。

官方表的 13 条全是基础设施与计费,**没有任何内容审核/侵权码**——但图片接口确有
moderation 参数,说明审核存在、只是错误形态未文档化。直接后果:一次侵权或违规
拒绝很可能以普通 400 到达,只带一句人类可读消息。若一律归「服务端错误」,用户会
看到「服务出错了」并反复重试一个注定被拒的提示词。

故用 error.param 的有无做结构化区分(带 param = 参数错,不带 = 按内容被拒),
三条依据:文档的 service_tier 那条明确说参数错会带 param;内容拒绝不指向任何
具体参数;我方已在调用前校验过尺寸/画质/格式,能被 400 拒掉的参数嫌疑本就不大。
这一条是推断,走到时记 WARNING——那是日后校正它的唯一素材。client.py 相应补上
param 的解析。

两处顺带收获:
- 无 code 的 429 按限流处理,现在是**文档支持的默认**(六种 429 里只有「Rate
  limit reached for requests」没有文档化的 code),不再是猜测。
- 文档提醒视频过载已从 429 改判为 503。我们两者都归可重试,故不受影响;加了一条
  测试钉住这个「无影响」。

同一张表按 code 与 type 两个字段分别查:文档把标识列在哪个字段下,不保证响应里
它就在那个字段,查错的代价是把配额耗尽降级成限流、白烧一轮重试预算。

未接 Retry-After 并记录理由:Java 侧退避基线 30 秒、上限 10 分钟,已长于服务商
通常给的值,忽略只会偏慢、不会加剧限流;接它是一次跨语言契约变更。

测试 271 → 284。四道门禁逐条读退出码,全绿。
2026-09-21 15:52:25 +08:00

213 lines
12 KiB
Markdown
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.
# 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 的官方错误码表里全是基础设施与计费错误**,**没有任何内容审核/侵权码**
——一次「这个提示词不能生成」只能以通用 400 到达。故 `providers/openai/errors.py`
用 `error.param` 的有无区分「参数错」与「内容被拒」,那一条是推断并记 WARNING;
- **`Retry-After` 目前不读**:Java 侧退避阶梯基线 30 秒、上限 10 分钟,已长于服务商
通常给的值,忽略它只会偏慢、不会加剧限流。接它是一次跨语言契约变更。
### 同步型供应商的代价
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`。