pub mod anthropic; pub mod compatible; pub mod gemini; pub mod ollama; pub mod openai; pub mod openrouter; pub mod reliable; pub mod router; pub mod traits; #[allow(unused_imports)] pub use traits::{ChatMessage, ChatResponse, Provider, ToolCall}; use compatible::{AuthStyle, OpenAiCompatibleProvider}; use reliable::ReliableProvider; const MAX_API_ERROR_CHARS: usize = 200; fn is_secret_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':') } fn token_end(input: &str, from: usize) -> usize { let mut end = from; for (i, c) in input[from..].char_indices() { if is_secret_char(c) { end = from + i + c.len_utf8(); } else { break; } } end } /// Scrub known secret-like token prefixes from provider error strings. /// /// Redacts tokens with prefixes like `sk-`, `xoxb-`, and `xoxp-`. pub fn scrub_secret_patterns(input: &str) -> String { const PREFIXES: [&str; 3] = ["sk-", "xoxb-", "xoxp-"]; let mut scrubbed = input.to_string(); for prefix in PREFIXES { let mut search_from = 0; loop { let Some(rel) = scrubbed[search_from..].find(prefix) else { break; }; let start = search_from + rel; let content_start = start + prefix.len(); let end = token_end(&scrubbed, content_start); // Bare prefixes like "sk-" should not stop future scans. if end == content_start { search_from = content_start; continue; } scrubbed.replace_range(start..end, "[REDACTED]"); search_from = start + "[REDACTED]".len(); } } scrubbed } /// Sanitize API error text by scrubbing secrets and truncating length. pub fn sanitize_api_error(input: &str) -> String { let scrubbed = scrub_secret_patterns(input); if scrubbed.chars().count() <= MAX_API_ERROR_CHARS { return scrubbed; } let mut end = MAX_API_ERROR_CHARS; while end > 0 && !scrubbed.is_char_boundary(end) { end -= 1; } format!("{}...", &scrubbed[..end]) } /// Build a sanitized provider error from a failed HTTP response. pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::Error { let status = response.status(); let body = response .text() .await .unwrap_or_else(|_| "".to_string()); let sanitized = sanitize_api_error(&body); anyhow::anyhow!("{provider} API error ({status}): {sanitized}") } /// Resolve API key for a provider from config and environment variables. /// /// Resolution order: /// 1. Explicitly provided `api_key` parameter (trimmed, filtered if empty) /// 2. Provider-specific environment variable (e.g., `ANTHROPIC_OAUTH_TOKEN`, `OPENROUTER_API_KEY`) /// 3. Generic fallback variables (`ZEROCLAW_API_KEY`, `API_KEY`) /// /// For Anthropic, the provider-specific env var is `ANTHROPIC_OAUTH_TOKEN` (for setup-tokens) /// followed by `ANTHROPIC_API_KEY` (for regular API keys). fn resolve_api_key(name: &str, api_key: Option<&str>) -> Option { if let Some(key) = api_key.map(str::trim).filter(|k| !k.is_empty()) { return Some(key.to_string()); } let provider_env_candidates: Vec<&str> = match name { "anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], "openrouter" => vec!["OPENROUTER_API_KEY"], "openai" => vec!["OPENAI_API_KEY"], "venice" => vec!["VENICE_API_KEY"], "groq" => vec!["GROQ_API_KEY"], "mistral" => vec!["MISTRAL_API_KEY"], "deepseek" => vec!["DEEPSEEK_API_KEY"], "xai" | "grok" => vec!["XAI_API_KEY"], "together" | "together-ai" => vec!["TOGETHER_API_KEY"], "fireworks" | "fireworks-ai" => vec!["FIREWORKS_API_KEY"], "perplexity" => vec!["PERPLEXITY_API_KEY"], "cohere" => vec!["COHERE_API_KEY"], "moonshot" | "kimi" => vec!["MOONSHOT_API_KEY"], "glm" | "zhipu" => vec!["GLM_API_KEY"], "minimax" => vec!["MINIMAX_API_KEY"], "qianfan" | "baidu" => vec!["QIANFAN_API_KEY"], "zai" | "z.ai" => vec!["ZAI_API_KEY"], "synthetic" => vec!["SYNTHETIC_API_KEY"], "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"], "vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"], "cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"], _ => vec![], }; for env_var in provider_env_candidates { if let Ok(value) = std::env::var(env_var) { let value = value.trim(); if !value.is_empty() { return Some(value.to_string()); } } } for env_var in ["ZEROCLAW_API_KEY", "API_KEY"] { if let Ok(value) = std::env::var(env_var) { let value = value.trim(); if !value.is_empty() { return Some(value.to_string()); } } } None } fn parse_custom_provider_url( raw_url: &str, provider_label: &str, format_hint: &str, ) -> anyhow::Result { let base_url = raw_url.trim(); if base_url.is_empty() { anyhow::bail!("{provider_label} requires a URL. Format: {format_hint}"); } let parsed = reqwest::Url::parse(base_url).map_err(|_| { anyhow::anyhow!("{provider_label} requires a valid URL. Format: {format_hint}") })?; match parsed.scheme() { "http" | "https" => Ok(base_url.to_string()), _ => anyhow::bail!( "{provider_label} requires an http:// or https:// URL. Format: {format_hint}" ), } } /// Factory: create the right provider from config #[allow(clippy::too_many_lines)] pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result> { let resolved_key = resolve_api_key(name, api_key); let key = resolved_key.as_deref(); match name { // ── Primary providers (custom implementations) ─────── "openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))), "anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))), "openai" => Ok(Box::new(openai::OpenAiProvider::new(key))), // Ollama is a local service that doesn't use API keys. // The api_key parameter is ignored to avoid it being misinterpreted as a base_url. "ollama" => Ok(Box::new(ollama::OllamaProvider::new(None))), "gemini" | "google" | "google-gemini" => { Ok(Box::new(gemini::GeminiProvider::new(key))) } // ── OpenAI-compatible providers ────────────────────── "venice" => Ok(Box::new(OpenAiCompatibleProvider::new( "Venice", "https://api.venice.ai", key, AuthStyle::Bearer, ))), "vercel" | "vercel-ai" => Ok(Box::new(OpenAiCompatibleProvider::new( "Vercel AI Gateway", "https://api.vercel.ai", key, AuthStyle::Bearer, ))), "cloudflare" | "cloudflare-ai" => Ok(Box::new(OpenAiCompatibleProvider::new( "Cloudflare AI Gateway", "https://gateway.ai.cloudflare.com/v1", api_key, AuthStyle::Bearer, ))), "moonshot" | "kimi" => Ok(Box::new(OpenAiCompatibleProvider::new( "Moonshot", "https://api.moonshot.cn", key, AuthStyle::Bearer, ))), "synthetic" => Ok(Box::new(OpenAiCompatibleProvider::new( "Synthetic", "https://api.synthetic.com", key, AuthStyle::Bearer, ))), "opencode" | "opencode-zen" => Ok(Box::new(OpenAiCompatibleProvider::new( "OpenCode Zen", "https://api.opencode.ai", key, AuthStyle::Bearer, ))), "zai" | "z.ai" => Ok(Box::new(OpenAiCompatibleProvider::new( "Z.AI", "https://api.z.ai/api/coding/paas/v4", key, AuthStyle::Bearer, ))), "glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new( "GLM", "https://open.bigmodel.cn/api/paas/v4", key, AuthStyle::Bearer, ))), "minimax" => Ok(Box::new(OpenAiCompatibleProvider::new( "MiniMax", "https://api.minimaxi.com/v1", key, AuthStyle::Bearer, ))), "bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new( "Amazon Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", api_key, AuthStyle::Bearer, ))), "qianfan" | "baidu" => Ok(Box::new(OpenAiCompatibleProvider::new( "Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer, ))), // ── Extended ecosystem (community favorites) ───────── "groq" => Ok(Box::new(OpenAiCompatibleProvider::new( "Groq", "https://api.groq.com/openai", key, AuthStyle::Bearer, ))), "mistral" => Ok(Box::new(OpenAiCompatibleProvider::new( "Mistral", "https://api.mistral.ai", key, AuthStyle::Bearer, ))), "xai" | "grok" => Ok(Box::new(OpenAiCompatibleProvider::new( "xAI", "https://api.x.ai", key, AuthStyle::Bearer, ))), "deepseek" => Ok(Box::new(OpenAiCompatibleProvider::new( "DeepSeek", "https://api.deepseek.com", key, AuthStyle::Bearer, ))), "together" | "together-ai" => Ok(Box::new(OpenAiCompatibleProvider::new( "Together AI", "https://api.together.xyz", key, AuthStyle::Bearer, ))), "fireworks" | "fireworks-ai" => Ok(Box::new(OpenAiCompatibleProvider::new( "Fireworks AI", "https://api.fireworks.ai/inference/v1", key, AuthStyle::Bearer, ))), "perplexity" => Ok(Box::new(OpenAiCompatibleProvider::new( "Perplexity", "https://api.perplexity.ai", key, AuthStyle::Bearer, ))), "cohere" => Ok(Box::new(OpenAiCompatibleProvider::new( "Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer, ))), "copilot" | "github-copilot" => Ok(Box::new(OpenAiCompatibleProvider::new( "GitHub Copilot", "https://api.githubcopilot.com", key, AuthStyle::Bearer, ))), // ── Bring Your Own Provider (custom URL) ─────────── // Format: "custom:https://your-api.com" or "custom:http://localhost:1234" name if name.starts_with("custom:") => { let base_url = parse_custom_provider_url( name.strip_prefix("custom:").unwrap_or(""), "Custom provider", "custom:https://your-api.com", )?; Ok(Box::new(OpenAiCompatibleProvider::new( "Custom", &base_url, key, AuthStyle::Bearer, ))) } // ── Anthropic-compatible custom endpoints ─────────── // Format: "anthropic-custom:https://your-api.com" name if name.starts_with("anthropic-custom:") => { let base_url = parse_custom_provider_url( name.strip_prefix("anthropic-custom:").unwrap_or(""), "Anthropic-custom provider", "anthropic-custom:https://your-api.com", )?; Ok(Box::new(anthropic::AnthropicProvider::with_base_url( key, Some(&base_url), ))) } _ => anyhow::bail!( "Unknown provider: {name}. Check README for supported providers or run `zeroclaw onboard --interactive` to reconfigure.\n\ Tip: Use \"custom:https://your-api.com\" for OpenAI-compatible endpoints.\n\ Tip: Use \"anthropic-custom:https://your-api.com\" for Anthropic-compatible endpoints." ), } } /// Create provider chain with retry and fallback behavior. pub fn create_resilient_provider( primary_name: &str, api_key: Option<&str>, reliability: &crate::config::ReliabilityConfig, ) -> anyhow::Result> { let mut providers: Vec<(String, Box)> = Vec::new(); providers.push(( primary_name.to_string(), create_provider(primary_name, api_key)?, )); for fallback in &reliability.fallback_providers { if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) { continue; } if api_key.is_some() && fallback != "ollama" { tracing::warn!( fallback_provider = fallback, primary_provider = primary_name, "Fallback provider will use the primary provider's API key — \ this will fail if the providers require different keys" ); } match create_provider(fallback, api_key) { Ok(provider) => providers.push((fallback.clone(), provider)), Err(e) => { tracing::warn!( fallback_provider = fallback, "Ignoring invalid fallback provider: {e}" ); } } } let reliable = ReliableProvider::new( providers, reliability.provider_retries, reliability.provider_backoff_ms, ) .with_api_keys(reliability.api_keys.clone()) .with_model_fallbacks(reliability.model_fallbacks.clone()); Ok(Box::new(reliable)) } /// Create a RouterProvider if model routes are configured, otherwise return a /// standard resilient provider. The router wraps individual providers per route, /// each with its own retry/fallback chain. pub fn create_routed_provider( primary_name: &str, api_key: Option<&str>, reliability: &crate::config::ReliabilityConfig, model_routes: &[crate::config::ModelRouteConfig], default_model: &str, ) -> anyhow::Result> { if model_routes.is_empty() { return create_resilient_provider(primary_name, api_key, reliability); } // Collect unique provider names needed let mut needed: Vec = vec![primary_name.to_string()]; for route in model_routes { if !needed.iter().any(|n| n == &route.provider) { needed.push(route.provider.clone()); } } // Create each provider (with its own resilience wrapper) let mut providers: Vec<(String, Box)> = Vec::new(); for name in &needed { let key = model_routes .iter() .find(|r| &r.provider == name) .and_then(|r| r.api_key.as_deref()) .or(api_key); match create_resilient_provider(name, key, reliability) { Ok(provider) => providers.push((name.clone(), provider)), Err(e) => { if name == primary_name { return Err(e); } tracing::warn!( provider = name.as_str(), "Ignoring routed provider that failed to create: {e}" ); } } } // Build route table let routes: Vec<(String, router::Route)> = model_routes .iter() .map(|r| { ( r.hint.clone(), router::Route { provider_name: r.provider.clone(), model: r.model.clone(), }, ) }) .collect(); Ok(Box::new(router::RouterProvider::new( providers, routes, default_model.to_string(), ))) } #[cfg(test)] mod tests { use super::*; // ── Primary providers ──────────────────────────────────── #[test] fn factory_openrouter() { assert!(create_provider("openrouter", Some("sk-test")).is_ok()); assert!(create_provider("openrouter", None).is_ok()); } #[test] fn factory_anthropic() { assert!(create_provider("anthropic", Some("sk-test")).is_ok()); } #[test] fn factory_openai() { assert!(create_provider("openai", Some("sk-test")).is_ok()); } #[test] fn factory_ollama() { assert!(create_provider("ollama", None).is_ok()); // Ollama ignores the api_key parameter since it's a local service assert!(create_provider("ollama", Some("dummy")).is_ok()); assert!(create_provider("ollama", Some("any-value-here")).is_ok()); } #[test] fn factory_gemini() { assert!(create_provider("gemini", Some("test-key")).is_ok()); assert!(create_provider("google", Some("test-key")).is_ok()); assert!(create_provider("google-gemini", Some("test-key")).is_ok()); // Should also work without key (will try CLI auth) assert!(create_provider("gemini", None).is_ok()); } // ── OpenAI-compatible providers ────────────────────────── #[test] fn factory_venice() { assert!(create_provider("venice", Some("vn-key")).is_ok()); } #[test] fn factory_vercel() { assert!(create_provider("vercel", Some("key")).is_ok()); assert!(create_provider("vercel-ai", Some("key")).is_ok()); } #[test] fn factory_cloudflare() { assert!(create_provider("cloudflare", Some("key")).is_ok()); assert!(create_provider("cloudflare-ai", Some("key")).is_ok()); } #[test] fn factory_moonshot() { assert!(create_provider("moonshot", Some("key")).is_ok()); assert!(create_provider("kimi", Some("key")).is_ok()); } #[test] fn factory_synthetic() { assert!(create_provider("synthetic", Some("key")).is_ok()); } #[test] fn factory_opencode() { assert!(create_provider("opencode", Some("key")).is_ok()); assert!(create_provider("opencode-zen", Some("key")).is_ok()); } #[test] fn factory_zai() { assert!(create_provider("zai", Some("key")).is_ok()); assert!(create_provider("z.ai", Some("key")).is_ok()); } #[test] fn factory_glm() { assert!(create_provider("glm", Some("key")).is_ok()); assert!(create_provider("zhipu", Some("key")).is_ok()); } #[test] fn factory_minimax() { assert!(create_provider("minimax", Some("key")).is_ok()); } #[test] fn factory_bedrock() { assert!(create_provider("bedrock", Some("key")).is_ok()); assert!(create_provider("aws-bedrock", Some("key")).is_ok()); } #[test] fn factory_qianfan() { assert!(create_provider("qianfan", Some("key")).is_ok()); assert!(create_provider("baidu", Some("key")).is_ok()); } // ── Extended ecosystem ─────────────────────────────────── #[test] fn factory_groq() { assert!(create_provider("groq", Some("key")).is_ok()); } #[test] fn factory_mistral() { assert!(create_provider("mistral", Some("key")).is_ok()); } #[test] fn factory_xai() { assert!(create_provider("xai", Some("key")).is_ok()); assert!(create_provider("grok", Some("key")).is_ok()); } #[test] fn factory_deepseek() { assert!(create_provider("deepseek", Some("key")).is_ok()); } #[test] fn factory_together() { assert!(create_provider("together", Some("key")).is_ok()); assert!(create_provider("together-ai", Some("key")).is_ok()); } #[test] fn factory_fireworks() { assert!(create_provider("fireworks", Some("key")).is_ok()); assert!(create_provider("fireworks-ai", Some("key")).is_ok()); } #[test] fn factory_perplexity() { assert!(create_provider("perplexity", Some("key")).is_ok()); } #[test] fn factory_cohere() { assert!(create_provider("cohere", Some("key")).is_ok()); } #[test] fn factory_copilot() { assert!(create_provider("copilot", Some("key")).is_ok()); assert!(create_provider("github-copilot", Some("key")).is_ok()); } // ── Custom / BYOP provider ───────────────────────────── #[test] fn factory_custom_url() { let p = create_provider("custom:https://my-llm.example.com", Some("key")); assert!(p.is_ok()); } #[test] fn factory_custom_localhost() { let p = create_provider("custom:http://localhost:1234", Some("key")); assert!(p.is_ok()); } #[test] fn factory_custom_no_key() { let p = create_provider("custom:https://my-llm.example.com", None); assert!(p.is_ok()); } #[test] fn factory_custom_empty_url_errors() { match create_provider("custom:", None) { Err(e) => assert!( e.to_string().contains("requires a URL"), "Expected 'requires a URL', got: {e}" ), Ok(_) => panic!("Expected error for empty custom URL"), } } #[test] fn factory_custom_invalid_url_errors() { match create_provider("custom:not-a-url", None) { Err(e) => assert!( e.to_string().contains("requires a valid URL"), "Expected 'requires a valid URL', got: {e}" ), Ok(_) => panic!("Expected error for invalid custom URL"), } } #[test] fn factory_custom_unsupported_scheme_errors() { match create_provider("custom:ftp://example.com", None) { Err(e) => assert!( e.to_string().contains("http:// or https://"), "Expected scheme validation error, got: {e}" ), Ok(_) => panic!("Expected error for unsupported custom URL scheme"), } } #[test] fn factory_custom_trims_whitespace() { let p = create_provider("custom: https://my-llm.example.com ", Some("key")); assert!(p.is_ok()); } // ── Anthropic-compatible custom endpoints ───────────────── #[test] fn factory_anthropic_custom_url() { let p = create_provider("anthropic-custom:https://api.example.com", Some("key")); assert!(p.is_ok()); } #[test] fn factory_anthropic_custom_trailing_slash() { let p = create_provider("anthropic-custom:https://api.example.com/", Some("key")); assert!(p.is_ok()); } #[test] fn factory_anthropic_custom_no_key() { let p = create_provider("anthropic-custom:https://api.example.com", None); assert!(p.is_ok()); } #[test] fn factory_anthropic_custom_empty_url_errors() { match create_provider("anthropic-custom:", None) { Err(e) => assert!( e.to_string().contains("requires a URL"), "Expected 'requires a URL', got: {e}" ), Ok(_) => panic!("Expected error for empty anthropic-custom URL"), } } #[test] fn factory_anthropic_custom_invalid_url_errors() { match create_provider("anthropic-custom:not-a-url", None) { Err(e) => assert!( e.to_string().contains("requires a valid URL"), "Expected 'requires a valid URL', got: {e}" ), Ok(_) => panic!("Expected error for invalid anthropic-custom URL"), } } #[test] fn factory_anthropic_custom_unsupported_scheme_errors() { match create_provider("anthropic-custom:ftp://example.com", None) { Err(e) => assert!( e.to_string().contains("http:// or https://"), "Expected scheme validation error, got: {e}" ), Ok(_) => panic!("Expected error for unsupported anthropic-custom URL scheme"), } } // ── Error cases ────────────────────────────────────────── #[test] fn factory_unknown_provider_errors() { let p = create_provider("nonexistent", None); assert!(p.is_err()); let msg = p.err().unwrap().to_string(); assert!(msg.contains("Unknown provider")); assert!(msg.contains("nonexistent")); } #[test] fn factory_empty_name_errors() { assert!(create_provider("", None).is_err()); } #[test] fn resilient_provider_ignores_duplicate_and_invalid_fallbacks() { let reliability = crate::config::ReliabilityConfig { provider_retries: 1, provider_backoff_ms: 100, fallback_providers: vec![ "openrouter".into(), "nonexistent-provider".into(), "openai".into(), "openai".into(), ], api_keys: Vec::new(), model_fallbacks: std::collections::HashMap::new(), channel_initial_backoff_secs: 2, channel_max_backoff_secs: 60, scheduler_poll_secs: 15, scheduler_retries: 2, }; let provider = create_resilient_provider("openrouter", Some("sk-test"), &reliability); assert!(provider.is_ok()); } #[test] fn resilient_provider_errors_for_invalid_primary() { let reliability = crate::config::ReliabilityConfig::default(); let provider = create_resilient_provider("totally-invalid", Some("sk-test"), &reliability); assert!(provider.is_err()); } #[test] fn factory_all_providers_create_successfully() { let providers = [ "openrouter", "anthropic", "openai", "ollama", "gemini", "venice", "vercel", "cloudflare", "moonshot", "synthetic", "opencode", "zai", "glm", "minimax", "bedrock", "qianfan", "groq", "mistral", "xai", "deepseek", "together", "fireworks", "perplexity", "cohere", "copilot", ]; for name in providers { assert!( create_provider(name, Some("test-key")).is_ok(), "Provider '{name}' should create successfully" ); } } // ── API error sanitization ─────────────────────────────── #[test] fn sanitize_scrubs_sk_prefix() { let input = "request failed: sk-1234567890abcdef"; let out = sanitize_api_error(input); assert!(!out.contains("sk-1234567890abcdef")); assert!(out.contains("[REDACTED]")); } #[test] fn sanitize_scrubs_multiple_prefixes() { let input = "keys sk-abcdef xoxb-12345 xoxp-67890"; let out = sanitize_api_error(input); assert!(!out.contains("sk-abcdef")); assert!(!out.contains("xoxb-12345")); assert!(!out.contains("xoxp-67890")); } #[test] fn sanitize_short_prefix_then_real_key() { let input = "error with sk- prefix and key sk-1234567890"; let result = sanitize_api_error(input); assert!(!result.contains("sk-1234567890")); assert!(result.contains("[REDACTED]")); } #[test] fn sanitize_sk_proj_comment_then_real_key() { let input = "note: sk- then sk-proj-abc123def456"; let result = sanitize_api_error(input); assert!(!result.contains("sk-proj-abc123def456")); assert!(result.contains("[REDACTED]")); } #[test] fn sanitize_keeps_bare_prefix() { let input = "only prefix sk- present"; let result = sanitize_api_error(input); assert!(result.contains("sk-")); } #[test] fn sanitize_handles_json_wrapped_key() { let input = r#"{"error":"invalid key sk-abc123xyz"}"#; let result = sanitize_api_error(input); assert!(!result.contains("sk-abc123xyz")); } #[test] fn sanitize_handles_delimiter_boundaries() { let input = "bad token xoxb-abc123}; next"; let result = sanitize_api_error(input); assert!(!result.contains("xoxb-abc123")); assert!(result.contains("};")); } #[test] fn sanitize_truncates_long_error() { let long = "a".repeat(400); let result = sanitize_api_error(&long); assert!(result.len() <= 203); assert!(result.ends_with("...")); } #[test] fn sanitize_truncates_after_scrub() { let input = format!("{} sk-abcdef123456 {}", "a".repeat(190), "b".repeat(190)); let result = sanitize_api_error(&input); assert!(!result.contains("sk-abcdef123456")); assert!(result.len() <= 203); } #[test] fn sanitize_preserves_unicode_boundaries() { let input = format!("{} sk-abcdef123", "こんにちは".repeat(80)); let result = sanitize_api_error(&input); assert!(std::str::from_utf8(result.as_bytes()).is_ok()); assert!(!result.contains("sk-abcdef123")); } #[test] fn sanitize_no_secret_no_change() { let input = "simple upstream timeout"; let result = sanitize_api_error(input); assert_eq!(result, input); } }