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:
TheArchitectit 2026-06-16 13:30:15 -05:00
parent 3dde9be4ce
commit b08c32d760
5 changed files with 185 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {