From a0afeec5cb19fd010acf7afd9dde28cf4daf0fe5 Mon Sep 17 00:00:00 2001 From: tzt <14718231+flying-travel@user.noreply.gitee.com> Date: Sat, 5 Sep 2026 09:18:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20T-P3=20=E8=AE=A1=E4=BB=B7?= =?UTF-8?q?=EF=BC=88=E5=B3=B0=E8=B0=B7=E7=AA=97=E5=8F=A3/=E6=AF=AB?= =?UTF-8?q?=E5=85=83=E6=95=B4=E6=95=B0/=E5=B7=AE=E5=BC=82=E5=8C=96?= =?UTF-8?q?=E5=94=AE=E4=BB=B7/=E9=BB=84=E9=87=91=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pricing.py:is_offpeak(含跨午夜窗口、非法回落、±1min 边界语义); compute 全整数毫元——上游成本叠加 offpeak_factor(空闲半价), 学生售价按差异化折扣(in 0.5 / out 0.8,不叠加热闲系数,全天一口价), margin = charged - cost 恒等式 - 未配置价格模型三值零(调用方可拦截);配置换算加载期唯一 - 测试 +9(窗口边界/跨午夜/回落/黄金手算 #1#2/恒等式 10 组扫/零用量/ 稳定性/未知模型/换算不漂移),全量 354 passed --- gateway/proxy/pricing.py | 78 +++++++++++++++++++-- tests/test_proxy_pricing.py | 131 ++++++++++++++++++++++++++++++++++++ 任务拆解与执行计划.md | 2 +- 3 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 tests/test_proxy_pricing.py diff --git a/gateway/proxy/pricing.py b/gateway/proxy/pricing.py index a3da201..4cd71a0 100644 --- a/gateway/proxy/pricing.py +++ b/gateway/proxy/pricing.py @@ -1,17 +1,81 @@ -"""计价(T-P3 落地;本文件先立签名)。 +"""计价(T-P3):峰谷窗口 + 毫元整数计算。 -D-P1:compute 输入输出全为整数毫元;配置换算在 config 加载期完成。 +D-P1:价格表在 ProxyConfig 加载期已是整数毫元/1M tokens;本模块全程整数运算, +唯一浮点是折扣/峰谷系数的乘法,且每一步立即取整(取整策略固定:各分项 +round 后求和,黄金用例锁死)。 + +毛利恒等式:margin_milli = charged_milli - upstream_cost_milli(测试断言)。 """ from __future__ import annotations +import time from typing import Any, Dict +from gateway.proxy.ledgerutil import YUAN_TO_MILLI -def is_offpeak(ts: float, peak_start: str, peak_end: str) -> bool: - """峰谷窗口判定(本地时区 HH:MM;T-P3 实现)。""" - raise NotImplementedError("T-P3") + +def _hhmm_to_minutes(text: str, fallback: int) -> int: + parts = str(text or "").strip().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 h * 60 + m + return fallback + + +def is_offpeak(ts: float, peak_start: str = "08:30", + peak_end: str = "23:59") -> bool: + """峰谷窗口判定(本地时区 HH:MM;窗口含头含尾:start<=t<=end 为高峰)。 + + 支持跨午夜窗口(如 start=23:00, end=06:00)。 + """ + lt = time.localtime(ts) + cur = lt.tm_hour * 60 + lt.tm_min + s = _hhmm_to_minutes(peak_start, 8 * 60 + 30) + e = _hhmm_to_minutes(peak_end, 23 * 60 + 59) + if s <= e: + return not (s <= cur <= e) + # 跨午夜:高峰 = [s, 24h) ∪ [0, e] + return not (cur >= s or cur <= e) + + +def _apply_factor(milli: int, factor: float) -> int: + return int(round(milli * factor)) 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") + """按 usage 计费(§6 签名)-> CostBreakdown(全整数毫元)。 + + usage: {in_miss, in_hit, out}(tokens) + 上游成本 = (in_miss × P_miss + in_hit × P_hit + out × P_out) / 1M, + 峰谷系数只作用于上游成本(空闲时段上游半价);学生售价 = 上游分项价 × + sale_discount(差异化 in/out 折扣,秒杀"输出固定亏 50%"问题), + 售价不叠加热闲系数(对学生全天一口价)。 + """ + price = cfg.price(model) + if price is None: + # 未配置价格模型:不收费不记成本(free tier),调用方可自行拦截 + return {"upstream_cost_milli": 0, "charged_milli": 0, "margin_milli": 0} + + factor = 1.0 if not is_offpeak(ts, cfg.peak_start, cfg.peak_end) else cfg.offpeak_factor + + # ---- 上游成本(毫元,每 1M tokens = 1e6)---- + in_miss_cost = usage.get("in_miss", 0) * price.in_miss / 1_000_000 + in_hit_cost = usage.get("in_hit", 0) * price.in_hit / 1_000_000 + out_cost = usage.get("out", 0) * price.out / 1_000_000 + cost = _apply_factor(in_miss_cost + in_hit_cost + out_cost, factor) + + # ---- 学生售价(分项折扣后求和,round 各项;无峰谷系数)---- + charged_in_miss = int(round(usage.get("in_miss", 0) * price.in_miss + * cfg.sale_in / 1_000_000)) + charged_in_hit = int(round(usage.get("in_hit", 0) * price.in_hit + * cfg.sale_in / 1_000_000)) + charged_out = int(round(usage.get("out", 0) * price.out + * cfg.sale_out / 1_000_000)) + charged = charged_in_miss + charged_in_hit + charged_out + + return { + "upstream_cost_milli": int(cost), + "charged_milli": int(charged), + "margin_milli": int(charged) - int(cost), + } diff --git a/tests/test_proxy_pricing.py b/tests/test_proxy_pricing.py new file mode 100644 index 0000000..dcff921 --- /dev/null +++ b/tests/test_proxy_pricing.py @@ -0,0 +1,131 @@ +"""计价测试(T-P3):峰谷边界(假时钟 ±1 分钟)/ 毫元取整不漂移 / margin 恒等式。""" +import time + +import pytest + +from gateway.proxy.config import ModelPrice, ProxyConfig, build_proxy_config +from gateway.proxy.pricing import compute, is_offpeak +from gateway.settings import DEFAULTS + + +@pytest.fixture() +def cfg(): + return build_proxy_config(DEFAULTS) + + +# ---------------- 峰谷窗口 ---------------- + +def _ts(y, mo, d, h, mi): + return time.mktime((y, mo, d, h, mi, 0, 0, 0, -1)) + + +def test_offpeak_boundaries_plus_minus_one_minute(): + """窗口 [08:30, 23:59]:边界 ±1 分钟翻转。""" + base = _ts(2026, 9, 1, 8, 29) + assert is_offpeak(base, "08:30", "23:59") is True # 08:29 空闲 + assert is_offpeak(base + 60, "08:30", "23:59") is False # 08:30 高峰 + base2 = _ts(2026, 9, 1, 23, 58) + assert is_offpeak(base2, "08:30", "23:59") is False # 23:58 高峰 + assert is_offpeak(base2 + 120, "08:30", "23:59") is True # 00:00 次日空闲 + + +def test_offpeak_overnight_window(): + """跨午夜窗口 [23:00, 06:00]:中午空闲、深夜高峰。""" + noon = _ts(2026, 9, 1, 12, 0) + deep = _ts(2026, 9, 1, 23, 30) + early = _ts(2026, 9, 1, 5, 59) + assert is_offpeak(noon, "23:00", "06:00") is True + assert is_offpeak(deep, "23:00", "06:00") is False + assert is_offpeak(early, "23:00", "06:00") is False + + +def test_offpeak_invalid_window_falls_back(): + """非法窗口回落默认 08:30-23:59。""" + noon = _ts(2026, 9, 1, 12, 0) + assert is_offpeak(noon, "garbage", "!!") is False + assert is_offpeak(_ts(2026, 9, 1, 7, 0), "garbage", "!!") is True + + +# ---------------- 计算与取整 ---------------- + +USAGE = {"in_miss": 3_000_000, "in_hit": 1_000_000, "out": 800_000} + + +def test_compute_peak_math_exact(cfg): + """高峰:成本/售价逐项可手算(黄金用例 #1)。 + + deepseek-chat:in_miss=3000, in_hit=100, out=9000(毫元/1M) + 成本 = 3M×3000/1M + 1M×100/1M + 0.8M×9000/1M = 9000+100+7200 = 16300 毫元 + 售价 = 9000×0.5 + 100×0.5 + 7200×0.8 = 4500+50+5760 = 10310 毫元 + """ + ts = _ts(2026, 9, 1, 12, 0) # 高峰 + r = compute(USAGE, "deepseek-chat", ts, cfg) + assert r["upstream_cost_milli"] == 16300 + assert r["charged_milli"] == 10310 + assert r["margin_milli"] == r["charged_milli"] - r["upstream_cost_milli"] + + +def test_compute_offpeak_half_cost(cfg): + """空闲:上游成本半价(offpeak_factor=0.5),售价不变(黄金用例 #2)。""" + ts = _ts(2026, 9, 2, 3, 0) # 空闲 + r = compute(USAGE, "deepseek-chat", ts, cfg) + assert r["upstream_cost_milli"] == 8150 # 16300 × 0.5 + assert r["charged_milli"] == 10310 + assert r["margin_milli"] == 2160 + + +def test_margin_identity_holds_for_golden_cases(cfg): + """margin = charged - cost 恒等式(10 组黄金用例,量级/零/大数扫)。""" + ts = _ts(2026, 9, 1, 12, 0) + cases = [ + {"in_miss": 0, "in_hit": 0, "out": 0}, + {"in_miss": 1, "in_hit": 0, "out": 0}, + {"in_miss": 0, "in_hit": 1, "out": 0}, + {"in_miss": 0, "in_hit": 0, "out": 1}, + {"in_miss": 1000, "in_hit": 0, "out": 0}, + {"in_miss": 0, "in_hit": 1000, "out": 0}, + {"in_miss": 0, "in_hit": 0, "out": 4096}, + {"in_miss": 3500, "in_hit": 1500, "out": 800}, + {"in_miss": 50_000, "in_hit": 10_000, "out": 10_000}, # 智能体任务 + {"in_miss": 1_000_000, "in_hit": 1_000_000, "out": 100_000}, + ] + for usage in cases: + r = compute(usage, "deepseek-chat", ts, cfg) + assert r["margin_milli"] == r["charged_milli"] - r["upstream_cost_milli"] + assert r["upstream_cost_milli"] >= 0 and r["charged_milli"] >= 0 + # 零用量 -> 三值全零 + assert compute(cases[0], "deepseek-chat", ts, cfg) == { + "upstream_cost_milli": 0, "charged_milli": 0, "margin_milli": 0} + + +def test_no_drift_integer_only(cfg): + """取整不漂移:分项 round 后求和,重复计算结果稳定一致。""" + ts = _ts(2026, 9, 1, 12, 0) + usage = {"in_miss": 123_456, "in_hit": 65_432, "out": 7_777} + r1 = compute(usage, "deepseek-chat", ts, cfg) + r2 = compute(usage, "deepseek-chat", ts, cfg) + assert r1 == r2 + for v in r1.values(): + assert isinstance(v, int) + + +def test_unknown_model_free(cfg): + """未配置价格的模型 -> 三值零(调用方可拦截)。""" + ts = _ts(2026, 9, 1, 12, 0) + assert compute(USAGE, "no-such-model", ts, cfg) == { + "upstream_cost_milli": 0, "charged_milli": 0, "margin_milli": 0} + + +def test_config_milli_conversion_no_drift(): + """配置换算唯一性:元/1M 浮点 -> 毫元/1M 整数只发生一次。""" + c = build_proxy_config({"proxy": {"pricing": { + "m1": {"in_miss": 0.003, "in_hit": 0.0001, "out": 0.009}, + "m2": {"in_miss": 12.3456, "out": 1.5}}}}) + assert c.price("m1").in_miss == 3 + assert c.price("m1").in_hit == 1 # 0.0001 元 -> 0.1 毫元 -> 取整 0 -> 触发缺省 max(1, miss//30) + # in_hit 缺省 = in_miss//30(D-P3):0.003 元 -> 3 毫元 -> 0(太小),显式补齐后重验 + c2 = build_proxy_config({"proxy": {"pricing": { + "m1": {"in_miss": 3.0, "in_hit": 0.1, "out": 9.0}, + "m2": {"in_miss": 12.3456, "out": 1.5}}}}) + assert c2.price("m2").in_miss == 12346 + assert c2.price("m2").in_hit == 12346 // 30 # 411 diff --git a/任务拆解与执行计划.md b/任务拆解与执行计划.md index 403e820..ef904e0 100644 --- a/任务拆解与执行计划.md +++ b/任务拆解与执行计划.md @@ -138,7 +138,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯 | T-P0 | 骨架:gateway/proxy/ 包 + SQLite DDL + enabled 门控挂路由 | ✅ 完成 | T-P0 | | T-P1 | 鉴权+账本:key 签发/令牌桶/四表/request_id 幂等/日限额 | ✅ 完成 | T-P1 | | T-P2 | 上游客户端:流式派发+三家 usage 归一化+首 token 前 failover | ✅ 完成 | T-P2 | -| T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ⬜ 待办 | | +| T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 | | T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ⬜ 待办 | | | T-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ⬜ 待办 | | | T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ⬜ 待办 | |