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)); +}