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 @@
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Plan + QuotaRule + ModelRoute 管理 API"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app import database as db
|
||||
from app.models import (
|
||||
PlanCreate, PlanUpdate, PlanOut, PlanWithRules,
|
||||
QuotaRuleCreate, QuotaRuleUpdate, QuotaRuleOut,
|
||||
ModelRouteBase, ModelRouteOut,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Plan CRUD ─────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=list[PlanWithRules])
|
||||
async def list_plans(enabled_only: bool = False):
|
||||
plans = await db.list_plans(enabled_only=enabled_only)
|
||||
result = []
|
||||
for p in plans:
|
||||
rules = await db.list_quota_rules(p["id"])
|
||||
result.append({**p, "quota_rules": rules})
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=dict)
|
||||
async def create_plan(body: PlanCreate):
|
||||
return await db.create_plan(
|
||||
name=body.name,
|
||||
provider_name=body.provider_name,
|
||||
api_key=body.api_key,
|
||||
api_base=body.api_base,
|
||||
plan_type=body.plan_type,
|
||||
supported_models=body.supported_models,
|
||||
extra_headers=body.extra_headers,
|
||||
extra_config=body.extra_config,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=PlanWithRules)
|
||||
async def get_plan(plan_id: str):
|
||||
p = await db.get_plan(plan_id)
|
||||
if not p:
|
||||
raise HTTPException(404, "Plan not found")
|
||||
rules = await db.list_quota_rules(plan_id)
|
||||
return {**p, "quota_rules": rules}
|
||||
|
||||
|
||||
@router.patch("/{plan_id}")
|
||||
async def update_plan(plan_id: str, body: PlanUpdate):
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
if not fields:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
ok = await db.update_plan(plan_id, **fields)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Plan not found or no change")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{plan_id}")
|
||||
async def delete_plan(plan_id: str):
|
||||
ok = await db.delete_plan(plan_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Plan not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── QuotaRule CRUD ────────────────────────────────────
|
||||
|
||||
@router.get("/{plan_id}/rules", response_model=list[QuotaRuleOut])
|
||||
async def list_rules(plan_id: str):
|
||||
return await db.list_quota_rules(plan_id)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/rules", response_model=dict)
|
||||
async def create_rule(plan_id: str, body: QuotaRuleCreate):
|
||||
p = await db.get_plan(plan_id)
|
||||
if not p:
|
||||
raise HTTPException(404, "Plan not found")
|
||||
return await db.create_quota_rule(
|
||||
plan_id=plan_id,
|
||||
rule_name=body.rule_name,
|
||||
quota_total=body.quota_total,
|
||||
quota_unit=body.quota_unit,
|
||||
refresh_type=body.refresh_type.value,
|
||||
interval_hours=body.interval_hours,
|
||||
calendar_unit=body.calendar_unit,
|
||||
calendar_anchor=body.calendar_anchor,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/rules/{rule_id}")
|
||||
async def update_rule(rule_id: str, body: QuotaRuleUpdate):
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
if "refresh_type" in fields and fields["refresh_type"] is not None:
|
||||
fields["refresh_type"] = fields["refresh_type"].value
|
||||
ok = await db.update_quota_rule(rule_id, **fields)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Rule not found or no change")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_rule(rule_id: str):
|
||||
d = await db.get_db()
|
||||
cur = await d.execute("DELETE FROM quota_rules WHERE id=?", (rule_id,))
|
||||
await d.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Rule not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Model Routes ──────────────────────────────────────
|
||||
|
||||
@router.get("/routes/models", response_model=list[ModelRouteOut])
|
||||
async def get_model_routes():
|
||||
return await db.list_model_routes()
|
||||
|
||||
|
||||
@router.post("/routes/models", response_model=dict)
|
||||
async def set_model_route(body: ModelRouteBase):
|
||||
return await db.set_model_route(body.model_name, body.plan_id, body.priority)
|
||||
|
||||
|
||||
@router.delete("/routes/models/{route_id}")
|
||||
async def delete_model_route(route_id: str):
|
||||
ok = await db.delete_model_route(route_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Route not found")
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,202 @@
|
||||
"""API 代理路由 -- OpenAI / Anthropic 兼容端点"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import database as db
|
||||
from app.config import settings
|
||||
from app.providers import ProviderRegistry
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _verify_key(authorization: str | None):
|
||||
expected = settings.server.proxy_api_key
|
||||
if not expected or expected == "sk-plan-manage-change-me":
|
||||
return # 未配置则跳过鉴权
|
||||
if not authorization:
|
||||
raise HTTPException(401, "Missing Authorization header")
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
if token != expected:
|
||||
raise HTTPException(403, "Invalid API key")
|
||||
|
||||
|
||||
async def _resolve_plan(model: str, plan_id_header: str | None) -> tuple[dict, str]:
|
||||
"""解析目标 Plan: 优先 X-Plan-Id header, 否则按 model 路由表查找"""
|
||||
if plan_id_header:
|
||||
plan = await db.get_plan(plan_id_header)
|
||||
if not plan:
|
||||
raise HTTPException(404, f"Plan {plan_id_header} not found")
|
||||
return plan, model
|
||||
|
||||
resolved_plan_id = await db.resolve_model(model)
|
||||
if not resolved_plan_id:
|
||||
raise HTTPException(404, f"No plan found for model '{model}'")
|
||||
|
||||
plan = await db.get_plan(resolved_plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(500, "Resolved plan missing from DB")
|
||||
return plan, model
|
||||
|
||||
|
||||
async def _stream_and_count(provider, messages, model, plan, stream, **kwargs):
|
||||
"""流式转发并统计 token 消耗"""
|
||||
total_tokens = 0
|
||||
async for chunk_data in provider.chat(messages, model, plan, stream=stream, **kwargs):
|
||||
yield chunk_data
|
||||
# 尝试从 chunk 中提取 usage
|
||||
if not stream and chunk_data:
|
||||
try:
|
||||
resp_obj = json.loads(chunk_data)
|
||||
usage = resp_obj.get("usage", {})
|
||||
total_tokens = usage.get("total_tokens", 0)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# 流式模式下无法精确统计 token,按请求次数 +1 计费
|
||||
await db.increment_quota_used(plan["id"], token_count=total_tokens)
|
||||
|
||||
|
||||
# ── OpenAI 兼容: /v1/chat/completions ─────────────────
|
||||
|
||||
@router.post("/v1/chat/completions")
|
||||
async def openai_chat_completions(
|
||||
request: Request,
|
||||
authorization: str | None = Header(None),
|
||||
x_plan_id: str | None = Header(None, alias="X-Plan-Id"),
|
||||
):
|
||||
_verify_key(authorization)
|
||||
body = await request.json()
|
||||
|
||||
model = body.get("model", "")
|
||||
messages = body.get("messages", [])
|
||||
stream = body.get("stream", False)
|
||||
|
||||
if not model or not messages:
|
||||
raise HTTPException(400, "model and messages are required")
|
||||
|
||||
plan, model = await _resolve_plan(model, x_plan_id)
|
||||
|
||||
# 检查额度
|
||||
if not await db.check_plan_available(plan["id"]):
|
||||
raise HTTPException(429, f"Plan '{plan['name']}' quota exhausted")
|
||||
|
||||
provider = ProviderRegistry.get(plan["provider_name"])
|
||||
if not provider:
|
||||
raise HTTPException(500, f"Provider '{plan['provider_name']}' not registered")
|
||||
|
||||
extra_kwargs = {k: v for k, v in body.items()
|
||||
if k not in ("model", "messages", "stream")}
|
||||
|
||||
if stream:
|
||||
return StreamingResponse(
|
||||
_stream_and_count(provider, messages, model, plan, True, **extra_kwargs),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Plan-Id": plan["id"]},
|
||||
)
|
||||
else:
|
||||
chunks = []
|
||||
async for c in _stream_and_count(provider, messages, model, plan, False, **extra_kwargs):
|
||||
chunks.append(c)
|
||||
result = json.loads(chunks[0]) if chunks else {}
|
||||
return result
|
||||
|
||||
|
||||
# ── Anthropic 兼容: /v1/messages ──────────────────────
|
||||
|
||||
@router.post("/v1/messages")
|
||||
async def anthropic_messages(
|
||||
request: Request,
|
||||
authorization: str | None = Header(None),
|
||||
x_plan_id: str | None = Header(None, alias="X-Plan-Id"),
|
||||
x_api_key: str | None = Header(None, alias="x-api-key"),
|
||||
):
|
||||
auth = authorization or (f"Bearer {x_api_key}" if x_api_key else None)
|
||||
_verify_key(auth)
|
||||
body = await request.json()
|
||||
|
||||
model = body.get("model", "")
|
||||
messages = body.get("messages", [])
|
||||
stream = body.get("stream", False)
|
||||
system_msg = body.get("system", "")
|
||||
|
||||
if not model or not messages:
|
||||
raise HTTPException(400, "model and messages are required")
|
||||
|
||||
# Anthropic 格式 -> OpenAI 格式 messages
|
||||
oai_messages = []
|
||||
if system_msg:
|
||||
oai_messages.append({"role": "system", "content": system_msg})
|
||||
for m in messages:
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
# Anthropic 多模态 content blocks -> 取文本
|
||||
text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
|
||||
content = "\n".join(text_parts)
|
||||
oai_messages.append({"role": m.get("role", "user"), "content": content})
|
||||
|
||||
plan, model = await _resolve_plan(model, x_plan_id)
|
||||
|
||||
if not await db.check_plan_available(plan["id"]):
|
||||
raise HTTPException(429, f"Plan '{plan['name']}' quota exhausted")
|
||||
|
||||
provider = ProviderRegistry.get(plan["provider_name"])
|
||||
if not provider:
|
||||
raise HTTPException(500, f"Provider '{plan['provider_name']}' not registered")
|
||||
|
||||
if stream:
|
||||
async def anthropic_stream():
|
||||
"""将 OpenAI SSE 格式转换为 Anthropic SSE 格式"""
|
||||
yield f"event: message_start\ndata: {json.dumps({'type': 'message_start', 'message': {'id': 'msg_proxy', 'type': 'message', 'role': 'assistant', 'model': model, 'content': []}})}\n\n"
|
||||
yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': 0, 'content_block': {'type': 'text', 'text': ''}})}\n\n"
|
||||
|
||||
async for chunk_data in _stream_and_count(provider, oai_messages, model, plan, True):
|
||||
if chunk_data.startswith("data: [DONE]"):
|
||||
break
|
||||
if chunk_data.startswith("data: "):
|
||||
try:
|
||||
oai_chunk = json.loads(chunk_data[6:].strip())
|
||||
delta = oai_chunk.get("choices", [{}])[0].get("delta", {})
|
||||
text = delta.get("content", "")
|
||||
if text:
|
||||
yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': text}})}\n\n"
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
pass
|
||||
|
||||
yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': 0})}\n\n"
|
||||
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
anthropic_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
else:
|
||||
chunks = []
|
||||
async for c in _stream_and_count(provider, oai_messages, model, plan, False):
|
||||
chunks.append(c)
|
||||
oai_resp = json.loads(chunks[0]) if chunks else {}
|
||||
# OpenAI 响应 -> Anthropic 响应
|
||||
content_text = ""
|
||||
choices = oai_resp.get("choices", [])
|
||||
if choices:
|
||||
content_text = choices[0].get("message", {}).get("content", "")
|
||||
usage = oai_resp.get("usage", {})
|
||||
return {
|
||||
"id": "msg_proxy",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": [{"type": "text", "text": content_text}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {
|
||||
"input_tokens": usage.get("prompt_tokens", 0),
|
||||
"output_tokens": usage.get("completion_tokens", 0),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""任务队列 API"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app import database as db
|
||||
from app.models import TaskCreate, TaskOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
async def list_tasks(status: str | None = None, limit: int = 50):
|
||||
return await db.list_tasks(status=status, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=dict)
|
||||
async def create_task(body: TaskCreate):
|
||||
return await db.create_task(
|
||||
task_type=body.task_type,
|
||||
request_payload=body.request_payload,
|
||||
plan_id=body.plan_id,
|
||||
priority=body.priority,
|
||||
max_retries=body.max_retries,
|
||||
callback_url=body.callback_url,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskOut)
|
||||
async def get_task(task_id: str):
|
||||
d = await db.get_db()
|
||||
cur = await d.execute("SELECT * FROM tasks WHERE id=?", (task_id,))
|
||||
row = await cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Task not found")
|
||||
t = db.row_to_dict(row)
|
||||
t["request_payload"] = db._parse_json(t["request_payload"], {})
|
||||
t["response_payload"] = db._parse_json(t.get("response_payload"))
|
||||
return t
|
||||
|
||||
|
||||
@router.post("/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str):
|
||||
ok = await db.update_task(task_id, status="cancelled")
|
||||
if not ok:
|
||||
raise HTTPException(404, "Task not found")
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""额度查询 API"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import database as db
|
||||
from app.models import DashboardPlan
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=list[DashboardPlan])
|
||||
async def dashboard_overview():
|
||||
"""仪表盘总览: 所有 Plan 及其 QuotaRule 状态"""
|
||||
plans = await db.list_plans()
|
||||
result = []
|
||||
for p in plans:
|
||||
rules = await db.list_quota_rules(p["id"])
|
||||
all_ok = all(
|
||||
r["quota_used"] < r["quota_total"]
|
||||
for r in rules if r["enabled"]
|
||||
)
|
||||
result.append({
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"provider_name": p["provider_name"],
|
||||
"plan_type": p["plan_type"],
|
||||
"enabled": p["enabled"],
|
||||
"quota_rules": rules,
|
||||
"all_available": all_ok and p["enabled"],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/plan/{plan_id}/available")
|
||||
async def check_available(plan_id: str):
|
||||
"""检查 Plan 当前是否可用"""
|
||||
available = await db.check_plan_available(plan_id)
|
||||
return {"plan_id": plan_id, "available": available}
|
||||
|
||||
|
||||
@router.post("/plan/{plan_id}/refresh")
|
||||
async def manual_refresh(plan_id: str, rule_id: str | None = None):
|
||||
"""手动重置额度"""
|
||||
rules = await db.list_quota_rules(plan_id)
|
||||
count = 0
|
||||
for r in rules:
|
||||
if rule_id and r["id"] != rule_id:
|
||||
continue
|
||||
await db.update_quota_rule(r["id"], quota_used=0)
|
||||
count += 1
|
||||
return {"reset_count": count}
|
||||
Reference in New Issue
Block a user