feat: AI平台适配器(DeepSeek/Kimi/Open余额查询+Minimax骨架)+ capabilities按钮控制,测试17项通过

This commit is contained in:
gouki
2026-08-02 16:59:25 +00:00
parent f657bfb10b
commit 1e574315bd
5 changed files with 177 additions and 3 deletions
+110
View File
@@ -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 适配器余额接口待完善(请提供具体接口文档)"}
+13
View File
@@ -26,6 +26,7 @@ def register(*sdk_types: str):
def load_all() -> None: def load_all() -> None:
"""导入所有适配器模块以触发注册(幂等)""" """导入所有适配器模块以触发注册(幂等)"""
from app.adapters import ( # noqa: F401 from app.adapters import ( # noqa: F401
ai,
aliyun, aliyun,
cloudflare, cloudflare,
digitalocean, digitalocean,
@@ -53,6 +54,18 @@ def supported_types() -> list:
return sorted(_REGISTRY.keys()) 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: def adapter_meta() -> list:
"""返回所有已注册适配器的元信息(供前端展示所需凭证字段与能力)""" """返回所有已注册适配器的元信息(供前端展示所需凭证字段与能力)"""
load_all() load_all()
+1
View File
@@ -31,6 +31,7 @@ def adapters() -> dict:
return { return {
"adapters": registry.adapter_meta(), "adapters": registry.adapter_meta(),
"supported_types": registry.supported_types(), "supported_types": registry.supported_types(),
"capabilities": registry.sdk_capabilities(),
} }
+5 -3
View File
@@ -320,7 +320,7 @@ const ProvidersView = {
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap"> <div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button> <button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="isSupported(p.sdk_type)" @click="test(p)" :disabled="testing[p.id]" class="text-emerald-600 dark:text-emerald-400 disabled:opacity-50">{{ testing[p.id] ? '测试中…' : '测试连接' }}</button> <button v-if="isSupported(p.sdk_type)" @click="test(p)" :disabled="testing[p.id]" class="text-emerald-600 dark:text-emerald-400 disabled:opacity-50">{{ testing[p.id] ? '测试中…' : '测试连接' }}</button>
<button v-if="isSupported(p.sdk_type)" @click="sync(p)" :disabled="syncing[p.id]" class="text-violet-600 dark:text-violet-400 disabled:opacity-50">{{ syncing[p.id] ? '同步中…' : '同步资产' }}</button> <button v-if="canSync(p.sdk_type)" @click="sync(p)" :disabled="syncing[p.id]" class="text-violet-600 dark:text-violet-400 disabled:opacity-50">{{ syncing[p.id] ? '同步中…' : '同步资产' }}</button>
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button> <button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
</div> </div>
<div v-if="results[p.id]" class="mt-2 text-xs px-2 py-1.5 rounded-lg break-words" :class="results[p.id].ok ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'">{{ results[p.id].text }}</div> <div v-if="results[p.id]" class="mt-2 text-xs px-2 py-1.5 rounded-lg break-words" :class="results[p.id].ok ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'">{{ results[p.id].text }}</div>
@@ -332,10 +332,12 @@ const ProvidersView = {
const syncing = reactive({}); const syncing = reactive({});
const results = reactive({}); const results = reactive({});
const supported = ref([]); const supported = ref([]);
const capabilities = ref({});
onMounted(async () => { 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 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) { function fmtTime(iso) {
if (!iso) return ''; if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); 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 }; } } catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { syncing[p.id] = false; } 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 };
}, },
}; };
+48
View File
@@ -9,6 +9,7 @@ import base64
from unittest.mock import patch from unittest.mock import patch
from app.adapters import registry from app.adapters import registry
from app.adapters.ai import DeepSeekAdapter, KimiAdapter, OpenAIAdapter
from app.adapters.aliyun import AliyunAdapter from app.adapters.aliyun import AliyunAdapter
from app.adapters.cloudflare import CloudflareAdapter from app.adapters.cloudflare import CloudflareAdapter
from app.adapters.digitalocean import DigitalOceanAdapter 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("alibabacloud-sdk", {}), AliyunAdapter)
assert isinstance(registry.get_adapter("tencent-sdk", {}), TencentAdapter) assert isinstance(registry.get_adapter("tencent-sdk", {}), TencentAdapter)
assert isinstance(registry.get_adapter("tencent-intl-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} 应被支持"