feat(setup): separate env namespace for custom OpenAI-compat provider
The Custom (OpenAI-compat) /setup option was saving kind: openai and injecting OPENAI_API_KEY / OPENAI_BASE_URL. That collides with users who have real OpenAI/NeuralWatt credentials in their environment. Introduce a dedicated custom-openai provider kind that uses its own environment variables: - CLAWCUSTOMOPENAI_API_KEY - CLAWCUSTOMOPENAI_BASE_URL A new custom/ routing prefix selects the OpenAI-compatible client with those env vars and is stripped on the wire, so the proxy receives the bare model id. /setup now saves kind: custom-openai and prompts for the new env vars. Bare model names saved by /setup are normalized to custom/<model>. Manual verification against http://100.96.49.42:4001/v1 succeeds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
3dde9be4ce
commit
b08c32d760
|
|
@ -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)?))
|
||||
|
|
|
|||
|
|
@ -270,6 +270,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
|||
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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -3133,26 +3133,21 @@ fn config_model_for_current_dir() -> Option<String> {
|
|||
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<String, String> {
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<dyn std::error::Error>> {
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue