diff --git a/src-tauri/src/agents/backups/restore.rs b/src-tauri/src/agents/backups/restore.rs index 389d9e0f..de661503 100644 --- a/src-tauri/src/agents/backups/restore.rs +++ b/src-tauri/src/agents/backups/restore.rs @@ -101,6 +101,7 @@ fn desktop_restore_configuration( context_window: None, input_modalities: None, harness_metadata: None, + catalog_metadata: None, }) .collect(); Ok(Some((mappings, models))) diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index e8df370b..a74e456f 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -27,6 +27,7 @@ fn models() -> Vec { .map(|name| AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: name.into(), alias: None, is_alias: false, @@ -1172,6 +1173,9 @@ fn linked_configuration_and_backup_directories_are_rejected() { fs::read_to_string(outside.0.join("config.toml")).unwrap(), "custom='outside'" ); + #[cfg(unix)] + fs::remove_file(link).unwrap(); + #[cfg(windows)] fs::remove_dir(link).unwrap(); let data = agent_data_directory(&paths).unwrap(); fs::create_dir_all(&data).unwrap(); @@ -1179,6 +1183,9 @@ fn linked_configuration_and_backup_directories_are_rejected() { assert!(create_backup("codex", &home.0).is_err()); assert!(list_backups("codex", &home.0).is_err()); assert!(delete_backup("codex", &home.0, "1").is_err()); + #[cfg(unix)] + fs::remove_file(data.join("backups")).unwrap(); + #[cfg(windows)] fs::remove_dir(data.join("backups")).unwrap(); } diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index eb472efc..18a2ef6f 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -722,13 +722,29 @@ pub(crate) async fn fetch_agent_models( port: u16, api_key: &str, ) -> Result, String> { - let payload = fetch_agent_model_payload(port, api_key).await?; + fetch_agent_models_for_client(port, api_key, None).await +} + +async fn fetch_agent_models_for_client( + port: u16, + api_key: &str, + client_version: Option<&str>, +) -> Result, String> { + let payload = fetch_agent_model_payload_for_client(port, api_key, client_version).await?; parse_agent_model_options(&payload) } pub(crate) async fn fetch_agent_model_payload( port: u16, api_key: &str, +) -> Result { + fetch_agent_model_payload_for_client(port, api_key, None).await +} + +async fn fetch_agent_model_payload_for_client( + port: u16, + api_key: &str, + client_version: Option<&str>, ) -> Result { if port == 0 { return Err("Invalid kernel port".to_string()); @@ -748,8 +764,11 @@ pub(crate) async fn fetch_agent_model_payload( ]; for (index, endpoint) in endpoints.iter().enumerate() { - let response = client - .get(endpoint) + let mut request = client.get(endpoint); + if let Some(version) = client_version { + request = request.query(&[("client_version", version)]); + } + let response = request .bearer_auth(api_key) .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::USER_AGENT, USER_AGENT) @@ -804,7 +823,9 @@ pub(crate) async fn fetch_codex_runtime_models( for (index, endpoint) in endpoints.iter().enumerate() { let response = client .get(endpoint) - .query(&[("client_version", env!("CARGO_PKG_VERSION"))]) + // Request the complete catalog. The GUI version is not the target + // Codex CLI version and would incorrectly suppress newer efforts. + .query(&[("client_version", "")]) .bearer_auth(api_key) .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::USER_AGENT, USER_AGENT) @@ -858,7 +879,9 @@ pub(crate) async fn fetch_prepared_agent_models( let runtime_models = fetch_codex_catalog_runtime_models(config).await?; prepare_codex_agent_models(&runtime_models) } else { - let mut models = if client == AgentClient::DeepSeekHarness { + let mut models = if client == AgentClient::OpenCode { + fetch_agent_models_for_client(config.port, api_key, Some("opencode")).await? + } else if client == AgentClient::DeepSeekHarness { fetch_deepseek_harness_models(config).await? } else { fetch_agent_models(config.port, api_key).await? @@ -1327,7 +1350,7 @@ pub(crate) fn parse_agent_model_options( let name = if let Some(name) = item.as_str() { name.trim().to_string() } else { - ["id", "name", "model", "value"] + ["slug", "id", "name", "model", "value"] .into_iter() .find_map(|key| item.get(key).and_then(serde_json::Value::as_str)) .unwrap_or_default() @@ -1367,6 +1390,7 @@ pub(crate) fn parse_agent_model_options( codex_catalog::parse_modalities(&serde_json::json!({"input_modalities": item.get("input")})) }); let harness_metadata = harness_api_metadata(item); + let first_new_model = models.len(); if let Some(model_alias) = model_alias { if keep_original { @@ -1376,6 +1400,12 @@ pub(crate) fn parse_agent_model_options( } else { append_agent_model_option(&mut models, &name, display_name, false, context_window, input_modalities, harness_metadata); } + for model in &mut models[first_new_model..] { + let metadata = ["max_tokens", "max_completion_tokens", "max_output_tokens", "input_modalities", "supported_input_modalities", "output_modalities", "supported_reasoning_levels"] + .into_iter().filter_map(|key| item.get(key).map(|value| (key.to_string(), value.clone()))) + .collect::>(); + model.catalog_metadata = (!metadata.is_empty()).then_some(serde_json::Value::Object(metadata)); + } } Ok(models) } @@ -1405,6 +1435,7 @@ pub(crate) fn append_agent_model_option( models.push(AgentModelOption { input_modalities, harness_metadata, + catalog_metadata: None, name: name.to_string(), alias, is_alias, diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index ff235f95..297249ea 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -719,6 +719,7 @@ pub(crate) fn ordered_agent_models( ordered.push(AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: selected_model.to_string(), alias: None, is_alias: false, @@ -3026,7 +3027,9 @@ pub(crate) fn build_opencode_agent_config( .into_iter() .map(|model| { let display_name = model.alias.as_deref().unwrap_or(&model.name).to_string(); - (model.name, serde_json::json!({ "name": display_name })) + let mut entry = serde_json::json!({ "name": display_name }); + apply_opencode_catalog_capabilities(&mut entry, &model); + (model.name, entry) }) .collect::>(); let managed_provider = ensure_json_object_entry(providers, MANAGED_AGENT_PROVIDER_ID); @@ -3052,6 +3055,75 @@ pub(crate) fn build_opencode_agent_config( } } +fn apply_opencode_catalog_capabilities(entry: &mut serde_json::Value, model: &AgentModelOption) { + let Some(metadata) = model.catalog_metadata.as_ref() else { + return; + }; + let output = ["max_tokens", "max_completion_tokens", "max_output_tokens"] + .into_iter() + .find_map(|key| metadata.get(key).and_then(json_positive_u64)); + // OpenCode requires both fields whenever a limit object is supplied. + if let (Some(context), Some(output)) = (model.context_window, output) { + entry["limit"] = serde_json::json!({"context": context, "output": output}); + } + let mut modalities = serde_json::Map::new(); + for (target, source) in [ + ("input", "input_modalities"), + ("output", "output_modalities"), + ] { + let value = if target == "input" { + metadata.get("supported_input_modalities").or_else(|| metadata.get(source)) + } else { + metadata.get(source) + }; + if let Some(values) = value.and_then(serde_json::Value::as_array) { + let supported = values + .iter() + .filter_map(serde_json::Value::as_str) + .filter(|value| matches!(*value, "text" | "image" | "audio" | "video" | "pdf")) + .map(|value| serde_json::json!(value)) + .collect::>(); + modalities.insert(target.into(), serde_json::json!(supported)); + } + } + if !modalities.is_empty() { + entry["modalities"] = serde_json::Value::Object(modalities); + } + if let Some(levels) = metadata + .get("supported_reasoning_levels") + .and_then(serde_json::Value::as_array) + { + // OpenCode merges configured variants into SDK-inferred defaults. Explicit + // tombstones are required to keep an advertised effort list authoritative. + let mut variants = [ + "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", + ] + .into_iter() + .map(|effort| (effort.to_string(), serde_json::json!({"disabled": true}))) + .collect::>(); + for level in levels { + if let Some(effort) = level + .as_str() + .or_else(|| level.get("effort").and_then(serde_json::Value::as_str)) + { + if matches!( + effort, + "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra" + ) { + variants.insert( + effort.into(), + serde_json::json!({"reasoningEffort": effort}), + ); + } + } + } + entry["reasoning"] = serde_json::json!(variants + .iter() + .any(|(level, value)| level != "none" && value.get("reasoningEffort").is_some())); + entry["variants"] = serde_json::Value::Object(variants); + } +} + pub(crate) fn build_zcode_agent_config( existing: Option<&str>, base_url: &str, diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 5c21d305..88bf6150 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -225,7 +225,18 @@ pub(crate) fn pi_provider_update_available(installed: &str, latest: &str) -> Res pub(crate) fn pi_package_source_matches(value: &str) -> bool { let value = value.trim(); - value == PI_CLIPROXYAPI_PACKAGE || value.starts_with(&format!("{PI_CLIPROXYAPI_PACKAGE}@")) + if value == PI_CLIPROXYAPI_PACKAGE || value.starts_with(&format!("{PI_CLIPROXYAPI_PACKAGE}@")) { + return true; + } + let path = Path::new(value); + path.is_absolute() + && fs::read_to_string(path.join("package.json")) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .is_some_and(|package| { + package.get("name").and_then(serde_json::Value::as_str) + == Some("@router-for-me/pi-cliproxyapi-provider") + }) } pub(crate) fn read_pi_settings(home: &Path) -> Result, String> { diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 86182321..b14eccf4 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -637,6 +637,7 @@ pub(crate) fn fresh_agent_contents( let models = [AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: model.to_string(), alias: None, is_alias: false, @@ -1489,6 +1490,7 @@ pub(crate) fn reset_agent_configuration_to_default( models: &[AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: model.to_string(), alias: None, is_alias: false, diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index c85b2c94..3b568bc8 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -703,6 +703,13 @@ fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: & object.remove(*key); } } + if client == "opencode" && after.get("variants").is_some() { + if let Some(variants) = extensions.get_mut("variants").and_then(Value::as_object_mut) { + // Do not resurrect previously generated efforts after a catalog change. + // Named user variants remain extensions of the managed model. + variants.retain(|key, _| !matches!(key.as_str(), "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra")); + } + } fill_missing(&extensions, after); } fn inventory(client: &str, before: &Value, after: &mut Value) { diff --git a/src-tauri/src/agents/transactions/tests.rs b/src-tauri/src/agents/transactions/tests.rs index 888fa5e0..f66b28f3 100644 --- a/src-tauri/src/agents/transactions/tests.rs +++ b/src-tauri/src/agents/transactions/tests.rs @@ -1,5 +1,24 @@ use super::*; +#[test] +fn opencode_model_merge_drops_retired_efforts_and_preserves_custom_variants() { + let before = serde_json::json!({"provider": {"cpa-gui": {"models": {"source/model": { + "name": "old", + "variants": {"medium": {"reasoningEffort": "medium"}, "careful": {"temperature": 0.1}}, + "options": {"custom": true} + }}}}}); + let mut after = serde_json::json!({"provider": {"cpa-gui": {"models": {"source/model": { + "name": "new", + "variants": {"low": {"reasoningEffort": "low"}, "high": {"reasoningEffort": "high"}, "medium": {"disabled": true}} + }}}}}); + preserve_model_extensions("opencode", Path::new("opencode.json"), &before, &mut after); + let model = &after["provider"]["cpa-gui"]["models"]["source/model"]; + assert_eq!(model["variants"]["medium"], serde_json::json!({"disabled": true})); + assert_eq!(model["variants"]["careful"]["temperature"], 0.1); + assert_eq!(model["variants"]["high"]["reasoningEffort"], "high"); + assert_eq!(model["options"]["custom"], true); +} + #[test] fn codex_model_merge_does_not_resurrect_removed_schema_fields() { let before = serde_json::json!({"models": [{ diff --git a/src-tauri/src/agents/workbuddy/tests.rs b/src-tauri/src/agents/workbuddy/tests.rs index 6dd71d5e..dd3046b1 100644 --- a/src-tauri/src/agents/workbuddy/tests.rs +++ b/src-tauri/src/agents/workbuddy/tests.rs @@ -366,6 +366,7 @@ fn workbuddy_switch_refreshes_retained_credentials_and_metadata() { context_window: Some(128_000), input_modalities: Some(vec!["text".into(), "image".into()]), harness_metadata: None, + catalog_metadata: None, }], ) .unwrap(); @@ -437,6 +438,7 @@ fn workbuddy_metadata_and_model_extensions_survive_reapply() { context_window: Some(128_000), input_modalities: Some(vec!["text".into(), "image".into()]), harness_metadata: None, + catalog_metadata: None, }], ) .unwrap(); diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index e398b7d2..01e1daa3 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -19,6 +19,7 @@ static CATALOG_STATE: OnceLock, String>> = OnceLock: #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CodexRuntimeModel { pub(crate) slug: String, + canonical_model_id: Option, display_name: Option, description: Option, context_window: Option, @@ -26,6 +27,7 @@ pub(crate) struct CodexRuntimeModel { context_source: &'static str, input_modalities: Option>, default_reasoning_level: Option, + capabilities: Map, hidden: bool, } @@ -94,6 +96,7 @@ pub(crate) fn is_managed_model_field(field: &str) -> bool { ) .map(String::as_str) .chain(customizations::EDITABLE_FIELDS) + .chain(["max_tokens"]) .map(str::to_string) .collect() }) @@ -202,6 +205,7 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result| CodexRuntimeModel { slug: slug.to_string(), + canonical_model_id: optional_string(value, &["canonical_model_id"]), display_name, description: description.clone(), context_window, @@ -213,6 +217,7 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result) { model.remove("minimal_client_version"); } +fn runtime_capability_fields(value: &Value) -> Map { + let mut fields = Map::new(); + if let Some(visibility) = optional_string(value, &["visibility"]) + .map(|visibility| visibility.to_ascii_lowercase()) + .filter(|visibility| matches!(visibility.as_str(), "list" | "hide")) + { + fields.insert("visibility".into(), Value::String(visibility)); + } + if let Some(limit) = positive_u64_field( + value, + &["max_tokens", "max_completion_tokens", "max_output_tokens"], + ) { + fields.insert("max_tokens".into(), Value::from(limit)); + } + if let Some(levels) = value + .get("supported_reasoning_levels") + .and_then(Value::as_array) + { + let levels = levels + .iter() + .filter_map(|level| { + let effort = level + .as_str() + .or_else(|| level.get("effort").and_then(Value::as_str))?; + is_allowed_reasoning_level(effort).then(|| serde_json::json!({ + "effort": effort, + "description": level.get("description").and_then(Value::as_str).unwrap_or(effort) + })) + }) + .collect::>(); + fields.insert("supported_reasoning_levels".into(), Value::Array(levels)); + } + if let Some(tiers) = value.get("service_tiers").and_then(Value::as_array) { + let tiers = tiers + .iter() + .filter(|tier| tier.get("id").and_then(Value::as_str).is_some()) + .cloned() + .collect(); + fields.insert("service_tiers".into(), Value::Array(tiers)); + } + if let Some(speeds) = value + .get("additional_speed_tiers") + .and_then(Value::as_array) + { + let speeds = ["fast", "ultrafast"] + .into_iter() + .filter(|speed| speeds.iter().any(|value| value.as_str() == Some(*speed))) + .map(Value::from) + .collect(); + fields.insert("additional_speed_tiers".into(), Value::Array(speeds)); + } + fields +} + +fn apply_runtime_capability_fields(model: &mut Map, runtime: &CodexRuntimeModel) { + model.extend(runtime.capabilities.clone()); + // The canonical identity chooses trusted instructions, not the capabilities + // of this route. Explicit runtime restrictions apply to native templates too. + if let Some(modalities) = &runtime.input_modalities { + model.insert("input_modalities".into(), serde_json::json!(modalities)); + } + if let Some(levels) = runtime + .capabilities + .get("supported_reasoning_levels") + .and_then(Value::as_array) + { + let default = runtime + .default_reasoning_level + .as_deref() + .filter(|default| levels.iter().any(|level| level["effort"] == *default)) + .or_else(|| levels.first().and_then(|level| level["effort"].as_str())); + model.insert( + "default_reasoning_level".into(), + default.map(Value::from).unwrap_or(Value::Null), + ); + } + if let Some(tiers) = runtime + .capabilities + .get("service_tiers") + .and_then(Value::as_array) + { + if !runtime.capabilities.contains_key("additional_speed_tiers") { + let mut speeds = Vec::new(); + if tiers + .iter() + .any(|tier| tier["id"] == "priority" || tier["id"] == "fast") + { + speeds.push("fast"); + } + if tiers.iter().any(|tier| tier["id"] == "ultrafast") { + speeds.push("ultrafast"); + } + model.insert("additional_speed_tiers".into(), serde_json::json!(speeds)); + } + if !tiers + .iter() + .any(|tier| Some(&tier["id"]) == model.get("default_service_tier")) + { + model.insert("default_service_tier".into(), Value::Null); + } + } +} + fn enable_fast_mode(model: &mut Map) { model.insert( "service_tiers".to_string(), @@ -863,6 +979,83 @@ mod tests { parse_runtime_models(&payload).unwrap() } + #[test] + fn routing_alias_preserves_explicit_structured_instructions() { + let messages = serde_json::json!({"instructions_template":"Native {{personality}}", "instructions_variables":{"personality":"precise"}, "multi_agent":{"test":"retained"}}); + let models = runtime(serde_json::json!({"models":[{ + "slug":"personal/provider/A", "canonical_model_id":"A", + "base_instructions":"Native base", "model_messages":messages, + "include_plugin_usage_instructions":true, "include_apps_usage_instructions":true, + "include_skills_usage_instructions":false + }]})); + let mut sources = test_sources(); + let template = &mut sources.templates.get_mut("a").unwrap().value; + template.insert("base_instructions".into(), Value::from("Native base")); + template.insert("model_messages".into(), messages.clone()); + template.insert("include_plugin_usage_instructions".into(), Value::Bool(true)); + template.insert("include_apps_usage_instructions".into(), Value::Bool(true)); + template.insert("include_skills_usage_instructions".into(), Value::Bool(false)); + let output = output_models(&prepare_catalog_with_sources(&models, &sources).unwrap()); + assert_eq!(output[0]["slug"], "personal/provider/A"); + assert_eq!(output[0]["base_instructions"], "Native base"); + assert_eq!(output[0]["model_messages"], messages); + assert_eq!(output[0]["include_plugin_usage_instructions"], true); + assert_eq!(output[0]["include_apps_usage_instructions"], true); + assert_eq!(output[0]["include_skills_usage_instructions"], false); + } + + #[test] + fn canonical_alias_overlays_route_modalities_and_visibility_without_changing_prompts() { + let mut sources = test_sources(); + let template = &mut sources.templates.get_mut("a").unwrap().value; + template.insert( + "input_modalities".into(), + serde_json::json!(["text", "image"]), + ); + template.insert("visibility".into(), Value::from("hide")); + template.insert( + "model_messages".into(), + serde_json::json!({"instructions_template":"Trusted instructions"}), + ); + let models = runtime(serde_json::json!({"models":[ + {"slug":"personal/text-only", "canonical_model_id":"A", "display_name":"P · Text", + "input_modalities":["text"], "visibility":"list", "base_instructions":"Untrusted"}, + {"slug":"personal/hidden", "canonical_model_id":"A", "visibility":"hide"}, + {"slug":"personal/unspecified", "canonical_model_id":"A"} + ]})); + let output = output_models(&prepare_catalog_with_sources(&models, &sources).unwrap()); + let find = |id: &str| output.iter().find(|model| model["slug"] == id).unwrap(); + let restricted = find("personal/text-only"); + assert_eq!(restricted["input_modalities"], serde_json::json!(["text"])); + assert_eq!(restricted["visibility"], "list"); + assert_eq!(restricted["display_name"], "P · Text"); + assert_eq!(restricted["base_instructions"], "Known A"); + assert_eq!( + restricted["model_messages"]["instructions_template"], + "Trusted instructions" + ); + assert_eq!(find("personal/hidden")["visibility"], "hide"); + assert_eq!( + find("personal/unspecified")["input_modalities"], + serde_json::json!(["text", "image"]) + ); + assert_eq!(find("personal/unspecified")["visibility"], "hide"); + } + + #[test] + fn canonical_template_requires_explicit_identity_and_ignores_remote_instructions() { + let models = runtime(serde_json::json!({"models":[ + {"slug":"personal/provider/A", "canonical_model_id":"A"}, + {"slug":"unrelated/A"}, + {"slug":"another", "base_instructions":"Explicit replacement"} + ]})); + let output = output_models(&prepare_catalog_with_sources(&models, &test_sources()).unwrap()); + let find = |id: &str| output.iter().find(|m| m["slug"] == id).unwrap(); + assert_eq!(find("personal/provider/A")["base_instructions"], "Known A"); + assert_ne!(find("unrelated/A")["base_instructions"], "Known A"); + assert_ne!(find("another")["base_instructions"], "Explicit replacement"); + } + fn output_models(catalog: &PreparedCodexCatalog) -> Vec> { let root = serde_json::from_str::(&catalog.json).unwrap(); assert!(root.get("fallback_model").is_none()); @@ -1110,6 +1303,7 @@ mod tests { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "GPT-Test".to_string(), alias: None, is_alias: false, @@ -1118,6 +1312,7 @@ mod tests { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "unmatched".to_string(), alias: None, is_alias: false, @@ -1126,6 +1321,7 @@ mod tests { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "max-only".to_string(), alias: None, is_alias: false, @@ -1266,23 +1462,19 @@ mod tests { } } assert_eq!(model["slug"], "a"); - assert_eq!(model["display_name"], "a"); + assert_eq!(model["display_name"], "Overwrite"); assert_eq!(model["nested"]["unknown"], true); assert_eq!(model["context_window"], 1); assert_eq!(model["max_context_window"], 1); assert_eq!( model["service_tiers"], - serde_json::json!([{ - "id": "priority", - "name": "Fast", - "description": "1.5x speed, increased usage" - }]) + serde_json::json!([]) ); - assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"])); + assert_eq!(model["additional_speed_tiers"], serde_json::json!([])); } #[test] - fn known_template_uses_runtime_slug_as_display_name() { + fn known_template_preserves_runtime_display_name_without_changing_route() { let runtime = runtime(serde_json::json!({"models":[{ "id":"A", "display_name":"Friendly A" @@ -1291,11 +1483,11 @@ mod tests { let model = &output_models(&catalog)[0]; assert_eq!(model["slug"], "A"); - assert_eq!(model["display_name"], "A"); + assert_eq!(model["display_name"], "Friendly A"); } #[test] - fn known_template_preserves_its_reasoning_levels_and_default() { + fn known_template_honors_explicit_runtime_reasoning_levels() { let mut sources = test_sources(); let template = &mut sources.templates.get_mut("a").unwrap().value; template.insert( @@ -1318,8 +1510,8 @@ mod tests { let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap(); let model = &output_models(&catalog)[0]; - assert_eq!(model["default_reasoning_level"], "medium"); - assert_eq!(reasoning_efforts(model), ["low", "medium", "max"]); + assert_eq!(model["default_reasoning_level"], "high"); + assert_eq!(reasoning_efforts(model), ["low", "high"]); } #[test] @@ -1406,6 +1598,33 @@ mod tests { ); } + #[test] + fn runtime_speed_tiers_survive_catalog_generation() { + for (fields, expected) in [ + ( + serde_json::json!({"service_tiers":[{"id":"priority"},{"id":"ultrafast"}]}), + serde_json::json!(["fast", "ultrafast"]), + ), + ( + serde_json::json!({"service_tiers":[{"id":"priority"}],"additional_speed_tiers":["ultrafast","invalid","ultrafast"]}), + serde_json::json!(["ultrafast"]), + ), + ( + serde_json::json!({"service_tiers":[{"id":"priority"}],"additional_speed_tiers":[]}), + serde_json::json!([]), + ), + ] { + let mut route = fields.as_object().unwrap().clone(); + route.insert("id".into(), Value::from("A")); + let runtime = runtime(serde_json::json!({"models":[route]})); + let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap(); + assert_eq!( + output_models(&catalog)[0]["additional_speed_tiers"], + expected + ); + } + } + #[test] fn fallback_accepts_only_safe_valid_metadata() { let runtime = runtime(serde_json::json!({"models":[{ @@ -1425,12 +1644,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!(reasoning_efforts(model).is_empty()); + assert_eq!(reasoning_efforts(model), ["low", "high"]); 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["supports_image_detail_original"], false); - assert_eq!(model["default_reasoning_level"], Value::Null); + assert_eq!(model["default_reasoning_level"], "high"); assert_eq!(model["visibility"], "hide"); assert_eq!( model["base_instructions"], @@ -1439,11 +1658,7 @@ mod tests { assert_eq!(model["supports_search_tool"], false); assert_eq!( model["service_tiers"], - serde_json::json!([{ - "id": "priority", - "name": "Fast", - "description": "1.5x speed, increased usage" - }]) + serde_json::json!([{"id":"priority"}]) ); assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"])); } @@ -1604,7 +1819,7 @@ mod tests { } #[test] - fn fallback_does_not_invent_reasoning_capabilities_from_runtime_metadata() { + fn fallback_uses_supported_runtime_effort_when_default_is_invalid() { let runtime = runtime(serde_json::json!({"models":[{ "id":"C", "supported_reasoning_levels":["xhigh"], @@ -1619,8 +1834,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"], Value::Null); - assert!(reasoning_efforts(model).is_empty()); + assert_eq!(model["default_reasoning_level"], "xhigh"); + assert_eq!(reasoning_efforts(model), ["xhigh"]); } #[test] diff --git a/src-tauri/src/codex_catalog/customizations.rs b/src-tauri/src/codex_catalog/customizations.rs index 5d2ee87e..2b2caaf8 100644 --- a/src-tauri/src/codex_catalog/customizations.rs +++ b/src-tauri/src/codex_catalog/customizations.rs @@ -412,6 +412,7 @@ mod tests { fn runtime_model(slug: &str) -> CodexRuntimeModel { CodexRuntimeModel { + canonical_model_id: None, slug: slug.to_string(), display_name: None, description: None, @@ -420,6 +421,7 @@ mod tests { context_source: "template", input_modalities: None, default_reasoning_level: None, + capabilities: Map::new(), hidden: false, } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 63eeaa26..e2d7a9ff 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1121,6 +1121,8 @@ struct AgentModelOption { input_modalities: Option>, #[serde(skip)] harness_metadata: Option, + #[serde(skip)] + catalog_metadata: Option, } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index df42a989..fa87ae58 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1,6 +1,44 @@ use super::support::*; use super::*; +#[tokio::test] +async fn codex_catalog_discovery_does_not_use_gui_version_as_cli_version() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 2048]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let read = socket.read(&mut buffer).unwrap(); + assert!(read > 0); + request.extend_from_slice(&buffer[..read]); + } + let request = String::from_utf8(request).unwrap().to_ascii_lowercase(); + assert!( + request.starts_with("get /v1/models?client_version= http/1.1\r\n"), + "{request}" + ); + assert!(request.contains("authorization: bearer test-catalog-key\r\n")); + let body = r#"{"models":[{"slug":"personal-provider/test-model","context_window":64000,"max_tokens":8192,"default_reasoning_level":"ultra","supported_reasoning_levels":[{"effort":"ultra","description":"Ultra"}]}]}"#; + write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); + }); + let runtime = fetch_codex_runtime_models(port, "test-catalog-key") + .await + .unwrap(); + server.join().unwrap(); + let catalog = codex_catalog::prepare_catalog(&runtime).unwrap(); + let payload: serde_json::Value = serde_json::from_str(&catalog.json).unwrap(); + let model = &payload["models"][0]; + assert_eq!(model["slug"], "personal-provider/test-model"); + assert_eq!(model["default_reasoning_level"], "ultra"); + assert_eq!(model["supported_reasoning_levels"][0]["effort"], "ultra"); +} + #[test] fn claude_agent_config_preserves_existing_fields() { let rendered = build_claude_agent_config( @@ -159,6 +197,7 @@ fn claude_code_role_mappings_drive_settings() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "gpt-opus-base".to_string(), alias: None, is_alias: false, @@ -167,6 +206,7 @@ fn claude_code_role_mappings_drive_settings() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: mappings.opus.clone(), alias: Some("gpt-opus-base".to_string()), is_alias: true, @@ -175,6 +215,7 @@ fn claude_code_role_mappings_drive_settings() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: mappings.sonnet.clone(), alias: None, is_alias: false, @@ -183,6 +224,7 @@ fn claude_code_role_mappings_drive_settings() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: mappings.haiku.clone(), alias: None, is_alias: false, @@ -228,6 +270,7 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "custom-pro".to_string(), alias: Some("Custom Pro".to_string()), is_alias: false, @@ -236,6 +279,7 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "custom-flash".to_string(), alias: Some("Custom Flash".to_string()), is_alias: false, @@ -295,6 +339,7 @@ fn claude_desktop_omits_unsupported_context_window_when_1m_is_off() { let models = vec![AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "runtime-model".to_string(), alias: None, is_alias: false, @@ -625,6 +670,7 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: mappings.opus.clone(), alias: None, is_alias: false, @@ -633,6 +679,7 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: mappings.sonnet.clone(), alias: None, is_alias: false, @@ -641,6 +688,7 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: mappings.haiku.clone(), alias: None, is_alias: false, @@ -780,6 +828,98 @@ fn opencode_agent_config_preserves_other_providers() { assert!(value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["deepseek-test"].is_object()); } +#[test] +fn opencode_agent_config_preserves_catalog_capabilities() { + let models = parse_agent_model_options(&serde_json::json!({"models":[{ + "slug":"opencode-go/glm-lab", "display_name":"Personal · OpenCode Go · Lab", + "context_window":98304, "max_tokens":12345, + "input_modalities":["text","image"], "output_modalities":["text"], + "supported_input_modalities":["text","image","audio","video","pdf"], + "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}] + }]})) + .unwrap(); + assert_eq!(models.len(), 1); + let rendered = build_opencode_agent_config( + None, + "http://127.0.0.1:8317/v1", + "test-key", + "opencode-go/glm-lab", + &models, + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let model = &value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["opencode-go/glm-lab"]; + assert_eq!(model["name"], "Personal · OpenCode Go · Lab"); + assert_eq!( + model["limit"], + serde_json::json!({"context":98304,"output":12345}) + ); + assert_eq!( + model["modalities"], + serde_json::json!({"input":["text","image","audio","video","pdf"],"output":["text"]}) + ); + assert_eq!(model["reasoning"], true); + assert_eq!( + model["variants"], + serde_json::json!({ + "none":{"disabled":true}, "minimal":{"disabled":true}, + "low":{"reasoningEffort":"low"}, "medium":{"disabled":true}, + "high":{"reasoningEffort":"high"}, "xhigh":{"disabled":true}, + "max":{"disabled":true}, "ultra":{"disabled":true} + }) + ); +} + +#[test] +fn opencode_explicit_efforts_disable_inferred_sdk_variants() { + for (advertised, expected) in [ + (serde_json::json!([{"effort":"high"}]), vec!["high"]), + (serde_json::json!([]), vec![]), + (serde_json::json!(["none"]), vec!["none"]), + ] { + let models = parse_agent_model_options(&serde_json::json!({"models":[{ + "slug":"restricted", "supported_reasoning_levels":advertised + }]})) + .unwrap(); + let rendered = + build_opencode_agent_config(None, "http://localhost/v1", "test", "restricted", &models) + .unwrap(); + let root: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let model = &root["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["restricted"]; + // Match the consumer boundary: OpenCode merges these SDK defaults with + // config variants, then removes entries marked disabled. + let mut consumer_variants = serde_json::json!({ + "low":{"reasoningEffort":"low"}, "medium":{"reasoningEffort":"medium"}, + "high":{"reasoningEffort":"high"} + }) + .as_object() + .unwrap() + .clone(); + consumer_variants.extend(model["variants"].as_object().unwrap().clone()); + consumer_variants.retain(|_, value| value["disabled"] != true); + assert_eq!( + consumer_variants + .keys() + .map(String::as_str) + .collect::>(), + expected + ); + assert_eq!( + model["reasoning"], + expected.iter().any(|effort| *effort != "none") + ); + } + let models = + parse_agent_model_options(&serde_json::json!({"models":[{"slug":"unknown"}]})).unwrap(); + let rendered = + build_opencode_agent_config(None, "http://localhost/v1", "test", "unknown", &models) + .unwrap(); + let root: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let model = &root["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["unknown"]; + assert!(model.get("variants").is_none()); + assert!(model.get("reasoning").is_none()); +} + #[test] fn opencode_config_path_supports_jsonc_and_custom_config() { let home = agent_test_home("opencode-config-paths"); @@ -1409,6 +1549,7 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "gpt-5".to_string(), alias: Some("GPT 5".to_string()), is_alias: false, @@ -1417,6 +1558,7 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "claude-sonnet".to_string(), alias: None, is_alias: false, @@ -1425,6 +1567,7 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "claude-sonnet-xhigh".to_string(), alias: Some("claude-sonnet".to_string()), is_alias: true, @@ -1433,6 +1576,7 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "visible-alias".to_string(), alias: Some("hidden-original".to_string()), is_alias: true, @@ -1441,6 +1585,7 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "deepseek-chat".to_string(), alias: None, is_alias: false, @@ -1592,6 +1737,7 @@ agent-default-model: AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "gpt-selected".to_string(), alias: Some("Selected Model".to_string()), is_alias: false, @@ -1600,6 +1746,7 @@ agent-default-model: AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "gpt-other".to_string(), alias: None, is_alias: false, @@ -2165,6 +2312,7 @@ fn claude_desktop_uses_selected_alias_directly_with_original_context() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "gpt-original".to_string(), alias: None, is_alias: false, @@ -2173,6 +2321,7 @@ fn claude_desktop_uses_selected_alias_directly_with_original_context() { AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "gpt-high".to_string(), alias: Some("gpt-original".to_string()), is_alias: true, @@ -2409,6 +2558,7 @@ fn agent_model_validation_only_accepts_models_in_current_list() { let models = vec![AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: "gpt-5.4".to_string(), alias: Some("GPT 5.4".to_string()), is_alias: false, diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index 225f864c..7a0ccabc 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -104,6 +104,19 @@ fn pi_package_source_match_accepts_unpinned_and_pinned_sources() { }))); } +#[test] +fn pi_package_source_match_recognizes_local_provider_manifest() { + let home = agent_test_home("pi-local-provider"); + fs::create_dir_all(&home).unwrap(); + let manifest = home.join("package.json"); + assert!(!pi_package_source_matches(home.to_str().unwrap())); + fs::write(&manifest, r#"{"name":"@router-for-me/other-provider"}"#).unwrap(); + assert!(!pi_package_source_matches(home.to_str().unwrap())); + fs::write(&manifest, r#"{"name":"@router-for-me/pi-cliproxyapi-provider"}"#).unwrap(); + assert!(pi_package_source_matches(home.to_str().unwrap())); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn agent_status_cache_requires_matching_port_and_api_key() { let cache = AgentConfigStatusCache::default(); diff --git a/src-tauri/src/tests/support.rs b/src-tauri/src/tests/support.rs index 2d5e812e..81a8ed59 100644 --- a/src-tauri/src/tests/support.rs +++ b/src-tauri/src/tests/support.rs @@ -19,6 +19,7 @@ pub(super) fn test_agent_models(names: &[&str]) -> Vec { .map(|name| AgentModelOption { input_modalities: None, harness_metadata: None, + catalog_metadata: None, name: (*name).to_string(), alias: None, is_alias: false,