commit 61ce80963436a81a8e561cb721788f31beb0fec4 Author: 锦麟 王 Date: Tue Mar 31 15:50:42 2026 +0800 feat: 多平台 Coding Plan 统一管理系统初始实现 - 支持 MiniMax/OpenAI/Google Gemini/智谱/Kimi 五个平台 - 插件化 Provider 架构,自动发现注册 - 多维度 QuotaRule 额度追踪(固定间隔/自然周期/API同步/手动) - OpenAI + Anthropic 兼容 API 代理,SSE 流式转发 - Model 路由表 + 额度耗尽自动 fallback - 多媒体任务队列(图片/语音/视频) - Vue3 + Tailwind 单文件 Web 仪表盘 - Docker 一键部署 Made-with: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7076e99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +*.pyo +.env +data/ +*.db +*.db-journal +*.db-wal +.venv/ +venv/ +.idea/ +.vscode/ +.cursor/ +*.egg-info/ +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b29b18e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d85e4d --- /dev/null +++ b/README.md @@ -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 文档。 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..735dbb7 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..b942ec2 --- /dev/null +++ b/app/database.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..273a1af --- /dev/null +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..0d24078 --- /dev/null +++ b/app/models.py @@ -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 均有余量 diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..d232fc0 --- /dev/null +++ b/app/providers/__init__.py @@ -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] diff --git a/app/providers/base.py b/app/providers/base.py new file mode 100644 index 0000000..4166aa9 --- /dev/null +++ b/app/providers/base.py @@ -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("/") diff --git a/app/providers/google.py b/app/providers/google.py new file mode 100644 index 0000000..dc8c1a1 --- /dev/null +++ b/app/providers/google.py @@ -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), + }, + } diff --git a/app/providers/kimi.py b/app/providers/kimi.py new file mode 100644 index 0000000..1a9781c --- /dev/null +++ b/app/providers/kimi.py @@ -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()) diff --git a/app/providers/minimax.py b/app/providers/minimax.py new file mode 100644 index 0000000..870ebce --- /dev/null +++ b/app/providers/minimax.py @@ -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 diff --git a/app/providers/openai_provider.py b/app/providers/openai_provider.py new file mode 100644 index 0000000..459b6f0 --- /dev/null +++ b/app/providers/openai_provider.py @@ -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() diff --git a/app/providers/zhipu.py b/app/providers/zhipu.py new file mode 100644 index 0000000..7b96627 --- /dev/null +++ b/app/providers/zhipu.py @@ -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 diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ + diff --git a/app/routers/plans.py b/app/routers/plans.py new file mode 100644 index 0000000..312e41e --- /dev/null +++ b/app/routers/plans.py @@ -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} diff --git a/app/routers/proxy.py b/app/routers/proxy.py new file mode 100644 index 0000000..1ead057 --- /dev/null +++ b/app/routers/proxy.py @@ -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), + }, + } diff --git a/app/routers/queue.py b/app/routers/queue.py new file mode 100644 index 0000000..4ce682f --- /dev/null +++ b/app/routers/queue.py @@ -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} diff --git a/app/routers/quota.py b/app/routers/quota.py new file mode 100644 index 0000000..0ce5d6b --- /dev/null +++ b/app/routers/quota.py @@ -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} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/app/services/queue_service.py b/app/services/queue_service.py new file mode 100644 index 0000000..b02b5e5 --- /dev/null +++ b/app/services/queue_service.py @@ -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 diff --git a/app/services/quota_service.py b/app/services/quota_service.py new file mode 100644 index 0000000..357052e --- /dev/null +++ b/app/services/quota_service.py @@ -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 diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..3c9fc89 --- /dev/null +++ b/app/services/scheduler.py @@ -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") diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..414e5c1 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,507 @@ + + + + + +Plan Manager + + + + + + +
+ +
+
+

Plan Manager

+ +
+
+ +
+ + +
+
+
+ +
+
+

{{ p.name }}

+ + {{ p.provider_name }} + + {{ p.plan_type }} +
+ +
+ +
+
+ {{ r.rule_name }} + {{ refreshLabel(r) }} + + {{ r.quota_used }} / {{ r.quota_total }} {{ r.quota_unit }} +
+
+
+
+
+ 刷新倒计时: {{ countdown(r.next_refresh_at) }} +
+
+ +
+ + +
+
+
+
+ 暂无 Plan,请在配置页添加 +
+
+ + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
ID类型状态优先级创建时间操作
{{ t.id.slice(0,8) }}{{ t.task_type }} + {{ t.status }} + {{ t.priority }}{{ fmtTime(t.created_at) }} + +
暂无任务
+
+
+ + +
+
+ +
+
+

Plan 列表

+ +
+
+
+
+ {{ p.name }} + {{ p.provider_name }} +
+
+ + +
+
+
{{ p.api_base }}
+
+ 模型: {{ (p.supported_models || []).join(', ') || '无' }} +
+
+
+ +
+
+

Model 路由

+ +
+
+ + + + + + + + + + + + + + + + + +
ModelPlan优先级
{{ r.model_name }}{{ planName(r.plan_id) }}{{ r.priority }} + +
+
+
+
+
+
+ + +
+
+

新建任务

+ + + + + + + + +
+ + +
+
+
+ + +
+
+

{{ editingPlan ? '编辑' : '添加' }} Plan

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+

添加 Model 路由

+ + + + + + +
+ + +
+
+
+
+ + + + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..bdb8c51 --- /dev/null +++ b/config.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d6532aa --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2997fde --- /dev/null +++ b/requirements.txt @@ -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