From 1e574315bdafd08ff4bd03afada08c14fe87b2a4 Mon Sep 17 00:00:00 2001 From: gouki Date: Sun, 2 Aug 2026 16:59:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20AI=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=EF=BC=88DeepSeek/Kimi/Open=E4=BD=99=E9=A2=9D=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2+Minimax=E9=AA=A8=E6=9E=B6=EF=BC=89+=20capabilities?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E6=8E=A7=E5=88=B6=EF=BC=8C=E6=B5=8B=E8=AF=95?= =?UTF-8?q?17=E9=A1=B9=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/adapters/ai.py | 110 +++++++++++++++++++++++++++++++++++++++ app/adapters/registry.py | 13 +++++ app/routers/providers.py | 1 + static/js/app.js | 8 +-- tests/test_adapters.py | 48 +++++++++++++++++ 5 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 app/adapters/ai.py diff --git a/app/adapters/ai.py b/app/adapters/ai.py new file mode 100644 index 0000000..19e4fba --- /dev/null +++ b/app/adapters/ai.py @@ -0,0 +1,110 @@ +"""AI 平台适配器(验证 Key + 余额/用量查询) + +各 AI 平台 API 差异较大,此处实现 test_connection(验证 Key,附带余额) +与 get_account(余额)。余额接口可能随平台调整,填入真实凭证后以实际返回为准。 +所需配置统一为 {"api_key": "..."}(Minimax 另需 group_id)。 +""" + +import httpx + +from app.adapters.base import AccountInfo, BaseAdapter +from app.adapters.registry import register + + +class _AIBase(BaseAdapter): + """AI 适配器公共逻辑(Bearer Key + JSON GET)""" + + required_config = ["api_key"] + + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self.config.get('api_key', '')}"} + + def _get(self, url: str) -> dict: + with httpx.Client(timeout=30) as client: + resp = client.get(url, headers=self._headers()) + resp.raise_for_status() + return resp.json() + + def _http_error(self, e: httpx.HTTPStatusError) -> dict: + return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key 无效或权限不足"} + + +@register("deepseek-api") +class DeepSeekAdapter(_AIBase): + BASE = "https://api.deepseek.com" + + def _balance(self) -> AccountInfo: + data = self._get(self.BASE + "/user/balance").get("data", {}) + try: + total = float(data.get("total_balance")) if data.get("total_balance") is not None else None + except (TypeError, ValueError): + total = None + return AccountInfo(balance=total, currency="CNY", raw=data) + + def test_connection(self) -> dict: + try: + acc = self._balance() + return {"ok": True, "message": f"连接成功,余额 {acc.balance} CNY"} + except httpx.HTTPStatusError as e: + return self._http_error(e) + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + def get_account(self) -> AccountInfo: + return self._balance() + + +@register("moonshot-api") +class KimiAdapter(_AIBase): + BASE = "https://api.moonshot.cn/v1" + + def _balance(self) -> AccountInfo: + data = self._get(self.BASE + "/users/me/balance").get("data", {}) + return AccountInfo(balance=data.get("available_balance"), currency="CNY", raw=data) + + def test_connection(self) -> dict: + try: + acc = self._balance() + return {"ok": True, "message": f"连接成功,可用余额 {acc.balance} CNY"} + except httpx.HTTPStatusError as e: + return self._http_error(e) + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + def get_account(self) -> AccountInfo: + return self._balance() + + +@register("openai-api") +class OpenAIAdapter(_AIBase): + BASE = "https://api.openai.com/v1" + + def test_connection(self) -> dict: + try: + data = self._get(self.BASE + "/models") + count = len(data.get("data", [])) + return {"ok": True, "message": f"连接成功,可用模型 {count} 个"} + except httpx.HTTPStatusError as e: + return self._http_error(e) + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + def get_account(self) -> AccountInfo: + # OpenAI 余额接口需组织级权限,尝试旧版 billing,失败返回空 + try: + data = self._get("https://api.openai.com/dashboard/billing/credit_grants") + return AccountInfo(balance=data.get("total_available"), currency="USD", raw=data) + except Exception: # noqa: BLE001 + return AccountInfo(balance=None, currency="USD", raw={}) + + +@register("minimax-api") +class MinimaxAdapter(_AIBase): + # Minimax 需 group_id,余额接口因账号类型而异,此处为骨架待完善 + required_config = ["api_key", "group_id"] + BASE = "https://api.minimax.chat/v1" + + def test_connection(self) -> dict: + if not self.config.get("group_id"): + return {"ok": False, "message": "Minimax 需配置 group_id(适配器余额接口待完善)"} + return {"ok": False, "message": "Minimax 适配器余额接口待完善(请提供具体接口文档)"} diff --git a/app/adapters/registry.py b/app/adapters/registry.py index a3669b2..6750d60 100644 --- a/app/adapters/registry.py +++ b/app/adapters/registry.py @@ -26,6 +26,7 @@ def register(*sdk_types: str): def load_all() -> None: """导入所有适配器模块以触发注册(幂等)""" from app.adapters import ( # noqa: F401 + ai, aliyun, cloudflare, digitalocean, @@ -53,6 +54,18 @@ def supported_types() -> list: return sorted(_REGISTRY.keys()) +def sdk_capabilities() -> dict: + """返回 sdk_type -> capabilities 映射(供前端判断测试/同步按钮)""" + load_all() + result = {} + for sdk_type, cls in _REGISTRY.items(): + try: + result[sdk_type] = cls({}).capabilities() + except Exception: # noqa: BLE001 + result[sdk_type] = {} + return result + + def adapter_meta() -> list: """返回所有已注册适配器的元信息(供前端展示所需凭证字段与能力)""" load_all() diff --git a/app/routers/providers.py b/app/routers/providers.py index 185e10c..a989c85 100644 --- a/app/routers/providers.py +++ b/app/routers/providers.py @@ -31,6 +31,7 @@ def adapters() -> dict: return { "adapters": registry.adapter_meta(), "supported_types": registry.supported_types(), + "capabilities": registry.sdk_capabilities(), } diff --git a/static/js/app.js b/static/js/app.js index 6c4aa05..5e62fc9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -320,7 +320,7 @@ const ProvidersView = {
- +
{{ results[p.id].text }}
@@ -332,10 +332,12 @@ const ProvidersView = { const syncing = reactive({}); const results = reactive({}); const supported = ref([]); + const capabilities = ref({}); onMounted(async () => { - try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; } catch (e) { /* ignore */ } + try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ } }); function isSupported(t) { return !!t && supported.value.includes(t); } + function canSync(t) { const c = capabilities.value[t]; return !!(c && (c.list_vps || c.list_domains)); } function fmtTime(iso) { if (!iso) return ''; return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); @@ -363,7 +365,7 @@ const ProvidersView = { } catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; } finally { syncing[p.id] = false; } } - return { store, Fmt, testing, syncing, results, isSupported, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider }; + return { store, Fmt, testing, syncing, results, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider }; }, }; diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 55b6335..02846f9 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -9,6 +9,7 @@ import base64 from unittest.mock import patch from app.adapters import registry +from app.adapters.ai import DeepSeekAdapter, KimiAdapter, OpenAIAdapter from app.adapters.aliyun import AliyunAdapter from app.adapters.cloudflare import CloudflareAdapter from app.adapters.digitalocean import DigitalOceanAdapter @@ -172,3 +173,50 @@ def test_registry_get_adapter_routing(): assert isinstance(registry.get_adapter("alibabacloud-sdk", {}), AliyunAdapter) assert isinstance(registry.get_adapter("tencent-sdk", {}), TencentAdapter) assert isinstance(registry.get_adapter("tencent-intl-sdk", {}), TencentAdapter) + + +# --------------------------- AI 平台适配器 --------------------------- # +def test_deepseek_balance_normalization(): + adapter = DeepSeekAdapter({"api_key": "x"}) + mock = {"data": {"total_balance": "88.50", "granted_balance": "10.00"}} + with patch.object(DeepSeekAdapter, "_get", return_value=mock): + acc = adapter.get_account() + assert acc.balance == 88.5 # 字符串 -> float + assert acc.currency == "CNY" + + +def test_deepseek_test_connection_includes_balance(): + adapter = DeepSeekAdapter({"api_key": "x"}) + with patch.object(DeepSeekAdapter, "_get", return_value={"data": {"total_balance": "88.50"}}): + result = adapter.test_connection() + assert result["ok"] is True + assert "88.5" in result["message"] + + +def test_kimi_balance_normalization(): + adapter = KimiAdapter({"api_key": "x"}) + with patch.object(KimiAdapter, "_get", return_value={"data": {"available_balance": 123.45}}): + acc = adapter.get_account() + assert acc.balance == 123.45 + assert acc.currency == "CNY" + + +def test_openai_test_connection_models(): + adapter = OpenAIAdapter({"api_key": "x"}) + mock = {"data": [{"id": "gpt-4"}, {"id": "gpt-3.5-turbo"}]} + with patch.object(OpenAIAdapter, "_get", return_value=mock): + result = adapter.test_connection() + assert result["ok"] is True + assert "2" in result["message"] + + +def test_ai_capabilities_account_only(): + caps = DeepSeekAdapter({"api_key": "x"}).capabilities() + assert caps["get_account"] is True + assert caps["list_vps"] is False + assert caps["list_domains"] is False + + +def test_registry_supports_ai_types(): + for t in ["deepseek-api", "moonshot-api", "openai-api", "minimax-api"]: + assert registry.is_supported(t), f"{t} 应被支持"