Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions crates/codex-plus-core/src/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ pub fn apply_relay_profile_files_to_home_with_context(
&profile.auto_compact_limit,
)?;
let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?;
let config_with_catalog = apply_custom_catalog_fallback(home, &config_with_catalog, profile)?;
apply_relay_files_to_home(home, &config_with_catalog, &profile.auth_contents)
}

Expand Down Expand Up @@ -403,6 +404,7 @@ pub fn apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard(
&profile.auto_compact_limit,
)?;
let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?;
let config_with_catalog = apply_custom_catalog_fallback(home, &config_with_catalog, profile)?;

if profile.relay_mode == crate::settings::RelayMode::PureApi {
apply_relay_files_to_home_with_computer_use_guard(
Expand Down Expand Up @@ -440,6 +442,7 @@ pub fn apply_relay_profile_config_to_home_with_context(
&profile.auto_compact_limit,
)?;
let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?;
let config_with_catalog = apply_custom_catalog_fallback(home, &config_with_catalog, profile)?;
apply_relay_config_file_to_home(home, &config_with_catalog)
}

Expand Down Expand Up @@ -1517,6 +1520,88 @@ fn root_positive_int_string(config_text: &str, key: &str) -> Option<String> {
.filter(|value| *value > 0)
.map(|value| value.to_string())
}
/// Fallback custom model catalog: only generates when `apply_model_catalog_to_config`
/// hasn't already set `model_catalog_json` (e.g. no suffixes in model_list).
fn apply_custom_catalog_fallback(
home: &Path,
config_text: &str,
profile: &RelayProfile,
) -> anyhow::Result<String> {
let mut doc = parse_toml_document(config_text)?;
// Only generate when no catalog was set by apply_model_catalog_to_config
let context_window = parse_optional_positive_u64(&profile.context_window, "上下文大小")?;
if let Some(value) = context_window {
ensure_custom_model_catalog(home, &mut doc, value)?;
}
Ok(normalize_optional_toml(doc))
}

/// Generate a custom model catalog file for non-standard models that have
/// an explicit `model_context_window` set. This allows Codex CLI to find
/// the model in its catalog lookup and use the user-specified context window.
///
/// Skips generation if:
/// - `model_catalog_json` is already set in the config
/// - No `model` is configured
fn ensure_custom_model_catalog(
home: &Path,
doc: &mut DocumentMut,
context_window: u64,
) -> anyhow::Result<()> {
// Don't override an existing model_catalog_json setting
if doc.contains_key("model_catalog_json") {
return Ok(());
}

// Read model name from the TOML doc
let model = doc
.get("model")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());

let model = match model {
Some(m) if !m.is_empty() => m,
_ => return Ok(()),
};

let catalog_filename = "model_catalog_custom.json";
let catalog_path = home.join(catalog_filename);

// Use a display name derived from the slug
let display_name = model
.split(['-', '_'])
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");

let catalog = serde_json::json!({
"models": [{
"slug": model,
"display_name": display_name,
"base_instructions": "You are Codex, a coding agent that helps users write code.",
"context_window": context_window,
"max_context_window": context_window,
"effective_context_window_percent": 95
}]
});

let catalog_json = serde_json::to_string_pretty(&catalog)
.with_context(|| "序列化自定义模型目录失败")?;
crate::settings::atomic_write(&catalog_path, catalog_json.as_bytes())
.with_context(|| format!("写入自定义模型目录 {} 失败", catalog_path.display()))?;

doc["model_catalog_json"] = toml_edit::value(catalog_path.to_string_lossy().to_string());

Ok(())
}

fn toml_value_is_subset(target: &toml_edit::Value, source: &toml_edit::Value) -> bool {
match (target, source) {
Expand Down
151 changes: 140 additions & 11 deletions crates/codex-plus-core/tests/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1015,14 +1015,14 @@ enabled = true
}

#[test]
fn apply_relay_profile_does_not_write_model_catalog_json_for_selected_models() {
fn apply_relay_profile_writes_model_catalog_json_for_custom_model_with_context_window() {
let temp = tempfile::tempdir().unwrap();
let profile = RelayProfile {
id: "relay-a".to_string(),
name: "Relay A".to_string(),
model: "qwen3-coder".to_string(),
model: "mimo-v2.5".to_string(),
relay_mode: RelayMode::PureApi,
config_contents: r#"model = "qwen3-coder"
config_contents: r#"model = "mimo-v2.5"
model_provider = "custom"

[model_providers.custom]
Expand All @@ -1035,19 +1035,143 @@ experimental_bearer_token = "sk-new"
.to_string(),
auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(),
model_insert_mode: Default::default(),
model_list: "deepseek-coder\nqwen3-coder".to_string(),
context_window: "200000".to_string(),
auto_compact_limit: "160000".to_string(),
model_list: String::new(),
context_window: "1000000".to_string(),
auto_compact_limit: "800000".to_string(),
..RelayProfile::default()
};

apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap();

// Check config has all expected settings
let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
assert!(config.contains(r#"model = "mimo-v2.5""#));
assert!(config.contains("model_context_window = 1000000"));
assert!(config.contains("model_auto_compact_token_limit = 800000"));
// Custom model with context_window should generate a model_catalog_json entry
assert!(config.contains("model_catalog_json"), "Expected model_catalog_json for custom model with context_window");
assert!(config.contains("model_catalog_custom.json"));

// Check the custom catalog file was created
let catalog_path = temp.path().join("model_catalog_custom.json");
assert!(catalog_path.exists(), "Custom catalog file should exist");
let catalog: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&catalog_path).unwrap()).unwrap();
let models = catalog.get("models").and_then(|v| v.as_array()).unwrap();
assert_eq!(models.len(), 1);
let model_entry = &models[0];
assert_eq!(model_entry["slug"].as_str().unwrap(), "mimo-v2.5");
assert_eq!(model_entry["context_window"].as_u64().unwrap(), 1000000);
assert_eq!(model_entry["max_context_window"].as_u64().unwrap(), 1000000);
assert!(model_entry["base_instructions"].as_str().unwrap().contains("coding agent"));
}

#[test]
fn apply_relay_profile_skips_custom_catalog_when_context_window_empty() {
let temp = tempfile::tempdir().unwrap();
let profile = RelayProfile {
id: "relay-a".to_string(),
name: "Relay A".to_string(),
model: "mimo-v2.5".to_string(),
relay_mode: RelayMode::PureApi,
config_contents: r#"model = "mimo-v2.5"
model_provider = "custom"

[model_providers.custom]
name = "custom"
wire_api = "responses"
requires_openai_auth = true
base_url = "https://relay.example/v1"
"#
.to_string(),
auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(),
model_insert_mode: Default::default(),
model_list: String::new(),
context_window: String::new(), // empty = no override
auto_compact_limit: String::new(),
..RelayProfile::default()
};

apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap();

let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
assert!(config.contains(r#"model = "mimo-v2.5""#));
// No context_window set → no model_context_window in config → no custom catalog
assert!(!config.contains("model_context_window"));
assert!(!config.contains("model_catalog_json"));
assert!(config.contains("model_context_window = 200000"));
assert!(config.contains("model_auto_compact_token_limit = 160000"));
assert!(!temp.path().join("model-catalogs").exists());
assert!(!temp.path().join("model_catalog_custom.json").exists());
}

#[test]
fn apply_relay_profile_skips_custom_catalog_when_model_empty() {
let temp = tempfile::tempdir().unwrap();
let profile = RelayProfile {
id: "relay-a".to_string(),
name: "Relay A".to_string(),
model: String::new(), // empty
relay_mode: RelayMode::PureApi,
config_contents: r#"model = ""
model_provider = "custom"

[model_providers.custom]
name = "custom"
wire_api = "responses"
requires_openai_auth = true
base_url = "https://relay.example/v1"
"#
.to_string(),
auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(),
model_insert_mode: Default::default(),
model_list: String::new(),
context_window: "1000000".to_string(),
auto_compact_limit: "800000".to_string(),
..RelayProfile::default()
};

apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap();

let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
// context_window is set, but model is empty → no custom catalog
assert!(config.contains("model_context_window = 1000000"));
assert!(!config.contains("model_catalog_json"));
assert!(!temp.path().join("model_catalog_custom.json").exists());
}

#[test]
fn apply_relay_profile_does_not_override_existing_model_catalog_json() {
let temp = tempfile::tempdir().unwrap();
let profile = RelayProfile {
id: "relay-a".to_string(),
name: "Relay A".to_string(),
model: "mimo-v2.5".to_string(),
relay_mode: RelayMode::PureApi,
config_contents: r#"model = "mimo-v2.5"
model_catalog_json = "/custom/path/to/catalog.json"
model_provider = "custom"

[model_providers.custom]
name = "custom"
wire_api = "responses"
requires_openai_auth = true
base_url = "https://relay.example/v1"
experimental_bearer_token = "sk-new"
"#
.to_string(),
auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(),
model_insert_mode: Default::default(),
model_list: String::new(),
context_window: "1000000".to_string(),
auto_compact_limit: "800000".to_string(),
..RelayProfile::default()
};

apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap();

let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
// Existing model_catalog_json should be preserved, not overwritten
assert!(config.contains(r#"model_catalog_json = "/custom/path/to/catalog.json""#));
assert!(!config.contains("model_catalog_custom.json"));
assert!(!temp.path().join("model_catalog_custom.json").exists());
}

#[test]
Expand Down Expand Up @@ -2956,7 +3080,7 @@ experimental_bearer_token = "sk-new"
}

#[test]
fn apply_relay_profile_no_catalog_when_model_list_has_no_suffix() {
fn apply_relay_profile_generates_custom_catalog_when_context_window_set() {
let temp = tempfile::tempdir().unwrap();
let profile = RelayProfile {
id: "relay-a".to_string(),
Expand Down Expand Up @@ -2985,9 +3109,14 @@ experimental_bearer_token = "sk-new"
apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap();

let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
assert!(!config.contains("model_catalog_json"));
// context_window set → custom fallback catalog is generated
assert!(config.contains("model_catalog_json"));
assert!(config.contains("model_catalog_custom.json"));
assert!(config.contains("model_context_window = 200000"));
// But no suffix-based model-catalogs directory
assert!(!temp.path().join("model-catalogs").exists());
// Custom catalog file exists
assert!(temp.path().join("model_catalog_custom.json").exists());
}

#[test]
Expand Down
Loading