193 lines
6.2 KiB
Python
193 lines
6.2 KiB
Python
"""供应商适配器基类与能力声明。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
import httpx
|
|
|
|
|
|
@dataclass
|
|
class ProviderConfig:
|
|
"""一条供应商配置(api_key 已解密)。"""
|
|
|
|
id: str
|
|
name: str
|
|
adapter: str
|
|
base_url: str
|
|
api_key: str
|
|
default_model: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Capabilities:
|
|
"""适配器能力声明,服务端据此校验入参。"""
|
|
|
|
supports_image_input: bool = False
|
|
size_hint: str = "WxH"
|
|
kind: str = "image"
|
|
|
|
|
|
class ProviderError(Exception):
|
|
"""上游调用失败。"""
|
|
|
|
def __init__(self, message: str, status: int = 502):
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.status = status
|
|
|
|
|
|
@dataclass
|
|
class ImageResult:
|
|
"""单张出图结果:要么有二进制数据,要么有远端 URL。"""
|
|
|
|
data: bytes | None = None
|
|
url: str | None = None
|
|
filename: str | None = None
|
|
content_type: str = "image/png"
|
|
|
|
|
|
class ProviderAdapter:
|
|
"""适配器基类。子类需实现 list_models 与 generate。"""
|
|
|
|
name = "base"
|
|
capabilities = Capabilities()
|
|
|
|
def __init__(self, cfg: ProviderConfig):
|
|
self.cfg = cfg
|
|
|
|
# ----------------------------------------------------------- 工具方法
|
|
def _headers(self) -> dict[str, str]:
|
|
headers = {"Accept": "application/json"}
|
|
if self.cfg.api_key:
|
|
headers["Authorization"] = f"Bearer {self.cfg.api_key}"
|
|
return headers
|
|
|
|
def _url(self, path: str) -> str:
|
|
return self.cfg.base_url.rstrip("/") + "/" + path.lstrip("/")
|
|
|
|
def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
json_body: dict | None = None,
|
|
timeout: float = 60.0,
|
|
retries: int = 2,
|
|
) -> httpx.Response:
|
|
url = self._url(path)
|
|
last_exc: Exception | None = None
|
|
for attempt in range(retries + 1):
|
|
try:
|
|
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
resp = client.request(method, url, headers=self._headers(), json=json_body)
|
|
except httpx.HTTPError as exc:
|
|
last_exc = exc
|
|
else:
|
|
if resp.status_code < 400:
|
|
return resp
|
|
if resp.status_code in (429, 500, 502, 503, 504) and attempt < retries:
|
|
time.sleep(1.5 * (attempt + 1))
|
|
continue
|
|
raise ProviderError(
|
|
f"上游返回 HTTP {resp.status_code}: {resp.text[:400]}",
|
|
status=resp.status_code,
|
|
)
|
|
if attempt < retries:
|
|
time.sleep(1.5 * (attempt + 1))
|
|
raise ProviderError(f"请求上游失败: {last_exc}")
|
|
|
|
def _json(self, resp: httpx.Response) -> dict:
|
|
try:
|
|
payload = resp.json()
|
|
except ValueError:
|
|
raise ProviderError(f"上游返回非 JSON 内容: {resp.text[:200]}")
|
|
if not isinstance(payload, dict):
|
|
raise ProviderError("上游 JSON 响应必须是对象")
|
|
return payload
|
|
|
|
# ----------------------------------------------------------- 接口定义
|
|
def list_models(self) -> list[str]:
|
|
payload = self._json(self._request("GET", "/models"))
|
|
items = payload.get("data") or payload.get("models") or []
|
|
if not isinstance(items, list):
|
|
raise ProviderError("上游模型列表格式错误")
|
|
ids: list[str] = []
|
|
for item in items:
|
|
mid = (item.get("id") or item.get("name")) if isinstance(item, dict) else item
|
|
if isinstance(mid, str) and mid and mid not in ids:
|
|
ids.append(mid)
|
|
return ids
|
|
|
|
def generate(
|
|
self,
|
|
model: str,
|
|
prompt: str,
|
|
size: str,
|
|
ratio: str,
|
|
n: int,
|
|
images: list[str],
|
|
) -> list[ImageResult]:
|
|
raise NotImplementedError
|
|
|
|
# ----------------------------------------------------------- 公共逻辑
|
|
@staticmethod
|
|
def filter_image_models(model_ids: list[str]) -> list[str]:
|
|
"""命中规则:模型 ID 转小写后包含 image。"""
|
|
return [m for m in model_ids if "image" in m.lower()]
|
|
|
|
def download(self, url: str) -> ImageResult:
|
|
try:
|
|
with httpx.Client(timeout=120.0, follow_redirects=True) as client:
|
|
resp = client.get(url)
|
|
except httpx.HTTPError as exc:
|
|
raise ProviderError(f"下载图片失败: {exc}")
|
|
if resp.status_code >= 400:
|
|
raise ProviderError(f"下载图片失败: HTTP {resp.status_code}")
|
|
ctype = resp.headers.get("content-type", "image/png")
|
|
return ImageResult(data=resp.content, content_type=ctype, filename=_ext_of(ctype))
|
|
|
|
@staticmethod
|
|
def collect_results(payload: dict) -> list[ImageResult]:
|
|
"""从 data[0].b64_json / data[0].url 两种形态中取图。"""
|
|
import base64
|
|
import binascii
|
|
|
|
items = payload.get("data") or []
|
|
if not isinstance(items, list):
|
|
raise ProviderError("上游图片列表格式错误")
|
|
results: list[ImageResult] = []
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
b64 = item.get("b64_json")
|
|
if b64:
|
|
try:
|
|
data = base64.b64decode(b64, validate=True)
|
|
except (binascii.Error, ValueError, TypeError) as exc:
|
|
raise ProviderError("上游返回无效的 Base64 图片") from exc
|
|
if not data:
|
|
raise ProviderError("上游返回空图片")
|
|
results.append(
|
|
ImageResult(data=data, content_type="image/png", filename=".png")
|
|
)
|
|
continue
|
|
url = item.get("url")
|
|
if url:
|
|
results.append(ImageResult(url=url, filename=_ext_of(url)))
|
|
if not results:
|
|
err = payload.get("error")
|
|
if err:
|
|
raise ProviderError(f"上游错误: {err}")
|
|
raise ProviderError(f"上游未返回图片: {str(payload)[:300]}")
|
|
return results
|
|
|
|
|
|
def _ext_of(value: str) -> str:
|
|
v = value.lower()
|
|
for ext in (".png", ".jpg", ".jpeg", ".webp"):
|
|
if ext in v:
|
|
return ext
|
|
return ".png"
|