diff --git a/USAGE.md b/USAGE.md index c8e7b09692..4d7d6e0c44 100644 --- a/USAGE.md +++ b/USAGE.md @@ -308,6 +308,147 @@ The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **O **Model-name prefix routing:** If a model name starts with `openai/`, `gpt-`, `qwen/`, or `qwen-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. +### Configured compatible providers + +If you use several compatible providers, define named provider profiles in `settings.json` instead of changing `OPENAI_BASE_URL` before every run. Each profile gets its own protocol, base URL, credential env var, and model allow-list. + +Run `claw login` or `/login` to create these profiles interactively for Z.AI, MiniMax, OpenAI, Kimi, Moonshot, or a custom compatible endpoint. The wizard can store a pasted local token in `~/.claw/settings.json`, but `apiKeyEnv` is preferred when you can keep the secret in your shell environment: + +```json +{ + "model": "zai/glm-5.1", + "modelProviders": { + "zai": { + "type": "openai-compatible", + "baseUrl": "https://api.z.ai/api/paas/v4", + "apiKeyEnv": "Z_AI_API_KEY", + "models": [ + "glm-5.1", + "glm-5", + "glm-5-turbo", + "glm-4.7", + "glm-4.7-flashx", + "glm-4.7-flash", + "glm-4.6", + "glm-4.5", + "glm-4.5-x", + "glm-4.5-air", + "glm-4.5-airx", + "glm-4.5-flash", + "glm-4-32b-0414-128k" + ], + "defaultModel": "glm-5.1" + }, + "zai-coding-plan": { + "type": "openai-compatible", + "baseUrl": "https://api.z.ai/api/coding/paas/v4", + "apiKeyEnv": "Z_AI_API_KEY", + "models": [ + "glm-4.5-air", + "glm-4.7", + "glm-5-turbo", + "glm-5.1", + "glm-5v-turbo" + ], + "defaultModel": "glm-5.1" + }, + "minimax-coding-plan": { + "type": "anthropic-compatible", + "baseUrl": "https://api.minimax.io/anthropic/v1", + "apiKeyEnv": "MINIMAX_API_KEY", + "models": [ + "MiniMax-M2", + "MiniMax-M2.1", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed" + ], + "defaultModel": "MiniMax-M2.7-highspeed" + }, + "openai": { + "type": "openai-compatible", + "baseUrl": "https://api.openai.com/v1", + "apiKeyEnv": "OPENAI_API_KEY", + "models": [ + "gpt-5-codex", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-fast", + "gpt-5.4-mini", + "gpt-5.4-mini-fast", + "gpt-5.5", + "gpt-5.5-fast", + "gpt-5.5-pro" + ], + "defaultModel": "gpt-5.5" + }, + "kimi-for-coding": { + "type": "anthropic-compatible", + "baseUrl": "https://api.kimi.com/coding/v1", + "apiKeyEnv": "KIMI_API_KEY", + "models": [ + "k2p5", + "k2p6", + "kimi-k2-thinking" + ], + "defaultModel": "k2p6" + }, + "moonshot": { + "type": "openai-compatible", + "baseUrl": "https://api.moonshot.ai/v1", + "apiKeyEnv": "MOONSHOT_API_KEY", + "models": [ + "kimi-k2.6", + "kimi-k2.5", + "kimi-k2-0905-preview", + "kimi-k2-0711-preview", + "kimi-k2-turbo-preview", + "kimi-k2-thinking", + "kimi-k2-thinking-turbo", + "moonshot-v1-8k", + "moonshot-v1-32k", + "moonshot-v1-128k", + "moonshot-v1-8k-vision-preview", + "moonshot-v1-32k-vision-preview", + "moonshot-v1-128k-vision-preview" + ], + "defaultModel": "kimi-k2.6" + } + } +} +``` + +Use `/model provider/model` in the REPL to switch without restarting: + +```text +/model zai/glm-5.1 +/model zai-coding-plan/glm-5.1 +/model minimax-coding-plan/MiniMax-M2.7-highspeed +/model openai/gpt-5.5 +/model kimi-for-coding/k2p6 +/model moonshot/kimi-k2.6 +``` + +You can also use the provider name alone when it has `defaultModel` configured: + +```text +/model zai +/model zai-coding-plan +/model minimax-coding-plan +/model openai +/model kimi-for-coding +/model moonshot +``` + +Prefer `apiKeyEnv` so secrets stay out of source-controlled project settings. `apiKey` is supported for local-only files when an environment variable is not practical. + ### Tested models and aliases These are the models registered in the built-in alias table with known token limits: diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 6e68fd2e2c..c7863ff3cf 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -46,6 +46,24 @@ impl ProviderClient { } } + #[must_use] + pub fn from_openai_compatible_profile( + api_key: impl Into, + base_url: impl Into, + ) -> Self { + Self::OpenAi( + OpenAiCompatClient::new(api_key, OpenAiCompatConfig::openai()).with_base_url(base_url), + ) + } + + #[must_use] + pub fn from_anthropic_compatible_profile( + api_key: impl Into, + base_url: impl Into, + ) -> Self { + Self::Anthropic(AnthropicClient::new(api_key).with_base_url(base_url)) + } + #[must_use] pub const fn provider_kind(&self) -> ProviderKind { match self { diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1566189282..46d871d54f 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -64,9 +64,21 @@ pub struct RuntimeFeatureConfig { permission_rules: RuntimePermissionRuleConfig, sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, + model_providers: BTreeMap, trusted_roots: Vec, } +/// User-configured model provider profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelProviderConfig { + provider_type: String, + base_url: String, + api_key_env: Option, + api_key: Option, + models: Vec, + default_model: Option, +} + /// Ordered chain of fallback model identifiers used when the primary /// provider returns a retryable failure (429/500/503/etc.). The chain is /// strict: each entry is tried in order until one succeeds. @@ -314,6 +326,7 @@ impl ConfigLoader { permission_rules: parse_optional_permission_rules(&merged_value)?, sandbox: parse_optional_sandbox_config(&merged_value)?, provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, + model_providers: parse_optional_model_providers(&merged_value)?, trusted_roots: parse_optional_trusted_roots(&merged_value)?, }; @@ -410,6 +423,11 @@ impl RuntimeConfig { &self.feature_config.provider_fallbacks } + #[must_use] + pub fn model_providers(&self) -> &BTreeMap { + &self.feature_config.model_providers + } + #[must_use] pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots @@ -479,12 +497,68 @@ impl RuntimeFeatureConfig { &self.provider_fallbacks } + #[must_use] + pub fn model_providers(&self) -> &BTreeMap { + &self.model_providers + } + #[must_use] pub fn trusted_roots(&self) -> &[String] { &self.trusted_roots } } +impl ModelProviderConfig { + #[must_use] + pub fn new( + provider_type: String, + base_url: String, + api_key_env: Option, + api_key: Option, + models: Vec, + default_model: Option, + ) -> Self { + Self { + provider_type, + base_url, + api_key_env, + api_key, + models, + default_model, + } + } + + #[must_use] + pub fn provider_type(&self) -> &str { + &self.provider_type + } + + #[must_use] + pub fn base_url(&self) -> &str { + &self.base_url + } + + #[must_use] + pub fn api_key_env(&self) -> Option<&str> { + self.api_key_env.as_deref() + } + + #[must_use] + pub fn api_key(&self) -> Option<&str> { + self.api_key.as_deref() + } + + #[must_use] + pub fn models(&self) -> &[String] { + &self.models + } + + #[must_use] + pub fn default_model(&self) -> Option<&str> { + self.default_model.as_deref() + } +} + impl ProviderFallbackConfig { #[must_use] pub fn new(primary: Option, fallbacks: Vec) -> Self { @@ -904,6 +978,64 @@ fn parse_optional_provider_fallbacks( Ok(ProviderFallbackConfig { primary, fallbacks }) } +fn parse_optional_model_providers( + root: &JsonValue, +) -> Result, ConfigError> { + let Some(object) = root.as_object() else { + return Ok(BTreeMap::new()); + }; + let Some(value) = object.get("modelProviders") else { + return Ok(BTreeMap::new()); + }; + let providers = expect_object(value, "merged settings.modelProviders")?; + let mut parsed = BTreeMap::new(); + for (name, value) in providers { + let context = format!("merged settings.modelProviders.{name}"); + let provider = expect_object(value, &context)?; + let provider_type = optional_string(provider, "type", &context)? + .unwrap_or("openai-compatible") + .to_string(); + if !matches!( + provider_type.as_str(), + "openai-compatible" | "openai" | "anthropic-compatible" | "anthropic" + ) { + return Err(ConfigError::Parse(format!( + "{context}: unsupported provider type {provider_type}" + ))); + } + let base_url = expect_string(provider, "baseUrl", &context)?.to_string(); + let api_key_env = optional_string(provider, "apiKeyEnv", &context)?.map(str::to_string); + let api_key = optional_string(provider, "apiKey", &context)?.map(str::to_string); + let models = optional_string_array(provider, "models", &context)?.unwrap_or_default(); + let default_model = + optional_string(provider, "defaultModel", &context)?.map(str::to_string); + if models.is_empty() && default_model.is_none() { + return Err(ConfigError::Parse(format!( + "{context}: expected at least one model in models or defaultModel" + ))); + } + if let Some(default_model) = &default_model { + if !models.is_empty() && !models.iter().any(|model| model == default_model) { + return Err(ConfigError::Parse(format!( + "{context}: defaultModel must be listed in models" + ))); + } + } + parsed.insert( + name.clone(), + ModelProviderConfig::new( + provider_type, + base_url, + api_key_env, + api_key, + models, + default_model, + ), + ); + } + Ok(parsed) +} + fn parse_optional_trusted_roots(root: &JsonValue) -> Result, ConfigError> { let Some(object) = root.as_object() else { return Ok(Vec::new()); @@ -1812,6 +1944,51 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn parses_model_provider_profiles_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#"{ + "model": "zai/glm-5.1", + "modelProviders": { + "zai": { + "type": "openai-compatible", + "baseUrl": "https://api.z.ai/api/paas/v4", + "apiKeyEnv": "Z_AI_API_KEY", + "models": ["glm-5.1", "glm-4.6"], + "defaultModel": "glm-5.1" + } + } + }"#, + ) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + let provider = loaded + .model_providers() + .get("zai") + .expect("zai provider should parse"); + + assert_eq!(loaded.model(), Some("zai/glm-5.1")); + assert_eq!(provider.provider_type(), "openai-compatible"); + assert_eq!(provider.base_url(), "https://api.z.ai/api/paas/v4"); + assert_eq!(provider.api_key_env(), Some("Z_AI_API_KEY")); + assert_eq!(provider.default_model(), Some("glm-5.1")); + assert_eq!( + provider.models(), + &["glm-5.1".to_string(), "glm-4.6".to_string()] + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn empty_settings_file_loads_defaults() { // given diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 7a9c1c4adc..3a50bf5516 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -193,6 +193,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "providerFallbacks", expected: FieldType::Object, }, + FieldSpec { + name: "modelProviders", + expected: FieldType::Object, + }, FieldSpec { name: "trustedRoots", expected: FieldType::StringArray, @@ -310,6 +314,33 @@ const OAUTH_FIELDS: &[FieldSpec] = &[ }, ]; +const MODEL_PROVIDER_FIELDS: &[FieldSpec] = &[ + FieldSpec { + name: "type", + expected: FieldType::String, + }, + FieldSpec { + name: "baseUrl", + expected: FieldType::String, + }, + FieldSpec { + name: "apiKeyEnv", + expected: FieldType::String, + }, + FieldSpec { + name: "apiKey", + expected: FieldType::String, + }, + FieldSpec { + name: "models", + expected: FieldType::StringArray, + }, + FieldSpec { + name: "defaultModel", + expected: FieldType::String, + }, +]; + const DEPRECATED_FIELDS: &[DeprecatedField] = &[ DeprecatedField { name: "permissionMode", @@ -501,6 +532,19 @@ pub fn validate_config_file( &path_display, )); } + if let Some(model_providers) = object.get("modelProviders").and_then(JsonValue::as_object) { + for (name, provider) in model_providers { + if let Some(provider) = provider.as_object() { + result.merge(validate_object_keys( + provider, + MODEL_PROVIDER_FIELDS, + &format!("modelProviders.{name}"), + source, + &path_display, + )); + } + } + } result } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index dbdbd07b64..7b326da74f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -96,6 +96,14 @@ struct ModelProvenance { source: ModelSource, } +#[derive(Debug, Clone)] +struct ConfiguredModelProvider { + wire_model: String, + provider_type: String, + api_key: String, + base_url: String, +} + impl ModelProvenance { fn default_fallback() -> Self { Self { @@ -364,6 +372,11 @@ fn run() -> Result<(), Box> { output_format, } => print_system_prompt(cwd, date, output_format)?, CliAction::Version { output_format } => print_version(output_format)?, + CliAction::Login => { + if let Some(model) = run_login_wizard()? { + println!("Configured provider. Use `claw --model {model}` or `/model {model}`."); + } + } CliAction::ResumeSession { session_path, commands, @@ -504,6 +517,7 @@ enum CliAction { Version { output_format: CliOutputFormat, }, + Login, ResumeSession { session_path: PathBuf, commands: Vec, @@ -948,7 +962,8 @@ fn parse_args(args: &[String]) -> Result { } "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), "acp" => parse_acp_args(&rest[1..], output_format), - "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), + "login" => Ok(CliAction::Login), + "logout" => Err(removed_auth_surface_error(rest[0].as_str())), "init" => Ok(CliAction::Init { output_format }), "export" => parse_export_args(&rest[1..], output_format), "prompt" => { @@ -1443,9 +1458,432 @@ fn resolve_model_alias_with_config(model: &str) -> String { if let Some(resolved) = config_alias_for_current_dir(trimmed) { return resolve_model_alias(&resolved).to_string(); } + if let Some(resolved) = config_provider_default_model_for_current_dir(trimmed) { + return resolved; + } resolve_model_alias(trimmed).to_string() } +fn config_provider_default_model_for_current_dir(provider_name: &str) -> Option { + if provider_name.is_empty() || provider_name.contains('/') { + return None; + } + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + let config = loader.load().ok()?; + let provider = config.model_providers().get(provider_name)?; + let model = provider + .default_model() + .or_else(|| provider.models().first().map(String::as_str))?; + Some(format!("{provider_name}/{model}")) +} + +fn configured_provider_names_for_current_dir() -> Vec { + let Ok(cwd) = env::current_dir() else { + return Vec::new(); + }; + let loader = ConfigLoader::default_for(&cwd); + loader + .load() + .map(|config| config.model_providers().keys().cloned().collect()) + .unwrap_or_default() +} + +fn configured_provider_for_model( + model: &str, +) -> Result, Box> { + let Some((provider_name, requested_model)) = model.split_once('/') else { + return Ok(None); + }; + let cwd = env::current_dir()?; + let config = ConfigLoader::default_for(&cwd).load()?; + let Some(provider) = config.model_providers().get(provider_name) else { + return Ok(None); + }; + if !matches!( + provider.provider_type(), + "openai-compatible" | "openai" | "anthropic-compatible" | "anthropic" + ) { + return Err(format!( + "model provider '{provider_name}' uses unsupported type '{}'", + provider.provider_type() + ) + .into()); + } + let wire_model = if requested_model.is_empty() { + provider.default_model().ok_or_else(|| { + format!("model provider '{provider_name}' does not define defaultModel") + })? + } else { + requested_model + }; + if !provider.models().is_empty() && !provider.models().iter().any(|model| model == wire_model) { + return Err(format!( + "model '{wire_model}' is not listed in modelProviders.{provider_name}.models" + ) + .into()); + } + let api_key = if let Some(api_key) = provider.api_key().filter(|value| !value.is_empty()) { + api_key.to_string() + } else if let Some(env_name) = provider.api_key_env() { + env::var(env_name) + .map_err(|_| format!("model provider '{provider_name}' requires env var {env_name}"))? + } else { + return Err( + format!("model provider '{provider_name}' requires apiKeyEnv or apiKey").into(), + ); + }; + Ok(Some(ConfiguredModelProvider { + wire_model: wire_model.to_string(), + provider_type: provider.provider_type().to_string(), + api_key, + base_url: provider.base_url().to_string(), + })) +} + +struct LoginProviderTemplate { + id: &'static str, + label: &'static str, + provider_type: &'static str, + base_url: &'static str, + api_key_env: &'static str, + models: &'static [&'static str], + default_model: &'static str, +} + +const LOGIN_PROVIDER_TEMPLATES: &[LoginProviderTemplate] = &[ + LoginProviderTemplate { + id: "zai", + label: "Z.AI", + provider_type: "openai-compatible", + base_url: "https://api.z.ai/api/paas/v4", + api_key_env: "Z_AI_API_KEY", + models: &[ + "glm-5.1", + "glm-5", + "glm-5-turbo", + "glm-4.7", + "glm-4.7-flashx", + "glm-4.7-flash", + "glm-4.6", + "glm-4.5", + "glm-4.5-x", + "glm-4.5-air", + "glm-4.5-airx", + "glm-4.5-flash", + "glm-4-32b-0414-128k", + ], + default_model: "glm-5.1", + }, + LoginProviderTemplate { + id: "zai-coding-plan", + label: "Z.AI Coding Plan", + provider_type: "openai-compatible", + base_url: "https://api.z.ai/api/coding/paas/v4", + api_key_env: "Z_AI_API_KEY", + models: &[ + "glm-4.5-air", + "glm-4.7", + "glm-5-turbo", + "glm-5.1", + "glm-5v-turbo", + ], + default_model: "glm-5.1", + }, + LoginProviderTemplate { + id: "minimax-coding-plan", + label: "MiniMax Coding Plan", + provider_type: "anthropic-compatible", + base_url: "https://api.minimax.io/anthropic/v1", + api_key_env: "MINIMAX_API_KEY", + models: &[ + "MiniMax-M2", + "MiniMax-M2.1", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + ], + default_model: "MiniMax-M2.7-highspeed", + }, + LoginProviderTemplate { + id: "openai", + label: "OpenAI", + provider_type: "openai-compatible", + base_url: "https://api.openai.com/v1", + api_key_env: "OPENAI_API_KEY", + models: &[ + "gpt-5-codex", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-fast", + "gpt-5.4-mini", + "gpt-5.4-mini-fast", + "gpt-5.5", + "gpt-5.5-fast", + "gpt-5.5-pro", + ], + default_model: "gpt-5.5", + }, + LoginProviderTemplate { + id: "kimi-for-coding", + label: "Kimi For Coding", + provider_type: "anthropic-compatible", + base_url: "https://api.kimi.com/coding/v1", + api_key_env: "KIMI_API_KEY", + models: &["k2p5", "k2p6", "kimi-k2-thinking"], + default_model: "k2p6", + }, + LoginProviderTemplate { + id: "moonshot", + label: "Moonshot / Kimi", + provider_type: "openai-compatible", + base_url: "https://api.moonshot.ai/v1", + api_key_env: "MOONSHOT_API_KEY", + models: &[ + "kimi-k2.6", + "kimi-k2.5", + "kimi-k2-0905-preview", + "kimi-k2-0711-preview", + "kimi-k2-turbo-preview", + "kimi-k2-thinking", + "kimi-k2-thinking-turbo", + "moonshot-v1-8k", + "moonshot-v1-32k", + "moonshot-v1-128k", + "moonshot-v1-8k-vision-preview", + "moonshot-v1-32k-vision-preview", + "moonshot-v1-128k-vision-preview", + ], + default_model: "kimi-k2.6", + }, +]; + +fn run_login_wizard() -> Result, Box> { + if !io::stdin().is_terminal() { + return Err("login requires an interactive terminal".into()); + } + + println!(); + println!("Claw provider login"); + println!("Configure a model provider profile."); + println!("Press Enter to accept defaults."); + println!(); + + for (index, provider) in LOGIN_PROVIDER_TEMPLATES.iter().enumerate() { + println!(" [{}] {}", index + 1, provider.label); + } + println!( + " [{}] Custom compatible endpoint", + LOGIN_PROVIDER_TEMPLATES.len() + 1 + ); + + let choice = read_prompt("Select provider [1]: ")?; + let choice = if choice.trim().is_empty() { + 1 + } else { + choice.trim().parse::()? + }; + + let ( + provider_id, + label, + provider_type, + default_base_url, + default_api_key_env, + default_models, + default_model, + ) = if choice == LOGIN_PROVIDER_TEMPLATES.len() + 1 { + let id = read_required_prompt("Provider id (e.g. openrouter): ")?; + let provider_type = read_prompt( + "Provider type [openai-compatible, anthropic-compatible] [openai-compatible]: ", + )?; + let provider_type = if provider_type.trim().is_empty() { + "openai-compatible".to_string() + } else { + provider_type.trim().to_string() + }; + if !matches!( + provider_type.as_str(), + "openai-compatible" | "openai" | "anthropic-compatible" | "anthropic" + ) { + return Err(format!("unsupported provider type: {provider_type}").into()); + } + let base_url = read_required_prompt("Base URL: ")?; + let api_key_env = read_prompt("API key env var [OPENAI_API_KEY]: ")?; + let model = read_required_prompt("Default model: ")?; + ( + id, + "Custom".to_string(), + provider_type, + base_url, + if api_key_env.trim().is_empty() { + "OPENAI_API_KEY".to_string() + } else { + api_key_env.trim().to_string() + }, + vec![model.clone()], + model, + ) + } else { + let template = LOGIN_PROVIDER_TEMPLATES + .get(choice.saturating_sub(1)) + .ok_or_else(|| format!("invalid provider choice: {choice}"))?; + ( + template.id.to_string(), + template.label.to_string(), + template.provider_type.to_string(), + template.base_url.to_string(), + template.api_key_env.to_string(), + template + .models + .iter() + .map(|model| (*model).to_string()) + .collect::>(), + template.default_model.to_string(), + ) + }; + + println!(); + println!("{label}"); + println!("Provider type: {provider_type}"); + let base_url = read_prompt(&format!("Base URL [{default_base_url}]: "))?; + let base_url = if base_url.trim().is_empty() { + default_base_url + } else { + base_url.trim().to_string() + }; + let api_key_env = read_prompt(&format!("API key env var [{default_api_key_env}]: "))?; + let api_key_env = if api_key_env.trim().is_empty() { + default_api_key_env + } else { + api_key_env.trim().to_string() + }; + + let token = read_prompt("Paste API key / bearer token now, or press Enter to use env var: ")?; + let api_key = (!token.trim().is_empty()).then(|| token.trim().to_string()); + + println!("Available models: {}", default_models.join(", ")); + let model = read_prompt(&format!("Default model [{default_model}]: "))?; + let model = if model.trim().is_empty() { + default_model + } else { + model.trim().to_string() + }; + let mut models = default_models; + if !models.iter().any(|known| known == &model) { + models.push(model.clone()); + } + + save_model_provider_profile( + &provider_id, + &provider_type, + &base_url, + &api_key_env, + api_key.as_deref(), + &models, + &model, + )?; + Ok(Some(format!("{provider_id}/{model}"))) +} + +fn read_prompt(prompt: &str) -> Result> { + print!("{prompt}"); + io::stdout().flush()?; + let mut buffer = String::new(); + io::stdin().read_line(&mut buffer)?; + Ok(buffer) +} + +fn read_required_prompt(prompt: &str) -> Result> { + let value = read_prompt(prompt)?; + let value = value.trim(); + if value.is_empty() { + return Err(format!("{prompt} is required").into()); + } + Ok(value.to_string()) +} + +fn save_model_provider_profile( + provider_id: &str, + provider_type: &str, + base_url: &str, + api_key_env: &str, + api_key: Option<&str>, + models: &[String], + default_model: &str, +) -> Result<(), Box> { + let cwd = env::current_dir()?; + let config_home = ConfigLoader::default_for(&cwd).config_home().to_path_buf(); + fs::create_dir_all(&config_home)?; + let settings_path = config_home.join("settings.json"); + let mut root = match fs::read_to_string(&settings_path) { + Ok(contents) if !contents.trim().is_empty() => serde_json::from_str::(&contents)?, + Ok(_) => Value::Object(Map::new()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Value::Object(Map::new()), + Err(error) => return Err(error.into()), + }; + if !root.is_object() { + root = Value::Object(Map::new()); + } + let root_object = root.as_object_mut().expect("root object initialized"); + let providers = root_object + .entry("modelProviders") + .or_insert_with(|| Value::Object(Map::new())); + if !providers.is_object() { + *providers = Value::Object(Map::new()); + } + let provider_map = providers + .as_object_mut() + .expect("modelProviders object initialized"); + + let mut provider = Map::new(); + provider.insert("type".to_string(), Value::String(provider_type.to_string())); + provider.insert("baseUrl".to_string(), Value::String(base_url.to_string())); + provider.insert( + "apiKeyEnv".to_string(), + Value::String(api_key_env.to_string()), + ); + if let Some(api_key) = api_key { + provider.insert("apiKey".to_string(), Value::String(api_key.to_string())); + } + provider.insert( + "models".to_string(), + Value::Array( + models + .iter() + .map(|model| Value::String(model.clone())) + .collect(), + ), + ); + provider.insert( + "defaultModel".to_string(), + Value::String(default_model.to_string()), + ); + provider_map.insert(provider_id.to_string(), Value::Object(provider)); + root_object.insert( + "model".to_string(), + Value::String(format!("{provider_id}/{default_model}")), + ); + + let serialized = format!("{}\n", serde_json::to_string_pretty(&root)?); + fs::write(&settings_path, serialized)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&settings_path)?.permissions(); + permissions.set_mode(0o600); + fs::set_permissions(&settings_path, permissions)?; + } + Ok(()) +} + /// Validate model syntax at parse time. /// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern. /// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. @@ -1459,6 +1897,12 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { "opus" | "sonnet" | "haiku" => return Ok(()), _ => {} } + if configured_provider_names_for_current_dir() + .iter() + .any(|name| name == trimmed) + { + return Ok(()); + } // Check for spaces (malformed) if trimmed.contains(' ') { return Err(format!( @@ -2117,7 +2561,7 @@ fn check_auth_health() -> DiagnosticCheck { token_set.scopes.join(",") } ), - "Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed" + "Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN for Anthropic, or run `claw login` to configure a compatible provider" .to_string(), ]) .with_data(Map::from_iter([ @@ -4713,8 +5157,13 @@ impl LiveCli { println!("{}", format_cost_report(usage)); false } - SlashCommand::Login - | SlashCommand::Logout + SlashCommand::Login => { + if let Some(model) = run_login_wizard()? { + self.set_model(Some(model))?; + } + false + } + SlashCommand::Logout | SlashCommand::Vim | SlashCommand::Upgrade | SlashCommand::Share @@ -7527,6 +7976,10 @@ fn build_runtime_with_plugin_state( plugin_registry, mcp_state, } = runtime_plugin_state; + let configured_provider = configured_provider_for_model(&model)?; + let request_model = configured_provider + .as_ref() + .map_or_else(|| model.clone(), |provider| provider.wire_model.clone()); plugin_registry.initialize()?; let policy = permission_policy(permission_mode, &feature_config, &tool_registry) .map_err(std::io::Error::other)?; @@ -7534,7 +7987,8 @@ fn build_runtime_with_plugin_state( session, AnthropicRuntimeClient::new( session_id, - model, + request_model, + configured_provider, enable_tools, emit_output, allowed_tools.clone(), @@ -7662,6 +8116,7 @@ impl AnthropicRuntimeClient { fn new( session_id: &str, model: String, + configured_provider: Option, enable_tools: bool, emit_output: bool, allowed_tools: Option, @@ -7688,26 +8143,47 @@ impl AnthropicRuntimeClient { // prompt cache is Anthropic-only so non-Anthropic variants // skip it. 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(provider) = configured_provider { + match provider.provider_type.as_str() { + "anthropic-compatible" | "anthropic" => { + ApiProviderClient::from_anthropic_compatible_profile( + provider.api_key, + provider.base_url, + ) + .with_prompt_cache(PromptCache::new(session_id)) + } + "openai-compatible" | "openai" => { + ApiProviderClient::from_openai_compatible_profile( + provider.api_key, + provider.base_url, + ) + } + other => { + return Err(format!("unsupported provider type: {other}").into()); + } + } + } 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 => { + // 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)? + } } }; Ok(Self { @@ -8134,7 +8610,6 @@ fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec io::Result<()> { out, " Diagnose local auth, config, workspace, and sandbox health" )?; + writeln!(out, " claw login")?; + writeln!( + out, + " Configure a compatible model provider in settings.json" + )?; writeln!(out, " claw acp [serve]")?; writeln!( out, @@ -9261,6 +9741,7 @@ mod tests { SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS, }; + use crate::configured_provider_for_model; use api::{ApiError, MessageResponse, OutputContentBlock, Usage}; use plugins::{ PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission, @@ -9919,6 +10400,80 @@ mod tests { assert_eq!(builtin, "claude-haiku-4-5-20251213"); } + #[test] + fn configured_model_provider_default_resolves_to_provider_model_ref() { + // given + 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.join(".claw")).expect("project config dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + cwd.join(".claw").join("settings.json"), + r#"{"modelProviders":{"zai":{"type":"openai-compatible","baseUrl":"https://api.z.ai/api/paas/v4","apiKeyEnv":"Z_AI_API_KEY","models":["glm-5.1","glm-4.6"],"defaultModel":"glm-5.1"}}}"#, + ) + .expect("project config should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + + // when + let resolved = with_current_dir(&cwd, || resolve_model_alias_with_config("zai")); + + 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"); + + // then + assert_eq!(resolved, "zai/glm-5.1"); + } + + #[test] + fn configured_model_provider_resolves_runtime_connection_details() { + // given + 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.join(".claw")).expect("project config dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + cwd.join(".claw").join("settings.json"), + r#"{"modelProviders":{"minimax":{"baseUrl":"https://api.minimax.io/v1","apiKeyEnv":"MINIMAX_API_KEY","models":["MiniMax-M2.7-highspeed"]}}}"#, + ) + .expect("project config should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_key = std::env::var("MINIMAX_API_KEY").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::set_var("MINIMAX_API_KEY", "test-minimax-key"); + + // when + let provider = with_current_dir(&cwd, || { + configured_provider_for_model("minimax/MiniMax-M2.7-highspeed") + }) + .expect("provider lookup should succeed") + .expect("provider should exist"); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_key { + Some(value) => std::env::set_var("MINIMAX_API_KEY", value), + None => std::env::remove_var("MINIMAX_API_KEY"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + // then + assert_eq!(provider.wire_model, "MiniMax-M2.7-highspeed"); + assert_eq!(provider.api_key, "test-minimax-key"); + assert_eq!(provider.base_url, "https://api.minimax.io/v1"); + } + #[test] fn parses_version_flags_without_initializing_prompt_mode() { assert_eq!( @@ -10068,9 +10623,11 @@ mod tests { } #[test] - fn removed_login_and_logout_subcommands_error_helpfully() { - let login = parse_args(&["login".to_string()]).expect_err("login should be removed"); - assert!(login.contains("ANTHROPIC_API_KEY")); + fn login_subcommand_parses_and_logout_errors_helpfully() { + assert_eq!( + parse_args(&["login".to_string()]).expect("login should parse"), + CliAction::Login + ); let logout = parse_args(&["logout".to_string()]).expect_err("logout should be removed"); assert!(logout.contains("ANTHROPIC_AUTH_TOKEN")); assert_eq!( @@ -11790,7 +12347,7 @@ mod tests { assert!(help.contains("claw /skills")); assert!(help.contains("ultraworkers/claw-code")); assert!(help.contains("cargo install claw-code")); - assert!(!help.contains("claw login")); + assert!(help.contains("claw login")); assert!(!help.contains("claw logout")); }