#![allow(clippy::cast_possible_truncation)] use std::future::Future; use std::pin::Pin; use serde::Serialize; use crate::error::ApiError; use crate::types::{MessageRequest, MessageResponse}; pub mod anthropic; pub mod openai_compat; #[allow(dead_code)] pub type ProviderFuture<'a, T> = Pin> + Send + 'a>>; #[allow(dead_code)] pub trait Provider { type Stream; fn send_message<'a>( &'a self, request: &'a MessageRequest, ) -> ProviderFuture<'a, MessageResponse>; fn stream_message<'a>( &'a self, request: &'a MessageRequest, ) -> ProviderFuture<'a, Self::Stream>; } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderKind { Anthropic, OpenAi, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProviderMetadata { pub provider: ProviderKind, pub auth_env: &'static str, pub base_url_env: &'static str, pub default_base_url: &'static str, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ModelTokenLimit { pub max_output_tokens: u32, pub context_window_tokens: u32, } const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[ ( "opus", ProviderMetadata { provider: ProviderKind::Anthropic, auth_env: "ANTHROPIC_API_KEY", base_url_env: "ANTHROPIC_BASE_URL", default_base_url: anthropic::DEFAULT_BASE_URL, }, ), ( "sonnet", ProviderMetadata { provider: ProviderKind::Anthropic, auth_env: "ANTHROPIC_API_KEY", base_url_env: "ANTHROPIC_BASE_URL", default_base_url: anthropic::DEFAULT_BASE_URL, }, ), ( "haiku", ProviderMetadata { provider: ProviderKind::Anthropic, auth_env: "ANTHROPIC_API_KEY", base_url_env: "ANTHROPIC_BASE_URL", default_base_url: anthropic::DEFAULT_BASE_URL, }, ), ]; #[must_use] pub fn resolve_model_alias(model: &str) -> String { let trimmed = model.trim(); let lower = trimmed.to_ascii_lowercase(); MODEL_REGISTRY .iter() .find_map(|(alias, metadata)| { (*alias == lower).then_some(match metadata.provider { ProviderKind::Anthropic => match *alias { "opus" => "claude-opus-4-6", "sonnet" => "claude-sonnet-4-6", "haiku" => "claude-haiku-4-5-20251213", _ => trimmed, }, ProviderKind::OpenAi => trimmed, }) }) .map_or_else(|| trimmed.to_string(), ToOwned::to_owned) } #[must_use] pub fn metadata_for_model(model: &str) -> Option { let canonical = resolve_model_alias(model); if canonical.starts_with("claude") { return Some(ProviderMetadata { provider: ProviderKind::Anthropic, auth_env: "ANTHROPIC_API_KEY", base_url_env: "ANTHROPIC_BASE_URL", default_base_url: anthropic::DEFAULT_BASE_URL, }); } // Explicit provider-namespaced models (e.g. "openai/gpt-4.1-mini") must // route to the correct provider regardless of which auth env vars are set. // Without this, detect_provider_kind falls through to the auth-sniffer // order and misroutes to Anthropic if ANTHROPIC_API_KEY is present. if canonical.starts_with("openai/") || canonical.starts_with("gpt-") { return Some(ProviderMetadata { provider: ProviderKind::OpenAi, auth_env: "OPENAI_API_KEY", base_url_env: "OPENAI_BASE_URL", default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL, }); } None } #[must_use] pub fn detect_provider_kind(model: &str) -> ProviderKind { if let Some(metadata) = metadata_for_model(model) { return metadata.provider; } // When OPENAI_BASE_URL is set, the user explicitly configured an // OpenAI-compatible endpoint. Prefer it over the Anthropic fallback // even when the model name has no recognized prefix — this is the // common case for local providers (Ollama, LM Studio, vLLM, etc.) // where model names like "qwen2.5-coder:7b" don't match any prefix. if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY") { return ProviderKind::OpenAi; } if anthropic::has_auth_from_env_or_saved().unwrap_or(false) { return ProviderKind::Anthropic; } if openai_compat::has_api_key("OPENAI_API_KEY") { return ProviderKind::OpenAi; } // Last resort: if OPENAI_BASE_URL is set without OPENAI_API_KEY (some // local providers like Ollama don't require auth), still route there. if std::env::var_os("OPENAI_BASE_URL").is_some() { return ProviderKind::OpenAi; } ProviderKind::Anthropic } #[must_use] pub fn max_tokens_for_model(model: &str) -> u32 { model_token_limit(model).map_or_else( || { let canonical = resolve_model_alias(model); if canonical.contains("opus") { 32_000 } else { 64_000 } }, |limit| limit.max_output_tokens, ) } /// Returns the effective max output tokens for a model, preferring a plugin /// override when present. Falls back to [`max_tokens_for_model`] when the /// override is `None`. #[must_use] pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option) -> u32 { plugin_override.unwrap_or_else(|| max_tokens_for_model(model)) } #[must_use] pub fn model_token_limit(model: &str) -> Option { let canonical = resolve_model_alias(model); match canonical.as_str() { "claude-opus-4-6" => Some(ModelTokenLimit { max_output_tokens: 32_000, context_window_tokens: 200_000, }), "claude-sonnet-4-6" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit { max_output_tokens: 64_000, context_window_tokens: 200_000, }), _ => None, } } /// Detect whether the active provider is a local inference endpoint /// (llama.cpp, LM Studio, Ollama, vLLM, mock servers, etc.) where /// KV cache prefix stability is critical and tools-in-system-prompt /// is beneficial. /// /// Detection heuristics: /// 1. Explicit opt-in via `CLAW_LOCAL_INFERENCE=true` /// 2. `OPENAI_BASE_URL` or `ANTHROPIC_BASE_URL` points to a loopback address /// 3. A base URL is set without its corresponding API key (no-auth local server) #[must_use] pub fn is_local_inference() -> bool { if std::env::var_os("CLAW_LOCAL_INFERENCE") .is_some_and(|v| v == "true" || v == "1") { return true; } let local_hosts = ["localhost", "127.0.0.1", "0.0.0.0"]; // Helper: check if a base URL env points to a loopback address. let base_url_looks_local = |key: &str| -> bool { std::env::var(key).is_ok_and(|url| { if url.is_empty() { return false; } local_hosts.iter().any(|h| url.contains(h)) }) }; if base_url_looks_local("OPENAI_BASE_URL") { return true; } if base_url_looks_local("ANTHROPIC_BASE_URL") { return true; } // Base URL set without its API key = likely a no-auth local server. if std::env::var("OPENAI_BASE_URL").is_ok_and(|u| !u.is_empty()) && std::env::var("OPENAI_API_KEY").is_err() { return true; } if std::env::var("ANTHROPIC_BASE_URL").is_ok_and(|u| !u.is_empty()) && std::env::var("ANTHROPIC_API_KEY").is_err() { return true; } false } pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiError> { let Some(limit) = model_token_limit(&request.model) else { return Ok(()); }; let estimated_input_tokens = estimate_message_request_input_tokens(request); let estimated_total_tokens = estimated_input_tokens.saturating_add(request.max_tokens); if estimated_total_tokens > limit.context_window_tokens { return Err(ApiError::ContextWindowExceeded { model: resolve_model_alias(&request.model), estimated_input_tokens, requested_output_tokens: request.max_tokens, estimated_total_tokens, context_window_tokens: limit.context_window_tokens, }); } Ok(()) } fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 { let mut estimate = estimate_serialized_tokens(&request.messages); estimate = estimate.saturating_add(estimate_serialized_tokens(&request.system)); estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tools)); estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tool_choice)); estimate } fn estimate_serialized_tokens(value: &T) -> u32 { serde_json::to_vec(value) .ok() .map_or(0, |bytes| (bytes.len() / 4 + 1) as u32) } /// Env var names used by other provider backends. When Anthropic auth /// resolution fails we sniff these so we can hint the user that their /// credentials probably belong to a different provider and suggest the /// model-prefix routing fix that would select it. const FOREIGN_PROVIDER_ENV_VARS: &[(&str, &str, &str)] = &[( "OPENAI_API_KEY", "OpenAI-compat", "prefix your model name with `openai/` (e.g. `--model openai/gpt-4.1-mini`) so prefix routing selects the OpenAI-compatible provider, and set `OPENAI_BASE_URL` if you are pointing at OpenRouter/Ollama/a local server", )]; /// Check whether an env var is set to a non-empty value either in the real /// process environment or in the working-directory `.env` file. Mirrors the /// credential discovery path used by `read_env_non_empty` so the hint text /// stays truthful when users rely on `.env` instead of a real export. fn env_or_dotenv_present(key: &str) -> bool { match std::env::var(key) { Ok(value) if !value.is_empty() => true, Ok(_) | Err(std::env::VarError::NotPresent) => { dotenv_value(key).is_some_and(|value| !value.is_empty()) } Err(_) => false, } } /// Produce a hint string describing the first foreign provider credential /// that is present in the environment when Anthropic auth resolution has /// just failed. Returns `None` when no foreign credential is set, in which /// case the caller should fall back to the plain `missing_credentials` /// error without a hint. pub(crate) fn anthropic_missing_credentials_hint() -> Option { for (env_var, provider_label, fix_hint) in FOREIGN_PROVIDER_ENV_VARS { if env_or_dotenv_present(env_var) { return Some(format!( "I see {env_var} is set — if you meant to use the {provider_label} provider, {fix_hint}." )); } } None } /// Build an Anthropic-specific `MissingCredentials` error, attaching a /// hint suggesting the probable fix whenever a different provider's /// credentials are already present in the environment. Anthropic call /// sites should prefer this helper over `ApiError::missing_credentials` /// so users who mistyped a model name or forgot the prefix get a useful /// signal instead of a generic "missing Anthropic credentials" wall. pub(crate) fn anthropic_missing_credentials() -> ApiError { const PROVIDER: &str = "Anthropic"; const ENV_VARS: &[&str] = &["ANTHROPIC_API_KEY"]; match anthropic_missing_credentials_hint() { Some(hint) => ApiError::missing_credentials_with_hint(PROVIDER, ENV_VARS, hint), None => ApiError::missing_credentials(PROVIDER, ENV_VARS), } } /// Parse a `.env` file body into key/value pairs using a minimal `KEY=VALUE` /// grammar. Lines that are blank, start with `#`, or do not contain `=` are /// ignored. Surrounding double or single quotes are stripped from the value. /// An optional leading `export ` prefix on the key is also stripped so files /// shared with shell `source` workflows still parse cleanly. pub(crate) fn parse_dotenv(content: &str) -> std::collections::HashMap { let mut values = std::collections::HashMap::new(); for raw_line in content.lines() { let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((raw_key, raw_value)) = line.split_once('=') else { continue; }; let trimmed_key = raw_key.trim(); let key = trimmed_key .strip_prefix("export ") .map_or(trimmed_key, str::trim) .to_string(); if key.is_empty() { continue; } let trimmed_value = raw_value.trim(); let unquoted = if (trimmed_value.starts_with('"') && trimmed_value.ends_with('"') || trimmed_value.starts_with('\'') && trimmed_value.ends_with('\'')) && trimmed_value.len() >= 2 { &trimmed_value[1..trimmed_value.len() - 1] } else { trimmed_value }; values.insert(key, unquoted.to_string()); } values } /// Load and parse a `.env` file from the given path. Missing files yield /// `None` instead of an error so callers can use this as a soft fallback. pub(crate) fn load_dotenv_file( path: &std::path::Path, ) -> Option> { let content = std::fs::read_to_string(path).ok()?; Some(parse_dotenv(&content)) } /// Look up `key` in the first-found `.env` file. /// Priority: `cwd/.env` → `cwd/.claw/.env` → `~/.claw/.env` /// (`$CLAW_CONFIG_HOME/.env` overrides `~/.claw/.env`). /// Returns `None` when the key is absent or its value is empty. pub(crate) fn dotenv_value(key: &str) -> Option { let values = resolve_first_dotenv()?; values.get(key).filter(|value| !value.is_empty()).cloned() } /// Load the first-found `.env` file into the process environment. /// Priority: `cwd/.env` → `cwd/.claw/.env` → `~/.claw/.env` /// Existing vars are NOT overwritten. Call early in `main()` so ALL /// `std::env::var()` calls in any crate pick up `.env` values. pub fn load_env_file_to_process() { runtime::text_only_models::reload(); let Some(values) = resolve_first_dotenv() else { return }; for (key, value) in values { if std::env::var(&key).is_err() { std::env::set_var(&key, &value); } } } /// Resolve the user config home: `$CLAW_CONFIG_HOME` or `~/.claw`. fn user_config_home() -> Option { if let Some(custom) = std::env::var_os("CLAW_CONFIG_HOME") { return Some(std::path::PathBuf::from(custom)); } #[cfg(windows)] let home = std::env::var_os("USERPROFILE"); #[cfg(not(windows))] let home = std::env::var_os("HOME"); home.map(|h| std::path::PathBuf::from(h).join(".claw")) } /// Try `.env` files in order: `cwd/.env` → `cwd/.claw/.env` → user home. /// Returns the contents of the first existing file, or `None`. fn resolve_first_dotenv() -> Option> { // Project-local candidates if let Ok(cwd) = std::env::current_dir() { for candidate in [cwd.join(".env"), cwd.join(".claw").join(".env")] { if let Some(values) = load_dotenv_file(&candidate) { return Some(values); } } } // User-level fallback load_dotenv_file(&user_config_home()?.join(".env")) } #[cfg(test)] mod tests { use std::ffi::OsString; use std::sync::{Arc, Mutex, OnceLock}; use serde_json::json; use crate::error::ApiError; use crate::types::{ InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition, }; use super::{ anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind, load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override, model_token_limit, parse_dotenv, preflight_message_request, ProviderKind, }; /// Serializes every test in this module that mutates process-wide /// environment variables so concurrent test threads cannot observe /// each other's partially-applied state while probing the foreign /// provider credential sniffer. fn env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } /// Snapshot-restore guard for a single environment variable. Captures /// the original value on construction, applies the requested override /// (set or remove), and restores the original on drop so tests leave /// the process env untouched even when they panic mid-assertion. struct EnvVarGuard { key: &'static str, original: Option, } impl EnvVarGuard { fn set(key: &'static str, value: Option<&str>) -> Self { let original = std::env::var_os(key); match value { Some(value) => std::env::set_var(key, value), None => std::env::remove_var(key), } Self { key, original } } } impl Drop for EnvVarGuard { fn drop(&mut self) { match self.original.take() { Some(value) => std::env::set_var(self.key, value), None => std::env::remove_var(self.key), } } } #[test] fn detects_provider_from_model_name_first() { assert_eq!( detect_provider_kind("claude-sonnet-4-6"), ProviderKind::Anthropic ); } #[test] fn openai_namespaced_model_routes_to_openai_not_anthropic() { // Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when // ANTHROPIC_API_KEY was set because metadata_for_model returned None // and detect_provider_kind fell through to auth-sniffer order. // The model prefix must win over env-var presence. let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else( || detect_provider_kind("openai/gpt-4.1-mini"), |m| m.provider, ); assert_eq!( kind, ProviderKind::OpenAi, "openai/ prefix must route to OpenAi regardless of ANTHROPIC_API_KEY" ); // Also cover bare gpt- prefix let kind2 = super::metadata_for_model("gpt-4o") .map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider); assert_eq!(kind2, ProviderKind::OpenAi); } #[test] fn keeps_existing_max_token_heuristic() { assert_eq!(max_tokens_for_model("opus"), 32_000); } #[test] fn plugin_config_max_output_tokens_overrides_model_default() { // given let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("time should be after epoch") .as_nanos(); let root = std::env::temp_dir().join(format!("api-plugin-max-tokens-{nanos}")); let cwd = root.join("project"); let home = root.join("home").join(".claw"); std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir"); std::fs::create_dir_all(&home).expect("home config dir"); std::fs::write( home.join("settings.json"), r#"{ "plugins": { "maxOutputTokens": 12345 } }"#, ) .expect("write plugin settings"); // when let loaded = runtime::ConfigLoader::new(&cwd, &home) .load() .expect("config should load"); let plugin_override = loaded.plugins().max_output_tokens(); let effective = max_tokens_for_model_with_override("claude-opus-4-6", plugin_override); // then assert_eq!(plugin_override, Some(12345)); assert_eq!(effective, 12345); assert_ne!(effective, max_tokens_for_model("claude-opus-4-6")); std::fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn max_tokens_for_model_with_override_falls_back_when_plugin_unset() { // given let plugin_override: Option = None; // when let effective = max_tokens_for_model_with_override("claude-opus-4-6", plugin_override); // then assert_eq!(effective, max_tokens_for_model("claude-opus-4-6")); assert_eq!(effective, 32_000); } #[test] fn returns_context_window_metadata_for_supported_models() { assert_eq!( model_token_limit("claude-sonnet-4-6") .expect("claude-sonnet-4-6 should be registered") .context_window_tokens, 200_000 ); } #[test] fn preflight_blocks_requests_that_exceed_the_model_context_window() { let request = MessageRequest { model: "claude-sonnet-4-6".to_string(), max_tokens: 64_000, messages: Arc::new(vec![InputMessage { role: "user".to_string(), content: vec![InputContentBlock::Text { text: "x".repeat(600_000), }], }]), system: Some(Arc::from("Keep the answer short.")), tools: Some(vec![ToolDefinition { name: "weather".to_string(), description: Some("Fetches weather".to_string()), input_schema: json!({ "type": "object", "properties": { "city": { "type": "string" } }, }), }]), tool_choice: Some(ToolChoice::Auto), stream: true, ..Default::default() }; let error = preflight_message_request(&request) .expect_err("oversized request should be rejected before the provider call"); match error { ApiError::ContextWindowExceeded { model, estimated_input_tokens, requested_output_tokens, estimated_total_tokens, context_window_tokens, } => { assert_eq!(model, "claude-sonnet-4-6"); assert!(estimated_input_tokens > 136_000); assert_eq!(requested_output_tokens, 64_000); assert!(estimated_total_tokens > context_window_tokens); assert_eq!(context_window_tokens, 200_000); } other => panic!("expected context-window preflight failure, got {other:?}"), } } #[test] fn preflight_skips_unknown_models() { let request = MessageRequest { model: "unknown-model".to_string(), max_tokens: 64_000, messages: Arc::new(vec![InputMessage { role: "user".to_string(), content: vec![InputContentBlock::Text { text: "x".repeat(600_000), }], }]), system: None, tools: None, tool_choice: None, stream: false, ..Default::default() }; preflight_message_request(&request) .expect("models without context metadata should skip the guarded preflight"); } #[test] fn parse_dotenv_extracts_keys_handles_comments_quotes_and_export_prefix() { // given let body = "\ # this is a comment ANTHROPIC_API_KEY=plain-value OPENAI_API_KEY='single-quoted' PADDED_KEY = padded-value EMPTY_VALUE= NO_EQUALS_LINE "; // when let values = parse_dotenv(body); // then assert_eq!( values.get("ANTHROPIC_API_KEY").map(String::as_str), Some("plain-value") ); assert_eq!( values.get("OPENAI_API_KEY").map(String::as_str), Some("single-quoted") ); assert_eq!( values.get("PADDED_KEY").map(String::as_str), Some("padded-value") ); assert_eq!(values.get("EMPTY_VALUE").map(String::as_str), Some("")); assert!(!values.contains_key("NO_EQUALS_LINE")); assert!(!values.contains_key("# this is a comment")); } #[test] fn load_dotenv_file_reads_keys_from_disk_and_returns_none_when_missing() { // given let temp_root = std::env::temp_dir().join(format!( "api-dotenv-test-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |duration| duration.as_nanos()) )); std::fs::create_dir_all(&temp_root).expect("create temp dir"); let env_path = temp_root.join(".env"); std::fs::write( &env_path, "ANTHROPIC_API_KEY=secret-from-file\n# comment\n", ) .expect("write .env"); let missing_path = temp_root.join("does-not-exist.env"); // when let loaded = load_dotenv_file(&env_path).expect("file should load"); let missing = load_dotenv_file(&missing_path); // then assert_eq!( loaded.get("ANTHROPIC_API_KEY").map(String::as_str), Some("secret-from-file") ); assert!(missing.is_none()); let _ = std::fs::remove_dir_all(&temp_root); } #[test] fn anthropic_missing_credentials_hint_is_none_when_no_foreign_creds_present() { // given let _lock = env_lock(); let _openai = EnvVarGuard::set("OPENAI_API_KEY", None); // when let hint = anthropic_missing_credentials_hint(); // then assert!( hint.is_none(), "no hint should be produced when every foreign provider env var is absent, got {hint:?}" ); } #[test] fn anthropic_missing_credentials_hint_detects_openai_api_key_and_recommends_openai_prefix() { // given let _lock = env_lock(); let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg")); // when let hint = anthropic_missing_credentials_hint() .expect("OPENAI_API_KEY presence should produce a hint"); // then assert!( hint.contains("OPENAI_API_KEY is set"), "hint should name the detected env var so users recognize it: {hint}" ); assert!( hint.contains("OpenAI-compat"), "hint should identify the target provider: {hint}" ); assert!( hint.contains("openai/"), "hint should mention the `openai/` prefix routing fix: {hint}" ); assert!( hint.contains("OPENAI_BASE_URL"), "hint should mention OPENAI_BASE_URL so OpenRouter users see the full picture: {hint}" ); } #[test] fn anthropic_missing_credentials_builds_error_with_canonical_env_vars_and_no_hint_when_clean() { // given let _lock = env_lock(); let _openai = EnvVarGuard::set("OPENAI_API_KEY", None); // when let error = anthropic_missing_credentials(); // then match &error { ApiError::MissingCredentials { provider, env_vars, hint, } => { assert_eq!(*provider, "Anthropic"); assert_eq!(*env_vars, &["ANTHROPIC_API_KEY"]); assert!( hint.is_none(), "clean environment should not generate a hint, got {hint:?}" ); } other => panic!("expected MissingCredentials variant, got {other:?}"), } let rendered = error.to_string(); assert!( !rendered.contains(" — hint: "), "rendered error should be a plain missing-creds message: {rendered}" ); } #[test] fn anthropic_missing_credentials_builds_error_with_hint_when_openai_key_is_set() { // given let _lock = env_lock(); let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg")); // when let error = anthropic_missing_credentials(); // then match &error { ApiError::MissingCredentials { provider, env_vars, hint, } => { assert_eq!(*provider, "Anthropic"); assert_eq!(*env_vars, &["ANTHROPIC_API_KEY"]); let hint_value = hint.as_deref().expect("hint should be populated"); assert!( hint_value.contains("OPENAI_API_KEY is set"), "hint should name the detected env var: {hint_value}" ); } other => panic!("expected MissingCredentials variant, got {other:?}"), } let rendered = error.to_string(); assert!( rendered.starts_with("missing Anthropic credentials;"), "canonical base message should still lead the rendered error: {rendered}" ); assert!( rendered.contains(" — hint: I see OPENAI_API_KEY is set"), "rendered error should carry the env-driven hint: {rendered}" ); } #[test] fn anthropic_missing_credentials_hint_ignores_empty_string_values() { // given let _lock = env_lock(); // An empty value is semantically equivalent to "not set" for the // credential discovery path, so the sniffer must treat it that way // to avoid false-positive hints for users who intentionally cleared // a stale export with `OPENAI_API_KEY=`. let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("")); // when let hint = anthropic_missing_credentials_hint(); // then assert!( hint.is_none(), "empty env var should not trigger the hint sniffer, got {hint:?}" ); } #[test] fn openai_base_url_overrides_anthropic_fallback_for_unknown_model() { // given — user has OPENAI_BASE_URL + OPENAI_API_KEY but no Anthropic // creds, and a model name with no recognized prefix. let _lock = env_lock(); let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1")); let _api_key = EnvVarGuard::set("OPENAI_API_KEY", Some("dummy")); let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None); // when let provider = detect_provider_kind("qwen2.5-coder:7b"); // then — should route to OpenAI, not Anthropic assert_eq!( provider, ProviderKind::OpenAi, "OPENAI_BASE_URL should win over Anthropic fallback for unknown models" ); } // NOTE: a "OPENAI_BASE_URL without OPENAI_API_KEY" test is omitted // because workspace-parallel test binaries can race on process env // (env_lock only protects within a single binary). The detection logic // is covered: OPENAI_BASE_URL alone routes to OpenAi as a last-resort // fallback in detect_provider_kind(). }