diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 240559e809..fb68d25873 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -13,7 +13,130 @@ pub enum ProviderClient { OpenAi(OpenAiCompatClient), } +/// Provider selected explicitly by persisted/runtime configuration. +/// +/// This is intentionally separate from `ProviderKind`: DashScope speaks the +/// OpenAI-compatible wire protocol but remains a distinct configured provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderConfigKind { + Anthropic, + Xai, + OpenAi, + DashScope, +} + +/// Explicit provider configuration supplied by the runtime layer. +/// +/// This keeps the API crate independent of runtime configuration types. +/// Credentials are redacted from Debug output. +#[derive(Clone)] +pub struct ProviderConfig { + pub kind: ProviderConfigKind, + pub model: String, + pub api_key: Option, + pub base_url: Option, +} + +impl std::fmt::Debug for ProviderConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProviderConfig") + .field("kind", &self.kind) + .field("model", &self.model) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .field("base_url", &self.base_url) + .finish() + } +} + +fn read_env_non_empty(key: &str) -> Option { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) +} + impl ProviderClient { + /// Construct a client from an explicit provider configuration. + /// + /// The persisted provider kind is authoritative for provider selection, + /// while environment credentials and base URLs retain precedence over + /// persisted values. + pub fn from_config(config: &ProviderConfig) -> Result { + match config.kind { + ProviderConfigKind::Anthropic => { + let api_key = read_env_non_empty("ANTHROPIC_API_KEY") + .or_else(|| config.api_key.clone()) + .ok_or_else(|| { + ApiError::missing_credentials( + "Anthropic", + &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + ) + })?; + + let mut client = AnthropicClient::new(api_key); + let base_url = read_env_non_empty("ANTHROPIC_BASE_URL") + .or_else(|| config.base_url.clone()) + .unwrap_or_else(anthropic::read_base_url); + client = client.with_base_url(base_url); + Ok(Self::Anthropic(client)) + } + ProviderConfigKind::Xai => { + let compat = OpenAiCompatConfig::xai(); + let api_key = + read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone()); + + let mut client = match api_key { + Some(api_key) => OpenAiCompatClient::new(api_key, compat), + None => OpenAiCompatClient::from_env(compat)?, + }; + + let base_url = read_env_non_empty(compat.base_url_env) + .or_else(|| config.base_url.clone()) + .unwrap_or_else(|| compat.default_base_url.to_string()); + client = client.with_base_url(base_url); + + Ok(Self::Xai(client)) + } + ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => { + let compat = match config.kind { + ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(), + ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(), + _ => unreachable!("non-OpenAI provider reached compatibility path"), + }; + + if config.kind == ProviderConfigKind::OpenAi + && read_env_non_empty("OLLAMA_HOST").is_some() + && config.api_key.is_none() + && config.base_url.is_none() + { + return Ok(Self::OpenAi( + openai_compat::OpenAiCompatClient::from_ollama_env() + .expect("from_ollama_env always returns Some"), + )); + } + + let api_key = + read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone()); + let persisted_base_url = config.base_url.clone(); + let mut client = match api_key { + Some(api_key) => OpenAiCompatClient::new(api_key, compat), + None if config.kind == ProviderConfigKind::OpenAi + && persisted_base_url + .as_deref() + .is_some_and(openai_compat::is_local_openai_compatible_base_url) => + { + OpenAiCompatClient::new("local-dev-token", compat) + } + None => OpenAiCompatClient::from_env(compat)?, + }; + let base_url = read_env_non_empty(compat.base_url_env) + .or(persisted_base_url) + .unwrap_or_else(|| compat.default_base_url.to_string()); + client = client.with_base_url(base_url); + + Ok(Self::OpenAi(client)) + } + } + } pub fn from_model(model: &str) -> Result { Self::from_model_with_anthropic_auth(model, None) } @@ -34,7 +157,7 @@ impl ProviderClient { ProviderKind::OpenAi => { // OLLAMA_HOST takes priority: local Ollama needs no API key // and ignores DashScope/OpenAI env-based dispatch. - if std::env::var_os("OLLAMA_HOST").is_some() { + if read_env_non_empty("OLLAMA_HOST").is_some() { Ok(Self::OpenAi( openai_compat::OpenAiCompatClient::from_ollama_env() .expect("from_ollama_env always returns Some"), @@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String { mod tests { use std::sync::{Mutex, OnceLock}; - use super::ProviderClient; + use super::{ProviderClient, ProviderConfig, ProviderConfigKind}; use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind}; /// Serializes every test in this module that mutates process-wide @@ -213,6 +336,72 @@ mod tests { } } + #[test] + fn persisted_provider_kind_overrides_model_provider_detection() { + let _lock = env_lock(); + let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key")); + let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key")); + + let config = ProviderConfig { + kind: ProviderConfigKind::Xai, + model: "claude-sonnet-4-6".to_string(), + api_key: None, + base_url: None, + }; + + match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") { + ProviderClient::Xai(client) => { + assert!(client.base_url().contains("api.x.ai")); + } + other => panic!("Expected explicit xAI provider, got: {other:?}"), + } + } + + #[test] + fn persisted_dashscope_kind_overrides_qwen_model_routing() { + let _lock = env_lock(); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key")); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key")); + + let config = ProviderConfig { + kind: ProviderConfigKind::DashScope, + model: "gpt-5".to_string(), + api_key: None, + base_url: None, + }; + + match ProviderClient::from_config(&config) + .expect("explicit DashScope config should succeed") + { + ProviderClient::OpenAi(client) => { + assert!(client.base_url().contains("dashscope.aliyuncs.com")); + } + other => panic!("Expected explicit DashScope provider, got: {other:?}"), + } + } + + #[test] + fn persisted_openai_kind_does_not_become_dashscope_from_model() { + let _lock = env_lock(); + let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key")); + let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key")); + + let config = ProviderConfig { + kind: ProviderConfigKind::OpenAi, + model: "qwen-plus".to_string(), + api_key: None, + base_url: None, + }; + + match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") { + ProviderClient::OpenAi(client) => { + assert!(client.base_url().contains("api.openai.com")); + assert!(!client.base_url().contains("dashscope.aliyuncs.com")); + } + other => panic!("Expected explicit OpenAI provider, got: {other:?}"), + } + } + #[test] fn dashscope_model_uses_dashscope_config_not_openai() { // Regression: qwen-plus was being routed to OpenAiCompatConfig::openai() diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index e96e92f830..e81bff6c45 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -1,4 +1,5 @@ mod client; + mod error; mod http_client; mod prompt_cache; @@ -8,7 +9,8 @@ mod types; pub use client::{ oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token, - resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, + resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig, + ProviderConfigKind, }; pub use error::ApiError; pub use http_client::{ diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 8fb3969913..0b8861a6d7 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1027,7 +1027,7 @@ fn url_host(url: &str) -> &str { host_port.split(':').next().unwrap_or("") } -fn is_local_openai_compatible_base_url(url: &str) -> bool { +pub(crate) fn is_local_openai_compatible_base_url(url: &str) -> bool { let host = url_host(url.trim()); if host.eq_ignore_ascii_case("localhost") || host == "::1" { return true; diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 9505756357..75bd79a49d 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -204,6 +204,11 @@ pub struct RuntimeProviderConfig { } impl RuntimeProviderConfig { + #[must_use] + pub fn is_configured(&self) -> bool { + self.kind.is_some() + } + #[must_use] pub fn kind(&self) -> Option<&str> { self.kind.as_deref() @@ -2611,6 +2616,42 @@ mod tests { std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}")) } + #[test] + fn loads_persisted_provider_configuration_from_settings() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + + fs::write( + home.join("settings.json"), + r#"{ + "provider": { + "kind": "dashscope", + "apiKey": "persisted-test-key", + "baseUrl": "https://dashscope.example/v1", + "model": "qwen-plus" + } + }"#, + ) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("provider configuration should load"); + + let provider = loaded.provider(); + + assert_eq!(provider.kind(), Some("dashscope")); + assert_eq!(provider.api_key(), Some("persisted-test-key")); + assert_eq!(provider.base_url(), Some("https://dashscope.example/v1")); + assert_eq!(provider.model(), Some("qwen-plus")); + assert!(provider.is_configured()); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn rejects_non_object_settings_files() { let root = temp_dir(); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 665ce632cf..5b5cc73cf6 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -12416,6 +12416,7 @@ fn build_runtime_with_plugin_state( plugin_registry.initialize()?; let policy = permission_policy(permission_mode, &feature_config, &tool_registry) .map_err(std::io::Error::other)?; + let provider_config = provider_config_from_runtime(feature_config.provider())?; let mut runtime = ConversationRuntime::new_with_features( session, AnthropicRuntimeClient::new( @@ -12426,6 +12427,7 @@ fn build_runtime_with_plugin_state( allowed_tools.clone(), tool_registry.clone(), progress_reporter, + provider_config, )?, CliToolExecutor::new( allowed_tools.clone(), @@ -12527,10 +12529,40 @@ impl runtime::PermissionPrompter for CliPermissionPrompter { // NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct // now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, -// OpenAI, or DashScope at construction time based on -// `detect_provider_kind(&model)`. The struct name is kept to avoid -// churning `BuiltRuntime` and every Deref/DerefMut site that references -// it. See ROADMAP #29 for the provider-dispatch routing fix. +// OpenAI, or DashScope at construction time. Explicit persisted provider +// configuration takes precedence; model-based provider detection remains +// the fallback when no provider configuration is present. The struct name +// is kept to avoid churning `BuiltRuntime` and every Deref/DerefMut site +// that references it. + +fn provider_config_from_runtime( + provider: &runtime::RuntimeProviderConfig, +) -> Result, Box> { + let Some(kind) = provider.kind() else { + return Ok(None); + }; + + let kind = match kind.trim().to_ascii_lowercase().as_str() { + "anthropic" => api::ProviderConfigKind::Anthropic, + "xai" => api::ProviderConfigKind::Xai, + "openai" => api::ProviderConfigKind::OpenAi, + "dashscope" => api::ProviderConfigKind::DashScope, + other => { + return Err(format!( + "invalid provider configuration: unsupported provider kind `{other}`" + ) + .into()); + } + }; + + Ok(Some(api::ProviderConfig { + kind, + model: provider.model().unwrap_or_default().to_string(), + api_key: provider.api_key().map(str::to_string), + base_url: provider.base_url().map(str::to_string), + })) +} + struct AnthropicRuntimeClient { runtime: tokio::runtime::Runtime, client: ApiProviderClient, @@ -12553,47 +12585,54 @@ impl AnthropicRuntimeClient { allowed_tools: Option, tool_registry: GlobalToolRegistry, progress_reporter: Option, + provider_config: Option, ) -> Result> { - // Dispatch to the correct provider at construction time. - // `ApiProviderClient` (exposed by the api crate as - // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI - // variants, where xAI and OpenAI both use the OpenAI-compat - // wire format under the hood. We consult - // `detect_provider_kind(&resolved_model)` so model-name prefix - // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over - // env-var presence. - // - // For Anthropic we build the client directly instead of going - // through `ApiProviderClient::from_model_with_anthropic_auth` - // so we can explicitly apply `api::read_base_url()` — that - // reads `ANTHROPIC_BASE_URL` and is required for the local - // mock-server test harness - // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point - // claw at its fake Anthropic endpoint. We also attach a - // session-scoped prompt cache on the Anthropic path; the - // prompt cache is Anthropic-only so non-Anthropic variants - // skip it. + // Explicit persisted provider configuration takes precedence. + // When absent, preserve the existing model-based provider detection. let resolved_model = api::resolve_model_alias(&model); - let client = match detect_provider_kind(&resolved_model) { - ProviderKind::Anthropic => { - let auth = resolve_cli_auth_source()?; - let inner = AnthropicClient::from_auth(auth) - .with_base_url(api::read_base_url()) - .with_prompt_cache(PromptCache::new(session_id)); - ApiProviderClient::Anthropic(inner) - } - ProviderKind::Xai | ProviderKind::OpenAi => { - // The api crate's `ProviderClient::from_model_with_anthropic_auth` - // with `None` for the anthropic auth routes via - // `detect_provider_kind` and builds an - // `OpenAiCompatClient::from_env` with the matching - // `OpenAiCompatConfig` (openai / xai / dashscope). - // That reads the correct API-key env var and BASE_URL - // override internally, so this one call covers OpenAI, - // OpenRouter, xAI, DashScope, Ollama, and any other - // OpenAI-compat endpoint users configure via - // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. - ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + let client = if let Some(config) = provider_config { + match config.kind { + api::ProviderConfigKind::Anthropic => { + let auth = match resolve_cli_auth_source() { + Ok(auth) => auth, + Err(_) => { + config + .api_key + .clone() + .map(AuthSource::ApiKey) + .ok_or_else(|| { + api::ApiError::missing_credentials( + "Anthropic", + &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + ) + })? + } + }; + let mut inner = AnthropicClient::from_auth(auth); + let base_url = std::env::var("ANTHROPIC_BASE_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| config.base_url.clone()) + .unwrap_or_else(api::read_base_url); + inner = inner.with_base_url(base_url); + ApiProviderClient::Anthropic( + inner.with_prompt_cache(PromptCache::new(session_id)), + ) + } + _ => ApiProviderClient::from_config(&config)?, + } + } else { + match detect_provider_kind(&resolved_model) { + ProviderKind::Anthropic => { + let auth = resolve_cli_auth_source()?; + let inner = AnthropicClient::from_auth(auth) + .with_base_url(api::read_base_url()) + .with_prompt_cache(PromptCache::new(session_id)); + ApiProviderClient::Anthropic(inner) + } + ProviderKind::Xai | ProviderKind::OpenAi => { + ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + } } }; Ok(Self { @@ -19169,6 +19208,65 @@ UU conflicted.rs", let _ = fs::remove_dir_all(source_root); } + #[test] + fn persisted_provider_configuration_controls_runtime_provider_dispatch() { + let config_home = temp_dir(); + let workspace = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + + fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "dashscope", + "apiKey": "persisted-test-key", + "baseUrl": "https://dashscope.example/v1", + "model": "qwen-plus" + }, + "model": "qwen-plus" + }"#, + ) + .expect("write provider settings"); + + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("runtime plugin state should load"); + + let provider_config = super::provider_config_from_runtime(state.feature_config.provider()) + .expect("provider configuration should adapt") + .expect("persisted provider configuration should be present"); + + assert_eq!( + provider_config.kind, + api::ProviderConfigKind::DashScope, + "persisted provider kind must survive the runtime configuration boundary" + ); + + let runtime = super::AnthropicRuntimeClient::new( + "provider-resolution-test", + "qwen-plus".to_string(), + false, + false, + None, + GlobalToolRegistry::builtin(), + None, + Some(provider_config), + ) + .expect("runtime client should construct from persisted provider configuration"); + + match runtime.client { + api::ProviderClient::OpenAi(_) => {} + other => panic!( + "persisted DashScope configuration must dispatch to the OpenAI-compatible client, got {other:?}" + ), + } + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + } + #[test] #[allow(clippy::too_many_lines)] fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {