Files
patbond-ai/app/providers/openai/client.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

162 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()