diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 63dbf72da..5a6da2b36 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1431,7 +1431,84 @@ pub fn normalize_config_text(contents: &str) -> String { normalize_duplicate_toml_text(contents) } +/// 把可能含重复表头/重复根键的文本,折叠成一份合法、去重的 TOML。 +/// +/// 历史实现是逐行文本去重:按表头整行字符串匹配,撞见第二次出现的 `[header]` +/// 就把该表体整段丢弃。这在语义上是错的——`[mcp_servers.node_repl]`(带正确的 +/// `.env` 子表)和其后一个裸的 `[mcp_servers]`(空表头,来自历史脚本/手改残留) +/// 是两个不同的表头字符串,行级去重完全看不到它们其实是同一棵 TOML 树上的父子 +/// 关系;反过来,若两次出现的是同一个表头但各自只写了部分字段,行级去重会把后一 +/// 份连同它独有的字段整段丢弃,而不是把两份合并。真实故障(Codex `config.toml` +/// 反复复现 `invalid transport`)就是这条路径把裸 env 变量和 `.env` 子表的残留 +/// 一起原样保留进了最终文件。 +/// +/// 改成按顶层表头切块 + 用已有的 `merge_toml_table_like` 做语义合并:每块单独 +/// 解析成 `DocumentMut`(块内容本身必须是合法 TOML),后出现的块合并进先出现的 +/// 同名表(标量后写覆盖前写,子表递归合并、不清空),根键重复时也是后写覆盖。 +/// 任何一块解析失败就说明输入本身已经坏到不是逐块可解析的程度,退回历史的逐行 +/// 丢弃策略,不让这次修复反而让原本能跑的输入报错。 fn normalize_duplicate_toml_text(contents: &str) -> String { + match merge_duplicate_toml_blocks(contents) { + Some(merged) => normalize_optional_toml(merged), + None => normalize_duplicate_toml_text_line_fallback(contents), + } +} + +/// 按不带前导空白的 `[table]` / `[[array_table]]` 行、以及根级重复键,切成若干 +/// 独立可解析的 TOML 片段,再逐块 parse 后语义合并。 +/// +/// 只按表头切块不够:`model = "a"\nmodel = "b"\n` 两行根键都落在同一个块里, +/// 直接喂给 `DocumentMut::parse` 会因为 TOML 语法本身不允许根级重复键而整体 +/// 解析失败。所以还要在「当前块的根级部分已经出现过这个 key」时,先切出一个 +/// 新块,让后一次赋值单独成块参与合并(合并语义是后写覆盖前写)。 +fn merge_duplicate_toml_blocks(contents: &str) -> Option { + let mut blocks: Vec = Vec::new(); + let mut current = String::new(); + let mut current_in_root = true; + let mut current_root_keys: HashSet = HashSet::new(); + + for line in contents.lines() { + let trimmed = line.trim(); + let is_new_table_header = trimmed.starts_with('[') && trimmed.ends_with(']'); + + if is_new_table_header { + if !current.trim().is_empty() { + blocks.push(std::mem::take(&mut current)); + } + current_in_root = false; + current_root_keys.clear(); + } else if current_in_root && !trimmed.is_empty() && !trimmed.starts_with('#') { + if let Some((key, _)) = trimmed.split_once('=') { + let key = key.trim().to_string(); + if !key.is_empty() && !current_root_keys.insert(key) { + // 同一个根键在当前块里再次出现:先把当前块切出去, + // 让这一行作为新块的第一行,参与合并时以「后写」身份覆盖前值。 + if !current.trim().is_empty() { + blocks.push(std::mem::take(&mut current)); + } + current_root_keys.clear(); + } + } + } + + current.push_str(line); + current.push('\n'); + } + if !current.trim().is_empty() { + blocks.push(current); + } + + let mut merged = DocumentMut::new(); + for block in blocks { + let block_doc: DocumentMut = block.parse().ok()?; + merge_toml_table_like(merged.as_table_mut(), block_doc.as_table()); + } + Some(merged) +} + +/// 逐块合并失败时的保底路径:原历史实现,逐行文本去重(丢弃后出现的重复表头/ +/// 根键),保证至少不比修复前更差。 +fn normalize_duplicate_toml_text_line_fallback(contents: &str) -> String { let mut seen_root_keys = HashSet::new(); let mut seen_headers = HashSet::new(); let mut kept = Vec::new(); @@ -1683,6 +1760,18 @@ fn apply_model_catalog_to_config( // Catalog capabilities must follow the effective config, not stale profile URLs. let official_deepseek_responses = uses_official_deepseek_responses_for_config(profile, &config_text); + let (model_list, model_windows): (String, std::collections::HashMap) = + if profile.model_windows.trim().is_empty() && profile.model_list.contains('[') { + crate::model_suffix::migrate_model_list_with_suffixes(&profile.model_list) + } else { + ( + profile.model_list.clone(), + serde_json::from_str(&profile.model_windows).unwrap_or_default(), + ) + }; + let entries = + crate::model_suffix::collect_catalog_entries(&model_list, &model_windows, &profile.model); + let fallback = parse_optional_positive_u64(&profile.context_window, "上下文大小")?; // 用户已手写 model_catalog_json 指针时保留,不覆盖(保 preserves_user_model_catalog_json 测试) // 仅当现有指针指向本 profile 自己生成的 catalog 时才重新生成。 // cc-switch 的固定文件名属于已知的其他管理器投影,不视为用户手写 catalog; @@ -1694,7 +1783,13 @@ fn apply_model_catalog_to_config( } else if official_deepseek_responses { return Ok(config_text.to_string()); } else if custom_responses - && copy_standard_responses_catalog(home, &existing, &catalog_relative)? + && copy_standard_responses_catalog( + home, + &existing, + &catalog_relative, + &entries, + fallback, + )? { let mut doc = parse_toml_document(&config_text)?; doc["model_catalog_json"] = toml_edit::value(catalog_relative); @@ -1709,7 +1804,13 @@ fn apply_model_catalog_to_config( { let mut doc = parse_toml_document(&config_text)?; if custom_responses - && copy_standard_responses_catalog(home, &external_catalog, &catalog_relative)? + && copy_standard_responses_catalog( + home, + &external_catalog, + &catalog_relative, + &entries, + fallback, + )? { doc["model_catalog_json"] = toml_edit::value(catalog_relative); } else { @@ -1717,17 +1818,6 @@ fn apply_model_catalog_to_config( } return Ok(normalize_optional_toml(doc)); } - let (model_list, model_windows): (String, std::collections::HashMap) = - if profile.model_windows.trim().is_empty() && profile.model_list.contains('[') { - crate::model_suffix::migrate_model_list_with_suffixes(&profile.model_list) - } else { - ( - profile.model_list.clone(), - serde_json::from_str(&profile.model_windows).unwrap_or_default(), - ) - }; - let entries = - crate::model_suffix::collect_catalog_entries(&model_list, &model_windows, &profile.model); // Known bundled metadata entries need a catalog even without a user-supplied window. if !entries.iter().any(|entry| { entry.suffix_window.is_some() @@ -1736,7 +1826,6 @@ fn apply_model_catalog_to_config( }) { return Ok(config_text); } - let fallback = parse_optional_positive_u64(&profile.context_window, "上下文大小")?; let catalog_path = home.join(&catalog_relative); if let Some(parent) = catalog_path.parent() { std::fs::create_dir_all(parent)?; @@ -1877,6 +1966,8 @@ fn copy_standard_responses_catalog( home: &Path, source: &str, target_relative: &str, + entries: &[crate::model_suffix::ModelCatalogEntry], + fallback_window: Option, ) -> anyhow::Result { let source_path = { let path = Path::new(source); @@ -1896,11 +1987,29 @@ fn copy_standard_responses_catalog( return Ok(false); }; let mut changed = false; + let configured_windows = entries + .iter() + .map(|entry| (entry.slug.as_str(), entry.suffix_window.or(fallback_window))) + .collect::>(); for model in models { if model.get("use_responses_lite").and_then(Value::as_bool) == Some(true) { model["use_responses_lite"] = Value::Bool(false); changed = true; } + let Some(slug) = model.get("slug").and_then(Value::as_str) else { + continue; + }; + let Some(Some(window)) = configured_windows.get(slug) else { + continue; + }; + if model.get("context_window").and_then(Value::as_u64) != Some(*window) { + model["context_window"] = json!(window); + changed = true; + } + if model.get("max_context_window").and_then(Value::as_u64) != Some(*window) { + model["max_context_window"] = json!(window); + changed = true; + } } if !changed { return Ok(false); @@ -3090,6 +3199,64 @@ fn account_label_from_jwt(token: &str) -> Option { mod tests { use super::*; + /// 回归真实故障:`[mcp_servers.node_repl]`(带正确的 `.env` 子表)之后又混入 + /// 一个裸的空 `[mcp_servers]` 表头。行级去重会把两者当成互不相干的字符串, + /// 原样保留进输出——空表头在 TOML 语义上会与已有子表冲突,Codex 加载时报 + /// `invalid transport`。语义合并后 `mcp_servers.node_repl` 的字段必须完整 + /// 保留,且不能再出现裸的 `[mcp_servers]` 空表头。 + #[test] + fn normalize_duplicate_toml_text_merges_child_table_with_later_empty_parent_header() { + let contents = "\ +[mcp_servers.node_repl] +command = \"node\" + +[mcp_servers.node_repl.env] +CODEX_HOME = \"/home/user/.codex\" + +[mcp_servers] +"; + + let normalized = normalize_duplicate_toml_text(contents); + let doc = normalized.parse::().expect("must stay valid TOML"); + + assert_eq!( + doc["mcp_servers"]["node_repl"]["command"].as_str(), + Some("node") + ); + assert_eq!( + doc["mcp_servers"]["node_repl"]["env"]["CODEX_HOME"].as_str(), + Some("/home/user/.codex") + ); + } + + /// 同一个表头出现两次、各自只写了部分字段:语义合并要拼成一份完整的表, + /// 而不是按旧的行级去重丢弃第二次出现的整段内容。 + #[test] + fn normalize_duplicate_toml_text_merges_fields_from_repeated_same_header() { + let contents = "\ +[mcp_servers.node_repl] +command = \"node\" + +[mcp_servers.node_repl] +cwd = \"/tmp\" +"; + + let normalized = normalize_duplicate_toml_text(contents); + let doc = normalized.parse::().expect("must stay valid TOML"); + + assert_eq!(doc["mcp_servers"]["node_repl"]["command"].as_str(), Some("node")); + assert_eq!(doc["mcp_servers"]["node_repl"]["cwd"].as_str(), Some("/tmp")); + } + + /// 重复根键:后写覆盖前写,与 TOML 对同名键重复赋值时的直觉一致。 + #[test] + fn normalize_duplicate_toml_text_later_root_key_wins() { + let contents = "model = \"a\"\nmodel = \"b\"\n"; + let normalized = normalize_duplicate_toml_text(contents); + let doc = normalized.parse::().expect("must stay valid TOML"); + assert_eq!(doc["model"].as_str(), Some("b")); + } + #[test] fn merge_common_config_preserves_explicit_profile_goals_override() { let disabled = merge_common_config_into_config( diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index fd6738ee6..1ebae82c6 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -4493,6 +4493,8 @@ fn apply_relay_profile_copies_external_lite_catalog_for_standard_responses() { "models": [ { "slug": "gpt-5.6-sol", + "context_window": 272000, + "max_context_window": 272000, "supports_search_tool": true, "web_search_tool_type": "text_and_image", "use_responses_lite": true @@ -4521,6 +4523,8 @@ experimental_bearer_token = "sk-new" ), auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(), model_list: "gpt-5.6-sol".to_string(), + context_window: "1000000".to_string(), + auto_compact_limit: "900000".to_string(), ..RelayProfile::default() }; @@ -4543,6 +4547,15 @@ experimental_bearer_token = "sk-new" "text_and_image" ); assert_eq!(copied["models"][0]["use_responses_lite"], false); + assert_eq!(copied["models"][0]["context_window"], 1_000_000); + assert_eq!(copied["models"][0]["max_context_window"], 1_000_000); + + let config_value: toml::Value = toml::from_str(&config).unwrap(); + assert_eq!(config_value["model_context_window"].as_integer(), Some(1_000_000)); + assert_eq!( + config_value["model_auto_compact_token_limit"].as_integer(), + Some(900_000) + ); let original: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(external_catalog).unwrap()).unwrap();