Skip to main content

zeroclaw/providers/
mod.rs

1//! Provider subsystem for model inference backends.
2//!
3//! This module implements the factory pattern for AI model providers. Each provider
4//! implements the [`Provider`] trait defined in [`traits`], and is registered in the
5//! factory function [`create_provider`] by its canonical string key (e.g., `"openai"`,
6//! `"anthropic"`, `"ollama"`, `"gemini"`). Provider aliases are resolved internally
7//! so that user-facing keys remain stable.
8//!
9//! The subsystem supports resilient multi-provider configurations through the
10//! [`ReliableProvider`](reliable::ReliableProvider) wrapper, which handles fallback
11//! chains and automatic retry. Model routing across providers is available via
12//! [`create_routed_provider`].
13//!
14//! # Extension
15//!
16//! To add a new provider, implement [`Provider`] in a new submodule and register it
17//! in [`create_provider_with_url`]. See `AGENTS.md` §7.1 for the full change playbook.
18
19pub mod anthropic;
20pub mod bedrock;
21pub mod compatible;
22pub mod copilot;
23pub mod gemini;
24pub mod ollama;
25pub mod openai;
26pub mod openai_codex;
27pub mod openrouter;
28pub mod reliable;
29pub mod router;
30pub mod telnyx;
31pub mod traits;
32
33#[allow(unused_imports)]
34pub use traits::{
35    ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ProviderCapabilityError,
36    ToolCall, ToolResultMessage,
37};
38
39use crate::auth::AuthService;
40use compatible::{AuthStyle, OpenAiCompatibleProvider};
41use reliable::ReliableProvider;
42use serde::Deserialize;
43use std::path::PathBuf;
44
45const MAX_API_ERROR_CHARS: usize = 200;
46const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
47const MINIMAX_CN_BASE_URL: &str = "https://api.minimaxi.com/v1";
48const MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT: &str = "https://api.minimax.io/oauth/token";
49const MINIMAX_OAUTH_CN_TOKEN_ENDPOINT: &str = "https://api.minimaxi.com/oauth/token";
50const MINIMAX_OAUTH_PLACEHOLDER: &str = "minimax-oauth";
51const MINIMAX_OAUTH_CN_PLACEHOLDER: &str = "minimax-oauth-cn";
52const MINIMAX_OAUTH_TOKEN_ENV: &str = "MINIMAX_OAUTH_TOKEN";
53const MINIMAX_API_KEY_ENV: &str = "MINIMAX_API_KEY";
54const MINIMAX_OAUTH_REFRESH_TOKEN_ENV: &str = "MINIMAX_OAUTH_REFRESH_TOKEN";
55const MINIMAX_OAUTH_REGION_ENV: &str = "MINIMAX_OAUTH_REGION";
56const MINIMAX_OAUTH_CLIENT_ID_ENV: &str = "MINIMAX_OAUTH_CLIENT_ID";
57const MINIMAX_OAUTH_DEFAULT_CLIENT_ID: &str = "78257093-7e40-4613-99e0-527b14b39113";
58const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
59const GLM_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
60const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1";
61const MOONSHOT_CN_BASE_URL: &str = "https://api.moonshot.cn/v1";
62const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
63const QWEN_INTL_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
64const QWEN_US_BASE_URL: &str = "https://dashscope-us.aliyuncs.com/compatible-mode/v1";
65const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
66const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
67const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
68const QWEN_OAUTH_TOKEN_ENV: &str = "QWEN_OAUTH_TOKEN";
69const QWEN_OAUTH_REFRESH_TOKEN_ENV: &str = "QWEN_OAUTH_REFRESH_TOKEN";
70const QWEN_OAUTH_RESOURCE_URL_ENV: &str = "QWEN_OAUTH_RESOURCE_URL";
71const QWEN_OAUTH_CLIENT_ID_ENV: &str = "QWEN_OAUTH_CLIENT_ID";
72const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
73const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
74const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
75const ZAI_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
76const VERCEL_AI_GATEWAY_BASE_URL: &str = "https://ai-gateway.vercel.sh/v1";
77
78pub(crate) fn is_minimax_intl_alias(name: &str) -> bool {
79    matches!(
80        name,
81        "minimax"
82            | "minimax-intl"
83            | "minimax-io"
84            | "minimax-global"
85            | "minimax-oauth"
86            | "minimax-portal"
87            | "minimax-oauth-global"
88            | "minimax-portal-global"
89    )
90}
91
92pub(crate) fn is_minimax_cn_alias(name: &str) -> bool {
93    matches!(
94        name,
95        "minimax-cn" | "minimaxi" | "minimax-oauth-cn" | "minimax-portal-cn"
96    )
97}
98
99pub(crate) fn is_minimax_alias(name: &str) -> bool {
100    is_minimax_intl_alias(name) || is_minimax_cn_alias(name)
101}
102
103pub(crate) fn is_glm_global_alias(name: &str) -> bool {
104    matches!(name, "glm" | "zhipu" | "glm-global" | "zhipu-global")
105}
106
107pub(crate) fn is_glm_cn_alias(name: &str) -> bool {
108    matches!(name, "glm-cn" | "zhipu-cn" | "bigmodel")
109}
110
111pub(crate) fn is_glm_alias(name: &str) -> bool {
112    is_glm_global_alias(name) || is_glm_cn_alias(name)
113}
114
115pub(crate) fn is_moonshot_intl_alias(name: &str) -> bool {
116    matches!(
117        name,
118        "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
119    )
120}
121
122pub(crate) fn is_moonshot_cn_alias(name: &str) -> bool {
123    matches!(name, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn")
124}
125
126pub(crate) fn is_moonshot_alias(name: &str) -> bool {
127    is_moonshot_intl_alias(name) || is_moonshot_cn_alias(name)
128}
129
130pub(crate) fn is_qwen_cn_alias(name: &str) -> bool {
131    matches!(name, "qwen" | "dashscope" | "qwen-cn" | "dashscope-cn")
132}
133
134pub(crate) fn is_qwen_intl_alias(name: &str) -> bool {
135    matches!(
136        name,
137        "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
138    )
139}
140
141pub(crate) fn is_qwen_us_alias(name: &str) -> bool {
142    matches!(name, "qwen-us" | "dashscope-us")
143}
144
145pub(crate) fn is_qwen_oauth_alias(name: &str) -> bool {
146    matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
147}
148
149pub(crate) fn is_qwen_alias(name: &str) -> bool {
150    is_qwen_cn_alias(name)
151        || is_qwen_intl_alias(name)
152        || is_qwen_us_alias(name)
153        || is_qwen_oauth_alias(name)
154}
155
156pub(crate) fn is_zai_global_alias(name: &str) -> bool {
157    matches!(name, "zai" | "z.ai" | "zai-global" | "z.ai-global")
158}
159
160pub(crate) fn is_zai_cn_alias(name: &str) -> bool {
161    matches!(name, "zai-cn" | "z.ai-cn")
162}
163
164pub(crate) fn is_zai_alias(name: &str) -> bool {
165    is_zai_global_alias(name) || is_zai_cn_alias(name)
166}
167
168pub(crate) fn is_qianfan_alias(name: &str) -> bool {
169    matches!(name, "qianfan" | "baidu")
170}
171
172pub(crate) fn is_doubao_alias(name: &str) -> bool {
173    matches!(name, "doubao" | "volcengine" | "ark" | "doubao-cn")
174}
175
176#[derive(Clone, Copy, Debug)]
177enum MinimaxOauthRegion {
178    Global,
179    Cn,
180}
181
182impl MinimaxOauthRegion {
183    fn token_endpoint(self) -> &'static str {
184        match self {
185            Self::Global => MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT,
186            Self::Cn => MINIMAX_OAUTH_CN_TOKEN_ENDPOINT,
187        }
188    }
189}
190
191#[derive(Debug, Deserialize)]
192struct MinimaxOauthRefreshResponse {
193    #[serde(default)]
194    status: Option<String>,
195    #[serde(default)]
196    access_token: Option<String>,
197    #[serde(default)]
198    base_resp: Option<MinimaxOauthBaseResponse>,
199}
200
201#[derive(Debug, Deserialize)]
202struct MinimaxOauthBaseResponse {
203    #[serde(default)]
204    status_msg: Option<String>,
205}
206
207#[derive(Clone, Deserialize, Default)]
208struct QwenOauthCredentials {
209    #[serde(default)]
210    access_token: Option<String>,
211    #[serde(default)]
212    refresh_token: Option<String>,
213    #[serde(default)]
214    resource_url: Option<String>,
215    #[serde(default)]
216    expiry_date: Option<i64>,
217}
218
219impl std::fmt::Debug for QwenOauthCredentials {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        f.debug_struct("QwenOauthCredentials")
222            .field("resource_url", &self.resource_url)
223            .field("expiry_date", &self.expiry_date)
224            .finish_non_exhaustive()
225    }
226}
227
228#[derive(Debug, Deserialize)]
229struct QwenOauthTokenResponse {
230    #[serde(default)]
231    access_token: Option<String>,
232    #[serde(default)]
233    refresh_token: Option<String>,
234    #[serde(default)]
235    expires_in: Option<i64>,
236    #[serde(default)]
237    resource_url: Option<String>,
238    #[serde(default)]
239    error: Option<String>,
240    #[serde(default)]
241    error_description: Option<String>,
242}
243
244#[derive(Clone, Default)]
245struct QwenOauthProviderContext {
246    credential: Option<String>,
247    base_url: Option<String>,
248}
249
250impl std::fmt::Debug for QwenOauthProviderContext {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        f.debug_struct("QwenOauthProviderContext")
253            .field("base_url", &self.base_url)
254            .finish_non_exhaustive()
255    }
256}
257
258fn read_non_empty_env(name: &str) -> Option<String> {
259    std::env::var(name)
260        .ok()
261        .map(|value| value.trim().to_string())
262        .filter(|value| !value.is_empty())
263}
264
265fn is_minimax_oauth_placeholder(value: &str) -> bool {
266    value.eq_ignore_ascii_case(MINIMAX_OAUTH_PLACEHOLDER)
267        || value.eq_ignore_ascii_case(MINIMAX_OAUTH_CN_PLACEHOLDER)
268}
269
270fn minimax_oauth_region(name: &str) -> MinimaxOauthRegion {
271    if let Some(region) = read_non_empty_env(MINIMAX_OAUTH_REGION_ENV) {
272        let normalized = region.to_ascii_lowercase();
273        if matches!(normalized.as_str(), "cn" | "china") {
274            return MinimaxOauthRegion::Cn;
275        }
276        if matches!(normalized.as_str(), "global" | "intl" | "international") {
277            return MinimaxOauthRegion::Global;
278        }
279    }
280
281    if is_minimax_cn_alias(name) {
282        MinimaxOauthRegion::Cn
283    } else {
284        MinimaxOauthRegion::Global
285    }
286}
287
288fn minimax_oauth_client_id() -> String {
289    read_non_empty_env(MINIMAX_OAUTH_CLIENT_ID_ENV)
290        .unwrap_or_else(|| MINIMAX_OAUTH_DEFAULT_CLIENT_ID.to_string())
291}
292
293fn qwen_oauth_client_id() -> String {
294    read_non_empty_env(QWEN_OAUTH_CLIENT_ID_ENV)
295        .unwrap_or_else(|| QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string())
296}
297
298fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
299    std::env::var_os("HOME")
300        .map(PathBuf::from)
301        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
302        .map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
303}
304
305fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
306    let trimmed = raw.trim().trim_end_matches('/');
307    if trimmed.is_empty() {
308        return None;
309    }
310
311    let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
312        trimmed.to_string()
313    } else {
314        format!("https://{trimmed}")
315    };
316
317    let normalized = with_scheme.trim_end_matches('/').to_string();
318    if normalized.ends_with("/v1") {
319        Some(normalized)
320    } else {
321        Some(format!("{normalized}/v1"))
322    }
323}
324
325fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
326    let path = qwen_oauth_credentials_file_path()?;
327    let content = std::fs::read_to_string(path).ok()?;
328    serde_json::from_str::<QwenOauthCredentials>(&content).ok()
329}
330
331fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
332    if raw < 10_000_000_000 {
333        raw.saturating_mul(1000)
334    } else {
335        raw
336    }
337}
338
339fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
340    let Some(expiry) = credentials.expiry_date else {
341        return false;
342    };
343
344    let expiry_millis = normalized_qwen_expiry_millis(expiry);
345    let now_millis = std::time::SystemTime::now()
346        .duration_since(std::time::UNIX_EPOCH)
347        .ok()
348        .and_then(|duration| i64::try_from(duration.as_millis()).ok())
349        .unwrap_or(i64::MAX);
350
351    expiry_millis <= now_millis.saturating_add(30_000)
352}
353
354fn refresh_qwen_oauth_access_token(refresh_token: &str) -> anyhow::Result<QwenOauthCredentials> {
355    let client_id = qwen_oauth_client_id();
356    let client = reqwest::blocking::Client::builder()
357        .timeout(std::time::Duration::from_secs(15))
358        .connect_timeout(std::time::Duration::from_secs(5))
359        .build()
360        .unwrap_or_else(|_| reqwest::blocking::Client::new());
361
362    let response = client
363        .post(QWEN_OAUTH_TOKEN_ENDPOINT)
364        .header("Content-Type", "application/x-www-form-urlencoded")
365        .header("Accept", "application/json")
366        .form(&[
367            ("grant_type", "refresh_token"),
368            ("refresh_token", refresh_token),
369            ("client_id", client_id.as_str()),
370        ])
371        .send()
372        .map_err(|error| anyhow::anyhow!("Qwen OAuth refresh request failed: {error}"))?;
373
374    let status = response.status();
375    let body = response
376        .text()
377        .unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
378
379    let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
380
381    if !status.is_success() {
382        let detail = parsed
383            .as_ref()
384            .and_then(|payload| payload.error_description.as_deref())
385            .or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
386            .filter(|msg| !msg.trim().is_empty())
387            .unwrap_or(body.as_str());
388        anyhow::bail!("Qwen OAuth refresh failed (HTTP {status}): {detail}");
389    }
390
391    let payload =
392        parsed.ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response is not JSON"))?;
393
394    if let Some(error_code) = payload
395        .error
396        .as_deref()
397        .filter(|value| !value.trim().is_empty())
398    {
399        let detail = payload.error_description.as_deref().unwrap_or(error_code);
400        anyhow::bail!("Qwen OAuth refresh failed: {detail}");
401    }
402
403    let access_token = payload
404        .access_token
405        .as_deref()
406        .map(str::trim)
407        .filter(|token| !token.is_empty())
408        .ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response missing access_token"))?
409        .to_string();
410
411    let expiry_date = payload.expires_in.and_then(|seconds| {
412        let now_secs = std::time::SystemTime::now()
413            .duration_since(std::time::UNIX_EPOCH)
414            .ok()
415            .and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
416        now_secs
417            .checked_add(seconds)
418            .and_then(|unix_secs| unix_secs.checked_mul(1000))
419    });
420
421    Ok(QwenOauthCredentials {
422        access_token: Some(access_token),
423        refresh_token: payload
424            .refresh_token
425            .as_deref()
426            .map(str::trim)
427            .filter(|value| !value.is_empty())
428            .map(ToString::to_string),
429        resource_url: payload
430            .resource_url
431            .as_deref()
432            .map(str::trim)
433            .filter(|value| !value.is_empty())
434            .map(ToString::to_string),
435        expiry_date,
436    })
437}
438
439fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
440    let override_value = credential_override
441        .map(str::trim)
442        .filter(|value| !value.is_empty());
443    let placeholder_requested = override_value
444        .map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
445        .unwrap_or(false);
446
447    if let Some(explicit) = override_value {
448        if !placeholder_requested {
449            return QwenOauthProviderContext {
450                credential: Some(explicit.to_string()),
451                base_url: None,
452            };
453        }
454    }
455
456    let mut cached = read_qwen_oauth_cached_credentials();
457
458    let env_token = read_non_empty_env(QWEN_OAUTH_TOKEN_ENV);
459    let env_refresh_token = read_non_empty_env(QWEN_OAUTH_REFRESH_TOKEN_ENV);
460    let env_resource_url = read_non_empty_env(QWEN_OAUTH_RESOURCE_URL_ENV);
461
462    if env_token.is_none() {
463        let refresh_token = env_refresh_token.clone().or_else(|| {
464            cached
465                .as_ref()
466                .and_then(|credentials| credentials.refresh_token.clone())
467        });
468
469        let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
470            || cached
471                .as_ref()
472                .and_then(|credentials| credentials.access_token.as_deref())
473                .is_none_or(|value| value.trim().is_empty());
474
475        if should_refresh {
476            if let Some(refresh_token) = refresh_token.as_deref() {
477                match refresh_qwen_oauth_access_token(refresh_token) {
478                    Ok(refreshed) => {
479                        cached = Some(refreshed);
480                    }
481                    Err(error) => {
482                        tracing::warn!(error = %error, "Qwen OAuth refresh failed");
483                    }
484                }
485            }
486        }
487    }
488
489    let mut credential = env_token.or_else(|| {
490        cached
491            .as_ref()
492            .and_then(|credentials| credentials.access_token.clone())
493    });
494    credential = credential
495        .as_deref()
496        .map(str::trim)
497        .filter(|value| !value.is_empty())
498        .map(ToString::to_string);
499
500    if credential.is_none() && !placeholder_requested {
501        credential = read_non_empty_env("DASHSCOPE_API_KEY");
502    }
503
504    let base_url = env_resource_url
505        .as_deref()
506        .and_then(normalize_qwen_oauth_base_url)
507        .or_else(|| {
508            cached
509                .as_ref()
510                .and_then(|credentials| credentials.resource_url.as_deref())
511                .and_then(normalize_qwen_oauth_base_url)
512        });
513
514    QwenOauthProviderContext {
515        credential,
516        base_url,
517    }
518}
519
520fn resolve_minimax_static_credential() -> Option<String> {
521    read_non_empty_env(MINIMAX_OAUTH_TOKEN_ENV).or_else(|| read_non_empty_env(MINIMAX_API_KEY_ENV))
522}
523
524fn refresh_minimax_oauth_access_token(name: &str, refresh_token: &str) -> anyhow::Result<String> {
525    let region = minimax_oauth_region(name);
526    let endpoint = region.token_endpoint();
527    let client_id = minimax_oauth_client_id();
528    let client = reqwest::blocking::Client::builder()
529        .timeout(std::time::Duration::from_secs(15))
530        .connect_timeout(std::time::Duration::from_secs(5))
531        .build()
532        .unwrap_or_else(|_| reqwest::blocking::Client::new());
533
534    let response = client
535        .post(endpoint)
536        .header("Content-Type", "application/x-www-form-urlencoded")
537        .header("Accept", "application/json")
538        .form(&[
539            ("grant_type", "refresh_token"),
540            ("refresh_token", refresh_token),
541            ("client_id", client_id.as_str()),
542        ])
543        .send()
544        .map_err(|error| anyhow::anyhow!("MiniMax OAuth refresh request failed: {error}"))?;
545
546    let status = response.status();
547    let body = response
548        .text()
549        .unwrap_or_else(|_| "<failed to read MiniMax OAuth response body>".to_string());
550
551    let parsed = serde_json::from_str::<MinimaxOauthRefreshResponse>(&body).ok();
552
553    if !status.is_success() {
554        let detail = parsed
555            .as_ref()
556            .and_then(|payload| payload.base_resp.as_ref())
557            .and_then(|base| base.status_msg.as_deref())
558            .filter(|msg| !msg.trim().is_empty())
559            .unwrap_or(body.as_str());
560        anyhow::bail!("MiniMax OAuth refresh failed (HTTP {status}): {detail}");
561    }
562
563    if let Some(payload) = parsed {
564        if let Some(status_text) = payload.status.as_deref() {
565            if !status_text.eq_ignore_ascii_case("success") {
566                let detail = payload
567                    .base_resp
568                    .as_ref()
569                    .and_then(|base| base.status_msg.as_deref())
570                    .unwrap_or(status_text);
571                anyhow::bail!("MiniMax OAuth refresh failed: {detail}");
572            }
573        }
574
575        if let Some(token) = payload
576            .access_token
577            .as_deref()
578            .map(str::trim)
579            .filter(|token| !token.is_empty())
580        {
581            return Ok(token.to_string());
582        }
583    }
584
585    anyhow::bail!("MiniMax OAuth refresh response missing access_token");
586}
587
588fn resolve_minimax_oauth_refresh_token(name: &str) -> Option<String> {
589    let refresh_token = read_non_empty_env(MINIMAX_OAUTH_REFRESH_TOKEN_ENV)?;
590
591    match refresh_minimax_oauth_access_token(name, &refresh_token) {
592        Ok(token) => Some(token),
593        Err(error) => {
594            tracing::warn!(provider = name, error = %error, "MiniMax OAuth refresh failed");
595            None
596        }
597    }
598}
599
600pub(crate) fn canonical_china_provider_name(name: &str) -> Option<&'static str> {
601    if is_qwen_alias(name) {
602        Some("qwen")
603    } else if is_glm_alias(name) {
604        Some("glm")
605    } else if is_moonshot_alias(name) {
606        Some("moonshot")
607    } else if is_minimax_alias(name) {
608        Some("minimax")
609    } else if is_zai_alias(name) {
610        Some("zai")
611    } else if is_qianfan_alias(name) {
612        Some("qianfan")
613    } else if is_doubao_alias(name) {
614        Some("doubao")
615    } else {
616        None
617    }
618}
619
620fn minimax_base_url(name: &str) -> Option<&'static str> {
621    if is_minimax_cn_alias(name) {
622        Some(MINIMAX_CN_BASE_URL)
623    } else if is_minimax_intl_alias(name) {
624        Some(MINIMAX_INTL_BASE_URL)
625    } else {
626        None
627    }
628}
629
630fn glm_base_url(name: &str) -> Option<&'static str> {
631    if is_glm_cn_alias(name) {
632        Some(GLM_CN_BASE_URL)
633    } else if is_glm_global_alias(name) {
634        Some(GLM_GLOBAL_BASE_URL)
635    } else {
636        None
637    }
638}
639
640fn moonshot_base_url(name: &str) -> Option<&'static str> {
641    if is_moonshot_intl_alias(name) {
642        Some(MOONSHOT_INTL_BASE_URL)
643    } else if is_moonshot_cn_alias(name) {
644        Some(MOONSHOT_CN_BASE_URL)
645    } else {
646        None
647    }
648}
649
650fn qwen_base_url(name: &str) -> Option<&'static str> {
651    if is_qwen_cn_alias(name) || is_qwen_oauth_alias(name) {
652        Some(QWEN_CN_BASE_URL)
653    } else if is_qwen_intl_alias(name) {
654        Some(QWEN_INTL_BASE_URL)
655    } else if is_qwen_us_alias(name) {
656        Some(QWEN_US_BASE_URL)
657    } else {
658        None
659    }
660}
661
662fn zai_base_url(name: &str) -> Option<&'static str> {
663    if is_zai_cn_alias(name) {
664        Some(ZAI_CN_BASE_URL)
665    } else if is_zai_global_alias(name) {
666        Some(ZAI_GLOBAL_BASE_URL)
667    } else {
668        None
669    }
670}
671
672#[derive(Debug, Clone)]
673pub struct ProviderRuntimeOptions {
674    pub auth_profile_override: Option<String>,
675    pub provider_api_url: Option<String>,
676    pub zeroclaw_dir: Option<PathBuf>,
677    pub secrets_encrypt: bool,
678    pub reasoning_enabled: Option<bool>,
679}
680
681impl Default for ProviderRuntimeOptions {
682    fn default() -> Self {
683        Self {
684            auth_profile_override: None,
685            provider_api_url: None,
686            zeroclaw_dir: None,
687            secrets_encrypt: true,
688            reasoning_enabled: None,
689        }
690    }
691}
692
693fn is_secret_char(c: char) -> bool {
694    c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
695}
696
697fn token_end(input: &str, from: usize) -> usize {
698    let mut end = from;
699    for (i, c) in input[from..].char_indices() {
700        if is_secret_char(c) {
701            end = from + i + c.len_utf8();
702        } else {
703            break;
704        }
705    }
706    end
707}
708
709/// Scrub known secret-like token prefixes from provider error strings.
710///
711/// Redacts tokens with prefixes like `sk-`, `xoxb-`, `xoxp-`, `ghp_`, `gho_`,
712/// `ghu_`, and `github_pat_`.
713pub fn scrub_secret_patterns(input: &str) -> String {
714    const PREFIXES: [&str; 7] = [
715        "sk-",
716        "xoxb-",
717        "xoxp-",
718        "ghp_",
719        "gho_",
720        "ghu_",
721        "github_pat_",
722    ];
723
724    let mut scrubbed = input.to_string();
725
726    for prefix in PREFIXES {
727        let mut search_from = 0;
728        loop {
729            let Some(rel) = scrubbed[search_from..].find(prefix) else {
730                break;
731            };
732
733            let start = search_from + rel;
734            let content_start = start + prefix.len();
735            let end = token_end(&scrubbed, content_start);
736
737            // Bare prefixes like "sk-" should not stop future scans.
738            if end == content_start {
739                search_from = content_start;
740                continue;
741            }
742
743            scrubbed.replace_range(start..end, "[REDACTED]");
744            search_from = start + "[REDACTED]".len();
745        }
746    }
747
748    scrubbed
749}
750
751/// Sanitize API error text by scrubbing secrets and truncating length.
752pub fn sanitize_api_error(input: &str) -> String {
753    let scrubbed = scrub_secret_patterns(input);
754
755    if scrubbed.chars().count() <= MAX_API_ERROR_CHARS {
756        return scrubbed;
757    }
758
759    let mut end = MAX_API_ERROR_CHARS;
760    while end > 0 && !scrubbed.is_char_boundary(end) {
761        end -= 1;
762    }
763
764    format!("{}...", &scrubbed[..end])
765}
766
767/// Build a sanitized provider error from a failed HTTP response.
768pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::Error {
769    let status = response.status();
770    let body = response
771        .text()
772        .await
773        .unwrap_or_else(|_| "<failed to read provider error body>".to_string());
774    let sanitized = sanitize_api_error(&body);
775    anyhow::anyhow!("{provider} API error ({status}): {sanitized}")
776}
777
778/// Resolve API key for a provider from config and environment variables.
779///
780/// Resolution order:
781/// 1. Explicitly provided `api_key` parameter (trimmed, filtered if empty)
782/// 2. Provider-specific environment variable (e.g., `ANTHROPIC_OAUTH_TOKEN`, `OPENROUTER_API_KEY`)
783/// 3. Generic fallback variables (`ZEROCLAW_API_KEY`, `API_KEY`)
784///
785/// For Anthropic, the provider-specific env var is `ANTHROPIC_OAUTH_TOKEN` (for setup-tokens)
786/// followed by `ANTHROPIC_API_KEY` (for regular API keys).
787///
788/// For MiniMax, OAuth mode supports `api_key = "minimax-oauth"`, resolving credentials from
789/// `MINIMAX_OAUTH_TOKEN` first, then `MINIMAX_API_KEY`, and finally
790/// `MINIMAX_OAUTH_REFRESH_TOKEN` (automatic access-token refresh).
791fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> Option<String> {
792    let mut minimax_oauth_placeholder_requested = false;
793
794    if let Some(raw_override) = credential_override {
795        let trimmed_override = raw_override.trim();
796        if !trimmed_override.is_empty() {
797            if is_minimax_alias(name) && is_minimax_oauth_placeholder(trimmed_override) {
798                minimax_oauth_placeholder_requested = true;
799                if let Some(credential) = resolve_minimax_static_credential() {
800                    return Some(credential);
801                }
802                if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
803                    return Some(credential);
804                }
805            } else {
806                return Some(trimmed_override.to_owned());
807            }
808        }
809    }
810
811    let provider_env_candidates: Vec<&str> = match name {
812        "anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
813        "openrouter" => vec!["OPENROUTER_API_KEY"],
814        "openai" => vec!["OPENAI_API_KEY"],
815        "ollama" => vec!["OLLAMA_API_KEY"],
816        "venice" => vec!["VENICE_API_KEY"],
817        "groq" => vec!["GROQ_API_KEY"],
818        "mistral" => vec!["MISTRAL_API_KEY"],
819        "deepseek" => vec!["DEEPSEEK_API_KEY"],
820        "xai" | "grok" => vec!["XAI_API_KEY"],
821        "together" | "together-ai" => vec!["TOGETHER_API_KEY"],
822        "fireworks" | "fireworks-ai" => vec!["FIREWORKS_API_KEY"],
823        "novita" => vec!["NOVITA_API_KEY"],
824        "perplexity" => vec!["PERPLEXITY_API_KEY"],
825        "cohere" => vec!["COHERE_API_KEY"],
826        name if is_moonshot_alias(name) => vec!["MOONSHOT_API_KEY"],
827        "kimi-code" | "kimi_coding" | "kimi_for_coding" => {
828            vec!["KIMI_CODE_API_KEY", "MOONSHOT_API_KEY"]
829        }
830        name if is_glm_alias(name) => vec!["GLM_API_KEY"],
831        name if is_minimax_alias(name) => vec![MINIMAX_OAUTH_TOKEN_ENV, MINIMAX_API_KEY_ENV],
832        // Bedrock uses AWS AKSK from env vars (AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY),
833        // not a single API key. Credential resolution happens inside BedrockProvider.
834        "bedrock" | "aws-bedrock" => return None,
835        name if is_qianfan_alias(name) => vec!["QIANFAN_API_KEY"],
836        name if is_doubao_alias(name) => vec!["ARK_API_KEY", "DOUBAO_API_KEY"],
837        name if is_qwen_alias(name) => vec!["DASHSCOPE_API_KEY"],
838        name if is_zai_alias(name) => vec!["ZAI_API_KEY"],
839        "nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
840        "synthetic" => vec!["SYNTHETIC_API_KEY"],
841        "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
842        "vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
843        "cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
844        "ovhcloud" | "ovh" => vec!["OVH_AI_ENDPOINTS_ACCESS_TOKEN"],
845        "astrai" => vec!["ASTRAI_API_KEY"],
846        "llamacpp" | "llama.cpp" => vec!["LLAMACPP_API_KEY"],
847        "sglang" => vec!["SGLANG_API_KEY"],
848        "vllm" => vec!["VLLM_API_KEY"],
849        "osaurus" => vec!["OSAURUS_API_KEY"],
850        "telnyx" => vec!["TELNYX_API_KEY"],
851        _ => vec![],
852    };
853
854    for env_var in provider_env_candidates {
855        if let Ok(value) = std::env::var(env_var) {
856            let value = value.trim();
857            if !value.is_empty() {
858                return Some(value.to_string());
859            }
860        }
861    }
862
863    if is_minimax_alias(name) {
864        if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
865            return Some(credential);
866        }
867    }
868
869    if minimax_oauth_placeholder_requested && is_minimax_alias(name) {
870        return None;
871    }
872
873    for env_var in ["ZEROCLAW_API_KEY", "API_KEY"] {
874        if let Ok(value) = std::env::var(env_var) {
875            let value = value.trim();
876            if !value.is_empty() {
877                return Some(value.to_string());
878            }
879        }
880    }
881
882    None
883}
884
885fn parse_custom_provider_url(
886    raw_url: &str,
887    provider_label: &str,
888    format_hint: &str,
889) -> anyhow::Result<String> {
890    let base_url = raw_url.trim();
891
892    if base_url.is_empty() {
893        anyhow::bail!("{provider_label} requires a URL. Format: {format_hint}");
894    }
895
896    let parsed = reqwest::Url::parse(base_url).map_err(|_| {
897        anyhow::anyhow!("{provider_label} requires a valid URL. Format: {format_hint}")
898    })?;
899
900    match parsed.scheme() {
901        "http" | "https" => Ok(base_url.to_string()),
902        _ => anyhow::bail!(
903            "{provider_label} requires an http:// or https:// URL. Format: {format_hint}"
904        ),
905    }
906}
907
908/// Factory: create the right provider from config (without custom URL)
909pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<dyn Provider>> {
910    create_provider_with_options(name, api_key, &ProviderRuntimeOptions::default())
911}
912
913/// Factory: create provider with runtime options (auth profile override, state dir).
914pub fn create_provider_with_options(
915    name: &str,
916    api_key: Option<&str>,
917    options: &ProviderRuntimeOptions,
918) -> anyhow::Result<Box<dyn Provider>> {
919    match name {
920        "openai-codex" | "openai_codex" | "codex" => Ok(Box::new(
921            openai_codex::OpenAiCodexProvider::new(options, api_key)?,
922        )),
923        _ => create_provider_with_url_and_options(name, api_key, None, options),
924    }
925}
926
927/// Factory: create the right provider from config with optional custom base URL
928pub fn create_provider_with_url(
929    name: &str,
930    api_key: Option<&str>,
931    api_url: Option<&str>,
932) -> anyhow::Result<Box<dyn Provider>> {
933    create_provider_with_url_and_options(name, api_key, api_url, &ProviderRuntimeOptions::default())
934}
935
936/// Factory: create provider with optional base URL and runtime options.
937#[allow(clippy::too_many_lines)]
938fn create_provider_with_url_and_options(
939    name: &str,
940    api_key: Option<&str>,
941    api_url: Option<&str>,
942    options: &ProviderRuntimeOptions,
943) -> anyhow::Result<Box<dyn Provider>> {
944    let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key));
945
946    // Resolve credential and break static-analysis taint chain from the
947    // `api_key` parameter so that downstream provider storage of the value
948    // is not linked to the original sensitive-named source.
949    let resolved_credential = if let Some(context) = qwen_oauth_context.as_ref() {
950        context.credential.clone()
951    } else {
952        resolve_provider_credential(name, api_key)
953    }
954    .map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
955    #[allow(clippy::option_as_ref_deref)]
956    let key = resolved_credential.as_ref().map(String::as_str);
957    match name {
958        "openai-codex" | "openai_codex" | "codex" => {
959            let mut codex_options = options.clone();
960            codex_options.provider_api_url = api_url
961                .map(str::trim)
962                .filter(|value| !value.is_empty())
963                .map(ToString::to_string)
964                .or_else(|| options.provider_api_url.clone());
965            Ok(Box::new(openai_codex::OpenAiCodexProvider::new(
966                &codex_options,
967                key,
968            )?))
969        }
970        // ── Primary providers (custom implementations) ───────
971        "openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))),
972        "anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))),
973        "openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))),
974        // Ollama uses api_url for custom base URL (e.g. remote Ollama instance)
975        "ollama" => Ok(Box::new(ollama::OllamaProvider::new_with_reasoning(
976            api_url,
977            key,
978            options.reasoning_enabled,
979        ))),
980        "gemini" | "google" | "google-gemini" => {
981            let state_dir = options
982                .zeroclaw_dir
983                .clone()
984                .unwrap_or_else(|| {
985                    directories::UserDirs::new().map_or_else(
986                        || PathBuf::from(".zeroclaw"),
987                        |dirs| dirs.home_dir().join(".zeroclaw"),
988                    )
989                });
990            let auth_service = AuthService::new(&state_dir, options.secrets_encrypt);
991            Ok(Box::new(gemini::GeminiProvider::new_with_auth(
992                key,
993                auth_service,
994                options.auth_profile_override.clone(),
995            )))
996        }
997        "telnyx" => Ok(Box::new(telnyx::TelnyxProvider::new(key))),
998
999        // ── OpenAI-compatible providers ──────────────────────
1000        "venice" => Ok(Box::new(OpenAiCompatibleProvider::new(
1001            "Venice", "https://api.venice.ai", key, AuthStyle::Bearer,
1002        ))),
1003        "vercel" | "vercel-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1004            "Vercel AI Gateway",
1005            VERCEL_AI_GATEWAY_BASE_URL,
1006            key,
1007            AuthStyle::Bearer,
1008        ))),
1009        "cloudflare" | "cloudflare-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1010            "Cloudflare AI Gateway",
1011            "https://gateway.ai.cloudflare.com/v1",
1012            key,
1013            AuthStyle::Bearer,
1014        ))),
1015        name if moonshot_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
1016            "Moonshot",
1017            moonshot_base_url(name).expect("checked in guard"),
1018            key,
1019            AuthStyle::Bearer,
1020        ))),
1021        "kimi-code" | "kimi_coding" | "kimi_for_coding" => Ok(Box::new(
1022            OpenAiCompatibleProvider::new_with_user_agent(
1023                "Kimi Code",
1024                "https://api.kimi.com/coding/v1",
1025                key,
1026                AuthStyle::Bearer,
1027                "KimiCLI/0.77",
1028            ),
1029        )),
1030        "synthetic" => Ok(Box::new(OpenAiCompatibleProvider::new(
1031            "Synthetic", "https://api.synthetic.new/openai/v1", key, AuthStyle::Bearer,
1032        ))),
1033        "opencode" | "opencode-zen" => Ok(Box::new(OpenAiCompatibleProvider::new(
1034            "OpenCode Zen", "https://opencode.ai/zen/v1", key, AuthStyle::Bearer,
1035        ))),
1036        name if zai_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
1037            "Z.AI",
1038            zai_base_url(name).expect("checked in guard"),
1039            key,
1040            AuthStyle::Bearer,
1041        ))),
1042        name if glm_base_url(name).is_some() => {
1043            Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback(
1044                "GLM",
1045                glm_base_url(name).expect("checked in guard"),
1046                key,
1047                AuthStyle::Bearer,
1048            )))
1049        }
1050        name if minimax_base_url(name).is_some() => Ok(Box::new(
1051            OpenAiCompatibleProvider::new_merge_system_into_user(
1052                "MiniMax",
1053                minimax_base_url(name).expect("checked in guard"),
1054                key,
1055                AuthStyle::Bearer,
1056            )
1057        )),
1058        "bedrock" | "aws-bedrock" => Ok(Box::new(bedrock::BedrockProvider::new())),
1059        name if is_qwen_oauth_alias(name) => {
1060            let base_url = api_url
1061                .map(str::trim)
1062                .filter(|value| !value.is_empty())
1063                .map(ToString::to_string)
1064                .or_else(|| qwen_oauth_context.as_ref().and_then(|context| context.base_url.clone()))
1065                .unwrap_or_else(|| QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
1066
1067            Ok(Box::new(
1068                OpenAiCompatibleProvider::new_with_user_agent_and_vision(
1069                "Qwen Code",
1070                &base_url,
1071                key,
1072                AuthStyle::Bearer,
1073                "QwenCode/1.0",
1074                true,
1075            )))
1076        }
1077        name if is_qianfan_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
1078            "Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer,
1079        ))),
1080        name if is_doubao_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
1081            "Doubao",
1082            "https://ark.cn-beijing.volces.com/api/v3",
1083            key,
1084            AuthStyle::Bearer,
1085        ))),
1086        name if qwen_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new_with_vision(
1087            "Qwen",
1088            qwen_base_url(name).expect("checked in guard"),
1089            key,
1090            AuthStyle::Bearer,
1091            true,
1092        ))),
1093
1094        // ── Extended ecosystem (community favorites) ─────────
1095        "groq" => Ok(Box::new(OpenAiCompatibleProvider::new(
1096            "Groq", "https://api.groq.com/openai/v1", key, AuthStyle::Bearer,
1097        ))),
1098        "mistral" => Ok(Box::new(OpenAiCompatibleProvider::new(
1099            "Mistral", "https://api.mistral.ai/v1", key, AuthStyle::Bearer,
1100        ))),
1101        "xai" | "grok" => Ok(Box::new(OpenAiCompatibleProvider::new(
1102            "xAI", "https://api.x.ai", key, AuthStyle::Bearer,
1103        ))),
1104        "deepseek" => Ok(Box::new(OpenAiCompatibleProvider::new(
1105            "DeepSeek", "https://api.deepseek.com", key, AuthStyle::Bearer,
1106        ))),
1107        "together" | "together-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1108            "Together AI", "https://api.together.xyz", key, AuthStyle::Bearer,
1109        ))),
1110        "fireworks" | "fireworks-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1111            "Fireworks AI", "https://api.fireworks.ai/inference/v1", key, AuthStyle::Bearer,
1112        ))),
1113        "novita" => Ok(Box::new(OpenAiCompatibleProvider::new(
1114            "Novita AI", "https://api.novita.ai/openai", key, AuthStyle::Bearer,
1115        ))),
1116        "perplexity" => Ok(Box::new(OpenAiCompatibleProvider::new(
1117            "Perplexity", "https://api.perplexity.ai", key, AuthStyle::Bearer,
1118        ))),
1119        "cohere" => Ok(Box::new(OpenAiCompatibleProvider::new(
1120            "Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer,
1121        ))),
1122        "copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))),
1123        "lmstudio" | "lm-studio" => {
1124            let lm_studio_key = key
1125                .map(str::trim)
1126                .filter(|value| !value.is_empty())
1127                .unwrap_or("lm-studio");
1128            Ok(Box::new(OpenAiCompatibleProvider::new(
1129                "LM Studio",
1130                "http://localhost:1234/v1",
1131                Some(lm_studio_key),
1132                AuthStyle::Bearer,
1133            )))
1134        }
1135        "llamacpp" | "llama.cpp" => {
1136            let base_url = api_url
1137                .map(str::trim)
1138                .filter(|value| !value.is_empty())
1139                .unwrap_or("http://localhost:8080/v1");
1140            let llama_cpp_key = key
1141                .map(str::trim)
1142                .filter(|value| !value.is_empty())
1143                .unwrap_or("llama.cpp");
1144            Ok(Box::new(OpenAiCompatibleProvider::new(
1145                "llama.cpp",
1146                base_url,
1147                Some(llama_cpp_key),
1148                AuthStyle::Bearer,
1149            )))
1150        }
1151        "sglang" => {
1152            let base_url = api_url
1153                .map(str::trim)
1154                .filter(|value| !value.is_empty())
1155                .unwrap_or("http://localhost:30000/v1");
1156            Ok(Box::new(OpenAiCompatibleProvider::new(
1157                "SGLang",
1158                base_url,
1159                key,
1160                AuthStyle::Bearer,
1161            )))
1162        }
1163        "vllm" => {
1164            let base_url = api_url
1165                .map(str::trim)
1166                .filter(|value| !value.is_empty())
1167                .unwrap_or("http://localhost:8000/v1");
1168            Ok(Box::new(OpenAiCompatibleProvider::new(
1169                "vLLM",
1170                base_url,
1171                key,
1172                AuthStyle::Bearer,
1173            )))
1174        }
1175        "osaurus" => {
1176            let base_url = api_url
1177                .map(str::trim)
1178                .filter(|value| !value.is_empty())
1179                .unwrap_or("http://localhost:1337/v1");
1180            let osaurus_key = key
1181                .map(str::trim)
1182                .filter(|value| !value.is_empty())
1183                .unwrap_or("osaurus");
1184            Ok(Box::new(OpenAiCompatibleProvider::new(
1185                "Osaurus",
1186                base_url,
1187                Some(osaurus_key),
1188                AuthStyle::Bearer,
1189            )))
1190        }
1191        "nvidia" | "nvidia-nim" | "build.nvidia.com" => Ok(Box::new(
1192            OpenAiCompatibleProvider::new_no_responses_fallback(
1193                "NVIDIA NIM",
1194                "https://integrate.api.nvidia.com/v1",
1195                key,
1196                AuthStyle::Bearer,
1197            ),
1198        )),
1199
1200        // ── AI inference routers ─────────────────────────────
1201        "astrai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1202            "Astrai", "https://as-trai.com/v1", key, AuthStyle::Bearer,
1203        ))),
1204
1205        // ── Cloud AI endpoints ───────────────────────────────
1206        "ovhcloud" | "ovh" => Ok(Box::new(openai::OpenAiProvider::with_base_url(
1207            Some("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"),
1208            key,
1209        ))),
1210
1211        // ── Bring Your Own Provider (custom URL) ───────────
1212        // Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
1213        name if name.starts_with("custom:") => {
1214            let base_url = parse_custom_provider_url(
1215                name.strip_prefix("custom:").unwrap_or(""),
1216                "Custom provider",
1217                "custom:https://your-api.com",
1218            )?;
1219            Ok(Box::new(OpenAiCompatibleProvider::new_with_vision(
1220                "Custom",
1221                &base_url,
1222                key,
1223                AuthStyle::Bearer,
1224                true,
1225            )))
1226        }
1227
1228        // ── Anthropic-compatible custom endpoints ───────────
1229        // Format: "anthropic-custom:https://your-api.com"
1230        name if name.starts_with("anthropic-custom:") => {
1231            let base_url = parse_custom_provider_url(
1232                name.strip_prefix("anthropic-custom:").unwrap_or(""),
1233                "Anthropic-custom provider",
1234                "anthropic-custom:https://your-api.com",
1235            )?;
1236            Ok(Box::new(anthropic::AnthropicProvider::with_base_url(
1237                key,
1238                Some(&base_url),
1239            )))
1240        }
1241
1242        _ => anyhow::bail!(
1243            "Unknown provider: {name}. Check README for supported providers or run `zeroclaw onboard --interactive` to reconfigure.\n\
1244             Tip: Use \"custom:https://your-api.com\" for OpenAI-compatible endpoints.\n\
1245             Tip: Use \"anthropic-custom:https://your-api.com\" for Anthropic-compatible endpoints."
1246        ),
1247    }
1248}
1249
1250/// Parse `"provider:profile"` syntax for fallback entries.
1251///
1252/// Returns `(provider_name, Some(profile))` when the entry contains a colon-
1253/// delimited profile, or `(original_str, None)` otherwise.  Entries starting
1254/// with `custom:` or `anthropic-custom:` are left untouched because the colon
1255/// is part of the URL scheme.
1256fn parse_provider_profile(s: &str) -> (&str, Option<&str>) {
1257    if s.starts_with("custom:") || s.starts_with("anthropic-custom:") {
1258        return (s, None);
1259    }
1260    match s.split_once(':') {
1261        Some((provider, profile)) if !profile.is_empty() => (provider, Some(profile)),
1262        _ => (s, None),
1263    }
1264}
1265
1266/// Create provider chain with retry and fallback behavior.
1267pub fn create_resilient_provider(
1268    primary_name: &str,
1269    api_key: Option<&str>,
1270    api_url: Option<&str>,
1271    reliability: &crate::config::ReliabilityConfig,
1272) -> anyhow::Result<Box<dyn Provider>> {
1273    create_resilient_provider_with_options(
1274        primary_name,
1275        api_key,
1276        api_url,
1277        reliability,
1278        &ProviderRuntimeOptions::default(),
1279    )
1280}
1281
1282/// Create provider chain with retry/fallback behavior and auth runtime options.
1283pub fn create_resilient_provider_with_options(
1284    primary_name: &str,
1285    api_key: Option<&str>,
1286    api_url: Option<&str>,
1287    reliability: &crate::config::ReliabilityConfig,
1288    options: &ProviderRuntimeOptions,
1289) -> anyhow::Result<Box<dyn Provider>> {
1290    let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
1291
1292    let primary_provider = match primary_name {
1293        "openai-codex" | "openai_codex" | "codex" => {
1294            create_provider_with_options(primary_name, api_key, options)?
1295        }
1296        _ => create_provider_with_url_and_options(primary_name, api_key, api_url, options)?,
1297    };
1298    providers.push((primary_name.to_string(), primary_provider));
1299
1300    for fallback in &reliability.fallback_providers {
1301        if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) {
1302            continue;
1303        }
1304
1305        let (provider_name, profile_override) = parse_provider_profile(fallback);
1306
1307        // Each fallback provider resolves its own credential via provider-
1308        // specific env vars (e.g. DEEPSEEK_API_KEY for "deepseek") instead
1309        // of inheriting the primary provider's key. Passing `None` lets
1310        // `resolve_provider_credential` check the correct env var for the
1311        // fallback provider name.
1312        //
1313        // When a profile override is present (e.g. "openai-codex:second"),
1314        // propagate it through `auth_profile_override` so the provider
1315        // picks up the correct OAuth credential set.
1316        let fallback_options = match profile_override {
1317            Some(profile) => {
1318                let mut opts = options.clone();
1319                opts.auth_profile_override = Some(profile.to_string());
1320                opts
1321            }
1322            None => options.clone(),
1323        };
1324
1325        match create_provider_with_options(provider_name, None, &fallback_options) {
1326            Ok(provider) => providers.push((fallback.clone(), provider)),
1327            Err(_error) => {
1328                tracing::warn!(
1329                    fallback_provider = fallback,
1330                    "Ignoring invalid fallback provider during initialization"
1331                );
1332            }
1333        }
1334    }
1335
1336    let reliable = ReliableProvider::new(
1337        providers,
1338        reliability.provider_retries,
1339        reliability.provider_backoff_ms,
1340    )
1341    .with_api_keys(reliability.api_keys.clone())
1342    .with_model_fallbacks(reliability.model_fallbacks.clone());
1343
1344    Ok(Box::new(reliable))
1345}
1346
1347/// Create a RouterProvider if model routes are configured, otherwise return a
1348/// standard resilient provider. The router wraps individual providers per route,
1349/// each with its own retry/fallback chain.
1350pub fn create_routed_provider(
1351    primary_name: &str,
1352    api_key: Option<&str>,
1353    api_url: Option<&str>,
1354    reliability: &crate::config::ReliabilityConfig,
1355    model_routes: &[crate::config::ModelRouteConfig],
1356    default_model: &str,
1357) -> anyhow::Result<Box<dyn Provider>> {
1358    create_routed_provider_with_options(
1359        primary_name,
1360        api_key,
1361        api_url,
1362        reliability,
1363        model_routes,
1364        default_model,
1365        &ProviderRuntimeOptions::default(),
1366    )
1367}
1368
1369/// Create a routed provider using explicit runtime options.
1370pub fn create_routed_provider_with_options(
1371    primary_name: &str,
1372    api_key: Option<&str>,
1373    api_url: Option<&str>,
1374    reliability: &crate::config::ReliabilityConfig,
1375    model_routes: &[crate::config::ModelRouteConfig],
1376    default_model: &str,
1377    options: &ProviderRuntimeOptions,
1378) -> anyhow::Result<Box<dyn Provider>> {
1379    if model_routes.is_empty() {
1380        return create_resilient_provider_with_options(
1381            primary_name,
1382            api_key,
1383            api_url,
1384            reliability,
1385            options,
1386        );
1387    }
1388
1389    // Collect unique provider names needed
1390    let mut needed: Vec<String> = vec![primary_name.to_string()];
1391    for route in model_routes {
1392        if !needed.iter().any(|n| n == &route.provider) {
1393            needed.push(route.provider.clone());
1394        }
1395    }
1396
1397    // Create each provider (with its own resilience wrapper)
1398    let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
1399    for name in &needed {
1400        let routed_credential = model_routes
1401            .iter()
1402            .find(|r| &r.provider == name)
1403            .and_then(|r| {
1404                r.api_key.as_ref().and_then(|raw_key| {
1405                    let trimmed_key = raw_key.trim();
1406                    (!trimmed_key.is_empty()).then_some(trimmed_key)
1407                })
1408            });
1409        let key = routed_credential.or(api_key);
1410        // Only use api_url for the primary provider
1411        let url = if name == primary_name { api_url } else { None };
1412        match create_resilient_provider_with_options(name, key, url, reliability, options) {
1413            Ok(provider) => providers.push((name.clone(), provider)),
1414            Err(e) => {
1415                if name == primary_name {
1416                    return Err(e);
1417                }
1418                tracing::warn!(
1419                    provider = name.as_str(),
1420                    "Ignoring routed provider that failed to initialize"
1421                );
1422            }
1423        }
1424    }
1425
1426    // Build route table
1427    let routes: Vec<(String, router::Route)> = model_routes
1428        .iter()
1429        .map(|r| {
1430            (
1431                r.hint.clone(),
1432                router::Route {
1433                    provider_name: r.provider.clone(),
1434                    model: r.model.clone(),
1435                },
1436            )
1437        })
1438        .collect();
1439
1440    Ok(Box::new(router::RouterProvider::new(
1441        providers,
1442        routes,
1443        default_model.to_string(),
1444    )))
1445}
1446
1447/// Information about a supported provider for display purposes.
1448pub struct ProviderInfo {
1449    /// Canonical name used in config (e.g. `"openrouter"`)
1450    pub name: &'static str,
1451    /// Human-readable display name
1452    pub display_name: &'static str,
1453    /// Alternative names accepted in config
1454    pub aliases: &'static [&'static str],
1455    /// Whether the provider runs locally (no API key required)
1456    pub local: bool,
1457}
1458
1459/// Return the list of all known providers for display in `zeroclaw providers list`.
1460///
1461/// This is intentionally separate from the factory match in `create_provider`
1462/// (display concern vs. construction concern).
1463pub fn list_providers() -> Vec<ProviderInfo> {
1464    vec![
1465        // ── Primary providers ────────────────────────────────
1466        ProviderInfo {
1467            name: "openrouter",
1468            display_name: "OpenRouter",
1469            aliases: &[],
1470            local: false,
1471        },
1472        ProviderInfo {
1473            name: "anthropic",
1474            display_name: "Anthropic",
1475            aliases: &[],
1476            local: false,
1477        },
1478        ProviderInfo {
1479            name: "openai",
1480            display_name: "OpenAI",
1481            aliases: &[],
1482            local: false,
1483        },
1484        ProviderInfo {
1485            name: "openai-codex",
1486            display_name: "OpenAI Codex (OAuth)",
1487            aliases: &["openai_codex", "codex"],
1488            local: false,
1489        },
1490        ProviderInfo {
1491            name: "ollama",
1492            display_name: "Ollama",
1493            aliases: &[],
1494            local: true,
1495        },
1496        ProviderInfo {
1497            name: "gemini",
1498            display_name: "Google Gemini",
1499            aliases: &["google", "google-gemini"],
1500            local: false,
1501        },
1502        // ── OpenAI-compatible providers ──────────────────────
1503        ProviderInfo {
1504            name: "venice",
1505            display_name: "Venice",
1506            aliases: &[],
1507            local: false,
1508        },
1509        ProviderInfo {
1510            name: "vercel",
1511            display_name: "Vercel AI Gateway",
1512            aliases: &["vercel-ai"],
1513            local: false,
1514        },
1515        ProviderInfo {
1516            name: "cloudflare",
1517            display_name: "Cloudflare AI",
1518            aliases: &["cloudflare-ai"],
1519            local: false,
1520        },
1521        ProviderInfo {
1522            name: "moonshot",
1523            display_name: "Moonshot",
1524            aliases: &["kimi"],
1525            local: false,
1526        },
1527        ProviderInfo {
1528            name: "kimi-code",
1529            display_name: "Kimi Code",
1530            aliases: &["kimi_coding", "kimi_for_coding"],
1531            local: false,
1532        },
1533        ProviderInfo {
1534            name: "synthetic",
1535            display_name: "Synthetic",
1536            aliases: &[],
1537            local: false,
1538        },
1539        ProviderInfo {
1540            name: "opencode",
1541            display_name: "OpenCode Zen",
1542            aliases: &["opencode-zen"],
1543            local: false,
1544        },
1545        ProviderInfo {
1546            name: "zai",
1547            display_name: "Z.AI",
1548            aliases: &["z.ai"],
1549            local: false,
1550        },
1551        ProviderInfo {
1552            name: "glm",
1553            display_name: "GLM (Zhipu)",
1554            aliases: &["zhipu"],
1555            local: false,
1556        },
1557        ProviderInfo {
1558            name: "minimax",
1559            display_name: "MiniMax",
1560            aliases: &[
1561                "minimax-intl",
1562                "minimax-io",
1563                "minimax-global",
1564                "minimax-cn",
1565                "minimaxi",
1566                "minimax-oauth",
1567                "minimax-oauth-cn",
1568                "minimax-portal",
1569                "minimax-portal-cn",
1570            ],
1571            local: false,
1572        },
1573        ProviderInfo {
1574            name: "bedrock",
1575            display_name: "Amazon Bedrock",
1576            aliases: &["aws-bedrock"],
1577            local: false,
1578        },
1579        ProviderInfo {
1580            name: "qianfan",
1581            display_name: "Qianfan (Baidu)",
1582            aliases: &["baidu"],
1583            local: false,
1584        },
1585        ProviderInfo {
1586            name: "doubao",
1587            display_name: "Doubao (Volcengine)",
1588            aliases: &["volcengine", "ark", "doubao-cn"],
1589            local: false,
1590        },
1591        ProviderInfo {
1592            name: "qwen",
1593            display_name: "Qwen (DashScope / Qwen Code OAuth)",
1594            aliases: &[
1595                "dashscope",
1596                "qwen-intl",
1597                "dashscope-intl",
1598                "qwen-us",
1599                "dashscope-us",
1600                "qwen-code",
1601                "qwen-oauth",
1602                "qwen_oauth",
1603            ],
1604            local: false,
1605        },
1606        ProviderInfo {
1607            name: "groq",
1608            display_name: "Groq",
1609            aliases: &[],
1610            local: false,
1611        },
1612        ProviderInfo {
1613            name: "mistral",
1614            display_name: "Mistral",
1615            aliases: &[],
1616            local: false,
1617        },
1618        ProviderInfo {
1619            name: "xai",
1620            display_name: "xAI (Grok)",
1621            aliases: &["grok"],
1622            local: false,
1623        },
1624        ProviderInfo {
1625            name: "deepseek",
1626            display_name: "DeepSeek",
1627            aliases: &[],
1628            local: false,
1629        },
1630        ProviderInfo {
1631            name: "together",
1632            display_name: "Together AI",
1633            aliases: &["together-ai"],
1634            local: false,
1635        },
1636        ProviderInfo {
1637            name: "fireworks",
1638            display_name: "Fireworks AI",
1639            aliases: &["fireworks-ai"],
1640            local: false,
1641        },
1642        ProviderInfo {
1643            name: "novita",
1644            display_name: "Novita AI",
1645            aliases: &[],
1646            local: false,
1647        },
1648        ProviderInfo {
1649            name: "perplexity",
1650            display_name: "Perplexity",
1651            aliases: &[],
1652            local: false,
1653        },
1654        ProviderInfo {
1655            name: "cohere",
1656            display_name: "Cohere",
1657            aliases: &[],
1658            local: false,
1659        },
1660        ProviderInfo {
1661            name: "copilot",
1662            display_name: "GitHub Copilot",
1663            aliases: &["github-copilot"],
1664            local: false,
1665        },
1666        ProviderInfo {
1667            name: "lmstudio",
1668            display_name: "LM Studio",
1669            aliases: &["lm-studio"],
1670            local: true,
1671        },
1672        ProviderInfo {
1673            name: "llamacpp",
1674            display_name: "llama.cpp server",
1675            aliases: &["llama.cpp"],
1676            local: true,
1677        },
1678        ProviderInfo {
1679            name: "sglang",
1680            display_name: "SGLang",
1681            aliases: &[],
1682            local: true,
1683        },
1684        ProviderInfo {
1685            name: "vllm",
1686            display_name: "vLLM",
1687            aliases: &[],
1688            local: true,
1689        },
1690        ProviderInfo {
1691            name: "osaurus",
1692            display_name: "Osaurus",
1693            aliases: &[],
1694            local: true,
1695        },
1696        ProviderInfo {
1697            name: "nvidia",
1698            display_name: "NVIDIA NIM",
1699            aliases: &["nvidia-nim", "build.nvidia.com"],
1700            local: false,
1701        },
1702        ProviderInfo {
1703            name: "ovhcloud",
1704            display_name: "OVHcloud AI Endpoints",
1705            aliases: &["ovh"],
1706            local: false,
1707        },
1708    ]
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713    use super::*;
1714    use std::sync::{Mutex, OnceLock};
1715
1716    struct EnvGuard {
1717        key: &'static str,
1718        original: Option<String>,
1719    }
1720
1721    impl EnvGuard {
1722        fn set(key: &'static str, value: Option<&str>) -> Self {
1723            let original = std::env::var(key).ok();
1724            match value {
1725                Some(next) => std::env::set_var(key, next),
1726                None => std::env::remove_var(key),
1727            }
1728
1729            Self { key, original }
1730        }
1731    }
1732
1733    impl Drop for EnvGuard {
1734        fn drop(&mut self) {
1735            if let Some(original) = self.original.as_deref() {
1736                std::env::set_var(self.key, original);
1737            } else {
1738                std::env::remove_var(self.key);
1739            }
1740        }
1741    }
1742
1743    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1744        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1745        LOCK.get_or_init(|| Mutex::new(()))
1746            .lock()
1747            .expect("env lock poisoned")
1748    }
1749
1750    #[test]
1751    fn resolve_provider_credential_prefers_explicit_argument() {
1752        let resolved = resolve_provider_credential("openrouter", Some("  explicit-key  "));
1753        assert_eq!(resolved, Some("explicit-key".to_string()));
1754    }
1755
1756    #[test]
1757    fn resolve_provider_credential_uses_minimax_oauth_env_for_placeholder() {
1758        let _env_lock = env_lock();
1759        let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, Some("oauth-token"));
1760        let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
1761        let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
1762
1763        let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
1764
1765        assert_eq!(resolved.as_deref(), Some("oauth-token"));
1766    }
1767
1768    #[test]
1769    fn resolve_provider_credential_falls_back_to_minimax_api_key_for_placeholder() {
1770        let _env_lock = env_lock();
1771        let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
1772        let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
1773        let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
1774
1775        let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
1776
1777        assert_eq!(resolved.as_deref(), Some("api-key"));
1778    }
1779
1780    #[test]
1781    fn resolve_provider_credential_placeholder_ignores_generic_api_key_fallback() {
1782        let _env_lock = env_lock();
1783        let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
1784        let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, None);
1785        let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
1786        let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
1787
1788        let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
1789
1790        assert!(resolved.is_none());
1791    }
1792
1793    #[test]
1794    fn resolve_provider_credential_bedrock_uses_internal_credential_path() {
1795        let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
1796        let _override_guard = EnvGuard::set("OPENROUTER_API_KEY", Some("openrouter-key"));
1797
1798        assert_eq!(
1799            resolve_provider_credential("bedrock", Some("explicit")),
1800            Some("explicit".to_string())
1801        );
1802        assert!(resolve_provider_credential("bedrock", None).is_none());
1803        assert!(resolve_provider_credential("aws-bedrock", None).is_none());
1804    }
1805
1806    #[test]
1807    fn resolve_qwen_oauth_context_prefers_explicit_override() {
1808        let _env_lock = env_lock();
1809        let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}", std::process::id());
1810        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
1811        let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
1812        let _resource_guard = EnvGuard::set(
1813            QWEN_OAUTH_RESOURCE_URL_ENV,
1814            Some("coding-intl.dashscope.aliyuncs.com"),
1815        );
1816
1817        let context = resolve_qwen_oauth_context(Some("  explicit-qwen-token  "));
1818
1819        assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
1820        assert!(context.base_url.is_none());
1821    }
1822
1823    #[test]
1824    fn resolve_qwen_oauth_context_uses_env_token_and_resource_url() {
1825        let _env_lock = env_lock();
1826        let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-env", std::process::id());
1827        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
1828        let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
1829        let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
1830        let _resource_guard = EnvGuard::set(
1831            QWEN_OAUTH_RESOURCE_URL_ENV,
1832            Some("coding-intl.dashscope.aliyuncs.com"),
1833        );
1834        let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
1835
1836        let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
1837
1838        assert_eq!(context.credential.as_deref(), Some("oauth-token"));
1839        assert_eq!(
1840            context.base_url.as_deref(),
1841            Some("https://coding-intl.dashscope.aliyuncs.com/v1")
1842        );
1843    }
1844
1845    #[test]
1846    fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
1847        let _env_lock = env_lock();
1848        let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-file", std::process::id());
1849        let creds_dir = PathBuf::from(&fake_home).join(".qwen");
1850        std::fs::create_dir_all(&creds_dir).unwrap();
1851        let creds_path = creds_dir.join("oauth_creds.json");
1852        std::fs::write(
1853            &creds_path,
1854            r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
1855        )
1856        .unwrap();
1857
1858        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
1859        let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
1860        let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
1861        let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
1862        let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", None);
1863
1864        let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
1865
1866        assert_eq!(context.credential.as_deref(), Some("cached-token"));
1867        assert_eq!(
1868            context.base_url.as_deref(),
1869            Some("https://resource.example.com/v1")
1870        );
1871    }
1872
1873    #[test]
1874    fn resolve_qwen_oauth_context_placeholder_does_not_use_dashscope_fallback() {
1875        let _env_lock = env_lock();
1876        let fake_home = format!(
1877            "/tmp/zeroclaw-qwen-oauth-home-{}-placeholder",
1878            std::process::id()
1879        );
1880        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
1881        let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
1882        let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
1883        let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
1884        let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
1885
1886        let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
1887
1888        assert!(context.credential.is_none());
1889    }
1890
1891    #[test]
1892    fn regional_alias_predicates_cover_expected_variants() {
1893        assert!(is_moonshot_alias("moonshot"));
1894        assert!(is_moonshot_alias("kimi-global"));
1895        assert!(is_glm_alias("glm"));
1896        assert!(is_glm_alias("bigmodel"));
1897        assert!(is_minimax_alias("minimax-io"));
1898        assert!(is_minimax_alias("minimaxi"));
1899        assert!(is_minimax_alias("minimax-oauth"));
1900        assert!(is_minimax_alias("minimax-portal-cn"));
1901        assert!(is_qwen_alias("dashscope"));
1902        assert!(is_qwen_alias("qwen-us"));
1903        assert!(is_qwen_alias("qwen-code"));
1904        assert!(is_qwen_oauth_alias("qwen-code"));
1905        assert!(is_qwen_oauth_alias("qwen_oauth"));
1906        assert!(is_zai_alias("z.ai"));
1907        assert!(is_zai_alias("zai-cn"));
1908        assert!(is_qianfan_alias("qianfan"));
1909        assert!(is_qianfan_alias("baidu"));
1910        assert!(is_doubao_alias("doubao"));
1911        assert!(is_doubao_alias("volcengine"));
1912        assert!(is_doubao_alias("ark"));
1913        assert!(is_doubao_alias("doubao-cn"));
1914
1915        assert!(!is_moonshot_alias("openrouter"));
1916        assert!(!is_glm_alias("openai"));
1917        assert!(!is_qwen_alias("gemini"));
1918        assert!(!is_zai_alias("anthropic"));
1919        assert!(!is_qianfan_alias("cohere"));
1920        assert!(!is_doubao_alias("deepseek"));
1921    }
1922
1923    #[test]
1924    fn canonical_china_provider_name_maps_regional_aliases() {
1925        assert_eq!(canonical_china_provider_name("moonshot"), Some("moonshot"));
1926        assert_eq!(canonical_china_provider_name("kimi-intl"), Some("moonshot"));
1927        assert_eq!(canonical_china_provider_name("glm"), Some("glm"));
1928        assert_eq!(canonical_china_provider_name("zhipu-cn"), Some("glm"));
1929        assert_eq!(canonical_china_provider_name("minimax"), Some("minimax"));
1930        assert_eq!(canonical_china_provider_name("minimax-cn"), Some("minimax"));
1931        assert_eq!(canonical_china_provider_name("qwen"), Some("qwen"));
1932        assert_eq!(canonical_china_provider_name("dashscope-us"), Some("qwen"));
1933        assert_eq!(canonical_china_provider_name("qwen-code"), Some("qwen"));
1934        assert_eq!(canonical_china_provider_name("zai"), Some("zai"));
1935        assert_eq!(canonical_china_provider_name("z.ai-cn"), Some("zai"));
1936        assert_eq!(canonical_china_provider_name("qianfan"), Some("qianfan"));
1937        assert_eq!(canonical_china_provider_name("baidu"), Some("qianfan"));
1938        assert_eq!(canonical_china_provider_name("doubao"), Some("doubao"));
1939        assert_eq!(canonical_china_provider_name("volcengine"), Some("doubao"));
1940        assert_eq!(canonical_china_provider_name("openai"), None);
1941    }
1942
1943    #[test]
1944    fn regional_endpoint_aliases_map_to_expected_urls() {
1945        assert_eq!(minimax_base_url("minimax"), Some(MINIMAX_INTL_BASE_URL));
1946        assert_eq!(
1947            minimax_base_url("minimax-intl"),
1948            Some(MINIMAX_INTL_BASE_URL)
1949        );
1950        assert_eq!(minimax_base_url("minimax-cn"), Some(MINIMAX_CN_BASE_URL));
1951
1952        assert_eq!(glm_base_url("glm"), Some(GLM_GLOBAL_BASE_URL));
1953        assert_eq!(glm_base_url("glm-cn"), Some(GLM_CN_BASE_URL));
1954        assert_eq!(glm_base_url("bigmodel"), Some(GLM_CN_BASE_URL));
1955
1956        assert_eq!(moonshot_base_url("moonshot"), Some(MOONSHOT_CN_BASE_URL));
1957        assert_eq!(
1958            moonshot_base_url("moonshot-intl"),
1959            Some(MOONSHOT_INTL_BASE_URL)
1960        );
1961
1962        assert_eq!(qwen_base_url("qwen"), Some(QWEN_CN_BASE_URL));
1963        assert_eq!(qwen_base_url("qwen-cn"), Some(QWEN_CN_BASE_URL));
1964        assert_eq!(qwen_base_url("qwen-intl"), Some(QWEN_INTL_BASE_URL));
1965        assert_eq!(qwen_base_url("qwen-us"), Some(QWEN_US_BASE_URL));
1966        assert_eq!(qwen_base_url("qwen-code"), Some(QWEN_CN_BASE_URL));
1967
1968        assert_eq!(zai_base_url("zai"), Some(ZAI_GLOBAL_BASE_URL));
1969        assert_eq!(zai_base_url("z.ai"), Some(ZAI_GLOBAL_BASE_URL));
1970        assert_eq!(zai_base_url("zai-global"), Some(ZAI_GLOBAL_BASE_URL));
1971        assert_eq!(zai_base_url("z.ai-global"), Some(ZAI_GLOBAL_BASE_URL));
1972        assert_eq!(zai_base_url("zai-cn"), Some(ZAI_CN_BASE_URL));
1973        assert_eq!(zai_base_url("z.ai-cn"), Some(ZAI_CN_BASE_URL));
1974    }
1975
1976    // ── Primary providers ────────────────────────────────────
1977
1978    #[test]
1979    fn factory_openrouter() {
1980        assert!(create_provider("openrouter", Some("provider-test-credential")).is_ok());
1981        assert!(create_provider("openrouter", None).is_ok());
1982    }
1983
1984    #[test]
1985    fn factory_anthropic() {
1986        assert!(create_provider("anthropic", Some("provider-test-credential")).is_ok());
1987    }
1988
1989    #[test]
1990    fn factory_openai() {
1991        assert!(create_provider("openai", Some("provider-test-credential")).is_ok());
1992    }
1993
1994    #[test]
1995    fn factory_openai_codex() {
1996        let options = ProviderRuntimeOptions::default();
1997        assert!(create_provider_with_options("openai-codex", None, &options).is_ok());
1998    }
1999
2000    #[test]
2001    fn factory_ollama() {
2002        assert!(create_provider("ollama", None).is_ok());
2003        // Ollama may use API key when a remote endpoint is configured.
2004        assert!(create_provider("ollama", Some("dummy")).is_ok());
2005        assert!(create_provider("ollama", Some("any-value-here")).is_ok());
2006    }
2007
2008    #[test]
2009    fn factory_gemini() {
2010        assert!(create_provider("gemini", Some("test-key")).is_ok());
2011        assert!(create_provider("google", Some("test-key")).is_ok());
2012        assert!(create_provider("google-gemini", Some("test-key")).is_ok());
2013        // Should also work without key (will try CLI auth)
2014        assert!(create_provider("gemini", None).is_ok());
2015    }
2016
2017    #[test]
2018    fn factory_telnyx() {
2019        assert!(create_provider("telnyx", Some("test-key")).is_ok());
2020        assert!(create_provider("telnyx", None).is_ok());
2021    }
2022
2023    // ── OpenAI-compatible providers ──────────────────────────
2024
2025    #[test]
2026    fn factory_venice() {
2027        assert!(create_provider("venice", Some("vn-key")).is_ok());
2028    }
2029
2030    #[test]
2031    fn factory_vercel() {
2032        assert!(create_provider("vercel", Some("key")).is_ok());
2033        assert!(create_provider("vercel-ai", Some("key")).is_ok());
2034    }
2035
2036    #[test]
2037    fn vercel_gateway_base_url_matches_public_gateway_endpoint() {
2038        assert_eq!(
2039            VERCEL_AI_GATEWAY_BASE_URL,
2040            "https://ai-gateway.vercel.sh/v1"
2041        );
2042    }
2043
2044    #[test]
2045    fn factory_cloudflare() {
2046        assert!(create_provider("cloudflare", Some("key")).is_ok());
2047        assert!(create_provider("cloudflare-ai", Some("key")).is_ok());
2048    }
2049
2050    #[test]
2051    fn factory_moonshot() {
2052        assert!(create_provider("moonshot", Some("key")).is_ok());
2053        assert!(create_provider("kimi", Some("key")).is_ok());
2054        assert!(create_provider("moonshot-intl", Some("key")).is_ok());
2055        assert!(create_provider("moonshot-cn", Some("key")).is_ok());
2056        assert!(create_provider("kimi-intl", Some("key")).is_ok());
2057        assert!(create_provider("kimi-cn", Some("key")).is_ok());
2058    }
2059
2060    #[test]
2061    fn factory_kimi_code() {
2062        assert!(create_provider("kimi-code", Some("key")).is_ok());
2063        assert!(create_provider("kimi_coding", Some("key")).is_ok());
2064        assert!(create_provider("kimi_for_coding", Some("key")).is_ok());
2065    }
2066
2067    #[test]
2068    fn factory_synthetic() {
2069        assert!(create_provider("synthetic", Some("key")).is_ok());
2070    }
2071
2072    #[test]
2073    fn factory_opencode() {
2074        assert!(create_provider("opencode", Some("key")).is_ok());
2075        assert!(create_provider("opencode-zen", Some("key")).is_ok());
2076    }
2077
2078    #[test]
2079    fn factory_zai() {
2080        assert!(create_provider("zai", Some("key")).is_ok());
2081        assert!(create_provider("z.ai", Some("key")).is_ok());
2082        assert!(create_provider("zai-global", Some("key")).is_ok());
2083        assert!(create_provider("z.ai-global", Some("key")).is_ok());
2084        assert!(create_provider("zai-cn", Some("key")).is_ok());
2085        assert!(create_provider("z.ai-cn", Some("key")).is_ok());
2086    }
2087
2088    #[test]
2089    fn factory_glm() {
2090        assert!(create_provider("glm", Some("key")).is_ok());
2091        assert!(create_provider("zhipu", Some("key")).is_ok());
2092        assert!(create_provider("glm-cn", Some("key")).is_ok());
2093        assert!(create_provider("zhipu-cn", Some("key")).is_ok());
2094        assert!(create_provider("glm-global", Some("key")).is_ok());
2095        assert!(create_provider("bigmodel", Some("key")).is_ok());
2096    }
2097
2098    #[test]
2099    fn factory_minimax() {
2100        assert!(create_provider("minimax", Some("key")).is_ok());
2101        assert!(create_provider("minimax-intl", Some("key")).is_ok());
2102        assert!(create_provider("minimax-io", Some("key")).is_ok());
2103        assert!(create_provider("minimax-global", Some("key")).is_ok());
2104        assert!(create_provider("minimax-cn", Some("key")).is_ok());
2105        assert!(create_provider("minimaxi", Some("key")).is_ok());
2106        assert!(create_provider("minimax-oauth", Some("key")).is_ok());
2107        assert!(create_provider("minimax-oauth-cn", Some("key")).is_ok());
2108        assert!(create_provider("minimax-portal", Some("key")).is_ok());
2109        assert!(create_provider("minimax-portal-cn", Some("key")).is_ok());
2110    }
2111
2112    #[test]
2113    fn factory_minimax_disables_native_tool_calling() {
2114        let minimax = create_provider("minimax", Some("key")).expect("provider should resolve");
2115        assert!(!minimax.supports_native_tools());
2116
2117        let minimax_cn =
2118            create_provider("minimax-cn", Some("key")).expect("provider should resolve");
2119        assert!(!minimax_cn.supports_native_tools());
2120    }
2121
2122    #[test]
2123    fn factory_bedrock() {
2124        // Bedrock uses AWS env vars for credentials, not API key.
2125        assert!(create_provider("bedrock", None).is_ok());
2126        assert!(create_provider("aws-bedrock", None).is_ok());
2127        // Passing an api_key is harmless (ignored).
2128        assert!(create_provider("bedrock", Some("ignored")).is_ok());
2129    }
2130
2131    #[test]
2132    fn factory_qianfan() {
2133        assert!(create_provider("qianfan", Some("key")).is_ok());
2134        assert!(create_provider("baidu", Some("key")).is_ok());
2135    }
2136
2137    #[test]
2138    fn factory_doubao() {
2139        assert!(create_provider("doubao", Some("key")).is_ok());
2140        assert!(create_provider("volcengine", Some("key")).is_ok());
2141        assert!(create_provider("ark", Some("key")).is_ok());
2142        assert!(create_provider("doubao-cn", Some("key")).is_ok());
2143    }
2144
2145    #[test]
2146    fn factory_qwen() {
2147        assert!(create_provider("qwen", Some("key")).is_ok());
2148        assert!(create_provider("dashscope", Some("key")).is_ok());
2149        assert!(create_provider("qwen-cn", Some("key")).is_ok());
2150        assert!(create_provider("dashscope-cn", Some("key")).is_ok());
2151        assert!(create_provider("qwen-intl", Some("key")).is_ok());
2152        assert!(create_provider("dashscope-intl", Some("key")).is_ok());
2153        assert!(create_provider("qwen-international", Some("key")).is_ok());
2154        assert!(create_provider("dashscope-international", Some("key")).is_ok());
2155        assert!(create_provider("qwen-us", Some("key")).is_ok());
2156        assert!(create_provider("dashscope-us", Some("key")).is_ok());
2157        assert!(create_provider("qwen-code", Some("key")).is_ok());
2158        assert!(create_provider("qwen-oauth", Some("key")).is_ok());
2159    }
2160
2161    #[test]
2162    fn qwen_provider_supports_vision() {
2163        let provider = create_provider("qwen", Some("key")).expect("qwen provider should build");
2164        assert!(provider.supports_vision());
2165
2166        let oauth_provider =
2167            create_provider("qwen-code", Some("key")).expect("qwen oauth provider should build");
2168        assert!(oauth_provider.supports_vision());
2169    }
2170
2171    #[test]
2172    fn factory_lmstudio() {
2173        assert!(create_provider("lmstudio", Some("key")).is_ok());
2174        assert!(create_provider("lm-studio", Some("key")).is_ok());
2175        assert!(create_provider("lmstudio", None).is_ok());
2176    }
2177
2178    #[test]
2179    fn factory_llamacpp() {
2180        assert!(create_provider("llamacpp", Some("key")).is_ok());
2181        assert!(create_provider("llama.cpp", Some("key")).is_ok());
2182        assert!(create_provider("llamacpp", None).is_ok());
2183    }
2184
2185    #[test]
2186    fn factory_sglang() {
2187        assert!(create_provider("sglang", None).is_ok());
2188        assert!(create_provider("sglang", Some("key")).is_ok());
2189    }
2190
2191    #[test]
2192    fn factory_vllm() {
2193        assert!(create_provider("vllm", None).is_ok());
2194        assert!(create_provider("vllm", Some("key")).is_ok());
2195    }
2196
2197    #[test]
2198    fn factory_osaurus() {
2199        // Osaurus works without an explicit key (defaults to "osaurus").
2200        assert!(create_provider("osaurus", None).is_ok());
2201        // Osaurus also works with an explicit key.
2202        assert!(create_provider("osaurus", Some("custom-key")).is_ok());
2203    }
2204
2205    #[test]
2206    fn factory_osaurus_uses_default_key_when_none() {
2207        // Verify that create_provider_with_url_and_options succeeds even
2208        // without an API key — the match arm provides a default placeholder.
2209        let options = ProviderRuntimeOptions::default();
2210        let p = create_provider_with_url_and_options("osaurus", None, None, &options);
2211        assert!(p.is_ok());
2212    }
2213
2214    #[test]
2215    fn factory_osaurus_custom_url() {
2216        // Verify that a custom api_url overrides the default localhost endpoint.
2217        let options = ProviderRuntimeOptions::default();
2218        let p = create_provider_with_url_and_options(
2219            "osaurus",
2220            Some("key"),
2221            Some("http://192.168.1.100:1337/v1"),
2222            &options,
2223        );
2224        assert!(p.is_ok());
2225    }
2226
2227    #[test]
2228    fn resolve_provider_credential_osaurus_env() {
2229        let _env_lock = env_lock();
2230        let _guard = EnvGuard::set("OSAURUS_API_KEY", Some("osaurus-test-key"));
2231        let resolved = resolve_provider_credential("osaurus", None);
2232        assert_eq!(resolved, Some("osaurus-test-key".to_string()));
2233    }
2234
2235    // ── Extended ecosystem ───────────────────────────────────
2236
2237    #[test]
2238    fn factory_groq() {
2239        assert!(create_provider("groq", Some("key")).is_ok());
2240    }
2241
2242    #[test]
2243    fn factory_mistral() {
2244        assert!(create_provider("mistral", Some("key")).is_ok());
2245    }
2246
2247    #[test]
2248    fn factory_xai() {
2249        assert!(create_provider("xai", Some("key")).is_ok());
2250        assert!(create_provider("grok", Some("key")).is_ok());
2251    }
2252
2253    #[test]
2254    fn factory_deepseek() {
2255        assert!(create_provider("deepseek", Some("key")).is_ok());
2256    }
2257
2258    #[test]
2259    fn deepseek_provider_keeps_vision_disabled() {
2260        let provider =
2261            create_provider("deepseek", Some("key")).expect("deepseek provider should build");
2262        assert!(!provider.supports_vision());
2263    }
2264
2265    #[test]
2266    fn factory_together() {
2267        assert!(create_provider("together", Some("key")).is_ok());
2268        assert!(create_provider("together-ai", Some("key")).is_ok());
2269    }
2270
2271    #[test]
2272    fn factory_fireworks() {
2273        assert!(create_provider("fireworks", Some("key")).is_ok());
2274        assert!(create_provider("fireworks-ai", Some("key")).is_ok());
2275    }
2276
2277    #[test]
2278    fn factory_novita() {
2279        assert!(create_provider("novita", Some("key")).is_ok());
2280    }
2281
2282    #[test]
2283    fn factory_perplexity() {
2284        assert!(create_provider("perplexity", Some("key")).is_ok());
2285    }
2286
2287    #[test]
2288    fn factory_cohere() {
2289        assert!(create_provider("cohere", Some("key")).is_ok());
2290    }
2291
2292    #[test]
2293    fn factory_copilot() {
2294        assert!(create_provider("copilot", Some("key")).is_ok());
2295        assert!(create_provider("github-copilot", Some("key")).is_ok());
2296    }
2297
2298    #[test]
2299    fn factory_nvidia() {
2300        assert!(create_provider("nvidia", Some("nvapi-test")).is_ok());
2301        assert!(create_provider("nvidia-nim", Some("nvapi-test")).is_ok());
2302        assert!(create_provider("build.nvidia.com", Some("nvapi-test")).is_ok());
2303    }
2304
2305    // ── AI inference routers ─────────────────────────────────
2306
2307    #[test]
2308    fn factory_astrai() {
2309        assert!(create_provider("astrai", Some("sk-astrai-test")).is_ok());
2310    }
2311
2312    // ── Custom / BYOP provider ─────────────────────────────
2313
2314    #[test]
2315    fn factory_custom_url() {
2316        let p = create_provider("custom:https://my-llm.example.com", Some("key"));
2317        assert!(p.is_ok());
2318    }
2319
2320    #[test]
2321    fn factory_custom_localhost() {
2322        let p = create_provider("custom:http://localhost:1234", Some("key"));
2323        assert!(p.is_ok());
2324    }
2325
2326    #[test]
2327    fn factory_custom_no_key() {
2328        let p = create_provider("custom:https://my-llm.example.com", None);
2329        assert!(p.is_ok());
2330    }
2331
2332    #[test]
2333    fn factory_custom_empty_url_errors() {
2334        match create_provider("custom:", None) {
2335            Err(e) => assert!(
2336                e.to_string().contains("requires a URL"),
2337                "Expected 'requires a URL', got: {e}"
2338            ),
2339            Ok(_) => panic!("Expected error for empty custom URL"),
2340        }
2341    }
2342
2343    #[test]
2344    fn factory_custom_invalid_url_errors() {
2345        match create_provider("custom:not-a-url", None) {
2346            Err(e) => assert!(
2347                e.to_string().contains("requires a valid URL"),
2348                "Expected 'requires a valid URL', got: {e}"
2349            ),
2350            Ok(_) => panic!("Expected error for invalid custom URL"),
2351        }
2352    }
2353
2354    #[test]
2355    fn factory_custom_unsupported_scheme_errors() {
2356        match create_provider("custom:ftp://example.com", None) {
2357            Err(e) => assert!(
2358                e.to_string().contains("http:// or https://"),
2359                "Expected scheme validation error, got: {e}"
2360            ),
2361            Ok(_) => panic!("Expected error for unsupported custom URL scheme"),
2362        }
2363    }
2364
2365    #[test]
2366    fn factory_custom_trims_whitespace() {
2367        let p = create_provider("custom:  https://my-llm.example.com  ", Some("key"));
2368        assert!(p.is_ok());
2369    }
2370
2371    // ── Anthropic-compatible custom endpoints ─────────────────
2372
2373    #[test]
2374    fn factory_anthropic_custom_url() {
2375        let p = create_provider("anthropic-custom:https://api.example.com", Some("key"));
2376        assert!(p.is_ok());
2377    }
2378
2379    #[test]
2380    fn factory_anthropic_custom_trailing_slash() {
2381        let p = create_provider("anthropic-custom:https://api.example.com/", Some("key"));
2382        assert!(p.is_ok());
2383    }
2384
2385    #[test]
2386    fn factory_anthropic_custom_no_key() {
2387        let p = create_provider("anthropic-custom:https://api.example.com", None);
2388        assert!(p.is_ok());
2389    }
2390
2391    #[test]
2392    fn factory_anthropic_custom_empty_url_errors() {
2393        match create_provider("anthropic-custom:", None) {
2394            Err(e) => assert!(
2395                e.to_string().contains("requires a URL"),
2396                "Expected 'requires a URL', got: {e}"
2397            ),
2398            Ok(_) => panic!("Expected error for empty anthropic-custom URL"),
2399        }
2400    }
2401
2402    #[test]
2403    fn factory_anthropic_custom_invalid_url_errors() {
2404        match create_provider("anthropic-custom:not-a-url", None) {
2405            Err(e) => assert!(
2406                e.to_string().contains("requires a valid URL"),
2407                "Expected 'requires a valid URL', got: {e}"
2408            ),
2409            Ok(_) => panic!("Expected error for invalid anthropic-custom URL"),
2410        }
2411    }
2412
2413    #[test]
2414    fn factory_anthropic_custom_unsupported_scheme_errors() {
2415        match create_provider("anthropic-custom:ftp://example.com", None) {
2416            Err(e) => assert!(
2417                e.to_string().contains("http:// or https://"),
2418                "Expected scheme validation error, got: {e}"
2419            ),
2420            Ok(_) => panic!("Expected error for unsupported anthropic-custom URL scheme"),
2421        }
2422    }
2423
2424    // ── Error cases ──────────────────────────────────────────
2425
2426    #[test]
2427    fn factory_unknown_provider_errors() {
2428        let p = create_provider("nonexistent", None);
2429        assert!(p.is_err());
2430        let msg = p.err().unwrap().to_string();
2431        assert!(msg.contains("Unknown provider"));
2432        assert!(msg.contains("nonexistent"));
2433    }
2434
2435    #[test]
2436    fn factory_empty_name_errors() {
2437        assert!(create_provider("", None).is_err());
2438    }
2439
2440    #[test]
2441    fn resilient_provider_ignores_duplicate_and_invalid_fallbacks() {
2442        let reliability = crate::config::ReliabilityConfig {
2443            provider_retries: 1,
2444            provider_backoff_ms: 100,
2445            fallback_providers: vec![
2446                "openrouter".into(),
2447                "nonexistent-provider".into(),
2448                "openai".into(),
2449                "openai".into(),
2450            ],
2451            api_keys: Vec::new(),
2452            model_fallbacks: std::collections::HashMap::new(),
2453            channel_initial_backoff_secs: 2,
2454            channel_max_backoff_secs: 60,
2455            scheduler_poll_secs: 15,
2456            scheduler_retries: 2,
2457        };
2458
2459        let provider = create_resilient_provider(
2460            "openrouter",
2461            Some("provider-test-credential"),
2462            None,
2463            &reliability,
2464        );
2465        assert!(provider.is_ok());
2466    }
2467
2468    #[test]
2469    fn resilient_provider_errors_for_invalid_primary() {
2470        let reliability = crate::config::ReliabilityConfig::default();
2471        let provider = create_resilient_provider(
2472            "totally-invalid",
2473            Some("provider-test-credential"),
2474            None,
2475            &reliability,
2476        );
2477        assert!(provider.is_err());
2478    }
2479
2480    /// Fallback providers resolve their own credentials via provider-specific
2481    /// env vars rather than inheriting the primary provider's key.  A provider
2482    /// that requires no key (e.g. lmstudio, ollama) must initialize
2483    /// successfully even when the primary uses a completely different key.
2484    #[test]
2485    fn resilient_fallback_resolves_own_credential() {
2486        let reliability = crate::config::ReliabilityConfig {
2487            provider_retries: 1,
2488            provider_backoff_ms: 100,
2489            fallback_providers: vec!["lmstudio".into(), "ollama".into()],
2490            api_keys: Vec::new(),
2491            model_fallbacks: std::collections::HashMap::new(),
2492            channel_initial_backoff_secs: 2,
2493            channel_max_backoff_secs: 60,
2494            scheduler_poll_secs: 15,
2495            scheduler_retries: 2,
2496        };
2497
2498        // Primary uses a ZAI key; fallbacks (lmstudio, ollama) should NOT
2499        // receive this key; they resolve their own credentials independently.
2500        let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
2501        assert!(provider.is_ok());
2502    }
2503
2504    /// `custom:` URL entries work as fallback providers, enabling arbitrary
2505    /// OpenAI-compatible endpoints (e.g. local LM Studio on a Docker host).
2506    #[test]
2507    fn resilient_fallback_supports_custom_url() {
2508        let reliability = crate::config::ReliabilityConfig {
2509            provider_retries: 1,
2510            provider_backoff_ms: 100,
2511            fallback_providers: vec!["custom:http://host.docker.internal:1234/v1".into()],
2512            api_keys: Vec::new(),
2513            model_fallbacks: std::collections::HashMap::new(),
2514            channel_initial_backoff_secs: 2,
2515            channel_max_backoff_secs: 60,
2516            scheduler_poll_secs: 15,
2517            scheduler_retries: 2,
2518        };
2519
2520        let provider =
2521            create_resilient_provider("openai", Some("openai-test-key"), None, &reliability);
2522        assert!(provider.is_ok());
2523    }
2524
2525    /// Mixed fallback chain: named providers, custom URLs, and invalid entries
2526    /// all coexist.  Invalid entries are silently ignored; valid ones initialize.
2527    #[test]
2528    fn resilient_fallback_mixed_chain() {
2529        let reliability = crate::config::ReliabilityConfig {
2530            provider_retries: 1,
2531            provider_backoff_ms: 100,
2532            fallback_providers: vec![
2533                "deepseek".into(),
2534                "custom:http://localhost:8080/v1".into(),
2535                "nonexistent-provider".into(),
2536                "lmstudio".into(),
2537            ],
2538            api_keys: Vec::new(),
2539            model_fallbacks: std::collections::HashMap::new(),
2540            channel_initial_backoff_secs: 2,
2541            channel_max_backoff_secs: 60,
2542            scheduler_poll_secs: 15,
2543            scheduler_retries: 2,
2544        };
2545
2546        let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
2547        assert!(provider.is_ok());
2548    }
2549
2550    #[test]
2551    fn ollama_with_custom_url() {
2552        let provider = create_provider_with_url("ollama", None, Some("http://10.100.2.32:11434"));
2553        assert!(provider.is_ok());
2554    }
2555
2556    #[test]
2557    fn ollama_cloud_with_custom_url() {
2558        let provider =
2559            create_provider_with_url("ollama", Some("ollama-key"), Some("https://ollama.com"));
2560        assert!(provider.is_ok());
2561    }
2562
2563    /// Osaurus works as a fallback provider alongside other named providers.
2564    #[test]
2565    fn resilient_fallback_includes_osaurus() {
2566        let reliability = crate::config::ReliabilityConfig {
2567            provider_retries: 1,
2568            provider_backoff_ms: 100,
2569            fallback_providers: vec!["osaurus".into(), "lmstudio".into()],
2570            api_keys: Vec::new(),
2571            model_fallbacks: std::collections::HashMap::new(),
2572            channel_initial_backoff_secs: 2,
2573            channel_max_backoff_secs: 60,
2574            scheduler_poll_secs: 15,
2575            scheduler_retries: 2,
2576        };
2577
2578        let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
2579        assert!(provider.is_ok());
2580    }
2581
2582    #[test]
2583    fn factory_all_providers_create_successfully() {
2584        let providers = [
2585            "openrouter",
2586            "anthropic",
2587            "openai",
2588            "ollama",
2589            "gemini",
2590            "venice",
2591            "vercel",
2592            "cloudflare",
2593            "moonshot",
2594            "moonshot-intl",
2595            "kimi-code",
2596            "moonshot-cn",
2597            "kimi-code",
2598            "synthetic",
2599            "opencode",
2600            "zai",
2601            "zai-cn",
2602            "glm",
2603            "glm-cn",
2604            "minimax",
2605            "minimax-cn",
2606            "bedrock",
2607            "qianfan",
2608            "doubao",
2609            "qwen",
2610            "qwen-intl",
2611            "qwen-cn",
2612            "qwen-us",
2613            "qwen-code",
2614            "lmstudio",
2615            "llamacpp",
2616            "sglang",
2617            "vllm",
2618            "osaurus",
2619            "telnyx",
2620            "groq",
2621            "mistral",
2622            "xai",
2623            "deepseek",
2624            "together",
2625            "fireworks",
2626            "novita",
2627            "perplexity",
2628            "cohere",
2629            "copilot",
2630            "nvidia",
2631            "astrai",
2632            "ovhcloud",
2633        ];
2634        for name in providers {
2635            assert!(
2636                create_provider(name, Some("test-key")).is_ok(),
2637                "Provider '{name}' should create successfully"
2638            );
2639        }
2640    }
2641
2642    #[test]
2643    fn listed_providers_have_unique_ids_and_aliases() {
2644        let providers = list_providers();
2645        let mut canonical_ids = std::collections::HashSet::new();
2646        let mut aliases = std::collections::HashSet::new();
2647
2648        for provider in providers {
2649            assert!(
2650                canonical_ids.insert(provider.name),
2651                "Duplicate canonical provider id: {}",
2652                provider.name
2653            );
2654
2655            for alias in provider.aliases {
2656                assert_ne!(
2657                    *alias, provider.name,
2658                    "Alias must differ from canonical id: {}",
2659                    provider.name
2660                );
2661                assert!(
2662                    !canonical_ids.contains(alias),
2663                    "Alias conflicts with canonical provider id: {}",
2664                    alias
2665                );
2666                assert!(aliases.insert(alias), "Duplicate provider alias: {}", alias);
2667            }
2668        }
2669    }
2670
2671    #[test]
2672    fn listed_providers_and_aliases_are_constructible() {
2673        for provider in list_providers() {
2674            assert!(
2675                create_provider(provider.name, Some("provider-test-credential")).is_ok(),
2676                "Canonical provider id should be constructible: {}",
2677                provider.name
2678            );
2679
2680            for alias in provider.aliases {
2681                assert!(
2682                    create_provider(alias, Some("provider-test-credential")).is_ok(),
2683                    "Provider alias should be constructible: {} (for {})",
2684                    alias,
2685                    provider.name
2686                );
2687            }
2688        }
2689    }
2690
2691    // ── API error sanitization ───────────────────────────────
2692
2693    #[test]
2694    fn sanitize_scrubs_sk_prefix() {
2695        let input = "request failed: sk-1234567890abcdef";
2696        let out = sanitize_api_error(input);
2697        assert!(!out.contains("sk-1234567890abcdef"));
2698        assert!(out.contains("[REDACTED]"));
2699    }
2700
2701    #[test]
2702    fn sanitize_scrubs_multiple_prefixes() {
2703        let input = "keys sk-abcdef xoxb-12345 xoxp-67890";
2704        let out = sanitize_api_error(input);
2705        assert!(!out.contains("sk-abcdef"));
2706        assert!(!out.contains("xoxb-12345"));
2707        assert!(!out.contains("xoxp-67890"));
2708    }
2709
2710    #[test]
2711    fn sanitize_short_prefix_then_real_key() {
2712        let input = "error with sk- prefix and key sk-1234567890";
2713        let result = sanitize_api_error(input);
2714        assert!(!result.contains("sk-1234567890"));
2715        assert!(result.contains("[REDACTED]"));
2716    }
2717
2718    #[test]
2719    fn sanitize_sk_proj_comment_then_real_key() {
2720        let input = "note: sk- then sk-proj-abc123def456";
2721        let result = sanitize_api_error(input);
2722        assert!(!result.contains("sk-proj-abc123def456"));
2723        assert!(result.contains("[REDACTED]"));
2724    }
2725
2726    #[test]
2727    fn sanitize_keeps_bare_prefix() {
2728        let input = "only prefix sk- present";
2729        let result = sanitize_api_error(input);
2730        assert!(result.contains("sk-"));
2731    }
2732
2733    #[test]
2734    fn sanitize_handles_json_wrapped_key() {
2735        let input = r#"{"error":"invalid key sk-abc123xyz"}"#;
2736        let result = sanitize_api_error(input);
2737        assert!(!result.contains("sk-abc123xyz"));
2738    }
2739
2740    #[test]
2741    fn sanitize_handles_delimiter_boundaries() {
2742        let input = "bad token xoxb-abc123}; next";
2743        let result = sanitize_api_error(input);
2744        assert!(!result.contains("xoxb-abc123"));
2745        assert!(result.contains("};"));
2746    }
2747
2748    #[test]
2749    fn sanitize_truncates_long_error() {
2750        let long = "a".repeat(400);
2751        let result = sanitize_api_error(&long);
2752        assert!(result.len() <= 203);
2753        assert!(result.ends_with("..."));
2754    }
2755
2756    #[test]
2757    fn sanitize_truncates_after_scrub() {
2758        let input = format!("{} sk-abcdef123456 {}", "a".repeat(190), "b".repeat(190));
2759        let result = sanitize_api_error(&input);
2760        assert!(!result.contains("sk-abcdef123456"));
2761        assert!(result.len() <= 203);
2762    }
2763
2764    #[test]
2765    fn sanitize_preserves_unicode_boundaries() {
2766        let input = format!("{} sk-abcdef123", "hello🙂".repeat(80));
2767        let result = sanitize_api_error(&input);
2768        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
2769        assert!(!result.contains("sk-abcdef123"));
2770    }
2771
2772    #[test]
2773    fn sanitize_no_secret_no_change() {
2774        let input = "simple upstream timeout";
2775        let result = sanitize_api_error(input);
2776        assert_eq!(result, input);
2777    }
2778
2779    #[test]
2780    fn scrub_github_personal_access_token() {
2781        let input = "auth failed with token ghp_abc123def456";
2782        let result = scrub_secret_patterns(input);
2783        assert_eq!(result, "auth failed with token [REDACTED]");
2784    }
2785
2786    #[test]
2787    fn scrub_github_oauth_token() {
2788        let input = "Bearer gho_1234567890abcdef";
2789        let result = scrub_secret_patterns(input);
2790        assert_eq!(result, "Bearer [REDACTED]");
2791    }
2792
2793    #[test]
2794    fn scrub_github_user_token() {
2795        let input = "token ghu_sessiontoken123";
2796        let result = scrub_secret_patterns(input);
2797        assert_eq!(result, "token [REDACTED]");
2798    }
2799
2800    #[test]
2801    fn scrub_github_fine_grained_pat() {
2802        let input = "failed: github_pat_11AABBC_xyzzy789";
2803        let result = scrub_secret_patterns(input);
2804        assert_eq!(result, "failed: [REDACTED]");
2805    }
2806
2807    // --- parse_provider_profile ---
2808
2809    #[test]
2810    fn parse_provider_profile_plain_name() {
2811        let (name, profile) = parse_provider_profile("gemini");
2812        assert_eq!(name, "gemini");
2813        assert_eq!(profile, None);
2814    }
2815
2816    #[test]
2817    fn parse_provider_profile_with_profile() {
2818        let (name, profile) = parse_provider_profile("openai-codex:second");
2819        assert_eq!(name, "openai-codex");
2820        assert_eq!(profile, Some("second"));
2821    }
2822
2823    #[test]
2824    fn parse_provider_profile_custom_url_not_split() {
2825        let input = "custom:https://my-api.example.com/v1";
2826        let (name, profile) = parse_provider_profile(input);
2827        assert_eq!(name, input);
2828        assert_eq!(profile, None);
2829    }
2830
2831    #[test]
2832    fn parse_provider_profile_anthropic_custom_not_split() {
2833        let input = "anthropic-custom:https://bedrock.example.com";
2834        let (name, profile) = parse_provider_profile(input);
2835        assert_eq!(name, input);
2836        assert_eq!(profile, None);
2837    }
2838
2839    #[test]
2840    fn parse_provider_profile_empty_profile_ignored() {
2841        let (name, profile) = parse_provider_profile("openai-codex:");
2842        assert_eq!(name, "openai-codex:");
2843        assert_eq!(profile, None);
2844    }
2845
2846    #[test]
2847    fn parse_provider_profile_extra_colons_kept() {
2848        let (name, profile) = parse_provider_profile("provider:profile:extra");
2849        assert_eq!(name, "provider");
2850        assert_eq!(profile, Some("profile:extra"));
2851    }
2852
2853    // --- resilient fallback with profile syntax ---
2854
2855    #[test]
2856    fn resilient_fallback_with_profile_syntax() {
2857        let _guard = env_lock();
2858
2859        let reliability = crate::config::ReliabilityConfig {
2860            provider_retries: 1,
2861            provider_backoff_ms: 100,
2862            fallback_providers: vec!["openai-codex:second".into()],
2863            api_keys: Vec::new(),
2864            model_fallbacks: std::collections::HashMap::new(),
2865            channel_initial_backoff_secs: 2,
2866            channel_max_backoff_secs: 60,
2867            scheduler_poll_secs: 15,
2868            scheduler_retries: 2,
2869        };
2870
2871        // openai-codex resolves its own OAuth credential; it should not
2872        // fail even with a profile override that has no local token file.
2873        // The provider initializes successfully and will attempt auth at
2874        // request time.
2875        let provider = create_resilient_provider("lmstudio", None, None, &reliability);
2876        assert!(provider.is_ok());
2877    }
2878
2879    #[test]
2880    fn resilient_fallback_mixed_profiles_and_custom() {
2881        let _guard = env_lock();
2882
2883        let reliability = crate::config::ReliabilityConfig {
2884            provider_retries: 1,
2885            provider_backoff_ms: 100,
2886            fallback_providers: vec![
2887                "openai-codex:second".into(),
2888                "custom:http://localhost:8080/v1".into(),
2889                "lmstudio".into(),
2890                "nonexistent-provider".into(),
2891            ],
2892            api_keys: Vec::new(),
2893            model_fallbacks: std::collections::HashMap::new(),
2894            channel_initial_backoff_secs: 2,
2895            channel_max_backoff_secs: 60,
2896            scheduler_poll_secs: 15,
2897            scheduler_retries: 2,
2898        };
2899
2900        let provider = create_resilient_provider("ollama", None, None, &reliability);
2901        assert!(provider.is_ok());
2902    }
2903}