541376e9be
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。四道门禁逐条读退出码,全绿。
167 lines
6.5 KiB
Python
167 lines
6.5 KiB
Python
"""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()
|