From 31f4c090c95b0a9a059b23f2b82df512ae7c131a Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 19:03:53 +0800 Subject: [PATCH 01/31] =?UTF-8?q?feat(MOC-236):=20=E6=B1=A0=E5=8C=96?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=B7=AF=E7=94=B1=E5=90=8E=E7=AB=AF=E5=BC=95?= =?UTF-8?q?=E6=93=8E(exposeAllProviderModels,=E9=BB=98=E8=AE=A4=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加 provider 后所有模型可进统一池,Codex 按模型选择,proxy 按 catalog slug `/` 自动分流到对应上游;切模型不再重写 config / 重启 Codex。 - registry: unique_pool_slugs(确定性排序 + 碰撞 -N 后缀)/ pooled_model_ids (pooledModels → 回退槽位映射)/ build_catalog_slug_map - proxy: StaticResolver.catalog_slug_map 反查表,decide_provider 先精确查表 再回退;空表 = 行为与池化前一致 - codex_integration: catalog_models_for_pool(provider-qualified 显示名)+ ApplyConfig.pool + 泛化 root model 锚定 - src-tauri: snapshot build_pool_catalog 全 provider 构建 + 编排,proxy_runner 按开关建反查表,save_settings 翻开关 re-apply + needsCodexRestart(失败 surface) 默认关时行为零变化。catalog 生成端与路由端共用 unique_pool_slugs 保证 slug 逐字 一致、不错路由(含跨进程一致性单测)。Phase 0 已用真机 codex debug models 实证 catalog 接受斜杠 slug。前端(pooledModels 抓取持久化 + provider→model 级联选择)后续搭车。 Refs MOC-236 --- crates/codex_integration/src/apply.rs | 117 +++++++- crates/codex_integration/src/lib.rs | 5 +- crates/codex_integration/src/model_catalog.rs | 130 +++++++- crates/proxy/src/resolver.rs | 122 +++++++- crates/registry/src/lib.rs | 6 +- crates/registry/src/model_alias.rs | 235 +++++++++++++++ src-tauri/src/admin/handlers/settings.rs | 66 ++++- .../src/admin/services/desktop/snapshot.rs | 279 ++++++++++++++++-- src-tauri/src/proxy_runner.rs | 16 +- 9 files changed, 914 insertions(+), 62 deletions(-) diff --git a/crates/codex_integration/src/apply.rs b/crates/codex_integration/src/apply.rs index 852752de..26f9867d 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::{ @@ -104,6 +104,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)] @@ -243,15 +253,21 @@ pub fn apply_provider(paths: &CodexPaths, cfg: &ApplyConfig) -> Result Result = 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"))?; @@ -624,6 +659,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -681,6 +718,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: true, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -729,6 +768,8 @@ mod tests { codex_network_access: false, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -776,6 +817,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -845,6 +888,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -913,6 +958,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -978,6 +1025,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1019,6 +1068,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1068,6 +1119,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1109,6 +1162,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1130,6 +1185,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1182,6 +1239,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1228,6 +1287,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1293,6 +1354,8 @@ mod tests { codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1477,6 +1540,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1546,6 +1611,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1567,6 +1634,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1602,6 +1671,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1629,6 +1700,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1663,6 +1736,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1703,6 +1778,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1755,6 +1832,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1810,6 +1889,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1865,6 +1946,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1901,6 +1984,8 @@ model = \"gpt-5.5\" codex_network_access: true, // 即便 on,direct 也不写 sandbox direct: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1955,6 +2040,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -1980,6 +2067,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -2042,6 +2131,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: true, preserve_chatgpt_auth: false, + pool: None, + pool_default_slug: None, }, ) .unwrap(); @@ -2352,6 +2443,8 @@ model = \"gpt-5.5\" codex_network_access: true, direct: false, 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..cbd14bd4 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, 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..c3397cd1 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,67 @@ 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(); + let supports_1m = 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() +} + 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 +1220,74 @@ 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(), + }, + PoolEntry { + provider_idx: 1, + slug: "ag/gemini-3.5-flash-low".into(), + real_model: "gemini-3.5-flash-low".into(), + }, + ]; + 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(), + }]; + let meta = vec![PoolProviderMeta { + provider_name: "P".into(), + model_capabilities: json!({}), + display_names: Value::Null, + }]; + assert!(catalog_models_for_pool(&entries, &meta).is_empty()); + } } diff --git a/crates/proxy/src/resolver.rs b/crates/proxy/src/resolver.rs index 7cfb7fa0..55912746 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,13 @@ 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, } impl StaticResolver { @@ -132,9 +140,16 @@ impl StaticResolver { gateway_key, providers, default_provider_id, + catalog_slug_map: HashMap::new(), } } + /// 装配池化反查表(builder 形式,保持 `new` 三参签名不变 → 既有调用 / 测试不破)。 + pub fn with_catalog_slug_map(mut self, map: HashMap) -> Self { + self.catalog_slug_map = map; + self + } + fn find_by_id(&self, id: &str) -> Option<&Provider> { self.providers.iter().find(|p| p.id == id) } @@ -309,25 +324,46 @@ 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()) { - 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)))); - } + // 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 路由(手动调用 / 池化前兼容)。 + 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)))); } } } + + // 2. 默认 provider + 槽位映射改写。 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))); - } + 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 +815,64 @@ 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")); + } } diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index 1c3297c1..bf2af792 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, pooled_model_ids, 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..39f8d7ad 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,139 @@ 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, +} + +fn push_unique_model(raw: &str, out: &mut Vec, seen: &mut HashSet) { + let cleaned = strip_internal_model_suffix(raw); + let trimmed = cleaned.trim(); + if !trimmed.is_empty() && seen.insert(trimmed.to_owned()) { + out.push(trimmed.to_owned()); + } +} + +/// 某 provider 在池里的"可选模型 id 列表"。 +/// +/// 优先用持久化的 `pooledModels`(用户获取 / 手加的完整列表);为空则回退到已配置的 +/// 槽位映射(`default` 优先,再按 `MODEL_SLOTS` 顺序取非空值)。回退保证老 provider +/// 无需重新获取也能进池、池永不为空。返回值已 strip 内部 `[1m]` 后缀、去重、稳定顺序。 +pub fn pooled_model_ids(pooled_models: Option<&Value>, models: Option<&Value>) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + // 1. 持久化 pooledModels(字符串数组) + if let Some(Value::Array(arr)) = pooled_models { + for item in arr { + if let Some(s) = item.as_str() { + push_unique_model(s, &mut out, &mut seen); + } + } + } + if !out.is_empty() { + return out; + } + + // 2. 回退:槽位映射的非空值(default 优先,再按槽位顺序) + let mappings = normalize_model_mappings(models); + if let Some(d) = mappings.get(DEFAULT_MODEL_KEY) { + push_unique_model(d, &mut out, &mut seen); + } + for slot in MODEL_SLOTS { + if slot.key == DEFAULT_MODEL_KEY { + continue; + } + if let Some(v) = mappings.get(slot.key) { + push_unique_model(v, &mut out, &mut seen); + } + } + out +} + +/// 给一组 provider 产出全部池条目(catalog slug ↔ provider/real_model)。 +/// +/// **确定性**是核心契约: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 { + let mut order: Vec = (0..providers.len()).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 ids = pooled_model_ids(provider.extra.get("pooledModels"), models_value.as_ref()); + for real in ids { + let slug = format!("{base}{POOL_SLUG_SEPARATOR}{real}"); + if used_slug.insert(slug.clone()) { + entries.push(PoolEntry { + provider_idx: idx, + slug, + real_model: real, + }); + } + } + } + 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 +397,103 @@ mod tests { ] ); } + + // ── 池化路由 helper ── + + fn mk_provider(id: &str, name: &str) -> crate::Provider { + 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: IndexMap::new(), + } + } + + #[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_empty_array_falls_back_to_mappings() { + let pooled = json!([]); + let models = json!({"default": "m1"}); + assert_eq!(pooled_model_ids(Some(&pooled), 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); + } } diff --git a/src-tauri/src/admin/handlers/settings.rs b/src-tauri/src/admin/handlers/settings.rs index 1f19c74b..813e9a89 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"); @@ -377,7 +378,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 +404,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 +433,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,10 +491,48 @@ 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 **成功**(success==true)才提示 needsCodexRestart —— 失败时 + // catalog 没真正改,提示重启无意义。 + // - re-apply 失败(attempted 但 !success)必须 surface(exposeAllProviderModelsWarning) + // + error log,绝不静默吞:开关已写盘但 catalog/proxy 没跟上 = 状态不一致,用户无 + // 信号会以为"开关没生效"(守 no-silent-failure,对齐 webFetchSyncWarning)。 + // - 无 active provider 时 sync attempted=false → 既不提示重启也不算失败。 + let mut needs_codex_restart = false; + let mut expose_all_warning: Option = None; + if expose_all_changed { + let sync = + crate::admin::services::desktop::snapshot::sync_desktop_for_active_provider( + &state, + ) + .await; + let attempted = sync.get("attempted").and_then(Value::as_bool) == Some(true); + let succeeded = sync.get("success").and_then(Value::as_bool) == Some(true); + needs_codex_restart = succeeded; + if attempted && !succeeded { + let msg = sync + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + tracing::error!( + error_id = "POOL_TOGGLE_REAPPLY_FAILED", + "exposeAllProviderModels 翻转后 re-apply 失败: {msg}" + ); + expose_all_warning = Some(msg); + } + } let mut body = json!({"success": true, "settings": settings}); if let Some(w) = web_fetch_warning { body["webFetchSyncWarning"] = json!(w); } + if let Some(w) = expose_all_warning { + body["exposeAllProviderModelsWarning"] = json!(w); + } + if needs_codex_restart { + body["needsCodexRestart"] = json!(true); + } Json(body).into_response() } Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), diff --git a/src-tauri/src/admin/services/desktop/snapshot.rs b/src-tauri/src/admin/services/desktop/snapshot.rs index 6e9f3dc5..d5a034f1 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_pool, 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, 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::{unique_pool_slugs, 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,80 @@ fn antigravity_display_names(api_format_lower: &str) -> Value { Value::Object(map) } +/// 池化 catalog 构建:对 config 里**全部** provider 用 `unique_pool_slugs`(与 proxy +/// resolver 反查表**同一 helper** → slug 逐字一致、不错路由)产池条目,再 +/// `catalog_models_for_pool` 生成 Codex catalog。返回 `(catalog, pool_default_slug)`, +/// 后者 = active provider 的首条池条目 slug(给 apply 锚定 root `model`)。 +/// +/// `None` = 无法池化(无 provider / 解析异常 / 无可池模型)→ caller 退回单 provider +/// catalog,**绝不返回空 catalog**(空会让 Codex 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; + } + } + } + let entries = unique_pool_slugs(&typed); + if entries.is_empty() { + return None; + } + // meta 直接从 typed 构建(与 entries 同源 → `entry.provider_idx` 对齐是**结构保证**, + // 不再靠"两次遍历同序"约定)。typed 反序列化要求 `name` 存在,故此处 name 必有(可能为 + // 空串,catalog_models_for_pool 视空串为不加 provider 前缀,与 raw accessor 行为一致)。 + 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_pool(&entries, &meta); + if catalog.is_empty() { + return None; + } + // 诊断:哪些 provider 没贡献任何池条目(空映射 + 无 pooledModels)→ 在 picker 里缺席。 + // debug 级,便于排查"某 provider 模型没出现在池里"。 + let covered: std::collections::HashSet = + entries.iter().map(|e| e.provider_idx).collect(); + for (idx, p) in typed.iter().enumerate() { + if !covered.contains(&idx) { + tracing::debug!("池化:provider {:?} 无可池模型,不进池", p.id); + } + } + let default_slug = active_id.and_then(|id| { + let idx = typed.iter().position(|p| p.id == id)?; + entries + .iter() + .find(|e| e.provider_idx == idx) + .map(|e| e.slug.clone()) + }); + Some((catalog, default_slug)) +} + pub fn desktop_config_target_for_provider( cfg: &mut RawConfig, provider: &Value, @@ -130,6 +211,9 @@ 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), + // direct 直连不写 catalog(apply.rs 会 strip),故池化对其无意义。 + pool: None, + pool_default_slug: None, }; } @@ -144,6 +228,16 @@ 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 => (None, None), + } + } else { + (None, None) + }; DesktopConfigTarget { base_url, api_key, @@ -158,6 +252,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, } } @@ -183,27 +279,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 { @@ -429,6 +532,9 @@ fn apply_desktop_target_impl( // issue #317:direct 直连只写上游配置,strip 全部 transfer 私货。 direct: target.mode == "direct", 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}"))?; @@ -772,6 +878,125 @@ mod tests { }) } + #[test] + fn pool_mode_target_builds_all_provider_catalog() { + // exposeAllProviderModels=true + 2 provider(local_proxy)→ target.pool 含每个 + // provider 的 /;pool_default_slug = active provider 首条; + // expected_model_items 反映池。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"}, "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", "kimi-for-coding"], "sortIndex": 1 + } + ], + "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(&"deepseek/deepseek-v4-pro"), "{slugs:?}"); + assert!(slugs.contains(&"kimi/kimi-k2.6"), "{slugs:?}"); + assert!(slugs.contains(&"kimi/kimi-for-coding"), "{slugs:?}"); + // pooledModels 优先:kimi 的 default(kimi-k2.6)+ 手加(kimi-for-coding)都在池 + assert_eq!( + target.pool_default_slug.as_deref(), + Some("deepseek/deepseek-v4-pro"), + "锚定到 active provider 首条池条目" + ); + // display_name provider-qualified(消歧) + let kimi_coding = pool + .iter() + .find(|m| m.slug == "kimi/kimi-for-coding") + .unwrap(); + assert_eq!(kimi_coding.display_name, "Kimi / kimi-for-coding"); + // expected_model_items(dashboard / needsApply 判定)反映池 + let names: Vec = desktop_expected_model_items(&target) + .iter() + .filter_map(|i| i["name"].as_str().map(str::to_owned)) + .collect(); + assert!( + names.iter().any(|n| n == "kimi/kimi-for-coding"), + "{names:?}" + ); + + // toggle 关 → 退回单 active provider(pool=None,catalog 走 gpt-5.x 槽) + 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_catalog_slugs_match_resolver_map_keys_byte_for_byte() { + // **最高severity invariant 守门**:catalog 生成端(snapshot::build_pool_catalog)与 + // resolver 路由端(proxy_runner:Config deser → unique_pool_slugs → build_catalog_slug_map) + // 对同一 config 必须产出**逐字一致**的 slug 集合;否则 Codex picker 选的模型会路由到 + // 错误上游(把 prompt 发错 provider = 数据泄露级)。含 slug 碰撞 + pooledModels 两种用例。 + use codex_app_transfer_registry::{build_catalog_slug_map, unique_pool_slugs, Config}; + // id `acme` 与 `ACME` slug 化后都成 `acme`(provider_slug 优先用 id 并 lowercase)→ + // 制造真实碰撞,验证 catalog 与 resolver 两端用同一 -N 后缀消歧。p3 走 pooledModels。 + let cfg = json!({ + "version": APP_VERSION, + "activeProvider": "acme", + "gatewayApiKey": "cas_test", + "providers": [ + {"id":"acme","name":"Acme One","baseUrl":"https://a/v1","apiFormat":"openai_chat", + "apiKey":"k","models":{"default":"qna-v1"},"sortIndex":0}, + {"id":"ACME","name":"Acme Two","baseUrl":"https://b/v1","apiFormat":"openai_chat", + "apiKey":"k","models":{"default":"qna-v2"},"sortIndex":1}, + {"id":"kimi","name":"Kimi","baseUrl":"https://c/v1","apiFormat":"openai_chat", + "apiKey":"k","models":{"default":"kimi-k2.6"}, + "pooledModels":["kimi-k2.6","kimi-for-coding"],"sortIndex":2} + ], + "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("acme")).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(&unique_pool_slugs(&config.providers)); + let map_keys: std::collections::BTreeSet = map.keys().cloned().collect(); + + assert_eq!( + catalog_slugs, map_keys, + "catalog slug 集合必须 == resolver 反查表键集合(否则路由错上游)" + ); + // sanity:碰撞消歧(acme/ 与 acme-2/)+ pooledModels 生效 + assert!( + map_keys.iter().any(|s| s.starts_with("acme/")), + "{map_keys:?}" + ); + assert!( + map_keys.iter().any(|s| s.starts_with("acme-2/")), + "{map_keys:?}" + ); + assert!(map_keys.contains("kimi/kimi-for-coding"), "{map_keys:?}"); + } + fn agent_debug_log(hypothesis_id: &str, location: &str, message: &str, data: Value) { let payload = json!({ "sessionId": "bf3f9f", @@ -1404,6 +1629,8 @@ mod tests { mode: "direct", proxy_port: 0, codex_network_access: true, + pool: None, + pool_default_slug: None, }; assert!( one_million_catalog_ready(&paths, &direct_target), diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 9595261f..feb81b6b 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, unique_pool_slugs, Config}; use serde::Serialize; use tokio::sync::oneshot; @@ -258,10 +259,21 @@ 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 生成端共用 + // `unique_pool_slugs`,保证 slug 逐字一致)。关 → 空表 → `decide_provider` 退回 + // slug-split / 默认 provider,行为与池化前完全一致。 + let pool_map = if cfg.settings.expose_all_provider_models { + build_catalog_slug_map(&unique_pool_slugs(&cfg.providers)) + } else { + HashMap::new() + }; + 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, cfg.active_provider) + .with_catalog_slug_map(pool_map), gateway_auth: true, }) } From 20477752338e3b715e82d721028cb3a4ee52a1dc Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 19:24:10 +0800 Subject: [PATCH 02/31] =?UTF-8?q?fix(MOC-236):=20=E6=B1=A0=E5=8C=96=20cata?= =?UTF-8?q?log=20=E4=BF=AE=E4=B8=A4=E5=A4=84=20bot=20review=20P2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留 [1m] 1M 信号:新增 pooled_models_with_one_m + PoolEntry.supports_one_m, 让仅靠 [1m] 标 1M 的自定义模型(无 modelCapabilities / 非 documented)在池模式 也拿 1M 窗口,与单 provider 模式一致 - 全新 pool config(config.toml 无 root model)锚到 pool_default_slug,避免 Codex 隐式默认 gpt-5.5 不在池 catalog(全是 / slug) Refs MOC-236 --- crates/codex_integration/src/apply.rs | 73 +++++++++++- crates/codex_integration/src/model_catalog.rs | 31 ++++- crates/registry/src/lib.rs | 6 +- crates/registry/src/model_alias.rs | 109 +++++++++++++++--- 4 files changed, 196 insertions(+), 23 deletions(-) diff --git a/crates/codex_integration/src/apply.rs b/crates/codex_integration/src/apply.rs index 26f9867d..e700576a 100644 --- a/crates/codex_integration/src/apply.rs +++ b/crates/codex_integration/src/apply.rs @@ -295,9 +295,14 @@ 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)); + 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 里时才重置(守 @@ -1090,6 +1095,68 @@ 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, + direct: false, + 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(); diff --git a/crates/codex_integration/src/model_catalog.rs b/crates/codex_integration/src/model_catalog.rs index c3397cd1..56820d61 100644 --- a/crates/codex_integration/src/model_catalog.rs +++ b/crates/codex_integration/src/model_catalog.rs @@ -247,7 +247,10 @@ pub fn catalog_models_for_pool( let meta = providers_meta.get(entry.provider_idx)?; let caps = Some(&meta.model_capabilities); let real = entry.real_model.as_str(); - let supports_1m = model_supports_1m(real, caps); + // 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() { @@ -1231,11 +1234,13 @@ mod tests { 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![ @@ -1282,6 +1287,7 @@ mod tests { provider_idx: 5, slug: "x/y".into(), real_model: "y".into(), + supports_one_m: false, }]; let meta = vec![PoolProviderMeta { provider_name: "P".into(), @@ -1290,4 +1296,27 @@ mod tests { }]; 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/registry/src/lib.rs b/crates/registry/src/lib.rs index bf2af792..d151abbe 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -32,9 +32,9 @@ pub use healing::heal_builtin_provider_fields; pub use healing::heal_legacy_update_url; pub use model_alias::{ build_catalog_slug_map, empty_model_mappings, has_internal_one_m_suffix, - normalize_model_mappings, openai_model_slot, pooled_model_ids, provider_slug, - strip_internal_model_suffix, unique_pool_slugs, PoolEntry, MODEL_ORDER, MODEL_SLOTS, - POOL_SLUG_SEPARATOR, + normalize_model_mappings, openai_model_slot, pooled_model_ids, pooled_models_with_one_m, + 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 39f8d7ad..7733983e 100644 --- a/crates/registry/src/model_alias.rs +++ b/crates/registry/src/model_alias.rs @@ -189,30 +189,53 @@ pub struct PoolEntry { 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_unique_model(raw: &str, out: &mut Vec, seen: &mut HashSet) { +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() && seen.insert(trimmed.to_owned()) { - out.push(trimmed.to_owned()); + 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 在池里的"可选模型 id 列表"。 -/// -/// 优先用持久化的 `pooledModels`(用户获取 / 手加的完整列表);为空则回退到已配置的 -/// 槽位映射(`default` 优先,再按 `MODEL_SLOTS` 顺序取非空值)。回退保证老 provider -/// 无需重新获取也能进池、池永不为空。返回值已 strip 内部 `[1m]` 后缀、去重、稳定顺序。 -pub fn pooled_model_ids(pooled_models: Option<&Value>, models: Option<&Value>) -> Vec { - let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); +/// 某 provider 在池里的"可选模型列表",每条附带"是否声明 1M"(由被 strip 的 `[1m]` +/// 标记得出)。优先用持久化 `pooledModels`,为空则回退槽位映射(`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(字符串数组) if let Some(Value::Array(arr)) = pooled_models { for item in arr { if let Some(s) = item.as_str() { - push_unique_model(s, &mut out, &mut seen); + push_pooled_with_one_m(s, &mut out, &mut index); } } } @@ -223,19 +246,27 @@ pub fn pooled_model_ids(pooled_models: Option<&Value>, models: Option<&Value>) - // 2. 回退:槽位映射的非空值(default 优先,再按槽位顺序) let mappings = normalize_model_mappings(models); if let Some(d) = mappings.get(DEFAULT_MODEL_KEY) { - push_unique_model(d, &mut out, &mut seen); + 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_unique_model(v, &mut out, &mut seen); + 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 产出全部池条目(catalog slug ↔ provider/real_model)。 /// /// **确定性**是核心契约:catalog 生成端与 resolver 路由端各自调用本函数(对同一份 @@ -274,14 +305,16 @@ pub fn unique_pool_slugs(providers: &[crate::Provider]) -> Vec { let base = &base_for_idx[idx]; let provider = &providers[idx]; let models_value = serde_json::to_value(&provider.models).ok(); - let ids = pooled_model_ids(provider.extra.get("pooledModels"), models_value.as_ref()); - for real in ids { + 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, }); } } @@ -496,4 +529,48 @@ mod tests { 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](上游不收)" + ); + } } From 1a63e3984c70c30f7eff667dd3a30a3fe1e174aa Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 19:40:11 +0800 Subject: [PATCH 03/31] =?UTF-8?q?fix(MOC-236):=20=E6=B1=A0=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20provider=20CRUD/autofill/reorder=20=E5=90=8E?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=E6=B1=A0=20catalog=20+=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bot review P2:池模式下 catalog/路由依赖**全部** provider,但原 CRUD 只在 active provider 变更时 re-sync —— 增删改 / autofill / reorder 一个**非 active** provider 后, Codex picker 漏新模型、被删 provider 的 slug 仍用陈旧 in-memory 凭据路由,直到下次 无关 re-apply / 重启才纠。 - 新增 providers::resync_pool_if_enabled(开池才动、单模式整体 no-op) - 接入 add_provider / update_provider / delete_provider / reorder_providers / save_draft / autofill_provider_models(均补 State,axum 自动注入、无需改路由) - update_models 改 was_active → was_active || pool_on 才 sync 单模式行为零变化(helper no-op、update_models 退回原 was_active 门)。 Refs MOC-236 --- .../src/admin/handlers/providers/crud.rs | 46 ++++++++++++++++--- src-tauri/src/admin/handlers/providers/mod.rs | 25 ++++++++++ .../src/admin/handlers/providers/models.rs | 14 +++++- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index df394569..7bcfb384 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 但用户看不到原因) @@ -328,10 +331,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 { @@ -437,10 +443,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,7 +497,12 @@ 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(), } @@ -530,7 +546,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") @@ -583,6 +602,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(), @@ -592,10 +613,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)] @@ -624,7 +646,19 @@ pub async fn update_models( }); 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..c6438224 100644 --- a/src-tauri/src/admin/handlers/providers/mod.rs +++ b/src-tauri/src/admin/handlers/providers/mod.rs @@ -23,8 +23,33 @@ 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 改动到下次切换才生效)。 +/// +/// 失败只在 sync 内部记录、不阻塞调用方的 CRUD 成功响应(下次 re-apply / 重启幂等补偿); +/// 关池模式时整体 no-op(连 config 都不读 settings 之外)。 +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 { + let _ = crate::admin::services::desktop::snapshot::sync_desktop_for_active_provider(state) + .await; + } +} + 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..be86d5f6 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}; @@ -571,7 +576,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。 @@ -616,6 +624,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(); } + // 池模式:autofill 改了该 provider 的模型映射 → 全局池 catalog + 反查表重建(非 active 也是)。 + super::resync_pool_if_enabled(&state).await; Json(json!({ "success": true, "models": result.get("models").cloned().unwrap_or_else(|| json!([])), From fa1d281f4230be701a8afd2fe66e362f28cce282 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 19:54:22 +0800 Subject: [PATCH 04/31] =?UTF-8?q?fix(MOC-236):=20=E6=B1=A0=E5=8C=96?= =?UTF-8?q?=E5=BC=80=E5=85=B3=20re-apply=20=E5=A4=B1=E8=B4=A5=E6=94=B9?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=20loud=20log(=E5=8E=BB=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=9C=AA=E6=B6=88=E8=B4=B9=E7=9A=84=E5=93=8D=E5=BA=94=E5=AD=97?= =?UTF-8?q?=E6=AE=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bot review P2:save_settings 之前返回的 needsCodexRestart / exposeAllProviderModelsWarning 被前端 CCApi.saveSettings wrapper 丢弃(它只透传 webFetchSyncWarning)。本 PR 后端 only, 故去掉这两个尚无前端消费方的响应字段;re-apply 失败改为后端 tracing::error 记录(不静默吞)。 用户面 toast / 「需重启 Codex」引导属池化前端 UX,随 follow-up 落地。 Refs MOC-236 --- src-tauri/src/admin/handlers/settings.rs | 34 ++++++++---------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/admin/handlers/settings.rs b/src-tauri/src/admin/handlers/settings.rs index 813e9a89..fdee8a6f 100644 --- a/src-tauri/src/admin/handlers/settings.rs +++ b/src-tauri/src/admin/handlers/settings.rs @@ -492,47 +492,35 @@ pub async fn save_settings( } } // 池化开关翻转 → re-apply 当前 active provider:开 = catalog 写全 provider 池, - // 关 = 退回单 provider catalog;同时重启 proxy 刷新 resolver 反查表。 - // - 仅在 re-apply **成功**(success==true)才提示 needsCodexRestart —— 失败时 - // catalog 没真正改,提示重启无意义。 - // - re-apply 失败(attempted 但 !success)必须 surface(exposeAllProviderModelsWarning) - // + error log,绝不静默吞:开关已写盘但 catalog/proxy 没跟上 = 状态不一致,用户无 - // 信号会以为"开关没生效"(守 no-silent-failure,对齐 webFetchSyncWarning)。 - // - 无 active provider 时 sync attempted=false → 既不提示重启也不算失败。 - let mut needs_codex_restart = false; - let mut expose_all_warning: Option = None; + // 关 = 退回单 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; - let attempted = sync.get("attempted").and_then(Value::as_bool) == Some(true); - let succeeded = sync.get("success").and_then(Value::as_bool) == Some(true); - needs_codex_restart = succeeded; - if attempted && !succeeded { + 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(Value::as_str) - .unwrap_or("unknown") - .to_owned(); + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); tracing::error!( error_id = "POOL_TOGGLE_REAPPLY_FAILED", "exposeAllProviderModels 翻转后 re-apply 失败: {msg}" ); - expose_all_warning = Some(msg); } } let mut body = json!({"success": true, "settings": settings}); if let Some(w) = web_fetch_warning { body["webFetchSyncWarning"] = json!(w); } - if let Some(w) = expose_all_warning { - body["exposeAllProviderModelsWarning"] = json!(w); - } - if needs_codex_restart { - body["needsCodexRestart"] = json!(true); - } Json(body).into_response() } Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), From 234cd27922dc5451aa363df974584dd58b50895f Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 20:04:15 +0800 Subject: [PATCH 05/31] =?UTF-8?q?fix(MOC-236):=20=E6=B1=A0=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20CRUD=20re-sync=20=E5=A4=B1=E8=B4=A5=20loud=20log(?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E4=B8=A2=E5=BC=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bot review P2:resync_pool_if_enabled 用 `let _ =` 丢弃 sync 结果 —— re-apply 失败 (proxy 重启绑定失败 / apply 出错)时 CRUD 仍报成功,Codex 被留在停掉 / 陈旧的 proxy+catalog 却无任何记录。改为检查 sync 结果,attempted 但 !success 时 tracing::error(POOL_CRUD_RESYNC_FAILED)。不阻塞 CRUD(config 已落盘,幂等补偿)。 Refs MOC-236 --- src-tauri/src/admin/handlers/providers/mod.rs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/admin/handlers/providers/mod.rs b/src-tauri/src/admin/handlers/providers/mod.rs index c6438224..da417b97 100644 --- a/src-tauri/src/admin/handlers/providers/mod.rs +++ b/src-tauri/src/admin/handlers/providers/mod.rs @@ -31,8 +31,9 @@ static ID_COUNTER: AtomicU32 = AtomicU32::new(0); /// catalog + 重启 proxy 刷新反查表 —— 因为池模式 catalog/路由依赖所有 provider,而非 /// 只 active。单模式无需(catalog/路由只看 active provider,非 active 改动到下次切换才生效)。 /// -/// 失败只在 sync 内部记录、不阻塞调用方的 CRUD 成功响应(下次 re-apply / 重启幂等补偿); -/// 关池模式时整体 no-op(连 config 都不读 settings 之外)。 +/// 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() @@ -44,9 +45,22 @@ pub(crate) async fn resync_pool_if_enabled(state: &AdminState) { ) }) .unwrap_or(false); - if pool_on { - let _ = crate::admin::services::desktop::snapshot::sync_desktop_for_active_provider(state) - .await; + 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}" + ); } } From 8c85a675c7798e09356f14531ff663ccffbea40a Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 20:25:11 +0800 Subject: [PATCH 06/31] =?UTF-8?q?feat(MOC-236):=20pooledModels=20=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20=E2=80=94=E2=80=94=20=E6=B1=A0=E5=AD=90?= =?UTF-8?q?=E4=BB=8E=E3=80=8C=E8=8E=B7=E5=8F=96=E6=A8=A1=E5=9E=8B=E3=80=8D?= =?UTF-8?q?=E6=8B=89=E5=8F=96=E7=9A=84=E5=AE=8C=E6=95=B4=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stacked 在后端引擎 PR #476 之上。加 provider / 「获取模型」(autofill)时把可用模型 id 持久化到 provider.pooledModels(按 provider 隔离,经 extra flatten);autofill **合并**而非 覆盖(保留用户手加 + 追加新上游,守 no-silent-destructive)。catalog 池化(pooled_model_ids) 优先用 pooledModels、为空才回退槽位映射 —— 即「provider 所有模型进池」。 - crud.rs: AddProviderInput.pooledModels + add/update 持久化(update 不带该字段=保留现值) - models.rs: autofill 合并写入 usable 模型 id(去 embedding/rerank) - api.js: mapProvider 暴露 + providerBody 下发 pooledModels 前端编辑流解耦(不再每次进编辑页强制 fetch)+ provider→model 级联选择 + 开关文案随后续 commit。 Refs MOC-236 --- frontend/js/api.js | 6 +++++ .../src/admin/handlers/providers/crud.rs | 13 +++++++++ .../src/admin/handlers/providers/models.rs | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/frontend/js/api.js b/frontend/js/api.js index e9f9c69a..ba9428f1 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -142,6 +142,8 @@ isBuiltin: !!provider.isBuiltin, // [MOC-173] auto-review 审查模型槽位 key(gpt_5_X);显式挑字段,不加这行前端拿不到后端返的值。 reviewModelSlot: provider.reviewModelSlot || '', + // 池化:按 provider 持久化的可选模型列表(显式挑字段,否则被这层 mapper 静默丢)。 + pooledModels: Array.isArray(provider.pooledModels) ? provider.pooledModels : [], mappings: { default: models.default || '', gpt_5_5: models.gpt_5_5 || '', @@ -211,6 +213,10 @@ if (payload.grokWeb) { body.grokWeb = payload.grokWeb; } + // 池化:带 pooledModels 才下发(数组),让后端 add/update 持久化;不带 = 后端保留现值。 + if (Array.isArray(payload.pooledModels)) { + body.pooledModels = payload.pooledModels; + } return body; } diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index 7bcfb384..163a8ef8 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -181,6 +181,11 @@ pub struct AddProviderInput { /// 回退复用主模型)。经 `Provider.extra` flatten 透传持久化为 `reviewModelSlot`。 #[serde(rename = "reviewModelSlot")] pub review_model_slot: Option, + /// 池化模式的可选模型列表(用户「获取模型」拉取 + 手加),按 provider 隔离持久化为 + /// `pooledModels`(经 `Provider.extra` flatten)。catalog 池化(`unique_pool_slugs` / + /// `pooled_model_ids`)优先用它,为空才回退槽位映射。`None` = 本次不改动该字段。 + #[serde(rename = "pooledModels")] + pub pooled_models: Option, } pub async fn add_provider( @@ -284,6 +289,10 @@ pub async fn add_provider( json!({"default":"","gpt_5_5":"","gpt_5_4":"","gpt_5_4_mini":"","gpt_5_3_codex":"","gpt_5_2":""}) }), ); + // 池化:加 provider 时若带了已抓取/手加的模型列表,持久化为 pooledModels(按 provider 隔离)。 + if let Some(pooled) = input.pooled_models.clone() { + new_provider.insert("pooledModels".into(), pooled); + } new_provider.insert( "extraHeaders".into(), input.extra_headers.clone().unwrap_or_else(|| json!({})), @@ -428,6 +437,10 @@ pub async fn update_provider( updated.insert("models".into(), Value::Object(merged)); } } + // 池化:带 pooledModels 就更新(前端把 fetched + 手加合并后整列表下发;不带 = 不动)。 + if let Some(pooled) = input.pooled_models.clone() { + updated.insert("pooledModels".into(), pooled); + } updated.insert("id".into(), Value::String(id.clone())); updated.insert("isBuiltin".into(), Value::Bool(is_builtin)); diff --git a/src-tauri/src/admin/handlers/providers/models.rs b/src-tauri/src/admin/handlers/providers/models.rs index be86d5f6..e743af91 100644 --- a/src-tauri/src/admin/handlers/providers/models.rs +++ b/src-tauri/src/admin/handlers/providers/models.rs @@ -606,6 +606,17 @@ pub async fn autofill_provider_models( .cloned() .unwrap_or_else(|| json!({})); + // 池化:把抓取到的可用模型 id 写入 pooledModels(去 embedding/rerank 等);**合并**而非 + // 覆盖 —— 保留用户手加的、追加新上游的(「重新获取只负责更新列表」,守 no-silent-destructive)。 + let fetched_pool_ids: Vec = result + .get("models") + .and_then(|v| v.as_array()) + .map(|arr| { + let ids: Vec = arr.iter().filter_map(model_id_from_item).collect(); + usable_model_ids(&ids) + }) + .unwrap_or_default(); + // 真 mutate + save 走 atomic RMW let suggested_for_closure = suggested.clone(); let write_result = with_config_write(|cfg| { @@ -616,6 +627,22 @@ pub async fn autofill_provider_models( if let Some(providers) = cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { if let Some(provider) = providers.get_mut(idx).and_then(|v| v.as_object_mut()) { provider.insert("models".into(), suggested_for_closure.clone()); + // pooledModels 合并:现有(含手加)在前,新抓取去重追加。 + let mut pooled: Vec = provider + .get("pooledModels") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let mut seen: HashSet = pooled + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(); + for id in &fetched_pool_ids { + if seen.insert(id.clone()) { + pooled.push(Value::String(id.clone())); + } + } + provider.insert("pooledModels".into(), Value::Array(pooled)); return Ok(ConfigMutation::Modified(())); } } From f66f1da979ad45160b7b762b79b2bb84bccda3b3 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 20:39:15 +0800 Subject: [PATCH 07/31] =?UTF-8?q?feat(MOC-236):=20=E6=B1=A0=E5=8C=96?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20UX=20=E2=80=94=E2=80=94=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=B5=81=E8=A7=A3=E8=80=A6=20+=20=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E6=96=87=E6=A1=88=20+=20set-default=20=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 编辑流解耦(用户要求):进 provider 编辑页直接用持久化的 pooledModels 渲染模型下拉, 不再强制网络拉取;「获取模型」仍刷新并(后端)合并 pooledModels,返回后刷新下拉。 pooledModels 空时回退原行为(下拉禁用直到获取)。antigravity 自动拉取降级为锦上添花。 - 开关文案:exposeAllProviderModels 提示改为「Codex 模型列表显示所有 provider 模型 + 代理 自动分流;切开关 / 改模型列表需重启 Codex 生效」。 - set-default 语义:池化模式下「设为默认」= 新对话 / 不带前缀请求的默认 provider, active badge / 按钮文案 + tooltip 随 poolMode 切换(关闭维持原文案)。 - 后端:status handler 返回真实 exposeAllProviderModels(此前硬编 false 的 stub),供前端读取。 i18n 补 zh+en:setPoolDefault / setPoolDefaultHint / poolDefaultBadge + 更新 modelMenu 提示。 Refs MOC-236 --- frontend/js/app.js | 65 ++++++++++++++++++++++---- frontend/js/i18n.js | 14 ++++-- src-tauri/src/admin/handlers/common.rs | 4 +- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/frontend/js/app.js b/frontend/js/app.js index fe06c423..dcbb06a0 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -716,6 +716,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,行为同改前。 @@ -913,7 +936,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 @@ -1136,7 +1166,7 @@ return null; } - function providerCardMarkup(provider) { + function providerCardMarkup(provider, poolMode = false) { const mapping = [ provider.mappings.default, provider.mappings.gpt_5_5, @@ -1156,6 +1186,11 @@ const baseUrlMarkup = docsUrl ? `${providerUrl}` : `${providerUrl}`; + // 池化模式下「设为默认」语义从「全部路由到这里」变成「新对话 / 不带前缀请求的默认 provider」, + // active badge / 启用按钮文案随之切换(关闭时维持原文案)。 + const activeBadgeText = poolMode ? t("providers.poolDefaultBadge") : t("status.active"); + const enableBtnText = poolMode ? t("providers.setPoolDefault") : t("providers.enable"); + const enableBtnTitle = poolMode ? t("providers.setPoolDefaultHint") : ""; return `
@@ -1166,9 +1201,9 @@ ${mappingText} - ${provider.default ? `${escapeHtml(t("status.active"))}` : ""} - @@ -1282,8 +1317,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("")}
`; @@ -2338,13 +2381,17 @@ !!formRequestOptions.web_search_enabled, matchedPreset?.id || null ); - 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(); } diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 2d13c94f..bda3476b 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -468,10 +468,13 @@ "providers.keyPlaceholder": "sk-...", "providers.keySavedPlaceholder": "已填入,可点眼睛查看", "providers.modelMenuTitle": "OpenAI 模型菜单", - "providers.modelMenuSingleHint": "当前只把默认提供商的模型显示到 Codex CLI。新增或改名模型后,需要重新一键应用并重启终端。", - "providers.modelMenuAllHint": "已开启全部模型。下次一键应用后,Codex CLI 会显示所有已配置提供商的模型;同步过的模型之间切换不用再回本工具切换。", + "providers.modelMenuSingleHint": "当前只把默认提供商的模型显示到 Codex 模型列表。切换此开关、或改动该提供商的模型列表后,需要重新一键应用并重启 Codex 才能生效。", + "providers.modelMenuAllHint": "已开启:Codex 模型列表显示所有 provider 的模型(由代理按模型自动分流)。切换此开关、或改动任一提供商的模型列表后,需要重新一键应用并重启 Codex 才能生效。", "providers.showAllModels": "显示全部模型", "providers.showSingleModel": "只显示当前模型", + "providers.setPoolDefault": "设为默认", + "providers.setPoolDefaultHint": "全部模型模式下:设为新对话 / 不带前缀请求的默认提供商(其他提供商的模型仍可在 Codex 模型列表里直接选)。", + "providers.poolDefaultBadge": "默认", "models.title": "模型映射", "models.subtitle": "为每个提供商配置模型别名", "models.provider": "提供商", @@ -1192,10 +1195,13 @@ "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.modelMenuSingleHint": "Only the default provider's models are shown in the Codex model list. Toggling this switch, or changing this provider's model list, requires re-applying and restarting Codex to take effect.", + "providers.modelMenuAllHint": "On: the Codex model list shows every provider's models (the proxy auto-routes each model). Toggling this switch, or changing any provider's model list, requires re-applying and restarting Codex to take effect.", "providers.showAllModels": "Show All Models", "providers.showSingleModel": "Show Current Only", + "providers.setPoolDefault": "Set as Default", + "providers.setPoolDefaultHint": "In all-model mode: makes this the default provider for new chats / unprefixed requests (other providers' models can still be picked directly from the Codex model list).", + "providers.poolDefaultBadge": "Default", "models.title": "Model Mapping", "models.subtitle": "Configure model aliases for each provider", "models.provider": "Provider", diff --git a/src-tauri/src/admin/handlers/common.rs b/src-tauri/src/admin/handlers/common.rs index 4c4fe847..ae837fc8 100644 --- a/src-tauri/src/admin/handlers/common.rs +++ b/src-tauri/src/admin/handlers/common.rs @@ -179,7 +179,9 @@ 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), })) .into_response() } From b4eda601cf7c246ea995db69bd8eeeb17f490eec Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 20:51:03 +0800 Subject: [PATCH 08/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=202?= =?UTF-8?q?=20=E6=9D=A1=20P2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - providerPayloadFromForm 带 pooledModels(当前下拉列表 ids,去重):fetch-form-models 后 保存也持久化完整列表,不再静默回退槽位(满足「添加 provider 时记录已获取模型」) - pooledModels 用新 chat_usable_model_ids(no-fallback 过滤):provider 只返 embedding/ rerank/语音 等时返回空、不把非 chat 模型写进池(否则进 Codex chat picker 把 chat 路由到 不支持的端点);usable_model_ids 保留 fallback 供映射下拉用 Refs MOC-236 --- frontend/js/app.js | 15 +++++++ .../src/admin/handlers/providers/models.rs | 44 ++++++++++++------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/frontend/js/app.js b/frontend/js/app.js index dcbb06a0..6b13d819 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1136,6 +1136,21 @@ } if (includeModels) { payload.models = mappings; + // 池化:把当前下拉里的模型列表(进编辑页 seed 自持久化 pooledModels、点「获取模型」后 + // 刷新/合并过)一并作为 pooledModels 持久化 —— 否则 fetch-form-models + 保存后池子拿不到 + // 完整列表、回退槽位(bot review P2;也满足「添加 provider 时记录已获取模型」)。 + // 空(从未获取且无持久化)→ 不下发,后端保留现值。 + const pooled = [ + ...new Set( + providerAvailableModels + .map(modelEntryId) + .map((s) => String(s || "").trim()) + .filter(Boolean) + ), + ]; + if (pooled.length) { + payload.pooledModels = pooled; + } } // R1 PR-7:apiFormat=grok_web 时打包 extra.grokWeb(cookies + statsigId)。 // Provider 后端 schema 用 `#[serde(flatten)] extra`,任何不在已知字段的 key diff --git a/src-tauri/src/admin/handlers/providers/models.rs b/src-tauri/src/admin/handlers/providers/models.rs index e743af91..f46d3f54 100644 --- a/src-tauri/src/admin/handlers/providers/models.rs +++ b/src-tauri/src/admin/handlers/providers/models.rs @@ -210,26 +210,38 @@ 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 请求。 +const NON_CHAT_MODEL_KEYWORDS: &[&str] = &[ + "embedding", + "rerank", + "moderation", + "whisper", + "tts", + "image", + "vision", + "audio", +]; + +/// 只保留可用于 chat 的模型 id(过滤 embedding/rerank/语音/图像);**全被过滤则返回空** +/// (不 fallback 原列表)。池化 pooledModels 用它 —— 否则只含 embedding 的 provider 会把 +/// 这些模型写进池、出现在 Codex chat picker 并把 chat 请求路由到不支持的端点(bot review P2)。 +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(); + .collect() +} + +fn usable_model_ids(model_ids: &[String]) -> Vec { + let usable = chat_usable_model_ids(model_ids); if usable.is_empty() { + // suggest_model_mappings 等场景:全被过滤时退回原列表,保证映射下拉不空。 model_ids.to_vec() } else { usable @@ -613,7 +625,9 @@ pub async fn autofill_provider_models( .and_then(|v| v.as_array()) .map(|arr| { let ids: Vec = arr.iter().filter_map(model_id_from_item).collect(); - usable_model_ids(&ids) + // 池化用 no-fallback 过滤:全是 embedding/rerank 等时返回空,不把非 chat 模型写进池 + // (否则出现在 Codex chat picker 并把 chat 路由到不支持的端点,bot review P2)。 + chat_usable_model_ids(&ids) }) .unwrap_or_default(); From cbfbc5b5585407474a5f6e48678a5b30ea98c4f4 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 21:10:27 +0800 Subject: [PATCH 09/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-2(pooledModels=20chat=20=E8=BF=87=E6=BB=A4=20+=20=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=E6=89=8B=E8=BE=93=20mappings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端单一过滤真源:新增 chat_filter_pooled_value,add/update provider 持久化 pooledModels 前统一 chat-only 过滤(覆盖表单保存路径;embedding/rerank 等非 chat 模型不进池) - 前端 providerPayloadFromForm 的 pooledModels 并入当前 mappings 值(含用户手输的新模型 id), 否则新模型不进池、且旧 seed 出来的 pooledModels 反而盖掉它(registry 优先 pooledModels) Refs MOC-236 --- frontend/js/app.js | 15 +++++++----- .../src/admin/handlers/providers/crud.rs | 14 +++++++---- .../src/admin/handlers/providers/models.rs | 23 ++++++++++++++++++- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/frontend/js/app.js b/frontend/js/app.js index 6b13d819..4c71618f 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1136,14 +1136,17 @@ } if (includeModels) { payload.models = mappings; - // 池化:把当前下拉里的模型列表(进编辑页 seed 自持久化 pooledModels、点「获取模型」后 - // 刷新/合并过)一并作为 pooledModels 持久化 —— 否则 fetch-form-models + 保存后池子拿不到 - // 完整列表、回退槽位(bot review P2;也满足「添加 provider 时记录已获取模型」)。 - // 空(从未获取且无持久化)→ 不下发,后端保留现值。 + // 池化列表 = 当前下拉(进编辑页 seed 自持久化 pooledModels、点「获取模型」后刷新/合并过) + // ∪ 当前 mappings 值(含用户**手输**的新模型 id)。必须并入 mappings:否则手输的新模型 + // 不进池,且旧 seed 出来的 pooledModels 反而盖掉它(registry 优先 pooledModels)→ 新模型 + // 永不出现(bot review P2)。chat-only 过滤在后端 chat_filter_pooled_value 统一做。 + // 空(从未获取 + 无持久化 + 无映射)→ 不下发,后端保留现值。 const pooled = [ ...new Set( - providerAvailableModels - .map(modelEntryId) + [ + ...providerAvailableModels.map(modelEntryId), + ...Object.values(mappings || {}), + ] .map((s) => String(s || "").trim()) .filter(Boolean) ), diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index 163a8ef8..c9ad22ee 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -289,9 +289,12 @@ pub async fn add_provider( json!({"default":"","gpt_5_5":"","gpt_5_4":"","gpt_5_4_mini":"","gpt_5_3_codex":"","gpt_5_2":""}) }), ); - // 池化:加 provider 时若带了已抓取/手加的模型列表,持久化为 pooledModels(按 provider 隔离)。 + // 池化:加 provider 时带了已抓取/手加的模型列表 → chat-only 过滤后持久化为 pooledModels。 if let Some(pooled) = input.pooled_models.clone() { - new_provider.insert("pooledModels".into(), pooled); + new_provider.insert( + "pooledModels".into(), + super::models::chat_filter_pooled_value(&pooled), + ); } new_provider.insert( "extraHeaders".into(), @@ -437,9 +440,12 @@ pub async fn update_provider( updated.insert("models".into(), Value::Object(merged)); } } - // 池化:带 pooledModels 就更新(前端把 fetched + 手加合并后整列表下发;不带 = 不动)。 + // 池化:带 pooledModels 就更新(chat-only 过滤;前端把 fetched + mappings 合并下发;不带=不动)。 if let Some(pooled) = input.pooled_models.clone() { - updated.insert("pooledModels".into(), pooled); + updated.insert( + "pooledModels".into(), + super::models::chat_filter_pooled_value(&pooled), + ); } updated.insert("id".into(), Value::String(id.clone())); updated.insert("isBuiltin".into(), Value::Bool(is_builtin)); diff --git a/src-tauri/src/admin/handlers/providers/models.rs b/src-tauri/src/admin/handlers/providers/models.rs index f46d3f54..3ce8e07c 100644 --- a/src-tauri/src/admin/handlers/providers/models.rs +++ b/src-tauri/src/admin/handlers/providers/models.rs @@ -225,7 +225,7 @@ const NON_CHAT_MODEL_KEYWORDS: &[&str] = &[ /// 只保留可用于 chat 的模型 id(过滤 embedding/rerank/语音/图像);**全被过滤则返回空** /// (不 fallback 原列表)。池化 pooledModels 用它 —— 否则只含 embedding 的 provider 会把 /// 这些模型写进池、出现在 Codex chat picker 并把 chat 请求路由到不支持的端点(bot review P2)。 -fn chat_usable_model_ids(model_ids: &[String]) -> Vec { +pub(crate) fn chat_usable_model_ids(model_ids: &[String]) -> Vec { model_ids .iter() .filter(|model_id| { @@ -238,6 +238,27 @@ fn chat_usable_model_ids(model_ids: &[String]) -> Vec { .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 usable_model_ids(model_ids: &[String]) -> Vec { let usable = chat_usable_model_ids(model_ids); if usable.is_empty() { From c3bf82b21c4567b4e7a8bfe2d2af16509102ad3f Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 21:29:19 +0800 Subject: [PATCH 10/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-3(=E6=B1=A0=E5=90=88=E5=B9=B6=E6=B0=B8=E4=B8=8D=E6=94=B6?= =?UTF-8?q?=E7=BC=A9=20+=20=E4=B8=8D=E6=8E=A8=E8=8D=90=20embedding=20defau?= =?UTF-8?q?lt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update_provider 改为**合并** pooledModels(现有 ∪ 新增、去重、永不收缩):防表单 fetch 失败 / 部分态时易失的下拉缓存把已持久化的完整池删剩 1-5 个映射值(bot review P2) - suggest_model_mappings 改用 chat_usable_model_ids(no-fallback):只含 embedding/rerank 的 provider 不再被自动推荐非 chat 模型当 default,避免「pooledModels 空 → 回退槽位映射」把该 embedding 漏进 Codex chat picker - 移除随之 dead 的 usable_model_ids Refs MOC-236 --- .../src/admin/handlers/providers/crud.rs | 29 +++++++++++++++---- .../src/admin/handlers/providers/models.rs | 16 ++++------ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index c9ad22ee..87135feb 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -440,12 +440,31 @@ pub async fn update_provider( updated.insert("models".into(), Value::Object(merged)); } } - // 池化:带 pooledModels 就更新(chat-only 过滤;前端把 fetched + mappings 合并下发;不带=不动)。 + // 池化:带 pooledModels 就**合并**进现有(chat-only 过滤;现有 ∪ 新增,去重,**永不收缩**)。 + // 合并而非替换是关键(bot review P2):表单保存的 incoming 源自易失的下拉缓存,fetch 失败 / + // 部分态会让它只剩 1-5 个映射值;若替换就会把已持久化的完整池删剩映射。合并兜底:既保留 + // 用户手加 / 已抓取的,又纳入本次映射里的新模型。不带该字段 = 完全不动。 if let Some(pooled) = input.pooled_models.clone() { - updated.insert( - "pooledModels".into(), - super::models::chat_filter_pooled_value(&pooled), - ); + let incoming = super::models::chat_filter_pooled_value(&pooled); + let mut merged: Vec = existing + .get("pooledModels") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let mut seen: std::collections::HashSet = merged + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(); + if let Some(arr) = incoming.as_array() { + for v in arr { + if let Some(s) = v.as_str() { + if seen.insert(s.to_owned()) { + merged.push(v.clone()); + } + } + } + } + updated.insert("pooledModels".into(), Value::Array(merged)); } updated.insert("id".into(), Value::String(id.clone())); updated.insert("isBuiltin".into(), Value::Bool(is_builtin)); diff --git a/src-tauri/src/admin/handlers/providers/models.rs b/src-tauri/src/admin/handlers/providers/models.rs index 3ce8e07c..d6a285e8 100644 --- a/src-tauri/src/admin/handlers/providers/models.rs +++ b/src-tauri/src/admin/handlers/providers/models.rs @@ -259,16 +259,6 @@ pub(crate) fn chat_filter_pooled_value(pooled: &Value) -> Value { ) } -fn usable_model_ids(model_ids: &[String]) -> Vec { - let usable = chat_usable_model_ids(model_ids); - if usable.is_empty() { - // suggest_model_mappings 等场景:全被过滤时退回原列表,保证映射下拉不空。 - model_ids.to_vec() - } else { - usable - } -} - fn pick_model(model_ids: &[String], keywords: &[&str], fallback_index: usize) -> String { for keyword in keywords { for model_id in model_ids { @@ -293,7 +283,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; From 9aa0bcd7827401ff7355036931eb565409105a66 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 21:36:08 +0800 Subject: [PATCH 11/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-4(=E6=98=A0=E5=B0=84=E9=A1=B5=E4=BF=9D=E5=AD=98=E4=B9=9F?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=20pooledModels)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_models(Model Mapping 页 PUT /models)此前只写 models;池模式偏好非空 pooledModels, 导致映射页改/手输的新模型持久化了却不进池、不出现/不路由(bot review P2)。现在该 provider 已在用池(pooledModels 非空)时,把映射值 chat 过滤后并入池(去重、永不收缩);空池无需(走 mappings fallback)。 Refs MOC-236 --- .../src/admin/handlers/providers/crud.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index 87135feb..5cdcc691 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -679,6 +679,36 @@ pub async fn update_models( .unwrap(); if let Some(o) = providers[idx].as_object_mut() { o.insert("models".into(), input.models.clone()); + // 池化:若该 provider 已在用池(pooledModels 非空),把映射页编辑的模型值并进池 + // (chat 过滤、去重、永不收缩)。否则池模式偏好非空 pooledModels、映射页改/手输的 + // 新模型永不出现(bot review P2)。空池时无需(走 mappings fallback)。 + let mut pooled: Vec = o + .get("pooledModels") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if !pooled.is_empty() { + let mapping_ids: Vec = input + .models + .as_object() + .map(|m| { + m.values() + .filter_map(|v| v.as_str().map(|s| s.trim().to_owned())) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + let mut seen: std::collections::HashSet = pooled + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(); + for id in super::models::chat_usable_model_ids(&mapping_ids) { + if seen.insert(id.clone()) { + pooled.push(Value::String(id)); + } + } + o.insert("pooledModels".into(), Value::Array(pooled)); + } } Ok(ConfigMutation::Modified(was_active)) }); From 8876ee121d0dedb7485c18b3018e499b9a324375 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 21:46:22 +0800 Subject: [PATCH 12/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-5(upstream=20=E8=BA=AB=E4=BB=BD=E5=8F=98=E6=9B=B4=E6=B8=85?= =?UTF-8?q?=E7=A9=BA=20stale=20=E6=B1=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_provider:当 baseUrl / apiFormat 与原值不同(= 指向不同上游)时清空 pooledModels — 旧池属旧端点,不再随「永不收缩」合并保留,避免 stale slug 路由到新上游不存在的模型(bot review P2)。同一上游的编辑仍合并永不收缩(防 fetch 失败/部分态删池 + 保留手加)。apiKey 变更 视作同上游再鉴权、不算身份变更。 Refs MOC-236 --- src-tauri/src/admin/handlers/providers/crud.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index 5cdcc691..15db531c 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -440,11 +440,19 @@ pub async fn update_provider( updated.insert("models".into(), Value::Object(merged)); } } - // 池化:带 pooledModels 就**合并**进现有(chat-only 过滤;现有 ∪ 新增,去重,**永不收缩**)。 - // 合并而非替换是关键(bot review P2):表单保存的 incoming 源自易失的下拉缓存,fetch 失败 / - // 部分态会让它只剩 1-5 个映射值;若替换就会把已持久化的完整池删剩映射。合并兜底:既保留 - // 用户手加 / 已抓取的,又纳入本次映射里的新模型。不带该字段 = 完全不动。 - if let Some(pooled) = input.pooled_models.clone() { + // 池化 pooledModels 维护: + // - **upstream 身份变了**(baseUrl / apiFormat 与原值不同)→ 旧池属旧端点,清空 + // pooledModels(回退新映射,等用户对新 upstream「获取模型」重建池)。incoming 此时 + // 多半还是旧缓存,一并丢弃(bot review P2)。 + // - 同一 upstream + 带 pooledModels → **合并**进现有(chat 过滤、去重、**永不收缩**): + // 表单 incoming 源自易失下拉缓存,fetch 失败/部分态只剩 1-5 个映射值,替换会把已持久化 + // 的完整池删剩映射;合并既保留手加/已抓取又纳入本次映射新模型(bot review P2)。 + // - 同一 upstream + 不带该字段 → 完全不动。 + let identity_changed = existing.get("baseUrl") != updated.get("baseUrl") + || existing.get("apiFormat") != updated.get("apiFormat"); + if identity_changed { + updated.remove("pooledModels"); + } else if let Some(pooled) = input.pooled_models.clone() { let incoming = super::models::chat_filter_pooled_value(&pooled); let mut merged: Vec = existing .get("pooledModels") From 93169569d98f22ee8a287616474d32159e43b817 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 14 Jun 2026 21:52:31 +0800 Subject: [PATCH 13/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-6(apiKey=20=E5=8F=98=E6=9B=B4=E4=B9=9F=E6=B8=85=E7=A9=BA=20s?= =?UTF-8?q?tale=20=E6=B1=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_provider 身份变更判定加入 apiKey:换 key 常意味换账号,旧账号可见模型未必新 key 可用, stale slug 会拿新 key 路由 → invalid-model/权限错(bot review P2)。比对 existing vs updated 的 apiKey,用户没重填 key 时前端不下发、updated 沿用旧值 → 相等不误清。 Refs MOC-236 --- src-tauri/src/admin/handlers/providers/crud.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index 15db531c..b8346c81 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -448,8 +448,13 @@ pub async fn update_provider( // 表单 incoming 源自易失下拉缓存,fetch 失败/部分态只剩 1-5 个映射值,替换会把已持久化 // 的完整池删剩映射;合并既保留手加/已抓取又纳入本次映射新模型(bot review P2)。 // - 同一 upstream + 不带该字段 → 完全不动。 + // 身份 = baseUrl / apiFormat / apiKey。apiKey 变更也算(bot review P2):换 key 常意味换 + // 账号,旧账号可见的模型未必新 key 可用,stale slug 会拿新 key 路由 → invalid-model/权限 + // 错。比对 existing vs updated 的 apiKey:用户没重填 key → 前端不下发 → updated 沿用旧值 + // → 相等不触发,正常编辑不误清;真改了 key 才清。 let identity_changed = existing.get("baseUrl") != updated.get("baseUrl") - || existing.get("apiFormat") != updated.get("apiFormat"); + || existing.get("apiFormat") != updated.get("apiFormat") + || existing.get("apiKey") != updated.get("apiKey"); if identity_changed { updated.remove("pooledModels"); } else if let Some(pooled) = input.pooled_models.clone() { From 05919172bcc771dd60365c57e4d481d510e0f355 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 00:14:15 +0800 Subject: [PATCH 14/31] =?UTF-8?q?feat(MOC-236):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=B1=A0=E5=AD=90=E9=9B=86=E5=8C=96=20+=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=20curation=20=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unique_pool_slugs 仅纳入 pooledEnabled==true 的 provider(整合页「手动添加的子集」) - 新增 PUT /api/providers/{id}/pool:enabled 加入/移出整合、models 权威替换可选模型列表 - 池为空时退回单 provider catalog 并 loud log(POOL_EMPTY_FELL_BACK_TO_SINGLE) - models 非数组拒为 400、provider 元素损坏拒为 500(不静默 no-op 报 success) - 回归测试:active provider 未加入整合时池非空 + default_slug=None --- crates/registry/src/lib.rs | 4 +- crates/registry/src/model_alias.rs | 42 +++++++++- .../src/admin/handlers/providers/crud.rs | 64 ++++++++++++++++ src-tauri/src/admin/mod.rs | 4 + .../src/admin/services/desktop/snapshot.rs | 76 +++++++++++++++++-- 5 files changed, 180 insertions(+), 10 deletions(-) diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index d151abbe..984e6ea8 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -33,8 +33,8 @@ pub use healing::heal_legacy_update_url; pub use model_alias::{ build_catalog_slug_map, empty_model_mappings, has_internal_one_m_suffix, normalize_model_mappings, openai_model_slot, pooled_model_ids, pooled_models_with_one_m, - provider_slug, strip_internal_model_suffix, unique_pool_slugs, PoolEntry, MODEL_ORDER, - MODEL_SLOTS, POOL_SLUG_SEPARATOR, + 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 7733983e..be9a3f67 100644 --- a/crates/registry/src/model_alias.rs +++ b/crates/registry/src/model_alias.rs @@ -267,8 +267,20 @@ pub fn pooled_model_ids(pooled_models: Option<&Value>, models: Option<&Value>) - .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; @@ -276,7 +288,10 @@ pub fn pooled_model_ids(pooled_models: Option<&Value>, models: Option<&Value>) - /// - `provider_idx` 始终是**原始切片**下标,方便两端各自索引自己的数据; /// - 全局 slug 去重(同 provider 内重复模型 / 跨 provider 撞全名都只保留首次)。 pub fn unique_pool_slugs(providers: &[crate::Provider]) -> Vec { - let mut order: Vec = (0..providers.len()).collect(); + // 只处理已加入整合的 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 @@ -434,6 +449,10 @@ 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(), @@ -447,10 +466,29 @@ mod tests { request_options: IndexMap::new(), is_builtin: false, sort_index: 0, - extra: IndexMap::new(), + extra, } } + #[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 pooled_model_ids_prefers_pooled_models_list() { // pooledModels 非空 → 用它;strip [1m];去重;忽略槽位映射 diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index b8346c81..49361a57 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -559,6 +559,70 @@ pub async fn delete_provider( } } +#[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(), + } +} + pub async fn set_default_provider( State(state): State, Path(id): Path, diff --git a/src-tauri/src/admin/mod.rs b/src-tauri/src/admin/mod.rs index b702db9d..16efb844 100644 --- a/src-tauri/src/admin/mod.rs +++ b/src-tauri/src/admin/mod.rs @@ -55,6 +55,10 @@ 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/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 d5a034f1..7baee27e 100644 --- a/src-tauri/src/admin/services/desktop/snapshot.rs +++ b/src-tauri/src/admin/services/desktop/snapshot.rs @@ -233,7 +233,17 @@ pub fn desktop_config_target_for_provider( 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 => (None, None), + None => { + // 整合开关已开却无可池条目(最常见:刚翻开关、还没把任何 provider「加入整合」, + // 或加入的 provider 没有可池模型)。此时静默退回单 active provider catalog —— + // 必须 loud log,否则用户看「整合已开 + 应用锁定」却只看到单 provider 模型,无从排查 + // (守 no-silent-degradation)。前端整合页下池另有「还没加入提供商」引导。 + tracing::warn!( + error_id = "POOL_EMPTY_FELL_BACK_TO_SINGLE", + "整合开关已开但池为空(无 provider 加入整合 / 加入的无可池模型),退回单 active provider catalog" + ); + (None, None) + } } } else { (None, None) @@ -891,13 +901,15 @@ mod tests { { "id": "deepseek", "name": "DeepSeek", "baseUrl": "https://a.example/v1", "apiFormat": "openai_chat", - "apiKey": "k1", "models": {"default": "deepseek-v4-pro"}, "sortIndex": 0 + "apiKey": "k1", "models": {"default": "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", "kimi-for-coding"], "sortIndex": 1 + "pooledModels": ["kimi-k2.6", "kimi-for-coding"], + "pooledEnabled": true, "sortIndex": 1 } ], "settings": { @@ -944,6 +956,58 @@ mod tests { assert!(target_off.pool.is_none(), "toggle 关 → pool=None"); } + #[test] + fn pool_mode_excludes_active_provider_not_in_integration() { + // 子集语义新引入的运行态:整合开 + active provider(deepseek)未「加入整合」 + // (无 pooledEnabled),仅 kimi 加入 → 池非空(只含 kimi),但 active 不在池里 + // → pool_default_slug=None。这是本次改动新可达的状态(以前全 provider 都入池, + // active 必有条目、default_slug 必 Some);apply 层据此退回 catalog 首条锚定,picker 不空。 + 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"}, + "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", "kimi-for-coding"], + "pooledEnabled": true, "sortIndex": 1 + } + ], + "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("kimi 加入整合 → pool 应为 Some"); + let slugs: Vec<&str> = pool.iter().map(|m| m.slug.as_str()).collect(); + assert!(slugs.contains(&"kimi/kimi-k2.6"), "{slugs:?}"); + assert!(slugs.contains(&"kimi/kimi-for-coding"), "{slugs:?}"); + assert!( + !slugs.iter().any(|s| s.starts_with("deepseek/")), + "active 但未加入整合的 deepseek 不应在池: {slugs:?}" + ); + assert!(!pool.is_empty(), "池非空 → picker 不空"); + assert!( + target.pool_default_slug.is_none(), + "active 不在整合子集 → pool_default_slug=None, got {:?}", + target.pool_default_slug + ); + } + #[test] fn pool_catalog_slugs_match_resolver_map_keys_byte_for_byte() { // **最高severity invariant 守门**:catalog 生成端(snapshot::build_pool_catalog)与 @@ -959,12 +1023,12 @@ mod tests { "gatewayApiKey": "cas_test", "providers": [ {"id":"acme","name":"Acme One","baseUrl":"https://a/v1","apiFormat":"openai_chat", - "apiKey":"k","models":{"default":"qna-v1"},"sortIndex":0}, + "apiKey":"k","models":{"default":"qna-v1"},"pooledEnabled":true,"sortIndex":0}, {"id":"ACME","name":"Acme Two","baseUrl":"https://b/v1","apiFormat":"openai_chat", - "apiKey":"k","models":{"default":"qna-v2"},"sortIndex":1}, + "apiKey":"k","models":{"default":"qna-v2"},"pooledEnabled":true,"sortIndex":1}, {"id":"kimi","name":"Kimi","baseUrl":"https://c/v1","apiFormat":"openai_chat", "apiKey":"k","models":{"default":"kimi-k2.6"}, - "pooledModels":["kimi-k2.6","kimi-for-coding"],"sortIndex":2} + "pooledModels":["kimi-k2.6","kimi-for-coding"],"pooledEnabled":true,"sortIndex":2} ], "settings": {"theme":"default","language":"zh","proxyPort":18080,"adminPort":18081, "autoStart":false,"autoApplyOnStart":true,"exposeAllProviderModels":true, From 5545e60d9457447ff598599a0b10c7698d19247f Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 00:14:28 +0800 Subject: [PATCH 15/31] =?UTF-8?q?feat(MOC-236):=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E5=95=86=E9=A1=B5=E5=8F=8C=E5=8D=A1=E6=B1=A0?= =?UTF-8?q?=20UI(=E5=AD=90=E9=9B=86=E9=80=89=E6=8B=A9=20+=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20curation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 第二页改造为「整合提供商」页:右上角整合开关,上池选要整合的 provider、下池按 provider 分组增删可选模型 - 加入整合后自动获取该 provider 模型并列入下池;支持手动加 / 删模型、重新获取 - 整合开启时锁定控制台页与提供商编辑页的「应用 / 启用」按钮 + 顶部提示,避免与模型池冲突 - api.js 暴露 pooledEnabled + setProviderPool;清理被替换面板的死 i18n key --- frontend/css/pages/dashboard.css | 3 +- frontend/css/pages/providers.css | 228 ++++++++++++++++++++++++- frontend/index.html | 65 +++++--- frontend/js/api.js | 12 ++ frontend/js/app.js | 276 +++++++++++++++++++++++++++---- frontend/js/i18n.js | 70 ++++++-- 6 files changed, 572 insertions(+), 82 deletions(-) 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..940c7676 100644 --- a/frontend/css/pages/providers.css +++ b/frontend/css/pages/providers.css @@ -475,27 +475,239 @@ border-top: 1px solid var(--line); } -.model-menu-mode-panel { - display: flex; +/* ── 整合提供商页(模型池):页头开关 + off 提示 + 上下两卡池 ───────────── */ +.integration-title-row .integration-toggle { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 14px; - margin-bottom: 12px; - padding: 14px 18px; + 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; } -.model-menu-mode-panel h2 { +.pool-panel-header { + margin-bottom: 14px; +} + +.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; +} + +/* 整合模式锁定提示(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 1815a1e8..03402c8d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -64,6 +64,13 @@
+
@@ -273,8 +280,15 @@

模型映射

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

+
- + 取消
@@ -290,35 +304,42 @@

快捷预设

-
+
-

提供商

-

管理已配置的 API 提供商

+

整合提供商

+

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

- 添加提供商 +
-
diff --git a/frontend/js/api.js b/frontend/js/api.js index ba9428f1..1f3649f2 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -144,6 +144,9 @@ reviewModelSlot: provider.reviewModelSlot || '', // 池化:按 provider 持久化的可选模型列表(显式挑字段,否则被这层 mapper 静默丢)。 pooledModels: Array.isArray(provider.pooledModels) ? provider.pooledModels : [], + // 整合页子集开关:该 provider 是否参与模型池。默认 false —— 子集语义, + // 只有用户在整合页显式「添加」的 provider 才进池(与后端 provider_pooled_enabled 默认一致)。 + pooledEnabled: provider.pooledEnabled === true, mappings: { default: models.default || '', gpt_5_5: models.gpt_5_5 || '', @@ -317,6 +320,15 @@ 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); + }, + 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 4c71618f..2ebcf6a8 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1204,11 +1204,14 @@ const baseUrlMarkup = docsUrl ? `${providerUrl}` : `${providerUrl}`; - // 池化模式下「设为默认」语义从「全部路由到这里」变成「新对话 / 不带前缀请求的默认 provider」, - // active badge / 启用按钮文案随之切换(关闭时维持原文案)。 + // 整合(池化)模式下:统一由模型池管理,单 provider 的「启用 / 应用」锁定避免冲突 + // (用户指示)—— set-default 按钮换成 disabled 锁定态;active badge 仍标「默认」。 const activeBadgeText = poolMode ? t("providers.poolDefaultBadge") : t("status.active"); - const enableBtnText = poolMode ? t("providers.setPoolDefault") : t("providers.enable"); - const enableBtnTitle = poolMode ? t("providers.setPoolDefaultHint") : ""; + const enableBtn = poolMode + ? `` + : ``; return `
@@ -1220,9 +1223,7 @@ ${mappingText} ${provider.default ? `${escapeHtml(activeBadgeText)}` : ""} - + ${enableBtn} @@ -1704,6 +1705,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) { @@ -2417,6 +2421,7 @@ async function renderProviderForm() { await renderPresets(); + await applyIntegrationLockToForm(); if (editingProviderId) { await fillProviderForEdit(editingProviderId); return; @@ -2429,32 +2434,146 @@ 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; + try { + enabled = !!(await CCApi.getSettings()).exposeAllProviderModels; + } 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); + 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"); + // 上池:把已配置的 provider 当候选,加入(pooledEnabled)/移出整合子集。 + function renderPoolProviderGrid(providers) { + const grid = $("#poolProviderGrid"); + if (!grid) return; + if (!providers.length) { + grid.innerHTML = `

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

`; + return; } - if (hint) { - hint.textContent = enabled ? t("providers.modelMenuAllHint") : t("providers.modelMenuSingleHint"); + grid.innerHTML = providers.map((p) => poolProviderCardMarkup(p)).join(""); + } + + function poolProviderCardMarkup(provider) { + const id = escapeHtml(provider.id); + const name = escapeHtml(provider.name); + const added = provider.pooledEnabled === true; + const count = Array.isArray(provider.pooledModels) ? provider.pooledModels.length : 0; + 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; } - const settingToggle = $("#exposeAllProviderModels"); - if (settingToggle) settingToggle.checked = enabled; + wrap.innerHTML = integrated.map((p) => poolModelGroupMarkup(p)).join(""); } - async function renderModelMenuModePanel() { - const settings = await CCApi.getSettings(); - renderModelMenuModeState(settings); + function poolModelGroupMarkup(provider) { + const id = escapeHtml(provider.id); + const name = escapeHtml(provider.name); + const models = Array.isArray(provider.pooledModels) ? provider.pooledModels : []; + const chips = models.length + ? models.map((m) => poolModelChipMarkup(provider.id, m)).join("") + : `

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

`; + return ` +
+
+ + ${name} + +
+
${chips}
+
+ + +
+
+ `; + } + + function poolModelChipMarkup(providerId, model) { + const m = escapeHtml(model); + const pid = escapeHtml(providerId); + return ` + + ${m} + + + `; + } + + // 加入整合后自动获取该 provider 的模型(依次);失败不回滚「加入」状态 —— + // provider 已入池,模型可稍后「重新获取」或手动添加(守 no-silent-destructive)。 + async function autofillPoolProviderModels(providerId) { + try { + await CCApi.autofillProviderModels(providerId); + } catch (e) { + console.warn(`[integration] 自动获取模型失败 ${providerId}:`, e); + showToast(formatModelFetchError(e)); + } + } + + // 整合开关同步: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() { @@ -3874,12 +3993,87 @@ 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")); + // ── 整合页模型池操作 ──────────────────────────────────────────────── + if (action === "pool-add-provider") { + const pid = actionEl.dataset.id; + actionEl.disabled = true; + try { + await CCApi.setProviderPool(pid, { enabled: true }); + showToast(t("toast.integrationProviderAdded")); + // 加入整合后自动获取该 provider 的模型(用户要求)。 + await autofillPoolProviderModels(pid); + 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 { + await CCApi.autofillProviderModels(pid); + 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); + const models = Array.isArray(provider?.pooledModels) ? provider.pooledModels.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); + const models = (Array.isArray(provider?.pooledModels) ? provider.pooledModels : []).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") { @@ -8586,6 +8780,24 @@ $("[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(); + }); $("#showGrayProviders")?.addEventListener("change", async () => { // MOC-91:更新展示过滤缓存 + 持久化。设置页当前不展示 preset,无需即时重渲染; // 下次进「添加 provider」/ dashboard 时 visiblePresets() 即按新值过滤。 diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index bda3476b..9f78d9b2 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -467,14 +467,28 @@ "providers.empty": "还没有提供商,先从预设添加一个。", "providers.keyPlaceholder": "sk-...", "providers.keySavedPlaceholder": "已填入,可点眼睛查看", - "providers.modelMenuTitle": "OpenAI 模型菜单", - "providers.modelMenuSingleHint": "当前只把默认提供商的模型显示到 Codex 模型列表。切换此开关、或改动该提供商的模型列表后,需要重新一键应用并重启 Codex 才能生效。", - "providers.modelMenuAllHint": "已开启:Codex 模型列表显示所有 provider 的模型(由代理按模型自动分流)。切换此开关、或改动任一提供商的模型列表后,需要重新一键应用并重启 Codex 才能生效。", - "providers.showAllModels": "显示全部模型", - "providers.showSingleModel": "只显示当前模型", - "providers.setPoolDefault": "设为默认", - "providers.setPoolDefaultHint": "全部模型模式下:设为新对话 / 不带前缀请求的默认提供商(其他提供商的模型仍可在 Codex 模型列表里直接选)。", "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.integrationAddProvider": "加入整合", + "providers.integrationRemoveProvider": "移出整合", + "providers.integrationFetchModels": "重新获取模型", + "providers.integrationFetchingModels": "正在获取模型...", + "providers.integrationNoProviders": "还没有提供商。先到控制台添加提供商,再回到这里整合。", + "providers.integrationNoIntegrated": "还没有加入任何提供商。从上方选择要整合的提供商。", + "providers.integrationNoModels": "暂无可选模型。点「重新获取模型」拉取,或在提供商编辑页手动添加。", + "providers.integrationRemoveModel": "移出该模型", + "providers.integrationAddModelPlaceholder": "手动添加模型 id", + "providers.integrationAddModel": "添加", + "providers.integrationApplyLockedTitle": "整合模式已开启", + "providers.integrationApplyLockedHint": "整合模式下由统一模型池管理,单提供商「应用 / 启用」已锁定,避免与模型池冲突。如需单独应用,请先关闭提供商整合。", "models.title": "模型映射", "models.subtitle": "为每个提供商配置模型别名", "models.provider": "提供商", @@ -713,8 +727,11 @@ "toast.configExported": "配置已导出", "toast.configImported": "配置已导入", "toast.configImportFailed": "配置导入失败", - "toast.allModelsEnabled": "已开启全部模型显示,请重新一键应用并重启终端", - "toast.singleModelEnabled": "已切回只显示当前模型,请重新一键应用并重启终端", + "toast.integrationEnabled": "已开启提供商整合,请重新一键应用并重启 Codex", + "toast.integrationDisabled": "已关闭提供商整合,已切回单提供商模式", + "toast.integrationProviderAdded": "已加入整合,正在获取模型...", + "toast.integrationProviderRemoved": "已移出整合", + "toast.integrationModelsUpdated": "模型池已更新,请重新一键应用并重启 Codex", "toast.compatibilityChecked": "兼容性检查完成", "toast.requestFailed": "操作失败,请查看后端日志", "confirm.desktopApply": "即将生成 Codex CLI 环境变量配置命令并复制到剪贴板。确认继续?", @@ -1194,14 +1211,28 @@ "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's models are shown in the Codex model list. Toggling this switch, or changing this provider's model list, requires re-applying and restarting Codex to take effect.", - "providers.modelMenuAllHint": "On: the Codex model list shows every provider's models (the proxy auto-routes each model). Toggling this switch, or changing any provider's model list, requires re-applying and restarting Codex to take effect.", - "providers.showAllModels": "Show All Models", - "providers.showSingleModel": "Show Current Only", - "providers.setPoolDefault": "Set as Default", - "providers.setPoolDefaultHint": "In all-model mode: makes this the default provider for new chats / unprefixed requests (other providers' models can still be picked directly from the Codex model list).", "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", + "providers.integrationModelsPoolHint": "These models appear in the Codex model list (grouped by provider, no cross-confusion for same-named models). Add or remove freely.", + "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.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", @@ -1447,8 +1478,11 @@ "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.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?", From c4805b00df8c55c19a63ccee1e245dd2092376aa Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 00:18:27 +0800 Subject: [PATCH 16/31] =?UTF-8?q?style(MOC-236):=20cargo=20fmt=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=B1=A0=E5=AD=90=E9=9B=86=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/registry/src/model_alias.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/registry/src/model_alias.rs b/crates/registry/src/model_alias.rs index be9a3f67..491ad4e5 100644 --- a/crates/registry/src/model_alias.rs +++ b/crates/registry/src/model_alias.rs @@ -484,7 +484,11 @@ mod tests { 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 进池"); + assert_eq!( + slugs, + vec!["a/a-model"], + "只有 pooledEnabled=true 的 a 进池" + ); // provider_idx 仍是原始下标(a 在 0) assert_eq!(entries[0].provider_idx, 0); } From 2b981ea78384b1221ebfeb093f44f40008c6d169 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 00:36:24 +0800 Subject: [PATCH 17/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review(?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E7=A9=BA=E6=B1=A0=E4=B8=8D=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=A7=BD=E4=BD=8D=E6=98=A0=E5=B0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit curation 删光某 provider 模型写入 pooledModels:[] 时,pooled_models_with_one_m 原把空数组等同缺省、回退 models 槽位映射 → UI 删光了 Codex catalog/resolver 仍带 该 provider 映射模型(静默不一致)。改为「数组存在即权威(含空)」,仅整个缺省才回退; 新增 explicit-empty / absent / unique_pool_slugs 三个回归测试 --- crates/registry/src/model_alias.rs | 48 +++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/registry/src/model_alias.rs b/crates/registry/src/model_alias.rs index 491ad4e5..573b25c2 100644 --- a/crates/registry/src/model_alias.rs +++ b/crates/registry/src/model_alias.rs @@ -222,8 +222,9 @@ fn push_pooled_with_one_m( } /// 某 provider 在池里的"可选模型列表",每条附带"是否声明 1M"(由被 strip 的 `[1m]` -/// 标记得出)。优先用持久化 `pooledModels`,为空则回退槽位映射(`default` 优先,再按 -/// `MODEL_SLOTS` 顺序)。clean id 去重、稳定顺序;同 clean id 多变体只要一个带 `[1m]` 即 true。 +/// 标记得出)。`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>, @@ -231,15 +232,17 @@ pub fn pooled_models_with_one_m( let mut out: Vec<(String, bool)> = Vec::new(); let mut index: HashMap = HashMap::new(); - // 1. 持久化 pooledModels(字符串数组) + // 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); } } - } - if !out.is_empty() { return out; } @@ -493,6 +496,21 @@ mod tests { 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];去重;忽略槽位映射 @@ -519,10 +537,26 @@ mod tests { } #[test] - fn pooled_model_ids_empty_array_falls_back_to_mappings() { + 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"}); - assert_eq!(pooled_model_ids(Some(&pooled), Some(&models)), vec!["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] From 6b57352b64561e4f4674198eda636c431563fedf Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 00:55:03 +0800 Subject: [PATCH 18/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-2(=E6=95=B4=E5=90=88=E8=8E=B7=E5=8F=96=E4=B8=8D=E6=94=B9?= =?UTF-8?q?=E6=98=A0=E5=B0=84=20+=20=E4=BF=9D=E7=95=99=E7=BC=BA=E7=9C=81/?= =?UTF-8?q?=E7=A9=BA=E4=B9=8B=E5=88=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 整合页「获取模型」改用非破坏性 GET /models/available + setProviderPool, 不再走 autofill(autofill 会顺带覆盖 provider.models 槽位映射,池外仍在用) - mapProvider 保留 pooledModels 缺省(null)与显式空([])之分;下池对「未 curation」 provider 显示并固化回退映射(effectivePoolModels),与后端 catalog 一致 - 增删模型以「实际生效列表」为基底(回退态先固化,避免单条编辑丢其余回退模型) --- frontend/css/pages/providers.css | 15 ++++++ frontend/js/api.js | 5 +- frontend/js/app.js | 83 ++++++++++++++++++++++++-------- frontend/js/i18n.js | 2 + 4 files changed, 85 insertions(+), 20 deletions(-) diff --git a/frontend/css/pages/providers.css b/frontend/css/pages/providers.css index 940c7676..1c83415c 100644 --- a/frontend/css/pages/providers.css +++ b/frontend/css/pages/providers.css @@ -673,6 +673,21 @@ 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); +} + /* 整合模式锁定提示(dashboard + 编辑页) */ .integration-lock-notice { display: flex; diff --git a/frontend/js/api.js b/frontend/js/api.js index 1f3649f2..94825e08 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -143,7 +143,10 @@ // [MOC-173] auto-review 审查模型槽位 key(gpt_5_X);显式挑字段,不加这行前端拿不到后端返的值。 reviewModelSlot: provider.reviewModelSlot || '', // 池化:按 provider 持久化的可选模型列表(显式挑字段,否则被这层 mapper 静默丢)。 - pooledModels: Array.isArray(provider.pooledModels) ? provider.pooledModels : [], + // **保留「缺省」与「显式空」之分**:数组(含 [])原样透出;字段缺省 → 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, diff --git a/frontend/js/app.js b/frontend/js/app.js index 2ebcf6a8..f2ea9457 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -2486,11 +2486,35 @@ 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 = Array.isArray(provider.pooledModels) ? provider.pooledModels.length : 0; + const count = effectivePoolModels(provider).length; const actionBtn = added ? `` : ``; @@ -2522,10 +2546,14 @@ function poolModelGroupMarkup(provider) { const id = escapeHtml(provider.id); const name = escapeHtml(provider.name); - const models = Array.isArray(provider.pooledModels) ? provider.pooledModels : []; + 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 `
@@ -2535,6 +2563,7 @@ ${escapeHtml(t("providers.integrationFetchModels"))}
+ ${fallbackHint}
${chips}
@@ -2555,17 +2584,6 @@ `; } - // 加入整合后自动获取该 provider 的模型(依次);失败不回滚「加入」状态 —— - // provider 已入池,模型可稍后「重新获取」或手动添加(守 no-silent-destructive)。 - async function autofillPoolProviderModels(providerId) { - try { - await CCApi.autofillProviderModels(providerId); - } catch (e) { - console.warn(`[integration] 自动获取模型失败 ${providerId}:`, e); - showToast(formatModelFetchError(e)); - } - } - // 整合开关同步:settings 页 checkbox + 整合页右上角 toggle 共用 exposeAllProviderModels, // 任一处渲染 settings 时两个都对齐(旧 providers 页的「显示全部模型」按钮已移除,留兜底 guard)。 function renderModelMenuModeState(settings = {}) { @@ -3998,10 +4016,20 @@ const pid = actionEl.dataset.id; actionEl.disabled = true; try { - await CCApi.setProviderPool(pid, { enabled: true }); + // 加入整合后自动获取该 provider 的模型(用户要求)。**用非破坏性的 available 拉取** + // (GET /models/available)而非 autofill —— autofill 会顺带覆盖 provider.models 槽位 + // 映射(用户在编辑页手调的、池外仍用的),整合页不该有此副作用(#477 bot review P2)。 + // 拿到后连同 enabled 一次写入(setProviderPool 后端 chat 过滤 + 只动 pooledModels)。 + let models = null; + try { + const result = await CCApi.fetchProviderModels(pid); + models = (result.models || []).map(modelEntryId).filter(Boolean); + } catch (fetchErr) { + console.warn(`[integration] 自动获取模型失败 ${pid}:`, fetchErr); + showToast(formatModelFetchError(fetchErr)); + } + await CCApi.setProviderPool(pid, models ? { enabled: true, models } : { enabled: true }); showToast(t("toast.integrationProviderAdded")); - // 加入整合后自动获取该 provider 的模型(用户要求)。 - await autofillPoolProviderModels(pid); await renderProviders(); } catch (error) { actionEl.disabled = false; @@ -4029,7 +4057,21 @@ const orig = span ? span.textContent : ""; if (span) span.textContent = t("providers.integrationFetchingModels"); try { - await CCApi.autofillProviderModels(pid); + // 非破坏性拉取 + 合并(「重新获取只负责更新列表」):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) { @@ -4048,7 +4090,9 @@ try { const providers = await CCApi.getProviders(); const provider = providers.find((p) => p.id === pid); - const models = Array.isArray(provider?.pooledModels) ? provider.pooledModels.slice() : []; + // 以「实际生效列表」为基底:若该 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")); @@ -4066,7 +4110,8 @@ try { const providers = await CCApi.getProviders(); const provider = providers.find((p) => p.id === pid); - const models = (Array.isArray(provider?.pooledModels) ? provider.pooledModels : []).filter((m) => m !== model); + // 同 add:基底取实际生效列表(回退态先固化),再剔除目标 model。 + const models = effectivePoolModels(provider).filter((m) => m !== model); await CCApi.setProviderPool(pid, { models }); showToast(t("toast.integrationModelsUpdated")); await renderProviders(); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 9f78d9b2..b6eaa6d0 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -484,6 +484,7 @@ "providers.integrationNoProviders": "还没有提供商。先到控制台添加提供商,再回到这里整合。", "providers.integrationNoIntegrated": "还没有加入任何提供商。从上方选择要整合的提供商。", "providers.integrationNoModels": "暂无可选模型。点「重新获取模型」拉取,或在提供商编辑页手动添加。", + "providers.integrationFallbackHint": "当前显示该提供商的默认模型映射(尚未整理);点「重新获取模型」或增删后,会固化为这个提供商的可选模型列表。", "providers.integrationRemoveModel": "移出该模型", "providers.integrationAddModelPlaceholder": "手动添加模型 id", "providers.integrationAddModel": "添加", @@ -1228,6 +1229,7 @@ "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", From 9a1e18f6f09c09cee67f7e1b64072a948148d9c4 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 01:16:39 +0800 Subject: [PATCH 19/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-3(pooledModels=20=E5=8D=95=E5=86=99=E8=80=85=20=3D=20?= =?UTF-8?q?=E6=95=B4=E5=90=88=E9=A1=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型池改由「整合提供商」页 setProviderPool 独家管理,provider 表单 / autofill / 映射页保存全部与 pool 解耦,不再读写 pooledModels: - 表单保存(providerPayloadFromForm / providerBody / add_provider / update_provider) 不再拼/持久化 pooledModels;update_provider 克隆 existing 原样保留整合页 curation 的池 - autofill / update_models 只更新槽位 models 映射,不动 pool(映射变仍 resync 兜底回退池) - 根因:多路径写 pool → 整合页删掉的模型在保存其它字段时被悄悄加回(resurrection) 保留:setProviderPool 仍是唯一写者;mapping 缺省回退仍生效 --- frontend/js/api.js | 6 +- frontend/js/app.js | 21 +---- .../src/admin/handlers/providers/crud.rs | 89 ++----------------- .../src/admin/handlers/providers/models.rs | 35 +------- 4 files changed, 17 insertions(+), 134 deletions(-) diff --git a/frontend/js/api.js b/frontend/js/api.js index 94825e08..9f63fced 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -219,10 +219,8 @@ if (payload.grokWeb) { body.grokWeb = payload.grokWeb; } - // 池化:带 pooledModels 才下发(数组),让后端 add/update 持久化;不带 = 后端保留现值。 - if (Array.isArray(payload.pooledModels)) { - body.pooledModels = payload.pooledModels; - } + // 模型池(pooledModels)不经 provider 表单下发 —— 由「整合提供商」页 setProviderPool + // 独家管理(/api/providers/{id}/pool)。表单写池会与整合页 curation 冲突(#477 bot review P2)。 return body; } diff --git a/frontend/js/app.js b/frontend/js/app.js index f2ea9457..ccde535f 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1136,24 +1136,9 @@ } if (includeModels) { payload.models = mappings; - // 池化列表 = 当前下拉(进编辑页 seed 自持久化 pooledModels、点「获取模型」后刷新/合并过) - // ∪ 当前 mappings 值(含用户**手输**的新模型 id)。必须并入 mappings:否则手输的新模型 - // 不进池,且旧 seed 出来的 pooledModels 反而盖掉它(registry 优先 pooledModels)→ 新模型 - // 永不出现(bot review P2)。chat-only 过滤在后端 chat_filter_pooled_value 统一做。 - // 空(从未获取 + 无持久化 + 无映射)→ 不下发,后端保留现值。 - const pooled = [ - ...new Set( - [ - ...providerAvailableModels.map(modelEntryId), - ...Object.values(mappings || {}), - ] - .map((s) => String(s || "").trim()) - .filter(Boolean) - ), - ]; - if (pooled.length) { - payload.pooledModels = pooled; - } + // 注意: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 diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index 49361a57..efd6c58a 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -181,11 +181,6 @@ pub struct AddProviderInput { /// 回退复用主模型)。经 `Provider.extra` flatten 透传持久化为 `reviewModelSlot`。 #[serde(rename = "reviewModelSlot")] pub review_model_slot: Option, - /// 池化模式的可选模型列表(用户「获取模型」拉取 + 手加),按 provider 隔离持久化为 - /// `pooledModels`(经 `Provider.extra` flatten)。catalog 池化(`unique_pool_slugs` / - /// `pooled_model_ids`)优先用它,为空才回退槽位映射。`None` = 本次不改动该字段。 - #[serde(rename = "pooledModels")] - pub pooled_models: Option, } pub async fn add_provider( @@ -289,13 +284,8 @@ pub async fn add_provider( json!({"default":"","gpt_5_5":"","gpt_5_4":"","gpt_5_4_mini":"","gpt_5_3_codex":"","gpt_5_2":""}) }), ); - // 池化:加 provider 时带了已抓取/手加的模型列表 → chat-only 过滤后持久化为 pooledModels。 - if let Some(pooled) = input.pooled_models.clone() { - new_provider.insert( - "pooledModels".into(), - super::models::chat_filter_pooled_value(&pooled), - ); - } + // 模型池(pooledModels)不在 add provider 时写入 —— 新 provider 默认未加入整合 + // (pooledEnabled 缺省 false),其池由「整合提供商」页 setProviderPool 独家管理。 new_provider.insert( "extraHeaders".into(), input.extra_headers.clone().unwrap_or_else(|| json!({})), @@ -440,45 +430,10 @@ pub async fn update_provider( updated.insert("models".into(), Value::Object(merged)); } } - // 池化 pooledModels 维护: - // - **upstream 身份变了**(baseUrl / apiFormat 与原值不同)→ 旧池属旧端点,清空 - // pooledModels(回退新映射,等用户对新 upstream「获取模型」重建池)。incoming 此时 - // 多半还是旧缓存,一并丢弃(bot review P2)。 - // - 同一 upstream + 带 pooledModels → **合并**进现有(chat 过滤、去重、**永不收缩**): - // 表单 incoming 源自易失下拉缓存,fetch 失败/部分态只剩 1-5 个映射值,替换会把已持久化 - // 的完整池删剩映射;合并既保留手加/已抓取又纳入本次映射新模型(bot review P2)。 - // - 同一 upstream + 不带该字段 → 完全不动。 - // 身份 = baseUrl / apiFormat / apiKey。apiKey 变更也算(bot review P2):换 key 常意味换 - // 账号,旧账号可见的模型未必新 key 可用,stale slug 会拿新 key 路由 → invalid-model/权限 - // 错。比对 existing vs updated 的 apiKey:用户没重填 key → 前端不下发 → updated 沿用旧值 - // → 相等不触发,正常编辑不误清;真改了 key 才清。 - 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.remove("pooledModels"); - } else if let Some(pooled) = input.pooled_models.clone() { - let incoming = super::models::chat_filter_pooled_value(&pooled); - let mut merged: Vec = existing - .get("pooledModels") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - let mut seen: std::collections::HashSet = merged - .iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect(); - if let Some(arr) = incoming.as_array() { - for v in arr { - if let Some(s) = v.as_str() { - if seen.insert(s.to_owned()) { - merged.push(v.clone()); - } - } - } - } - updated.insert("pooledModels".into(), Value::Array(merged)); - } + // 模型池(pooledModels)**不由 provider 表单维护** —— 由「整合提供商」页 setProviderPool + // 独家管理。`updated` 克隆自 existing,这里不动 pooledModels = 表单编辑任何字段都原样保留 + // 用户在整合页 curation 的池(含显式空 [])。表单写池会把删掉的模型悄悄加回(#477 P2)。 + // 上游(baseUrl/apiKey)改了导致池陈旧,由用户在整合页「重新获取」处理,不在表单静默清空。 updated.insert("id".into(), Value::String(id.clone())); updated.insert("isBuiltin".into(), Value::Bool(is_builtin)); @@ -756,36 +711,8 @@ pub async fn update_models( .unwrap(); if let Some(o) = providers[idx].as_object_mut() { o.insert("models".into(), input.models.clone()); - // 池化:若该 provider 已在用池(pooledModels 非空),把映射页编辑的模型值并进池 - // (chat 过滤、去重、永不收缩)。否则池模式偏好非空 pooledModels、映射页改/手输的 - // 新模型永不出现(bot review P2)。空池时无需(走 mappings fallback)。 - let mut pooled: Vec = o - .get("pooledModels") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - if !pooled.is_empty() { - let mapping_ids: Vec = input - .models - .as_object() - .map(|m| { - m.values() - .filter_map(|v| v.as_str().map(|s| s.trim().to_owned())) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default(); - let mut seen: std::collections::HashSet = pooled - .iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect(); - for id in super::models::chat_usable_model_ids(&mapping_ids) { - if seen.insert(id.clone()) { - pooled.push(Value::String(id)); - } - } - o.insert("pooledModels".into(), Value::Array(pooled)); - } + // 模型池(pooledModels)不由映射页保存维护 —— 由「整合提供商」页 setProviderPool + // 独家管理。映射页写池会把整合页 curation 删掉的模型悄悄加回(#477 bot review P2)。 } Ok(ConfigMutation::Modified(was_active)) }); diff --git a/src-tauri/src/admin/handlers/providers/models.rs b/src-tauri/src/admin/handlers/providers/models.rs index d6a285e8..bcfd09e4 100644 --- a/src-tauri/src/admin/handlers/providers/models.rs +++ b/src-tauri/src/admin/handlers/providers/models.rs @@ -633,20 +633,9 @@ pub async fn autofill_provider_models( .cloned() .unwrap_or_else(|| json!({})); - // 池化:把抓取到的可用模型 id 写入 pooledModels(去 embedding/rerank 等);**合并**而非 - // 覆盖 —— 保留用户手加的、追加新上游的(「重新获取只负责更新列表」,守 no-silent-destructive)。 - let fetched_pool_ids: Vec = result - .get("models") - .and_then(|v| v.as_array()) - .map(|arr| { - let ids: Vec = arr.iter().filter_map(model_id_from_item).collect(); - // 池化用 no-fallback 过滤:全是 embedding/rerank 等时返回空,不把非 chat 模型写进池 - // (否则出现在 Codex chat picker 并把 chat 路由到不支持的端点,bot review P2)。 - chat_usable_model_ids(&ids) - }) - .unwrap_or_default(); - - // 真 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 { @@ -656,22 +645,6 @@ pub async fn autofill_provider_models( if let Some(providers) = cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { if let Some(provider) = providers.get_mut(idx).and_then(|v| v.as_object_mut()) { provider.insert("models".into(), suggested_for_closure.clone()); - // pooledModels 合并:现有(含手加)在前,新抓取去重追加。 - let mut pooled: Vec = provider - .get("pooledModels") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - let mut seen: HashSet = pooled - .iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect(); - for id in &fetched_pool_ids { - if seen.insert(id.clone()) { - pooled.push(Value::String(id.clone())); - } - } - provider.insert("pooledModels".into(), Value::Array(pooled)); return Ok(ConfigMutation::Modified(())); } } @@ -680,7 +653,7 @@ pub async fn autofill_provider_models( if let Err(e) = write_result { return err(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(); } - // 池模式:autofill 改了该 provider 的模型映射 → 全局池 catalog + 反查表重建(非 active 也是)。 + // 整合下若该 provider 仍靠映射回退(pooledModels 缺省),映射变了 → 池 catalog 跟着变 → 重建。 super::resync_pool_if_enabled(&state).await; Json(json!({ "success": true, From f06ef0a9011bfdae4daa31f4d1b4faa667053a35 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 01:35:07 +0800 Subject: [PATCH 20/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-4(=E5=8A=A0=E5=85=A5=E6=95=B4=E5=90=88=E4=BF=9D=E6=98=A0?= =?UTF-8?q?=E5=B0=84=20+=20=E6=98=BE=E5=BC=8F=E7=A9=BA=E6=B1=A0=E4=B8=8D?= =?UTF-8?q?=E5=A4=8D=E6=B4=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pool-add-provider 改为合并 effectivePoolModels(映射回退)+ 抓取结果,不再只发抓取 结果当权威 —— 否则手填、/models 不返回的映射模型会被挤出池(thread 1) - build_pool_catalog 区分:无 provider 加入整合 → None(退回单 provider);有 provider 加入但 curation 全清空 → Some(空) 尊重显式空池,不退回单 provider 复活 active 模型(thread 2) - 新增 2 个 snapshot 回归测试 + 调整 POOL_EMPTY_FELL_BACK_TO_SINGLE 日志措辞 --- frontend/js/app.js | 20 ++++-- .../src/admin/services/desktop/snapshot.rs | 69 +++++++++++++++++-- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/frontend/js/app.js b/frontend/js/app.js index ccde535f..a16f3d85 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -4004,16 +4004,28 @@ // 加入整合后自动获取该 provider 的模型(用户要求)。**用非破坏性的 available 拉取** // (GET /models/available)而非 autofill —— autofill 会顺带覆盖 provider.models 槽位 // 映射(用户在编辑页手调的、池外仍用的),整合页不该有此副作用(#477 bot review P2)。 - // 拿到后连同 enabled 一次写入(setProviderPool 后端 chat 过滤 + 只动 pooledModels)。 - let models = null; + let fetched = []; try { const result = await CCApi.fetchProviderModels(pid); - models = (result.models || []).map(modelEntryId).filter(Boolean); + fetched = (result.models || []).map(modelEntryId).filter(Boolean); } catch (fetchErr) { console.warn(`[integration] 自动获取模型失败 ${pid}:`, fetchErr); showToast(formatModelFetchError(fetchErr)); } - await CCApi.setProviderPool(pid, models ? { enabled: true, models } : { enabled: true }); + // 合并「该 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) { diff --git a/src-tauri/src/admin/services/desktop/snapshot.rs b/src-tauri/src/admin/services/desktop/snapshot.rs index 7baee27e..b10dd52a 100644 --- a/src-tauri/src/admin/services/desktop/snapshot.rs +++ b/src-tauri/src/admin/services/desktop/snapshot.rs @@ -11,7 +11,7 @@ use codex_app_transfer_codex_integration::{ }; use codex_app_transfer_gemini_oauth::antigravity_static_models; use codex_app_transfer_proxy::proxy_telemetry; -use codex_app_transfer_registry::{unique_pool_slugs, RawConfig}; +use codex_app_transfer_registry::{provider_pooled_enabled, unique_pool_slugs, RawConfig}; use serde_json::{json, Value}; use crate::admin::handlers::common::{active_provider_name, read_setting_bool, APP_VERSION}; @@ -115,6 +115,14 @@ fn build_pool_catalog( } let entries = unique_pool_slugs(&typed); if entries.is_empty() { + // 区分两种「空」: + // - **无 provider 加入整合**(pooledEnabled 全 false)= 池还没配置 → `None`,caller 退回 + // 单 active provider catalog(整合刚开、还没添加,Codex 不至于空)。 + // - **有 provider 加入整合、但池被 curation 全清空**(全 `[]`)= 显式空池 → `Some(空)`, + // 尊重用户清空、**不**退回单 provider(否则被删的 active 模型在 Codex 复活,#477 P2)。 + if typed.iter().any(provider_pooled_enabled) { + return Some((Vec::new(), None)); + } return None; } // meta 直接从 typed 构建(与 entries 同源 → `entry.provider_idx` 对齐是**结构保证**, @@ -234,13 +242,14 @@ pub fn desktop_config_target_for_provider( match build_pool_catalog(cfg, provider.get("id").and_then(|v| v.as_str())) { Some((catalog, slug)) => (Some(catalog), slug), None => { - // 整合开关已开却无可池条目(最常见:刚翻开关、还没把任何 provider「加入整合」, - // 或加入的 provider 没有可池模型)。此时静默退回单 active provider catalog —— - // 必须 loud log,否则用户看「整合已开 + 应用锁定」却只看到单 provider 模型,无从排查 - // (守 no-silent-degradation)。前端整合页下池另有「还没加入提供商」引导。 + // 整合开关已开却**无任何 provider 加入整合**(刚翻开关、还没添加)→ 池尚未配置, + // 退回单 active provider catalog(Codex 不至于空)。必须 loud log,否则用户看 + // 「整合已开 + 应用锁定」却只看到单 provider 模型、无从排查(守 no-silent-degradation)。 + // 前端整合页下池另有「还没加入提供商」引导。 + // 注:已加入 provider 但 curation 清空(显式空池)走 Some(空) 分支、不到这里(#477 P2)。 tracing::warn!( error_id = "POOL_EMPTY_FELL_BACK_TO_SINGLE", - "整合开关已开但池为空(无 provider 加入整合 / 加入的无可池模型),退回单 active provider catalog" + "整合开关已开但还没有 provider 加入整合,退回单 active provider catalog" ); (None, None) } @@ -1008,6 +1017,54 @@ mod tests { ); } + #[test] + fn pool_mode_integrated_but_all_emptied_respects_explicit_empty() { + // #477 bot review P2:provider 加入整合但 curation 把模型全删空(pooledModels: [])→ + // build_pool_catalog 返 Some(空),**不**退回单 provider(否则被删的 active 模型在 Codex 复活)。 + let 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": [], "pooledEnabled": true, "sortIndex": 0 + } + ] + }); + let (catalog, slug) = build_pool_catalog(&cfg, Some("deepseek")) + .expect("有 provider 加入整合 → Some(即便空),不退回单 provider"); + assert!( + catalog.is_empty(), + "显式空池 → catalog 空, got {:?}", + catalog.iter().map(|m| &m.slug).collect::>() + ); + assert!(slug.is_none(), "空池无默认 slug"); + } + + #[test] + fn pool_mode_nothing_integrated_returns_none_for_fallback() { + // 对照:开关开但没有 provider 加入整合(pooledEnabled 全缺省)→ None → caller 退回单 provider。 + let 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"}, "sortIndex": 0 + } + ] + }); + assert!( + build_pool_catalog(&cfg, Some("deepseek")).is_none(), + "没有 provider 加入整合 → None(退回单 provider)" + ); + } + #[test] fn pool_catalog_slugs_match_resolver_map_keys_byte_for_byte() { // **最高severity invariant 守门**:catalog 生成端(snapshot::build_pool_catalog)与 From 80e85ecc4c5f7952ca4073866151cdc14508a99d Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 01:44:32 +0800 Subject: [PATCH 21/31] =?UTF-8?q?fix(MOC-236):=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=E9=A1=B5=E6=A0=87=E9=A2=98+=E5=BC=80=E5=85=B3=E4=B8=8D?= =?UTF-8?q?=E6=98=BE=E7=A4=BA(=E5=88=A0=E6=97=A7=20#page-providers=20.page?= =?UTF-8?q?-title=20hide)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧表格版提供商页有条 #page-providers .page-title{display:none}(旧设计无标题), 改造成整合页后这条把新的「整合提供商」标题 + 右上角整合开关整行藏了 → 用户看不到开关。 删除该规则;整合页标题/副标题/开关正常显示。(用户真机测试反馈) --- frontend/css/pages/providers.css | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/css/pages/providers.css b/frontend/css/pages/providers.css index 1c83415c..3f5437ad 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; From ab88ca66ff303dec5314e8b7dad052aa42776033 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 01:53:25 +0800 Subject: [PATCH 22/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-5(=E8=BA=AB=E4=BB=BD=E5=8F=98=E6=9B=B4=E6=B8=85=E7=A9=BA=20s?= =?UTF-8?q?tale=20=E6=B1=A0=20+=20=E7=A9=BA=E6=B1=A0=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E5=9B=9E=E9=80=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update_provider:upstream 身份变了(baseUrl/apiFormat/apiKey)→ 清空 pooledModels, 否则旧 slug 被 catalog/resolver 继续广播、请求路由到新上游不存在的模型(错路由)。 表单只作废池、绝不填充池(不重新引入 resurrection)(thread 1) - build_pool_catalog:池为空(没加入 / 加入的全清空)统一返 None → catalog 与 resolver 一致退回单 provider。此前对「加入但清空」返 Some(空) 但 resolver 空 map 仍 legacy 路由 → 不一致;改走「避免下发空池」graceful 回退 + loud log(thread 2) - 测试合并为 empty_pool_returns_none_for_consistent_fallback(两种空因) --- .../src/admin/handlers/providers/crud.rs | 19 ++++-- .../src/admin/services/desktop/snapshot.rs | 66 ++++++++----------- 2 files changed, 41 insertions(+), 44 deletions(-) diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index efd6c58a..1114ccf9 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -430,10 +430,21 @@ pub async fn update_provider( updated.insert("models".into(), Value::Object(merged)); } } - // 模型池(pooledModels)**不由 provider 表单维护** —— 由「整合提供商」页 setProviderPool - // 独家管理。`updated` 克隆自 existing,这里不动 pooledModels = 表单编辑任何字段都原样保留 - // 用户在整合页 curation 的池(含显式空 [])。表单写池会把删掉的模型悄悄加回(#477 P2)。 - // 上游(baseUrl/apiKey)改了导致池陈旧,由用户在整合页「重新获取」处理,不在表单静默清空。 + // 模型池(pooledModels)**不由 provider 表单填充** —— 由「整合提供商」页 setProviderPool + // 独家管理。`updated` 克隆自 existing,正常编辑不动 pooledModels = 原样保留整合页 curation + // 的池(含显式空 []),表单写池会把删掉的模型悄悄加回(#477 P2 round-3)。 + // + // **例外:upstream 身份变了**(baseUrl / apiFormat / apiKey 与原值不同)→ 旧池属于旧端点/ + // 旧账号,留着会让 catalog / resolver 继续广播旧 slug、把请求路由到新上游的不存在模型 + // (错路由,plan 头号风险)→ **清空** pooledModels(remove → 缺省 → 回退新映射,等用户在 + // 整合页「重新获取」对新上游重建池)。注意:表单这里只**作废**池、绝不**填充**池(非 resurrection)。 + // (#477 P2 round-5) + 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.remove("pooledModels"); + } updated.insert("id".into(), Value::String(id.clone())); updated.insert("isBuiltin".into(), Value::Bool(is_builtin)); diff --git a/src-tauri/src/admin/services/desktop/snapshot.rs b/src-tauri/src/admin/services/desktop/snapshot.rs index b10dd52a..cdcc842b 100644 --- a/src-tauri/src/admin/services/desktop/snapshot.rs +++ b/src-tauri/src/admin/services/desktop/snapshot.rs @@ -11,7 +11,7 @@ use codex_app_transfer_codex_integration::{ }; use codex_app_transfer_gemini_oauth::antigravity_static_models; use codex_app_transfer_proxy::proxy_telemetry; -use codex_app_transfer_registry::{provider_pooled_enabled, unique_pool_slugs, RawConfig}; +use codex_app_transfer_registry::{unique_pool_slugs, RawConfig}; use serde_json::{json, Value}; use crate::admin::handlers::common::{active_provider_name, read_setting_bool, APP_VERSION}; @@ -115,14 +115,11 @@ fn build_pool_catalog( } let entries = unique_pool_slugs(&typed); if entries.is_empty() { - // 区分两种「空」: - // - **无 provider 加入整合**(pooledEnabled 全 false)= 池还没配置 → `None`,caller 退回 - // 单 active provider catalog(整合刚开、还没添加,Codex 不至于空)。 - // - **有 provider 加入整合、但池被 curation 全清空**(全 `[]`)= 显式空池 → `Some(空)`, - // 尊重用户清空、**不**退回单 provider(否则被删的 active 模型在 Codex 复活,#477 P2)。 - if typed.iter().any(provider_pooled_enabled) { - return Some((Vec::new(), None)); - } + // 池里**一个 slug 都没有**(没 provider 加入整合,或加入的都被 curation 清空)→ 返回 + // `None`,caller 退回单 active provider catalog。**catalog 与 resolver 一致地回退**是关键: + // 此前曾对「加入但全清空」返 `Some(空)`,但 resolver 对空 catalog_slug_map 仍按 legacy 模式 + // (slug 拆分 / 默认 provider)路由 → catalog 空、resolver 仍回退 = 不一致(#477 P2 round-5)。 + // 故统一「池空即整体回退」(graceful、Codex 不至于空),回退本身有 loud log 兜底可排查。 return None; } // meta 直接从 typed 构建(与 entries 同源 → `entry.provider_idx` 对齐是**结构保证**, @@ -242,14 +239,13 @@ pub fn desktop_config_target_for_provider( match build_pool_catalog(cfg, provider.get("id").and_then(|v| v.as_str())) { Some((catalog, slug)) => (Some(catalog), slug), None => { - // 整合开关已开却**无任何 provider 加入整合**(刚翻开关、还没添加)→ 池尚未配置, - // 退回单 active provider catalog(Codex 不至于空)。必须 loud log,否则用户看 + // 整合开关已开但池为空(没 provider 加入整合 / 加入的都被 curation 清空)→ 退回单 + // active provider catalog(graceful、Codex 不至于空)。必须 loud log,否则用户看 // 「整合已开 + 应用锁定」却只看到单 provider 模型、无从排查(守 no-silent-degradation)。 - // 前端整合页下池另有「还没加入提供商」引导。 - // 注:已加入 provider 但 curation 清空(显式空池)走 Some(空) 分支、不到这里(#477 P2)。 + // 前端整合页下池另有「还没加入提供商 / 各 provider 无可选模型」引导。 tracing::warn!( error_id = "POOL_EMPTY_FELL_BACK_TO_SINGLE", - "整合开关已开但还没有 provider 加入整合,退回单 active provider catalog" + "整合开关已开但模型池为空(无 provider 加入整合或加入的都已清空),退回单 active provider catalog" ); (None, None) } @@ -1018,50 +1014,40 @@ mod tests { } #[test] - fn pool_mode_integrated_but_all_emptied_respects_explicit_empty() { - // #477 bot review P2:provider 加入整合但 curation 把模型全删空(pooledModels: [])→ - // build_pool_catalog 返 Some(空),**不**退回单 provider(否则被删的 active 模型在 Codex 复活)。 - let cfg = json!({ - "version": APP_VERSION, - "activeProvider": "deepseek", - "gatewayApiKey": "cas_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"}, - "pooledModels": [], "pooledEnabled": true, "sortIndex": 0 + "apiKey": "k1", "models": {"default": "deepseek-v4-pro"}, "sortIndex": 0 } ] }); - let (catalog, slug) = build_pool_catalog(&cfg, Some("deepseek")) - .expect("有 provider 加入整合 → Some(即便空),不退回单 provider"); assert!( - catalog.is_empty(), - "显式空池 → catalog 空, got {:?}", - catalog.iter().map(|m| &m.slug).collect::>() + build_pool_catalog(¬hing_integrated, Some("deepseek")).is_none(), + "没有 provider 加入整合 → None" ); - assert!(slug.is_none(), "空池无默认 slug"); - } - #[test] - fn pool_mode_nothing_integrated_returns_none_for_fallback() { - // 对照:开关开但没有 provider 加入整合(pooledEnabled 全缺省)→ None → caller 退回单 provider。 - let cfg = json!({ - "version": APP_VERSION, - "activeProvider": "deepseek", - "gatewayApiKey": "cas_test", + 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"}, "sortIndex": 0 + "apiKey": "k1", "models": {"default": "deepseek-v4-pro"}, + "pooledModels": [], "pooledEnabled": true, "sortIndex": 0 } ] }); assert!( - build_pool_catalog(&cfg, Some("deepseek")).is_none(), - "没有 provider 加入整合 → None(退回单 provider)" + build_pool_catalog(&all_emptied, Some("deepseek")).is_none(), + "加入整合但 curation 清空 → 同样 None(catalog 与 resolver 一致回退)" ); } From fe4d5be24f4dc26595d9fc63e5f2b6fb356a040c Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 02:11:30 +0800 Subject: [PATCH 23/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-6(=E6=B1=A0=E5=8C=96=E6=A8=A1=E5=BC=8F=E7=A6=81=E7=94=A8=20l?= =?UTF-8?q?egacy=20slug=20=E8=B7=AF=E7=94=B1=E5=88=B0=E5=B7=B2=E6=8E=92?= =?UTF-8?q?=E9=99=A4=20provider)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StaticResolver 加显式 pool_enabled(反查表非空即置位):池化模式下 decide_provider 反查表 miss 时不再走 / legacy 拆分 —— 否则旧会话 / 手输的 excluded-provider/model 会被路由到用户已移出整合的 provider(违反子集语义)→ 改退默认 provider。单 provider / 池空回退(表空)仍保留 legacy 拆分(向后兼容)。+1 回归测试。 (thread 2「unseeded 当显式空池」round-5 已修:空池统一回退单 provider、不下发空 catalog) --- crates/proxy/src/resolver.rs | 43 +++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/proxy/src/resolver.rs b/crates/proxy/src/resolver.rs index 55912746..9b00675b 100644 --- a/crates/proxy/src/resolver.rs +++ b/crates/proxy/src/resolver.rs @@ -128,6 +128,12 @@ pub struct StaticResolver { /// `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, } impl StaticResolver { @@ -141,11 +147,15 @@ impl StaticResolver { providers, default_provider_id, catalog_slug_map: HashMap::new(), + pool_enabled: false, } } /// 装配池化反查表(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 } @@ -349,9 +359,13 @@ fn decide_provider<'a>( ); } // 1. "/" 约定:按 provider slug 路由(手动调用 / 池化前兼容)。 - 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)))); + // **仅非池化模式**才走:池化模式下反查表 = 整合子集的全集,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)))); + } } } } @@ -875,4 +889,27 @@ mod tests { 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" + ); + } } From 3c4f412bad6a9e81404d4861012db6459ffcd22a Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 02:25:19 +0800 Subject: [PATCH 24/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-7(=E8=BA=AB=E4=BB=BD=E5=8F=98=E6=9B=B4=E7=BD=AE=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E7=A9=BA=E6=B1=A0,=E4=B8=8D=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E9=99=88=E6=97=A7=E6=98=A0=E5=B0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit round-5 身份变更用 remove(pooledModels),但缺省会让 unique_pool_slugs 回退到该 provider (同样陈旧的)models 槽位映射 → 旧 slug 立刻又进池广播、路由到新上游不存在模型。改为置 **显式空 []**(round-3 registry 语义 = 不贡献 slug 也不回退),等用户在整合页「重新获取」 对新上游重建池。表单仍只作废池、不填充。 --- src-tauri/src/admin/handlers/providers/crud.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/admin/handlers/providers/crud.rs b/src-tauri/src/admin/handlers/providers/crud.rs index 1114ccf9..3735e4c0 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -436,14 +436,16 @@ pub async fn update_provider( // // **例外:upstream 身份变了**(baseUrl / apiFormat / apiKey 与原值不同)→ 旧池属于旧端点/ // 旧账号,留着会让 catalog / resolver 继续广播旧 slug、把请求路由到新上游的不存在模型 - // (错路由,plan 头号风险)→ **清空** pooledModels(remove → 缺省 → 回退新映射,等用户在 - // 整合页「重新获取」对新上游重建池)。注意:表单这里只**作废**池、绝不**填充**池(非 resurrection)。 - // (#477 P2 round-5) + // (错路由,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.remove("pooledModels"); + updated.insert("pooledModels".into(), Value::Array(Vec::new())); } updated.insert("id".into(), Value::String(id.clone())); updated.insert("isBuiltin".into(), Value::Bool(is_builtin)); From 10bcaa866ef98af20474dd0daad41c36a9646348 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 02:40:55 +0800 Subject: [PATCH 25/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-8(pool-miss=20=E4=B8=8D=E9=80=80=E5=9B=9E=E8=A2=AB=E6=8E=92?= =?UTF-8?q?=E9=99=A4=E7=9A=84=20active=20provider)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit round-6 pool-miss 退 default_provider()=active,但 active 本身可能未加入整合(整合时 set-default 锁定,active 可能是开整合前的遗留)→ pool-miss 仍把流量打到被排除的 active。 proxy_runner 新增 pool_default_provider_id:池化下 default = active(若在子集内)否则池首条 所属 provider(与 apply root-model 锚定一致),绝不退被排除的 active。+1 单测。 --- src-tauri/src/proxy_runner.rs | 82 ++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index feb81b6b..704dc8a1 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -261,23 +261,57 @@ fn load_resolver_snapshot() -> Result { .ok_or_else(|| "gateway api key was not generated".to_owned())?; // 池化反查表:仅 `exposeAllProviderModels` 开时构建(与 catalog 生成端共用 - // `unique_pool_slugs`,保证 slug 逐字一致)。关 → 空表 → `decide_provider` 退回 - // slug-split / 默认 provider,行为与池化前完全一致。 - let pool_map = if cfg.settings.expose_all_provider_models { - build_catalog_slug_map(&unique_pool_slugs(&cfg.providers)) + // `unique_pool_slugs`,保证 slug 逐字一致)。关 → 空 entries → 空表 → `decide_provider` + // 退回 slug-split / 默认 provider,行为与池化前完全一致。 + let pool_entries = if cfg.settings.expose_all_provider_models { + unique_pool_slugs(&cfg.providers) } else { - HashMap::new() + 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, + ) }; 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), 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::*; @@ -289,6 +323,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", From bd3fa90f350575c9ec621b0fdd111d238c796900 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 02:54:50 +0800 Subject: [PATCH 26/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review=20rou?= =?UTF-8?q?nd-9(vision/audio=20=E5=A4=9A=E6=A8=A1=E6=80=81=20chat=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=BF=9D=E7=95=99=E8=BF=9B=E6=B1=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NON_CHAT_MODEL_KEYWORDS 含 vision/audio,但 moonshot-...-vision-preview / gpt-4o-audio-preview 等是多模态 chat 模型(只是支持图/音输入),按子串剔会把合法 chat 模型 静默踢出池、在 Codex picker 消失。移除 vision/audio;保留 embedding/rerank/moderation/ whisper/tts/image(真非 chat,image 是生成端点无 chat 同名变体)。+1 单测。 --- .../src/admin/handlers/providers/models.rs | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/admin/handlers/providers/models.rs b/src-tauri/src/admin/handlers/providers/models.rs index bcfd09e4..e1f74c76 100644 --- a/src-tauri/src/admin/handlers/providers/models.rs +++ b/src-tauri/src/admin/handlers/providers/models.rs @@ -210,7 +210,13 @@ fn extract_model_ids(payload: &Value) -> Vec { ids } -/// 非 chat 模型关键词(embedding / rerank / 语音 / 图像 等)—— 这些不能服务 chat 请求。 +/// 非 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", @@ -218,8 +224,6 @@ const NON_CHAT_MODEL_KEYWORDS: &[&str] = &[ "whisper", "tts", "image", - "vision", - "audio", ]; /// 只保留可用于 chat 的模型 id(过滤 embedding/rerank/语音/图像);**全被过滤则返回空** @@ -669,6 +673,48 @@ pub async fn autofill_provider_models( 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!({ From 8a7428917db04586b3de5fed2a2086e480402a1b Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 11:10:07 +0800 Subject: [PATCH 27/31] =?UTF-8?q?feat(MOC-236):=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=A0=87=E5=87=86=E6=A1=A3=E6=98=A0=E5=B0=84?= =?UTF-8?q?(provider/model=20=E4=B8=8D=E8=BF=9B=20Codex,=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E5=8C=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机确认 provider/model slug 进不了 Codex model picker,故整合模式改为:Codex 只暴露 映射了的标准档(gpt-5.x),选某档由 resolver 路由到映射的池中 (provider, model)。 - 数据:Config 新增全局 poolSlotMappings(gpt-5.x→{provider,model}) - registry pool_slot_entries(单源:catalog + resolver 共用,slug=标准档名,校验 target 在子集) - catalog_models_for_slot_mappings(标准档 catalog,套 Codex 原生 builtin 模板) - build_pool_catalog / proxy_runner 改用 pool_slot_entries;byte-identity 守恒 - PUT /api/pool/slot-mappings 端点 + status 暴露 + api.js - 前端:上池与下池间插「模型映射」区(复用槽位布局 + 右侧两级 select provider→model) - 下池/映射 model 显示 displayName(修 antigravity raw id 看不懂);下池改注「映射来源」 - 重写 3 个 snapshot 测试 + 新增 pool_slot_entries 测试 --- crates/codex_integration/src/lib.rs | 4 +- crates/codex_integration/src/model_catalog.rs | 30 +++ crates/registry/src/lib.rs | 6 +- crates/registry/src/model_alias.rs | 93 ++++++++ crates/registry/src/schema.rs | 7 + frontend/css/pages/providers.css | 34 +++ frontend/index.html | 12 +- frontend/js/api.js | 10 + frontend/js/app.js | 147 ++++++++++++- frontend/js/i18n.js | 20 +- src-tauri/src/admin/handlers/common.rs | 2 + .../src/admin/handlers/providers/crud.rs | 34 +++ src-tauri/src/admin/mod.rs | 4 + .../src/admin/services/desktop/snapshot.rs | 201 ++++++++---------- src-tauri/src/proxy_runner.rs | 10 +- 15 files changed, 478 insertions(+), 136 deletions(-) diff --git a/crates/codex_integration/src/lib.rs b/crates/codex_integration/src/lib.rs index cbd14bd4..dec4e1cf 100644 --- a/crates/codex_integration/src/lib.rs +++ b/crates/codex_integration/src/lib.rs @@ -40,8 +40,8 @@ pub use mcp_credentials::{ }; pub use model_catalog::{ catalog_models_for_pool, catalog_models_for_provider, - catalog_models_for_provider_with_display_names, strip_model_suffix, upsert_catalog_models, - CatalogModel, PoolProviderMeta, + 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 56820d61..a2ac533c 100644 --- a/crates/codex_integration/src/model_catalog.rs +++ b/crates/codex_integration/src/model_catalog.rs @@ -270,6 +270,36 @@ pub fn catalog_models_for_pool( .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) } diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index 984e6ea8..097e1ee6 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -32,9 +32,9 @@ pub use healing::heal_builtin_provider_fields; pub use healing::heal_legacy_update_url; pub use model_alias::{ build_catalog_slug_map, empty_model_mappings, has_internal_one_m_suffix, - normalize_model_mappings, openai_model_slot, 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, + 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 573b25c2..870d53a3 100644 --- a/crates/registry/src/model_alias.rs +++ b/crates/registry/src/model_alias.rs @@ -340,6 +340,62 @@ pub fn unique_pool_slugs(providers: &[crate::Provider]) -> Vec { 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; + }; + entries.push(PoolEntry { + provider_idx: idx, + slug: openai_id.to_owned(), + real_model: strip_internal_model_suffix(model), + 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 @@ -473,6 +529,43 @@ mod tests { } } + #[test] + fn pool_slot_entries_maps_standard_slots_to_valid_pool_targets() { + let a = mk_provider("deepseek", "DeepSeek"); // pooledEnabled=true + let b = mk_provider("kimi", "Kimi"); + 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_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.2"), "空 model 不产条目"); + assert!(!by_slug.contains_key("default"), "default 非标准档不产条目"); + } + #[test] fn unique_pool_slugs_excludes_providers_not_in_integration() { // pooledEnabled 缺失 / false → 不进池(整合子集语义)。 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/providers.css b/frontend/css/pages/providers.css index 3f5437ad..164fa3f7 100644 --- a/frontend/css/pages/providers.css +++ b/frontend/css/pages/providers.css @@ -684,6 +684,40 @@ 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; diff --git a/frontend/index.html b/frontend/index.html index 03402c8d..0c815dfd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -334,8 +334,16 @@

整合的提供商

-

可选模型

-

这些模型会出现在 Codex 模型列表里(按提供商分组,同名模型互不混淆)。可增删。

+

模型映射(进 Codex)

+

把 Codex 标准档映射到池中某「提供商 / 模型」。Codex 模型列表里只显示**映射了的**标准档,选某档即走你映射的上游;未映射的档不显示。

+
+
+
+ +
+
+

可选模型(映射来源)

+

每个整合提供商的可选模型清单,作为上方映射的候选来源。这些模型本身不直接进 Codex,可增删。

diff --git a/frontend/js/api.js b/frontend/js/api.js index 9f63fced..7d8fef1b 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -245,6 +245,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 + : {}, }; }, @@ -330,6 +335,11 @@ 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 a16f3d85..9e4b1d16 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -2446,10 +2446,14 @@ const pools = $("#integrationPools"); const toggle = $("#integrationToggle"); let enabled = false; + let slotMappings = {}; try { - enabled = !!(await CCApi.getSettings()).exposeAllProviderModels; + // getStatus 一次拿到整合开关 + 全局槽位映射(中间映射区渲染用)。 + const status = await CCApi.getStatus(); + enabled = !!status.exposeAllProviderModels; + slotMappings = status.poolSlotMappings || {}; } catch (e) { - console.warn("[renderProviders] 读取整合开关失败,按关闭处理:", e); + console.warn("[renderProviders] 读取整合状态失败,按关闭处理:", e); } if (toggle) toggle.checked = enabled; if (offHint) offHint.hidden = enabled; @@ -2457,7 +2461,129 @@ 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); + } + }); + } + + // 整合页模型 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); + } + } + 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; + } + // 5 个标准档(排除 default —— 非 OpenAI 档、不进 Codex picker)。 + const slots = providerFormModelSlots.filter((s) => s.key !== "default"); + wrap.innerHTML = slots + .map((slot) => poolSlotMappingRowMarkup(slot, integrated, slotMappings || {})) + .join(""); + } + + 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; + } + + async function savePoolSlotMappings(mappings) { + try { + await CCApi.setPoolSlotMappings(mappings); + showToast(t("toast.integrationMappingUpdated")); + } catch (e) { + showToast(e.message || t("toast.requestFailed")); + } + await renderProviders(); // 重渲染(成功固化 / 失败复原到后端真实状态 + 重填 model select) } // 上池:把已配置的 provider 当候选,加入(pooledEnabled)/移出整合子集。 @@ -2561,9 +2687,11 @@ 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 ` - - ${m} + + ${label} `; @@ -8840,6 +8968,17 @@ 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 b6eaa6d0..26d9530a 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -475,8 +475,13 @@ "providers.integrationOffHint": "当前为单提供商模式:仅默认提供商的模型显示到 Codex。开启右上角开关后,可把多个提供商整合进统一模型池,在 Codex 里直接选任意模型(改动整合相关项后需重新一键应用并重启 Codex)。", "providers.integrationProvidersPoolTitle": "整合的提供商", "providers.integrationProvidersPoolHint": "选择要加入模型池的提供商。加入后会自动获取其模型并列入下方可选模型。", - "providers.integrationModelsPoolTitle": "可选模型", - "providers.integrationModelsPoolHint": "这些模型会出现在 Codex 模型列表里(按提供商分组,同名模型互不混淆)。可增删。", + "providers.integrationModelsPoolTitle": "可选模型(映射来源)", + "providers.integrationModelsPoolHint": "每个整合提供商的可选模型清单,作为上方「模型映射」的候选来源。这些模型本身不直接进 Codex,可增删。", + "providers.integrationMappingTitle": "模型映射(进 Codex)", + "providers.integrationMappingHint": "把 Codex 标准档映射到池中某「提供商 / 模型」。Codex 模型列表里只显示映射了的标准档,选某档即走你映射的上游;未映射的档不显示。改动后需重新一键应用并重启 Codex。", + "providers.integrationSlotUnset": "— 不映射 —", + "providers.integrationSlotPickModel": "选择模型", + "providers.integrationSlotPickProviderFirst": "先选提供商", "providers.integrationAddProvider": "加入整合", "providers.integrationRemoveProvider": "移出整合", "providers.integrationFetchModels": "重新获取模型", @@ -733,6 +738,7 @@ "toast.integrationProviderAdded": "已加入整合,正在获取模型...", "toast.integrationProviderRemoved": "已移出整合", "toast.integrationModelsUpdated": "模型池已更新,请重新一键应用并重启 Codex", + "toast.integrationMappingUpdated": "模型映射已更新,请重新一键应用并重启 Codex", "toast.compatibilityChecked": "兼容性检查完成", "toast.requestFailed": "操作失败,请查看后端日志", "confirm.desktopApply": "即将生成 Codex CLI 环境变量配置命令并复制到剪贴板。确认继续?", @@ -1220,8 +1226,13 @@ "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", - "providers.integrationModelsPoolHint": "These models appear in the Codex model list (grouped by provider, no cross-confusion for same-named models). Add or remove freely.", + "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.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", @@ -1485,6 +1496,7 @@ "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.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 ae837fc8..89766bd5 100644 --- a/src-tauri/src/admin/handlers/common.rs +++ b/src-tauri/src/admin/handlers/common.rs @@ -182,6 +182,8 @@ pub async fn status(State(state): State) -> impl IntoResponse { // 池化开关真实值(此前硬编 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 3735e4c0..d16e6cad 100644 --- a/src-tauri/src/admin/handlers/providers/crud.rs +++ b/src-tauri/src/admin/handlers/providers/crud.rs @@ -591,6 +591,40 @@ pub async fn set_provider_pool( } } +#[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(), + } +} + pub async fn set_default_provider( State(state): State, Path(id): Path, diff --git a/src-tauri/src/admin/mod.rs b/src-tauri/src/admin/mod.rs index 16efb844..56382fc6 100644 --- a/src-tauri/src/admin/mod.rs +++ b/src-tauri/src/admin/mod.rs @@ -59,6 +59,10 @@ pub fn build_app_router(state: AdminState) -> Router { "/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 cdcc842b..2caac9d6 100644 --- a/src-tauri/src/admin/services/desktop/snapshot.rs +++ b/src-tauri/src/admin/services/desktop/snapshot.rs @@ -3,15 +3,15 @@ use std::path::PathBuf; use std::sync::Arc; use codex_app_transfer_codex_integration::{ - apply_provider, catalog_models_for_pool, 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, CatalogModel, CodexPaths, - PoolProviderMeta, + 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::{unique_pool_slugs, 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}; @@ -78,16 +78,17 @@ fn antigravity_display_names(api_format_lower: &str) -> Value { Value::Object(map) } -/// 池化 catalog 构建:对 config 里**全部** provider 用 `unique_pool_slugs`(与 proxy -/// resolver 反查表**同一 helper** → slug 逐字一致、不错路由)产池条目,再 -/// `catalog_models_for_pool` 生成 Codex catalog。返回 `(catalog, pool_default_slug)`, -/// 后者 = active provider 的首条池条目 slug(给 apply 锚定 root `model`)。 +/// 整合(池化)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`)。 /// -/// `None` = 无法池化(无 provider / 解析异常 / 无可池模型)→ caller 退回单 provider -/// catalog,**绝不返回空 catalog**(空会让 Codex picker 空)。 +/// **真机确认 `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>, + _active_id: Option<&str>, ) -> Option<(Vec, Option)> { let providers_raw = cfg.get("providers").and_then(|v| v.as_array())?; if providers_raw.is_empty() { @@ -113,18 +114,15 @@ fn build_pool_catalog( } } } - let entries = unique_pool_slugs(&typed); + // 全局槽位映射 → 标准档条目(pool_slot_entries 已校验 target provider 在整合子集内 + model 非空)。 + let entries = pool_slot_entries(&typed, cfg.get("poolSlotMappings")); if entries.is_empty() { - // 池里**一个 slug 都没有**(没 provider 加入整合,或加入的都被 curation 清空)→ 返回 - // `None`,caller 退回单 active provider catalog。**catalog 与 resolver 一致地回退**是关键: - // 此前曾对「加入但全清空」返 `Some(空)`,但 resolver 对空 catalog_slug_map 仍按 legacy 模式 - // (slug 拆分 / 默认 provider)路由 → catalog 空、resolver 仍回退 = 不一致(#477 P2 round-5)。 - // 故统一「池空即整体回退」(graceful、Codex 不至于空),回退本身有 loud log 兜底可排查。 + // 没有任何有效的标准档映射(没配 / target 都被移出整合 / model 空)→ 返回 `None`, + // caller 退回单 active provider catalog。**catalog 与 resolver 一致地回退**(resolver 端 + // 反查表也为空 → 退默认 provider),graceful、Codex 不至于空,回退有 loud log 兜底可排查。 return None; } - // meta 直接从 typed 构建(与 entries 同源 → `entry.provider_idx` 对齐是**结构保证**, - // 不再靠"两次遍历同序"约定)。typed 反序列化要求 `name` 存在,故此处 name 必有(可能为 - // 空串,catalog_models_for_pool 视空串为不加 provider 前缀,与 raw accessor 行为一致)。 + // meta 按 provider_idx 索引取窗口 / display(标准档 catalog 的 context_window 取映射模型的)。 let meta: Vec = typed .iter() .map(|p| PoolProviderMeta { @@ -134,26 +132,12 @@ fn build_pool_catalog( display_names: antigravity_display_names(&p.api_format.trim().to_ascii_lowercase()), }) .collect(); - let catalog = catalog_models_for_pool(&entries, &meta); + let catalog = catalog_models_for_slot_mappings(&entries, &meta); if catalog.is_empty() { return None; } - // 诊断:哪些 provider 没贡献任何池条目(空映射 + 无 pooledModels)→ 在 picker 里缺席。 - // debug 级,便于排查"某 provider 模型没出现在池里"。 - let covered: std::collections::HashSet = - entries.iter().map(|e| e.provider_idx).collect(); - for (idx, p) in typed.iter().enumerate() { - if !covered.contains(&idx) { - tracing::debug!("池化:provider {:?} 无可池模型,不进池", p.id); - } - } - let default_slug = active_id.and_then(|id| { - let idx = typed.iter().position(|p| p.id == id)?; - entries - .iter() - .find(|e| e.provider_idx == idx) - .map(|e| e.slug.clone()) - }); + // root `model` 锚到首条标准档(pool_slot_entries 按 MODEL_SLOTS 顺序 → gpt-5.5 优先)。 + let default_slug = entries.first().map(|e| e.slug.clone()); Some((catalog, default_slug)) } @@ -894,10 +878,11 @@ mod tests { } #[test] - fn pool_mode_target_builds_all_provider_catalog() { - // exposeAllProviderModels=true + 2 provider(local_proxy)→ target.pool 含每个 - // provider 的 /;pool_default_slug = active provider 首条; - // expected_model_items 反映池。toggle 关时退回单 provider(pool=None)。 + 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", @@ -907,16 +892,19 @@ mod tests { "id": "deepseek", "name": "DeepSeek", "baseUrl": "https://a.example/v1", "apiFormat": "openai_chat", "apiKey": "k1", "models": {"default": "deepseek-v4-pro"}, - "pooledEnabled": true, "sortIndex": 0 + "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", "kimi-for-coding"], - "pooledEnabled": true, "sortIndex": 1 + "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, @@ -928,64 +916,54 @@ mod tests { 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 pool = target + .pool + .clone() + .expect("toggle 开 + 有映射 → pool 应为 Some"); let slugs: Vec<&str> = pool.iter().map(|m| m.slug.as_str()).collect(); - assert!(slugs.contains(&"deepseek/deepseek-v4-pro"), "{slugs:?}"); - assert!(slugs.contains(&"kimi/kimi-k2.6"), "{slugs:?}"); - assert!(slugs.contains(&"kimi/kimi-for-coding"), "{slugs:?}"); - // pooledModels 优先:kimi 的 default(kimi-k2.6)+ 手加(kimi-for-coding)都在池 + 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("deepseek/deepseek-v4-pro"), - "锚定到 active provider 首条池条目" - ); - // display_name provider-qualified(消歧) - let kimi_coding = pool - .iter() - .find(|m| m.slug == "kimi/kimi-for-coding") - .unwrap(); - assert_eq!(kimi_coding.display_name, "Kimi / kimi-for-coding"); - // expected_model_items(dashboard / needsApply 判定)反映池 - let names: Vec = desktop_expected_model_items(&target) - .iter() - .filter_map(|i| i["name"].as_str().map(str::to_owned)) - .collect(); - assert!( - names.iter().any(|n| n == "kimi/kimi-for-coding"), - "{names:?}" + Some("gpt-5.5"), + "root model 锚到首条标准档(MODEL_SLOTS 顺序)" ); - // toggle 关 → 退回单 active provider(pool=None,catalog 走 gpt-5.x 槽) + // 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_mode_excludes_active_provider_not_in_integration() { - // 子集语义新引入的运行态:整合开 + active provider(deepseek)未「加入整合」 - // (无 pooledEnabled),仅 kimi 加入 → 池非空(只含 kimi),但 active 不在池里 - // → pool_default_slug=None。这是本次改动新可达的状态(以前全 provider 都入池, - // active 必有条目、default_slug 必 Some);apply 层据此退回 catalog 首条锚定,picker 不空。 - let mut cfg = json!({ + fn pool_slot_mapping_to_excluded_provider_is_dropped() { + // 映射 target 指向未加入整合的 provider → 该标准档不进 catalog(pool_slot_entries 过滤), + // 避免把请求路由到用户移出整合的 provider(子集语义)。 + let cfg = json!({ "version": APP_VERSION, - "activeProvider": "deepseek", + "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 + "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", "kimi-for-coding"], - "pooledEnabled": true, "sortIndex": 1 + "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, @@ -995,21 +973,15 @@ mod tests { } }); - let active = cfg["providers"][0].clone(); - let target = desktop_config_target_for_provider(&mut cfg, &active, None); - let pool = target.pool.clone().expect("kimi 加入整合 → pool 应为 Some"); - let slugs: Vec<&str> = pool.iter().map(|m| m.slug.as_str()).collect(); - assert!(slugs.contains(&"kimi/kimi-k2.6"), "{slugs:?}"); - assert!(slugs.contains(&"kimi/kimi-for-coding"), "{slugs:?}"); + 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.iter().any(|s| s.starts_with("deepseek/")), - "active 但未加入整合的 deepseek 不应在池: {slugs:?}" + slugs.contains(&"gpt-5.4"), + "kimi(已加入)的档在 catalog: {slugs:?}" ); - assert!(!pool.is_empty(), "池非空 → picker 不空"); assert!( - target.pool_default_slug.is_none(), - "active 不在整合子集 → pool_default_slug=None, got {:?}", - target.pool_default_slug + !slugs.contains(&"gpt-5.5"), + "映射到被排除 provider(deepseek)的档不进 catalog: {slugs:?}" ); } @@ -1054,54 +1026,51 @@ mod tests { #[test] fn pool_catalog_slugs_match_resolver_map_keys_byte_for_byte() { // **最高severity invariant 守门**:catalog 生成端(snapshot::build_pool_catalog)与 - // resolver 路由端(proxy_runner:Config deser → unique_pool_slugs → build_catalog_slug_map) - // 对同一 config 必须产出**逐字一致**的 slug 集合;否则 Codex picker 选的模型会路由到 - // 错误上游(把 prompt 发错 provider = 数据泄露级)。含 slug 碰撞 + pooledModels 两种用例。 - use codex_app_transfer_registry::{build_catalog_slug_map, unique_pool_slugs, Config}; - // id `acme` 与 `ACME` slug 化后都成 `acme`(provider_slug 优先用 id 并 lowercase)→ - // 制造真实碰撞,验证 catalog 与 resolver 两端用同一 -N 后缀消歧。p3 走 pooledModels。 + // 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": "acme", + "activeProvider": "deepseek", "gatewayApiKey": "cas_test", "providers": [ - {"id":"acme","name":"Acme One","baseUrl":"https://a/v1","apiFormat":"openai_chat", - "apiKey":"k","models":{"default":"qna-v1"},"pooledEnabled":true,"sortIndex":0}, - {"id":"ACME","name":"Acme Two","baseUrl":"https://b/v1","apiFormat":"openai_chat", - "apiKey":"k","models":{"default":"qna-v2"},"pooledEnabled":true,"sortIndex":1}, + {"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","kimi-for-coding"],"pooledEnabled":true,"sortIndex":2} + "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("acme")).expect("pool should build"); + 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(&unique_pool_slugs(&config.providers)); + 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:碰撞消歧(acme/ 与 acme-2/)+ pooledModels 生效 - assert!( - map_keys.iter().any(|s| s.starts_with("acme/")), - "{map_keys:?}" - ); - assert!( - map_keys.iter().any(|s| s.starts_with("acme-2/")), - "{map_keys:?}" - ); - assert!(map_keys.contains("kimi/kimi-for-coding"), "{map_keys:?}"); + // 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) { diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 704dc8a1..8017f1ee 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use std::sync::Mutex; use codex_app_transfer_proxy::{build_router_with_relogin, StaticResolver}; -use codex_app_transfer_registry::{build_catalog_slug_map, config_file, unique_pool_slugs, Config}; +use codex_app_transfer_registry::{build_catalog_slug_map, config_file, pool_slot_entries, Config}; use serde::Serialize; use tokio::sync::oneshot; @@ -260,11 +260,11 @@ fn load_resolver_snapshot() -> Result { .filter(|s| !s.is_empty()) .ok_or_else(|| "gateway api key was not generated".to_owned())?; - // 池化反查表:仅 `exposeAllProviderModels` 开时构建(与 catalog 生成端共用 - // `unique_pool_slugs`,保证 slug 逐字一致)。关 → 空 entries → 空表 → `decide_provider` - // 退回 slug-split / 默认 provider,行为与池化前完全一致。 + // 整合反查表:仅 `exposeAllProviderModels` 开时构建(与 catalog 生成端共用 + // `pool_slot_entries`,key = 标准档 slug `gpt-5.x`,保证逐字一致)。关 → 空 entries → 空表 + // → `decide_provider` 退默认 provider,行为与整合前一致。 let pool_entries = if cfg.settings.expose_all_provider_models { - unique_pool_slugs(&cfg.providers) + pool_slot_entries(&cfg.providers, Some(&cfg.pool_slot_mappings)) } else { Vec::new() }; From 8c7e5e33bda763e9da242d8c12dce92d087ad7c5 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 11:31:59 +0800 Subject: [PATCH 28/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review(?= =?UTF-8?q?=E6=A7=BD=E4=BD=8D=E6=98=A0=E5=B0=84=E9=A1=BB=E6=8C=87=E5=90=91?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E6=B1=A0=E6=A8=A1=E5=9E=8B=20+=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E4=BF=9D=E7=95=99=20poolSlotMappings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pool_slot_entries 增校验:target model 必须在该 provider 当前可选池(pooled_model_ids)内, 否则跳过 —— 删模型 / 换上游清空 pooledModels 后,旧映射不再把标准档暴露/路由到已删模型 - normalize_imported_config 白名单加 poolSlotMappings:导入备份保留全局映射,不再丢失退回单 provider - 更新 pool_slot_entries 测试覆盖「model 不在池→跳过」 --- crates/registry/src/model_alias.rs | 27 +++++++++++++++++++++--- src-tauri/src/admin/handlers/settings.rs | 3 +++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/registry/src/model_alias.rs b/crates/registry/src/model_alias.rs index 870d53a3..75478071 100644 --- a/crates/registry/src/model_alias.rs +++ b/crates/registry/src/model_alias.rs @@ -386,10 +386,23 @@ pub fn pool_slot_entries( 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: strip_internal_model_suffix(model), + real_model: real, supports_one_m: has_internal_one_m_suffix(model), }); } @@ -531,8 +544,11 @@ mod tests { #[test] fn pool_slot_entries_maps_standard_slots_to_valid_pool_targets() { - let a = mk_provider("deepseek", "DeepSeek"); // pooledEnabled=true - let b = mk_provider("kimi", "Kimi"); + 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]; @@ -540,6 +556,7 @@ mod tests { "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)); @@ -562,6 +579,10 @@ mod tests { !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 非标准档不产条目"); } diff --git a/src-tauri/src/admin/handlers/settings.rs b/src-tauri/src/admin/handlers/settings.rs index fdee8a6f..09074d7d 100644 --- a/src-tauri/src/admin/handlers/settings.rs +++ b/src-tauri/src/admin/handlers/settings.rs @@ -142,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()); From 9a08fc41ede57d9f835cee4bbd7b33792c34e2e3 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 11:49:18 +0800 Subject: [PATCH 29/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review(?= =?UTF-8?q?=E6=B1=A0=E6=A8=A1=E5=BC=8F=20catalog=20miss=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=88=B0=E6=B1=A0=E9=BB=98=E8=AE=A4=E6=A1=A3,?= =?UTF-8?q?=E4=B8=8D=E8=B5=B0=E5=8D=95=20provider=20=E6=98=A0=E5=B0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolver 加 pool_default((provider_idx, real_model)=首条标准档映射);池模式 catalog miss (旧会话残留 / 未映射档 / 未知 model)路由到池默认档的精确 (provider, model),不再 fall through 到 map_model_for_provider —— 后者会用 default provider 自己的槽位映射改写、绕过整合 catalog、把流量发给整合页未暴露的模型(#477 P2)。proxy_runner 传首条 entry;+1 回归测试。 --- crates/proxy/src/resolver.rs | 62 ++++++++++++++++++++++++++++++++++- src-tauri/src/proxy_runner.rs | 8 ++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/proxy/src/resolver.rs b/crates/proxy/src/resolver.rs index 9b00675b..8c008737 100644 --- a/crates/proxy/src/resolver.rs +++ b/crates/proxy/src/resolver.rs @@ -134,6 +134,11 @@ pub struct StaticResolver { /// 语义,#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 { @@ -148,6 +153,7 @@ impl StaticResolver { default_provider_id, catalog_slug_map: HashMap::new(), pool_enabled: false, + pool_default: None, } } @@ -160,6 +166,13 @@ impl StaticResolver { 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) } @@ -370,7 +383,23 @@ fn decide_provider<'a>( } } - // 2. 默认 provider + 槽位映射改写。 + // 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) { @@ -912,4 +941,35 @@ mod tests { "池化下 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/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 8017f1ee..f34363f1 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -281,12 +281,18 @@ fn load_resolver_snapshot() -> Result { &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, default_provider_id) - .with_catalog_slug_map(pool_map), + .with_catalog_slug_map(pool_map) + .with_pool_default(pool_default), gateway_auth: true, }) } From 737daf4a4947de41dce2d4faecf0be1a6c294f6e Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 12:02:53 +0800 Subject: [PATCH 30/31] =?UTF-8?q?fix(MOC-236):=20#477=20bot=20review(?= =?UTF-8?q?=E4=B8=B2=E8=A1=8C=E5=8C=96=20poolSlotMappings=20=E4=BF=9D?= =?UTF-8?q?=E5=AD=98,=E9=98=B2=E5=B9=B6=E5=8F=91=E4=B9=B1=E5=BA=8F?= =?UTF-8?q?=E8=A6=86=E7=9B=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连点多个映射 select 时,每次发整份权威快照;并发 PUT 乱序完成会让旧请求覆盖新选择。改为 单飞 + 最新覆盖(last-write-wins):同时只一个 PUT,期间新变更只更 pending,队列排空再统一 重渲染。 --- frontend/js/app.js | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/frontend/js/app.js b/frontend/js/app.js index 9e4b1d16..6b522fb8 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -2576,14 +2576,32 @@ 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 { - await CCApi.setPoolSlotMappings(mappings); - showToast(t("toast.integrationMappingUpdated")); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); + 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; } - await renderProviders(); // 重渲染(成功固化 / 失败复原到后端真实状态 + 重填 model select) + if (ok) showToast(t("toast.integrationMappingUpdated")); + await renderProviders(); // 队列排空后统一重渲染(后端最终状态 + 重填 model select) } // 上池:把已配置的 provider 当候选,加入(pooledEnabled)/移出整合子集。 From 1126c4a1dd6931860296003d9a721711b2b281a4 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 15 Jun 2026 15:02:21 +0800 Subject: [PATCH 31/31] =?UTF-8?q?fix(MOC-236):=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=A1=A5=E3=80=8C=E5=BA=94=E7=94=A8=E3=80=8D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=20+=20=E5=BC=BA=E5=88=B6=E8=B5=B0=E4=BB=A3?= =?UTF-8?q?=E7=90=86(=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88:=E6=95=B4?= =?UTF-8?q?=E5=90=88=E6=B2=A1=E7=94=9F=E6=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:开整合后没有「启用」按钮,Codex 用的还是开整合前那个 provider 的旧配置。根因: 单 provider「应用/启用」按用户要求锁定了,但整合模式本身缺一个应用入口;且 active provider 若是 responses 直连会 bypass 代理、绕过整合池。 - 整合模式 desktop target 强制 local_proxy(不 bypass):base_url=代理、catalog=整合池 - 整合页「模型映射」区加「应用整合并重启 Codex」按钮 → configureDesktop(写 config.toml 池 catalog + 代理)+ 必要时起代理 + 重启 Codex 生效 --- frontend/css/pages/providers.css | 13 ++++++++++ frontend/index.html | 11 +++++--- frontend/js/app.js | 25 +++++++++++++++++++ frontend/js/i18n.js | 4 +++ .../src/admin/services/desktop/snapshot.rs | 6 ++++- 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/frontend/css/pages/providers.css b/frontend/css/pages/providers.css index 164fa3f7..adea4f60 100644 --- a/frontend/css/pages/providers.css +++ b/frontend/css/pages/providers.css @@ -519,6 +519,19 @@ margin-bottom: 14px; } +/* 「模型映射」区头部:标题块左、应用按钮右(整合模式的「启用」入口) */ +.pool-panel-header.with-apply { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.integration-apply-btn { + flex-shrink: 0; + white-space: nowrap; +} + .pool-panel-header h2 { margin: 0 0 4px; font-size: 16px; diff --git a/frontend/index.html b/frontend/index.html index 0c815dfd..53c27657 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -333,9 +333,14 @@

整合的提供商

-
-

模型映射(进 Codex)

-

把 Codex 标准档映射到池中某「提供商 / 模型」。Codex 模型列表里只显示**映射了的**标准档,选某档即走你映射的上游;未映射的档不显示。

+
+
+

模型映射(进 Codex)

+

把 Codex 标准档映射到池中某「提供商 / 模型」。Codex 模型列表里只显示映射了的标准档,选某档即走你映射的上游;未映射的档不显示。

+
+
diff --git a/frontend/js/app.js b/frontend/js/app.js index 6b522fb8..d53ac602 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -4143,6 +4143,31 @@ } // ── 整合页模型池操作 ──────────────────────────────────────────────── + // 应用整合配置到 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; diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 26d9530a..ef1ba7d4 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -477,6 +477,7 @@ "providers.integrationProvidersPoolHint": "选择要加入模型池的提供商。加入后会自动获取其模型并列入下方可选模型。", "providers.integrationModelsPoolTitle": "可选模型(映射来源)", "providers.integrationModelsPoolHint": "每个整合提供商的可选模型清单,作为上方「模型映射」的候选来源。这些模型本身不直接进 Codex,可增删。", + "providers.integrationApply": "应用整合并重启 Codex", "providers.integrationMappingTitle": "模型映射(进 Codex)", "providers.integrationMappingHint": "把 Codex 标准档映射到池中某「提供商 / 模型」。Codex 模型列表里只显示映射了的标准档,选某档即走你映射的上游;未映射的档不显示。改动后需重新一键应用并重启 Codex。", "providers.integrationSlotUnset": "— 不映射 —", @@ -739,6 +740,7 @@ "toast.integrationProviderRemoved": "已移出整合", "toast.integrationModelsUpdated": "模型池已更新,请重新一键应用并重启 Codex", "toast.integrationMappingUpdated": "模型映射已更新,请重新一键应用并重启 Codex", + "toast.integrationApplied": "整合配置已写入 Codex,正在重启生效", "toast.compatibilityChecked": "兼容性检查完成", "toast.requestFailed": "操作失败,请查看后端日志", "confirm.desktopApply": "即将生成 Codex CLI 环境变量配置命令并复制到剪贴板。确认继续?", @@ -1228,6 +1230,7 @@ "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 —", @@ -1497,6 +1500,7 @@ "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/services/desktop/snapshot.rs b/src-tauri/src/admin/services/desktop/snapshot.rs index 2caac9d6..ff61b228 100644 --- a/src-tauri/src/admin/services/desktop/snapshot.rs +++ b/src-tauri/src/admin/services/desktop/snapshot.rs @@ -179,9 +179,13 @@ pub fn desktop_config_target_for_provider( .trim() .to_owned(); let direct_api_key = provider_api_key(provider); + // 整合模式必须走本地代理(池按 catalog slug 分流到各上游)→ 即便 active provider 是 + // responses 直连,也不能 bypass,否则 Codex 直连该 provider、绕过整合池(#477 用户反馈: + // 整合开了但 Codex 用的还是开整合前的 provider 配置)。 let bypass_proxy = matches!(api_format_lower.as_str(), "responses" | "openai_responses") && !provider_base_url.is_empty() - && !direct_api_key.is_empty(); + && !direct_api_key.is_empty() + && !read_setting_bool(cfg, "exposeAllProviderModels", false); let codex_network_access = crate::admin::handlers::proxy::read_codex_network_access(cfg);