diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 37609fd..d634428 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -245,6 +245,7 @@ pub struct AcpAgentInfo { pub codex_auth_json: Option, pub codex_config_toml: Option, pub cline_secrets_json: Option, + pub model_provider_id: Option, } /// Lightweight status info for a single agent, used by connect() pre-check. diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 66054a4..fb12817 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -19,6 +19,7 @@ use crate::acp::types::{ #[cfg(feature = "tauri-runtime")] use crate::acp::types::{ConnectionInfo, ForkResultInfo, PromptInputBlock}; use crate::db::service::agent_setting_service; +use crate::db::service::model_provider_service; use crate::db::AppDatabase; use crate::models::agent::AgentType; use crate::web::event_bridge::EventEmitter; @@ -1121,6 +1122,11 @@ pub(crate) fn load_agent_local_config_json(agent_type: AgentType) -> Option merge_json_values(base_value, patch_value), None => { @@ -1539,6 +1545,30 @@ pub(crate) fn build_runtime_env_from_setting( merged } +/// Resolve model provider credentials into runtime env vars if `model_provider_id` is set. +pub(crate) async fn apply_model_provider_env( + agent_type: AgentType, + setting: Option<&crate::db::entities::agent_setting::Model>, + runtime_env: &mut BTreeMap, + conn: &sea_orm::DatabaseConnection, +) { + let provider_id = match setting.and_then(|s| s.model_provider_id) { + Some(id) => id, + None => return, + }; + let provider = match model_provider_service::get_by_id(conn, provider_id).await { + Ok(Some(p)) => p, + _ => return, + }; + let (url_key, key_key, _) = important_env_targets(agent_type); + if !provider.api_url.trim().is_empty() { + runtime_env.insert(url_key.to_string(), provider.api_url.clone()); + } + if !provider.api_key.trim().is_empty() { + runtime_env.insert(key_key.to_string(), provider.api_key.clone()); + } +} + #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn acp_preflight( agent_type: AgentType, @@ -1579,6 +1609,9 @@ pub async fn acp_connect( let mut runtime_env = build_runtime_env_from_setting(agent_type, setting.as_ref(), local_config_json.as_deref()); + // Resolve model provider credentials if configured. + apply_model_provider_env(agent_type, setting.as_ref(), &mut runtime_env, &db.conn).await; + // For OpenClaw: when creating a new conversation (no session_id to resume), // signal that we want a fresh transcript via --reset-session. if agent_type == AgentType::OpenClaw && session_id.is_none() { @@ -1859,6 +1892,7 @@ pub(crate) async fn acp_list_agents_core( codex_auth_json, codex_config_toml, cline_secrets_json, + model_provider_id: setting.and_then(|m| m.model_provider_id), }); } @@ -1943,7 +1977,11 @@ pub(crate) async fn acp_update_agent_preferences_core( } } - let patch = agent_setting_service::AgentSettingsUpdate { enabled, env_json }; + let patch = agent_setting_service::AgentSettingsUpdate { + enabled, + env_json, + model_provider_id: None, + }; agent_setting_service::update(&db.conn, agent_type, patch) .await .map_err(|e| AcpError::protocol(e.to_string()))?; @@ -2018,6 +2056,152 @@ pub async fn acp_update_agent_preferences( ).await } +pub(crate) async fn acp_update_agent_env_core( + agent_type: AgentType, + enabled: bool, + env: BTreeMap, + model_provider_id: Option, + db: &AppDatabase, + emitter: &EventEmitter, +) -> Result<(), AcpError> { + let default = agent_setting_service::AgentDefaultInput { + agent_type, + registry_id: registry::registry_id_for(agent_type).to_string(), + default_sort_order: i32::MAX / 2, + }; + + agent_setting_service::ensure_defaults(&db.conn, &[default]) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + + let env_json = if env.is_empty() { + None + } else { + Some(serde_json::to_string(&env).map_err(|e| AcpError::protocol(e.to_string()))?) + }; + + let patch = agent_setting_service::AgentSettingsUpdate { + enabled, + env_json, + model_provider_id, + }; + agent_setting_service::update(&db.conn, agent_type, patch) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + + emit_acp_agents_updated(emitter, "env_updated", Some(agent_type)); + Ok(()) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_update_agent_env( + agent_type: AgentType, + enabled: bool, + env: BTreeMap, + model_provider_id: Option, + db: State<'_, AppDatabase>, + app: tauri::AppHandle, +) -> Result<(), AcpError> { + let emitter = EventEmitter::Tauri(app); + acp_update_agent_env_core(agent_type, enabled, env, model_provider_id, &db, &emitter).await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn acp_update_agent_config_core( + agent_type: AgentType, + config_json: Option, + opencode_auth_json: Option, + codex_auth_json: Option, + codex_config_toml: Option, + emitter: &EventEmitter, +) -> Result<(), AcpError> { + let config_json = config_json.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + let opencode_auth_json = opencode_auth_json.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + if let Some(raw) = config_json.as_deref() { + let parsed = serde_json::from_str::(raw) + .map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?; + if !parsed.is_object() { + return Err(AcpError::protocol( + "invalid config_json: root must be a JSON object", + )); + } + } + + if agent_type == AgentType::Codex { + if codex_auth_json.is_some() || codex_config_toml.is_some() { + persist_codex_native_config_files( + codex_auth_json.as_deref(), + codex_config_toml.as_deref(), + )?; + } + emit_acp_agents_updated(emitter, "config_updated", Some(agent_type)); + return Ok(()); + } + + if agent_type == AgentType::OpenCode { + if let Some(raw_auth) = opencode_auth_json.as_deref() { + persist_opencode_auth_json(raw_auth)?; + } + if let Some(raw) = config_json.as_deref() { + persist_agent_local_config_json(agent_type, Some(raw))?; + } + emit_acp_agents_updated(emitter, "config_updated", Some(agent_type)); + return Ok(()); + } + + if agent_type == AgentType::Cline { + if let Some(raw) = config_json.as_deref() { + persist_cline_local_config(Some(raw))?; + } + emit_acp_agents_updated(emitter, "config_updated", Some(agent_type)); + return Ok(()); + } + + // Claude Code, Gemini, OpenClaw — write config JSON to local file without merging env + let local_patch_value = config_json + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()) + .filter(|value| value.is_object()) + .unwrap_or_else(|| serde_json::json!({})); + let local_patch_json = serde_json::to_string(&local_patch_value) + .map_err(|e| AcpError::protocol(format!("serialize local patch failed: {e}")))?; + persist_agent_local_config_json(agent_type, Some(local_patch_json.as_str()))?; + emit_acp_agents_updated(emitter, "config_updated", Some(agent_type)); + Ok(()) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_update_agent_config( + agent_type: AgentType, + config_json: Option, + opencode_auth_json: Option, + codex_auth_json: Option, + codex_config_toml: Option, + app: tauri::AppHandle, +) -> Result<(), AcpError> { + let emitter = EventEmitter::Tauri(app); + acp_update_agent_config_core( + agent_type, config_json, opencode_auth_json, codex_auth_json, codex_config_toml, &emitter, + ) + .await +} + pub(crate) async fn acp_download_agent_binary_core( agent_type: AgentType, emitter: &EventEmitter, diff --git a/src-tauri/src/commands/model_provider.rs b/src-tauri/src/commands/model_provider.rs index 2e5afa8..d47b070 100644 --- a/src-tauri/src/commands/model_provider.rs +++ b/src-tauri/src/commands/model_provider.rs @@ -1,5 +1,5 @@ use crate::app_error::AppCommandError; -use crate::db::service::model_provider_service; +use crate::db::service::{agent_setting_service, model_provider_service}; use crate::db::AppDatabase; use crate::models::agent::AgentType; use crate::models::model_provider::ModelProviderInfo; @@ -118,6 +118,10 @@ pub async fn delete_model_provider_core( db: &AppDatabase, id: i32, ) -> Result<(), AppCommandError> { + // Clear any agent settings that reference this provider before deleting. + agent_setting_service::clear_model_provider_id(&db.conn, id) + .await + .map_err(AppCommandError::from)?; model_provider_service::delete(&db.conn, id) .await .map_err(AppCommandError::from)?; diff --git a/src-tauri/src/db/entities/agent_setting.rs b/src-tauri/src/db/entities/agent_setting.rs index ee2fc6d..6211911 100644 --- a/src-tauri/src/db/entities/agent_setting.rs +++ b/src-tauri/src/db/entities/agent_setting.rs @@ -11,6 +11,7 @@ pub struct Model { pub sort_order: i32, pub installed_version: Option, pub env_json: Option, + pub model_provider_id: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, } diff --git a/src-tauri/src/db/migration/m20260406_000001_agent_setting_model_provider.rs b/src-tauri/src/db/migration/m20260406_000001_agent_setting_model_provider.rs new file mode 100644 index 0000000..064d814 --- /dev/null +++ b/src-tauri/src/db/migration/m20260406_000001_agent_setting_model_provider.rs @@ -0,0 +1,35 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(AgentSetting::Table) + .add_column(ColumnDef::new(AgentSetting::ModelProviderId).integer().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(AgentSetting::Table) + .drop_column(AgentSetting::ModelProviderId) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum AgentSetting { + Table, + ModelProviderId, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index babc372..8698cbe 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -8,6 +8,7 @@ mod m20260227_000001_folder_parent_branch; mod m20260330_000001_chat_channel; mod m20260401_000001_chat_channel_sender_context; mod m20260404_000001_model_provider; +mod m20260406_000001_agent_setting_model_provider; pub struct Migrator; #[async_trait::async_trait] @@ -22,6 +23,7 @@ impl MigratorTrait for Migrator { Box::new(m20260330_000001_chat_channel::Migration), Box::new(m20260401_000001_chat_channel_sender_context::Migration), Box::new(m20260404_000001_model_provider::Migration), + Box::new(m20260406_000001_agent_setting_model_provider::Migration), ] } } diff --git a/src-tauri/src/db/service/agent_setting_service.rs b/src-tauri/src/db/service/agent_setting_service.rs index 59235d6..777f8a5 100644 --- a/src-tauri/src/db/service/agent_setting_service.rs +++ b/src-tauri/src/db/service/agent_setting_service.rs @@ -22,6 +22,7 @@ pub struct AgentDefaultInput { pub struct AgentSettingsUpdate { pub enabled: bool, pub env_json: Option, + pub model_provider_id: Option, } fn default_enabled(agent_type: AgentType) -> bool { @@ -67,6 +68,7 @@ pub async fn ensure_defaults( sort_order: Set(default.default_sort_order), installed_version: Set(None), env_json: Set(None), + model_provider_id: Set(None), created_at: Set(now), updated_at: Set(now), }; @@ -133,6 +135,7 @@ pub async fn update( let mut active = model.into_active_model(); active.enabled = Set(patch.enabled); active.env_json = Set(patch.env_json); + active.model_provider_id = Set(patch.model_provider_id); active.updated_at = Set(Utc::now()); active.update(conn).await?; Ok(()) @@ -202,6 +205,25 @@ async fn reorder_once(conn: &DatabaseConnection, agent_types: &[AgentType]) -> R Ok(()) } +pub async fn clear_model_provider_id( + conn: &DatabaseConnection, + model_provider_id: i32, +) -> Result<(), DbError> { + let rows = agent_setting::Entity::find() + .all(conn) + .await?; + let now = Utc::now(); + for row in rows { + if row.model_provider_id == Some(model_provider_id) { + let mut active = row.into_active_model(); + active.model_provider_id = Set(None); + active.updated_at = Set(now); + active.update(conn).await?; + } + } + Ok(()) +} + fn is_sqlite_full_error(err: &DbError) -> bool { let message = err.to_string(); message.contains("database or disk is full") || message.contains("(code: 13)") diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e47784..35d3c98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -347,6 +347,8 @@ mod tauri_app { acp_commands::acp_prepare_npx_agent, acp_commands::acp_uninstall_agent, acp_commands::acp_update_agent_preferences, + acp_commands::acp_update_agent_env, + acp_commands::acp_update_agent_config, acp_commands::acp_reorder_agents, acp_commands::acp_list_agent_skills, acp_commands::acp_read_agent_skill, diff --git a/src-tauri/src/models/model_provider.rs b/src-tauri/src/models/model_provider.rs index a4027eb..fe208f6 100644 --- a/src-tauri/src/models/model_provider.rs +++ b/src-tauri/src/models/model_provider.rs @@ -5,6 +5,7 @@ pub struct ModelProviderInfo { pub id: i32, pub name: String, pub api_url: String, + pub api_key: String, pub api_key_masked: String, pub agent_types: Vec, pub created_at: String, @@ -33,6 +34,7 @@ impl From for ModelProviderInfo { id: m.id, name: m.name, api_url: m.api_url, + api_key: m.api_key.clone(), api_key_masked: mask_api_key(&m.api_key), agent_types, created_at: m.created_at.to_rfc3339(), diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index c917e06..1084020 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -80,6 +80,15 @@ pub async fn acp_connect( local_config_json.as_deref(), ); + // Resolve model provider credentials if configured. + acp_commands::apply_model_provider_env( + params.agent_type, + setting.as_ref(), + &mut runtime_env, + &db.conn, + ) + .await; + if params.agent_type == AgentType::OpenClaw && params.session_id.is_none() { runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into()); } @@ -398,6 +407,62 @@ pub async fn acp_update_agent_preferences( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpUpdateAgentEnvParams { + pub agent_type: AgentType, + pub enabled: bool, + pub env: BTreeMap, + pub model_provider_id: Option, +} + +pub async fn acp_update_agent_env( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let db = &state.db; + let emitter = state.emitter.clone(); + acp_commands::acp_update_agent_env_core( + params.agent_type, + params.enabled, + params.env, + params.model_provider_id, + db, + &emitter, + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpUpdateAgentConfigParams { + pub agent_type: AgentType, + pub config_json: Option, + pub opencode_auth_json: Option, + pub codex_auth_json: Option, + pub codex_config_toml: Option, +} + +pub async fn acp_update_agent_config( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let emitter = state.emitter.clone(); + acp_commands::acp_update_agent_config_core( + params.agent_type, + params.config_json, + params.opencode_auth_json, + params.codex_auth_json, + params.codex_config_toml, + &emitter, + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(())) +} + pub async fn acp_download_agent_binary( Extension(state): Extension>, Json(params): Json, diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 5f111bc..89f2d67 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -164,6 +164,8 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: .route("/acp_list_connections", post(handlers::acp::acp_list_connections)) .route("/acp_clear_binary_cache", post(handlers::acp::acp_clear_binary_cache)) .route("/acp_update_agent_preferences", post(handlers::acp::acp_update_agent_preferences)) + .route("/acp_update_agent_env", post(handlers::acp::acp_update_agent_env)) + .route("/acp_update_agent_config", post(handlers::acp::acp_update_agent_config)) .route("/acp_download_agent_binary", post(handlers::acp::acp_download_agent_binary)) .route("/acp_detect_agent_local_version", post(handlers::acp::acp_detect_agent_local_version)) .route("/acp_prepare_npx_agent", post(handlers::acp::acp_prepare_npx_agent)) diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 8ec2086..a84e638 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -63,13 +63,16 @@ import { acpPrepareNpxAgent, acpReorderAgents, acpUninstallAgent, - acpUpdateAgentPreferences, + acpUpdateAgentConfig, + acpUpdateAgentEnv, + listModelProviders, } from "@/lib/api" import type { AcpAgentInfo, AgentType, CheckStatus, FixAction, + ModelProviderInfo, PreflightResult, } from "@/lib/types" @@ -78,6 +81,13 @@ interface AgentCheckState { error?: string } +const CLAUDE_AUTH_MODES = [ + "official_subscription", + "custom", + "model_provider", +] as const +type ClaudeAuthMode = (typeof CLAUDE_AUTH_MODES)[number] + interface AgentDraft { enabled: boolean envText: string @@ -85,6 +95,8 @@ interface AgentDraft { apiBaseUrl: string apiKey: string model: string + claudeAuthMode: ClaudeAuthMode + modelProviderId: number | null geminiAuthMode: GeminiAuthMode geminiApiKey: string googleApiKey: string @@ -231,6 +243,7 @@ const GEMINI_AUTH_MODES = [ "vertex_adc", "vertex_service_account", "vertex_api_key", + "model_provider", ] as const type GeminiAuthMode = (typeof GEMINI_AUTH_MODES)[number] @@ -785,7 +798,8 @@ function patchGeminiAuthMode( } function geminiAuthModeLabel(mode: GeminiAuthMode): string { - if (mode === "custom") return acpText("gemini.mode.custom", "Custom Endpoint") + if (mode === "custom") + return acpText("authModeCustomEndpoint", "Custom Endpoint") if (mode === "login_google") return acpText("gemini.mode.loginGoogle", "Google Login (OAuth)") if (mode === "gemini_api_key") return "Gemini API Key" @@ -795,6 +809,8 @@ function geminiAuthModeLabel(mode: GeminiAuthMode): string { "gemini.mode.vertexServiceAccount", "Vertex AI (Service Account)" ) + if (mode === "model_provider") + return acpText("authModeModelProvider", "Model Provider") return "Vertex AI API Key" } @@ -829,12 +845,48 @@ function geminiAuthModeHint(mode: GeminiAuthMode): string { "Set service account JSON path to GOOGLE_APPLICATION_CREDENTIALS." ) } + if (mode === "model_provider") { + return acpText( + "modelProviderHint", + "Use API URL and API Key from a configured model provider." + ) + } return acpText( "gemini.hint.vertexApiKey", "Fill GOOGLE_API_KEY when using Vertex AI API key." ) } +/** + * Compare original and current config objects. For any key present in + * original but missing in current, set it to `null` in the result so + * the backend merge can delete it from the file on disk. + */ +function markRemovedKeysNull( + original: Record, + current: Record +): Record { + const result: Record = { ...current } + for (const key of Object.keys(original)) { + if (!(key in result)) { + result[key] = null + } else if ( + original[key] && + typeof original[key] === "object" && + !Array.isArray(original[key]) && + result[key] && + typeof result[key] === "object" && + !Array.isArray(result[key]) + ) { + result[key] = markRemovedKeysNull( + original[key] as Record, + result[key] as Record + ) + } + } + return result +} + function normalizeConfigText(configText: string): string { const parseResult = parseConfigJsonText(configText) if (parseResult.error) return configText.trim() @@ -1003,7 +1055,11 @@ interface CodexImportantValues { const CODEX_DEFAULT_MODEL_PROVIDER = "codeg" -const CODEX_AUTH_MODES = ["api_key", "chatgpt_subscription"] as const +const CODEX_AUTH_MODES = [ + "api_key", + "chatgpt_subscription", + "model_provider", +] as const type CodexAuthMode = (typeof CODEX_AUTH_MODES)[number] type CodexReasoningEffort = "low" | "medium" | "high" | "xhigh" @@ -1474,6 +1530,22 @@ function findTomlSectionRange( return { start: sectionStart, end: sectionEnd } } +function removeTomlSection( + configTomlText: string, + sectionName: string +): string { + const lines = configTomlText.split(/\r?\n/) + const range = findTomlSectionRange(lines, sectionName) + if (!range) return configTomlText + // Remove blank line before section header if present + const removeStart = + range.start > 0 && lines[range.start - 1].trim() === "" + ? range.start - 1 + : range.start + lines.splice(removeStart, range.end - removeStart) + return lines.join("\n").trim() +} + function upsertTomlSectionBooleanKey( configTomlText: string, sectionName: string, @@ -1867,9 +1939,8 @@ function patchImportantConfigText( config[key] = trimmed } - assignOrRemove("apiBaseUrl", patch.apiBaseUrl) - assignOrRemove("apiKey", patch.apiKey) if (agentType === "claude_code") { + // Claude Code: write apiBaseUrl/apiKey into config.env, not root const env = typeof config.env === "object" && config.env && !Array.isArray(config.env) ? { ...(config.env as Record) } @@ -1882,6 +1953,11 @@ function patchImportantConfigText( } env[key] = trimmed } + // Remove root-level apiBaseUrl/apiKey if present (legacy cleanup) + delete config.apiBaseUrl + delete config.apiKey + assignEnv("ANTHROPIC_BASE_URL", patch.apiBaseUrl) + assignEnv("ANTHROPIC_AUTH_TOKEN", patch.apiKey) assignEnv(CLAUDE_MODEL_ENV_KEYS.claudeMainModel, patch.claudeMainModel) assignEnv( @@ -1907,6 +1983,8 @@ function patchImportantConfigText( config.env = env } } else { + assignOrRemove("apiBaseUrl", patch.apiBaseUrl) + assignOrRemove("apiKey", patch.apiKey) assignOrRemove("model", patch.model) } @@ -2028,16 +2106,29 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { : agent.agent_type === "open_code" ? openCodeImportant.model : important.model, - geminiAuthMode: geminiImportant.authMode, + claudeAuthMode: + agent.agent_type === "claude_code" && agent.model_provider_id != null + ? "model_provider" + : agent.agent_type === "claude_code" && + (important.apiBaseUrl || important.apiKey) + ? "custom" + : "official_subscription", + modelProviderId: agent.model_provider_id ?? null, + geminiAuthMode: + agent.agent_type === "gemini" && agent.model_provider_id != null + ? "model_provider" + : geminiImportant.authMode, geminiApiKey: geminiImportant.geminiApiKey, googleApiKey: geminiImportant.googleApiKey, googleCloudProject: geminiImportant.googleCloudProject, googleCloudLocation: geminiImportant.googleCloudLocation, googleApplicationCredentials: geminiImportant.googleApplicationCredentials, codexAuthMode: - agent.agent_type === "codex" - ? inferCodexAuthMode(codexAuthJsonText) - : "api_key", + agent.agent_type === "codex" && agent.model_provider_id != null + ? "model_provider" + : agent.agent_type === "codex" + ? inferCodexAuthMode(codexAuthJsonText) + : "api_key", codexModelProvider: codexImportant.modelProvider, codexProviderOptions: codexImportant.providerOptions, codexReasoningEffort: codexImportant.reasoningEffort, @@ -2334,7 +2425,13 @@ export function AcpAgentSettings() { const [runningActionKind, setRunningActionKind] = useState< Partial> >({}) - const [saving, setSaving] = useState>>({}) + const [savingEnv, setSavingEnv] = useState< + Partial> + >({}) + const [savingConfig, setSavingConfig] = useState< + Partial> + >({}) + const [modelProviders, setModelProviders] = useState([]) const [uninstallConfirmAgent, setUninstallConfirmAgent] = useState(null) const [expandedChecks, setExpandedChecks] = useState>( @@ -2399,8 +2496,12 @@ export function AcpAgentSettings() { setLoadingAgents(true) setLoadingError(null) try { - const next = await acpListAgents() + const [next, providers] = await Promise.all([ + acpListAgents(), + listModelProviders().catch(() => [] as ModelProviderInfo[]), + ]) setAgents(next) + setModelProviders(providers) setDrafts((prev) => { const updated = { ...prev } for (const agent of next) { @@ -2549,11 +2650,43 @@ export function AcpAgentSettings() { }) }, [sortedAgents]) - const persistPreferences = useCallback( + const persistEnv = useCallback( async ( agentType: AgentType, enabled: boolean, envText: string, + modelProviderId?: number | null + ) => { + const parsedEnv = parseEnvText(envText) + setSavingEnv((prev) => ({ ...prev, [agentType]: true })) + try { + await acpUpdateAgentEnv(agentType, { + enabled, + env: parsedEnv, + modelProviderId: modelProviderId ?? null, + }) + setAgents((prev) => + prev.map((agent) => + agent.agent_type === agentType + ? { + ...agent, + enabled, + env: parsedEnv, + model_provider_id: modelProviderId ?? null, + } + : agent + ) + ) + } finally { + setSavingEnv((prev) => ({ ...prev, [agentType]: false })) + } + }, + [] + ) + + const persistConfig = useCallback( + async ( + agentType: AgentType, configText: string, options?: { openCodeAuthJsonText?: string @@ -2565,34 +2698,47 @@ export function AcpAgentSettings() { if (parsedConfig.error) { throw new Error(parsedConfig.error) } - const openCodeAuthJsonText = options?.openCodeAuthJsonText const codexAuthJsonText = options?.codexAuthJsonText - const codexConfigTomlText = options?.codexConfigTomlText if (agentType === "codex" && typeof codexAuthJsonText === "string") { const authError = parseCodexAuthJsonText(codexAuthJsonText) if (authError) { throw new Error(authError) } } - const parsedEnv = parseEnvText(envText) const normalizedConfig = normalizeConfigText(configText) - const configForPersist = + // For agents using merge strategy, mark removed keys as null + // so the backend merge_json_values can delete them from disk. + let configForPersist = agentType === "open_code" && !normalizedConfig ? "{}" : normalizedConfig - setSaving((prev) => ({ ...prev, [agentType]: true })) + const usesMerge = + agentType === "claude_code" || + agentType === "gemini" || + agentType === "open_claw" + if (usesMerge && configForPersist) { + const originalAgent = agents.find((a) => a.agent_type === agentType) + const originalConfig = originalAgent?.config_json + ? parseConfigJsonText(originalAgent.config_json).config + : {} + const currentConfig = parsedConfig.config + configForPersist = JSON.stringify( + markRemovedKeysNull(originalConfig, currentConfig), + null, + 2 + ) + } + setSavingConfig((prev) => ({ ...prev, [agentType]: true })) try { - await acpUpdateAgentPreferences(agentType, { - enabled, - env: parsedEnv, + await acpUpdateAgentConfig(agentType, { config_json: configForPersist || null, opencode_auth_json: - typeof openCodeAuthJsonText === "string" - ? openCodeAuthJsonText + typeof options?.openCodeAuthJsonText === "string" + ? options.openCodeAuthJsonText : null, codex_auth_json: typeof codexAuthJsonText === "string" ? codexAuthJsonText : null, codex_config_toml: - typeof codexConfigTomlText === "string" - ? codexConfigTomlText + typeof options?.codexConfigTomlText === "string" + ? options.codexConfigTomlText : null, }) setAgents((prev) => @@ -2600,30 +2746,28 @@ export function AcpAgentSettings() { agent.agent_type === agentType ? { ...agent, - enabled, - env: parsedEnv, - config_json: configForPersist || null, + config_json: normalizedConfig || null, opencode_auth_json: - typeof openCodeAuthJsonText === "string" - ? openCodeAuthJsonText + typeof options?.openCodeAuthJsonText === "string" + ? options.openCodeAuthJsonText : agent.opencode_auth_json, codex_auth_json: typeof codexAuthJsonText === "string" ? codexAuthJsonText : agent.codex_auth_json, codex_config_toml: - typeof codexConfigTomlText === "string" - ? codexConfigTomlText + typeof options?.codexConfigTomlText === "string" + ? options.codexConfigTomlText : agent.codex_config_toml, } : agent ) ) } finally { - setSaving((prev) => ({ ...prev, [agentType]: false })) + setSavingConfig((prev) => ({ ...prev, [agentType]: false })) } }, - [] + [agents] ) const runBinaryAction = useCallback( @@ -2986,9 +3130,40 @@ export function AcpAgentSettings() { ? (configErrors[selectedAgent.agent_type] ?? null) : null const selectedIsSaving = selectedAgent - ? Boolean(saving[selectedAgent.agent_type]) + ? Boolean( + savingEnv[selectedAgent.agent_type] || + savingConfig[selectedAgent.agent_type] + ) + : false + const selectedIsSavingEnv = selectedAgent + ? Boolean(savingEnv[selectedAgent.agent_type]) + : false + const selectedIsSavingConfig = selectedAgent + ? Boolean(savingConfig[selectedAgent.agent_type]) : false const selectedAgentKind = selectedAgent?.agent_type ?? null + + const selectedModelProviders = useMemo(() => { + if (!selectedAgent) return [] + return modelProviders.filter((p) => + p.agent_types.includes(selectedAgent.agent_type) + ) + }, [modelProviders, selectedAgent]) + + const selectedNeedsModelProvider = useMemo(() => { + if (!selectedDraft) return false + if (!selectedAgent) return false + const at = selectedAgent.agent_type + if (at === "claude_code") + return selectedDraft.claudeAuthMode === "model_provider" + if (at === "codex") return selectedDraft.codexAuthMode === "model_provider" + if (at === "gemini") + return selectedDraft.geminiAuthMode === "model_provider" + return false + }, [selectedAgent, selectedDraft]) + + const selectedMissingModelProvider = + selectedNeedsModelProvider && selectedDraft?.modelProviderId == null const selectedCodexAuthJsonText = selectedDraft?.codexAuthJsonText ?? "" const selectedConfigText = selectedDraft?.configText ?? "" const selectedOpenCodeAuthJsonText = selectedDraft?.openCodeAuthJsonText ?? "" @@ -3196,6 +3371,166 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, t, updateSelectedDraft] ) + const handleClaudeAuthModeChange = useCallback( + (nextMode: ClaudeAuthMode) => { + if ( + !selectedAgent || + !selectedDraft || + selectedAgent.agent_type !== "claude_code" + ) + return + + const keys = importantEnvKeysByAgent("claude_code") + const allEnvKeys = [...keys.apiBaseUrl, ...keys.apiKey] + + if (nextMode === "official_subscription") { + // Clear API URL/API Key from env and config + const envPatch: Record = {} + for (const k of allEnvKeys) envPatch[k] = "" + // Build clean display config (remove null keys) + const parsed = parseConfigJsonText(selectedDraft.configText) + const config: Record = parsed.error + ? {} + : { ...parsed.config } + delete config.apiBaseUrl + delete config.apiKey + if (config.env && typeof config.env === "object") { + const cfgEnv = { ...(config.env as Record) } + for (const k of allEnvKeys) delete cfgEnv[k] + if (Object.keys(cfgEnv).length > 0) { + config.env = cfgEnv + } else { + delete config.env + } + } + const nextConfigText = + Object.keys(config).length > 0 ? JSON.stringify(config, null, 2) : "" + setConfigErrors((prev) => ({ + ...prev, + [selectedAgent.agent_type]: null, + })) + updateSelectedDraft((current) => ({ + ...current, + claudeAuthMode: nextMode, + modelProviderId: null, + apiBaseUrl: "", + apiKey: "", + envText: patchEnvText(current.envText, envPatch), + configText: nextConfigText, + })) + return + } + + // "custom" or "model_provider" — keep existing values, just switch mode + updateSelectedDraft((current) => ({ + ...current, + claudeAuthMode: nextMode, + modelProviderId: + nextMode === "model_provider" ? current.modelProviderId : null, + })) + }, + [selectedAgent, selectedDraft, updateSelectedDraft] + ) + + const handleModelProviderSelect = useCallback( + (providerIdStr: string) => { + if (!selectedAgent || !selectedDraft) return + const providerId = providerIdStr ? Number(providerIdStr) : null + const provider = providerId + ? modelProviders.find((p) => p.id === providerId) + : null + const apiUrl = provider?.api_url ?? "" + const apiKey = provider?.api_key ?? "" + const agentType = selectedAgent.agent_type + + if (agentType === "claude_code") { + const nextConfigJson = patchImportantConfigText( + agentType, + selectedDraft.configText, + { apiBaseUrl: apiUrl, apiKey } + ) + setConfigErrors((prev) => ({ + ...prev, + [agentType]: null, + })) + updateSelectedDraft((current) => ({ + ...current, + modelProviderId: providerId, + apiBaseUrl: apiUrl, + apiKey, + envText: patchEnvByImportantKey( + agentType, + patchEnvByImportantKey( + agentType, + current.envText, + "apiBaseUrl", + apiUrl + ), + "apiKey", + apiKey + ), + configText: nextConfigJson.configText, + })) + } else if (agentType === "codex") { + const nextAuthJsonText = apiKey + ? JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2) + : "{}" + const nextConfigTomlText = patchCodexConfigTomlText( + selectedDraft.codexConfigTomlText, + { + modelProvider: CODEX_DEFAULT_MODEL_PROVIDER, + apiBaseUrl: apiUrl, + } + ) + const synced = extractCodexImportantValues( + nextAuthJsonText, + nextConfigTomlText + ) + updateSelectedDraft((current) => ({ + ...current, + modelProviderId: providerId, + apiBaseUrl: apiUrl, + apiKey, + codexAuthJsonText: nextAuthJsonText, + codexConfigTomlText: nextConfigTomlText, + codexModelProvider: CODEX_DEFAULT_MODEL_PROVIDER, + codexProviderOptions: synced.providerOptions, + envText: patchEnvText(current.envText, { + OPENAI_API_KEY: apiKey, + OPENAI_BASE_URL: apiUrl, + }), + })) + } else if (agentType === "gemini") { + const nextConfigJson = patchGeminiConfigText(selectedDraft.configText, { + apiBaseUrl: apiUrl, + geminiApiKey: apiKey, + }) + setConfigErrors((prev) => ({ + ...prev, + [agentType]: null, + })) + updateSelectedDraft((current) => ({ + ...current, + modelProviderId: providerId, + apiBaseUrl: apiUrl, + apiKey, + geminiApiKey: apiKey, + envText: patchGeminiEnvText(current.envText, { + apiBaseUrl: apiUrl, + geminiApiKey: apiKey, + }), + configText: nextConfigJson.configText, + })) + } else { + updateSelectedDraft((current) => ({ + ...current, + modelProviderId: providerId, + })) + } + }, + [selectedAgent, selectedDraft, modelProviders, updateSelectedDraft] + ) + const handleGeminiFieldChange = useCallback( ( key: @@ -3291,6 +3626,16 @@ export function AcpAgentSettings() { ) return + if (nextMode === "model_provider") { + // Keep existing values; provider selection will fill API URL/Key + updateSelectedDraft((current) => ({ + ...current, + geminiAuthMode: nextMode, + modelProviderId: current.modelProviderId, + })) + return + } + const patched = patchGeminiAuthMode( { authMode: selectedDraft.geminiAuthMode, @@ -3326,6 +3671,7 @@ export function AcpAgentSettings() { updateSelectedDraft((current) => ({ ...current, geminiAuthMode: patched.authMode, + modelProviderId: null, apiBaseUrl: patched.apiBaseUrl, apiKey: patched.geminiApiKey || patched.googleApiKey, geminiApiKey: patched.geminiApiKey, @@ -3608,15 +3954,9 @@ export function AcpAgentSettings() { ) { return } - persistPreferences( - selectedAgent.agent_type, - removed.enabled, - removed.envText, - removed.configText, - { - openCodeAuthJsonText: removed.openCodeAuthJsonText, - } - ) + persistConfig(selectedAgent.agent_type, removed.configText, { + openCodeAuthJsonText: removed.openCodeAuthJsonText, + }) .then(() => { toast.success(t("toasts.providerDeleted", { providerId }), { description: t("toasts.openCodeConfigSynced"), @@ -3632,7 +3972,7 @@ export function AcpAgentSettings() { }, [ handleOpenCodeRemoveProvider, openCodeDeleteProviderId, - persistPreferences, + persistConfig, selectedAgent, t, ]) @@ -3994,42 +4334,6 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, updateSelectedDraft] ) - const handleCodexModelProviderChange = useCallback( - (nextProvider: string) => { - if ( - !selectedAgent || - !selectedDraft || - selectedAgent.agent_type !== "codex" - ) - return - const trimmedProvider = nextProvider.trim() - if (!trimmedProvider) return - const nextToml = patchCodexConfigTomlText( - selectedDraft.codexConfigTomlText, - { - modelProvider: trimmedProvider, - modelReasoningEffort: selectedDraft.codexReasoningEffort, - } - ) - const synced = extractCodexImportantValues( - selectedDraft.codexAuthJsonText, - nextToml - ) - updateSelectedDraft((current) => ({ - ...current, - apiBaseUrl: synced.apiBaseUrl, - apiKey: synced.apiKey ?? current.apiKey, - model: synced.model, - codexModelProvider: synced.modelProvider, - codexProviderOptions: synced.providerOptions, - codexReasoningEffort: synced.reasoningEffort, - codexSupportsWebsockets: synced.supportsWebsockets, - codexConfigTomlText: nextToml, - })) - }, - [selectedAgent, selectedDraft, updateSelectedDraft] - ) - const handleCodexAuthModeChange = useCallback( (nextMode: CodexAuthMode) => { if ( @@ -4039,41 +4343,71 @@ export function AcpAgentSettings() { ) return + if (nextMode === "chatgpt_subscription") { + // Official subscription: clear API URL/Key from env, remove model_provider config from toml + const nextAuthJsonText = "{}" + let nextConfigTomlText = updateTomlRootStringKey( + selectedDraft.codexConfigTomlText, + "model_provider", + "" + ) + nextConfigTomlText = removeTomlSection( + nextConfigTomlText, + `model_providers.${CODEX_DEFAULT_MODEL_PROVIDER}` + ) + const synced = extractCodexImportantValues( + nextAuthJsonText, + nextConfigTomlText + ) + updateSelectedDraft((current) => ({ + ...current, + codexAuthMode: nextMode, + modelProviderId: null, + codexAuthJsonText: nextAuthJsonText, + codexConfigTomlText: nextConfigTomlText, + envText: patchEnvText(current.envText, { + OPENAI_API_KEY: "", + OPENAI_BASE_URL: "", + }), + apiBaseUrl: "", + apiKey: "", + model: synced.model, + codexModelProvider: synced.modelProvider, + codexProviderOptions: synced.providerOptions, + codexReasoningEffort: synced.reasoningEffort, + codexSupportsWebsockets: synced.supportsWebsockets, + })) + return + } + + // "api_key" or "model_provider": ensure model_provider = "codeg" in toml + const nextConfigTomlText = patchCodexConfigTomlText( + selectedDraft.codexConfigTomlText, + { modelProvider: CODEX_DEFAULT_MODEL_PROVIDER } + ) const nextAuthJsonText = - nextMode === "chatgpt_subscription" - ? "{}" - : JSON.stringify({ OPENAI_API_KEY: "" }, null, 2) - - const nextConfigTomlText = - nextMode === "chatgpt_subscription" - ? "" - : selectedDraft.codexConfigTomlText - - const nextEnvText = - nextMode === "chatgpt_subscription" - ? patchEnvText(selectedDraft.envText, { - OPENAI_API_KEY: "", - OPENAI_BASE_URL: "", - }) - : selectedDraft.envText - + nextMode === "api_key" + ? JSON.stringify( + { OPENAI_API_KEY: selectedDraft.apiKey || "" }, + null, + 2 + ) + : selectedDraft.codexAuthJsonText const synced = extractCodexImportantValues( nextAuthJsonText, nextConfigTomlText ) - updateSelectedDraft((current) => ({ ...current, codexAuthMode: nextMode, + modelProviderId: + nextMode === "model_provider" ? current.modelProviderId : null, codexAuthJsonText: nextAuthJsonText, codexConfigTomlText: nextConfigTomlText, - envText: nextEnvText, - apiBaseUrl: - nextMode === "chatgpt_subscription" ? "" : synced.apiBaseUrl, - apiKey: - nextMode === "chatgpt_subscription" ? "" : (synced.apiKey ?? ""), + apiBaseUrl: synced.apiBaseUrl, + apiKey: synced.apiKey ?? current.apiKey, model: synced.model, - codexModelProvider: synced.modelProvider, + codexModelProvider: CODEX_DEFAULT_MODEL_PROVIDER, codexProviderOptions: synced.providerOptions, codexReasoningEffort: synced.reasoningEffort, codexSupportsWebsockets: synced.supportsWebsockets, @@ -4405,17 +4739,11 @@ export function AcpAgentSettings() { ...prev, [selectedAgent.agent_type]: nextDraft, })) - persistPreferences( + persistEnv( selectedAgent.agent_type, nextEnabled, nextDraft.envText, - nextDraft.configText, - selectedAgent.agent_type === "open_code" - ? { - openCodeAuthJsonText: - nextDraft.openCodeAuthJsonText, - } - : undefined + nextDraft.modelProviderId ).catch((err) => { console.error( "[Settings] persist enabled failed:", @@ -4488,17 +4816,11 @@ export function AcpAgentSettings() { + + + + )} {selectedAgent.agent_type === "claude_code" ? (
@@ -6304,12 +6811,22 @@ supports_websockets = true`}