feat: 多平台 Coding Plan 统一管理系统初始实现
- 支持 MiniMax/OpenAI/Google Gemini/智谱/Kimi 五个平台 - 插件化 Provider 架构,自动发现注册 - 多维度 QuotaRule 额度追踪(固定间隔/自然周期/API同步/手动) - OpenAI + Anthropic 兼容 API 代理,SSE 流式转发 - Model 路由表 + 额度耗尽自动 fallback - 多媒体任务队列(图片/语音/视频) - Vue3 + Tailwind 单文件 Web 仪表盘 - Docker 一键部署 Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"""Provider 自动发现 + 注册表"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.providers.base import BaseProvider, Capability
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""全局 Provider 注册表,启动时自动扫描 providers/ 目录"""
|
||||
|
||||
_providers: dict[str, "BaseProvider"] = {}
|
||||
|
||||
@classmethod
|
||||
def auto_discover(cls):
|
||||
"""扫描当前包下所有模块,注册 BaseProvider 子类实例"""
|
||||
from app.providers.base import BaseProvider as _Base
|
||||
|
||||
package_dir = Path(__file__).parent
|
||||
for info in pkgutil.iter_modules([str(package_dir)]):
|
||||
if info.name in ("base", "__init__"):
|
||||
continue
|
||||
mod = importlib.import_module(f"app.providers.{info.name}")
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, _Base)
|
||||
and attr is not _Base
|
||||
and getattr(attr, "name", "")
|
||||
):
|
||||
instance = attr()
|
||||
cls._providers[instance.name] = instance
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> "BaseProvider | None":
|
||||
return cls._providers.get(name)
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> dict[str, "BaseProvider"]:
|
||||
return dict(cls._providers)
|
||||
|
||||
@classmethod
|
||||
def by_capability(cls, cap: "Capability") -> list["BaseProvider"]:
|
||||
return [p for p in cls._providers.values() if cap in p.capabilities]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Provider 抽象基类 + 能力枚举"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Capability(str, Enum):
|
||||
CHAT = "chat"
|
||||
IMAGE = "image"
|
||||
VOICE = "voice"
|
||||
VIDEO = "video"
|
||||
FILE = "file"
|
||||
EMBEDDING = "embedding"
|
||||
|
||||
|
||||
class QuotaInfo(BaseModel):
|
||||
"""Provider 返回的额度信息"""
|
||||
quota_used: int = 0
|
||||
quota_remaining: int = 0
|
||||
quota_total: int = 0
|
||||
unit: str = "tokens"
|
||||
raw: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BaseProvider(ABC):
|
||||
"""
|
||||
所有平台适配器的基类。
|
||||
子类需要设置 name / display_name / capabilities,
|
||||
并实现对应能力的方法。
|
||||
"""
|
||||
name: str = ""
|
||||
display_name: str = ""
|
||||
capabilities: list[Capability] = []
|
||||
|
||||
@abstractmethod
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
plan: dict,
|
||||
stream: bool = True,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
聊天补全。返回 SSE 格式的 data 行。
|
||||
每 yield 一次代表一个 SSE event 的 data 字段内容。
|
||||
"""
|
||||
yield "" # pragma: no cover
|
||||
|
||||
async def generate_image(
|
||||
self, prompt: str, plan: dict, **kwargs
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError(f"{self.name} does not support image generation")
|
||||
|
||||
async def generate_voice(
|
||||
self, text: str, plan: dict, **kwargs
|
||||
) -> bytes:
|
||||
raise NotImplementedError(f"{self.name} does not support voice synthesis")
|
||||
|
||||
async def generate_video(
|
||||
self, prompt: str, plan: dict, **kwargs
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError(f"{self.name} does not support video generation")
|
||||
|
||||
async def query_quota(self, plan: dict) -> QuotaInfo | None:
|
||||
"""
|
||||
查询平台额度。返回 None 表示该平台不支持 API 查询,走本地追踪。
|
||||
"""
|
||||
return None
|
||||
|
||||
def _build_headers(self, plan: dict) -> dict[str, str]:
|
||||
"""构建请求头: Authorization + extra_headers"""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = plan.get("api_key", "")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
extra = plan.get("extra_headers") or {}
|
||||
headers.update(extra)
|
||||
return headers
|
||||
|
||||
def _base_url(self, plan: dict) -> str:
|
||||
return (plan.get("api_base") or "").rstrip("/")
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Google Gemini 适配器 -- 转换 OpenAI 格式到 Gemini API"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.providers.base import BaseProvider, Capability
|
||||
|
||||
|
||||
class GoogleProvider(BaseProvider):
|
||||
name = "google"
|
||||
display_name = "Google Gemini"
|
||||
capabilities = [Capability.CHAT, Capability.IMAGE]
|
||||
|
||||
def _gemini_url(self, plan: dict, model: str, method: str = "generateContent") -> str:
|
||||
base = self._base_url(plan)
|
||||
api_key = plan.get("api_key", "")
|
||||
return f"{base}/models/{model}:{method}?key={api_key}"
|
||||
|
||||
def _to_gemini_messages(self, messages: list[dict]) -> list[dict]:
|
||||
"""OpenAI 格式 messages -> Gemini contents"""
|
||||
contents = []
|
||||
for m in messages:
|
||||
role = "user" if m["role"] in ("user", "system") else "model"
|
||||
contents.append({
|
||||
"role": role,
|
||||
"parts": [{"text": m.get("content", "")}],
|
||||
})
|
||||
return contents
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
plan: dict,
|
||||
stream: bool = True,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
if stream:
|
||||
url = self._gemini_url(plan, model, "streamGenerateContent") + "&alt=sse"
|
||||
else:
|
||||
url = self._gemini_url(plan, model)
|
||||
|
||||
body = {"contents": self._to_gemini_messages(messages)}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
if stream:
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
gemini_data = json.loads(line[6:])
|
||||
oai_chunk = self._gemini_to_openai_chunk(gemini_data, model)
|
||||
yield f"data: {json.dumps(oai_chunk)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
else:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
gemini_resp = resp.json()
|
||||
oai_resp = self._gemini_to_openai_response(gemini_resp, model)
|
||||
yield json.dumps(oai_resp)
|
||||
|
||||
def _gemini_to_openai_chunk(self, data: dict, model: str) -> dict:
|
||||
"""Gemini SSE chunk -> OpenAI SSE chunk 格式"""
|
||||
text = ""
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
if parts:
|
||||
text = parts[0].get("text", "")
|
||||
return {
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": text},
|
||||
"finish_reason": None,
|
||||
}],
|
||||
}
|
||||
|
||||
def _gemini_to_openai_response(self, data: dict, model: str) -> dict:
|
||||
text = ""
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
if parts:
|
||||
text = parts[0].get("text", "")
|
||||
usage = data.get("usageMetadata", {})
|
||||
return {
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": usage.get("promptTokenCount", 0),
|
||||
"completion_tokens": usage.get("candidatesTokenCount", 0),
|
||||
"total_tokens": usage.get("totalTokenCount", 0),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Kimi / Moonshot 适配器 -- OpenAI 兼容格式"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.providers.base import BaseProvider, Capability
|
||||
|
||||
|
||||
class KimiProvider(BaseProvider):
|
||||
name = "kimi"
|
||||
display_name = "Kimi (Moonshot)"
|
||||
capabilities = [Capability.CHAT]
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
plan: dict,
|
||||
stream: bool = True,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Moonshot API 兼容 OpenAI 格式"""
|
||||
url = f"{self._base_url(plan)}/chat/completions"
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
**kwargs,
|
||||
}
|
||||
headers = self._build_headers(plan)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
if stream:
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
yield line + "\n\n"
|
||||
else:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
yield json.dumps(resp.json())
|
||||
@@ -0,0 +1,88 @@
|
||||
"""MiniMax 适配器 -- 支持 chat / image / voice / video"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.providers.base import BaseProvider, Capability, QuotaInfo
|
||||
|
||||
|
||||
class MiniMaxProvider(BaseProvider):
|
||||
name = "minimax"
|
||||
display_name = "MiniMax"
|
||||
capabilities = [Capability.CHAT, Capability.IMAGE, Capability.VOICE, Capability.VIDEO]
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
plan: dict,
|
||||
stream: bool = True,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
url = f"{self._base_url(plan)}/text/chatcompletion_v2"
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
**kwargs,
|
||||
}
|
||||
headers = self._build_headers(plan)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
if stream:
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
yield line + "\n\n"
|
||||
else:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
yield json.dumps(resp.json())
|
||||
|
||||
async def generate_image(self, prompt: str, plan: dict, **kwargs) -> dict[str, Any]:
|
||||
url = f"{self._base_url(plan)}/image/generation"
|
||||
body = {"model": kwargs.get("model", "image-01"), "prompt": prompt, **kwargs}
|
||||
headers = self._build_headers(plan)
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def generate_voice(self, text: str, plan: dict, **kwargs) -> bytes:
|
||||
url = f"{self._base_url(plan)}/tts/generation"
|
||||
body = {"text": text, "model": kwargs.get("model", "speech-02"), **kwargs}
|
||||
headers = self._build_headers(plan)
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
async def generate_video(self, prompt: str, plan: dict, **kwargs) -> dict[str, Any]:
|
||||
url = f"{self._base_url(plan)}/video/generation"
|
||||
body = {"model": kwargs.get("model", "video-01"), "prompt": prompt, **kwargs}
|
||||
headers = self._build_headers(plan)
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def query_quota(self, plan: dict) -> QuotaInfo | None:
|
||||
"""MiniMax 余额查询"""
|
||||
try:
|
||||
url = f"{self._base_url(plan)}/account/balance"
|
||||
headers = self._build_headers(plan)
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return QuotaInfo(
|
||||
quota_remaining=data.get("balance", 0),
|
||||
raw=data,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,56 @@
|
||||
"""OpenAI (GPT) 适配器"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.providers.base import BaseProvider, Capability, QuotaInfo
|
||||
|
||||
|
||||
class OpenAIProvider(BaseProvider):
|
||||
name = "openai"
|
||||
display_name = "OpenAI"
|
||||
capabilities = [Capability.CHAT, Capability.IMAGE, Capability.EMBEDDING]
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
plan: dict,
|
||||
stream: bool = True,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
url = f"{self._base_url(plan)}/chat/completions"
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
**kwargs,
|
||||
}
|
||||
headers = self._build_headers(plan)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
if stream:
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
yield line + "\n\n"
|
||||
elif line.strip() == "":
|
||||
continue
|
||||
else:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
yield json.dumps(resp.json())
|
||||
|
||||
async def generate_image(self, prompt: str, plan: dict, **kwargs) -> dict[str, Any]:
|
||||
url = f"{self._base_url(plan)}/images/generations"
|
||||
body = {"model": kwargs.get("model", "dall-e-3"), "prompt": prompt, "n": 1, **kwargs}
|
||||
headers = self._build_headers(plan)
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""智谱 GLM 适配器 -- OpenAI 兼容格式"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.providers.base import BaseProvider, Capability, QuotaInfo
|
||||
|
||||
|
||||
class ZhipuProvider(BaseProvider):
|
||||
name = "zhipu"
|
||||
display_name = "智谱 GLM"
|
||||
capabilities = [Capability.CHAT, Capability.IMAGE]
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
plan: dict,
|
||||
stream: bool = True,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
url = f"{self._base_url(plan)}/chat/completions"
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
**kwargs,
|
||||
}
|
||||
headers = self._build_headers(plan)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
if stream:
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
yield line + "\n\n"
|
||||
else:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
yield json.dumps(resp.json())
|
||||
|
||||
async def generate_image(self, prompt: str, plan: dict, **kwargs) -> dict[str, Any]:
|
||||
url = f"{self._base_url(plan)}/images/generations"
|
||||
body = {"model": kwargs.get("model", "cogview-3"), "prompt": prompt, **kwargs}
|
||||
headers = self._build_headers(plan)
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def query_quota(self, plan: dict) -> QuotaInfo | None:
|
||||
"""智谱余额查询"""
|
||||
try:
|
||||
url = f"{self._base_url(plan)}/../dashboard/billing/usage"
|
||||
headers = self._build_headers(plan)
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return QuotaInfo(raw=data)
|
||||
except Exception:
|
||||
return None
|
||||
Reference in New Issue
Block a user