feat: add configurable AI model pool
This commit is contained in:
@@ -1,43 +1,122 @@
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def _configured(value: str) -> bool:
|
||||
return bool(value) and not value.startswith("replace_with_")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderCapability:
|
||||
provider: str
|
||||
configured: bool
|
||||
strengths: tuple[str, ...]
|
||||
model_id: str = ""
|
||||
name: str = ""
|
||||
|
||||
|
||||
class ModelRouter:
|
||||
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings | Mapping[str, Any],
|
||||
tests: Mapping[str, Mapping[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.tests = tests
|
||||
|
||||
def _value(self, key: str, default: str = "") -> str:
|
||||
def _value(self, key: str, default: Any = "") -> Any:
|
||||
if isinstance(self.settings, Mapping):
|
||||
return str(self.settings.get(key, default) or "")
|
||||
return str(getattr(self.settings, key, default) or "")
|
||||
return self.settings.get(key, default)
|
||||
return getattr(self.settings, key, default)
|
||||
|
||||
def capabilities(self) -> list[ProviderCapability]:
|
||||
@property
|
||||
def pool(self) -> list[dict[str, Any]]:
|
||||
raw_pool = self._value("model_pool", [])
|
||||
return [item for item in raw_pool if isinstance(item, dict) and item.get("enabled", True)]
|
||||
|
||||
def _tested(self, item: Mapping[str, Any]) -> bool:
|
||||
if self.tests is None:
|
||||
return True
|
||||
return bool(self.tests.get(f"model:{item.get('id')}", {}).get("ok"))
|
||||
|
||||
def candidates(self, capability: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
ProviderCapability(
|
||||
provider=self._value("ai_provider", "custom"),
|
||||
configured=_configured(self._value("ai_api_key"))
|
||||
and bool(self._value("ai_base_url"))
|
||||
and bool(self._value("image_model")),
|
||||
strengths=("统一模型网关", "空间理解", "图像生成与编辑"),
|
||||
),
|
||||
item
|
||||
for item in self.pool
|
||||
if capability in item.get("capabilities", []) and self._tested(item)
|
||||
]
|
||||
|
||||
def orchestrator(self) -> dict[str, Any]:
|
||||
selected_id = str(self._value("orchestrator_model_id", ""))
|
||||
selected = next(
|
||||
(
|
||||
item
|
||||
for item in self.pool
|
||||
if item.get("id") == selected_id
|
||||
and item.get("category") == "language"
|
||||
and self._tested(item)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected:
|
||||
return selected
|
||||
raise RuntimeError("总调度模型未配置或尚未通过测试。")
|
||||
|
||||
def choose(self, route: str, orchestrator_choice_id: str | None = None) -> dict[str, Any]:
|
||||
if route == "spatial":
|
||||
mode_key, selected_key, capability = (
|
||||
"spatial_routing_mode",
|
||||
"spatial_model_id",
|
||||
"spatial_understanding",
|
||||
)
|
||||
elif route == "image":
|
||||
mode_key, selected_key, capability = (
|
||||
"image_routing_mode",
|
||||
"image_model_id",
|
||||
"image_generation",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"未知模型路由:{route}")
|
||||
|
||||
candidates = self.candidates(capability)
|
||||
if self._value(mode_key, "auto") == "manual":
|
||||
selected_id = str(self._value(selected_key, ""))
|
||||
selected = next((item for item in candidates if item.get("id") == selected_id), None)
|
||||
if selected:
|
||||
return selected
|
||||
raise RuntimeError(f"手动选择的{route}模型不可用或尚未通过测试。")
|
||||
if orchestrator_choice_id:
|
||||
selected = next((item for item in candidates if item.get("id") == orchestrator_choice_id), None)
|
||||
if selected:
|
||||
return selected
|
||||
raise RuntimeError("总调度返回的模型不在已测试候选列表中。")
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if len(candidates) > 1:
|
||||
raise RuntimeError("自动路由存在多个候选,需要总调度返回模型实例 ID。")
|
||||
raise RuntimeError(f"没有通过测试且支持{capability}的模型。")
|
||||
|
||||
def capabilities(self) -> list[ProviderCapability]:
|
||||
result: list[ProviderCapability] = []
|
||||
for item in self.pool:
|
||||
strengths: list[str] = []
|
||||
if "orchestration" in item.get("capabilities", []):
|
||||
strengths.append("总调度")
|
||||
if "spatial_understanding" in item.get("capabilities", []):
|
||||
strengths.append("空间理解")
|
||||
if "image_generation" in item.get("capabilities", []):
|
||||
strengths.append("图像生成")
|
||||
if "image_editing" in item.get("capabilities", []):
|
||||
strengths.append("图像编辑")
|
||||
result.append(
|
||||
ProviderCapability(
|
||||
provider=str(item.get("provider", "custom")),
|
||||
configured=self._tested(item),
|
||||
strengths=tuple(strengths),
|
||||
model_id=str(item.get("model_id", "")),
|
||||
name=str(item.get("name", "")),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def choose_image_provider(self, task: str) -> str:
|
||||
configured = [item.provider for item in self.capabilities() if item.configured]
|
||||
if configured:
|
||||
return configured[0]
|
||||
raise RuntimeError("No image provider is configured.")
|
||||
return str(self.choose("image").get("provider", "custom"))
|
||||
|
||||
@@ -4,48 +4,70 @@ from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
from app.integrations.model_router import ModelRouter
|
||||
|
||||
|
||||
class OpenAICompatibleGateway:
|
||||
"""One adapter for OpenAI, lk666.ai and other OpenAI-compatible gateways."""
|
||||
"""Adapter for independently configured OpenAI-compatible model-pool items."""
|
||||
|
||||
def __init__(self, values: Mapping[str, Any]) -> None:
|
||||
self.values = values
|
||||
self.router = ModelRouter(values)
|
||||
|
||||
def _url(self, path_key: str, fallback: str) -> str:
|
||||
base_url = str(self.values.get("ai_base_url", "")).rstrip("/") + "/"
|
||||
path = str(self.values.get(path_key, fallback)).lstrip("/")
|
||||
@staticmethod
|
||||
def _url(item: Mapping[str, Any], path_key: str, fallback: str) -> str:
|
||||
base_url = str(item.get("base_url", "")).rstrip("/") + "/"
|
||||
path = str(item.get(path_key, fallback)).lstrip("/")
|
||||
return urljoin(base_url, path)
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.values.get('ai_api_key', '')}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
@staticmethod
|
||||
def _headers(item: Mapping[str, Any], *, json_content: bool = True) -> dict[str, str]:
|
||||
headers = {"Authorization": f"Bearer {item.get('api_key', '')}"}
|
||||
if json_content:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
async def list_models(self, model: Mapping[str, Any] | None = None) -> list[str]:
|
||||
item = model or self.router.orchestrator()
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.get(self._url("ai_models_path", "/models"), headers=self.headers)
|
||||
response = await client.get(
|
||||
self._url(item, "models_path", "/models"),
|
||||
headers=self._headers(item),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return [item["id"] for item in payload.get("data", []) if isinstance(item, dict) and item.get("id")]
|
||||
return [candidate["id"] for candidate in payload.get("data", []) if isinstance(candidate, dict) and candidate.get("id")]
|
||||
|
||||
async def chat(self, messages: list[dict[str, Any]], *, vision: bool = False) -> dict[str, Any]:
|
||||
model_key = "vision_model" if vision else "orchestrator_model"
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
vision: bool = False,
|
||||
model_instance_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
item = self.router.choose("spatial", model_instance_id) if vision else self.router.orchestrator()
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
self._url("ai_chat_path", "/chat/completions"),
|
||||
headers=self.headers,
|
||||
json={"model": self.values[model_key], "messages": messages},
|
||||
self._url(item, "chat_path", "/chat/completions"),
|
||||
headers=self._headers(item),
|
||||
json={"model": item["model_id"], "messages": messages},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
async def generate_image(self, prompt: str, **options: Any) -> dict[str, Any]:
|
||||
payload = {"model": self.values["image_model"], "prompt": prompt, **options}
|
||||
|
||||
async def generate_image(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model_instance_id: str | None = None,
|
||||
**options: Any,
|
||||
) -> dict[str, Any]:
|
||||
item = self.router.choose("image", model_instance_id)
|
||||
payload = {"model": item["model_id"], "prompt": prompt, **options}
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
response = await client.post(
|
||||
self._url("ai_image_generation_path", "/images/generations"),
|
||||
headers=self.headers,
|
||||
self._url(item, "image_generation_path", "/images/generations"),
|
||||
headers=self._headers(item),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -58,19 +80,18 @@ class OpenAICompatibleGateway:
|
||||
*,
|
||||
filename: str = "image.png",
|
||||
mask: bytes | None = None,
|
||||
model_instance_id: str | None = None,
|
||||
**options: Any,
|
||||
) -> dict[str, Any]:
|
||||
files: dict[str, tuple[str, bytes, str]] = {
|
||||
"image": (filename, image, "image/png"),
|
||||
}
|
||||
item = self.router.choose("image", model_instance_id)
|
||||
files: dict[str, tuple[str, bytes, str]] = {"image": (filename, image, "image/png")}
|
||||
if mask is not None:
|
||||
files["mask"] = ("mask.png", mask, "image/png")
|
||||
data = {"model": self.values["image_model"], "prompt": prompt, **options}
|
||||
headers = {"Authorization": self.headers["Authorization"]}
|
||||
data = {"model": item["model_id"], "prompt": prompt, **options}
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
response = await client.post(
|
||||
self._url("ai_image_edit_path", "/images/edits"),
|
||||
headers=headers,
|
||||
self._url(item, "image_edit_path", "/images/edits"),
|
||||
headers=self._headers(item, json_content=False),
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user