Skip to content
Draft
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
1 change: 1 addition & 0 deletions src-tauri/src/agents/backups/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/agents/backups/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ fn models() -> Vec<AgentModelOption> {
.map(|name| AgentModelOption {
input_modalities: None,
harness_metadata: None,
catalog_metadata: None,
name: name.into(),
alias: None,
is_alias: false,
Expand Down Expand Up @@ -1172,13 +1173,19 @@ 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();
link_directory(&outside.0, &data.join("backups"));
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();
}

Expand Down
43 changes: 37 additions & 6 deletions src-tauri/src/agents/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -722,13 +722,29 @@ pub(crate) async fn fetch_agent_models(
port: u16,
api_key: &str,
) -> Result<Vec<AgentModelOption>, 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<Vec<AgentModelOption>, 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<serde_json::Value, String> {
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<serde_json::Value, String> {
if port == 0 {
return Err("Invalid kernel port".to_string());
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand All @@ -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::<serde_json::Map<_, _>>();
model.catalog_metadata = (!metadata.is_empty()).then_some(serde_json::Value::Object(metadata));
}
}
Ok(models)
}
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 73 additions & 1 deletion src-tauri/src/agents/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<serde_json::Map<_, _>>();
let managed_provider = ensure_json_object_entry(providers, MANAGED_AGENT_PROVIDER_ID);
Expand All @@ -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::<Vec<_>>();
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::<serde_json::Map<_, _>>();
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,
Expand Down
13 changes: 12 additions & 1 deletion src-tauri/src/agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(&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<Option<serde_json::Value>, String> {
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/agents/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/agents/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions src-tauri/src/agents/transactions/tests.rs
Original file line number Diff line number Diff line change
@@ -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": [{
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/agents/workbuddy/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading