feat(codex): support native Responses direct-connect with generated model catalog

Codex providers can now run in two modes per provider:

- Proxy-Chat (route takeover): apiFormat=openai_chat, existing Responses<->Chat conversion. Unchanged.

- Native-Responses (direct): apiFormat=openai_responses, no proxy. cc-switch generates ~/.codex/cc-switch-model-catalog.json so Codex shows the custom models and tools work without the freeform apply_patch (type=custom) tool that native gateways like MiMo reject; editing falls back to shell_command.

Catalog generation is keyed on apiFormat (CodexCatalogToolProfile), decoupled from the takeover toggle, so native providers persist a catalog without enabling local route mapping.

Codex's catalog parser requires base_instructions on every entry; the native template carries a neutral default and per-vendor official text overrides it (MiMo, MiniMax). Synthesized catalogs for Qwen/Doubao/LongCat use the neutral default.

Existing native providers must be re-saved once to regenerate a valid catalog (no DB migration).
This commit is contained in:
Jason
2026-06-27 23:12:30 +08:00
parent 61d7ac01fb
commit 15e712e779
11 changed files with 692 additions and 154 deletions
+299 -24
View File
@@ -15,6 +15,33 @@ pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
/// Which Codex tool surface the generated model catalog should target.
///
/// - `ProxyChat`: cc-switch's proxy takes over and converts Responses<->Chat,
/// so the catalog keeps Codex's default tool set (incl. the freeform
/// `apply_patch` custom tool, which the proxy rewrites to a function tool).
/// - `NativeResponses`: Codex talks directly to a provider's native
/// `/responses` endpoint (no proxy). Such gateways (e.g. Xiaomi MiMo,
/// MiniMax) reject `type=="custom"` tools, so the catalog must suppress the
/// freeform `apply_patch` and rely on `shell_type="shell_command"` for edits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexCatalogToolProfile {
ProxyChat,
NativeResponses,
}
impl CodexCatalogToolProfile {
/// Pick the catalog tool profile from a provider's `apiFormat` meta value.
/// Native (direct) Responses providers must suppress the custom apply_patch
/// tool; everything else keeps the proxy-chat behavior.
pub fn from_api_format(api_format: Option<&str>) -> Self {
match api_format {
Some("openai_responses") => CodexCatalogToolProfile::NativeResponses,
_ => CodexCatalogToolProfile::ProxyChat,
}
}
}
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
/// removed provider aliases.
@@ -313,27 +340,63 @@ fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option<u64> {
fn codex_catalog_model_entry(
template: &Value,
model: &str,
display_name: &str,
context_window: u64,
spec: &CodexCatalogModelSpec,
priority: usize,
profile: CodexCatalogToolProfile,
) -> Value {
let mut entry = template.clone();
let Some(entry_obj) = entry.as_object_mut() else {
return json!({});
};
entry_obj.insert("slug".to_string(), json!(model));
entry_obj.insert("display_name".to_string(), json!(display_name));
entry_obj.insert("description".to_string(), json!(display_name));
entry_obj.insert("context_window".to_string(), json!(context_window));
entry_obj.insert("max_context_window".to_string(), json!(context_window));
entry_obj.insert("slug".to_string(), json!(spec.model));
entry_obj.insert("display_name".to_string(), json!(spec.display_name));
entry_obj.insert("description".to_string(), json!(spec.display_name));
entry_obj.insert("context_window".to_string(), json!(spec.context_window));
entry_obj.insert("max_context_window".to_string(), json!(spec.context_window));
entry_obj.insert("priority".to_string(), json!(1000 + priority));
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
entry_obj.insert("service_tiers".to_string(), json!([]));
entry_obj.insert("availability_nux".to_string(), Value::Null);
entry_obj.insert("upgrade".to_string(), Value::Null);
if profile == CodexCatalogToolProfile::NativeResponses {
// Native `/responses` gateways reject Codex's freeform `apply_patch`
// (type=="custom") tool. Strip any key that would make Codex emit a
// custom/freeform tool, and rely on shell_type="shell_command" for
// edits. Defensive even though the native template is already clean
// (guards against template drift / an accidental gpt-5.5 clone).
//
// NOTE: `base_instructions` is NOT stripped — Codex's catalog parser
// treats it as a REQUIRED field and refuses to load the file without
// it ("missing field `base_instructions`"). The template carries a
// neutral identity default; per-vendor official text overrides below.
for key in [
"apply_patch_tool_type",
"web_search_tool_type",
"tools",
"model_messages",
] {
entry_obj.remove(key);
}
entry_obj.insert("shell_type".to_string(), json!("shell_command"));
if let Some(base_instructions) = spec
.base_instructions
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
entry_obj.insert("base_instructions".to_string(), json!(base_instructions));
}
if let Some(parallel) = spec.supports_parallel_tool_calls {
entry_obj.insert("supports_parallel_tool_calls".to_string(), json!(parallel));
}
if let Some(modalities) = &spec.input_modalities {
entry_obj.insert("input_modalities".to_string(), json!(modalities));
}
}
entry
}
@@ -342,6 +405,18 @@ struct CodexCatalogModelSpec {
model: String,
display_name: String,
context_window: u64,
/// Per-row override for the native template's `supports_parallel_tool_calls`
/// (e.g. MiniMax=true, MiMo=false). Only consulted for `NativeResponses`.
supports_parallel_tool_calls: Option<bool>,
/// Per-row override for the native template's `input_modalities`
/// (e.g. `["text","image"]`). Only consulted for `NativeResponses`.
input_modalities: Option<Vec<String>>,
/// Per-row override for the native template's `base_instructions` (the
/// model identity / system preamble). Carries each vendor's OFFICIAL value
/// (e.g. MiMo "developed by Xiaomi", MiniMax "based on MiniMax-M3"); falls
/// back to the template default when absent. Only consulted for
/// `NativeResponses`.
base_instructions: Option<String>,
}
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
@@ -386,10 +461,38 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
)
.unwrap_or(default_context_window);
let supports_parallel_tool_calls = model_config
.get("supportsParallelToolCalls")
.or_else(|| model_config.get("supports_parallel_tool_calls"))
.and_then(|value| value.as_bool());
let input_modalities = model_config
.get("inputModalities")
.or_else(|| model_config.get("input_modalities"))
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(str::to_string)
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty());
let base_instructions = model_config
.get("baseInstructions")
.or_else(|| model_config.get("base_instructions"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
specs.push(CodexCatalogModelSpec {
model: model.to_string(),
display_name: display_name.to_string(),
context_window,
supports_parallel_tool_calls,
input_modalities,
base_instructions,
});
}
@@ -628,6 +731,18 @@ fn load_codex_model_template_static() -> Option<Value> {
}
}
/// Bundled clean template for native `/responses` providers. Unlike the
/// gpt-5.5 template it carries NO freeform `apply_patch` / `web_search` tool
/// declarations and no GPT-5 base_instructions, so Codex never emits a
/// `type=="custom"` tool that native gateways (MiMo/MiniMax/…) reject. Edits
/// flow through `shell_type="shell_command"` instead. We deliberately do NOT
/// fall back to `models_cache.json` here (that would reintroduce gpt-5.5's
/// freeform apply_patch).
fn load_codex_native_responses_template() -> Value {
let text = include_str!("resources/codex_native_responses_template.json");
serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON")
}
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
// ① models_cache.json (created by Codex when it connects to OpenAI)
if let Some(template) = load_codex_model_template_from_cache()? {
@@ -647,19 +762,15 @@ fn load_codex_model_catalog_template() -> Result<Value, AppError> {
)))
}
fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Value) -> Value {
fn codex_model_catalog_from_specs(
specs: &[CodexCatalogModelSpec],
template: &Value,
profile: CodexCatalogToolProfile,
) -> Value {
let entries: Vec<Value> = specs
.iter()
.enumerate()
.map(|(index, spec)| {
codex_catalog_model_entry(
template,
&spec.model,
&spec.display_name,
spec.context_window,
index,
)
})
.map(|(index, spec)| codex_catalog_model_entry(template, spec, index, profile))
.collect();
json!({ "models": entries })
@@ -668,14 +779,23 @@ fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Va
fn codex_model_catalog_from_settings(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<Option<Value>, AppError> {
let specs = codex_catalog_model_specs(settings, config_text);
if specs.is_empty() {
return Ok(None);
}
let template = load_codex_model_catalog_template()?;
Ok(Some(codex_model_catalog_from_specs(&specs, &template)))
// Native providers use the bundled clean template (no freeform apply_patch,
// no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5
// entry so the proxy can rewrite custom<->function tools as before.
let template = match profile {
CodexCatalogToolProfile::NativeResponses => load_codex_native_responses_template(),
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
};
Ok(Some(codex_model_catalog_from_specs(
&specs, &template, profile,
)))
}
fn set_codex_model_catalog_json_field(
@@ -713,10 +833,11 @@ fn set_codex_model_catalog_json_field(
pub fn prepare_codex_config_text_with_model_catalog(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<String, AppError> {
let catalog_path = get_codex_model_catalog_path();
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text)? {
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))?;
write_json_file(&catalog_path, &catalog)?;
Ok(config_text)
@@ -839,6 +960,26 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
obj.insert("contextWindow".to_string(), json!(context_window));
}
// Preserve native-profile per-row overrides so a DB-SSOT-missing
// fallback round-trip doesn't silently drop them (they are ignored by
// the ProxyChat profile, so carrying them is harmless).
if let Some(parallel) = entry
.get("supports_parallel_tool_calls")
.and_then(|v| v.as_bool())
{
obj.insert("supportsParallelToolCalls".to_string(), json!(parallel));
}
if let Some(modalities) = entry.get("input_modalities").and_then(|v| v.as_array()) {
let mods: Vec<String> = modalities
.iter()
.filter_map(|m| m.as_str())
.map(str::to_string)
.collect();
if !mods.is_empty() {
obj.insert("inputModalities".to_string(), json!(mods));
}
}
entries.push(Value::Object(obj));
}
@@ -876,9 +1017,10 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
pub fn prepare_codex_live_config_text_with_optional_catalog(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<String, AppError> {
if settings.get("modelCatalog").is_some() {
prepare_codex_config_text_with_model_catalog(settings, config_text)
prepare_codex_config_text_with_model_catalog(settings, config_text, profile)
} else {
Ok(config_text.to_string())
}
@@ -889,9 +1031,10 @@ pub fn write_codex_provider_live_with_catalog(
category: Option<&str>,
auth: &Value,
config_text: Option<&str>,
profile: CodexCatalogToolProfile,
) -> Result<(), AppError> {
let prepared_config = config_text
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text))
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text, profile))
.transpose()?;
write_codex_live_for_provider(category, auth, prepared_config.as_deref())
@@ -2070,7 +2213,8 @@ base_url = "https://production.api/v1"
}
});
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
let catalog = codex_model_catalog_from_specs(&specs, &template);
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
let models = catalog
.get("models")
.and_then(|value| value.as_array())
@@ -2121,6 +2265,137 @@ base_url = "https://production.api/v1"
);
}
#[test]
fn native_responses_profile_suppresses_apply_patch_and_keeps_shell() {
// Native (direct) /responses providers must NOT emit a freeform
// apply_patch (type=="custom") tool — gateways like MiMo reject it.
// The native profile uses the bundled clean template and relies on
// shell_type="shell_command" for edits, plus per-row overrides.
let settings = json!({
"modelCatalog": {
"models": [
{
"model": "MiniMax-M3",
"displayName": "MiniMax-M3",
"contextWindow": 1_000_000,
"supportsParallelToolCalls": true,
"inputModalities": ["text", "image"],
"baseInstructions": "You are Codex, a coding agent based on MiniMax-M3."
}
]
}
});
let catalog = codex_model_catalog_from_settings(
&settings,
"",
CodexCatalogToolProfile::NativeResponses,
)
.expect("native catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let entry = &catalog["models"][0];
assert_eq!(
entry.get("slug").and_then(|v| v.as_str()),
Some("MiniMax-M3")
);
assert_eq!(
entry.get("shell_type").and_then(|v| v.as_str()),
Some("shell_command"),
"native entries edit via shell, not the custom apply_patch tool"
);
assert!(
entry.get("apply_patch_tool_type").is_none(),
"native entries must NOT declare a freeform apply_patch tool"
);
// `base_instructions` is REQUIRED by Codex's catalog parser, so it must
// be present — and the per-row official override must win over the
// template default.
assert_eq!(
entry.get("base_instructions").and_then(|v| v.as_str()),
Some("You are Codex, a coding agent based on MiniMax-M3."),
"per-row baseInstructions override must apply (and field must exist)"
);
assert!(
entry.get("model_messages").is_none(),
"native entries must not carry the gpt-5.5 model_messages persona text"
);
assert_eq!(
entry.get("supports_parallel_tool_calls"),
Some(&json!(true)),
"per-row supportsParallelToolCalls override must apply"
);
assert_eq!(
entry.get("input_modalities"),
Some(&json!(["text", "image"])),
"per-row inputModalities override must apply"
);
assert_eq!(
entry.get("context_window").and_then(|v| v.as_u64()),
Some(1_000_000)
);
}
#[test]
fn native_responses_catalog_always_carries_base_instructions() {
// Regression guard for the "missing field `base_instructions`" parse
// error: Codex refuses to load a model catalog whose entries lack
// base_instructions. Synthesized presets carry no per-row override, so
// the entry MUST inherit the template's neutral default rather than
// dropping the field entirely.
let settings = json!({
"modelCatalog": { "models": [{ "model": "qwen3-coder-plus" }] }
});
let catalog = codex_model_catalog_from_settings(
&settings,
"",
CodexCatalogToolProfile::NativeResponses,
)
.expect("native catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let base = catalog["models"][0]
.get("base_instructions")
.and_then(|v| v.as_str());
assert!(
base.is_some_and(|s| !s.trim().is_empty()),
"every native entry must carry a non-empty base_instructions (Codex requires it)"
);
}
#[test]
fn proxy_chat_profile_still_keeps_apply_patch() {
// Regression guard for Mode A: the proxy-chat profile must keep the
// freeform apply_patch tool (the proxy rewrites custom<->function).
let template = load_codex_native_responses_template();
let specs = vec![CodexCatalogModelSpec {
model: "x".to_string(),
display_name: "x".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
}];
// Using a gpt-5.5-shaped template under ProxyChat must NOT strip
// apply_patch_tool_type. (The native template lacks it, so synthesize
// one with the field present to prove ProxyChat leaves it intact.)
let mut proxy_template = template.clone();
proxy_template["apply_patch_tool_type"] = json!("freeform");
let catalog = codex_model_catalog_from_specs(
&specs,
&proxy_template,
CodexCatalogToolProfile::ProxyChat,
);
assert_eq!(
catalog["models"][0]
.get("apply_patch_tool_type")
.and_then(|v| v.as_str()),
Some("freeform"),
"ProxyChat must preserve apply_patch_tool_type (no native stripping)"
);
}
#[test]
fn model_catalog_json_field_writes_relative_filename() {
let input = r#"model_provider = "any"
@@ -0,0 +1,38 @@
{
"slug": "native-responses-template",
"display_name": "native-responses-template",
"description": "native-responses-template",
"base_instructions": "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.",
"default_reasoning_level": "high",
"supported_reasoning_levels": [
{
"effort": "none",
"description": "Disable Thinking"
},
{
"effort": "high",
"description": "Enabled Thinking"
}
],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 0,
"supports_reasoning_summaries": true,
"default_reasoning_summary": "none",
"support_verbosity": false,
"truncation_policy": {
"mode": "bytes",
"limit": 10000
},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": 262144,
"max_context_window": 262144,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": [
"text"
],
"supports_search_tool": false
}
+5
View File
@@ -159,11 +159,16 @@ impl ConfigService {
}
let cfg_text = settings.get("config").and_then(Value::as_str);
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
provider.category.as_deref(),
auth,
cfg_text,
profile,
)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
+8
View File
@@ -760,11 +760,19 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str());
// Native (direct) Responses providers must suppress Codex's freeform
// apply_patch custom tool via the generated catalog; chat/proxy
// providers keep the default tool set. Keyed on provider.meta.apiFormat.
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
provider.category.as_deref(),
auth,
config_str,
profile,
)?;
}
AppType::Gemini => {
+21 -2
View File
@@ -2145,12 +2145,16 @@ impl ProxyService {
.get("auth")
.ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?;
let config_str = effective_settings.get("config").and_then(|v| v.as_str());
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&effective_settings,
provider.category.as_deref(),
auth,
config_str,
profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
}
@@ -2410,12 +2414,16 @@ impl ProxyService {
.get("auth")
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
let config_str = config.get("config").and_then(|v| v.as_str());
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
config,
provider.category.as_deref(),
auth,
config_str,
profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))
}
@@ -2435,9 +2443,12 @@ impl ProxyService {
.filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))
{
let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or("");
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.and_then(|p| p.meta.as_ref()?.api_format.as_deref()),
);
let prepared_config =
crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(
config, config_str,
config, config_str, profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
let live_config =
@@ -2471,10 +2482,18 @@ impl ProxyService {
// living in the config's `experimental_bearer_token`). Computing it up here
// keeps every config-writing branch — write-auth, delete-auth, no-auth —
// consistent instead of letting the empty-auth path skip projection.
// Verbatim restore has no Provider in hand (we only have the stored
// backup config), so the catalog tool profile can't be recovered here.
// Default to ProxyChat: a restored native-direct backup keeps its inline
// modelCatalog but would not get apply_patch re-stripped until the next
// provider switch rewrites it via write_live_snapshot. Acceptable known
// limitation (restore-of-deleted-provider-backup only).
let prepared_cfg = config_str
.map(|cfg| {
crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(
config, cfg,
config,
cfg,
crate::codex_config::CodexCatalogToolProfile::ProxyChat,
)
})
.transpose()