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:
锦麟 王
2026-03-31 15:50:42 +08:00
commit 61ce809634
28 changed files with 2804 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
__pycache__/
*.pyc
*.pyo
.env
data/
*.db
*.db-journal
*.db-wal
.venv/
venv/
.idea/
.vscode/
.cursor/
*.egg-info/
dist/
build/

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY config.yaml .
RUN mkdir -p data/files
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
# Plan Manager - 多平台 Coding Plan 统一管理系统
统一管理 MiniMax、OpenAI、Google Gemini、智谱、Kimi 等多个 AI 平台的订阅计划提供额度查询、刷新倒计时、API 代理转发、多媒体任务队列和 Web 仪表盘。
## 功能
- **多 Plan 管理** -- 增删改查,支持任意数量的 AI 平台订阅
- **多维度额度追踪** -- 同一 Plan 可配置多条 QuotaRule如 Kimi 的"周额度 + 5 小时滚动窗口"
- **四种刷新策略** -- 固定间隔 / 自然周期 / API 同步 / 手动
- **API 代理** -- OpenAI (`/v1/chat/completions`) + Anthropic (`/v1/messages`) 兼容端点
- **智能路由** -- 按 model 名称自动路由到对应 Plan额度耗尽自动 fallback
- **任务队列** -- 支持图片、语音、视频等多媒体任务的异步提交和消费
- **插件化 Provider** -- 新增平台只需添加一个 Python 文件
- **Web 仪表盘** -- 额度进度条、刷新倒计时、队列管理、配置页
## 快速开始
### Docker 部署(推荐)
```bash
# 1. 编辑配置文件,填入各平台 API Key
vim config.yaml
# 2. 启动
docker compose up -d
# 3. 访问 http://localhost:8080
```
### 本地开发
```bash
# 安装依赖
pip install -r requirements.txt
# 启动
uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload
```
## 配置说明
编辑 `config.yaml`
```yaml
server:
host: "0.0.0.0"
port: 8080
proxy_api_key: "your-proxy-key" # 代理端点鉴权 Key留空则不鉴权
plans:
- name: "Kimi Coding Plan"
provider: kimi
api_key: "sk-xxx"
api_base: "https://api.moonshot.cn/v1"
quota_rules:
- rule_name: "周额度"
quota_total: 500
quota_unit: requests
refresh_type: calendar_cycle
calendar_unit: weekly
calendar_anchor: { weekday: 1, hour: 0 }
- rule_name: "5小时滚动窗口"
quota_total: 50
quota_unit: requests
refresh_type: fixed_interval
interval_hours: 5
```
### QuotaRule 刷新类型
| refresh_type | 说明 | 关键参数 |
|---|---|---|
| `fixed_interval` | 固定间隔刷新(如 5h、13h | `interval_hours` |
| `calendar_cycle` | 自然周期(日/周/月) | `calendar_unit` + `calendar_anchor` |
| `api_sync` | 调用平台 API 查询真实余额 | 无 |
| `manual` | 手动重置 | 无 |
## API 代理使用
配置好 Plan 和 Model 路由后,可将本系统作为统一网关:
```bash
# OpenAI 兼容格式
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer your-proxy-key" \
-H "Content-Type: application/json" \
-d '{"model": "moonshot-v1-8k", "messages": [{"role": "user", "content": "hello"}]}'
# 指定 Plan跳过 model 路由)
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer your-proxy-key" \
-H "X-Plan-Id: plan-id-here" \
-d '...'
# Anthropic 兼容格式
curl http://localhost:8080/v1/messages \
-H "x-api-key: your-proxy-key" \
-d '{"model": "glm-4-plus", "messages": [{"role": "user", "content": "hello"}]}'
```
## 扩展新平台
`app/providers/` 下新建文件即可,无需修改核心代码:
```python
# app/providers/new_platform.py
from app.providers.base import BaseProvider, Capability
class NewPlatformProvider(BaseProvider):
name = "new_platform"
display_name = "New Platform"
capabilities = [Capability.CHAT]
async def chat(self, messages, model, plan, stream=True, **kwargs):
# 实现 chat 逻辑...
yield "data: ..."
```
然后在 `config.yaml` 或 Web UI 中添加对应的 Plan 即可。
## API 文档
启动后访问 `http://localhost:8080/docs` 查看自动生成的 OpenAPI 文档。

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@

72
app/config.py Normal file
View File

@@ -0,0 +1,72 @@
"""配置加载模块 -- 从 config.yaml / 环境变量读取配置"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, Field
_CONFIG_PATH = os.getenv("CONFIG_PATH", "config.yaml")
class ServerConfig(BaseModel):
host: str = "0.0.0.0"
port: int = 8080
proxy_api_key: str = "sk-plan-manage-change-me"
class DatabaseConfig(BaseModel):
path: str = "./data/plan_manage.db"
class StorageConfig(BaseModel):
path: str = "./data/files"
class QuotaRuleSeed(BaseModel):
"""config.yaml 中单条 QuotaRule 种子"""
rule_name: str
quota_total: int
quota_unit: str = "requests"
refresh_type: str = "calendar_cycle"
interval_hours: float | None = None
calendar_unit: str | None = None
calendar_anchor: dict[str, Any] | None = None
class PlanSeed(BaseModel):
"""config.yaml 中单个 Plan 种子配置"""
name: str
provider: str
api_key: str = ""
api_base: str = ""
plan_type: str = "coding"
supported_models: list[str] = Field(default_factory=list)
extra_headers: dict[str, str] = Field(default_factory=dict)
extra_config: dict[str, Any] = Field(default_factory=dict)
quota_rules: list[QuotaRuleSeed] = Field(default_factory=list)
class AppConfig(BaseModel):
server: ServerConfig = Field(default_factory=ServerConfig)
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
plans: list[PlanSeed] = Field(default_factory=list)
def load_config(path: str | None = None) -> AppConfig:
"""加载配置文件,不存在则返回默认值"""
cfg_path = Path(path or _CONFIG_PATH)
if cfg_path.exists():
with open(cfg_path, "r", encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
return AppConfig(**raw)
return AppConfig()
# 全局单例
settings: AppConfig = load_config()

479
app/database.py Normal file
View File

@@ -0,0 +1,479 @@
"""SQLite 数据库管理 -- 异步连接 + 自动建表"""
from __future__ import annotations
import json
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
from app.config import settings
_db: aiosqlite.Connection | None = None
SQL_CREATE_TABLES = """
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
provider_name TEXT NOT NULL,
api_key TEXT DEFAULT '',
api_base TEXT DEFAULT '',
plan_type TEXT DEFAULT 'coding',
supported_models TEXT DEFAULT '[]',
extra_headers TEXT DEFAULT '{}',
extra_config TEXT DEFAULT '{}',
enabled INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS quota_rules (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
rule_name TEXT NOT NULL,
quota_total INTEGER NOT NULL DEFAULT 0,
quota_used INTEGER NOT NULL DEFAULT 0,
quota_unit TEXT DEFAULT 'requests',
refresh_type TEXT DEFAULT 'calendar_cycle',
interval_hours REAL,
calendar_unit TEXT,
calendar_anchor TEXT,
last_refresh_at TEXT,
next_refresh_at TEXT,
enabled INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS quota_snapshots (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES quota_rules(id) ON DELETE CASCADE,
quota_used INTEGER NOT NULL,
quota_remaining INTEGER NOT NULL,
checked_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS model_routes (
id TEXT PRIMARY KEY,
model_name TEXT NOT NULL,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
priority INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
task_type TEXT NOT NULL,
status TEXT DEFAULT 'pending',
request_payload TEXT DEFAULT '{}',
response_payload TEXT,
result_file_path TEXT,
result_mime_type TEXT,
priority INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
callback_url TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_quota_rules_plan ON quota_rules(plan_id);
CREATE INDEX IF NOT EXISTS idx_model_routes_model ON model_routes(model_name);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
"""
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def new_id() -> str:
return uuid.uuid4().hex[:16]
async def get_db() -> aiosqlite.Connection:
global _db
if _db is None:
db_path = Path(settings.database.path)
db_path.parent.mkdir(parents=True, exist_ok=True)
_db = await aiosqlite.connect(str(db_path))
_db.row_factory = aiosqlite.Row
await _db.execute("PRAGMA journal_mode=WAL")
await _db.execute("PRAGMA foreign_keys=ON")
await _db.executescript(SQL_CREATE_TABLES)
await _db.commit()
return _db
async def close_db():
global _db
if _db:
await _db.close()
_db = None
# ── 通用辅助 ──────────────────────────────────────────
def row_to_dict(row: aiosqlite.Row) -> dict[str, Any]:
return dict(row)
def _parse_json(val: str | None, default: Any = None) -> Any:
if val is None:
return default
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return default
# ── Plan CRUD ─────────────────────────────────────────
async def create_plan(
name: str,
provider_name: str,
api_key: str = "",
api_base: str = "",
plan_type: str = "coding",
supported_models: list[str] | None = None,
extra_headers: dict | None = None,
extra_config: dict | None = None,
enabled: bool = True,
) -> dict:
db = await get_db()
pid = new_id()
now = _now_iso()
await db.execute(
"""INSERT INTO plans
(id, name, provider_name, api_key, api_base, plan_type,
supported_models, extra_headers, extra_config, enabled, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
pid, name, provider_name, api_key, api_base, plan_type,
json.dumps(supported_models or []),
json.dumps(extra_headers or {}),
json.dumps(extra_config or {}),
int(enabled), now, now,
),
)
await db.commit()
return {"id": pid, "name": name, "provider_name": provider_name}
async def get_plan(plan_id: str) -> dict | None:
db = await get_db()
cur = await db.execute("SELECT * FROM plans WHERE id=?", (plan_id,))
row = await cur.fetchone()
if not row:
return None
d = row_to_dict(row)
d["supported_models"] = _parse_json(d["supported_models"], [])
d["extra_headers"] = _parse_json(d["extra_headers"], {})
d["extra_config"] = _parse_json(d["extra_config"], {})
d["enabled"] = bool(d["enabled"])
return d
async def list_plans(enabled_only: bool = False) -> list[dict]:
db = await get_db()
sql = "SELECT * FROM plans"
if enabled_only:
sql += " WHERE enabled=1"
cur = await db.execute(sql)
rows = await cur.fetchall()
result = []
for row in rows:
d = row_to_dict(row)
d["supported_models"] = _parse_json(d["supported_models"], [])
d["extra_headers"] = _parse_json(d["extra_headers"], {})
d["extra_config"] = _parse_json(d["extra_config"], {})
d["enabled"] = bool(d["enabled"])
result.append(d)
return result
async def update_plan(plan_id: str, **fields) -> bool:
db = await get_db()
json_fields = ("supported_models", "extra_headers", "extra_config")
sets, vals = [], []
for k, v in fields.items():
if v is None:
continue
if k in json_fields:
v = json.dumps(v)
if k == "enabled":
v = int(v)
sets.append(f"{k}=?")
vals.append(v)
if not sets:
return False
sets.append("updated_at=?")
vals.append(_now_iso())
vals.append(plan_id)
await db.execute(f"UPDATE plans SET {', '.join(sets)} WHERE id=?", vals)
await db.commit()
return True
async def delete_plan(plan_id: str) -> bool:
db = await get_db()
cur = await db.execute("DELETE FROM plans WHERE id=?", (plan_id,))
await db.commit()
return cur.rowcount > 0
# ── QuotaRule CRUD ────────────────────────────────────
async def create_quota_rule(
plan_id: str,
rule_name: str,
quota_total: int,
quota_unit: str = "requests",
refresh_type: str = "calendar_cycle",
interval_hours: float | None = None,
calendar_unit: str | None = None,
calendar_anchor: dict | None = None,
enabled: bool = True,
) -> dict:
db = await get_db()
rid = new_id()
now = _now_iso()
await db.execute(
"""INSERT INTO quota_rules
(id, plan_id, rule_name, quota_total, quota_used, quota_unit,
refresh_type, interval_hours, calendar_unit, calendar_anchor,
last_refresh_at, next_refresh_at, enabled)
VALUES (?,?,?,?,0,?,?,?,?,?,?,?,?)""",
(
rid, plan_id, rule_name, quota_total, quota_unit,
refresh_type, interval_hours, calendar_unit,
json.dumps(calendar_anchor) if calendar_anchor else None,
now, None, int(enabled),
),
)
await db.commit()
return {"id": rid, "plan_id": plan_id, "rule_name": rule_name}
async def list_quota_rules(plan_id: str) -> list[dict]:
db = await get_db()
cur = await db.execute("SELECT * FROM quota_rules WHERE plan_id=?", (plan_id,))
rows = await cur.fetchall()
result = []
for row in rows:
d = row_to_dict(row)
d["calendar_anchor"] = _parse_json(d.get("calendar_anchor"), {})
d["enabled"] = bool(d["enabled"])
result.append(d)
return result
async def get_all_quota_rules() -> list[dict]:
"""获取全部 QuotaRule供调度器使用"""
db = await get_db()
cur = await db.execute("SELECT * FROM quota_rules WHERE enabled=1")
rows = await cur.fetchall()
result = []
for row in rows:
d = row_to_dict(row)
d["calendar_anchor"] = _parse_json(d.get("calendar_anchor"), {})
d["enabled"] = bool(d["enabled"])
result.append(d)
return result
async def update_quota_rule(rule_id: str, **fields) -> bool:
db = await get_db()
json_fields = ("calendar_anchor",)
sets, vals = [], []
for k, v in fields.items():
if v is None:
continue
if k in json_fields:
v = json.dumps(v)
if k == "enabled":
v = int(v)
sets.append(f"{k}=?")
vals.append(v)
if not sets:
return False
vals.append(rule_id)
await db.execute(f"UPDATE quota_rules SET {', '.join(sets)} WHERE id=?", vals)
await db.commit()
return True
async def increment_quota_used(plan_id: str, token_count: int = 0):
"""请求完成后增加该 Plan 所有 Rule 的 quota_used"""
db = await get_db()
rules = await list_quota_rules(plan_id)
for r in rules:
if not r["enabled"]:
continue
inc = token_count if r["quota_unit"] == "tokens" else 1
await db.execute(
"UPDATE quota_rules SET quota_used = quota_used + ? WHERE id=?",
(inc, r["id"]),
)
await db.commit()
async def check_plan_available(plan_id: str) -> bool:
"""判断 Plan 所有 Rule 是否都有余量"""
rules = await list_quota_rules(plan_id)
for r in rules:
if not r["enabled"]:
continue
if r["quota_used"] >= r["quota_total"]:
return False
return True
# ── Model Route ───────────────────────────────────────
async def set_model_route(model_name: str, plan_id: str, priority: int = 0) -> dict:
db = await get_db()
mid = new_id()
await db.execute(
"DELETE FROM model_routes WHERE model_name=? AND plan_id=?",
(model_name, plan_id),
)
await db.execute(
"INSERT INTO model_routes (id, model_name, plan_id, priority) VALUES (?,?,?,?)",
(mid, model_name, plan_id, priority),
)
await db.commit()
return {"id": mid, "model_name": model_name, "plan_id": plan_id}
async def resolve_model(model_name: str) -> str | None:
"""按 priority 降序找到可用的 plan_idfallback 逻辑)"""
db = await get_db()
cur = await db.execute(
"SELECT plan_id FROM model_routes WHERE model_name=? ORDER BY priority DESC",
(model_name,),
)
rows = await cur.fetchall()
for row in rows:
pid = row["plan_id"]
if await check_plan_available(pid):
return pid
return rows[0]["plan_id"] if rows else None
async def list_model_routes() -> list[dict]:
db = await get_db()
cur = await db.execute("SELECT * FROM model_routes ORDER BY model_name, priority DESC")
return [row_to_dict(r) for r in await cur.fetchall()]
async def delete_model_route(route_id: str) -> bool:
db = await get_db()
cur = await db.execute("DELETE FROM model_routes WHERE id=?", (route_id,))
await db.commit()
return cur.rowcount > 0
# ── Task Queue ────────────────────────────────────────
async def create_task(
task_type: str,
request_payload: dict,
plan_id: str | None = None,
priority: int = 0,
max_retries: int = 3,
callback_url: str | None = None,
) -> dict:
db = await get_db()
tid = new_id()
now = _now_iso()
await db.execute(
"""INSERT INTO tasks
(id, plan_id, task_type, status, request_payload, priority,
max_retries, callback_url, created_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
(tid, plan_id, task_type, "pending", json.dumps(request_payload),
priority, max_retries, callback_url, now),
)
await db.commit()
return {"id": tid, "status": "pending"}
async def list_tasks(status: str | None = None, limit: int = 50) -> list[dict]:
db = await get_db()
sql = "SELECT * FROM tasks"
params: list = []
if status:
sql += " WHERE status=?"
params.append(status)
sql += " ORDER BY priority DESC, created_at ASC LIMIT ?"
params.append(limit)
cur = await db.execute(sql, params)
rows = await cur.fetchall()
result = []
for row in rows:
d = row_to_dict(row)
d["request_payload"] = _parse_json(d["request_payload"], {})
d["response_payload"] = _parse_json(d.get("response_payload"))
result.append(d)
return result
async def update_task(task_id: str, **fields) -> bool:
db = await get_db()
json_fields = ("request_payload", "response_payload")
sets, vals = [], []
for k, v in fields.items():
if v is None:
continue
if k in json_fields and isinstance(v, dict):
v = json.dumps(v)
sets.append(f"{k}=?")
vals.append(v)
if not sets:
return False
vals.append(task_id)
await db.execute(f"UPDATE tasks SET {', '.join(sets)} WHERE id=?", vals)
await db.commit()
return True
# ── 种子数据导入 ──────────────────────────────────────
async def seed_from_config():
"""首次启动时从 config.yaml 导入 Plan + QuotaRule + ModelRoute"""
from app.config import settings as cfg
db = await get_db()
cur = await db.execute("SELECT COUNT(*) as cnt FROM plans")
row = await cur.fetchone()
if row["cnt"] > 0:
return
for ps in cfg.plans:
plan = await create_plan(
name=ps.name,
provider_name=ps.provider,
api_key=ps.api_key,
api_base=ps.api_base,
plan_type=ps.plan_type,
supported_models=ps.supported_models,
extra_headers=ps.extra_headers,
extra_config=ps.extra_config,
)
for qr in ps.quota_rules:
await create_quota_rule(
plan_id=plan["id"],
rule_name=qr.rule_name,
quota_total=qr.quota_total,
quota_unit=qr.quota_unit,
refresh_type=qr.refresh_type,
interval_hours=qr.interval_hours,
calendar_unit=qr.calendar_unit,
calendar_anchor=qr.calendar_anchor,
)
for model in ps.supported_models:
await set_model_route(model, plan["id"], priority=0)

52
app/main.py Normal file
View File

@@ -0,0 +1,52 @@
"""FastAPI 应用入口"""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import get_db, close_db, seed_from_config
@asynccontextmanager
async def lifespan(app: FastAPI):
await get_db()
await seed_from_config()
from app.services.scheduler import start_scheduler
await start_scheduler()
Path(settings.storage.path).mkdir(parents=True, exist_ok=True)
yield
from app.services.scheduler import stop_scheduler
await stop_scheduler()
await close_db()
app = FastAPI(
title="Plan Manager",
description="多平台 Coding Plan 统一管理系统",
version="0.1.0",
lifespan=lifespan,
)
# 挂载 API / 代理路由
from app.routers import plans, quota, queue, proxy # noqa: E402
app.include_router(plans.router, prefix="/api/plans", tags=["Plans"])
app.include_router(quota.router, prefix="/api/quota", tags=["Quota"])
app.include_router(queue.router, prefix="/api/queue", tags=["Queue"])
app.include_router(proxy.router, tags=["Proxy"])
# 前端: 用显式路由而非 mount("/") 以避免遮盖 /docs, /api, /v1
_static_dir = Path(__file__).parent / "static"
@app.get("/", include_in_schema=False)
async def serve_index():
return FileResponse(_static_dir / "index.html")

165
app/models.py Normal file
View File

@@ -0,0 +1,165 @@
"""Pydantic 数据模型 -- API 请求/响应 + 数据库行映射"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
# ── 枚举 ──────────────────────────────────────────────
class RefreshType(str, Enum):
FIXED_INTERVAL = "fixed_interval"
CALENDAR_CYCLE = "calendar_cycle"
MANUAL = "manual"
API_SYNC = "api_sync"
class TaskStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
# ── Plan ──────────────────────────────────────────────
class PlanBase(BaseModel):
name: str
provider_name: str
api_base: str = ""
plan_type: str = "coding"
supported_models: list[str] = Field(default_factory=list)
extra_headers: dict[str, str] = Field(default_factory=dict)
extra_config: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
class PlanCreate(PlanBase):
api_key: str = ""
class PlanUpdate(BaseModel):
name: str | None = None
api_key: str | None = None
api_base: str | None = None
plan_type: str | None = None
supported_models: list[str] | None = None
extra_headers: dict[str, str] | None = None
extra_config: dict[str, Any] | None = None
enabled: bool | None = None
class PlanOut(PlanBase):
id: str
created_at: datetime
updated_at: datetime
# ── QuotaRule ─────────────────────────────────────────
class QuotaRuleBase(BaseModel):
rule_name: str
quota_total: int
quota_unit: str = "requests"
refresh_type: RefreshType = RefreshType.CALENDAR_CYCLE
interval_hours: float | None = None
calendar_unit: str | None = None
calendar_anchor: dict[str, Any] | None = None
enabled: bool = True
class QuotaRuleCreate(QuotaRuleBase):
plan_id: str
class QuotaRuleUpdate(BaseModel):
rule_name: str | None = None
quota_total: int | None = None
quota_unit: str | None = None
refresh_type: RefreshType | None = None
interval_hours: float | None = None
calendar_unit: str | None = None
calendar_anchor: dict[str, Any] | None = None
enabled: bool | None = None
class QuotaRuleOut(QuotaRuleBase):
id: str
plan_id: str
quota_used: int = 0
last_refresh_at: datetime | None = None
next_refresh_at: datetime | None = None
# ── QuotaSnapshot ─────────────────────────────────────
class QuotaSnapshotOut(BaseModel):
id: str
rule_id: str
quota_used: int
quota_remaining: int
checked_at: datetime
# ── Model Route ───────────────────────────────────────
class ModelRouteBase(BaseModel):
model_name: str
plan_id: str
priority: int = 0
class ModelRouteOut(ModelRouteBase):
id: str
# ── Task Queue ────────────────────────────────────────
class TaskCreate(BaseModel):
plan_id: str | None = None
task_type: str
request_payload: dict[str, Any] = Field(default_factory=dict)
priority: int = 0
max_retries: int = 3
callback_url: str | None = None
class TaskOut(BaseModel):
id: str
plan_id: str | None
task_type: str
status: TaskStatus
request_payload: dict[str, Any]
response_payload: dict[str, Any] | None = None
result_file_path: str | None = None
result_mime_type: str | None = None
priority: int
retry_count: int
max_retries: int
callback_url: str | None = None
created_at: datetime
started_at: datetime | None = None
completed_at: datetime | None = None
# ── 聚合视图 ──────────────────────────────────────────
class PlanWithRules(PlanOut):
"""Plan + 所有 QuotaRule 的聚合返回"""
quota_rules: list[QuotaRuleOut] = Field(default_factory=list)
class DashboardPlan(BaseModel):
"""仪表盘用的精简视图"""
id: str
name: str
provider_name: str
plan_type: str
enabled: bool
quota_rules: list[QuotaRuleOut]
all_available: bool = True # 所有 Rule 均有余量

50
app/providers/__init__.py Normal file
View File

@@ -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]

87
app/providers/base.py Normal file
View File

@@ -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("/")

106
app/providers/google.py Normal file
View File

@@ -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),
},
}

46
app/providers/kimi.py Normal file
View File

@@ -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())

88
app/providers/minimax.py Normal file
View File

@@ -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

View File

@@ -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()

67
app/providers/zhipu.py Normal file
View File

@@ -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

1
app/routers/__init__.py Normal file
View File

@@ -0,0 +1 @@

133
app/routers/plans.py Normal file
View File

@@ -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}

202
app/routers/proxy.py Normal file
View File

@@ -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),
},
}

46
app/routers/queue.py Normal file
View File

@@ -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}

51
app/routers/quota.py Normal file
View File

@@ -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}

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,17 @@
"""任务队列辅助服务"""
from __future__ import annotations
from app import database as db
async def get_queue_stats() -> dict:
"""队列统计"""
d = await db.get_db()
stats = {}
for status in ("pending", "running", "completed", "failed", "cancelled"):
cur = await d.execute("SELECT COUNT(*) as cnt FROM tasks WHERE status=?", (status,))
row = await cur.fetchone()
stats[status] = row["cnt"]
stats["total"] = sum(stats.values())
return stats

View File

@@ -0,0 +1,46 @@
"""额度查询聚合服务"""
from __future__ import annotations
from app import database as db
from app.providers import ProviderRegistry
from app.providers.base import QuotaInfo
async def query_plan_quota(plan_id: str) -> list[dict]:
"""查询指定 Plan 的所有 QuotaRule 状态,对 api_sync 类型尝试实时查询"""
plan = await db.get_plan(plan_id)
if not plan:
return []
rules = await db.list_quota_rules(plan_id)
result = []
for r in rules:
info = {
"rule_id": r["id"],
"rule_name": r["rule_name"],
"quota_total": r["quota_total"],
"quota_used": r["quota_used"],
"quota_remaining": r["quota_total"] - r["quota_used"],
"quota_unit": r["quota_unit"],
"refresh_type": r["refresh_type"],
"next_refresh_at": r.get("next_refresh_at"),
}
# api_sync 类型尝试实时查询
if r["refresh_type"] == "api_sync":
provider = ProviderRegistry.get(plan["provider_name"])
if provider:
try:
qi = await provider.query_quota(plan)
if qi:
info["quota_remaining"] = qi.quota_remaining
info["quota_used"] = qi.quota_used
info["api_raw"] = qi.raw
except Exception:
pass
result.append(info)
return result

256
app/services/scheduler.py Normal file
View File

@@ -0,0 +1,256 @@
"""后台调度器 -- 额度刷新 + 任务队列消费"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta, timezone
from app import database as db
logger = logging.getLogger("scheduler")
_task: asyncio.Task | None = None
_running = False
def _now() -> datetime:
return datetime.now(timezone.utc)
def _parse_dt(s: str | None) -> datetime | None:
if not s:
return None
try:
return datetime.fromisoformat(s)
except (ValueError, TypeError):
return None
def _compute_next_calendar(calendar_unit: str, anchor: dict, after: datetime) -> datetime:
"""
计算下一个自然周期的刷新时间。
anchor 示例:
daily: {"hour": 0}
weekly: {"weekday": 1, "hour": 0} # 周一 0 点
monthly: {"day": 1, "hour": 0} # 每月 1 号
"""
hour = anchor.get("hour", 0)
if calendar_unit == "daily":
candidate = after.replace(hour=hour, minute=0, second=0, microsecond=0)
if candidate <= after:
candidate += timedelta(days=1)
return candidate
if calendar_unit == "weekly":
weekday = anchor.get("weekday", 1) # 1=Monday
days_ahead = (weekday - after.isoweekday()) % 7
candidate = (after + timedelta(days=days_ahead)).replace(
hour=hour, minute=0, second=0, microsecond=0
)
if candidate <= after:
candidate += timedelta(weeks=1)
return candidate
if calendar_unit == "monthly":
day = anchor.get("day", 1)
year, month = after.year, after.month
try:
candidate = after.replace(day=day, hour=hour, minute=0, second=0, microsecond=0)
except ValueError:
# 日期不存在时(如 2 月 30 号),跳到下月
month += 1
if month > 12:
month, year = 1, year + 1
candidate = after.replace(year=year, month=month, day=day,
hour=hour, minute=0, second=0, microsecond=0)
if candidate <= after:
month += 1
if month > 12:
month, year = 1, year + 1
try:
candidate = candidate.replace(year=year, month=month)
except ValueError:
month += 1
if month > 12:
month, year = 1, year + 1
candidate = candidate.replace(year=year, month=month, day=1)
return candidate
return after + timedelta(days=1)
async def _refresh_quota_rules():
"""遍历所有 QuotaRule按刷新策略处理"""
now = _now()
rules = await db.get_all_quota_rules()
for rule in rules:
rt = rule.get("refresh_type", "manual")
if rt == "manual":
continue
next_at = _parse_dt(rule.get("next_refresh_at"))
if rt == "fixed_interval":
interval = rule.get("interval_hours")
if not interval:
continue
last_at = _parse_dt(rule.get("last_refresh_at")) or now
if next_at is None:
next_at = last_at + timedelta(hours=interval)
await db.update_quota_rule(rule["id"], next_refresh_at=next_at.isoformat())
if now >= next_at:
new_next = now + timedelta(hours=interval)
await db.update_quota_rule(
rule["id"],
quota_used=0,
last_refresh_at=now.isoformat(),
next_refresh_at=new_next.isoformat(),
)
logger.info("Refreshed rule %s (fixed_interval %sh)", rule["rule_name"], interval)
elif rt == "calendar_cycle":
cal_unit = rule.get("calendar_unit", "daily")
anchor = rule.get("calendar_anchor") or {}
if next_at is None:
last_at = _parse_dt(rule.get("last_refresh_at")) or now
next_at = _compute_next_calendar(cal_unit, anchor, last_at)
await db.update_quota_rule(rule["id"], next_refresh_at=next_at.isoformat())
if now >= next_at:
new_next = _compute_next_calendar(cal_unit, anchor, now)
await db.update_quota_rule(
rule["id"],
quota_used=0,
last_refresh_at=now.isoformat(),
next_refresh_at=new_next.isoformat(),
)
logger.info("Refreshed rule %s (calendar %s)", rule["rule_name"], cal_unit)
elif rt == "api_sync":
# 每 10 分钟同步一次
last_at = _parse_dt(rule.get("last_refresh_at"))
if last_at and (now - last_at).total_seconds() < 600:
continue
plan = await db.get_plan(rule["plan_id"])
if not plan:
continue
from app.providers import ProviderRegistry
provider = ProviderRegistry.get(plan["provider_name"])
if provider:
info = await provider.query_quota(plan)
if info:
await db.update_quota_rule(
rule["id"],
quota_used=info.quota_used,
last_refresh_at=now.isoformat(),
)
logger.info("API synced rule %s: used=%d", rule["rule_name"], info.quota_used)
async def _process_task_queue():
"""消费待处理任务"""
tasks = await db.list_tasks(status="pending", limit=5)
for task in tasks:
plan_id = task.get("plan_id")
if plan_id and not await db.check_plan_available(plan_id):
continue # 额度不足,跳过
await db.update_task(task["id"], status="running", started_at=_now().isoformat())
try:
if plan_id:
plan = await db.get_plan(plan_id)
if plan:
from app.providers import ProviderRegistry
provider = ProviderRegistry.get(plan["provider_name"])
if provider:
result = await _execute_task(provider, plan, task)
await db.update_task(
task["id"],
status="completed",
response_payload=result,
completed_at=_now().isoformat(),
)
await db.increment_quota_used(plan_id, token_count=0)
continue
await db.update_task(
task["id"],
status="failed",
response_payload={"error": "No provider available"},
completed_at=_now().isoformat(),
)
except Exception as e:
retry = task.get("retry_count", 0) + 1
max_r = task.get("max_retries", 3)
new_status = "pending" if retry < max_r else "failed"
await db.update_task(
task["id"],
status=new_status,
retry_count=retry,
response_payload={"error": str(e)},
completed_at=_now().isoformat() if new_status == "failed" else None,
)
logger.error("Task %s failed: %s", task["id"], e)
async def _execute_task(provider, plan: dict, task: dict) -> dict:
"""根据 task_type 调用对应的 Provider 方法"""
tt = task["task_type"]
payload = task.get("request_payload", {})
if tt == "image":
return await provider.generate_image(payload.get("prompt", ""), plan, **payload)
elif tt == "voice":
audio = await provider.generate_voice(payload.get("text", ""), plan, **payload)
# 保存到文件
from pathlib import Path
from app.config import settings
fpath = Path(settings.storage.path) / f"{task['id']}.mp3"
fpath.write_bytes(audio)
await db.update_task(task["id"], result_file_path=str(fpath), result_mime_type="audio/mp3")
return {"file": str(fpath)}
elif tt == "video":
return await provider.generate_video(payload.get("prompt", ""), plan, **payload)
else:
return {"error": f"Unknown task type: {tt}"}
async def _scheduler_loop():
"""主调度循环"""
global _running
while _running:
try:
await _refresh_quota_rules()
await _process_task_queue()
except Exception as e:
logger.error("Scheduler error: %s", e)
await asyncio.sleep(30)
async def start_scheduler():
global _task, _running
# 注册 Provider
from app.providers import ProviderRegistry
ProviderRegistry.auto_discover()
logger.info("Providers: %s", list(ProviderRegistry.all().keys()))
_running = True
_task = asyncio.create_task(_scheduler_loop())
logger.info("Scheduler started")
async def stop_scheduler():
global _task, _running
_running = False
if _task:
_task.cancel()
try:
await _task
except asyncio.CancelledError:
pass
logger.info("Scheduler stopped")

507
app/static/index.html Normal file
View File

@@ -0,0 +1,507 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plan Manager</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.progress-bar { transition: width 0.5s ease; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<div id="app" v-cloak>
<!-- 顶栏 -->
<header class="bg-white shadow-sm border-b sticky top-0 z-10">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<h1 class="text-xl font-bold text-gray-800">Plan Manager</h1>
<nav class="flex gap-2">
<button v-for="t in tabs" :key="t.id"
@click="activeTab = t.id"
:class="activeTab === t.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="px-4 py-1.5 rounded-full text-sm font-medium transition">
{{ t.label }}
</button>
</nav>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 py-6">
<!-- ═══ 仪表盘 ═══ -->
<section v-if="activeTab === 'dashboard'">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
<div v-for="p in plans" :key="p.id"
class="bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition">
<!-- 头部 -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-semibold text-gray-800">{{ p.name }}</h3>
<span class="text-xs px-2 py-0.5 rounded-full"
:class="providerColor(p.provider_name)">
{{ p.provider_name }}
</span>
<span class="text-xs ml-1 text-gray-400">{{ p.plan_type }}</span>
</div>
<span class="w-3 h-3 rounded-full"
:class="p.all_available ? 'bg-green-400' : 'bg-red-400'"
:title="p.all_available ? '可用' : '额度不足'"></span>
</div>
<!-- QuotaRules -->
<div v-for="r in p.quota_rules" :key="r.id" class="mb-3 last:mb-0">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span>{{ r.rule_name }}
<span class="ml-1 px-1.5 py-0.5 bg-gray-100 rounded text-gray-400">{{ refreshLabel(r) }}</span>
</span>
<span>{{ r.quota_used }} / {{ r.quota_total }} {{ r.quota_unit }}</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2.5">
<div class="h-2.5 rounded-full progress-bar"
:class="barColor(r)"
:style="{width: pct(r) + '%'}"></div>
</div>
<div v-if="r.next_refresh_at" class="text-xs text-gray-400 mt-0.5">
刷新倒计时: {{ countdown(r.next_refresh_at) }}
</div>
</div>
<!-- 操作 -->
<div class="flex gap-2 mt-4 pt-3 border-t">
<button @click="refreshQuota(p.id)" class="text-xs text-blue-500 hover:underline">手动刷新</button>
<button @click="editPlan(p)" class="text-xs text-gray-400 hover:underline">编辑</button>
</div>
</div>
</div>
<div v-if="plans.length === 0" class="text-center text-gray-400 py-20">
暂无 Plan请在配置页添加
</div>
</section>
<!-- ═══ 任务队列 ═══ -->
<section v-if="activeTab === 'queue'">
<div class="flex justify-between items-center mb-4">
<div class="flex gap-2">
<button v-for="s in ['all','pending','running','completed','failed']" :key="s"
@click="queueFilter = s === 'all' ? '' : s"
:class="(queueFilter === '' && s === 'all') || queueFilter === s ? 'bg-blue-600 text-white' : 'bg-gray-100'"
class="px-3 py-1 rounded text-xs font-medium transition">
{{ s === 'all' ? '全部' : s }}
</button>
</div>
<button @click="showNewTask = true"
class="bg-blue-600 text-white px-4 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition">
新建任务
</button>
</div>
<div class="bg-white rounded-xl shadow-sm border">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs">
<tr>
<th class="px-4 py-3 text-left">ID</th>
<th class="px-4 py-3 text-left">类型</th>
<th class="px-4 py-3 text-left">状态</th>
<th class="px-4 py-3 text-left">优先级</th>
<th class="px-4 py-3 text-left">创建时间</th>
<th class="px-4 py-3 text-left">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id" class="border-t hover:bg-gray-50">
<td class="px-4 py-3 font-mono text-xs">{{ t.id.slice(0,8) }}</td>
<td class="px-4 py-3">{{ t.task_type }}</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs"
:class="statusColor(t.status)">{{ t.status }}</span>
</td>
<td class="px-4 py-3">{{ t.priority }}</td>
<td class="px-4 py-3 text-xs text-gray-400">{{ fmtTime(t.created_at) }}</td>
<td class="px-4 py-3">
<button v-if="t.status === 'pending'" @click="cancelTask(t.id)"
class="text-xs text-red-500 hover:underline">取消</button>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无任务</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- ═══ 配置页 ═══ -->
<section v-if="activeTab === 'config'">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Plan 列表 -->
<div>
<div class="flex justify-between items-center mb-3">
<h2 class="font-semibold text-gray-700">Plan 列表</h2>
<button @click="showAddPlan = true"
class="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700">添加 Plan</button>
</div>
<div v-for="p in allPlans" :key="p.id"
class="bg-white rounded-lg border p-4 mb-3 hover:shadow-sm transition">
<div class="flex justify-between items-start">
<div>
<span class="font-medium">{{ p.name }}</span>
<span class="text-xs ml-2 px-1.5 py-0.5 rounded"
:class="providerColor(p.provider_name)">{{ p.provider_name }}</span>
</div>
<div class="flex gap-2">
<button @click="togglePlan(p)" class="text-xs"
:class="p.enabled ? 'text-green-600' : 'text-gray-400'">
{{ p.enabled ? '已启用' : '已禁用' }}
</button>
<button @click="deletePlan(p.id)" class="text-xs text-red-400 hover:text-red-600">删除</button>
</div>
</div>
<div class="text-xs text-gray-400 mt-1">{{ p.api_base }}</div>
<div class="text-xs text-gray-400 mt-1">
模型: {{ (p.supported_models || []).join(', ') || '无' }}
</div>
</div>
</div>
<!-- Model 路由表 -->
<div>
<div class="flex justify-between items-center mb-3">
<h2 class="font-semibold text-gray-700">Model 路由</h2>
<button @click="showAddRoute = true"
class="bg-green-600 text-white px-3 py-1 rounded text-sm hover:bg-green-700">添加路由</button>
</div>
<div class="bg-white rounded-lg border">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs">
<tr>
<th class="px-4 py-2 text-left">Model</th>
<th class="px-4 py-2 text-left">Plan</th>
<th class="px-4 py-2 text-left">优先级</th>
<th class="px-4 py-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="r in routes" :key="r.id" class="border-t">
<td class="px-4 py-2 font-mono text-xs">{{ r.model_name }}</td>
<td class="px-4 py-2 text-xs">{{ planName(r.plan_id) }}</td>
<td class="px-4 py-2 text-xs">{{ r.priority }}</td>
<td class="px-4 py-2">
<button @click="deleteRoute(r.id)" class="text-xs text-red-400 hover:text-red-600">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</main>
<!-- ═══ 弹窗: 新建任务 ═══ -->
<div v-if="showNewTask" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-20"
@click.self="showNewTask = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md">
<h3 class="font-semibold mb-4">新建任务</h3>
<label class="block text-sm text-gray-600 mb-1">任务类型</label>
<select v-model="newTask.task_type" class="w-full border rounded px-3 py-2 mb-3 text-sm">
<option v-for="t in ['image','voice','video','chat']" :value="t">{{ t }}</option>
</select>
<label class="block text-sm text-gray-600 mb-1">目标 Plan</label>
<select v-model="newTask.plan_id" class="w-full border rounded px-3 py-2 mb-3 text-sm">
<option value="">自动选择</option>
<option v-for="p in allPlans" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<label class="block text-sm text-gray-600 mb-1">Prompt / Text</label>
<textarea v-model="newTask.prompt" class="w-full border rounded px-3 py-2 mb-3 text-sm h-24"></textarea>
<label class="block text-sm text-gray-600 mb-1">优先级</label>
<input v-model.number="newTask.priority" type="number" class="w-full border rounded px-3 py-2 mb-4 text-sm">
<div class="flex justify-end gap-2">
<button @click="showNewTask = false" class="px-4 py-2 text-sm text-gray-500">取消</button>
<button @click="submitTask" class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">提交</button>
</div>
</div>
</div>
<!-- ═══ 弹窗: 添加 Plan ═══ -->
<div v-if="showAddPlan" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-20"
@click.self="showAddPlan = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-lg max-h-screen overflow-y-auto">
<h3 class="font-semibold mb-4">{{ editingPlan ? '编辑' : '添加' }} Plan</h3>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-500 mb-1">名称</label>
<input v-model="planForm.name" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Provider</label>
<input v-model="planForm.provider_name" class="w-full border rounded px-3 py-2 text-sm"
placeholder="openai / kimi / minimax / google / zhipu">
</div>
</div>
<div class="mb-3">
<label class="block text-xs text-gray-500 mb-1">API Key</label>
<input v-model="planForm.api_key" type="password" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div class="mb-3">
<label class="block text-xs text-gray-500 mb-1">API Base URL</label>
<input v-model="planForm.api_base" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div class="mb-3">
<label class="block text-xs text-gray-500 mb-1">支持模型 (逗号分隔)</label>
<input v-model="planForm.models_str" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div class="flex justify-end gap-2">
<button @click="showAddPlan = false; editingPlan = null" class="px-4 py-2 text-sm text-gray-500">取消</button>
<button @click="savePlan" class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">保存</button>
</div>
</div>
</div>
<!-- ═══ 弹窗: 添加路由 ═══ -->
<div v-if="showAddRoute" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-20"
@click.self="showAddRoute = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-sm">
<h3 class="font-semibold mb-4">添加 Model 路由</h3>
<label class="block text-xs text-gray-500 mb-1">Model 名称</label>
<input v-model="routeForm.model_name" class="w-full border rounded px-3 py-2 mb-3 text-sm">
<label class="block text-xs text-gray-500 mb-1">目标 Plan</label>
<select v-model="routeForm.plan_id" class="w-full border rounded px-3 py-2 mb-3 text-sm">
<option v-for="p in allPlans" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<label class="block text-xs text-gray-500 mb-1">优先级 (越大越优先)</label>
<input v-model.number="routeForm.priority" type="number" class="w-full border rounded px-3 py-2 mb-4 text-sm">
<div class="flex justify-end gap-2">
<button @click="showAddRoute = false" class="px-4 py-2 text-sm text-gray-500">取消</button>
<button @click="saveRoute" class="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">保存</button>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted } = Vue;
createApp({
setup() {
const tabs = [
{ id: 'dashboard', label: '仪表盘' },
{ id: 'queue', label: '任务队列' },
{ id: 'config', label: '配置' },
];
const activeTab = ref('dashboard');
// ── 数据 ──
const plans = ref([]);
const allPlans = ref([]);
const tasks = ref([]);
const routes = ref([]);
const queueFilter = ref('');
// ── 弹窗 ──
const showNewTask = ref(false);
const showAddPlan = ref(false);
const showAddRoute = ref(false);
const editingPlan = ref(null);
const newTask = reactive({ task_type: 'image', plan_id: '', prompt: '', priority: 0 });
const planForm = reactive({ name: '', provider_name: '', api_key: '', api_base: '', models_str: '' });
const routeForm = reactive({ model_name: '', plan_id: '', priority: 0 });
// ── API ──
const api = (path, opts = {}) => fetch('/api' + path, {
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
}).then(r => r.json());
async function loadDashboard() {
plans.value = await api('/quota/dashboard');
}
async function loadPlans() {
allPlans.value = await api('/plans');
}
async function loadTasks() {
const q = queueFilter.value ? `?status=${queueFilter.value}` : '';
tasks.value = await api(`/queue${q}`);
}
async function loadRoutes() {
routes.value = await api('/plans/routes/models');
}
async function refreshAll() {
await Promise.all([loadDashboard(), loadPlans(), loadTasks(), loadRoutes()]);
}
// 定时刷新
let timer;
onMounted(() => {
refreshAll();
timer = setInterval(refreshAll, 15000);
});
onUnmounted(() => clearInterval(timer));
// ── 操作 ──
async function refreshQuota(planId) {
await api(`/quota/plan/${planId}/refresh`, { method: 'POST' });
await loadDashboard();
}
function editPlan(p) {
editingPlan.value = p;
planForm.name = p.name;
planForm.provider_name = p.provider_name;
planForm.api_key = '';
planForm.api_base = p.api_base;
planForm.models_str = (p.supported_models || []).join(', ');
showAddPlan.value = true;
}
async function savePlan() {
const models = planForm.models_str.split(',').map(s => s.trim()).filter(Boolean);
if (editingPlan.value) {
const body = { name: planForm.name, api_base: planForm.api_base, supported_models: models };
if (planForm.api_key) body.api_key = planForm.api_key;
await api(`/plans/${editingPlan.value.id}`, { method: 'PATCH', body: JSON.stringify(body) });
} else {
await api('/plans', {
method: 'POST',
body: JSON.stringify({
name: planForm.name,
provider_name: planForm.provider_name,
api_key: planForm.api_key,
api_base: planForm.api_base,
supported_models: models,
}),
});
}
showAddPlan.value = false;
editingPlan.value = null;
await refreshAll();
}
async function deletePlan(id) {
if (!confirm('确认删除?')) return;
await api(`/plans/${id}`, { method: 'DELETE' });
await refreshAll();
}
async function togglePlan(p) {
await api(`/plans/${p.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: !p.enabled }) });
await refreshAll();
}
async function submitTask() {
const payload = { prompt: newTask.prompt };
if (newTask.task_type === 'voice') payload.text = newTask.prompt;
await api('/queue', {
method: 'POST',
body: JSON.stringify({
task_type: newTask.task_type,
plan_id: newTask.plan_id || null,
request_payload: payload,
priority: newTask.priority,
}),
});
showNewTask.value = false;
newTask.prompt = '';
await loadTasks();
}
async function cancelTask(id) {
await api(`/queue/${id}/cancel`, { method: 'POST' });
await loadTasks();
}
async function saveRoute() {
await api('/plans/routes/models', {
method: 'POST',
body: JSON.stringify(routeForm),
});
showAddRoute.value = false;
await loadRoutes();
}
async function deleteRoute(id) {
await api(`/plans/routes/models/${id}`, { method: 'DELETE' });
await loadRoutes();
}
// ── 工具函数 ──
function pct(r) {
if (!r.quota_total) return 0;
return Math.min(100, (r.quota_used / r.quota_total) * 100);
}
function barColor(r) {
const p = pct(r);
if (p >= 90) return 'bg-red-500';
if (p >= 70) return 'bg-yellow-400';
return 'bg-blue-500';
}
function refreshLabel(r) {
if (r.refresh_type === 'fixed_interval') return `${r.interval_hours}h 滚动`;
if (r.refresh_type === 'calendar_cycle') return r.calendar_unit || '周期';
if (r.refresh_type === 'api_sync') return 'API 同步';
return '手动';
}
function countdown(isoStr) {
if (!isoStr) return '--';
const diff = new Date(isoStr) - Date.now();
if (diff <= 0) return '即将刷新';
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
if (h > 24) return `${Math.floor(h / 24)}${h % 24}`;
if (h > 0) return `${h}${m}`;
return `${m}${s}`;
}
function providerColor(name) {
const m = {
openai: 'bg-green-100 text-green-700',
kimi: 'bg-purple-100 text-purple-700',
minimax: 'bg-orange-100 text-orange-700',
google: 'bg-blue-100 text-blue-700',
zhipu: 'bg-indigo-100 text-indigo-700',
};
return m[name] || 'bg-gray-100 text-gray-600';
}
function statusColor(s) {
const m = {
pending: 'bg-yellow-100 text-yellow-700',
running: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
failed: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-500',
};
return m[s] || '';
}
function fmtTime(iso) {
if (!iso) return '--';
return new Date(iso).toLocaleString('zh-CN');
}
function planName(id) {
const p = allPlans.value.find(x => x.id === id);
return p ? p.name : id;
}
// 倒计时实时更新
let cdTimer;
const tick = ref(0);
onMounted(() => { cdTimer = setInterval(() => tick.value++, 1000); });
onUnmounted(() => clearInterval(cdTimer));
return {
tabs, activeTab, plans, allPlans, tasks, routes, queueFilter,
showNewTask, showAddPlan, showAddRoute, editingPlan,
newTask, planForm, routeForm,
loadDashboard, loadPlans, loadTasks, loadRoutes,
refreshQuota, editPlan, savePlan, deletePlan, togglePlan,
submitTask, cancelTask, saveRoute, deleteRoute,
pct, barColor, refreshLabel, countdown: (s) => { tick.value; return countdown(s); },
providerColor, statusColor, fmtTime, planName,
};
},
}).mount('#app');
</script>
</body>
</html>

99
config.yaml Normal file
View File

@@ -0,0 +1,99 @@
server:
host: "0.0.0.0"
port: 8080
proxy_api_key: "sk-plan-manage-change-me"
database:
path: "./data/plan_manage.db"
storage:
path: "./data/files"
plans:
- name: "Kimi Coding Plan"
provider: kimi
api_key: ""
api_base: "https://api.moonshot.cn/v1"
plan_type: coding
supported_models: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]
extra_headers: {}
extra_config: {}
quota_rules:
- rule_name: "周额度"
quota_total: 500
quota_unit: requests
refresh_type: calendar_cycle
calendar_unit: weekly
calendar_anchor: { weekday: 1, hour: 0 }
- rule_name: "5小时滚动窗口"
quota_total: 50
quota_unit: requests
refresh_type: fixed_interval
interval_hours: 5
- name: "MiniMax Token Plan"
provider: minimax
api_key: ""
api_base: "https://api.minimax.chat/v1"
plan_type: token
supported_models: ["MiniMax-Text-01", "abab6.5s-chat"]
extra_headers: {}
extra_config: {}
quota_rules:
- rule_name: "月额度"
quota_total: 10000000
quota_unit: tokens
refresh_type: calendar_cycle
calendar_unit: monthly
calendar_anchor: { day: 1, hour: 0 }
- rule_name: "13小时滚动窗口"
quota_total: 1000000
quota_unit: tokens
refresh_type: fixed_interval
interval_hours: 13
- name: "GPT Go Plan"
provider: openai
api_key: ""
api_base: "https://api.openai.com/v1"
plan_type: coding
supported_models: ["gpt-4o", "gpt-4o-mini", "o3-mini"]
extra_headers: {}
extra_config: {}
quota_rules:
- rule_name: "月额度"
quota_total: 10000000
quota_unit: tokens
refresh_type: calendar_cycle
calendar_unit: monthly
calendar_anchor: { day: 1, hour: 0 }
- name: "Google One AI Premium"
provider: google
api_key: ""
api_base: "https://generativelanguage.googleapis.com/v1beta"
plan_type: subscription
supported_models: ["gemini-2.0-flash", "gemini-2.0-pro"]
extra_headers: {}
extra_config: {}
quota_rules:
- rule_name: "每日请求限制"
quota_total: 1500
quota_unit: requests
refresh_type: calendar_cycle
calendar_unit: daily
calendar_anchor: { hour: 0 }
- name: "智谱 Coding Plan"
provider: zhipu
api_key: ""
api_base: "https://open.bigmodel.cn/api/paas/v4"
plan_type: coding
supported_models: ["glm-4-plus", "glm-4-flash"]
extra_headers: {}
extra_config: {}
quota_rules:
- rule_name: "月额度"
quota_total: 5000000
quota_unit: tokens
refresh_type: api_sync

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
plan-manager:
build: .
container_name: plan-manager
ports:
- "8080:8080"
volumes:
- ./config.yaml:/app/config.yaml:ro
- plan-data:/app/data
environment:
- CONFIG_PATH=/app/config.yaml
restart: unless-stopped
volumes:
plan-data:

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
aiosqlite>=0.20.0
pyyaml>=6.0
httpx>=0.28.0
pydantic>=2.10.0
cryptography>=44.0.0