//! 多 Provider 负载均衡池 — F-260614-04 //! //! 与 `df_ai::router`(F-01 阶段4,纯函数选模型)对齐的纯逻辑核心:给定 enabled provider //! 列表 + 已选模型 ID + 选择策略,返回**有序候选 provider 列表**(主→备用)。 //! 零 IO / 零状态,所有状态(DB 读 / 健康度 / 计数器)由调用方持有。 //! //! ## 架构定位(为何放 src-tauri 而非 df-ai) //! `AiProviderRecord` 定义在 df-storage。df-ai 当前存储无关(router.rs 操作 df-ai-core 的 //! `ModelConfig`,非 storage 类型)。把 ProviderPool 放 df-ai 会强制 df-ai→df-storage 依赖, //! 破坏 df-ai 的存储无关边界。ProviderPool 与 `prompt.rs::get_active_provider` / //! `secret.rs::build_provider_for` 同属"消费 AiProviderRecord 的 app 层逻辑",故放 src-tauri。 //! //! ## 与 router 协同(职责切分,不重叠) //! - **router.select_model_id**:在**单个 provider 的 model_configs 池**中选最优模型(F-01)。 //! - **ProviderPool::select**:在**多个 provider 间**选主 + 列出备用(本模块)。 //! 调用顺序:先 router 选模型 → 再 ProviderPool 选 provider 实例(模型在多 provider 间共享时)。 //! //! ## 选择策略(论证,见 select 文档) //! 1. **模型亲和优先**:router 已选 model_id,优先选**其池中含该模型的 provider**(正确性:不擅自换模型)。 //! 2. **加权**:同亲和级内按 weight 降序(weight=0 provider 不入选)。 //! 3. **is_default 兜底**:模型亲和全 miss 时,默认 provider 优先(零变化启动行为)。 //! //! ## fallback 顺序(select 返回的 Vec 即为 fallback 序) //! 调用方(agentic loop)按顺序尝试:主 provider 失败(可重试错误)→ 列表下一个 provider 重试。 //! 不可重试错误(401/400)立即放弃,不浪费备用 provider(对齐 retry.rs Fatal 分类)。 //! //! ## 向后兼容 //! - enabled provider 仅 1 个 → 返回单元素 Vec,调用方无 fallback 路径(行为同 F-01 前)。 //! - enabled provider 0 个 → 返回空 Vec,调用方兜底 get_active_provider(行为同 F-01 前)。 //! - 模型在多 provider 间共享 → 主=含模型且 weight 最高的 provider,备用=其余含模型的 provider。 use df_storage::models::AiProviderRecord; /// 多 Provider 负载均衡池(单元结构,无状态)。 /// /// `select` 为关联函数:给定 enabled provider 列表 + 已选模型 ID,返回有序候选列表。 /// 对齐 `ModelRouter`(df_ai::router)的单元结构 + 关联函数风格。 pub struct ProviderPool; impl ProviderPool { /// 在 enabled provider 池中选出**有序候选列表**(主 → 备用),供调用方 fallback。 /// /// ## 选择算法(3 步,稳定排序) /// 1. **过滤 enabled**:只选 `enabled == true` 的 provider。 /// 2. **过滤零权重**:weight == 0 的 provider 不参与(weight=0 = 显式禁用主选; /// 若需"仅 fallback"配置,用 enabled=false 替代,语义更清晰)。 /// 3. **排序**(稳定,保输入顺序作 tiebreak): /// - **主键:模型亲和**。含 `model_id` 的 provider 排前(模型亲和=true → 排前)。 /// model_id 为 None(调用方未走 router,如标题/扫描)→ 退化为全亲和,跳过此键。 /// - **次键:weight 降序**。同亲和级内 weight 高者排前。 /// - **末键:is_default 兜底**。同亲和同 weight 时 is_default 排前(启动行为不变)。 /// /// ## 向后兼容 /// - enabled provider 1 个 → 单元素 Vec,无 fallback。 /// - 0 个 → 空 Vec,调用方兜底 get_active_provider。 /// - model_id None → 全亲和,纯按 weight + is_default 排序(标题/扫描路径用)。 pub fn select( providers: &[AiProviderRecord], model_id: Option<&str>, ) -> Vec { // 步骤 1+2:enabled 且 weight > 0 let mut candidates: Vec = providers .iter() .filter(|p| p.enabled && p.weight > 0) .cloned() .collect(); // 步骤 3:稳定排序(主键模型亲和,次键 weight,末键 is_default)。 // sort_by 稳定:同 key 保输入顺序(created_at DESC,list_all 返回序)。 // 反转比较结果:大者排前(亲和 true > false;weight 大 > 小;is_default true > false)。 candidates.sort_by(|a, b| { // 主键:模型亲和。model_id None → 视两方都亲和(退化为全过此键)。 let a_affinity = model_id .map_or(true, |mid| a.model_configs.iter().any(|m| m.model_id == mid)); let b_affinity = model_id .map_or(true, |mid| b.model_configs.iter().any(|m| m.model_id == mid)); let by_affinity = b_affinity.cmp(&a_affinity); // true 排前 if by_affinity != std::cmp::Ordering::Equal { return by_affinity; } // 次键:weight 降序。 let by_weight = b.weight.cmp(&a.weight); if by_weight != std::cmp::Ordering::Equal { return by_weight; } // 末键:is_default 兜底。 b.is_default.cmp(&a.is_default) }); candidates } } // ============================================================ // 单测(纯逻辑,零 IO) // ============================================================ #[cfg(test)] mod tests { use super::*; use df_ai::df_ai_core::model::ModelConfig; /// 构造可定制 AiProviderRecord(默认 enabled/weight=50/is_default=false,无 model_configs)。 fn provider(id: &str) -> AiProviderRecord { AiProviderRecord { id: id.into(), name: id.into(), provider_type: "openai_compat".into(), api_key: String::new(), base_url: "https://x".into(), default_model: "glm-4-flash".into(), models: None, model_configs: Vec::new(), is_default: false, config: None, created_at: "0".into(), updated_at: "0".into(), enabled: true, weight: 50, } } /// ── 向后兼容:单 provider 路径 ── #[test] fn empty_pool_returns_empty() { // enabled provider 0 个 → 空 Vec,调用方兜底 get_active_provider。 let pool: Vec = vec![]; assert!(ProviderPool::select(&pool, Some("glm-4-flash")).is_empty()); } #[test] fn single_provider_returns_singleton() { // 单 provider → 单元素 Vec,无 fallback(行为同 F-01 前)。 let pool = vec![provider("p1")]; let selected = ProviderPool::select(&pool, Some("glm-4-flash")); assert_eq!(selected.len(), 1); assert_eq!(selected[0].id, "p1"); } /// ── 过滤:enabled / weight=0 ── #[test] fn disabled_provider_filtered() { // enabled=false → 不入选(单 provider 场景兜底由调用方处理)。 let pool = vec![ AiProviderRecord { enabled: false, ..provider("disabled") }, provider("enabled"), ]; let selected = ProviderPool::select(&pool, None); assert_eq!(selected.len(), 1); assert_eq!(selected[0].id, "enabled"); } #[test] fn zero_weight_provider_filtered() { // weight=0 → 显式排除(语义:weight=0 = 不参与主选也不作 fallback)。 let pool = vec![ AiProviderRecord { weight: 0, ..provider("zero") }, provider("normal"), ]; let selected = ProviderPool::select(&pool, None); assert_eq!(selected.len(), 1); assert_eq!(selected[0].id, "normal"); } /// ── 主键:模型亲和 ── #[test] fn model_affinity_picks_provider_with_model() { // 两 provider,仅 p2 含目标模型 → p2 排前(即使 p1 weight 更高)。 let pool = vec![ AiProviderRecord { weight: 90, ..provider("p1") }, AiProviderRecord { weight: 30, model_configs: vec![ModelConfig::with_defaults("glm-4-flash")], ..provider("p2") }, ]; let selected = ProviderPool::select(&pool, Some("glm-4-flash")); assert_eq!(selected[0].id, "p2", "含目标模型的 provider 应排前"); assert_eq!(selected[1].id, "p1"); } #[test] fn model_id_none_all_affinity_equal() { // model_id None(标题/扫描路径)→ 全亲和,纯按 weight 排序。 let pool = vec![ AiProviderRecord { weight: 30, ..provider("low") }, AiProviderRecord { weight: 90, ..provider("high") }, ]; let selected = ProviderPool::select(&pool, None); assert_eq!(selected[0].id, "high"); assert_eq!(selected[1].id, "low"); } /// ── 次键:weight 降序 ── #[test] fn higher_weight_wins_same_affinity() { // 两 provider 都不含目标模型(同亲和=false),weight 高者排前。 let pool = vec![ AiProviderRecord { weight: 50, ..provider("low") }, AiProviderRecord { weight: 80, ..provider("high") }, ]; let selected = ProviderPool::select(&pool, Some("glm-4-flash")); assert_eq!(selected[0].id, "high"); } /// ── 末键:is_default 兜底 ── #[test] fn is_default_breaks_weight_tie() { // 同亲和 + 同 weight → is_default 排前(启动行为不变)。 let pool = vec![ provider("normal"), // is_default=false AiProviderRecord { is_default: true, ..provider("default") }, ]; let selected = ProviderPool::select(&pool, None); assert_eq!(selected[0].id, "default"); } /// ── 综合:多 provider 多维度 ── #[test] fn full_scenario_orders_correctly() { // 候选: // a: enabled=true, weight=70, 含目标模型 → 亲和+weight70 // b: enabled=true, weight=90, 不含目标模型 → 非亲和+weight90(排 a 后) // c: enabled=true, weight=70, 含目标模型, is_default=true → 亲和+weight70+default(同 a 但 default 排前) // d: enabled=false → 排除 // e: weight=0 → 排除 // // 预期顺序:c(亲和/70/default) > a(亲和/70) > b(非亲和/90) let pool = vec![ AiProviderRecord { weight: 70, model_configs: vec![ModelConfig::with_defaults("glm-4-flash")], ..provider("a") }, AiProviderRecord { weight: 90, ..provider("b") }, AiProviderRecord { weight: 70, is_default: true, model_configs: vec![ModelConfig::with_defaults("glm-4-flash")], ..provider("c") }, AiProviderRecord { enabled: false, ..provider("d") }, AiProviderRecord { weight: 0, ..provider("e") }, ]; let selected = ProviderPool::select(&pool, Some("glm-4-flash")); assert_eq!( selected.iter().map(|p| p.id.as_str()).collect::>(), vec!["c", "a", "b"], "应按 亲和 > weight > is_default 排序,排除 enabled=false/weight=0" ); } }