From 4442bd075f8a81f52a078b1e9f663c208ba186d4 Mon Sep 17 00:00:00 2001 From: argszero Date: Fri, 25 Sep 2026 16:47:40 +0800 Subject: [PATCH] fix(gateway): require a price before a request reaches upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `keys` row is routed by `model` alone (`dao::find_keys_by_model` has no `provider` in its SQL), while billing prices it by `(key.provider, model)` against the `models` catalog. The catalog has three writers and none of them maintains `keys`: `db::seed_models` (startup sync with config.toml), `admin_models::remove`, and `admin_models::patch` (both `provider` and `model` are editable in the admin form). So a `status='on'` key can reach the state "routable but unpriceable" *after* it was created — and there it is billed as free. `sharing::create` already refuses to create such a key (C2065: "查不到即 0 计费 —— 一次真实调用会静默变成免费"), so this is one invariant with two doors; only the creation door was closed. A catalog edit — rename or delete a model row, or a config shrink at startup — reopens it: the call returns 200, `settle_usage` falls into `None => (0.0, 0.0)`, the consumer is not charged, the sharer earns nothing and `usage_records.cost` is 0. Because `pts = 0` never lowers the balance, one funded consumer can call that model unlimited times for free. Close the second door the same way the first one is closed: `forward` and `forward_stream` price each candidate key before picking one, and refuse with 503 ("provider 与 model 不在模型目录中,无法计价") before anything is sent upstream. Candidates that cannot be priced are dropped from the pick set rather than relying on stickiness/randomness never choosing them. The settlement fallback arm becomes a loud `log::error!` instead of a silent zero — a real call must not be billed as free just because its catalog row moved between the preflight and the settle ("missing data" is not "data of zero"). Flow control: `forward_stream` needs the same gate, otherwise the two protocols diverge. No new i18n key: the refusal sentence already exists in `sharing.rs` and is mapped by `ERR_MAP` (`err.modelNotInCatalog`), so the route side says exactly what the creation side already says. Gate `state_gate::the_router_requires_a_price_before_it_reaches_upstream` derives the forwarder roster from "who calls `router.pick(`" and requires (a) every forwarder to price its candidates before picking a key, (b) the settlement fallback arm to log, and (c) the price call's result to actually drive the branch (so a discarded result or a blanket refusal fails). Each rule has an independent mutant. `db.rs`'s catalog-deletion comment becomes an invariant instead of a roster of historical referrers. Co-authored-by: argszero --- src/db.rs | 7 +- src/gateway.rs | 93 +++++++++- src/state_gate.rs | 448 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 546 insertions(+), 2 deletions(-) diff --git a/src/db.rs b/src/db.rs index ebc9e0a..cd689b9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -599,7 +599,12 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> )?; n += 1; } - // 同步删除 config 中已移除的模型(模型行无外键引用,usage_records/transactions 均按 model 字符串记) + // 同步删除 config 中已移除的模型。⚠️ **不变量**:`models` 行是路由键 `keys.model` 的**计价依赖** —— + // 一把 `status='on'` 的 key 若其 (provider, model) 在目录里没有行,调用仍可被路由,但结算取价会落 + // `None` ⇒ 静默免费(R156)。因此**删除/改名一个 (provider, model) 之前,必须处理引用它的 key**; + // 本函数与 `admin_models::{remove, patch}` 是全部三个写入者,都受此约束。权威执行点是路由入口 + // (`gateway::forward{,_stream}` 在发往上游前要求该 (provider, model) 可取价)与创建入口 + // (`sharing::create`);此处只记约束、**不做数据清理** —— `keys` 是用户数据,绝不因目录变动被改写。 let mut removed = 0u32; let stale: Vec<(String, String)> = { let mut stmt = conn diff --git a/src/gateway.rs b/src/gateway.rs index 4a2fee9..9df2092 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -212,7 +212,20 @@ fn settle_usage( ); (pts, cost) } - None => (0.0, 0.0), + None => { + // 计价门(R156):`forward`/`forward_stream` 已在发往上游之前滤掉取不到价的 + // (provider, model),所以正常路径不该走到这里。真到了说明目录在「预检取价」与 + // 「结算」之间变了,或有别的调用点绕过了预检 —— 此时按 0 计费等于把这次真实 + // 调用白送出去,必须留下可追溯的痕迹(「缺数据」不能和「数据为 0」共用同一 + // 载体,见 stream-usage-billing 轴),而不是静默免费。 + log::error!( + "计价失败(models 目录中无 provider/model)key_id={} provider={} model={} ⇒ 本次按 0 计费", + key.id, + key.provider, + model + ); + (0.0, 0.0) + } }; let params = billing::SettleParams { consumer_id: auth.user_id, @@ -262,6 +275,27 @@ async fn forward( )); } + // 计价门(R156):路由只认 `model`(`find_keys_by_model` 的 SQL 里没有 provider),计费却按 + // `(key.provider, model)` 查 `models` 目录 —— 而**目录变更不维护 `keys`**:管理页改 + // provider/model、删目录行,或 config 收缩触发的启动同步,都能让一把 `status='on'` 的 key + // 落到「有 key、没有价」的状态。那种调用会 200 正常返回,而 `settle_usage` 取到 + // `None => (0.0, 0.0)` ⇒ 消费者不扣点、分享者无收益:一次真实调用静默变成免费。 + // 创建入口(C2065 `sharing::create`)已拒绝这样的 (provider, model);这里是同一不变量的 + // 第二扇门,覆盖目录变更这条路径 —— 候选 key 逐个对目录取价,取不到的**不进候选集** + // (否则粘性/随机可能正好选中它),全被滤掉即该模型不可计价:**发往上游之前**就拒绝。 + let keys: Vec = { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + keys.into_iter() + .filter(|k| dao::get_model_price(&conn, &k.provider, model).is_some()) + .collect() + }; + if keys.is_empty() { + return Err(err_json( + StatusCode::SERVICE_UNAVAILABLE, + "provider 与 model 不在模型目录中,无法计价", + )); + } + for _ in 0..crate::router::MAX_SWITCHES { // 选 key(粘性优先 / 随机 / 排除冷却) let Some(key_id) = st.router.pick(&keys, auth.user_id, model) else { @@ -474,6 +508,22 @@ async fn forward_stream( )); } + // 计价门(R156,与 `forward` 同一条不变量、同一句话):候选 key 必须能在 `models` + // 目录里取到 (provider, model) 的价,否则这次调用会在 `settle_usage` 里落到 + // `None => (0.0, 0.0)` 而静默免费 —— 流式与非流式是两扇门,必须同口径。 + let keys: Vec = { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + keys.into_iter() + .filter(|k| dao::get_model_price(&conn, &k.provider, model).is_some()) + .collect() + }; + if keys.is_empty() { + return Err(err_json( + StatusCode::SERVICE_UNAVAILABLE, + "provider 与 model 不在模型目录中,无法计价", + )); + } + // 选 key 并建立上游连接(此阶段失败可切换;连接成功后不再切换) for _ in 0..crate::router::MAX_SWITCHES { let Some(key_id) = st.router.pick(&keys, auth.user_id, model) else { @@ -1375,6 +1425,13 @@ mod tests { async fn dead_upstream_failover_503() { // 两个 key 都指向不可达端口 → 3 次尝试全失败 → 503 + 冷却 2 个 key let st = test_state("dead", "test-dead", "http://127.0.0.1:9"); + // R156:计价门要求 (provider, model) 在目录里可取价,否则在发往上游前就被拒(503)。 + // 本测试要的是**上游全挂**那条路径,故先给 `dead-model` 一行价(`insert_key` 的 provider + // 是 'test')。 + { + let conn = st.db.lock().unwrap(); + models_row(&conn, "test", "dead-model", 10.0, 20.0); + } insert_key(&st, 100, 1, "dead-model", "test-dead"); insert_key(&st, 101, 1, "dead-model", "test-dead"); let key = login_key(st.clone()).await; @@ -1399,6 +1456,40 @@ mod tests { assert_eq!(st.router.cooldown_len(), 2, "两次失败后两个 key 均进入冷却"); } + #[tokio::test] + async fn unpriced_model_503_before_upstream() { + // R156:「可路由」与「可计价」是两扇门。key 在(model 匹配、status=on),但 + // (provider, model) 不在 `models` 目录里 ⇒ 必须在**发往上游之前**拒绝,而不是照常 + // 转发、结算时落到 `None => (0.0, 0.0)` 静默免费。 + let st = test_state("unpriced", "test-unpriced", "http://127.0.0.1:9"); + insert_key(&st, 200, 1, "no-price-model", "test-unpriced"); + let key = login_key(st.clone()).await; + let (s, body) = post_raw( + st.clone(), + "/v1/chat/completions", + r#"{"model":"no-price-model"}"#, + Some(&key), + ) + .await; + assert_eq!( + s, + StatusCode::SERVICE_UNAVAILABLE, + "body: {}", + String::from_utf8_lossy(&body) + ); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + v["error"]["message"].as_str().unwrap().contains("无法计价"), + "body: {}", + String::from_utf8_lossy(&body) + ); + assert_eq!( + st.router.cooldown_len(), + 0, + "被计价门拒绝的调用不该碰上游,更不该把 key 打进冷却" + ); + } + #[tokio::test] async fn e2e_success_ledger_and_sticky() { // 起假上游 diff --git a/src/state_gate.rs b/src/state_gate.rs index 5cea6b1..1e7310e 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -16680,3 +16680,451 @@ fn r2174_variant_coherent_wrong_rename(pack: &str) -> String { " \"ops.keys.sub\": \"Aggregated by provider · enabled vs. total listed keys\",\n", ) } + +// --------------------------------------------------------------------------------------- +// R156 · 「可路由」与「可计价」是两扇门:路由入口必须在发往上游前要求 (provider, model) 可取价 +// (`forward` / `forward_stream` 的计价门 + 结算兜底臂必须响亮) +// --------------------------------------------------------------------------------------- +// +// 路由只认 `model`(`dao::find_keys_by_model` 的 SQL 里没有 provider),而计费按 +// `(key.provider, model)` 查 `models` 目录。目录行被改名/删除 —— 管理页编辑(provider/model +// 两个字段都可改)、删除,或 config 收缩触发的启动同步 —— 之后,一把 `status='on'` 的 key 仍可 +// 被路由(它的 `model` 还在),但结算取价落 `None => (0.0, 0.0)`:一次真实调用 200 正常返回、 +// 消费者不扣点、分享者无收益、`usage_records.cost = 0`。因 `pts = 0` 余额不降,只要余额一次 +// > 0 就能无限复现(静默免费)。 +// +// 创建入口已拒绝这样的 (provider, model)(`sharing::create` 的 400,句子已在词表里),而 +// **目录变更**是同一个不变量漏掉的第二扇门 —— 本门禁钉的就是那扇门。 +// +// 规则(每条都从代码**派生**,不写死函数名 —— 转发器名册由「哪些函数调用 `router.pick(`」 +// 读出,结算函数由「谁调用 `billing::settle(`」读出,#469): +// R1 计价门:**每个**转发器(调用 `router.pick(` 的函数)都在**选中 key 之前**调用取价 +// (位置判据:取价调用点 < 第一次 `router.pick(`)。缺了它,目录里没有 (provider, model) +// 的 key 会被照常送给上游。 +// R2 兜底响亮:结算函数的 `None =>` 臂(到兜底值 `(0.0` 为止)必须带 `log::` —— 纵深防御: +// 目录若在预检与结算之间变了,必须留下痕迹,而不是与「价真的是 0」共用同一载体 +// (stream-usage-billing 轴:「缺数据」≠「数据为 0」)。 +// R3 质量(不是「一律拒绝」):每个**含计价调用**的转发器,其调用的**结果**必须骑在一个 +// 谓词上(`.is_some()` / `.is_none()`)—— 取价后把结果丢掉、或把门写成无条件拒绝,都会 +// 翻转它。R3 只对「已出现计价调用」的转发器断言(存在性归 R1)⇒ 删掉计价调用只翻 R1。 +// +// 射程(如实):本门禁是**静态词法**的 —— 它证「每个转发器在选 key 前取价」「兜底臂留痕」 +// 「取价结果确实决定分支」三件**形状**;**不证**运行期「目录行存在时那次调用真的 200」 +// (那一半归探针 `r156_model_price_invariant_probe.py` 的 L1/L8 腿与 A/B 腿)。两台仪器各自 +// 能看见对方看不见的东西(C2148/R168/R173 前例)。 + +/// `src/gateway.rs`:路由、计价门与结算兜底的真源(R156)。 +const GATEWAY_RS: &str = include_str!("gateway.rs"); + +/// 取价调用的拼写(含模块前缀)—— 本门禁唯一手写的调用名。 +const R156_PRICE_CALL: &str = "dao::get_model_price("; + +/// 选 key 的调用拼写 —— 「转发器」名册的判据(谁在选 key 送出上游,谁就得先取价)。 +const R156_PICK_CALL: &str = "router.pick("; + +/// 结算函数的判据:只有它调用 `billing::settle(`。 +const R156_SETTLE_CALL: &str = "billing::settle("; + +/// 转发器名册的下限(低一步就让 R1 在单个函数上恒真 —— 坑 68 家族)。 +const R156_MIN_FORWARDERS: usize = 2; + +// ── 扫描器 ────────────────────────────────────────────────────────────────────────────── + +/// Rust 一行里的 `fn NAME(` 名字(`pub fn` / `async fn` / `pub async fn` 都认)。 +/// +/// 只认**声明形状**:`fn` 之前(同行,去空白)只能是声明关键字 ⇒ `my_fn(`、`x.fn_(` 之类的 +/// 用法不会被误收(否则名册会被调用点污染)。 +fn r156_fn_name(line: &str) -> Option { + let at = line.find("fn ")?; + let prefix = line[..at].trim(); + const OK: &[&str] = &[ + "", + "pub", + "async", + "pub async", + "unsafe", + "pub unsafe", + "extern", + "pub extern", + "pub(crate)", + "pub(crate) async", + ]; + if !OK.contains(&prefix) { + return None; + } + let name: String = line[at + 3..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +/// `fn NAME(` 之后按**花括号配平**的函数体(含两端花括号)。 +/// +/// 不用 JS 的声明行收尾约定:Rust 签名可换行(同 `r164_body_after` 的理由)。格式串里的 +/// `{}` / `{e}` 是成对的,不影响配平。 +fn r156_fn_body(code: &str, name: &str) -> Option { + r164_body_after(code, &format!("fn {name}(")) +} + +/// 「转发器」名册:调用 `router.pick(` 的函数 → 它的函数体。 +fn r156_forwarders(code: &str) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + for line in code.lines() { + let Some(name) = r156_fn_name(line) else { + continue; + }; + if out.contains_key(&name) { + continue; + } + if let Some(body) = r156_fn_body(code, &name) { + if body.contains(R156_PICK_CALL) { + out.insert(name, body); + } + } + } + out +} + +/// 结算函数体:唯一调用 `billing::settle(` 的那个函数。 +fn r156_settle_body(code: &str) -> Option { + for line in code.lines() { + let Some(name) = r156_fn_name(line) else { + continue; + }; + if let Some(body) = r156_fn_body(code, &name) { + if body.contains(R156_SETTLE_CALL) { + return Some(body); + } + } + } + None +} + +/// `open` 处必须是 `(`;返回与它配平的 `)` 的下标。 +fn r156_matching_paren(src: &str, open: usize) -> Option { + if src.as_bytes().get(open) != Some(&b'(') { + return None; + } + let mut depth = 0i32; + for (i, c) in src[open..].char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(open + i); + } + } + _ => {} + } + } + None +} + +/// 一个转发器的计价门读数(全部从它的函数体派生)。 +struct R156Gate { + /// 第一次取价调用的位置。 + price_at: Option, + /// 第一次 `router.pick(` 的位置。 + pick_at: Option, + /// 取价调用的**结果**是否骑在谓词上(`.is_some()` / `.is_none()`)。 + gated: bool, +} + +fn r156_gate(body: &str) -> R156Gate { + let price_at = body.find(R156_PRICE_CALL); + let pick_at = body.find(R156_PICK_CALL); + let gated = + match price_at.and_then(|at| r156_matching_paren(body, at + R156_PRICE_CALL.len() - 1)) { + Some(close) => { + let after = body[close + 1..].trim_start(); + after.starts_with(".is_some()") || after.starts_with(".is_none()") + } + None => false, + }; + R156Gate { + price_at, + pick_at, + gated, + } +} + +/// 结算函数体里的 `None =>` 兜底臂(到兜底值 `(0.0` 为止)是否带 `log::`。 +fn r156_none_arm_logs(body: &str) -> bool { + let mut from = 0usize; + while let Some(rel) = body[from..].find("None =>") { + let at = from + rel + "None =>".len(); + let end = body[at..] + .find("(0.0") + .map(|i| at + i) + .unwrap_or(body.len()); + if body[at..end].contains("log::") { + return true; + } + from = at; + if from >= body.len() { + break; + } + } + false +} + +// ── 判词 ──────────────────────────────────────────────────────────────────────────────── + +/// 一次读取的全部证据与三条判词。 +struct R156Reading { + forwarders: BTreeSet, + price_present: BTreeSet, + gated: BTreeSet, + settle_found: bool, + settle_logs: bool, + r1: bool, + r2: bool, + r3: bool, +} + +impl R156Reading { + fn verdicts(&self) -> (bool, bool, bool) { + (self.r1, self.r2, self.r3) + } + + fn report(&self) -> String { + format!( + "r1={} r2={} r3={} | forwarders={:?} price_present={:?} gated={:?} \ + settle_found={} settle_logs={}", + self.r1, + self.r2, + self.r3, + self.forwarders, + self.price_present, + self.gated, + self.settle_found, + self.settle_logs + ) + } +} + +/// 读 `src/gateway.rs`(注释剥掉),导出三条判词。名册与期望值全部**派生**。 +fn r156_read(src: &str) -> R156Reading { + let code = code_text(src); + // 只扫**生产代码**:`#[cfg(test)]` 之后的测试模块不上线(与 `backend_error_literals` 同款, + // 也避免测试夹具里的同名函数污染名册)。 + let code = match code.find("#[cfg(test)]") { + Some(i) => code[..i].to_string(), + None => code, + }; + let fw = r156_forwarders(&code); + + let mut forwarders: BTreeSet = BTreeSet::new(); + let mut price_present: BTreeSet = BTreeSet::new(); + let mut gated: BTreeSet = BTreeSet::new(); + let mut before_pick: BTreeSet = BTreeSet::new(); + for (name, body) in &fw { + forwarders.insert(name.clone()); + let g = r156_gate(body); + if g.price_at.is_some() { + price_present.insert(name.clone()); + } + if g.gated { + gated.insert(name.clone()); + } + if let (Some(p), Some(k)) = (g.price_at, g.pick_at) { + if p < k { + before_pick.insert(name.clone()); + } + } + } + + let settle = r156_settle_body(&code); + let settle_found = settle.is_some(); + let settle_logs = settle.as_deref().is_some_and(r156_none_arm_logs); + + let r1 = !forwarders.is_empty() && before_pick == forwarders; + let r2 = settle_found && settle_logs; + let r3 = gated == price_present; + + R156Reading { + forwarders, + price_present, + gated, + settle_found, + settle_logs, + r1, + r2, + r3, + } +} + +// ── 测试 ──────────────────────────────────────────────────────────────────────────────── + +/// 轴:路由入口必须在发往上游前要求 (provider, model) 可取价。 +/// +/// 三条规则的含义见上方文件头。每条规则的**牙**由 [`the_r156_rules_have_teeth`] 用合成变异体 +/// 逐条测量(基线=已知为绿的活树)。 +#[test] +fn the_router_requires_a_price_before_it_reaches_upstream() { + let rd = r156_read(GATEWAY_RS); + assert!( + rd.forwarders.len() >= R156_MIN_FORWARDERS, + "转发器名册少于 {R156_MIN_FORWARDERS} 个 —— R1 退化成单函数上的关系式:{}", + rd.report() + ); + assert!( + rd.verdicts() == (true, true, true), + "R156 未修:路由入口没有要求 (provider, model) 可取价\ + (R1 计价门/R2 兜底响亮/R3 取价结果确实决定分支):{}", + rd.report() + ); +} + +/// 提取器自证 + 反面对照:退回原缺陷形状(两扇门都不关)⇒ R1/R2 同时红、R3 仍绿。 +#[test] +fn the_r156_roster_is_real() { + let live = r156_read(GATEWAY_RS); + assert!( + live.forwarders.len() >= R156_MIN_FORWARDERS, + "转发器名册没被推导出来:{}", + live.report() + ); + assert_eq!( + live.price_present, + live.forwarders, + "活树上有转发器没取价 —— 变异体的读数无从解释:{}", + live.report() + ); + assert_eq!( + live.gated, + live.price_present, + "活树上有取价调用不决定分支:{}", + live.report() + ); + assert!(live.settle_found, "结算函数没被推导出来:{}", live.report()); + assert!(live.settle_logs, "活树上兜底臂就不响亮:{}", live.report()); + + let broken = r156_variant_defect(GATEWAY_RS); + let rd = r156_read(&broken); + assert_eq!( + rd.verdicts(), + (false, false, true), + "退回缺陷形状没有同时打翻 R1/R2(R3 应仍绿——零取价调用上它空洞为真):{}", + rd.report() + ); +} + +/// 三条规则**各有独立的牙**:每个合成变异体只打翻它针对的那一条(基线=已知为绿的活树)。 +/// +/// ⚠️ 变异体一律从**活树**上构造,且每个锚点都断言唯一(#612 家族):变体若没生效, +/// 构造器里的断言先响,而不是让某条腿因错误的原因变绿(#605)。 +#[test] +fn the_r156_rules_have_teeth() { + let live = r156_read(GATEWAY_RS); + assert_eq!( + live.verdicts(), + (true, true, true), + "基线不是绿的 —— 变异体的读数无从解释:{}", + live.report() + ); + + // ① 原缺陷的一半:两个转发器都不再取价 ⇒ 只翻 R1(R3 在零取价调用上空洞为真)。 + let v = r156_variant_drop_gate(GATEWAY_RS); + let rd = r156_read(&v); + assert_eq!( + rd.verdicts(), + (false, true, true), + "删掉计价门只应翻掉 R1:{}", + rd.report() + ); + + // ② 兜底臂不再响亮 ⇒ 只翻 R2。 + let v = r156_variant_bare_none(GATEWAY_RS); + let rd = r156_read(&v); + assert_eq!( + rd.verdicts(), + (true, false, true), + "兜底臂丢掉 log 只应翻掉 R2:{}", + rd.report() + ); + + // ③ 取价结果被丢掉(调用仍在、位置仍对,但不决定分支)⇒ 只翻 R3。 + let v = r156_variant_discard_result(GATEWAY_RS); + let rd = r156_read(&v); + assert_eq!( + rd.verdicts(), + (true, true, false), + "取价后把结果丢掉只应翻掉 R3:{}", + rd.report() + ); +} + +/// 扫描器自证:位置判据与配平在**合成输入**上按声明工作(与活树无关)。 +#[test] +fn the_r156_scanners_have_teeth() { + // 名册只认声明形状:`fn` 之前必须是关键字。 + assert_eq!( + r156_fn_name("async fn forward(").as_deref(), + Some("forward") + ); + assert_eq!(r156_fn_name("pub fn x(").as_deref(), Some("x")); + assert_eq!(r156_fn_name("fn y(").as_deref(), Some("y")); + assert_eq!(r156_fn_name(" my_fn(a);"), None); + assert_eq!(r156_fn_name("let f = fn_call("), None); + + // 配平:内层括号不提前收尾。 + assert_eq!( + r156_matching_paren("f(a(b), c) rest", 1), + Some(9), + "配平停在了内层括号" + ); + + // 位置判据:取价在 `router.pick(` **之后** ⇒ 不算「在选 key 之前」。 + let after = "let x = router.pick(); dao::get_model_price(a).is_some();"; + let g = r156_gate(after); + assert!(g.price_at > g.pick_at, "位置读数错了:{after:?}"); + + // 兜底臂:`(0.0` 之前有 log 才算响亮。 + assert!(r156_none_arm_logs( + "None => { log::error!(\"x\"); (0.0, 0.0) }" + )); + assert!(!r156_none_arm_logs("None => (0.0, 0.0)")); + assert!(!r156_none_arm_logs("None => { (0.0, 0.0) }")); +} + +/// 变异体①:两个转发器都不再取价(原缺陷的形状)。 +fn r156_variant_drop_gate(src: &str) -> String { + let line = + " .filter(|k| dao::get_model_price(&conn, &k.provider, model).is_some())\n"; + assert_eq!( + src.matches(line).count(), + 2, + "计价门锚点不唯一(应恰两处:forward / forward_stream)" + ); + src.replace(line, " .filter(|_k| true)\n") +} + +/// 变异体②:结算兜底臂丢掉日志,退回裸 `(0.0, 0.0)`。 +fn r156_variant_bare_none(src: &str) -> String { + let log = " log::error!(\n \"计价失败(models 目录中无 provider/model)key_id={} provider={} model={} ⇒ 本次按 0 计费\",\n key.id,\n key.provider,\n model\n );\n"; + assert_eq!(src.matches(log).count(), 1, "兜底日志锚点不唯一"); + src.replace(log, "") +} + +/// 变异体③:取价结果被丢掉(调用仍在、位置仍对,但不决定分支)—— R3 的牙。 +fn r156_variant_discard_result(src: &str) -> String { + let line = + " .filter(|k| dao::get_model_price(&conn, &k.provider, model).is_some())\n"; + assert_eq!(src.matches(line).count(), 2, "计价门锚点不唯一"); + src.replace( + line, + " .filter(|k| { let _ = dao::get_model_price(&conn, &k.provider, model); true })\n", + ) +} + +/// 原缺陷形状:两扇门都不关(R1/R2 一起红,R3 仍绿)。 +fn r156_variant_defect(src: &str) -> String { + r156_variant_bare_none(&r156_variant_drop_gate(src)) +}