feat: AI平台适配器(DeepSeek/Kimi/Open余额查询+Minimax骨架)+ capabilities按钮控制,测试17项通过
This commit is contained in:
@@ -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 适配器余额接口待完善(请提供具体接口文档)"}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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} 应被支持"
|
||||||
|
|||||||
Reference in New Issue
Block a user