from __future__ import annotations from datetime import datetime, timezone from typing import Any from urllib.parse import urlparse from backend.features.screener.compiler import LLMCompilerError, test_llm_connection class LLMServiceMixin: def _personal_llm_profile(self) -> dict[str, Any]: credentials = self._credentials() return { "source": "personal", "primary": { "api_key": credentials["llm_primary_api_key"], "base_url": credentials["llm_primary_base_url"], "model": credentials["llm_primary_model"], }, "fallback": { "api_key": credentials["llm_fallback_api_key"], "base_url": credentials["llm_fallback_base_url"], "model": credentials["llm_fallback_model"], }, } def _platform_llm_profile(self) -> dict[str, Any]: models = { str(item.get("id") or ""): item for item in self._system_credentials.get("llm_models") or [] if isinstance(item, dict) and item.get("id") } def selected(role: str) -> dict[str, str]: item = models.get(str(self._system_credentials.get(f"{role}_model_id") or ""), {}) return { "id": str(item.get("id") or ""), "name": str(item.get("name") or ""), "api_key": str(item.get("api_key") or ""), "base_url": str(item.get("base_url") or ""), "model": str(item.get("model") or ""), } return { "source": "platform", "primary": selected("primary"), "fallback": selected("fallback"), } @staticmethod def _profile_configured(profile: dict[str, str]) -> bool: return bool(profile.get("api_key") and profile.get("base_url") and profile.get("model")) def _resolved_llm_profile(self) -> dict[str, Any]: platform = self._platform_llm_profile() platform_ready = self.membership()["active"] and self._profile_configured(platform["primary"]) if platform_ready: return platform return {"source": "none", "primary": {}, "fallback": {}} @property def llm_primary_api_key(self) -> str: return str(self._resolved_llm_profile()["primary"].get("api_key") or "") @property def llm_primary_base_url(self) -> str: return str(self._resolved_llm_profile()["primary"].get("base_url") or "") @property def llm_primary_model(self) -> str: return str(self._resolved_llm_profile()["primary"].get("model") or "") @property def llm_fallback_api_key(self) -> str: return str(self._resolved_llm_profile()["fallback"].get("api_key") or "") @property def llm_fallback_base_url(self) -> str: return str(self._resolved_llm_profile()["fallback"].get("base_url") or "") @property def llm_fallback_model(self) -> str: return str(self._resolved_llm_profile()["fallback"].get("model") or "") @property def llm_source(self) -> str: return str(self._resolved_llm_profile().get("source") or "none") @property def llm_configured(self) -> bool: return bool(self.llm_primary_api_key and self.llm_primary_model) @property def llm_fallback_configured(self) -> bool: return bool( self.llm_fallback_api_key and self.llm_fallback_base_url and self.llm_fallback_model ) def save_llm_settings( self, primary: dict[str, Any], fallback: dict[str, Any], fallback_enabled: bool, ) -> None: personal = self._personal_llm_profile() primary_profile = self._validate_llm_profile( primary, personal["primary"], required=True, label="主模型", ) if fallback_enabled: fallback_profile = self._validate_llm_profile( fallback, personal["fallback"], required=True, label="辅助模型", ) else: fallback_profile = {"api_key": "", "base_url": "", "model": ""} credentials = self._credentials() credentials.update( { "llm_primary_api_key": primary_profile["api_key"], "llm_primary_base_url": primary_profile["base_url"], "llm_primary_model": primary_profile["model"], "llm_fallback_api_key": fallback_profile["api_key"], "llm_fallback_base_url": fallback_profile["base_url"], "llm_fallback_model": fallback_profile["model"], } ) self._save_credentials(credentials) def save_llm_mode(self, mode: str) -> None: raise ValueError("LLM 算力由管理员统一配置,会员账号自动使用平台模型。") def test_llm_profile(self, role: str, payload: dict[str, Any]) -> dict[str, Any]: personal = self._personal_llm_profile() if role == "primary": current = personal["primary"] label = "主模型" elif role == "fallback": current = personal["fallback"] label = "辅助模型" else: raise ValueError("模型角色不支持。") profile = self._validate_llm_profile(payload, current, required=True, label=label) try: return self.llm_gateway.probe( profile, lambda model: test_llm_connection( model.api_key, model.base_url, model.model ), ) except LLMCompilerError as exc: raise ValueError(str(exc)) from exc @staticmethod def _validate_llm_profile( payload: dict[str, Any], current: dict[str, str], required: bool, label: str, ) -> dict[str, str]: api_key = str(payload.get("api_key") or current.get("api_key") or "").strip() base_url = str(payload.get("base_url") or current.get("base_url") or "").strip().rstrip("/") model = str(payload.get("model") or current.get("model") or "").strip() if not required and not any((api_key, base_url, model)): return {"api_key": "", "base_url": "", "model": ""} parsed = urlparse(base_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError(f"{label} Base URL 格式不正确。") if not api_key or len(api_key) > 300: raise ValueError(f"{label} API Key 不能为空或过长。") if not model or len(model) > 100: raise ValueError(f"{label}模型名称不能为空或过长。") return {"api_key": api_key, "base_url": base_url, "model": model} def llm_access_status(self) -> dict[str, Any]: platform = self._platform_llm_profile() membership = self.membership() limit = max(1, int(self._system_credentials.get("member_daily_limit") or 50)) used = self._platform_usage_today() if membership["active"] else 0 resolved = self._resolved_llm_profile() return { "mode": "platform" if membership["active"] else "locked", "resolved_source": resolved.get("source") or "none", "resolved_model": str(resolved.get("primary", {}).get("model") or ""), "platform_configured": self._profile_configured(platform["primary"]), "membership": membership, "daily_limit": limit, "used_today": used, "remaining_calls": None if membership["is_admin"] else max(0, limit - used), } def _platform_usage_today(self) -> int: return self._platform_usage_today_for_user(self.current_user_id) def _platform_usage_today_for_user(self, user_id: int) -> int: now = datetime.now().astimezone() start = now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc) return self.database.count_llm_usage_since( user_id, "platform", start.isoformat(timespec="seconds"), ) def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]: current = next( ( item for item in self._system_credentials.get("llm_models") or [] if str(item.get("id") or "") == model_id ), {}, ) label = validate_text(payload.get("name") or current.get("name"), "模型名称", 50, required=True) profile = self._validate_llm_profile( payload, current, required=True, label=label ) try: return self.llm_gateway.probe( profile, lambda model: test_llm_connection( model.api_key, model.base_url, model.model ), ) except LLMCompilerError as exc: raise ValueError(str(exc)) from exc