mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(codex): disable web_search for native gateways that reject it
Some native /responses gateways whose first-party models lack OpenAI's hosted web_search tool (MiMo, LongCat, MiniMax) reject a web_search tool with "tool type 'web_search' is not supported by this gateway phase". Codex sends the tool by default (config-driven, not gated by the catalog's supports_search_tool), so cc-switch now writes the top-level `web_search = "disabled"` line per those vendors' official Codex docs. Scope is a blacklist (default-on): only providers matched by base_url host or model brand prefix are disabled; relays serving real GPT, DouBao, Qwen, and any unknown provider keep Codex's default. Matching by model brand (not just host) also catches aggregators (e.g. SiliconFlow) fronting a reject vendor's model. The field is injected alongside model_catalog_json at switch time and removed via an ownership sentinel, so existing providers need no re-save and switching back re-enables web search.
This commit is contained in:
@@ -13,6 +13,83 @@ use toml_edit::DocumentMut;
|
||||
|
||||
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
|
||||
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
|
||||
|
||||
/// Top-level `config.toml` key that controls Codex's built-in web-search tool.
|
||||
const CODEX_WEB_SEARCH_FIELD: &str = "web_search";
|
||||
/// Value that disables the web-search tool. Some native `/responses` gateways
|
||||
/// reject a `web_search` tool with `responses_feature_not_supported` ("tool type
|
||||
/// 'web_search' is not supported by this gateway phase"), so for those we write
|
||||
/// this per the vendors' official Codex docs. Also doubles as cc-switch's
|
||||
/// ownership sentinel: we only ever remove a `web_search` key whose value equals
|
||||
/// this string, never a user's own setting.
|
||||
const CODEX_WEB_SEARCH_DISABLED: &str = "disabled";
|
||||
|
||||
/// Native `/responses` gateways whose first-party models do NOT support the Codex
|
||||
/// `web_search` hosted tool. A BLACKLIST (default-on): everything not listed keeps
|
||||
/// Codex's default, so relays/aggregators fronting real GPT — and any unknown
|
||||
/// provider — are never touched. This avoids a whitelist's dangerous failure mode
|
||||
/// (a fragile "is this GPT?" heuristic wrongly keeping web_search ON → hard 400);
|
||||
/// the blacklist's failure mode is the safe, recoverable one (a not-yet-listed
|
||||
/// broken gateway errors once → add it here).
|
||||
///
|
||||
/// Matched two ways so an aggregator (e.g. SiliconFlow) fronting these vendors'
|
||||
/// models is also caught:
|
||||
/// - `base_url` host substring, and
|
||||
/// - the model id's brand prefix (after stripping any `vendor/` path segment).
|
||||
///
|
||||
/// Verified 2026-06-27 doc audit — reject: MiMo (hard 400), LongCat (official
|
||||
/// config ships `web_search = "disabled"`), MiniMax (tool-type enum `['function']`
|
||||
/// only). Deliberately NOT listed (they support it): 火山方舟豆包, 阿里百炼 Qwen,
|
||||
/// and all GPT-native relays.
|
||||
const CODEX_WEB_SEARCH_REJECT_HOSTS: &[&str] = &[
|
||||
"xiaomimimo.com", // Xiaomi MiMo (api.xiaomimimo.com, token-plan-cn.xiaomimimo.com)
|
||||
"longcat.chat", // Meituan LongCat (api.longcat.chat)
|
||||
"minimax.io", // MiniMax global (api.minimax.io)
|
||||
"minimaxi.com", // MiniMax CN (api.minimaxi.com)
|
||||
];
|
||||
|
||||
/// Brand prefixes of models whose native gateways reject `web_search`, matched
|
||||
/// against the model id's last `/`-segment so aggregator ids like
|
||||
/// `MiniMaxAI/MiniMax-M3` are caught. Exact brand names (not a fuzzy heuristic),
|
||||
/// so a supporting gateway is never wrongly matched.
|
||||
const CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES: &[&str] = &["mimo", "longcat", "minimax"];
|
||||
|
||||
/// Top-level `model` id from a Codex `config.toml`.
|
||||
fn codex_top_level_model(config_text: &str) -> Option<String> {
|
||||
let doc = config_text.parse::<toml::Value>().ok()?;
|
||||
doc.get("model")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Whether a native `/responses` provider's gateway is known to reject the Codex
|
||||
/// `web_search` hosted tool — by `base_url` host OR by the active model's brand
|
||||
/// (so an aggregator fronting a reject vendor's model is caught too). Driven by
|
||||
/// the live `config.toml`, so it applies to existing providers without a re-save.
|
||||
fn codex_native_gateway_rejects_web_search(config_text: &str) -> bool {
|
||||
if let Some(base_url) = extract_codex_base_url(config_text) {
|
||||
let base_url = base_url.to_ascii_lowercase();
|
||||
if CODEX_WEB_SEARCH_REJECT_HOSTS
|
||||
.iter()
|
||||
.any(|host| base_url.contains(host))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(model) = codex_top_level_model(config_text) {
|
||||
let model = model.to_ascii_lowercase();
|
||||
// Strip any aggregator "vendor/" prefix, e.g. "MiniMaxAI/MiniMax-M3".
|
||||
let model = model.rsplit('/').next().unwrap_or(model.as_str());
|
||||
if CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| model.starts_with(prefix))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
|
||||
|
||||
/// Which Codex tool surface the generated model catalog should target.
|
||||
@@ -828,6 +905,37 @@ fn set_codex_model_catalog_json_field(
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Pure toggle for the top-level `web_search` field that turns Codex's built-in
|
||||
/// web-search tool off. When `disable` is true we write `web_search = "disabled"`
|
||||
/// (the catalog's `supports_search_tool` does NOT gate this — the request-time
|
||||
/// tool comes from the config, defaulting on). When false we *remove* the field,
|
||||
/// but only when it carries cc-switch's own `"disabled"` sentinel, so switching
|
||||
/// back to a web-search-capable provider re-enables it without clobbering a
|
||||
/// user's manual setting.
|
||||
///
|
||||
/// The caller decides `disable` (see `codex_native_gateway_rejects_web_search`);
|
||||
/// lifecycle is bound to the cc-switch catalog pointer so the field is set/cleaned
|
||||
/// up wherever the native catalog is written/removed.
|
||||
fn set_codex_native_web_search_field(config_text: &str, disable: bool) -> Result<String, AppError> {
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||
|
||||
if disable {
|
||||
doc[CODEX_WEB_SEARCH_FIELD] = toml_edit::value(CODEX_WEB_SEARCH_DISABLED);
|
||||
} else {
|
||||
let owned = doc
|
||||
.get(CODEX_WEB_SEARCH_FIELD)
|
||||
.and_then(|item| item.as_str())
|
||||
== Some(CODEX_WEB_SEARCH_DISABLED);
|
||||
if owned {
|
||||
doc.as_table_mut().remove(CODEX_WEB_SEARCH_FIELD);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Generate Codex `model_catalog_json` from provider settings and inject/remove
|
||||
/// the top-level TOML field that points Codex to the generated file.
|
||||
pub fn prepare_codex_config_text_with_model_catalog(
|
||||
@@ -839,10 +947,17 @@ pub fn prepare_codex_config_text_with_model_catalog(
|
||||
|
||||
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text, profile)? {
|
||||
let config_text = set_codex_model_catalog_json_field(config_text, Some(&catalog_path))?;
|
||||
// Disable web_search only for native gateways on the reject blacklist
|
||||
// (MiMo/LongCat/MiniMax, by host or model brand). Everything else —
|
||||
// relays, DouBao/Qwen, unknown providers — keeps Codex's default.
|
||||
let disable_web_search = profile == CodexCatalogToolProfile::NativeResponses
|
||||
&& codex_native_gateway_rejects_web_search(&config_text);
|
||||
let config_text = set_codex_native_web_search_field(&config_text, disable_web_search)?;
|
||||
write_json_file(&catalog_path, &catalog)?;
|
||||
Ok(config_text)
|
||||
} else {
|
||||
set_codex_model_catalog_json_field(config_text, None)
|
||||
let config_text = set_codex_model_catalog_json_field(config_text, None)?;
|
||||
set_codex_native_web_search_field(&config_text, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2423,6 +2538,119 @@ name = "any"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_web_search_field_disables_at_top_level() {
|
||||
// Native `/responses` gateways reject the web_search tool, so the
|
||||
// NativeResponses profile must write the top-level disable line even
|
||||
// when sections are present (it must NOT land inside a section).
|
||||
let input = r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "xiaomi_mimo"
|
||||
"#;
|
||||
let result = set_codex_native_web_search_field(input, true).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert_eq!(
|
||||
parsed.get("web_search").and_then(|value| value.as_str()),
|
||||
Some("disabled")
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.get("model_providers")
|
||||
.and_then(|value| value.get("custom"))
|
||||
.and_then(|value| value.get("web_search"))
|
||||
.is_none(),
|
||||
"web_search should stay top-level"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_web_search_field_removes_own_sentinel_when_not_disabled() {
|
||||
// Switching away from a native provider must re-enable web search by
|
||||
// removing cc-switch's own "disabled" sentinel.
|
||||
let input = r#"model = "gpt-5.5"
|
||||
web_search = "disabled"
|
||||
"#;
|
||||
let result = set_codex_native_web_search_field(input, false).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert!(
|
||||
parsed.get("web_search").is_none(),
|
||||
"cc-switch's disabled sentinel should be removed when not native"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_web_search_field_preserves_user_value() {
|
||||
// A user's own web_search value must never be clobbered by cleanup,
|
||||
// only cc-switch's "disabled" sentinel is owned/removable.
|
||||
let input = r#"web_search = "enabled"
|
||||
"#;
|
||||
let result = set_codex_native_web_search_field(input, false).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert_eq!(
|
||||
parsed.get("web_search").and_then(|value| value.as_str()),
|
||||
Some("enabled"),
|
||||
"a user-set web_search value must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_blacklist_disables_only_known_reject_gateways() {
|
||||
let cfg = |model: &str, base_url: &str| {
|
||||
format!(
|
||||
"model_provider = \"custom\"\nmodel = \"{model}\"\n\n[model_providers.custom]\nname = \"x\"\nbase_url = \"{base_url}\"\nwire_api = \"responses\"\n"
|
||||
)
|
||||
};
|
||||
|
||||
// Blacklisted by host (first-party reject gateways) → disable.
|
||||
for (model, host) in [
|
||||
("mimo-v2.5-pro", "https://api.xiaomimimo.com/v1"),
|
||||
("mimo-v2.5", "https://token-plan-cn.xiaomimimo.com/v1"),
|
||||
("LongCat-2.0-Preview", "https://api.longcat.chat/openai/v1"),
|
||||
("MiniMax-M3", "https://api.minimax.io/v1"),
|
||||
("MiniMax-M3", "https://api.minimaxi.com/v1"),
|
||||
] {
|
||||
assert!(
|
||||
codex_native_gateway_rejects_web_search(&cfg(model, host)),
|
||||
"{host} should be blacklisted"
|
||||
);
|
||||
}
|
||||
|
||||
// Blacklisted by MODEL brand even on an aggregator host (SiliconFlow
|
||||
// fronting a reject vendor's model) → disable.
|
||||
for (model, host) in [
|
||||
("MiniMax-M3", "https://api.siliconflow.cn/v1"),
|
||||
("MiniMaxAI/MiniMax-M3", "https://api.siliconflow.cn/v1"),
|
||||
("mimo-v2.5-pro", "https://some-aggregator.example/v1"),
|
||||
] {
|
||||
assert!(
|
||||
codex_native_gateway_rejects_web_search(&cfg(model, host)),
|
||||
"{model} @ {host} should be blacklisted by model brand"
|
||||
);
|
||||
}
|
||||
|
||||
// NOT blacklisted → keep Codex default (relays/GPT, DouBao, Qwen, and any
|
||||
// unknown provider incl. an aggregator serving a non-reject model).
|
||||
for (model, host) in [
|
||||
("gpt-5.5", "https://www.packyapi.com/v1"),
|
||||
("gpt-5-codex", "https://aihubmix.com/v1"),
|
||||
(
|
||||
"doubao-seed-2-1-pro",
|
||||
"https://ark.cn-beijing.volces.com/api/v3",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-plus",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
("Pro/moonshotai/Kimi-K2.6", "https://api.siliconflow.cn/v1"),
|
||||
] {
|
||||
assert!(
|
||||
!codex_native_gateway_rejects_web_search(&cfg(model, host)),
|
||||
"{model} @ {host} should NOT be blacklisted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_catalog_path_returns_none_when_config_missing_field() {
|
||||
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
|
||||
|
||||
Reference in New Issue
Block a user