Compare commits

11 Commits

Author SHA1 Message Date
lixi 541376e9be fix(ai): 按官方 Error codes 页重写 OpenAI 错误映射
CI / ai-gates (push) Successful in 55s
拿到 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
lixi 8275a04903 feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
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 目录。
2026-09-21 12:10:07 +08:00
lixi 062e11b1ff feat(ai): 内部 HTTP 端点 + 服务票校验 + 契约漂移守卫(166 测试)
CI / ai-gates (push) Successful in 39s
按 openapi-internal.yaml 落地两个端点与鉴权,全部离线可测。

**鉴权(app/api/auth.py)**是这一层的安全边界,三条纪律各有测试:
- **强制校验 aud 与 iss**(交给 pyjwt 强制,不解出来自己比——自己比容易漏掉
  「claim 不存在」那一路)。这是独立密钥对方案唯一的意义所在:Java 侧现有的
  JwtVerifier 只验签名、不校验 aud,正因如此才必须独立密钥;若本服务也只验签名,
  那对独立密钥就白搭了。
- **额外强制 typ=service**,作为「有人误把用户密钥配到这里」的第二道防线。
- **缺公钥时全拒而非全过**。配置缺失必须立刻暴露,而不是变成一扇敞开的门;
  与 Java 侧 InternalAuthFilter 未配置 internal-token 时全拒 /internal/** 一致。
- 失败一律返回同一个笼统原因,**不告诉调用方是签名错/过期/aud 不符**——对方是
  我们自己的服务,真正原因在日志里;细节回给调用方只会在将来某天变成提示。

**三类失败分开**(契约核心)。第三类「任务失败」走 200 + state=failed + failure,
因为那种情况下 HTTP 调用是成功的;做成非 2xx 会让「查不到任务」与「任务失败了」
难以区分。另外两处刻意选择:
- 服务商 429 **不透传**,一律 502 + provider 信封。429 既可能是「等几秒」也可能是
  「等到明天」,状态码表达不了这个区别,retry 才是权威信号。
- 超出模型限制的请求归 service/invalid_request 而非 provider——请求**根本没发出去**,
  归到 provider 会让调用方以为是上游问题而去退避重试,而重试同样的参数永远不会变好。
- 服务商 404(含超出其 7 天保留期)归 service/task_not_found:调用方要做的是
  **停止轮询**,不是退避重试。
- 接管 FastAPI 默认的 {"detail": [...]} 校验错误,否则调用方要处理三种形状,
  而第三种还是框架实现细节。

**契约漂移守卫(tests/api/test_contract_drift.py,14 项)**:本项目开工审计已证明
「没有门禁的契约必然漂移」——四模块快照锁实际不存在,白名单 42/41 之误四份文档
一路不可见整整一个迭代。内部契约同样需要门禁。比对路径/方法/字段集/枚举/必填项,
**不比对字节**(永远不会相同,只会制造一个天天红然后被 skip 绕过的门禁)。

写这个守卫时它立刻抓到一处问题,但**问题在我的内省方式而不在应用**:本版本
FastAPI 的 include_router 会包一层内部 wrapper,路由并不平铺在 app.routes 上。
已改用 app.openapi()——它是「究竟暴露了什么」的权威视图,且不依赖框架内部结构,
否则这个门禁会在升级框架时莫名失效,而一个悄悄失效的门禁比没有门禁更糟。

**新增 pyproject.toml**:`Depends()` 放在参数默认值里正是 FastAPI 的标准写法,
ruff 的 B008 在此是误报。按 ruff 正规方式声明这些调用可安全求值,而不是在每个
端点上撒 noqa——后者迟早被用来压制真正的问题。

schemas 用 extra="forbid":拼错的字段应当立刻报错而非被静默忽略,后者表现为
「我传了 durationSec 却没生效」这类极难查的问题。测试覆盖了 camelCase 与
user_id(契约刻意不含它)两种被拒情形。

新增依赖 fastapi / uvicorn / pyjwt[crypto];测试 127 → 166。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:59:44 +08:00
lixi fd23dfc0b9 feat(ai): 内部契约 openapi-internal.yaml + 像素对照表(127 测试)
CI / ai-gates (push) Successful in 33s
按用户拍板的四点定型 Java↔Python 的 JSON 契约。

**① 字段命名 snake_case**(与对客户端的 camelCase 契约不同,有意为之):本服务是
Python,snake_case 使 JSON 与领域 dataclass 字段逐一对应、无需转换层;Java 侧只需
在这一个 Feign 客户端上配置命名策略。用 camelCase 反而要在两端都加转换。

**② 两类失败分开**,判别字段是 `error.kind` 而非 HTTP 状态码:
- `service`(Java↔Python):unauthenticated / invalid_request / task_not_found /
  provider_unavailable / internal_error。属我方 bug 或配置问题。
- `provider`(Python↔服务商):非 2xx + 完整分类(retry / user_facing /
  provider_code / message),字段与 Failure 一致,故 Java 侧可复用同一段处置
  逻辑,不必为「提交时被限流」和「任务失败于限流」写两套分支。
- **任务失败是第三类,走 200 + state=failed + failure**:查询一个失败的任务,
  HTTP 调用本身是成功的。做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。

**③ 像素对照表已实现**——此处需更正一个理解:该表**不需要查询任何东西、也不依赖
存储方案**。它是纯静态数据(官方文档「不同宽高比对应的宽高像素值」一节),厂商
查询响应已返回 ratio 与 resolution,查表即可,无需下载产物或解码。故
widthPx/heightPx 在 AI 视频产物上可零成本填真值(ADR-023 §11)。

实现上一个关键观察:**2.5 与 2.0 的 24 格里只有两格不同**(480p 的 16:9 与
9:16),其余完全一致。故写成「共享表 + 两格覆盖」,而非抄两份 24 行——后者每次
改动要改两处,迟早只改一处。另 4k 六格仅 2.0 有。
查不到时返回 None 而不猜(ratio=adaptive、未知模型族、该模型不支持的组合三种
情形):一个错的尺寸会让客户端按错误比例渲染,且看不出是错的。
连带给 SeedanceTask 补解析响应里的 model 字段——同一适配器实例可能同时处理两个
模型族的任务,按响应里的实际模型定族才对。

**④ 路径参数用服务商任务 ID**,并在契约里写明原因:本服务不持有状态,唯一能用来
问服务商的就是这个 ID;用 job_id 就得存一份映射,「无状态」不再成立,且会多出
一个「Java 有 ID 但查不到任何东西」的中间态。你指出的那点完全对——submit 会阻塞
一次到服务商的短往返才能拿到 ID,但服务商的提交接口本身是异步的(只入队即返回),
所以这一跳很快。契约里也如实登记了那个**无法消除的窗口**:该调用超时时 Java 拿
不到 ID 而服务商可能已创建任务,无幂等键可用,只能由 max_attempts 兜住。

测试 123 → 127:旧的「像素刻意留空」断言已作废,换成断言真实换算,并覆盖两族
差异的那两格、4k 仅 2.0、adaptive 与未知模型族的留空回退。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:26:49 +08:00
lixi d4213a6ece fix(ai): capabilities 嵌套 if 合并(SIM102)——修 8f1d1bc 的 lint 红
CI / ai-gates (push) Successful in 1m16s
上一条提交时我先 commit 后看门禁输出,lint 已红却已入库。四道门禁现逐条复验
全绿(secret-scan / format / lint / pytest 退出码均 0,123 测试)。

教训与 db87d66 同源:门禁必须在 commit **之前**逐条读对退出码。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:13:23 +08:00
lixi 8f1d1bc5f3 feat(seedance): 两个已开通模型的限制表与提交前本地预校验(23 测试)
账号实际接入 doubao-seedance-2-5-260628 与 doubao-seedance-2-0-260128,两者限制
差异不小,且同一组参数在一个上合法、在另一个上不合法:

|            | 2.5              | 2.0                |
| 时长        | [4, 30]          | [4, 15]            |
| 分辨率      | 480p/720p/1080p  | 480p/720p/1080p/4k |
| 图生视频 ratio | **只能 adaptive** | 可指定             |
| output_format | mp4/mov        | 仅 mp4             |

超范围的请求会被厂商 400 拒绝——那要花一次往返、计入 RPM,并表现为一个失败的
任务。故在 capabilities.py 落一张限制表并在提交前本地预校验,错误信息直接指出
是哪个模型的哪条限制。

两处刻意的设计:
- **按模型族前缀匹配**而非完整 ID:尾部是日期版本(260628/260128),会随厂商
  小版本更新而变,按前缀匹配使一次升级不必改表。
- **未知模型一律放行**(只记日志):账号新开通一个模型时不该因为这张表没更新
  就用不了,否则「开通即可用」会退化成「开通还得改代码发版」。代价只是失去
  本地预校验,厂商仍会兜底。

测试 100 → 123:限制表 23 项逐条对应官方文档的一条限制,另在适配器层加 4 项
端到端断言(超范围请求的 handler 直接 assert 不该被调用,确认请求根本没发出)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:12:06 +08:00
lixi db87d66403 fix(ai): 修 ruff lint 三处错误——CI 由红转绿
CI / ai-gates (push) Successful in 31s
`4762358` 与 `86d63ab` 两次 CI 均因 `ruff check` 失败而红。

**根因是我的门禁自查方法错了**:用 `ruff check . | tail -1; echo $?` 读到的是
`tail` 的退出码而不是 ruff 的,于是把一次真实的 lint 失败读成了通过——先前声称
的「四道门禁全绿」是基于这个坏掉的检查得出的。已在 ci.yml 的 Gates 步骤就地注明
此坑,并改为逐条分开跑读各自退出码。

三处都是实际问题,非误报:
- `client.py:138` PYI034:`__enter__` 的返回注解硬编码为类名,应为 `Self`
  (子类化后注解会失准)
- `test_video.py:58` C408:`dict()` 调用改字面量
- `test_layering.py:85` PIE810:两次 `endswith` 合并为一次元组调用

修复后在与 CI 同构的隔离 venv(同镜像源、同 requirements-dev.txt,实测 ruff
版本与本地一致 0.16.8)逐道复验:secret-scan / format / lint / pytest 四道
退出码均为 0,100 测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:36:17 +08:00
lixi 86d63ab1cb docs: 补正 4762358 的提交记录——该提交实际包含 AI 服务分层重构
CI / ai-gates (push) Failing after 31s
**这是一条记录补正,不含代码改动。**

4762358 的提交信息只写了「凭证扫描放行依赖注入传参 + 四仓口径更正」,但它实际
包含了 patbond-ai 的整个分层重构(22 个文件、约 1900 行增、约 700 行删)。

成因:前一次提交尝试被 pre-commit 钩子拦下(它正确地拦了 assembly.py 里
`api_key=volcengine.api_key` 这个形态),但 `git add -A` 已经把重构文件留在
暂存区;随后修规则的那次提交只 `git add` 了脚本,却把暂存区里既有的内容一并
提交了。提交前未核对 `git status` 的暂存清单——正是 git-workflow.md「禁止事项」
第一条要求逐一核对的那一步。

不用 amend 重写历史:dev 是共享分支,git-workflow.md 明确禁止对其 force push。
故以本条补正记录说明 4762358 的真实内容,其完整说明如下。

---

**refactor(ai): 按高内聚低耦合分层重构 + 集中配置(100 测试)**

原先 seedance.py 一个文件里混了 HTTP 传输、鉴权、厂商 DTO、状态映射与视频业务,
接第二个火山接口(图片/语音)时只能整段再抄一遍。按依赖单向向内重切四层:

    config.py → assembly.py → providers/ → domain/

- **domain/generation.py**:与服务商无关的领域模型,对厂商与 httpx **零依赖**
  ——「换供应商不动上层」的支点。判断归属的准绳是「换一家供应商它还在吗」:
  Retry/UserFacing 描述 Worker 与客户端该怎么做,故在领域层;expired 状态、
  QuotaExceeded 这类码是火山特有的,故在适配层。GenerationResult 拒绝
  「FAILED 但无 Failure」与「SUCCEEDED 但无 asset_url」——半个失败态放给上层
  只会让它盲目重试或盲目放弃。
- **providers/base.py + registry.py**:能力抽象与 provider_code 分发,对具体
  供应商零依赖。registry 拒绝重复注册(静默覆盖会让「哪个实例在处理任务」
  取决于注册顺序)。
- **providers/volcengine/** 拆四块:client.py(HTTP 传输与鉴权,**不认识
  「视频」**,以后接火山图片/语音接口复用它——这是拆它的全部理由;并修正连接
  归属:只关自己创建的连接池)、errors.py(火山码 → 领域 Failure,补 message
  透传)、models.py(厂商 DTO 与常量,base URL 从 config 收拢至此)、
  video.py(仅视频业务)。
- **config.py**:集中配置。非法值响亮失败而不静默回落;厂商区间
  [3600,259200] 启动时校验;密钥绝无默认值。另加 .env.sample。
- **assembly.py**:装配根,唯一同时知道配置与具体供应商的地方。缺凭证的供应商
  被跳过而非让启动失败。
- **tests/test_layering.py**:用 AST 断言架构约束本身。分层的价值全在「换供应商
  不动上层」,而这个性质只要有人无意加一行 import 就会悄悄失效——靠纪律守不住,
  靠测试才守得住。写完它当即抓到一处真问题:厂商 base URL 硬编码在 config.py。

测试 58 → 100,全部离线。另两处新行为:succeeded 但缺 video_url 判为可重试失败;
asset_url 随 URL 给出 24 小时时效,使上层没有理由把厂商地址当永久存储。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:19:50 +08:00
lixi 4762358f54 fix(security): 凭证扫描放行依赖注入传参 + 四仓口径更正
CI / ai-gates (push) Failing after 29s
`api_key=volcengine.api_key` 这类**依赖注入传参**被 KEY-ASSIGN 判为疑似凭证。
这是规则的真缺口而非代码问题:行里没有任何秘密,而做 DI 的代码到处都是这种
写法——绕过一次就得在每个调用点绕过,那等于把规则废掉。故改规则。

新增 ALLOW_REFERENCE,刻意收得很紧以免开后门:
- 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状)
- 只允许**恰好一个点且随即行尾**
- 连字符不在字符集内

对抗性夹具验证(三真三假):
- 拦下 `api_key = "sk-T3Blbk…"`(OpenAI 形态)
- 拦下 `secret_key: "AKID…"`(腾讯云形态)
- 拦下 `auth_token=eyJ….eyJ….Sfl…`(**三段式 JWT——两个点,故不落进放行**)
- 放行 `api_key=lowercase.reference` / `api_key = settings.api_key` / `api_token: config.authToken`
原 5 形态泄漏夹具复验仍 8 条命中,一条不少。

另更正口径:脚本头与 git-workflow.md 仍写「三仓同构」,实际已是四仓——
patbond-ai 是唯一持有 AI 服务商凭证的仓,漏同步它风险最大。四仓 md5 已一致
(4945940093b4b8413b7d3eec8bf6a5ec),四仓 --all 全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:18:18 +08:00
lixi c6e8dddc8d feat(seedance): 火山方舟视频生成客户端与错误分类(58 测试,全离线)
CI / ai-gates (push) Successful in 31s
按用户提供的三份官方文档适配 Seedance 视频生成:提交
(POST /contents/generations/tasks)与查询(GET .../tasks/{id})。
取证记录见 patbond-doc docs/development/providers/volcengine-seedance.md。

**开工前问的五项,两项与原假设不同:**

1. **不支持幂等键**。请求参数全集里没有任何幂等字段。故 ADR-023 §4 设想的
   「把我方 idempotency_key 透传为厂商幂等头」对本供应商不成立,「提交超时后
   不知道有没有扣费」只能由 max_attempts 兜住——如实登记,不假装解决。
   两点减轻:提交是异步的(只入队即返回,超时概率低);且厂商支持 callback_url,
   孤儿任务会通过回调把 ID 送回来,故回调在此供应商上还是**孤儿回收通道**,
   不只是延迟优化——这是 ADR-023 §4「回调仅预留」之外的新论据,值得复评。
2. **产物是有效期 24 小时的 URL**(2.5 另有 100 次下载上限)。因此厂商 URL
   **不能当存储用**:存进 media.assets.external_url 会在 24h 后变死链。
   **这对挂起中的存储决策是硬约束**——无论选本地还是云,都必须在 24 小时内
   把文件复制到我们自己的存储。

其余三项:查询接口只保留 7 天记录;无明确频率数值,但官方 SDK 示例用 10s 轮询
(我们的 3s/10s/30s 阶梯与之兼容)、客户端截止 30 分钟(建议据此把 ADR-023 §10
的视频客户端超时从 15~20 分钟对齐到 30 分钟);execution_expires_after 范围
[3600, 259200],实现取下限 1 小时(厂商默认 48h 对交互式应用过长,且无法更短)。

**两个文档自身的问题,实现按宽的那份:**

- **状态枚举不一致**:查询文档列 5 个(queued/running/cancelled/succeeded/
  failed),创建文档的回调一节额外列 **expired**。只按查询文档实现会让超时
  任务掉进未知分支。按 6 个实现,expired 视为**可重试**失败(是超时不是内容
  问题),且厂商 expired 时不一定给 error.code 故单独分类。
- **QuotaExceeded 一码两义**:同一 code 既表示「排队任务数超限」(等几秒)
  又表示「额度耗尽」(等到明天或充值)。按 code 一刀切必然有一半场景的文案
  和出口是错的;只能按 message 判别,两条用例都在测试里钉住。

**顺带拿到两个免费收益**:last_frame_url 可直接作视频封面(iteration-4/03 R13
把缩略图列为视频成本项,实际免费);查询返回 ratio/resolution/framespersecond/
duration,**Worker 写产物时维度与时长都在手里、无需解码**——widthPx/heightPx
债在 AI 产物上可零成本修掉。

一处精度陷阱已处理:返回的 duration 是「帧数/24 向下取整」的约数(133 帧实际
5.54s 返回 5),且 duration 与 frames 只返回其一。故优先按 frames/fps 精确换算,
只有 duration 时退回整数秒并标注为约数,缺失时返回 None 而非编 0。

取消语义已查明:厂商**只支持取消 queued 状态**的任务,故我方取消已 running
的任务无法向厂商传导,**仍会计费**。ADR-023 §6 关于取消的说明据此可定稿。

测试 58 项(错误分类 32 + 客户端 26),全部离线用 httpx MockTransport,
夹具响应体逐字取自官方文档示例以免字段名对不上。

过程中防泄漏钩子拦下了测试夹具里的 `api_key="test-key"`——规则是对的(这正是
真实密钥最常见的泄漏形态),故改用 ALLOW 清单认可的占位词,未放宽规则。

未实现:产物转存(阻塞于存储决策)、HTTP 服务端点与 openapi-internal.yaml。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:51:37 +08:00
lixi 6990709124 chore: 建仓门禁四件套——CI、防泄漏脚本与钩子、gitignore、职责边界 README
CI / ai-gates (push) Successful in 47s
patbond-ai 纳入发布语义(ADR-023 §12)。本仓是四仓中唯一直接持有 AI
服务商凭证的仓库,因此不得成为唯一没有防泄漏门禁的仓库——建仓即配齐,
不等代码落地。

- .gitea/workflows/ci.yml:ai-gates 作业,push(dev) + pull_request 双触发,
  与另三仓同构。Secret scan 为第一个 step 且先于任何依赖安装。
  pip 走腾讯云镜像(与 patbond-doc 装 mkdocs 同一策略)。
  门禁命令 ruff format --check / ruff check / pytest,对应 flutter 仓的
  dart format + analyze + test 三步。requirements*.txt 落地前只装工具本身,
  使 CI 自建仓第一天即为绿;pytest 无测试时退出码 5 视为通过。
- scripts/check-secrets.sh:四仓同构副本,md5 640994c12bfa78b4512e872d709ed685。
  已覆盖 AI 服务商密钥形态(sk-ant- / sk- / sk-proj- / sk-svcacct-)与
  api_key/auth_token 实值赋值——本仓正是这些规则的主要适用对象。
- scripts/hooks/pre-commit:与另三仓同构。四仓 core.hooksPath 均已启用,
  ADR-023 §12 登记的「防泄漏第一层从未生效」至此闭环。
  实测验证:含 5 种真实凭证形态的 .env 夹具被钩子拦下,提交未产生。
- .gitignore:Python 产物与工具缓存;凭证载体(.env / credentials* / *.pem
  / *.key)一律排除,占位样例走 .env.sample(对应脚本的 DENY_NAME_OK)。
- README.md:职责边界(不访问数据库、不持有对象存储凭证、对上游同步异步
  一律对外呈现为异步)、与 Java 侧的接口约定(REST 非 gRPC、独立
  openapi-internal.yaml、服务身份 JWT 独立密钥对、请求体不含 userId)、
  本地门禁命令与凭证纪律。细节以 ADR-023 为准。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:42:34 +08:00
58 changed files with 7380 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# patbond-ai 配置样例。复制为 .env 后填入实际值——.env 已被 gitignore。
#
# 凭证纪律(ADR-021):真实密钥只允许存在于被忽略的文件中,本样例只放占位值。
# 被拦下时的第一动作是去服务商控制台轮换/禁用该密钥,然后才是清理提交。
# ---- 火山方舟(Ark)----
# 密钥无默认值。缺失时该适配器不装配,服务仍可启动并提供健康检查。
ARK_API_KEY=your-ark-api-key
# 北京区官方地址,一般无需改。
# ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
# 单次 HTTP 往返超时(秒)。**不是生成耗时**——提交接口是异步的(只入队即返回),
# 调大成「等出图」会重新引入长连接占用与计费歧义。
# ARK_TIMEOUT_SEC=10
# 视频模型 ID,仅作兜底。正常路径由 Java 侧传入任务行冻结的模型快照——
# 适配层不自行选模型,否则任务记录与实际调用会不一致、事后无法对账。
ARK_VIDEO_MODEL=
# 厂商 execution_expires_after(秒),区间 [3600, 259200]。
# 默认取下限 1 小时:厂商默认 48 小时对交互式应用过长,被放弃的任务会挂两天。
# ARK_TASK_EXPIRY_SEC=3600
# ---- OpenAI 图片(gpt-image-2)----
# 密钥无默认值。缺失时该适配器不装配,服务仍可启动并提供健康检查。
OPENAI_API_KEY=your-openai-api-key
# OPENAI_BASE_URL=https://api.openai.com/v1
# 属于多个组织时才需要指定。
# OPENAI_ORGANIZATION=
# ⚠️ 与 ARK_TIMEOUT_SEC 语义不同:OpenAI 图片接口是**同步**的,一次请求里等到
# 图片生成完才返回,故这个值**确实是生成耗时的上限**,不是短往返的上限。
# OPENAI_TIMEOUT_SEC=180
# 图片模型 ID,仅作兜底。同 ARK_VIDEO_MODEL:正常由 Java 侧传入任务行的快照。
OPENAI_IMAGE_MODEL=
# 同时执行的同步调用数上限。这同时也是「同时有多少钱在花」的上界——超出的提交
# 排队,而不是一起打到服务商那里。
# OPENAI_MAX_CONCURRENT=4
# 已完成任务在内存注册表里的保留时长(秒)。**必须大于 Java 侧最长的轮询间隔**
# (当前阶梯最长 30 秒),否则结果会在被取走前被清掉——症状是「任务成功了但查
# 不到」,极难查。
# OPENAI_COMPLETED_TTL_SEC=900
# 注册表条目上限。满了以后新提交被拒(503),**不挤掉在途任务**——挤掉一个在途
# 任务会让一次可能正在计费的调用变成无人认领的开销。
# OPENAI_MAX_INFLIGHT=256
# ---- 服务身份 JWT(ADR-023 §5)----
# 与 patbond-creation 持有的私钥配对的 RS256 公钥。注意这是一对**独立于用户
# token 的**密钥:现有 JwtVerifier 只验签名、不校验 aud/iss,复用同一密钥对会
# 让服务票被四个 Java 模块当成合法用户 token 接受。
PATBOND_SERVICE_JWT_PUBLIC_KEY=
# PATBOND_SERVICE_JWT_ISSUER=patbond-api
# audience 必须强制校验,否则独立密钥对失去意义。
# PATBOND_SERVICE_JWT_AUDIENCE=patbond-ai
# PATBOND_SERVICE_JWT_TYPE=service
# ---- 其他 ----
# PATBOND_AI_LOG_LEVEL=INFO
+65
View File
@@ -0,0 +1,65 @@
# Gitea Actions 门禁:与本地门禁同一套命令(规范见 patbond-doc
# docs/development/git-workflow.md)。零外部 action / 零 GitHub 依赖,
# 原因见 patbond-api 的 ci.yml 头注释。
# pip 与 PyPI 走腾讯云镜像,与 patbond-doc 的 mkdocs 安装同一策略。
#
# 本仓是唯一存放 AI 服务商凭证的仓库,因此 Secret scan 是第一个 step,
# 且先于任何依赖安装执行(ADR-021、ADR-023 §12)。
name: CI
on:
push:
branches: [dev]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ai-gates:
runs-on: ubuntu-latest
steps:
- name: Checkout (manual)
run: |
git init -q .
AUTH_URL=$(echo "${{ github.server_url }}" | sed "s#https://#https://oauth2:${{ github.token }}@#")
git remote add origin "$AUTH_URL/${{ github.repository }}.git"
git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head"
git checkout -q refs/ci-head
# 凭证防泄漏兜底(ADR-021):与本地 pre-commit 同一脚本、同一规则表,
# 四仓副本内容同构,扫全部已跟踪文件(本次 push 变更的超集)。
- name: Secret scan
run: sh scripts/check-secrets.sh --all
- name: Set up Python
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends python3 python3-venv python3-pip
python3 -m venv .venv
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
echo "PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple" >> "$GITHUB_ENV"
- name: Install dependencies
run: |
python -m pip install -q --upgrade pip
# requirements-dev.txt 落地前,门禁只装 lint/test 工具本身,
# 使 CI 从建仓第一天就是绿的而非「等代码来了再配」。
if [ -f requirements-dev.txt ]; then
pip install -q -r requirements-dev.txt
else
pip install -q ruff pytest
fi
if [ -f requirements.txt ]; then pip install -q -r requirements.txt; fi
# 与本地门禁同一套命令。ruff 兼任 format 检查与 lint,
# 对应 patbond-flutter 的 dart format + flutter analyze 两步。
#
# 本地自查时注意:`ruff check . | tail -1; echo $?` 读到的是 **tail** 的
# 退出码,不是 ruff 的——这样会把一次真实的 lint 失败读成通过(本仓已因此
# 红过一次)。逐条分开跑,或用 ${PIPESTATUS[0]}。
- name: Gates
run: |
python --version
ruff format --check .
ruff check .
# 尚无测试文件时 pytest 退出码为 5(no tests collected),
# 该情形视为通过;真实失败仍会以非 0/5 退出。
pytest -q || [ $? -eq 5 ]
+57
View File
@@ -0,0 +1,57 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Keep application sources visible even if a global gitignore ignores lib/.
!/lib/
!/lib/**
# Virtualenvs
.venv/
venv/
env/
ENV/
# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
.coverage.*
htmlcov/
coverage.xml
# 凭证载体一律不入库(ADR-021)。真实值只存在于被忽略的文件中,
# 占位样例走 .env.sample / *.example(check-secrets.sh 的 DENY_NAME_OK)。
.env
.env.*
!.env.sample
!.env.example
credentials*
*.pem
*.key
# 本地产物与临时文件
*.log
*.swp
.DS_Store
tmp/
.idea/
.vscode/
+212
View File
@@ -0,0 +1,212 @@
# 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`。
View File
+25
View File
@@ -0,0 +1,25 @@
"""启动入口:`python -m app`。
配置全部从环境变量读(见 `app/config.py` 与 `.env.sample`)。缺服务商凭证时服务
仍会启动,只是该适配器不装配——`/health` 会报出实际装配了哪些,便于确认。
"""
from __future__ import annotations
import os
import uvicorn
from app.api.main import create_app
def main() -> None:
# 只监听回环是默认:本服务只接受来自 patbond-creation 的内部调用,
# 不该对外可达(与项目「默认拒绝、需要外部访问时经 nginx 反代」的纪律一致)。
host = os.environ.get("PATBOND_AI_HOST", "127.0.0.1")
port = int(os.environ.get("PATBOND_AI_PORT", "8000"))
uvicorn.run(create_app(), host=host, port=port)
if __name__ == "__main__":
main()
View File
+78
View File
@@ -0,0 +1,78 @@
"""服务身份 JWT 校验(ADR-023 §5)。
本模块是**唯一**的鉴权入口。三条纪律,每条都有测试钉住:
1. **强制校验 `aud` 与 `typ`。** 这是独立密钥对方案唯一的意义所在——Java 侧那边
现有的 `JwtVerifier` 只验签名、不校验 `aud`/`iss`,正是因为如此才必须用一对
独立密钥;如果本服务也只验签名,那对独立密钥就白搭了。
2. **用户 token 一律拒绝。** 它们由另一对密钥签发,签名校验本身就会失败;但即使
有人误把用户密钥配到这里,缺 `aud` 与 `typ=service` 也会被拦下——两道。
3. **缺公钥时拒绝所有请求,而不是放行。** 配置缺失必须是「全拒」而非「全过」:
前者立刻暴露,后者是敞开的门。这与 Java 侧 `InternalAuthFilter` 未配置
`internal-token` 时全拒 `/internal/**` 的既有选择一致。
"""
from __future__ import annotations
import logging
import jwt
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import ServiceAuthSettings
from .errors import ServiceError, ServiceErrorCode
log = logging.getLogger(__name__)
#: auto_error=False:自己产生 401 以便统一走 service 错误信封,
#: 而不是 FastAPI 默认的 `{"detail": ...}`——后者与本契约的两类错误形状都不符。
_bearer = HTTPBearer(auto_error=False)
def verify_service_token(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> None:
"""校验服务票;不通过即抛 `ServiceError(unauthenticated)`。
所有失败都返回同一个笼统原因,**不告诉调用方是签名错、过期、还是 aud 不符**。
对方是我们自己的服务,真正的原因在本服务日志里;把细节回给调用方只会在将来
某天变成攻击者的提示。
"""
settings: ServiceAuthSettings = request.app.state.settings.service_auth
if not settings.public_key:
# 配置缺失 → 全拒。绝不「未配置即放行」。
log.error("PATBOND_SERVICE_JWT_PUBLIC_KEY 未配置,/internal/** 全部拒绝")
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "服务端未配置服务票公钥")
if credentials is None or not credentials.credentials:
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "缺少服务票")
try:
claims = jwt.decode(
credentials.credentials,
settings.public_key,
algorithms=["RS256"],
# audience 与 issuer 交给库强制校验,而不是解出来自己比——
# 自己比容易漏掉「claim 不存在」这一路。
audience=settings.audience,
issuer=settings.issuer,
options={"require": ["exp", "aud", "iss", "sub"]},
)
except jwt.InvalidTokenError as exc:
# 具体原因只进日志。
log.warning("服务票校验失败:%s", exc)
raise ServiceError(
ServiceErrorCode.UNAUTHENTICATED, "服务票无效或已过期"
) from exc
token_type = claims.get("typ")
if token_type != settings.required_type:
# 用户 token 走到这里的唯一可能是有人误配了用户密钥。拦住它。
log.warning(
"服务票 typ 不符:期望 %s,实际 %r", settings.required_type, token_type
)
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "服务票类型不符")
+86
View File
@@ -0,0 +1,86 @@
"""两类错误的异常与信封(契约 `openapi-internal.yaml` 的核心设计)。
**两类失败必须分开**,因为调用方对它们的反应完全不同:
- `service`(Java↔Python):我方 bug 或配置问题。除 `internal_error` /
`provider_unavailable` 外都不该重试——重试一个拼错的请求体不会变好。
- `provider`(Python↔服务商):以 `retry` 字段为准,调用方不该自己解读厂商码。
还有**第三类不在这里**:任务本身失败(生成跑完但失败了)走 `200 + state=failed`
+ `failure`。因为那种情况下 **HTTP 调用是成功的**,失败的是任务。做成错误响应会
让「查不到任务」与「任务失败了」难以区分。
判别字段是 `error.kind`,**不是 HTTP 状态码**:状态码在两类之间会撞(502 既可能是
服务商挂了也可能是我们打错地址),而 `kind` 不会。
"""
from __future__ import annotations
from enum import Enum
from app.domain.generation import Failure
class ServiceErrorCode(Enum):
"""`service` 类错误码,与契约的枚举逐一对应。"""
#: 服务票缺失、过期、签名错、aud/typ 不符。细节只进日志。
UNAUTHENTICATED = ("unauthenticated", 401)
#: 请求体不合契约,或参数超出该模型的限制(本地预校验拦下)。
INVALID_REQUEST = ("invalid_request", 400)
#: 路径里的服务商任务 ID 不存在(含超过服务商 7 天保留期)。
TASK_NOT_FOUND = ("task_not_found", 404)
#: 适配器未装配(如缺服务商凭证)或 media_kind 无对应适配器。
PROVIDER_UNAVAILABLE = ("provider_unavailable", 503)
#: 本服务 bug。
INTERNAL_ERROR = ("internal_error", 500)
def __init__(self, code: str, http_status: int):
self.code = code
self.http_status = http_status
class ServiceError(Exception):
"""Java↔Python 之间的问题。"""
def __init__(self, code: ServiceErrorCode, message: str):
super().__init__(message)
self.code = code
self.message = message
def to_envelope(self) -> dict:
return {
"error": {
"kind": "service",
"code": self.code.code,
"message": self.message,
}
}
class ProviderError(Exception):
"""Python↔服务商之间的问题,携带完整分类。
字段与 `Failure` 一致**是刻意的**:调用方因此可以复用同一段处置逻辑,不必为
「提交时被限流」和「任务失败于限流」写两套分支。
"""
#: 502:本服务本身没问题,是上游调用没成功。
#: 刻意不透传服务商的状态码——429 既可能是「等几秒」也可能是「等到明天」,
#: 状态码表达不了这个区别,`retry` 才是权威信号。
HTTP_STATUS = 502
def __init__(self, failure: Failure):
super().__init__(failure.message or failure.provider_code)
self.failure = failure
def to_envelope(self) -> dict:
return {
"error": {
"kind": "provider",
"retry": self.failure.retry.value,
"user_facing": self.failure.user_facing.value,
"provider_code": self.failure.provider_code,
"message": self.failure.message,
}
}
+121
View File
@@ -0,0 +1,121 @@
"""FastAPI 应用工厂。
装配顺序:读配置 → 建适配器注册表 → 挂路由与异常处理器。配置与注册表放在
`app.state` 上,请求处理时从 `request.app` 取——**不用模块级全局**,这样测试能起
多个配置不同的实例而互不干扰。
**异常处理器是本模块的重点**:契约定义了两类错误信封(`kind = service` /
`provider`),而 FastAPI 默认的校验错误是 `{"detail": [...]}`,与两者都不符。若不
接管,调用方就要处理三种形状——而第三种还是框架的实现细节。
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.assembly import build_registry
from app.config import Settings
from .errors import ProviderError, ServiceError, ServiceErrorCode
from .routes import router
log = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI:
"""建应用。
:param settings: 省略即从环境变量读。测试传入显式配置以免依赖环境。
"""
resolved = settings or Settings.from_env()
logging.basicConfig(level=resolved.log_level)
app = FastAPI(
title="patbond-ai 内部接口",
version="0.1.0",
description=(
"patbond-creation(Java)调用本服务执行 AI 生成的内部契约。"
"权威契约见仓库根目录的 openapi-internal.yaml——"
"本服务自动生成的 /openapi.json 仅供调试,不是契约。"
),
# 内部服务不开交互文档:少一个对外表面,且契约以手写 yaml 为准。
docs_url=None,
redoc_url=None,
)
app.state.settings = resolved
app.state.registry = build_registry(resolved)
app.include_router(router)
_install_exception_handlers(app)
_install_health(app)
return app
def _install_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(ServiceError)
def handle_service_error(request: Request, exc: ServiceError) -> JSONResponse:
return JSONResponse(status_code=exc.code.http_status, content=exc.to_envelope())
@app.exception_handler(ProviderError)
def handle_provider_error(request: Request, exc: ProviderError) -> JSONResponse:
log.warning(
"服务商调用失败 code=%s retry=%s",
exc.failure.provider_code,
exc.failure.retry.value,
)
return JSONResponse(
status_code=ProviderError.HTTP_STATUS, content=exc.to_envelope()
)
@app.exception_handler(RequestValidationError)
def handle_validation_error(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""把 FastAPI 的校验错误翻成 service 信封。
默认形状是 `{"detail": [...]}`,与契约的两类信封都不符。不接管的话调用方
要处理三种形状,而第三种还是框架的实现细节——换个框架就变了。
"""
first = exc.errors()[0] if exc.errors() else {}
# 字段路径形如 ("body", "duration_sec"),去掉 "body" 前缀更可读。
location = ".".join(
str(part) for part in first.get("loc", ()) if part != "body"
)
detail = first.get("msg", "请求体不合契约")
message = f"{location}: {detail}" if location else detail
error = ServiceError(ServiceErrorCode.INVALID_REQUEST, message)
return JSONResponse(
status_code=error.code.http_status, content=error.to_envelope()
)
@app.exception_handler(Exception)
def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
"""兜底:本服务的 bug。
完整堆栈只进日志,绝不回给调用方——内部细节外泄没有任何好处。
"""
log.exception("未处理的异常")
error = ServiceError(ServiceErrorCode.INTERNAL_ERROR, "服务内部错误")
return JSONResponse(
status_code=error.code.http_status, content=error.to_envelope()
)
def _install_health(app: FastAPI) -> None:
@app.get("/health", include_in_schema=False)
def health() -> dict:
"""探活。**在 /internal 之外,故不需要服务票**——与 Java 侧各服务
`/health` 不挂鉴权的既有约定一致。
同时报出已装配的适配器:缺凭证时适配器不装配,而那个症状(任务永远排队)
离根因很远,让探活直接说出来能省很多排查时间。
"""
registry = app.state.registry
return {
"status": "ok",
"providers": sorted(registry.codes),
}
+123
View File
@@ -0,0 +1,123 @@
"""内部接口路由。
**本层只做编排,不含任何厂商知识**:解析请求 → 按 `media_kind` 找适配器 → 调用 →
把领域结果序列化。厂商差异全在 `app/providers/<厂商>/` 内。
一处刻意的选择:路径参数是**服务商的任务 ID**,不是我方 `job_id`。本服务不持有
状态,唯一能用来问服务商的就是这个 ID;若用 `job_id` 就得存一份映射,「无状态」
不再成立,且会多出一个「Java 拿到 ID 但查不到任何东西」的中间态。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Request
from app.domain.generation import MediaKind
from app.providers.base import GenerationProvider
from app.providers.openai.client import OpenAiError
from app.providers.registry import ProviderRegistry
from app.providers.volcengine.client import ArkError
from .auth import verify_service_token
from .errors import ProviderError, ServiceError, ServiceErrorCode
from .schemas import SubmitRequest, SubmitResponse, TaskResponse
log = logging.getLogger(__name__)
router = APIRouter(
prefix="/internal/generation",
dependencies=[Depends(verify_service_token)],
)
def _registry(request: Request) -> ProviderRegistry:
return request.app.state.registry
def _provider_for(
registry: ProviderRegistry, media_kind: MediaKind
) -> GenerationProvider:
"""按 `media_kind` 取适配器。
查不到即 `provider_unavailable`(503)而不是 500:这通常是凭证未配置导致适配器
没装配,属环境问题而非代码缺陷,且对调用方而言是**可重试**的——配好就能用。
"""
for provider in registry.all():
if provider.media_kind is media_kind:
return provider
raise ServiceError(
ServiceErrorCode.PROVIDER_UNAVAILABLE,
f"没有可用的 {media_kind.value} 生成适配器(检查服务商凭证是否已配置)",
)
@router.post("/tasks", response_model=SubmitResponse)
def submit_task(payload: SubmitRequest, request: Request) -> SubmitResponse:
"""提交生成任务,返回服务商任务 ID。
调用方**必须在上游产生任何计费动作之前**把该 ID 落库——火山不支持幂等键,
这是提交超时后唯一能判断「是否已提交」的依据。
"""
provider = _provider_for(_registry(request), payload.media_kind)
try:
provider_request_id = provider.submit(payload.to_domain())
except (ArkError, OpenAiError) as exc:
# 服务商调用失败:原样带上分类,调用方据 retry 决定重试。
raise ProviderError(exc.failure) from exc
except ValueError as exc:
# 本地预校验拦下的参数问题(超出该模型的时长/尺寸/ratio 限制、内容为空、
# 参考图不可下载)。属 invalid_request 而非 provider 错误——请求根本没发出去。
raise ServiceError(ServiceErrorCode.INVALID_REQUEST, str(exc)) from exc
except RuntimeError as exc:
# 同步型适配器的在途任务已达上限。这不是请求的问题而是容量问题,且稍后
# 重试会好,故归 provider_unavailable(503)而非 invalid_request——后者会
# 让调用方判为不可重试并直接失败掉一个本可成功的任务。
raise ServiceError(ServiceErrorCode.PROVIDER_UNAVAILABLE, str(exc)) from exc
log.info(
"已提交生成任务 job_id=%s provider_request_id=%s",
payload.job_id,
provider_request_id,
)
return SubmitResponse(provider_request_id=provider_request_id)
@router.get("/tasks/{provider_request_id}", response_model=TaskResponse)
def get_task(
provider_request_id: str,
request: Request,
media_kind: MediaKind = MediaKind.VIDEO,
) -> TaskResponse:
"""查询任务。
**任务失败也返回 200**:HTTP 调用成功了,失败的是任务,`state=failed` +
`failure` 表达它。做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
:param media_kind: 决定问哪个适配器。默认 video——当前只有视频适配器;
接入图片后调用方应显式传入。刻意不做「遍历所有适配器试一遍」:那会把
一个任务 ID 拿去问不相干的服务商。
"""
provider = _provider_for(_registry(request), media_kind)
try:
result = provider.fetch(provider_request_id)
except KeyError as exc:
# 同步型适配器的本地注册表里没有这个 ID:从未存在过,或进程重启后丢失
# (两者无法区分)。两种情形下调用方都该**停止轮询**而非退避重试。
raise ServiceError(
ServiceErrorCode.TASK_NOT_FOUND,
f"本服务无此任务(可能因进程重启而丢失):{provider_request_id}",
) from exc
except ArkError as exc:
# 服务商说任务不存在(含超过 7 天保留期)→ 这是 service 类的 task_not_found,
# 而不是 provider 错误:调用方要做的是停止轮询,不是退避重试。
if exc.http_status == 404:
raise ServiceError(
ServiceErrorCode.TASK_NOT_FOUND,
f"服务商无此任务(或已超出其 7 天保留期):{provider_request_id}",
) from exc
raise ProviderError(exc.failure) from exc
except OpenAiError as exc:
raise ProviderError(exc.failure) from exc
return TaskResponse.of(result)
+142
View File
@@ -0,0 +1,142 @@
"""请求/响应的 pydantic 模型。
字段名为 **snake_case**,与契约一致。这使 JSON、pydantic 模型、领域 dataclass
三者字段逐一对应,**无需任何转换层**——若用 camelCase,两端都要加转换。
这层与 `app/domain/generation.py` 的分工:领域模型是纯数据、无框架依赖(那是
「换供应商不动上层」的支点);本层负责 HTTP 边界的校验与序列化。两者字段重合,
但让领域层依赖 pydantic 会把框架拖进本该最干净的一层。
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.generation import (
GenerationRequest,
GenerationResult,
MediaKind,
)
class SubmitRequest(BaseModel):
"""提交生成任务。
**刻意不含 `user_id`**(数据最小化,ADR-023 §5):本服务干活只需要任务参数。
需要给服务商做滥用检测时传的是 `safety_identifier`——一个哈希,不是明文 ID。
"""
# 拒绝未声明的字段:调用方拼错字段名时应当立刻知道,而不是让那个参数被静默
# 忽略——后者会表现为「我传了 duration 却没生效」这类极难查的问题。
model_config = ConfigDict(extra="forbid")
job_id: str
media_kind: MediaKind
model: str = Field(min_length=1)
prompt: str | None = Field(default=None, max_length=10000)
input_url: str | None = None
ratio: str | None = None
resolution: str | None = None
#: 图片路径用像素尺寸;视频路径用 ratio+resolution。两家服务商的尺寸表达方式
#: 不同,契约同时保留两种而不强行统一——任何统一都会在某一侧失真。
width_px: int | None = None
height_px: int | None = None
duration_sec: int | None = None
generate_audio: bool = True
watermark: bool = False
safety_identifier: str | None = Field(default=None, max_length=64)
callback_url: str | None = None
upload_target: str | None = None
@model_validator(mode="after")
def require_prompt_or_input(self) -> SubmitRequest:
"""`prompt` 与 `input_url` 至少要有一个。
空内容必然被服务商拒,但那要花一次往返、还可能计入 RPM。本地先拦。
"""
if not self.prompt and not self.input_url:
raise ValueError("prompt 与 input_url 至少要有一个")
return self
def to_domain(self) -> GenerationRequest:
return GenerationRequest(
job_id=self.job_id,
media_kind=self.media_kind,
model=self.model,
prompt=self.prompt,
input_url=self.input_url,
ratio=self.ratio,
resolution=self.resolution,
width_px=self.width_px,
height_px=self.height_px,
duration_sec=self.duration_sec,
generate_audio=self.generate_audio,
watermark=self.watermark,
safety_identifier=self.safety_identifier,
callback_url=self.callback_url,
upload_target=self.upload_target,
)
class SubmitResponse(BaseModel):
provider_request_id: str
class FailureModel(BaseModel):
"""失败分类。三个字段各有明确消费方,见契约说明。"""
retry: str
user_facing: str
provider_code: str
message: str = ""
class TaskResponse(BaseModel):
"""任务当前状态。
注意 `state` 的取值集**比服务商的更小**:服务商的 `queued` 映射为 `pending`、
`expired` 归入 `failed`(它是超时,属失败的一种,重试策略由 `failure` 表达)。
"""
provider_request_id: str
state: str
progress: int
asset_url: str | None = None
asset_url_expires_in_sec: int | None = None
cover_url: str | None = None
ratio: str | None = None
resolution: str | None = None
width_px: int | None = None
height_px: int | None = None
duration_ms: int | None = None
output_format: str | None = None
billing_tokens: int | None = None
failure: FailureModel | None = None
@classmethod
def of(cls, result: GenerationResult) -> TaskResponse:
return cls(
provider_request_id=result.provider_request_id,
state=result.state.value,
progress=result.progress,
asset_url=result.asset_url,
asset_url_expires_in_sec=result.asset_url_expires_in_sec,
cover_url=result.cover_url,
ratio=result.ratio,
resolution=result.resolution,
width_px=result.width_px,
height_px=result.height_px,
duration_ms=result.duration_ms,
output_format=result.output_format,
billing_tokens=result.billing_tokens,
failure=(
FailureModel(
retry=result.failure.retry.value,
user_facing=result.failure.user_facing.value,
provider_code=result.failure.provider_code,
message=result.failure.message,
)
if result.failure
else None
),
)
+79
View File
@@ -0,0 +1,79 @@
"""装配根:把配置变成可用的适配器。
**这是唯一允许同时知道「配置」与「具体供应商」的地方。** 其余模块只依赖抽象:
适配器通过构造参数拿配置(而非自己读环境变量),上层通过 `ProviderRegistry`
按 `provider_code` 拿适配器。新增一家供应商只需在此登记一行。
`app/providers/base.py`、`registry.py` 与 `app/domain/` 对具体供应商零依赖,
这一点由 `tests/test_layering.py` 自动守着——靠纪律守不住,靠测试才守得住。
"""
from __future__ import annotations
import logging
from app.config import Settings
from app.providers.base import GenerationProvider
from app.providers.openai.client import OpenAiClient
from app.providers.openai.image import OpenAiImageProvider
from app.providers.openai.tasks import SyncTaskRegistry
from app.providers.registry import ProviderRegistry
from app.providers.volcengine.client import ArkClient
from app.providers.volcengine.video import SeedanceVideoProvider
log = logging.getLogger(__name__)
def build_registry(settings: Settings) -> ProviderRegistry:
"""按配置装配所有可用的适配器。
**缺凭证的供应商被跳过而不是让装配失败**:本地只配了一家凭证时,服务仍应能
起来并提供其余能力与健康检查。真正的失败点在调用时——那时 `find()` 返回
None,调用方按「适配器不可用」处理,而不是把一个半死的实例放出去。
"""
providers: list[GenerationProvider] = []
volcengine = settings.volcengine
if volcengine.api_key:
providers.append(
SeedanceVideoProvider(
ArkClient(
api_key=volcengine.api_key,
base_url=volcengine.base_url,
timeout_sec=volcengine.timeout_sec,
),
default_model=volcengine.video_model,
task_expiry_sec=volcengine.task_expiry_sec,
)
)
else:
# 警告而非静默:这是最容易被忽略的配置缺失,而症状(任务永远排队)
# 与它相隔很远。
log.warning("ARK_API_KEY 未配置,火山方舟视频适配器未装配")
openai = settings.openai
if openai.api_key:
providers.append(
OpenAiImageProvider(
OpenAiClient(
api_key=openai.api_key,
base_url=openai.base_url,
timeout_sec=openai.timeout_sec,
organization=openai.organization or None,
),
# 同步→异步的任务注册表。每个适配器实例一份:它承载的是「本进程
# 内哪些同步调用正在跑」,共享一份会让两个适配器互相挤占容量。
SyncTaskRegistry(
max_workers=openai.max_concurrent,
completed_ttl_sec=openai.completed_ttl_sec,
max_entries=openai.max_inflight,
),
default_model=openai.image_model,
)
)
else:
log.warning("OPENAI_API_KEY 未配置,图片生成适配器未装配")
registry = ProviderRegistry(providers)
log.info("已装配 %d 个生成适配器:%s", len(registry), sorted(registry.codes))
return registry
+181
View File
@@ -0,0 +1,181 @@
"""集中配置:全部旋钮从环境变量读,一处定义。
为什么不用 pydantic-settings:当前运行时依赖只有 httpx 一个,为十来个环境变量
引入一个框架不划算。需要嵌套配置或复杂校验时再换。
**凭证纪律**(ADR-021):密钥只从环境变量读,**绝不写默认值**。缺失时
`from_env()` 不抛异常——服务仍可启动并提供健康检查,只在真正要调用厂商时才
响亮失败。这与 Java 侧 `JwtVerifier`/`ServiceTokenSigner` 的既有约定一致:
缺配置是服务端错误,不该伪装成上游故障,但也不该让整个进程起不来。
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from app.providers.openai.client import DEFAULT_BASE_URL as OPENAI_DEFAULT_BASE_URL
from app.providers.volcengine.models import DEFAULT_BASE_URL
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def _env_int(name: str, default: int) -> int:
raw = _env(name)
if not raw:
return default
try:
return int(raw)
except ValueError as exc:
# 静默回落到默认值会让一次配置笔误变成难查的行为偏差。
raise ValueError(f"环境变量 {name} 不是合法整数:{raw!r}") from exc
def _env_float(name: str, default: float) -> float:
raw = _env(name)
if not raw:
return default
try:
return float(raw)
except ValueError as exc:
raise ValueError(f"环境变量 {name} 不是合法数字:{raw!r}") from exc
@dataclass(frozen=True)
class VolcengineSettings:
"""火山方舟(Ark)接入配置。
:param api_key: `ARK_API_KEY`。**无默认值**,缺失时适配层在调用点报错。
:param timeout_sec: 单次 HTTP 往返超时。**不是生成耗时**——提交与查询都是
短请求(提交只入队即返回),把它调大成「等出图」会重新引入长连接占用与
计费歧义,见 ADR-023 §4。
:param video_model: 视频生成的默认模型 ID。仅作**兜底**:正常路径由 Java 侧
从目录表的快照列传入,适配层不自行选模型(选型是目录的职责,且任务行里
冻结的快照必须与实际调用的模型一致)。
:param task_expiry_sec: 传给厂商的 `execution_expires_after`。厂商取值范围
`[3600, 259200]`,默认 48 小时对交互式应用过长,故默认取下限 1 小时。
"""
api_key: str = ""
base_url: str = DEFAULT_BASE_URL
timeout_sec: float = 10.0
video_model: str = ""
task_expiry_sec: int = 3600
@classmethod
def from_env(cls) -> VolcengineSettings:
return cls(
api_key=_env("ARK_API_KEY"),
base_url=_env("ARK_BASE_URL", cls.base_url),
timeout_sec=_env_float("ARK_TIMEOUT_SEC", cls.timeout_sec),
video_model=_env("ARK_VIDEO_MODEL"),
task_expiry_sec=_env_int("ARK_TASK_EXPIRY_SEC", cls.task_expiry_sec),
)
def __post_init__(self) -> None:
# 厂商硬性区间。设成区间外的值会在提交时被厂商拒(InvalidParameter),
# 那要花一次往返才知道,不如在启动时就说清楚。
if not 3600 <= self.task_expiry_sec <= 259200:
raise ValueError(
f"ARK_TASK_EXPIRY_SEC 必须在 [3600, 259200] 之间(厂商限制),"
f"当前 {self.task_expiry_sec}"
)
@dataclass(frozen=True)
class OpenAiSettings:
"""OpenAI 图片接入配置。
:param api_key: `OPENAI_API_KEY`。**无默认值**,缺失时该适配器不装配。
:param timeout_sec: **这里确实是生成耗时的上限**,与火山那边不同——OpenAI 的
图片接口是同步的,一次请求里等到图片生成完才返回。故默认值比火山的
「短往返超时」大一个量级。
:param image_model: 图片模型 ID,仅作兜底;正常由 Java 侧传入任务行的快照。
:param max_concurrent: 同时执行的同步调用数上限。**同时也是对服务商并发和
「同时有多少钱在花」的自我限制**——超出的提交排队而非一起打过去。
:param completed_ttl_sec: 已完成任务在内存注册表里的保留时长。须大于 Java 侧
最长的轮询间隔(当前阶梯最长 30 秒),否则结果会在被取走前被清理。
"""
api_key: str = ""
base_url: str = OPENAI_DEFAULT_BASE_URL
timeout_sec: float = 180.0
image_model: str = ""
organization: str = ""
max_concurrent: int = 4
completed_ttl_sec: float = 900.0
max_inflight: int = 256
@classmethod
def from_env(cls) -> OpenAiSettings:
return cls(
api_key=_env("OPENAI_API_KEY"),
base_url=_env("OPENAI_BASE_URL", cls.base_url),
timeout_sec=_env_float("OPENAI_TIMEOUT_SEC", cls.timeout_sec),
image_model=_env("OPENAI_IMAGE_MODEL"),
organization=_env("OPENAI_ORGANIZATION"),
max_concurrent=_env_int("OPENAI_MAX_CONCURRENT", cls.max_concurrent),
completed_ttl_sec=_env_float(
"OPENAI_COMPLETED_TTL_SEC", cls.completed_ttl_sec
),
max_inflight=_env_int("OPENAI_MAX_INFLIGHT", cls.max_inflight),
)
def __post_init__(self) -> None:
if self.max_concurrent < 1:
raise ValueError(
f"OPENAI_MAX_CONCURRENT 必须 >= 1,当前 {self.max_concurrent}"
)
# 小于最长轮询间隔会让结果在被取走前就被清掉——那表现为「任务成功了但查
# 不到」,极难查。
if self.completed_ttl_sec < 60:
raise ValueError(
f"OPENAI_COMPLETED_TTL_SEC 不应小于 60 秒(须大于 Java 侧最长轮询"
f"间隔,否则结果会在被取走前被清理),当前 {self.completed_ttl_sec}"
)
@dataclass(frozen=True)
class ServiceAuthSettings:
"""校验 Java 侧服务身份 JWT 所需的配置(ADR-023 §5)。
:param public_key: 与 `patbond-creation` 持有的私钥配对的 RS256 公钥。
注意这是一对**独立于用户 token 的**密钥——理由见 ADR-023 §5。
:param audience: 必须强制校验。缺了这一步,任何用同一密钥签出的 token 都能
进来,而独立密钥对的意义正在于此。
"""
public_key: str = ""
issuer: str = "patbond-api"
audience: str = "patbond-ai"
required_type: str = "service"
@classmethod
def from_env(cls) -> ServiceAuthSettings:
return cls(
public_key=_env("PATBOND_SERVICE_JWT_PUBLIC_KEY"),
issuer=_env("PATBOND_SERVICE_JWT_ISSUER", cls.issuer),
audience=_env("PATBOND_SERVICE_JWT_AUDIENCE", cls.audience),
required_type=_env("PATBOND_SERVICE_JWT_TYPE", cls.required_type),
)
@dataclass(frozen=True)
class Settings:
"""本服务的全部配置。"""
volcengine: VolcengineSettings = field(default_factory=VolcengineSettings)
openai: OpenAiSettings = field(default_factory=OpenAiSettings)
service_auth: ServiceAuthSettings = field(default_factory=ServiceAuthSettings)
log_level: str = "INFO"
@classmethod
def from_env(cls) -> Settings:
return cls(
volcengine=VolcengineSettings.from_env(),
openai=OpenAiSettings.from_env(),
service_auth=ServiceAuthSettings.from_env(),
log_level=_env("PATBOND_AI_LOG_LEVEL", cls.log_level).upper(),
)
View File
+162
View File
@@ -0,0 +1,162 @@
"""与服务商无关的生成领域模型。
**本模块不得 import 任何服务商代码,也不得 import httpx。** 这不是洁癖,是本层
存在的唯一理由——Java 侧、未来的 gpt-image-2、以及任何第三家供应商都对着这一层
编程。一旦这里漏进一个厂商概念,「换供应商不动上层」就不再成立,而那正是分层
想买到的东西。
判断某个概念该放这里还是放 `providers/<厂商>/`:问「换一家供应商,这个概念还在
吗」。`Retry`/`UserFacing` 描述的是 **Worker 与客户端该怎么做**,与谁家供应商
无关,故在此;`expired` 状态、`QuotaExceeded` 这类码是火山特有的,故不在此。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class MediaKind(Enum):
"""生成产物的类型,与 `creation.generation_jobs.media_kind` 同值。"""
IMAGE = "image"
VIDEO = "video"
class Retry(Enum):
"""失败后该怎么办。由服务商适配层判定,由 Java 侧的 Worker 执行。"""
#: 不可重试:同样的输入必然得到同样的结果(参数错、内容被拒、鉴权失败)。
NEVER = "never"
#: 可重试且应尽快:限流、突发流量保护、服务过载。退避几秒到几十秒。
BACKOFF = "backoff"
#: 可重试但要等很久:额度/配额耗尽,通常要等到下一个计费周期或充值之后。
#: Worker 不该把重试预算浪费在这上面,应直接终态失败并让用户看到出口。
QUOTA = "quota"
class UserFacing(Enum):
"""给终端用户的呈现类别,对应客户端的两个 429 分支(ADR-023 §9)。
「配额耗尽」与「频率过快」必须分开:前者等的是明天或充值(弹 sheet 给出口),
后者等的是几秒(弹 SnackBar 可自动重试)。合成一类必然有一半场景的文案和
出口是错的。
"""
QUOTA_EXHAUSTED = "quota_exhausted"
RATE_LIMITED = "rate_limited"
CONTENT_REJECTED = "content_rejected"
SERVER_ERROR = "server_error"
UPSTREAM_UNAVAILABLE = "upstream_unavailable"
@dataclass(frozen=True)
class Failure:
"""一次失败的完整描述。
:param provider_code: 原始厂商错误码,**原样保留、绝不丢弃**——排查时它往往
是唯一线索。领域层不理解它的含义,只负责把它带到日志和响应里。
"""
retry: Retry
user_facing: UserFacing
provider_code: str
message: str = ""
class TaskState(Enum):
"""与服务商无关的任务状态。
刻意比火山的状态集**更小**:火山有 queued / running / cancelled / succeeded /
failed / expired 六个,这里把 queued 与 running 合成 PENDING/RUNNING 两态、
把 expired 归入 FAILED(它是超时,属于失败的一种,重试策略由 `Failure`
单独表达)。映射的取舍放在适配层,领域层只保留上层真正需要区分的状态。
"""
PENDING = "pending"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
@property
def is_terminal(self) -> bool:
return self in {TaskState.SUCCEEDED, TaskState.FAILED, TaskState.CANCELLED}
@dataclass(frozen=True)
class GenerationRequest:
"""一次生成请求,服务商无关。
这是 Java 侧发过来的内容。**刻意不含 `user_id`**:本服务干活只需要
`job_id`、生成参数与上传目标,传用户标识属于不必要的扩散(ADR-023 §5
数据最小化)。需要给厂商做滥用检测时传的是 `safety_identifier`——一个哈希,
不是明文用户 ID。
:param model: 厂商侧的模型标识,由 Java 侧从目录表的 `provider_model_name`
快照取值后传入。**适配层不自行选模型**——选型是目录的职责,且任务行里
冻结的快照必须与实际调用的模型一致。
:param input_url: 参考图/首帧的公网可达 URL。为空即文生视频(参考图选填,
决策 A2)。
:param upload_target: 产物的预签名 PUT 地址。当前未启用(转存阻塞于存储
决策,ADR-023 §7),保留字段使将来启用无需改契约。
"""
job_id: str
media_kind: MediaKind
model: str
prompt: str | None = None
input_url: str | None = None
ratio: str | None = None
resolution: str | None = None
#: 像素尺寸。**图片路径用它,视频路径用 ratio+resolution**——两家服务商的
#: 尺寸表达方式不同:火山用「分辨率档 + 宽高比」,OpenAI 用 WIDTHxHEIGHT。
#: 领域层同时保留两种表达而不强行统一,是因为任何统一都会在某一侧失真
#: (把 720p+16:9 折成像素会丢掉"档位"语义,把像素折成比例会丢掉精确值)。
width_px: int | None = None
height_px: int | None = None
duration_sec: int | None = None
generate_audio: bool = True
watermark: bool = False
safety_identifier: str | None = None
callback_url: str | None = None
upload_target: str | None = None
@dataclass(frozen=True)
class GenerationResult:
"""一次查询的结果,服务商无关。
:param asset_url: 产物 URL。**可能是有时效的厂商地址**——火山 Seedance 的
有效期是 24 小时,因此调用方必须在有效期内转存,不可长期保存此值。
适配层通过 `asset_url_expires_in_sec` 告知时效,避免上层把它当永久地址。
:param cover_url: 封面图 URL(视频场景)。火山的尾帧图可免费充当此用途。
:param width_px: 像素宽高。**火山不直接返回像素值**,只返回 `ratio` 与
`resolution`;换算需要一张随模型family变化的对照表。故此处在 V7 目录
种子定下模型族之前保持 None,并把 `ratio`/`resolution` 原样透出,
由上层或后续实现换算——编一个可能错的尺寸比留空更糟。
:param billing_tokens: 计费对账依据(火山为 `usage.completion_tokens`)。
"""
provider_request_id: str
state: TaskState
progress: int = 0
asset_url: str | None = None
asset_url_expires_in_sec: int | None = None
cover_url: str | None = None
ratio: str | None = None
resolution: str | None = None
width_px: int | None = None
height_px: int | None = None
duration_ms: int | None = None
output_format: str | None = None
failure: Failure | None = None
billing_tokens: int | None = None
def __post_init__(self) -> None:
# 失败态必须带 Failure,否则上层无法决定重试策略,只能盲目重试或盲目
# 放弃——两者都错。宁可在这里炸掉也不要把半个失败态放出去。
if self.state is TaskState.FAILED and self.failure is None:
raise ValueError("FAILED 状态必须携带 Failure")
if self.state is TaskState.SUCCEEDED and not self.asset_url:
raise ValueError("SUCCEEDED 状态必须携带 asset_url")
View File
+49
View File
@@ -0,0 +1,49 @@
"""与服务商无关的生成能力抽象。
上层(HTTP 端点、将来的 Java 侧契约)只对着本接口编程。新增一家供应商 = 实现
本接口 + 在注册表登记,**不动上层一行**。
为什么是「submit + fetch」两个方法而不是一个 `generate`:
Java↔Python 边界一律异步(ADR-023 §4)。`submit` 立即返回厂商任务 ID,`fetch`
查进度。同步型供应商(一次长连接出图)由其适配层在内部消化这个差异,**绝不能
让它渗透到这个接口上**——否则客户端契约、等待态设计、E2E 都要分两套。
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from app.domain.generation import GenerationRequest, GenerationResult, MediaKind
class GenerationProvider(ABC):
"""一家供应商的一种生成能力。"""
@property
@abstractmethod
def provider_code(self) -> str:
"""与 `creation.generation_models.provider_code` 同值。
Java 侧按任务行冻结的 `provider_code_snapshot` 找适配器,**不按当前目录**
——目录可能被改指向或禁用,而在途任务必须仍由当初提交它的适配器轮询。
"""
@property
@abstractmethod
def media_kind(self) -> MediaKind:
"""本适配器负责的产物类型。"""
@abstractmethod
def submit(self, request: GenerationRequest) -> str:
"""提交任务,返回厂商任务 ID。
**不得阻塞等待生成完成。** 调用方会在上游产生任何计费动作之前把返回的 ID
落库——那是超时后唯一能判断「是否已提交」的依据。
:raises app.providers.volcengine.client.ArkError: 或其他供应商的等价异常,
均携带已分类的 `Failure`。
"""
@abstractmethod
def fetch(self, provider_request_id: str) -> GenerationResult:
"""查询任务当前状态。"""
View File
+151
View File
@@ -0,0 +1,151 @@
"""gpt-image-2 的尺寸与参数约束。
与火山那边不同,OpenAI 的图片接口不用「分辨率 + 宽高比」两个字段,而是一个
`WIDTHxHEIGHT` 字符串,并对它施加几条**算术约束**。逐条取自官方文档:
- 宽高**都必须能被 16 整除**
- 宽高比必须在 `1:3` 到 `3:1` 之间
- 最大支持 `3840x2160`
- 超过 `2560x1440` 属**实验性**
- 另有「模型当前的像素与边长限制」——文档未给具体数值,故无法在本地校验
在本地先校验这些,是因为违反约束的请求会被 OpenAI 以 400 拒绝,而那要花一次
往返、计入配额,并表现为一个失败的任务。错误信息还能直接指出违反了哪一条。
**标准尺寸**(`1024x1024` / `1536x1024` / `1024x1536`)与 `auto` 一律放行,
不做算术校验:它们是文档明示支持的取值,其中 `auto` 由模型自行决定。
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
log = logging.getLogger(__name__)
#: 文档明示的标准尺寸,直接放行。
STANDARD_SIZES = frozenset({"1024x1024", "1536x1024", "1024x1536"})
#: 交给模型自选尺寸。
AUTO_SIZE = "auto"
#: `WIDTHxHEIGHT`。
_SIZE_PATTERN = re.compile(r"^(\d+)x(\d+)$")
#: 宽高必须同时是它的倍数。
EDGE_MULTIPLE = 16
#: 宽高比的允许区间(宽/高),即 1:3 ~ 3:1。
MIN_ASPECT = 1 / 3
MAX_ASPECT = 3.0
#: 最大支持尺寸,文档原文是「the maximum supported resolution is 3840x2160」。
#:
#: ⚠️ **这里有一处文档未明确、由我们推断的语义**:那句话没说清限制是「逐边」
#: (宽≤3840 且 高≤2160)还是「按总像素」。按逐边理解会**禁掉所有高于 2160 的
#: 竖图**,而 `1024x1536` 是文档明示的标准竖图尺寸、竖版显然受支持——故逐边理解
#: 必然过严。
#:
#: 采用的读法:**最长边 ≤ 3840,且总像素 ≤ 3840×2160**。它允许 `2160x3840`
#: 这类竖图(与横图同像素量),又拦住明显越界的尺寸。若日后拿到更明确的文档或
#: 实测结果,回来校正本处并更新取证页。
MAX_EDGE = 3840
MAX_PIXELS = 3840 * 2160
#: 超过此面积属实验性——放行但记一条日志,便于把偶发的异常表现与它关联起来。
#: 同样按面积而非逐边理解,理由同上。
EXPERIMENTAL_ABOVE_PIXELS = 2560 * 1440
#: 可选的画质档。xhigh 与 max 仅 2.5 系列支持,gpt-image-2 不含。
QUALITIES_GPT_IMAGE_2 = frozenset({"low", "medium", "high", "auto"})
#: 输出格式。注意 transparent 背景要求 png 或 webp。
OUTPUT_FORMATS = frozenset({"png", "jpeg", "webp"})
@dataclass(frozen=True)
class Size:
width: int
height: int
def __str__(self) -> str:
return f"{self.width}x{self.height}"
def parse_size(value: str) -> Size | None:
"""把 `WIDTHxHEIGHT` 解析成尺寸;标准尺寸与 `auto` 之外的非法串返回 None。"""
match = _SIZE_PATTERN.match(value.strip())
if not match:
return None
return Size(int(match.group(1)), int(match.group(2)))
def validate_size(value: str | None) -> None:
"""校验尺寸,不合法则抛 `ValueError`(消息指明违反了哪一条约束)。
`None`、`auto`、标准尺寸一律放行。
"""
if not value or value == AUTO_SIZE or value in STANDARD_SIZES:
return
size = parse_size(value)
if size is None:
raise ValueError(
f"size 形态非法:{value!r},应为 'WIDTHxHEIGHT'、'auto' "
f"或标准尺寸 {sorted(STANDARD_SIZES)}"
)
if size.width % EDGE_MULTIPLE or size.height % EDGE_MULTIPLE:
raise ValueError(f"size {size} 的宽高必须都能被 {EDGE_MULTIPLE} 整除")
# 宽高比先判:它是文档最明确的一条,且比尺寸上限更常被违反(比例写错比
# 尺寸写大更容易发生),先报它的错误信息更有用。
aspect = size.width / size.height
if not MIN_ASPECT <= aspect <= MAX_ASPECT:
raise ValueError(f"size {size} 的宽高比 {aspect:.2f} 超出允许区间 1:3 ~ 3:1")
if max(size.width, size.height) > MAX_EDGE:
raise ValueError(f"size {size} 的最长边超出上限 {MAX_EDGE}")
pixels = size.width * size.height
if pixels > MAX_PIXELS:
raise ValueError(
f"size {size} 的总像素 {pixels} 超出上限 {MAX_PIXELS}"
f"(即 3840x2160 的像素量)"
)
if pixels > EXPERIMENTAL_ABOVE_PIXELS:
# 放行但记一条:文档称实验性,出问题时这条日志能把现象与它关联起来。
log.info("size %s 超过 2560x1440,属服务商标注的实验性范围", size)
def validate_quality(value: str | None) -> None:
"""校验画质档。
只覆盖 gpt-image-2:`xhigh`/`max` 是 2.5 系列才支持的档,传给 gpt-image-2
会被拒。不按模型名分表是因为目前只接入一个图片模型——接入第二个时再分。
"""
if value and value not in QUALITIES_GPT_IMAGE_2:
raise ValueError(
f"quality {value!r} 不被 gpt-image-2 支持,"
f"可选:{sorted(QUALITIES_GPT_IMAGE_2)}"
f"(xhigh / max 仅 gpt-image-2.5 系列支持)"
)
def validate_output_format(value: str | None, *, transparent: bool = False) -> None:
"""校验输出格式。
:param transparent: 背景为透明时**只能** png 或 webp——jpeg 没有 alpha 通道,
指定它会得到一张背景被填成不透明的图,而那不会报错、只会静默不符合预期。
"""
if value and value not in OUTPUT_FORMATS:
raise ValueError(
f"output_format {value!r} 非法,可选:{sorted(OUTPUT_FORMATS)}"
)
if transparent and value == "jpeg":
raise ValueError(
"背景为 transparent 时 output_format 不能是 jpeg(无 alpha 通道),"
"请用 png 或 webp"
)
+166
View File
@@ -0,0 +1,166 @@
"""OpenAI 图片接口的 HTTP 传输层。
与火山那边的 `ArkClient` 职责相同(鉴权、超时、错误解包),但**刻意不共用**:
两家的错误体形状不同(OpenAI 是 `{"error": {message, type, code, param}}`,火山是
`{"error": {code, message}}`),且 OpenAI 的图片编辑走 multipart 而非 JSON。
强行抽一个共同基类会让两边都被对方的特例污染——供应商适配层之间**不该**互相
复用,各自复用自家的传输层才是分层的本意。
本模块不认识「图片生成」这个业务:以后接 OpenAI 的对话或语音接口时复用它。
"""
from __future__ import annotations
import logging
from typing import Any, Self
import httpx
from app.domain.generation import Failure
from .errors import classify
log = logging.getLogger(__name__)
#: 官方 API base URL。
DEFAULT_BASE_URL = "https://api.openai.com/v1"
class OpenAiError(RuntimeError):
"""OpenAI 返回的错误,携带领域分类。"""
def __init__(self, failure: Failure, http_status: int | None = None):
super().__init__(failure.message or failure.provider_code or "OpenAI 调用失败")
self.failure = failure
self.http_status = http_status
class OpenAiClient:
"""OpenAI API 的同步 HTTP 客户端。
**超时语义与火山那边不同,这里必须说清**:OpenAI 的图片接口是**同步**的——
一次请求里等到图片生成完再返回。因此这里的 `timeout_sec` **确实是生成耗时的
上限**,不是「短往返」的上限,默认值也因此比火山那边大一个量级。
这也是 `image.py` 必须在服务内部把它转成异步的原因(ADR-023 §4:同步型供应商
由其适配层在内部消化差异,绝不让它渗透到对外接口)。
"""
def __init__(
self,
api_key: str,
base_url: str = DEFAULT_BASE_URL,
timeout_sec: float = 180.0,
organization: str | None = None,
client: httpx.Client | None = None,
):
if not api_key:
raise ValueError(
"OPENAI_API_KEY 未配置:请通过环境变量注入,切勿写入代码或样例文件"
)
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._organization = organization
self._owns_client = client is None
self._client = client or httpx.Client(timeout=timeout_sec)
def post_json(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
return self._send("POST", path, json=body)
def post_multipart(
self,
path: str,
data: dict[str, Any],
files: list[tuple[str, tuple[str, bytes, str]]],
) -> dict[str, Any]:
"""multipart 提交,图像编辑接口需要它(`image` 是文件而非 URL)。
:param files: `(field_name, (filename, content, content_type))`。用 list
而非 dict 是因为**同名字段可以重复**——编辑接口支持传多张图,它们
共用字段名 `image[]`,dict 表达不了。
"""
return self._send("POST", path, data=data, files=files)
# ---------------------------------------------------------------- 内部
def _send(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
url = f"{self._base_url}{path}"
try:
response = self._client.request(
method, url, headers=self._headers(), **kwargs
)
except httpx.HTTPError as exc:
# 网络层失败按可重试处理。注意**同步接口的超时最危险**:请求可能已经
# 在服务商侧完成并计费,而我们拿不到结果。OpenAI 的图片接口没有任务
# ID 可供事后查询,故这个窗口无法通过"再查一次"弥补——只能由
# max_attempts 兜住。详见 image.py 的说明。
raise OpenAiError(
classify(None, message=f"请求 OpenAI 失败:{exc}")
) from exc
return self._unwrap(response)
def _headers(self) -> dict[str, str]:
headers = {"Authorization": f"Bearer {self._api_key}"}
if self._organization:
headers["OpenAI-Organization"] = self._organization
# 刻意不设 Content-Type:JSON 与 multipart 的取值不同,交给 httpx 按
# 实际载荷推断(multipart 还需要 boundary,手写必错)。
return headers
@staticmethod
def _unwrap(response: httpx.Response) -> dict[str, Any]:
"""把响应转成 dict;非 2xx 转成带分类的 `OpenAiError`。
错误体形状取自官方 Error codes 页:`{"error": {message, type, code, param}}`。
四个字段**都要取**——`param` 是区分「参数错」与「内容被拒」的唯一结构化
线索(理由见 `errors.py` 的 `_classify_bad_request`)。解析不出这个形状时
不静默,按状态码分类并把原始文本带上。
"""
if response.is_success:
try:
return response.json()
except ValueError as exc:
raise OpenAiError(
classify(
response.status_code,
message=f"响应不是合法 JSON:{response.text[:200]}",
),
response.status_code,
) from exc
error_type: str | None = None
error_code: str | None = None
error_param: str | None = None
message = response.text[:500]
try:
error = response.json().get("error") or {}
error_type = error.get("type")
error_code = error.get("code")
error_param = error.get("param")
message = error.get("message") or message
except ValueError:
pass
failure = classify(
response.status_code, error_type, error_code, message, error_param
)
log.warning(
"OpenAI 返回错误 http=%s type=%s code=%s param=%s retry=%s",
response.status_code,
error_type,
error_code,
error_param,
failure.retry.value,
)
raise OpenAiError(failure, response.status_code)
def close(self) -> None:
# 只关自己创建的连接池:注入进来的归调用方管理。
if self._owns_client:
self._client.close()
def __enter__(self) -> Self:
return self
def __exit__(self, *exc_info: object) -> None:
self.close()
+172
View File
@@ -0,0 +1,172 @@
"""OpenAI 错误 → 领域 `Failure`。
映射依据:用户提供的 `doc_temp/gpt错误码.md`(官方 Error codes 页)。本模块曾经是
按 HTTP 状态码推断的,拿到该文档后已按实际码名重写——**此前猜的五个码名
(`rate_limit_exceeded`、`moderation_blocked`、`content_policy_violation`、
`invalid_api_key`、`image_generation_user_error`)在官方文档里一个都不存在**,
已全部删除。这段历史留在这里,作为「凭记忆列错误码表」代价的记录。
## ⚠️ 官方错误码表里没有任何业务层失败
文档的 13 条全是**基础设施与计费**:鉴权、IP/地域、限流、余额与限额、服务过载。
**没有内容审核、没有版权/侵权、没有「这个提示词不能生成」。** 但图片接口明确有
`moderation` 参数(`low`/`auto`),说明审核确实存在——只是它的错误形态未被文档化。
这对我们的直接后果:**一次侵权或违规拒绝很可能以一个普通 400 到达**,只带一句
人类可读的消息。若把这种 400 一律归为「服务端错误」,用户会看到「服务出错了」
然后反复重试同一个注定被拒的提示词。故本模块用 `error.param` 做结构化区分,
见 `_classify_bad_request()`。那是**推断,不是文档事实**,已就地标注。
## 未处理的一项:`Retry-After`
文档在 429 与 503 处反复要求遵守 `Retry-After` 响应头。本服务目前**不读它**,
理由是 Java 侧 Worker 的退避阶梯基线已是 30 秒、指数增长、上限 10 分钟,**在绝大
多数情形下已长于服务商给的 `Retry-After`**——也就是我们已经比要求更保守,忽略它
只会让重试偏慢,不会加剧限流。要把它接起来是一次跨语言契约变更(领域 `Failure`
加字段 + `openapi-internal.yaml` 小版本 + Java 侧 DTO 与调度),收益只是延迟优化,
故记在取证页的待办里而不是现在做。
"""
from __future__ import annotations
import logging
from app.domain.generation import Failure, Retry, UserFacing
log = logging.getLogger(__name__)
#: 文档化的错误标识 → 领域处置。
#:
#: **按 `code` 与 `type` 两个字段分别查同一张表**,而不是各维护一张。理由:文档
#: 把某个标识列在哪个字段下,并不保证实际响应里它一定出现在那个字段(`insufficient_quota`
#: 就既被描述为「broader `error.type`」,又很容易出现在 `code` 里)。查错字段的代价
#: 是把配额耗尽降级成限流、白烧一轮重试预算;而两边都查不引入任何**新**词汇——
#: 表里每个键都抄自文档。
_RULES: dict[str, tuple[Retry, UserFacing]] = {
# ---- 计费/额度(官方表 429 一节,文档列在 `code` 下)----
#
# 这四条共同的性质是文档明说的那句:「Retrying billing, spend, or quota errors
# won't restore API access」——重试不会恢复访问,要人去充值或改限额。这正是
# `Retry.QUOTA` 存在的理由:Worker 不该把重试预算花在这上面,应直接终态失败
# 并让用户看到出口。
#
# 预付余额耗尽。
"credit_balance_exhausted": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# 组织月度消费限额(自己设的)。
"organization_spend_limit_exceeded": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# 项目月度消费限额(自己设的)。
"project_spend_limit_exceeded": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# OpenAI 分配的用量限额(不是自己设的,要申请提额)。
"organization_usage_limit_exceeded": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# 文档原文:「The broader `error.type` can still be `insufficient_quota`」
# ——计费类错误的宽类型,具体原因要看 code。
"insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# ---- 限流 ----
#
# `slow_down` 是「涨得太快」而非「超了额定速率」——文档说即使在 RPM/TPM 限额
# 之内也会触发。两者处置相同(退避后重试),故不再细分。
"slow_down": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
"rate_limit_error": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
# ---- 服务过载 ----
#
# 文档特别提醒:**视频过载已从 429 改判为 503**,且 Python SDK 分别抛
# RateLimitError 与 InternalServerError,只捕一类会漏。我们两者都归可重试,
# 故这次厂商侧的改判对本服务无影响——记下来是为了说明「为什么我们没被影响」。
"server_is_overloaded": (Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE),
"service_unavailable_error": (Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE),
}
def classify(
http_status: int | None,
error_type: str | None = None,
error_code: str | None = None,
message: str | None = None,
error_param: str | None = None,
) -> Failure:
"""把一次失败分类。
判定顺序:`code` → `type` → HTTP 状态码。越具体的依据优先——`code` 是文档明确
列出取值的字段,状态码只是最后的兜底。
:param http_status: `None` 表示网络层失败(连不上、超时),按可重试处理。
:param error_param: 厂商指出的出错参数名。**它的有无是我们区分「参数错」与
「内容被拒」的唯一结构化线索**,见 `_classify_bad_request()`。
"""
text = message or ""
# provider_code 永远不为空——它是排查时的第一线索。
provider_code = error_code or error_type or _status_label(http_status)
# code 优先于 type:文档里 code 比 type 具体(同一个 429 下六种 code 共享
# 少数几个 type)。两者都查同一张表,理由见 `_RULES`。
rule = _RULES.get(error_code or "") or _RULES.get(error_type or "")
if rule:
retry, user_facing = rule
return Failure(retry, user_facing, provider_code, text)
if http_status is None:
return Failure(
Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text
)
if http_status == 429:
# 到这里说明既没有 code 也没有认识的 type。官方表里 429 的六种中,只有
# 「Rate limit reached for requests」没有文档化的 code——故无码的 429
# **按限流处理是文档支持的默认**,不再是一个猜测。
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, provider_code, text)
if http_status == 400:
return _classify_bad_request(provider_code, text, error_param)
if http_status in (401, 403):
# 401:密钥无效/过期/不属于该组织;403:地域不受支持。
# 两者都是**我方配置问题**,重试无用,且都不该把锅甩给用户的提示词。
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
if 400 < http_status < 500:
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
# 5xx 与一切未知:可重试。把一次可恢复的抖动判死是更糟的错误,而重试预算
# 本身有上限(max_attempts),故偏向重试是有界的。
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text)
def _classify_bad_request(
provider_code: str, text: str, error_param: str | None
) -> Failure:
"""400 的两义区分:参数错 vs 内容被拒。
**这是本模块唯一的推断,不是文档事实。**
依据与理由:
- 文档在 `service_tier` 那条明确写了「as an `invalid_request_error` with
`error.param` set to `service_tier`」——即**参数类错误会带 `param`**。
- 官方错误码表**没有任何内容审核/侵权码**,故一次内容拒绝只能以某种通用
400 到达,且它不指向任何具体参数,`param` 应为空。
- 我方在调用前已用 `capabilities.py` 校验过尺寸、画质、格式,`model`/`n` 由
程序生成。**能活着到达服务商并被 400 拒掉的,参数嫌疑本来就不大。**
故:带 `param` → 参数错(服务端错误);不带 `param` → 按内容被拒处理。
取错的代价是不对称的,这也是选这个方向的理由:把内容拒绝报成「服务出错」会
让用户反复重试同一个注定被拒的提示词;反过来把一个参数 bug 报成「内容可能不
被支持」只是让用户多试一次,而排查者看到的 `provider_code` 与日志仍是准确的。
拿到真实的审核错误形态后,回来把这里换成确定的判定并更新取证页。
"""
if error_param:
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
# 记 warning 而不是静默:这条分支是推断出来的,日志里能看到它被走了多少次、
# 携带的是什么消息,是日后校正它的唯一素材来源。
log.warning(
"400 且无 error.param,按内容被拒处理(推断,非文档事实):code=%s message=%s",
provider_code,
text[:200],
)
return Failure(Retry.NEVER, UserFacing.CONTENT_REJECTED, provider_code, text)
def _status_label(http_status: int | None) -> str:
return f"HTTP_{http_status}" if http_status else "NETWORK_ERROR"
+253
View File
@@ -0,0 +1,253 @@
"""gpt-image-2 图片生成:文生图与图生图(图像编辑)。
## 与火山视频适配器的三处根本差异
1. **接口是同步的。** `POST /images/generations` 一次请求等到底,**没有任务 ID、
没有查询接口**。差异由 `tasks.SyncTaskRegistry` 在本服务内部吸收(ADR-023 §4),
代价与风险见该模块顶部说明。
2. **产物是 base64 字节,不是 URL。** GPT image 系列**始终**返回 base64
(`response_format` 对它们不支持)。好处是没有「URL 24 小时过期」问题;坏处是
字节只在内存里,必须立刻转存——而转存阻塞于存储决策(ADR-023 §7)。
3. **图生图走 multipart,不是 JSON。** `/images/edits` 的 `image` 字段是文件。
我们的输入是一个 URL,故需先把它下载下来再作为 multipart 上传。
## 尺寸从响应直接拿,不需要对照表
响应带 `size` 字段(`WIDTHxHEIGHT`),故像素宽高是确定值——不像火山那边只给
`ratio`+`resolution`、需要一张随模型族变化的对照表。
"""
from __future__ import annotations
import logging
import httpx
from app.domain.generation import (
Failure,
GenerationRequest,
GenerationResult,
MediaKind,
Retry,
TaskState,
UserFacing,
)
from app.providers.base import GenerationProvider
from .capabilities import (
parse_size,
validate_output_format,
validate_quality,
validate_size,
)
from .client import OpenAiClient
from .models import ImagesResponse, ImageSubmission
from .tasks import SyncTaskRegistry
log = logging.getLogger(__name__)
#: `creation.generation_models.provider_code` 里对应本适配器的值。
PROVIDER_CODE = "openai-gpt-image"
#: 下载参考图的超时。这是一次内部/公网取图,与生成耗时无关,故很短。
_INPUT_FETCH_TIMEOUT_SEC = 15.0
#: 参考图大小上限。服务商限制单图 < 50MB;这里取更小的值,因为我们还要把它整个
#: 读进内存再转发——一张几十 MB 的图会让内存占用随并发线性上涨。
_MAX_INPUT_BYTES = 20 * 1024 * 1024
class OpenAiImageProvider(GenerationProvider):
"""gpt-image-2 的图片生成适配器。"""
def __init__(
self,
client: OpenAiClient,
registry: SyncTaskRegistry,
default_model: str = "",
input_fetch_client: httpx.Client | None = None,
):
self._client = client
self._registry = registry
self._default_model = default_model
self._owns_fetch_client = input_fetch_client is None
self._fetch_client = input_fetch_client or httpx.Client(
timeout=_INPUT_FETCH_TIMEOUT_SEC, follow_redirects=True
)
@property
def provider_code(self) -> str:
return PROVIDER_CODE
@property
def media_kind(self) -> MediaKind:
return MediaKind.IMAGE
# ------------------------------------------------------------- submit
def submit(self, request: GenerationRequest) -> str:
if request.media_kind is not MediaKind.IMAGE:
raise ValueError(
f"{PROVIDER_CODE} 只处理图片生成,收到 {request.media_kind.value}"
)
submission = self._to_submission(request)
# 参数校验放在提交进注册表**之前**:违规请求应当立刻以 400 回给调用方,
# 而不是变成一个注定失败的后台任务——后者要等一轮轮询才被发现,还占了一个
# 线程池槽位。
validate_size(submission.size)
validate_quality(submission.quality)
validate_output_format(
submission.output_format, transparent=submission.background == "transparent"
)
if request.input_url:
# 图生图:先取图。**取图也放在受理之前**——取不到就是参数问题
# (URL 不可达),应当立刻拒绝而不是变成一个后台失败。
image_bytes, filename, content_type = self._fetch_input(request.input_url)
work = lambda: self._run_edit(
submission, image_bytes, filename, content_type
)
else:
work = lambda: self._run_generate(submission)
# 注册表满时 `submit` 抛 RuntimeError,这里**刻意不接**:归成 ValueError 让上层
# 映射成 invalid_request 是错的——那不是请求的问题而是容量问题,且稍后重试会
# 好。原样上抛,由端点映射为 provider_unavailable(503)。
return self._registry.submit(work)
def _to_submission(self, request: GenerationRequest) -> ImageSubmission:
"""领域请求 → 厂商请求体。
模型优先取请求里的(来自任务行冻结的快照),仅在缺失时回落到配置的默认值
——适配层不自行选模型,且任务行的快照必须与实际调用一致,否则无法对账。
"""
model = request.model or self._default_model
if not model:
raise ValueError(
"未指定模型:请求未携带 model 且 OPENAI_IMAGE_MODEL 未配置"
)
return ImageSubmission(
model=model,
prompt=request.prompt or "",
# 领域层的 ratio/resolution 是为视频设计的;图片用像素尺寸。宽高都有
# 才拼 size,否则交给模型自选(auto)——半个尺寸不如不给。
size=(
f"{request.width_px}x{request.height_px}"
if request.width_px and request.height_px
else None
),
safety_identifier=request.safety_identifier,
)
# -------------------------------------------------------------- fetch
def fetch(self, provider_request_id: str) -> GenerationResult:
result = self._registry.fetch(provider_request_id)
if result is None:
# 未知 ID:从未存在过,或进程重启后丢失(两者无法区分,见 tasks.py)。
# 抛 KeyError 让端点映射成 task_not_found——调用方要做的是停止轮询,
# 而不是退避重试。
raise KeyError(provider_request_id)
return result
# ------------------------------------------------- 实际的同步调用
def _run_generate(self, submission: ImageSubmission) -> GenerationResult:
"""文生图。在注册表的后台线程里执行。"""
payload = self._client.post_json(
"/images/generations", submission.to_json_body()
)
return self._to_result(ImagesResponse.from_payload(payload))
def _run_edit(
self,
submission: ImageSubmission,
image_bytes: bytes,
filename: str,
content_type: str,
) -> GenerationResult:
"""图生图(图像编辑)。走 multipart。"""
payload = self._client.post_multipart(
"/images/edits",
data=submission.to_form_fields(),
# 字段名用 `image[]`:编辑接口支持多图且共用该字段名。单图时也用它,
# 避免"单图一种写法、多图另一种"的分叉。
files=[("image[]", (filename, image_bytes, content_type))],
)
return self._to_result(ImagesResponse.from_payload(payload))
def _to_result(self, response: ImagesResponse) -> GenerationResult:
"""厂商响应 → 领域结果。"""
image = response.first_image()
if image is None or not image.b64_json:
# 2xx 却没有图片数据。当成可重试的上游异常,而不是把一个没有产物的
# 「成功」放给上层去空指针。
log.error("OpenAI 返回 2xx 但缺少图片数据:%r", response.raw_keys())
return GenerationResult(
provider_request_id="",
state=TaskState.FAILED,
failure=Failure(
Retry.BACKOFF,
UserFacing.UPSTREAM_UNAVAILABLE,
"MissingImageData",
"服务商返回成功但响应中没有图片数据",
),
)
size = parse_size(response.size or "")
return GenerationResult(
# 真实 ID 由注册表在 fetch 时填入——这里填不了,因为工作函数运行时
# 还拿不到自己的任务 ID。
provider_request_id="",
state=TaskState.SUCCEEDED,
progress=100,
# ⚠️ 产物是 base64 字节而非可访问 URL。这里放 data URI 只是为了让领域
# 模型的「SUCCEEDED 必须有 asset_url」不变式成立,**它不是一个可长期
# 保存的地址**:一张 1024×1024 的 png 约 1~2MB,base64 后更大,绝不
# 应该原样写进 media.assets.external_url。转存落地后这里应改为返回
# 上传后的 object key。
asset_url=f"data:image/{response.output_format or 'png'};base64,"
+ image.b64_json,
# 无时效——字节在手,不像火山 URL 会过期。用 None 而非一个假的秒数。
asset_url_expires_in_sec=None,
width_px=size.width if size else None,
height_px=size.height if size else None,
output_format=response.output_format,
billing_tokens=response.total_tokens,
)
# ------------------------------------------------------------ helpers
def _fetch_input(self, url: str) -> tuple[bytes, str, str]:
"""下载参考图,供 multipart 上传。
服务商要求 png / webp / jpg 且单图 < 50MB。这里额外设了更小的上限,因为
字节要整个进内存再转发。
"""
try:
with self._fetch_client.stream("GET", url) as response:
response.raise_for_status()
declared = response.headers.get("Content-Length")
if declared and int(declared) > _MAX_INPUT_BYTES:
raise ValueError(
f"参考图过大({declared} 字节),上限 {_MAX_INPUT_BYTES}"
)
chunks: list[bytes] = []
total = 0
for chunk in response.iter_bytes():
total += len(chunk)
if total > _MAX_INPUT_BYTES:
# 不信 Content-Length:它可能缺失或撒谎,边读边数才拦得住。
raise ValueError(f"参考图超过上限 {_MAX_INPUT_BYTES} 字节")
chunks.append(chunk)
content_type = response.headers.get("Content-Type", "image/png")
except httpx.HTTPError as exc:
raise ValueError(f"参考图无法下载:{url}({exc})") from exc
return b"".join(chunks), "input.png", content_type.split(";")[0]
def close(self) -> None:
if self._owns_fetch_client:
self._fetch_client.close()
+149
View File
@@ -0,0 +1,149 @@
"""OpenAI 图片接口的请求/响应 DTO。
只放**厂商词汇**。领域概念在 `app/domain/generation.py`,翻译在 `image.py`。
一处值得注意的厂商差异:同一份请求体在**文生图走 JSON、图生图走 multipart**
(后者的 `image` 是文件)。故本类提供两种输出形态——`to_json_body()` 与
`to_form_fields()`。multipart 的字段值必须是字符串,布尔与数字要转,不能直接塞。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ImageSubmission:
"""一次图片生成请求。
刻意只暴露 Patbond 会用到的参数。厂商参数远多于此(`n`、`partial_images`、
`stream`、`mask`、`input_fidelity`、`output_compression`、`moderation`…),
多暴露一个就多一处要在契约、客户端、文档三处同步维护的地方。
:param n: **固定为 1,不开放**。领域模型的一个任务对应一个产物
(`generation_jobs.output_asset_id` 是单值);允许 n>1 就得改数据模型。
:param quality: `None` 交给服务商默认(auto)。注意 `xhigh`/`max` 仅
gpt-image-2.5 系列支持,gpt-image-2 传了会被拒(`capabilities` 会先拦)。
:param background: `transparent` 时 `output_format` 只能 png/webp。
文档称 gpt-image-2 对透明背景的支持**处于预览**,故默认不指定。
:param safety_identifier: 对应厂商的 `user` 字段——终端用户标识,供其做滥用
检测。必须是哈希,不可传明文用户 ID。
"""
model: str
prompt: str
size: str | None = None
quality: str | None = None
output_format: str | None = None
background: str | None = None
safety_identifier: str | None = None
def to_json_body(self) -> dict[str, Any]:
"""文生图(`/images/generations`)的 JSON 体。"""
body: dict[str, Any] = {
"model": self.model,
"prompt": self.prompt,
# 一个任务一个产物,见类 docstring。
"n": 1,
}
self._add_optional(body)
return body
def to_form_fields(self) -> dict[str, str]:
"""图生图(`/images/edits`)的 multipart 文本字段。
multipart 的字段值必须是字符串——数字与布尔直接塞进去会被 httpx 拒绝或
编码成意外形态。
"""
fields: dict[str, Any] = {
"model": self.model,
"prompt": self.prompt,
"n": 1,
}
self._add_optional(fields)
return {key: str(value) for key, value in fields.items()}
def _add_optional(self, target: dict[str, Any]) -> None:
"""只加已设置的可选项。
不设的字段**留空而不显式传 null**:服务商对每个参数都有文档化的默认值,
显式 null 可能覆盖掉那些默认值。
"""
if self.size:
target["size"] = self.size
if self.quality:
target["quality"] = self.quality
if self.output_format:
target["output_format"] = self.output_format
if self.background:
target["background"] = self.background
if self.safety_identifier:
# 厂商字段名是 `user`,不是 safety_identifier。
target["user"] = self.safety_identifier
@dataclass(frozen=True)
class GeneratedImage:
"""`data[]` 里的一张图。
:param b64_json: base64 编码的图片。**GPT image 系列始终返回它**
(`response_format` 对这些模型不支持,`url` 只用于 dall-e)。
"""
b64_json: str | None = None
url: str | None = None
revised_prompt: str | None = None
@dataclass(frozen=True)
class ImagesResponse:
"""`/images/generations` 与 `/images/edits` 的共同响应形状。
:param size: `WIDTHxHEIGHT` 字符串。**像素宽高由此直接得到**,不像火山那边
只给 ratio+resolution、需要一张随模型族变化的对照表。
:param total_tokens: 计费对账依据(`usage.total_tokens`)。
"""
created: int | None = None
data: tuple[GeneratedImage, ...] = ()
size: str | None = None
output_format: str | None = None
quality: str | None = None
background: str | None = None
total_tokens: int | None = None
input_tokens: int | None = None
output_tokens: int | None = None
_raw: dict[str, Any] | None = None
def first_image(self) -> GeneratedImage | None:
return self.data[0] if self.data else None
def raw_keys(self) -> list[str]:
"""原始响应的顶层键,仅用于「2xx 但没有图」时的日志。"""
return sorted(self._raw) if self._raw else []
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> ImagesResponse:
usage = payload.get("usage") or {}
images = tuple(
GeneratedImage(
b64_json=item.get("b64_json"),
url=item.get("url"),
revised_prompt=item.get("revised_prompt"),
)
for item in (payload.get("data") or [])
if isinstance(item, dict)
)
return cls(
created=payload.get("created"),
data=images,
size=payload.get("size"),
output_format=payload.get("output_format"),
quality=payload.get("quality"),
background=payload.get("background"),
total_tokens=usage.get("total_tokens"),
input_tokens=usage.get("input_tokens"),
output_tokens=usage.get("output_tokens"),
_raw=payload,
)
+224
View File
@@ -0,0 +1,224 @@
"""同步型供应商的任务注册表:把「一次调用等到底」转成「提交 + 查询」。
## 为什么必须有它
ADR-023 §4 定的是「Java↔Python 边界一律异步,同步型供应商由其适配层在内部消化
这个差异」。火山的视频接口本身异步,所以视频适配器不需要这一层。**OpenAI 的图片
接口是同步的**——`POST /images/generations` 在一次请求里等到图片生成完才返回,
没有任务 ID,也没有查询接口。差异必须在这里被吸收,否则就会渗透到客户端契约、
等待态设计和 E2E,那是 §4 明确要避免的。
## 这与「Python 不持有状态」的张力,以及取舍
Seedance 那条路径上,Python 确实无状态:任务状态的唯一事实来源是 Java 的
`generation_jobs`,Python 只是个转发器。**图片这条路径做不到**——同步接口没有可
供事后查询的厂商 ID,所以「在途任务」这件事只能记在本进程内存里。
代价与处置如实登记:
1. **进程重启会丢失在途任务。** 之后 Java 轮询会得到 `task_not_found`,任务被判
终态失败,而**那次调用可能已经完成并计费**。OpenAI 的图片接口没有任务 ID 可供
事后查询,所以这个窗口**无法靠"再查一次"弥补**,只能由 `max_attempts` 兜住。
这与火山那边「提交超时不知道有没有扣费」是同一类风险,但触发面更大(那边只有
提交超时,这边还包括进程重启)。
2. **产物是 base64 字节,只在内存里。** 完成的任务必须在 TTL 内被取走,否则丢失。
而转存阻塞于对象存储决策(ADR-023 §7),所以**图片链路在存储决策落地前比视频
更受限**:视频至少有个 24 小时的厂商 URL 可以先看效果,图片的字节一旦被清理
就什么都没有了。
3. 注册表**有界且有 TTL**,否则它就是个内存泄漏。
## 为什么用线程而不是 asyncio
FastAPI 的端点在本服务里是同步函数(`def` 而非 `async def`),且 `httpx.Client`
是同步客户端。用线程池承载那个长调用与现有代码一致;改成 asyncio 需要把传输层、
端点、测试全部改成异步,收益只是少几个线程——本服务的并发量级(一台轻量服务器上
的少量生成请求)用不上那个收益。
"""
from __future__ import annotations
import logging
import threading
import time
import uuid
from collections.abc import Callable
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass, field
from app.domain.generation import Failure, GenerationResult, TaskState
log = logging.getLogger(__name__)
#: 本地任务 ID 的前缀,便于在日志里与厂商 ID 区分开。
LOCAL_TASK_PREFIX = "local-"
@dataclass
class _Entry:
"""一条在途或已完成的任务。"""
future: Future
created_at: float = field(default_factory=time.monotonic)
#: **首次观察到**完成的时刻;未完成为 None。TTL 从这里起算而不是从创建起算——
#: 否则一个耗时接近 TTL 的任务会在刚完成时就被清理掉,结果还没被取走。
#:
#: 为什么是「首次观察到」而不是「实际完成」:实际完成时刻需要一个
#: `add_done_callback` 才能捕捉,而那会**死锁**——快速完成的任务在
#: `add_done_callback` 时已经 done,回调便**同步地在调用线程执行**,而该线程
#: 此刻正持有本注册表的锁(非可重入)。改用惰性记录既无此风险,又只会让结果
#: 多留一会儿(保守方向),不会让它提前消失。
finished_at: float | None = None
class SyncTaskRegistry:
"""把同步调用包装成可轮询的任务。
线程安全:`submit`/`fetch`/清理可能来自不同请求线程。
"""
def __init__(
self,
*,
max_workers: int = 4,
completed_ttl_sec: float = 900.0,
max_entries: int = 256,
):
"""
:param max_workers: 并发执行的同步调用数上限。**同时也是对服务商并发的
自我限制**:超出的提交会排队而不是一起打过去,既避免把自己的 RPM 打爆,
也让「同时有多少钱在花」有个上界。
:param completed_ttl_sec: 已完成任务的保留时长。需大于 Java 侧最长的轮询
间隔(当前阶梯最长 30 秒),否则结果会在被取走之前被清理。默认 15
分钟留足余量。
:param max_entries: 注册表容量上限。满了先清理过期项,仍满则拒绝新提交
(而不是无声挤掉某个在途任务——那会让一个已经在计费的调用变成孤儿)。
"""
self._executor = ThreadPoolExecutor(
max_workers=max_workers, thread_name_prefix="sync-gen"
)
self._completed_ttl_sec = completed_ttl_sec
self._max_entries = max_entries
self._entries: dict[str, _Entry] = {}
self._lock = threading.Lock()
def submit(self, work: Callable[[], GenerationResult]) -> str:
"""安排一次同步调用在后台执行,立刻返回本地任务 ID。
:raises RuntimeError: 注册表已满(并发在途任务过多)
"""
with self._lock:
self._evict_expired_locked()
if len(self._entries) >= self._max_entries:
raise RuntimeError(
f"在途生成任务已达上限 {self._max_entries},请稍后重试"
)
task_id = LOCAL_TASK_PREFIX + uuid.uuid4().hex
# 刻意不注册 done_callback,原因见 _Entry.finished_at 的说明(会死锁)。
self._entries[task_id] = _Entry(future=self._executor.submit(work))
log.info("已受理同步生成任务 %s(在途 %d)", task_id, len(self._entries))
return task_id
def fetch(self, task_id: str) -> GenerationResult | None:
"""查任务;未知 ID 返回 None。
未知 ID 的两种成因**无法区分**:从未存在过,或进程重启后丢失。两者对调用
方的处置相同(停止轮询、任务判失败),故不强行区分——区分不了的事不要假装
区分。
"""
with self._lock:
entry = self._entries.get(task_id)
if entry is not None and entry.finished_at is None and entry.future.done():
# 首次观察到完成:记下时刻,TTL 由此起算。
entry.finished_at = time.monotonic()
if entry is None:
return None
if not entry.future.done():
# 同步接口不提供进度,故只能报 0。客户端契约本就不承诺平滑进度
# (ADR-023 §10),一个诚实的 0 不该被读成「卡住」。
return GenerationResult(
provider_request_id=task_id, state=TaskState.RUNNING
)
try:
result = entry.future.result()
except Exception as exc: # noqa: BLE001 - 兜底转成领域失败
return self._failure_result(task_id, exc)
# 用真实的任务 ID 覆盖工作函数里可能留空的那个字段。
return GenerationResult(
provider_request_id=task_id,
state=result.state,
progress=result.progress,
asset_url=result.asset_url,
asset_url_expires_in_sec=result.asset_url_expires_in_sec,
cover_url=result.cover_url,
ratio=result.ratio,
resolution=result.resolution,
width_px=result.width_px,
height_px=result.height_px,
duration_ms=result.duration_ms,
output_format=result.output_format,
failure=result.failure,
billing_tokens=result.billing_tokens,
)
@staticmethod
def _failure_result(task_id: str, exc: BaseException) -> GenerationResult:
"""工作函数抛出的异常转成领域失败。
已分类的服务商异常保留其分类;其余按可重试的上游问题处理,并把类型名带进
`provider_code`——否则排查时只有一句空洞的错误消息。
"""
from app.domain.generation import Retry, UserFacing
from .client import OpenAiError
if isinstance(exc, OpenAiError):
failure = exc.failure
else:
log.exception("同步生成任务 %s 抛出未分类异常", task_id)
failure = Failure(
Retry.BACKOFF,
UserFacing.UPSTREAM_UNAVAILABLE,
type(exc).__name__,
str(exc),
)
return GenerationResult(
provider_request_id=task_id, state=TaskState.FAILED, failure=failure
)
def _evict_expired_locked(self) -> None:
"""清理已完成且超过 TTL 的条目。调用方必须已持锁。
**只清已完成的**:在途任务无论多久都不清——它可能正在花钱,清掉会让它
变成一个无人认领的开销。真正卡死的在途任务由线程池的上限兜住。
"""
now = time.monotonic()
# 顺带给「已完成但从未被 fetch 过」的条目补上观察时刻,否则它们的
# finished_at 永远是 None,就永远不会过期——那正是内存泄漏。
for entry in self._entries.values():
if entry.finished_at is None and entry.future.done():
entry.finished_at = now
expired = [
task_id
for task_id, entry in self._entries.items()
if entry.finished_at is not None
and now - entry.finished_at > self._completed_ttl_sec
]
for task_id in expired:
del self._entries[task_id]
if expired:
log.debug("清理了 %d 个已完成的同步任务条目", len(expired))
@property
def size(self) -> int:
with self._lock:
return len(self._entries)
def shutdown(self) -> None:
"""停止接受新任务。
`wait=False`:在途的同步调用可能还要跑几分钟,进程退出时不等它们——结果
反正已经无处可交付了。它们会随进程一起消失,这正是上面第 1 点登记的风险。
"""
self._executor.shutdown(wait=False, cancel_futures=True)
+49
View File
@@ -0,0 +1,49 @@
"""provider 注册表:按 `provider_code` 分发。
Java 侧按任务行冻结的 `provider_code_snapshot` 找适配器,**不按当前目录**——目录
可能被改指向或禁用,而在途任务必须仍由当初提交它的适配器轮询。用当前目录路由会
拿着别人的任务 ID 去问错的供应商。
"""
from __future__ import annotations
from app.providers.base import GenerationProvider
class ProviderRegistry:
"""`provider_code` → 适配器。"""
def __init__(self, providers: list[GenerationProvider] | None = None):
self._by_code: dict[str, GenerationProvider] = {}
for provider in providers or []:
self.register(provider)
def register(self, provider: GenerationProvider) -> None:
code = provider.provider_code
if code in self._by_code:
# 重复注册意味着装配出错。静默覆盖会让「到底哪个实例在处理任务」
# 取决于注册顺序,是最难查的一类问题。
raise ValueError(f"provider_code 重复注册:{code}")
self._by_code[code] = provider
def find(self, provider_code: str) -> GenerationProvider | None:
"""未知 code 返回 None。
对一个已持久化的任务而言,未知 code 意味着当初提交它的适配器已被移除。
调用方应当作**不可重试**的失败,而不是循环重试到预算耗尽。
"""
return self._by_code.get(provider_code)
def all(self) -> list[GenerationProvider]:
"""全部已装配的适配器。
供「按 media_kind 找适配器」这类查询用——按能力而非按 code 检索时用它。
"""
return list(self._by_code.values())
@property
def codes(self) -> frozenset[str]:
return frozenset(self._by_code)
def __len__(self) -> int:
return len(self._by_code)
+125
View File
@@ -0,0 +1,125 @@
"""各 Seedance 模型的参数限制。
**为什么需要它**:已接入的两个模型限制不同——2.5 时长上限 30 秒但没有 4k,
2.0 有 4k 但时长上限 15 秒;2.5 在图生视频时 `ratio` 只能是 `adaptive`。超范围的
请求会被厂商以 400 拒绝,那要花一次往返、计入 RPM,并表现为一个失败的任务。
在本地先拦下来,错误信息还能具体指出是哪个模型的哪条限制。
**未知模型一律放行**(只记一条日志):账号新开通一个模型时,不应该因为这张表
没更新就用不了——那会让「开通即可用」变成「开通还得改代码发版」。代价只是失去
本地预校验,厂商仍会兜底。
限制取自官方文档,取证见 patbond-doc
`docs/development/providers/volcengine-seedance.md` §5。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class ModelLimits:
"""一个模型族的参数限制。
:param ratio_must_be_adaptive_with_first_frame: 2.5 在图生视频(首帧/首尾帧)
与视频编辑/延长任务中**只支持 `adaptive`**——模型自动保持输出宽高比与
首帧图片一致,不接受指定值。2.0 系列则可指定。
:param supports_output_format: `output_format`(mp4/mov)仅 2.5 支持。
"""
resolutions: frozenset[str]
min_duration_sec: int
max_duration_sec: int
ratio_must_be_adaptive_with_first_frame: bool
supports_output_format: bool
#: Seedance 2.5:时长可到 30 秒,但没有 4k;图生视频时宽高比锁 adaptive。
#: 1080p 输出为 10bit + H.265/HEVC,少数播放环境可能不兼容。
SEEDANCE_2_5 = ModelLimits(
resolutions=frozenset({"480p", "720p", "1080p"}),
min_duration_sec=4,
max_duration_sec=30,
ratio_must_be_adaptive_with_first_frame=True,
supports_output_format=True,
)
#: Seedance 2.0 系列:有 4k,但时长上限 15 秒。4k 输出为 10bit + H.265/HEVC。
SEEDANCE_2_0 = ModelLimits(
resolutions=frozenset({"480p", "720p", "1080p", "4k"}),
min_duration_sec=4,
max_duration_sec=15,
ratio_must_be_adaptive_with_first_frame=False,
supports_output_format=False,
)
#: 按模型族前缀匹配,而非精确匹配完整 model ID。
#: 完整 ID 形如 `doubao-seedance-2-5-260628`,尾部的日期版本会随厂商更新而变;
#: 按前缀匹配使一次模型小版本升级不需要改这张表。
_BY_PREFIX: tuple[tuple[str, ModelLimits], ...] = (
("doubao-seedance-2-5", SEEDANCE_2_5),
("doubao-seedance-2-0", SEEDANCE_2_0),
)
#: `duration` 的特殊值:交给模型在有效范围内自选。不参与范围校验。
AUTO_DURATION = -1
def limits_for(model: str) -> ModelLimits | None:
"""查模型限制;未知模型返回 None(表示不做本地预校验)。"""
for prefix, limits in _BY_PREFIX:
if model.startswith(prefix):
return limits
return None
def validate(
model: str,
*,
resolution: str | None = None,
duration_sec: int | None = None,
ratio: str | None = None,
has_first_frame: bool = False,
) -> None:
"""按模型限制校验参数,不合法则抛 `ValueError`。
未知模型直接放行——见模块 docstring。
:raises ValueError: 参数超出该模型的限制,消息里指明模型与具体限制
"""
limits = limits_for(model)
if limits is None:
log.info("模型 %s 无本地限制表,跳过预校验,由厂商兜底", model)
return
if resolution and resolution not in limits.resolutions:
raise ValueError(
f"模型 {model} 不支持分辨率 {resolution},"
f"可选:{sorted(limits.resolutions)}"
)
if (
duration_sec is not None
and duration_sec != AUTO_DURATION
and not limits.min_duration_sec <= duration_sec <= limits.max_duration_sec
):
raise ValueError(
f"模型 {model} 的时长需在 "
f"[{limits.min_duration_sec}, {limits.max_duration_sec}] 秒内"
f"(或 {AUTO_DURATION} 表示由模型自选),当前 {duration_sec}"
)
if (
has_first_frame
and limits.ratio_must_be_adaptive_with_first_frame
and ratio
and ratio != "adaptive"
):
raise ValueError(
f"模型 {model} 在图生视频时只支持 ratio=adaptive"
f"(输出宽高比自动与首帧图片一致),当前 {ratio}"
)
+142
View File
@@ -0,0 +1,142 @@
"""火山方舟(Ark)的 HTTP 传输层。
**本模块不认识「视频」这个概念。** 它只管鉴权、超时、请求发送、以及把厂商的错误
体解包成带分类的异常。以后接火山的图片生成、语音或对话接口时,那些模块复用本
client,而不是各自再抄一遍鉴权与错误解包——这是把它单独拆出来的全部理由。
判断代码该进这里还是进 `video.py`:问「换成火山的另一个接口,这段还一样吗」。
鉴权头、错误体形状、超时语义都一样,故在此;`content` 数组怎么拼、`ratio` 怎么
取值是视频接口特有的,故不在此。
"""
from __future__ import annotations
import logging
from typing import Any, Self
import httpx
from app.domain.generation import Failure
from .errors import classify
log = logging.getLogger(__name__)
class ArkError(RuntimeError):
"""火山方舟返回的错误,携带领域分类。
:param failure: 已分类的失败,调用方据此决定重试策略与用户呈现,**不必再去
看厂商错误码**——那是适配层的职责边界。
"""
def __init__(self, failure: Failure, http_status: int | None = None):
super().__init__(failure.message or failure.provider_code or "Ark 调用失败")
self.failure = failure
self.http_status = http_status
class ArkClient:
"""火山方舟 API 的同步 HTTP 客户端。
**超时语义**:`timeout_sec` 是单次 HTTP 往返的超时,**不是生成耗时**。方舟的
内容生成接口是异步的(提交只入队即返回),所以几秒足够;把它调大成「等出图」
会重新引入长连接占用与「不知道有没有扣费」的歧义(ADR-023 §4)。
"""
def __init__(
self,
api_key: str,
base_url: str,
timeout_sec: float = 10.0,
client: httpx.Client | None = None,
):
if not api_key:
# 缺密钥是服务端配置错误,不是上游故障。在构造点就说清楚,
# 免得表现为一串莫名的 401。
raise ValueError(
"ARK_API_KEY 未配置:请通过环境变量注入,切勿写入代码或样例文件"
)
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._owns_client = client is None
self._client = client or httpx.Client(timeout=timeout_sec)
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
return self._send("POST", path, json=body)
def get(self, path: str) -> dict[str, Any]:
return self._send("GET", path)
# ---------------------------------------------------------------- 内部
def _send(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
url = f"{self._base_url}{path}"
try:
response = self._client.request(
method, url, headers=self._headers(), **kwargs
)
except httpx.HTTPError as exc:
# 网络层失败按可重试处理。注意**提交超时属于「不知道有没有扣费」的
# 窗口**:火山不支持幂等键,调用方必须依赖已落库的 provider_request_id
# 或回调来判断,不可盲目重发。
raise ArkError(classify(None, f"请求火山方舟失败:{exc}")) from exc
return self._unwrap(response)
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
@staticmethod
def _unwrap(response: httpx.Response) -> dict[str, Any]:
"""把响应转成 dict;非 2xx 转成带分类的 `ArkError`。
错误体形状取自官方错误码表:`{"error": {"code": ..., "message": ...}}`。
响应体解析失败时**不静默**——那通常意味着打到了错误的地址(例如网关返回
HTML 错误页),静默会让排查绕一大圈。
"""
if response.is_success:
try:
return response.json()
except ValueError as exc:
raise ArkError(
classify(
None,
f"响应不是合法 JSON(HTTP {response.status_code}):"
f"{response.text[:200]}",
),
response.status_code,
) from exc
code: str | None = None
message = response.text[:500]
try:
error = response.json().get("error") or {}
code = error.get("code")
message = error.get("message") or message
except ValueError:
pass
failure = classify(code, message)
log.warning(
"火山方舟返回错误 http=%s code=%s retry=%s",
response.status_code,
code,
failure.retry.value,
)
raise ArkError(failure, response.status_code)
def close(self) -> None:
# 只关自己创建的连接池:注入进来的 client 归调用方管理,
# 在这里关掉会让共享同一个 client 的其他适配器一起失效。
if self._owns_client:
self._client.close()
def __enter__(self) -> Self:
return self
def __exit__(self, *exc_info: object) -> None:
self.close()
+85
View File
@@ -0,0 +1,85 @@
"""`resolution` × `ratio` → 像素宽高的对照表。
**这是纯静态数据,不需要查询任何东西、也不依赖存储方案。** 厂商的查询接口会返回
`ratio` 与 `resolution`(但不返回像素值),查本表即可换算——无需下载产物、无需
探测文件。数值逐格取自官方文档「不同宽高比对应的宽高像素值」一节。
有了它,`widthPx`/`heightPx` 在 AI 视频产物上就能填真实值,而不必等到把文件
下载下来解码——这也正是 ADR-023 §11 所说「AI 输出路径不需要解码」的落地方式。
**实现上的一个关键观察**:Seedance 2.5 与 2.0 系列的 24 格里**只有两格不同**
(480p 的 `16:9` 与 `9:16`),其余完全一致。故实现为「共享表 + 两格覆盖」,
而不是抄两份 24 行——后者每次改动都要改两处,迟早只改一处。
"""
from __future__ import annotations
from .capabilities import SEEDANCE_2_0, SEEDANCE_2_5, ModelLimits
#: (resolution, ratio) → (width, height)。两个模型族共用的 22 格。
_SHARED: dict[tuple[str, str], tuple[int, int]] = {
("480p", "4:3"): (752, 560),
("480p", "1:1"): (640, 640),
("480p", "3:4"): (560, 752),
("480p", "21:9"): (992, 432),
("720p", "16:9"): (1280, 720),
("720p", "4:3"): (1112, 834),
("720p", "1:1"): (960, 960),
("720p", "3:4"): (834, 1112),
("720p", "9:16"): (720, 1280),
("720p", "21:9"): (1470, 630),
("1080p", "16:9"): (1920, 1080),
("1080p", "4:3"): (1664, 1248),
("1080p", "1:1"): (1440, 1440),
("1080p", "3:4"): (1248, 1664),
("1080p", "9:16"): (1080, 1920),
("1080p", "21:9"): (2206, 946),
}
#: 2.5 与 2.0 系列**唯一**不同的两格:480p 的 16:9 与 9:16。
_OVERRIDES_2_5: dict[tuple[str, str], tuple[int, int]] = {
("480p", "16:9"): (854, 480),
("480p", "9:16"): (480, 854),
}
_OVERRIDES_2_0: dict[tuple[str, str], tuple[int, int]] = {
("480p", "16:9"): (864, 496),
("480p", "9:16"): (496, 864),
# 4k 仅 2.0 系列支持。
("4k", "16:9"): (3840, 2160),
("4k", "4:3"): (3326, 2494),
("4k", "1:1"): (2880, 2880),
("4k", "3:4"): (2494, 3326),
("4k", "9:16"): (2160, 3840),
("4k", "21:9"): (4398, 1886),
}
_BY_FAMILY: dict[int, dict[tuple[str, str], tuple[int, int]]] = {
id(SEEDANCE_2_5): {**_SHARED, **_OVERRIDES_2_5},
id(SEEDANCE_2_0): {**_SHARED, **_OVERRIDES_2_0},
}
def pixels_for(
limits: ModelLimits | None, resolution: str | None, ratio: str | None
) -> tuple[int | None, int | None]:
"""查像素宽高;查不到返回 `(None, None)`。
查不到的三种情形都**返回 None 而不猜**——一个错的尺寸比留空更糟,它会让
客户端按错误的比例渲染,而且看不出是错的:
1. **`ratio` 为 `adaptive`**:厂商在图生视频时自动适配首帧图片的比例。查询
响应是否把它解析成具体比例、抑或原样返回 `adaptive`,文档未明确说明;
原样返回时本表无从查起。
2. **未知模型族**:能力表放行了未知模型(见 `capabilities.py`),但像素值
无法凭空推断。
3. **该模型不支持的组合**:如 2.5 的 4k——本不该发生(提交前已预校验),
出现即说明厂商行为与文档不符,留空并让上层看到缺值。
"""
if limits is None or not resolution or not ratio:
return (None, None)
table = _BY_FAMILY.get(id(limits))
if table is None:
return (None, None)
found = table.get((resolution, ratio))
return found if found else (None, None)
+161
View File
@@ -0,0 +1,161 @@
"""火山方舟错误码 → 领域 `Failure` 的映射。
**本模块是唯一理解火山错误码的地方。** 上层只看 `Failure` 的 `retry` 与
`user_facing`,不接触厂商码——把厂商词汇挡在这一层,是「换供应商不动上层」
的前提。
三处如果搞错只会在生产暴露的地方:
1. **`QuotaExceeded` 一码两义。** 同一个 code 既表示「免费额度/周期限额耗尽」
(要等到明天或去充值),也表示「当前账号排队中任务数超限」(等几秒就好)。
官方错误码表里这两条是相邻两行、code 完全相同,只有 message 不同。按 code
一刀切,必然有一半场景的文案和出口是错的。
2. **内容安全类重试毫无意义**(同样的输入必然同样被拒),必须与限流类分开,
否则会白烧三次重试预算再失败。
3. **未知码必须偏向可重试**而不是判死:厂商在持续新增码。
映射的完整取证见 patbond-doc `docs/development/providers/volcengine-seedance.md`。
"""
from __future__ import annotations
import re
from app.domain.generation import Failure, Retry, UserFacing
#: 内容安全类:**重试无意义**。覆盖输入/输出的文本、图片、视频、音频,以及
#: 版权、真人、伪造证件等子类,另含风险识别产品(*RiskDetection)的四个码。
#: 用前缀匹配而非穷举:官方这一族的码在持续增加(Input/Output × 媒体类型 ×
#: 子原因),穷举必然漏,而这一族的处置方式完全一致。
_CONTENT_PREFIXES = (
"SensitiveContentDetected",
"InputTextSensitiveContentDetected",
"InputImageSensitiveContentDetected",
"InputVideoSensitiveContentDetected",
"InputAudioSensitiveContentDetected",
"OutputTextSensitiveContentDetected",
"OutputImageSensitiveContentDetected",
"OutputVideoSensitiveContentDetected",
"OutputAudioSensitiveContentDetected",
"InputTextRiskDetection",
"InputImageRiskDetection",
"OutputTextRiskDetection",
"OutputImageRiskDetection",
)
#: 限流类:退避后重试。等的是秒级。
_RATE_LIMIT_CODES = frozenset(
{
"RateLimitExceeded.EndpointRPMExceeded",
"RateLimitExceeded.EndpointTPMExceeded",
"RateLimitExceeded.EndpointFlexTPMExceeded",
"ModelAccountRpmRateLimitExceeded",
"ModelAccountTpmRateLimitExceeded",
"ModelAccountFlexTpmRateLimitExceeded",
"ModelAccountIpmRateLimitExceeded",
"APIAccountRpmRateLimitExceeded",
"AccountRateLimitExceeded",
"ServerOverloaded",
"RequestBurstTooFast",
}
)
#: 配额/计费类:等的是「明天」或「充值」。重试解决不了。
#: 注意 403 的欠费与未开通也归此类——HTTP 状态码不同,但用户要做的事一样。
_QUOTA_CODES = frozenset(
{
"SetLimitExceeded",
"InflightBatchsizeExceeded",
"QuotaExceeded.AgentPlanQuotaExceeded",
"QuotaExceeded.DoubaoSearchFreeQuotaExceeded",
"AccountOverdueError",
"OperationDenied.ServiceOverdue",
"OperationDenied.FileQuotaExceeded",
"ModelNotOpen",
"OperationDenied.ServiceNotOpen",
}
)
#: 我方配置或代码错误:重试无意义,需要人来改。
_SERVER_FAULT_CODES = frozenset(
{
"AuthenticationError",
"InvalidAccountStatus",
"AccessDenied",
"InvalidEndpointOrModel.NotFound",
"InvalidEndpointOrModel.ModelIDAccessDisabled",
"InvalidEndpoint.ClosedEndpoint",
"OperationDenied.PermissionDenied",
"OperationDenied.ArkAccessRoleNotFound",
"OperationDenied.TosAccessDenied",
"PathNotFound",
}
)
#: 上游内部错误:可重试。
_UPSTREAM_CODES = frozenset({"InternalServiceError", "UpstreamUnavailable"})
#: `QuotaExceeded` 一码两义的判别式。命中即为「排队任务数超限」,属秒级等待;
#: 否则按额度耗尽处理。取自官方两行 message 的区别性措辞。
#: 判别放在 message 上是不得已——code 相同,没有别的字段可依据。
_QUEUE_BACKLOG_MESSAGE = re.compile(
r"exceeded the quota|排队中状态的任务数", re.IGNORECASE
)
def classify(provider_code: str | None, message: str | None = None) -> Failure:
"""把厂商错误码分类。
未知码一律按**可退避重试的上游问题**处理而不是直接判死:厂商在持续新增错误
码,把未知码当终态失败会让一次可恢复的抖动变成用户看到的失败;而重试预算
本身有上限(`max_attempts`),最坏情况只是多试两次。
"""
code = (provider_code or "").strip()
text = message or ""
if not code:
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "", text)
if code.startswith(_CONTENT_PREFIXES):
return Failure(Retry.NEVER, UserFacing.CONTENT_REJECTED, code, text)
if code == "ContentSecurityDetectionError":
# 风险识别产品自身请求失败,不是内容真的违规 —— 可重试。
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text)
if code == "QuotaExceeded":
# 一码两义,见模块 docstring。
if _QUEUE_BACKLOG_MESSAGE.search(text):
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, code, text)
return Failure(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code, text)
if code in _RATE_LIMIT_CODES:
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, code, text)
if code in _QUOTA_CODES:
return Failure(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code, text)
if code in _SERVER_FAULT_CODES:
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, code, text)
if code in _UPSTREAM_CODES:
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text)
# 参数类:我方拼错了请求体,重试同样的请求体不会变好。
# 含 InvalidParameter.TaskTypeConstraint / TaskTypeMismatch —— 那两个是
# Seedance 2.5 全模态任务的参数与模型判定的任务类型不兼容,属我方参数问题。
if code.startswith(
(
"InvalidParameter",
"MissingParameter",
"InvalidArgumentError",
"InvalidImageURL",
"InvalidPayload",
"RequestTooLarge",
"RequestBodyTooLarge",
"OutofContextError",
)
):
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, code, text)
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text)
+191
View File
@@ -0,0 +1,191 @@
"""火山方舟内容生成接口的请求/响应 DTO。
只放**厂商词汇**:字段名、取值、以及厂商文档里那些需要解释的怪异之处。领域概念
在 `app/domain/generation.py`,两者的翻译在 `video.py`。
这层单独存在的理由:厂商字段名与我们的领域名不一致(`framespersecond` vs `fps`、
`content.video_url` vs `asset_url`),且厂商有若干**文档自身矛盾或反直觉**之处
需要就地注明。把这些注释和翻译混进业务代码,业务逻辑就会被厂商细节淹没。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any
#: 官方 API base URL(北京区)。厂商事实收在厂商包内——放到配置里会让「厂商换
#: 域名」需要改两处,而其中一处还在与厂商无关的层里。
DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
#: 内容生成任务的路径(提交与查询共用前缀)。
TASKS_PATH = "/contents/generations/tasks"
#: 产物 URL 的有效期。厂商文档明示 24 小时(Seedance 2.5 另有 100 次下载上限)。
#: **产物必须在此时限内转存**——把厂商 URL 长期存为 external_url 会变死链。
ASSET_URL_TTL_SEC = 24 * 3600
class SeedanceStatus(Enum):
"""厂商任务状态。
**六个取值,而查询接口文档只列了五个。** `expired` 仅见于创建接口的
`callback_url` 一节(任务超过 `execution_expires_after` 被自动终止)。只按
查询文档实现,线上遇到超时任务会掉进未知分支。
"""
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
#: 查询接口文档未列出,来自创建接口的回调状态说明。
EXPIRED = "expired"
@classmethod
def parse(cls, value: str | None) -> SeedanceStatus | None:
"""未知状态返回 None 而非抛异常。
厂商新增状态时抛异常会让所有在途任务一起失败;返回 None 让上层当成
「还没结束」继续轮询,最坏是等到我们自己的超时。
"""
for status in cls:
if status.value == value:
return status
return None
@dataclass(frozen=True)
class SeedanceSubmission:
"""提交视频生成任务的请求体。
刻意只暴露 Patbond 会用到的参数。厂商参数远多于此(全模态参考、样片任务、
priority、service_tier…),**多暴露一个就多一处要在契约、客户端、文档三处
同步维护的地方**,等真用到再加。
"""
model: str
prompt: str | None = None
#: 首帧图片 URL。给了即图生视频,不给即文生视频(参考图选填,决策 A2)。
first_frame_url: str | None = None
#: `adaptive` 让模型自适应。注意 Seedance 2.5 在首帧/首尾帧、视频编辑、
#: 视频延长任务中**只支持 adaptive**,指定具体比例会被拒。
ratio: str = "adaptive"
resolution: str = "720p"
#: 秒。`-1` 交给模型在有效范围内自选(2.5 为 [4,30],2.0 系列为 [4,15])。
duration: int = 5
generate_audio: bool = True
watermark: bool = False
#: 返回尾帧图。**默认开启**:成本为零,且可直接充当视频封面,省掉抽帧。
return_last_frame: bool = True
#: 厂商取值范围 [3600, 259200],默认 48 小时对交互式应用过长。
execution_expires_after: int = 3600
#: 终端用户哈希标识,供厂商做滥用检测。**必须是哈希,不可传明文用户 ID**。
safety_identifier: str | None = None
callback_url: str | None = None
def to_body(self) -> dict[str, Any]:
content: list[dict[str, Any]] = []
if self.prompt:
content.append({"type": "text", "text": self.prompt})
if self.first_frame_url:
content.append(
{
"type": "image_url",
"image_url": {"url": self.first_frame_url},
# role 省略时厂商按 first_frame 处理;显式写出以免依赖默认行为。
"role": "first_frame",
}
)
if not content:
# 空内容必然被厂商拒,但那要花一次往返(还可能计入 RPM)。本地先拦。
raise ValueError("提交内容为空:prompt 与 first_frame_url 至少要有一个")
body: dict[str, Any] = {
"model": self.model,
"content": content,
"ratio": self.ratio,
"resolution": self.resolution,
"duration": self.duration,
"generate_audio": self.generate_audio,
"watermark": self.watermark,
"return_last_frame": self.return_last_frame,
"execution_expires_after": self.execution_expires_after,
}
if self.safety_identifier:
body["safety_identifier"] = self.safety_identifier
if self.callback_url:
body["callback_url"] = self.callback_url
return body
@dataclass(frozen=True)
class SeedanceTask:
"""查询接口返回的任务。
:param duration_sec: 厂商返回的时长**是向下取整的约数**。官方公式:
`duration = 实际总帧数 / 24` 向下取整——133 帧实际 5.54 秒会返回 5。
:param frames: 与 `duration_sec` **只会返回其中一个**(提交时指定了 frames
才返回 frames)。故 `duration_ms` 优先按帧算。
"""
id: str
status: SeedanceStatus | None
raw_status: str | None
#: 任务实际使用的模型(形如 `doubao-seedance-2-0-260128`)。用于按模型族
#: 换算像素宽高——同一适配器实例可能同时处理两个模型族的任务。
model: str | None = None
video_url: str | None = None
last_frame_url: str | None = None
duration_sec: int | None = None
frames: int | None = None
fps: int | None = None
ratio: str | None = None
resolution: str | None = None
output_format: str | None = None
error_code: str | None = None
error_message: str | None = None
completion_tokens: int | None = None
created_at: int | None = None
updated_at: int | None = None
@property
def duration_ms(self) -> int | None:
"""尽可能精确的时长毫秒数。
有 `frames` + `fps` 时按帧算(精确);否则退回厂商的整数秒(向下取整的
约数,最多可能短 1 秒)。两者都缺时返回 None,而不是编一个 0 出来。
"""
if self.frames is not None and self.fps:
return round(self.frames / self.fps * 1000)
if self.duration_sec is not None:
return self.duration_sec * 1000
return None
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> SeedanceTask:
content = payload.get("content") or {}
error = payload.get("error") or {}
usage = payload.get("usage") or {}
raw_status = payload.get("status")
return cls(
id=str(payload.get("id") or ""),
status=SeedanceStatus.parse(raw_status),
raw_status=raw_status,
model=payload.get("model"),
video_url=content.get("video_url"),
last_frame_url=content.get("last_frame_url"),
duration_sec=payload.get("duration"),
frames=payload.get("frames"),
# 厂商字段名是 framespersecond(无下划线),不是 fps。
fps=payload.get("framespersecond"),
ratio=payload.get("ratio"),
resolution=payload.get("resolution"),
output_format=payload.get("output_format"),
error_code=error.get("code"),
error_message=error.get("message"),
# 视频生成不统计输入 token,故 total == completion,取其一即可。
completion_tokens=usage.get("completion_tokens"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
)
+224
View File
@@ -0,0 +1,224 @@
"""Seedance 视频生成:领域请求 ↔ 火山请求体的翻译。
本模块只管**视频生成这一个业务**:怎么拼 `content` 数组、状态怎么映射到领域状态、
失败怎么分类。HTTP 传输与鉴权在 `client.py`,厂商 DTO 在 `models.py`,错误码映射
在 `errors.py`。
以后接火山的**图片生成**时,应新建一个兄弟模块(如 `image.py`)复用同一个
`ArkClient` 与 `errors.py`,而不是在本文件里加分支——视频与图片的参数集、状态机、
产物形态都不同,混在一处会让两边都难改。
"""
from __future__ import annotations
import logging
from app.domain.generation import (
Failure,
GenerationRequest,
GenerationResult,
MediaKind,
Retry,
TaskState,
UserFacing,
)
from app.providers.base import GenerationProvider
from .capabilities import limits_for
from .capabilities import validate as validate_limits
from .client import ArkClient
from .dimensions import pixels_for
from .models import (
ASSET_URL_TTL_SEC,
TASKS_PATH,
SeedanceStatus,
SeedanceSubmission,
SeedanceTask,
)
log = logging.getLogger(__name__)
#: `creation.generation_models.provider_code` 里对应本适配器的值。
PROVIDER_CODE = "volcengine-seedance"
class SeedanceVideoProvider(GenerationProvider):
"""火山方舟 Seedance 的视频生成适配器。"""
def __init__(
self,
client: ArkClient,
default_model: str = "",
task_expiry_sec: int = 3600,
):
self._client = client
self._default_model = default_model
self._task_expiry_sec = task_expiry_sec
@property
def provider_code(self) -> str:
return PROVIDER_CODE
@property
def media_kind(self) -> MediaKind:
return MediaKind.VIDEO
# ------------------------------------------------------------- submit
def submit(self, request: GenerationRequest) -> str:
if request.media_kind is not MediaKind.VIDEO:
# 路由错了。与其把一个图片请求塞给视频接口让厂商拒,不如就地说清楚。
raise ValueError(
f"{PROVIDER_CODE} 只处理视频生成,收到 {request.media_kind.value}"
)
submission = self._to_submission(request)
payload = self._client.post(TASKS_PATH, submission.to_body())
task_id = payload.get("id")
if not task_id:
# 2xx 但没有 ID:我们再也查不到这个任务,而它可能已经在计费。
# 绝不能当成功放过。
raise ValueError(f"火山方舟提交成功但响应缺少任务 ID:{payload!r}")
return str(task_id)
def _to_submission(self, request: GenerationRequest) -> SeedanceSubmission:
"""领域请求 → 厂商请求体。
模型取值优先用请求里的(来自任务行冻结的快照),仅在缺失时回落到配置的
默认值。**适配层不自行选模型**:选型是目录的职责,且任务行里的快照必须
与实际调用的模型一致,否则事后无法对账。
"""
model = request.model or self._default_model
if not model:
raise ValueError("未指定模型:请求未携带 model 且 ARK_VIDEO_MODEL 未配置")
# 本地预校验:已接入的两个模型限制不同(2.5 时长到 30 秒但无 4k,2.0 有 4k
# 但时长上限 15 秒;2.5 图生视频时 ratio 锁 adaptive)。超范围的请求会被厂商
# 400 拒绝——那要花一次往返、计入 RPM,并表现为一个失败的任务。未知模型放行。
validate_limits(
model,
resolution=request.resolution,
duration_sec=request.duration_sec,
ratio=request.ratio,
has_first_frame=bool(request.input_url),
)
return SeedanceSubmission(
model=model,
prompt=request.prompt,
first_frame_url=request.input_url,
ratio=request.ratio or SeedanceSubmission.ratio,
resolution=request.resolution or SeedanceSubmission.resolution,
duration=(
request.duration_sec
if request.duration_sec is not None
else SeedanceSubmission.duration
),
generate_audio=request.generate_audio,
watermark=request.watermark,
execution_expires_after=self._task_expiry_sec,
safety_identifier=request.safety_identifier,
callback_url=request.callback_url,
)
# -------------------------------------------------------------- fetch
def fetch(self, provider_request_id: str) -> GenerationResult:
payload = self._client.get(f"{TASKS_PATH}/{provider_request_id}")
task = SeedanceTask.from_payload(payload)
return self._to_result(task, provider_request_id)
def _to_result(self, task: SeedanceTask, request_id: str) -> GenerationResult:
"""厂商任务 → 领域结果。
状态映射见下。注意领域状态集比厂商的**更小**:queued/running 都还没结束,
expired 是失败的一种(重试策略由 `Failure` 表达,不需要一个独立状态)。
"""
state = self._map_state(task)
failure = self._map_failure(task)
if state is TaskState.SUCCEEDED and not task.video_url:
# 厂商说成功却没给 URL:这不是成功。当成可重试的上游异常,
# 而不是把一个没有产物的「成功」放给上层去空指针。
log.error("火山方舟任务 %s 状态为 succeeded 但缺少 video_url", task.id)
return GenerationResult(
provider_request_id=request_id,
state=TaskState.FAILED,
failure=Failure(
Retry.BACKOFF,
UserFacing.UPSTREAM_UNAVAILABLE,
"MissingVideoUrl",
"厂商返回 succeeded 但 content.video_url 为空",
),
)
# 像素宽高由 (模型族, resolution, ratio) 查静态对照表得到——厂商只返回后两者,
# 不返回像素值。查表不需要下载产物也不需要解码,故 widthPx/heightPx 在 AI
# 视频产物上可零成本填真值(ADR-023 §11)。查不到时留空而不猜:一个错的
# 尺寸会让客户端按错误比例渲染,且看不出是错的。
width_px, height_px = pixels_for(
limits_for(self._model_of(task)), task.resolution, task.ratio
)
return GenerationResult(
provider_request_id=request_id,
state=state,
# 火山不返回百分比进度。这不是缺陷而是事实:客户端契约本就不承诺
# 平滑进度(ADR-023 §10),成功前一律 0、成功即 100。
progress=100 if state is TaskState.SUCCEEDED else 0,
asset_url=task.video_url,
asset_url_expires_in_sec=(ASSET_URL_TTL_SEC if task.video_url else None),
cover_url=task.last_frame_url,
ratio=task.ratio,
resolution=task.resolution,
width_px=width_px,
height_px=height_px,
duration_ms=task.duration_ms,
output_format=task.output_format,
failure=failure,
billing_tokens=task.completion_tokens,
)
def _model_of(self, task: SeedanceTask) -> str:
"""任务所用的模型。
查询响应带 `model` 字段(形如 `doubao-seedance-2-0-260128`),优先用它——
这样同一个适配器实例处理不同模型的任务时,像素换算也能各按其族。缺失时
才回落到配置的默认模型。
"""
return task.model or self._default_model
@staticmethod
def _map_state(task: SeedanceTask) -> TaskState:
match task.status:
case SeedanceStatus.QUEUED:
return TaskState.PENDING
case SeedanceStatus.RUNNING:
return TaskState.RUNNING
case SeedanceStatus.SUCCEEDED:
return TaskState.SUCCEEDED
case SeedanceStatus.CANCELLED:
return TaskState.CANCELLED
case SeedanceStatus.FAILED | SeedanceStatus.EXPIRED:
return TaskState.FAILED
case _:
# 未知状态当作「还在跑」:厂商新增状态时把在途任务判死更糟,
# 最坏是等到我们自己的超时。
log.warning("火山方舟返回未知任务状态:%r", task.raw_status)
return TaskState.RUNNING
@staticmethod
def _map_failure(task: SeedanceTask) -> Failure | None:
from .errors import classify
if task.status is SeedanceStatus.EXPIRED:
# 超时不是内容或参数问题,可重试。单独处理是因为厂商在 expired 时
# 不一定给 error.code,走通用分类会被当成「无码」。
return Failure(
Retry.BACKOFF,
UserFacing.UPSTREAM_UNAVAILABLE,
"TaskExpired",
"任务超过 execution_expires_after 被厂商终止",
)
if task.status is SeedanceStatus.FAILED:
return classify(task.error_code, task.error_message)
return None
+412
View File
@@ -0,0 +1,412 @@
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
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "patbond-ai"
version = "0.1.0"
description = "Patbond AI 生成服务:调用 AI 服务商 API 完成图片与视频生成"
requires-python = ">=3.11"
[tool.ruff]
# 与 CI 门禁一致(.gitea/workflows/ci.yml)。
line-length = 88
[tool.ruff.lint.flake8-bugbear]
# `Depends()` / `Query()` 等放在参数默认值里**正是 FastAPI 的标准写法**,
# B008 在此是误报。为迎合规则把依赖声明搬进函数体会失去类型提示与自动文档,
# 是本末倒置——故按 ruff 提供的正规方式声明这些调用为可安全求值,
# 而不是在每个端点上撒 `# noqa`(后者迟早被用来压制真正的问题)。
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
"fastapi.Path",
"fastapi.Header",
"fastapi.Body",
]
[tool.pytest.ini_options]
# 让 `pytest` 从仓库根目录直接跑通,无需先安装包。
pythonpath = ["."]
testpaths = ["tests"]
+5
View File
@@ -0,0 +1,5 @@
-r requirements.txt
# 与 CI 门禁同一套工具(.gitea/workflows/ci.yml)
ruff>=0.6
pytest>=8.0
pyyaml>=6.0
+8
View File
@@ -0,0 +1,8 @@
# 运行时依赖。版本下限按实测可用版本给,不锁死上限——本服务只用 httpx 的
# 基础同步 API,其小版本变更风险低。
httpx>=0.27
# HTTP 服务(内部接口)。uvicorn 是运行时服务器,非库依赖。
fastapi>=0.115
uvicorn>=0.30
# 服务身份 JWT 的 RS256 校验;[crypto] 带入 cryptography。
pyjwt[crypto]>=2.9
+119
View File
@@ -0,0 +1,119 @@
#!/bin/sh
# check-secrets.sh —— 凭证防泄漏检查(ADR-021,规范见 patbond-doc docs/development/git-workflow.md)
#
# 规则单一来源:本地 pre-commit 与 CI 兜底跑的是同一个脚本、同一张规则表。
# 四仓(patbond-api / patbond-flutter / patbond-doc / patbond-ai)各存一份同构副本,
# 改规则时四仓同步提交。patbond-ai 是唯一持有 AI 服务商凭证的仓,尤其不可漏。
#
# 用法:
# sh scripts/check-secrets.sh --staged # pre-commit:扫暂存区内容(经 scripts/hooks/pre-commit 调用)
# sh scripts/check-secrets.sh --all # CI 兜底 / 手动自查:扫全部已跟踪文件(缺省模式)
# sh scripts/check-secrets.sh <文件...> # 扫指定文件
#
# 拦下真实凭证时的第一动作:去云控制台轮换/禁用该密钥,然后才是清理提交。
set -u
mode="${1:---all}"
# 允许清单:行内出现任一形态即放行(${} 注入、占位值、明显示例值)
# 注:x{3,}(xxx / xxxx…)是本项目文档描述凭证形态时的既有占位约定,
# 放行它使「讲解规则的文档」不会被规则自身拦下(如 iteration-4/08 §漏检清单)。
ALLOW='\$\{[^}]*\}|\{\{[^}]*\}\}|changeme|change[-_]me|your[-_][a-zA-Z0-9_-]+|<[a-zA-Z0-9 ,_.-]+>|placeholder|example|sample|dummy|fake|redacted|\*\*\*|x{3,}'
# 依赖注入的传参不是字面量密钥:`api_key=settings.api_key`、
# `.param("secretKey", props.getSecretKey())` 这类行里没有任何秘密,而做 DI 的
# 代码到处都是这种写法——不放行就得在每个调用点绕过一次。
# 收得很紧,避免把真凭证放进来:
# * 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状)
# * 只允许**恰好一个点**且随即行尾(JWT 是 a.b.c 三段,仍会被拦)
# * 连字符不在字符集内(sk-ant-… 这类前缀仍会被拦)
ALLOW_REFERENCE='[:=][[:space:]]*[a-z][a-zA-Z0-9_]*\.[a-z][a-zA-Z0-9_]*[[:space:]]*[,);]?$'
# 内容扫描跳过:本脚本与 hook 自身(含规则文本,非凭证)
SKIP_PATHS='(^|/)scripts/(check-secrets\.sh|hooks/pre-commit)$'
# 文件名黑名单:凭证载体文件本体禁止入库(.sample/.example 除外)
DENY_NAME='(^|/)\.env(\.[^/]+)?$|(^|/)credentials[^/]*$|[Aa]ccess[Kk]eys?[^/]*\.csv$|(^|/)rootkey\.csv$'
DENY_NAME_OK='\.(sample|example)$'
# 规则表:ID<TAB>大小写旗标(i=忽略大小写,-=敏感)<TAB>文件范围ERE(-=全部文件)<TAB>行模式ERE
RULES=$(cat <<'EOF'
AK-AWS - - AKIA[0-9A-Z]{16}
AK-QCLOUD - - AKID[0-9A-Za-z]{16,}
AK-ALIYUN - - LTAI[0-9A-Za-z]{12,}
AK-ANTHROPIC - - sk-ant-[A-Za-z0-9_-]{16,}
AK-OPENAI - - sk-(proj-|svcacct-)?[A-Za-z0-9]{20,}
MINIO-DEFAULT i - minio[-_.]?admin
PRIVATE-KEY - - ^[[:space:]]*-----BEGIN [A-Z ]*PRIVATE KEY-----[[:space:]]*$
KEY-ASSIGN i - (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key|api[-_]?(key|secret|token)|auth[-_]?token)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
JWT-SECRET i - (jwt[-_.]?secret|signing[-_]?key|token[-_]?secret|hmac[-_]?(key|secret))["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
DB-PASSWORD i \.(ya?ml|properties|toml|conf|ini)(\.sample|\.example)?$ (password|passwd|pwd)["']?[[:space:]]*[:=][[:space:]]*["']?[^[:space:]"'$]{6,}
EOF
)
case "$mode" in
--staged)
files=$(git diff --cached --name-only --diff-filter=ACM)
src=index
;;
--all)
files=$(git ls-files)
src=worktree
;;
-*)
echo "用法: $0 [--staged|--all|<文件...>]" >&2
exit 2
;;
*)
files=$(printf '%s\n' "$@")
src=worktree
;;
esac
[ -n "$files" ] || exit 0
tmp=$(mktemp) || exit 2
viol=$(mktemp) || exit 2
trap 'rm -f "$tmp" "$viol"' EXIT
# 第一道:文件名黑名单
printf '%s\n' "$files" | grep -E "$DENY_NAME" | grep -vE "$DENY_NAME_OK" |
sed 's/^/[NAME-DENY] /' >>"$viol" || true
# 第二道:逐文件逐规则内容扫描(二进制文件经 grep -I 自然跳过)
IFS='
'
for f in $files; do
printf '%s' "$f" | grep -qE "$SKIP_PATHS" && continue
if [ "$src" = index ]; then
git show ":$f" >"$tmp" 2>/dev/null || continue
else
[ -f "$f" ] || continue
cat -- "$f" >"$tmp"
fi
printf '%s\n' "$RULES" | while IFS="$(printf '\t')" read -r id flag scope pat; do
[ -n "$id" ] || continue
if [ "$scope" != "-" ]; then
printf '%s' "$f" | grep -qE "$scope" || continue
fi
ci=""
[ "$flag" = "i" ] && ci="-i"
grep -InE $ci -e "$pat" "$tmp" 2>/dev/null | grep -viE "$ALLOW" |
grep -vE "$ALLOW_REFERENCE" |
sed "s|^|[$id] $f:|" >>"$viol" || true
done
done
if [ -s "$viol" ]; then
echo "凭证防泄漏检查未通过(ADR-021)——以下内容疑似真实凭证:" >&2
cat "$viol" >&2
cat >&2 <<'MSG'
处置:
1. 若是真实凭证:先去云控制台轮换/禁用该密钥,再从提交中移除;
2. 若是误报:改用 ${} 注入或占位值(changeme / your-xxx / <占位>),
或与团队确认后调整四仓同构的 scripts/check-secrets.sh 规则表。
敏感信息只允许存在于被 gitignore 的文件或 .sample 占位中(git-workflow.md)。
MSG
exit 1
fi
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# pre-commit —— 凭证防泄漏(ADR-021)。启用(每人每仓一次):
# git config core.hooksPath scripts/hooks
# 注意:core.hooksPath 会整体接管 hooks 目录;本仓无其他自定义 hook。
repo_root=$(git rev-parse --show-toplevel) || exit 1
exec sh "$repo_root/scripts/check-secrets.sh" --staged
View File
View File
+177
View File
@@ -0,0 +1,177 @@
"""手写契约 `openapi-internal.yaml` 与实际实现的一致性守卫。
**为什么需要它**:契约是手写的,实现是另一份代码。本项目开工审计已经证明「没有
门禁的契约必然漂移」——四模块快照锁实际不存在,结果白名单 42/41 之误四份文档一路
不可见,整整一个迭代没人发现。内部契约同样是契约,同样需要门禁。
**不做的事**:不比对 FastAPI 自动生成的 `/openapi.json` 与手写 yaml 的字节一致性。
两者永远不会字节相同(描述、示例、`$ref` 组织方式都不同),逐字比对只会制造一个
天天红的门禁,然后被人加 skip 绕过。这里比对的是**有实质意义的东西**:路径、方法、
字段集、枚举取值。
"""
from __future__ import annotations
import pathlib
import yaml
from app.api.errors import ServiceErrorCode
from app.api.main import create_app
from app.api.schemas import FailureModel, SubmitRequest, SubmitResponse, TaskResponse
from app.config import ServiceAuthSettings, Settings, VolcengineSettings
from app.domain.generation import Retry, TaskState, UserFacing
CONTRACT = yaml.safe_load(
(
pathlib.Path(__file__).resolve().parent.parent.parent / "openapi-internal.yaml"
).read_text(encoding="utf-8")
)
def schema_properties(name: str) -> set[str]:
return set(CONTRACT["components"]["schemas"][name]["properties"])
def app_routes() -> set[tuple[str, str]]:
"""实际对外暴露的 `/internal` 端点集合。
取自 `app.openapi()` 而非遍历 `app.routes`:后者在不同 FastAPI 版本里结构不同
(本版本的 `include_router` 会包一层内部 wrapper,路由并不平铺在 `app.routes`
上),依赖它会让这个守卫在升级框架时莫名失效——而一个悄悄失效的门禁比没有
门禁更糟。`openapi()` 是「究竟暴露了什么」的权威视图。
"""
app = create_app(
Settings(
volcengine=VolcengineSettings(api_key="dummy-api-key-for-tests"),
service_auth=ServiceAuthSettings(public_key=""),
)
)
return {
(method.upper(), path)
for path, operations in app.openapi()["paths"].items()
if path.startswith("/internal")
for method in operations
}
class TestPathsMatch:
def test_every_contract_path_is_implemented(self):
implemented = app_routes()
for path, operations in CONTRACT["paths"].items():
for method in operations:
assert (method.upper(), path) in implemented, (
f"契约声明了 {method.upper()} {path} 但实现里没有"
)
def test_no_undeclared_internal_route_exists(self):
"""实现里不得有契约之外的 /internal 端点。
未声明的内部端点是最容易被遗忘的表面——它不在契约里,评审看不到,
但它确实对外开放。
"""
declared = {
(method.upper(), path)
for path, operations in CONTRACT["paths"].items()
for method in operations
}
assert app_routes() <= declared
class TestFieldSetsMatch:
"""字段集漂移是最隐蔽的一种:多一个字段无人察觉,少一个字段调用方拿到 None。"""
def test_submit_request(self):
assert schema_properties("SubmitRequest") == set(SubmitRequest.model_fields)
def test_submit_response(self):
assert schema_properties("SubmitResponse") == set(SubmitResponse.model_fields)
def test_task_response(self):
assert schema_properties("TaskResponse") == set(TaskResponse.model_fields)
def test_failure(self):
assert schema_properties("Failure") == set(FailureModel.model_fields)
def test_all_field_names_are_snake_case(self):
"""契约定为 snake_case(与对客户端的 camelCase 契约不同,见契约说明)。
混入一个 camelCase 字段就会让「JSON 与 dataclass 字段逐一对应、无需转换层」
这个前提失效。
"""
for name in ("SubmitRequest", "SubmitResponse", "TaskResponse", "Failure"):
for field in schema_properties(name):
assert field.islower() or "_" in field, (
f"{name}.{field} 不是 snake_case"
)
assert not any(char.isupper() for char in field), (
f"{name}.{field} 含大写字母"
)
class TestEnumsMatch:
"""枚举漂移的后果是调用方收到一个它没处理的取值——而那通常走到默认分支。"""
def test_task_states(self):
declared = set(
CONTRACT["components"]["schemas"]["TaskResponse"]["properties"]["state"][
"enum"
]
)
assert declared == {state.value for state in TaskState}
def test_retry_strategies(self):
declared = set(
CONTRACT["components"]["schemas"]["Failure"]["properties"]["retry"]["enum"]
)
assert declared == {retry.value for retry in Retry}
def test_user_facing_categories(self):
declared = set(
CONTRACT["components"]["schemas"]["Failure"]["properties"]["user_facing"][
"enum"
]
)
assert declared == {category.value for category in UserFacing}
def test_service_error_codes(self):
declared = set(
CONTRACT["components"]["schemas"]["ServiceErrorEnvelope"]["properties"][
"error"
]["properties"]["code"]["enum"]
)
assert declared == {code.code for code in ServiceErrorCode}
def test_provider_error_reuses_the_failure_classification_fields(self):
"""`provider` 错误信封与 `Failure` 的分类字段必须一致。
这是刻意设计:调用方因此可以复用同一段处置逻辑,不必为「提交时被限流」
与「任务失败于限流」写两套分支。两边若漂移,那份复用就会悄悄失效。
"""
envelope = set(
CONTRACT["components"]["schemas"]["ProviderErrorEnvelope"]["properties"][
"error"
]["properties"]
)
failure = schema_properties("Failure")
assert envelope == failure | {"kind"}
class TestRequiredFieldsMatch:
def test_submit_request_required(self):
declared = set(CONTRACT["components"]["schemas"]["SubmitRequest"]["required"])
actual = {
name
for name, field in SubmitRequest.model_fields.items()
if field.is_required()
}
assert declared == actual
def test_task_response_required(self):
declared = set(CONTRACT["components"]["schemas"]["TaskResponse"]["required"])
actual = {
name
for name, field in TaskResponse.model_fields.items()
if field.is_required()
}
assert declared == actual
+512
View File
@@ -0,0 +1,512 @@
"""内部接口的测试:鉴权边界、两类错误信封、端到端编排。
全部离线——服务商调用被 `httpx.MockTransport` 拦下,服务票用测试期临时生成的
RSA 密钥对签发。
"""
from __future__ import annotations
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from app.api.main import create_app
from app.config import ServiceAuthSettings, Settings, VolcengineSettings
from app.providers.registry import ProviderRegistry
from app.providers.volcengine.client import ArkClient
from app.providers.volcengine.video import SeedanceVideoProvider
FAKE_API_KEY = "dummy-api-key-for-tests"
MODEL_2_5 = "doubao-seedance-2-5-260628"
MODEL_2_0 = "doubao-seedance-2-0-260128"
# ----------------------------------------------------------------- 夹具
@pytest.fixture(scope="module")
def keypair() -> tuple[str, str]:
"""测试期临时 RSA 密钥对(私钥签票、公钥配给服务)。
现场生成而非硬编码 PEM:密钥形态的 PEM 写进仓库会被防泄漏扫描拦下,而且
那条规则是对的。
"""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
public_pem = (
key.public_key()
.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode()
)
return private_pem, public_pem
def service_token(private_pem: str, **overrides) -> str:
"""签一张服务票;`overrides` 用于构造各种不合格的票。"""
import time
claims = {
"iss": "patbond-api",
"aud": "patbond-ai",
"sub": "service:creation-worker",
"typ": "service",
"iat": int(time.time()),
"exp": int(time.time()) + 300,
"jti": "test-jti",
}
claims.update(overrides)
return jwt.encode(claims, private_pem, algorithm="RS256")
def client_for(keypair, handler, *, with_provider: bool = True) -> TestClient:
"""建一个测试客户端,服务商调用被 handler 拦下。"""
_, public_pem = keypair
settings = Settings(
volcengine=VolcengineSettings(api_key=FAKE_API_KEY if with_provider else ""),
service_auth=ServiceAuthSettings(public_key=public_pem),
)
app = create_app(settings)
# 换掉装配好的适配器,让它的 HTTP 走 MockTransport。
if with_provider:
app.state.registry = ProviderRegistry(
[
SeedanceVideoProvider(
ArkClient(
api_key=FAKE_API_KEY,
base_url="https://ark.example.invalid/api/v3",
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
)
]
)
else:
app.state.registry = ProviderRegistry([])
return TestClient(app, raise_server_exceptions=False)
def auth(keypair, **overrides) -> dict[str, str]:
private_pem, _ = keypair
return {"Authorization": f"Bearer {service_token(private_pem, **overrides)}"}
SUBMIT_BODY = {
"job_id": "0199a1b2-c3d4-7000-8000-000000000001",
"media_kind": "video",
"model": MODEL_2_5,
"prompt": "我的柯基在草地上奔跑",
}
# ------------------------------------------------------------- 鉴权边界
class TestAuthentication:
"""服务票是这一层的安全边界。失败一律返回同一个笼统原因,细节只进日志。"""
def test_missing_token_is_rejected(self, keypair):
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post("/internal/generation/tasks", json=SUBMIT_BODY)
assert response.status_code == 401
assert response.json()["error"]["kind"] == "service"
assert response.json()["error"]["code"] == "unauthenticated"
def test_wrong_audience_is_rejected(self, keypair):
"""`aud` 强制校验是独立密钥对方案唯一的意义所在。
Java 侧现有的 JwtVerifier 只验签名、不校验 aud——正因如此才必须用独立
密钥对;如果本服务也只验签名,那对独立密钥就白搭了。
"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json=SUBMIT_BODY,
headers=auth(keypair, aud="someone-else"),
)
assert response.status_code == 401
def test_wrong_issuer_is_rejected(self, keypair):
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json=SUBMIT_BODY,
headers=auth(keypair, iss="attacker"),
)
assert response.status_code == 401
def test_a_user_token_shape_is_rejected_even_if_signed_with_this_key(self, keypair):
"""有人误把用户密钥配到这里时的第二道防线。
用户 token 没有 `typ=service`(Java 侧的 JwtSigner 也不签 aud),故即使
签名能过也会被拦下。
"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json=SUBMIT_BODY,
headers=auth(keypair, typ=None),
)
assert response.status_code == 401
def test_expired_token_is_rejected(self, keypair):
import time
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json=SUBMIT_BODY,
headers=auth(keypair, exp=int(time.time()) - 10),
)
assert response.status_code == 401
def test_missing_public_key_denies_everything_rather_than_allowing(self, keypair):
"""配置缺失必须是「全拒」而非「全过」。
前者立刻暴露,后者是一扇敞开的门。与 Java 侧 InternalAuthFilter 未配置
internal-token 时全拒 /internal/** 的既有选择一致。
"""
settings = Settings(
volcengine=VolcengineSettings(api_key=FAKE_API_KEY),
service_auth=ServiceAuthSettings(public_key=""),
)
client = TestClient(create_app(settings), raise_server_exceptions=False)
response = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
)
assert response.status_code == 401
def test_error_body_never_says_why(self, keypair):
"""具体原因(签名错/过期/aud 不符)只进日志。
对方是我们自己的服务,真正原因在本服务日志里;把细节回给调用方只会在
将来某天变成攻击者的提示。
"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
body = client.post("/internal/generation/tasks", json=SUBMIT_BODY).json()
message = body["error"]["message"]
for leak in ("signature", "aud", "audience", "expired", "iss"):
assert leak.lower() not in message.lower()
class TestHealthNeedsNoToken:
def test_health_is_outside_internal_and_unauthenticated(self, keypair):
client = client_for(keypair, lambda r: httpx.Response(200, json={}))
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_health_reports_assembled_providers(self, keypair):
"""缺凭证时适配器不装配,而那个症状(任务永远排队)离根因很远。
让探活直接说出来能省很多排查时间。
"""
with_provider = client_for(keypair, lambda r: httpx.Response(200, json={}))
assert with_provider.get("/health").json()["providers"] == [
"volcengine-seedance"
]
without = client_for(
keypair, lambda r: httpx.Response(200, json={}), with_provider=False
)
assert without.get("/health").json()["providers"] == []
# ------------------------------------------------------------------ 提交
class TestSubmit:
def test_returns_provider_request_id(self, keypair):
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/contents/generations/tasks")
return httpx.Response(200, json={"id": "cgt-20260920-abc123"})
client = client_for(keypair, handler)
response = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
)
assert response.status_code == 200
assert response.json() == {"provider_request_id": "cgt-20260920-abc123"}
def test_field_names_are_snake_case(self, keypair):
"""契约为 snake_case——camelCase 的请求体应被拒。
`extra="forbid"` 使拼错的字段立刻报错,而不是被静默忽略(后者表现为
「我传了 durationSec 却没生效」这类极难查的问题)。
"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json={**SUBMIT_BODY, "durationSec": 5},
headers=auth(keypair),
)
assert response.status_code == 400
assert response.json()["error"]["code"] == "invalid_request"
def test_user_id_is_not_accepted(self, keypair):
"""契约刻意不含 user_id(数据最小化)。传了应当被拒而不是被忽略。"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json={**SUBMIT_BODY, "user_id": "u-1"},
headers=auth(keypair),
)
assert response.status_code == 400
def test_empty_content_is_rejected_locally(self, keypair):
def handler(request):
raise AssertionError("不应发出请求")
client = client_for(keypair, handler)
body = {k: v for k, v in SUBMIT_BODY.items() if k != "prompt"}
response = client.post(
"/internal/generation/tasks", json=body, headers=auth(keypair)
)
assert response.status_code == 400
assert "至少要有一个" in response.json()["error"]["message"]
def test_model_limit_violation_is_invalid_request_not_provider_error(self, keypair):
"""超出模型限制的请求**根本没发出去**,故属 service/invalid_request。
归到 provider 类会让调用方以为是上游的问题而去退避重试——而重试同样的
参数永远不会变好。
"""
def handler(request):
raise AssertionError("不应发出请求")
client = client_for(keypair, handler)
response = client.post(
"/internal/generation/tasks",
json={**SUBMIT_BODY, "model": MODEL_2_0, "duration_sec": 20},
headers=auth(keypair),
)
assert response.status_code == 400
error = response.json()["error"]
assert error["kind"] == "service"
assert error["code"] == "invalid_request"
assert "[4, 15]" in error["message"]
def test_no_adapter_yields_provider_unavailable(self, keypair):
"""缺凭证 → 适配器未装配。503 而非 500:这是环境问题且可恢复。"""
client = client_for(
keypair, lambda r: httpx.Response(200, json={}), with_provider=False
)
response = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
)
assert response.status_code == 503
assert response.json()["error"]["code"] == "provider_unavailable"
# ------------------------------------------------------- 两类错误的分界
class TestTwoErrorKindsAreDistinct:
"""这是契约的核心设计:调用方对两类失败的反应完全不同。"""
def test_vendor_rate_limit_is_a_provider_error_with_full_classification(
self, keypair
):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"code": "RateLimitExceeded.EndpointRPMExceeded",
"message": "RPM limit exceeded",
}
},
)
client = client_for(keypair, handler)
response = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
)
# 刻意不透传服务商的 429:429 既可能是「等几秒」也可能是「等到明天」,
# 状态码表达不了这个区别,retry 才是权威信号。
assert response.status_code == 502
error = response.json()["error"]
assert error["kind"] == "provider"
assert error["retry"] == "backoff"
assert error["user_facing"] == "rate_limited"
assert error["provider_code"] == "RateLimitExceeded.EndpointRPMExceeded"
def test_quota_exhausted_carries_a_different_retry_than_rate_limit(self, keypair):
"""同为服务商 429,但一个等几秒、一个等到明天。
调用方只看 retry 就能分开,不必解读厂商码。
"""
def handler(request):
return httpx.Response(
429,
json={
"error": {
"code": "QuotaExceeded",
"message": "Your account has exhausted its free trial quota.",
}
},
)
client = client_for(keypair, handler)
error = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
).json()["error"]
assert error["retry"] == "quota"
assert error["user_facing"] == "quota_exhausted"
def test_kind_is_the_discriminator_not_the_status_code(self, keypair):
"""两类错误的状态码会撞,`kind` 不会。
502 既可能是服务商挂了(provider),也可能是我们打错地址导致的解析失败
(也归 provider,但 service 类错误同样可能出现 5xx)。判别必须看 kind。
"""
def handler(request):
return httpx.Response(502, text="<html>bad gateway</html>")
client = client_for(keypair, handler)
body = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
).json()
assert body["error"]["kind"] == "provider"
assert "kind" in body["error"]
# ------------------------------------------------------------------ 查询
#: 官方查询文档「返回格式」一节的原样示例(2.0 系列 720p + 16:9)。
OFFICIAL_QUERY_PAYLOAD = {
"id": "cgt-1",
"model": MODEL_2_0,
"status": "succeeded",
"content": {
"video_url": "https://v/out.mp4",
"last_frame_url": "https://v/f.png",
},
"usage": {"completion_tokens": 108900, "total_tokens": 108900},
"resolution": "720p",
"ratio": "16:9",
"duration": 5,
"framespersecond": 24,
}
class TestGetTask:
def test_succeeded_task_is_fully_serialised(self, keypair):
client = client_for(
keypair, lambda r: httpx.Response(200, json=OFFICIAL_QUERY_PAYLOAD)
)
body = client.get(
"/internal/generation/tasks/cgt-1", headers=auth(keypair)
).json()
assert body["state"] == "succeeded"
assert body["progress"] == 100
assert body["asset_url"] == "https://v/out.mp4"
assert body["cover_url"] == "https://v/f.png"
assert (body["width_px"], body["height_px"]) == (1280, 720)
assert body["duration_ms"] == 5000
assert body["billing_tokens"] == 108900
assert body["failure"] is None
def test_asset_url_always_carries_its_expiry(self, keypair):
"""服务商 URL 只有 24 小时有效。时效随 URL 一起给出,调用方就没有理由
把它当永久地址存进 external_url。"""
client = client_for(
keypair, lambda r: httpx.Response(200, json=OFFICIAL_QUERY_PAYLOAD)
)
body = client.get(
"/internal/generation/tasks/cgt-1", headers=auth(keypair)
).json()
assert body["asset_url_expires_in_sec"] == 86400
def test_a_failed_task_is_200_not_an_error_response(self, keypair):
"""查询一个失败的任务,**HTTP 调用是成功的**,失败的是任务。
做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
"""
def handler(request):
return httpx.Response(
200,
json={
"id": "cgt-1",
"status": "failed",
"content": {},
"error": {
"code": "OutputVideoSensitiveContentDetected",
"message": "output may contain sensitive information",
},
},
)
client = client_for(keypair, handler)
response = client.get("/internal/generation/tasks/cgt-1", headers=auth(keypair))
assert response.status_code == 200
body = response.json()
assert body["state"] == "failed"
assert body["failure"]["retry"] == "never"
assert body["failure"]["user_facing"] == "content_rejected"
assert body["failure"]["provider_code"] == "OutputVideoSensitiveContentDetected"
def test_vendor_404_becomes_task_not_found_so_the_caller_stops_polling(
self, keypair
):
"""服务商只保留 7 天记录。查不到时调用方要做的是**停止轮询**,
不是退避重试——故归 service/task_not_found 而非 provider。"""
def handler(request):
return httpx.Response(
404, json={"error": {"code": "NotFound.", "message": "not found"}}
)
client = client_for(keypair, handler)
response = client.get(
"/internal/generation/tasks/cgt-gone", headers=auth(keypair)
)
assert response.status_code == 404
error = response.json()["error"]
assert error["kind"] == "service"
assert error["code"] == "task_not_found"
def test_running_task_has_zero_progress(self, keypair):
# 服务商不给百分比,故成功前恒 0。这不是缺陷——契约本就不承诺平滑进度。
def handler(request):
return httpx.Response(
200, json={"id": "cgt-1", "status": "running", "content": {}}
)
body = (
client_for(keypair, handler)
.get("/internal/generation/tasks/cgt-1", headers=auth(keypair))
.json()
)
assert body["state"] == "running"
assert body["progress"] == 0
def test_queued_maps_to_pending(self, keypair):
def handler(request):
return httpx.Response(
200, json={"id": "cgt-1", "status": "queued", "content": {}}
)
body = (
client_for(keypair, handler)
.get("/internal/generation/tasks/cgt-1", headers=auth(keypair))
.json()
)
assert body["state"] == "pending"
def test_get_requires_a_token_too(self, keypair):
client = client_for(keypair, lambda r: httpx.Response(200, json={}))
assert client.get("/internal/generation/tasks/cgt-1").status_code == 401
View File
View File
+150
View File
@@ -0,0 +1,150 @@
"""gpt-image-2 尺寸与参数约束的测试。
每个用例对应官方文档里的一条约束。这些约束若搞错,表现是用户提交后拿到一个失败
任务(服务商 400),而本地测试全绿——所以值得逐条钉住。
"""
from __future__ import annotations
import pytest
from app.providers.openai.capabilities import (
parse_size,
validate_output_format,
validate_quality,
validate_size,
)
class TestStandardSizesAndAutoAlwaysPass:
"""文档明示支持的取值不做算术校验。"""
def test_standard_sizes(self):
for size in ("1024x1024", "1536x1024", "1024x1536"):
validate_size(size)
def test_auto(self):
validate_size("auto")
def test_none(self):
validate_size(None)
class TestEdgeMultipleOf16:
"""官方:宽高都必须能被 16 整除。"""
def test_both_divisible_passes(self):
validate_size("1536x864") # 文档给的例子
def test_width_not_divisible_is_rejected(self):
with pytest.raises(ValueError, match="16 整除"):
validate_size("1000x864")
def test_height_not_divisible_is_rejected(self):
with pytest.raises(ValueError, match="16 整除"):
validate_size("1536x865")
class TestAspectRatioBounds:
"""官方:宽高比必须在 1:3 ~ 3:1 之间。"""
def test_three_to_one_is_allowed(self):
# 2880x960 = 3:1,且都能被 16 整除。
validate_size("2880x960")
def test_one_to_three_is_allowed(self):
validate_size("960x2880")
def test_portrait_at_the_pixel_limit_is_allowed(self):
"""竖版 4k(2160x3840)与横版同像素量,必须放行。
按「逐边」理解上限会禁掉所有高于 2160 的竖图,而 1024x1536 是文档明示的
标准竖图尺寸——故逐边理解必然过严。见 capabilities 里的说明。
"""
validate_size("2160x3840")
def test_wider_than_three_to_one_is_rejected(self):
# 3840x960 = 4:1。
with pytest.raises(ValueError, match="宽高比"):
validate_size("3840x960")
def test_taller_than_one_to_three_is_rejected(self):
# 640x2880 = 1:4.5,且在像素上限之内,故触发的必须是宽高比那条。
with pytest.raises(ValueError, match="宽高比"):
validate_size("640x2880")
class TestMaximumResolution:
def test_the_maximum_itself_is_allowed(self):
validate_size("3840x2160")
def test_longest_edge_beyond_the_cap_is_rejected(self):
with pytest.raises(ValueError, match="最长边"):
validate_size("4096x2048")
def test_within_edge_cap_but_beyond_the_pixel_cap_is_rejected(self):
# 3840x2688:最长边合规、宽高比合规,但总像素超了。
with pytest.raises(ValueError, match="总像素"):
validate_size("3840x2688")
def test_a_tall_portrait_within_both_caps_passes(self):
validate_size("1024x2176")
class TestMalformedSize:
def test_not_a_size_string(self):
with pytest.raises(ValueError, match="形态非法"):
validate_size("large")
def test_missing_separator(self):
with pytest.raises(ValueError, match="形态非法"):
validate_size("1024")
def test_parse_returns_none_for_garbage(self):
assert parse_size("nonsense") is None
def test_parse_reads_a_valid_pair(self):
size = parse_size("1536x864")
assert (size.width, size.height) == (1536, 864)
assert str(size) == "1536x864"
class TestQuality:
def test_supported_values_pass(self):
for quality in ("low", "medium", "high", "auto"):
validate_quality(quality)
def test_xhigh_is_rejected_because_it_is_2_5_only(self):
# 官方:xhigh 与 max 仅 gpt-image-2.5 系列支持。
with pytest.raises(ValueError, match="xhigh"):
validate_quality("xhigh")
def test_max_is_rejected_too(self):
with pytest.raises(ValueError):
validate_quality("max")
def test_none_passes(self):
validate_quality(None)
class TestOutputFormat:
def test_supported_formats(self):
for fmt in ("png", "jpeg", "webp"):
validate_output_format(fmt)
def test_unknown_format_is_rejected(self):
with pytest.raises(ValueError, match="非法"):
validate_output_format("gif")
def test_jpeg_with_transparent_background_is_rejected(self):
"""jpeg 没有 alpha 通道。
服务商不会因此报错——只会给一张背景被填成不透明的图,而那是**静默的**
不符合预期,比报错难查得多。
"""
with pytest.raises(ValueError, match="alpha"):
validate_output_format("jpeg", transparent=True)
def test_png_with_transparent_background_is_fine(self):
validate_output_format("png", transparent=True)
validate_output_format("webp", transparent=True)
+230
View File
@@ -0,0 +1,230 @@
"""OpenAI HTTP 传输层的测试:鉴权、两种载荷形式、错误解包。
全部离线(`httpx.MockTransport`),不触网。
"""
from __future__ import annotations
import httpx
import pytest
from app.domain.generation import Retry, UserFacing
from app.providers.openai.client import (
DEFAULT_BASE_URL,
OpenAiClient,
OpenAiError,
)
FAKE_API_KEY = "dummy-api-key-for-tests"
def client_with(handler, **kwargs) -> OpenAiClient:
return OpenAiClient(
api_key=FAKE_API_KEY,
client=httpx.Client(transport=httpx.MockTransport(handler)),
**kwargs,
)
class TestCredentialDiscipline:
def test_an_empty_key_fails_loudly_at_construction(self):
"""缺密钥是配置问题,应当在装配时就说清楚,而不是等第一次调用。"""
with pytest.raises(ValueError, match="OPENAI_API_KEY 未配置"):
OpenAiClient(api_key="")
def test_the_key_is_sent_as_a_bearer_token(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["auth"] = request.headers.get("Authorization")
return httpx.Response(200, json={"ok": True})
client_with(handler).post_json("/images/generations", {})
assert seen["auth"] == f"Bearer {FAKE_API_KEY}"
def test_organization_header_is_sent_only_when_configured(self):
seen: list = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.headers.get("OpenAI-Organization"))
return httpx.Response(200, json={})
client_with(handler).post_json("/x", {})
client_with(handler, organization="org-abc").post_json("/x", {})
assert seen == [None, "org-abc"]
class TestUrlComposition:
def test_default_base_url_is_the_official_one(self):
assert DEFAULT_BASE_URL == "https://api.openai.com/v1"
def test_a_trailing_slash_on_the_base_url_does_not_double_up(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
return httpx.Response(200, json={})
client_with(handler, base_url="https://proxy.invalid/v1/").post_json("/img", {})
assert seen["url"] == "https://proxy.invalid/v1/img"
class TestPayloadForms:
def test_post_json_sends_json(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["content_type"] = request.headers.get("Content-Type")
seen["body"] = request.content
return httpx.Response(200, json={"echo": 1})
result = client_with(handler).post_json("/x", {"prompt": "hi"})
assert seen["content_type"] == "application/json"
assert b'"prompt"' in seen["body"]
assert result == {"echo": 1}
def test_post_multipart_repeats_a_field_name(self):
"""用 list 而非 dict 传 files 的全部理由:同名字段可以重复。
编辑端点支持多图,它们共用字段名 `image[]`——dict 表达不了。
"""
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["content_type"] = request.headers.get("Content-Type", "")
seen["raw"] = request.content
return httpx.Response(200, json={})
client_with(handler).post_multipart(
"/images/edits",
data={"prompt": "merge"},
files=[
("image[]", ("a.png", b"first", "image/png")),
("image[]", ("b.png", b"second", "image/png")),
],
)
assert "multipart/form-data" in seen["content_type"]
assert seen["raw"].count(b'name="image[]"') == 2
assert b"first" in seen["raw"]
assert b"second" in seen["raw"]
def test_content_type_is_not_hand_written(self):
"""multipart 还需要 boundary,手写必错——交给 httpx 按载荷推断。"""
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["ct"] = request.headers.get("Content-Type", "")
return httpx.Response(200, json={})
client_with(handler).post_multipart(
"/x", data={}, files=[("image[]", ("a", b"x", "image/png"))]
)
assert "boundary=" in seen["ct"]
class TestErrorUnwrapping:
def test_the_vendor_error_envelope_is_parsed(self):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"message": "Your request rate increased too quickly",
"type": "rate_limit_error",
"code": "slow_down",
"param": None,
}
},
)
with pytest.raises(OpenAiError) as caught:
client_with(handler).post_json("/x", {})
error = caught.value
assert error.http_status == 429
assert error.failure.retry is Retry.BACKOFF
assert error.failure.provider_code == "slow_down"
assert "increased too quickly" in error.failure.message
def test_the_param_field_is_extracted_because_classification_needs_it(self):
"""`param` 是区分「参数错」与「内容被拒」的唯一结构化线索。
漏了它,所有 400 都会被当成内容被拒——包括真正的参数错。
"""
def handler(request):
return httpx.Response(
400,
json={
"error": {
"message": "Invalid service_tier argument",
"type": "invalid_request_error",
"param": "service_tier",
}
},
)
with pytest.raises(OpenAiError) as caught:
client_with(handler).post_json("/x", {})
# 带 param ⇒ 参数错 ⇒ 服务端错误,而不是让用户去改提示词。
assert caught.value.failure.user_facing is UserFacing.SERVER_ERROR
def test_a_non_json_error_body_still_classifies_by_status(self):
"""错误体不一定是文档描述的那个形状(网关、代理会插自己的页面)。
解析不出时不能静默丢掉线索。
"""
def handler(request):
return httpx.Response(502, text="<html>bad gateway</html>")
with pytest.raises(OpenAiError) as caught:
client_with(handler).post_json("/x", {})
assert caught.value.failure.retry is Retry.BACKOFF
assert caught.value.failure.provider_code == "HTTP_502"
# 原始文本被带上,否则排查时只知道「502」。
assert "bad gateway" in caught.value.failure.message
def test_a_2xx_with_a_non_json_body_is_an_error_not_a_success(self):
def handler(request):
return httpx.Response(200, text="not json at all")
with pytest.raises(OpenAiError, match="不是合法 JSON"):
client_with(handler).post_json("/x", {})
def test_a_network_failure_is_classified_as_retryable(self):
"""⚠️ 同步接口的超时最危险:那次调用可能已经完成并计费。
图片接口没有任务 ID 可供事后查询,故这个窗口只能由 max_attempts 兜住。
"""
def handler(request):
raise httpx.ConnectTimeout("timed out")
with pytest.raises(OpenAiError) as caught:
client_with(handler).post_json("/x", {})
assert caught.value.http_status is None
assert caught.value.failure.retry is Retry.BACKOFF
assert caught.value.failure.provider_code == "NETWORK_ERROR"
class TestConnectionOwnership:
def test_an_injected_client_is_not_closed_by_us(self):
"""注入进来的连接池归调用方管理。
关掉它会让共享同一个 client 的其他适配器一起失效。
"""
injected = httpx.Client(
transport=httpx.MockTransport(lambda r: httpx.Response(200, json={}))
)
OpenAiClient(api_key=FAKE_API_KEY, client=injected).close()
assert not injected.is_closed
def test_our_own_client_is_closed_on_exit(self):
with OpenAiClient(api_key=FAKE_API_KEY) as client:
inner = client._client
assert inner.is_closed
+211
View File
@@ -0,0 +1,211 @@
"""OpenAI 错误分类的逐条测试,对照官方 Error codes 页(`doc_temp/gpt错误码.md`)。
每个用例的码名都**抄自文档**,不是记忆。本文件此前钉的是五个并不存在的码名
(见 `errors.py` 顶部),重写后每条都能在文档里搜到。
"""
from __future__ import annotations
import pytest
from app.domain.generation import Retry, UserFacing
from app.providers.openai.errors import classify
class TestBillingAndQuotaCodes:
"""官方表 429 的六种里有四种是钱的问题,且文档明说重试不会恢复访问。"""
@pytest.mark.parametrize(
"code",
[
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
],
)
def test_they_are_all_quota_not_backoff(self, code):
failure = classify(429, error_code=code)
# Retry.QUOTA 的语义正是文档那句「Retrying billing, spend, or quota errors
# won't restore API access」——Worker 不该把重试预算花在这上面。
assert failure.retry is Retry.QUOTA
assert failure.user_facing is UserFacing.QUOTA_EXHAUSTED
assert failure.provider_code == code
def test_the_broad_type_is_recognised_when_the_code_is_absent(self):
"""文档:「The broader `error.type` can still be `insufficient_quota`」。"""
failure = classify(429, error_type="insufficient_quota")
assert failure.retry is Retry.QUOTA
def test_a_known_code_wins_over_the_broad_type(self):
# code 比 type 具体;此例两者同向,测的是优先级本身被实现了。
failure = classify(
429, error_type="insufficient_quota", error_code="credit_balance_exhausted"
)
assert failure.provider_code == "credit_balance_exhausted"
assert failure.retry is Retry.QUOTA
@pytest.mark.parametrize("field", ["code", "type"])
@pytest.mark.parametrize(
"token", ["insufficient_quota", "credit_balance_exhausted", "slow_down"]
)
def test_a_documented_token_is_honoured_in_either_field(self, field, token):
"""同一张表按 `code` 与 `type` 两个字段分别查。
文档把某个标识列在哪个字段下,不保证实际响应里它一定出现在那个字段。查错
字段的代价是把配额耗尽降级成限流、白烧一轮重试预算;而两边都查不引入任何
**新**词汇——表里每个键都抄自文档。
"""
expected = Retry.BACKOFF if token == "slow_down" else Retry.QUOTA
assert classify(429, **{f"error_{field}": token}).retry is expected
class TestRateLimitCodes:
def test_slow_down_backs_off(self):
"""`slow_down` 是「涨得太快」,文档说即使在 RPM/TPM 额度内也会触发。"""
failure = classify(429, error_type="rate_limit_error", error_code="slow_down")
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.RATE_LIMITED
def test_the_rate_limit_type_alone_is_enough(self):
failure = classify(429, error_type="rate_limit_error")
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.RATE_LIMITED
def test_a_bare_429_defaults_to_rate_limiting(self):
"""这个默认值现在是**文档支持的**,不再是猜测。
官方表 429 的六种里,只有「Rate limit reached for requests」没有文档化的
code——故一个既无 code 又无已知 type 的 429 就是它。
"""
failure = classify(429)
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.RATE_LIMITED
class TestOverload:
def test_server_is_overloaded_is_retryable(self):
failure = classify(
503,
error_type="service_unavailable_error",
error_code="server_is_overloaded",
)
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_the_vendors_429_to_503_reclassification_does_not_affect_us(self):
"""文档提醒:视频过载已从 429 改判为 503,只捕一类会漏。
我们两者都归可重试,故这次改判对本服务无影响。这条测试就是那个「无影响」
的凭据——哪天有人把 5xx 改成不可重试,它会红。
"""
assert classify(429).retry is Retry.BACKOFF
assert classify(503).retry is Retry.BACKOFF
class TestBadRequestIsTwoThingsAtOnce:
"""400 既是参数错也是(推断的)内容被拒,用 `error.param` 区分。
官方错误码表里**没有任何内容审核/侵权码**,故内容拒绝只能以通用 400 到达。
"""
def test_a_param_error_is_our_problem_not_the_prompts(self):
# 文档的 service_tier 那条明确说会带 param。
failure = classify(
400,
error_type="invalid_request_error",
error_param="service_tier",
message="Invalid service_tier argument",
)
assert failure.retry is Retry.NEVER
assert failure.user_facing is UserFacing.SERVER_ERROR
def test_a_400_without_param_is_treated_as_a_content_refusal(self):
"""推断分支:不指向任何参数的 400,按内容被拒处理。
若归「服务端错误」,用户会看到「服务出错了」并反复重试同一个注定被拒的
提示词——那是更糟的结果。
"""
failure = classify(400, message="Your request was rejected")
assert failure.retry is Retry.NEVER
assert failure.user_facing is UserFacing.CONTENT_REJECTED
def test_the_inferred_branch_logs_a_warning(self, caplog):
"""这条分支是推断的,日志是日后校正它的唯一素材来源。"""
with caplog.at_level("WARNING"):
classify(400, message="something was refused")
assert "推断" in caplog.text
def test_an_empty_param_counts_as_absent(self):
# 厂商可能回 "" 或 null,两者语义相同。
assert classify(400, error_param="").user_facing is UserFacing.CONTENT_REJECTED
class TestAuthAndGeo:
def test_invalid_authentication_is_terminal_and_not_the_users_fault(self):
failure = classify(401, message="Incorrect API key provided")
assert failure.retry is Retry.NEVER
assert failure.user_facing is UserFacing.SERVER_ERROR
def test_unsupported_country_is_also_our_configuration_problem(self):
failure = classify(403, message="Country, region, or territory not supported")
assert failure.retry is Retry.NEVER
assert failure.user_facing is UserFacing.SERVER_ERROR
class TestStatusCodeFallback:
@pytest.mark.parametrize("status", [404, 409, 422])
def test_other_4xx_are_terminal(self, status):
assert classify(status).retry is Retry.NEVER
@pytest.mark.parametrize("status", [500, 502, 504])
def test_5xx_is_retryable(self, status):
failure = classify(status)
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_network_failure_is_retryable(self):
failure = classify(None, message="connect timeout")
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_an_unknown_status_leans_towards_retrying(self):
assert classify(599).retry is Retry.BACKOFF
class TestUnknownCodesFallThroughRatherThanBlowUp:
"""厂商随时可能加新码;不认识的码必须回落,不能抛异常或判死。"""
def test_an_unknown_code_on_a_429_still_backs_off(self):
failure = classify(429, error_code="some_future_code")
assert failure.retry is Retry.BACKOFF
# 码本身照原样带出去——它是排查这条新码的唯一线索。
assert failure.provider_code == "some_future_code"
def test_an_unknown_type_on_a_5xx_still_backs_off(self):
assert classify(500, error_type="brand_new_error").retry is Retry.BACKOFF
class TestProviderCodeIsNeverEmpty:
"""它是排查时的第一线索,空字符串等于把线索丢了。"""
def test_code_is_preferred(self):
assert classify(400, error_type="t", error_code="c").provider_code == "c"
def test_type_is_used_when_code_is_absent(self):
assert classify(400, error_type="t").provider_code == "t"
def test_status_label_is_the_last_resort(self):
assert classify(503).provider_code == "HTTP_503"
def test_network_failures_get_a_named_label_too(self):
assert classify(None).provider_code == "NETWORK_ERROR"
class TestMessageIsCarriedThrough:
def test_message_is_kept(self):
assert classify(400, message="bad size").message == "bad size"
def test_missing_message_becomes_empty_not_none(self):
# 契约里 message 是字符串;None 会在序列化处变成 null 而非 ""。
assert classify(400).message == ""
+433
View File
@@ -0,0 +1,433 @@
"""gpt-image-2 适配器的测试:文生图、图生图、响应映射。
全部离线(`httpx.MockTransport`),并用同步的注册表配置(`max_workers=1`)+ 轮询
等待,使断言不依赖时序。
"""
from __future__ import annotations
import base64
import time
import httpx
import pytest
from app.domain.generation import (
GenerationRequest,
MediaKind,
Retry,
TaskState,
UserFacing,
)
from app.providers.openai.client import OpenAiClient
from app.providers.openai.image import PROVIDER_CODE, OpenAiImageProvider
from app.providers.openai.tasks import SyncTaskRegistry
FAKE_API_KEY = "dummy-api-key-for-tests"
MODEL = "gpt-image-2"
PNG_B64 = base64.b64encode(b"fake-png-bytes").decode()
#: 官方文档「Generate image」一节的响应形状。
OFFICIAL_RESPONSE = {
"created": 1713833628,
"data": [{"b64_json": PNG_B64}],
"size": "1024x1024",
"output_format": "png",
"quality": "high",
"usage": {
"total_tokens": 100,
"input_tokens": 50,
"output_tokens": 50,
"input_tokens_details": {"text_tokens": 10, "image_tokens": 40},
},
}
def provider_with(handler, *, fetch_handler=None, **kwargs) -> OpenAiImageProvider:
client = OpenAiClient(
api_key=FAKE_API_KEY,
base_url="https://api.openai.invalid/v1",
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
fetch_client = (
httpx.Client(transport=httpx.MockTransport(fetch_handler))
if fetch_handler
else None
)
return OpenAiImageProvider(
client,
SyncTaskRegistry(max_workers=1, max_entries=8),
input_fetch_client=fetch_client,
**kwargs,
)
def image_request(**kwargs) -> GenerationRequest:
defaults = {
"job_id": "job-1",
"media_kind": MediaKind.IMAGE,
"model": MODEL,
"prompt": "一只柯基在草地上",
}
return GenerationRequest(**{**defaults, **kwargs})
def await_result(provider: OpenAiImageProvider, task_id: str, timeout: float = 3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = provider.fetch(task_id)
if result.state.is_terminal:
return result
time.sleep(0.01)
raise AssertionError("任务未在超时内结束")
class TestIdentity:
def test_provider_code_matches_the_catalog_column(self):
assert (
provider_with(lambda r: httpx.Response(200, json={})).provider_code
== PROVIDER_CODE
)
def test_declares_itself_as_an_image_provider(self):
assert (
provider_with(lambda r: httpx.Response(200, json={})).media_kind
is MediaKind.IMAGE
)
def test_rejects_a_misrouted_video_request(self):
with pytest.raises(ValueError, match="只处理图片生成"):
provider_with(lambda r: httpx.Response(200, json={})).submit(
image_request(media_kind=MediaKind.VIDEO)
)
class TestSubmitIsNonBlockingDespiteTheSyncVendor:
"""整个 tasks 层存在的理由:服务商同步,但我们对外必须是异步的。"""
def test_submit_returns_a_task_id_without_waiting_for_the_image(self):
import threading
release = threading.Event()
def handler(request):
release.wait(timeout=3)
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
begin = time.monotonic()
task_id = provider.submit(image_request())
elapsed = time.monotonic() - begin
assert task_id.startswith("local-")
assert elapsed < 0.5
assert provider.fetch(task_id).state is TaskState.RUNNING
release.set()
await_result(provider, task_id)
class TestTextToImage:
def test_posts_json_to_the_generations_endpoint(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen["path"] = request.url.path
seen["body"] = json.loads(request.content)
seen["auth"] = request.headers.get("Authorization")
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
await_result(provider, provider.submit(image_request()))
assert seen["path"].endswith("/images/generations")
assert seen["auth"] == f"Bearer {FAKE_API_KEY}"
assert seen["body"]["prompt"] == "一只柯基在草地上"
assert seen["body"]["model"] == MODEL
# 一个任务一个产物:generation_jobs.output_asset_id 是单值。
assert seen["body"]["n"] == 1
def test_pixel_size_is_sent_only_when_both_dimensions_are_given(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
await_result(
provider, provider.submit(image_request(width_px=1536, height_px=864))
)
assert seen["size"] == "1536x864"
def test_half_a_size_is_treated_as_no_size_at_all(self):
# 只给宽不给高时交给模型自选,而不是猜一个高度出来。
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
await_result(provider, provider.submit(image_request(width_px=1024)))
assert "size" not in seen
def test_safety_identifier_is_sent_as_the_vendors_user_field(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
await_result(
provider, provider.submit(image_request(safety_identifier="abc123hash"))
)
# 厂商字段名是 user,不是 safety_identifier。
assert seen["user"] == "abc123hash"
assert "safety_identifier" not in seen
class TestModelResolution:
def test_request_model_wins_over_the_configured_default(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler, default_model="fallback")
await_result(provider, provider.submit(image_request(model="snapshot-model")))
assert seen["model"] == "snapshot-model"
def test_no_model_anywhere_fails_before_any_call(self):
def handler(request):
raise AssertionError("不应发出请求")
with pytest.raises(ValueError, match="未指定模型"):
provider_with(handler).submit(image_request(model=""))
class TestLocalValidationHappensBeforeAccepting:
"""违规请求应当立刻被拒,而不是变成一个注定失败的后台任务。
后者要等一轮轮询才被发现,还白占一个线程池槽位。
"""
def test_bad_size_is_rejected_synchronously(self):
def handler(request):
raise AssertionError("不应发出请求")
with pytest.raises(ValueError, match="16 整除"):
provider_with(handler).submit(image_request(width_px=1000, height_px=864))
class TestImageToImage:
def test_downloads_the_input_then_posts_multipart_to_edits(self):
seen: dict = {}
def openai_handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["content_type"] = request.headers.get("Content-Type", "")
seen["raw"] = request.content
return httpx.Response(200, json=OFFICIAL_RESPONSE)
def fetch_handler(request: httpx.Request) -> httpx.Response:
seen["fetched"] = str(request.url)
return httpx.Response(
200,
content=b"reference-image-bytes",
headers={"Content-Type": "image/png"},
)
provider = provider_with(openai_handler, fetch_handler=fetch_handler)
await_result(
provider, provider.submit(image_request(input_url="https://x/ref.png"))
)
assert seen["fetched"] == "https://x/ref.png"
# 编辑接口走 multipart 而非 JSON——`image` 是文件。
assert seen["path"].endswith("/images/edits")
assert "multipart/form-data" in seen["content_type"]
assert b"reference-image-bytes" in seen["raw"]
# 字段名统一用 image[](该接口支持多图且共用此名),避免单图多图两种写法。
assert b'name="image[]"' in seen["raw"]
def test_an_undownloadable_input_is_rejected_synchronously(self):
"""取不到参考图就是参数问题,应立刻拒绝而不是变成后台失败。"""
def openai_handler(request):
raise AssertionError("不应发出生成请求")
def fetch_handler(request):
raise httpx.ConnectError("refused")
with pytest.raises(ValueError, match="无法下载"):
provider_with(openai_handler, fetch_handler=fetch_handler).submit(
image_request(input_url="https://unreachable/ref.png")
)
def test_an_oversized_input_is_rejected_by_its_declared_length(self):
"""第一道:声明的 Content-Length 就超了,连读都不必读。"""
def openai_handler(request):
raise AssertionError("不应发出生成请求")
def fetch_handler(request):
return httpx.Response(200, content=b"x" * (21 * 1024 * 1024))
with pytest.raises(ValueError, match="参考图过大"):
provider_with(openai_handler, fetch_handler=fetch_handler).submit(
image_request(input_url="https://x/huge.png")
)
def test_an_oversized_input_is_caught_even_when_the_length_lies(self):
"""第二道:不信 Content-Length——它可能缺失或撒谎,必须边读边数。
用分块流式响应(无 Content-Length)构造这种情形:只有累计计数才拦得住。
"""
def openai_handler(request):
raise AssertionError("不应发出生成请求")
def chunks():
# 每块 1MB,共 21MB,分块传输故无 Content-Length。
for _ in range(21):
yield b"x" * (1024 * 1024)
def fetch_handler(request):
return httpx.Response(200, content=chunks())
with pytest.raises(ValueError, match="超过上限"):
provider_with(openai_handler, fetch_handler=fetch_handler).submit(
image_request(input_url="https://x/lying.png")
)
class TestResponseMapping:
def test_dimensions_come_straight_from_the_response(self):
"""不像火山那边要查对照表——响应直接给 size。"""
provider = provider_with(lambda r: httpx.Response(200, json=OFFICIAL_RESPONSE))
result = await_result(provider, provider.submit(image_request()))
assert result.state is TaskState.SUCCEEDED
assert (result.width_px, result.height_px) == (1024, 1024)
assert result.output_format == "png"
assert result.billing_tokens == 100
assert result.failure is None
def test_arbitrary_size_is_parsed_too(self):
payload = {**OFFICIAL_RESPONSE, "size": "1536x864"}
provider = provider_with(lambda r: httpx.Response(200, json=payload))
result = await_result(provider, provider.submit(image_request()))
assert (result.width_px, result.height_px) == (1536, 864)
def test_asset_url_is_a_data_uri_with_no_expiry(self):
"""产物是 base64 字节,不是可访问 URL。
没有「24 小时过期」问题(与火山不同),故 expires_in 为 None 而不是编一个
假的秒数。但它**也不是可长期保存的地址**——绝不能原样写进 external_url。
"""
provider = provider_with(lambda r: httpx.Response(200, json=OFFICIAL_RESPONSE))
result = await_result(provider, provider.submit(image_request()))
assert result.asset_url.startswith("data:image/png;base64,")
assert PNG_B64 in result.asset_url
assert result.asset_url_expires_in_sec is None
def test_a_2xx_without_image_data_is_a_retryable_failure(self):
"""服务商说成功却没给图——那不是成功。"""
provider = provider_with(
lambda r: httpx.Response(200, json={"created": 1, "data": []})
)
result = await_result(provider, provider.submit(image_request()))
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.BACKOFF
assert result.failure.provider_code == "MissingImageData"
class TestFailurePropagation:
"""只验「厂商错误能原样穿过后台线程到达查询结果」,分类本身在 test_errors.py。
码名全部抄自官方 Error codes 页(`doc_temp/gpt错误码.md`)。
"""
def test_vendor_rate_limit_becomes_a_retryable_failure(self):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"message": "Your request rate increased too quickly",
"type": "rate_limit_error",
"code": "slow_down",
}
},
)
provider = provider_with(handler)
result = await_result(provider, provider.submit(image_request()))
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.BACKOFF
assert result.failure.user_facing is UserFacing.RATE_LIMITED
def test_an_exhausted_credit_balance_is_not_worth_retrying(self):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"message": "Your organization has no prepaid credits remaining",
"type": "insufficient_quota",
"code": "credit_balance_exhausted",
}
},
)
provider = provider_with(handler)
result = await_result(provider, provider.submit(image_request()))
assert result.failure.retry is Retry.QUOTA
assert result.failure.user_facing is UserFacing.QUOTA_EXHAUSTED
def test_a_bare_400_is_surfaced_as_a_content_refusal(self):
"""官方错误码表里**没有任何内容审核/侵权码**。
故一次「这个提示词不能生成」只能以通用 400 到达。不带 `error.param` 时按
内容被拒处理——详见 `errors.py` 的 `_classify_bad_request`。
"""
def handler(request):
return httpx.Response(
400,
json={
"error": {
"message": "Your request was rejected",
"type": "invalid_request_error",
}
},
)
provider = provider_with(handler)
result = await_result(provider, provider.submit(image_request()))
assert result.failure.retry is Retry.NEVER
assert result.failure.user_facing is UserFacing.CONTENT_REJECTED
class TestUnknownTaskId:
def test_fetch_raises_key_error_so_the_endpoint_can_map_it(self):
provider = provider_with(lambda r: httpx.Response(200, json={}))
with pytest.raises(KeyError):
provider.fetch("local-never-existed")
+249
View File
@@ -0,0 +1,249 @@
"""同步→异步任务注册表的测试。
这一层是 gpt-image-2 与 Seedance 的根本差异所在(同步接口没有任务 ID),也是唯一
让本服务**持有状态**的地方。它写错的表现是丢任务或内存泄漏,两者都要到生产才显形,
所以逐条钉住。
"""
from __future__ import annotations
import threading
import time
import pytest
from app.domain.generation import (
Failure,
GenerationResult,
Retry,
TaskState,
UserFacing,
)
from app.providers.openai.client import OpenAiError
from app.providers.openai.tasks import LOCAL_TASK_PREFIX, SyncTaskRegistry
def succeeded(url: str = "data:image/png;base64,AAA") -> GenerationResult:
return GenerationResult(
provider_request_id="", state=TaskState.SUCCEEDED, progress=100, asset_url=url
)
def wait_until_done(registry: SyncTaskRegistry, task_id: str, timeout: float = 3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = registry.fetch(task_id)
if result is not None and result.state.is_terminal:
return result
time.sleep(0.01)
raise AssertionError(f"任务 {task_id} 在 {timeout}s 内未结束")
class TestSubmitReturnsImmediately:
def test_submit_does_not_block_on_the_slow_call(self):
"""这是整层存在的理由:同步调用必须在后台跑,提交立刻返回。"""
registry = SyncTaskRegistry(max_workers=2)
started = threading.Event()
release = threading.Event()
def slow():
started.set()
release.wait(timeout=3)
return succeeded()
begin = time.monotonic()
task_id = registry.submit(slow)
elapsed = time.monotonic() - begin
assert started.wait(timeout=2)
# 提交本身几乎不耗时,尽管那个调用还在跑。
assert elapsed < 0.5
assert registry.fetch(task_id).state is TaskState.RUNNING
release.set()
wait_until_done(registry, task_id)
registry.shutdown()
def test_task_id_is_prefixed_so_logs_distinguish_it_from_a_vendor_id(self):
registry = SyncTaskRegistry()
task_id = registry.submit(succeeded)
assert task_id.startswith(LOCAL_TASK_PREFIX)
registry.shutdown()
def test_ids_are_unique(self):
registry = SyncTaskRegistry()
ids = {registry.submit(succeeded) for _ in range(20)}
assert len(ids) == 20
registry.shutdown()
class TestInFlightAndCompletedStates:
def test_running_reports_zero_progress(self):
# 同步接口不提供进度,只能报 0。契约本就不承诺平滑进度(ADR-023 §10)。
registry = SyncTaskRegistry()
release = threading.Event()
task_id = registry.submit(lambda: (release.wait(timeout=3), succeeded())[1])
result = registry.fetch(task_id)
assert result.state is TaskState.RUNNING
assert result.progress == 0
release.set()
registry.shutdown()
def test_completed_result_is_returned_with_the_real_task_id(self):
"""工作函数拿不到自己的任务 ID,故由注册表在 fetch 时填入。"""
registry = SyncTaskRegistry()
task_id = registry.submit(succeeded)
result = wait_until_done(registry, task_id)
assert result.state is TaskState.SUCCEEDED
assert result.provider_request_id == task_id
assert result.asset_url is not None
registry.shutdown()
def test_result_can_be_fetched_more_than_once(self):
# Java 侧可能重复轮询同一个已完成任务(比如一次响应丢了)。
registry = SyncTaskRegistry()
task_id = registry.submit(succeeded)
wait_until_done(registry, task_id)
assert registry.fetch(task_id).state is TaskState.SUCCEEDED
assert registry.fetch(task_id).state is TaskState.SUCCEEDED
registry.shutdown()
class TestUnknownIdIsIndistinguishableOnPurpose:
def test_never_existed_returns_none(self):
"""从未存在过与「进程重启后丢失」无法区分——不假装区分。
两种情形对调用方的处置相同:停止轮询、任务判失败。
"""
assert SyncTaskRegistry().fetch("local-nonexistent") is None
class TestFailurePropagation:
def test_a_classified_provider_error_keeps_its_classification(self):
registry = SyncTaskRegistry()
failure = Failure(
Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, "insufficient_quota", "no credit"
)
def failing():
raise OpenAiError(failure, 429)
task_id = registry.submit(failing)
result = wait_until_done(registry, task_id)
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.QUOTA
assert result.failure.provider_code == "insufficient_quota"
registry.shutdown()
def test_an_unclassified_exception_becomes_a_retryable_failure_naming_its_type(
self,
):
"""未分类异常不该让任务卡住,也不该丢掉线索。
把异常类型名放进 provider_code——否则排查时只有一句空洞的错误消息。
"""
registry = SyncTaskRegistry()
def boom():
raise ZeroDivisionError("unexpected")
task_id = registry.submit(boom)
result = wait_until_done(registry, task_id)
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.BACKOFF
assert result.failure.provider_code == "ZeroDivisionError"
registry.shutdown()
class TestCapacityIsBounded:
def test_submitting_beyond_the_cap_is_rejected_rather_than_evicting(self):
"""满了要拒绝新提交,**不能挤掉在途任务**。
挤掉一个在途任务会让一个可能正在计费的调用变成无人认领的开销。
"""
registry = SyncTaskRegistry(max_workers=1, max_entries=2)
release = threading.Event()
def blocked():
release.wait(timeout=3)
return succeeded()
registry.submit(blocked)
registry.submit(blocked)
with pytest.raises(RuntimeError, match="上限"):
registry.submit(blocked)
release.set()
registry.shutdown()
def test_concurrency_is_capped_by_max_workers(self):
"""并发上限同时也是「同时有多少钱在花」的上界。"""
registry = SyncTaskRegistry(max_workers=2, max_entries=10)
concurrent = []
lock = threading.Lock()
release = threading.Event()
def work():
with lock:
concurrent.append(1)
peak = len(concurrent)
release.wait(timeout=3)
with lock:
concurrent.pop()
return succeeded(f"data:image/png;base64,{peak}")
for _ in range(5):
registry.submit(work)
time.sleep(0.2)
with lock:
assert len(concurrent) <= 2
release.set()
registry.shutdown()
class TestExpiryOnlyTouchesCompletedEntries:
def test_completed_entries_expire(self):
registry = SyncTaskRegistry(completed_ttl_sec=0.05, max_entries=10)
task_id = registry.submit(succeeded)
wait_until_done(registry, task_id)
time.sleep(0.1)
# 清理发生在下一次 submit 时(不额外起一个定时线程——本服务的规模用不上)。
registry.submit(succeeded)
assert registry.fetch(task_id) is None
registry.shutdown()
def test_in_flight_entries_are_never_expired(self):
"""在途任务无论多久都不清——它可能正在花钱。"""
registry = SyncTaskRegistry(completed_ttl_sec=0.01, max_entries=10)
release = threading.Event()
task_id = registry.submit(lambda: (release.wait(timeout=3), succeeded())[1])
time.sleep(0.1)
registry.submit(succeeded)
# 仍在注册表里。
assert registry.fetch(task_id) is not None
assert registry.fetch(task_id).state is TaskState.RUNNING
release.set()
registry.shutdown()
def test_ttl_starts_at_completion_not_at_creation(self):
"""否则一个耗时接近 TTL 的任务会在刚完成时就被清掉——结果还没被取走。"""
registry = SyncTaskRegistry(completed_ttl_sec=5.0, max_entries=10)
release = threading.Event()
task_id = registry.submit(lambda: (release.wait(timeout=3), succeeded())[1])
time.sleep(0.15)
release.set()
wait_until_done(registry, task_id)
registry.submit(succeeded)
assert registry.fetch(task_id).state is TaskState.SUCCEEDED
registry.shutdown()
@@ -0,0 +1,130 @@
"""模型限制表的测试。
每个用例对应官方文档里两个已接入模型的一条具体限制。这些限制若搞错,表现是
用户提交后拿到一个失败任务(厂商 400),而本地测试全绿——所以值得逐条钉住。
"""
from __future__ import annotations
import pytest
from app.providers.volcengine.capabilities import (
AUTO_DURATION,
SEEDANCE_2_0,
SEEDANCE_2_5,
limits_for,
validate,
)
#: 账号实际接入的两个模型 ID。
MODEL_2_5 = "doubao-seedance-2-5-260628"
MODEL_2_0 = "doubao-seedance-2-0-260128"
class TestFamilyDetection:
def test_both_activated_models_are_recognised(self):
assert limits_for(MODEL_2_5) is SEEDANCE_2_5
assert limits_for(MODEL_2_0) is SEEDANCE_2_0
def test_matching_is_by_family_prefix_not_exact_id(self):
"""尾部日期版本会随厂商更新而变。
按前缀匹配使一次模型小版本升级(如 2-5-261231)不需要改这张表。
"""
assert limits_for("doubao-seedance-2-5-999999") is SEEDANCE_2_5
def test_unknown_model_has_no_limits(self):
assert limits_for("some-future-model") is None
class TestUnknownModelsArePassedThrough:
"""账号新开通一个模型时不该因为这张表没更新就用不了。
否则「开通即可用」会变成「开通还得改代码发版」。代价只是失去本地预校验。
"""
def test_anything_goes_for_an_unknown_model(self):
validate(
"brand-new-model",
resolution="8k",
duration_sec=999,
ratio="16:9",
has_first_frame=True,
)
class TestResolutionLimits:
def test_2_5_has_no_4k(self):
# 官方:2.5 可选 480p / 720p / 1080p。
with pytest.raises(ValueError, match="不支持分辨率 4k"):
validate(MODEL_2_5, resolution="4k")
def test_2_0_does_have_4k(self):
validate(MODEL_2_0, resolution="4k")
def test_shared_resolutions_work_on_both(self):
for model in (MODEL_2_5, MODEL_2_0):
for resolution in ("480p", "720p", "1080p"):
validate(model, resolution=resolution)
def test_error_names_the_model_and_lists_the_options(self):
with pytest.raises(ValueError) as exc:
validate(MODEL_2_5, resolution="4k")
assert MODEL_2_5 in str(exc.value)
assert "1080p" in str(exc.value)
class TestDurationLimits:
def test_2_5_allows_up_to_30_seconds(self):
validate(MODEL_2_5, duration_sec=30)
def test_2_0_caps_at_15_seconds(self):
# 官方:2.0 系列取值范围 [4, 15]。
validate(MODEL_2_0, duration_sec=15)
with pytest.raises(ValueError, match=r"\[4, 15\]"):
validate(MODEL_2_0, duration_sec=20)
def test_the_same_20_seconds_is_fine_on_2_5(self):
"""同一个时长在两个模型上一个合法一个不合法——这正是需要这张表的理由。"""
validate(MODEL_2_5, duration_sec=20)
with pytest.raises(ValueError):
validate(MODEL_2_0, duration_sec=20)
def test_below_minimum_is_rejected_on_both(self):
for model in (MODEL_2_5, MODEL_2_0):
with pytest.raises(ValueError):
validate(model, duration_sec=2)
def test_auto_duration_bypasses_the_range_check(self):
# -1 表示交给模型在有效范围内自选,不该被范围校验拦下。
validate(MODEL_2_5, duration_sec=AUTO_DURATION)
validate(MODEL_2_0, duration_sec=AUTO_DURATION)
def test_omitted_duration_is_not_checked(self):
validate(MODEL_2_5)
class TestRatioLockedToAdaptiveOnImageToVideo:
"""2.5 在图生视频时输出宽高比自动与首帧图片一致,不接受指定值。"""
def test_2_5_rejects_a_specific_ratio_when_a_first_frame_is_given(self):
with pytest.raises(ValueError, match="只支持 ratio=adaptive"):
validate(MODEL_2_5, ratio="16:9", has_first_frame=True)
def test_2_5_accepts_adaptive_with_a_first_frame(self):
validate(MODEL_2_5, ratio="adaptive", has_first_frame=True)
def test_2_5_accepts_a_specific_ratio_for_text_to_video(self):
# 限制只在图生视频场景成立;文生视频可指定宽高比。
validate(MODEL_2_5, ratio="16:9", has_first_frame=False)
def test_2_0_accepts_a_specific_ratio_even_with_a_first_frame(self):
validate(MODEL_2_0, ratio="16:9", has_first_frame=True)
class TestOutputFormatSupport:
def test_only_2_5_supports_mov(self):
# 我们当前不发送 output_format(默认 mp4),但这张表记录了差异,
# 以便将来开放该参数时不必重读文档。
assert SEEDANCE_2_5.supports_output_format is True
assert SEEDANCE_2_0.supports_output_format is False
+153
View File
@@ -0,0 +1,153 @@
"""`ArkClient` 的测试——只测传输层:鉴权头、错误解包、连接生命周期。
**刻意不测任何视频业务**:本 client 不认识「视频」,测试也不该。视频相关的断言
在 `test_video.py`。
"""
from __future__ import annotations
import httpx
import pytest
from app.domain.generation import Retry, UserFacing
from app.providers.volcengine.client import ArkClient, ArkError
#: 显式的假值。防泄漏扫描会拦下 `api_key = "..."` 形态(规则正确——这正是真实
#: 密钥最常见的泄漏形态),故夹具用 ALLOW 清单认可的占位词。
FAKE_API_KEY = "dummy-api-key-for-tests"
BASE_URL = "https://ark.example.invalid/api/v3"
def client_with(handler) -> ArkClient:
return ArkClient(
api_key=FAKE_API_KEY,
base_url=BASE_URL,
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
class TestConstruction:
def test_missing_api_key_fails_at_construction_not_at_first_call(self):
# 缺密钥是配置错误。让它表现为一串莫名的 401 会浪费排查时间。
with pytest.raises(ValueError, match="ARK_API_KEY"):
ArkClient(api_key="", base_url=BASE_URL)
def test_trailing_slash_in_base_url_does_not_double_up(self):
def handler(request: httpx.Request) -> httpx.Response:
assert "//contents" not in str(request.url)
return httpx.Response(200, json={})
ArkClient(
api_key=FAKE_API_KEY,
base_url=BASE_URL + "/",
client=httpx.Client(transport=httpx.MockTransport(handler)),
).get("/contents/x")
class TestAuthAndTransport:
def test_bearer_token_and_content_type_are_sent(self):
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["Authorization"] == f"Bearer {FAKE_API_KEY}"
assert request.headers["Content-Type"] == "application/json"
return httpx.Response(200, json={"ok": True})
assert client_with(handler).post("/x", {}) == {"ok": True}
def test_get_and_post_hit_the_expected_method(self):
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.method)
return httpx.Response(200, json={})
client = client_with(handler)
client.post("/x", {})
client.get("/x")
assert seen == ["POST", "GET"]
def test_network_failure_is_classified_retryable(self):
def handler(request):
raise httpx.ConnectError("connection refused")
with pytest.raises(ArkError) as exc:
client_with(handler).get("/x")
assert exc.value.failure.retry is Retry.BACKOFF
assert exc.value.http_status is None
class TestErrorUnwrapping:
def test_vendor_error_body_is_classified(self):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"code": "RateLimitExceeded.EndpointRPMExceeded",
"message": "RPM limit exceeded",
}
},
)
with pytest.raises(ArkError) as exc:
client_with(handler).get("/x")
assert exc.value.failure.retry is Retry.BACKOFF
assert exc.value.failure.user_facing is UserFacing.RATE_LIMITED
assert (
exc.value.failure.provider_code == "RateLimitExceeded.EndpointRPMExceeded"
)
assert exc.value.http_status == 429
def test_vendor_message_is_preserved_for_troubleshooting(self):
def handler(request):
return httpx.Response(
401,
json={
"error": {"code": "AuthenticationError", "message": "key invalid"}
},
)
with pytest.raises(ArkError) as exc:
client_with(handler).get("/x")
assert "key invalid" in exc.value.failure.message
def test_non_json_error_body_is_not_swallowed(self):
# 打到错误地址时常见:网关返回 HTML 错误页。静默会让排查绕远路。
def handler(request):
return httpx.Response(502, text="<html>bad gateway</html>")
with pytest.raises(ArkError) as exc:
client_with(handler).get("/x")
assert "bad gateway" in exc.value.failure.message
assert exc.value.failure.retry is Retry.BACKOFF
def test_non_json_success_body_is_an_error_too(self):
def handler(request):
return httpx.Response(200, text="not json")
with pytest.raises(ArkError, match="不是合法 JSON"):
client_with(handler).get("/x")
class TestConnectionOwnership:
def test_injected_client_is_not_closed_by_us(self):
"""注入进来的连接池归调用方管理。
在这里关掉会让共享同一个 httpx.Client 的其他适配器一起失效——接了第二个
火山接口后这会变成一个极难查的偶发失败。
"""
injected = httpx.Client(
transport=httpx.MockTransport(lambda r: httpx.Response(200, json={}))
)
ArkClient(api_key=FAKE_API_KEY, base_url=BASE_URL, client=injected).close()
assert not injected.is_closed
def test_own_client_is_closed(self):
client = ArkClient(api_key=FAKE_API_KEY, base_url=BASE_URL)
client.close()
assert client._client.is_closed
def test_usable_as_context_manager(self):
with ArkClient(api_key=FAKE_API_KEY, base_url=BASE_URL) as client:
inner = client._client
assert inner.is_closed
+176
View File
@@ -0,0 +1,176 @@
"""火山方舟错误分类的测试。
每个用例对应官方错误码表里的一行或一类。重点在三处**如果搞错只会在生产暴露**
的地方:`QuotaExceeded` 的一码两义、内容安全类必须不可重试、未知码必须偏向
可重试而不是直接判死。
"""
from app.domain.generation import Retry, UserFacing
from app.providers.volcengine.errors import classify
class TestQuotaExceededIsTwoDifferentThings:
"""同一个 code,两种完全不同的处置。只看 code 必然错一半。"""
def test_queue_backlog_is_a_seconds_long_wait(self):
# 官方原文:「当前账号处于排队中状态的任务数已超过限制,请稍后重试。」
result = classify(
"QuotaExceeded", "The request has exceeded the quota. Request ID: x."
)
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.RATE_LIMITED
def test_queue_backlog_recognised_from_chinese_message(self):
result = classify("QuotaExceeded", "当前账号处于排队中状态的任务数已超过限制")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.RATE_LIMITED
def test_free_trial_exhausted_needs_billing_action(self):
# 官方原文:「免费试用额度已消耗完毕,请前往控制台开通对应模型服务。」
result = classify(
"QuotaExceeded",
"Your account [x] has exhausted its free trial quota for the [y] model.",
)
assert result.retry is Retry.QUOTA
assert result.user_facing is UserFacing.QUOTA_EXHAUSTED
def test_periodic_limit_exhausted_needs_to_wait_for_reset(self):
result = classify(
"QuotaExceeded",
"You have exceeded the 5-hour/weekly/monthly usage quota. It will reset at T.",
)
assert result.retry is Retry.QUOTA
assert result.user_facing is UserFacing.QUOTA_EXHAUSTED
class TestContentRejectionMustNotBeRetried:
"""同样的输入必然同样被拒。重试只会白烧额度再失败。"""
def test_input_image_sensitive(self):
result = classify("InputImageSensitiveContentDetected")
assert result.retry is Retry.NEVER
assert result.user_facing is UserFacing.CONTENT_REJECTED
def test_output_video_sensitive(self):
result = classify("OutputVideoSensitiveContentDetected")
assert result.retry is Retry.NEVER
assert result.user_facing is UserFacing.CONTENT_REJECTED
def test_policy_violation_subcode(self):
result = classify("InputVideoSensitiveContentDetected.PolicyViolation")
assert result.retry is Retry.NEVER
def test_real_person_subcode(self):
result = classify("InputImageSensitiveContentDetected.PrivacyInformation")
assert result.retry is Retry.NEVER
def test_risk_detection_product_codes(self):
assert classify("InputTextRiskDetection").retry is Retry.NEVER
assert classify("OutputImageRiskDetection").retry is Retry.NEVER
def test_but_the_detector_itself_failing_is_retryable(self):
# 风险识别产品请求失败 ≠ 内容真的违规。
result = classify("ContentSecurityDetectionError", "Internal error.")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
class TestRateLimitFamily:
def test_endpoint_rpm(self):
result = classify("RateLimitExceeded.EndpointRPMExceeded")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.RATE_LIMITED
def test_model_account_tpm(self):
assert classify("ModelAccountTpmRateLimitExceeded").retry is Retry.BACKOFF
def test_server_overloaded(self):
assert classify("ServerOverloaded").retry is Retry.BACKOFF
def test_request_burst_too_fast(self):
# doubao-seed-2.0 之后版本的突增流量保护码。
assert classify("RequestBurstTooFast").retry is Retry.BACKOFF
def test_images_per_minute(self):
assert classify("ModelAccountIpmRateLimitExceeded").retry is Retry.BACKOFF
class TestQuotaAndBillingFamily:
def test_inference_limit_set_by_account(self):
result = classify("SetLimitExceeded")
assert result.retry is Retry.QUOTA
assert result.user_facing is UserFacing.QUOTA_EXHAUSTED
def test_concurrency_cap_needs_topup(self):
assert classify("InflightBatchsizeExceeded").retry is Retry.QUOTA
def test_account_overdue_is_403_but_same_user_action(self):
# HTTP 状态码不同(403 而非 429),但用户要做的事一样:去充值。
result = classify("AccountOverdueError")
assert result.retry is Retry.QUOTA
assert result.user_facing is UserFacing.QUOTA_EXHAUSTED
def test_model_not_activated(self):
assert classify("ModelNotOpen").retry is Retry.QUOTA
class TestOurOwnFaults:
"""重试无意义,需要人改代码或改配置。"""
def test_bad_api_key(self):
result = classify("AuthenticationError")
assert result.retry is Retry.NEVER
assert result.user_facing is UserFacing.SERVER_ERROR
def test_unknown_model_id(self):
assert classify("InvalidEndpointOrModel.NotFound").retry is Retry.NEVER
def test_invalid_parameter(self):
assert classify("InvalidParameter").retry is Retry.NEVER
def test_task_type_constraint_is_our_parameter_problem(self):
# Seedance 2.5 全模态任务的参数与模型判定的任务类型不兼容。
assert classify("InvalidParameter.TaskTypeConstraint").retry is Retry.NEVER
def test_task_type_mismatch(self):
assert classify("InvalidParameter.TaskTypeMismatch").retry is Retry.NEVER
def test_missing_parameter(self):
assert classify("MissingParameter").retry is Retry.NEVER
def test_payload_too_large(self):
assert classify("RequestTooLarge").retry is Retry.NEVER
class TestUpstreamErrors:
def test_internal_service_error_is_retryable(self):
result = classify("InternalServiceError")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
class TestUnknownCodesLeanTowardsRetry:
"""厂商在持续新增错误码。把未知码判死会让一次抖动变成用户看到的失败;
重试预算本身有上限,最坏只是多试两次。"""
def test_unknown_code(self):
result = classify("SomeBrandNewCodeVolcengineAddedLastWeek")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_missing_code(self):
assert classify(None).retry is Retry.BACKOFF
assert classify("").retry is Retry.BACKOFF
class TestProviderCodeIsAlwaysPreserved:
"""原始码绝不丢弃——排查时它是唯一线索。"""
def test_known_code(self):
assert classify("ServerOverloaded").provider_code == "ServerOverloaded"
def test_unknown_code(self):
assert classify("WeirdCode").provider_code == "WeirdCode"
def test_whitespace_is_trimmed(self):
assert classify(" ServerOverloaded ").provider_code == "ServerOverloaded"
+542
View File
@@ -0,0 +1,542 @@
"""Seedance 视频适配器的测试:领域请求 ↔ 厂商请求体的翻译,以及状态映射。
夹具里的响应体逐字取自官方文档的「返回格式」示例,避免测试通过而真实字段名对不上。
"""
from __future__ import annotations
import httpx
import pytest
from app.domain.generation import (
GenerationRequest,
MediaKind,
Retry,
TaskState,
UserFacing,
)
from app.providers.volcengine.client import ArkClient
from app.providers.volcengine.models import SeedanceSubmission
from app.providers.volcengine.video import PROVIDER_CODE, SeedanceVideoProvider
FAKE_API_KEY = "dummy-api-key-for-tests"
#: 官方查询文档「返回格式」一节的原样示例。
OFFICIAL_SUCCESS_PAYLOAD = {
"id": "cgt-2026****-****",
"model": "doubao-seedance-2-0-260128",
"status": "succeeded",
"content": {
"video_url": "https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com/xxx"
},
"usage": {"completion_tokens": 108900, "total_tokens": 108900},
"created_at": 1779348818,
"updated_at": 1779348874,
"seed": 78674,
"resolution": "720p",
"ratio": "16:9",
"duration": 5,
"framespersecond": 24,
"service_tier": "default",
"execution_expires_after": 172800,
"generate_audio": True,
"draft": False,
"priority": 0,
}
def provider_with(handler, **kwargs) -> SeedanceVideoProvider:
client = ArkClient(
api_key=FAKE_API_KEY,
base_url="https://ark.example.invalid/api/v3",
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
return SeedanceVideoProvider(client, **kwargs)
def video_request(**kwargs) -> GenerationRequest:
defaults = {
"job_id": "job-1",
"media_kind": MediaKind.VIDEO,
"model": "doubao-seedance-x",
"prompt": "一只猫",
}
return GenerationRequest(**{**defaults, **kwargs})
class TestIdentity:
def test_provider_code_matches_the_catalog_column(self):
assert (
provider_with(lambda r: httpx.Response(200, json={})).provider_code
== PROVIDER_CODE
)
def test_declares_itself_as_a_video_provider(self):
assert (
provider_with(lambda r: httpx.Response(200, json={})).media_kind
is MediaKind.VIDEO
)
class TestSubmitTranslation:
def test_text_to_video_sends_only_text(self):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
captured.update(json.loads(request.content))
return httpx.Response(200, json={"id": "cgt-1"})
provider_with(handler).submit(video_request())
assert captured["content"] == [{"type": "text", "text": "一只猫"}]
def test_input_url_becomes_first_frame_with_explicit_role(self):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
captured.update(json.loads(request.content))
return httpx.Response(200, json={"id": "cgt-1"})
provider_with(handler).submit(video_request(input_url="https://x/a.png"))
image = captured["content"][1]
assert image["type"] == "image_url"
assert image["image_url"]["url"] == "https://x/a.png"
# role 省略时厂商按 first_frame 处理,但显式写出以免依赖默认行为。
assert image["role"] == "first_frame"
def test_model_from_request_wins_over_the_configured_default(self):
"""请求里的模型来自任务行冻结的快照,必须优先。
否则「任务记录里写的模型」与「实际调用的模型」会不一致,事后无法对账。
"""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
captured.update(json.loads(request.content))
return httpx.Response(200, json={"id": "cgt-1"})
provider_with(handler, default_model="fallback-model").submit(
video_request(model="snapshot-model")
)
assert captured["model"] == "snapshot-model"
def test_configured_default_is_only_a_fallback(self):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
captured.update(json.loads(request.content))
return httpx.Response(200, json={"id": "cgt-1"})
provider_with(handler, default_model="fallback-model").submit(
video_request(model="")
)
assert captured["model"] == "fallback-model"
def test_no_model_anywhere_fails_before_calling_the_vendor(self):
with pytest.raises(ValueError, match="未指定模型"):
provider_with(lambda r: httpx.Response(200, json={})).submit(
video_request(model="")
)
def test_task_expiry_comes_from_config_not_the_vendor_default(self):
# 厂商默认 48 小时,对交互式应用过长——被放弃的任务会在厂商侧挂两天。
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
captured.update(json.loads(request.content))
return httpx.Response(200, json={"id": "cgt-1"})
provider_with(handler, task_expiry_sec=7200).submit(video_request())
assert captured["execution_expires_after"] == 7200
def test_rejects_a_misrouted_image_request(self):
with pytest.raises(ValueError, match="只处理视频生成"):
provider_with(lambda r: httpx.Response(200, json={})).submit(
video_request(media_kind=MediaKind.IMAGE)
)
def test_returns_vendor_task_id(self):
def handler(request):
return httpx.Response(200, json={"id": "cgt-123"})
assert provider_with(handler).submit(video_request()) == "cgt-123"
def test_success_without_task_id_is_an_error(self):
# 2xx 但没有 ID:我们再也查不到这个任务,而它可能已经在计费。
def handler(request):
return httpx.Response(200, json={"unexpected": True})
with pytest.raises(ValueError, match="缺少任务 ID"):
provider_with(handler).submit(video_request())
class TestSubmissionBody:
"""`SeedanceSubmission` 自身的行为,不经过 HTTP。"""
def test_empty_content_is_rejected_locally(self):
# 空内容必然被厂商拒,但那要花一次往返(还可能计入 RPM)。
with pytest.raises(ValueError, match="至少要有一个"):
SeedanceSubmission(model="m").to_body()
def test_last_frame_requested_by_default_for_a_free_cover_image(self):
assert (
SeedanceSubmission(model="m", prompt="x").to_body()["return_last_frame"]
is True
)
def test_optional_fields_are_omitted_when_unset(self):
body = SeedanceSubmission(model="m", prompt="x").to_body()
assert "safety_identifier" not in body
assert "callback_url" not in body
class TestFetchStateMapping:
def test_succeeded(self):
result = provider_with(
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
).fetch("cgt-1")
assert result.state is TaskState.SUCCEEDED
assert result.progress == 100
assert result.asset_url.endswith("/xxx")
assert result.ratio == "16:9"
assert result.resolution == "720p"
assert result.billing_tokens == 108900
assert result.failure is None
def test_asset_url_carries_its_expiry_so_callers_cannot_treat_it_as_permanent(self):
"""厂商 URL 只有 24 小时有效期,存成 external_url 会变死链。
把时效随 URL 一起给出,上层就没有理由把它当永久地址。
"""
result = provider_with(
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
).fetch("cgt-1")
assert result.asset_url_expires_in_sec == 24 * 3600
def test_queued_maps_to_pending(self):
def handler(request):
return httpx.Response(
200, json={"id": "x", "status": "queued", "content": {}}
)
assert provider_with(handler).fetch("x").state is TaskState.PENDING
def test_running_has_zero_progress_because_the_vendor_gives_none(self):
# 不是缺陷:客户端契约本就不承诺平滑进度(ADR-023 §10)。
def handler(request):
return httpx.Response(
200, json={"id": "x", "status": "running", "content": {}}
)
result = provider_with(handler).fetch("x")
assert result.state is TaskState.RUNNING
assert result.progress == 0
def test_cancelled_is_terminal_without_a_failure(self):
def handler(request):
return httpx.Response(
200, json={"id": "x", "status": "cancelled", "content": {}}
)
result = provider_with(handler).fetch("x")
assert result.state is TaskState.CANCELLED
assert result.state.is_terminal
assert result.failure is None
def test_expired_maps_to_failed_and_stays_retryable(self):
"""`expired` 只见于创建接口的回调说明,查询接口文档没列。
只按查询文档实现会让超时任务掉进未知分支。它是超时而非内容问题,故可重试;
且厂商 expired 时不一定给 error.code,故单独分类而非走通用路径。
"""
def handler(request):
return httpx.Response(
200, json={"id": "x", "status": "expired", "content": {}}
)
result = provider_with(handler).fetch("x")
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.BACKOFF
assert result.failure.provider_code == "TaskExpired"
def test_unknown_status_keeps_the_task_alive(self):
# 厂商新增状态时把在途任务判死更糟,最坏是等到我们自己的超时。
def handler(request):
return httpx.Response(
200, json={"id": "x", "status": "brand_new", "content": {}}
)
assert provider_with(handler).fetch("x").state is TaskState.RUNNING
def test_succeeded_without_a_url_is_treated_as_a_retryable_failure(self):
"""厂商说成功却没给 URL——那不是成功。
放一个没有产物的「成功」给上层,只会把空指针推到更远的地方。
"""
def handler(request):
return httpx.Response(
200, json={"id": "x", "status": "succeeded", "content": {}}
)
result = provider_with(handler).fetch("x")
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.BACKOFF
assert result.failure.provider_code == "MissingVideoUrl"
class TestFetchFailureClassification:
def test_content_rejection_is_not_retried(self):
def handler(request):
return httpx.Response(
200,
json={
"id": "x",
"status": "failed",
"content": {},
"error": {
"code": "OutputVideoSensitiveContentDetected",
"message": "output may contain sensitive information",
},
},
)
result = provider_with(handler).fetch("x")
assert result.failure.retry is Retry.NEVER
assert result.failure.user_facing is UserFacing.CONTENT_REJECTED
# 原始码与 message 都要带到上层——排查时它们是唯一线索。
assert result.failure.provider_code == "OutputVideoSensitiveContentDetected"
assert "sensitive" in result.failure.message
class TestDurationPrecision:
def test_frames_over_fps_is_exact(self):
# 官方例子:133 帧 / 24fps = 5.54 秒。
def handler(request):
return httpx.Response(
200,
json={
"id": "x",
"status": "succeeded",
"frames": 133,
"framespersecond": 24,
"content": {"video_url": "https://v"},
},
)
assert provider_with(handler).fetch("x").duration_ms == 5542
def test_falls_back_to_the_vendors_floored_seconds(self):
# duration 与 frames 只返回其一。只有 duration 时它是「帧数/24 向下取整」
# 的约数,最多可能短 1 秒——可用,但不可当精确值。
def handler(request):
return httpx.Response(
200,
json={
"id": "x",
"status": "succeeded",
"duration": 5,
"content": {"video_url": "https://v"},
},
)
assert provider_with(handler).fetch("x").duration_ms == 5000
def test_none_rather_than_a_made_up_zero(self):
def handler(request):
return httpx.Response(
200, json={"id": "x", "status": "running", "content": {}}
)
assert provider_with(handler).fetch("x").duration_ms is None
class TestCoverImage:
def test_last_frame_becomes_the_cover_url(self):
"""尾帧图可免费充当视频封面——iteration-4/03 R13 把缩略图列为视频成本项,
实际上厂商白给。"""
def handler(request):
return httpx.Response(
200,
json={
"id": "x",
"status": "succeeded",
"content": {
"video_url": "https://v",
"last_frame_url": "https://f.png",
},
},
)
assert provider_with(handler).fetch("x").cover_url == "https://f.png"
class TestPixelDimensions:
"""厂商只返回 ratio 与 resolution,像素值由静态对照表换算。
不需要下载产物、不需要解码、不依赖存储方案——这是 widthPx/heightPx 在 AI
视频产物上能零成本填真值的依据(ADR-023 §11)。
"""
def test_derived_from_ratio_and_resolution(self):
# 官方表:2.0 系列 720p + 16:9 = 1280×720。
result = provider_with(
lambda r: httpx.Response(200, json=OFFICIAL_SUCCESS_PAYLOAD)
).fetch("x")
assert (result.width_px, result.height_px) == (1280, 720)
# 原始信息同时透出,便于排查与将来复核。
assert (result.ratio, result.resolution) == ("16:9", "720p")
def test_the_two_cells_where_the_families_differ(self):
"""2.5 与 2.0 的 24 格里只有两格不同:480p 的 16:9 与 9:16。
这两格若搞错,客户端会按错误比例渲染 480p 视频且看不出是错的。
"""
def payload(model: str) -> dict:
return {
"id": "x",
"model": model,
"status": "succeeded",
"resolution": "480p",
"ratio": "16:9",
"content": {"video_url": "https://v"},
}
result_2_5 = provider_with(
lambda r: httpx.Response(200, json=payload("doubao-seedance-2-5-260628"))
).fetch("x")
result_2_0 = provider_with(
lambda r: httpx.Response(200, json=payload("doubao-seedance-2-0-260128"))
).fetch("x")
assert (result_2_5.width_px, result_2_5.height_px) == (854, 480)
assert (result_2_0.width_px, result_2_0.height_px) == (864, 496)
def test_4k_resolves_only_on_2_0(self):
def handler(request):
return httpx.Response(
200,
json={
"id": "x",
"model": "doubao-seedance-2-0-260128",
"status": "succeeded",
"resolution": "4k",
"ratio": "16:9",
"content": {"video_url": "https://v"},
},
)
result = provider_with(handler).fetch("x")
assert (result.width_px, result.height_px) == (3840, 2160)
def test_adaptive_ratio_yields_none_rather_than_a_guess(self):
"""`adaptive` 时厂商自动适配首帧比例,本表无从查起。
留空而不猜:一个错的尺寸会让客户端按错误比例渲染,而且看不出是错的。
"""
def handler(request):
return httpx.Response(
200,
json={
"id": "x",
"model": "doubao-seedance-2-5-260628",
"status": "succeeded",
"resolution": "720p",
"ratio": "adaptive",
"content": {"video_url": "https://v"},
},
)
result = provider_with(handler).fetch("x")
assert (result.width_px, result.height_px) == (None, None)
# 但原始值仍透出,上层至少知道厂商说了什么。
assert result.ratio == "adaptive"
def test_unknown_model_family_yields_none(self):
def handler(request):
return httpx.Response(
200,
json={
"id": "x",
"model": "some-future-model",
"status": "succeeded",
"resolution": "720p",
"ratio": "16:9",
"content": {"video_url": "https://v"},
},
)
result = provider_with(handler).fetch("x")
assert (result.width_px, result.height_px) == (None, None)
class TestPerModelLimitsAreEnforcedBeforeCallingTheVendor:
"""超范围的请求本地就拦下,不花一次往返去换一个厂商 400。
两个已接入模型的限制不同,同一组参数在一个上合法、在另一个上不合法——
这正是需要本地预校验的理由。
"""
def test_duration_beyond_the_2_0_cap_is_rejected_locally(self):
def handler(request):
raise AssertionError("不应发出请求:本地预校验就该拦下")
with pytest.raises(ValueError, match=r"\[4, 15\]"):
provider_with(handler).submit(
video_request(model="doubao-seedance-2-0-260128", duration_sec=20)
)
def test_the_same_duration_passes_on_2_5(self):
def handler(request):
return httpx.Response(200, json={"id": "cgt-1"})
assert (
provider_with(handler).submit(
video_request(model="doubao-seedance-2-5-260628", duration_sec=20)
)
== "cgt-1"
)
def test_4k_is_rejected_on_2_5_but_allowed_on_2_0(self):
def rejecting(request):
raise AssertionError("不应发出请求")
with pytest.raises(ValueError, match="不支持分辨率 4k"):
provider_with(rejecting).submit(
video_request(model="doubao-seedance-2-5-260628", resolution="4k")
)
def accepting(request):
return httpx.Response(200, json={"id": "cgt-2"})
assert (
provider_with(accepting).submit(
video_request(model="doubao-seedance-2-0-260128", resolution="4k")
)
== "cgt-2"
)
def test_2_5_image_to_video_rejects_a_specific_ratio(self):
def handler(request):
raise AssertionError("不应发出请求")
with pytest.raises(ValueError, match="只支持 ratio=adaptive"):
provider_with(handler).submit(
video_request(
model="doubao-seedance-2-5-260628",
input_url="https://x/a.png",
ratio="16:9",
)
)
+89
View File
@@ -0,0 +1,89 @@
"""装配根与注册表的测试。"""
from __future__ import annotations
import pytest
from app.assembly import build_registry
from app.config import Settings, VolcengineSettings
from app.domain.generation import (
GenerationRequest,
GenerationResult,
MediaKind,
TaskState,
)
from app.providers.base import GenerationProvider
from app.providers.registry import ProviderRegistry
from app.providers.volcengine.video import PROVIDER_CODE
FAKE_API_KEY = "dummy-api-key-for-tests"
class StubProvider(GenerationProvider):
def __init__(self, code: str, kind: MediaKind = MediaKind.VIDEO):
self._code = code
self._kind = kind
@property
def provider_code(self) -> str:
return self._code
@property
def media_kind(self) -> MediaKind:
return self._kind
def submit(self, request: GenerationRequest) -> str:
return "stub-1"
def fetch(self, provider_request_id: str) -> GenerationResult:
return GenerationResult(provider_request_id, TaskState.RUNNING)
class TestRegistry:
def test_finds_by_code(self):
registry = ProviderRegistry([StubProvider("a"), StubProvider("b")])
assert registry.find("a").provider_code == "a"
assert registry.codes == frozenset({"a", "b"})
def test_unknown_code_returns_none(self):
"""对已持久化的任务,未知 code 意味着当初提交它的适配器已被移除。
调用方应当作不可重试的失败,而不是循环重试到预算耗尽。
"""
assert ProviderRegistry([StubProvider("a")]).find("gone") is None
def test_duplicate_registration_is_rejected(self):
# 静默覆盖会让「哪个实例在处理任务」取决于注册顺序,是最难查的一类问题。
with pytest.raises(ValueError, match="重复注册"):
ProviderRegistry([StubProvider("same"), StubProvider("same")])
class TestAssembly:
def test_seedance_is_registered_when_credentials_exist(self):
registry = build_registry(
Settings(volcengine=VolcengineSettings(api_key=FAKE_API_KEY))
)
provider = registry.find(PROVIDER_CODE)
assert provider is not None
assert provider.media_kind is MediaKind.VIDEO
def test_missing_credentials_skip_the_provider_instead_of_failing_startup(self):
"""本地只配了一家凭证时,服务仍应起得来。
真正的失败点在调用时:find() 返回 None,调用方按「适配器不可用」处理,
而不是拿到一个半死的实例。
"""
registry = build_registry(Settings(volcengine=VolcengineSettings(api_key="")))
assert registry.find(PROVIDER_CODE) is None
assert len(registry) == 0
def test_registered_code_matches_the_catalog_column(self):
"""注册表的键必须与 creation.generation_models.provider_code 同值。
对不上时,Java 侧按任务行的快照来查会一直查不到适配器——而症状是任务
永远卡在排队,离根因很远。
"""
registry = build_registry(
Settings(volcengine=VolcengineSettings(api_key=FAKE_API_KEY))
)
assert PROVIDER_CODE in registry.codes
+98
View File
@@ -0,0 +1,98 @@
"""配置加载的测试。
重点在两处:非法值必须**响亮失败而不是静默回落**(一次配置笔误变成难查的行为
偏差是最糟的结果),以及密钥**绝不能有默认值**。
"""
from __future__ import annotations
import pytest
from app.config import ServiceAuthSettings, Settings, VolcengineSettings
class TestCredentialsHaveNoDefaults:
def test_api_key_defaults_to_empty_not_a_placeholder(self, monkeypatch):
monkeypatch.delenv("ARK_API_KEY", raising=False)
assert VolcengineSettings.from_env().api_key == ""
def test_service_jwt_public_key_defaults_to_empty(self, monkeypatch):
monkeypatch.delenv("PATBOND_SERVICE_JWT_PUBLIC_KEY", raising=False)
assert ServiceAuthSettings.from_env().public_key == ""
def test_missing_credentials_do_not_prevent_loading(self, monkeypatch):
"""缺密钥时配置仍能加载。
服务要能起来并提供健康检查;缺配置在**调用点**响亮失败,而不是让整个
进程起不来——与 Java 侧 JwtVerifier/ServiceTokenSigner 的既有约定一致。
"""
for name in ("ARK_API_KEY", "PATBOND_SERVICE_JWT_PUBLIC_KEY"):
monkeypatch.delenv(name, raising=False)
settings = Settings.from_env()
assert settings.volcengine.api_key == ""
assert settings.service_auth.public_key == ""
class TestEnvParsing:
def test_values_are_trimmed(self, monkeypatch):
# 从 .env 或 compose 里复制粘贴时尾随空格极常见。
monkeypatch.setenv("ARK_API_KEY", " dummy-value ")
assert VolcengineSettings.from_env().api_key == "dummy-value"
def test_int_override(self, monkeypatch):
monkeypatch.setenv("ARK_TASK_EXPIRY_SEC", "7200")
assert VolcengineSettings.from_env().task_expiry_sec == 7200
def test_float_override(self, monkeypatch):
monkeypatch.setenv("ARK_TIMEOUT_SEC", "2.5")
assert VolcengineSettings.from_env().timeout_sec == 2.5
def test_malformed_int_fails_loudly_instead_of_falling_back(self, monkeypatch):
# 静默回落到默认值会让一次笔误变成难查的行为偏差。
monkeypatch.setenv("ARK_TASK_EXPIRY_SEC", "一小时")
with pytest.raises(ValueError, match="不是合法整数"):
VolcengineSettings.from_env()
def test_malformed_float_fails_loudly(self, monkeypatch):
monkeypatch.setenv("ARK_TIMEOUT_SEC", "fast")
with pytest.raises(ValueError, match="不是合法数字"):
VolcengineSettings.from_env()
def test_empty_string_uses_the_default(self, monkeypatch):
# compose 里 `ARK_TIMEOUT_SEC: ${FOO:-}` 展开成空串是常见情形。
monkeypatch.setenv("ARK_TIMEOUT_SEC", "")
assert VolcengineSettings.from_env().timeout_sec == 10.0
class TestVendorConstraintsAreEnforcedAtStartup:
"""厂商的硬性区间在启动时校验,而不是等提交时被拒。"""
def test_expiry_below_the_vendor_minimum_is_rejected(self):
with pytest.raises(ValueError, match=r"\[3600, 259200\]"):
VolcengineSettings(task_expiry_sec=60)
def test_expiry_above_the_vendor_maximum_is_rejected(self):
with pytest.raises(ValueError, match=r"\[3600, 259200\]"):
VolcengineSettings(task_expiry_sec=300000)
def test_the_boundaries_themselves_are_allowed(self):
assert VolcengineSettings(task_expiry_sec=3600).task_expiry_sec == 3600
assert VolcengineSettings(task_expiry_sec=259200).task_expiry_sec == 259200
def test_default_is_the_minimum_not_the_vendor_default(self):
# 厂商默认 48 小时,被放弃的任务会在厂商侧挂两天。
assert VolcengineSettings().task_expiry_sec == 3600
class TestServiceAuthDefaults:
def test_audience_and_type_have_sensible_defaults(self, monkeypatch):
for name in (
"PATBOND_SERVICE_JWT_AUDIENCE",
"PATBOND_SERVICE_JWT_ISSUER",
"PATBOND_SERVICE_JWT_TYPE",
):
monkeypatch.delenv(name, raising=False)
auth = ServiceAuthSettings.from_env()
assert auth.audience == "patbond-ai"
assert auth.issuer == "patbond-api"
assert auth.required_type == "service"
+127
View File
@@ -0,0 +1,127 @@
"""分层边界的测试。
这些断言不测业务,测的是**架构约束本身**。分层的价值全在「换供应商不动上层」,
而这个性质只要有人无意加一行 import 就会悄悄失效——没有测试守着,它迟早会失效。
"""
from __future__ import annotations
import ast
import pathlib
APP = pathlib.Path(__file__).resolve().parent.parent / "app"
def imports_of(path: pathlib.Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
names.add(node.module)
return names
def modules_under(*parts: str) -> list[pathlib.Path]:
return sorted((APP.joinpath(*parts)).rglob("*.py"))
class TestDomainIsIndependent:
"""领域层是「换供应商不动上层」的支点。它一旦依赖厂商或 HTTP,分层就名存实亡。"""
def test_domain_imports_no_provider_code(self):
for module in modules_under("domain"):
offenders = {name for name in imports_of(module) if "providers" in name}
assert not offenders, f"{module.name} 不应依赖服务商代码:{offenders}"
def test_domain_imports_no_http_library(self):
for module in modules_under("domain"):
offenders = {
name
for name in imports_of(module)
if name.split(".")[0] in {"httpx", "requests"}
}
assert not offenders, f"{module.name} 不应依赖 HTTP 库:{offenders}"
def test_domain_does_not_read_configuration(self):
# 领域对象应当是纯数据;让它自己读配置会使单测必须摆弄环境变量。
for module in modules_under("domain"):
offenders = {
name for name in imports_of(module) if name in {"os", "app.config"}
}
assert not offenders, f"{module.name} 不应读取配置:{offenders}"
class TestAbstractionDoesNotLeak:
def test_provider_base_knows_no_specific_vendor(self):
"""抽象接口若 import 了火山,新增第二家供应商就得改抽象层。"""
offenders = {
name
for name in imports_of(APP / "providers" / "base.py")
if "volcengine" in name
}
assert not offenders, f"抽象层不应依赖具体服务商:{offenders}"
def test_registry_knows_no_specific_vendor(self):
offenders = {
name
for name in imports_of(APP / "providers" / "registry.py")
if "volcengine" in name
}
assert not offenders, f"注册表不应依赖具体服务商:{offenders}"
class TestTransportStaysBusinessAgnostic:
def test_ark_client_does_not_import_video_business(self):
"""传输层复用的前提是它不认识「视频」。
一旦 client.py 依赖了 video.py,接火山的图片接口时就没法复用它,
只能再抄一遍鉴权与错误解包——那正是拆分要避免的。
"""
offenders = {
name
for name in imports_of(APP / "providers" / "volcengine" / "client.py")
if name.endswith((".video", ".image", "models"))
}
assert not offenders, f"传输层不应依赖具体业务模块:{offenders}"
class TestVendorVocabularyIsContained:
def test_only_the_volcengine_package_mentions_ark_paths(self):
"""厂商特有的路径、字段名只应出现在其适配包内。
`framespersecond` 这种厂商拼法漏到上层,就意味着上层已经知道自己在跟
火山说话——换供应商时会牵连到它。
"""
vendor_markers = ("framespersecond", "contents/generations", "ark.cn-beijing")
for module in sorted(APP.rglob("*.py")):
if "volcengine" in module.parts:
continue
text = module.read_text(encoding="utf-8")
for marker in vendor_markers:
assert marker not in text, (
f"{module.relative_to(APP)} 出现了厂商专有词汇 {marker!r},"
f"应收拢到 providers/volcengine/ 内"
)
class TestDependencyDirectionIsOneWay:
"""配置 → 适配层,绝不反向。
`config.py` 作为装配根知道有哪家供应商是合理的;反过来,适配层一旦自己去读
配置,依赖注入就失效了——单测又得摆弄环境变量,而且同一进程内没法跑两套
不同配置的适配器实例。
"""
def test_providers_do_not_read_config(self):
for module in modules_under("providers"):
offenders = {
name
for name in imports_of(module)
if name == "app.config" or name == "os"
}
assert not offenders, (
f"{module.relative_to(APP)} 不应自行读取配置或环境变量:"
f"{offenders}。请通过构造参数注入。"
)