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:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
15
Dockerfile
Normal 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
123
README.md
Normal 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
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
72
app/config.py
Normal file
72
app/config.py
Normal 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
479
app/database.py
Normal 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_id(fallback 逻辑)"""
|
||||
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
52
app/main.py
Normal 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
165
app/models.py
Normal 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
50
app/providers/__init__.py
Normal 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
87
app/providers/base.py
Normal 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
106
app/providers/google.py
Normal 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
46
app/providers/kimi.py
Normal 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
88
app/providers/minimax.py
Normal 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
|
||||
56
app/providers/openai_provider.py
Normal file
56
app/providers/openai_provider.py
Normal 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
67
app/providers/zhipu.py
Normal 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
1
app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
133
app/routers/plans.py
Normal file
133
app/routers/plans.py
Normal 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
202
app/routers/proxy.py
Normal 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
46
app/routers/queue.py
Normal 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
51
app/routers/quota.py
Normal 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
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
app/services/queue_service.py
Normal file
17
app/services/queue_service.py
Normal 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
|
||||
46
app/services/quota_service.py
Normal file
46
app/services/quota_service.py
Normal 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
256
app/services/scheduler.py
Normal 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
507
app/static/index.html
Normal 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
99
config.yaml
Normal 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
15
docker-compose.yml
Normal 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
7
requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user