mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
+299
-24
@@ -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
|
||||
}
|
||||
@@ -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 结构,在新架构中为空
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -107,6 +107,15 @@ function createCatalogRow(seed?: Partial<CodexCatalogModel>): CodexCatalogRow {
|
||||
model: seed?.model ?? "",
|
||||
displayName: seed?.displayName ?? "",
|
||||
contextWindow: seed?.contextWindow ?? "",
|
||||
// Carry native-profile overrides verbatim (not user-editable in the row UI,
|
||||
// but must survive load->save so the official catalog fidelity is kept).
|
||||
...(seed?.supportsParallelToolCalls !== undefined
|
||||
? { supportsParallelToolCalls: seed.supportsParallelToolCalls }
|
||||
: {}),
|
||||
...(seed?.inputModalities ? { inputModalities: seed.inputModalities } : {}),
|
||||
...(seed?.baseInstructions
|
||||
? { baseInstructions: seed.baseInstructions }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -571,9 +580,10 @@ export function CodexFormFields({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型映射 —— 仅在本地路由开启 + 可编辑时显示(与上游格式解耦,
|
||||
Responses 原生供应商同样可配置);上方恒有 UA 字段,分隔线无需条件 */}
|
||||
{takeoverEnabled && canEditCatalog && (
|
||||
{/* 模型映射 / 模型目录 —— 接管开启(Chat 模式生成兼容路由)或
|
||||
原生 Responses 模式(生成 model-catalogs.json)时显示;可编辑即渲染。 */}
|
||||
{(takeoverEnabled || apiFormat === "openai_responses") &&
|
||||
canEditCatalog && (
|
||||
<div className="space-y-4 border-t border-border-default pt-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -180,10 +180,24 @@ export const normalizeCodexCatalogModelsForSave = (
|
||||
? Number.parseInt(rawContextWindow, 10)
|
||||
: undefined;
|
||||
|
||||
const inputModalities = item.inputModalities?.filter(
|
||||
(m) => typeof m === "string" && m.trim(),
|
||||
);
|
||||
|
||||
const baseInstructions = item.baseInstructions?.trim();
|
||||
|
||||
normalized.push({
|
||||
model,
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
|
||||
// Native Responses profile overrides (ignored by the chat/proxy profile).
|
||||
...(typeof item.supportsParallelToolCalls === "boolean"
|
||||
? { supportsParallelToolCalls: item.supportsParallelToolCalls }
|
||||
: {}),
|
||||
...(inputModalities && inputModalities.length > 0
|
||||
? { inputModalities }
|
||||
: {}),
|
||||
...(baseInstructions ? { baseInstructions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -599,7 +613,12 @@ function ProviderFormFull({
|
||||
// 接管已开)。只在 useState 初始化与预设重置点设置,跟 localCodexApiFormat
|
||||
// 对称,避免漂移。
|
||||
const [codexTakeoverEnabled, setCodexTakeoverEnabled] = useState<boolean>(
|
||||
() => codexCatalogCountFromSettings(initialData?.settingsConfig) > 0,
|
||||
() =>
|
||||
// Native (openai_responses) providers run DIRECT (no proxy takeover) yet
|
||||
// still carry a modelCatalog so cc-switch can generate model-catalogs.json.
|
||||
// So a catalog must NOT auto-enable takeover for native providers.
|
||||
initialCodexApiFormat !== "openai_responses" &&
|
||||
codexCatalogCountFromSettings(initialData?.settingsConfig) > 0,
|
||||
);
|
||||
|
||||
const { configError: codexConfigError, debouncedValidate } =
|
||||
@@ -1277,8 +1296,13 @@ function ProviderFormFull({
|
||||
category !== "official" && (codexConfig ?? "").trim()
|
||||
? setCodexWireApi(codexConfig ?? "", "responses")
|
||||
: (codexConfig ?? "");
|
||||
// Persist the catalog for proxy-takeover (Mode A) OR native-direct
|
||||
// (Mode B, apiFormat=openai_responses). Native providers run without the
|
||||
// proxy but still need cc-switch to generate model-catalogs.json, so the
|
||||
// catalog must survive even when the takeover toggle is off.
|
||||
const normalizedCatalogModels =
|
||||
category !== "official" && codexTakeoverEnabled
|
||||
category !== "official" &&
|
||||
(codexTakeoverEnabled || localCodexApiFormat === "openai_responses")
|
||||
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
|
||||
: [];
|
||||
// Sync first catalog row's model into config.toml so Codex uses it as default
|
||||
@@ -1671,8 +1695,12 @@ function ProviderFormFull({
|
||||
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
|
||||
"openai_responses",
|
||||
);
|
||||
// 路由开关与格式无关,仅按预设是否带模型映射决定
|
||||
setCodexTakeoverEnabled((preset.modelCatalog?.length ?? 0) > 0);
|
||||
// 接管开关:原生(openai_responses)预设走直连、不开接管,即便带 catalog
|
||||
//(catalog 用于生成 model-catalogs.json);只有 chat 预设按是否带映射开启。
|
||||
setCodexTakeoverEnabled(
|
||||
(preset.apiFormat ?? "openai_responses") !== "openai_responses" &&
|
||||
(preset.modelCatalog?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
form.reset({
|
||||
name: preset.nameKey ? t(preset.nameKey) : preset.name,
|
||||
|
||||
@@ -71,7 +71,20 @@ requires_openai_auth = true`;
|
||||
|
||||
function modelCatalog(
|
||||
models: Array<
|
||||
string | { model: string; displayName?: string; contextWindow?: number }
|
||||
| string
|
||||
| {
|
||||
model: string;
|
||||
displayName?: string;
|
||||
contextWindow?: number;
|
||||
// Native Responses (direct) overrides for the generated
|
||||
// model-catalogs.json; omit to inherit the native template defaults
|
||||
// (supports_parallel_tool_calls=false, input_modalities=["text"]).
|
||||
supportsParallelToolCalls?: boolean;
|
||||
inputModalities?: string[];
|
||||
// Vendor's OFFICIAL base_instructions; omit to inherit the neutral
|
||||
// template default. Required by Codex, so the backend always emits one.
|
||||
baseInstructions?: string;
|
||||
}
|
||||
>,
|
||||
): CodexCatalogModel[] {
|
||||
return models.map((entry) =>
|
||||
@@ -81,6 +94,9 @@ function modelCatalog(
|
||||
model: entry.model,
|
||||
displayName: entry.displayName,
|
||||
contextWindow: entry.contextWindow,
|
||||
supportsParallelToolCalls: entry.supportsParallelToolCalls,
|
||||
inputModalities: entry.inputModalities,
|
||||
baseInstructions: entry.baseInstructions,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -204,6 +220,15 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
endpointCandidates: ["https://ark.cn-beijing.volces.com/api/v3"],
|
||||
// 火山方舟主数据面 /api/v3 原生支持 Responses API(/api/v3/responses),无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch),
|
||||
// 让 Codex 直连显示模型并避免 custom 工具被网关拒绝
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "doubao-seed-2-1-pro",
|
||||
displayName: "Doubao Seed 2.1 Pro",
|
||||
contextWindow: 262144,
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "doubaoseed",
|
||||
@@ -411,6 +436,14 @@ requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://dashscope.aliyuncs.com/compatible-mode/v1"],
|
||||
// 阿里百炼 DashScope 原生支持 OpenAI Responses API(/compatible-mode/v1/responses,同一 base_url),无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch)
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "qwen3-coder-plus",
|
||||
displayName: "Qwen3 Coder Plus",
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
icon: "bailian",
|
||||
iconColor: "#624AFF",
|
||||
@@ -586,6 +619,15 @@ requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://api.longcat.chat/openai/v1"],
|
||||
// 美团 LongCat 官方 Codex 文档用 wire_api=responses 对自家 base_url,原生 Responses,无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch)。
|
||||
// 注:LongCat 的 /responses 工具类型契约文档化程度最低,建议真机冒烟一次
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "LongCat-2.0-Preview",
|
||||
displayName: "LongCat 2.0 Preview",
|
||||
contextWindow: 131072,
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
icon: "longcat",
|
||||
iconColor: "#29E154",
|
||||
@@ -603,6 +645,19 @@ requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://api.minimaxi.com/v1"],
|
||||
// MiniMax 官方 API 参考已列 /v1/responses 为正式端点(CN/intl 双区,POST /v1/responses),原生 Responses,无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 官方 Codex catalog(platform.minimaxi.com/docs/token-plan/codex-cli):
|
||||
// shell_command 编辑、并行工具、文本+图像,不声明 freeform apply_patch
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "MiniMax-M3",
|
||||
displayName: "MiniMax-M3",
|
||||
contextWindow: 1000000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
"You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.",
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: {
|
||||
@@ -625,6 +680,19 @@ requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://api.minimax.io/v1"],
|
||||
// MiniMax 官方 API 参考已列 /v1/responses 为正式端点(CN/intl 双区,POST /v1/responses),原生 Responses,无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 官方 Codex catalog(platform.minimax.io/docs/token-plan/codex):
|
||||
// shell_command 编辑、并行工具、文本+图像,不声明 freeform apply_patch
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "MiniMax-M3",
|
||||
displayName: "MiniMax-M3",
|
||||
contextWindow: 1000000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
"You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.",
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: {
|
||||
@@ -668,6 +736,26 @@ requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://api.xiaomimimo.com/v1"],
|
||||
// 小米 MiMo 官方 Codex 文档已声明原生支持 Responses API(wire_api=responses 对自家 base_url),无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 官方 Codex catalog(mimo.mi.com/.../codex-configuration):
|
||||
// shell_command 编辑、不声明 freeform apply_patch
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "mimo-v2.5-pro",
|
||||
displayName: "MiMo V2.5 Pro",
|
||||
contextWindow: 1048576,
|
||||
inputModalities: ["text"],
|
||||
baseInstructions:
|
||||
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
|
||||
},
|
||||
{
|
||||
model: "mimo-v2.5",
|
||||
displayName: "MiMo V2.5",
|
||||
contextWindow: 1048576,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
icon: "xiaomimimo",
|
||||
iconColor: "#000000",
|
||||
@@ -685,6 +773,26 @@ requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://token-plan-cn.xiaomimimo.com/v1"],
|
||||
// 小米 MiMo 官方 Codex 文档已声明原生支持 Responses API(wire_api=responses 对自家 base_url),无需路由接管转换
|
||||
apiFormat: "openai_responses",
|
||||
// 官方 Codex catalog(mimo.mi.com/.../codex-configuration):
|
||||
// shell_command 编辑、不声明 freeform apply_patch
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "mimo-v2.5-pro",
|
||||
displayName: "MiMo V2.5 Pro",
|
||||
contextWindow: 1048576,
|
||||
inputModalities: ["text"],
|
||||
baseInstructions:
|
||||
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
|
||||
},
|
||||
{
|
||||
model: "mimo-v2.5",
|
||||
displayName: "MiMo V2.5",
|
||||
contextWindow: 1048576,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
|
||||
},
|
||||
]),
|
||||
category: "cn_official",
|
||||
icon: "xiaomimimo",
|
||||
iconColor: "#000000",
|
||||
|
||||
@@ -258,6 +258,15 @@ export interface CodexCatalogModel {
|
||||
model: string;
|
||||
displayName?: string;
|
||||
contextWindow?: string | number;
|
||||
// Native Responses (direct) profile overrides for the generated
|
||||
// model-catalogs.json. Ignored by the chat/proxy profile.
|
||||
// e.g. MiniMax: supportsParallelToolCalls=true, inputModalities=["text","image"].
|
||||
supportsParallelToolCalls?: boolean;
|
||||
inputModalities?: string[];
|
||||
// Vendor's OFFICIAL base_instructions (model identity / system preamble).
|
||||
// Codex requires this field in every catalog entry; when omitted the backend
|
||||
// falls back to a neutral default. e.g. MiMo "developed by Xiaomi".
|
||||
baseInstructions?: string;
|
||||
}
|
||||
|
||||
// Claude 认证字段类型
|
||||
|
||||
@@ -15,4 +15,38 @@ describe("ProviderForm Codex catalog helpers", () => {
|
||||
{ model: "kimi-k2", contextWindow: 128000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves native-profile overrides (parallel tool calls + input modalities + base instructions)", () => {
|
||||
expect(
|
||||
normalizeCodexCatalogModelsForSave([
|
||||
{
|
||||
model: "MiniMax-M3",
|
||||
displayName: "MiniMax-M3",
|
||||
contextWindow: 1000000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions:
|
||||
" You are Codex, a coding agent based on MiniMax-M3. ",
|
||||
},
|
||||
// false must be preserved (not dropped as falsy); empty modalities dropped;
|
||||
// empty/whitespace baseInstructions dropped
|
||||
{
|
||||
model: "mimo-v2.5-pro",
|
||||
supportsParallelToolCalls: false,
|
||||
inputModalities: [],
|
||||
baseInstructions: " ",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
model: "MiniMax-M3",
|
||||
displayName: "MiniMax-M3",
|
||||
contextWindow: 1000000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
baseInstructions: "You are Codex, a coding agent based on MiniMax-M3.",
|
||||
},
|
||||
{ model: "mimo-v2.5-pro", supportsParallelToolCalls: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,8 +169,12 @@ describe("Codex Chat provider presets", () => {
|
||||
|
||||
expect(preset, `${name} preset`).toBeDefined();
|
||||
expect(preset?.apiFormat).toBe("openai_responses");
|
||||
// 原生 Responses 预设必须不带 modelCatalog,否则“本地路由映射”开关会默认勾选
|
||||
expect(preset?.modelCatalog ?? []).toHaveLength(0);
|
||||
// 原生 Responses 预设现在带 modelCatalog:cc-switch 直连时据此生成
|
||||
// ~/.codex 的 model-catalogs.json(shell_command 编辑、不发 freeform
|
||||
// apply_patch)。带 catalog 不再强制开“本地路由映射”——前端已按
|
||||
// apiFormat 解耦(openai_responses 默认不开接管)。
|
||||
expect((preset?.modelCatalog ?? []).length).toBeGreaterThan(0);
|
||||
// 原生(直连)不走 Chat 转换,因此不需要 codexChatReasoning。
|
||||
expect(preset?.codexChatReasoning).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user