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

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

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

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

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

80 lines
3.3 KiB
Python

"""装配根:把配置变成可用的适配器。
**这是唯一允许同时知道「配置」与「具体供应商」的地方。** 其余模块只依赖抽象:
适配器通过构造参数拿配置(而非自己读环境变量),上层通过 `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