From ab08177b754672879115273e06bc3f0ba477dcc9 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Thu, 22 Oct 2026 11:08:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E4=B8=80=20alias=20=E7=9A=84?= =?UTF-8?q?=E5=AE=B6=E5=86=85=E5=A4=9A=E6=9D=A1=E6=98=A0=E5=B0=84=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=80=99=E9=80=89=E9=93=BE=E4=B8=BB=E5=A4=87=EF=BC=88?= =?UTF-8?q?=E5=AE=B6=E5=86=85=E4=B8=8E=E5=AE=B6=E5=A4=96=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E7=AE=97=E6=B3=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-family alias mappings now form an ordered failover chain, same algorithm as the cross-family one. 问题:把同一个账号(同一提供商家族)里的多个上游模型映射到同一个 对外名(如 free)时,只有按配置顺序的第一条生效,其余全是影子条目 —— 撞了限额只能换账号,模型本身没有后备。同名映射挂在不同提供商 (跨家)早已是按账号优先级主备切换的候选链,家内却不是。 Fix: 把家内的多条映射摊平成一条有序候选链,与跨家同算法: - `catalog::target_candidates`:按「原生承载(有则独占)→ Cline 池 前缀项 → 其余映射(配置顺序)→ 旧版全局条目」给出全部候选;链首 与改造前的单一答案逐字相等,存量配置行为不变。 - `provider_loop`:账号循环内新增位次游标(lane)。模型作用域的失败 (限额 429 / 上游 404)先在本账号内换下一位(开一条新明细、不吃 换号预算、不触发退避 / 熔断),链走完才顺延下一个账号。模型测试 (pinned)不顺延。 - `lane_targets`:已限额的目标稳定排到链尾(免费优先);全部在冷却 时按原序硬试 —— 与选路第二级「恢复最早的试一次」同一取舍。 - `CooldownKeys` 缓存整条候选链(`for_provider_all`); `rate_limit_reset_at` 改为「任一目标可用 = 账号可用」,全部在冷却 时报最早的恢复时间。单目标配置与改造前逐字等价。 - `send_body` 新增 `wire` 参数:转发层按位传目标,「一次解析、两处 消费」的同源约束保持不变。 测试:候选链顺序 / 原生独占 / 池前缀优先 / 配置序 / 关闭映射剔除 / 冷却逐位判定共 11 个新用例;单目标回归路径被既有测试覆盖。 (本机跑全套时另有约 66 个「临时 SQLite 目录拒绝访问」的环境性失败, 与本次改动无关,干净基线同样失败。) 已知边界:自定义提供商与有状态(CatPaw)路径维持单目标解析,行为 不变(见代码注释)。 Problem: mapping several upstream models of the SAME provider family to one public alias (e.g. "free") left only the first mapping effective; the rest were unreachable shadows — quota exhaustion on one model could only be answered by switching accounts. Same-name mappings spread across providers (cross-family) have long been a priority failover chain; in-family ones were not. Fix: flatten in-family mappings into an ordered candidate chain that follows the same algorithm as the cross-family one: - `catalog::target_candidates` returns all candidates ordered as native (exclusive when present) → Cline pool-prefixed → remaining mappings in config order → legacy global entries. The chain head equals the previous single answer, so existing configs behave identically. - `provider_loop` gains a per-account lane cursor. Model-scoped failures (429 / upstream 404) advance to the next target within the same account (new attempt entry, no switch budget consumed, no backoff/breaker marking) before falling through to account failover. Pinned model tests do not advance. - `lane_targets` moves rate-limited targets to the tail (free first); when everything is cooled the chain is tried in original order — same trade-off as the selector's second tier. - `CooldownKeys` now caches the whole chain (`for_provider_all`); `rate_limit_reset_at` treats "any target free" as usable and reports the earliest reset when all are cooled. Single-target configs are byte-for-byte equivalent to before. - `send_body` takes an explicit target: one resolution, two consumers, unchanged. Tests: 11 new cases covering chain ordering, native exclusivity, pool prefix preference, config order, disabled-mapping exclusion and per-target cooldown; single-target regression paths are covered by existing tests. (About 66 pre-existing environment failures about "temp SQLite directory access denied" occur on this machine for a clean baseline too; unrelated to this change.) Known limits: custom providers and the stateful (CatPaw) path keep single-target resolution; behavior unchanged. --- 复核补丁(同一 PR 内折叠)─────────────────────────────────── `send_cache` 的键原先是 (家, 池, 降级, lane),但 lane → 目标的对应关系是 **按账号**算的(`lane_targets` 的「免费优先」分区读该账号自己的冷却状态)。 同家同池的两个账号会把不同目标排在同一位次上,用序号做键时第二个账号会命中 第一个账号那一档的发送体 —— 「这一家先发哪个模型」被上一个账号的冷却状态 决定,发出去的模型名与本次 `lane_targets` 的结论不符,429 记账也跟着记到错误 的真名上(原注释「候选链按家算,位次相同意味着发送名相同」正是不成立的那句: 解析按家,排序按账号)。现改为键带**这一档实际要发的上游模型名** (`send_cache_key` 收口):位次相同且目标相同才复用,目标相同即发送体相同。 顺带两处: - **「敏」命中表按家去重**:`send_body` 新增 `count_redactions`(+ 循环外的 `redaction_counted`)。换位次 / 翻转降级标记 / 换池都会重算发送体、重过一遍 脱敏层,命中的却是同一份内容,计数不该跟着翻倍 —— payload 模块头原本就写着 「同一家重复发送时不重复计」,原先只是靠 `send_cache` 的旧键形状顺带成立。 - **`lane_targets` 的排序抽成纯函数 `order_lane`**,补 4 个用例:稳定分区 (组内保持配置序)、全免费不动、全冷却按原序硬试,以及这条缓存键契约 (同一位次不同目标必须是两条缓存项)。 测试口径:`cargo test --release -p agent2api-server --lib` → 534 passed / 66 failed,失败集与 base 68d0a4e 逐条同名(均为「临时 SQLite 目录拒绝访问」的 环境性失败),新增 4 个用例全绿。 Review follow-up folded into this commit: the per-family send-body cache key was (family, pool, degraded, lane index), but lane -> target is resolved per account, so two accounts in the same family/pool could place different targets at the same lane index and the second account reused the first account's body (wrong model name sent, and the 429 cooldown booked under the wrong real name). The key now carries the target name itself (send_cache_key). Also deduped the redaction-hit accounting per family (new count_redactions parameter) and extracted the free-first ordering into a pure order_lane() covered by 4 new tests. --- 第二轮复核(同一 PR 内折叠)───────────────────────────────── - **空链冷却兜底**:`rate_limit_reset_at` 在候选链为空时退回**单答案键** (`for_account`,解析不出时正是请求名)。链为空 = 发送侧原样发请求名 (`wire: None`)、限额也记在请求名上;判定侧只认链就会读不到那条记录, 账号被反复选中再撞一次 429。这就把「链为空」拉回改造前的口径。 - **顺延判据收口到 `next_lane`**:两处顺延(限额 429 / 上游 404)原先各写一遍 `pinned.is_none() && lane + 1 < len`,现在共用一条判据 + 一个用例,把三条边界 钉住(链还有下一位才顺延、单目标/空链不顺延、pinned 从不顺延)。 - **候选链解析按家缓存**(`chain_cache`):链的**内容**是「家 × 请求名」的函数, 链的**顺序**才是账号的函数。原先每个账号都重解析一遍(内含一次 `model_rules::current()` 的 config 克隆 + 一遍 `manifest_for` 清单扫描), 现在解析在账号循环外只做一次、逐账号只做冷却分区(clone 一条小链)。 `lane_targets` 的入参因此改成「已解析好的链」。 测试:`cargo test --release -p agent2api-server --lib` → 536 passed / 66 failed; 那 66 个与 base `68d0a4e`(519 passed / 66 failed)逐条同名,全是「临时 SQLite 目录拒绝访问」的环境性失败;本 PR 的新用例(6 个契约/边界用例)全部通过。 Round two of review follow-ups: (1) an empty candidate chain now falls back to the single-answer cooldown key (the request name), which is what the sender actually uses in that case — without it a 429 booked under the request name would never be seen by the selector; (2) the two lane-advance sites (429 / upstream 404) now share one predicate `next_lane()`, pinned by a test covering all three boundaries; (3) chain resolution is cached per family (`chain_cache`) since its content depends only on family x requested name while only the ordering is per account. --- .../server/src/server/core/model_rules/mod.rs | 14 +- .../src/server/core/providers/catalog.rs | 2 +- .../server/core/providers/catalog/routing.rs | 233 ++++++++++++- .../src/server/core/providers/router.rs | 7 + .../server/src/server/core/routing.rs | 259 ++++++++++++--- .../src/server/core/upstream/payload.rs | 38 ++- .../src/server/core/upstream/provider_loop.rs | 311 +++++++++++++++--- .../core/upstream/provider_loop/tests.rs | 104 ++++++ 8 files changed, 854 insertions(+), 114 deletions(-) diff --git a/desktop-tauri/src-tauri/server/src/server/core/model_rules/mod.rs b/desktop-tauri/src-tauri/server/src/server/core/model_rules/mod.rs index 3357897b7..17d897117 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/model_rules/mod.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/model_rules/mod.rs @@ -22,12 +22,14 @@ //! - **映射**(照抄 OmniProxy 的模型映射语义):把「上游模型(提供商 × 真名)」 //! 以对外名 `alias` 暴露给下游。`alias` **自由命名**——允许与任何上游模型 id //! 同名(同名时该上游的原生路由仍在、且优先,映射是追加的兜底路,不存在遮蔽); -//! **同一 alias 可以有多条映射**(不同提供商各一条)——下游用同一个名字请求, -//! 路由在「原生承载家 + 各映射提供商」之间按账号全局优先级主备切换。 -//! 候选链的展开与发送名的按家改写见 `providers::router` 与 -//! `catalog::wire_target_for_provider`。每条映射自带一个**开关** -//! (`enabled`,缺省 true):关掉 = 这条别名暂时不存在(不广告、不路由、 -//! 不改写发送名),管理页里可再打开 —— 与模型的启停开关同一哲学。 +//! **同一 alias 可以有多条映射**:不同提供商各一条是**跨家**主备,同一家内 +//! 多条(各自指向不同上游模型)是**家内**主备 —— 两者同算法:下游用同一个 +//! 名字请求,路由把全部目标摊成一条候选链,按账号全局优先级选账号、账号内 +//! 按链序逐个试(撞限额 / 上游 404 就换下一位;展开见 +//! `providers::catalog::target_candidates`,驱动见 `upstream::provider_loop`)。 +//! 每条映射自带一个**开关**(`enabled`,缺省 true):关掉 = 这条别名暂时 +//! 不存在(不广告、不路由、不改写发送名),管理页里可再打开 —— 与模型的 +//! 启停开关同一哲学。 //! //! ── 历史的 `hidden` 机制已移除(一次性清理语义)────────────── //! 旧版有一个 `hidden` 列表(管理页「删除」按钮背后的东西):从清单里拿掉、 diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/catalog.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/catalog.rs index debeb0564..f06a44ddb 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/catalog.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/catalog.rs @@ -21,7 +21,7 @@ use super::workbuddy::Region as WorkBuddyRegion; pub use routing::{ default_model_catalog, default_model_usable, forwarding_providers, model_blocked_everywhere, - providers_for_model, wire_target_for_provider, WireTarget, + providers_for_model, wire_target_for_provider, wire_targets_for_provider, WireTarget, }; pub use view::{ advertised_manifest_contains, advertised_model_ids, has_available_providers, manage_view, diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/catalog/routing.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/catalog/routing.rs index 77b334c62..9d1194599 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/catalog/routing.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/catalog/routing.rs @@ -1,4 +1,12 @@ //! 对外模型名到上游模型的解析。路由与发送侧共用本解析,避免选中一家后再换一套规则。 +//! +//! ── 一个名字可以解析出**多个**目标(候选链)────────────────── +//! `target_candidates` 返回的是**有序列表**而不是单个答案:同一 alias 在同一家 +//! 挂多条映射时,它们全部成为候选、按顺序尝试(撞限额 / 路由不存在就换下一个 +//! —— 由 `upstream::provider_loop` 驱动)。这与「同一别名在多家各挂一条」 +//! 的跨家主备是同一个算法,只是把候选粒度从「家」细化到「家 × 上游模型」。 +//! 单个答案的入口(`wire_target_for_provider` / `builtin_target`)保留, +//! 取链上第一位 —— 于是**不消费候选链的调用方行为逐字不变**。 use serde_json::Value; @@ -35,13 +43,52 @@ pub(super) fn builtin_target( manifest: &[Value], requested: &str, ) -> Option { + target_candidates(rules, kind, manifest, requested).into_iter().next() +} + +/// 一家对某请求名的**全部**候选目标,按应当尝试的顺序排列。 +/// +/// ── 为什么需要「全部」而不是「一个」────────────────────────── +/// 同一 alias 在同一家挂多条映射(各自指向不同上游模型)时,旧实现只返回 +/// **一个**:按配置顺序的第一个。于是「同名多模型」在一家之内只有先建的那条 +/// 生效,其余全是影子条目 —— 而跨家(多家各挂一条同名映射)本来就一起进候选 +/// 链主备切换。本函数把家内的多条映射也摊平成一条候选链,家内与家外因此走 +/// **同一个算法**:按顺序试,撞限额/路由不存在就换下一个(消费方见 +/// `upstream::provider_loop` 的 target 循环)。 +/// +/// ── 顺序的约定(第一位必须逐字等于旧的唯一答案)──────────────── +/// 向后兼容的关键在这里:`target_candidates(...).first()` 等于旧 +/// `builtin_target` 的返回值。否则存量配置的行为会在升级后悄悄改变 —— +/// 那是本项目最忌讳的一类回归。旧实现的挑选顺序是 +/// 「原生 → Cline 池前缀项 → 第一条带 provider 的 → 第一条」, +/// 因此摊平成链时按同一优先级分三段: +/// ① **原生承载**(清单里有这个 id 且默认绑定启用):旧实现在这一步就 +/// 直接返回,所以它必须**单独占满整条链** —— 后面的映射一条都不进 +/// (「原生路由优先、映射是追加的兜底路」的既有语义); +/// ② Cline 的**池前缀**匹配项(target 带本池的通道前缀,见 +/// `cline::models::Pool::target_prefix`); +/// ③ 其余带 `provider` 的映射项(按 `modelRules.mappings` 的配置顺序); +/// ④ `provider` 缺失的旧版全局条目(同等条件下排最后)。 +/// +/// `enabled=false` 的映射一条都不进链(那条别名暂时不存在的既有语义)。 +/// +/// 链长 = 该 alias 在这家上的映射条数(由用户在管理页配置,天然有界); +/// 每条目标都是**不同的上游模型**,因而也是不同的额度桶 —— 这正是 +/// 「撞一个模型的限额就换另一个模型」成立的前提。 +pub(super) fn target_candidates( + rules: &model_rules::ModelRules, + kind: ProviderKind, + manifest: &[Value], + requested: &str, +) -> Vec { let provider = kind_id(kind); + // ⓪ 原生承载:命中就到此为止(见函数头 ①) if let Some(id) = entry_id_in_manifest(manifest, requested) { if rules.default_enabled(provider, &id) { - return Some(WireTarget { + return vec![WireTarget { reasoning: rules.binding(provider, &id, &id).and_then(|m| m.reasoning.clone()), model: id, - }); + }]; } } let candidates: Vec<_> = rules.mappings.iter() @@ -56,16 +103,39 @@ pub(super) fn builtin_target( .collect(); let prefix = super::super::cline::models::Pool::from_provider_id(provider) .map(super::super::cline::models::Pool::target_prefix); - let selected = candidates.iter() - .filter(|(mapping, _)| mapping.provider.is_some()) - .find(|(_, target)| prefix.is_some_and(|prefix| target.starts_with(prefix))) - .or_else(|| candidates.iter().find(|(mapping, _)| mapping.provider.is_some())) - .or_else(|| candidates.first()); - selected.map(|(mapping, target)| WireTarget { - model: target.clone(), - reasoning: rules.binding(provider, &mapping.alias, &mapping.target) + // 分组顺序即尝试顺序(见函数头 ①②③④)。组内保持 `rules.mappings` 的 + // 配置顺序 —— 稳定分区,不用 sort(那会按 target 字典序重排候选, + // 与旧实现「先建先用」的口径分叉)。 + let mut out: Vec = Vec::with_capacity(candidates.len()); + let mut rest: Vec<&(&model_rules::Mapping, String)> = Vec::with_capacity(candidates.len()); + let mut tail: Vec<&(&model_rules::Mapping, String)> = Vec::new(); + for entry in &candidates { + match (entry.0.provider.is_some(), prefix.is_some_and(|p| entry.1.starts_with(p))) { + // ② 池前缀优先级最高(Cline 两条池现在是两家 provider, + // 带本池前缀的 target 才是这一家真正认的发送名) + (true, true) => out.push(wire_of(rules, provider, entry)), + // ③ 其余带 provider 的条目先进缓冲,② 全部收完再按原序接上 + (true, false) => rest.push(entry), + // ④ 旧版全局条目垫底 + (false, _) => tail.push(entry), + } + } + out.extend(rest.iter().map(|entry| wire_of(rules, provider, entry))); + out.extend(tail.iter().map(|entry| wire_of(rules, provider, entry))); + out +} + +/// 一条候选 → 发送目标(思考等级跟着那条映射走,与旧实现同源)。 +fn wire_of( + rules: &model_rules::ModelRules, + provider: &str, + entry: &(&model_rules::Mapping, String), +) -> WireTarget { + WireTarget { + model: entry.1.clone(), + reasoning: rules.binding(provider, &entry.0.alias, &entry.0.target) .and_then(|effective| effective.reasoning.clone()), - }) + } } /// 原始能力查询不应用开关,供管理 API 展开旧版全局规则。 @@ -126,6 +196,27 @@ pub fn wire_target_for_provider(model: &str, provider_id: &str, _account: Option resolved.unwrap_or_else(|| WireTarget { model: requested.to_string(), reasoning: None }) } +/// 一家对某请求名的**全部**候选目标(有序,见 [`target_candidates`] 的顺序约定)。 +/// +/// 消费方是转发层的 target 循环:按位逐个尝试,模型作用域的失败(限额 / +/// 路由不存在)换下一位。解析不出(请求名为空 / 该家既不原生承载、也没有 +/// 映射指向它)时返回**空表** —— 与 [`wire_target_for_provider`] 的兜底口径 +/// 不同:那边「发请求名」总能构造一次发送,这边没有可尝试的候选,调用方 +/// 据此跳过循环、退回单目标路径(发送名原样 = 请求名,行为不变)。 +/// +/// `_account` 与 [`wire_target_for_provider`] 同名同义:候选链是按家算的, +/// 同家的账号共享同一份清单与规则(账号唯一影响发送时的池判定,见 +/// `provider_loop` 的 send_cache 键),发送侧因此不按账号重算。 +pub fn wire_targets_for_provider(model: &str, provider_id: &str, _account: Option<&Value>) -> Vec { + let requested = model.trim(); + if requested.is_empty() { + return Vec::new(); + } + kind_from_id(provider_id) + .map(|kind| target_candidates(&model_rules::current(), kind, &manifest_for(kind), requested)) + .unwrap_or_default() +} + pub fn default_model_catalog() -> Vec { let rules = model_rules::current(); let mut result = Vec::new(); @@ -145,3 +236,123 @@ pub fn default_model_catalog() -> Vec { pub fn default_model_usable(model: &str) -> bool { entry_id_in_manifest(&default_model_catalog(), model).is_some() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::core::model_rules::ModelRules; + use serde_json::json; + + /// `from_raw` 收的是整份 config(它自己取 `modelRules` 键),所以包一层。 + fn rules_with(mappings: Value) -> ModelRules { + let raw = json!({ "modelRules": { "mappings": mappings } }); + ModelRules::from_raw(raw.as_object().expect("对象字面量")) + } + + /// 一家的最小清单:两个上游模型 + 一个展示名条目。 + fn manifest() -> Vec { + vec![ + json!({ "id": "model-a", "name": "Model A" }), + json!({ "id": "model-b", "name": "Model B" }), + ] + } + + fn targets(rules: &ModelRules, requested: &str) -> Vec { + target_candidates(rules, ProviderKind::WorkBuddy, &manifest(), requested) + .into_iter() + .map(|wire| wire.model) + .collect() + } + + /// 家内主备的核心契约:同一 alias 在同一家挂多条映射时,**全部**进链, + /// 组内保持配置顺序(先建先用)。首位必须与改造前 `builtin_target` 的 + /// 唯一答案逐字相等 —— 那是存量配置行为不变的锚点。 + #[test] + fn same_alias_in_one_family_becomes_an_ordered_chain() { + let rules = rules_with(json!([ + { "alias": "free", "target": "model-a", "provider": "workbuddy", "enabled": true }, + { "alias": "free", "target": "model-b", "provider": "workbuddy", "enabled": true }, + ])); + assert_eq!(targets(&rules, "free"), vec!["model-a", "model-b"]); + } + + /// 原生承载独占整条链:请求名命中清单 id 时映射一条都不进 —— + /// 「原生路由优先、映射是追加的兜底路」的既有语义不被摊平破坏。 + #[test] + fn native_route_excludes_mappings() { + let rules = rules_with(json!([ + { "alias": "model-a", "target": "model-b", "provider": "workbuddy", "enabled": true }, + ])); + // model-a 是清单里的原生 id:链上只有它自己 + assert_eq!(targets(&rules, "model-a"), vec!["model-a"]); + // model-b 走映射:链上只有映射的 target + assert_eq!(targets(&rules, "model-b"), vec!["model-b"]); + } + + /// 关闭的映射一条都不进链(「这条别名暂时不存在」的既有语义)。 + #[test] + fn disabled_mapping_is_excluded() { + let rules = rules_with(json!([ + { "alias": "free", "target": "model-a", "provider": "workbuddy", "enabled": false }, + { "alias": "free", "target": "model-b", "provider": "workbuddy", "enabled": true }, + ])); + assert_eq!(targets(&rules, "free"), vec!["model-b"]); + } + + /// 组内顺序是配置顺序,不是字典序 —— 稳定分区,不用 sort。 + #[test] + fn mapping_order_is_config_order_not_lexicographic() { + let rules = rules_with(json!([ + { "alias": "free", "target": "zeta", "provider": "workbuddy", "enabled": true }, + { "alias": "free", "target": "alpha", "provider": "workbuddy", "enabled": true }, + ])); + let mut manifest = manifest(); + manifest.push(json!({ "id": "zeta", "name": "Zeta" })); + manifest.push(json!({ "id": "alpha", "name": "Alpha" })); + let got: Vec = target_candidates(&rules, ProviderKind::WorkBuddy, &manifest, "free") + .into_iter() + .map(|wire| wire.model) + .collect(); + assert_eq!(got, vec!["zeta", "alpha"]); + } + + /// Cline 池前缀优先:带本池通道前缀的 target 排在链首(与旧实现同偏好), + /// 其余带 provider 的条目按原序跟在后面。 + /// + /// 展示名刻意不与请求名同形 —— `entry_id_in_manifest` 有「按展示名回落」 + /// 的兼容,同名会触发原生承载短路(那是另一个正确的分支,见 + /// `native_route_excludes_mappings`),测不到池前缀优先。 + #[test] + fn cline_pool_prefix_is_preferred_then_rest_follow_in_order() { + let rules = rules_with(json!([ + { "alias": "glm", "target": "z-ai/glm-5.3-flash", "provider": "cline-free", "enabled": true }, + { "alias": "glm", "target": "cline-free/glm-5.3-flash", "provider": "cline-free", "enabled": true }, + ])); + let manifest = vec![json!({ "id": "cline-free/glm-5.3-flash", "name": "Cline Free GLM Flash" }), + json!({ "id": "z-ai/glm-5.3-flash", "name": "Z AI GLM Flash" })]; + let got: Vec = target_candidates(&rules, ProviderKind::ClineFree, &manifest, "glm") + .into_iter() + .map(|wire| wire.model) + .collect(); + // 带池前缀的 `cline-free/` 在前(旧实现的首位),裸 id 在后 + assert_eq!(got, vec!["cline-free/glm-5.3-flash", "z-ai/glm-5.3-flash"]); + } + + /// 解析不出任何目标(未知名字、无映射)→ 空表:调用方退回单答案路径。 + #[test] + fn unresolvable_name_yields_empty_chain() { + let rules = rules_with(json!([])); + assert!(targets(&rules, "unknown-model").is_empty()); + // 空请求名同样为空(wire_targets_for_provider 的入口短路) + assert!(target_candidates(&rules, ProviderKind::WorkBuddy, &manifest(), "").is_empty()); + } + + /// 别家挂的同名映射不进这一家的链(provider 过滤仍在)。 + #[test] + fn other_family_mapping_is_not_in_this_chain() { + let rules = rules_with(json!([ + { "alias": "free", "target": "model-a", "provider": "raccoon", "enabled": true }, + ])); + assert!(targets(&rules, "free").is_empty()); + } +} diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/router.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/router.rs index 95d83398d..9d59d3488 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/router.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/router.rs @@ -1,5 +1,12 @@ //! 模型转发候选。只允许开启的默认绑定或别名进入全局账号队列。 //! provider 顺序仅用于稳定展示,实际尝试顺序仍由账号优先级决定。 +//! +//! 候选粒度是**家**(提供商家族):本函数回答「哪些家能承载这个名字」, +//! 选路与换号在此基础上按账号全局优先级走。同一 alias 在**同一家内**的多条 +//! 映射(家内主备)不在这里展开 —— 那是发送侧 per-账号的候选链 +//! (`catalog::wire_targets_for_provider` → `upstream::provider_loop` 的 +//! `lane_targets`),两层各管各的粒度:家决定「谁有资格」,链决定 +//! 「这一家先发哪个模型」。 use crate::server::core::providers::catalog::{forwarding_providers, providers_for_model}; use crate::server::core::providers::DEFAULT_PROVIDER_ID; diff --git a/desktop-tauri/src-tauri/server/src/server/core/routing.rs b/desktop-tauri/src-tauri/server/src/server/core/routing.rs index e04de90dc..ed511a1c8 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/routing.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/routing.rs @@ -63,24 +63,34 @@ use crate::server::core::account_store::store_util::js_truthy; /// 真名是**按家**解析的:同一个 `gpt-6-astra` 在 workbuddy 家映射到 /// `deepseek-v4.1-flash`,在 catpaw 家可能映射到别的 target。而选路要对 /// **账号列表**逐个判定,同一家往往有多个账号 —— 不缓存的话,一家有几个账号 -/// 就把 `wire_target_for_provider`(内含一次 `model_rules::current()` 克隆与 -/// 一轮清单扫描)跑几遍。缓存键是 provider id,规模恒等于候选家数(≤6)。 +/// 就把候选链解析(内含一次 `model_rules::current()` 克隆与一轮清单扫描) +/// 跑几遍。缓存键是 provider id,规模恒等于候选家数(≤6)。 +/// +/// ── 缓存的是**候选链**而不是单名(家内主备,2026-10)────────── +/// 同一 alias 在一家挂多条映射时,转发侧按位逐个尝试,限额冷却必须逐位判定: +/// 「第一个目标撞了限额、第二个目标明明还有额度」的账号不该被整条摘掉。所以 +/// [`Self::for_provider_all`] 给出链上全部真名,[`rate_limit_reset_at`] 按 +/// 「任一可用 = 账号可用」判定;单答案的 [`Self::for_provider`] 取链首,供 +/// 视图 / 日志这类「按家问一个名字」的路径继续使用。 /// /// 用 `Mutex` 而不是 `RefCell`:本结构会被**异步**的选路路径持有(`select_target_account` /// 是 async),而 `RefCell` 不是 `Sync` —— 它会让整个 handler 的 future 失去 /// `Send`,编译期直接失败(这正好也说明它跨 await 存在)。锁的临界区只有 -/// 几次哈希查找,且与 `wire_target_for_provider` 的解析不重叠(解析在锁外做, -/// 结果才写回),不存在锁竞争问题。 +/// 几次哈希查找,且与候选链的解析不重叠(解析在锁外做,结果才写回), +/// 不存在锁竞争问题。 pub struct CooldownKeys<'a> { /// 客户端请求名(原始形态,未解析) requested: &'a str, - /// provider id → 该家收到的上游真名 - resolved: Mutex>, + /// provider id → 该家候选链上的全部上游真名(每位一个额度桶) + resolved_all: Mutex>>, } impl<'a> CooldownKeys<'a> { pub fn new(requested: &'a str) -> Self { - Self { requested, resolved: Mutex::new(HashMap::new()) } + Self { + requested, + resolved_all: Mutex::new(HashMap::new()), + } } /// 请求名原文(轮转游标按它分键:不同模型的候选集合不同,各轮各的) @@ -88,58 +98,86 @@ impl<'a> CooldownKeys<'a> { self.requested } - /// 该家实际收到的上游模型名 —— 也就是它的冷却键。 + /// 该家**候选链上全部**上游真名 —— 每个都是独立的额度桶。 /// - /// 解析不出(请求名为空 / 该家既不原生承载、也没有映射指向它)时原样返回 - /// 请求名:与转发侧 `wire_target_for_provider` 的 ④ 兜底同一口径 —— - /// 那时发出去的就是请求名,键也该是它。 + /// 同一 alias 在一家挂多条映射(家内主备)时,转发侧按位逐个尝试 + /// (`catalog::wire_targets_for_provider` 的顺序),限额冷却必须逐位判定: + /// 否则「第一个目标撞了限额、第二个目标明明还有额度」的账号会被整条摘掉 + /// —— 那正是家内候选链要修的浪费。解析不出时返回空表(与 + /// [`Self::for_provider`] 的「原样返回请求名」不同:那边总能构造一次发送, + /// 这边没有可冷却的键)。 /// - /// 锁中毒(别的线程 panic 过)时**不做缓存**、直接现算 —— 缓存只是加速, - /// 选路结果不该因为一个内部加速器而失败。 - pub fn for_provider(&self, provider_id: &str) -> String { + /// 锁中毒时**不做缓存**、直接现算 —— 缓存只是加速,选路结果不该因为一个 + /// 内部加速器而失败。传 `None` 账号解析:候选链按家算,同家账号共享同一份 + /// 清单与规则(见 `wire_targets_for_provider` 的 `_account` 说明)。 + pub fn for_provider_all(&self, provider_id: &str) -> Vec { if self.requested.is_empty() || provider_id.is_empty() { - return self.requested.to_string(); + return Vec::new(); } - if let Ok(cache) = self.resolved.lock() { + if let Ok(cache) = self.resolved_all.lock() { if let Some(cached) = cache.get(provider_id) { return cached.clone(); } } - // 传 `None` 账号:`wire_target_for_provider` 当前不读它(同家多条映射 - // 由 target 的通道前缀判定,见那里的说明),而选路时账号还没被选中 —— - // 「按账号解析」在判定阶段根本无从谈起。 - // - // ── 自定义提供商走自己的解析(第二阶段)─────────────────── - // modelRules 的映射表不认识 custom id(添加侧按注册表校验), - // `wire_target_for_provider` 对它只会原样返回请求名 —— 而自定义家的 - // 真名解析有自己的数据源(提供商记录上的 `mappings`,见 - // `custom_providers::wire_model_for`)。跳过这一步会让 alias 请求的 - // 冷却写在请求名上:正是本模块头描述的那类「判定漏命中、已限额账号 - // 被反复选中」的事故形态,所以这里必须按家分派。 - let wire = + let wires: Vec = if crate::server::core::custom_providers::is_custom_provider_id(provider_id) { - crate::server::core::providers::custom::forward::cooldown_model( + // 自定义家的真名解析有自己的数据源(提供商记录上的 `mappings`) + // 且目前是单答案 —— 家内候选链尚未覆盖(见 bindings::resolve)。 + // 跳过这一步会让 alias 请求的冷却写在请求名上:正是本模块头描述 + // 的那类「判定漏命中、已限额账号被反复选中」的事故形态。 + vec![crate::server::core::providers::custom::forward::cooldown_model( provider_id, self.requested, - ) + )] } else { - crate::server::core::providers::catalog::wire_target_for_provider( + crate::server::core::providers::catalog::wire_targets_for_provider( self.requested, provider_id, None, ) - .model + .into_iter() + .map(|target| target.model) + .collect() }; - if let Ok(mut cache) = self.resolved.lock() { - cache.insert(provider_id.to_string(), wire.clone()); + if let Ok(mut cache) = self.resolved_all.lock() { + cache.insert(provider_id.to_string(), wires.clone()); } - wire + wires + } + + /// 该家实际收到的**第一个**上游模型名 —— 单答案消费方的兼容入口。 + /// + /// 语义 = 候选链首位([`Self::for_provider_all`] 的第一项),解析不出时 + /// 原样返回请求名(与改造前逐字一致)。转发侧的发送名已不从这里取 + /// (target 循环按位传目标),剩下的调用方都是「按家问一个名字」的视图、 + /// 日志与记账路径 —— 单目标配置(绝大多数)行为不变;多目标配置下它代表 + /// 「主位」,要逐位判定请改用 [`Self::for_provider_all`]。 + pub fn for_provider(&self, provider_id: &str) -> String { + self.for_provider_all(provider_id) + .into_iter() + .next() + .unwrap_or_else(|| self.requested.to_string()) + } + + /// 某条账号记录的**全部**冷却键:按它所属的家解析(每条账号记录只属于一家)。 + pub fn for_account_all(&self, account: &Value) -> Vec { + self.for_provider_all(provider_of(account)) } - /// 某条账号记录的冷却键:按它所属的家解析(每条账号记录只属于一家)。 + /// 某条账号记录的冷却键(首位):按它所属的家解析。 pub fn for_account(&self, account: &Value) -> String { self.for_provider(provider_of(account)) } + + /// 注入式构造(**仅测试**):绕过按家解析,直接给定各家的候选链。 + /// + /// 冷却判定的测试要回答的是「链上多个真名时怎么算恢复时间」,而真实解析 + /// 依赖提供商清单与 modelRules —— 那是另一个模块的状态,拉进来只会把测试 + /// 变成集成测试。生产入口仍是 [`Self::new`]。 + #[cfg(test)] + pub(crate) fn with_chain(requested: &'a str, chains: HashMap>) -> Self { + Self { requested, resolved_all: Mutex::new(chains) } + } } /// 账号对某模型是否处于限额冷却期。 @@ -157,21 +195,48 @@ pub fn is_rate_limited(account: &Value, keys: &CooldownKeys<'_>, now: i64) -> bo /// Node 版口径:`Number(limit.resetAt) || 0`,只有大于 now 才返回, /// 否则返回 0 —— 这个 0 在选路里表示「当前可用」,不是「立刻恢复」。 /// -/// 冷却键按 [`CooldownKeys`] 解析(真名,不是请求名)。 +/// ── 候选链语义(家内主备,2026-10)─────────────────────────── +/// 冷却键按 [`CooldownKeys::for_account_all`] 逐位判定:候选链上**只要还有 +/// 一个目标没在冷却,这个账号就继续参与** —— 撞限额的那一位会被发送侧的 +/// target 循环跳过(免费目标优先,见 `provider_loop::lane_targets`),而不是 +/// 把整个账号摘掉。全部都在冷却时返回**最早**的恢复时间(第一个腾出额度的 +/// 目标的时刻),与选路第二级「恢复最早的试一次」同一把尺子。 +/// +/// 单目标配置(绝大多数)与改造前逐字等价:链上只有一位,「任一可用」退化成 +/// 「那一位可用」。 +/// +/// ── 链为空时的兜底(复核补丁)───────────────────────────────── +/// 解析不出任何目标(未知名、该家既不原生承载也没映射指向它)时,发送侧会 +/// **原样把请求名发出去**(`wire_targets_for_provider` 返回空表 → `wire: None` +/// → `wire_target_for_provider` 的兜底),限额也就记在请求名上。所以这里必须 +/// 退回单答案键(`for_account`,解析不出时正是请求名)—— 否则「按请求名记下的 +/// 冷却」在判定侧查不到,账号会被反复选中、反复撞同一个 429,正是本模块头 +/// 点名的那类事故。这就把「链为空」也拉回改造前的口径。 pub fn rate_limit_reset_at(account: &Value, keys: &CooldownKeys<'_>, now: i64) -> i64 { - let key = keys.for_account(account); - let Some(limit) = account - .get("rateLimits") - .and_then(|limits| limits.get(key.as_str())) - else { + let Some(limits) = account.get("rateLimits") else { return 0; }; - let reset_at = limit - .get("resetAt") - .and_then(js_number) - .unwrap_or(0.0); - if reset_at > now as f64 { - reset_at as i64 + let mut chain = keys.for_account_all(account); + if chain.is_empty() { + chain.push(keys.for_account(account)); + } + let mut earliest = f64::INFINITY; + for key in chain { + let reset_at = limits + .get(key.as_str()) + .and_then(|limit| limit.get("resetAt")) + .and_then(js_number) + .unwrap_or(0.0); + // 没有记录 / 已过期 = 这一位可用 = 账号整体可用(0) + if reset_at <= now as f64 { + return 0; + } + if reset_at < earliest { + earliest = reset_at; + } + } + if earliest.is_finite() { + earliest as i64 } else { 0 } @@ -1160,6 +1225,104 @@ mod tests { let order: Vec<&str> = plan.ordered.iter().map(|entry| entry.id.as_str()).collect(); assert_eq!(order, vec!["a1", "a2", "b1", "b2"]); } + + // ── 家内候选链的冷却判定(rate_limit_reset_at 的「任一可用 = 账号可用」)── + + /// 一条带 `rateLimits` 的账号记录;`now` 取一个远未来的常数,测试里 + /// 直接用绝对时间戳构造「在冷却 / 已过期」两种状态。 + fn account_with_limits(limits: Value) -> Value { + json!({ "id": "a1", "provider": "prov", "priority": 10, "rateLimits": limits }) + } + + /// 候选链上只要还有一个目标没在冷却,账号就继续可用(reset 0)—— + /// 撞限额的那一位由发送侧的 target 循环跳过,而不是把整个账号摘掉。 + #[test] + fn a_chain_with_any_free_target_keeps_the_account_usable() { + let now = 1_700_000_000_000i64; + let keys = CooldownKeys::with_chain( + "free", + [("prov".to_string(), vec!["model-a".to_string(), "model-b".to_string()])] + .into_iter() + .collect(), + ); + let account = account_with_limits(json!({ + "model-a": { "resetAt": (now + 60_000) as f64 }, + })); + assert_eq!(rate_limit_reset_at(&account, &keys, now), 0); + assert!(!is_rate_limited(&account, &keys, now)); + } + + /// 全部都在冷却时,返回**最早**的恢复时间(第一个腾出额度的目标的时刻) + /// —— 与选路第二级「恢复最早的试一次」同一把尺子。 + #[test] + fn an_all_cooled_chain_reports_the_earliest_reset() { + let now = 1_700_000_000_000i64; + let keys = CooldownKeys::with_chain( + "free", + [("prov".to_string(), vec!["model-a".to_string(), "model-b".to_string()])] + .into_iter() + .collect(), + ); + let account = account_with_limits(json!({ + "model-a": { "resetAt": (now + 90_000) as f64 }, + "model-b": { "resetAt": (now + 30_000) as f64 }, + })); + assert_eq!(rate_limit_reset_at(&account, &keys, now), now + 30_000); + assert!(is_rate_limited(&account, &keys, now)); + } + + /// 链上任何一位没有记录 / 已过期 = 那一位可用 = 整体可用(单目标配置 + /// 与改造前逐字等价:链上只有一位,「任一可用」退化成「那一位可用」)。 + #[test] + fn an_expired_entry_counts_as_free() { + let now = 1_700_000_000_000i64; + let keys = CooldownKeys::with_chain( + "free", + [("prov".to_string(), vec!["model-a".to_string()])].into_iter().collect(), + ); + let account = account_with_limits(json!({ + "model-a": { "resetAt": (now - 1) as f64 }, + })); + assert_eq!(rate_limit_reset_at(&account, &keys, now), 0); + } + + /// 链为空(这个名字在这一家解析不出目标)时退回**单答案键**,也就是 + /// 请求名 —— 与改造前逐字一致。 + /// + /// 为什么必须有这条:解析不出目标时发送侧原样发请求名(`wire: None`), + /// 限额也记在请求名上;若判定侧只认链、读不到那条记录,账号就会被反复 + /// 选中再撞一次 429。 + #[test] + fn an_empty_chain_falls_back_to_the_request_name_key() { + let now = 1_700_000_000_000i64; + let keys = CooldownKeys::with_chain("free", HashMap::new()); + let account = account_with_limits(json!({ + "free": { "resetAt": (now + 60_000) as f64 }, + })); + assert_eq!(rate_limit_reset_at(&account, &keys, now), now + 60_000); + assert!(is_rate_limited(&account, &keys, now)); + // 对照:请求名上没有记录 → 可用(不是「一律冷却」) + let fresh = account_with_limits(json!({})); + assert_eq!(rate_limit_reset_at(&fresh, &keys, now), 0); + } + + /// 单答案兼容入口 `for_provider` 取链上首位;链为空(解析不出)时原样 + /// 返回请求名 —— 与改造前的兜底口径一致。 + #[test] + fn for_provider_returns_the_chain_head_and_falls_back_to_the_request_name() { + let keys = CooldownKeys::with_chain( + "free", + [("prov".to_string(), vec!["model-b".to_string(), "model-a".to_string()])] + .into_iter() + .collect(), + ); + assert_eq!(keys.for_provider("prov"), "model-b"); + // 没有这一家的链 → 回落请求名 + assert_eq!(keys.for_provider("other"), "free"); + // 请求名为空 → 空链 → 兜底请求名(空串) + let empty = CooldownKeys::with_chain("", HashMap::new()); + assert_eq!(empty.for_provider("prov"), ""); + } } diff --git a/desktop-tauri/src-tauri/server/src/server/core/upstream/payload.rs b/desktop-tauri/src-tauri/server/src/server/core/upstream/payload.rs index 50e39a6ff..66a8d4605 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/upstream/payload.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/upstream/payload.rs @@ -173,15 +173,31 @@ pub(super) struct SendBody<'a> { /// 与 `note_attempt` 的「最后一次为准」不同,命中按**并集**累加:候选链上 /// A 家处理过、降级到 B 家又处理一次,同一条规则会被两轮各命中一次。 /// 「这次请求命中了什么」才是用户要的答案(B 家只是同一份内容又匹配了一遍), -/// 所以同一家重复发送时不重复计(`send_cache` 已经保证了这一点:同一家同池 -/// 的发送体只算一次),跨家则合并计数。 +/// 所以同一家重复发送时不重复计 —— 由调用方按家去重后传 `count_redactions` +/// (见下),跨家则合并计数。 /// 实时性上也有必要:命中发生在**某一家即将发送时**,那时请求还没收尾, /// telemetry 槽位还开着(记账点读快照在最后)。 +/// +/// ── `wire`:候选链上的目标(家内主备,2026-10)─────────────── +/// `Some` = 转发层的 target 循环已经按位选好这一轮要发的目标,这里**只应用、 +/// 不再解析** —— 「一次解析、两个产物同源」的既有约束(见 [`SendBody`] 的 +/// 说明)在多目标形态下的延伸:解析点挪到循环开头(`lane_targets`,一次算出 +/// 整条链),发送位只是取链上第 n 项。`None` = 维持旧语义,按单答案解析 +/// (`wire_target_for_provider`)—— 自定义家与会话式路径、以及候选链为空 +/// (模型名解析不出)的兜底都走这条,行为逐字不变。 +/// +/// ── `count_redactions`:这一次要不要记「敏」命中 ────────────────── +/// 调用方按**家**去重:同一次请求里,同一家的发送体可能被重算好几遍(家内 +/// 顺延换位次、内容拦截后翻转降级标记、账号换池),但过的是**同一份内容**, +/// 命中明细也必然相同 —— 由 `send_cache` 之外的一层(`provider_loop` 的 +/// `redaction_counted`)保证「一家只记一次」。`false` = 只改写字节、不记账。 pub(super) fn send_body<'a>( ctx: &'a ProviderContext<'_>, provider_id: &str, account: Option<&Value>, degraded: bool, + wire: Option<&crate::server::core::providers::catalog::WireTarget>, + count_redactions: bool, ) -> SendBody<'a> { // ── ⓪ 角色归一层(开发者角色兼容开关)─────────────────────────── // 在所有改写**之前**:先把 developer 角色归一成 system,后面各层(提示词、 @@ -243,7 +259,7 @@ pub(super) fn send_body<'a>( // 命中表可能为空:`sanitize_text` 末尾的去空白也能单独构成一次 // 改动(预检命中、但没有任何规则真正替换)。那时不该往请求日志 // 的「敏」标签里写一条空记录。 - if !hits.is_empty() { + if count_redactions && !hits.is_empty() { ctx.telemetry.note_sensitive_hits(&hits); } Cow::Owned(scrubbed) @@ -292,12 +308,16 @@ pub(super) fn send_body<'a>( return SendBody { body, wire_model: requested }; } // 一次解析出两个属性:该家要收的名字 + 跟着那条映射走的思考等级 - // (同源,见模块头「思考等级绑定为什么也在这一步」) - let wire = crate::server::core::providers::catalog::wire_target_for_provider( - &requested, - provider_id, - account, - ); + // (同源,见模块头「思考等级绑定为什么也在这一步」)。 + // `wire` 已由转发层按位选好(家内候选链)时直接应用;否则走单答案解析。 + let wire = match wire { + Some(target) => target.clone(), + None => crate::server::core::providers::catalog::wire_target_for_provider( + &requested, + provider_id, + account, + ), + }; ctx.telemetry.note_upstream_model(&wire.model); rewrite_model(&mut body, &requested, &wire.model, provider_id); let injected = apply_reasoning( diff --git a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop.rs b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop.rs index 52e697f2d..c500f10e3 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop.rs @@ -11,13 +11,21 @@ //! └ 429 → 标记该账号对该模型冷却,回到账号循环选下一个(可能换了一家) //! ``` //! +//! ── 家内候选链(同一 alias 在一家挂多条映射,2026-10)──────── +//! 账号循环的每轮里还有一层**位次游标**(`lane`):同一对外名在这一家映射到 +//! 多个上游模型时,`lane_targets` 按序给出整条链(免费目标优先),模型作用域 +//! 的失败(限额 / 上游 404)在本账号内换下一位,链走完才轮到账号顺延 —— +//! 与「同一别名挂多家」的跨家主备同算法,只是候选粒度细化到「家 × 上游模型」。 +//! 顺序约定与兼容性说明见 `catalog::target_candidates`。 +//! //! 曾经是「外层按 provider 路由优先级轮询、内层在该家账号里选路」两层循环; //! 现在各家的账号排在同一条队里,先用哪一家由账号优先级本身决定,provider //! 只是每个账号的属性(决定用哪个适配器发)。候选池按 provider 过滤只剩一个 //! 目的:不把不提供该模型的家的账号放进来。 //! //! ── 错误分类怎么驱动控制流(架构文档 §4.2 的三个动作)──────── -//! 1. `QuotaLimited` → 标记该账号对该模型冷却 + 换下一个候选账号; +//! 1. `QuotaLimited` → 标记该账号对该模型冷却;本账号链上还有下一位就换位 +//! (家内顺延,见下),否则换下一个候选账号; //! 2. `TokenExpired` → 调 `refresh_access_token` 刷新凭证,**同一账号** //! 原样重试一次(只一次:再失败按普通失败处理,不会空转); //! 3. `Fatal` → 原样透传给客户端。 @@ -72,7 +80,7 @@ //! 模型目录刷新、定时任务、账号被标记限额)。判断一条日志该去哪边,就看 //! **它的条数会不会跟着用户发请求一起涨**。 -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use serde_json::{json, Value}; @@ -83,6 +91,7 @@ use crate::server::core::protocol::strip_internal_fields; use crate::server::core::providers::adapter::{ adapter_for, ProviderAdapter, RetryAdvice, UpstreamErrorClass, }; +use crate::server::core::providers::catalog::WireTarget; use crate::server::core::providers::custom::forward as custom_forward; use crate::server::core::providers::readiness; use crate::server::core::providers::router::route_for_forward; @@ -589,10 +598,29 @@ async fn attempt_queue( // 那一份发送体是另一份副本,键不同就自然落进另一条缓存项 —— 不必手工失效 // 缓存,也不会把「降级前那份」错发给后面的账号。 // + // 键的第四维是**这一档实际要发的上游模型名**(不是位次序号 `lane`)。 + // 为什么不能只用序号:位次 → 目标的对应关系是**按账号**算出来的 + // (`lane_targets` 的「免费优先」分区读的是该账号自己的冷却状态),同家 + // 同池的两个账号完全可能把不同目标排在同一位次上。用序号做键时,第二个 + // 账号会命中第一个账号那一档的发送体 —— 于是「这一家先发哪个模型」被上一个 + // 账号的冷却状态决定,发出去的模型名与本次 `lane_targets` 的结论不符(429 + // 记账也跟着记到错误的真名上)。用发送名做键则与「这份发送体由哪条映射 + // 算出」一一对应:位次相同**且目标相同**才复用。 + // // 值里同时带着**该家实际收到的上游模型名**(`SendBody::wire_model`)—— // 它是限额冷却的键,与发出去的字节同源(见 `payload::SendBody`)。 // 缓存因此不只省一次脱敏:429 记账与成功清理都从这里取真名,不必再解析一遍。 - let mut send_cache: HashMap<(&'static str, String, bool), SendBody<'_>> = HashMap::new(); + let mut send_cache: HashMap<(&'static str, String, bool, Option), SendBody<'_>> = + HashMap::new(); + // 「敏」命中表按**家**只记一次。同一次请求里换位次(家内顺延)、翻转降级 + // 标记、换池都会重算发送体、重过一遍脱敏层,而命中的是**同一份内容**—— + // 计数跟着翻倍就是虚高(口径见 `payload::send_body` 的 `count_redactions`)。 + let mut redaction_counted: HashSet<&'static str> = HashSet::new(); + // 家内候选链的**解析**结果按家缓存(顺序不缓存 —— 那是账号的函数)。 + // 同一个家在一次请求里往往有多个账号轮流承载,而解析要走一次 + // `model_rules::current()`(克隆整份 config raw,含提示词全文)与一遍 + // `manifest_for` 的清单扫描;放在账号循环外就只付一次。 + let mut chain_cache: HashMap<&'static str, Vec> = HashMap::new(); // 标签是必需的:下面「429 降级到下一个账号」发生在**内层发送循环**里, // 裸 `continue` 会回到内层(用同一个账号再发一次,正好是要避免的事)。 @@ -955,6 +983,27 @@ async fn attempt_queue( .trim() .to_string(); + // ── 家内候选链(同一 alias 在这一家的多条映射,按序主备)──────── + // 链的**内容**按家解析一次并缓存(`chain_cache`:解析内含一次 config + // 克隆与一遍清单扫描,同一个家在一次请求里常被多个账号轮流承载, + // 没必要按账号重算);链的**顺序**是账号的函数,逐账号重排(免费目标 + // 优先,见 `lane_targets`)。`lane` 是发送游标 —— 模型作用域的失败 + // (限额 / 404)在**本账号内**顺延到下一位,链走完才轮到账号顺延。 + // 链为空 = 这个名字在这一家解析不出任何目标,发送侧退回单答案路径 + // (`wire: None`,发送名原样),与改造前逐字一致。位置在 + // note_attempt_started 之后、发送循环之前:逐位冷却查询要用到本账号的 + // 记录(`target.account_id`)。 + let chain = chain_cache + .entry(provider_id) + .or_insert_with(|| { + crate::server::core::providers::catalog::wire_targets_for_provider( + &model, provider_id, None, + ) + }) + .clone(); + let lane_wires = lane_targets(chain, service, target.account_id.as_deref()); + let mut lane = 0usize; + // ── 一次账号内的发送链:最多三次(首次 + 内容拦截补救一次 + 401 刷新重试一次)── // 为什么把刷新重试并进同一个循环:重试**自己也可能是** 429 // (额度确实用尽)。若把它当成独立分支直接返回,就会把一条限额错误 @@ -964,7 +1013,8 @@ async fn attempt_queue( // 同一层还承载「内容拦截 → 换中性提示词重试一次」(动作 0):它同样是 // 「不换账号、就地再发一次」,只是换了 body 而不是凭证。两个 `continue` // 各自有一个一次性开关(`degraded` / `refreshed`),所以迭代次数有上界 - // ——不存在「一直重发」的路径。 + // ——不存在「一直重发」的路径。家内顺延(换 lane)不设一次性开关: + // 链长由用户配置的映射条数决定,每位至多发一次(失败即换位)。 let started_at = logging::now_ms(); let mut refreshed = false; // 第三个元素是上游响应的协议(适配器在构造请求时给出):chat 之外 @@ -972,12 +1022,28 @@ async fn attempt_queue( // Antigravity 请求信封里的会话键(签名缓存兜底档的写入键,issue #291; // 其余家是空串 —— 提取见循环内唯一那条成功 break) let (response, wire_model, response_protocol, antigravity_session) = loop { - // 发送体在这一轮发送前取一次(同一家同池同降级状态下复用缓存项): - // 借用在本次迭代内有效,`continue`(401 刷新 / 内容拦截补救)时 - // 重新取 —— 于是「换了 body 的那次重试」拿到的一定是新的一份。 - let send = send_cache.entry((provider_id, account_pool.clone(), degraded)).or_insert_with( - || send_body(ctx, provider_id, target.account.as_ref(), degraded), - ); + // 发送体在这一轮发送前取一次(同一家同池同降级状态**同一目标**下 + // 复用缓存项):借用只在本次迭代内有效,`continue`(401 刷新 / + // 内容拦截补救 / 家内顺延)时重新取 —— 于是「换了 body 的那次重试」 + // 拿到的一定是新的一份。键带**这一档的目标名**(见 send_cache 的 + // 说明):`lane` 只是本账号内的位次,同一个位次在不同账号上可能指向 + // 不同模型,只有目标名相同才是同一份发送体。 + let lane_wire = lane_wires.get(lane); + let send = send_cache + .entry(send_cache_key(provider_id, &account_pool, degraded, lane_wire)) + .or_insert_with(|| { + // 只有「这一家的第一次发送」才记「敏」命中(见 send_cache + // 上方的说明):重算发送体不等于又命中了一遍同样的内容。 + let count_redactions = redaction_counted.insert(provider_id); + send_body( + ctx, + provider_id, + target.account.as_ref(), + degraded, + lane_wire, + count_redactions, + ) + }); // 内置家是「把 chat 体原样发给上游」的透传出口:入口翻译(Anthropic / // Responses)暂存的内部字段(_wb_*,见 protocol::mod 的说明)绝不能 // 到这里 —— 严格校验的上游会拒绝消息上的未知字段整轮 400。没有暂存 @@ -1292,6 +1358,47 @@ async fn attempt_queue( Some(i64::from(failure.error.status_code)), Some(&failure.error.message), ); + // ── 家内顺延(模型作用域的失败先在本账号内换候选)────── + // 见 `lane_targets` 与 `lane` 的说明。这里收「上游 404 = + // 模型不在这份套餐 / 路由不存在」这一位:换账号发同一个名字 + // 是同样的 404(原判据 DETERMINISTIC_NOT_FOUND_STATUSES 的 + // 既有结论),换链上下一个目标才是有意义的动作。限额(429) + // 的顺延在动作 1 里 —— 那边必须先把冷却落盘再换位(见该处 + // 说明)。模型测试(pinned)不顺延:测试问的是 + // 「这个账号 × 这个模型行不行」,换模型答就不是这个答案了。 + // + // 位置在明细定稿之后、动作 1/3 之前:这条 `continue` 留在 + // 内层发送循环里 —— 不进 tried_ids(账号没换)、不吃换号 + // 预算、不触发退避 / 熔断(404 是模型的事实,不是账号健康 + // 的问题,动作 3 的 route_state 标记因此被跳过)。 + let not_found = matches!( + &failure.class, + UpstreamErrorClass::Fatal { status, .. } + if DETERMINISTIC_NOT_FOUND_STATUSES.contains(status) + ); + if not_found { + if let Some(next) = + next_lane(lane, lane_wires.len(), ctx.pinned_account.is_some()) + { + lane = next; + ctx.telemetry.note_attempt( + target.account_id.as_deref(), + &attempt_account, + provider_id, + ); + ctx.telemetry.note_attempt_started(provider_id, &attempt_account); + logging::console_line( + "[Upstream]", + &format!( + "⚠️ 账号 {attempt_account} 对模型 {} 上游 404(模型不在其套餐或路由),\ + 家内候选顺延 → {}", + limit_model_label(&model, &wire_model), + lane_wires[lane].model, + ), + ); + continue; + } + } // ── 动作 1:限额 → 标记冷却 + 换下一个账号 ────────── if let ( UpstreamErrorClass::QuotaLimited { @@ -1303,35 +1410,70 @@ async fn attempt_queue( Some(account_id), ) = (&failure.class, target.account_id.clone()) { + // 冷却记账移出 `tried_ids` 去重(家内顺延后,同一账号的 + // 另一位撞限额是**另一个额度桶**,键不同 —— 漏标会让 + // 后续请求把已耗尽的账号再选中一次、再白撞一个 429)。 + // push 本身仍幂等。原实现「contains 就整段跳过」的分支 + // 在旧流程里实际不可达(选路排除已尝试、顺延只取未尝试 + // 的账号),因此这里对既有行为零回归。 if !tried_ids.contains(&account_id) { tried_ids.push(account_id.clone()); - // 冷却键 = **上游真名**(不是请求名):上游按它记额度, - // 判定侧(`cooldown_keys`)也按它查,两处同源见 - // `routing::CooldownKeys`。 - let reset_text = rotate::mark_account_limited( - service, - &account_id, - &wire_model, - *status as i32, - *upstream_code, - *reset_at, - &failure.error.message, + } + // 冷却键 = **上游真名**(不是请求名):上游按它记额度, + // 判定侧(`cooldown_keys`)也按它查,两处同源见 + // `routing::CooldownKeys`。真名取自发送体(与发出去的 + // 字节同源)—— 家内顺延后这里是**当前位次**的名字。 + let reset_text = rotate::mark_account_limited( + service, + &account_id, + &wire_model, + *status as i32, + *upstream_code, + *reset_at, + &failure.error.message, + ); + let limit_at = + rotate::account_limit_reset_at(service, &account_id, &wire_model); + // 限流垫底(Sink):它还**有**额度时被 429 的(额度尽是 + // 正常冷却,不算),排到所有没被 429 过的后面 —— 负载 + // 摊开,而不是每次都回到队列头再撞一遍。成功一次或 + // 重启后放出(见 route_state.rs)。 + if config::routing_settings().sink && ctx.pinned_account.is_none() { + service.route.sink_mark(&account_id, logging::now_ms()); + } + // 日志文案与账号页的「限流」列同口径:以真名为主, + // 映射生效时补 `请求名 →` 前缀(见 `limit_model_label`) + let limit_label = limit_model_label(&model, &wire_model); + let from_label = + account_label(target.account.as_ref(), &account_id, &session); + // ── 家内顺延:冷却已落盘 → 本账号内换下一位候选 ──── + // 必须在标记**之后**判定:先换位后标记的话,这一位在 + // 后续请求里仍会被 `lane_targets` 当作可用目标再撞一次 + // 429。顺延后开一条新明细(与账号轮换同一记账形态), + // `continue` 回发送循环 —— send_cache 的键带目标名, + // 新位次自然拿到新发送体。 + if let Some(next) = + next_lane(lane, lane_wires.len(), ctx.pinned_account.is_some()) + { + lane = next; + ctx.telemetry.note_attempt( + target.account_id.as_deref(), + &attempt_account, + provider_id, ); - let limit_at = - rotate::account_limit_reset_at(service, &account_id, &wire_model); - // 限流垫底(Sink):它还**有**额度时被 429 的(额度尽是 - // 正常冷却,不算),排到所有没被 429 过的后面 —— 负载 - // 摊开,而不是每次都回到队列头再撞一遍。成功一次或 - // 重启后放出(见 route_state.rs)。 - if config::routing_settings().sink && ctx.pinned_account.is_none() { - service.route.sink_mark(&account_id, logging::now_ms()); - } - // 日志文案与账号页的「限流」列同口径:以真名为主, - // 映射生效时补 `请求名 →` 前缀(见 `limit_model_label`) - let limit_label = limit_model_label(&model, &wire_model); - let from_label = - account_label(target.account.as_ref(), &account_id, &session); - match rotate::pick_next_account( + ctx.telemetry.note_attempt_started(provider_id, &attempt_account); + logging::console_line( + "[Upstream]", + &format!( + "⚠️ 账号 {from_label} 对模型 {limit_label} 已限额{},\ + 家内候选顺延 → {}(本账号内换映射目标)", + reset_hint(&reset_text), + lane_wires[lane].model, + ), + ); + continue; + } + match rotate::pick_next_account( service, provider_ids, &cooldown_keys, @@ -1417,7 +1559,6 @@ async fn attempt_queue( .with_optional_code(*upstream_code)); } } - } } // ── 动作 3:其它错误 → 换队列里的下一个账号 ────────── // 旧版(按家轮询)遇到任何一家失败都会换下一家再试;全局队列把 @@ -1911,7 +2052,9 @@ async fn attempt_custom( // **即将发送**。`send_body` 对自定义 id 是零改写(它的模型名改写只认 // modelRules),处理结果就是「提示词/脱敏后的客户端请求体」—— 自定义 // 语义的改写(映射 alias → 真名、思考等级)在 forward 里做。 - let send = send_body(ctx, &provider_id, target.account.as_ref(), degraded); + // `wire: None`:自定义家的映射解析是单答案(见 bindings::resolve 的说明), + // 家内候选链尚未覆盖 —— 按单答案路径解析,行为不变。 + let send = send_body(ctx, &provider_id, target.account.as_ref(), degraded, None, true); // 「上游模型」列以**真名**为准:send_body 对自定义 id 是零改写(它记的 // 是请求名),真名的解析与改写发生在 forward 内部 —— 这里按同源解析 // 覆盖一次(note_upstream_model 是覆盖式,最后一次为准;空串被内部过滤)。 @@ -2108,7 +2251,9 @@ async fn attempt_stateful( // `degraded` 由调用方(账号循环)给出:本路径**没有**就地补救(有状态 // provider 一次转发就是一个会话轮次,没有「换提示词重发」这一步), // 但降级期内(状态机已生效)首发的提示词也要跟着换。 - let send = send_body(ctx, provider_id, target.account.as_ref(), degraded); + // `wire: None`:会话式路径维持单答案解析(家内候选链不进本路径 —— 它 + // 没有账号内重试的机制,见 attempt_stateful 对 CatPaw 的说明),行为不变。 + let send = send_body(ctx, provider_id, target.account.as_ref(), degraded, None, true); // 内置家透传出口的内部字段剥离:与无状态路径同一理由(见那里的说明) let stripped = strip_internal_fields(&send.body); let body: &serde_json::Value = &stripped; @@ -2316,6 +2461,94 @@ fn limit_model_label(requested: &str, wire: &str) -> String { format!("{requested} → {wire}") } +/// 本账号这一轮要按序尝试的发送目标:把**已按家解析好**的候选链按「免费优先」 +/// 重排(链本身由调用方按家缓存,见 `attempt_queue` 的 `chain_cache`)。 +/// +/// ── 两段为什么分开 ────────────────────────────────────────── +/// 链的**内容**是家 × 请求名的函数(`catalog::target_candidates`:原生承载有则 +/// 独占、Cline 池前缀项、其余映射条目,组内保持 `modelRules.mappings` 的配置 +/// 顺序 —— 首位与旧实现的唯一答案逐字相等);链的**顺序**才是账号的函数(冷却 +/// 状态逐账号)。同一个家在一个请求里常常有多个账号轮流承载,把解析留在外面 +/// 只做一次,换来的是「一次 config 克隆 + 一遍清单扫描」不用按账号重复付。 +/// +/// ── 免费优先(已限额的目标稳定地排到链尾)────────────────── +/// 多条候选时,把已处于限额冷却的目标排到未限额的后面:额度用尽的目标再发 +/// 一次只会白吃一个 429(外加把冷却标记重写一遍),而链上其他目标可能就在 +/// 这一发之间答出来。**组内保持原序** —— 用稳定分区而不是排序,不引入任何 +/// 按名称的次序。全部都在冷却时按原序硬试一遍:与选路第二级「恢复最早的 +/// 试一次」同一取舍(上游若已实际解除限额可直接成功,否则把真实 429 与 +/// 恢复时间带回来)。 +/// +/// 冷却判定逐位读账号记录上的 `rateLimits[真名]`(`account_limit_reset_at` +/// 同一数据源与判据:0 = 未在冷却)。没有账号记录(默认登录态 / 环境变量 +/// 旁路)时不做冷却过滤 —— 那类通道本就不落限额标记。 +/// +/// ── 空表的语义 ────────────────────────────────────────────── +/// 链为空 = 名字在这一家解析不出任何目标(未知模型),调用方据此给 `send_body` +/// 传 `wire: None` 退回单答案路径(发送名原样 = 请求名)—— 与改造前「未知名字 +/// 交给默认家」的既有语义衔接。 +fn lane_targets(all: Vec, service: &UpstreamService, account_id: Option<&str>) -> Vec { + // 单候选(或空)不过滤:空表交给调用方兜底,单条没有可让位的对象。 + if all.len() <= 1 { + return all; + } + let Some(account_id) = account_id else { + return all; + }; + order_lane(all, |model| { + rotate::account_limit_reset_at(service, account_id, model) == 0 + }) +} + +/// 模型作用域的失败之后该不该在本账号内顺延,顺延到哪一位。 +/// +/// 两处顺延(动作 1 的限额 429、动作 3 之前的「上游 404 = 模型不在套餐」)共用 +/// 这一条判据,免得两边各写一遍 `lane + 1 < len`: +/// +/// - **模型测试(pinned)不顺延**:测试问的是「这个账号 × 这个模型行不行」, +/// 换一个模型去答就不是这个问题的答案了; +/// - 链上只有这一位、或链为空(这个名字在这一家解析不出目标)时没有可换的 +/// 对象 —— 返回 `None`,交给调用方走既有的换号路径。 +/// +/// `Some(n)` 只表示「换到第 n 位」,不表达任何记账 / 退避语义:那两条由调用方 +/// 各自决定(限额需先落盘冷却,404 不需要)。 +fn next_lane(lane: usize, chain_len: usize, pinned: bool) -> Option { + if pinned { + return None; + } + let next = lane + 1; + (next < chain_len).then_some(next) +} + +/// `send_cache` 的键:家 + 池 + 降级标记 + **这一档的目标名**。 +/// +/// 最后一维为什么是目标名而不是位次序号 —— 见 `send_cache` 声明处的说明: +/// 位次 → 目标按账号算,序号相同不代表发送名相同。`None` = 这一档没有候选 +/// (链为空,走单答案兜底),与任何具体目标都不共用。 +fn send_cache_key( + provider_id: &'static str, + pool: &str, + degraded: bool, + wire: Option<&WireTarget>, +) -> (&'static str, String, bool, Option) { + (provider_id, pool.to_string(), degraded, wire.map(|target| target.model.clone())) +} + +/// 把候选链按「未在冷却的排前面」稳定分区 —— `lane_targets` 的排序本体。 +/// +/// 抽成纯函数是为了可测:`lane_targets` 自己要读全局配置(`modelRules`)与 +/// 账号记录,测起来是集成测试;而**顺序契约**(稳定分区、组内保持原序、 +/// 全冷却时按原序硬试)完全可以脱离数据源单独钉死,见本文件的 +/// `lane_ordering` 用例组。 +fn order_lane(all: Vec, is_free: impl Fn(&str) -> bool) -> Vec { + let (free, cooled): (Vec<_>, Vec<_>) = all.into_iter().partition(|target| is_free(&target.model)); + // 全在冷却:按原序硬试(见 `lane_targets` 函数头「免费优先」末段的取舍) + if free.is_empty() { + return cooled; + } + free.into_iter().chain(cooled).collect() +} + /// 下发帧的改写策略:能力位由适配器回答,全局开关由配置回答,通用层不出现 /// provider 分支。 /// diff --git a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs index 67968a7ce..55ad9dadf 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs @@ -511,3 +511,107 @@ fn a_deterministic_404_skips_the_in_place_resend() { "404 的豁免不许顺手把 400 也豁免了" ); } + +// ── 家内候选链:排序契约与发送体缓存键 ──────────────────────────── +// +// 这两组都是「纯排序 / 纯键」的契约,不碰全局配置与账号库,因此不起服务器、 +// 不建临时库(`lane_targets` 本身要读 `modelRules` 与账号记录,那一层是集成 +// 形状,见 `lane_targets` 的说明)。 + +fn chain(models: &[&str]) -> Vec { + models + .iter() + .map(|model| WireTarget { model: (*model).to_string(), reasoning: None }) + .collect() +} + +fn order_of(all: Vec, cooled: &[&str]) -> Vec { + order_lane(all, |model| !cooled.contains(&model)) + .into_iter() + .map(|target| target.model) + .collect() +} + +/// 没在冷却的排前面,**组内保持原序**(稳定分区,不是排序)。 +#[test] +fn lane_ordering_puts_the_uncooled_first_and_keeps_the_group_order() { + // 第 1 位在冷却 → 它掉到链尾,其余相对次序不变 + assert_eq!( + order_of(chain(&["m1", "m2", "m3"]), &["m1"]), + vec!["m2", "m3", "m1"] + ); + // 中间一位在冷却 → 同样只让它自己往后掉 + assert_eq!( + order_of(chain(&["m1", "m2", "m3"]), &["m2"]), + vec!["m1", "m3", "m2"] + ); + // 两位在冷却 → 冷却组内部仍按原序(m1 在 m3 之前) + assert_eq!( + order_of(chain(&["m1", "m2", "m3"]), &["m3", "m1"]), + vec!["m2", "m1", "m3"] + ); +} + +/// 一位都没在冷却 → 原样返回(绝大多数请求的形态,不该有任何重排)。 +#[test] +fn lane_ordering_leaves_a_fully_free_chain_untouched() { + assert_eq!(order_of(chain(&["m1", "m2"]), &[]), vec!["m1", "m2"]); +} + +/// 全在冷却 → 按**原序**硬试,而不是返回空表:与选路第二级「恢复最早的试 +/// 一次」同一取舍(上游若已实际解除限额可直接成功)。 +#[test] +fn lane_ordering_tries_an_all_cooled_chain_in_its_original_order() { + assert_eq!( + order_of(chain(&["m1", "m2"]), &["m1", "m2"]), + vec!["m1", "m2"] + ); + assert_eq!(order_of(chain(&["only"]), &["only"]), vec!["only"]); +} + +/// 家内顺延的判据:两处顺延(429 / 404)共用,三条边界各钉一条。 +#[test] +fn next_lane_advances_only_while_the_chain_has_another_target() { + // 链上还有下一位 → 顺延 + assert_eq!(next_lane(0, 2, false), Some(1)); + assert_eq!(next_lane(0, 3, false), Some(1)); + assert_eq!(next_lane(1, 3, false), Some(2)); + // 走完了 → 没有下一位(交给换号) + assert_eq!(next_lane(1, 2, false), None); + // 只有一位(单目标配置,绝大多数请求)→ 从不顺延 + assert_eq!(next_lane(0, 1, false), None); + // 链为空(解析不出目标)→ 无从顺延 + assert_eq!(next_lane(0, 0, false), None); + // 模型测试(pinned)→ 任何情况都不顺延:换模型答就不是那个问题的答案 + assert_eq!(next_lane(0, 3, true), None); +} + +/// `send_cache` 的键必须带**目标名**,不能只用位次序号。 +/// +/// 这是本组用例的重点:位次 → 目标的对应关系是按账号算的(免费优先分区读的是 +/// 各账号自己的冷却状态),同家同池的两个账号会把不同目标排在同一位次上。 +/// 键里只放序号时,第二个账号会命中第一个账号那一档的发送体 —— 发错模型。 +#[test] +fn send_cache_key_distinguishes_targets_at_the_same_lane_index() { + // 账号 A 的链:m1 在冷却 → 第 0 位是 m2 + let a = order_lane(chain(&["m1", "m2"]), |model| model != "m1"); + // 账号 B 的链:都没在冷却 → 第 0 位是 m1 + let b = order_lane(chain(&["m1", "m2"]), |_| true); + assert_eq!(a[0].model, "m2"); + assert_eq!(b[0].model, "m1"); + + let key_a = send_cache_key("workbuddy", "", false, Some(&a[0])); + let key_b = send_cache_key("workbuddy", "", false, Some(&b[0])); + assert_ne!(key_a, key_b, "同位次但目标不同 → 必须是两条缓存项"); + + // 同一目标照旧复用(缓存的意义所在) + assert_eq!( + key_a, + send_cache_key("workbuddy", "", false, Some(&chain(&["m2"])[0])) + ); + // 池 / 降级标记仍是独立维度 + assert_ne!(key_a, send_cache_key("workbuddy", "free", false, Some(&a[0]))); + assert_ne!(key_a, send_cache_key("workbuddy", "", true, Some(&a[0]))); + // 链为空(单答案兜底档)不与任何具体目标共用 + assert_ne!(key_a, send_cache_key("workbuddy", "", false, None)); +}