Skip to main content

construct/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 azure_openai;
21pub mod bedrock;
22pub mod claude_code;
23pub mod compatible;
24pub mod copilot;
25pub mod gemini;
26pub mod gemini_cli;
27pub mod kilocli;
28pub mod ollama;
29pub mod openai;
30pub mod openai_codex;
31pub mod openrouter;
32pub mod reliable;
33pub mod router;
34pub mod telnyx;
35pub mod traits;
36
37#[allow(unused_imports)]
38pub use traits::{
39    ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ProviderCapabilityError,
40    ToolCall, ToolResultMessage,
41};
42
43use crate::auth::AuthService;
44use compatible::{AuthStyle, OpenAiCompatibleProvider};
45use reliable::ReliableProvider;
46use serde::Deserialize;
47use std::path::PathBuf;
48
49const MAX_API_ERROR_CHARS: usize = 500;
50const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
51const MINIMAX_CN_BASE_URL: &str = "https://api.minimaxi.com/v1";
52const MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT: &str = "https://api.minimax.io/oauth/token";
53const MINIMAX_OAUTH_CN_TOKEN_ENDPOINT: &str = "https://api.minimaxi.com/oauth/token";
54const MINIMAX_OAUTH_PLACEHOLDER: &str = "minimax-oauth";
55const MINIMAX_OAUTH_CN_PLACEHOLDER: &str = "minimax-oauth-cn";
56const MINIMAX_OAUTH_TOKEN_ENV: &str = "MINIMAX_OAUTH_TOKEN";
57const MINIMAX_API_KEY_ENV: &str = "MINIMAX_API_KEY";
58const MINIMAX_OAUTH_REFRESH_TOKEN_ENV: &str = "MINIMAX_OAUTH_REFRESH_TOKEN";
59const MINIMAX_OAUTH_REGION_ENV: &str = "MINIMAX_OAUTH_REGION";
60const MINIMAX_OAUTH_CLIENT_ID_ENV: &str = "MINIMAX_OAUTH_CLIENT_ID";
61const MINIMAX_OAUTH_DEFAULT_CLIENT_ID: &str = "78257093-7e40-4613-99e0-527b14b39113";
62const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
63const GLM_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
64const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1";
65const MOONSHOT_CN_BASE_URL: &str = "https://api.moonshot.cn/v1";
66const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
67const QWEN_INTL_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
68const QWEN_US_BASE_URL: &str = "https://dashscope-us.aliyuncs.com/compatible-mode/v1";
69const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
70const BAILIAN_BASE_URL: &str = "https://coding.dashscope.aliyuncs.com/v1";
71const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
72const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
73const QWEN_OAUTH_TOKEN_ENV: &str = "QWEN_OAUTH_TOKEN";
74const QWEN_OAUTH_REFRESH_TOKEN_ENV: &str = "QWEN_OAUTH_REFRESH_TOKEN";
75const QWEN_OAUTH_RESOURCE_URL_ENV: &str = "QWEN_OAUTH_RESOURCE_URL";
76const QWEN_OAUTH_CLIENT_ID_ENV: &str = "QWEN_OAUTH_CLIENT_ID";
77const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
78const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
79const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
80const ZAI_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
81const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
82const VERCEL_AI_GATEWAY_BASE_URL: &str = "https://ai-gateway.vercel.sh/v1";
83
84pub(crate) fn is_minimax_intl_alias(name: &str) -> bool {
85    matches!(
86        name,
87        "minimax"
88            | "minimax-intl"
89            | "minimax-io"
90            | "minimax-global"
91            | "minimax-oauth"
92            | "minimax-portal"
93            | "minimax-oauth-global"
94            | "minimax-portal-global"
95    )
96}
97
98pub(crate) fn is_minimax_cn_alias(name: &str) -> bool {
99    matches!(
100        name,
101        "minimax-cn" | "minimaxi" | "minimax-oauth-cn" | "minimax-portal-cn"
102    )
103}
104
105pub(crate) fn is_minimax_alias(name: &str) -> bool {
106    is_minimax_intl_alias(name) || is_minimax_cn_alias(name)
107}
108
109pub(crate) fn is_glm_global_alias(name: &str) -> bool {
110    matches!(name, "glm" | "zhipu" | "glm-global" | "zhipu-global")
111}
112
113pub(crate) fn is_glm_cn_alias(name: &str) -> bool {
114    matches!(name, "glm-cn" | "zhipu-cn" | "bigmodel")
115}
116
117pub(crate) fn is_glm_alias(name: &str) -> bool {
118    is_glm_global_alias(name) || is_glm_cn_alias(name)
119}
120
121pub(crate) fn is_moonshot_intl_alias(name: &str) -> bool {
122    matches!(
123        name,
124        "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
125    )
126}
127
128pub(crate) fn is_moonshot_cn_alias(name: &str) -> bool {
129    matches!(name, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn")
130}
131
132pub(crate) fn is_moonshot_alias(name: &str) -> bool {
133    is_moonshot_intl_alias(name) || is_moonshot_cn_alias(name)
134}
135
136pub(crate) fn is_qwen_cn_alias(name: &str) -> bool {
137    matches!(name, "qwen" | "dashscope" | "qwen-cn" | "dashscope-cn")
138}
139
140pub(crate) fn is_qwen_intl_alias(name: &str) -> bool {
141    matches!(
142        name,
143        "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
144    )
145}
146
147pub(crate) fn is_qwen_us_alias(name: &str) -> bool {
148    matches!(name, "qwen-us" | "dashscope-us")
149}
150
151pub(crate) fn is_qwen_oauth_alias(name: &str) -> bool {
152    matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
153}
154
155pub(crate) fn is_bailian_alias(name: &str) -> bool {
156    matches!(name, "bailian" | "aliyun-bailian" | "aliyun")
157}
158
159pub(crate) fn is_qwen_alias(name: &str) -> bool {
160    is_qwen_cn_alias(name)
161        || is_qwen_intl_alias(name)
162        || is_qwen_us_alias(name)
163        || is_qwen_oauth_alias(name)
164}
165
166pub(crate) fn is_zai_global_alias(name: &str) -> bool {
167    matches!(name, "zai" | "z.ai" | "zai-global" | "z.ai-global")
168}
169
170pub(crate) fn is_zai_cn_alias(name: &str) -> bool {
171    matches!(name, "zai-cn" | "z.ai-cn")
172}
173
174pub(crate) fn is_zai_alias(name: &str) -> bool {
175    is_zai_global_alias(name) || is_zai_cn_alias(name)
176}
177
178pub(crate) fn is_qianfan_alias(name: &str) -> bool {
179    matches!(name, "qianfan" | "baidu")
180}
181
182fn qianfan_base_url(api_url: Option<&str>) -> String {
183    api_url
184        .map(str::trim)
185        .filter(|value| !value.is_empty())
186        .map(ToString::to_string)
187        .unwrap_or_else(|| QIANFAN_BASE_URL.to_string())
188}
189
190pub(crate) fn is_doubao_alias(name: &str) -> bool {
191    matches!(name, "doubao" | "volcengine" | "ark" | "doubao-cn")
192}
193
194#[derive(Clone, Copy, Debug)]
195enum MinimaxOauthRegion {
196    Global,
197    Cn,
198}
199
200impl MinimaxOauthRegion {
201    fn token_endpoint(self) -> &'static str {
202        match self {
203            Self::Global => MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT,
204            Self::Cn => MINIMAX_OAUTH_CN_TOKEN_ENDPOINT,
205        }
206    }
207}
208
209#[derive(Debug, Deserialize)]
210struct MinimaxOauthRefreshResponse {
211    #[serde(default)]
212    status: Option<String>,
213    #[serde(default)]
214    access_token: Option<String>,
215    #[serde(default)]
216    base_resp: Option<MinimaxOauthBaseResponse>,
217}
218
219#[derive(Debug, Deserialize)]
220struct MinimaxOauthBaseResponse {
221    #[serde(default)]
222    status_msg: Option<String>,
223}
224
225#[derive(Clone, Deserialize, Default)]
226struct QwenOauthCredentials {
227    #[serde(default)]
228    access_token: Option<String>,
229    #[serde(default)]
230    refresh_token: Option<String>,
231    #[serde(default)]
232    resource_url: Option<String>,
233    #[serde(default)]
234    expiry_date: Option<i64>,
235}
236
237impl std::fmt::Debug for QwenOauthCredentials {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.debug_struct("QwenOauthCredentials")
240            .field("resource_url", &self.resource_url)
241            .field("expiry_date", &self.expiry_date)
242            .finish_non_exhaustive()
243    }
244}
245
246#[derive(Debug, Deserialize)]
247struct QwenOauthTokenResponse {
248    #[serde(default)]
249    access_token: Option<String>,
250    #[serde(default)]
251    refresh_token: Option<String>,
252    #[serde(default)]
253    expires_in: Option<i64>,
254    #[serde(default)]
255    resource_url: Option<String>,
256    #[serde(default)]
257    error: Option<String>,
258    #[serde(default)]
259    error_description: Option<String>,
260}
261
262#[derive(Clone, Default)]
263struct QwenOauthProviderContext {
264    credential: Option<String>,
265    base_url: Option<String>,
266}
267
268impl std::fmt::Debug for QwenOauthProviderContext {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.debug_struct("QwenOauthProviderContext")
271            .field("base_url", &self.base_url)
272            .finish_non_exhaustive()
273    }
274}
275
276fn read_non_empty_env(name: &str) -> Option<String> {
277    std::env::var(name)
278        .ok()
279        .map(|value| value.trim().to_string())
280        .filter(|value| !value.is_empty())
281}
282
283fn is_minimax_oauth_placeholder(value: &str) -> bool {
284    value.eq_ignore_ascii_case(MINIMAX_OAUTH_PLACEHOLDER)
285        || value.eq_ignore_ascii_case(MINIMAX_OAUTH_CN_PLACEHOLDER)
286}
287
288fn minimax_oauth_region(name: &str) -> MinimaxOauthRegion {
289    if let Some(region) = read_non_empty_env(MINIMAX_OAUTH_REGION_ENV) {
290        let normalized = region.to_ascii_lowercase();
291        if matches!(normalized.as_str(), "cn" | "china") {
292            return MinimaxOauthRegion::Cn;
293        }
294        if matches!(normalized.as_str(), "global" | "intl" | "international") {
295            return MinimaxOauthRegion::Global;
296        }
297    }
298
299    if is_minimax_cn_alias(name) {
300        MinimaxOauthRegion::Cn
301    } else {
302        MinimaxOauthRegion::Global
303    }
304}
305
306fn minimax_oauth_client_id() -> String {
307    read_non_empty_env(MINIMAX_OAUTH_CLIENT_ID_ENV)
308        .unwrap_or_else(|| MINIMAX_OAUTH_DEFAULT_CLIENT_ID.to_string())
309}
310
311fn qwen_oauth_client_id() -> String {
312    read_non_empty_env(QWEN_OAUTH_CLIENT_ID_ENV)
313        .unwrap_or_else(|| QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string())
314}
315
316fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
317    std::env::var_os("HOME")
318        .map(PathBuf::from)
319        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
320        .map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
321}
322
323fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
324    let trimmed = raw.trim().trim_end_matches('/');
325    if trimmed.is_empty() {
326        return None;
327    }
328
329    let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
330        trimmed.to_string()
331    } else {
332        format!("https://{trimmed}")
333    };
334
335    let normalized = with_scheme.trim_end_matches('/').to_string();
336    if normalized.ends_with("/v1") {
337        Some(normalized)
338    } else {
339        Some(format!("{normalized}/v1"))
340    }
341}
342
343fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
344    let path = qwen_oauth_credentials_file_path()?;
345    let content = std::fs::read_to_string(path).ok()?;
346    serde_json::from_str::<QwenOauthCredentials>(&content).ok()
347}
348
349fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
350    if raw < 10_000_000_000 {
351        raw.saturating_mul(1000)
352    } else {
353        raw
354    }
355}
356
357fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
358    let Some(expiry) = credentials.expiry_date else {
359        return false;
360    };
361
362    let expiry_millis = normalized_qwen_expiry_millis(expiry);
363    let now_millis = std::time::SystemTime::now()
364        .duration_since(std::time::UNIX_EPOCH)
365        .ok()
366        .and_then(|duration| i64::try_from(duration.as_millis()).ok())
367        .unwrap_or(i64::MAX);
368
369    expiry_millis <= now_millis.saturating_add(30_000)
370}
371
372fn refresh_qwen_oauth_access_token(refresh_token: &str) -> anyhow::Result<QwenOauthCredentials> {
373    let client_id = qwen_oauth_client_id();
374    let client = reqwest::blocking::Client::builder()
375        .timeout(std::time::Duration::from_secs(15))
376        .connect_timeout(std::time::Duration::from_secs(5))
377        .build()
378        .unwrap_or_else(|_| reqwest::blocking::Client::new());
379
380    let response = client
381        .post(QWEN_OAUTH_TOKEN_ENDPOINT)
382        .header("Content-Type", "application/x-www-form-urlencoded")
383        .header("Accept", "application/json")
384        .form(&[
385            ("grant_type", "refresh_token"),
386            ("refresh_token", refresh_token),
387            ("client_id", client_id.as_str()),
388        ])
389        .send()
390        .map_err(|error| anyhow::anyhow!("Qwen OAuth refresh request failed: {error}"))?;
391
392    let status = response.status();
393    let body = response
394        .text()
395        .unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
396
397    let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
398
399    if !status.is_success() {
400        let detail = parsed
401            .as_ref()
402            .and_then(|payload| payload.error_description.as_deref())
403            .or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
404            .filter(|msg| !msg.trim().is_empty())
405            .unwrap_or(body.as_str());
406        anyhow::bail!("Qwen OAuth refresh failed (HTTP {status}): {detail}");
407    }
408
409    let payload =
410        parsed.ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response is not JSON"))?;
411
412    if let Some(error_code) = payload
413        .error
414        .as_deref()
415        .filter(|value| !value.trim().is_empty())
416    {
417        let detail = payload.error_description.as_deref().unwrap_or(error_code);
418        anyhow::bail!("Qwen OAuth refresh failed: {detail}");
419    }
420
421    let access_token = payload
422        .access_token
423        .as_deref()
424        .map(str::trim)
425        .filter(|token| !token.is_empty())
426        .ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response missing access_token"))?
427        .to_string();
428
429    let expiry_date = payload.expires_in.and_then(|seconds| {
430        let now_secs = std::time::SystemTime::now()
431            .duration_since(std::time::UNIX_EPOCH)
432            .ok()
433            .and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
434        now_secs
435            .checked_add(seconds)
436            .and_then(|unix_secs| unix_secs.checked_mul(1000))
437    });
438
439    Ok(QwenOauthCredentials {
440        access_token: Some(access_token),
441        refresh_token: payload
442            .refresh_token
443            .as_deref()
444            .map(str::trim)
445            .filter(|value| !value.is_empty())
446            .map(ToString::to_string),
447        resource_url: payload
448            .resource_url
449            .as_deref()
450            .map(str::trim)
451            .filter(|value| !value.is_empty())
452            .map(ToString::to_string),
453        expiry_date,
454    })
455}
456
457fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
458    let override_value = credential_override
459        .map(str::trim)
460        .filter(|value| !value.is_empty());
461    let placeholder_requested = override_value
462        .map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
463        .unwrap_or(false);
464
465    if let Some(explicit) = override_value {
466        if !placeholder_requested {
467            return QwenOauthProviderContext {
468                credential: Some(explicit.to_string()),
469                base_url: None,
470            };
471        }
472    }
473
474    let mut cached = read_qwen_oauth_cached_credentials();
475
476    let env_token = read_non_empty_env(QWEN_OAUTH_TOKEN_ENV);
477    let env_refresh_token = read_non_empty_env(QWEN_OAUTH_REFRESH_TOKEN_ENV);
478    let env_resource_url = read_non_empty_env(QWEN_OAUTH_RESOURCE_URL_ENV);
479
480    if env_token.is_none() {
481        let refresh_token = env_refresh_token.clone().or_else(|| {
482            cached
483                .as_ref()
484                .and_then(|credentials| credentials.refresh_token.clone())
485        });
486
487        let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
488            || cached
489                .as_ref()
490                .and_then(|credentials| credentials.access_token.as_deref())
491                .is_none_or(|value| value.trim().is_empty());
492
493        if should_refresh {
494            if let Some(refresh_token) = refresh_token.as_deref() {
495                match refresh_qwen_oauth_access_token(refresh_token) {
496                    Ok(refreshed) => {
497                        cached = Some(refreshed);
498                    }
499                    Err(error) => {
500                        tracing::warn!(error = %error, "Qwen OAuth refresh failed");
501                    }
502                }
503            }
504        }
505    }
506
507    let mut credential = env_token.or_else(|| {
508        cached
509            .as_ref()
510            .and_then(|credentials| credentials.access_token.clone())
511    });
512    credential = credential
513        .as_deref()
514        .map(str::trim)
515        .filter(|value| !value.is_empty())
516        .map(ToString::to_string);
517
518    if credential.is_none() && !placeholder_requested {
519        credential = read_non_empty_env("DASHSCOPE_API_KEY");
520    }
521
522    let base_url = env_resource_url
523        .as_deref()
524        .and_then(normalize_qwen_oauth_base_url)
525        .or_else(|| {
526            cached
527                .as_ref()
528                .and_then(|credentials| credentials.resource_url.as_deref())
529                .and_then(normalize_qwen_oauth_base_url)
530        });
531
532    QwenOauthProviderContext {
533        credential,
534        base_url,
535    }
536}
537
538fn resolve_minimax_static_credential() -> Option<String> {
539    read_non_empty_env(MINIMAX_OAUTH_TOKEN_ENV).or_else(|| read_non_empty_env(MINIMAX_API_KEY_ENV))
540}
541
542fn refresh_minimax_oauth_access_token(name: &str, refresh_token: &str) -> anyhow::Result<String> {
543    let region = minimax_oauth_region(name);
544    let endpoint = region.token_endpoint();
545    let client_id = minimax_oauth_client_id();
546    let client = reqwest::blocking::Client::builder()
547        .timeout(std::time::Duration::from_secs(15))
548        .connect_timeout(std::time::Duration::from_secs(5))
549        .build()
550        .unwrap_or_else(|_| reqwest::blocking::Client::new());
551
552    let response = client
553        .post(endpoint)
554        .header("Content-Type", "application/x-www-form-urlencoded")
555        .header("Accept", "application/json")
556        .form(&[
557            ("grant_type", "refresh_token"),
558            ("refresh_token", refresh_token),
559            ("client_id", client_id.as_str()),
560        ])
561        .send()
562        .map_err(|error| anyhow::anyhow!("MiniMax OAuth refresh request failed: {error}"))?;
563
564    let status = response.status();
565    let body = response
566        .text()
567        .unwrap_or_else(|_| "<failed to read MiniMax OAuth response body>".to_string());
568
569    let parsed = serde_json::from_str::<MinimaxOauthRefreshResponse>(&body).ok();
570
571    if !status.is_success() {
572        let detail = parsed
573            .as_ref()
574            .and_then(|payload| payload.base_resp.as_ref())
575            .and_then(|base| base.status_msg.as_deref())
576            .filter(|msg| !msg.trim().is_empty())
577            .unwrap_or(body.as_str());
578        anyhow::bail!("MiniMax OAuth refresh failed (HTTP {status}): {detail}");
579    }
580
581    if let Some(payload) = parsed {
582        if let Some(status_text) = payload.status.as_deref() {
583            if !status_text.eq_ignore_ascii_case("success") {
584                let detail = payload
585                    .base_resp
586                    .as_ref()
587                    .and_then(|base| base.status_msg.as_deref())
588                    .unwrap_or(status_text);
589                anyhow::bail!("MiniMax OAuth refresh failed: {detail}");
590            }
591        }
592
593        if let Some(token) = payload
594            .access_token
595            .as_deref()
596            .map(str::trim)
597            .filter(|token| !token.is_empty())
598        {
599            return Ok(token.to_string());
600        }
601    }
602
603    anyhow::bail!("MiniMax OAuth refresh response missing access_token");
604}
605
606fn resolve_minimax_oauth_refresh_token(name: &str) -> Option<String> {
607    let refresh_token = read_non_empty_env(MINIMAX_OAUTH_REFRESH_TOKEN_ENV)?;
608
609    match refresh_minimax_oauth_access_token(name, &refresh_token) {
610        Ok(token) => Some(token),
611        Err(error) => {
612            tracing::warn!(provider = name, error = %error, "MiniMax OAuth refresh failed");
613            None
614        }
615    }
616}
617
618pub(crate) fn canonical_china_provider_name(name: &str) -> Option<&'static str> {
619    if is_qwen_alias(name) {
620        Some("qwen")
621    } else if is_glm_alias(name) {
622        Some("glm")
623    } else if is_moonshot_alias(name) {
624        Some("moonshot")
625    } else if is_minimax_alias(name) {
626        Some("minimax")
627    } else if is_zai_alias(name) {
628        Some("zai")
629    } else if is_qianfan_alias(name) {
630        Some("qianfan")
631    } else if is_doubao_alias(name) {
632        Some("doubao")
633    } else if is_bailian_alias(name) {
634        Some("bailian")
635    } else {
636        None
637    }
638}
639
640fn minimax_base_url(name: &str) -> Option<&'static str> {
641    if is_minimax_cn_alias(name) {
642        Some(MINIMAX_CN_BASE_URL)
643    } else if is_minimax_intl_alias(name) {
644        Some(MINIMAX_INTL_BASE_URL)
645    } else {
646        None
647    }
648}
649
650fn glm_base_url(name: &str) -> Option<&'static str> {
651    if is_glm_cn_alias(name) {
652        Some(GLM_CN_BASE_URL)
653    } else if is_glm_global_alias(name) {
654        Some(GLM_GLOBAL_BASE_URL)
655    } else {
656        None
657    }
658}
659
660fn moonshot_base_url(name: &str) -> Option<&'static str> {
661    if is_moonshot_intl_alias(name) {
662        Some(MOONSHOT_INTL_BASE_URL)
663    } else if is_moonshot_cn_alias(name) {
664        Some(MOONSHOT_CN_BASE_URL)
665    } else {
666        None
667    }
668}
669
670fn qwen_base_url(name: &str) -> Option<&'static str> {
671    if is_qwen_cn_alias(name) || is_qwen_oauth_alias(name) {
672        Some(QWEN_CN_BASE_URL)
673    } else if is_qwen_intl_alias(name) {
674        Some(QWEN_INTL_BASE_URL)
675    } else if is_qwen_us_alias(name) {
676        Some(QWEN_US_BASE_URL)
677    } else {
678        None
679    }
680}
681
682fn zai_base_url(name: &str) -> Option<&'static str> {
683    if is_zai_cn_alias(name) {
684        Some(ZAI_CN_BASE_URL)
685    } else if is_zai_global_alias(name) {
686        Some(ZAI_GLOBAL_BASE_URL)
687    } else {
688        None
689    }
690}
691
692#[derive(Debug, Clone)]
693pub struct ProviderRuntimeOptions {
694    pub auth_profile_override: Option<String>,
695    pub provider_api_url: Option<String>,
696    pub construct_dir: Option<PathBuf>,
697    pub secrets_encrypt: bool,
698    pub reasoning_enabled: Option<bool>,
699    pub reasoning_effort: Option<String>,
700    /// HTTP request timeout in seconds for LLM provider API calls.
701    /// `None` uses the provider's built-in default (120s for compatible providers).
702    pub provider_timeout_secs: Option<u64>,
703    /// Extra HTTP headers to include in provider API requests.
704    /// These are merged from the config file and `CONSTRUCT_EXTRA_HEADERS` env var.
705    pub extra_headers: std::collections::HashMap<String, String>,
706    /// Custom API path suffix for OpenAI-compatible providers
707    /// (e.g. "/v2/generate" instead of the default "/chat/completions").
708    pub api_path: Option<String>,
709    /// Maximum output tokens for LLM provider API requests.
710    /// `None` uses the provider's built-in default.
711    pub provider_max_tokens: Option<u32>,
712}
713
714impl Default for ProviderRuntimeOptions {
715    fn default() -> Self {
716        Self {
717            auth_profile_override: None,
718            provider_api_url: None,
719            construct_dir: None,
720            secrets_encrypt: true,
721            reasoning_enabled: None,
722            reasoning_effort: None,
723            provider_timeout_secs: None,
724            extra_headers: std::collections::HashMap::new(),
725            api_path: None,
726            provider_max_tokens: None,
727        }
728    }
729}
730
731pub fn provider_runtime_options_from_config(
732    config: &crate::config::Config,
733) -> ProviderRuntimeOptions {
734    ProviderRuntimeOptions {
735        auth_profile_override: None,
736        provider_api_url: config.api_url.clone(),
737        construct_dir: config.config_path.parent().map(PathBuf::from),
738        secrets_encrypt: config.secrets.encrypt,
739        reasoning_enabled: config.runtime.reasoning_enabled,
740        reasoning_effort: config.runtime.reasoning_effort.clone(),
741        provider_timeout_secs: Some(config.provider_timeout_secs),
742        extra_headers: config.extra_headers.clone(),
743        api_path: config.api_path.clone(),
744        provider_max_tokens: config.provider_max_tokens,
745    }
746}
747
748fn is_secret_char(c: char) -> bool {
749    c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
750}
751
752fn token_end(input: &str, from: usize) -> usize {
753    let mut end = from;
754    for (i, c) in input[from..].char_indices() {
755        if is_secret_char(c) {
756            end = from + i + c.len_utf8();
757        } else {
758            break;
759        }
760    }
761    end
762}
763
764/// Scrub known secret-like token prefixes from provider error strings.
765///
766/// Redacts tokens with prefixes like `sk-`, `xoxb-`, `xoxp-`, `ghp_`, `gho_`,
767/// `ghu_`, and `github_pat_`.
768pub fn scrub_secret_patterns(input: &str) -> String {
769    const PREFIXES: [&str; 7] = [
770        "sk-",
771        "xoxb-",
772        "xoxp-",
773        "ghp_",
774        "gho_",
775        "ghu_",
776        "github_pat_",
777    ];
778
779    let mut scrubbed = input.to_string();
780
781    for prefix in PREFIXES {
782        let mut search_from = 0;
783        loop {
784            let Some(rel) = scrubbed[search_from..].find(prefix) else {
785                break;
786            };
787
788            let start = search_from + rel;
789            let content_start = start + prefix.len();
790            let end = token_end(&scrubbed, content_start);
791
792            // Bare prefixes like "sk-" should not stop future scans.
793            if end == content_start {
794                search_from = content_start;
795                continue;
796            }
797
798            scrubbed.replace_range(start..end, "[REDACTED]");
799            search_from = start + "[REDACTED]".len();
800        }
801    }
802
803    scrubbed
804}
805
806/// Sanitize API error text by scrubbing secrets and truncating length.
807pub fn sanitize_api_error(input: &str) -> String {
808    let scrubbed = scrub_secret_patterns(input);
809
810    if scrubbed.chars().count() <= MAX_API_ERROR_CHARS {
811        return scrubbed;
812    }
813
814    let mut end = MAX_API_ERROR_CHARS;
815    while end > 0 && !scrubbed.is_char_boundary(end) {
816        end -= 1;
817    }
818
819    format!("{}...", &scrubbed[..end])
820}
821
822/// Build a sanitized provider error from a failed HTTP response.
823pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::Error {
824    let status = response.status();
825    let body = response
826        .text()
827        .await
828        .unwrap_or_else(|_| "<failed to read provider error body>".to_string());
829    let sanitized = sanitize_api_error(&body);
830    anyhow::anyhow!("{provider} API error ({status}): {sanitized}")
831}
832
833/// Resolve API key for a provider from config and environment variables.
834///
835/// Resolution order:
836/// 1. Explicitly provided `api_key` parameter (trimmed, filtered if empty)
837/// 2. Provider-specific environment variable (e.g., `ANTHROPIC_OAUTH_TOKEN`, `OPENROUTER_API_KEY`)
838/// 3. Generic fallback variables (`CONSTRUCT_API_KEY`, `API_KEY`)
839///
840/// For Anthropic, the provider-specific env var is `ANTHROPIC_OAUTH_TOKEN` (for setup-tokens)
841/// followed by `ANTHROPIC_API_KEY` (for regular API keys).
842///
843/// For MiniMax, OAuth mode supports `api_key = "minimax-oauth"`, resolving credentials from
844/// `MINIMAX_OAUTH_TOKEN` first, then `MINIMAX_API_KEY`, and finally
845/// `MINIMAX_OAUTH_REFRESH_TOKEN` (automatic access-token refresh).
846pub(crate) fn resolve_provider_credential(
847    name: &str,
848    credential_override: Option<&str>,
849) -> Option<String> {
850    let mut minimax_oauth_placeholder_requested = false;
851
852    if let Some(raw_override) = credential_override {
853        let trimmed_override = raw_override.trim();
854        if !trimmed_override.is_empty() {
855            if is_minimax_alias(name) && is_minimax_oauth_placeholder(trimmed_override) {
856                minimax_oauth_placeholder_requested = true;
857                if let Some(credential) = resolve_minimax_static_credential() {
858                    return Some(credential);
859                }
860                if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
861                    return Some(credential);
862                }
863            } else if name == "anthropic" || name == "openai" || name == "groq" {
864                // For well-known providers, prefer provider-specific env vars over the
865                // global api_key override, since the global key may belong to a different
866                // provider (e.g. a custom: gateway). This enables multi-provider setups
867                // where the primary uses a custom gateway and fallbacks use named providers.
868                let env_candidates: &[&str] = match name {
869                    "anthropic" => &["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
870                    "openai" => &["OPENAI_API_KEY"],
871                    "groq" => &["GROQ_API_KEY"],
872                    _ => &[],
873                };
874                for env_var in env_candidates {
875                    if let Ok(val) = std::env::var(env_var) {
876                        let trimmed = val.trim().to_string();
877                        if !trimmed.is_empty() {
878                            return Some(trimmed);
879                        }
880                    }
881                }
882                return Some(trimmed_override.to_owned());
883            } else {
884                return Some(trimmed_override.to_owned());
885            }
886        }
887    }
888
889    let provider_env_candidates: Vec<&str> = match name {
890        "anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
891        "openrouter" => vec!["OPENROUTER_API_KEY"],
892        "openai" => vec!["OPENAI_API_KEY"],
893        "ollama" => vec!["OLLAMA_API_KEY"],
894        "venice" => vec!["VENICE_API_KEY"],
895        "groq" => vec!["GROQ_API_KEY"],
896        "mistral" => vec!["MISTRAL_API_KEY"],
897        "deepseek" => vec!["DEEPSEEK_API_KEY"],
898        "xai" | "grok" => vec!["XAI_API_KEY"],
899        "together" | "together-ai" => vec!["TOGETHER_API_KEY"],
900        "fireworks" | "fireworks-ai" => vec!["FIREWORKS_API_KEY"],
901        "novita" => vec!["NOVITA_API_KEY"],
902        "perplexity" => vec!["PERPLEXITY_API_KEY"],
903        "cohere" => vec!["COHERE_API_KEY"],
904        name if is_moonshot_alias(name) => vec!["MOONSHOT_API_KEY"],
905        "kimi-code" | "kimi_coding" | "kimi_for_coding" => {
906            vec!["KIMI_CODE_API_KEY", "MOONSHOT_API_KEY"]
907        }
908        name if is_glm_alias(name) => vec!["GLM_API_KEY"],
909        name if is_minimax_alias(name) => vec![MINIMAX_OAUTH_TOKEN_ENV, MINIMAX_API_KEY_ENV],
910        // Bedrock supports Bearer token auth via BEDROCK_API_KEY env var, in addition
911        // to AWS AKSK (SigV4). If BEDROCK_API_KEY is set, return it; otherwise return
912        // None and let BedrockProvider handle SigV4 credential resolution internally.
913        "bedrock" | "aws-bedrock" => {
914            if let Ok(val) = std::env::var("BEDROCK_API_KEY") {
915                let trimmed = val.trim().to_string();
916                if !trimmed.is_empty() {
917                    return Some(trimmed);
918                }
919            }
920            return None;
921        }
922        name if is_qianfan_alias(name) => vec!["QIANFAN_API_KEY"],
923        name if is_doubao_alias(name) => {
924            vec!["ARK_API_KEY", "VOLCENGINE_API_KEY", "DOUBAO_API_KEY"]
925        }
926        name if is_qwen_alias(name) => vec!["DASHSCOPE_API_KEY"],
927        name if is_bailian_alias(name) => vec!["BAILIAN_API_KEY", "DASHSCOPE_API_KEY"],
928        name if is_zai_alias(name) => vec!["ZAI_API_KEY"],
929        "nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
930        "synthetic" => vec!["SYNTHETIC_API_KEY"],
931        "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
932        "opencode-go" => vec!["OPENCODE_GO_API_KEY"],
933        "vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
934        "cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
935        "ovhcloud" | "ovh" => vec!["OVH_AI_ENDPOINTS_ACCESS_TOKEN"],
936        "astrai" => vec!["ASTRAI_API_KEY"],
937        "avian" => vec!["AVIAN_API_KEY"],
938        "deepmyst" | "deep-myst" => vec!["DEEPMYST_API_KEY"],
939        "llamacpp" | "llama.cpp" => vec!["LLAMACPP_API_KEY"],
940        "sglang" => vec!["SGLANG_API_KEY"],
941        "vllm" => vec!["VLLM_API_KEY"],
942        "aihubmix" => vec!["AIHUBMIX_API_KEY"],
943        "siliconflow" | "silicon-flow" => vec!["SILICONFLOW_API_KEY"],
944        "osaurus" => vec!["OSAURUS_API_KEY"],
945        "telnyx" => vec!["TELNYX_API_KEY"],
946        "azure_openai" | "azure-openai" | "azure" => vec!["AZURE_OPENAI_API_KEY"],
947        _ => vec![],
948    };
949
950    for env_var in provider_env_candidates {
951        if let Ok(value) = std::env::var(env_var) {
952            let value = value.trim();
953            if !value.is_empty() {
954                return Some(value.to_string());
955            }
956        }
957    }
958
959    if is_minimax_alias(name) {
960        if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
961            return Some(credential);
962        }
963    }
964
965    if minimax_oauth_placeholder_requested && is_minimax_alias(name) {
966        return None;
967    }
968
969    for env_var in ["CONSTRUCT_API_KEY", "API_KEY"] {
970        if let Ok(value) = std::env::var(env_var) {
971            let value = value.trim();
972            if !value.is_empty() {
973                return Some(value.to_string());
974            }
975        }
976    }
977
978    None
979}
980
981/// Check whether an API key's prefix matches the selected provider.
982///
983/// Returns `Some("likely_provider")` when the key clearly belongs to a
984/// *different* provider (cross-provider mismatch).  Returns `None` when
985/// everything looks fine or the format is unrecognised.
986fn check_api_key_prefix(provider_name: &str, key: &str) -> Option<&'static str> {
987    // Identify which provider the key likely belongs to (longest prefix first).
988    let likely_provider = if key.starts_with("sk-ant-") {
989        Some("anthropic")
990    } else if key.starts_with("sk-or-") {
991        Some("openrouter")
992    } else if key.starts_with("sk-") {
993        Some("openai")
994    } else if key.starts_with("gsk_") {
995        Some("groq")
996    } else if key.starts_with("pplx-") {
997        Some("perplexity")
998    } else if key.starts_with("xai-") {
999        Some("xai")
1000    } else if key.starts_with("nvapi-") {
1001        Some("nvidia")
1002    } else if key.starts_with("KEY-") {
1003        Some("telnyx")
1004    } else {
1005        None
1006    };
1007
1008    let expected = likely_provider?;
1009
1010    // Only flag mismatch for providers where we know the key format.
1011    let matches = match provider_name {
1012        "anthropic" => expected == "anthropic",
1013        "openrouter" => expected == "openrouter",
1014        "openai" => expected == "openai",
1015        "groq" => expected == "groq",
1016        "perplexity" => expected == "perplexity",
1017        "xai" | "grok" => expected == "xai",
1018        "nvidia" | "nvidia-nim" | "build.nvidia.com" => expected == "nvidia",
1019        "telnyx" => expected == "telnyx",
1020        _ => return None, // Unknown format provider — skip
1021    };
1022
1023    if matches { None } else { Some(expected) }
1024}
1025
1026fn parse_custom_provider_url(
1027    raw_url: &str,
1028    provider_label: &str,
1029    format_hint: &str,
1030) -> anyhow::Result<String> {
1031    let base_url = raw_url.trim();
1032
1033    if base_url.is_empty() {
1034        anyhow::bail!("{provider_label} requires a URL. Format: {format_hint}");
1035    }
1036
1037    let parsed = reqwest::Url::parse(base_url).map_err(|_| {
1038        anyhow::anyhow!("{provider_label} requires a valid URL. Format: {format_hint}")
1039    })?;
1040
1041    match parsed.scheme() {
1042        "http" | "https" => Ok(base_url.to_string()),
1043        _ => anyhow::bail!(
1044            "{provider_label} requires an http:// or https:// URL. Format: {format_hint}"
1045        ),
1046    }
1047}
1048
1049/// Factory: create the right provider from config (without custom URL)
1050pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<dyn Provider>> {
1051    create_provider_with_options(name, api_key, &ProviderRuntimeOptions::default())
1052}
1053
1054/// Factory: create provider with runtime options (auth profile override, state dir).
1055pub fn create_provider_with_options(
1056    name: &str,
1057    api_key: Option<&str>,
1058    options: &ProviderRuntimeOptions,
1059) -> anyhow::Result<Box<dyn Provider>> {
1060    match name {
1061        "openai-codex" | "openai_codex" | "codex" => Ok(Box::new(
1062            openai_codex::OpenAiCodexProvider::new(options, api_key)?,
1063        )),
1064        _ => create_provider_with_url_and_options(name, api_key, None, options),
1065    }
1066}
1067
1068/// Factory: create the right provider from config with optional custom base URL
1069pub fn create_provider_with_url(
1070    name: &str,
1071    api_key: Option<&str>,
1072    api_url: Option<&str>,
1073) -> anyhow::Result<Box<dyn Provider>> {
1074    create_provider_with_url_and_options(name, api_key, api_url, &ProviderRuntimeOptions::default())
1075}
1076
1077/// Factory: create provider with optional base URL and runtime options.
1078#[allow(clippy::too_many_lines)]
1079fn create_provider_with_url_and_options(
1080    name: &str,
1081    api_key: Option<&str>,
1082    api_url: Option<&str>,
1083    options: &ProviderRuntimeOptions,
1084) -> anyhow::Result<Box<dyn Provider>> {
1085    // Closure to optionally apply the configured provider timeout and extra
1086    // headers to OpenAI-compatible providers before boxing them as trait objects.
1087    let compat = {
1088        let timeout = options.provider_timeout_secs;
1089        let reasoning_effort = options.reasoning_effort.clone();
1090        let extra_headers = options.extra_headers.clone();
1091        let api_path = options.api_path.clone();
1092        let max_tokens = options.provider_max_tokens;
1093        move |p: OpenAiCompatibleProvider| -> Box<dyn Provider> {
1094            let mut p = p;
1095            if let Some(t) = timeout {
1096                p = p.with_timeout_secs(t);
1097            }
1098            if let Some(ref effort) = reasoning_effort {
1099                p = p.with_reasoning_effort(Some(effort.clone()));
1100            }
1101            if !extra_headers.is_empty() {
1102                p = p.with_extra_headers(extra_headers.clone());
1103            }
1104            if api_path.is_some() {
1105                p = p.with_api_path(api_path.clone());
1106            }
1107            if let Some(mt) = max_tokens {
1108                p = p.with_max_tokens(Some(mt));
1109            }
1110            Box::new(p)
1111        }
1112    };
1113
1114    let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key));
1115
1116    // Resolve credential and break static-analysis taint chain from the
1117    // `api_key` parameter so that downstream provider storage of the value
1118    // is not linked to the original sensitive-named source.
1119    let resolved_credential = if let Some(context) = qwen_oauth_context.as_ref() {
1120        context.credential.clone()
1121    } else {
1122        resolve_provider_credential(name, api_key)
1123    }
1124    .map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
1125    #[allow(clippy::option_as_ref_deref)]
1126    let key = resolved_credential.as_ref().map(String::as_str);
1127
1128    // Pre-flight: catch obvious API-key / provider mismatches early.
1129    if let Some(key_value) = key {
1130        let is_custom = name.starts_with("custom:") || name.starts_with("anthropic-custom:");
1131        let has_custom_url = api_url.map(str::trim).filter(|u| !u.is_empty()).is_some();
1132        if !is_custom && !has_custom_url {
1133            if let Some(likely_provider) = check_api_key_prefix(name, key_value) {
1134                let visible = &key_value[..key_value.len().min(8)];
1135                anyhow::bail!(
1136                    "API key prefix mismatch: key \"{visible}...\" looks like a \
1137                     {likely_provider} key, but provider \"{name}\" is selected. \
1138                     Set the correct provider-specific env var or use `-p {likely_provider}`."
1139                );
1140            }
1141        }
1142    }
1143
1144    match name {
1145        "openai-codex" | "openai_codex" | "codex" => {
1146            let mut codex_options = options.clone();
1147            codex_options.provider_api_url = api_url
1148                .map(str::trim)
1149                .filter(|value| !value.is_empty())
1150                .map(ToString::to_string)
1151                .or_else(|| options.provider_api_url.clone());
1152            Ok(Box::new(openai_codex::OpenAiCodexProvider::new(
1153                &codex_options,
1154                key,
1155            )?))
1156        }
1157        // ── Primary providers (custom implementations) ───────
1158        "openrouter" => Ok(Box::new(
1159            openrouter::OpenRouterProvider::new(key, options.provider_timeout_secs)
1160                .with_max_tokens(options.provider_max_tokens),
1161        )),
1162        "anthropic" => {
1163            let mut p = anthropic::AnthropicProvider::new(key);
1164            if let Some(mt) = options.provider_max_tokens {
1165                p = p.with_max_tokens(mt);
1166            }
1167            Ok(Box::new(p))
1168        }
1169        "openai" => {
1170            let mut p = openai::OpenAiProvider::with_base_url(api_url, key);
1171            if let Some(mt) = options.provider_max_tokens {
1172                p = p.with_max_tokens(Some(mt));
1173            }
1174            Ok(Box::new(p))
1175        }
1176        // Ollama uses api_url for custom base URL (e.g. remote Ollama instance)
1177        "ollama" => {
1178            let env_url = std::env::var("CONSTRUCT_PROVIDER_URL").ok();
1179
1180            let api_url = env_url.as_deref().or(api_url);
1181
1182            Ok(Box::new(ollama::OllamaProvider::new_with_reasoning(
1183                api_url,
1184                key,
1185                options.reasoning_enabled,
1186            )))
1187        }
1188        "gemini" | "google" | "google-gemini" => {
1189            let state_dir = options.construct_dir.clone().unwrap_or_else(|| {
1190                directories::UserDirs::new().map_or_else(
1191                    || PathBuf::from(".construct"),
1192                    |dirs| dirs.home_dir().join(".construct"),
1193                )
1194            });
1195            let auth_service = AuthService::new(&state_dir, options.secrets_encrypt);
1196            Ok(Box::new(gemini::GeminiProvider::new_with_auth(
1197                key,
1198                auth_service,
1199                options.auth_profile_override.clone(),
1200            )))
1201        }
1202        "telnyx" => Ok(Box::new(telnyx::TelnyxProvider::new(key))),
1203
1204        // ── OpenAI-compatible providers ──────────────────────
1205        "venice" => Ok(compat(
1206            OpenAiCompatibleProvider::new(
1207                "Venice",
1208                "https://api.venice.ai",
1209                key,
1210                AuthStyle::Bearer,
1211            )
1212            .without_native_tools(),
1213        )),
1214        "vercel" | "vercel-ai" => Ok(compat(OpenAiCompatibleProvider::new(
1215            "Vercel AI Gateway",
1216            VERCEL_AI_GATEWAY_BASE_URL,
1217            key,
1218            AuthStyle::Bearer,
1219        ))),
1220        "cloudflare" | "cloudflare-ai" => Ok(compat(OpenAiCompatibleProvider::new(
1221            "Cloudflare AI Gateway",
1222            "https://gateway.ai.cloudflare.com/v1",
1223            key,
1224            AuthStyle::Bearer,
1225        ))),
1226        name if moonshot_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new(
1227            "Moonshot",
1228            moonshot_base_url(name).expect("checked in guard"),
1229            key,
1230            AuthStyle::Bearer,
1231        ))),
1232        "kimi-code" | "kimi_coding" | "kimi_for_coding" => {
1233            Ok(compat(OpenAiCompatibleProvider::new_with_user_agent(
1234                "Kimi Code",
1235                "https://api.kimi.com/coding/v1",
1236                key,
1237                AuthStyle::Bearer,
1238                "KimiCLI/0.77",
1239            )))
1240        }
1241        "synthetic" => Ok(compat(OpenAiCompatibleProvider::new(
1242            "Synthetic",
1243            "https://api.synthetic.new/openai/v1",
1244            key,
1245            AuthStyle::Bearer,
1246        ))),
1247        "opencode" | "opencode-zen" => Ok(compat(OpenAiCompatibleProvider::new(
1248            "OpenCode Zen",
1249            "https://opencode.ai/zen/v1",
1250            key,
1251            AuthStyle::Bearer,
1252        ))),
1253        "opencode-go" => Ok(compat(OpenAiCompatibleProvider::new(
1254            "OpenCode Go",
1255            "https://opencode.ai/zen/go/v1",
1256            key,
1257            AuthStyle::Bearer,
1258        ))),
1259        name if zai_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new(
1260            "Z.AI",
1261            zai_base_url(name).expect("checked in guard"),
1262            key,
1263            AuthStyle::Bearer,
1264        ))),
1265        name if glm_base_url(name).is_some() => {
1266            Ok(compat(OpenAiCompatibleProvider::new_no_responses_fallback(
1267                "GLM",
1268                glm_base_url(name).expect("checked in guard"),
1269                key,
1270                AuthStyle::Bearer,
1271            )))
1272        }
1273        name if minimax_base_url(name).is_some() => Ok(compat(
1274            OpenAiCompatibleProvider::new_merge_system_into_user(
1275                "MiniMax",
1276                minimax_base_url(name).expect("checked in guard"),
1277                key,
1278                AuthStyle::Bearer,
1279            ),
1280        )),
1281        "azure_openai" | "azure-openai" | "azure" => {
1282            let resource = std::env::var("AZURE_OPENAI_RESOURCE")
1283                .unwrap_or_else(|_| "my-resource".to_string());
1284            let deployment =
1285                std::env::var("AZURE_OPENAI_DEPLOYMENT").unwrap_or_else(|_| "gpt-4o".to_string());
1286            let api_version = std::env::var("AZURE_OPENAI_API_VERSION").ok();
1287            Ok(Box::new(azure_openai::AzureOpenAiProvider::new(
1288                key,
1289                &resource,
1290                &deployment,
1291                api_version.as_deref(),
1292            )))
1293        }
1294        "bedrock" | "aws-bedrock" => {
1295            let mut p = if let Some(api_key) = key {
1296                bedrock::BedrockProvider::with_bearer_token(api_key)
1297            } else {
1298                bedrock::BedrockProvider::new()
1299            };
1300            if let Some(mt) = options.provider_max_tokens {
1301                p = p.with_max_tokens(mt);
1302            }
1303            Ok(Box::new(p))
1304        }
1305        name if is_qwen_oauth_alias(name) => {
1306            let base_url = api_url
1307                .map(str::trim)
1308                .filter(|value| !value.is_empty())
1309                .map(ToString::to_string)
1310                .or_else(|| {
1311                    qwen_oauth_context
1312                        .as_ref()
1313                        .and_then(|context| context.base_url.clone())
1314                })
1315                .unwrap_or_else(|| QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
1316
1317            Ok(compat(
1318                OpenAiCompatibleProvider::new_with_user_agent_and_vision(
1319                    "Qwen Code",
1320                    &base_url,
1321                    key,
1322                    AuthStyle::Bearer,
1323                    "QwenCode/1.0",
1324                    true,
1325                ),
1326            ))
1327        }
1328        name if is_qianfan_alias(name) => {
1329            let base_url = qianfan_base_url(api_url);
1330            Ok(compat(OpenAiCompatibleProvider::new(
1331                "Qianfan",
1332                &base_url,
1333                key,
1334                AuthStyle::Bearer,
1335            )))
1336        }
1337        name if is_doubao_alias(name) => Ok(compat(OpenAiCompatibleProvider::new(
1338            "Doubao",
1339            "https://ark.cn-beijing.volces.com/api/v3",
1340            key,
1341            AuthStyle::Bearer,
1342        ))),
1343        name if is_bailian_alias(name) => Ok(Box::new(
1344            OpenAiCompatibleProvider::new_with_user_agent_and_vision(
1345                "Bailian",
1346                BAILIAN_BASE_URL,
1347                key,
1348                AuthStyle::Bearer,
1349                "openclaw",
1350                true,
1351            ),
1352        )),
1353        name if qwen_base_url(name).is_some() => {
1354            Ok(compat(OpenAiCompatibleProvider::new_with_vision(
1355                "Qwen",
1356                qwen_base_url(name).expect("checked in guard"),
1357                key,
1358                AuthStyle::Bearer,
1359                true,
1360            )))
1361        }
1362
1363        // ── Extended ecosystem (community favorites) ─────────
1364        "groq" => Ok(compat(OpenAiCompatibleProvider::new(
1365            "Groq",
1366            "https://api.groq.com/openai/v1",
1367            key,
1368            AuthStyle::Bearer,
1369        ))),
1370        "mistral" => Ok(compat(OpenAiCompatibleProvider::new(
1371            "Mistral",
1372            "https://api.mistral.ai/v1",
1373            key,
1374            AuthStyle::Bearer,
1375        ))),
1376        "xai" | "grok" => Ok(compat(OpenAiCompatibleProvider::new(
1377            "xAI",
1378            "https://api.x.ai",
1379            key,
1380            AuthStyle::Bearer,
1381        ))),
1382        "deepseek" => Ok(compat(OpenAiCompatibleProvider::new(
1383            "DeepSeek",
1384            "https://api.deepseek.com",
1385            key,
1386            AuthStyle::Bearer,
1387        ))),
1388        "together" | "together-ai" => Ok(compat(OpenAiCompatibleProvider::new(
1389            "Together AI",
1390            "https://api.together.xyz",
1391            key,
1392            AuthStyle::Bearer,
1393        ))),
1394        "fireworks" | "fireworks-ai" => Ok(compat(OpenAiCompatibleProvider::new(
1395            "Fireworks AI",
1396            "https://api.fireworks.ai/inference/v1",
1397            key,
1398            AuthStyle::Bearer,
1399        ))),
1400        "novita" => Ok(compat(OpenAiCompatibleProvider::new(
1401            "Novita AI",
1402            "https://api.novita.ai/openai",
1403            key,
1404            AuthStyle::Bearer,
1405        ))),
1406        "perplexity" => Ok(compat(OpenAiCompatibleProvider::new(
1407            "Perplexity",
1408            "https://api.perplexity.ai",
1409            key,
1410            AuthStyle::Bearer,
1411        ))),
1412        "cohere" => Ok(compat(OpenAiCompatibleProvider::new(
1413            "Cohere",
1414            "https://api.cohere.com/compatibility",
1415            key,
1416            AuthStyle::Bearer,
1417        ))),
1418        "copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))),
1419        "claude-code" => Ok(Box::new(claude_code::ClaudeCodeProvider::new())),
1420        "gemini-cli" => Ok(Box::new(gemini_cli::GeminiCliProvider::new())),
1421        "kilocli" | "kilo" => Ok(Box::new(kilocli::KiloCliProvider::new())),
1422        "lmstudio" | "lm-studio" => {
1423            let lm_studio_key = key
1424                .map(str::trim)
1425                .filter(|value| !value.is_empty())
1426                .unwrap_or("lm-studio");
1427            Ok(compat(OpenAiCompatibleProvider::new(
1428                "LM Studio",
1429                "http://localhost:1234/v1",
1430                Some(lm_studio_key),
1431                AuthStyle::Bearer,
1432            )))
1433        }
1434        "llamacpp" | "llama.cpp" => {
1435            let base_url = api_url
1436                .map(str::trim)
1437                .filter(|value| !value.is_empty())
1438                .unwrap_or("http://localhost:8080/v1");
1439            let llama_cpp_key = key
1440                .map(str::trim)
1441                .filter(|value| !value.is_empty())
1442                .unwrap_or("llama.cpp");
1443            Ok(compat(OpenAiCompatibleProvider::new_with_vision(
1444                "llama.cpp",
1445                base_url,
1446                Some(llama_cpp_key),
1447                AuthStyle::Bearer,
1448                true,
1449            )))
1450        }
1451        "sglang" => {
1452            let base_url = api_url
1453                .map(str::trim)
1454                .filter(|value| !value.is_empty())
1455                .unwrap_or("http://localhost:30000/v1");
1456            Ok(compat(OpenAiCompatibleProvider::new(
1457                "SGLang",
1458                base_url,
1459                key,
1460                AuthStyle::Bearer,
1461            )))
1462        }
1463        "vllm" => {
1464            let base_url = api_url
1465                .map(str::trim)
1466                .filter(|value| !value.is_empty())
1467                .unwrap_or("http://localhost:8000/v1");
1468            Ok(compat(OpenAiCompatibleProvider::new(
1469                "vLLM",
1470                base_url,
1471                key,
1472                AuthStyle::Bearer,
1473            )))
1474        }
1475        "osaurus" => {
1476            let base_url = api_url
1477                .map(str::trim)
1478                .filter(|value| !value.is_empty())
1479                .unwrap_or("http://localhost:1337/v1");
1480            let osaurus_key = key
1481                .map(str::trim)
1482                .filter(|value| !value.is_empty())
1483                .unwrap_or("osaurus");
1484            Ok(compat(OpenAiCompatibleProvider::new(
1485                "Osaurus",
1486                base_url,
1487                Some(osaurus_key),
1488                AuthStyle::Bearer,
1489            )))
1490        }
1491        "nvidia" | "nvidia-nim" | "build.nvidia.com" => {
1492            Ok(compat(OpenAiCompatibleProvider::new_no_responses_fallback(
1493                "NVIDIA NIM",
1494                "https://integrate.api.nvidia.com/v1",
1495                key,
1496                AuthStyle::Bearer,
1497            )))
1498        }
1499
1500        // ── AI inference routers ─────────────────────────────
1501        "astrai" => Ok(compat(OpenAiCompatibleProvider::new(
1502            "Astrai",
1503            "https://as-trai.com/v1",
1504            key,
1505            AuthStyle::Bearer,
1506        ))),
1507        "siliconflow" | "silicon-flow" => Ok(compat(OpenAiCompatibleProvider::new(
1508            "SiliconFlow",
1509            "https://api.siliconflow.cn/v1",
1510            key,
1511            AuthStyle::Bearer,
1512        ))),
1513        "aihubmix" => Ok(compat(OpenAiCompatibleProvider::new(
1514            "AiHubMix",
1515            "https://aihubmix.com/v1",
1516            key,
1517            AuthStyle::Bearer,
1518        ))),
1519        "litellm" | "lite-llm" => {
1520            let base_url = api_url
1521                .map(str::trim)
1522                .filter(|value| !value.is_empty())
1523                .unwrap_or("http://localhost:4000/v1");
1524            Ok(compat(OpenAiCompatibleProvider::new(
1525                "LiteLLM",
1526                base_url,
1527                key,
1528                AuthStyle::Bearer,
1529            )))
1530        }
1531
1532        // ── Fast inference providers ──────────────────────────
1533        "cerebras" => Ok(compat(OpenAiCompatibleProvider::new(
1534            "Cerebras",
1535            "https://api.cerebras.ai/v1",
1536            key,
1537            AuthStyle::Bearer,
1538        ))),
1539        "sambanova" => Ok(compat(OpenAiCompatibleProvider::new(
1540            "SambaNova",
1541            "https://api.sambanova.ai/v1",
1542            key,
1543            AuthStyle::Bearer,
1544        ))),
1545        "hyperbolic" => Ok(compat(OpenAiCompatibleProvider::new(
1546            "Hyperbolic",
1547            "https://api.hyperbolic.xyz/v1",
1548            key,
1549            AuthStyle::Bearer,
1550        ))),
1551
1552        // ── Model hosting platforms ──────────────────────────
1553        "deepinfra" | "deep-infra" => Ok(compat(OpenAiCompatibleProvider::new(
1554            "DeepInfra",
1555            "https://api.deepinfra.com/v1/openai",
1556            key,
1557            AuthStyle::Bearer,
1558        ))),
1559        "huggingface" | "hf" => Ok(compat(OpenAiCompatibleProvider::new(
1560            "Hugging Face",
1561            "https://router.huggingface.co/v1",
1562            key,
1563            AuthStyle::Bearer,
1564        ))),
1565        "ai21" | "ai21-labs" => Ok(compat(OpenAiCompatibleProvider::new(
1566            "AI21 Labs",
1567            "https://api.ai21.com/studio/v1",
1568            key,
1569            AuthStyle::Bearer,
1570        ))),
1571        "reka" => Ok(compat(OpenAiCompatibleProvider::new(
1572            "Reka",
1573            "https://api.reka.ai/v1",
1574            key,
1575            AuthStyle::Bearer,
1576        ))),
1577        "baseten" => Ok(compat(OpenAiCompatibleProvider::new(
1578            "Baseten",
1579            "https://inference.baseten.co/v1",
1580            key,
1581            AuthStyle::Bearer,
1582        ))),
1583        "nscale" => Ok(compat(OpenAiCompatibleProvider::new(
1584            "Nscale",
1585            "https://inference.api.nscale.com/v1",
1586            key,
1587            AuthStyle::Bearer,
1588        ))),
1589        "anyscale" => Ok(compat(OpenAiCompatibleProvider::new(
1590            "Anyscale",
1591            "https://api.endpoints.anyscale.com/v1",
1592            key,
1593            AuthStyle::Bearer,
1594        ))),
1595        "nebius" => Ok(compat(OpenAiCompatibleProvider::new(
1596            "Nebius AI Studio",
1597            "https://api.studio.nebius.ai/v1",
1598            key,
1599            AuthStyle::Bearer,
1600        ))),
1601        "friendli" | "friendliai" => Ok(compat(OpenAiCompatibleProvider::new(
1602            "Friendli AI",
1603            "https://api.friendli.ai/serverless/v1",
1604            key,
1605            AuthStyle::Bearer,
1606        ))),
1607        "lepton" | "lepton-ai" => {
1608            let base_url = api_url
1609                .map(str::trim)
1610                .filter(|value| !value.is_empty())
1611                .unwrap_or("https://llama3-1-405b.lepton.run/api/v1");
1612            Ok(compat(OpenAiCompatibleProvider::new(
1613                "Lepton AI",
1614                base_url,
1615                key,
1616                AuthStyle::Bearer,
1617            )))
1618        }
1619
1620        // ── Chinese AI providers ─────────────────────────────
1621        "stepfun" | "step" => Ok(compat(OpenAiCompatibleProvider::new(
1622            "Stepfun",
1623            "https://api.stepfun.com/v1",
1624            key,
1625            AuthStyle::Bearer,
1626        ))),
1627        "baichuan" => Ok(compat(OpenAiCompatibleProvider::new(
1628            "Baichuan",
1629            "https://api.baichuan-ai.com/v1",
1630            key,
1631            AuthStyle::Bearer,
1632        ))),
1633        "yi" | "01ai" | "lingyiwanwu" => Ok(compat(OpenAiCompatibleProvider::new(
1634            "01.AI (Yi)",
1635            "https://api.lingyiwanwu.com/v1",
1636            key,
1637            AuthStyle::Bearer,
1638        ))),
1639        "hunyuan" | "tencent" => Ok(compat(OpenAiCompatibleProvider::new(
1640            "Tencent Hunyuan",
1641            "https://api.hunyuan.cloud.tencent.com/v1",
1642            key,
1643            AuthStyle::Bearer,
1644        ))),
1645        "avian" => Ok(compat(OpenAiCompatibleProvider::new(
1646            "Avian",
1647            "https://api.avian.io/v1",
1648            key,
1649            AuthStyle::Bearer,
1650        ))),
1651        "deepmyst" | "deep-myst" => Ok(compat(OpenAiCompatibleProvider::new(
1652            "DeepMyst",
1653            "https://api.deepmyst.com/v1",
1654            key,
1655            AuthStyle::Bearer,
1656        ))),
1657
1658        // ── Cloud AI endpoints ───────────────────────────────
1659        "ovhcloud" | "ovh" => Ok(Box::new(openai::OpenAiProvider::with_base_url(
1660            Some("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"),
1661            key,
1662        ))),
1663
1664        // ── Bring Your Own Provider (custom URL) ───────────
1665        // Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
1666        name if name.starts_with("custom:") => {
1667            let base_url = parse_custom_provider_url(
1668                name.strip_prefix("custom:").unwrap_or(""),
1669                "Custom provider",
1670                "custom:https://your-api.com",
1671            )?;
1672            Ok(compat(OpenAiCompatibleProvider::new_with_vision(
1673                "Custom",
1674                &base_url,
1675                key,
1676                AuthStyle::Bearer,
1677                true,
1678            )))
1679        }
1680
1681        // ── Anthropic-compatible custom endpoints ───────────
1682        // Format: "anthropic-custom:https://your-api.com"
1683        name if name.starts_with("anthropic-custom:") => {
1684            let base_url = parse_custom_provider_url(
1685                name.strip_prefix("anthropic-custom:").unwrap_or(""),
1686                "Anthropic-custom provider",
1687                "anthropic-custom:https://your-api.com",
1688            )?;
1689            Ok(Box::new(anthropic::AnthropicProvider::with_base_url(
1690                key,
1691                Some(&base_url),
1692            )))
1693        }
1694
1695        _ => anyhow::bail!(
1696            "Unknown provider: {name}. Check README for supported providers or run `construct onboard` to reconfigure.\n\
1697             Tip: Use \"custom:https://your-api.com\" for OpenAI-compatible endpoints.\n\
1698             Tip: Use \"anthropic-custom:https://your-api.com\" for Anthropic-compatible endpoints."
1699        ),
1700    }
1701}
1702
1703/// Parse `"provider:profile"` syntax for fallback entries.
1704///
1705/// Returns `(provider_name, Some(profile))` when the entry contains a colon-
1706/// delimited profile, or `(original_str, None)` otherwise.  Entries starting
1707/// with `custom:` or `anthropic-custom:` are left untouched because the colon
1708/// is part of the URL scheme.
1709fn parse_provider_profile(s: &str) -> (&str, Option<&str>) {
1710    if s.starts_with("custom:") || s.starts_with("anthropic-custom:") {
1711        return (s, None);
1712    }
1713    match s.split_once(':') {
1714        Some((provider, profile)) if !profile.is_empty() => (provider, Some(profile)),
1715        _ => (s, None),
1716    }
1717}
1718
1719/// Create provider chain with retry and fallback behavior.
1720pub fn create_resilient_provider(
1721    primary_name: &str,
1722    api_key: Option<&str>,
1723    api_url: Option<&str>,
1724    reliability: &crate::config::ReliabilityConfig,
1725) -> anyhow::Result<Box<dyn Provider>> {
1726    create_resilient_provider_with_options(
1727        primary_name,
1728        api_key,
1729        api_url,
1730        reliability,
1731        &ProviderRuntimeOptions::default(),
1732    )
1733}
1734
1735/// Create provider chain with retry/fallback behavior and auth runtime options.
1736pub fn create_resilient_provider_with_options(
1737    primary_name: &str,
1738    api_key: Option<&str>,
1739    api_url: Option<&str>,
1740    reliability: &crate::config::ReliabilityConfig,
1741    options: &ProviderRuntimeOptions,
1742) -> anyhow::Result<Box<dyn Provider>> {
1743    let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
1744
1745    let primary_provider = match primary_name {
1746        "openai-codex" | "openai_codex" | "codex" => {
1747            create_provider_with_options(primary_name, api_key, options)?
1748        }
1749        _ => create_provider_with_url_and_options(primary_name, api_key, api_url, options)?,
1750    };
1751    providers.push((primary_name.to_string(), primary_provider));
1752
1753    for fallback in &reliability.fallback_providers {
1754        if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) {
1755            continue;
1756        }
1757
1758        let (provider_name, profile_override) = parse_provider_profile(fallback);
1759
1760        // Each fallback provider resolves its own credential via provider-
1761        // specific env vars (e.g. DEEPSEEK_API_KEY for "deepseek") instead
1762        // of inheriting the primary provider's key. Passing `None` lets
1763        // `resolve_provider_credential` check the correct env var for the
1764        // fallback provider name.
1765        //
1766        // When a profile override is present (e.g. "openai-codex:second"),
1767        // propagate it through `auth_profile_override` so the provider
1768        // picks up the correct OAuth credential set.
1769        let fallback_options = match profile_override {
1770            Some(profile) => {
1771                let mut opts = options.clone();
1772                opts.auth_profile_override = Some(profile.to_string());
1773                opts
1774            }
1775            None => options.clone(),
1776        };
1777
1778        match create_provider_with_options(provider_name, None, &fallback_options) {
1779            Ok(provider) => providers.push((fallback.clone(), provider)),
1780            Err(_error) => {
1781                tracing::warn!(
1782                    fallback_provider = fallback,
1783                    "Ignoring invalid fallback provider during initialization"
1784                );
1785            }
1786        }
1787    }
1788
1789    let reliable = ReliableProvider::new(
1790        providers,
1791        reliability.provider_retries,
1792        reliability.provider_backoff_ms,
1793    )
1794    .with_api_keys(reliability.api_keys.clone())
1795    .with_model_fallbacks(reliability.model_fallbacks.clone());
1796
1797    Ok(Box::new(reliable))
1798}
1799
1800/// Create a RouterProvider if model routes are configured, otherwise return a
1801/// standard resilient provider. The router wraps individual providers per route,
1802/// each with its own retry/fallback chain.
1803pub fn create_routed_provider(
1804    primary_name: &str,
1805    api_key: Option<&str>,
1806    api_url: Option<&str>,
1807    reliability: &crate::config::ReliabilityConfig,
1808    model_routes: &[crate::config::ModelRouteConfig],
1809    default_model: &str,
1810) -> anyhow::Result<Box<dyn Provider>> {
1811    create_routed_provider_with_options(
1812        primary_name,
1813        api_key,
1814        api_url,
1815        reliability,
1816        model_routes,
1817        default_model,
1818        &ProviderRuntimeOptions::default(),
1819    )
1820}
1821
1822/// Create a routed provider using explicit runtime options.
1823pub fn create_routed_provider_with_options(
1824    primary_name: &str,
1825    api_key: Option<&str>,
1826    api_url: Option<&str>,
1827    reliability: &crate::config::ReliabilityConfig,
1828    model_routes: &[crate::config::ModelRouteConfig],
1829    default_model: &str,
1830    options: &ProviderRuntimeOptions,
1831) -> anyhow::Result<Box<dyn Provider>> {
1832    if model_routes.is_empty() {
1833        return create_resilient_provider_with_options(
1834            primary_name,
1835            api_key,
1836            api_url,
1837            reliability,
1838            options,
1839        );
1840    }
1841
1842    // Collect unique provider names needed
1843    let mut needed: Vec<String> = vec![primary_name.to_string()];
1844    for route in model_routes {
1845        if !needed.iter().any(|n| n == &route.provider) {
1846            needed.push(route.provider.clone());
1847        }
1848    }
1849
1850    // Create each provider (with its own resilience wrapper)
1851    let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
1852    for name in &needed {
1853        let routed_credential = model_routes
1854            .iter()
1855            .find(|r| &r.provider == name)
1856            .and_then(|r| {
1857                r.api_key.as_ref().and_then(|raw_key| {
1858                    let trimmed_key = raw_key.trim();
1859                    (!trimmed_key.is_empty()).then_some(trimmed_key)
1860                })
1861            });
1862        let key = routed_credential.or(api_key);
1863        // Only use api_url for the primary provider
1864        let url = if name == primary_name { api_url } else { None };
1865        match create_resilient_provider_with_options(name, key, url, reliability, options) {
1866            Ok(provider) => providers.push((name.clone(), provider)),
1867            Err(e) => {
1868                if name == primary_name {
1869                    return Err(e);
1870                }
1871                tracing::warn!(
1872                    provider = name.as_str(),
1873                    "Ignoring routed provider that failed to initialize"
1874                );
1875            }
1876        }
1877    }
1878
1879    // Build route table
1880    let routes: Vec<(String, router::Route)> = model_routes
1881        .iter()
1882        .map(|r| {
1883            (
1884                r.hint.clone(),
1885                router::Route {
1886                    provider_name: r.provider.clone(),
1887                    model: r.model.clone(),
1888                },
1889            )
1890        })
1891        .collect();
1892
1893    Ok(Box::new(router::RouterProvider::new(
1894        providers,
1895        routes,
1896        default_model.to_string(),
1897    )))
1898}
1899
1900/// Information about a supported provider for display purposes.
1901pub struct ProviderInfo {
1902    /// Canonical name used in config (e.g. `"openrouter"`)
1903    pub name: &'static str,
1904    /// Human-readable display name
1905    pub display_name: &'static str,
1906    /// Alternative names accepted in config
1907    pub aliases: &'static [&'static str],
1908    /// Whether the provider runs locally (no API key required)
1909    pub local: bool,
1910}
1911
1912/// Return the list of all known providers for display in `construct providers list`.
1913///
1914/// This is intentionally separate from the factory match in `create_provider`
1915/// (display concern vs. construction concern).
1916pub fn list_providers() -> Vec<ProviderInfo> {
1917    vec![
1918        // ── Primary providers ────────────────────────────────
1919        ProviderInfo {
1920            name: "openrouter",
1921            display_name: "OpenRouter",
1922            aliases: &[],
1923            local: false,
1924        },
1925        ProviderInfo {
1926            name: "anthropic",
1927            display_name: "Anthropic",
1928            aliases: &[],
1929            local: false,
1930        },
1931        ProviderInfo {
1932            name: "openai",
1933            display_name: "OpenAI",
1934            aliases: &[],
1935            local: false,
1936        },
1937        ProviderInfo {
1938            name: "openai-codex",
1939            display_name: "OpenAI Codex (OAuth)",
1940            aliases: &["openai_codex", "codex"],
1941            local: false,
1942        },
1943        ProviderInfo {
1944            name: "telnyx",
1945            display_name: "Telnyx",
1946            aliases: &[],
1947            local: false,
1948        },
1949        ProviderInfo {
1950            name: "azure_openai",
1951            display_name: "Azure OpenAI",
1952            aliases: &["azure-openai", "azure"],
1953            local: false,
1954        },
1955        ProviderInfo {
1956            name: "ollama",
1957            display_name: "Ollama",
1958            aliases: &[],
1959            local: true,
1960        },
1961        ProviderInfo {
1962            name: "gemini",
1963            display_name: "Google Gemini",
1964            aliases: &["google", "google-gemini"],
1965            local: false,
1966        },
1967        // ── OpenAI-compatible providers ──────────────────────
1968        ProviderInfo {
1969            name: "venice",
1970            display_name: "Venice",
1971            aliases: &[],
1972            local: false,
1973        },
1974        ProviderInfo {
1975            name: "vercel",
1976            display_name: "Vercel AI Gateway",
1977            aliases: &["vercel-ai"],
1978            local: false,
1979        },
1980        ProviderInfo {
1981            name: "cloudflare",
1982            display_name: "Cloudflare AI",
1983            aliases: &["cloudflare-ai"],
1984            local: false,
1985        },
1986        ProviderInfo {
1987            name: "moonshot",
1988            display_name: "Moonshot",
1989            aliases: &["kimi"],
1990            local: false,
1991        },
1992        ProviderInfo {
1993            name: "kimi-code",
1994            display_name: "Kimi Code",
1995            aliases: &["kimi_coding", "kimi_for_coding"],
1996            local: false,
1997        },
1998        ProviderInfo {
1999            name: "synthetic",
2000            display_name: "Synthetic",
2001            aliases: &[],
2002            local: false,
2003        },
2004        ProviderInfo {
2005            name: "opencode",
2006            display_name: "OpenCode Zen",
2007            aliases: &["opencode-zen"],
2008            local: false,
2009        },
2010        ProviderInfo {
2011            name: "opencode-go",
2012            display_name: "OpenCode Go",
2013            aliases: &[],
2014            local: false,
2015        },
2016        ProviderInfo {
2017            name: "zai",
2018            display_name: "Z.AI",
2019            aliases: &["z.ai"],
2020            local: false,
2021        },
2022        ProviderInfo {
2023            name: "glm",
2024            display_name: "GLM (Zhipu)",
2025            aliases: &["zhipu"],
2026            local: false,
2027        },
2028        ProviderInfo {
2029            name: "minimax",
2030            display_name: "MiniMax",
2031            aliases: &[
2032                "minimax-intl",
2033                "minimax-io",
2034                "minimax-global",
2035                "minimax-cn",
2036                "minimaxi",
2037                "minimax-oauth",
2038                "minimax-oauth-cn",
2039                "minimax-portal",
2040                "minimax-portal-cn",
2041            ],
2042            local: false,
2043        },
2044        ProviderInfo {
2045            name: "bedrock",
2046            display_name: "Amazon Bedrock",
2047            aliases: &["aws-bedrock"],
2048            local: false,
2049        },
2050        ProviderInfo {
2051            name: "qianfan",
2052            display_name: "Qianfan (Baidu)",
2053            aliases: &["baidu"],
2054            local: false,
2055        },
2056        ProviderInfo {
2057            name: "doubao",
2058            display_name: "Doubao (Volcengine)",
2059            aliases: &["volcengine", "ark", "doubao-cn"],
2060            local: false,
2061        },
2062        ProviderInfo {
2063            name: "qwen",
2064            display_name: "Qwen (DashScope / Qwen Code OAuth)",
2065            aliases: &[
2066                "dashscope",
2067                "qwen-intl",
2068                "dashscope-intl",
2069                "qwen-us",
2070                "dashscope-us",
2071                "qwen-code",
2072                "qwen-oauth",
2073                "qwen_oauth",
2074            ],
2075            local: false,
2076        },
2077        ProviderInfo {
2078            name: "bailian",
2079            display_name: "Bailian (Aliyun)",
2080            aliases: &["aliyun-bailian", "aliyun"],
2081            local: false,
2082        },
2083        ProviderInfo {
2084            name: "groq",
2085            display_name: "Groq",
2086            aliases: &[],
2087            local: false,
2088        },
2089        ProviderInfo {
2090            name: "mistral",
2091            display_name: "Mistral",
2092            aliases: &[],
2093            local: false,
2094        },
2095        ProviderInfo {
2096            name: "xai",
2097            display_name: "xAI (Grok)",
2098            aliases: &["grok"],
2099            local: false,
2100        },
2101        ProviderInfo {
2102            name: "deepseek",
2103            display_name: "DeepSeek",
2104            aliases: &[],
2105            local: false,
2106        },
2107        ProviderInfo {
2108            name: "together",
2109            display_name: "Together AI",
2110            aliases: &["together-ai"],
2111            local: false,
2112        },
2113        ProviderInfo {
2114            name: "fireworks",
2115            display_name: "Fireworks AI",
2116            aliases: &["fireworks-ai"],
2117            local: false,
2118        },
2119        ProviderInfo {
2120            name: "novita",
2121            display_name: "Novita AI",
2122            aliases: &[],
2123            local: false,
2124        },
2125        ProviderInfo {
2126            name: "perplexity",
2127            display_name: "Perplexity",
2128            aliases: &[],
2129            local: false,
2130        },
2131        ProviderInfo {
2132            name: "cohere",
2133            display_name: "Cohere",
2134            aliases: &[],
2135            local: false,
2136        },
2137        ProviderInfo {
2138            name: "copilot",
2139            display_name: "GitHub Copilot",
2140            aliases: &["github-copilot"],
2141            local: false,
2142        },
2143        ProviderInfo {
2144            name: "claude-code",
2145            display_name: "Claude Code (CLI)",
2146            aliases: &[],
2147            local: true,
2148        },
2149        ProviderInfo {
2150            name: "gemini-cli",
2151            display_name: "Gemini CLI",
2152            aliases: &[],
2153            local: true,
2154        },
2155        ProviderInfo {
2156            name: "kilocli",
2157            display_name: "KiloCLI",
2158            aliases: &["kilo"],
2159            local: true,
2160        },
2161        ProviderInfo {
2162            name: "lmstudio",
2163            display_name: "LM Studio",
2164            aliases: &["lm-studio"],
2165            local: true,
2166        },
2167        ProviderInfo {
2168            name: "llamacpp",
2169            display_name: "llama.cpp server",
2170            aliases: &["llama.cpp"],
2171            local: true,
2172        },
2173        ProviderInfo {
2174            name: "sglang",
2175            display_name: "SGLang",
2176            aliases: &[],
2177            local: true,
2178        },
2179        ProviderInfo {
2180            name: "vllm",
2181            display_name: "vLLM",
2182            aliases: &[],
2183            local: true,
2184        },
2185        ProviderInfo {
2186            name: "osaurus",
2187            display_name: "Osaurus",
2188            aliases: &[],
2189            local: true,
2190        },
2191        ProviderInfo {
2192            name: "nvidia",
2193            display_name: "NVIDIA NIM",
2194            aliases: &["nvidia-nim", "build.nvidia.com"],
2195            local: false,
2196        },
2197        ProviderInfo {
2198            name: "siliconflow",
2199            display_name: "SiliconFlow",
2200            aliases: &["silicon-flow"],
2201            local: false,
2202        },
2203        ProviderInfo {
2204            name: "aihubmix",
2205            display_name: "AiHubMix",
2206            aliases: &[],
2207            local: false,
2208        },
2209        ProviderInfo {
2210            name: "litellm",
2211            display_name: "LiteLLM",
2212            aliases: &["lite-llm"],
2213            local: false,
2214        },
2215        // ── Fast inference ────────────────────────────────────
2216        ProviderInfo {
2217            name: "cerebras",
2218            display_name: "Cerebras",
2219            aliases: &[],
2220            local: false,
2221        },
2222        ProviderInfo {
2223            name: "sambanova",
2224            display_name: "SambaNova",
2225            aliases: &[],
2226            local: false,
2227        },
2228        ProviderInfo {
2229            name: "hyperbolic",
2230            display_name: "Hyperbolic",
2231            aliases: &[],
2232            local: false,
2233        },
2234        // ── Model hosting platforms ──────────────────────────
2235        ProviderInfo {
2236            name: "deepinfra",
2237            display_name: "DeepInfra",
2238            aliases: &["deep-infra"],
2239            local: false,
2240        },
2241        ProviderInfo {
2242            name: "huggingface",
2243            display_name: "Hugging Face",
2244            aliases: &["hf"],
2245            local: false,
2246        },
2247        ProviderInfo {
2248            name: "ai21",
2249            display_name: "AI21 Labs",
2250            aliases: &["ai21-labs"],
2251            local: false,
2252        },
2253        ProviderInfo {
2254            name: "reka",
2255            display_name: "Reka",
2256            aliases: &[],
2257            local: false,
2258        },
2259        ProviderInfo {
2260            name: "baseten",
2261            display_name: "Baseten",
2262            aliases: &[],
2263            local: false,
2264        },
2265        ProviderInfo {
2266            name: "nscale",
2267            display_name: "Nscale",
2268            aliases: &[],
2269            local: false,
2270        },
2271        ProviderInfo {
2272            name: "anyscale",
2273            display_name: "Anyscale",
2274            aliases: &[],
2275            local: false,
2276        },
2277        ProviderInfo {
2278            name: "nebius",
2279            display_name: "Nebius AI Studio",
2280            aliases: &[],
2281            local: false,
2282        },
2283        ProviderInfo {
2284            name: "friendli",
2285            display_name: "Friendli AI",
2286            aliases: &["friendliai"],
2287            local: false,
2288        },
2289        ProviderInfo {
2290            name: "lepton",
2291            display_name: "Lepton AI",
2292            aliases: &["lepton-ai"],
2293            local: false,
2294        },
2295        // ── Chinese AI providers ─────────────────────────────
2296        ProviderInfo {
2297            name: "stepfun",
2298            display_name: "Stepfun",
2299            aliases: &["step"],
2300            local: false,
2301        },
2302        ProviderInfo {
2303            name: "baichuan",
2304            display_name: "Baichuan",
2305            aliases: &[],
2306            local: false,
2307        },
2308        ProviderInfo {
2309            name: "yi",
2310            display_name: "01.AI (Yi)",
2311            aliases: &["01ai", "lingyiwanwu"],
2312            local: false,
2313        },
2314        ProviderInfo {
2315            name: "hunyuan",
2316            display_name: "Tencent Hunyuan",
2317            aliases: &["tencent"],
2318            local: false,
2319        },
2320        // ── Cloud AI endpoints ───────────────────────────────
2321        ProviderInfo {
2322            name: "ovhcloud",
2323            display_name: "OVHcloud AI Endpoints",
2324            aliases: &["ovh"],
2325            local: false,
2326        },
2327        ProviderInfo {
2328            name: "avian",
2329            display_name: "Avian",
2330            aliases: &[],
2331            local: false,
2332        },
2333    ]
2334}
2335
2336#[cfg(test)]
2337mod tests {
2338    use super::*;
2339    use std::sync::{Mutex, OnceLock};
2340
2341    struct EnvGuard {
2342        key: &'static str,
2343        original: Option<String>,
2344    }
2345
2346    impl EnvGuard {
2347        fn set(key: &'static str, value: Option<&str>) -> Self {
2348            let original = std::env::var(key).ok();
2349            match value {
2350                // SAFETY: test-only, single-threaded test runner.
2351                Some(next) => unsafe { std::env::set_var(key, next) },
2352                // SAFETY: test-only, single-threaded test runner.
2353                None => unsafe { std::env::remove_var(key) },
2354            }
2355
2356            Self { key, original }
2357        }
2358    }
2359
2360    impl Drop for EnvGuard {
2361        fn drop(&mut self) {
2362            if let Some(original) = self.original.as_deref() {
2363                // SAFETY: test-only, single-threaded test runner.
2364                unsafe { std::env::set_var(self.key, original) };
2365            } else {
2366                // SAFETY: test-only, single-threaded test runner.
2367                unsafe { std::env::remove_var(self.key) };
2368            }
2369        }
2370    }
2371
2372    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2373        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2374        LOCK.get_or_init(|| Mutex::new(()))
2375            .lock()
2376            .expect("env lock poisoned")
2377    }
2378
2379    #[test]
2380    fn resolve_provider_credential_prefers_explicit_argument() {
2381        let resolved = resolve_provider_credential("openrouter", Some("  explicit-key  "));
2382        assert_eq!(resolved, Some("explicit-key".to_string()));
2383    }
2384
2385    #[test]
2386    fn resolve_provider_credential_uses_minimax_oauth_env_for_placeholder() {
2387        let _env_lock = env_lock();
2388        let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, Some("oauth-token"));
2389        let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
2390        let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
2391
2392        let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
2393
2394        assert_eq!(resolved.as_deref(), Some("oauth-token"));
2395    }
2396
2397    #[test]
2398    fn resolve_provider_credential_falls_back_to_minimax_api_key_for_placeholder() {
2399        let _env_lock = env_lock();
2400        let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
2401        let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
2402        let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
2403
2404        let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
2405
2406        assert_eq!(resolved.as_deref(), Some("api-key"));
2407    }
2408
2409    #[test]
2410    fn resolve_provider_credential_placeholder_ignores_generic_api_key_fallback() {
2411        let _env_lock = env_lock();
2412        let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
2413        let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, None);
2414        let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
2415        let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
2416
2417        let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
2418
2419        assert!(resolved.is_none());
2420    }
2421
2422    #[test]
2423    fn resolve_provider_credential_bedrock_uses_internal_credential_path() {
2424        let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
2425        let _override_guard = EnvGuard::set("OPENROUTER_API_KEY", Some("openrouter-key"));
2426        let _bedrock_guard = EnvGuard::set("BEDROCK_API_KEY", None);
2427
2428        assert_eq!(
2429            resolve_provider_credential("bedrock", Some("explicit")),
2430            Some("explicit".to_string())
2431        );
2432        assert!(resolve_provider_credential("bedrock", None).is_none());
2433        assert!(resolve_provider_credential("aws-bedrock", None).is_none());
2434    }
2435
2436    #[test]
2437    fn resolve_provider_credential_bedrock_returns_bearer_token_from_env() {
2438        let _bedrock_guard = EnvGuard::set("BEDROCK_API_KEY", Some("bedrock-bearer-token"));
2439
2440        assert_eq!(
2441            resolve_provider_credential("bedrock", None),
2442            Some("bedrock-bearer-token".to_string())
2443        );
2444        assert_eq!(
2445            resolve_provider_credential("aws-bedrock", None),
2446            Some("bedrock-bearer-token".to_string())
2447        );
2448    }
2449
2450    #[test]
2451    fn resolve_qwen_oauth_context_prefers_explicit_override() {
2452        let _env_lock = env_lock();
2453        let fake_home = format!("/tmp/construct-qwen-oauth-home-{}", std::process::id());
2454        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2455        let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
2456        let _resource_guard = EnvGuard::set(
2457            QWEN_OAUTH_RESOURCE_URL_ENV,
2458            Some("coding-intl.dashscope.aliyuncs.com"),
2459        );
2460
2461        let context = resolve_qwen_oauth_context(Some("  explicit-qwen-token  "));
2462
2463        assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
2464        assert!(context.base_url.is_none());
2465    }
2466
2467    #[test]
2468    fn resolve_qwen_oauth_context_uses_env_token_and_resource_url() {
2469        let _env_lock = env_lock();
2470        let fake_home = format!("/tmp/construct-qwen-oauth-home-{}-env", std::process::id());
2471        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2472        let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
2473        let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
2474        let _resource_guard = EnvGuard::set(
2475            QWEN_OAUTH_RESOURCE_URL_ENV,
2476            Some("coding-intl.dashscope.aliyuncs.com"),
2477        );
2478        let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
2479
2480        let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2481
2482        assert_eq!(context.credential.as_deref(), Some("oauth-token"));
2483        assert_eq!(
2484            context.base_url.as_deref(),
2485            Some("https://coding-intl.dashscope.aliyuncs.com/v1")
2486        );
2487    }
2488
2489    #[test]
2490    fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
2491        let _env_lock = env_lock();
2492        let fake_home = format!("/tmp/construct-qwen-oauth-home-{}-file", std::process::id());
2493        let creds_dir = PathBuf::from(&fake_home).join(".qwen");
2494        std::fs::create_dir_all(&creds_dir).unwrap();
2495        let creds_path = creds_dir.join("oauth_creds.json");
2496        std::fs::write(
2497            &creds_path,
2498            r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
2499        )
2500        .unwrap();
2501
2502        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2503        let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
2504        let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
2505        let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
2506        let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", None);
2507
2508        let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2509
2510        assert_eq!(context.credential.as_deref(), Some("cached-token"));
2511        assert_eq!(
2512            context.base_url.as_deref(),
2513            Some("https://resource.example.com/v1")
2514        );
2515    }
2516
2517    #[test]
2518    fn resolve_qwen_oauth_context_placeholder_does_not_use_dashscope_fallback() {
2519        let _env_lock = env_lock();
2520        let fake_home = format!(
2521            "/tmp/construct-qwen-oauth-home-{}-placeholder",
2522            std::process::id()
2523        );
2524        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2525        let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
2526        let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
2527        let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
2528        let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
2529
2530        let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2531
2532        assert!(context.credential.is_none());
2533    }
2534
2535    #[test]
2536    fn regional_alias_predicates_cover_expected_variants() {
2537        assert!(is_moonshot_alias("moonshot"));
2538        assert!(is_moonshot_alias("kimi-global"));
2539        assert!(is_glm_alias("glm"));
2540        assert!(is_glm_alias("bigmodel"));
2541        assert!(is_minimax_alias("minimax-io"));
2542        assert!(is_minimax_alias("minimaxi"));
2543        assert!(is_minimax_alias("minimax-oauth"));
2544        assert!(is_minimax_alias("minimax-portal-cn"));
2545        assert!(is_qwen_alias("dashscope"));
2546        assert!(is_qwen_alias("qwen-us"));
2547        assert!(is_qwen_alias("qwen-code"));
2548        assert!(is_qwen_oauth_alias("qwen-code"));
2549        assert!(is_qwen_oauth_alias("qwen_oauth"));
2550        assert!(is_zai_alias("z.ai"));
2551        assert!(is_zai_alias("zai-cn"));
2552        assert!(is_qianfan_alias("qianfan"));
2553        assert!(is_qianfan_alias("baidu"));
2554        assert!(is_doubao_alias("doubao"));
2555        assert!(is_doubao_alias("volcengine"));
2556        assert!(is_doubao_alias("ark"));
2557        assert!(is_doubao_alias("doubao-cn"));
2558
2559        assert!(!is_moonshot_alias("openrouter"));
2560        assert!(!is_glm_alias("openai"));
2561        assert!(!is_qwen_alias("gemini"));
2562        assert!(!is_zai_alias("anthropic"));
2563        assert!(!is_qianfan_alias("cohere"));
2564        assert!(!is_doubao_alias("deepseek"));
2565    }
2566
2567    #[test]
2568    fn canonical_china_provider_name_maps_regional_aliases() {
2569        assert_eq!(canonical_china_provider_name("moonshot"), Some("moonshot"));
2570        assert_eq!(canonical_china_provider_name("kimi-intl"), Some("moonshot"));
2571        assert_eq!(canonical_china_provider_name("glm"), Some("glm"));
2572        assert_eq!(canonical_china_provider_name("zhipu-cn"), Some("glm"));
2573        assert_eq!(canonical_china_provider_name("minimax"), Some("minimax"));
2574        assert_eq!(canonical_china_provider_name("minimax-cn"), Some("minimax"));
2575        assert_eq!(canonical_china_provider_name("qwen"), Some("qwen"));
2576        assert_eq!(canonical_china_provider_name("dashscope-us"), Some("qwen"));
2577        assert_eq!(canonical_china_provider_name("qwen-code"), Some("qwen"));
2578        assert_eq!(canonical_china_provider_name("zai"), Some("zai"));
2579        assert_eq!(canonical_china_provider_name("z.ai-cn"), Some("zai"));
2580        assert_eq!(canonical_china_provider_name("qianfan"), Some("qianfan"));
2581        assert_eq!(canonical_china_provider_name("baidu"), Some("qianfan"));
2582        assert_eq!(canonical_china_provider_name("doubao"), Some("doubao"));
2583        assert_eq!(canonical_china_provider_name("volcengine"), Some("doubao"));
2584        assert_eq!(canonical_china_provider_name("bailian"), Some("bailian"));
2585        assert_eq!(
2586            canonical_china_provider_name("aliyun-bailian"),
2587            Some("bailian")
2588        );
2589        assert_eq!(canonical_china_provider_name("aliyun"), Some("bailian"));
2590        assert_eq!(canonical_china_provider_name("openai"), None);
2591    }
2592
2593    #[test]
2594    fn regional_endpoint_aliases_map_to_expected_urls() {
2595        assert_eq!(minimax_base_url("minimax"), Some(MINIMAX_INTL_BASE_URL));
2596        assert_eq!(
2597            minimax_base_url("minimax-intl"),
2598            Some(MINIMAX_INTL_BASE_URL)
2599        );
2600        assert_eq!(minimax_base_url("minimax-cn"), Some(MINIMAX_CN_BASE_URL));
2601
2602        assert_eq!(glm_base_url("glm"), Some(GLM_GLOBAL_BASE_URL));
2603        assert_eq!(glm_base_url("glm-cn"), Some(GLM_CN_BASE_URL));
2604        assert_eq!(glm_base_url("bigmodel"), Some(GLM_CN_BASE_URL));
2605
2606        assert_eq!(moonshot_base_url("moonshot"), Some(MOONSHOT_CN_BASE_URL));
2607        assert_eq!(
2608            moonshot_base_url("moonshot-intl"),
2609            Some(MOONSHOT_INTL_BASE_URL)
2610        );
2611
2612        assert_eq!(qwen_base_url("qwen"), Some(QWEN_CN_BASE_URL));
2613        assert_eq!(qwen_base_url("qwen-cn"), Some(QWEN_CN_BASE_URL));
2614        assert_eq!(qwen_base_url("qwen-intl"), Some(QWEN_INTL_BASE_URL));
2615        assert_eq!(qwen_base_url("qwen-us"), Some(QWEN_US_BASE_URL));
2616        assert_eq!(qwen_base_url("qwen-code"), Some(QWEN_CN_BASE_URL));
2617
2618        assert_eq!(zai_base_url("zai"), Some(ZAI_GLOBAL_BASE_URL));
2619        assert_eq!(zai_base_url("z.ai"), Some(ZAI_GLOBAL_BASE_URL));
2620        assert_eq!(zai_base_url("zai-global"), Some(ZAI_GLOBAL_BASE_URL));
2621        assert_eq!(zai_base_url("z.ai-global"), Some(ZAI_GLOBAL_BASE_URL));
2622        assert_eq!(zai_base_url("zai-cn"), Some(ZAI_CN_BASE_URL));
2623        assert_eq!(zai_base_url("z.ai-cn"), Some(ZAI_CN_BASE_URL));
2624    }
2625
2626    // ── Primary providers ────────────────────────────────────
2627
2628    #[test]
2629    fn factory_openrouter() {
2630        assert!(create_provider("openrouter", Some("provider-test-credential")).is_ok());
2631        assert!(create_provider("openrouter", None).is_ok());
2632    }
2633
2634    #[test]
2635    fn factory_anthropic() {
2636        assert!(create_provider("anthropic", Some("provider-test-credential")).is_ok());
2637    }
2638
2639    #[test]
2640    fn factory_openai() {
2641        assert!(create_provider("openai", Some("provider-test-credential")).is_ok());
2642    }
2643
2644    #[test]
2645    fn factory_openai_codex() {
2646        let options = ProviderRuntimeOptions::default();
2647        assert!(create_provider_with_options("openai-codex", None, &options).is_ok());
2648    }
2649
2650    #[test]
2651    fn factory_ollama() {
2652        assert!(create_provider("ollama", None).is_ok());
2653        // Ollama may use API key when a remote endpoint is configured.
2654        assert!(create_provider("ollama", Some("dummy")).is_ok());
2655        assert!(create_provider("ollama", Some("any-value-here")).is_ok());
2656    }
2657
2658    #[test]
2659    fn factory_gemini() {
2660        assert!(create_provider("gemini", Some("test-key")).is_ok());
2661        assert!(create_provider("google", Some("test-key")).is_ok());
2662        assert!(create_provider("google-gemini", Some("test-key")).is_ok());
2663        // Should also work without key (will try CLI auth)
2664        assert!(create_provider("gemini", None).is_ok());
2665    }
2666
2667    #[test]
2668    fn factory_telnyx() {
2669        assert!(create_provider("telnyx", Some("test-key")).is_ok());
2670        assert!(create_provider("telnyx", None).is_ok());
2671    }
2672
2673    // ── OpenAI-compatible providers ──────────────────────────
2674
2675    #[test]
2676    fn factory_venice() {
2677        let provider = create_provider("venice", Some("vn-key")).unwrap();
2678        assert!(
2679            !provider.capabilities().native_tool_calling,
2680            "Venice should use prompt-guided tools, not native tool calling"
2681        );
2682    }
2683
2684    #[test]
2685    fn factory_vercel() {
2686        assert!(create_provider("vercel", Some("key")).is_ok());
2687        assert!(create_provider("vercel-ai", Some("key")).is_ok());
2688    }
2689
2690    #[test]
2691    fn vercel_gateway_base_url_matches_public_gateway_endpoint() {
2692        assert_eq!(
2693            VERCEL_AI_GATEWAY_BASE_URL,
2694            "https://ai-gateway.vercel.sh/v1"
2695        );
2696    }
2697
2698    #[test]
2699    fn factory_cloudflare() {
2700        assert!(create_provider("cloudflare", Some("key")).is_ok());
2701        assert!(create_provider("cloudflare-ai", Some("key")).is_ok());
2702    }
2703
2704    #[test]
2705    fn factory_moonshot() {
2706        assert!(create_provider("moonshot", Some("key")).is_ok());
2707        assert!(create_provider("kimi", Some("key")).is_ok());
2708        assert!(create_provider("moonshot-intl", Some("key")).is_ok());
2709        assert!(create_provider("moonshot-cn", Some("key")).is_ok());
2710        assert!(create_provider("kimi-intl", Some("key")).is_ok());
2711        assert!(create_provider("kimi-cn", Some("key")).is_ok());
2712    }
2713
2714    #[test]
2715    fn factory_kimi_code() {
2716        assert!(create_provider("kimi-code", Some("key")).is_ok());
2717        assert!(create_provider("kimi_coding", Some("key")).is_ok());
2718        assert!(create_provider("kimi_for_coding", Some("key")).is_ok());
2719    }
2720
2721    #[test]
2722    fn factory_synthetic() {
2723        assert!(create_provider("synthetic", Some("key")).is_ok());
2724    }
2725
2726    #[test]
2727    fn factory_opencode() {
2728        assert!(create_provider("opencode", Some("key")).is_ok());
2729        assert!(create_provider("opencode-zen", Some("key")).is_ok());
2730    }
2731
2732    #[test]
2733    fn factory_opencode_go() {
2734        assert!(create_provider("opencode-go", Some("key")).is_ok());
2735    }
2736
2737    #[test]
2738    fn resolve_provider_credential_opencode_go_env() {
2739        let _env_lock = env_lock();
2740        let _provider_guard = EnvGuard::set("OPENCODE_GO_API_KEY", Some("go-test-key"));
2741        let _generic_guard = EnvGuard::set("API_KEY", None);
2742        let _construct_guard = EnvGuard::set("CONSTRUCT_API_KEY", None);
2743
2744        let resolved = resolve_provider_credential("opencode-go", None);
2745        assert_eq!(resolved.as_deref(), Some("go-test-key"));
2746    }
2747
2748    #[test]
2749    fn factory_zai() {
2750        assert!(create_provider("zai", Some("key")).is_ok());
2751        assert!(create_provider("z.ai", Some("key")).is_ok());
2752        assert!(create_provider("zai-global", Some("key")).is_ok());
2753        assert!(create_provider("z.ai-global", Some("key")).is_ok());
2754        assert!(create_provider("zai-cn", Some("key")).is_ok());
2755        assert!(create_provider("z.ai-cn", Some("key")).is_ok());
2756    }
2757
2758    #[test]
2759    fn factory_glm() {
2760        assert!(create_provider("glm", Some("key")).is_ok());
2761        assert!(create_provider("zhipu", Some("key")).is_ok());
2762        assert!(create_provider("glm-cn", Some("key")).is_ok());
2763        assert!(create_provider("zhipu-cn", Some("key")).is_ok());
2764        assert!(create_provider("glm-global", Some("key")).is_ok());
2765        assert!(create_provider("bigmodel", Some("key")).is_ok());
2766    }
2767
2768    #[test]
2769    fn factory_minimax() {
2770        assert!(create_provider("minimax", Some("key")).is_ok());
2771        assert!(create_provider("minimax-intl", Some("key")).is_ok());
2772        assert!(create_provider("minimax-io", Some("key")).is_ok());
2773        assert!(create_provider("minimax-global", Some("key")).is_ok());
2774        assert!(create_provider("minimax-cn", Some("key")).is_ok());
2775        assert!(create_provider("minimaxi", Some("key")).is_ok());
2776        assert!(create_provider("minimax-oauth", Some("key")).is_ok());
2777        assert!(create_provider("minimax-oauth-cn", Some("key")).is_ok());
2778        assert!(create_provider("minimax-portal", Some("key")).is_ok());
2779        assert!(create_provider("minimax-portal-cn", Some("key")).is_ok());
2780    }
2781
2782    #[test]
2783    fn factory_minimax_disables_native_tool_calling() {
2784        let minimax = create_provider("minimax", Some("key")).expect("provider should resolve");
2785        assert!(!minimax.supports_native_tools());
2786
2787        let minimax_cn =
2788            create_provider("minimax-cn", Some("key")).expect("provider should resolve");
2789        assert!(!minimax_cn.supports_native_tools());
2790    }
2791
2792    #[test]
2793    fn factory_bedrock() {
2794        // Bedrock uses AWS env vars for credentials, not API key.
2795        assert!(create_provider("bedrock", None).is_ok());
2796        assert!(create_provider("aws-bedrock", None).is_ok());
2797        // Passing an api_key is harmless (ignored).
2798        assert!(create_provider("bedrock", Some("ignored")).is_ok());
2799    }
2800
2801    #[test]
2802    fn factory_qianfan() {
2803        assert!(create_provider("qianfan", Some("key")).is_ok());
2804        assert!(create_provider("baidu", Some("key")).is_ok());
2805    }
2806
2807    #[test]
2808    fn factory_doubao() {
2809        assert!(create_provider("doubao", Some("key")).is_ok());
2810        assert!(create_provider("volcengine", Some("key")).is_ok());
2811        assert!(create_provider("ark", Some("key")).is_ok());
2812        assert!(create_provider("doubao-cn", Some("key")).is_ok());
2813    }
2814
2815    #[test]
2816    fn factory_qwen() {
2817        assert!(create_provider("qwen", Some("key")).is_ok());
2818        assert!(create_provider("dashscope", Some("key")).is_ok());
2819        assert!(create_provider("qwen-cn", Some("key")).is_ok());
2820        assert!(create_provider("dashscope-cn", Some("key")).is_ok());
2821        assert!(create_provider("qwen-intl", Some("key")).is_ok());
2822        assert!(create_provider("dashscope-intl", Some("key")).is_ok());
2823        assert!(create_provider("qwen-international", Some("key")).is_ok());
2824        assert!(create_provider("dashscope-international", Some("key")).is_ok());
2825        assert!(create_provider("qwen-us", Some("key")).is_ok());
2826        assert!(create_provider("dashscope-us", Some("key")).is_ok());
2827        assert!(create_provider("qwen-code", Some("key")).is_ok());
2828        assert!(create_provider("qwen-oauth", Some("key")).is_ok());
2829    }
2830
2831    #[test]
2832    fn qwen_provider_supports_vision() {
2833        let provider = create_provider("qwen", Some("key")).expect("qwen provider should build");
2834        assert!(provider.supports_vision());
2835
2836        let oauth_provider =
2837            create_provider("qwen-code", Some("key")).expect("qwen oauth provider should build");
2838        assert!(oauth_provider.supports_vision());
2839    }
2840
2841    #[test]
2842    fn factory_lmstudio() {
2843        assert!(create_provider("lmstudio", Some("key")).is_ok());
2844        assert!(create_provider("lm-studio", Some("key")).is_ok());
2845        assert!(create_provider("lmstudio", None).is_ok());
2846    }
2847
2848    #[test]
2849    fn factory_llamacpp() {
2850        assert!(create_provider("llamacpp", Some("key")).is_ok());
2851        assert!(create_provider("llama.cpp", Some("key")).is_ok());
2852        assert!(create_provider("llamacpp", None).is_ok());
2853    }
2854
2855    #[test]
2856    fn factory_sglang() {
2857        assert!(create_provider("sglang", None).is_ok());
2858        assert!(create_provider("sglang", Some("key")).is_ok());
2859    }
2860
2861    #[test]
2862    fn factory_vllm() {
2863        assert!(create_provider("vllm", None).is_ok());
2864        assert!(create_provider("vllm", Some("key")).is_ok());
2865    }
2866
2867    #[test]
2868    fn factory_osaurus() {
2869        // Osaurus works without an explicit key (defaults to "osaurus").
2870        assert!(create_provider("osaurus", None).is_ok());
2871        // Osaurus also works with an explicit key.
2872        assert!(create_provider("osaurus", Some("custom-key")).is_ok());
2873    }
2874
2875    #[test]
2876    fn factory_osaurus_uses_default_key_when_none() {
2877        // Verify that create_provider_with_url_and_options succeeds even
2878        // without an API key — the match arm provides a default placeholder.
2879        let options = ProviderRuntimeOptions::default();
2880        let p = create_provider_with_url_and_options("osaurus", None, None, &options);
2881        assert!(p.is_ok());
2882    }
2883
2884    #[test]
2885    fn factory_osaurus_custom_url() {
2886        // Verify that a custom api_url overrides the default localhost endpoint.
2887        let options = ProviderRuntimeOptions::default();
2888        let p = create_provider_with_url_and_options(
2889            "osaurus",
2890            Some("key"),
2891            Some("http://192.168.1.100:1337/v1"),
2892            &options,
2893        );
2894        assert!(p.is_ok());
2895    }
2896
2897    #[test]
2898    fn resolve_provider_credential_osaurus_env() {
2899        let _env_lock = env_lock();
2900        let _guard = EnvGuard::set("OSAURUS_API_KEY", Some("osaurus-test-key"));
2901        let resolved = resolve_provider_credential("osaurus", None);
2902        assert_eq!(resolved, Some("osaurus-test-key".to_string()));
2903    }
2904
2905    #[test]
2906    fn resolve_provider_credential_volcengine_env() {
2907        let _env_lock = env_lock();
2908        let _guard = EnvGuard::set("VOLCENGINE_API_KEY", Some("volc-test-key"));
2909        let resolved = resolve_provider_credential("volcengine", None);
2910        assert_eq!(resolved, Some("volc-test-key".to_string()));
2911    }
2912
2913    #[test]
2914    fn resolve_provider_credential_aihubmix_env() {
2915        let _env_lock = env_lock();
2916        let _guard = EnvGuard::set("AIHUBMIX_API_KEY", Some("aihubmix-test-key"));
2917        let resolved = resolve_provider_credential("aihubmix", None);
2918        assert_eq!(resolved, Some("aihubmix-test-key".to_string()));
2919    }
2920
2921    #[test]
2922    fn resolve_provider_credential_siliconflow_env() {
2923        let _env_lock = env_lock();
2924        let _guard = EnvGuard::set("SILICONFLOW_API_KEY", Some("sf-test-key"));
2925        let resolved = resolve_provider_credential("siliconflow", None);
2926        assert_eq!(resolved, Some("sf-test-key".to_string()));
2927    }
2928
2929    #[test]
2930    fn factory_aihubmix() {
2931        assert!(create_provider("aihubmix", Some("key")).is_ok());
2932    }
2933
2934    #[test]
2935    fn factory_siliconflow() {
2936        assert!(create_provider("siliconflow", Some("key")).is_ok());
2937        assert!(create_provider("silicon-flow", Some("key")).is_ok());
2938    }
2939
2940    #[test]
2941    fn factory_codex_oauth_aliases() {
2942        let options = ProviderRuntimeOptions::default();
2943        for alias in &["codex", "openai-codex", "openai_codex"] {
2944            assert!(
2945                create_provider_with_options(alias, None, &options).is_ok(),
2946                "codex alias '{alias}' should produce a provider"
2947            );
2948        }
2949    }
2950
2951    // ── Extended ecosystem ───────────────────────────────────
2952
2953    #[test]
2954    fn factory_groq() {
2955        assert!(create_provider("groq", Some("key")).is_ok());
2956    }
2957
2958    #[test]
2959    fn factory_mistral() {
2960        assert!(create_provider("mistral", Some("key")).is_ok());
2961    }
2962
2963    #[test]
2964    fn factory_xai() {
2965        assert!(create_provider("xai", Some("key")).is_ok());
2966        assert!(create_provider("grok", Some("key")).is_ok());
2967    }
2968
2969    #[test]
2970    fn factory_deepseek() {
2971        assert!(create_provider("deepseek", Some("key")).is_ok());
2972    }
2973
2974    #[test]
2975    fn deepseek_provider_keeps_vision_disabled() {
2976        let provider =
2977            create_provider("deepseek", Some("key")).expect("deepseek provider should build");
2978        assert!(!provider.supports_vision());
2979    }
2980
2981    #[test]
2982    fn factory_together() {
2983        assert!(create_provider("together", Some("key")).is_ok());
2984        assert!(create_provider("together-ai", Some("key")).is_ok());
2985    }
2986
2987    #[test]
2988    fn factory_fireworks() {
2989        assert!(create_provider("fireworks", Some("key")).is_ok());
2990        assert!(create_provider("fireworks-ai", Some("key")).is_ok());
2991    }
2992
2993    #[test]
2994    fn factory_novita() {
2995        assert!(create_provider("novita", Some("key")).is_ok());
2996    }
2997
2998    #[test]
2999    fn factory_perplexity() {
3000        assert!(create_provider("perplexity", Some("key")).is_ok());
3001    }
3002
3003    #[test]
3004    fn factory_cohere() {
3005        assert!(create_provider("cohere", Some("key")).is_ok());
3006    }
3007
3008    #[test]
3009    fn factory_copilot() {
3010        assert!(create_provider("copilot", Some("key")).is_ok());
3011        assert!(create_provider("github-copilot", Some("key")).is_ok());
3012    }
3013
3014    #[test]
3015    fn factory_claude_code() {
3016        assert!(create_provider("claude-code", None).is_ok());
3017    }
3018
3019    #[test]
3020    fn factory_gemini_cli() {
3021        assert!(create_provider("gemini-cli", None).is_ok());
3022    }
3023
3024    #[test]
3025    fn factory_kilocli() {
3026        assert!(create_provider("kilocli", None).is_ok());
3027        assert!(create_provider("kilo", None).is_ok());
3028    }
3029
3030    #[test]
3031    fn factory_nvidia() {
3032        assert!(create_provider("nvidia", Some("nvapi-test")).is_ok());
3033        assert!(create_provider("nvidia-nim", Some("nvapi-test")).is_ok());
3034        assert!(create_provider("build.nvidia.com", Some("nvapi-test")).is_ok());
3035    }
3036
3037    // ── AI inference routers ─────────────────────────────────
3038
3039    #[test]
3040    fn factory_astrai() {
3041        assert!(create_provider("astrai", Some("sk-astrai-test")).is_ok());
3042    }
3043
3044    #[test]
3045    fn factory_avian() {
3046        assert!(create_provider("avian", Some("sk-avian-test")).is_ok());
3047    }
3048
3049    #[test]
3050    fn factory_deepmyst() {
3051        assert!(create_provider("deepmyst", Some("key")).is_ok());
3052        assert!(create_provider("deep-myst", Some("key")).is_ok());
3053    }
3054
3055    #[test]
3056    fn resolve_provider_credential_deepmyst_env() {
3057        let _env_lock = env_lock();
3058        let _guard = EnvGuard::set("DEEPMYST_API_KEY", Some("dm-test-key"));
3059        let resolved = resolve_provider_credential("deepmyst", None);
3060        assert_eq!(resolved, Some("dm-test-key".to_string()));
3061    }
3062
3063    // ── Custom / BYOP provider ─────────────────────────────
3064
3065    #[test]
3066    fn factory_custom_url() {
3067        let p = create_provider("custom:https://my-llm.example.com", Some("key"));
3068        assert!(p.is_ok());
3069    }
3070
3071    #[test]
3072    fn factory_custom_localhost() {
3073        let p = create_provider("custom:http://localhost:1234", Some("key"));
3074        assert!(p.is_ok());
3075    }
3076
3077    #[test]
3078    fn factory_custom_no_key() {
3079        let p = create_provider("custom:https://my-llm.example.com", None);
3080        assert!(p.is_ok());
3081    }
3082
3083    #[test]
3084    fn factory_custom_empty_url_errors() {
3085        match create_provider("custom:", None) {
3086            Err(e) => assert!(
3087                e.to_string().contains("requires a URL"),
3088                "Expected 'requires a URL', got: {e}"
3089            ),
3090            Ok(_) => panic!("Expected error for empty custom URL"),
3091        }
3092    }
3093
3094    #[test]
3095    fn factory_custom_invalid_url_errors() {
3096        match create_provider("custom:not-a-url", None) {
3097            Err(e) => assert!(
3098                e.to_string().contains("requires a valid URL"),
3099                "Expected 'requires a valid URL', got: {e}"
3100            ),
3101            Ok(_) => panic!("Expected error for invalid custom URL"),
3102        }
3103    }
3104
3105    #[test]
3106    fn factory_custom_unsupported_scheme_errors() {
3107        match create_provider("custom:ftp://example.com", None) {
3108            Err(e) => assert!(
3109                e.to_string().contains("http:// or https://"),
3110                "Expected scheme validation error, got: {e}"
3111            ),
3112            Ok(_) => panic!("Expected error for unsupported custom URL scheme"),
3113        }
3114    }
3115
3116    #[test]
3117    fn factory_custom_trims_whitespace() {
3118        let p = create_provider("custom:  https://my-llm.example.com  ", Some("key"));
3119        assert!(p.is_ok());
3120    }
3121
3122    // ── Anthropic-compatible custom endpoints ─────────────────
3123
3124    #[test]
3125    fn factory_anthropic_custom_url() {
3126        let p = create_provider("anthropic-custom:https://api.example.com", Some("key"));
3127        assert!(p.is_ok());
3128    }
3129
3130    #[test]
3131    fn factory_anthropic_custom_trailing_slash() {
3132        let p = create_provider("anthropic-custom:https://api.example.com/", Some("key"));
3133        assert!(p.is_ok());
3134    }
3135
3136    #[test]
3137    fn factory_anthropic_custom_no_key() {
3138        let p = create_provider("anthropic-custom:https://api.example.com", None);
3139        assert!(p.is_ok());
3140    }
3141
3142    #[test]
3143    fn factory_anthropic_custom_empty_url_errors() {
3144        match create_provider("anthropic-custom:", None) {
3145            Err(e) => assert!(
3146                e.to_string().contains("requires a URL"),
3147                "Expected 'requires a URL', got: {e}"
3148            ),
3149            Ok(_) => panic!("Expected error for empty anthropic-custom URL"),
3150        }
3151    }
3152
3153    #[test]
3154    fn factory_anthropic_custom_invalid_url_errors() {
3155        match create_provider("anthropic-custom:not-a-url", None) {
3156            Err(e) => assert!(
3157                e.to_string().contains("requires a valid URL"),
3158                "Expected 'requires a valid URL', got: {e}"
3159            ),
3160            Ok(_) => panic!("Expected error for invalid anthropic-custom URL"),
3161        }
3162    }
3163
3164    #[test]
3165    fn factory_anthropic_custom_unsupported_scheme_errors() {
3166        match create_provider("anthropic-custom:ftp://example.com", None) {
3167            Err(e) => assert!(
3168                e.to_string().contains("http:// or https://"),
3169                "Expected scheme validation error, got: {e}"
3170            ),
3171            Ok(_) => panic!("Expected error for unsupported anthropic-custom URL scheme"),
3172        }
3173    }
3174
3175    // ── Error cases ──────────────────────────────────────────
3176
3177    #[test]
3178    fn factory_unknown_provider_errors() {
3179        let p = create_provider("nonexistent", None);
3180        assert!(p.is_err());
3181        let msg = p.err().unwrap().to_string();
3182        assert!(msg.contains("Unknown provider"));
3183        assert!(msg.contains("nonexistent"));
3184    }
3185
3186    #[test]
3187    fn factory_empty_name_errors() {
3188        assert!(create_provider("", None).is_err());
3189    }
3190
3191    #[test]
3192    fn resilient_provider_ignores_duplicate_and_invalid_fallbacks() {
3193        let reliability = crate::config::ReliabilityConfig {
3194            provider_retries: 1,
3195            provider_backoff_ms: 100,
3196            fallback_providers: vec![
3197                "openrouter".into(),
3198                "nonexistent-provider".into(),
3199                "openai".into(),
3200                "openai".into(),
3201            ],
3202            api_keys: Vec::new(),
3203            model_fallbacks: std::collections::HashMap::new(),
3204            channel_initial_backoff_secs: 2,
3205            channel_max_backoff_secs: 60,
3206            scheduler_poll_secs: 15,
3207            scheduler_retries: 2,
3208        };
3209
3210        let provider = create_resilient_provider(
3211            "openrouter",
3212            Some("provider-test-credential"),
3213            None,
3214            &reliability,
3215        );
3216        assert!(provider.is_ok());
3217    }
3218
3219    #[test]
3220    fn resilient_provider_errors_for_invalid_primary() {
3221        let reliability = crate::config::ReliabilityConfig::default();
3222        let provider = create_resilient_provider(
3223            "totally-invalid",
3224            Some("provider-test-credential"),
3225            None,
3226            &reliability,
3227        );
3228        assert!(provider.is_err());
3229    }
3230
3231    /// Fallback providers resolve their own credentials via provider-specific
3232    /// env vars rather than inheriting the primary provider's key.  A provider
3233    /// that requires no key (e.g. lmstudio, ollama) must initialize
3234    /// successfully even when the primary uses a completely different key.
3235    #[test]
3236    fn resilient_fallback_resolves_own_credential() {
3237        let reliability = crate::config::ReliabilityConfig {
3238            provider_retries: 1,
3239            provider_backoff_ms: 100,
3240            fallback_providers: vec!["lmstudio".into(), "ollama".into()],
3241            api_keys: Vec::new(),
3242            model_fallbacks: std::collections::HashMap::new(),
3243            channel_initial_backoff_secs: 2,
3244            channel_max_backoff_secs: 60,
3245            scheduler_poll_secs: 15,
3246            scheduler_retries: 2,
3247        };
3248
3249        // Primary uses a ZAI key; fallbacks (lmstudio, ollama) should NOT
3250        // receive this key; they resolve their own credentials independently.
3251        let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
3252        assert!(provider.is_ok());
3253    }
3254
3255    /// `custom:` URL entries work as fallback providers, enabling arbitrary
3256    /// OpenAI-compatible endpoints (e.g. local LM Studio on a Docker host).
3257    #[test]
3258    fn resilient_fallback_supports_custom_url() {
3259        let reliability = crate::config::ReliabilityConfig {
3260            provider_retries: 1,
3261            provider_backoff_ms: 100,
3262            fallback_providers: vec!["custom:http://host.docker.internal:1234/v1".into()],
3263            api_keys: Vec::new(),
3264            model_fallbacks: std::collections::HashMap::new(),
3265            channel_initial_backoff_secs: 2,
3266            channel_max_backoff_secs: 60,
3267            scheduler_poll_secs: 15,
3268            scheduler_retries: 2,
3269        };
3270
3271        let provider =
3272            create_resilient_provider("openai", Some("openai-test-key"), None, &reliability);
3273        assert!(provider.is_ok());
3274    }
3275
3276    /// Mixed fallback chain: named providers, custom URLs, and invalid entries
3277    /// all coexist.  Invalid entries are silently ignored; valid ones initialize.
3278    #[test]
3279    fn resilient_fallback_mixed_chain() {
3280        let reliability = crate::config::ReliabilityConfig {
3281            provider_retries: 1,
3282            provider_backoff_ms: 100,
3283            fallback_providers: vec![
3284                "deepseek".into(),
3285                "custom:http://localhost:8080/v1".into(),
3286                "nonexistent-provider".into(),
3287                "lmstudio".into(),
3288            ],
3289            api_keys: Vec::new(),
3290            model_fallbacks: std::collections::HashMap::new(),
3291            channel_initial_backoff_secs: 2,
3292            channel_max_backoff_secs: 60,
3293            scheduler_poll_secs: 15,
3294            scheduler_retries: 2,
3295        };
3296
3297        let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
3298        assert!(provider.is_ok());
3299    }
3300
3301    #[test]
3302    fn ollama_with_custom_url() {
3303        let provider = create_provider_with_url("ollama", None, Some("http://10.100.2.32:11434"));
3304        assert!(provider.is_ok());
3305    }
3306
3307    #[test]
3308    fn ollama_cloud_with_custom_url() {
3309        let provider =
3310            create_provider_with_url("ollama", Some("ollama-key"), Some("https://ollama.com"));
3311        assert!(provider.is_ok());
3312    }
3313
3314    /// Osaurus works as a fallback provider alongside other named providers.
3315    #[test]
3316    fn resilient_fallback_includes_osaurus() {
3317        let reliability = crate::config::ReliabilityConfig {
3318            provider_retries: 1,
3319            provider_backoff_ms: 100,
3320            fallback_providers: vec!["osaurus".into(), "lmstudio".into()],
3321            api_keys: Vec::new(),
3322            model_fallbacks: std::collections::HashMap::new(),
3323            channel_initial_backoff_secs: 2,
3324            channel_max_backoff_secs: 60,
3325            scheduler_poll_secs: 15,
3326            scheduler_retries: 2,
3327        };
3328
3329        let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
3330        assert!(provider.is_ok());
3331    }
3332
3333    #[test]
3334    fn factory_all_providers_create_successfully() {
3335        let providers = [
3336            "openrouter",
3337            "anthropic",
3338            "openai",
3339            "ollama",
3340            "gemini",
3341            "venice",
3342            "vercel",
3343            "cloudflare",
3344            "moonshot",
3345            "moonshot-intl",
3346            "kimi-code",
3347            "moonshot-cn",
3348            "kimi-code",
3349            "synthetic",
3350            "opencode",
3351            "opencode-go",
3352            "zai",
3353            "zai-cn",
3354            "glm",
3355            "glm-cn",
3356            "minimax",
3357            "minimax-cn",
3358            "bedrock",
3359            "qianfan",
3360            "doubao",
3361            "qwen",
3362            "qwen-intl",
3363            "qwen-cn",
3364            "qwen-us",
3365            "qwen-code",
3366            "lmstudio",
3367            "llamacpp",
3368            "sglang",
3369            "vllm",
3370            "osaurus",
3371            "telnyx",
3372            "groq",
3373            "mistral",
3374            "xai",
3375            "deepseek",
3376            "together",
3377            "fireworks",
3378            "novita",
3379            "perplexity",
3380            "cohere",
3381            "copilot",
3382            "claude-code",
3383            "gemini-cli",
3384            "kilocli",
3385            "nvidia",
3386            "astrai",
3387            "avian",
3388            "ovhcloud",
3389        ];
3390        for name in providers {
3391            assert!(
3392                create_provider(name, Some("test-key")).is_ok(),
3393                "Provider '{name}' should create successfully"
3394            );
3395        }
3396    }
3397
3398    #[test]
3399    fn listed_providers_have_unique_ids_and_aliases() {
3400        let providers = list_providers();
3401        let mut canonical_ids = std::collections::HashSet::new();
3402        let mut aliases = std::collections::HashSet::new();
3403
3404        for provider in providers {
3405            assert!(
3406                canonical_ids.insert(provider.name),
3407                "Duplicate canonical provider id: {}",
3408                provider.name
3409            );
3410
3411            for alias in provider.aliases {
3412                assert_ne!(
3413                    *alias, provider.name,
3414                    "Alias must differ from canonical id: {}",
3415                    provider.name
3416                );
3417                assert!(
3418                    !canonical_ids.contains(alias),
3419                    "Alias conflicts with canonical provider id: {}",
3420                    alias
3421                );
3422                assert!(aliases.insert(alias), "Duplicate provider alias: {}", alias);
3423            }
3424        }
3425    }
3426
3427    #[test]
3428    fn listed_providers_and_aliases_are_constructible() {
3429        for provider in list_providers() {
3430            assert!(
3431                create_provider(provider.name, Some("provider-test-credential")).is_ok(),
3432                "Canonical provider id should be constructible: {}",
3433                provider.name
3434            );
3435
3436            for alias in provider.aliases {
3437                assert!(
3438                    create_provider(alias, Some("provider-test-credential")).is_ok(),
3439                    "Provider alias should be constructible: {} (for {})",
3440                    alias,
3441                    provider.name
3442                );
3443            }
3444        }
3445    }
3446
3447    // ── API error sanitization ───────────────────────────────
3448
3449    #[test]
3450    fn sanitize_scrubs_sk_prefix() {
3451        let input = "request failed: sk-1234567890abcdef";
3452        let out = sanitize_api_error(input);
3453        assert!(!out.contains("sk-1234567890abcdef"));
3454        assert!(out.contains("[REDACTED]"));
3455    }
3456
3457    #[test]
3458    fn sanitize_scrubs_multiple_prefixes() {
3459        let input = "keys sk-abcdef xoxb-12345 xoxp-67890";
3460        let out = sanitize_api_error(input);
3461        assert!(!out.contains("sk-abcdef"));
3462        assert!(!out.contains("xoxb-12345"));
3463        assert!(!out.contains("xoxp-67890"));
3464    }
3465
3466    #[test]
3467    fn sanitize_short_prefix_then_real_key() {
3468        let input = "error with sk- prefix and key sk-1234567890";
3469        let result = sanitize_api_error(input);
3470        assert!(!result.contains("sk-1234567890"));
3471        assert!(result.contains("[REDACTED]"));
3472    }
3473
3474    #[test]
3475    fn sanitize_sk_proj_comment_then_real_key() {
3476        let input = "note: sk- then sk-proj-abc123def456";
3477        let result = sanitize_api_error(input);
3478        assert!(!result.contains("sk-proj-abc123def456"));
3479        assert!(result.contains("[REDACTED]"));
3480    }
3481
3482    #[test]
3483    fn sanitize_keeps_bare_prefix() {
3484        let input = "only prefix sk- present";
3485        let result = sanitize_api_error(input);
3486        assert!(result.contains("sk-"));
3487    }
3488
3489    #[test]
3490    fn sanitize_handles_json_wrapped_key() {
3491        let input = r#"{"error":"invalid key sk-abc123xyz"}"#;
3492        let result = sanitize_api_error(input);
3493        assert!(!result.contains("sk-abc123xyz"));
3494    }
3495
3496    #[test]
3497    fn sanitize_handles_delimiter_boundaries() {
3498        let input = "bad token xoxb-abc123}; next";
3499        let result = sanitize_api_error(input);
3500        assert!(!result.contains("xoxb-abc123"));
3501        assert!(result.contains("};"));
3502    }
3503
3504    #[test]
3505    fn sanitize_truncates_long_error() {
3506        let long = "a".repeat(600);
3507        let result = sanitize_api_error(&long);
3508        assert!(result.len() <= 503);
3509        assert!(result.ends_with("..."));
3510    }
3511
3512    #[test]
3513    fn sanitize_truncates_after_scrub() {
3514        let input = format!("{} sk-abcdef123456 {}", "a".repeat(290), "b".repeat(290));
3515        let result = sanitize_api_error(&input);
3516        assert!(!result.contains("sk-abcdef123456"));
3517        assert!(result.len() <= 503);
3518    }
3519
3520    #[test]
3521    fn sanitize_preserves_unicode_boundaries() {
3522        let input = format!("{} sk-abcdef123", "hello🙂".repeat(80));
3523        let result = sanitize_api_error(&input);
3524        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
3525        assert!(!result.contains("sk-abcdef123"));
3526    }
3527
3528    #[test]
3529    fn sanitize_no_secret_no_change() {
3530        let input = "simple upstream timeout";
3531        let result = sanitize_api_error(input);
3532        assert_eq!(result, input);
3533    }
3534
3535    #[test]
3536    fn scrub_github_personal_access_token() {
3537        let input = "auth failed with token ghp_abc123def456";
3538        let result = scrub_secret_patterns(input);
3539        assert_eq!(result, "auth failed with token [REDACTED]");
3540    }
3541
3542    #[test]
3543    fn scrub_github_oauth_token() {
3544        let input = "Bearer gho_1234567890abcdef";
3545        let result = scrub_secret_patterns(input);
3546        assert_eq!(result, "Bearer [REDACTED]");
3547    }
3548
3549    #[test]
3550    fn scrub_github_user_token() {
3551        let input = "token ghu_sessiontoken123";
3552        let result = scrub_secret_patterns(input);
3553        assert_eq!(result, "token [REDACTED]");
3554    }
3555
3556    #[test]
3557    fn scrub_github_fine_grained_pat() {
3558        let input = "failed: github_pat_11AABBC_xyzzy789";
3559        let result = scrub_secret_patterns(input);
3560        assert_eq!(result, "failed: [REDACTED]");
3561    }
3562
3563    // --- parse_provider_profile ---
3564
3565    #[test]
3566    fn parse_provider_profile_plain_name() {
3567        let (name, profile) = parse_provider_profile("gemini");
3568        assert_eq!(name, "gemini");
3569        assert_eq!(profile, None);
3570    }
3571
3572    #[test]
3573    fn parse_provider_profile_with_profile() {
3574        let (name, profile) = parse_provider_profile("openai-codex:second");
3575        assert_eq!(name, "openai-codex");
3576        assert_eq!(profile, Some("second"));
3577    }
3578
3579    #[test]
3580    fn parse_provider_profile_custom_url_not_split() {
3581        let input = "custom:https://my-api.example.com/v1";
3582        let (name, profile) = parse_provider_profile(input);
3583        assert_eq!(name, input);
3584        assert_eq!(profile, None);
3585    }
3586
3587    #[test]
3588    fn parse_provider_profile_anthropic_custom_not_split() {
3589        let input = "anthropic-custom:https://bedrock.example.com";
3590        let (name, profile) = parse_provider_profile(input);
3591        assert_eq!(name, input);
3592        assert_eq!(profile, None);
3593    }
3594
3595    #[test]
3596    fn parse_provider_profile_empty_profile_ignored() {
3597        let (name, profile) = parse_provider_profile("openai-codex:");
3598        assert_eq!(name, "openai-codex:");
3599        assert_eq!(profile, None);
3600    }
3601
3602    #[test]
3603    fn parse_provider_profile_extra_colons_kept() {
3604        let (name, profile) = parse_provider_profile("provider:profile:extra");
3605        assert_eq!(name, "provider");
3606        assert_eq!(profile, Some("profile:extra"));
3607    }
3608
3609    // --- resilient fallback with profile syntax ---
3610
3611    #[test]
3612    fn resilient_fallback_with_profile_syntax() {
3613        let _guard = env_lock();
3614
3615        let reliability = crate::config::ReliabilityConfig {
3616            provider_retries: 1,
3617            provider_backoff_ms: 100,
3618            fallback_providers: vec!["openai-codex:second".into()],
3619            api_keys: Vec::new(),
3620            model_fallbacks: std::collections::HashMap::new(),
3621            channel_initial_backoff_secs: 2,
3622            channel_max_backoff_secs: 60,
3623            scheduler_poll_secs: 15,
3624            scheduler_retries: 2,
3625        };
3626
3627        // openai-codex resolves its own OAuth credential; it should not
3628        // fail even with a profile override that has no local token file.
3629        // The provider initializes successfully and will attempt auth at
3630        // request time.
3631        let provider = create_resilient_provider("lmstudio", None, None, &reliability);
3632        assert!(provider.is_ok());
3633    }
3634
3635    #[test]
3636    fn resilient_fallback_mixed_profiles_and_custom() {
3637        let _guard = env_lock();
3638
3639        let reliability = crate::config::ReliabilityConfig {
3640            provider_retries: 1,
3641            provider_backoff_ms: 100,
3642            fallback_providers: vec![
3643                "openai-codex:second".into(),
3644                "custom:http://localhost:8080/v1".into(),
3645                "lmstudio".into(),
3646                "nonexistent-provider".into(),
3647            ],
3648            api_keys: Vec::new(),
3649            model_fallbacks: std::collections::HashMap::new(),
3650            channel_initial_backoff_secs: 2,
3651            channel_max_backoff_secs: 60,
3652            scheduler_poll_secs: 15,
3653            scheduler_retries: 2,
3654        };
3655
3656        let provider = create_resilient_provider("ollama", None, None, &reliability);
3657        assert!(provider.is_ok());
3658    }
3659
3660    // ── API key prefix pre-flight ───────────────────────────
3661
3662    #[test]
3663    fn api_key_prefix_cross_provider_mismatch() {
3664        // Anthropic key used with openrouter
3665        assert_eq!(
3666            check_api_key_prefix("openrouter", "sk-ant-api03-xyz"),
3667            Some("anthropic")
3668        );
3669        // OpenRouter key used with anthropic
3670        assert_eq!(
3671            check_api_key_prefix("anthropic", "sk-or-v1-xyz"),
3672            Some("openrouter")
3673        );
3674        // Anthropic key used with openai
3675        assert_eq!(
3676            check_api_key_prefix("openai", "sk-ant-xyz"),
3677            Some("anthropic")
3678        );
3679        // Groq key used with openai
3680        assert_eq!(check_api_key_prefix("openai", "gsk_xyz"), Some("groq"));
3681    }
3682
3683    #[test]
3684    fn api_key_prefix_correct_match() {
3685        assert_eq!(check_api_key_prefix("anthropic", "sk-ant-api03-xyz"), None);
3686        assert_eq!(check_api_key_prefix("openrouter", "sk-or-v1-xyz"), None);
3687        assert_eq!(check_api_key_prefix("openai", "sk-proj-xyz"), None);
3688        assert_eq!(check_api_key_prefix("groq", "gsk_xyz"), None);
3689    }
3690
3691    #[test]
3692    fn api_key_prefix_unknown_provider_skips() {
3693        // Providers without known key formats should never flag a mismatch.
3694        assert_eq!(check_api_key_prefix("deepseek", "sk-ant-xyz"), None);
3695        assert_eq!(check_api_key_prefix("ollama", "anything"), None);
3696    }
3697
3698    #[test]
3699    fn api_key_prefix_unknown_key_format_skips() {
3700        // Keys without a recognisable prefix should never flag a mismatch.
3701        assert_eq!(check_api_key_prefix("openai", "my-custom-key-123"), None);
3702        assert_eq!(check_api_key_prefix("anthropic", "some-random-key"), None);
3703    }
3704
3705    #[test]
3706    fn provider_runtime_options_default_has_empty_extra_headers() {
3707        let options = ProviderRuntimeOptions::default();
3708        assert!(options.extra_headers.is_empty());
3709    }
3710
3711    #[test]
3712    fn provider_runtime_options_extra_headers_passed_through() {
3713        let mut extra_headers = std::collections::HashMap::new();
3714        extra_headers.insert("X-Title".to_string(), "construct".to_string());
3715        let options = ProviderRuntimeOptions {
3716            extra_headers,
3717            ..ProviderRuntimeOptions::default()
3718        };
3719        assert_eq!(options.extra_headers.len(), 1);
3720        assert_eq!(options.extra_headers.get("X-Title").unwrap(), "construct");
3721    }
3722
3723    #[test]
3724    fn env_provider_url_overrides_api_url() {
3725        // SAFETY: test-only, single-threaded test runner.
3726        unsafe { std::env::set_var("CONSTRUCT_PROVIDER_URL", "http://env-ollama:11434") };
3727
3728        let options = ProviderRuntimeOptions::default();
3729
3730        let provider = create_provider_with_url_and_options(
3731            "ollama",
3732            Some("http://config-ollama:11434"),
3733            None,
3734            &options,
3735        );
3736
3737        assert!(provider.is_ok());
3738
3739        // SAFETY: test-only, single-threaded test runner.
3740        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER_URL") };
3741    }
3742}