diff --git a/crates/codex_integration/src/apply.rs b/crates/codex_integration/src/apply.rs index 35a0238c..1f153bdc 100644 --- a/crates/codex_integration/src/apply.rs +++ b/crates/codex_integration/src/apply.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::auth::{read_auth, write_auth}; use crate::model_catalog::{ catalog_models_for_provider_with_display_names, clear_catalog_models, upsert_catalog_models, - CODEX_MODEL_CATALOG_KEY, + CatalogModel, CODEX_MODEL_CATALOG_KEY, }; use crate::paths::CodexPaths; use crate::snapshot::{ @@ -97,6 +97,16 @@ pub struct ApplyConfig<'a> { /// 改走现有 `openai_base_url` 根键路径。 #[serde(default)] pub preserve_chatgpt_auth: bool, + /// **池化模式**:全 provider 的 catalog 模型列表(由 src-tauri 用 + /// `unique_pool_slugs` + `catalog_models_for_pool` 构建)。`Some` → catalog 写池 + /// (Codex picker 显示所有 provider 的所有模型);`None` → 走单 active provider 路径 + /// (与池化前**字节一致**)。仅 local_proxy 模式可能为 `Some`(direct 不写 catalog)。 + #[serde(skip)] + pub pool: Option<&'a [CatalogModel]>, + /// 池模式下 root `model` 锚定目标 slug(一般是 active provider 的池默认 slug)。 + /// 仅当当前 config.toml 的 `model` 不在新 catalog 时用它重置(单模式锚 `gpt-5.5`)。 + #[serde(skip)] + pub pool_default_slug: Option<&'a str>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -208,15 +218,21 @@ pub fn apply_provider(paths: &CodexPaths, cfg: &ApplyConfig) -> Result / ",`model_context_window` 仍只在 1M 时设。 - let models = catalog_models_for_provider_with_display_names( - cfg.provider_name, - cfg.default_model, - cfg.supports_1m, - cfg.model_mappings, - cfg.model_capabilities, - cfg.model_display_names, - cfg.review_model_slot, - ); + // 池化(整合)模式:catalog = src-tauri 构建好的池条目(标准档映射);否则单 active + // provider catalog(行为不变)。后续 `if models.is_empty()` 的留空 strip 逻辑两种都适用。 + let models = if let Some(pool) = cfg.pool { + pool.to_vec() + } else { + catalog_models_for_provider_with_display_names( + cfg.provider_name, + cfg.default_model, + cfg.supports_1m, + cfg.model_mappings, + cfg.model_capabilities, + cfg.model_display_names, + cfg.review_model_slot, + ) + }; if models.is_empty() { // [MOC-234] responses passthrough provider 允许**留空默认模型**(UI 如此 —— // model 原样透传给原生上游),此时 catalog models 为空。**绝不写 `models:[]` @@ -261,11 +277,35 @@ pub fn apply_provider(paths: &CodexPaths, cfg: &ApplyConfig) -> Result None, Err(e) => return Err(e.into()), }; - let model_needs_reset = current_model - .as_deref() - .is_some_and(|m| !m.is_empty() && !models.iter().any(|cm| cm.slug == m)); - if model_needs_reset && models.iter().any(|cm| cm.slug == "gpt-5.5") { - sync_root_value(&paths.config_toml, "model", Some("\"gpt-5.5\""))?; + let model_needs_reset = match current_model.as_deref() { + Some(m) => !m.is_empty() && !models.iter().any(|cm| cm.slug == m), + // 池模式下 config 无 root `model` key:Codex 隐式默认 gpt-5.5,但池 catalog 不含 + // 该 slug(全是 `/`)→ 必须主动锚到池默认 slug,否则新会话起在 + // 一个不在 catalog 的隐式模型上(bot review P2)。单模式 catalog 含 gpt-5.5,隐式 + // 默认即可、不强写(保持原行为)。 + None => cfg.pool.is_some(), + }; + if model_needs_reset { + // 锚定目标:池模式 → active provider 的池默认 slug(回退到首条池条目); + // 单模式 → gpt-5.5。只在目标确实在刚写入的 catalog 里时才重置(守 + // no-silent-destructive:绝不把 model 设成 catalog 没有的 slug)。 + let anchor: Option<&str> = if cfg.pool.is_some() { + match cfg.pool_default_slug { + Some(s) if !s.is_empty() && models.iter().any(|cm| cm.slug == s) => Some(s), + _ => models.first().map(|m| m.slug.as_str()), + } + } else if models.iter().any(|cm| cm.slug == "gpt-5.5") { + Some("gpt-5.5") + } else { + None + }; + if let Some(anchor) = anchor { + sync_root_value( + &paths.config_toml, + "model", + Some(&toml_string_literal(anchor)), + )?; + } } if cfg.supports_1m { sync_root_value(&paths.config_toml, "model_context_window", Some("1000000"))?; @@ -609,6 +649,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -661,6 +703,8 @@ mod tests { app_version: "v2.0.0-stage2.5", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -717,6 +761,8 @@ mod tests { app_version: "v2.0.0-stage2.5", codex_network_access: true, preserve_chatgpt_auth: true, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -764,6 +810,8 @@ mod tests { app_version: "v", codex_network_access: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -810,6 +858,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -878,6 +928,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -945,6 +997,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1009,6 +1063,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1049,6 +1105,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1069,6 +1127,67 @@ mod tests { .any(|m| m["slug"] == "gpt-5.5")); } + #[test] + fn apply_pool_anchors_model_when_config_has_no_model_key() { + // bot review P2:全新 config(无 root `model`)+ 池模式 → 必须锚到 pool_default_slug, + // 否则 Codex 隐式默认 gpt-5.5,而池 catalog 全是 `/` slug、不含它。 + let (_t, paths) = setup(); + let pool = vec![ + CatalogModel { + slug: "deepseek/deepseek-v4-pro".into(), + display_name: "DeepSeek / deepseek-v4-pro".into(), + provider_name: "DeepSeek".into(), + context_window: 1_000_000, + effective_context_window_percent: 95, + auto_review_model_override: None, + }, + CatalogModel { + slug: "kimi/kimi-k2.6".into(), + display_name: "Kimi / kimi-k2.6".into(), + provider_name: "Kimi".into(), + context_window: 262_144, + effective_context_window_percent: 95, + auto_review_model_override: None, + }, + ]; + apply_provider( + &paths, + &ApplyConfig { + base_url: "http://127.0.0.1:18080", + gateway_api_key: "k", + supports_1m: false, + provider_name: "DeepSeek", + default_model: "deepseek-v4-pro", + model_mappings: None, + model_capabilities: None, + model_display_names: None, + review_model_slot: None, + app_version: "v", + codex_network_access: true, + preserve_chatgpt_auth: false, + pool: Some(&pool), + pool_default_slug: Some("deepseek/deepseek-v4-pro"), + }, + ) + .unwrap(); + let toml = read_toml(&paths); + assert!( + toml.contains(r#"model = "deepseek/deepseek-v4-pro""#), + "全新 pool config 应锚到 pool_default_slug:\n{toml}" + ); + let catalog: serde_json::Value = + serde_json::from_slice(&std::fs::read(&paths.model_catalog_json).unwrap()).unwrap(); + let slugs: Vec<&str> = catalog["models"] + .as_array() + .unwrap() + .iter() + .filter_map(|m| m["slug"].as_str()) + .collect(); + assert!(slugs.contains(&"deepseek/deepseek-v4-pro")); + assert!(slugs.contains(&"kimi/kimi-k2.6")); + assert!(!slugs.contains(&"gpt-5.5"), "池 catalog 不应含 gpt-5.5"); + } + #[test] fn apply_with_supports_1m_uses_provider_slot_mapping() { let (_t, paths) = setup(); @@ -1097,6 +1216,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1137,6 +1258,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1157,6 +1280,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1208,6 +1333,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1253,6 +1380,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1317,6 +1446,8 @@ mod tests { app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1500,6 +1631,8 @@ model = \"gpt-5.5\" app_version: "v-active", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1568,6 +1701,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1588,6 +1723,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1622,6 +1759,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1648,6 +1787,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1681,6 +1822,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1720,6 +1863,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1771,6 +1916,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1825,6 +1972,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1879,6 +2028,8 @@ model = \"gpt-5.5\" app_version: "v", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -2186,6 +2337,8 @@ model = \"gpt-5.5\" app_version: "v-test", codex_network_access: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); diff --git a/crates/codex_integration/src/lib.rs b/crates/codex_integration/src/lib.rs index 537dbe2d..dec4e1cf 100644 --- a/crates/codex_integration/src/lib.rs +++ b/crates/codex_integration/src/lib.rs @@ -39,8 +39,9 @@ pub use mcp_credentials::{ restore_mcp_credentials_from_mirror, sync_mcp_credentials, SyncReport, }; pub use model_catalog::{ - catalog_models_for_provider, catalog_models_for_provider_with_display_names, - strip_model_suffix, upsert_catalog_models, + catalog_models_for_pool, catalog_models_for_provider, + catalog_models_for_provider_with_display_names, catalog_models_for_slot_mappings, + strip_model_suffix, upsert_catalog_models, CatalogModel, PoolProviderMeta, }; pub use paths::CodexPaths; pub use residual::{ diff --git a/crates/codex_integration/src/model_catalog.rs b/crates/codex_integration/src/model_catalog.rs index bb4341fd..a2ac533c 100644 --- a/crates/codex_integration/src/model_catalog.rs +++ b/crates/codex_integration/src/model_catalog.rs @@ -19,7 +19,7 @@ use codex_app_transfer_registry::{ documented_context_window, load_raw_config, model_supports_1m, normalize_model_mappings, - save_raw_config, strip_internal_model_suffix, CAS_BASE_INSTRUCTIONS, MODEL_SLOTS, + save_raw_config, strip_internal_model_suffix, PoolEntry, CAS_BASE_INSTRUCTIONS, MODEL_SLOTS, }; use serde_json::{json, Value}; @@ -211,11 +211,100 @@ pub fn catalog_models_for_provider_with_display_names( models } +/// 池化模式下单个 provider 的元数据,按 [`PoolEntry::provider_idx`] 索引。 +/// +/// 拥有自己的数据(非借用)以避开 caller 端 `Value` 生命周期纠缠。空 object `{}` / +/// `Null` 表示"无该信息"(context_window / display 反查都会安全回退)。 +#[derive(Debug, Clone)] +pub struct PoolProviderMeta { + pub provider_name: String, + /// `modelCapabilities`(model id → {context_window, supports1m, ...});`{}` = 无。 + pub model_capabilities: Value, + /// model id → 人类可读 displayName(仅 antigravity 非空);`Null` = 回退 raw id。 + pub display_names: Value, +} + +/// 池化 catalog:对 [`unique_pool_slugs`](codex_app_transfer_registry::unique_pool_slugs) +/// 产出的每条 [`PoolEntry`] 生成一条 [`CatalogModel`]。 +/// +/// - `slug` 直接用 entry 的 `/`(已去碰撞); +/// - `display_name` 池模式下 **provider-qualified**(`" / "`)以消歧 +/// 多 provider 同名模型 —— Codex 原生 picker 是扁平列表,只能靠这个区分(单 provider +/// 模式去前缀的旧决策不适用池模式); +/// - `context_window` 复用 [`context_window_for_model`] 的优先级链(显式 caps → documented +/// → 1M/258K 二档),按**每个模型**独立计算; +/// - `auto_review_model_override = None`(slot 机制对斜杠 slug 无意义)。 +/// +/// `providers_meta` 必须与传给 `unique_pool_slugs` 的 provider 切片**同序**(entry 的 +/// `provider_idx` 即下标)。下标越界的 entry 被跳过(防御,正常不发生)。 +pub fn catalog_models_for_pool( + entries: &[PoolEntry], + providers_meta: &[PoolProviderMeta], +) -> Vec { + entries + .iter() + .filter_map(|entry| { + let meta = providers_meta.get(entry.provider_idx)?; + let caps = Some(&meta.model_capabilities); + let real = entry.real_model.as_str(); + // 1M 信号:显式 modelCapabilities/documented(model_supports_1m)**或** 被 strip 的 + // 内部 `[1m]` 标记(entry.supports_one_m)。后者保证无显式声明的自定义模型在池模式 + // 也拿 1M 窗口,与单 provider 模式一致(bot review P2)。 + let supports_1m = entry.supports_one_m || model_supports_1m(real, caps); + let context_window = context_window_for_model(real, real, real, supports_1m, caps); + let label = resolve_display_label(real, Some(&meta.display_names)); + let display_name = if meta.provider_name.trim().is_empty() { + label + } else { + format!("{} / {}", meta.provider_name.trim(), label) + }; + Some(CatalogModel { + slug: entry.slug.clone(), + display_name, + provider_name: meta.provider_name.clone(), + context_window, + effective_context_window_percent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT, + auto_review_model_override: None, + }) + }) + .collect() +} + +/// 整合(池化)模式 catalog:把全局槽位映射(`registry::pool_slot_entries` 产的标准档条目) +/// 转成 CatalogModel。与 [`catalog_models_for_pool`] 不同:slug **是标准档名**(`gpt-5.5` 等)、 +/// display_name 也用标准档名 —— 真机确认 `provider/model` slug 进不了 Codex picker,故整合模式 +/// 只把标准档暴露给 Codex,选某档由 resolver 按映射路由到真实 (provider, model)。 +/// context_window 取**映射模型**的(target provider 的 caps),保证与真实上游窗口一致; +/// `model_to_json` 对 `gpt-5.x` slug 会自动套 Codex 原生 builtin 模板(完整档位字段)。 +pub fn catalog_models_for_slot_mappings( + entries: &[PoolEntry], + providers_meta: &[PoolProviderMeta], +) -> Vec { + entries + .iter() + .filter_map(|entry| { + let meta = providers_meta.get(entry.provider_idx)?; + let caps = Some(&meta.model_capabilities); + let real = entry.real_model.as_str(); + let supports_1m = entry.supports_one_m || model_supports_1m(real, caps); + let context_window = context_window_for_model(real, real, real, supports_1m, caps); + Some(CatalogModel { + slug: entry.slug.clone(), // 标准档名,如 "gpt-5.5" + display_name: entry.slug.clone(), // Codex picker 显示熟悉的标准档名 + provider_name: meta.provider_name.clone(), + context_window, + effective_context_window_percent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT, + auto_review_model_override: None, + }) + }) + .collect() +} + pub fn strip_model_suffix(model: &str) -> String { strip_internal_model_suffix(model) } -fn context_window_for_model( +pub(crate) fn context_window_for_model( original_model: &str, clean_model: &str, default_model: &str, @@ -1164,4 +1253,100 @@ mod tests { let _typed: codex_app_transfer_registry::Config = serde_json::from_value(v).expect("top-level models must not break registry config"); } + + // ── 池化 catalog(catalog_models_for_pool)── + + #[test] + fn catalog_models_for_pool_builds_provider_qualified_entries() { + use codex_app_transfer_registry::PoolEntry; + let entries = vec![ + PoolEntry { + provider_idx: 0, + slug: "deepseek/deepseek-v4-pro".into(), + real_model: "deepseek-v4-pro".into(), + supports_one_m: false, + }, + PoolEntry { + provider_idx: 1, + slug: "ag/gemini-3.5-flash-low".into(), + real_model: "gemini-3.5-flash-low".into(), + supports_one_m: false, + }, + ]; + let meta = vec![ + PoolProviderMeta { + provider_name: "DeepSeek".into(), + model_capabilities: json!({"deepseek-v4-pro": {"context_window": 512000}}), + display_names: Value::Null, + }, + PoolProviderMeta { + provider_name: "Antigravity".into(), + model_capabilities: json!({}), + display_names: json!({"gemini-3.5-flash-low": "Gemini 3.5 Flash (Medium)"}), + }, + ]; + let models = catalog_models_for_pool(&entries, &meta); + assert_eq!(models.len(), 2); + + // provider-qualified display + slug 透传 + 显式 caps 窗口胜出 + assert_eq!(models[0].slug, "deepseek/deepseek-v4-pro"); + assert_eq!(models[0].display_name, "DeepSeek / deepseek-v4-pro"); + assert_eq!(models[0].context_window, 512_000); + assert!(models[0].auto_review_model_override.is_none()); + + // display_names 反查命中 → 人类名,仍 provider-qualified + assert_eq!(models[1].slug, "ag/gemini-3.5-flash-low"); + assert_eq!( + models[1].display_name, + "Antigravity / Gemini 3.5 Flash (Medium)" + ); + + // model_to_json:斜杠 slug 原样保留 + visibility=list + supported_in_api(会进 picker) + let json0 = model_to_json(&models[0]); + assert_eq!(json0["slug"], "deepseek/deepseek-v4-pro"); + assert_eq!(json0["visibility"], "list"); + assert_eq!(json0["supported_in_api"], true); + assert_eq!(json0["context_window"], 512_000); + assert_eq!(json0["apply_patch_tool_type"], "freeform"); + } + + #[test] + fn catalog_models_for_pool_skips_out_of_range_provider_idx() { + use codex_app_transfer_registry::PoolEntry; + let entries = vec![PoolEntry { + provider_idx: 5, + slug: "x/y".into(), + real_model: "y".into(), + supports_one_m: false, + }]; + let meta = vec![PoolProviderMeta { + provider_name: "P".into(), + model_capabilities: json!({}), + display_names: Value::Null, + }]; + assert!(catalog_models_for_pool(&entries, &meta).is_empty()); + } + + #[test] + fn catalog_models_for_pool_honors_supports_one_m_marker_without_caps() { + // bot review P2:仅靠 `[1m]` 标 1M 的自定义模型(无 modelCapabilities / 非 documented), + // 池模式也要拿 1M 窗口(与单 provider 模式一致),靠 PoolEntry.supports_one_m 传递。 + use codex_app_transfer_registry::PoolEntry; + let entries = vec![PoolEntry { + provider_idx: 0, + slug: "x/custom-undocumented".into(), + real_model: "custom-undocumented".into(), + supports_one_m: true, + }]; + let meta = vec![PoolProviderMeta { + provider_name: "X".into(), + model_capabilities: json!({}), // 无显式声明 + display_names: Value::Null, + }]; + let models = catalog_models_for_pool(&entries, &meta); + assert_eq!( + models[0].context_window, 1_000_000, + "supports_one_m → 1M 窗口" + ); + } } diff --git a/crates/proxy/src/resolver.rs b/crates/proxy/src/resolver.rs index 7cfb7fa0..8c008737 100644 --- a/crates/proxy/src/resolver.rs +++ b/crates/proxy/src/resolver.rs @@ -8,6 +8,7 @@ //! `registry::Config` 的内存实现;Stage 4 接入 UI / 文件监听后,可换成 //! `ConfigWatcher` 持有实时 config 的版本. +use std::collections::HashMap; use std::sync::Arc; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; @@ -120,6 +121,24 @@ pub struct StaticResolver { /// 当 incoming 请求里没法决定 provider 时,fallback 用的 id. /// 一般等于 `Config::active_provider`. pub default_provider_id: Option, + /// 池化模式反查表:Codex catalog slug → (`providers` 下标, 上游真实 model)。 + /// + /// 空 = 未开池化(或无可池模型)→ `decide_provider` 退回 slug-split / 默认 + /// provider,行为与池化前**完全一致**。非空时由 `proxy_runner` 在启动时按 + /// `settings.exposeAllProviderModels` gate 后用 `registry::unique_pool_slugs` + /// 构建(与 catalog 生成端**同一 helper**,保证 slug 逐字一致、不错路由)。 + pub catalog_slug_map: HashMap, + /// 是否处于池化(整合)模式。`true` = catalog 由 `catalog_slug_map` 这个**子集**定义, + /// `decide_provider` 在反查表 miss 时**不再**走 `/` legacy 拆分(否则会把 + /// 旧会话 / 手输的 `excluded-provider/model` 路由到用户已移出整合的 provider,违反子集 + /// 语义,#477 P2 round-6)→ 直接退默认 provider。`false` = 单 provider / 池空回退,保留 + /// legacy 拆分(向后兼容)。由 `with_catalog_slug_map` 按「反查表非空」自动置位。 + pub pool_enabled: bool, + /// 池化模式「默认档」路由:`(providers 下标, 上游真实 model)`,= 首条标准档映射 + /// (MODEL_SLOTS 顺序 → gpt-5.5 优先)。池模式下 catalog miss(旧会话 / 未映射档 / 未知 + /// model)路由到这里,**不**走 `map_model_for_provider`(那会用 default provider 自己的槽位 + /// 映射、绕过整合 catalog,把流量发给整合页未暴露的模型,#477 P2)。`None` = 非池模式。 + pub pool_default: Option<(usize, String)>, } impl StaticResolver { @@ -132,9 +151,28 @@ impl StaticResolver { gateway_key, providers, default_provider_id, + catalog_slug_map: HashMap::new(), + pool_enabled: false, + pool_default: None, } } + /// 装配池化反查表(builder 形式,保持 `new` 三参签名不变 → 既有调用 / 测试不破)。 + /// 反查表非空 ⟺ 池化模式(proxy_runner 仅在 expose 开 + 池非空时灌非空表)→ 同步置 + /// `pool_enabled`,作为 legacy slug-split 是否禁用的**显式**状态。 + pub fn with_catalog_slug_map(mut self, map: HashMap) -> Self { + self.pool_enabled = !map.is_empty(); + self.catalog_slug_map = map; + self + } + + /// 装配池化「默认档」路由(catalog miss 时用)。proxy_runner 传首条标准档映射的 + /// `(provider_idx, real_model)`。 + pub fn with_pool_default(mut self, default: Option<(usize, String)>) -> Self { + self.pool_default = default; + self + } + fn find_by_id(&self, id: &str) -> Option<&Provider> { self.providers.iter().find(|p| p.id == id) } @@ -309,9 +347,34 @@ fn decide_provider<'a>( res: &'a StaticResolver, body: &[u8], ) -> Option<(&'a Provider, Option)> { - // 试着从 body JSON 里抠 "model". - if let Ok(v) = serde_json::from_slice::(body) { - if let Some(model) = v.get("model").and_then(|m| m.as_str()) { + // body JSON 里的 "model"(可能没有);只解析一次。 + let model = serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("model").and_then(|m| m.as_str()).map(str::to_owned)); + + if let Some(model) = model.as_deref() { + // 0. 池化反查表:catalog slug 精确命中 → 路由到该 provider + 改写成真实 model。 + // 放最前:池化 slug 形如 "/",base 可能带碰撞后缀(-2),靠精确表 + // 命中而非 find_by_slug 的裸 slug,避免碰撞误路由(把 prompt 发错上游)。 + if let Some((idx, real)) = res.catalog_slug_map.get(model) { + if let Some(p) = res.providers.get(*idx) { + return Some((p, Some(real.clone()))); + } + // 反查表命中但 idx 越界 = catalog↔map 单一真源约定被破坏(正常不可能:两端都用 + // unique_pool_slugs 对同一 config 构建)。loud log 而非静默 fall through —— 否则会 + // 落到默认 provider + 默认 model,把 prompt 发错上游。 + crate::telemetry::proxy_telemetry().logs.add( + "ERROR", + format!( + "POOL_SLUG_MAP_IDX_OUT_OF_RANGE: slug {model:?} → idx {idx} 越界(providers={}),fall through", + res.providers.len() + ), + ); + } + // 1. "/" 约定:按 provider slug 路由(手动调用 / 池化前兼容)。 + // **仅非池化模式**才走:池化模式下反查表 = 整合子集的全集,miss 必须退默认 provider, + // 不能用 legacy 拆分把 `excluded-provider/model`(用户移出整合的)路由回去(#477 P2 round-6)。 + if !res.pool_enabled { if let Some((slug, real)) = model.split_once('/') { if let Some(p) = res.find_by_slug(slug) { return Some((p, Some(strip_internal_model_suffix(real)))); @@ -319,15 +382,31 @@ fn decide_provider<'a>( } } } - let provider = res.default_provider()?; - if let Ok(v) = serde_json::from_slice::(body) { - if let Some(model) = v.get("model").and_then(|m| m.as_str()) { - if let Some(mapped) = res.map_model_for_provider(provider, model) { - return Some((provider, Some(mapped))); + + // 2a. 池模式 catalog miss(旧会话残留 / 未映射档 / 未知 model)→ 路由到「池默认档」的 + // 精确 (provider, real_model),**不**走 map_model_for_provider —— 后者会用 default + // provider 自己的槽位映射改写,绕过整合 catalog、把流量发给整合页未暴露的模型 + // (#477 bot review P2)。把池模式所有流量都留在用户 curate 的映射内。 + if res.pool_enabled { + if let Some((idx, real)) = res.pool_default.as_ref() { + if let Some(p) = res.providers.get(*idx) { + return Some((p, Some(real.clone()))); } } + // 防御:pool_enabled 但无池默认(理论上不可能,map 非空必有首条)→ 退默认 provider + // 原样(strip 后),仍不套单 provider 槽位映射。 + let provider = res.default_provider()?; + return Some((provider, model.as_deref().map(strip_internal_model_suffix))); + } + + // 2b. 非池模式:默认 provider + 槽位映射改写(原行为)。 + let provider = res.default_provider()?; + if let Some(model) = model.as_deref() { + if let Some(mapped) = res.map_model_for_provider(provider, model) { + return Some((provider, Some(mapped))); + } } - // 没 / 或没可映射 model → 走默认 provider. + // 没 / 或没可映射 model → 走默认 provider 不改写。 Some((provider, None)) } @@ -779,4 +858,118 @@ mod tests { assert_eq!(res.extra_headers.get("x-api-key").unwrap(), "sk-real-key"); assert_eq!(res.extra_headers.get("x-plain").unwrap(), "no-template"); } + + // ── 池化反查表路由(catalog_slug_map)── + + #[test] + fn catalog_slug_map_routes_pool_slug_and_rewrites_model() { + let providers = vec![ + provider("openai", "https://up-1", "sk-1"), + provider("deepseek", "https://up-2", "sk-2"), + ]; + let mut map = HashMap::new(); + map.insert( + "deepseek/deepseek-v4-pro".to_owned(), + (1usize, "deepseek-v4-pro".to_owned()), + ); + let r = + StaticResolver::new(None, providers, Some("openai".into())).with_catalog_slug_map(map); + let p = parts_with(&[]); + let res = r + .resolve(&p, br#"{"model":"deepseek/deepseek-v4-pro"}"#) + .unwrap(); + assert_eq!(res.provider_id, "deepseek"); + assert_eq!(res.api_key, "sk-2"); + assert_eq!(res.rewritten_model.as_deref(), Some("deepseek-v4-pro")); + } + + #[test] + fn catalog_slug_map_resolves_collision_suffix_slug_that_find_by_slug_cannot() { + // 碰撞后缀 slug "x-2/m":没有任何 provider 的 provider_slug == "x-2", + // find_by_slug 永远命中不了 → 必须靠精确反查表路由到正确 provider。 + let providers = vec![ + provider("a", "https://up-a", "sk-a"), + provider("b", "https://up-b", "sk-b"), + ]; + let mut map = HashMap::new(); + map.insert("x-2/m".to_owned(), (1usize, "m".to_owned())); + let r = StaticResolver::new(None, providers, Some("a".into())).with_catalog_slug_map(map); + let p = parts_with(&[]); + let res = r.resolve(&p, br#"{"model":"x-2/m"}"#).unwrap(); + assert_eq!(res.provider_id, "b"); + assert_eq!(res.rewritten_model.as_deref(), Some("m")); + } + + #[test] + fn empty_catalog_slug_map_preserves_legacy_slug_split_routing() { + // 空表(未开池化)→ decide_provider 退回 "/" split,行为不变。 + let r = StaticResolver::new( + None, + vec![ + provider("openai", "https://up-1", "sk-1"), + provider("deepseek", "https://up-2", "sk-2"), + ], + Some("openai".into()), + ); + let p = parts_with(&[]); + let res = r + .resolve(&p, br#"{"model":"deepseek/deepseek-v4-pro"}"#) + .unwrap(); + assert_eq!(res.provider_id, "deepseek"); + assert_eq!(res.rewritten_model.as_deref(), Some("deepseek-v4-pro")); + } + + #[test] + fn pool_mode_does_not_legacy_route_excluded_provider_slug() { + // #477 P2 round-6:池化模式(反查表非空)下,请求一个**不在整合子集**的 provider slug + // (用户已移出整合 / 旧会话遗留)→ 反查表 miss → **不**走 legacy split 路由回该 provider, + // 退默认 provider。子集语义:移出整合的 provider 不该再被路由到。 + let providers = vec![ + provider("openai", "https://up-1", "sk-1"), // default + 在整合子集 + provider("deepseek", "https://up-2", "sk-2"), // 已移出整合(不在反查表) + ]; + let mut map = HashMap::new(); + map.insert("openai/o-pro".to_owned(), (0usize, "o-pro".to_owned())); + let r = + StaticResolver::new(None, providers, Some("openai".into())).with_catalog_slug_map(map); + let p = parts_with(&[]); + let res = r + .resolve(&p, br#"{"model":"deepseek/deepseek-v4-pro"}"#) + .unwrap(); + assert_eq!( + res.provider_id, "openai", + "池化下 excluded provider 的 slug 应退默认 provider,绝不 legacy 路由回 deepseek" + ); + } + + #[test] + fn pool_mode_routes_standard_slot_and_miss_to_pool_default() { + // 新设计:catalog_slug_map key = 标准档(gpt-5.x)→ 映射的 (provider, real_model)。 + let providers = vec![ + provider("openai", "https://up-1", "sk-1"), + provider("deepseek", "https://up-2", "sk-2"), + ]; + let mut map = HashMap::new(); + map.insert("gpt-5.5".to_owned(), (1usize, "deepseek-v4-pro".to_owned())); + let r = StaticResolver::new(None, providers, Some("openai".into())) + .with_catalog_slug_map(map) + .with_pool_default(Some((1usize, "deepseek-v4-pro".to_owned()))); + let p = parts_with(&[]); + // 映射命中:gpt-5.5 → deepseek/deepseek-v4-pro + let hit = r.resolve(&p, br#"{"model":"gpt-5.5"}"#).unwrap(); + assert_eq!(hit.provider_id, "deepseek"); + assert_eq!(hit.rewritten_model.as_deref(), Some("deepseek-v4-pro")); + // #477 P2:catalog miss(未映射的 gpt-5.2 / 旧会话)→ 路由到池默认档,**不**用 default + // provider(openai)自己的槽位映射改写。 + let miss = r.resolve(&p, br#"{"model":"gpt-5.2"}"#).unwrap(); + assert_eq!( + miss.provider_id, "deepseek", + "miss 路由到池默认档 provider,非 default openai" + ); + assert_eq!( + miss.rewritten_model.as_deref(), + Some("deepseek-v4-pro"), + "miss 改写成池默认档 real model" + ); + } } diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index 1c3297c1..097e1ee6 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -31,8 +31,10 @@ pub use healing::heal_builtin_extra_headers; pub use healing::heal_builtin_provider_fields; pub use healing::heal_legacy_update_url; pub use model_alias::{ - empty_model_mappings, has_internal_one_m_suffix, normalize_model_mappings, openai_model_slot, - provider_slug, strip_internal_model_suffix, MODEL_ORDER, MODEL_SLOTS, + build_catalog_slug_map, empty_model_mappings, has_internal_one_m_suffix, + normalize_model_mappings, openai_model_slot, pool_slot_entries, pooled_model_ids, + pooled_models_with_one_m, provider_pooled_enabled, provider_slug, strip_internal_model_suffix, + unique_pool_slugs, PoolEntry, MODEL_ORDER, MODEL_SLOTS, POOL_SLUG_SEPARATOR, }; pub use model_context_policy::{ documented_context_window, model_supports_1m, ONE_M_CONTEXT_WINDOW, diff --git a/crates/registry/src/model_alias.rs b/crates/registry/src/model_alias.rs index edb2839d..75478071 100644 --- a/crates/registry/src/model_alias.rs +++ b/crates/registry/src/model_alias.rs @@ -1,7 +1,10 @@ //! 模型别名 / 多 provider 路由(对应 `backend/model_alias.py`). +use std::collections::{HashMap, HashSet}; + use indexmap::IndexMap; use once_cell::sync::Lazy; +use serde_json::Value; use crate::schema::ModelMappings; @@ -161,6 +164,259 @@ pub fn normalize_model_mappings(input: Option<&serde_json::Value>) -> ModelMappi out } +// ───────────────────────── 池化模型路由(pool mode)───────────────────────── +// +// 池化模式下,所有 provider 的所有模型进入一个统一池,Codex catalog 用 +// `/` 作 slug 显示,proxy 按 slug 反查表自动分流到对应 +// 上游。本节的 helper 是 catalog 生成端(snapshot.rs)与 resolver 路由端 +// (proxy_runner.rs)的**单一真源** —— 两端对同一份 config 必须产出逐字一致的 +// slug,否则 picker 显示的模型与实际路由的 provider 会错位(把 prompt 发错上游)。 + +/// 池 catalog slug 里 provider 段与模型段的分隔符。 +/// +/// 选 `/`:① 实测 Codex catalog 接受(`codex debug models` 渲染保留,见 MOC pool +/// Phase 0);② 可读(vendor/model 习惯);③ resolver 既有 `decide_provider` 的 +/// `split_once('/')` 兼容路径天然支持。真正的路由靠 [`build_catalog_slug_map`] 的 +/// 精确反查表,分隔符不影响路由正确性(故碰撞 / 归一化都不致错路由)。 +pub const POOL_SLUG_SEPARATOR: char = '/'; + +/// 池里的一条模型:Codex 看到的 catalog slug + 应改写到的上游真实模型 id。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PoolEntry { + /// 在传入 `providers` 切片里的下标(catalog 端取元数据 / resolver 端取鉴权都用它)。 + pub provider_idx: usize, + /// Codex catalog slug,形如 `deepseek/deepseek-v4-pro`(provider 段已去碰撞)。 + pub slug: String, + /// 上游真实模型 id(已 strip 内部 `[1m]` 后缀)。 + pub real_model: String, + /// 该模型是否声明 1M 上下文 —— 源自被 strip 掉的内部 `[1m]` 标记。slug / real_model + /// 都已去后缀(slug 干净、上游不收 `[1m]`),但 catalog 端需要这个信号:无显式 + /// `modelCapabilities` / documented window 的自定义模型,仅靠 `[1m]` 标 1M 时,池模式 + /// 也要给 1M 窗口(否则比单 provider 模式早压缩,bot review P2)。 + pub supports_one_m: bool, +} + +fn push_pooled_with_one_m( + raw: &str, + out: &mut Vec<(String, bool)>, + index: &mut HashMap, +) { + let one_m = has_internal_one_m_suffix(raw); + let cleaned = strip_internal_model_suffix(raw); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + return; + } + match index.get(trimmed) { + // 同一 clean id 的多个变体:只要有一个带 `[1m]` 即视为支持 1M。 + Some(&i) => { + if one_m { + out[i].1 = true; + } + } + None => { + index.insert(trimmed.to_owned(), out.len()); + out.push((trimmed.to_owned(), one_m)); + } + } +} + +/// 某 provider 在池里的"可选模型列表",每条附带"是否声明 1M"(由被 strip 的 `[1m]` +/// 标记得出)。`pooledModels` **数组存在即权威(含空数组)**;仅当其整个缺省(key 不存在 / +/// 非数组)才回退槽位映射(`default` 优先,再按 `MODEL_SLOTS` 顺序)。clean id 去重、稳定 +/// 顺序;同 clean id 多变体只要一个带 `[1m]` 即 true。 +pub fn pooled_models_with_one_m( + pooled_models: Option<&Value>, + models: Option<&Value>, +) -> Vec<(String, bool)> { + let mut out: Vec<(String, bool)> = Vec::new(); + let mut index: HashMap = HashMap::new(); + + // 1. 持久化 pooledModels(字符串数组)。**数组存在即权威(含空数组)**:整合页 curation + // 把某 provider 的模型删光会写入 `pooledModels: []`,必须当「显式空池」返回空,**不能** + // 回退槽位映射 —— 否则 UI 删光了、Codex catalog / resolver 仍带该 provider 的映射模型 + // = 静默不一致(#477 bot review P2)。仅当 pooledModels 整个缺省(key 不存在 / 非数组, + // 含老 config 与从未 curation 的 provider)才走步骤 2 回退。 + if let Some(Value::Array(arr)) = pooled_models { + for item in arr { + if let Some(s) = item.as_str() { + push_pooled_with_one_m(s, &mut out, &mut index); + } + } + return out; + } + + // 2. 回退:槽位映射的非空值(default 优先,再按槽位顺序) + let mappings = normalize_model_mappings(models); + if let Some(d) = mappings.get(DEFAULT_MODEL_KEY) { + push_pooled_with_one_m(d, &mut out, &mut index); + } + for slot in MODEL_SLOTS { + if slot.key == DEFAULT_MODEL_KEY { + continue; + } + if let Some(v) = mappings.get(slot.key) { + push_pooled_with_one_m(v, &mut out, &mut index); + } + } + out +} + +/// 同 [`pooled_models_with_one_m`] 但只返 clean id 列表(已 strip `[1m]`、去重、稳定顺序)。 +pub fn pooled_model_ids(pooled_models: Option<&Value>, models: Option<&Value>) -> Vec { + pooled_models_with_one_m(pooled_models, models) + .into_iter() + .map(|(id, _)| id) + .collect() +} + +/// provider 是否已加入「整合」(`extra["pooledEnabled"] == true`)。整合页里用户「添加」进去 +/// 的子集才置 true;未加入的不进池(catalog 不显示、resolver 不路由其 slug)。 +pub fn provider_pooled_enabled(provider: &crate::Provider) -> bool { + provider + .extra + .get("pooledEnabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +/// 给一组 provider 产出全部池条目(catalog slug ↔ provider/real_model)。 +/// +/// **只纳入已加入整合的 provider**(`pooledEnabled==true`)—— 整合页用户「添加」的子集。 +/// +/// **确定性**是核心契约:catalog 生成端与 resolver 路由端各自调用本函数(对同一份 +/// `providers`),必须得到逐字一致的 slug。为此: +/// - 按 `(sort_index, id, 原始下标)` 稳定排序后分配 provider base slug; +/// - base slug 碰撞(两 provider slug 化后撞名)时追加 `-2` / `-3` … 直到唯一; +/// - `provider_idx` 始终是**原始切片**下标,方便两端各自索引自己的数据; +/// - 全局 slug 去重(同 provider 内重复模型 / 跨 provider 撞全名都只保留首次)。 +pub fn unique_pool_slugs(providers: &[crate::Provider]) -> Vec { + // 只处理已加入整合的 provider;provider_idx 仍取**原始切片**下标(两端一致)。 + let mut order: Vec = (0..providers.len()) + .filter(|&i| provider_pooled_enabled(&providers[i])) + .collect(); + order.sort_by(|&a, &b| { + providers[a] + .sort_index + .cmp(&providers[b].sort_index) + .then_with(|| providers[a].id.cmp(&providers[b].id)) + .then(a.cmp(&b)) + }); + + // 先按稳定顺序给每个 provider 定唯一 base slug(碰撞追加 -N)。 + let mut used_base: HashSet = HashSet::new(); + let mut base_for_idx: Vec = vec![String::new(); providers.len()]; + for &idx in &order { + let base = provider_slug(&providers[idx]); + let mut candidate = base.clone(); + let mut n = 1; + while !used_base.insert(candidate.clone()) { + n += 1; + candidate = format!("{base}-{n}"); + } + base_for_idx[idx] = candidate; + } + + let mut used_slug: HashSet = HashSet::new(); + let mut entries: Vec = Vec::new(); + for &idx in &order { + let base = &base_for_idx[idx]; + let provider = &providers[idx]; + let models_value = serde_json::to_value(&provider.models).ok(); + let pairs = + pooled_models_with_one_m(provider.extra.get("pooledModels"), models_value.as_ref()); + for (real, supports_one_m) in pairs { + let slug = format!("{base}{POOL_SLUG_SEPARATOR}{real}"); + if used_slug.insert(slug.clone()) { + entries.push(PoolEntry { + provider_idx: idx, + slug, + real_model: real, + supports_one_m, + }); + } + } + } + entries +} + +/// 全局槽位映射的池条目:把 OpenAI 标准档(`gpt-5.5` 等)映射到池中某 (provider, model)。 +/// +/// 与 [`unique_pool_slugs`] 不同 —— 真机确认 `provider/model` 这种 slug **进不了 Codex model +/// picker**,故整合模式 catalog 改走「标准档 slug(`gpt-5.5` 等)+ 全局映射」:Codex 只显示标准 +/// 档,选某档由 resolver 按本表路由到映射的 (provider, model)。catalog 生成端(snapshot)与 +/// resolver 路由端(proxy_runner)共用本函数 → slug 逐字一致(byte-identity)。 +/// +/// `slot_mappings` 形如 `{ "gpt_5_5": {"provider":"","model":""}, ... }`(全局一份)。 +/// 只产**有效**条目:target provider 必须在整合子集内(`pooledEnabled`)且 model 非空 —— +/// 否则会路由到用户移出整合 / 不存在的目标。slug = 槽位 OpenAI id(`gpt-5.5`), +/// real_model = 映射模型(已 strip 内部 `[1m]`,1M 信号转入 `supports_one_m`)。 +pub fn pool_slot_entries( + providers: &[crate::Provider], + slot_mappings: Option<&Value>, +) -> Vec { + let mut entries = Vec::new(); + let Some(obj) = slot_mappings.and_then(Value::as_object) else { + return entries; + }; + for slot in MODEL_SLOTS { + let Some(openai_id) = slot.openai_id else { + continue; // 只映射有 OpenAI id 的标准档(跳过 default) + }; + let Some(target) = obj.get(slot.key).and_then(Value::as_object) else { + continue; + }; + let provider_id = target + .get("provider") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + let model = target + .get("model") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if provider_id.is_empty() || model.is_empty() { + continue; + } + // target provider 必须在整合子集内,否则路由到被排除 / 已删的 provider。 + let Some(idx) = providers + .iter() + .position(|p| p.id == provider_id && provider_pooled_enabled(p)) + else { + continue; + }; + let real = strip_internal_model_suffix(model); + // target model 还必须在该 provider **当前可选池**里(下池 curation 删模型 / 换上游 + // 把 pooledModels 置空后,旧映射会变陈旧)→ 否则跳过,避免 catalog/resolver 继续把标准档 + // 暴露 / 路由到已被删除的模型(#477 bot review P2)。pooled_model_ids 已含「缺省回退槽位 + // 映射」语义,与前端 effectivePoolModels 的候选来源一致。 + let provider = &providers[idx]; + let pool_ids = pooled_model_ids( + provider.extra.get("pooledModels"), + serde_json::to_value(&provider.models).ok().as_ref(), + ); + if !pool_ids.iter().any(|m| m == &real) { + continue; + } + entries.push(PoolEntry { + provider_idx: idx, + slug: openai_id.to_owned(), + real_model: real, + supports_one_m: has_internal_one_m_suffix(model), + }); + } + entries +} + +/// 由池条目构建 resolver 用的反查表:`catalog slug → (provider_idx, real_model)`。 +pub fn build_catalog_slug_map(entries: &[PoolEntry]) -> HashMap { + entries + .iter() + .map(|e| (e.slug.clone(), (e.provider_idx, e.real_model.clone()))) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -261,4 +517,250 @@ mod tests { ] ); } + + // ── 池化路由 helper ── + + fn mk_provider(id: &str, name: &str) -> crate::Provider { + // 默认已加入整合(pooledEnabled=true),让 unique_pool_slugs 测试纳入它; + // 「未加入则排除」由专门的 excludes 测试覆盖。 + let mut extra = IndexMap::new(); + extra.insert("pooledEnabled".to_owned(), serde_json::json!(true)); + crate::Provider { + id: id.into(), + name: name.into(), + base_url: String::new(), + auth_scheme: String::new(), + api_format: String::new(), + api_key: String::new(), + models: IndexMap::new(), + extra_headers: IndexMap::new(), + model_capabilities: IndexMap::new(), + request_options: IndexMap::new(), + is_builtin: false, + sort_index: 0, + extra, + } + } + + #[test] + fn pool_slot_entries_maps_standard_slots_to_valid_pool_targets() { + let mut a = mk_provider("deepseek", "DeepSeek"); // pooledEnabled=true + a.extra + .insert("pooledModels".into(), json!(["deepseek-v4-pro"])); + let mut b = mk_provider("kimi", "Kimi"); + b.extra.insert("pooledModels".into(), json!(["kimi-k2.6"])); + let mut excluded = mk_provider("x", "X"); + excluded.extra.insert("pooledEnabled".into(), json!(false)); + let providers = vec![a, b, excluded]; + let mappings = json!({ + "gpt_5_5": {"provider": "deepseek", "model": "deepseek-v4-pro"}, + "gpt_5_4": {"provider": "kimi", "model": "kimi-k2.6[1m]"}, + "gpt_5_4_mini": {"provider": "x", "model": "whatever"}, // x 未加入整合 → 跳过 + "gpt_5_3_codex": {"provider": "deepseek", "model": "not-in-pool"}, // 不在池 → 跳过 + "gpt_5_2": {"provider": "deepseek", "model": ""} // model 空 → 跳过 + }); + let entries = pool_slot_entries(&providers, Some(&mappings)); + let by_slug: HashMap<&str, (usize, &str, bool)> = entries + .iter() + .map(|e| { + ( + e.slug.as_str(), + (e.provider_idx, e.real_model.as_str(), e.supports_one_m), + ) + }) + .collect(); + assert_eq!( + by_slug.get("gpt-5.5"), + Some(&(0usize, "deepseek-v4-pro", false)) + ); + // [1m] 被 strip,1M 信号转入 supports_one_m + assert_eq!(by_slug.get("gpt-5.4"), Some(&(1usize, "kimi-k2.6", true))); + assert!( + !by_slug.contains_key("gpt-5.4-mini"), + "excluded provider 不产条目" + ); + assert!( + !by_slug.contains_key("gpt-5.3-codex"), + "model 不在该 provider 当前池 → 不产条目(stale 映射)" + ); + assert!(!by_slug.contains_key("gpt-5.2"), "空 model 不产条目"); + assert!(!by_slug.contains_key("default"), "default 非标准档不产条目"); + } + + #[test] + fn unique_pool_slugs_excludes_providers_not_in_integration() { + // pooledEnabled 缺失 / false → 不进池(整合子集语义)。 + let mut included = mk_provider("a", "A"); + included.models.insert("default".into(), "a-model".into()); + let mut excluded = mk_provider("b", "B"); + excluded.models.insert("default".into(), "b-model".into()); + excluded.extra.insert("pooledEnabled".into(), json!(false)); + let mut no_flag = mk_provider("c", "C"); + no_flag.models.insert("default".into(), "c-model".into()); + no_flag.extra.shift_remove("pooledEnabled"); + + let entries = unique_pool_slugs(&[included, excluded, no_flag]); + let slugs: Vec<&str> = entries.iter().map(|e| e.slug.as_str()).collect(); + assert_eq!( + slugs, + vec!["a/a-model"], + "只有 pooledEnabled=true 的 a 进池" + ); + // provider_idx 仍是原始下标(a 在 0) + assert_eq!(entries[0].provider_idx, 0); + } + + #[test] + fn unique_pool_slugs_integrated_provider_with_explicit_empty_pool_contributes_nothing() { + // #477 bot review P2:整合页把某 provider 的模型 curation 删光(pooledModels: [])后, + // 即便它有 models 槽位映射,也**不能**回退映射进池 —— 否则 UI 删光了 Codex 仍能选。 + let mut emptied = mk_provider("a", "A"); + emptied.models.insert("default".into(), "a-model".into()); // 有映射但被显式清空 + emptied.extra.insert("pooledModels".into(), json!([])); + let mut kept = mk_provider("b", "B"); + kept.extra.insert("pooledModels".into(), json!(["b-x"])); + + let entries = unique_pool_slugs(&[emptied, kept]); + let slugs: Vec<&str> = entries.iter().map(|e| e.slug.as_str()).collect(); + assert_eq!(slugs, vec!["b/b-x"], "显式空池的 a 不进池,只有 b/b-x"); + } + + #[test] + fn pooled_model_ids_prefers_pooled_models_list() { + // pooledModels 非空 → 用它;strip [1m];去重;忽略槽位映射 + let pooled = json!(["deepseek-v4-pro[1m]", "deepseek-chat", "deepseek-v4-pro"]); + let models = json!({"default": "ignored-default"}); + assert_eq!( + pooled_model_ids(Some(&pooled), Some(&models)), + vec!["deepseek-v4-pro", "deepseek-chat"] + ); + } + + #[test] + fn pooled_model_ids_falls_back_to_slot_mappings() { + // pooledModels 缺失 → 回退槽位映射:default 优先,再按槽位顺序,strip + 去重 + let models = json!({ + "default": "deepseek-v4-pro", + "gpt_5_5": "deepseek-v4-pro", + "gpt_5_4": "deepseek-chat[1m]", + }); + assert_eq!( + pooled_model_ids(None, Some(&models)), + vec!["deepseek-v4-pro", "deepseek-chat"] + ); + } + + #[test] + fn pooled_model_ids_explicit_empty_array_is_empty_not_fallback() { + // 整合页 curation 删光 = 显式空池(pooledModels: [])→ 返回空,**不**回退槽位映射。 + // (区分「显式空」与「整个缺省」:后者才回退,见下一个 test。)#477 bot review P2。 + let pooled = json!([]); + let models = json!({"default": "m1"}); + let empty: Vec = vec![]; + assert_eq!(pooled_model_ids(Some(&pooled), Some(&models)), empty); + } + + #[test] + fn pooled_model_ids_absent_falls_back_to_mappings() { + // pooledModels 整个缺省(None / 非数组)= 从未 curation(老 config)→ 回退槽位映射, + // 保证未整理过的 provider 不会因「没设 pooledModels」就空池。 + let models = json!({"default": "m1"}); + assert_eq!(pooled_model_ids(None, Some(&models)), vec!["m1"]); + let not_array = json!("oops"); + assert_eq!( + pooled_model_ids(Some(¬_array), Some(&models)), + vec!["m1"] + ); + } + + #[test] + fn unique_pool_slugs_builds_provider_prefixed_entries() { + let mut a = mk_provider("deepseek", "DeepSeek"); + a.models.insert("default".into(), "deepseek-v4-pro".into()); + let mut b = mk_provider("kimi", "Kimi"); + b.extra.insert( + "pooledModels".into(), + json!(["kimi-k2.6", "kimi-for-coding"]), + ); + + let entries = unique_pool_slugs(&[a, b]); + let slugs: Vec<&str> = entries.iter().map(|e| e.slug.as_str()).collect(); + assert!(slugs.contains(&"deepseek/deepseek-v4-pro")); + assert!(slugs.contains(&"kimi/kimi-k2.6")); + assert!(slugs.contains(&"kimi/kimi-for-coding")); + + let map = build_catalog_slug_map(&entries); + let (idx, real) = map.get("kimi/kimi-for-coding").unwrap(); + assert_eq!(*idx, 1); + assert_eq!(real, "kimi-for-coding"); + } + + #[test] + fn unique_pool_slugs_disambiguates_colliding_provider_slugs() { + // 两个 provider slug 化后都撞成 "qiniu" → 第二个加 -2 后缀,两边各自路由不混 + let mut a = mk_provider("", "七牛 / Qiniu"); + a.models.insert("default".into(), "qna-v1".into()); + let mut b = mk_provider("", "Qiniu!!"); + b.models.insert("default".into(), "qna-v2".into()); + + let entries = unique_pool_slugs(&[a, b]); + assert_eq!(entries.len(), 2, "两条互不相同的池条目"); + let idxs: HashSet = entries.iter().map(|e| e.provider_idx).collect(); + assert_eq!(idxs.len(), 2, "两条分别路由到不同 provider"); + + let slugs: Vec<&str> = entries.iter().map(|e| e.slug.as_str()).collect(); + assert!(slugs.iter().any(|s| s.starts_with("qiniu/"))); + assert!( + slugs.iter().any(|s| s.starts_with("qiniu-2/")), + "碰撞 provider 应拿到 -2 后缀: {slugs:?}" + ); + + // 反查表:两条 slug 解析到不同 provider_idx + 各自 real model + let map = build_catalog_slug_map(&entries); + assert_eq!(map.len(), 2); + } + + #[test] + fn pooled_models_with_one_m_preserves_1m_marker() { + // `[1m]` 标记被 strip 进 clean id,但 1M 信号保留在 bool 上(供 catalog 给 1M 窗口)。 + let pooled = json!(["custom-1m[1m]", "plain-model"]); + let pairs = pooled_models_with_one_m(Some(&pooled), None); + assert_eq!( + pairs, + vec![ + ("custom-1m".to_owned(), true), + ("plain-model".to_owned(), false) + ] + ); + // pooled_model_ids 契约不变(只 clean id) + assert_eq!( + pooled_model_ids(Some(&pooled), None), + vec!["custom-1m", "plain-model"] + ); + } + + #[test] + fn pooled_models_with_one_m_ors_1m_across_duplicate_variants() { + // 同 clean id 多变体:无后缀在前、带 [1m] 在后 → 仍标 1M(OR 合并)。 + let pooled = json!(["m", "m[1m]"]); + let pairs = pooled_models_with_one_m(Some(&pooled), None); + assert_eq!(pairs, vec![("m".to_owned(), true)]); + } + + #[test] + fn unique_pool_slugs_carries_supports_one_m_from_marker() { + let mut p = mk_provider("deepseek", "DeepSeek"); + p.models + .insert("default".into(), "deepseek-v4-pro[1m]".into()); + let entries = unique_pool_slugs(&[p]); + let e = entries + .iter() + .find(|e| e.slug == "deepseek/deepseek-v4-pro") + .expect("slug 应 strip [1m]"); + assert!(e.supports_one_m, "entry 应携带 1M 标记"); + assert_eq!( + e.real_model, "deepseek-v4-pro", + "real_model 不带 [1m](上游不收)" + ); + } } diff --git a/crates/registry/src/schema.rs b/crates/registry/src/schema.rs index 58dc0490..d58b2b40 100644 --- a/crates/registry/src/schema.rs +++ b/crates/registry/src/schema.rs @@ -34,6 +34,12 @@ pub struct Config { #[serde(default)] pub providers: Vec, pub settings: Settings, + /// 整合(池化)模式的**全局**槽位映射:`{ "gpt_5_5": {"provider":"","model":""}, ... }`。 + /// 把 Codex 标准档(gpt-5.x)映射到池中某 (provider, model);catalog + resolver 经 + /// `pool_slot_entries` 消费(真机确认 provider/model slug 进不了 Codex picker,故整合模式只 + /// 暴露标准档)。缺省 `Null`(且 `skip_serializing_if` → 不写盘,旧 config round-trip 不变)。 + #[serde(default, skip_serializing_if = "Value::is_null")] + pub pool_slot_mappings: Value, } impl Default for Config { @@ -44,6 +50,7 @@ impl Default for Config { gateway_api_key: None, providers: Vec::new(), settings: Settings::default(), + pool_slot_mappings: Value::Null, } } } diff --git a/frontend/css/pages/dashboard.css b/frontend/css/pages/dashboard.css index 666895de..e8852685 100644 --- a/frontend/css/pages/dashboard.css +++ b/frontend/css/pages/dashboard.css @@ -146,8 +146,7 @@ font-size: 13px; } -.provider-card-list, -#providerRows { +.provider-card-list { display: grid; gap: 12px; } diff --git a/frontend/css/pages/providers.css b/frontend/css/pages/providers.css index 2b8645ac..adea4f60 100644 --- a/frontend/css/pages/providers.css +++ b/frontend/css/pages/providers.css @@ -2,7 +2,7 @@ pages/providers.css — 提供商 page 大段 layer1: 642-720 (.preset-list / .preset-item / .provider-logo / .preset-logo) + 721-805 (.table-panel / .provider-table-header / .provider-row / .drag-handle / .provider-name / .truncate / .row-actions) + 809-865 (.mapping-stack / .mapping-card / .mapping-icon / .alias-pill / .source-model) - layer2: 2018-2020 (#page-providers .page-title hide) + 2212-2398 (provider-switch-card 大段 + preset-card + provider-actions + active-indicator) + layer2: 2212-2398 (provider-switch-card 大段 + preset-card + provider-actions + active-indicator) + 2398-2440 (.speed-result / .provider-feedback / .usage-result) + 2442-2734 (.provider-mapping-section / .api-format-* / .form-mapping-* / .mapping-slot-* / .preset-options / .preset-option-item / .preset-notice / .apply-explain) + 2736-2818 (.preset-notice 续 / .apply-explain / .desktop-explain 部分 — desktop-explain 已归 dashboard, 2819-2821 单独放过去) @@ -235,10 +235,6 @@ /* === Layer 2: compact desktop === */ -#page-providers .page-title { - display: none; -} - .provider-switch-card { display: grid; position: relative; @@ -475,27 +471,301 @@ border-top: 1px solid var(--line); } -.model-menu-mode-panel { - display: flex; +/* ── 整合提供商页(模型池):页头开关 + off 提示 + 上下两卡池 ───────────── */ +.integration-title-row .integration-toggle { + display: inline-flex; align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.integration-toggle-label { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.integration-off-hint { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 18px; +} + +.integration-off-hint > i { + font-size: 20px; + color: var(--primary); + margin-top: 2px; +} + +.integration-off-hint h2 { + margin: 0 0 4px; + font-size: 16px; +} + +.integration-off-hint p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.integration-pools { + display: grid; + gap: 16px; +} + +.pool-panel-header { + margin-bottom: 14px; +} + +/* 「模型映射」区头部:标题块左、应用按钮右(整合模式的「启用」入口) */ +.pool-panel-header.with-apply { + display: flex; + align-items: flex-start; justify-content: space-between; - gap: 14px; - margin-bottom: 12px; - padding: 14px 18px; + gap: 16px; +} + +.integration-apply-btn { + flex-shrink: 0; + white-space: nowrap; } -.model-menu-mode-panel h2 { +.pool-panel-header h2 { margin: 0 0 4px; font-size: 16px; } -.model-menu-mode-panel p { +.pool-panel-header p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.pool-empty { + margin: 0; + padding: 18px; + text-align: center; + color: var(--muted); + font-size: 13px; +} + +/* 上池:provider 候选卡片(加入 / 移出整合子集) */ +.pool-provider-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.pool-provider-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--radius-md, 12px); + background: var(--surface); +} + +.pool-provider-card.added { + border-color: var(--primary); + background: var(--primary-soft); +} + +.pool-provider-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.pool-provider-main strong { + font-size: 14px; +} + +.pool-provider-sub { + font-size: 12px; + color: var(--muted); +} + +.pool-provider-count { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--muted); + flex-shrink: 0; +} + +/* 下池:按 provider 分组的可选模型 chip(增删) */ +.pool-model-groups { + display: grid; + gap: 16px; +} + +.pool-model-group { + display: grid; + gap: 10px; + padding-bottom: 16px; + border-bottom: 1px solid var(--line); +} + +.pool-model-group:last-child { + padding-bottom: 0; + border-bottom: none; +} + +.pool-model-group-header { + display: flex; + align-items: center; + gap: 10px; +} + +.pool-model-group-header strong { + font-size: 14px; + flex: 1; + min-width: 0; +} + +.pool-model-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.pool-model-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 6px 5px 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--soft-surface); + font-size: 13px; + max-width: 100%; +} + +.pool-model-name { + max-width: 240px; +} + +.pool-model-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; +} + +.pool-model-remove:hover { + background: var(--danger-soft, rgba(220, 53, 69, 0.12)); + color: var(--danger, #dc3545); +} + +.pool-model-add { + display: flex; + gap: 8px; + align-items: center; +} + +.pool-model-add input { + max-width: 280px; +} + +.pool-fallback-hint { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 0; + font-size: 12px; + color: var(--muted); + line-height: 1.5; +} + +.pool-fallback-hint > i { + margin-top: 2px; + color: var(--primary); +} + +/* 中间「模型映射」区:左标准档标签 + 两级 select(provider → model) */ +.pool-slot-mappings { + display: grid; + gap: 10px; +} + +.pool-slot-row { + display: grid; + grid-template-columns: 116px auto minmax(0, 1fr) minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.pool-slot-label { + font-size: 13px; + font-weight: 600; +} + +.pool-slot-arrow { + color: var(--muted); +} + +@media (max-width: 680px) { + .pool-slot-row { + grid-template-columns: 1fr 1fr; + } + .pool-slot-label { + grid-column: 1 / -1; + } + .pool-slot-arrow { + display: none; + } +} + +/* 整合模式锁定提示(dashboard + 编辑页) */ +.integration-lock-notice { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + margin-bottom: 14px; + border: 1px solid var(--primary); + border-radius: var(--radius-md, 12px); + background: var(--primary-soft); +} + +.integration-lock-notice > i { + font-size: 18px; + color: var(--primary); + margin-top: 2px; +} + +.integration-lock-notice strong { + display: block; + font-size: 14px; + margin-bottom: 2px; +} + +.integration-lock-notice p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; } +.compact-enable.locked { + cursor: not-allowed; +} + .api-format-display { display: flex; align-items: center; diff --git a/frontend/index.html b/frontend/index.html index 2f282645..a97e9387 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -64,6 +64,13 @@
+
@@ -282,8 +289,15 @@

模型映射

auto-review 自动审批工具调用时改用的模型,从上方已配置的模型里选(通常选快/便宜模型加速审批)。留空 = 跟随主模型。

+
- + 取消
@@ -299,35 +313,55 @@

快捷预设

-
+
-

提供商

-

管理已配置的 API 提供商

+

整合提供商

+

把多个提供商的模型整合进统一模型池;在 Codex 里直接选任意模型,转发器按模型自动分流。

- 添加提供商 +
-
diff --git a/frontend/js/api.js b/frontend/js/api.js index 4bf25ad3..87923a88 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -145,6 +145,14 @@ isBuiltin: !!provider.isBuiltin, // [MOC-173] auto-review 审查模型槽位 key(gpt_5_X);显式挑字段,不加这行前端拿不到后端返的值。 reviewModelSlot: provider.reviewModelSlot || '', + // 池化:按 provider 持久化的可选模型列表(显式挑字段,否则被这层 mapper 静默丢)。 + // **保留「缺省」与「显式空」之分**:数组(含 [])原样透出;字段缺省 → null(= 从未 + // curation,后端回退槽位映射)。collapse 成 [] 会让「未整理」provider 在 UI 显示为空、 + // 与后端实际仍用映射的 catalog 不一致(#477 bot review P2)。 + pooledModels: Array.isArray(provider.pooledModels) ? provider.pooledModels : null, + // 整合页子集开关:该 provider 是否参与模型池。默认 false —— 子集语义, + // 只有用户在整合页显式「添加」的 provider 才进池(与后端 provider_pooled_enabled 默认一致)。 + pooledEnabled: provider.pooledEnabled === true, mappings: { default: models.default || '', gpt_5_5: models.gpt_5_5 || '', @@ -214,6 +222,8 @@ if (payload.grokWeb) { body.grokWeb = payload.grokWeb; } + // 模型池(pooledModels)不经 provider 表单下发 —— 由「整合提供商」页 setProviderPool + // 独家管理(/api/providers/{id}/pool)。表单写池会与整合页 curation 冲突(#477 bot review P2)。 return body; } @@ -238,6 +248,11 @@ activeProviderId: data.activeProviderId, desktopHealth: data.desktopHealth || { needsApply: false, issues: [] }, exposeAllProviderModels: !!data.exposeAllProviderModels, + // 整合页中间「模型映射」区渲染用:全局 gpt-5.x → {provider, model}。 + poolSlotMappings: + data.poolSlotMappings && typeof data.poolSlotMappings === 'object' + ? data.poolSlotMappings + : {}, }; }, @@ -314,6 +329,20 @@ return api('PUT', `/api/providers/${encodeURIComponent(id)}/default`); }, + // 整合页:把 provider 加入/移出模型池(enabled)+ 权威设置其可选模型列表(models,增删)。 + // 任一字段缺省即后端不动该项。 + async setProviderPool(id, { enabled, models } = {}) { + const body = {}; + if (typeof enabled === 'boolean') body.enabled = enabled; + if (Array.isArray(models)) body.models = models; + return api('PUT', `/api/providers/${encodeURIComponent(id)}/pool`, body); + }, + + // 整合页中间「模型映射」区:全局 gpt-5.x → {provider, model}(权威替换整份映射)。 + async setPoolSlotMappings(mappings) { + return api('PUT', '/api/pool/slot-mappings', { mappings: mappings || {} }); + }, + async saveDraft(id, payload) { return api('POST', `/api/providers/${encodeURIComponent(id)}/draft`, providerBody(payload, true)); }, diff --git a/frontend/js/app.js b/frontend/js/app.js index 640d9a8c..4574dd15 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -763,6 +763,29 @@ return providerAvailableModels.find((e) => modelEntryId(e).trim().toLowerCase() === target) || null; } + // 池化:进编辑页时,模型下拉「立即」用 provider 已持久化的 pooledModels 渲染(无需先 + // 网络拉取)。pooledModels 是 raw id string 数组 —— modelEntryId/DisplayLabel 已兼容 + // string entry(value=显示文本=id),直接当 providerAvailableModels 用即可。 + // 同时把当前 mappings 里已选、但还不在 pooledModels 的值并进来,保证「选框选中项必出现在 + // 下拉里」(沿用改前:无论 available 列表内容,已选值始终可见)。dedup 用 lowercase id。 + function pooledModelOptionsFromProvider(pooledModels = [], mappings = {}) { + const out = []; + const seen = new Set(); + const push = (id) => { + const raw = String(id || "").trim(); + if (!raw) return; + const key = raw.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + out.push(raw); + }; + (Array.isArray(pooledModels) ? pooledModels : []).forEach(push); + // mappings 里的现值(default + 各槽)兜底并入,确保已配置但未出现在 pooledModels 的 + // 模型在下拉里仍可见 / 可重选。 + Object.values(mappings || {}).forEach(push); + return out; + } + function providerModelOptionsMarkup(currentValue = "") { // recommended:true 置顶,其余保持原相对顺序(稳定排序);非推荐仍全量保留可见。 // 其他 provider(全 string entry)recommended 恒 false,排序 no-op,行为同改前。 @@ -960,7 +983,14 @@ const result = await CCApi.fetchProviderModelsPayload(payload); const models = Array.isArray(result.models) ? result.models.slice() : []; if (models.length) { - setProviderMappings(providerFormMappings, { availableModels: models }); + // 富 entry(带 display_name)优先,再并入已 seed 的 pooledModels 里上游没返回的 + // raw id —— 避免上游 list 缺某个池化模型时把它从下拉里挤掉(非破坏性合并)。 + const covered = new Set(models.map((e) => modelEntryId(e).trim().toLowerCase()).filter(Boolean)); + const extras = providerAvailableModels.filter((e) => { + const id = modelEntryId(e).trim().toLowerCase(); + return id && !covered.has(id); + }); + setProviderMappings(providerFormMappings, { availableModels: [...models, ...extras] }); } } catch (e) { // 非破坏性 fallback:保持选框 raw id 显示,不弹 toast 不打扰用户;但留 devtools @@ -1153,6 +1183,9 @@ } if (includeModels) { payload.models = mappings; + // 注意:provider 表单**不再**下发 pooledModels —— 模型池(整合)由「整合提供商」页 + // (setProviderPool)独家管理。表单只管 provider 配置 + 槽位映射;表单 / autofill 写池会 + // 把整合页 curation 删掉的模型在保存其它字段时悄悄加回(#477 bot review P2)。 } // R1 PR-7:apiFormat=grok_web 时打包 extra.grokWeb(cookies + statsigId)。 // Provider 后端 schema 用 `#[serde(flatten)] extra`,任何不在已知字段的 key @@ -1183,7 +1216,7 @@ return null; } - function providerCardMarkup(provider) { + function providerCardMarkup(provider, poolMode = false) { const mapping = [ provider.mappings.default, provider.mappings.gpt_5_5, @@ -1203,6 +1236,14 @@ const baseUrlMarkup = docsUrl ? `${providerUrl}` : `${providerUrl}`; + // 整合(池化)模式下:统一由模型池管理,单 provider 的「启用 / 应用」锁定避免冲突 + // (用户指示)—— set-default 按钮换成 disabled 锁定态;active badge 仍标「默认」。 + const activeBadgeText = poolMode ? t("providers.poolDefaultBadge") : t("status.active"); + const enableBtn = poolMode + ? `` + : ``; return `
@@ -1213,10 +1254,8 @@ ${mappingText} - ${provider.default ? `${escapeHtml(t("status.active"))}` : ""} - + ${provider.default ? `${escapeHtml(activeBadgeText)}` : ""} + ${enableBtn} @@ -1329,8 +1368,16 @@ if (!target) return; const providers = await CCApi.getProviders(); if (!presetCache.length) presetCache = await CCApi.getPresets(); + // 池化模式开关:决定 set-default 按钮 / active badge 文案语义(读 status 比 getSettings + // 轻;失败时退到 false = 原文案,非破坏性)。 + let poolMode = false; + try { + poolMode = !!(await CCApi.getStatus()).exposeAllProviderModels; + } catch (e) { + console.warn("[renderProviderCards] 读取 exposeAllProviderModels 失败,按关闭处理:", e); + } const providerList = providers.length - ? `
${providers.map(providerCardMarkup).join("")}
` + ? `
${providers.map((p) => providerCardMarkup(p, poolMode)).join("")}
` : ""; if (!providers.length) { target.innerHTML = `
${visiblePresets().map((preset) => providerPresetCardMarkup(preset)).join("")}
`; @@ -1690,6 +1737,9 @@ } const health = status.desktopHealth || {}; const desktopReady = status.desktopConfigured && !health.needsApply; + // 整合模式开 → dashboard 顶部显示锁定提示(provider 卡片的「启用」按钮也会锁定)。 + const lockBanner = $("#dashboardIntegrationLock"); + if (lockBanner) lockBanner.hidden = !status.exposeAllProviderModels; try { await renderProviderCards("#dashboardProviderCards", { includePresets: true }); } catch (err) { @@ -2389,13 +2439,17 @@ ); // [MOC-211] 编辑已保存的 MiMo Token Plan provider → 显示「登录小米账号」row(有 provider.id 可落 cookie) setMimoLoginRow(isMimoTokenPlan(provider.baseUrl), !!provider.hasMimoCookie, provider.id); - providerAvailableModels = []; - setProviderMappings(provider.mappings || emptyMappings()); + // 池化:进编辑页「立即」用持久化的 pooledModels 渲染下拉(解耦强制网络拉取); + // pooledModels 空时回退空列表,行为同改前(下拉禁用直到点「获取模型」)。 + // setProviderMappings 内部会把 availableModels 写进 providerAvailableModels(单一赋值源)。 + const pooledOptions = pooledModelOptionsFromProvider(provider.pooledModels, provider.mappings); + setProviderMappings(provider.mappings || emptyMappings(), { availableModels: pooledOptions }); setReviewModelSlotField(provider.reviewModelSlot || ""); // 回填已配置的审查模型槽位 renderPresetOptions(selectedPreset, provider.mappings || emptyMappings()); updatePresetSelection(); // [MOC-69] antigravity 自动拉模型列表,让映射选框立即显示 displayName(不必手点「获取模型」); - // 失败/离线静默保持 raw id 显示。只对 antigravity(唯一带 displayName 的 provider)生效。 + // 失败/离线静默保持上面 pooledModels 的 raw id 显示。只对 antigravity(唯一带 + // displayName 的 provider)生效;这是「锦上添花」式异步增强,不再是下拉的唯一数据源。 if ((effectiveFormat || provider.apiFormat) === "antigravity_oauth") { await autoFetchModelsForDisplay(); } @@ -2403,6 +2457,7 @@ async function renderProviderForm() { await renderPresets(); + await applyIntegrationLockToForm(); if (editingProviderId) { await fillProviderForEdit(editingProviderId); return; @@ -2415,32 +2470,310 @@ resetProviderForm(); } + // 整合模式开 → 编辑页「启用 / 应用」按钮锁定 + 顶部提示(用户指示,避免与模型池冲突); + // 「仅保存」不锁(改 provider 配置 / 映射仍允许,只是不再单独 apply 到 Codex)。 + async function applyIntegrationLockToForm() { + let enabled = false; + try { + enabled = !!(await CCApi.getStatus()).exposeAllProviderModels; + } catch (e) { + console.warn("[renderProviderForm] 读取整合开关失败,按关闭处理:", e); + } + const notice = $("#providerFormIntegrationLock"); + if (notice) notice.hidden = !enabled; + const applyBtn = $("#providerApplyBtn"); + if (applyBtn) { + applyBtn.disabled = enabled; + applyBtn.classList.toggle("locked", enabled); + applyBtn.title = enabled ? t("providers.integrationApplyLockedHint") : ""; + } + } + + // ── 整合提供商页(模型池)────────────────────────────────────────────────── + // #providers 路由。整合开关(= exposeAllProviderModels)关 → 只显示 off 提示; + // 开 → 两个卡池:上池选「整合的提供商」(子集),下池按 provider 分组做「可选模型」增删。 async function renderProviders() { - await renderModelMenuModePanel(); - await renderProviderCards("#providerRows"); + const offHint = $("#integrationOffHint"); + const pools = $("#integrationPools"); + const toggle = $("#integrationToggle"); + let enabled = false; + let slotMappings = {}; + try { + // getStatus 一次拿到整合开关 + 全局槽位映射(中间映射区渲染用)。 + const status = await CCApi.getStatus(); + enabled = !!status.exposeAllProviderModels; + slotMappings = status.poolSlotMappings || {}; + } catch (e) { + console.warn("[renderProviders] 读取整合状态失败,按关闭处理:", e); + } + if (toggle) toggle.checked = enabled; + if (offHint) offHint.hidden = enabled; + if (pools) pools.hidden = !enabled; + if (!enabled) return; + const providers = await CCApi.getProviders(); + renderPoolProviderGrid(providers); + renderPoolSlotMappings(providers, slotMappings); + renderPoolModelGroups(providers); + // antigravity 等 displayName 异步预取(锦上添花);完成后只重渲染映射 + 下池(不重置整页)。 + ensurePoolDisplayNames(providers).then((changed) => { + if (changed && routeFromHash() === "providers") { + renderPoolSlotMappings(providers, slotMappings); + renderPoolModelGroups(providers); + } + }); } - function renderModelMenuModeState(settings = {}) { - const enabled = !!settings.exposeAllProviderModels; - const button = $("#modelMenuModeToggle"); - const hint = $("#modelMenuModeHint"); - if (button) { - button.classList.toggle("btn-primary", enabled); - button.classList.toggle("btn-outline-primary", !enabled); - const span = $("span", button); - if (span) span.textContent = enabled ? t("providers.showSingleModel") : t("providers.showAllModels"); - button.setAttribute("aria-pressed", enabled ? "true" : "false"); + // 整合页模型 displayName 缓存:providerId → { 小写 rawId: displayName }。仅 antigravity + // (raw id 不可读)异步拉富模型列表反查;其他 provider raw id 即 display(不拉、不打扰)。 + let poolDisplayCache = {}; + function poolModelLabel(providerId, modelId) { + const map = poolDisplayCache[providerId]; + const hit = map && map[String(modelId || "").trim().toLowerCase()]; + return hit || modelId; + } + async function ensurePoolDisplayNames(providers) { + let changed = false; + for (const p of providers) { + if (p.pooledEnabled !== true) continue; + if (p.apiFormat !== "antigravity_oauth") continue; // 只 antigravity 需反查 displayName + if (poolDisplayCache[p.id]) continue; // 已缓存 / 已尝试 + try { + const result = await CCApi.fetchProviderModels(p.id); + const map = {}; + for (const e of result.models || []) { + const id = modelEntryId(e); + const label = modelEntryDisplayLabel(e); + if (id && label) map[id.trim().toLowerCase()] = label; + } + poolDisplayCache[p.id] = map; + if (Object.keys(map).length) changed = true; + } catch (e) { + poolDisplayCache[p.id] = {}; // 标记已尝试,避免重复拉 + console.warn(`[integration] displayName 预取失败 ${p.id}:`, e); + } } - if (hint) { - hint.textContent = enabled ? t("providers.modelMenuAllHint") : t("providers.modelMenuSingleHint"); + return changed; + } + + // 中间「模型映射」区(进 Codex):复用编辑页槽位映射的左右布局,右侧两级 select + // (先选 provider 再选该 provider 池中 model)。全局映射 poolSlotMappings 一份。 + function renderPoolSlotMappings(providers, slotMappings) { + const wrap = $("#poolSlotMappings"); + if (!wrap) return; + const integrated = providers.filter((p) => p.pooledEnabled === true); + if (!integrated.length) { + wrap.innerHTML = `

${escapeHtml(t("providers.integrationNoIntegrated"))}

`; + return; } - const settingToggle = $("#exposeAllProviderModels"); - if (settingToggle) settingToggle.checked = enabled; + // 5 个标准档(排除 default —— 非 OpenAI 档、不进 Codex picker)。 + const slots = providerFormModelSlots.filter((s) => s.key !== "default"); + wrap.innerHTML = slots + .map((slot) => poolSlotMappingRowMarkup(slot, integrated, slotMappings || {})) + .join(""); } - async function renderModelMenuModePanel() { - const settings = await CCApi.getSettings(); - renderModelMenuModeState(settings); + function poolSlotMappingRowMarkup(slot, integrated, slotMappings) { + const m = slotMappings[slot.key] || {}; + const selProvider = m.provider || ""; + const selModel = m.model || ""; + const providerOpts = [ + ``, + ] + .concat( + integrated.map( + (p) => + `` + ) + ) + .join(""); + const selProviderObj = integrated.find((p) => p.id === selProvider); + let modelOpts; + if (selProviderObj) { + const models = effectivePoolModels(selProviderObj); + modelOpts = [ + ``, + ] + .concat( + models.map( + (mid) => + `` + ) + ) + .join(""); + } else { + modelOpts = ``; + } + return ` +
+ ${escapeHtml(slot.label)} + + + +
+ `; + } + + // 从 DOM 读全部映射行 → 重建 poolSlotMappings 对象。`resetModelForSlot` 给「换了 provider」 + // 的行用:旧 model 不属于新 provider,清空(等用户再选 model)。 + function collectPoolSlotMappings(resetModelForSlot) { + const out = {}; + $all(".pool-slot-row").forEach((row) => { + const slotKey = row.dataset.slot; + const provider = $(".pool-slot-provider", row)?.value || ""; + let model = $(".pool-slot-model", row)?.value || ""; + if (slotKey === resetModelForSlot) model = ""; + if (provider) out[slotKey] = { provider, model }; + }); + return out; + } + + // 串行化保存:连点多个映射 select 时,每次都发整份权威快照。并发 PUT 可能乱序完成 → + // 较慢的旧请求落在新请求后、覆盖用户更新的选择(#477 bot review P2)。改为「单飞 + 最新覆盖」: + // 同一时刻只有一个 PUT 在跑,期间新变更只更新 pending(last-write-wins),队列排空后统一重渲染。 + let poolSlotSaving = false; + let poolSlotPending = null; + async function savePoolSlotMappings(mappings) { + poolSlotPending = mappings; // 最新一次覆盖 + if (poolSlotSaving) return; // 已有保存循环在跑,会消费最新 pending + poolSlotSaving = true; + let ok = true; + try { + while (poolSlotPending !== null) { + const next = poolSlotPending; + poolSlotPending = null; + try { + await CCApi.setPoolSlotMappings(next); // 串行 PUT,杜绝并发乱序覆盖 + } catch (e) { + ok = false; + showToast(e.message || t("toast.requestFailed")); + } + } + } finally { + poolSlotSaving = false; + } + if (ok) showToast(t("toast.integrationMappingUpdated")); + await renderProviders(); // 队列排空后统一重渲染(后端最终状态 + 重填 model select) + } + + // 上池:把已配置的 provider 当候选,加入(pooledEnabled)/移出整合子集。 + function renderPoolProviderGrid(providers) { + const grid = $("#poolProviderGrid"); + if (!grid) return; + if (!providers.length) { + grid.innerHTML = `

${escapeHtml(t("providers.integrationNoProviders"))}

`; + return; + } + grid.innerHTML = providers.map((p) => poolProviderCardMarkup(p)).join(""); + } + + // 整合下池「实际生效的模型列表」。pooledModels 是数组(含空 [])即权威;缺省(null, + // 从未 curation)时**镜像后端 `pooled_models_with_one_m` 的回退** —— default 优先 + 各槽位 + // 非空去重。保证 UI 显示与后端 catalog 一致(#477 bot review P2:别把「回退中」当空)。 + function effectivePoolModels(provider) { + if (Array.isArray(provider?.pooledModels)) return provider.pooledModels; + const m = provider?.mappings || {}; + const order = [m.default, m.gpt_5_5, m.gpt_5_4, m.gpt_5_4_mini, m.gpt_5_3_codex, m.gpt_5_2]; + const seen = new Set(); + const out = []; + for (const v of order) { + const s = String(v || "").trim(); + if (s && !seen.has(s)) { + seen.add(s); + out.push(s); + } + } + return out; + } + + // pooledModels 缺省 = 还没 curation,下池显示的是回退映射(非用户整理的固定列表)。 + function poolModelsAreFallback(provider) { + return !Array.isArray(provider?.pooledModels); + } + + function poolProviderCardMarkup(provider) { + const id = escapeHtml(provider.id); + const name = escapeHtml(provider.name); + const added = provider.pooledEnabled === true; + const count = effectivePoolModels(provider).length; + const actionBtn = added + ? `` + : ``; + return ` +
+ + + ${name} + ${escapeHtml(provider.baseUrl)} + + ${added ? `${count}` : ""} + ${actionBtn} +
+ `; + } + + // 下池:按 integrated provider 分组,逐 model 展示 chip(可删)+ 手动加 + 重新获取。 + function renderPoolModelGroups(providers) { + const wrap = $("#poolModelGroups"); + if (!wrap) return; + const integrated = providers.filter((p) => p.pooledEnabled === true); + if (!integrated.length) { + wrap.innerHTML = `

${escapeHtml(t("providers.integrationNoIntegrated"))}

`; + return; + } + wrap.innerHTML = integrated.map((p) => poolModelGroupMarkup(p)).join(""); + } + + function poolModelGroupMarkup(provider) { + const id = escapeHtml(provider.id); + const name = escapeHtml(provider.name); + const models = effectivePoolModels(provider); + const chips = models.length + ? models.map((m) => poolModelChipMarkup(provider.id, m)).join("") + : `

${escapeHtml(t("providers.integrationNoModels"))}

`; + // 回退态(未 curation):提示这些是默认映射、获取/增删后才固化(与后端回退保持一致)。 + const fallbackHint = poolModelsAreFallback(provider) && models.length + ? `

${escapeHtml(t("providers.integrationFallbackHint"))}

` + : ""; + return ` +
+
+ + ${name} + +
+ ${fallbackHint} +
${chips}
+
+ + +
+
+ `; + } + + function poolModelChipMarkup(providerId, model) { + const m = escapeHtml(model); + const pid = escapeHtml(providerId); + // 显示 displayName(antigravity 等 raw id 看不懂);value / data-model 仍是 raw id。 + const label = escapeHtml(poolModelLabel(providerId, model)); + return ` + + ${label} + + + `; + } + + // 整合开关同步:settings 页 checkbox + 整合页右上角 toggle 共用 exposeAllProviderModels, + // 任一处渲染 settings 时两个都对齐(旧 providers 页的「显示全部模型」按钮已移除,留兜底 guard)。 + function renderModelMenuModeState(settings = {}) { + const enabled = !!settings.exposeAllProviderModels; + const settingToggle = $("#exposeAllProviderModels"); + if (settingToggle) settingToggle.checked = enabled; + const integrationToggle = $("#integrationToggle"); + if (integrationToggle) integrationToggle.checked = enabled; } async function renderModelSelectors() { @@ -3861,12 +4194,151 @@ openFeedbackModal(); } - if (action === "toggle-model-menu-mode") { - const settings = await CCApi.getSettings(); - const next = !settings.exposeAllProviderModels; - const saved = await CCApi.saveSettings({ exposeAllProviderModels: next }); - renderModelMenuModeState(saved); - showToast(next ? t("toast.allModelsEnabled") : t("toast.singleModelEnabled")); + // ── 整合页模型池操作 ──────────────────────────────────────────────── + // 应用整合配置到 Codex(整合模式专用「启用」入口 —— 单 provider「应用/启用」已锁定): + // 写 config.toml(整合池 catalog + 代理 base_url)→ 确保代理在跑 → 重启 Codex 生效。 + if (action === "apply-integration") { + actionEl.disabled = true; + try { + const result = await CCApi.configureDesktop(); + if (result && result.requiresProxy) { + await CCApi.startProxy(); + } + showToast(t("toast.integrationApplied")); + await renderDashboard(); + // 重启 Codex 让新 config.toml(整合 catalog)生效;restartCodexAppNow 自带非 app + // 启动场景的兜底(失败只提示,不抛)。 + await restartCodexAppNow({ + buttonId: "integrationApplyBtn", + fallbackLabelKey: "providers.integrationApply", + hideModal: false, + }); + } catch (e) { + showToast(e.message || t("toast.requestFailed")); + } finally { + actionEl.disabled = false; + } + } + + if (action === "pool-add-provider") { + const pid = actionEl.dataset.id; + actionEl.disabled = true; + try { + // 加入整合后自动获取该 provider 的模型(用户要求)。**用非破坏性的 available 拉取** + // (GET /models/available)而非 autofill —— autofill 会顺带覆盖 provider.models 槽位 + // 映射(用户在编辑页手调的、池外仍用的),整合页不该有此副作用(#477 bot review P2)。 + let fetched = []; + try { + const result = await CCApi.fetchProviderModels(pid); + fetched = (result.models || []).map(modelEntryId).filter(Boolean); + } catch (fetchErr) { + console.warn(`[integration] 自动获取模型失败 ${pid}:`, fetchErr); + showToast(formatModelFetchError(fetchErr)); + } + // 合并「该 provider 现有生效模型(还没 curation → 映射回退)」+ 本次抓取,去重。 + // 不能只发抓取结果:用户手填、但 /models 不返回(或被后端过滤)的映射模型,会被当成 + // 权威 pooledModels 的抓取列表挤掉、从池里消失(#477 bot review P2)。 + const providers = await CCApi.getProviders(); + const provider = providers.find((p) => p.id === pid); + const merged = effectivePoolModels(provider).slice(); + const seen = new Set(merged); + for (const m of fetched) { + if (!seen.has(m)) { + seen.add(m); + merged.push(m); + } + } + await CCApi.setProviderPool(pid, merged.length ? { enabled: true, models: merged } : { enabled: true }); + showToast(t("toast.integrationProviderAdded")); + await renderProviders(); + } catch (error) { + actionEl.disabled = false; + showToast(error.message || t("toast.requestFailed")); + } + } + + if (action === "pool-remove-provider") { + const pid = actionEl.dataset.id; + actionEl.disabled = true; + try { + await CCApi.setProviderPool(pid, { enabled: false }); + showToast(t("toast.integrationProviderRemoved")); + await renderProviders(); + } catch (error) { + actionEl.disabled = false; + showToast(error.message || t("toast.requestFailed")); + } + } + + if (action === "pool-fetch-models") { + const pid = actionEl.dataset.id; + actionEl.disabled = true; + const span = $("span", actionEl); + const orig = span ? span.textContent : ""; + if (span) span.textContent = t("providers.integrationFetchingModels"); + try { + // 非破坏性拉取 + 合并(「重新获取只负责更新列表」):available 拉取不动 provider.models, + // 与现有 pooledModels 去重合并后经 setProviderPool 权威写回(后端 chat 过滤)。 + const result = await CCApi.fetchProviderModels(pid); + const fetched = (result.models || []).map(modelEntryId).filter(Boolean); + const providers = await CCApi.getProviders(); + const provider = providers.find((p) => p.id === pid); + const merged = effectivePoolModels(provider).slice(); + const seen = new Set(merged); + for (const m of fetched) { + if (!seen.has(m)) { + seen.add(m); + merged.push(m); + } + } + await CCApi.setProviderPool(pid, { models: merged }); + showToast(t("toast.integrationModelsUpdated")); + await renderProviders(); + } catch (error) { + if (span) span.textContent = orig; + actionEl.disabled = false; + showToast(formatModelFetchError(error)); + } + } + + if (action === "pool-add-model") { + const pid = actionEl.dataset.id; + const input = $all("[data-pool-add-input]").find((el) => el.dataset.poolAddInput === pid); + const value = (input?.value || "").trim(); + if (!value) return; + actionEl.disabled = true; + try { + const providers = await CCApi.getProviders(); + const provider = providers.find((p) => p.id === pid); + // 以「实际生效列表」为基底:若该 provider 还没 curation(回退映射中),增删要把回退 + // 映射先固化进 pooledModels,否则只存这一条编辑会丢掉回退里的其它模型(#477 P2)。 + const models = effectivePoolModels(provider).slice(); + if (!models.includes(value)) models.push(value); + await CCApi.setProviderPool(pid, { models }); + showToast(t("toast.integrationModelsUpdated")); + await renderProviders(); + } catch (error) { + actionEl.disabled = false; + showToast(error.message || t("toast.requestFailed")); + } + } + + if (action === "pool-remove-model") { + const pid = actionEl.dataset.id; + const model = actionEl.dataset.model; + actionEl.disabled = true; + try { + const providers = await CCApi.getProviders(); + const provider = providers.find((p) => p.id === pid); + // 同 add:基底取实际生效列表(回退态先固化),再剔除目标 model。 + const models = effectivePoolModels(provider).filter((m) => m !== model); + await CCApi.setProviderPool(pid, { models }); + showToast(t("toast.integrationModelsUpdated")); + await renderProviders(); + } catch (error) { + actionEl.disabled = false; + showToast(error.message || t("toast.requestFailed")); + } } if (action === "check-provider-compatibility") { @@ -8573,6 +9045,35 @@ $("[data-action=real-account-login]")?.click(); // 复用「登录真实账号」逻辑 }); $("#exposeAllProviderModels").addEventListener("change", saveSettingsFromForm); + // 整合页右上角开关:与 settings 页 exposeAllProviderModels 同一设置。 + $("#integrationToggle")?.addEventListener("change", async (e) => { + const next = e.target.checked; + let saved; + try { + saved = await CCApi.saveSettings({ exposeAllProviderModels: next }); + } catch (err) { + e.target.checked = !next; // 仅「保存失败」回滚 UI(非破坏性);渲染失败不回滚已存设置 + showToast(err.message || t("toast.requestFailed")); + return; + } + renderModelMenuModeState(saved); + showToast(next ? t("toast.integrationEnabled") : t("toast.integrationDisabled")); + // 设置已落盘;重渲染整合页 + dashboard(「启用 / 应用」按钮随开关锁定 / 解锁)。 + // 渲染异常不回滚开关——下次进入页面会自我纠正。 + await renderProviders(); + await renderDashboard(); + }); + // 整合页中间「模型映射」区的两级 select(provider / model)变更 → 重建并保存全局映射。 + document.addEventListener("change", async (e) => { + const el = e.target; + if (!el || !el.classList) return; + if (el.classList.contains("pool-slot-provider")) { + // 换了 provider → 旧 model 不属于新 provider,清空该档 model(等用户再选)。 + await savePoolSlotMappings(collectPoolSlotMappings(el.dataset.slot)); + } else if (el.classList.contains("pool-slot-model")) { + await savePoolSlotMappings(collectPoolSlotMappings()); + } + }); $("#showGrayProviders")?.addEventListener("change", async () => { // MOC-91:更新展示过滤缓存 + 持久化。设置页当前不展示 preset,无需即时重渲染; // 下次进「添加 provider」/ dashboard 时 visiblePresets() 即按新值过滤。 diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 73b94e41..c7eb58c1 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -475,11 +475,35 @@ "providers.empty": "还没有提供商,先从预设添加一个。", "providers.keyPlaceholder": "sk-...", "providers.keySavedPlaceholder": "已填入,可点眼睛查看", - "providers.modelMenuTitle": "OpenAI 模型菜单", - "providers.modelMenuSingleHint": "当前只把默认提供商的模型显示到 Codex CLI。新增或改名模型后,需要重新一键应用并重启终端。", - "providers.modelMenuAllHint": "已开启全部模型。下次一键应用后,Codex CLI 会显示所有已配置提供商的模型;同步过的模型之间切换不用再回本工具切换。", - "providers.showAllModels": "显示全部模型", - "providers.showSingleModel": "只显示当前模型", + "providers.poolDefaultBadge": "默认", + "providers.integrationTitle": "整合提供商", + "providers.integrationSubtitle": "把多个提供商的模型整合进统一模型池;在 Codex 里直接选任意模型,转发器按模型自动分流到对应上游,切模型不再重写配置 / 重启。", + "providers.integrationToggleLabel": "开启提供商整合", + "providers.integrationOffTitle": "整合未开启", + "providers.integrationOffHint": "当前为单提供商模式:仅默认提供商的模型显示到 Codex。开启右上角开关后,可把多个提供商整合进统一模型池,在 Codex 里直接选任意模型(改动整合相关项后需重新一键应用并重启 Codex)。", + "providers.integrationProvidersPoolTitle": "整合的提供商", + "providers.integrationProvidersPoolHint": "选择要加入模型池的提供商。加入后会自动获取其模型并列入下方可选模型。", + "providers.integrationModelsPoolTitle": "可选模型(映射来源)", + "providers.integrationModelsPoolHint": "每个整合提供商的可选模型清单,作为上方「模型映射」的候选来源。这些模型本身不直接进 Codex,可增删。", + "providers.integrationApply": "应用整合并重启 Codex", + "providers.integrationMappingTitle": "模型映射(进 Codex)", + "providers.integrationMappingHint": "把 Codex 标准档映射到池中某「提供商 / 模型」。Codex 模型列表里只显示映射了的标准档,选某档即走你映射的上游;未映射的档不显示。改动后需重新一键应用并重启 Codex。", + "providers.integrationSlotUnset": "— 不映射 —", + "providers.integrationSlotPickModel": "选择模型", + "providers.integrationSlotPickProviderFirst": "先选提供商", + "providers.integrationAddProvider": "加入整合", + "providers.integrationRemoveProvider": "移出整合", + "providers.integrationFetchModels": "重新获取模型", + "providers.integrationFetchingModels": "正在获取模型...", + "providers.integrationNoProviders": "还没有提供商。先到控制台添加提供商,再回到这里整合。", + "providers.integrationNoIntegrated": "还没有加入任何提供商。从上方选择要整合的提供商。", + "providers.integrationNoModels": "暂无可选模型。点「重新获取模型」拉取,或在提供商编辑页手动添加。", + "providers.integrationFallbackHint": "当前显示该提供商的默认模型映射(尚未整理);点「重新获取模型」或增删后,会固化为这个提供商的可选模型列表。", + "providers.integrationRemoveModel": "移出该模型", + "providers.integrationAddModelPlaceholder": "手动添加模型 id", + "providers.integrationAddModel": "添加", + "providers.integrationApplyLockedTitle": "整合模式已开启", + "providers.integrationApplyLockedHint": "整合模式下由统一模型池管理,单提供商「应用 / 启用」已锁定,避免与模型池冲突。如需单独应用,请先关闭提供商整合。", "models.title": "模型映射", "models.subtitle": "为每个提供商配置模型别名", "models.provider": "提供商", @@ -718,8 +742,13 @@ "toast.configExported": "配置已导出", "toast.configImported": "配置已导入", "toast.configImportFailed": "配置导入失败", - "toast.allModelsEnabled": "已开启全部模型显示,请重新一键应用并重启终端", - "toast.singleModelEnabled": "已切回只显示当前模型,请重新一键应用并重启终端", + "toast.integrationEnabled": "已开启提供商整合,请重新一键应用并重启 Codex", + "toast.integrationDisabled": "已关闭提供商整合,已切回单提供商模式", + "toast.integrationProviderAdded": "已加入整合,正在获取模型...", + "toast.integrationProviderRemoved": "已移出整合", + "toast.integrationModelsUpdated": "模型池已更新,请重新一键应用并重启 Codex", + "toast.integrationMappingUpdated": "模型映射已更新,请重新一键应用并重启 Codex", + "toast.integrationApplied": "整合配置已写入 Codex,正在重启生效", "toast.compatibilityChecked": "兼容性检查完成", "toast.requestFailed": "操作失败,请查看后端日志", "confirm.desktopApply": "即将生成 Codex CLI 环境变量配置命令并复制到剪贴板。确认继续?", @@ -1207,11 +1236,35 @@ "providers.empty": "No provider yet. Add one from presets.", "providers.keyPlaceholder": "sk-...", "providers.keySavedPlaceholder": "Saved. Use the eye button to view it.", - "providers.modelMenuTitle": "OpenAI Model Menu", - "providers.modelMenuSingleHint": "Only the default provider is shown in Codex CLI. Re-apply and restart your terminal after adding or renaming models.", - "providers.modelMenuAllHint": "All-model mode is on. After applying once, Codex CLI can show every configured provider model; switching synced models does not require switching providers in this app.", - "providers.showAllModels": "Show All Models", - "providers.showSingleModel": "Show Current Only", + "providers.poolDefaultBadge": "Default", + "providers.integrationTitle": "Integrate Providers", + "providers.integrationSubtitle": "Merge models from multiple providers into one unified pool; pick any model directly in Codex and the proxy auto-routes each to its upstream — no config rewrite or restart when switching models.", + "providers.integrationToggleLabel": "Enable provider integration", + "providers.integrationOffTitle": "Integration is off", + "providers.integrationOffHint": "Single-provider mode: only the default provider's models appear in Codex. Turn on the switch (top right) to merge multiple providers into one unified model pool and pick any model directly in Codex (re-apply and restart Codex after changing integration settings).", + "providers.integrationProvidersPoolTitle": "Integrated providers", + "providers.integrationProvidersPoolHint": "Pick the providers to add to the model pool. Their models are fetched automatically and listed below.", + "providers.integrationModelsPoolTitle": "Selectable models (mapping source)", + "providers.integrationModelsPoolHint": "Each integrated provider's selectable models, used as the candidate source for the Model mapping above. These models don't go into Codex directly; add or remove freely.", + "providers.integrationApply": "Apply integration & restart Codex", + "providers.integrationMappingTitle": "Model mapping (into Codex)", + "providers.integrationMappingHint": "Map Codex's standard tiers to a pool's provider / model. Only mapped tiers appear in the Codex model list; picking one routes to the mapped upstream. Re-apply and restart Codex after changes.", + "providers.integrationSlotUnset": "— Not mapped —", + "providers.integrationSlotPickModel": "Pick a model", + "providers.integrationSlotPickProviderFirst": "Pick a provider first", + "providers.integrationAddProvider": "Add to pool", + "providers.integrationRemoveProvider": "Remove from pool", + "providers.integrationFetchModels": "Re-fetch models", + "providers.integrationFetchingModels": "Fetching models...", + "providers.integrationNoProviders": "No providers yet. Add one on the Dashboard first, then come back to integrate it.", + "providers.integrationNoIntegrated": "No providers added yet. Pick providers to integrate from above.", + "providers.integrationNoModels": "No models yet. Click \"Re-fetch models\" or add them manually on the provider edit page.", + "providers.integrationFallbackHint": "Showing this provider's default model mappings (not curated yet). Click \"Re-fetch models\" or add/remove to pin them as this provider's selectable list.", + "providers.integrationRemoveModel": "Remove this model", + "providers.integrationAddModelPlaceholder": "Add model id manually", + "providers.integrationAddModel": "Add", + "providers.integrationApplyLockedTitle": "Integration mode is on", + "providers.integrationApplyLockedHint": "In integration mode the unified model pool is in charge; per-provider \"Apply / Enable\" is locked to avoid conflicts. To apply a single provider, turn off provider integration first.", "models.title": "Model Mapping", "models.subtitle": "Configure model aliases for each provider", "models.provider": "Provider", @@ -1457,8 +1510,13 @@ "toast.configExported": "Config exported", "toast.configImported": "Config imported", "toast.configImportFailed": "Config import failed", - "toast.allModelsEnabled": "All-model display enabled. Re-apply and restart your terminal.", - "toast.singleModelEnabled": "Switched back to current-provider display. Re-apply and restart your terminal.", + "toast.integrationEnabled": "Provider integration enabled. Re-apply and restart Codex.", + "toast.integrationDisabled": "Provider integration disabled. Back to single-provider mode.", + "toast.integrationProviderAdded": "Added to pool. Fetching models...", + "toast.integrationProviderRemoved": "Removed from pool.", + "toast.integrationModelsUpdated": "Model pool updated. Re-apply and restart Codex.", + "toast.integrationMappingUpdated": "Model mapping updated. Re-apply and restart Codex.", + "toast.integrationApplied": "Integration config written to Codex. Restarting to apply.", "toast.compatibilityChecked": "Compatibility check completed", "toast.requestFailed": "Operation failed. Check backend logs.", "confirm.desktopApply": "This will generate Codex CLI environment variable commands and copy them to clipboard. Continue?", diff --git a/src-tauri/src/admin/handlers/common.rs b/src-tauri/src/admin/handlers/common.rs index 4c4fe847..89766bd5 100644 --- a/src-tauri/src/admin/handlers/common.rs +++ b/src-tauri/src/admin/handlers/common.rs @@ -179,7 +179,11 @@ pub async fn status(State(state): State) -> impl IntoResponse { "activeProviderId": active_id, "providerCount": providers_count, "desktopHealth": desktop_health, - "exposeAllProviderModels": false, + // 池化开关真实值(此前硬编 false 的 stub):前端据此把 set-default 文案切到 + // 「新对话默认 provider」语义 + 决定模型菜单提示。 + "exposeAllProviderModels": read_setting_bool(&cfg, "exposeAllProviderModels", false), + // 整合页中间「模型映射」区渲染用:全局 gpt-5.x → {provider, model}(缺省 {})。 + "poolSlotMappings": cfg.get("poolSlotMappings").cloned().unwrap_or_else(|| json!({})), })) .into_response() } diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index 41745590..6ae33751 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -183,7 +183,10 @@ pub struct AddProviderInput { pub review_model_slot: Option, } -pub async fn add_provider(Json(input): Json) -> impl IntoResponse { +pub async fn add_provider( + State(state): State, + Json(input): Json, +) -> impl IntoResponse { // 校验 extraHeaders 在保存前合法,避免运行时静默丢 header(实测痛点:Kimi // KimiCLI UA 字符串带换行 → resolver 运行时 HeaderValue::from_str 失败 → // WARN 后跳过 → Kimi 上游 403 但用户看不到原因) @@ -281,6 +284,8 @@ pub async fn add_provider(Json(input): Json) -> impl IntoRespo json!({"default":"","gpt_5_5":"","gpt_5_4":"","gpt_5_4_mini":"","gpt_5_3_codex":"","gpt_5_2":""}) }), ); + // 模型池(pooledModels)不在 add provider 时写入 —— 新 provider 默认未加入整合 + // (pooledEnabled 缺省 false),其池由「整合提供商」页 setProviderPool 独家管理。 new_provider.insert( "extraHeaders".into(), input.extra_headers.clone().unwrap_or_else(|| json!({})), @@ -328,10 +333,13 @@ pub async fn add_provider(Json(input): Json) -> impl IntoRespo Ok(v) => v, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), }; + // 池模式:新 provider 的模型要进全局池 catalog + resolver 反查表 → 立即重建。 + super::resync_pool_if_enabled(&state).await; Json(json!({"success": true, "provider": public_provider(&new_provider_value)})).into_response() } pub async fn update_provider( + State(state): State, Path(id): Path, Json(input): Json, ) -> impl IntoResponse { @@ -422,6 +430,23 @@ pub async fn update_provider( updated.insert("models".into(), Value::Object(merged)); } } + // 模型池(pooledModels)**不由 provider 表单填充** —— 由「整合提供商」页 setProviderPool + // 独家管理。`updated` 克隆自 existing,正常编辑不动 pooledModels = 原样保留整合页 curation + // 的池(含显式空 []),表单写池会把删掉的模型悄悄加回(#477 P2 round-3)。 + // + // **例外:upstream 身份变了**(baseUrl / apiFormat / apiKey 与原值不同)→ 旧池属于旧端点/ + // 旧账号,留着会让 catalog / resolver 继续广播旧 slug、把请求路由到新上游的不存在模型 + // (错路由,plan 头号风险)→ 置 pooledModels 为**显式空 `[]`**(等用户在整合页「重新获取」 + // 对新上游重建)。**不能** remove:缺省会让 `unique_pool_slugs` 回退到该 provider 的(同样 + // 陈旧的)`models` 槽位映射,旧 slug 立刻又被广播进池(#477 P2 round-7);显式 `[]` 经 + // round-3 registry 语义 = 该 provider 不贡献任何 slug、也不回退。表单只**作废**池、绝不 + // **填充**(非 resurrection)。(#477 P2 round-5/7) + let identity_changed = existing.get("baseUrl") != updated.get("baseUrl") + || existing.get("apiFormat") != updated.get("apiFormat") + || existing.get("apiKey") != updated.get("apiKey"); + if identity_changed { + updated.insert("pooledModels".into(), Value::Array(Vec::new())); + } updated.insert("id".into(), Value::String(id.clone())); updated.insert("isBuiltin".into(), Value::Bool(is_builtin)); @@ -437,10 +462,15 @@ pub async fn update_provider( } Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), }; + // 池模式:该 provider(即便非 active)的模型在全局池里 → 改动后重建 catalog + 反查表。 + super::resync_pool_if_enabled(&state).await; Json(json!({"success": true, "provider": public_provider(&updated_value)})).into_response() } -pub async fn delete_provider(Path(id): Path) -> impl IntoResponse { +pub async fn delete_provider( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { let result = with_config_write(|cfg| { let providers = cfg .get("providers") @@ -486,12 +516,115 @@ pub async fn delete_provider(Path(id): Path) -> impl IntoResponse { Ok(ConfigMutation::Modified(())) }); match result { - Ok(()) => Json(json!({"success": true})).into_response(), + Ok(()) => { + // 池模式:删 provider 后必须从池 catalog + resolver 反查表移除其 slug, + // 否则 picker 仍显示、且旧反查表仍用陈旧凭据路由到已删 provider(bot review P2)。 + super::resync_pool_if_enabled(&state).await; + Json(json!({"success": true})).into_response() + } Err(e) if e == "provider not found" => err(StatusCode::NOT_FOUND, e).into_response(), Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), } } +#[derive(Debug, Deserialize)] +pub struct SetPoolInput { + /// 整合开关:把该 provider 加入(true)/移出(false)整合页子集。缺省 = 不动。 + #[serde(default)] + pub enabled: Option, + /// 可选模型列表(整合页下方卡池增删的**权威结果**)。缺省 = 不动; + /// `Some` 时**整列表替换**(curation 既要增也要删,非合并),经 chat 过滤去重。 + #[serde(default)] + pub models: Option, +} + +/// `PUT /api/providers/{id}/pool` —— 整合页对单个 provider 的池操作。 +/// +/// 两件事各自独立、缺省即不动: +/// - `enabled`:写 `pooledEnabled`(决定该 provider 是否进入 `unique_pool_slugs` 子集)。 +/// - `models`:**权威替换** `pooledModels`(curation 的增删都靠它,不能 merge, +/// 否则用户删不掉模型 —— 与 CRUD 的 never-shrink 合并语义相反,这里就是要精确集合)。 +pub async fn set_provider_pool( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> impl IntoResponse { + // `models` 是权威替换:若客户端传了非数组,coerce 成 [] 会**静默清空** pooledModels + // (守 no-silent-destructive)→ 提前拒为 400,不进写盘闭包。缺省(None)= 不动该项。 + if let Some(models) = input.models.as_ref() { + if !models.is_array() { + return err(StatusCode::BAD_REQUEST, "models must be an array").into_response(); + } + } + let result = with_config_write(|cfg| { + let Some(idx) = provider_index(cfg, &id) else { + return Err("provider not found".into()); + }; + let providers = cfg + .get_mut("providers") + .and_then(|v| v.as_array_mut()) + .unwrap(); + // provider_index 已证 idx 在数组内;但元素非 object = config 损坏, + // 不能静默 no-op 还回 success(否则客户端以为写成功了)。 + let Some(o) = providers[idx].as_object_mut() else { + return Err("provider entry is not an object (corrupt config)".into()); + }; + if let Some(enabled) = input.enabled { + o.insert("pooledEnabled".into(), Value::Bool(enabled)); + } + if let Some(models) = input.models.as_ref() { + o.insert( + "pooledModels".into(), + super::models::chat_filter_pooled_value(models), + ); + } + Ok(ConfigMutation::Modified(())) + }); + match result { + Ok(()) => { + // 整合子集 / 池模型列表变化 → 重建池 catalog + resolver 反查表。 + super::resync_pool_if_enabled(&state).await; + Json(json!({"success": true})).into_response() + } + Err(e) if e == "provider not found" => err(StatusCode::NOT_FOUND, e).into_response(), + Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct SetSlotMappingsInput { + /// 全局标准档映射:`{ "gpt_5_5": {"provider":"","model":""}, ... }`(object; + /// `null` / `{}` = 清空)。**权威替换**(整合页中间映射区一次提交完整映射)。 + pub mappings: Value, +} + +/// `PUT /api/pool/slot-mappings` —— 整合页「模型映射」区:全局 gpt-5.x → (provider, model)。 +/// 权威替换顶层 `poolSlotMappings`(`pool_slot_entries` 在读取时会过滤 target 不在整合子集 / +/// model 空的条目,故这里只校验整体是 object/null,细粒度留给 catalog/resolver 构建端)。 +pub async fn set_pool_slot_mappings( + State(state): State, + Json(input): Json, +) -> impl IntoResponse { + if !input.mappings.is_object() && !input.mappings.is_null() { + return err(StatusCode::BAD_REQUEST, "mappings must be an object").into_response(); + } + let result = with_config_write(|cfg| { + let Some(obj) = cfg.as_object_mut() else { + return Err("config root is not an object".into()); + }; + obj.insert("poolSlotMappings".into(), input.mappings.clone()); + Ok(ConfigMutation::Modified(())) + }); + match result { + Ok(()) => { + // 全局映射变化 → 重建池 catalog(标准档)+ resolver 反查表。 + super::resync_pool_if_enabled(&state).await; + Json(json!({"success": true})).into_response() + } + Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } +} + /// [MOC-211] 触发小米账号内嵌 webview 登录,抓取网页 session cookie 存到该 provider 的 /// `mimoCookie`(masked,见 public_provider),daemon 之后带它查 MiMo 套餐用量。仅对 MiMo /// token-plan provider 有意义(前端只在该类 provider 上显示登录按钮)。阻塞到登录成功 / @@ -578,7 +711,10 @@ pub struct ReorderInput { pub provider_ids: Vec, } -pub async fn reorder_providers(Json(input): Json) -> impl IntoResponse { +pub async fn reorder_providers( + State(state): State, + Json(input): Json, +) -> impl IntoResponse { let result = with_config_write(|cfg| { let providers = cfg .get("providers") @@ -631,6 +767,8 @@ pub async fn reorder_providers(Json(input): Json) -> impl IntoResp }); match result { Ok(public_ordered) => { + // 池模式:reorder 改 sortIndex → 改 unique_pool_slugs 的碰撞后缀分配 → 重建。 + super::resync_pool_if_enabled(&state).await; Json(json!({"success": true, "providers": public_ordered})).into_response() } Err(e) if e == "reorder count mismatch" => err(StatusCode::BAD_REQUEST, e).into_response(), @@ -640,10 +778,11 @@ pub async fn reorder_providers(Json(input): Json) -> impl IntoResp // /api/providers/{id}/draft —— v1 当 update 用,我们直接复用 pub async fn save_draft( + State(state): State, Path(id): Path, Json(input): Json, ) -> impl IntoResponse { - update_provider(Path(id), Json(input)).await + update_provider(State(state), Path(id), Json(input)).await } #[derive(Debug, Deserialize)] @@ -667,12 +806,26 @@ pub async fn update_models( .unwrap(); if let Some(o) = providers[idx].as_object_mut() { o.insert("models".into(), input.models.clone()); + // 模型池(pooledModels)不由映射页保存维护 —— 由「整合提供商」页 setProviderPool + // 独家管理。映射页写池会把整合页 curation 删掉的模型悄悄加回(#477 bot review P2)。 } Ok(ConfigMutation::Modified(was_active)) }); match result { Ok(was_active) => { - let desktop_sync = if was_active { + // 单模式:仅 active provider 的映射影响 catalog/路由 → 只它变了才 sync。 + // 池模式:任何 provider 的模型都在全局池里 → 非 active 也要重建(bot review P2)。 + let pool_on = crate::admin::registry_io::load() + .ok() + .map(|cfg| { + crate::admin::handlers::common::read_setting_bool( + &cfg, + "exposeAllProviderModels", + false, + ) + }) + .unwrap_or(false); + let desktop_sync = if was_active || pool_on { let sync = crate::admin::services::desktop::snapshot::sync_desktop_for_active_provider( &state, diff --git a/src-tauri/src/admin/handlers/providers/mod.rs b/src-tauri/src/admin/handlers/providers/mod.rs index 85b96007..da417b97 100644 --- a/src-tauri/src/admin/handlers/providers/mod.rs +++ b/src-tauri/src/admin/handlers/providers/mod.rs @@ -23,8 +23,47 @@ use codex_app_transfer_registry::{ }; use serde_json::{json, Value}; +use crate::admin::state::AdminState; + static ID_COUNTER: AtomicU32 = AtomicU32::new(0); +/// 池化模式下,任一 provider 变更(增删改 / reorder / autofill)后重建**全 provider** +/// catalog + 重启 proxy 刷新反查表 —— 因为池模式 catalog/路由依赖所有 provider,而非 +/// 只 active。单模式无需(catalog/路由只看 active provider,非 active 改动到下次切换才生效)。 +/// +/// re-apply 失败(proxy 重启绑定失败 / apply 出错)必须 **loud log**(`POOL_CRUD_RESYNC_FAILED`), +/// 不静默吞 —— 否则 CRUD 报成功但 Codex 被留在「停掉 / 陈旧的 proxy + catalog」(bot review P2)。 +/// **不阻塞** CRUD 成功响应(config 已落盘,下次 re-apply / 重启幂等补偿);用户面提示归前端 UX。 +pub(crate) async fn resync_pool_if_enabled(state: &AdminState) { + let pool_on = crate::admin::registry_io::load() + .ok() + .map(|cfg| { + crate::admin::handlers::common::read_setting_bool( + &cfg, + "exposeAllProviderModels", + false, + ) + }) + .unwrap_or(false); + if !pool_on { + return; + } + let sync = + crate::admin::services::desktop::snapshot::sync_desktop_for_active_provider(state).await; + if sync.get("attempted").and_then(Value::as_bool) == Some(true) + && sync.get("success").and_then(Value::as_bool) != Some(true) + { + let msg = sync + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tracing::error!( + error_id = "POOL_CRUD_RESYNC_FAILED", + "池模式 provider 变更后 re-apply 失败(proxy/catalog 可能陈旧或已停): {msg}" + ); + } +} + pub(crate) fn fresh_provider_id(existing: &[String]) -> String { loop { let nanos = SystemTime::now() diff --git a/src-tauri/src/admin/handlers/providers/models.rs b/src-tauri/src/admin/handlers/providers/models.rs index 9f783503..e1f74c76 100644 --- a/src-tauri/src/admin/handlers/providers/models.rs +++ b/src-tauri/src/admin/handlers/providers/models.rs @@ -3,7 +3,12 @@ use std::collections::HashSet; use std::time::Duration; -use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; use codex_app_transfer_registry::MODEL_ORDER; use serde_json::{json, Value}; @@ -205,30 +210,57 @@ fn extract_model_ids(payload: &Value) -> Vec { ids } -fn usable_model_ids(model_ids: &[String]) -> Vec { - const EXCLUDE: &[&str] = &[ - "embedding", - "rerank", - "moderation", - "whisper", - "tts", - "image", - "vision", - "audio", - ]; - let usable: Vec = model_ids +/// 非 chat 模型关键词(embedding / rerank / 审核 / 语音转写 / 语音合成 / 图像生成)—— +/// 这些**真正**不能服务 chat 请求。 +/// +/// **不含** `vision` / `audio`:多模态 chat 模型常带这些词(`moonshot-v1-8k-vision-preview`、 +/// `gpt-4o-audio-preview` 等仍是 chat 模型,只是支持图/音输入),按子串剔除会把合法 chat 模型 +/// 静默踢出池、在 Codex picker 里消失(#477 bot review P2 round-9)。`image` 保留:`gpt-image-1` +/// 等是图像**生成**端点、非 chat,且无以 `image` 命名的常见 chat 变体。 +const NON_CHAT_MODEL_KEYWORDS: &[&str] = &[ + "embedding", + "rerank", + "moderation", + "whisper", + "tts", + "image", +]; + +/// 只保留可用于 chat 的模型 id(过滤 embedding/rerank/语音/图像);**全被过滤则返回空** +/// (不 fallback 原列表)。池化 pooledModels 用它 —— 否则只含 embedding 的 provider 会把 +/// 这些模型写进池、出现在 Codex chat picker 并把 chat 请求路由到不支持的端点(bot review P2)。 +pub(crate) fn chat_usable_model_ids(model_ids: &[String]) -> Vec { + model_ids .iter() .filter(|model_id| { let lower = model_id.to_ascii_lowercase(); - !EXCLUDE.iter().any(|keyword| lower.contains(keyword)) + !NON_CHAT_MODEL_KEYWORDS + .iter() + .any(|keyword| lower.contains(keyword)) }) .cloned() - .collect(); - if usable.is_empty() { - model_ids.to_vec() - } else { - usable - } + .collect() +} + +/// 对外来的 `pooledModels`(JSON 字符串数组)做 chat-only 过滤,返回过滤后的 `Value::Array`。 +/// add/update provider 持久化 pooledModels 前用它统一兜底 —— 保证无论前端 / API 哪条路径 +/// 写入,非 chat 模型(embedding/rerank/语音)都不会进池(单一过滤真源,bot review P2)。 +pub(crate) fn chat_filter_pooled_value(pooled: &Value) -> Value { + let ids: Vec = pooled + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.trim().to_owned())) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + Value::Array( + chat_usable_model_ids(&ids) + .into_iter() + .map(Value::String) + .collect(), + ) } fn pick_model(model_ids: &[String], keywords: &[&str], fallback_index: usize) -> String { @@ -255,7 +287,11 @@ fn empty_model_mappings_value() -> Value { } fn suggest_model_mappings(model_ids: &[String]) -> Value { - let usable = usable_model_ids(model_ids); + // 用 no-fallback 的 chat 过滤:**只含 embedding/rerank 的 provider 不应自动推荐**一个非 chat + // 模型当 default —— 否则池化的「pooledModels 空 → 回退槽位映射」会把该 embedding 漏进 + // Codex chat picker(bot review P2)。正常 provider(有 chat 模型)行为不变(此时与 + // usable_model_ids 结果一致)。 + let usable = chat_usable_model_ids(model_ids); let mut result = empty_model_mappings_value(); if usable.is_empty() { return result; @@ -571,7 +607,10 @@ pub async fn fetch_provider_models_payload(Json(payload): Json) -> impl I (status, Json(result)).into_response() } -pub async fn autofill_provider_models(Path(id): Path) -> impl IntoResponse { +pub async fn autofill_provider_models( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { // **不能在 with_config_write 闭包内 await**(closure 是 sync)。先 load 一份 // provider snapshot 给 fetch 用,await long async 在锁外,然后真 mutate + // save 走 atomic RMW。 @@ -598,7 +637,9 @@ pub async fn autofill_provider_models(Path(id): Path) -> impl IntoRespon .cloned() .unwrap_or_else(|| json!({})); - // 真 mutate + save 走 atomic RMW + // autofill 只更新该 provider 的槽位 `models` 映射 —— **不写 pooledModels**(模型池由 + // 「整合提供商」页 setProviderPool 独家管理;autofill 写池会把整合页 curation 删掉的模型 + // 悄悄加回,#477 bot review P2)。 let suggested_for_closure = suggested.clone(); let write_result = with_config_write(|cfg| { let Some(idx) = provider_index(cfg, &id) else { @@ -616,6 +657,8 @@ pub async fn autofill_provider_models(Path(id): Path) -> impl IntoRespon if let Err(e) = write_result { return err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(); } + // 整合下若该 provider 仍靠映射回退(pooledModels 缺省),映射变了 → 池 catalog 跟着变 → 重建。 + super::resync_pool_if_enabled(&state).await; Json(json!({ "success": true, "models": result.get("models").cloned().unwrap_or_else(|| json!([])), @@ -630,6 +673,48 @@ pub async fn autofill_provider_models(Path(id): Path) -> impl IntoRespon mod tests { use super::*; + #[test] + fn chat_usable_keeps_vision_audio_drops_true_non_chat() { + // #477 P2 round-9:vision / audio 多模态 chat 模型必须保留;只剔真正非 chat 的。 + let ids: Vec = [ + "moonshot-v1-8k-vision-preview", + "gpt-4o-audio-preview", + "deepseek-chat", + "text-embedding-3-large", + "bge-reranker-v2", + "whisper-1", + "tts-1", + "gpt-image-1", + "omni-moderation-latest", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + let kept = chat_usable_model_ids(&ids); + assert!( + kept.contains(&"moonshot-v1-8k-vision-preview".to_string()), + "vision 是 chat" + ); + assert!( + kept.contains(&"gpt-4o-audio-preview".to_string()), + "audio 是 chat" + ); + assert!(kept.contains(&"deepseek-chat".to_string())); + for dropped in [ + "text-embedding-3-large", + "bge-reranker-v2", + "whisper-1", + "tts-1", + "gpt-image-1", + "omni-moderation-latest", + ] { + assert!( + !kept.iter().any(|m| m == dropped), + "{dropped} 应被过滤(真非 chat)" + ); + } + } + #[test] fn provider_is_bailian_token_plan_matches_only_token_plan_host() { assert!(provider_is_bailian_token_plan(&json!({ diff --git a/src-tauri/src/admin/handlers/settings.rs b/src-tauri/src/admin/handlers/settings.rs index 1f19c74b..09074d7d 100644 --- a/src-tauri/src/admin/handlers/settings.rs +++ b/src-tauri/src/admin/handlers/settings.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::PathBuf; use std::time::SystemTime; -use axum::{http::StatusCode, response::IntoResponse, Json}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use codex_app_transfer_registry::{ config_dir, normalize_model_mappings, RawConfig, DEFAULT_UPDATE_URL, }; @@ -15,6 +15,7 @@ use serde_json::{json, Value}; use super::super::registry_io::save_for_test as save_registry; use super::super::registry_io::{load as load_registry, with_config_write, ConfigMutation}; use super::common::{err, random_hex, APP_VERSION}; +use crate::admin::state::AdminState; pub(super) fn ensure_settings_object(cfg: &mut RawConfig) -> &mut serde_json::Map { let obj = cfg.as_object_mut().expect("registry root is object"); @@ -141,6 +142,9 @@ pub(super) fn normalize_imported_config(data: &Value) -> Result { "gatewayApiKey", "providers", "settings", + // 整合模式全局标准档映射:导入备份时一并保留,否则整合开着却丢了所有映射、 + // 退回单 provider 直到用户手动重建(#477 bot review P2)。 + "poolSlotMappings", ] { if let Some(value) = source_obj.get(key) { obj.insert(key.to_owned(), value.clone()); @@ -377,7 +381,10 @@ pub async fn get_settings() -> impl IntoResponse { Json(settings).into_response() } -pub async fn save_settings(Json(input): Json) -> impl IntoResponse { +pub async fn save_settings( + State(state): State, + Json(input): Json, +) -> impl IntoResponse { let result = with_config_write(|cfg| { // #MOC-62:记下旧值,只在 mcpCredentialsPortableStore 真变了才触发即时生效 // (避免改主题等无关 settings 也去写 config.toml)。 @@ -400,6 +407,12 @@ pub async fn save_settings(Json(input): Json) -> impl IntoResponse { .and_then(Value::as_str) .unwrap_or(codex_app_transfer_registry::schema::DEFAULT_WEB_FETCH_BACKEND) .to_string(); + // 池化开关旧值:真翻转才在写后 re-apply(重建 catalog + 重启 proxy)。 + let old_expose_all = cfg + .get("settings") + .and_then(|s| s.get("exposeAllProviderModels")) + .and_then(Value::as_bool) + .unwrap_or(false); let s = ensure_settings_object(cfg); if let Some(obj) = input.as_object() { for (k, v) in obj { @@ -423,15 +436,27 @@ pub async fn save_settings(Json(input): Json) -> impl IntoResponse { .unwrap_or(codex_app_transfer_registry::schema::DEFAULT_WEB_FETCH_BACKEND) .to_string(); let web_fetch_changed = (new_web_fetch != old_web_fetch).then_some(new_web_fetch); + let new_expose_all = settings + .get("exposeAllProviderModels") + .and_then(Value::as_bool) + .unwrap_or(false); + let expose_all_changed = new_expose_all != old_expose_all; Ok(ConfigMutation::Modified(( settings, portable_changed, auto_unlock_changed, web_fetch_changed, + expose_all_changed, ))) }); match result { - Ok((settings, portable_changed, auto_unlock_changed, web_fetch_changed)) => { + Ok(( + settings, + portable_changed, + auto_unlock_changed, + web_fetch_changed, + expose_all_changed, + )) => { // #262:settings.language 改动后 hot reload 到 adapters 全局, // 让接下来的 prompt 注入跟新语言一致(用户切语言无需重启 transfer)。 sync_user_language_from_settings(&settings); @@ -469,6 +494,32 @@ pub async fn save_settings(Json(input): Json) -> impl IntoResponse { web_fetch_warning = Some(e); } } + // 池化开关翻转 → re-apply 当前 active provider:开 = catalog 写全 provider 池, + // 关 = 退回单 provider catalog;同时重启 proxy 刷新 resolver 反查表。re-apply 失败 + // 必须 **loud log**(不静默吞:开关已写盘但 catalog/proxy 没跟上 = 状态不一致)。 + // + // 用户面提示(失败 toast / "需重启 Codex" 引导)属池化前端 UX —— 本 PR 后端 only, + // 故此处只到后端日志层;翻开关的 toast / 重启提示随前端 follow-up 落地(MOC-236)。 + // 无 active provider 时 sync attempted=false(无可 apply),非失败。 + if expose_all_changed { + let sync = + crate::admin::services::desktop::snapshot::sync_desktop_for_active_provider( + &state, + ) + .await; + if sync.get("attempted").and_then(Value::as_bool) == Some(true) + && sync.get("success").and_then(Value::as_bool) != Some(true) + { + let msg = sync + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tracing::error!( + error_id = "POOL_TOGGLE_REAPPLY_FAILED", + "exposeAllProviderModels 翻转后 re-apply 失败: {msg}" + ); + } + } let mut body = json!({"success": true, "settings": settings}); if let Some(w) = web_fetch_warning { body["webFetchSyncWarning"] = json!(w); diff --git a/src-tauri/src/admin/mod.rs b/src-tauri/src/admin/mod.rs index 6d78090e..4990cf5e 100644 --- a/src-tauri/src/admin/mod.rs +++ b/src-tauri/src/admin/mod.rs @@ -55,6 +55,14 @@ pub fn build_app_router(state: AdminState) -> Router { "/api/providers/{id}/default", put(handlers::providers::crud::set_default_provider), ) + .route( + "/api/providers/{id}/pool", + put(handlers::providers::crud::set_provider_pool), + ) + .route( + "/api/pool/slot-mappings", + put(handlers::providers::crud::set_pool_slot_mappings), + ) .route( "/api/providers/{id}/activate", post(handlers::providers::crud::activate_provider), diff --git a/src-tauri/src/admin/services/desktop/snapshot.rs b/src-tauri/src/admin/services/desktop/snapshot.rs index 770a0e7f..13e6544d 100644 --- a/src-tauri/src/admin/services/desktop/snapshot.rs +++ b/src-tauri/src/admin/services/desktop/snapshot.rs @@ -3,14 +3,15 @@ use std::path::PathBuf; use std::sync::Arc; use codex_app_transfer_codex_integration::{ - apply_provider, catalog_models_for_provider_with_display_names, ensure_file_store_mode, - get_snapshot_status, has_snapshot, has_stale_active_snapshot, list_snapshots, read_auth, - restore_available_count, restore_codex_snapshot, restore_codex_state, sync_mcp_credentials, - ApplyConfig, CodexPaths, + apply_provider, catalog_models_for_provider_with_display_names, + catalog_models_for_slot_mappings, ensure_file_store_mode, get_snapshot_status, has_snapshot, + has_stale_active_snapshot, list_snapshots, read_auth, restore_available_count, + restore_codex_snapshot, restore_codex_state, sync_mcp_credentials, ApplyConfig, CatalogModel, + CodexPaths, PoolProviderMeta, }; use codex_app_transfer_gemini_oauth::antigravity_static_models; use codex_app_transfer_proxy::proxy_telemetry; -use codex_app_transfer_registry::RawConfig; +use codex_app_transfer_registry::{pool_slot_entries, RawConfig}; use serde_json::{json, Value}; use crate::admin::handlers::common::{active_provider_name, read_setting_bool, APP_VERSION}; @@ -51,6 +52,12 @@ pub struct DesktopConfigTarget { /// 从 provider `reviewModelSlot` 读;透传给 catalog 生成写每个 entry 的 /// `auto_review_model_override`,让审查脱钩主模型走该槽位的现有映射。 pub review_model_slot: Option, + /// **池化模式** catalog(全 provider 的池条目)。仅 `exposeAllProviderModels` 开 + + /// local_proxy 模式才 `Some`;`None` = 单 active provider catalog(行为不变)。 + /// 与 resolver 反查表共用 `unique_pool_slugs`,保证 slug 一致、不错路由。 + pub pool: Option>, + /// 池模式下 root `model` 锚定目标(active provider 的池默认 slug);`None` = 单模式。 + pub pool_default_slug: Option, } /// [MOC-69] 给 antigravity provider 构建 model id → displayName 反查表(JSON object), @@ -71,6 +78,69 @@ fn antigravity_display_names(api_format_lower: &str) -> Value { Value::Object(map) } +/// 整合(池化)catalog 构建:把**全局槽位映射** `poolSlotMappings`(`gpt-5.x → {provider,model}`) +/// 经 `pool_slot_entries`(与 proxy resolver 反查表**同一 helper** → slug 逐字一致)产标准档条目, +/// 再 `catalog_models_for_slot_mappings` 生成 Codex catalog。返回 `(catalog, pool_default_slug)`, +/// 后者 = 首条标准档 slug(MODEL_SLOTS 顺序,即 gpt-5.5 优先;给 apply 锚定 root `model`)。 +/// +/// **真机确认 `provider/model` slug 进不了 Codex picker**,故整合模式只把标准档(gpt-5.x)暴露 +/// 给 Codex,下池 provider/model 仅作映射来源。`None` = 无有效映射(没配 / target 都被移出整合 / +/// model 空 / 解析异常)→ caller 退回单 provider catalog,**绝不返回空 catalog**(空会让 picker 空)。 +fn build_pool_catalog( + cfg: &RawConfig, + _active_id: Option<&str>, +) -> Option<(Vec, Option)> { + let providers_raw = cfg.get("providers").and_then(|v| v.as_array())?; + if providers_raw.is_empty() { + return None; + } + // 全 provider 转 typed(`extra` flatten 无损)。**all-or-nothing**:任一条解析失败 → + // 放弃整池、退回单 provider catalog(**绝不** skip 个别 provider —— 那会静默丢该 provider + // 的模型 = 破坏性降级)。失败必 loud log 指明是哪个 provider,避免"开了池却只看到一个 + // provider"无从排查(守 no-silent-degradation)。typed 与 providers_raw 等长、同序是后续 + // meta / `entry.provider_idx` 对齐的前提。 + let mut typed: Vec = + Vec::with_capacity(providers_raw.len()); + for p in providers_raw { + match serde_json::from_value::(p.clone()) { + Ok(prov) => typed.push(prov), + Err(e) => { + let id = p.get("id").and_then(|v| v.as_str()).unwrap_or(""); + tracing::error!( + error_id = "POOL_PROVIDER_DESERIALIZE_FAILED", + "池化禁用:provider {id:?} 解析失败({e}),退回单 active provider catalog" + ); + return None; + } + } + } + // 全局槽位映射 → 标准档条目(pool_slot_entries 已校验 target provider 在整合子集内 + model 非空)。 + let entries = pool_slot_entries(&typed, cfg.get("poolSlotMappings")); + if entries.is_empty() { + // 没有任何有效的标准档映射(没配 / target 都被移出整合 / model 空)→ 返回 `None`, + // caller 退回单 active provider catalog。**catalog 与 resolver 一致地回退**(resolver 端 + // 反查表也为空 → 退默认 provider),graceful、Codex 不至于空,回退有 loud log 兜底可排查。 + return None; + } + // meta 按 provider_idx 索引取窗口 / display(标准档 catalog 的 context_window 取映射模型的)。 + let meta: Vec = typed + .iter() + .map(|p| PoolProviderMeta { + provider_name: p.name.clone(), + model_capabilities: serde_json::to_value(&p.model_capabilities) + .unwrap_or_else(|_| json!({})), + display_names: antigravity_display_names(&p.api_format.trim().to_ascii_lowercase()), + }) + .collect(); + let catalog = catalog_models_for_slot_mappings(&entries, &meta); + if catalog.is_empty() { + return None; + } + // root `model` 锚到首条标准档(pool_slot_entries 按 MODEL_SLOTS 顺序 → gpt-5.5 优先)。 + let default_slug = entries.first().map(|e| e.slug.clone()); + Some((catalog, default_slug)) +} + pub fn desktop_config_target_for_provider( cfg: &mut RawConfig, provider: &Value, @@ -105,6 +175,26 @@ pub fn desktop_config_target_for_provider( tracing::error!(error_id = "GATEWAY_KEY_CSPRNG_FAILED", "{e}"); String::new() }); + // 池化模式:开关开 + local_proxy 时,catalog = 全 provider 池条目;否则单 provider。 + // 解析失败 / 无可池模型 → 退回单 provider(build_pool_catalog 返 None)。 + let (pool, pool_default_slug) = if read_setting_bool(cfg, "exposeAllProviderModels", false) { + match build_pool_catalog(cfg, provider.get("id").and_then(|v| v.as_str())) { + Some((catalog, slug)) => (Some(catalog), slug), + None => { + // 整合开关已开但池为空(没 provider 加入整合 / 加入的都被 curation 清空)→ 退回单 + // active provider catalog(graceful、Codex 不至于空)。必须 loud log,否则用户看 + // 「整合已开 + 应用锁定」却只看到单 provider 模型、无从排查(守 no-silent-degradation)。 + // 前端整合页下池另有「还没加入提供商 / 各 provider 无可选模型」引导。 + tracing::warn!( + error_id = "POOL_EMPTY_FELL_BACK_TO_SINGLE", + "整合开关已开但模型池为空(无 provider 加入整合或加入的都已清空),退回单 active provider catalog" + ); + (None, None) + } + } + } else { + (None, None) + }; DesktopConfigTarget { base_url, api_key, @@ -119,6 +209,8 @@ pub fn desktop_config_target_for_provider( codex_network_access, model_display_names: antigravity_display_names(&api_format_lower), review_model_slot: provider_review_model_slot(provider), + pool, + pool_default_slug, } } @@ -145,27 +237,34 @@ pub fn active_provider_supports_relay() -> bool { } pub fn desktop_expected_model_items(target: &DesktopConfigTarget) -> Vec { - catalog_models_for_provider_with_display_names( - &target.provider_name, - &target.default_model, - target.supports_1m, - Some(&target.model_mappings), - Some(&target.model_capabilities), - Some(&target.model_display_names), - target.review_model_slot.as_deref(), - ) - .into_iter() - .map(|model| { - let mut item = json!({ - "name": model.slug, - "displayName": model.display_name, - }); - if model.context_window >= ONE_M_CONTEXT_WINDOW { - item["supports1m"] = Value::Bool(true); - } - item - }) - .collect() + // 池化模式:dashboard 展示 / needsApply(one_million_catalog_ready)判定都用与实际 + // 写入一致的池 catalog;否则单 active provider catalog(行为不变)。 + let models = if let Some(pool) = &target.pool { + pool.clone() + } else { + catalog_models_for_provider_with_display_names( + &target.provider_name, + &target.default_model, + target.supports_1m, + Some(&target.model_mappings), + Some(&target.model_capabilities), + Some(&target.model_display_names), + target.review_model_slot.as_deref(), + ) + }; + models + .into_iter() + .map(|model| { + let mut item = json!({ + "name": model.slug, + "displayName": model.display_name, + }); + if model.context_window >= ONE_M_CONTEXT_WINDOW { + item["supports1m"] = Value::Bool(true); + } + item + }) + .collect() } pub fn desktop_inference_models_json(target: Option<&DesktopConfigTarget>) -> String { @@ -388,6 +487,9 @@ fn apply_desktop_target_impl( app_version: APP_VERSION, codex_network_access: target.codex_network_access, preserve_chatgpt_auth, + // 池化:Some 时 apply 把 catalog 写成全 provider 池(否则单 provider)。 + pool: target.pool.as_deref(), + pool_default_slug: target.pool_default_slug.as_deref(), }, ) .map_err(|e| format!("apply 失败: {e}"))?; @@ -724,6 +826,202 @@ mod tests { }) } + #[test] + fn pool_mode_target_builds_slot_catalog() { + // exposeAllProviderModels=true + poolSlotMappings(标准档→池中 (provider,model))→ + // target.pool 含**标准档** slug(gpt-5.x;真机确认 provider/model slug 进不了 Codex picker, + // 故整合模式只暴露标准档)。pool_default_slug = 首条标准档(MODEL_SLOTS 顺序 → gpt-5.5)。 + // toggle 关时退回单 provider(pool=None)。 + let mut cfg = json!({ + "version": APP_VERSION, + "activeProvider": "deepseek", + "gatewayApiKey": "cas_test", + "providers": [ + { + "id": "deepseek", "name": "DeepSeek", + "baseUrl": "https://a.example/v1", "apiFormat": "openai_chat", + "apiKey": "k1", "models": {"default": "deepseek-v4-pro"}, + "pooledModels": ["deepseek-v4-pro"], "pooledEnabled": true, "sortIndex": 0 + }, + { + "id": "kimi", "name": "Kimi", + "baseUrl": "https://b.example/v1", "apiFormat": "openai_chat", + "apiKey": "k2", "models": {"default": "kimi-k2.6"}, + "pooledModels": ["kimi-k2.6"], "pooledEnabled": true, "sortIndex": 1 + } + ], + "poolSlotMappings": { + "gpt_5_5": {"provider": "deepseek", "model": "deepseek-v4-pro"}, + "gpt_5_4": {"provider": "kimi", "model": "kimi-k2.6"} + }, + "settings": { + "theme": "default", "language": "zh", + "proxyPort": 18080, "adminPort": 18081, + "autoStart": false, "autoApplyOnStart": true, + "exposeAllProviderModels": true, "restoreCodexOnExit": true, + "updateUrl": DEFAULT_UPDATE_URL + } + }); + + let active = cfg["providers"][0].clone(); + let target = desktop_config_target_for_provider(&mut cfg, &active, None); + let pool = target + .pool + .clone() + .expect("toggle 开 + 有映射 → pool 应为 Some"); + let slugs: Vec<&str> = pool.iter().map(|m| m.slug.as_str()).collect(); + assert!(slugs.contains(&"gpt-5.5"), "{slugs:?}"); + assert!(slugs.contains(&"gpt-5.4"), "{slugs:?}"); + assert!( + !slugs.iter().any(|s| s.contains('/')), + "整合模式只暴露标准档,不含 provider/model slug: {slugs:?}" + ); + assert_eq!( + target.pool_default_slug.as_deref(), + Some("gpt-5.5"), + "root model 锚到首条标准档(MODEL_SLOTS 顺序)" + ); + + // toggle 关 → 退回单 active provider(pool=None) + cfg["settings"]["exposeAllProviderModels"] = json!(false); + let target_off = desktop_config_target_for_provider(&mut cfg, &active, None); + assert!(target_off.pool.is_none(), "toggle 关 → pool=None"); + } + + #[test] + fn pool_slot_mapping_to_excluded_provider_is_dropped() { + // 映射 target 指向未加入整合的 provider → 该标准档不进 catalog(pool_slot_entries 过滤), + // 避免把请求路由到用户移出整合的 provider(子集语义)。 + let cfg = json!({ + "version": APP_VERSION, + "activeProvider": "kimi", + "gatewayApiKey": "cas_test", + "providers": [ + { + "id": "deepseek", "name": "DeepSeek", + "baseUrl": "https://a.example/v1", "apiFormat": "openai_chat", + "apiKey": "k1", "models": {"default": "deepseek-v4-pro"}, "sortIndex": 0 + }, + { + "id": "kimi", "name": "Kimi", + "baseUrl": "https://b.example/v1", "apiFormat": "openai_chat", + "apiKey": "k2", "models": {"default": "kimi-k2.6"}, + "pooledModels": ["kimi-k2.6"], "pooledEnabled": true, "sortIndex": 1 + } + ], + "poolSlotMappings": { + "gpt_5_5": {"provider": "deepseek", "model": "deepseek-v4-pro"}, + "gpt_5_4": {"provider": "kimi", "model": "kimi-k2.6"} + }, + "settings": { + "theme": "default", "language": "zh", + "proxyPort": 18080, "adminPort": 18081, + "autoStart": false, "autoApplyOnStart": true, + "exposeAllProviderModels": true, "restoreCodexOnExit": true, + "updateUrl": DEFAULT_UPDATE_URL + } + }); + + let (catalog, _) = build_pool_catalog(&cfg, Some("kimi")).expect("kimi 有映射 → Some"); + let slugs: Vec<&str> = catalog.iter().map(|m| m.slug.as_str()).collect(); + assert!( + slugs.contains(&"gpt-5.4"), + "kimi(已加入)的档在 catalog: {slugs:?}" + ); + assert!( + !slugs.contains(&"gpt-5.5"), + "映射到被排除 provider(deepseek)的档不进 catalog: {slugs:?}" + ); + } + + #[test] + fn pool_mode_empty_pool_returns_none_for_consistent_fallback() { + // #477 bot review P2 round-5:池里没有任何 slug 时(无论是「没 provider 加入整合」还是 + // 「加入的都被 curation 清空」)统一返回 None → caller 退回单 active provider catalog。 + // 关键是 **catalog 与 resolver 一致回退** —— 曾试过对「加入但清空」返 Some(空),但 resolver + // 对空 map 仍按 legacy 路由 → 不一致。两种空因都走同一 None 分支验证。 + let nothing_integrated = json!({ + "version": APP_VERSION, "activeProvider": "deepseek", "gatewayApiKey": "cas_test", + "providers": [ + { + "id": "deepseek", "name": "DeepSeek", + "baseUrl": "https://a.example/v1", "apiFormat": "openai_chat", + "apiKey": "k1", "models": {"default": "deepseek-v4-pro"}, "sortIndex": 0 + } + ] + }); + assert!( + build_pool_catalog(¬hing_integrated, Some("deepseek")).is_none(), + "没有 provider 加入整合 → None" + ); + + let all_emptied = json!({ + "version": APP_VERSION, "activeProvider": "deepseek", "gatewayApiKey": "cas_test", + "providers": [ + { + "id": "deepseek", "name": "DeepSeek", + "baseUrl": "https://a.example/v1", "apiFormat": "openai_chat", + "apiKey": "k1", "models": {"default": "deepseek-v4-pro"}, + "pooledModels": [], "pooledEnabled": true, "sortIndex": 0 + } + ] + }); + assert!( + build_pool_catalog(&all_emptied, Some("deepseek")).is_none(), + "加入整合但 curation 清空 → 同样 None(catalog 与 resolver 一致回退)" + ); + } + + #[test] + fn pool_catalog_slugs_match_resolver_map_keys_byte_for_byte() { + // **最高severity invariant 守门**:catalog 生成端(snapshot::build_pool_catalog)与 + // resolver 路由端(proxy_runner:Config deser → pool_slot_entries → build_catalog_slug_map) + // 对同一 config 必须产出**逐字一致**的 slug 集合(整合模式 = 标准档 slug);否则 Codex + // picker 选的档会路由到错误上游(把 prompt 发错 provider = 数据泄露级)。 + use codex_app_transfer_registry::{build_catalog_slug_map, pool_slot_entries, Config}; + let cfg = json!({ + "version": APP_VERSION, + "activeProvider": "deepseek", + "gatewayApiKey": "cas_test", + "providers": [ + {"id":"deepseek","name":"DeepSeek","baseUrl":"https://a/v1","apiFormat":"openai_chat", + "apiKey":"k","models":{"default":"deepseek-v4-pro"}, + "pooledModels":["deepseek-v4-pro"],"pooledEnabled":true,"sortIndex":0}, + {"id":"kimi","name":"Kimi","baseUrl":"https://c/v1","apiFormat":"openai_chat", + "apiKey":"k","models":{"default":"kimi-k2.6"}, + "pooledModels":["kimi-k2.6"],"pooledEnabled":true,"sortIndex":1} + ], + "poolSlotMappings": { + "gpt_5_5": {"provider":"deepseek","model":"deepseek-v4-pro"}, + "gpt_5_4": {"provider":"kimi","model":"kimi-k2.6"} + }, + "settings": {"theme":"default","language":"zh","proxyPort":18080,"adminPort":18081, + "autoStart":false,"autoApplyOnStart":true,"exposeAllProviderModels":true, + "restoreCodexOnExit":true,"updateUrl":DEFAULT_UPDATE_URL} + }); + + // catalog 端 + let (catalog, _) = build_pool_catalog(&cfg, Some("deepseek")).expect("pool should build"); + let catalog_slugs: std::collections::BTreeSet = + catalog.iter().map(|m| m.slug.clone()).collect(); + + // resolver 端(完全复刻 proxy_runner::load_resolver_snapshot 的构建路径) + let config: Config = serde_json::from_value(cfg.clone()).expect("config deser"); + let map = build_catalog_slug_map(&pool_slot_entries( + &config.providers, + Some(&config.pool_slot_mappings), + )); + let map_keys: std::collections::BTreeSet = map.keys().cloned().collect(); + + assert_eq!( + catalog_slugs, map_keys, + "catalog slug 集合必须 == resolver 反查表键集合(否则路由错上游)" + ); + // sanity:标准档 slug 生效 + assert!(map_keys.contains("gpt-5.5"), "{map_keys:?}"); + assert!(map_keys.contains("gpt-5.4"), "{map_keys:?}"); + } + fn agent_debug_log(hypothesis_id: &str, location: &str, message: &str, data: Value) { let payload = json!({ "sessionId": "bf3f9f", @@ -1372,6 +1670,8 @@ mod tests { mode: "local_proxy", proxy_port: 0, codex_network_access: true, + pool: None, + pool_default_slug: None, }; assert!( one_million_catalog_ready(&paths, &no_proxy_target), diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 9595261f..f34363f1 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -11,12 +11,13 @@ //! application-level gate middleware 等"兜底逻辑"—— `Runtime::shutdown_background` //! 是 tokio 提供的 OS-level "杀光所有 task" 原语,不需要 user-space cancel chain。 +use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::sync::Mutex; use codex_app_transfer_proxy::{build_router_with_relogin, StaticResolver}; -use codex_app_transfer_registry::{config_file, Config}; +use codex_app_transfer_registry::{build_catalog_slug_map, config_file, pool_slot_entries, Config}; use serde::Serialize; use tokio::sync::oneshot; @@ -258,14 +259,65 @@ fn load_resolver_snapshot() -> Result { .gateway_api_key .filter(|s| !s.is_empty()) .ok_or_else(|| "gateway api key was not generated".to_owned())?; + + // 整合反查表:仅 `exposeAllProviderModels` 开时构建(与 catalog 生成端共用 + // `pool_slot_entries`,key = 标准档 slug `gpt-5.x`,保证逐字一致)。关 → 空 entries → 空表 + // → `decide_provider` 退默认 provider,行为与整合前一致。 + let pool_entries = if cfg.settings.expose_all_provider_models { + pool_slot_entries(&cfg.providers, Some(&cfg.pool_slot_mappings)) + } else { + Vec::new() + }; + let pool_map = build_catalog_slug_map(&pool_entries); + // 默认 provider(resolver fallback):非池化 → active。池化 → active 若在整合子集内则 active, + // 否则用池首条所属 provider —— **绝不**退回被排除的 active(否则 pool-miss 把流量打到用户 + // 移出整合的 provider,#477 P2 round-8;且与 apply root-model 锚到「池首条」保持一致)。 + let default_provider_id = if pool_entries.is_empty() { + cfg.active_provider.clone() + } else { + pool_default_provider_id( + &cfg.providers, + cfg.active_provider.as_deref(), + &pool_entries, + ) + }; + // 池模式「默认档」路由:首条标准档映射的 (provider_idx, real_model)。catalog miss 时 + // decide_provider 路由到这里(不走 map_model_for_provider,#477 P2)。 + let pool_default = pool_entries + .first() + .map(|e| (e.provider_idx, e.real_model.clone())); + Ok(ResolverSnapshot { provider_count: cfg.providers.len(), active_provider: cfg.active_provider.clone(), - resolver: StaticResolver::new(Some(gateway_key), cfg.providers, cfg.active_provider), + resolver: StaticResolver::new(Some(gateway_key), cfg.providers, default_provider_id) + .with_catalog_slug_map(pool_map) + .with_pool_default(pool_default), gateway_auth: true, }) } +/// 池化模式下 resolver 的默认 provider id。active 在整合子集(`pool_entries`)内 → 用 active; +/// 否则退回池首条 entry 所属 provider(与 `apply` 把 root `model` 锚到池首条 slug 一致), +/// **绝不**返回被排除的 active —— 否则 pool-miss 的请求会路由到用户移出整合的 provider +/// (子集语义违例,#477 P2 round-8)。 +fn pool_default_provider_id( + providers: &[codex_app_transfer_registry::Provider], + active: Option<&str>, + pool_entries: &[codex_app_transfer_registry::PoolEntry], +) -> Option { + let active_idx = active.and_then(|aid| providers.iter().position(|p| p.id == aid)); + let active_in_pool = + active_idx.is_some_and(|ai| pool_entries.iter().any(|e| e.provider_idx == ai)); + if active_in_pool { + return active.map(str::to_owned); + } + pool_entries + .first() + .and_then(|e| providers.get(e.provider_idx)) + .map(|p| p.id.clone()) +} + #[cfg(test)] mod tests { use super::*; @@ -277,6 +329,42 @@ mod tests { use crate::admin::handlers::common::test_support::with_isolated_home; use crate::admin::registry_io::{load as load_registry, save_for_test as save_registry}; + #[test] + fn pool_default_provider_id_skips_excluded_active() { + use codex_app_transfer_registry::{PoolEntry, Provider}; + let providers: Vec = serde_json::from_value(json!([ + {"id":"a","name":"A","baseUrl":"https://a","apiFormat":"openai_chat","apiKey":"k","models":{"default":"ma"}}, + {"id":"b","name":"B","baseUrl":"https://b","apiFormat":"openai_chat","apiKey":"k","models":{"default":"mb"}}, + {"id":"c","name":"C","baseUrl":"https://c","apiFormat":"openai_chat","apiKey":"k","models":{"default":"mc"}} + ])) + .unwrap(); + // 整合子集 = b, c(idx 1,2);a 被排除(未加入整合)。 + let entries = vec![ + PoolEntry { + provider_idx: 1, + slug: "b/mb".into(), + real_model: "mb".into(), + supports_one_m: false, + }, + PoolEntry { + provider_idx: 2, + slug: "c/mc".into(), + real_model: "mc".into(), + supports_one_m: false, + }, + ]; + // active=a 被排除 → 默认改用池首条所属 provider(b),绝不退回排除的 a。 + assert_eq!( + pool_default_provider_id(&providers, Some("a"), &entries).as_deref(), + Some("b") + ); + // active=b 在子集内 → 默认仍 b。 + assert_eq!( + pool_default_provider_id(&providers, Some("b"), &entries).as_deref(), + Some("b") + ); + } + fn config_with_gateway(base_url: String, gateway: Value) -> Value { json!({ "version": "2.1.15",