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:
@@ -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-P7:enabled=False 时不注册任何 /proxy 路由。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# 元 -> 毫元换算系数(D-P1:1 元 = 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/30(D-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),
|
||||
)
|
||||
Reference in New Issue
Block a user