- Add independent Fable model mapping - Rename startup model to default model - Default to the Opus mapping and preserve Subagent inheritance - Replace 1M checkboxes with standard toggle switches - Align form fields and simplify explanatory text - Remove bulk role synchronization - Update translations and regression tests
1636 lines
62 KiB
Rust
1636 lines
62 KiB
Rust
use super::*;
|
|
|
|
const AGENT_STATUS_DETECTION_CONCURRENCY: usize = 4;
|
|
static CODEX_CATALOG_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
static CODEX_CATALOG_UPDATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
static CODEX_CATALOG_REFRESH: tokio::sync::Notify = tokio::sync::Notify::const_new();
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum AgentStatusDetectionTarget {
|
|
Client(AgentClient),
|
|
PiProvider,
|
|
}
|
|
|
|
pub(crate) fn inspect_agent_config_statuses(
|
|
app: &tauri::AppHandle,
|
|
config: &GuiConfigFile,
|
|
) -> Result<Vec<AgentConfigStatus>, String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
let api_key = effective_agent_api_key(config);
|
|
let targets = [
|
|
AgentStatusDetectionTarget::Client(AgentClient::ClaudeCode),
|
|
AgentStatusDetectionTarget::Client(AgentClient::ClaudeDesktop),
|
|
AgentStatusDetectionTarget::Client(AgentClient::Codex),
|
|
AgentStatusDetectionTarget::Client(AgentClient::OpenCode),
|
|
AgentStatusDetectionTarget::Client(AgentClient::OpenClaw),
|
|
AgentStatusDetectionTarget::Client(AgentClient::Hermes),
|
|
AgentStatusDetectionTarget::Client(AgentClient::DeepSeekHarness),
|
|
AgentStatusDetectionTarget::Client(AgentClient::ZCode),
|
|
AgentStatusDetectionTarget::Client(AgentClient::WorkBuddy),
|
|
AgentStatusDetectionTarget::Client(AgentClient::AntigravityCli),
|
|
AgentStatusDetectionTarget::Client(AgentClient::KimiCode),
|
|
AgentStatusDetectionTarget::Client(AgentClient::GrokBuild),
|
|
AgentStatusDetectionTarget::Client(AgentClient::Omp),
|
|
AgentStatusDetectionTarget::PiProvider,
|
|
];
|
|
let queue = Mutex::new(targets.into_iter().enumerate());
|
|
let results = Mutex::new(vec![None; targets.len()]);
|
|
thread::scope(|scope| {
|
|
let mut workers = Vec::with_capacity(AGENT_STATUS_DETECTION_CONCURRENCY);
|
|
for _ in 0..AGENT_STATUS_DETECTION_CONCURRENCY {
|
|
let queue = &queue;
|
|
let results = &results;
|
|
let home = &home;
|
|
workers.push(scope.spawn(move || -> Result<(), String> {
|
|
loop {
|
|
let next = queue
|
|
.lock()
|
|
.map_err(|_| "Agent detection task queue lock is poisoned".to_string())?
|
|
.next();
|
|
let Some((index, target)) = next else {
|
|
return Ok(());
|
|
};
|
|
let status = match target {
|
|
AgentStatusDetectionTarget::Client(client) => {
|
|
inspect_agent_config(client, home, config.port, api_key)
|
|
}
|
|
AgentStatusDetectionTarget::PiProvider => {
|
|
inspect_pi_provider_status(home, config.port, api_key)
|
|
}
|
|
};
|
|
results
|
|
.lock()
|
|
.map_err(|_| "Agent detection result lock is poisoned".to_string())?[index] = Some(status);
|
|
}
|
|
}));
|
|
}
|
|
for worker in workers {
|
|
worker
|
|
.join()
|
|
.map_err(|_| "Agent detection worker thread exited unexpectedly".to_string())??;
|
|
}
|
|
Ok::<(), String>(())
|
|
})?;
|
|
results
|
|
.into_inner()
|
|
.map_err(|_| "Agent detection result lock is poisoned".to_string())?
|
|
.into_iter()
|
|
.map(|status| status.ok_or_else(|| "Agent detection result is incomplete".to_string()))
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn refresh_agent_config_status_cache(
|
|
app: &tauri::AppHandle,
|
|
gui_config_state: &GuiConfigState,
|
|
cache: &AgentConfigStatusCache,
|
|
) -> Result<Vec<AgentConfigStatus>, String> {
|
|
let _refresh_guard = cache
|
|
.refresh_lock
|
|
.lock()
|
|
.map_err(|_| "Agent configuration status refresh lock is poisoned".to_string())?;
|
|
let config = gui_config_state.snapshot()?;
|
|
let port = config.port;
|
|
let api_key = effective_agent_api_key(&config);
|
|
let statuses = inspect_agent_config_statuses(app, &config)?;
|
|
cache.replace(port, api_key, statuses.clone())?;
|
|
Ok(statuses)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_agent_config_statuses(
|
|
app: tauri::AppHandle,
|
|
) -> Result<Vec<AgentConfigStatus>, String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let gui_config_state = app.state::<GuiConfigState>();
|
|
let cache = app.state::<AgentConfigStatusCache>();
|
|
{
|
|
let _refresh_guard = cache
|
|
.refresh_lock
|
|
.lock()
|
|
.map_err(|_| "Agent configuration status refresh lock is poisoned".to_string())?;
|
|
let config = gui_config_state.snapshot()?;
|
|
let port = config.port;
|
|
if let Some(statuses) = cache.get(port, effective_agent_api_key(&config))? {
|
|
return Ok(statuses);
|
|
}
|
|
}
|
|
refresh_agent_config_status_cache(&app, gui_config_state.inner(), cache.inner())
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Agent detection background task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn refresh_agent_config_statuses(
|
|
app: tauri::AppHandle,
|
|
) -> Result<Vec<AgentConfigStatus>, String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let gui_config_state = app.state::<GuiConfigState>();
|
|
let cache = app.state::<AgentConfigStatusCache>();
|
|
refresh_agent_config_status_cache(&app, gui_config_state.inner(), cache.inner())
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Agent detection background task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_agent_models(
|
|
_app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
client: String,
|
|
) -> Result<Vec<AgentModelOption>, String> {
|
|
if client.trim().eq_ignore_ascii_case(PI_AGENT_ID) {
|
|
let config = gui_config_state.snapshot()?;
|
|
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()?;
|
|
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
|
Ok(prepared.models)
|
|
}
|
|
|
|
pub(crate) async fn resolve_pi_default_model(
|
|
config: &GuiConfigFile,
|
|
model: &str,
|
|
) -> Result<String, String> {
|
|
let models = fetch_agent_models(config.port, effective_agent_api_key(config)).await?;
|
|
resolve_available_agent_model(&models, &validate_agent_model(model)?)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn check_pi_provider_update(
|
|
app: tauri::AppHandle,
|
|
) -> Result<PiProviderUpdateStatus, String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
let installed_version = read_pi_provider_version(&home)?;
|
|
let Some(installed_version_value) = installed_version.as_deref() else {
|
|
return Ok(PiProviderUpdateStatus {
|
|
installed_version: None,
|
|
latest_version: None,
|
|
update_available: false,
|
|
});
|
|
};
|
|
let proxy_url = app.state::<GuiConfigState>().snapshot()?.proxy_url;
|
|
let latest_version = fetch_pi_provider_latest_version(&proxy_url).await?;
|
|
let update_available = pi_provider_update_available(installed_version_value, &latest_version)?;
|
|
Ok(PiProviderUpdateStatus {
|
|
installed_version,
|
|
latest_version: Some(latest_version),
|
|
update_available,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn install_pi_provider(
|
|
app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
cache: tauri::State<'_, AgentConfigStatusCache>,
|
|
model: String,
|
|
) -> Result<AgentConfigActionResult, String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
let config = gui_config_state.snapshot()?;
|
|
let executable = find_pi_executable(&home);
|
|
if executable.is_none()
|
|
&& !inspect_pi_provider_status(&home, config.port, effective_agent_api_key(&config))
|
|
.config_exists
|
|
{
|
|
return Err("Pi CLI and Pi configuration file were not detected".to_string());
|
|
}
|
|
let model = resolve_pi_default_model(&config, &model).await?;
|
|
let port = config.port;
|
|
let api_key = effective_agent_api_key(&config).to_string();
|
|
let proxy_url = config.proxy_url.clone();
|
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
|
if let Some(executable) = executable {
|
|
install_pi_provider_inner(&home, &executable, port, &api_key, &model, &proxy_url)
|
|
} else {
|
|
configure_pi_provider_without_cli_inner(&home, port, &api_key, &model)
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Pi CLIProxyAPI provider installation task failed: {error}"))??;
|
|
cache.clear()?;
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn update_pi_provider(
|
|
app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
cache: tauri::State<'_, AgentConfigStatusCache>,
|
|
model: String,
|
|
) -> Result<AgentConfigActionResult, String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
let config = gui_config_state.snapshot()?;
|
|
let executable = find_pi_executable(&home)
|
|
.ok_or_else(|| "Pi CLI was not detected. Install Pi and ensure the pi command is in PATH".to_string())?;
|
|
let model = resolve_pi_default_model(&config, &model).await?;
|
|
let port = config.port;
|
|
let api_key = effective_agent_api_key(&config).to_string();
|
|
let proxy_url = config.proxy_url.clone();
|
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
|
update_pi_provider_inner(&home, &executable, port, &api_key, &model, &proxy_url)
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Pi plugin update task failed: {error}"))??;
|
|
cache.clear()?;
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn repair_pi_provider(
|
|
app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
cache: tauri::State<'_, AgentConfigStatusCache>,
|
|
model: String,
|
|
) -> Result<AgentConfigActionResult, String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
let config = gui_config_state.snapshot()?;
|
|
let model = resolve_pi_default_model(&config, &model).await?;
|
|
let port = config.port;
|
|
let api_key = effective_agent_api_key(&config).to_string();
|
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
|
repair_pi_provider_inner(&home, port, &api_key, &model)
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Pi configuration repair task failed: {error}"))??;
|
|
cache.clear()?;
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn uninstall_pi_provider(
|
|
app: tauri::AppHandle,
|
|
cache: tauri::State<'_, AgentConfigStatusCache>,
|
|
) -> Result<AgentConfigActionResult, String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
let executable =
|
|
find_pi_executable(&home).ok_or_else(|| "Pi CLI was not detected. Install Pi first".to_string())?;
|
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
|
uninstall_pi_provider_inner(&home, &executable)
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Pi plugin uninstall task failed: {error}"))??;
|
|
cache.clear()?;
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn check_codex_oauth_login(app: tauri::AppHandle) -> Result<(), String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
validate_codex_oauth_login(&home)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn update_codex_model_catalog(
|
|
app: tauri::AppHandle,
|
|
) -> Result<CodexModelCatalogUpdateResult, String> {
|
|
update_codex_model_catalog_inner(&app).await
|
|
}
|
|
|
|
pub(crate) async fn update_codex_model_catalog_inner(
|
|
app: &tauri::AppHandle,
|
|
) -> Result<CodexModelCatalogUpdateResult, String> {
|
|
let _update_guard = CODEX_CATALOG_UPDATE_LOCK.lock().await;
|
|
let proxy_url = app.state::<GuiConfigState>().snapshot()?.proxy_url;
|
|
let client = build_http_client_with_proxy(
|
|
reqwest::Client::builder()
|
|
.redirect(github_https_redirect_policy())
|
|
.connect_timeout(Duration::from_secs(8))
|
|
.read_timeout(Duration::from_secs(15))
|
|
.timeout(Duration::from_secs(25)),
|
|
&proxy_url,
|
|
"Failed to create Codex model catalog update client",
|
|
)?;
|
|
let response = client
|
|
.get(CODEX_MODEL_CATALOG_URL)
|
|
.header(reqwest::header::ACCEPT, "application/json")
|
|
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Failed to read Codex model catalog from GitHub: {error}"))?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
return Err(format!(
|
|
"Failed to read Codex model catalog from GitHub: HTTP {}",
|
|
status.as_u16()
|
|
));
|
|
}
|
|
if response
|
|
.content_length()
|
|
.is_some_and(|size| size > MAX_CODEX_MODEL_CATALOG_BYTES as u64)
|
|
{
|
|
return Err(format!(
|
|
"GitHub Codex model catalog exceeds the {} MiB limit",
|
|
MAX_CODEX_MODEL_CATALOG_BYTES / 1024 / 1024
|
|
));
|
|
}
|
|
|
|
let mut bytes = Vec::new();
|
|
let mut stream = response.bytes_stream();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = chunk.map_err(|error| format!("Failed to read GitHub Codex model catalog: {error}"))?;
|
|
if bytes.len().saturating_add(chunk.len()) > MAX_CODEX_MODEL_CATALOG_BYTES {
|
|
return Err(format!(
|
|
"GitHub Codex model catalog exceeds the {} MiB limit",
|
|
MAX_CODEX_MODEL_CATALOG_BYTES / 1024 / 1024
|
|
));
|
|
}
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
let catalog_json = String::from_utf8(bytes)
|
|
.map_err(|_| "GitHub Codex model catalog is not a valid UTF-8 file".to_string())?;
|
|
let revision = codex_catalog::validate_catalog_json(&catalog_json)
|
|
.map_err(|error| format!("GitHub Codex model catalog verification failed: {error}"))?;
|
|
|
|
if revision < codex_catalog::current_catalog_revision()?
|
|
|| codex_catalog::current_catalog_json()? == catalog_json
|
|
{
|
|
return Ok(CodexModelCatalogUpdateResult {
|
|
outcome: "unchanged".to_string(),
|
|
});
|
|
}
|
|
|
|
let path = codex_model_catalog_override_path(app)?;
|
|
write_bytes_atomically(&path, catalog_json.as_bytes())?;
|
|
let changed = codex_catalog::activate_catalog_json(&catalog_json)?;
|
|
Ok(CodexModelCatalogUpdateResult {
|
|
outcome: if changed { "updated" } else { "unchanged" }.to_string(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn codex_model_catalog_override_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
let app_data = app
|
|
.path()
|
|
.app_data_dir()
|
|
.map_err(|error| format!("Failed to get application data directory: {error}"))?;
|
|
Ok(app_data
|
|
.join(CODEX_MODEL_CATALOG_OVERRIDE_DIR)
|
|
.join(CODEX_MODEL_CATALOG_SOURCE_FILE))
|
|
}
|
|
|
|
pub(crate) fn load_codex_model_catalog_override(app: &tauri::AppHandle) -> Result<(), String> {
|
|
let path = codex_model_catalog_override_path(app)?;
|
|
if !path.is_file() {
|
|
return Ok(());
|
|
}
|
|
let catalog_json = fs::read_to_string(&path)
|
|
.map_err(|error| format!("Failed to read local Codex model catalog update file: {error}"))?;
|
|
codex_catalog::activate_catalog_json(&catalog_json)
|
|
.map_err(|error| format!("Invalid local Codex model catalog update file: {error}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn codex_model_customizations_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
Ok(codex_model_catalog_override_path(app)?.with_file_name("model-customizations.json"))
|
|
}
|
|
|
|
pub(crate) fn load_codex_model_customizations(app: &tauri::AppHandle) -> Result<(), String> {
|
|
codex_catalog::load_customizations(&codex_model_customizations_path(app)?)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_codex_model_catalog_editor(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
) -> Result<codex_catalog::CatalogEditorSnapshot, String> {
|
|
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
|
|
let config = gui_config_state.snapshot()?;
|
|
let runtime_models = fetch_codex_catalog_runtime_models(&config).await?;
|
|
codex_catalog::editor_snapshot(&runtime_models)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct CodexCatalogEditorSaveResult {
|
|
snapshot: codex_catalog::CatalogEditorSnapshot,
|
|
synchronization_error: Option<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn save_codex_model_catalog_editor(
|
|
app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
request: codex_catalog::CatalogEditorRequest,
|
|
) -> Result<CodexCatalogEditorSaveResult, String> {
|
|
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
|
|
let config = gui_config_state.snapshot()?;
|
|
let runtime_models = fetch_codex_catalog_runtime_models(&config).await?;
|
|
let snapshot = codex_catalog::save_customizations(
|
|
&codex_model_customizations_path(&app)?,
|
|
&runtime_models,
|
|
request,
|
|
)?;
|
|
let synchronization_error = prepare_codex_agent_models(&runtime_models)
|
|
.and_then(|prepared| sync_prepared_codex_model_catalog(&app, &config, &prepared))
|
|
.err();
|
|
Ok(CodexCatalogEditorSaveResult {
|
|
snapshot,
|
|
synchronization_error,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_thinking_aliases(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
) -> Result<Vec<ThinkingAliasEntry>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
thinking_aliases_from_yaml(&content)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_model_alias_sources(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
) -> Result<Vec<ThinkingAliasSource>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
let available_models =
|
|
fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
|
|
let definitions = fetch_oauth_model_definitions(&config).await;
|
|
Ok(resolved_oauth_alias_sources(
|
|
&content,
|
|
&definitions,
|
|
&available_models,
|
|
AliasSourceCapability::Base,
|
|
)?
|
|
.into_iter()
|
|
.map(|resolved| resolved.source)
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_thinking_alias_sources(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
) -> Result<Vec<ThinkingAliasSource>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
let available_models =
|
|
fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
|
|
let definitions = fetch_oauth_model_definitions(&config).await;
|
|
Ok(resolved_oauth_alias_sources(
|
|
&content,
|
|
&definitions,
|
|
&available_models,
|
|
AliasSourceCapability::Reasoning,
|
|
)?
|
|
.into_iter()
|
|
.map(|resolved| resolved.source)
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_model_alias_edit_source(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
alias: String,
|
|
) -> Result<ModelAliasEditContext, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let alias = existing_thinking_alias_model_id(&alias, "Alias model")?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
let definitions = fetch_oauth_model_definitions(&config).await;
|
|
model_alias_edit_context(&content, &alias, &definitions)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn create_thinking_alias(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
source_id: String,
|
|
alias: String,
|
|
effort: String,
|
|
fast: Option<bool>,
|
|
original_alias: Option<String>,
|
|
expected_revision: Option<String>,
|
|
) -> Result<Vec<ThinkingAliasEntry>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let source_id = source_id.trim().to_string();
|
|
if source_id.is_empty() {
|
|
return Err("Select the source model first".to_string());
|
|
}
|
|
let original_alias = original_alias
|
|
.as_deref()
|
|
.map(|original| existing_thinking_alias_model_id(original, "Alias model"))
|
|
.transpose()?;
|
|
let alias = match original_alias.as_deref() {
|
|
Some(original) if original.eq_ignore_ascii_case(alias.trim()) => {
|
|
existing_thinking_alias_model_id(&alias, "Alias model")?
|
|
}
|
|
_ => validate_thinking_alias_model_id(&alias, "Alias model")?,
|
|
};
|
|
let effort = if effort.trim().is_empty() {
|
|
String::new()
|
|
} else {
|
|
validate_thinking_alias_effort(&effort)?
|
|
};
|
|
let fast = fast.unwrap_or(false);
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
if original_alias.is_some() {
|
|
validate_model_alias_revision(&content, expected_revision.as_deref())?;
|
|
}
|
|
let available_models =
|
|
fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
|
|
let definitions = fetch_oauth_model_definitions(&config).await;
|
|
let capability = if !effort.is_empty() {
|
|
AliasSourceCapability::Reasoning
|
|
} else if fast {
|
|
AliasSourceCapability::Fast
|
|
} else {
|
|
AliasSourceCapability::Base
|
|
};
|
|
let source = if let Some(original) = original_alias.as_deref()
|
|
.filter(|original| source_id == model_alias_edit_source_id(original))
|
|
{
|
|
resolve_model_alias_edit_source(&content, original, &definitions)?
|
|
} else {
|
|
resolved_oauth_alias_sources(&content, &definitions, &available_models, capability)?
|
|
.into_iter()
|
|
.find(|source| source.source.id == source_id)
|
|
.ok_or_else(|| {
|
|
"The source model is no longer available in the kernel, or its configuration source has changed. Refresh and select it again".to_string()
|
|
})?
|
|
};
|
|
if fast && !alias_source_supports_fast(&source) {
|
|
return Err("Fast supports only OpenAI-compatible API, Codex API, or Codex OAuth model sources".to_string());
|
|
}
|
|
if !effort.is_empty()
|
|
&& !source
|
|
.source
|
|
.reasoning_levels
|
|
.iter()
|
|
.any(|level| level.eq_ignore_ascii_case(&effort))
|
|
{
|
|
return Err(format!(
|
|
"Reasoning effort {effort} is not among the levels currently supported by model {}",
|
|
source.source.model
|
|
));
|
|
}
|
|
if source.source.model.eq_ignore_ascii_case(&alias) {
|
|
return Err("Alias model cannot be the same as the source model".to_string());
|
|
}
|
|
|
|
if available_models.iter().any(|model| {
|
|
model.name.eq_ignore_ascii_case(&alias)
|
|
&& !original_alias
|
|
.as_deref()
|
|
.is_some_and(|original| original.eq_ignore_ascii_case(&alias))
|
|
}) {
|
|
return Err(format!("{alias} is already an actual model ID and cannot also be used as an alias"));
|
|
}
|
|
let updated = match original_alias.as_deref() {
|
|
Some(original) => edit_model_alias_in_yaml(&content, original, &source, &alias, &effort, fast)?,
|
|
None => add_model_alias_to_yaml(&content, &source, &alias, &effort, fast)?,
|
|
};
|
|
put_management_alias_config_changes(&config, &content, &updated).await?;
|
|
thinking_aliases_from_yaml(&updated)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn delete_thinking_alias(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
alias: String,
|
|
oauth_channel: Option<String>,
|
|
) -> Result<Vec<ThinkingAliasEntry>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let alias = existing_thinking_alias_model_id(&alias, "Alias model")?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
let updated =
|
|
remove_thinking_alias_from_yaml_for_channel(&content, &alias, oauth_channel.as_deref())?;
|
|
put_management_alias_config_changes(&config, &content, &updated).await?;
|
|
thinking_aliases_from_yaml(&updated)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_speed_aliases(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
) -> Result<Vec<SpeedAliasEntry>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
speed_aliases_from_yaml(&content)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn get_speed_alias_sources(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
) -> Result<Vec<ThinkingAliasSource>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
let available_models =
|
|
fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
|
|
let definitions = fetch_oauth_model_definitions(&config).await;
|
|
Ok(resolved_oauth_alias_sources(
|
|
&content,
|
|
&definitions,
|
|
&available_models,
|
|
AliasSourceCapability::Fast,
|
|
)?
|
|
.into_iter()
|
|
.map(|resolved| resolved.source)
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn create_speed_alias(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
source_id: String,
|
|
alias: String,
|
|
) -> Result<Vec<SpeedAliasEntry>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let source_id = source_id.trim().to_string();
|
|
if source_id.is_empty() {
|
|
return Err("Select the source model first".to_string());
|
|
}
|
|
let alias = validate_thinking_alias_model_id(&alias, "Alias model")?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
let available_models =
|
|
fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
|
|
let definitions = fetch_oauth_model_definitions(&config).await;
|
|
let sources = resolved_oauth_alias_sources(
|
|
&content,
|
|
&definitions,
|
|
&available_models,
|
|
AliasSourceCapability::Fast,
|
|
)?;
|
|
let source = sources
|
|
.iter()
|
|
.find(|source| source.source.id == source_id)
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
"The source model is no longer available in the kernel, or its configuration source has changed. Refresh and try again".to_string()
|
|
})?;
|
|
if source.source.model.eq_ignore_ascii_case(&alias) {
|
|
return Err("Alias model cannot be the same as the source model".to_string());
|
|
}
|
|
if available_models
|
|
.iter()
|
|
.any(|model| model.name.eq_ignore_ascii_case(&alias))
|
|
{
|
|
return Err(format!("{alias} is already an actual model ID and cannot also be used as an alias"));
|
|
}
|
|
let document = serde_norway::from_str::<serde_norway::Value>(&content)
|
|
.map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?;
|
|
let root = document
|
|
.as_mapping()
|
|
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
|
|
if configured_model_alias_exists(root, &alias) {
|
|
return Err(format!("Alias model {alias} already exists"));
|
|
}
|
|
|
|
let updated = add_speed_alias_to_yaml(&content, &source, &alias)?;
|
|
put_management_alias_config_changes(&config, &content, &updated).await?;
|
|
speed_aliases_from_yaml(&updated)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn delete_speed_alias(
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
alias: String,
|
|
oauth_channel: Option<String>,
|
|
) -> Result<Vec<SpeedAliasEntry>, String> {
|
|
let config = gui_config_state.snapshot()?;
|
|
let alias = existing_thinking_alias_model_id(&alias, "Alias model")?;
|
|
let content = fetch_management_config_yaml(&config).await?;
|
|
let updated =
|
|
remove_speed_alias_from_yaml_for_channel(&content, &alias, oauth_channel.as_deref())?;
|
|
put_management_alias_config_changes(&config, &content, &updated).await?;
|
|
speed_aliases_from_yaml(&updated)
|
|
}
|
|
|
|
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?;
|
|
parse_agent_model_options(&payload)
|
|
}
|
|
|
|
pub(crate) async fn fetch_agent_model_payload(
|
|
port: u16,
|
|
api_key: &str,
|
|
) -> Result<serde_json::Value, String> {
|
|
if port == 0 {
|
|
return Err("Invalid kernel port".to_string());
|
|
}
|
|
let tls_enabled = managed_core_tls_enabled();
|
|
let client = reqwest::Client::builder()
|
|
.no_proxy()
|
|
.connect_timeout(Duration::from_secs(3))
|
|
.timeout(Duration::from_secs(15))
|
|
.danger_accept_invalid_certs(tls_enabled)
|
|
.build()
|
|
.map_err(|error| format!("Failed to create model list client: {error}"))?;
|
|
let base_url = managed_core_loopback_origin(port);
|
|
let endpoints = [
|
|
format!("{base_url}/v1/models"),
|
|
format!("{base_url}/models"),
|
|
];
|
|
|
|
for (index, endpoint) in endpoints.iter().enumerate() {
|
|
let response = client
|
|
.get(endpoint)
|
|
.bearer_auth(api_key)
|
|
.header(reqwest::header::ACCEPT, "application/json")
|
|
.header(reqwest::header::USER_AGENT, USER_AGENT)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Failed to request local model list: {error}"))?;
|
|
let status = response.status();
|
|
let body = response
|
|
.text()
|
|
.await
|
|
.map_err(|error| format!("Failed to read local model list: {error}"))?;
|
|
if status.is_success() {
|
|
let payload = serde_json::from_str::<serde_json::Value>(&body).map_err(|error| {
|
|
format!(
|
|
"Failed to parse local model list: {error}; body={}",
|
|
truncate_for_error(&body)
|
|
)
|
|
})?;
|
|
return Ok(payload);
|
|
}
|
|
|
|
let can_try_legacy_path = index == 0 && matches!(status.as_u16(), 404 | 405);
|
|
if !can_try_legacy_path {
|
|
return Err(format_agent_models_error(status.as_u16(), &body));
|
|
}
|
|
}
|
|
|
|
Err("Local kernel does not support the model list endpoint".to_string())
|
|
}
|
|
|
|
pub(crate) async fn fetch_codex_runtime_models(
|
|
port: u16,
|
|
api_key: &str,
|
|
) -> Result<Vec<codex_catalog::CodexRuntimeModel>, String> {
|
|
if port == 0 {
|
|
return Err("Invalid kernel port".to_string());
|
|
}
|
|
let tls_enabled = managed_core_tls_enabled();
|
|
let client = reqwest::Client::builder()
|
|
.no_proxy()
|
|
.connect_timeout(Duration::from_secs(3))
|
|
.timeout(Duration::from_secs(15))
|
|
.danger_accept_invalid_certs(tls_enabled)
|
|
.build()
|
|
.map_err(|error| format!("Failed to create Codex model list client: {error}"))?;
|
|
let base_url = managed_core_loopback_origin(port);
|
|
let endpoints = [
|
|
format!("{base_url}/v1/models"),
|
|
format!("{base_url}/models"),
|
|
];
|
|
|
|
for (index, endpoint) in endpoints.iter().enumerate() {
|
|
let response = client
|
|
.get(endpoint)
|
|
.query(&[("client_version", env!("CARGO_PKG_VERSION"))])
|
|
.bearer_auth(api_key)
|
|
.header(reqwest::header::ACCEPT, "application/json")
|
|
.header(reqwest::header::USER_AGENT, USER_AGENT)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Failed to request local Codex model list: {error}"))?;
|
|
let status = response.status();
|
|
let body = response
|
|
.text()
|
|
.await
|
|
.map_err(|error| format!("Failed to read local Codex model list: {error}"))?;
|
|
if status.is_success() {
|
|
let payload = serde_json::from_str::<serde_json::Value>(&body).map_err(|error| {
|
|
format!(
|
|
"Failed to parse local Codex model list: {error}; body={}",
|
|
truncate_for_error(&body)
|
|
)
|
|
})?;
|
|
return codex_catalog::parse_runtime_models(&payload);
|
|
}
|
|
|
|
let can_try_legacy_path = index == 0 && matches!(status.as_u16(), 404 | 405);
|
|
if !can_try_legacy_path {
|
|
return Err(format_agent_models_error(status.as_u16(), &body));
|
|
}
|
|
}
|
|
|
|
Err("Local kernel does not support the Codex model list endpoint".to_string())
|
|
}
|
|
|
|
pub(crate) async fn fetch_codex_catalog_runtime_models(
|
|
config: &GuiConfigFile,
|
|
) -> Result<Vec<codex_catalog::CodexRuntimeModel>, String> {
|
|
let (runtime, content) = tokio::join!(
|
|
fetch_agent_model_payload(config.port, effective_agent_api_key(config)),
|
|
fetch_management_config_yaml(config),
|
|
);
|
|
// Codex selection and its written catalog must use the public /v1/models
|
|
// IDs, not the alternate catalog selected by client_version.
|
|
let mut runtime = codex_catalog::parse_public_models(&runtime?)?;
|
|
codex_catalog::apply_configured_context_limits(&mut runtime, &content?)?;
|
|
Ok(runtime)
|
|
}
|
|
|
|
pub(crate) async fn fetch_prepared_agent_models(
|
|
client: AgentClient,
|
|
config: &GuiConfigFile,
|
|
) -> Result<PreparedAgentModels, String> {
|
|
let api_key = effective_agent_api_key(config);
|
|
if client == AgentClient::Codex {
|
|
let runtime_models = fetch_codex_catalog_runtime_models(config).await?;
|
|
prepare_codex_agent_models(&runtime_models)
|
|
} else {
|
|
let mut models = if client == AgentClient::DeepSeekHarness {
|
|
fetch_deepseek_harness_models(config).await?
|
|
} else {
|
|
fetch_agent_models(config.port, api_key).await?
|
|
};
|
|
if agent_uses_cpa_runtime_context_windows(client) {
|
|
let runtime_models = fetch_codex_runtime_models(config.port, api_key).await?;
|
|
codex_catalog::merge_runtime_context_windows(&mut models, &runtime_models);
|
|
}
|
|
if matches!(client, AgentClient::ClaudeCode | AgentClient::ClaudeDesktop) {
|
|
let content = fetch_management_config_yaml(config).await?;
|
|
mark_configured_agent_model_aliases(&mut models, &content)?;
|
|
for model in &mut models {
|
|
model.context_window = Some(claude_catalog::context_window_for(
|
|
&model.name,
|
|
model.context_window,
|
|
)?);
|
|
if !model.is_alias {
|
|
if let Some(display_name) = claude_catalog::display_name_for(&model.name)? {
|
|
model.alias = Some(display_name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(PreparedAgentModels {
|
|
models,
|
|
codex_catalog: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
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!("Failed to get user directory: {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_catalog_runtime_models(config).await?;
|
|
let prepared = prepare_codex_agent_models(&runtime_models)?;
|
|
sync_prepared_codex_model_catalog(app, config, &prepared)
|
|
}
|
|
|
|
pub(crate) fn request_codex_model_catalog_refresh() {
|
|
CODEX_CATALOG_REFRESH.notify_one();
|
|
}
|
|
|
|
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 {
|
|
tokio::select! {
|
|
_ = interval.tick() => {},
|
|
_ = CODEX_CATALOG_REFRESH.notified() => {
|
|
tokio::time::sleep(Duration::from_millis(500)).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!("Failed to synchronize Codex model catalog in the background; keeping the existing configuration: {error}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> bool {
|
|
matches!(
|
|
client,
|
|
AgentClient::ZCode | AgentClient::WorkBuddy | AgentClient::KimiCode | AgentClient::GrokBuild
|
|
)
|
|
}
|
|
|
|
fn resolve_claude_desktop_source_model(models: &[AgentModelOption], value: &str) -> Result<String, String> {
|
|
let model = validate_agent_model(value)?;
|
|
Ok(models.iter().find(|entry| entry.name.eq_ignore_ascii_case(&model))
|
|
.map(|entry| entry.name.clone()).unwrap_or(model))
|
|
}
|
|
|
|
pub(crate) fn resolve_agent_configuration_model(
|
|
client: AgentClient,
|
|
models: &[AgentModelOption],
|
|
model: &str,
|
|
desktop_mappings: Option<&ClaudeDesktopModelMappings>,
|
|
) -> Result<String, String> {
|
|
if client == AgentClient::ClaudeDesktop {
|
|
if let Some(entries) = desktop_mappings.and_then(|mappings| mappings.desktop_models.as_ref()) {
|
|
validate_claude_desktop_entries(entries)?;
|
|
return resolve_claude_desktop_source_model(models, entries[0].source_or_alias());
|
|
}
|
|
}
|
|
resolve_available_agent_model(models, &validate_agent_model(model)?)
|
|
}
|
|
|
|
pub(crate) fn resolve_claude_desktop_model_mappings(
|
|
client: AgentClient,
|
|
models: &[AgentModelOption],
|
|
_selected_model: &str,
|
|
requested: Option<ClaudeDesktopModelMappings>,
|
|
) -> Result<Option<ClaudeDesktopModelMappings>, String> {
|
|
if client != AgentClient::ClaudeDesktop {
|
|
return Ok(None);
|
|
}
|
|
let mut requested = requested.ok_or("Reconfigure the Claude Desktop model and alias")?;
|
|
let resolve =
|
|
|model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?);
|
|
if let Some(entries) = requested.desktop_models.as_mut() {
|
|
validate_claude_desktop_entries(entries)?;
|
|
for entry in entries.iter_mut() {
|
|
entry.model = resolve_claude_desktop_source_model(models, entry.source_or_alias())?;
|
|
entry.alias = if entry.alias.trim().eq_ignore_ascii_case(&entry.model) {
|
|
String::new()
|
|
} else {
|
|
entry.alias.trim().to_string()
|
|
};
|
|
}
|
|
requested.sonnet = entries[0].source_or_alias().to_string();
|
|
requested.opus.clear();
|
|
requested.haiku.clear();
|
|
return Ok(Some(requested));
|
|
}
|
|
Ok(Some(ClaudeDesktopModelMappings {
|
|
desktop_models: None,
|
|
opus: resolve(&requested.opus)?,
|
|
sonnet: resolve(&requested.sonnet)?,
|
|
haiku: resolve(&requested.haiku)?,
|
|
opus_1m: requested.opus_1m,
|
|
sonnet_1m: requested.sonnet_1m,
|
|
haiku_1m: requested.haiku_1m,
|
|
fable: resolve(if requested.fable.trim().is_empty() { &requested.sonnet } else { &requested.fable })?,
|
|
fable_1m: requested.fable_1m,
|
|
max_context_tokens: requested.max_context_tokens,
|
|
auto_compact_pct: requested.auto_compact_pct,
|
|
disable_auto_compact: requested.disable_auto_compact,
|
|
manage_default_model: requested.manage_default_model,
|
|
manage_subagent_model: requested.manage_subagent_model,
|
|
startup_model: requested.startup_model.map(|value| value.trim().to_string()),
|
|
subagent_model: requested.subagent_model.map(|value| value.trim().to_string()),
|
|
}))
|
|
}
|
|
|
|
pub(crate) fn resolve_claude_code_model_mappings(
|
|
client: AgentClient,
|
|
models: &[AgentModelOption],
|
|
selected_model: &str,
|
|
requested: Option<ClaudeDesktopModelMappings>,
|
|
) -> Result<Option<ClaudeDesktopModelMappings>, String> {
|
|
if client != AgentClient::ClaudeCode {
|
|
return Ok(None);
|
|
}
|
|
let requested = requested.unwrap_or_else(|| ClaudeDesktopModelMappings::all(selected_model));
|
|
if !(100_000..=1_000_000).contains(&requested.max_context_tokens) {
|
|
return Err("Claude Code maximum context window must be between 100000 and 1000000".to_string());
|
|
}
|
|
if !(1..=100).contains(&requested.auto_compact_pct) {
|
|
return Err("Claude Code compaction trigger percentage must be between 1 and 100".to_string());
|
|
}
|
|
let explicit_1m = [&requested.startup_model, &requested.subagent_model]
|
|
.iter().any(|value| value.as_deref().is_some_and(|model|
|
|
model.trim().to_ascii_lowercase().ends_with("[1m]")));
|
|
let max_context_tokens = if requested.opus_1m || requested.sonnet_1m || requested.haiku_1m || requested.fable_1m || explicit_1m {
|
|
CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW
|
|
} else {
|
|
requested.max_context_tokens
|
|
};
|
|
let resolve =
|
|
|model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?);
|
|
let normalize_override = |value: Option<String>| -> Result<Option<String>, String> {
|
|
value.map(|value| {
|
|
if value.trim().is_empty() { Ok(String::new()) } else { validate_agent_model(&value) }
|
|
}).transpose()
|
|
};
|
|
Ok(Some(ClaudeDesktopModelMappings {
|
|
desktop_models: None,
|
|
opus: resolve(&requested.opus)?,
|
|
sonnet: resolve(&requested.sonnet)?,
|
|
haiku: resolve(&requested.haiku)?,
|
|
opus_1m: requested.opus_1m,
|
|
sonnet_1m: requested.sonnet_1m,
|
|
haiku_1m: requested.haiku_1m,
|
|
fable: resolve(if requested.fable.trim().is_empty() { &requested.sonnet } else { &requested.fable })?,
|
|
fable_1m: requested.fable_1m,
|
|
max_context_tokens,
|
|
auto_compact_pct: requested.auto_compact_pct,
|
|
disable_auto_compact: requested.disable_auto_compact,
|
|
manage_default_model: requested.manage_default_model,
|
|
manage_subagent_model: requested.manage_subagent_model,
|
|
startup_model: normalize_override(requested.startup_model)?,
|
|
subagent_model: normalize_override(requested.subagent_model)?,
|
|
}))
|
|
}
|
|
|
|
pub(crate) fn prepare_codex_agent_models(
|
|
runtime_models: &[codex_catalog::CodexRuntimeModel],
|
|
) -> Result<PreparedAgentModels, String> {
|
|
match codex_catalog::prepare_catalog(runtime_models) {
|
|
Ok(catalog) => Ok(PreparedAgentModels {
|
|
models: catalog.models,
|
|
codex_catalog: Some(catalog.json),
|
|
}),
|
|
Err(error) if error.contains("CPA currently has no models that can be written to Codex") => {
|
|
Ok(PreparedAgentModels {
|
|
models: Vec::new(),
|
|
codex_catalog: Some("{\n \"models\": []\n}\n".to_string()),
|
|
})
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn apply_agent_config(
|
|
app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
client: String,
|
|
model: String,
|
|
oauth_configuration: bool,
|
|
claude_code_model_mappings: Option<ClaudeDesktopModelMappings>,
|
|
claude_desktop_model_mappings: Option<ClaudeDesktopModelMappings>,
|
|
) -> Result<AgentConfigActionResult, String> {
|
|
let client = AgentClient::parse(&client)?;
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
let config = gui_config_state.snapshot()?;
|
|
let api_key = effective_agent_api_key(&config);
|
|
if client == AgentClient::Codex && oauth_configuration {
|
|
validate_codex_oauth_login(&home)?;
|
|
}
|
|
validate_agent_can_enable(client, &home, config.port, api_key)?;
|
|
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
|
let model = resolve_agent_configuration_model(
|
|
client, &prepared.models, &model, claude_desktop_model_mappings.as_ref(),
|
|
)?;
|
|
let claude_code_model_mappings = resolve_claude_code_model_mappings(
|
|
client,
|
|
&prepared.models,
|
|
&model,
|
|
claude_code_model_mappings,
|
|
)?;
|
|
let claude_desktop_model_mappings = resolve_claude_desktop_model_mappings(
|
|
client,
|
|
&prepared.models,
|
|
&model,
|
|
claude_desktop_model_mappings,
|
|
)?;
|
|
commit_agent_with_core(&config, claude_desktop_model_mappings.as_ref(), &prepared.models, || {
|
|
let _guard = AGENT_CONFIG_FILE_LOCK
|
|
.lock()
|
|
.map_err(|_| "Agent configuration file lock is poisoned".to_string())?;
|
|
apply_agent_configuration_with_oauth(
|
|
client,
|
|
&home,
|
|
config.port,
|
|
api_key,
|
|
&model,
|
|
AgentConfigurationOptions {
|
|
models: &prepared.models,
|
|
codex_catalog: prepared.codex_catalog.as_deref(),
|
|
oauth_configuration,
|
|
claude_code_model_mappings: claude_code_model_mappings.as_ref(),
|
|
claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(),
|
|
},
|
|
)
|
|
}).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn clear_codex_config(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
let _guard = AGENT_CONFIG_FILE_LOCK
|
|
.lock()
|
|
.map_err(|_| "Agent configuration file lock is poisoned".to_string())?;
|
|
clear_codex_config_files(&home)
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn set_agent_config_enabled(
|
|
app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
client: String,
|
|
model: String,
|
|
enabled: bool,
|
|
force_restore: bool,
|
|
claude_code_model_mappings: Option<ClaudeDesktopModelMappings>,
|
|
claude_desktop_model_mappings: Option<ClaudeDesktopModelMappings>,
|
|
) -> Result<AgentConfigActionResult, String> {
|
|
let client = AgentClient::parse(&client)?;
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("Failed to get user directory: {error}"))?;
|
|
if enabled {
|
|
let config = gui_config_state.snapshot()?;
|
|
let port = config.port;
|
|
let api_key = effective_agent_api_key(&config);
|
|
validate_agent_can_enable(client, &home, port, api_key)?;
|
|
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
|
let model = resolve_agent_configuration_model(
|
|
client, &prepared.models, &model, claude_desktop_model_mappings.as_ref(),
|
|
)?;
|
|
let claude_code_model_mappings = resolve_claude_code_model_mappings(
|
|
client,
|
|
&prepared.models,
|
|
&model,
|
|
claude_code_model_mappings,
|
|
)?;
|
|
let claude_desktop_model_mappings = resolve_claude_desktop_model_mappings(
|
|
client,
|
|
&prepared.models,
|
|
&model,
|
|
claude_desktop_model_mappings,
|
|
)?;
|
|
commit_agent_with_core(&config, claude_desktop_model_mappings.as_ref(), &prepared.models, || {
|
|
let _guard = AGENT_CONFIG_FILE_LOCK
|
|
.lock()
|
|
.map_err(|_| "Agent configuration file lock is poisoned".to_string())?;
|
|
let oauth_configuration = if client == AgentClient::Codex {
|
|
current_codex_oauth_configuration(&home)?
|
|
} else {
|
|
false
|
|
};
|
|
apply_agent_configuration_with_oauth(
|
|
client,
|
|
&home,
|
|
port,
|
|
api_key,
|
|
&model,
|
|
AgentConfigurationOptions {
|
|
models: &prepared.models,
|
|
codex_catalog: prepared.codex_catalog.as_deref(),
|
|
oauth_configuration,
|
|
claude_code_model_mappings: claude_code_model_mappings.as_ref(),
|
|
claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(),
|
|
},
|
|
)
|
|
}).await
|
|
} else {
|
|
let config = gui_config_state.snapshot()?;
|
|
let _guard = AGENT_CONFIG_FILE_LOCK
|
|
.lock()
|
|
.map_err(|_| "Agent configuration file lock is poisoned".to_string())?;
|
|
let _ = force_restore;
|
|
let result = clear_agent_managed_configuration(client, &home, config.port)?;
|
|
app.state::<AgentConfigStatusCache>().clear()?;
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn update_agent_config(
|
|
app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
client: String,
|
|
model: String,
|
|
oauth_configuration: bool,
|
|
claude_code_model_mappings: Option<ClaudeDesktopModelMappings>,
|
|
claude_desktop_model_mappings: Option<ClaudeDesktopModelMappings>,
|
|
) -> Result<AgentConfigActionResult, String> {
|
|
apply_agent_config(
|
|
app,
|
|
gui_config_state,
|
|
client,
|
|
model,
|
|
oauth_configuration,
|
|
claude_code_model_mappings,
|
|
claude_desktop_model_mappings,
|
|
).await
|
|
}
|
|
|
|
pub(crate) fn validate_agent_can_enable(
|
|
client: AgentClient,
|
|
home: &Path,
|
|
port: u16,
|
|
api_key: &str,
|
|
) -> Result<(), String> {
|
|
if !client.supported_platform() {
|
|
return Err(format!(
|
|
"{} is not supported on the current platform",
|
|
client.name()
|
|
));
|
|
}
|
|
let detection = inspect_agent_config(client, home, port, api_key);
|
|
if !detection.installed && !detection.config_exists {
|
|
return Err(format!("{} is not installed", client.name()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn validate_agent_model(value: &str) -> Result<String, String> {
|
|
let model = value.trim();
|
|
if model.is_empty() {
|
|
return Err("Select a model first".to_string());
|
|
}
|
|
if model.len() > 240 || model.chars().any(char::is_control) {
|
|
return Err("Invalid model name format".to_string());
|
|
}
|
|
Ok(model.to_string())
|
|
}
|
|
|
|
pub(crate) fn resolve_available_agent_model(
|
|
models: &[AgentModelOption],
|
|
model: &str,
|
|
) -> Result<String, String> {
|
|
if models.is_empty() {
|
|
return Err("The current kernel has no selectable models, so configuration changes cannot be applied".to_string());
|
|
}
|
|
models
|
|
.iter()
|
|
.find(|available| available.name.eq_ignore_ascii_case(model))
|
|
.map(|available| available.name.clone())
|
|
.ok_or_else(|| format!("Model {model} is not in the current list of available models. Refresh and select it again"))
|
|
}
|
|
|
|
pub(crate) fn parse_agent_model_options(
|
|
payload: &serde_json::Value,
|
|
) -> Result<Vec<AgentModelOption>, String> {
|
|
let object_models = payload.get("models").and_then(serde_json::Value::as_object).map(|models| {
|
|
models.iter().map(|(id, value)| {
|
|
let mut entry = value.as_object().cloned().unwrap_or_default();
|
|
entry.entry("id").or_insert(serde_json::json!(id));
|
|
serde_json::Value::Object(entry)
|
|
}).collect::<Vec<_>>()
|
|
});
|
|
let source = payload.as_array()
|
|
.or_else(|| payload.get("data").and_then(serde_json::Value::as_array))
|
|
.or_else(|| payload.get("models").and_then(serde_json::Value::as_array))
|
|
.or(object_models.as_ref())
|
|
.ok_or_else(|| "Local model list response is missing a data array or models catalog".to_string())?;
|
|
let mut models = Vec::new();
|
|
for item in source {
|
|
let name = if let Some(name) = item.as_str() {
|
|
name.trim().to_string()
|
|
} else {
|
|
["id", "name", "model", "value"]
|
|
.into_iter()
|
|
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_string()
|
|
};
|
|
let display_name = ["display_name", "displayName", "name"]
|
|
.into_iter()
|
|
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(&name))
|
|
.map(str::to_string);
|
|
let model_alias = item
|
|
.get("alias")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(&name))
|
|
.map(str::to_string);
|
|
let keep_original = item
|
|
.get("fork")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false);
|
|
let context_window = [
|
|
"context_length",
|
|
"contextLength",
|
|
"ContextLength",
|
|
"context_window",
|
|
"contextWindow",
|
|
"max_input_tokens",
|
|
"maxInputTokens",
|
|
"input_token_limit",
|
|
"inputTokenLimit",
|
|
]
|
|
.into_iter()
|
|
.find_map(|key| item.get(key).and_then(json_positive_u64));
|
|
let input_modalities = codex_catalog::parse_modalities(item).or_else(|| {
|
|
codex_catalog::parse_modalities(&serde_json::json!({"input_modalities": item.get("input")}))
|
|
});
|
|
let harness_metadata = harness_api_metadata(item);
|
|
|
|
if let Some(model_alias) = model_alias {
|
|
if keep_original {
|
|
append_agent_model_option(&mut models, &name, display_name, false, context_window, input_modalities.clone(), harness_metadata.clone());
|
|
}
|
|
append_agent_model_option(&mut models, &model_alias, Some(name), true, context_window, input_modalities, harness_metadata);
|
|
} else {
|
|
append_agent_model_option(&mut models, &name, display_name, false, context_window, input_modalities, harness_metadata);
|
|
}
|
|
}
|
|
Ok(models)
|
|
}
|
|
|
|
pub(crate) fn append_agent_model_option(
|
|
models: &mut Vec<AgentModelOption>,
|
|
name: &str,
|
|
alias: Option<String>,
|
|
is_alias: bool,
|
|
context_window: Option<u64>,
|
|
input_modalities: Option<Vec<String>>,
|
|
harness_metadata: Option<serde_json::Value>,
|
|
) {
|
|
let name = name.trim();
|
|
if name.is_empty()
|
|
|| models
|
|
.iter()
|
|
.any(|model| model.name.eq_ignore_ascii_case(name))
|
|
{
|
|
return;
|
|
}
|
|
let alias = alias
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(name))
|
|
.map(str::to_string);
|
|
models.push(AgentModelOption {
|
|
input_modalities,
|
|
harness_metadata,
|
|
name: name.to_string(),
|
|
alias,
|
|
is_alias,
|
|
context_window,
|
|
});
|
|
}
|
|
|
|
pub(crate) fn mark_configured_agent_model_aliases(
|
|
models: &mut [AgentModelOption],
|
|
content: &str,
|
|
) -> Result<(), String> {
|
|
let document = serde_norway::from_str::<serde_norway::Value>(content)
|
|
.map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?;
|
|
let root = document
|
|
.as_mapping()
|
|
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
|
|
|
|
for section in MODEL_ALIAS_CONFIG_SECTIONS {
|
|
let Some(providers) = yaml_mapping_value(root, section) else {
|
|
continue;
|
|
};
|
|
let providers = providers
|
|
.as_sequence()
|
|
.ok_or_else(|| format!("{section} must be an array"))?;
|
|
for provider in providers {
|
|
let Some(provider) = provider.as_mapping() else {
|
|
continue;
|
|
};
|
|
if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) {
|
|
continue;
|
|
}
|
|
let Some(configured_models) = yaml_mapping_value(provider, "models") else {
|
|
continue;
|
|
};
|
|
let configured_models = configured_models
|
|
.as_sequence()
|
|
.ok_or_else(|| format!("{section}.models must be an array"))?;
|
|
for configured in configured_models {
|
|
if configured_model_identity(configured).is_some_and(|(_, client_model, _)| {
|
|
configured_provider_model_is_enabled(provider, &client_model)
|
|
}) {
|
|
mark_agent_model_aliases_from_sequence(models, std::slice::from_ref(configured));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(oauth_aliases) = yaml_mapping_value(root, "oauth-model-alias") {
|
|
let oauth_aliases = oauth_aliases
|
|
.as_mapping()
|
|
.ok_or_else(|| "oauth-model-alias must be a YAML mapping".to_string())?;
|
|
for entries in oauth_aliases.values() {
|
|
let Some(entries) = entries.as_sequence() else {
|
|
continue;
|
|
};
|
|
mark_agent_model_aliases_from_sequence(models, entries);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn mark_agent_model_aliases_from_sequence(
|
|
models: &mut [AgentModelOption],
|
|
configured_models: &[serde_norway::Value],
|
|
) {
|
|
for configured in configured_models {
|
|
let Some((source_model, client_model, _)) = configured_model_identity(configured) else {
|
|
continue;
|
|
};
|
|
if source_model.eq_ignore_ascii_case(&client_model) {
|
|
continue;
|
|
}
|
|
let Some(model) = models
|
|
.iter_mut()
|
|
.find(|model| model.name.eq_ignore_ascii_case(&client_model))
|
|
else {
|
|
continue;
|
|
};
|
|
model.is_alias = true;
|
|
model.alias = Some(source_model);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parse_codex_model_definitions(
|
|
payload: &serde_json::Value,
|
|
) -> Result<Vec<CodexModelDefinition>, String> {
|
|
let source = payload
|
|
.as_array()
|
|
.or_else(|| payload.get("models").and_then(serde_json::Value::as_array))
|
|
.or_else(|| payload.get("data").and_then(serde_json::Value::as_array))
|
|
.ok_or_else(|| "Codex model definition response is missing a models or data array".to_string())?;
|
|
let mut definitions = Vec::new();
|
|
for item in source {
|
|
let id = ["id", "ID", "name"]
|
|
.into_iter()
|
|
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty());
|
|
let Some(id) = id else {
|
|
continue;
|
|
};
|
|
if definitions
|
|
.iter()
|
|
.any(|definition: &CodexModelDefinition| definition.id.eq_ignore_ascii_case(id))
|
|
{
|
|
continue;
|
|
}
|
|
let display_name = ["display_name", "displayName", "DisplayName"]
|
|
.into_iter()
|
|
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_string);
|
|
let description = item
|
|
.get("description")
|
|
.or_else(|| item.get("Description"))
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_string);
|
|
let context_window = ["context_length", "contextLength", "ContextLength"]
|
|
.into_iter()
|
|
.find_map(|key| item.get(key).and_then(json_positive_u64));
|
|
let reasoning_levels = item
|
|
.get("thinking")
|
|
.or_else(|| item.get("Thinking"))
|
|
.and_then(|thinking| thinking.get("levels").or_else(|| thinking.get("Levels")))
|
|
.and_then(serde_json::Value::as_array)
|
|
.map(|levels| {
|
|
levels
|
|
.iter()
|
|
.filter_map(serde_json::Value::as_str)
|
|
.map(str::trim)
|
|
.map(str::to_ascii_lowercase)
|
|
.filter(|level| is_codex_reasoning_level(level))
|
|
.fold(Vec::new(), |mut result, level| {
|
|
if !result.contains(&level) {
|
|
result.push(level);
|
|
}
|
|
result
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
let supports_tools = item
|
|
.get("supported_parameters")
|
|
.or_else(|| item.get("supportedParameters"))
|
|
.or_else(|| item.get("SupportedParameters"))
|
|
.and_then(serde_json::Value::as_array)
|
|
.map(|parameters| {
|
|
parameters.iter().any(|parameter| {
|
|
parameter
|
|
.as_str()
|
|
.is_some_and(|value| value.eq_ignore_ascii_case("tools"))
|
|
})
|
|
});
|
|
definitions.push(CodexModelDefinition {
|
|
id: id.to_string(),
|
|
display_name,
|
|
description,
|
|
context_window,
|
|
reasoning_levels,
|
|
supports_tools,
|
|
});
|
|
}
|
|
Ok(definitions)
|
|
}
|
|
|
|
pub(crate) fn json_positive_u64(value: &serde_json::Value) -> Option<u64> {
|
|
value
|
|
.as_u64()
|
|
.or_else(|| value.as_str()?.trim().parse::<u64>().ok())
|
|
.filter(|value| *value > 0)
|
|
}
|
|
|
|
pub(crate) fn is_codex_reasoning_level(value: &str) -> bool {
|
|
matches!(
|
|
value,
|
|
"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"
|
|
)
|
|
}
|
|
|
|
pub(crate) fn format_agent_models_error(status: u16, body: &str) -> String {
|
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
|
|
let message = value
|
|
.get("error")
|
|
.and_then(|error| {
|
|
error
|
|
.as_str()
|
|
.or_else(|| error.get("message").and_then(serde_json::Value::as_str))
|
|
})
|
|
.or_else(|| value.get("message").and_then(serde_json::Value::as_str))
|
|
.map(str::trim)
|
|
.filter(|message| !message.is_empty());
|
|
if let Some(message) = message {
|
|
return format!("Failed to get local model list ({status}): {message}");
|
|
}
|
|
}
|
|
let body = body.trim();
|
|
if body.is_empty() {
|
|
format!("Failed to get local model list ({status})")
|
|
} else {
|
|
format!(
|
|
"Failed to get local model list ({status}): {}",
|
|
truncate_for_error(body)
|
|
)
|
|
}
|
|
}
|