diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 240559e8..83de10f8 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -47,6 +47,9 @@ impl ProviderClient { Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => { OpenAiCompatConfig::dashscope() } + Some(meta) if meta.auth_env == "CLAWCUSTOMOPENAI_API_KEY" => { + OpenAiCompatConfig::custom_openai() + } _ => OpenAiCompatConfig::openai(), }; Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?)) diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 2524e552..4ccb0583 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -270,6 +270,14 @@ pub fn metadata_for_model(model: &str) -> Option { default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL, }); } + if canonical.starts_with("custom/") { + return Some(ProviderMetadata { + provider: ProviderKind::OpenAi, + auth_env: "CLAWCUSTOMOPENAI_API_KEY", + base_url_env: "CLAWCUSTOMOPENAI_BASE_URL", + default_base_url: openai_compat::DEFAULT_CUSTOM_OPENAI_BASE_URL, + }); + } // Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare // qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.) // to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1. @@ -377,6 +385,11 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind { { return ProviderKind::OpenAi; } + // Explicit `custom/` prefix selects the Claw custom OpenAI-compat provider + // even when no other credentials are present. + if resolved_model.starts_with("custom/") { + return ProviderKind::OpenAi; + } if anthropic::has_auth_from_env_or_saved().unwrap_or(false) { return ProviderKind::Anthropic; } @@ -1138,6 +1151,21 @@ mod tests { assert_eq!(super::resolve_model_alias("KIMI"), "kimi-k2.5"); // case insensitive } + #[test] + fn custom_prefix_routes_to_custom_openai_env_vars() { + let meta = super::metadata_for_model("custom/openclaw_3750") + .expect("custom/ prefix must resolve to custom OpenAI metadata"); + assert_eq!(meta.provider, ProviderKind::OpenAi); + assert_eq!(meta.auth_env, "CLAWCUSTOMOPENAI_API_KEY"); + assert_eq!(meta.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL"); + + assert_eq!( + detect_provider_kind("custom/openclaw_3750"), + ProviderKind::OpenAi, + "custom/ prefix must select OpenAi provider kind" + ); + } + #[test] fn provider_diagnostics_explain_openai_compatible_capabilities() { let diagnostics = super::provider_diagnostics_for_model("openai/deepseek-v4-pro"); diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 8fb39699..4fa01a5d 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -21,6 +21,11 @@ use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFu pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1"; pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1"; +/// Default base URL for Claw's custom OpenAI-compatible provider. +/// Intentionally left empty: a custom endpoint must set +/// `CLAWCUSTOMOPENAI_BASE_URL`; otherwise requests will fail at URL build +/// time rather than leaking credentials to the real OpenAI endpoint. +pub const DEFAULT_CUSTOM_OPENAI_BASE_URL: &str = ""; const REQUEST_ID_HEADER: &str = "request-id"; const ALT_REQUEST_ID_HEADER: &str = "x-request-id"; const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1); @@ -43,6 +48,7 @@ pub struct OpenAiCompatConfig { const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"]; const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"]; const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"]; +const CUSTOM_OPENAI_ENV_VARS: &[&str] = &["CLAWCUSTOMOPENAI_API_KEY"]; // Provider-specific request body size limits in bytes const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB @@ -95,12 +101,27 @@ impl OpenAiCompatConfig { } } + /// Claw-specific custom OpenAI-compatible endpoint. + /// Reads `CLAWCUSTOMOPENAI_API_KEY` / `CLAWCUSTOMOPENAI_BASE_URL` so it + /// can coexist with real OpenAI/NeuralWatt `OPENAI_*` environment vars. + #[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: DEFAULT_CUSTOM_OPENAI_BASE_URL, + max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES, + } + } + #[must_use] pub fn credential_env_vars(self) -> &'static [&'static str] { match self.provider_name { "xAI" => XAI_ENV_VARS, "OpenAI" => OPENAI_ENV_VARS, "DashScope" => DASHSCOPE_ENV_VARS, + "Custom OpenAI" => CUSTOM_OPENAI_ENV_VARS, _ => &[], } } @@ -1066,7 +1087,7 @@ fn wire_model_for_base_url<'a>( if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") { return Cow::Borrowed(&model[pos + 1..]); } - if lowered_prefix == "local" { + if matches!(lowered_prefix.as_str(), "local" | "custom") { return Cow::Borrowed(&model[pos + 1..]); } @@ -2278,6 +2299,29 @@ mod tests { assert_eq!(parse_tool_arguments("not-json"), json!({"raw": "not-json"})); } + #[test] + fn custom_routing_prefix_strips_on_wire() { + let payload = build_chat_completion_request( + &MessageRequest { + model: "custom/openclaw_3750".to_string(), + max_tokens: 64, + messages: vec![InputMessage::user_text("hello")], + ..Default::default() + }, + OpenAiCompatConfig::custom_openai(), + ); + + assert_eq!(payload["model"], json!("openclaw_3750")); + } + + #[test] + fn custom_openai_config_uses_separate_env_vars() { + let config = OpenAiCompatConfig::custom_openai(); + assert_eq!(config.provider_name, "Custom OpenAI"); + assert_eq!(config.api_key_env, "CLAWCUSTOMOPENAI_API_KEY"); + assert_eq!(config.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL"); + } + #[test] fn missing_xai_api_key_is_provider_specific() { let _lock = env_lock(); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index fa880f24..fd5ea6fa 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3133,26 +3133,21 @@ fn config_model_for_current_dir() -> Option { let model = config.model()?; // If the user configured a custom OpenAI-compatible endpoint with a bare - // model name (e.g. "openclaw"), route it through the local/ prefix so it - // passes validation, maps to the OpenAI-compat client, and gets stripped - // down to the bare model id on the wire (avoiding a proxy 404). - if config_has_custom_openai_base_url(&config) && !model.contains('/') { - return Some(format!("local/{model}")); + // model name (e.g. "openclaw"), route it through the custom/ prefix so it + // passes validation, maps to the custom OpenAI-compat client, and gets + // stripped down to the bare model id on the wire (avoiding a proxy 404). + if is_custom_openai_provider(&config) && !model.contains('/') { + return Some(format!("custom/{model}")); } Some(model.to_owned()) } -/// Check whether the loaded config has a custom OpenAI-compatible base URL. -fn config_has_custom_openai_base_url(config: &runtime::RuntimeConfig) -> bool { - let provider = config.provider(); - if provider.kind() != Some("openai") && provider.kind() != Some("openai-compat") { - return false; - } - let Some(base_url) = provider.base_url() else { - return false; - }; - !base_url.is_empty() && base_url != "https://api.openai.com/v1" +/// Check whether the loaded config uses the Claw custom OpenAI-compatible +/// provider, which has its own env var namespace so it can coexist with real +/// OpenAI/NeuralWatt credentials. +fn is_custom_openai_provider(config: &runtime::RuntimeConfig) -> bool { + config.provider().kind() == Some("custom-openai") } fn resolve_repl_model(cli_model: String) -> Result { @@ -13078,6 +13073,7 @@ fn inject_config_as_env_fallbacks() { "xai" => ("XAI_API_KEY", "XAI_BASE_URL"), "openai" => ("OPENAI_API_KEY", "OPENAI_BASE_URL"), "dashscope" => ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"), + "custom-openai" => ("CLAWCUSTOMOPENAI_API_KEY", "CLAWCUSTOMOPENAI_BASE_URL"), _ => return, // unknown provider kind — don't inject }; @@ -15117,7 +15113,42 @@ mod tests { } #[test] - fn config_model_normalizes_bare_name_to_local_prefix_for_custom_openai_endpoint() { + fn config_model_normalizes_bare_name_to_custom_prefix_for_custom_openai_provider() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(&cwd).expect("project dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "custom-openai", + "apiKey": "sk-test", + "baseUrl": "http://localhost:9999/v1" + }, + "model": "openclaw" + }"#, + ) + .expect("user settings should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + + let resolved = with_current_dir(&cwd, super::config_model_for_current_dir); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(resolved, Some("custom/openclaw".to_string())); + } + + #[test] + fn config_model_leaves_openai_kind_bare_model_unnormalized() { let _guard = env_lock(); let root = temp_dir(); let cwd = root.join("project"); @@ -15138,9 +15169,7 @@ mod tests { .expect("user settings should write"); let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); - let original_base_url = std::env::var("OPENAI_BASE_URL").ok(); std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::remove_var("OPENAI_BASE_URL"); let resolved = with_current_dir(&cwd, super::config_model_for_current_dir); @@ -15148,13 +15177,9 @@ mod tests { Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), None => std::env::remove_var("CLAW_CONFIG_HOME"), } - match original_base_url { - Some(value) => std::env::set_var("OPENAI_BASE_URL", value), - None => std::env::remove_var("OPENAI_BASE_URL"), - } std::fs::remove_dir_all(root).expect("temp config root should clean up"); - assert_eq!(resolved, Some("local/openclaw".to_string())); + assert_eq!(resolved, Some("openclaw".to_string())); } #[test] @@ -15208,6 +15233,57 @@ mod tests { assert_eq!(base_url, Some("http://localhost:9999/v1".to_string())); } + #[test] + fn inject_config_as_env_fallbacks_sets_custom_openai_provider_env_vars() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(&cwd).expect("project dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "custom-openai", + "apiKey": "sk-from-config", + "baseUrl": "http://localhost:9999/v1" + } + }"#, + ) + .expect("user settings should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_api_key = std::env::var("CLAWCUSTOMOPENAI_API_KEY").ok(); + let original_base_url = std::env::var("CLAWCUSTOMOPENAI_BASE_URL").ok(); + + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("CLAWCUSTOMOPENAI_API_KEY"); + std::env::remove_var("CLAWCUSTOMOPENAI_BASE_URL"); + + with_current_dir(&cwd, super::inject_config_as_env_fallbacks); + + let api_key = std::env::var("CLAWCUSTOMOPENAI_API_KEY").ok(); + let base_url = std::env::var("CLAWCUSTOMOPENAI_BASE_URL").ok(); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_api_key { + Some(value) => std::env::set_var("CLAWCUSTOMOPENAI_API_KEY", value), + None => std::env::remove_var("CLAWCUSTOMOPENAI_API_KEY"), + } + match original_base_url { + Some(value) => std::env::set_var("CLAWCUSTOMOPENAI_BASE_URL", value), + None => std::env::remove_var("CLAWCUSTOMOPENAI_BASE_URL"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(api_key, Some("sk-from-config".to_string())); + assert_eq!(base_url, Some("http://localhost:9999/v1".to_string())); + } + #[test] fn default_permission_mode_uses_project_config_when_env_is_unset() { let _guard = env_lock(); diff --git a/rust/crates/rusty-claude-cli/src/setup_wizard.rs b/rust/crates/rusty-claude-cli/src/setup_wizard.rs index c2f7b6ff..4ed2969b 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 base URL; the user must supply one. + ("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() {