新增 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 目录。
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user