feat: complete image console and add verified REST and MCP workflows
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"""适配器注册表。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .agnes import AgnesAdapter
|
||||
from .base import Capabilities, ImageResult, ProviderAdapter, ProviderConfig, ProviderError
|
||||
from .openai_compat import OpenAICompatAdapter
|
||||
|
||||
ADAPTERS: dict[str, type[ProviderAdapter]] = {
|
||||
OpenAICompatAdapter.name: OpenAICompatAdapter,
|
||||
AgnesAdapter.name: AgnesAdapter,
|
||||
}
|
||||
|
||||
ADAPTER_LABELS = {
|
||||
"openai_compat": "OpenAI 兼容",
|
||||
"agnes": "Agnes 原生",
|
||||
}
|
||||
|
||||
|
||||
def get_adapter(cfg: ProviderConfig) -> ProviderAdapter:
|
||||
cls = ADAPTERS.get(cfg.adapter)
|
||||
if cls is None:
|
||||
raise ProviderError(f"不支持的适配器类型: {cfg.adapter}", status=400)
|
||||
return cls(cfg)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADAPTERS",
|
||||
"ADAPTER_LABELS",
|
||||
"AgnesAdapter",
|
||||
"Capabilities",
|
||||
"ImageResult",
|
||||
"OpenAICompatAdapter",
|
||||
"ProviderAdapter",
|
||||
"ProviderConfig",
|
||||
"ProviderError",
|
||||
"get_adapter",
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Agnes 原生协议适配器。
|
||||
|
||||
模型发现: GET {base_url}/models
|
||||
出图: POST {base_url}/images/generations
|
||||
尺寸: size 档位(1K/2K/3K/4K)+ ratio 宽高比
|
||||
返回: data[0].b64_json 或 data[0].url
|
||||
注意: response_format 必须放在 extra_body 内(顶层不允许)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .base import Capabilities, ImageResult, ProviderAdapter, ProviderError
|
||||
|
||||
RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16", "2:3", "3:2", "21:9")
|
||||
|
||||
# 统一像素尺寸 → Agnes 档位
|
||||
_TIER_BY_MAX = (
|
||||
(1024, "1K"),
|
||||
(1536, "1K"),
|
||||
(2048, "2K"),
|
||||
(3072, "3K"),
|
||||
(4096, "4K"),
|
||||
)
|
||||
|
||||
|
||||
def size_to_tier_and_ratio(size: str, ratio: str) -> tuple[str, str]:
|
||||
"""把统一的 WxH(可选带 ratio)翻译成 Agnes 的档位 + 宽高比。"""
|
||||
m = re.match(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$", size or "")
|
||||
tier = "1K"
|
||||
derived = ""
|
||||
if m:
|
||||
w, h = int(m.group(1)), int(m.group(2))
|
||||
long_side = max(w, h)
|
||||
for limit, t in _TIER_BY_MAX:
|
||||
if long_side <= limit:
|
||||
tier = t
|
||||
break
|
||||
else:
|
||||
tier = "4K"
|
||||
derived = _nearest_ratio(w, h)
|
||||
elif size and size.strip().upper() in ("1K", "2K", "3K", "4K"):
|
||||
tier = size.strip().upper()
|
||||
if ratio:
|
||||
r = ratio.strip()
|
||||
if r in RATIOS:
|
||||
return tier, r
|
||||
raise ProviderError("不支持的宽高比: " + r, status=400)
|
||||
return tier, (derived or "1:1")
|
||||
|
||||
|
||||
def _nearest_ratio(w: int, h: int) -> str:
|
||||
target = w / h if h else 1.0
|
||||
best, best_diff = "1:1", 1e9
|
||||
for r in RATIOS:
|
||||
a, b = r.split(":")
|
||||
value = int(a) / int(b)
|
||||
diff = abs(value - target)
|
||||
if diff < best_diff:
|
||||
best, best_diff = r, diff
|
||||
return best
|
||||
|
||||
|
||||
class AgnesAdapter(ProviderAdapter):
|
||||
name = "agnes"
|
||||
capabilities = Capabilities(
|
||||
supports_image_input=True,
|
||||
size_hint="1K~4K / 8 比例",
|
||||
kind="image",
|
||||
)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
size: str,
|
||||
ratio: str,
|
||||
n: int,
|
||||
images: list[str],
|
||||
) -> list[ImageResult]:
|
||||
tier, r = size_to_tier_and_ratio(size, ratio)
|
||||
extra: dict = {"response_format": "b64_json"}
|
||||
if images:
|
||||
extra["image"] = images
|
||||
body: dict = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": tier,
|
||||
"ratio": r,
|
||||
"n": n,
|
||||
"extra_body": extra,
|
||||
}
|
||||
resp = self._request("POST", "/images/generations", json_body=body, timeout=600.0)
|
||||
payload = self._json(resp)
|
||||
results = self.collect_results(payload)
|
||||
out: list[ImageResult] = []
|
||||
for item in results:
|
||||
out.append(self.download(item.url) if item.url else item)
|
||||
return out
|
||||
@@ -0,0 +1,192 @@
|
||||
"""供应商适配器基类与能力声明。"""
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""OpenAI 兼容网关适配器。
|
||||
|
||||
模型发现: GET {base_url}/models
|
||||
出图: POST {base_url}/images/generations
|
||||
返回: data[0].b64_json 或 data[0].url
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import Capabilities, ImageResult, ProviderAdapter
|
||||
|
||||
|
||||
class OpenAICompatAdapter(ProviderAdapter):
|
||||
name = "openai_compat"
|
||||
capabilities = Capabilities(
|
||||
supports_image_input=False,
|
||||
size_hint="WxH",
|
||||
kind="image",
|
||||
)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
size: str,
|
||||
ratio: str,
|
||||
n: int,
|
||||
images: list[str],
|
||||
) -> list[ImageResult]:
|
||||
body: dict = {"model": model, "prompt": prompt, "n": n}
|
||||
if size:
|
||||
if size in ("1K", "2K", "3K", "4K"):
|
||||
side = int(size[0]) * 1024
|
||||
size = f"{side}x{side}"
|
||||
body["size"] = size
|
||||
resp = self._request("POST", "/images/generations", json_body=body, timeout=600.0)
|
||||
payload = self._json(resp)
|
||||
results = self.collect_results(payload)
|
||||
# URL 形态需要二次下载
|
||||
out: list[ImageResult] = []
|
||||
for r in results:
|
||||
out.append(self.download(r.url) if r.url else r)
|
||||
return out
|
||||
Reference in New Issue
Block a user