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
+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),
)