feat(settings): refactor agent auth modes and add model provider authentication

- Split env vars and config file persistence into separate save operations
- Add model_provider_id field to agent_setting for tracking selected provider
- Add "Model Provider" auth mode for Claude Code, Codex CLI, and Gemini CLI
- Add "Custom Endpoint" auth mode for Claude Code (previously only official subscription)
- Unify auth mode labels across all three agents (official subscription / custom endpoint / model provider)
- When model provider is selected, fill api_url and api_key into env and config automatically
- Resolve model provider credentials at ACP connect time as a backend fallback
- Clear provider deletion cascades to agent_setting.model_provider_id
- Claude Code writes API credentials to config.env (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN)
- Codex: switching auth modes patches config.toml instead of clearing it
- Add i18n keys for new auth modes in all 10 supported languages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-06 22:10:18 +08:00
parent d28c54a038
commit b64976e4d6
24 changed files with 1269 additions and 276 deletions

View File

@@ -245,6 +245,7 @@ pub struct AcpAgentInfo {
pub codex_auth_json: Option<String>,
pub codex_config_toml: Option<String>,
pub cline_secrets_json: Option<String>,
pub model_provider_id: Option<i32>,
}
/// Lightweight status info for a single agent, used by connect() pre-check.

View File

@@ -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<Stri
fn merge_json_values(base: &mut serde_json::Value, patch: &serde_json::Value) {
if let (Some(base_obj), Some(patch_obj)) = (base.as_object_mut(), patch.as_object()) {
for (key, patch_value) in patch_obj {
if patch_value.is_null() {
// null in patch means "remove this key"
base_obj.remove(key);
continue;
}
match base_obj.get_mut(key) {
Some(base_value) => 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<String, String>,
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<String, String>,
model_provider_id: Option<i32>,
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<String, String>,
model_provider_id: Option<i32>,
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<String>,
opencode_auth_json: Option<String>,
codex_auth_json: Option<String>,
codex_config_toml: Option<String>,
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::<serde_json::Value>(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::<serde_json::Value>(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<String>,
opencode_auth_json: Option<String>,
codex_auth_json: Option<String>,
codex_config_toml: Option<String>,
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,

View File

@@ -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)?;

View File

@@ -11,6 +11,7 @@ pub struct Model {
pub sort_order: i32,
pub installed_version: Option<String>,
pub env_json: Option<String>,
pub model_provider_id: Option<i32>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}

View File

@@ -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,
}

View File

@@ -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),
]
}
}

View File

@@ -22,6 +22,7 @@ pub struct AgentDefaultInput {
pub struct AgentSettingsUpdate {
pub enabled: bool,
pub env_json: Option<String>,
pub model_provider_id: Option<i32>,
}
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)")

View File

@@ -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,

View File

@@ -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<String>,
pub created_at: String,
@@ -33,6 +34,7 @@ impl From<crate::db::entities::model_provider::Model> 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(),

View File

@@ -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<String, String>,
pub model_provider_id: Option<i32>,
}
pub async fn acp_update_agent_env(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<AcpUpdateAgentEnvParams>,
) -> Result<Json<()>, 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<String>,
pub opencode_auth_json: Option<String>,
pub codex_auth_json: Option<String>,
pub codex_config_toml: Option<String>,
}
pub async fn acp_update_agent_config(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<AcpUpdateAgentConfigParams>,
) -> Result<Json<()>, 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<Arc<AppState>>,
Json(params): Json<AgentTypeParams>,

View File

@@ -164,6 +164,8 @@ pub fn build_router(state: Arc<AppState>, 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))