fix(codex): align fallback metadata and remove stale models automatically
- Align fallback defaults and instruction structure with upstream Codex while preserving Fast availability for all models - Stop forcing fallback reasoning levels or inferring original-image support - Sync managed catalogs on model refresh, configuration changes, and every 30 seconds - Remove unavailable models, replace missing defaults, and handle empty-list recovery - Preserve existing configuration on request failures or invalid responses - Serialize refreshes, update UI model lists, and preserve backups across restarts - Add regression coverage for fallback compatibility, stale-model cleanup, and configuration recovery
This commit is contained in:
1 parent
ca4a3307f3
commit
3f03158f5e
9 files changed
+760
-25
No files matched your search
@@ -18,9 +18,16 @@
|
||||
"experimental_supported_tools": [],
|
||||
"tool_mode": null,
|
||||
"multi_agent_version": null,
|
||||
"multi_agent_reasoning_effort": null,
|
||||
"use_responses_lite": false,
|
||||
"include_skills_usage_instructions": false,
|
||||
"include_plugin_usage_instructions": false,
|
||||
"include_apps_usage_instructions": false,
|
||||
"guardian": null,
|
||||
"node_repl_auto_review_required": false,
|
||||
"node_repl_disabled": false,
|
||||
"auto_review_model_override": null,
|
||||
"model_specialty": null,
|
||||
"context_window": 272000,
|
||||
"max_context_window": 272000,
|
||||
"auto_compact_token_limit": null,
|
||||
@@ -34,7 +41,7 @@
|
||||
"description": null,
|
||||
"default_reasoning_level": null,
|
||||
"supported_reasoning_levels": [],
|
||||
"shell_type": "default",
|
||||
"shell_type": "unified_exec",
|
||||
"visibility": "none",
|
||||
"supported_in_api": true,
|
||||
"availability_nux": null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
const AGENT_STATUS_DETECTION_CONCURRENCY: usize = 4;
|
||||
static CODEX_CATALOG_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AgentStatusDetectionTarget {
|
||||
@@ -132,6 +133,7 @@ pub(crate) async fn refresh_agent_config_statuses(
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_agent_models(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
client: String,
|
||||
) -> Result<Vec<AgentModelOption>, String> {
|
||||
@@ -140,8 +142,19 @@ pub(crate) async fn get_agent_models(
|
||||
return fetch_agent_models(config.port, effective_agent_api_key(&config)).await;
|
||||
}
|
||||
let client = AgentClient::parse(&client)?;
|
||||
let _sync_guard = if client == AgentClient::Codex {
|
||||
Some(CODEX_CATALOG_SYNC_LOCK.lock().await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let config = gui_config_state.snapshot()?;
|
||||
Ok(fetch_prepared_agent_models(client, &config).await?.models)
|
||||
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
||||
if client == AgentClient::Codex {
|
||||
if let Err(error) = sync_prepared_codex_model_catalog(&app, &config, &prepared) {
|
||||
eprintln!("自动刷新已应用的 Codex 模型目录失败: {error}");
|
||||
}
|
||||
}
|
||||
Ok(prepared.models)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_pi_default_model(
|
||||
@@ -764,6 +777,83 @@ pub(crate) async fn fetch_prepared_agent_models(
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_prepared_codex_model_catalog(
|
||||
app: &tauri::AppHandle,
|
||||
config: &GuiConfigFile,
|
||||
prepared: &PreparedAgentModels,
|
||||
) -> Result<bool, String> {
|
||||
let Some(catalog) = prepared.codex_catalog.as_deref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
||||
let changed = sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
config.port,
|
||||
effective_agent_api_key(config),
|
||||
&prepared.models,
|
||||
catalog,
|
||||
)?;
|
||||
if changed {
|
||||
app.state::<AgentConfigStatusCache>().clear()?;
|
||||
let _ = app.emit(
|
||||
CONFIG_FILES_CHANGED_EVENT,
|
||||
ConfigFilesChangedPayload {
|
||||
paths: agent_managed_paths(AgentClient::Codex, &home)
|
||||
.iter()
|
||||
.map(|path| path_to_string(path))
|
||||
.collect(),
|
||||
errors: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_applied_codex_model_catalog(
|
||||
app: &tauri::AppHandle,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<bool, String> {
|
||||
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
|
||||
let runtime_models =
|
||||
fetch_codex_runtime_models(config.port, effective_agent_api_key(config)).await?;
|
||||
let prepared = prepare_codex_agent_models(&runtime_models)?;
|
||||
sync_prepared_codex_model_catalog(app, config, &prepared)
|
||||
}
|
||||
|
||||
pub(crate) fn start_codex_model_catalog_sync(app: tauri::AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let Ok(home) = app.path().home_dir() else {
|
||||
return;
|
||||
};
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let Ok(config) = app.state::<GuiConfigState>().snapshot() else {
|
||||
continue;
|
||||
};
|
||||
let paths = agent_config_paths(AgentClient::Codex, &home);
|
||||
if !matches!(
|
||||
inspect_agent_managed_config(
|
||||
AgentClient::Codex,
|
||||
&paths,
|
||||
config.port,
|
||||
effective_agent_api_key(&config),
|
||||
),
|
||||
Ok((true, _, _))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = refresh_applied_codex_model_catalog(&app, &config).await {
|
||||
eprintln!("后台同步 Codex 模型目录失败,保留现有配置: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> bool {
|
||||
matches!(
|
||||
client,
|
||||
@@ -843,7 +933,7 @@ pub(crate) fn prepare_codex_agent_models(
|
||||
Err(error) if error.contains("CPA 当前没有可写入 Codex 的模型") => {
|
||||
Ok(PreparedAgentModels {
|
||||
models: Vec::new(),
|
||||
codex_catalog: None,
|
||||
codex_catalog: Some("{\n \"models\": []\n}\n".to_string()),
|
||||
})
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
|
||||
@@ -1517,6 +1517,96 @@ pub(crate) fn commit_agent_configuration(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sync_codex_model_catalog_if_configured(
|
||||
home: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
models: &[AgentModelOption],
|
||||
catalog: &str,
|
||||
) -> Result<bool, String> {
|
||||
let client = AgentClient::Codex;
|
||||
let paths = agent_config_paths(client, home);
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
let (configured, current_model, _) =
|
||||
inspect_agent_managed_config(client, &paths, port, api_key)?;
|
||||
if !configured {
|
||||
return Ok(false);
|
||||
}
|
||||
let next_model = models
|
||||
.iter()
|
||||
.find(|model| {
|
||||
current_model
|
||||
.as_deref()
|
||||
.is_some_and(|current| model.name.eq_ignore_ascii_case(current))
|
||||
})
|
||||
.or_else(|| models.first())
|
||||
.map(|model| model.name.as_str());
|
||||
if let Some(model) = next_model {
|
||||
validate_codex_catalog(catalog, model)?;
|
||||
} else {
|
||||
let root: serde_json::Value = serde_json::from_str(catalog)
|
||||
.map_err(|error| format!("Codex 模型目录格式无效: {error}"))?;
|
||||
if !root
|
||||
.get("models")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(Vec::is_empty)
|
||||
{
|
||||
return Err("空模型列表必须对应空的 Codex 模型目录".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let catalog_path = codex_model_catalog_path(home);
|
||||
let mut updates = Vec::new();
|
||||
if read_agent_bytes(&catalog_path)?.as_deref() != Some(catalog.as_bytes()) {
|
||||
updates.push(AgentFileUpdate {
|
||||
path: catalog_path,
|
||||
after: catalog.to_string(),
|
||||
});
|
||||
}
|
||||
if current_model.as_deref() != next_model {
|
||||
let content = fs::read_to_string(&paths[0])
|
||||
.map_err(|error| format!("读取 Codex 配置失败: {error}"))?;
|
||||
let mut document = content
|
||||
.parse::<toml_edit::Document>()
|
||||
.map_err(|error| format!("Codex config.toml 格式无效: {error}"))?;
|
||||
if let Some(model) = next_model {
|
||||
set_codex_table_item(document.as_table_mut(), "model", toml_edit::value(model));
|
||||
} else {
|
||||
document.remove("model");
|
||||
}
|
||||
updates.push(AgentFileUpdate {
|
||||
path: paths[0].clone(),
|
||||
after: document.to_string(),
|
||||
});
|
||||
}
|
||||
if updates.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if load_agent_applied_state(client, home)?.is_none() {
|
||||
for path in [paths[0].clone(), codex_model_catalog_path(home)] {
|
||||
if !updates.iter().any(|update| update.path == path) {
|
||||
let after = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("读取 Codex 配置以创建备份失败: {error}"))?;
|
||||
updates.push(AgentFileUpdate { path, after });
|
||||
}
|
||||
}
|
||||
}
|
||||
updates.sort_by_key(|update| update.path != paths[0]);
|
||||
|
||||
let result = commit_agent_configuration(
|
||||
client,
|
||||
home,
|
||||
next_model.unwrap_or_default(),
|
||||
&updates,
|
||||
"updated",
|
||||
None,
|
||||
)?;
|
||||
Ok(!result.changed_files.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_agent_configuration(
|
||||
client: AgentClient,
|
||||
|
||||
+233
-17
@@ -151,6 +151,9 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
|
||||
models.push(make_runtime_model(slug, display_name));
|
||||
}
|
||||
}
|
||||
if !values.is_empty() && models.is_empty() {
|
||||
return Err("Codex 模型列表响应未包含有效的模型 ID".to_string());
|
||||
}
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
@@ -357,6 +360,7 @@ fn prepare_catalog_with_sources(
|
||||
});
|
||||
} else {
|
||||
let mut value = sources.fallback.clone();
|
||||
normalize_fallback_model(&mut value);
|
||||
apply_runtime_metadata(&mut value, runtime);
|
||||
disable_fallback_capabilities(&mut value);
|
||||
enable_fast_mode(&mut value);
|
||||
@@ -427,6 +431,75 @@ fn prepare_catalog_with_sources(
|
||||
Ok(PreparedCodexCatalog { models, json })
|
||||
}
|
||||
|
||||
fn normalize_fallback_model(model: &mut Map<String, Value>) {
|
||||
if matches!(
|
||||
string_value(model, "shell_type").as_str(),
|
||||
"default" | "local" | "shell_command"
|
||||
) {
|
||||
model.insert(
|
||||
"shell_type".to_string(),
|
||||
Value::String("unified_exec".to_string()),
|
||||
);
|
||||
}
|
||||
for field in [
|
||||
"include_skills_usage_instructions",
|
||||
"include_plugin_usage_instructions",
|
||||
"include_apps_usage_instructions",
|
||||
"node_repl_auto_review_required",
|
||||
"node_repl_disabled",
|
||||
"supports_image_detail_original",
|
||||
] {
|
||||
model.entry(field.to_string()).or_insert(Value::Bool(false));
|
||||
}
|
||||
for field in [
|
||||
"guardian",
|
||||
"auto_review_model_override",
|
||||
"model_specialty",
|
||||
"tool_mode",
|
||||
"multi_agent_version",
|
||||
"multi_agent_reasoning_effort",
|
||||
] {
|
||||
model.entry(field.to_string()).or_insert(Value::Null);
|
||||
}
|
||||
let base_instructions = model
|
||||
.get("base_instructions")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let messages = model
|
||||
.entry("model_messages".to_string())
|
||||
.or_insert(Value::Null);
|
||||
if !messages.is_object() {
|
||||
*messages = Value::Object(Map::new());
|
||||
}
|
||||
if let Some(messages) = messages.as_object_mut() {
|
||||
if !messages
|
||||
.get("instructions_template")
|
||||
.is_some_and(Value::is_string)
|
||||
{
|
||||
messages.insert(
|
||||
"instructions_template".to_string(),
|
||||
Value::String(base_instructions),
|
||||
);
|
||||
}
|
||||
for field in [
|
||||
"persistent_instructions",
|
||||
"tools",
|
||||
"instructions_variables",
|
||||
"approvals",
|
||||
"collaboration_modes",
|
||||
"auto_review",
|
||||
"permissions",
|
||||
"multi_agent",
|
||||
"token_budget",
|
||||
"confirmation_policies",
|
||||
"guardian_v2",
|
||||
] {
|
||||
messages.entry(field.to_string()).or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_runtime_metadata(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
|
||||
model.insert("slug".to_string(), Value::String(runtime.slug.clone()));
|
||||
model.insert(
|
||||
@@ -467,13 +540,8 @@ fn apply_runtime_metadata(model: &mut Map<String, Value>, runtime: &CodexRuntime
|
||||
"input_modalities".to_string(),
|
||||
Value::Array(modalities.iter().cloned().map(Value::String).collect()),
|
||||
);
|
||||
model.insert(
|
||||
"supports_image_detail_original".to_string(),
|
||||
Value::Bool(modalities.iter().any(|value| value == "image")),
|
||||
);
|
||||
}
|
||||
|
||||
open_all_reasoning_levels(model, runtime);
|
||||
if runtime.hidden {
|
||||
model.insert("visibility".to_string(), Value::String("hide".to_string()));
|
||||
}
|
||||
@@ -652,12 +720,8 @@ mod tests {
|
||||
"context_window": 128000,
|
||||
"max_context_window": 128000,
|
||||
"input_modalities": ["text"],
|
||||
"default_reasoning_level": "medium",
|
||||
"supported_reasoning_levels": [
|
||||
{"effort":"low","description":"Low"},
|
||||
{"effort":"medium","description":"Medium"},
|
||||
{"effort":"high","description":"High"}
|
||||
],
|
||||
"default_reasoning_level": null,
|
||||
"supported_reasoning_levels": [],
|
||||
"shell_type": "shell_command",
|
||||
"supported_in_api": true,
|
||||
"default_reasoning_summary": "none",
|
||||
@@ -750,7 +814,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(sources.fallback["default_reasoning_level"], Value::Null);
|
||||
assert!(reasoning_efforts(&sources.fallback).is_empty());
|
||||
assert_eq!(sources.fallback["shell_type"], "default");
|
||||
assert_eq!(sources.fallback["shell_type"], "unified_exec");
|
||||
assert_eq!(sources.fallback["visibility"], "none");
|
||||
assert_eq!(sources.fallback["default_reasoning_summary"], "auto");
|
||||
assert_eq!(sources.fallback["supports_parallel_tool_calls"], false);
|
||||
@@ -816,6 +880,24 @@ mod tests {
|
||||
assert_eq!(models[1].slug, "Model-B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_parser_distinguishes_empty_lists_from_invalid_model_entries() {
|
||||
for payload in [
|
||||
serde_json::json!({"models": []}),
|
||||
serde_json::json!({"data": []}),
|
||||
serde_json::json!([]),
|
||||
] {
|
||||
assert!(parse_runtime_models(&payload).unwrap().is_empty());
|
||||
}
|
||||
for payload in [
|
||||
serde_json::json!({"error": "unavailable"}),
|
||||
serde_json::json!({"models": null}),
|
||||
serde_json::json!({"models": [null, {}, {"id": " "}]}),
|
||||
] {
|
||||
assert!(parse_runtime_models(&payload).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_parser_exposes_model_alias_as_a_runtime_slug() {
|
||||
let models = runtime(serde_json::json!({
|
||||
@@ -1033,11 +1115,12 @@ mod tests {
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
assert_eq!(model["display_name"], "C");
|
||||
assert_eq!(reasoning_efforts(model), ALL_REASONING_LEVELS);
|
||||
assert!(reasoning_efforts(model).is_empty());
|
||||
assert_eq!(model["context_window"], 200_000);
|
||||
assert_eq!(model["max_context_window"], 200_000);
|
||||
assert_eq!(model["input_modalities"], serde_json::json!(["image"]));
|
||||
assert_eq!(model["default_reasoning_level"], "high");
|
||||
assert_eq!(model["supports_image_detail_original"], false);
|
||||
assert_eq!(model["default_reasoning_level"], Value::Null);
|
||||
assert_eq!(model["visibility"], "hide");
|
||||
assert_eq!(
|
||||
model["base_instructions"],
|
||||
@@ -1055,6 +1138,139 @@ mod tests {
|
||||
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_fallback_uses_official_defaults_with_fast_available() {
|
||||
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
|
||||
let runtime = runtime(serde_json::json!({"models":[{"id":"unknown-model"}]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
let expected: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"slug": "unknown-model",
|
||||
"display_name": "unknown-model",
|
||||
"description": null,
|
||||
"context_window": 272000,
|
||||
"max_context_window": 272000,
|
||||
"auto_compact_token_limit": null,
|
||||
"comp_hash": null,
|
||||
"effective_context_window_percent": 95,
|
||||
"default_reasoning_level": null,
|
||||
"supported_reasoning_levels": [],
|
||||
"shell_type": "unified_exec",
|
||||
"input_modalities": ["text", "image"],
|
||||
"supports_image_detail_original": false,
|
||||
"supports_reasoning_summary_parameter": true,
|
||||
"default_reasoning_summary": "auto",
|
||||
"support_verbosity": false,
|
||||
"default_verbosity": null,
|
||||
"apply_patch_tool_type": null,
|
||||
"web_search_tool_type": "text",
|
||||
"truncation_policy": {"mode": "bytes", "limit": 10000},
|
||||
"experimental_supported_tools": [],
|
||||
"supported_in_api": true,
|
||||
"include_skills_usage_instructions": false,
|
||||
"include_plugin_usage_instructions": false,
|
||||
"include_apps_usage_instructions": false,
|
||||
"supports_search_tool": false,
|
||||
"use_responses_lite": false,
|
||||
"guardian": null,
|
||||
"node_repl_auto_review_required": false,
|
||||
"node_repl_disabled": false,
|
||||
"auto_review_model_override": null,
|
||||
"model_specialty": null,
|
||||
"tool_mode": null,
|
||||
"multi_agent_version": null,
|
||||
"multi_agent_reasoning_effort": null,
|
||||
"availability_nux": null,
|
||||
"upgrade": null,
|
||||
"default_service_tier": null,
|
||||
"visibility": "list",
|
||||
"additional_speed_tiers": ["fast"],
|
||||
"service_tiers": [{
|
||||
"id": "priority",
|
||||
"name": "Fast",
|
||||
"description": "1.5x speed, increased usage"
|
||||
}]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (field, value) in expected.as_object().unwrap() {
|
||||
assert_eq!(model.get(field), Some(value), "field: {field}");
|
||||
}
|
||||
assert_eq!(
|
||||
model["model_messages"]["instructions_template"],
|
||||
sources.fallback["base_instructions"]
|
||||
);
|
||||
let messages = model["model_messages"].as_object().unwrap();
|
||||
assert_eq!(messages.len(), 12);
|
||||
assert!(messages
|
||||
.iter()
|
||||
.all(|(field, value)| field == "instructions_template" || value.is_null()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_fallback_templates_receive_official_message_and_capability_defaults() {
|
||||
let sources = test_sources();
|
||||
let runtime = runtime(serde_json::json!({"models":[{"id":"C"}]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
|
||||
assert_eq!(model["shell_type"], "unified_exec");
|
||||
assert_eq!(model["include_apps_usage_instructions"], false);
|
||||
assert_eq!(model["include_plugin_usage_instructions"], false);
|
||||
assert_eq!(model["node_repl_disabled"], false);
|
||||
assert_eq!(model["default_reasoning_level"], Value::Null);
|
||||
assert!(reasoning_efforts(model).is_empty());
|
||||
assert_eq!(
|
||||
model["model_messages"]["instructions_template"],
|
||||
sources.fallback["base_instructions"]
|
||||
);
|
||||
assert_eq!(model["default_service_tier"], Value::Null);
|
||||
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_preserves_explicit_template_reasoning_and_structured_instructions() {
|
||||
let mut sources = test_sources();
|
||||
sources.fallback.insert(
|
||||
"model_messages".to_string(),
|
||||
serde_json::json!({
|
||||
"instructions_template": "Custom {{ personality }} instructions",
|
||||
"instructions_variables": {"personality_default": "pragmatic"},
|
||||
"tools": {"custom": "Keep this"}
|
||||
}),
|
||||
);
|
||||
sources.fallback.insert(
|
||||
"default_reasoning_level".to_string(),
|
||||
serde_json::json!("low"),
|
||||
);
|
||||
sources.fallback.insert(
|
||||
"supported_reasoning_levels".to_string(),
|
||||
serde_json::json!([{"effort": "low", "description": "Low"}]),
|
||||
);
|
||||
sources.fallback.insert(
|
||||
"supports_image_detail_original".to_string(),
|
||||
Value::Bool(true),
|
||||
);
|
||||
let runtime = runtime(serde_json::json!({"models":[{
|
||||
"id":"C",
|
||||
"default_reasoning_level":"high",
|
||||
"model_messages":{"instructions_template":"Untrusted instructions"}
|
||||
}]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
|
||||
assert_eq!(model["default_reasoning_level"], "low");
|
||||
assert_eq!(reasoning_efforts(model), ["low"]);
|
||||
assert_eq!(model["supports_image_detail_original"], true);
|
||||
for field in ["instructions_template", "instructions_variables", "tools"] {
|
||||
assert_eq!(
|
||||
model["model_messages"][field],
|
||||
sources.fallback["model_messages"][field]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_display_name_matches_requested_model_slug() {
|
||||
let runtime = runtime(serde_json::json!({"models":[{
|
||||
@@ -1071,7 +1287,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_runtime_reasoning_does_not_reduce_open_levels() {
|
||||
fn fallback_does_not_invent_reasoning_capabilities_from_runtime_metadata() {
|
||||
let runtime = runtime(serde_json::json!({"models":[{
|
||||
"id":"C",
|
||||
"supported_reasoning_levels":["xhigh"],
|
||||
@@ -1086,8 +1302,8 @@ mod tests {
|
||||
assert_eq!(model["web_search_tool_type"], "text");
|
||||
assert_eq!(model["availability_nux"], Value::Null);
|
||||
assert_eq!(model["upgrade"], Value::Null);
|
||||
assert_eq!(model["default_reasoning_level"], "medium");
|
||||
assert_eq!(reasoning_efforts(model), ALL_REASONING_LEVELS);
|
||||
assert_eq!(model["default_reasoning_level"], Value::Null);
|
||||
assert!(reasoning_efforts(model).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,9 +2,10 @@ use super::{
|
||||
agent_managed_paths, apply_gui_managed_settings, consume_software_write,
|
||||
core_config_settings_from_value, core_install_dir, gui_config_path, is_loopback_host,
|
||||
lock_core_config_file, normalized_config_path, path_to_string,
|
||||
refresh_agent_config_status_cache, validate_gui_config, write_yaml_if_changed, AgentClient,
|
||||
AgentConfigStatusCache, ConfigFilesChangedPayload, CoreConfigSettings, GuiConfigFile,
|
||||
GuiConfigState, CONFIG_FILES_CHANGED_EVENT, CORE_CONFIG_FILE,
|
||||
refresh_agent_config_status_cache, refresh_applied_codex_model_catalog, validate_gui_config,
|
||||
write_yaml_if_changed, AgentClient, AgentConfigStatusCache, ConfigFilesChangedPayload,
|
||||
CoreConfigSettings, GuiConfigFile, GuiConfigState, CONFIG_FILES_CHANGED_EVENT,
|
||||
CORE_CONFIG_FILE,
|
||||
};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::{
|
||||
@@ -202,6 +203,21 @@ fn handle_configuration_file_changes(
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let catalog_app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let config = match catalog_app.state::<GuiConfigState>().snapshot() {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("读取配置以刷新 Codex 模型目录失败: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = refresh_applied_codex_model_catalog(&catalog_app, &config).await {
|
||||
eprintln!("配置变化后刷新 Codex 模型目录失败: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refresh_agents |= tracked_changes
|
||||
|
||||
@@ -2378,6 +2378,8 @@ fn main() {
|
||||
eprintln!("启动配置文件监控失败: {error}");
|
||||
}
|
||||
|
||||
start_codex_model_catalog_sync(app.handle().clone());
|
||||
|
||||
let usage_app = app.handle().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(error) = usage::initialize_usage_storage() {
|
||||
|
||||
@@ -2185,7 +2185,11 @@ fn codex_model_list_is_empty_when_cpa_has_no_writable_models() {
|
||||
let prepared = prepare_codex_agent_models(&[]).unwrap();
|
||||
|
||||
assert!(prepared.models.is_empty());
|
||||
assert!(prepared.codex_catalog.is_none());
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(prepared.codex_catalog.as_deref().unwrap())
|
||||
.unwrap(),
|
||||
serde_json::json!({"models": []})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1310,6 +1310,315 @@ fn codex_applied_configuration_remains_detectable_after_process_restart() {
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_catalog_sync_removes_models_missing_from_runtime() {
|
||||
let home = agent_test_home("codex-runtime-catalog-sync");
|
||||
let original_models = test_agent_models(&["gpt-active", "gpt-stale"]);
|
||||
let original_catalog = test_codex_models(&["gpt-active", "gpt-stale"]);
|
||||
apply_agent_configuration(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-active",
|
||||
&original_models,
|
||||
Some(&original_catalog),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let current_models = test_agent_models(&["gpt-active"]);
|
||||
let current_catalog = test_codex_models(&["gpt-active"]);
|
||||
assert!(sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
¤t_models,
|
||||
¤t_catalog,
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
let written = fs::read_to_string(&catalog_path).unwrap();
|
||||
assert!(written.contains("gpt-active"));
|
||||
assert!(!written.contains("gpt-stale"));
|
||||
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
|
||||
assert!(!catalog_path.exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration() {
|
||||
let home = agent_test_home("codex-runtime-catalog-default-missing");
|
||||
let config_path = home.join(".codex/config.toml");
|
||||
let original_config =
|
||||
"# Keep my settings\nmodel = \"personal-model\"\napproval_policy = \"on-request\"\n";
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
fs::write(&config_path, original_config).unwrap();
|
||||
let original_models = test_agent_models(&["gpt-stale"]);
|
||||
let original_catalog = test_codex_models(&["gpt-stale"]);
|
||||
apply_agent_configuration(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-stale",
|
||||
&original_models,
|
||||
Some(&original_catalog),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let current_models = test_agent_models(&["gpt-active"]);
|
||||
let current_catalog = test_codex_models(&["gpt-active"]);
|
||||
assert!(sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
¤t_models,
|
||||
¤t_catalog,
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(codex_model_catalog_path(&home)).unwrap(),
|
||||
current_catalog
|
||||
);
|
||||
let updated = fs::read_to_string(&config_path).unwrap();
|
||||
let document: toml::Value = toml::from_str(&updated).unwrap();
|
||||
assert_eq!(document["model"].as_str(), Some("gpt-active"));
|
||||
assert_eq!(document["approval_policy"].as_str(), Some("on-request"));
|
||||
assert!(updated.contains("# Keep my settings"));
|
||||
assert_eq!(
|
||||
load_agent_applied_state(AgentClient::Codex, &home)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.model,
|
||||
"gpt-active"
|
||||
);
|
||||
assert!(!sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
¤t_models,
|
||||
¤t_catalog,
|
||||
)
|
||||
.unwrap());
|
||||
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
|
||||
assert_eq!(fs::read_to_string(&config_path).unwrap(), original_config);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_catalog_sync_clears_empty_list_and_recovers_when_models_return() {
|
||||
let home = agent_test_home("codex-runtime-catalog-empty-and-recovery");
|
||||
let config_path = home.join(".codex/config.toml");
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
apply_agent_configuration(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-stale",
|
||||
&test_agent_models(&["gpt-stale"]),
|
||||
Some(&test_codex_models(&["gpt-stale"])),
|
||||
)
|
||||
.unwrap();
|
||||
let auth_path = home.join(".codex/auth.json");
|
||||
let original_auth = fs::read(&auth_path).unwrap();
|
||||
let empty = prepare_codex_agent_models(&[]).unwrap();
|
||||
let empty_catalog = empty.codex_catalog.as_deref().unwrap();
|
||||
|
||||
assert!(sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
&empty.models,
|
||||
empty_catalog,
|
||||
)
|
||||
.unwrap());
|
||||
let document: toml::Value = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
|
||||
assert!(document.get("model").is_none());
|
||||
assert_eq!(
|
||||
document["model_provider"].as_str(),
|
||||
Some(MANAGED_AGENT_PROVIDER_ID)
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), empty_catalog);
|
||||
assert_eq!(fs::read(&auth_path).unwrap(), original_auth);
|
||||
assert!(!sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
&empty.models,
|
||||
empty_catalog,
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
let returned_models = test_agent_models(&["gpt-returned", "gpt-second"]);
|
||||
let returned_catalog = test_codex_models(&["gpt-returned", "gpt-second"]);
|
||||
assert!(sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
&returned_models,
|
||||
&returned_catalog,
|
||||
)
|
||||
.unwrap());
|
||||
let document: toml::Value = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
|
||||
assert_eq!(document["model"].as_str(), Some("gpt-returned"));
|
||||
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), returned_catalog);
|
||||
assert_eq!(fs::read(&auth_path).unwrap(), original_auth);
|
||||
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
|
||||
assert!(!config_path.exists());
|
||||
assert!(!catalog_path.exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_catalog_sync_after_restart_backs_up_managed_files_without_memory_state() {
|
||||
let home = agent_test_home("codex-runtime-catalog-after-restart");
|
||||
let config_path = home.join(".codex/config.toml");
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
let original_catalog = test_codex_models(&["gpt-stale"]);
|
||||
apply_agent_configuration(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-stale",
|
||||
&test_agent_models(&["gpt-stale"]),
|
||||
Some(&original_catalog),
|
||||
)
|
||||
.unwrap();
|
||||
let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap();
|
||||
clear_codex_applied_state(&state_path).unwrap();
|
||||
assert!(load_agent_applied_state(AgentClient::Codex, &home)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let current_catalog = test_codex_models(&["gpt-active"]);
|
||||
assert!(sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
&test_agent_models(&["gpt-active"]),
|
||||
¤t_catalog,
|
||||
)
|
||||
.unwrap());
|
||||
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), current_catalog);
|
||||
assert_eq!(
|
||||
fs::read_to_string(dated_agent_backup_path(&catalog_path).unwrap()).unwrap(),
|
||||
original_catalog
|
||||
);
|
||||
assert!(load_agent_applied_state(AgentClient::Codex, &home)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_catalog_only_sync_after_restart_preserves_complete_backups() {
|
||||
let home = agent_test_home("codex-runtime-catalog-only-after-restart");
|
||||
let config_path = home.join(".codex/config.toml");
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
let original_catalog = test_codex_models(&["gpt-active", "gpt-stale"]);
|
||||
apply_agent_configuration(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-active",
|
||||
&test_agent_models(&["gpt-active", "gpt-stale"]),
|
||||
Some(&original_catalog),
|
||||
)
|
||||
.unwrap();
|
||||
let original_config = fs::read(&config_path).unwrap();
|
||||
let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap();
|
||||
clear_codex_applied_state(&state_path).unwrap();
|
||||
assert!(sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
&test_agent_models(&["gpt-active"]),
|
||||
&test_codex_models(&["gpt-active"]),
|
||||
)
|
||||
.unwrap());
|
||||
assert_eq!(fs::read(&config_path).unwrap(), original_config);
|
||||
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
|
||||
assert_eq!(fs::read(&config_path).unwrap(), original_config);
|
||||
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_catalog_sync_leaves_unmanaged_configuration_untouched() {
|
||||
let home = agent_test_home("codex-runtime-catalog-unmanaged");
|
||||
let config_path = home.join(".codex/config.toml");
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
let original_config = "model_provider = \"personal\"\nmodel = \"personal-model\"\n";
|
||||
let original_catalog = test_codex_models(&["personal-model"]);
|
||||
fs::write(&config_path, original_config).unwrap();
|
||||
fs::write(&catalog_path, &original_catalog).unwrap();
|
||||
assert!(!sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
&[],
|
||||
"{\"models\":[]}",
|
||||
)
|
||||
.unwrap());
|
||||
assert_eq!(fs::read_to_string(&config_path).unwrap(), original_config);
|
||||
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_catalog_sync_rejects_invalid_snapshots_without_changing_files() {
|
||||
let home = agent_test_home("codex-runtime-catalog-invalid-snapshot");
|
||||
let config_path = home.join(".codex/config.toml");
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
apply_agent_configuration(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-stale",
|
||||
&test_agent_models(&["gpt-stale"]),
|
||||
Some(&test_codex_models(&["gpt-stale"])),
|
||||
)
|
||||
.unwrap();
|
||||
let config_before = fs::read(&config_path).unwrap();
|
||||
let catalog_before = fs::read(&catalog_path).unwrap();
|
||||
let state_before =
|
||||
serde_json::to_value(load_agent_applied_state(AgentClient::Codex, &home).unwrap()).unwrap();
|
||||
for invalid in [
|
||||
"not json",
|
||||
"{}",
|
||||
"{\"models\":null}",
|
||||
"{\"models\":[{\"slug\":\"gpt-stale\"}]}",
|
||||
] {
|
||||
assert!(
|
||||
sync_codex_model_catalog_if_configured(&home, 8317, DEFAULT_API_KEY, &[], invalid,)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(sync_codex_model_catalog_if_configured(
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
&test_agent_models(&["gpt-active"]),
|
||||
"{\"models\":[]}",
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(fs::read(&config_path).unwrap(), config_before);
|
||||
assert_eq!(fs::read(&catalog_path).unwrap(), catalog_before);
|
||||
assert_eq!(
|
||||
serde_json::to_value(load_agent_applied_state(AgentClient::Codex, &home).unwrap()).unwrap(),
|
||||
state_before
|
||||
);
|
||||
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_session_restore_recovers_from_a_dated_backup_without_state_json() {
|
||||
let home = agent_test_home("codex-session-backup-recovery");
|
||||
|
||||
@@ -783,6 +783,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
void loadStatuses().catch((requestError) => {
|
||||
if (!disposed) setDetectionError(String(requestError));
|
||||
});
|
||||
void loadModels(selected);
|
||||
}).then((unlisten) => {
|
||||
if (disposed) unlisten();
|
||||
else stop = unlisten;
|
||||
@@ -791,7 +792,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
disposed = true;
|
||||
stop?.();
|
||||
};
|
||||
}, [loadStatuses]);
|
||||
}, [loadModels, loadStatuses, selected]);
|
||||
|
||||
useEffect(() => {
|
||||
writeSelectedAgentClient(selected);
|
||||
|
||||
Reference in new issue
Block a user