新增 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,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"
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""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`。
|
||||
|
||||
错误体按 OpenAI 通用约定解析(`{"error": {message, type, code, param}}`)。
|
||||
**该形状未经用户提供的文档证实**——两份文档都没有错误码一节,见
|
||||
`errors.py` 顶部说明。解析不出这个形状时不静默,按状态码分类并把原始
|
||||
文本带上。
|
||||
"""
|
||||
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
|
||||
message = response.text[:500]
|
||||
try:
|
||||
error = response.json().get("error") or {}
|
||||
error_type = error.get("type")
|
||||
error_code = error.get("code")
|
||||
message = error.get("message") or message
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
failure = classify(response.status_code, error_type, error_code, message)
|
||||
log.warning(
|
||||
"OpenAI 返回错误 http=%s type=%s code=%s retry=%s",
|
||||
response.status_code,
|
||||
error_type,
|
||||
error_code,
|
||||
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()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""OpenAI 图片接口的错误 → 领域 `Failure`。
|
||||
|
||||
⚠️ **本模块的映射未经官方文档证实。**
|
||||
|
||||
用户提供的两份文档(`gpt生图.md` / `gpt图像编辑.md`)只覆盖了请求参数与返回体,
|
||||
**完全没有错误码表**——这与火山那边给了完整错误码表的情况不同。因此这里的映射
|
||||
建立在 OpenAI 通用错误约定之上:错误体形如
|
||||
`{"error": {"message", "type", "code", "param"}}`,并以 HTTP 状态码作为主要依据。
|
||||
|
||||
**处置策略**:以 **HTTP 状态码为主、`type`/`code` 为辅**。状态码语义是 HTTP 规范
|
||||
保证的,而 `type`/`code` 的取值集没有文档支撑,凭记忆列一张表只会得到一张看起来
|
||||
权威、实则可能错漏的映射。故这里对 `type`/`code` 只做**少量高置信度的加细**,
|
||||
其余一律按状态码归类。
|
||||
|
||||
**待补**:拿到官方错误码文档、或在真实调用中观察到实际取值后,回来校正本模块并
|
||||
更新 patbond-doc 的取证页。在那之前,本文件顶部这段说明不要删。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.domain.generation import Failure, Retry, UserFacing
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: 高置信度的 `type` 取值:这几个在 OpenAI 的错误体里语义明确且与状态码一致,
|
||||
#: 加细它们不会引入猜测——它们只是让分类比"仅看状态码"更准。
|
||||
#: 未列出的 type 一律回落到状态码判定。
|
||||
_TYPE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = {
|
||||
# 配额/计费耗尽:等的是充值或下一个周期,重试无用。
|
||||
"insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
|
||||
# 限流:等几秒。
|
||||
"rate_limit_exceeded": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
|
||||
# 内容被安全策略拒绝:同样的输入必然同样被拒。
|
||||
"image_generation_user_error": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
|
||||
}
|
||||
|
||||
#: 同上,但匹配 `code` 字段。
|
||||
_CODE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = {
|
||||
"insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
|
||||
"rate_limit_exceeded": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
|
||||
"moderation_blocked": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
|
||||
"content_policy_violation": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
|
||||
"invalid_api_key": (Retry.NEVER, UserFacing.SERVER_ERROR),
|
||||
}
|
||||
|
||||
|
||||
def classify(
|
||||
http_status: int | None,
|
||||
error_type: str | None = None,
|
||||
error_code: str | None = None,
|
||||
message: str | None = None,
|
||||
) -> Failure:
|
||||
"""把一次失败分类。
|
||||
|
||||
:param http_status: `None` 表示网络层失败(连接不上、超时),按可重试处理。
|
||||
|
||||
未知情形一律偏向**可退避重试**而非判死:把一次可恢复的抖动判成用户可见的
|
||||
失败是更糟的错误,而重试预算本身有上限(`max_attempts`)。
|
||||
"""
|
||||
text = message or ""
|
||||
# provider_code 优先用 code,其次 type——两者都缺时用状态码,保证这个字段
|
||||
# 永远不为空(它是排查时的第一线索)。
|
||||
provider_code = error_code or error_type or _status_code_label(http_status)
|
||||
|
||||
override = _CODE_OVERRIDES.get(error_code or "") or _TYPE_OVERRIDES.get(
|
||||
error_type or ""
|
||||
)
|
||||
if override:
|
||||
retry, user_facing = override
|
||||
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:
|
||||
# 429 在 OpenAI 上同时用于「限流」与「配额耗尽」,靠 type/code 区分;
|
||||
# 上面的 override 没命中说明拿不到那两个字段,此时**按限流处理**:
|
||||
# 猜成配额耗尽会让一次本可重试的限流被判死,代价更大。
|
||||
log.info("429 但无 type/code 可据,按限流处理(http=%s)", http_status)
|
||||
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, provider_code, text)
|
||||
|
||||
if http_status in (401, 403):
|
||||
# 密钥无效或无权访问:我方配置问题,重试无用。
|
||||
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
|
||||
|
||||
if http_status == 400:
|
||||
# 参数问题**或**内容被拒——OpenAI 两者都用 400。拿不到 type/code 时无法
|
||||
# 区分,按不可重试处理(两种情形重试同一请求都不会变好),用户侧归
|
||||
# SERVER_ERROR 而不猜成 CONTENT_REJECTED:错报「内容被拒」会让用户去改
|
||||
# 一个其实没问题的提示词。
|
||||
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 与其余一切:可重试。
|
||||
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text)
|
||||
|
||||
|
||||
def _status_code_label(http_status: int | None) -> str:
|
||||
return f"HTTP_{http_status}" if http_status else "NETWORK_ERROR"
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user