feat(proxy): T-X1 预算四档渐进干预(采纳 ai-model-router budgets 设计)
- billing.BillingMixin 新增 budget_mode(student, est, ts):整数基点判定 normal(<80%) / optimize(>=80%) / cheap(>=95%) / block(>100%); cap<=0 不限额恒 normal;学生缺失 normal(扣费与拒绝权威仍在 try_hold) - routes._run_chat:optimize/cheap 档自动降档池条目(_downgrade_entry, TIERS 排名制降档、杜绝反向升档、跳过停用与 mock),重算 model/est; 非正常档位经 X-Budget-Mode 响应头如实上报(缓存命中路径同样携带) - 黄金用例锁死边界:7999/8000/9499/9500/10000/10001bp 九组参数化断言, 全整数运算无浮点漂移;跨日重置与不限额口径与 try_hold 一致 pytest 443 passed(基线 425 + 18)
This commit is contained in:
@@ -21,6 +21,10 @@ _SQL_HOLD = """UPDATE students SET
|
||||
spent_today_milli = ?, spent_date = ?
|
||||
WHERE id = ? AND balance_milli >= ?"""
|
||||
|
||||
# 预算四档阈值(T-X1,基点 bp:1% = 100bp;整数比较,杜绝浮点边界漂移)
|
||||
BUDGET_OPTIMIZE_BP = 8000 # >= 80% 建议降一档
|
||||
BUDGET_CHEAP_BP = 9500 # >= 95% 强制最低档
|
||||
|
||||
_SQL_HOLD_MARK = """INSERT INTO usage_ledger
|
||||
(request_id, ts, key_id, model, bucket, charged_milli, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'holding')"""
|
||||
@@ -132,6 +136,41 @@ class BillingMixin:
|
||||
" WHERE request_id = ?", (status, request_id))
|
||||
return True
|
||||
|
||||
# ---------- 预算四档(T-X1) ----------
|
||||
def budget_mode(self, student_id: int, est_milli: int, ts: float) -> str:
|
||||
"""预算档位判定:normal / optimize / cheap / block。
|
||||
|
||||
以「当日已 spent + 本次预估」占日上限的比例判定(基点 bp,全整数运算,
|
||||
无浮点边界漂移;黄金用例锁死):
|
||||
< 8000bp(80%) -> normal(现行行为)
|
||||
>= 8000bp(80%) -> optimize(调用方应降一档模型)
|
||||
>= 9500bp(95%) -> cheap(调用方应强制最低档)
|
||||
> 10000bp(100%) -> block(try_hold 的硬拒绝语义兜底)
|
||||
daily_cap_milli <= 0 视为不限额 -> 恒 normal;学生不存在 -> normal
|
||||
(try_hold 才是扣费与拒绝的唯一权威,本方法只做档位建议)。
|
||||
"""
|
||||
today = _today(ts)
|
||||
with self._lock, self._connect() as conn:
|
||||
stu = conn.execute(
|
||||
"SELECT daily_cap_milli, spent_today_milli, spent_date"
|
||||
" FROM students WHERE id = ?", (student_id,)).fetchone()
|
||||
if stu is None:
|
||||
return "normal"
|
||||
cap = int(stu["daily_cap_milli"] or 0)
|
||||
if cap <= 0:
|
||||
return "normal"
|
||||
spent = int(stu["spent_today_milli"] or 0) \
|
||||
if stu["spent_date"] == today else 0
|
||||
projected = spent + max(0, int(est_milli))
|
||||
projected_bp = projected * 10000 // cap
|
||||
if projected_bp > 10000:
|
||||
return "block"
|
||||
if projected_bp >= BUDGET_CHEAP_BP:
|
||||
return "cheap"
|
||||
if projected_bp >= BUDGET_OPTIMIZE_BP:
|
||||
return "optimize"
|
||||
return "normal"
|
||||
|
||||
# ---------- 流水查询 ----------
|
||||
def get_usage(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
with self._lock, self._connect() as conn:
|
||||
|
||||
+63
-8
@@ -267,6 +267,41 @@ def _resolve_entry(pool, model: str, cfg: ProxyConfig) -> Optional[Dict[str, Any
|
||||
return None
|
||||
|
||||
|
||||
def _budget_headers(budget_mode: str) -> Dict[str, str]:
|
||||
"""预算档位响应头(normal 不发,避免噪音)。"""
|
||||
return {"X-Budget-Mode": budget_mode} if budget_mode and budget_mode != "normal" else {}
|
||||
|
||||
|
||||
def _downgrade_entry(pool, entry: Dict[str, Any], mode: str) -> Optional[Dict[str, Any]]:
|
||||
"""预算降档(T-X1):在池内找恰好低一档/最低档的启用条目。
|
||||
|
||||
档位序 TIERS = (local, budget, premium):
|
||||
- optimize:降一档(premium->budget,budget->local);
|
||||
- cheap:直落最低档 local;
|
||||
- 已在 local / 找不到该档启用条目 -> None(保持原条目,档位语义照常上报)。
|
||||
"""
|
||||
from gateway.model_pool import TIERS
|
||||
tier = str(entry.get("tier") or "")
|
||||
if tier not in TIERS:
|
||||
return None
|
||||
rank = TIERS.index(tier)
|
||||
if mode == "cheap":
|
||||
target = 0
|
||||
elif mode == "optimize":
|
||||
target = rank - 1
|
||||
else:
|
||||
return None
|
||||
if target < 0:
|
||||
return None
|
||||
want = TIERS[target]
|
||||
for e in pool.list().get("entries", []):
|
||||
if (e.get("enabled") and e.get("id") != entry.get("id")
|
||||
and e.get("tier") == want
|
||||
and e.get("backend") not in ("mock",) and e.get("base_url")):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
_semcache_instances: Dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -312,6 +347,21 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
|
||||
if isinstance(body.get("stream_options"), dict) else False
|
||||
is_stream = bool(body.get("stream"))
|
||||
|
||||
# ---- 预算四档(T-X1):接近日上限渐进降档;>100% 仍由 try_hold 硬拒 ----
|
||||
budget_mode = "normal"
|
||||
try:
|
||||
est_probe = _estimate_hold_milli(body, cfg, model)
|
||||
budget_mode = await asyncio.to_thread(
|
||||
ledger.budget_mode, ctx["student_id"], est_probe, ts)
|
||||
except Exception:
|
||||
budget_mode = "normal"
|
||||
if budget_mode in ("optimize", "cheap"):
|
||||
down = _downgrade_entry(pool, entry, budget_mode)
|
||||
if down is not None:
|
||||
entry = down
|
||||
model = str(entry.get("model") or model)
|
||||
body = {**body, "model": model}
|
||||
|
||||
# ---- 缓存分支(T-P6,§7 时序):仅缓存准入(stop+单轮)查询 ----
|
||||
cacheable = False
|
||||
cache = None
|
||||
@@ -350,7 +400,8 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
|
||||
replay(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-Cache": "HIT",
|
||||
"X-Request-Id": request_id})
|
||||
"X-Request-Id": request_id,
|
||||
**_budget_headers(budget_mode)})
|
||||
return JSONResponse({
|
||||
"id": f"chatcmpl-{request_id}", "object": "chat.completion",
|
||||
"created": int(time.time()), "model": model,
|
||||
@@ -360,7 +411,8 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
|
||||
"finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0,
|
||||
"total_tokens": 0},
|
||||
}, headers={"X-Cache": "HIT", "X-Request-Id": request_id})
|
||||
}, headers={"X-Cache": "HIT", "X-Request-Id": request_id,
|
||||
**_budget_headers(budget_mode)})
|
||||
raise BalanceError("余额或当日额度不足")
|
||||
except BalanceError:
|
||||
raise
|
||||
@@ -379,11 +431,12 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
|
||||
if is_stream:
|
||||
return await _stream_response(body, entry, sink, headers, client_wants_usage,
|
||||
request_id, ctx, cfg, ledger, model, est, t0,
|
||||
cache=cache, cacheable=cacheable)
|
||||
cache=cache, cacheable=cacheable,
|
||||
budget_mode=budget_mode)
|
||||
return await _json_response(body, entry, sink, request_id, ctx, cfg,
|
||||
ledger, model, est, t0,
|
||||
cache=cache, cacheable=cacheable,
|
||||
headers=headers)
|
||||
headers=headers, budget_mode=budget_mode)
|
||||
except UpstreamAborted as e:
|
||||
# 流中失败:按已收 usage 结算(无 usage 按字符估算),不缓存(D-P4)
|
||||
usage = sink.get("usage") or _estimate_usage_from_sink(sink)
|
||||
@@ -412,7 +465,7 @@ def _estimate_usage_from_sink(sink: Dict[str, Any]) -> Dict[str, int]:
|
||||
|
||||
async def _stream_response(body, entry, sink, headers, client_wants_usage,
|
||||
request_id, ctx, cfg, ledger, model, est, t0,
|
||||
cache=None, cacheable=False):
|
||||
cache=None, cacheable=False, budget_mode: str = "normal"):
|
||||
usage = {"in_miss": 0, "in_hit": 0, "out": 0}
|
||||
|
||||
async def gen():
|
||||
@@ -459,12 +512,13 @@ async def _stream_response(body, entry, sink, headers, client_wants_usage,
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-Request-Id": request_id})
|
||||
"X-Request-Id": request_id,
|
||||
**_budget_headers(budget_mode)})
|
||||
|
||||
|
||||
async def _json_response(body, entry, sink, request_id, ctx, cfg, ledger,
|
||||
model, est, t0, cache=None, cacheable=False,
|
||||
headers=None):
|
||||
headers=None, budget_mode: str = "normal"):
|
||||
parts = []
|
||||
async for raw_bytes in upstream_stream(body, entry, sink, [entry]):
|
||||
line = raw_bytes.decode("utf-8").strip()
|
||||
@@ -521,4 +575,5 @@ async def _json_response(body, entry, sink, request_id, ctx, cfg, ledger,
|
||||
"completion_tokens": usage.get("out", 0),
|
||||
"total_tokens": usage.get("in_miss", 0) + usage.get("in_hit", 0)
|
||||
+ usage.get("out", 0)},
|
||||
}, headers={"X-Request-Id": request_id})
|
||||
}, headers={"X-Request-Id": request_id,
|
||||
**_budget_headers(budget_mode)})
|
||||
|
||||
Reference in New Issue
Block a user