feat: complete image console and add verified REST and MCP workflows

This commit is contained in:
2026-11-04 00:41:54 +08:00
parent 3140cd51d2
commit bef8af53ed
48 changed files with 5167 additions and 1176 deletions
+38
View File
@@ -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",
]
+100
View File
@@ -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
+192
View File
@@ -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"
+43
View File
@@ -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