diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 6e5e7889..f265f646 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -15,13 +15,15 @@ pub enum ProviderClient { /// 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. +/// This is intentionally separate from `ProviderKind`: Custom OpenAI and +/// DashScope speak the OpenAI-compatible wire protocol but remain distinct +/// configured providers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderConfigKind { Anthropic, Xai, OpenAi, + CustomOpenAi, DashScope, } @@ -96,9 +98,12 @@ impl ProviderClient { Ok(Self::Xai(client)) } - ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => { + ProviderConfigKind::OpenAi + | ProviderConfigKind::CustomOpenAi + | ProviderConfigKind::DashScope => { let compat = match config.kind { ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(), + ProviderConfigKind::CustomOpenAi => OpenAiCompatConfig::custom_openai(), ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(), _ => unreachable!("non-OpenAI provider reached compatibility path"), }; @@ -403,6 +408,56 @@ mod tests { } } + #[test] + fn persisted_custom_openai_uses_its_own_environment_namespace() { + let _lock = env_lock(); + let _custom_key = EnvVarGuard::set("CLAWCUSTOMOPENAI_API_KEY", Some("custom-key")); + let _custom_url = EnvVarGuard::set( + "CLAWCUSTOMOPENAI_BASE_URL", + Some("https://custom.example/v1"), + ); + let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", Some("openai-key")); + let _openai_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("https://openai.example/v1")); + let _ollama = EnvVarGuard::set("OLLAMA_HOST", Some("http://localhost:11434")); + + let config = ProviderConfig { + kind: ProviderConfigKind::CustomOpenAi, + model: "qwen-plus".to_string(), + api_key: None, + base_url: None, + }; + + match ProviderClient::from_config(&config).expect("custom OpenAI config should succeed") { + ProviderClient::OpenAi(client) => { + assert_eq!(client.base_url(), "https://custom.example/v1"); + } + other => panic!("Expected custom OpenAI provider, got: {other:?}"), + } + } + + #[test] + fn persisted_custom_openai_does_not_reuse_openai_credentials() { + let _lock = env_lock(); + let _custom_key = EnvVarGuard::set("CLAWCUSTOMOPENAI_API_KEY", None); + let _custom_url = EnvVarGuard::set( + "CLAWCUSTOMOPENAI_BASE_URL", + Some("https://custom.example/v1"), + ); + let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", Some("openai-key")); + let _ollama = EnvVarGuard::set("OLLAMA_HOST", Some("http://localhost:11434")); + + let config = ProviderConfig { + kind: ProviderConfigKind::CustomOpenAi, + model: "gpt-5".to_string(), + api_key: None, + base_url: None, + }; + + let error = ProviderClient::from_config(&config) + .expect_err("custom OpenAI must require its own credentials"); + assert!(error.to_string().contains("CLAWCUSTOMOPENAI_API_KEY")); + } + #[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/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index e1cca048..9c240c6f 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -42,6 +42,7 @@ pub struct OpenAiCompatConfig { const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"]; const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"]; +const CUSTOM_OPENAI_ENV_VARS: &[&str] = &["CLAWCUSTOMOPENAI_API_KEY"]; const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"]; // Provider-specific request body size limits in bytes @@ -80,6 +81,18 @@ impl OpenAiCompatConfig { } } + /// OpenAI-compatible endpoint with its own environment namespace and no default URL. + #[must_use] + pub const fn custom_openai() -> Self { + Self { + provider_name: "Custom OpenAI", + api_key_env: "CLAWCUSTOMOPENAI_API_KEY", + base_url_env: "CLAWCUSTOMOPENAI_BASE_URL", + default_base_url: "", + max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES, + } + } + /// Alibaba `DashScope` compatible-mode endpoint (Qwen family models). /// Uses the OpenAI-compatible REST shape at /compatible-mode/v1. /// Requested via Discord #clawcode-get-help: native Alibaba API for @@ -100,6 +113,7 @@ impl OpenAiCompatConfig { match self.provider_name { "xAI" => XAI_ENV_VARS, "OpenAI" => OPENAI_ENV_VARS, + "Custom OpenAI" => CUSTOM_OPENAI_ENV_VARS, "DashScope" => DASHSCOPE_ENV_VARS, _ => &[], } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index cda89fa1..be461422 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -12910,6 +12910,7 @@ fn provider_config_from_runtime( "anthropic" => api::ProviderConfigKind::Anthropic, "xai" => api::ProviderConfigKind::Xai, "openai" => api::ProviderConfigKind::OpenAi, + "custom-openai" => api::ProviderConfigKind::CustomOpenAi, "dashscope" => api::ProviderConfigKind::DashScope, other => { return Err(format!( @@ -12947,6 +12948,7 @@ fn explicit_provider_kind_for_model( Some("anthropic") => Some(api::ProviderConfigKind::Anthropic), Some("xai") => Some(api::ProviderConfigKind::Xai), Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi), + Some("custom-openai") => Some(api::ProviderConfigKind::CustomOpenAi), Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope), _ => { let lower = resolved_model.to_ascii_lowercase(); @@ -19664,6 +19666,22 @@ UU conflicted.rs", super::explicit_provider_kind_for_model("openai/gpt-5", &config.model), Some(api::ProviderConfigKind::OpenAi) ); + assert_eq!( + super::explicit_provider_kind_for_model("custom-openai/local-model", &config.model), + Some(api::ProviderConfigKind::CustomOpenAi) + ); + assert_eq!( + super::explicit_provider_kind_for_model("CUSTOM-OPENAI/local-model", &config.model), + Some(api::ProviderConfigKind::CustomOpenAi) + ); + assert_eq!( + super::explicit_provider_kind_for_model( + "custom-openai/local-model", + "custom-openai/local-model" + ), + None, + "matching custom model must keep the persisted provider kind" + ); assert_eq!( super::explicit_provider_kind_for_model("qwen-plus", &config.model), None, @@ -19704,6 +19722,44 @@ UU conflicted.rs", } } + #[test] + fn persisted_custom_openai_configuration_survives_runtime_boundary() { + 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": "custom-openai", + "apiKey": "custom-test-key", + "baseUrl": "https://custom.example/v1", + "model": "local-model" + }, + "model": "local-model" + }"#, + ) + .expect("write provider settings"); + + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let provider_config = super::provider_config_from_runtime(runtime_config.provider()) + .expect("custom provider configuration should adapt") + .expect("persisted provider configuration should be present"); + + assert_eq!(provider_config.kind, api::ProviderConfigKind::CustomOpenAi); + assert_eq!(provider_config.model, "local-model"); + assert_eq!(provider_config.api_key.as_deref(), Some("custom-test-key")); + assert_eq!( + provider_config.base_url.as_deref(), + Some("https://custom.example/v1") + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + } + #[test] fn persisted_provider_configuration_controls_runtime_provider_dispatch() { let config_home = temp_dir(); diff --git a/rust/crates/rusty-claude-cli/src/setup_wizard.rs b/rust/crates/rusty-claude-cli/src/setup_wizard.rs index c2f7b6ff..f61db1f4 100644 --- a/rust/crates/rusty-claude-cli/src/setup_wizard.rs +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -9,7 +9,7 @@ const PROVIDERS: &[(&str, &str, &str)] = &[ ("2", "xAI / Grok", "xai"), ("3", "OpenAI", "openai"), ("4", "DashScope (Qwen/Kimi)", "dashscope"), - ("5", "Custom (OpenAI-compat)", "openai"), + ("5", "Custom (OpenAI-compat)", "custom-openai"), ]; const PROVIDER_MODELS: &[(&str, &[&str])] = &[ @@ -27,6 +27,8 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[ "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1", ), + // Custom OpenAI has no default; user must supply. + ("custom-openai", ""), ]; const API_KEY_ENV_VARS: &[(&str, &str)] = &[ @@ -34,6 +36,7 @@ const API_KEY_ENV_VARS: &[(&str, &str)] = &[ ("xai", "XAI_API_KEY"), ("openai", "OPENAI_API_KEY"), ("dashscope", "DASHSCOPE_API_KEY"), + ("custom-openai", "CLAWCUSTOMOPENAI_API_KEY"), ]; pub fn run_setup_wizard() -> Result<(), Box> { @@ -177,6 +180,7 @@ fn prompt_base_url( "xai" => "XAI_BASE_URL", "openai" => "OPENAI_BASE_URL", "dashscope" => "DASHSCOPE_BASE_URL", + "custom-openai" => "CLAWCUSTOMOPENAI_BASE_URL", _ => "BASE_URL", }; let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty()); @@ -214,7 +218,11 @@ fn prompt_model( if !aliases.is_empty() { println!(" Common: {}", aliases.join(", ")); } - println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)"); + if kind == "custom-openai" { + println!(" Or enter any model name (e.g. custom/gpt-4.1 for custom routing)"); + } else { + println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)"); + } let input = read_line(&format!(" Model [{current_model}]: "))?; if input.trim().is_empty() {