feat(proxy): T-P0 代理层骨架(包结构/DDL/门控挂载/单进程校验)

- gateway/proxy/ 十文件:config(ProxyConfig:元->毫元加载期换算 D-P1、差异化售价、
  桶/峰谷/限流/semcache 配置)/ errors(8 类 HTTP 语义异常)/ ledger(四表两索引 DDL,
  WAL,ReviewQueue 连接纪律)/ auth·pricing·normalizer·semcache·upstream(§6 签名占位)
  / routes(/proxy/v1/models OpenAI 形状)/ __init__(build_proxy_router 组装点)
- settings DEFAULTS 增 proxy 段(enabled 默认 False,D-P7)
- api.py 首次 include_router(enabled 门控 + 装配失败不拖垮主应用)
- serve.py workers>1 拒绝启动(D-P9:WEB_CONCURRENCY/UVICORN_WORKERS 校验)
- 测试 +4(门控 404/DDL 幂等/毫元换算/池模型列表),全量 322 passed
This commit is contained in:
tzt
2026-09-05 08:35:31 +08:00
parent 3cc9851623
commit b109576707
15 changed files with 580 additions and 1 deletions
+17
View File
@@ -0,0 +1,17 @@
"""校园 AI 代理层(T-P0…T-P8):/proxy/v1/* 学生面 + /proxy/admin/* 管理面。
组装唯一入口:build_proxy_router(cfg, pool) -> APIRouter
由 gateway.api 在 proxy.enabled 时 includeD-P7:关闭时不注册任何路由)。
"""
from __future__ import annotations
from fastapi import APIRouter
from gateway.proxy.config import ProxyConfig, build_proxy_config # noqa: F401
__all__ = ["build_proxy_router", "build_proxy_config", "ProxyConfig"]
def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
from gateway.proxy.routes import build_proxy_router as _build
return _build(cfg, pool)
+22
View File
@@ -0,0 +1,22 @@
"""鉴权与限流(T-P1 落地;本文件先立签名)。"""
from __future__ import annotations
from typing import Any, Dict, Optional
def issue_key(ledger, student_id: int, rpm_cap: Optional[int] = None,
day_cap_req: Optional[int] = None) -> Dict[str, Any]:
"""签发学生代理 key:明文只返回一次(T-P1 实现)。"""
raise NotImplementedError("T-P1")
def authenticate(authorization: str, ledger, limits, now: float) -> Dict[str, Any]:
"""校验 Bearer key -> 学生上下文(T-P1 实现;带热路径缓存)。"""
raise NotImplementedError("T-P1")
class RateLimiter:
"""令牌桶 rpm + per-key 并发信号量(T-P1 实现)。"""
def allow(self, key_id: int, rpm_cap: int) -> bool:
raise NotImplementedError("T-P1")
+164
View File
@@ -0,0 +1,164 @@
"""ProxyConfig:从 settings 的 proxy 段构建代理层配置(T-P0)。
锁定决策落地:
- D-P1 货币:账本毫元整数。配置里价格是浮点"元/1M tokens"(人类可读),
**加载期一次性转为整数毫元/1M tokens**,运行期纯整数运算(毫元漂移只可能
发生在这一次换算,黄金用例锁死)。
- 售价折扣采用差异化 {in: 0.5, out: 0.8}(设计文档 §2.5 结论:统一 5 折
盈亏平衡 h0≈32% 结构性危险;执行版 §4 示例与之冲突,按设计文档取差异化,
纯配置可热改)。
- D-P7enabled=False 时不注册任何 /proxy 路由。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
# 元 -> 毫元换算系数(D-P11 元 = 1000 毫元)
YUAN_TO_MILLI = 1000
@dataclass
class ModelPrice:
"""单一模型的整数价格表(毫元 / 1M tokens)。"""
in_miss: int
in_hit: int
out: int
@dataclass
class BucketCfg:
"""一个课程桶的配置。"""
name: str
system_template: str = "你是校园学习助手。"
doc_prefix_file: Optional[str] = None
doc_version: int = 1
ttl_hours: int = 72
@dataclass
class ProxyConfig:
"""代理层运行配置(全部为加载期定型值)。"""
enabled: bool = False
admin_key: str = ""
db_path: str = "data/proxy.sqlite3"
buckets: Dict[str, BucketCfg] = field(default_factory=dict)
model_prices: Dict[str, ModelPrice] = field(default_factory=dict)
peak_start: str = "08:30"
peak_end: str = "23:59"
offpeak_factor: float = 0.5
sale_in: float = 0.5
sale_out: float = 0.8
rpm_per_key: int = 10
day_req_cap: int = 200
concurrent_per_key: int = 2
max_body_chars: int = 60000
semcache_enabled: bool = True
sim_threshold: float = 0.92
max_entries: int = 300000
promote_frequency: int = 5
def bucket(self, name: str) -> BucketCfg:
"""取桶配置;未知名回落 default(D-P2 缺省桶)。"""
if name in self.buckets:
return self.buckets[name]
return self.buckets.get("default") or BucketCfg(name="default")
def price(self, model: str) -> Optional[ModelPrice]:
"""取模型价格表;未配置返回 None(调用方应拒绝或用 default 档)。"""
return self.model_prices.get(model)
def _to_milli_per_m(yuan_per_m: float) -> int:
"""元/1M -> 毫元/1M(四舍五入取整,加载期唯一换算点)。"""
return int(round(float(yuan_per_m) * YUAN_TO_MILLI))
def _parse_hhmm(text: str, fallback: str) -> str:
"""校验 HH:MM 形态,非法回落默认。"""
t = str(text or fallback).strip()
parts = t.split(":")
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
h, m = int(parts[0]), int(parts[1])
if 0 <= h <= 23 and 0 <= m <= 59:
return f"{h:02d}:{m:02d}"
return fallback
def build_proxy_config(settings_dict: Dict[str, Any]) -> ProxyConfig:
"""从 settings.to_dict() 的 proxy 段构建 ProxyConfig(缺省值兜底)。
settings_dict 为整个设置 dict(含 worker/architect/... 与 proxy 段)。
"""
raw = settings_dict.get("proxy") or {}
if not isinstance(raw, dict):
raw = {}
# ---- 桶 ----
buckets: Dict[str, BucketCfg] = {}
raw_buckets = raw.get("buckets") or {}
if isinstance(raw_buckets, dict):
for name, b in raw_buckets.items():
if not isinstance(b, dict):
continue
buckets[str(name)] = BucketCfg(
name=str(name),
system_template=str(b.get("system_template") or "你是校园学习助手。"),
doc_prefix_file=(str(b["doc_prefix_file"])
if b.get("doc_prefix_file") else None),
doc_version=int(b.get("doc_version", 1) or 1),
ttl_hours=int(b.get("ttl_hours", 72) or 72),
)
if "default" not in buckets:
buckets["default"] = BucketCfg(name="default")
# ---- 价格表:元/1M 浮点 -> 毫元/1M 整数(D-P1 加载期换算) ----
model_prices: Dict[str, ModelPrice] = {}
raw_pricing = raw.get("pricing") or {}
if isinstance(raw_pricing, dict):
for model, p in raw_pricing.items():
if not isinstance(p, dict) or "in_miss" not in p:
continue # peak_window/offpeak_factor/sale_discount 非模型键
in_miss = _to_milli_per_m(p.get("in_miss", 0))
in_hit = _to_milli_per_m(p.get("in_hit", 0))
out = _to_milli_per_m(p.get("out", 0))
if in_miss <= 0:
continue
if in_hit <= 0:
in_hit = max(1, in_miss // 30) # 缺省 = in_miss × 1/30D-P3
model_prices[str(model)] = ModelPrice(
in_miss=in_miss, in_hit=in_hit, out=out)
# ---- 峰谷窗口 / 系数 ----
pw = raw_pricing.get("peak_window") or {} if isinstance(raw_pricing, dict) else {}
off = float(raw_pricing.get("offpeak_factor", 0.5)) if isinstance(raw_pricing, dict) else 0.5
sale = raw_pricing.get("sale_discount") or {} if isinstance(raw_pricing, dict) else {}
# ---- 限流 / 缓存 ----
limits = raw.get("limits") or {}
if not isinstance(limits, dict):
limits = {}
sem = raw.get("semcache") or {}
if not isinstance(sem, dict):
sem = {}
return ProxyConfig(
enabled=bool(raw.get("enabled", False)),
admin_key=str(raw.get("admin_key") or ""),
db_path=str(raw.get("db_path") or "data/proxy.sqlite3"),
buckets=buckets,
model_prices=model_prices,
peak_start=_parse_hhmm(pw.get("start"), "08:30") if isinstance(pw, dict) else "08:30",
peak_end=_parse_hhmm(pw.get("end"), "23:59") if isinstance(pw, dict) else "23:59",
offpeak_factor=max(0.0, min(1.0, off)),
sale_in=max(0.0, min(1.0, float(sale.get("in", 0.5)))),
sale_out=max(0.0, min(1.0, float(sale.get("out", 0.8)))),
rpm_per_key=int(limits.get("rpm_per_key", 10) or 10),
day_req_cap=int(limits.get("day_req_cap", 200) or 200),
concurrent_per_key=int(limits.get("concurrent_per_key", 2) or 2),
max_body_chars=int(limits.get("max_body_chars", 60000) or 60000),
semcache_enabled=bool(sem.get("enabled", True)),
sim_threshold=float(sem.get("sim_threshold", 0.92) or 0.92),
max_entries=int(sem.get("max_entries", 300000) or 300000),
promote_frequency=int(sem.get("promote_frequency", 5) or 5),
)
+50
View File
@@ -0,0 +1,50 @@
"""代理层错误类型(§5.3 错误码映射)。"""
from __future__ import annotations
class ProxyError(Exception):
"""代理层错误基类(HTTP 语义由子类表达)。"""
status_code = 500
code = "proxy_error"
class ProxyAuthError(ProxyError):
"""无效/注销 key。"""
status_code = 401
code = "invalid_key"
class BalanceError(ProxyError):
"""余额或日上限不足(预扣失败)。"""
status_code = 402
code = "insufficient_balance"
class SuspendedError(ProxyError):
"""学生账户被停用。"""
status_code = 403
code = "student_suspended"
class QuotaError(ProxyError):
"""限流(rpm / 日请求上限 / 并发上限)。"""
status_code = 429
code = "rate_limited"
class BodyTooLargeError(ProxyError):
"""请求体超过 max_body_chars。"""
status_code = 413
code = "body_too_large"
class UpstreamError(ProxyError):
"""上游失败(首 token 前 failover 均失败)。"""
status_code = 502
code = "upstream_failed"
class AdminAuthError(ProxyError):
"""管理面鉴权失败。"""
status_code = 401
code = "admin_unauthorized"
+92
View File
@@ -0,0 +1,92 @@
"""账本(T-P0:DDL 初始化 + 连接纪律;CRUD/预扣在 T-P1 落地)。
工程纪律:
- D-P10sqlite3 是同步库,所有 DB 调用必须经 asyncio.to_thread(由调用方
routes 层包装;本模块保持同步实现,可测试性好)。连接 check_same_thread=False
+ threading.Lock 串行化(沿用 ReviewQueue 模式:每操作新连接 + 全局锁)。
- WAL 模式(§3)。
- D-P11:预扣用原子 UPDATE,见 try_holdT-P1 实现)。
"""
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
# 四表 + 两索引(§3,一字不改)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS students(
id INTEGER PRIMARY KEY, name TEXT NOT NULL, class TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
balance_milli INTEGER NOT NULL DEFAULT 0,
daily_cap_milli INTEGER NOT NULL DEFAULT 5000,
spent_today_milli INTEGER NOT NULL DEFAULT 0, spent_date TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS proxy_keys(
id INTEGER PRIMARY KEY, key_hash TEXT UNIQUE NOT NULL, key_prefix TEXT NOT NULL,
student_id INTEGER NOT NULL REFERENCES students(id),
created_ts INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0,
rpm_cap INTEGER NOT NULL DEFAULT 10, day_cap_req INTEGER NOT NULL DEFAULT 200,
req_today INTEGER NOT NULL DEFAULT 0, req_date TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS usage_ledger(
request_id TEXT PRIMARY KEY, ts INTEGER NOT NULL, key_id INTEGER NOT NULL,
model TEXT NOT NULL, bucket TEXT NOT NULL DEFAULT 'default',
in_miss_tok INTEGER NOT NULL DEFAULT 0, in_hit_tok INTEGER NOT NULL DEFAULT 0,
out_tok INTEGER NOT NULL DEFAULT 0, gateway_cached INTEGER NOT NULL DEFAULT 0,
upstream_cost_milli INTEGER NOT NULL DEFAULT 0, charged_milli INTEGER NOT NULL DEFAULT 0,
margin_milli INTEGER NOT NULL DEFAULT 0, ttfb_ms INTEGER, total_ms INTEGER,
status TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS semcache(
cache_key TEXT PRIMARY KEY,
bucket TEXT NOT NULL, q_norm TEXT NOT NULL, answer TEXT NOT NULL, model TEXT NOT NULL,
created_ts INTEGER NOT NULL, ttl_ts INTEGER NOT NULL,
doc_version INTEGER NOT NULL DEFAULT 1, hits INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS idx_semcache_bucket ON semcache(bucket, ttl_ts);
CREATE INDEX IF NOT EXISTS idx_ledger_ts ON usage_ledger(ts);
"""
_TABLES = ("students", "proxy_keys", "usage_ledger", "semcache")
class Ledger:
"""代理层账本(sqlite WAL;同步实现,调用方负责 to_thread)。"""
def __init__(self, db_path: str | Path):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._init_db()
# ---------- 初始化 ----------
@classmethod
def init_db(cls, db_path: str | Path) -> "Ledger":
"""工厂(§6 签名):建库建表(幂等)。"""
return cls(db_path)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_db(self) -> None:
with self._lock, self._connect() as conn:
conn.executescript(_SCHEMA)
# ---------- 自省(T-P0 验收用) ----------
def table_names(self) -> List[str]:
"""列出已建表名(测试验收)。"""
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()
return [r["name"] for r in rows]
def index_names(self) -> List[str]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'"
).fetchall()
return [r["name"] for r in rows]
+19
View File
@@ -0,0 +1,19 @@
"""请求规范化与桶解析(T-P5 落地;本文件先立签名)。"""
from __future__ import annotations
from typing import Any, Dict
def resolve_bucket(body: dict, headers: dict, cfg) -> Any:
"""解析课程桶:X-Campus-Bucket 头 > model->bucket 映射 > defaultT-P5 实现)。"""
raise NotImplementedError("T-P5")
def canonical_hash(bucket: str, doc_version: int, body: dict) -> str:
"""规范化哈希:稳定序列化 -> sha256(五规则见 §6T-P5 实现)。"""
raise NotImplementedError("T-P5")
def shape(body: dict, bucket_cfg) -> dict:
"""整形上游请求体:[canonical_system] -> [doc_prefix] -> [原 messages]T-P5 实现)。"""
raise NotImplementedError("T-P5")
+17
View File
@@ -0,0 +1,17 @@
"""计价(T-P3 落地;本文件先立签名)。
D-P1:compute 输入输出全为整数毫元;配置换算在 config 加载期完成。
"""
from __future__ import annotations
from typing import Any, Dict
def is_offpeak(ts: float, peak_start: str, peak_end: str) -> bool:
"""峰谷窗口判定(本地时区 HH:MM;T-P3 实现)。"""
raise NotImplementedError("T-P3")
def compute(usage: Dict[str, int], model: str, ts: float, cfg) -> Dict[str, int]:
"""按 usage 计费 -> {upstream_cost_milli, charged_milli, margin_milli}T-P3 实现)。"""
raise NotImplementedError("T-P3")
+28
View File
@@ -0,0 +1,28 @@
"""代理面路由(T-P0 骨架:/proxy/v1/models;主对话路由 T-P4 落地)。"""
from __future__ import annotations
from fastapi import APIRouter, Request
from gateway.proxy.config import ProxyConfig
def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
"""组装代理面路由(唯一组装点;pool 为 gateway.model_pool.PoolStore)。"""
router = APIRouter(prefix="/proxy")
@router.get("/v1/models", tags=["proxy"])
async def list_models():
"""学生面:池内允许代理的模型列表(OpenAI /models 形状)。"""
entries = pool.list().get("entries", [])
names = [e["model"] for e in entries
if e.get("enabled") and e.get("backend") not in ("mock",)]
# 去重保序
seen, data = set(), []
for n in names:
if n not in seen:
seen.add(n)
data.append({"id": n, "object": "model", "owned_by": "campus-proxy"})
return {"object": "list", "data": data}
# /v1/chat/completions 与 /admin/* 在 T-P1/T-P4 追加
return router
+24
View File
@@ -0,0 +1,24 @@
"""语义缓存(T-P6 落地;本文件先立签名)。
规格(§6):L1 精确 + L2 字符 2/3-gram 倒排(启动自 sqlite 重建),
加权 Jaccard3-gram 权 2+ 共享 gram>=3 门限 + 阈值 0.92
TTL + LRU(max_entries)L2 命中 promote_frequency 次晋升 L1。
"""
from __future__ import annotations
from typing import Any, Dict, Optional
class SemanticCache:
"""两级语义缓存(T-P6 实现)。"""
def lookup(self, bucket: str, doc_version: int, norm_hash: str,
norm_text: str, now: float) -> Optional[Dict[str, Any]]:
raise NotImplementedError("T-P6")
def put(self, bucket: str, doc_version: int, norm_hash: str,
norm_text: str, answer: str, model: str, now: float) -> None:
raise NotImplementedError("T-P6")
def stats(self) -> Dict[str, Any]:
raise NotImplementedError("T-P6")
+21
View File
@@ -0,0 +1,21 @@
"""上游客户端(T-P2 落地;本文件先立签名)。
规格(§6):模块级 httpx.AsyncClient 单例(keepalivemax_connections=100),
超时 connect=10s/read=120s/write=10s/pool=30s;始终注入
stream_options.include_usage(客户端未要求 usage 时过滤该 chunk 不下发);
首 token 前 failoverD-P4)。
"""
from __future__ import annotations
from typing import Any, AsyncIterator, Dict
async def stream(body: dict, entry: Dict[str, Any], usage_sink) -> AsyncIterator[bytes]:
"""流式派发到上游,逐块 yield(T-P2 实现)。"""
raise NotImplementedError("T-P2")
yield b"" # pragma: no cover
def normalize_usage(provider: str, usage_dict: Dict[str, Any]) -> Dict[str, int]:
"""三家 usage 字段 -> 统一 {in_miss, in_hit, out}T-P2 实现)。"""
raise NotImplementedError("T-P2")