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 1/5] =?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 2/5] =?UTF-8?q?fix(MOC-236):=20=E6=B1=A0=E5=8C=96=20catalo?= =?UTF-8?q?g=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 3/5] =?UTF-8?q?fix(MOC-236):=20=E6=B1=A0=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=20provider=20CRUD/autofill/reorder=20=E5=90=8E=E9=87=8D?= =?UTF-8?q?=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 4/5] =?UTF-8?q?fix(MOC-236):=20=E6=B1=A0=E5=8C=96=E5=BC=80?= =?UTF-8?q?=E5=85=B3=20re-apply=20=E5=A4=B1=E8=B4=A5=E6=94=B9=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20loud=20log(=E5=8E=BB=E5=89=8D=E7=AB=AF=E6=9C=AA?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E7=9A=84=E5=93=8D=E5=BA=94=E5=AD=97=E6=AE=B5?= =?UTF-8?q?)?= 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 5/5] =?UTF-8?q?fix(MOC-236):=20=E6=B1=A0=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=20CRUD=20re-sync=20=E5=A4=B1=E8=B4=A5=20loud=20log(=E4=B8=8D?= =?UTF-8?q?=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}" + ); } }