Skip to main content

zeroclaw_providers/
lib.rs

1#![allow(clippy::to_string_in_format_args)]
2//! ModelProvider subsystem for model inference backends.
3//!
4//! This module implements the factory pattern for AI model model_providers. Each model_provider
5//! implements the [`ModelProvider`] trait defined in [`traits`], and is registered in the
6//! factory function [`create_model_provider`] by its canonical string key (e.g., `"openai"`,
7//! `"anthropic"`, `"ollama"`, `"gemini"`). ModelProvider aliases are resolved internally
8//! so that user-facing keys remain stable.
9//!
10//! Each model_provider call goes through the [`ReliableModelProvider`] wrapper, which adds
11//! automatic retry with exponential backoff and API-key rotation on rate limits.
12//! Model routing across multiple model_providers is available via [`create_routed_model_provider_with_options`].
13//!
14//! # Extension
15//!
16//! To add a new model_provider, implement [`ModelProvider`] in a new submodule and register it
17//! in [`create_model_provider_with_url`]. See `AGENTS.md` §7.1 for the full change playbook.
18
19pub mod anthropic;
20pub mod auth;
21pub mod azure_openai;
22pub mod bedrock;
23pub mod catalog;
24pub mod compatible;
25pub mod copilot;
26pub mod factory;
27pub mod gemini;
28pub mod gemini_cli;
29// glm.rs excluded — not compiled in upstream (dead code with known issues)
30pub mod kilocli;
31pub mod model_pin;
32pub mod models_dev;
33pub mod multimodal;
34pub mod ollama;
35pub mod openai;
36pub mod openai_codex;
37pub mod openrouter;
38pub mod openrouter_catalog;
39pub mod reliable;
40pub mod router;
41pub(crate) mod stream_guard;
42pub mod telnyx;
43pub mod traits;
44
45#[allow(unused_imports)]
46pub use traits::{
47    ChatMessage, ChatRequest, ChatResponse, ConversationMessage, ModelProvider,
48    ProviderCapabilityError, ToolCall, ToolResultMessage,
49};
50
51use reliable::ReliableModelProvider;
52use serde::Deserialize;
53use std::path::PathBuf;
54
55const MAX_API_ERROR_CHARS: usize = 500;
56const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
57/// MiniMax-published OAuth client_id (same one their portal uses).
58/// Operators with a custom OAuth app override via
59/// `[providers.models.minimax.<alias>] oauth_client_id = "..."`.
60const MINIMAX_OAUTH_DEFAULT_CLIENT_ID: &str = "78257093-7e40-4613-99e0-527b14b39113";
61const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
62const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1";
63const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
64const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
65const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
66const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
67const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
68const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
69const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
70const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
71const VERCEL_AI_GATEWAY_BASE_URL: &str = "https://ai-gateway.vercel.sh/v1";
72
73pub fn is_minimax_intl_alias(name: &str) -> bool {
74    matches!(
75        name,
76        "minimax"
77            | "minimax-intl"
78            | "minimax-io"
79            | "minimax-global"
80            | "minimax-oauth"
81            | "minimax-portal"
82            | "minimax-oauth-global"
83            | "minimax-portal-global"
84    )
85}
86pub fn is_minimax_cn_alias(name: &str) -> bool {
87    matches!(
88        name,
89        "minimax-cn" | "minimaxi" | "minimax-oauth-cn" | "minimax-portal-cn"
90    )
91}
92pub fn is_minimax_alias(name: &str) -> bool {
93    is_minimax_intl_alias(name) || is_minimax_cn_alias(name)
94}
95pub fn is_glm_global_alias(name: &str) -> bool {
96    matches!(name, "glm" | "zhipu" | "glm-global" | "zhipu-global")
97}
98
99pub fn is_glm_cn_alias(name: &str) -> bool {
100    matches!(name, "glm-cn" | "zhipu-cn" | "bigmodel")
101}
102
103pub fn is_glm_alias(name: &str) -> bool {
104    is_glm_global_alias(name) || is_glm_cn_alias(name)
105}
106
107pub fn is_moonshot_intl_alias(name: &str) -> bool {
108    matches!(
109        name,
110        "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
111    )
112}
113
114pub fn is_moonshot_cn_alias(name: &str) -> bool {
115    matches!(name, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn")
116}
117
118pub fn is_moonshot_alias(name: &str) -> bool {
119    is_moonshot_intl_alias(name) || is_moonshot_cn_alias(name)
120}
121
122pub fn is_qwen_cn_alias(name: &str) -> bool {
123    matches!(name, "qwen" | "dashscope" | "qwen-cn" | "dashscope-cn")
124}
125
126pub fn is_qwen_intl_alias(name: &str) -> bool {
127    matches!(
128        name,
129        "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
130    )
131}
132
133pub fn is_qwen_us_alias(name: &str) -> bool {
134    matches!(name, "qwen-us" | "dashscope-us")
135}
136
137pub fn is_qwen_oauth_alias(name: &str) -> bool {
138    matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
139}
140
141pub fn is_bailian_alias(name: &str) -> bool {
142    matches!(name, "bailian" | "aliyun-bailian" | "aliyun")
143}
144
145pub fn is_qwen_alias(name: &str) -> bool {
146    is_qwen_cn_alias(name)
147        || is_qwen_intl_alias(name)
148        || is_qwen_us_alias(name)
149        || is_qwen_oauth_alias(name)
150}
151
152pub fn is_zai_global_alias(name: &str) -> bool {
153    matches!(name, "zai" | "z.ai" | "zai-global" | "z.ai-global")
154}
155
156pub fn is_zai_cn_alias(name: &str) -> bool {
157    matches!(name, "zai-cn" | "z.ai-cn")
158}
159
160pub fn is_zai_alias(name: &str) -> bool {
161    is_zai_global_alias(name) || is_zai_cn_alias(name)
162}
163
164pub fn is_qianfan_alias(name: &str) -> bool {
165    matches!(name, "qianfan" | "baidu")
166}
167
168fn qianfan_base_url(api_url: Option<&str>) -> String {
169    api_url
170        .map(str::trim)
171        .filter(|value| !value.is_empty())
172        .map(ToString::to_string)
173        .unwrap_or_else(|| QIANFAN_BASE_URL.to_string())
174}
175
176pub fn is_doubao_alias(name: &str) -> bool {
177    matches!(name, "doubao" | "volcengine" | "ark" | "doubao-cn")
178}
179
180#[derive(Clone, Deserialize, Default)]
181pub(crate) struct QwenOauthCredentials {
182    #[serde(default)]
183    pub(crate) access_token: Option<String>,
184    #[serde(default)]
185    pub(crate) refresh_token: Option<String>,
186    #[serde(default)]
187    pub(crate) resource_url: Option<String>,
188    #[serde(default)]
189    pub(crate) expiry_date: Option<i64>,
190}
191
192impl std::fmt::Debug for QwenOauthCredentials {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.debug_struct("QwenOauthCredentials")
195            .field("resource_url", &self.resource_url)
196            .field("expiry_date", &self.expiry_date)
197            .finish_non_exhaustive()
198    }
199}
200
201#[derive(Debug, Deserialize)]
202struct QwenOauthTokenResponse {
203    #[serde(default)]
204    access_token: Option<String>,
205    #[serde(default)]
206    refresh_token: Option<String>,
207    #[serde(default)]
208    expires_in: Option<i64>,
209    #[serde(default)]
210    resource_url: Option<String>,
211    #[serde(default)]
212    error: Option<String>,
213    #[serde(default)]
214    error_description: Option<String>,
215}
216
217#[derive(Clone, Default)]
218pub(crate) struct QwenOauthProviderContext {
219    pub(crate) credential: Option<String>,
220    pub(crate) base_url: Option<String>,
221}
222
223impl std::fmt::Debug for QwenOauthProviderContext {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct("QwenOauthProviderContext")
226            .field("base_url", &self.base_url)
227            .finish_non_exhaustive()
228    }
229}
230
231fn qwen_oauth_client_id() -> String {
232    QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string()
233}
234
235fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
236    // OS path resolution; not a config override.
237    std::env::var_os("HOME")
238        .map(PathBuf::from)
239        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
240        .map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
241}
242
243fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
244    let trimmed = raw.trim().trim_end_matches('/');
245    if trimmed.is_empty() {
246        return None;
247    }
248
249    let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
250        trimmed.to_string()
251    } else {
252        format!("https://{trimmed}")
253    };
254
255    let normalized = with_scheme.trim_end_matches('/').to_string();
256    if normalized.ends_with("/v1") {
257        Some(normalized)
258    } else {
259        Some(format!("{normalized}/v1"))
260    }
261}
262
263fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
264    let path = qwen_oauth_credentials_file_path()?;
265    let content = std::fs::read_to_string(path).ok()?;
266    serde_json::from_str::<QwenOauthCredentials>(&content).ok()
267}
268
269fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
270    if raw < 10_000_000_000 {
271        raw.saturating_mul(1000)
272    } else {
273        raw
274    }
275}
276
277fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
278    let Some(expiry) = credentials.expiry_date else {
279        return false;
280    };
281
282    let expiry_millis = normalized_qwen_expiry_millis(expiry);
283    let now_millis = std::time::SystemTime::now()
284        .duration_since(std::time::UNIX_EPOCH)
285        .ok()
286        .and_then(|duration| i64::try_from(duration.as_millis()).ok())
287        .unwrap_or(i64::MAX);
288
289    expiry_millis <= now_millis.saturating_add(30_000)
290}
291
292pub(crate) fn refresh_qwen_oauth_access_token(
293    refresh_token: &str,
294    client_id: &str,
295) -> anyhow::Result<QwenOauthCredentials> {
296    let client = reqwest::blocking::Client::builder()
297        .timeout(std::time::Duration::from_secs(15))
298        .connect_timeout(std::time::Duration::from_secs(5))
299        .build()
300        .unwrap_or_else(|_| reqwest::blocking::Client::new());
301
302    let response = client
303        .post(QWEN_OAUTH_TOKEN_ENDPOINT)
304        .header("Content-Type", "application/x-www-form-urlencoded")
305        .header("Accept", "application/json")
306        .form(&[
307            ("grant_type", "refresh_token"),
308            ("refresh_token", refresh_token),
309            ("client_id", client_id),
310        ])
311        .send()
312        .map_err(|error| {
313            ::zeroclaw_log::record!(
314                ERROR,
315                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
316                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
317                    .with_attrs(::serde_json::json!({
318                        "oauth_provider": "qwen",
319                        "phase": "refresh_request",
320                        "error": format!("{}", error),
321                    })),
322                "qwen: OAuth refresh request failed"
323            );
324            anyhow::Error::msg(format!("OAuth refresh request failed: {error}"))
325        })?;
326
327    let status = response.status();
328    let body = response
329        .text()
330        .unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
331
332    let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
333
334    if !status.is_success() {
335        let detail = parsed
336            .as_ref()
337            .and_then(|payload| payload.error_description.as_deref())
338            .or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
339            .filter(|msg| !msg.trim().is_empty())
340            .unwrap_or(body.as_str());
341        anyhow::bail!("OAuth refresh failed (HTTP {status}): {detail}");
342    }
343
344    let payload = parsed.ok_or_else(|| {
345        ::zeroclaw_log::record!(
346            ERROR,
347            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
348                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
349                .with_attrs(::serde_json::json!({
350                    "oauth_provider": "qwen",
351                    "phase": "refresh_parse",
352                })),
353            "qwen: OAuth refresh response is not JSON"
354        );
355        anyhow::Error::msg("OAuth refresh response is not JSON")
356    })?;
357
358    if let Some(error_code) = payload
359        .error
360        .as_deref()
361        .filter(|value| !value.trim().is_empty())
362    {
363        let detail = payload.error_description.as_deref().unwrap_or(error_code);
364        anyhow::bail!("OAuth refresh failed: {detail}");
365    }
366
367    let access_token = payload
368        .access_token
369        .as_deref()
370        .map(str::trim)
371        .filter(|token| !token.is_empty())
372        .ok_or_else(|| {
373            ::zeroclaw_log::record!(
374                ERROR,
375                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
376                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
377                    .with_attrs(::serde_json::json!({
378                        "oauth_provider": "qwen",
379                        "field": "access_token",
380                    })),
381                "qwen: OAuth refresh response missing access_token"
382            );
383            anyhow::Error::msg("OAuth refresh response missing access_token")
384        })?
385        .to_string();
386
387    let expiry_date = payload.expires_in.and_then(|seconds| {
388        let now_secs = std::time::SystemTime::now()
389            .duration_since(std::time::UNIX_EPOCH)
390            .ok()
391            .and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
392        now_secs
393            .checked_add(seconds)
394            .and_then(|unix_secs| unix_secs.checked_mul(1000))
395    });
396
397    Ok(QwenOauthCredentials {
398        access_token: Some(access_token),
399        refresh_token: payload
400            .refresh_token
401            .as_deref()
402            .map(str::trim)
403            .filter(|value| !value.is_empty())
404            .map(ToString::to_string),
405        resource_url: payload
406            .resource_url
407            .as_deref()
408            .map(str::trim)
409            .filter(|value| !value.is_empty())
410            .map(ToString::to_string),
411        expiry_date,
412    })
413}
414
415// ── MiniMax OAuth refresh ──────────────────────────────────────────────
416//
417// Restored as a per-alias schema-mirror flow: the operator's
418// `oauth_refresh_token` is exchanged at MinimaxModelProvider construction
419// time for a short-lived access token, which becomes the API credential.
420// Region selection follows the existing `MinimaxEndpoint` enum on the
421// alias config — no `MINIMAX_OAUTH_REGION` env-var needed.
422
423#[derive(Debug, Deserialize)]
424struct MinimaxOauthRefreshResponse {
425    #[serde(default)]
426    status: Option<String>,
427    #[serde(default)]
428    access_token: Option<String>,
429    #[serde(default)]
430    base_resp: Option<MinimaxOauthBaseResponse>,
431}
432
433#[derive(Debug, Deserialize)]
434struct MinimaxOauthBaseResponse {
435    #[serde(default)]
436    status_msg: Option<String>,
437}
438
439/// Exchange a long-lived MiniMax `oauth_refresh_token` for a short-lived
440/// access token. Synchronous (`reqwest::blocking`) by design — this runs
441/// during provider construction, before any async runtime is necessarily
442/// available; matches the pre-deletion behavior.
443pub(crate) fn refresh_minimax_oauth_access_token(
444    refresh_token: &str,
445    client_id: &str,
446    region: zeroclaw_config::schema::MinimaxEndpoint,
447) -> anyhow::Result<String> {
448    let endpoint = region.oauth_token_endpoint();
449    let client = reqwest::blocking::Client::builder()
450        .timeout(std::time::Duration::from_secs(15))
451        .connect_timeout(std::time::Duration::from_secs(5))
452        .build()
453        .unwrap_or_else(|_| reqwest::blocking::Client::new());
454
455    let response = client
456        .post(endpoint)
457        .header("Content-Type", "application/x-www-form-urlencoded")
458        .header("Accept", "application/json")
459        .form(&[
460            ("grant_type", "refresh_token"),
461            ("refresh_token", refresh_token),
462            ("client_id", client_id),
463        ])
464        .send()
465        .map_err(|error| {
466            ::zeroclaw_log::record!(
467                ERROR,
468                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
469                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
470                    .with_attrs(::serde_json::json!({
471                        "oauth_provider": "minimax",
472                        "phase": "refresh_request",
473                        "error": format!("{}", error),
474                    })),
475                "minimax: OAuth refresh request failed"
476            );
477            anyhow::Error::msg(format!("MiniMax OAuth refresh request failed: {error}"))
478        })?;
479
480    let status = response.status();
481    let body = response
482        .text()
483        .unwrap_or_else(|_| "<failed to read MiniMax OAuth response body>".to_string());
484    let parsed = serde_json::from_str::<MinimaxOauthRefreshResponse>(&body).ok();
485
486    if !status.is_success() {
487        let detail = parsed
488            .as_ref()
489            .and_then(|payload| payload.base_resp.as_ref())
490            .and_then(|base| base.status_msg.as_deref())
491            .filter(|msg| !msg.trim().is_empty())
492            .unwrap_or(body.as_str());
493        anyhow::bail!("MiniMax OAuth refresh failed (HTTP {status}): {detail}");
494    }
495
496    if let Some(payload) = parsed {
497        if let Some(status_text) = payload.status.as_deref()
498            && !status_text.eq_ignore_ascii_case("success")
499        {
500            let detail = payload
501                .base_resp
502                .as_ref()
503                .and_then(|base| base.status_msg.as_deref())
504                .unwrap_or(status_text);
505            anyhow::bail!("MiniMax OAuth refresh failed: {detail}");
506        }
507        if let Some(token) = payload
508            .access_token
509            .as_deref()
510            .map(str::trim)
511            .filter(|token| !token.is_empty())
512        {
513            return Ok(token.to_string());
514        }
515    }
516    anyhow::bail!("MiniMax OAuth refresh response missing access_token")
517}
518
519fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
520    let override_value = credential_override
521        .map(str::trim)
522        .filter(|value| !value.is_empty());
523    let placeholder_requested = override_value
524        .map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
525        .unwrap_or(false);
526
527    if let Some(explicit) = override_value
528        && !placeholder_requested
529    {
530        return QwenOauthProviderContext {
531            credential: Some(explicit.to_string()),
532            base_url: None,
533        };
534    }
535
536    // Qwen OAuth: file cache at `~/.qwen/oauth_creds.json` (populated by the
537    // upstream Qwen CLI's `qwen login` flow) is the ambient source. Direct
538    // injection goes through the schema-mirror grammar.
539    let mut cached = read_qwen_oauth_cached_credentials();
540
541    let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
542        || cached
543            .as_ref()
544            .and_then(|credentials| credentials.access_token.as_deref())
545            .is_none_or(|value| value.trim().is_empty());
546
547    if should_refresh
548        && let Some(refresh_token) = cached
549            .as_ref()
550            .and_then(|credentials| credentials.refresh_token.clone())
551    {
552        match refresh_qwen_oauth_access_token(&refresh_token, &qwen_oauth_client_id()) {
553            Ok(refreshed) => {
554                cached = Some(refreshed);
555            }
556            Err(error) => {
557                ::zeroclaw_log::record!(
558                    WARN,
559                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
560                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
561                        .with_attrs(::serde_json::json!({"error": format!("{}", error)})),
562                    "OAuth refresh failed"
563                );
564            }
565        }
566    }
567
568    let credential = cached
569        .as_ref()
570        .and_then(|credentials| credentials.access_token.as_deref())
571        .map(str::trim)
572        .filter(|value| !value.is_empty())
573        .map(ToString::to_string);
574
575    let base_url = cached
576        .as_ref()
577        .and_then(|credentials| credentials.resource_url.as_deref())
578        .and_then(normalize_qwen_oauth_base_url);
579
580    QwenOauthProviderContext {
581        credential,
582        base_url,
583    }
584}
585
586// `canonical_china_provider_name` and the per-family `*_base_url(name)`
587// lookup helpers were deleted in #6273: post-Phase-8 migration the runtime
588// only sees canonical family names (`"moonshot"`, `"qwen"`, `"glm"`,
589// `"minimax"`, `"zai"`, `"doubao"`, `"qianfan"`), and per-instance URLs
590// flow through `ModelProviderRuntimeOptions.provider_api_url` (pre-resolved
591// from the typed alias's `*Endpoint::uri()` by
592// `provider_runtime_options_for_agent`). Synonym detection lives only in
593// `crates/zeroclaw-config/src/schema/v2.rs::normalize_model_provider_type`.
594
595#[derive(Debug, Clone)]
596pub struct ModelProviderRuntimeOptions {
597    pub auth_profile_override: Option<String>,
598    /// Explicit provider implementation from `[providers.models.<family>.<alias>].kind`.
599    /// When unset, provider resolution falls back to the configured family.
600    pub provider_kind: Option<String>,
601    pub provider_api_url: Option<String>,
602    pub zeroclaw_dir: Option<PathBuf>,
603    pub secrets_encrypt: bool,
604    pub reasoning_enabled: Option<bool>,
605    pub reasoning_effort: Option<String>,
606    /// HTTP request timeout in seconds for LLM model_provider API calls.
607    /// `None` uses the model_provider's built-in default (120s for compatible model_providers).
608    pub provider_timeout_secs: Option<u64>,
609    /// Extra HTTP headers to include in model_provider API requests.
610    pub extra_headers: std::collections::HashMap<String, String>,
611    /// Custom API path suffix for OpenAI-compatible model_providers
612    /// (e.g. "/v2/generate" instead of the default "/chat/completions").
613    pub api_path: Option<String>,
614    /// Maximum output tokens for LLM model_provider API requests.
615    /// `None` uses the model_provider's built-in default.
616    pub provider_max_tokens: Option<u32>,
617    /// When true, system messages are merged into the first user message before
618    /// sending. Propagated from `ModelProviderConfig::merge_system_into_user`.
619    pub merge_system_into_user: bool,
620    /// Extra JSON parameters merged into API request bodies at the top level.
621    /// Propagated from `ModelProviderConfig::provider_extra`.
622    pub provider_extra: Option<serde_json::Value>,
623    /// When set, the provider is asked to use its native tool-calling
624    /// schema instead of OpenAI-compat tool calls. Generic across families.
625    pub native_tools: Option<bool>,
626    /// Wire protocol to use for this provider.
627    /// `Some("responses")` routes the provider through the OpenResponses
628    /// `/v1/responses` API instead of chat_completions.  `None` uses the
629    /// provider's built-in default (chat_completions for most providers).
630    pub wire_api: Option<String>,
631    /// Enable or disable chain-of-thought thinking. Forwarded as
632    /// `enable_thinking` in the request body. `None` lets the model decide.
633    pub think: Option<bool>,
634    /// Passed verbatim as `chat_template_kwargs` to the llamacpp provider.
635    pub chat_template_kwargs: Option<serde_json::Value>,
636}
637
638impl Default for ModelProviderRuntimeOptions {
639    fn default() -> Self {
640        Self {
641            auth_profile_override: None,
642            provider_kind: None,
643            provider_api_url: None,
644            zeroclaw_dir: None,
645            secrets_encrypt: true,
646            reasoning_enabled: None,
647            reasoning_effort: None,
648            provider_timeout_secs: None,
649            extra_headers: std::collections::HashMap::new(),
650            api_path: None,
651            provider_max_tokens: None,
652            merge_system_into_user: false,
653            provider_extra: None,
654            native_tools: None,
655            wire_api: None,
656            think: None,
657            chat_template_kwargs: None,
658        }
659    }
660}
661
662/// Build `ModelProviderRuntimeOptions` from a *specific* `ModelProviderConfig`
663/// entry plus the global config's process-wide settings (zeroclaw_dir,
664/// secrets, runtime). Splits out the per-entry resolution so callers with
665/// agent context can pass in the alias-resolved entry instead of hitting
666/// `providers.models.find(type, alias)`.
667///
668/// Pass `None` when no model_provider entry is resolvable (e.g. tests or fresh
669/// config with no models configured); falls back to safe defaults.
670pub fn model_provider_runtime_options_from_model_provider_entry(
671    config: &zeroclaw_config::schema::Config,
672    entry: Option<&zeroclaw_config::schema::ModelProviderConfig>,
673) -> ModelProviderRuntimeOptions {
674    // Resolve merge_system_into_user from the active model model_provider profile
675    // by matching api_url — providers.models retains all profiles. We keep
676    // this lookup based on URL match rather than identity because the entry
677    // we were given may itself originate from any of those profiles.
678    let merge_system_into_user = entry
679        .and_then(|e| e.uri.as_deref())
680        .map(str::trim)
681        .filter(|u| !u.is_empty())
682        .and_then(|active_uri| {
683            config
684                .providers
685                .models
686                .iter_entries()
687                .map(|(_, _, base)| base)
688                .find(|p| {
689                    p.uri
690                        .as_deref()
691                        .map(str::trim)
692                        .filter(|u: &&str| !u.is_empty())
693                        .map(|u: &str| u.trim_end_matches('/'))
694                        == Some(active_uri.trim_end_matches('/'))
695                })
696        })
697        .map(|p| p.merge_system_into_user)
698        .unwrap_or(false);
699
700    ModelProviderRuntimeOptions {
701        auth_profile_override: None,
702        provider_kind: entry.and_then(|e| {
703            e.kind
704                .as_deref()
705                .map(str::trim)
706                .filter(|value| !value.is_empty())
707                .map(ToString::to_string)
708        }),
709        provider_api_url: entry.and_then(|e| e.uri.clone()),
710        zeroclaw_dir: config.config_path.parent().map(PathBuf::from),
711        secrets_encrypt: config.secrets.encrypt,
712        reasoning_enabled: config.runtime.reasoning_enabled,
713        reasoning_effort: config.runtime.reasoning_effort.clone(),
714        provider_timeout_secs: Some(entry.and_then(|e| e.timeout_secs).unwrap_or(120)),
715        extra_headers: entry.map(|e| e.extra_headers.clone()).unwrap_or_default(),
716        api_path: None,
717        provider_max_tokens: entry.and_then(|e| e.max_tokens),
718        merge_system_into_user,
719        provider_extra: entry.and_then(|e| e.provider_extra.clone()),
720        native_tools: entry.and_then(|e| e.native_tools),
721        wire_api: entry.and_then(|e| e.wire_api.map(|w| w.as_str().to_string())),
722        think: entry.and_then(|e| e.think),
723        chat_template_kwargs: entry.and_then(|e| e.chat_template_kwargs.clone()),
724    }
725}
726
727/// Resolve `ModelProviderRuntimeOptions` from an agent's `model_provider` alias
728/// (`"<type>.<alias>"`). Returns safe defaults when the agent alias doesn't
729/// exist, doesn't have a `model_provider` set, or names a non-existent entry.
730pub fn provider_runtime_options_for_agent(
731    config: &zeroclaw_config::schema::Config,
732    agent_alias: &str,
733) -> ModelProviderRuntimeOptions {
734    let entry = config.model_provider_for_agent(agent_alias);
735    let mut options = model_provider_runtime_options_from_model_provider_entry(config, entry);
736
737    if let Some(agent) = config.agents.get(agent_alias)
738        && let Some((family, alias)) = agent.model_provider.split_once('.')
739    {
740        // Multi-endpoint families: pre-resolve the URI via the centralized
741        // `resolved_endpoint_uri` dispatch (driven by
742        // `for_each_model_provider_slot!`). Operator-set `base.uri` already
743        // populated above wins over the family default.
744        if options.provider_api_url.is_none()
745            && let Some(uri) = config.providers.models.resolved_endpoint_uri(family, alias)
746        {
747            options.provider_api_url = Some(uri.to_string());
748        }
749        // Family-specific typed extras (Azure resource, kilocli/gemini_cli
750        // binary_path, Gemini OAuth client credentials, OpenAI Codex
751        // auth-routing, etc.) are read directly by the factory branches
752        // from `config.providers.models.<family>.<alias>` — no flat
753        // dumping ground here.
754    }
755
756    options
757}
758/// Build runtime options for a specific dotted provider alias
759/// (`<family>.<alias>`). Mirrors `provider_runtime_options_for_agent` but
760/// keyed on the typed provider entry directly, so routed providers can
761/// resolve their alias-specific endpoint URI and other typed extras
762/// without going through an owning agent.
763pub fn provider_runtime_options_for_alias(
764    config: &zeroclaw_config::schema::Config,
765    family: &str,
766    alias: &str,
767) -> ModelProviderRuntimeOptions {
768    let entry = config.providers.models.find(family, alias);
769    let mut options = model_provider_runtime_options_from_model_provider_entry(config, entry);
770    if options.provider_api_url.is_none()
771        && let Some(uri) = config.providers.models.resolved_endpoint_uri(family, alias)
772    {
773        options.provider_api_url = Some(uri.to_string());
774    }
775    options
776}
777
778/// Options to use when building a provider from a name that may be either
779/// a bare family or a dotted alias. Dotted names yield alias-resolved
780/// options; bare names inherit only provider-agnostic settings from
781/// `fallback`.
782pub fn options_for_provider_ref(
783    config: &zeroclaw_config::schema::Config,
784    name: &str,
785    fallback: &ModelProviderRuntimeOptions,
786) -> ModelProviderRuntimeOptions {
787    match name.split_once('.') {
788        Some((family, alias)) => provider_runtime_options_for_alias(config, family, alias),
789        None => {
790            let mut options = fallback.clone();
791            options.provider_kind = None;
792            options.provider_api_url = None;
793            options
794        }
795    }
796}
797
798fn is_secret_char(c: char) -> bool {
799    c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
800}
801
802fn token_end(input: &str, from: usize) -> usize {
803    let mut end = from;
804    for (i, c) in input[from..].char_indices() {
805        if is_secret_char(c) {
806            end = from + i + c.len_utf8();
807        } else {
808            break;
809        }
810    }
811    end
812}
813
814/// Scrub known secret-like token prefixes from model_provider error strings.
815///
816/// Redacts tokens with prefixes like `sk-`, `xoxb-`, `xoxp-`, `ghp_`, `gho_`,
817/// `ghu_`, and `github_pat_`.
818pub fn scrub_secret_patterns(input: &str) -> String {
819    const PREFIXES: [&str; 7] = [
820        "sk-",
821        "xoxb-",
822        "xoxp-",
823        "ghp_",
824        "gho_",
825        "ghu_",
826        "github_pat_",
827    ];
828
829    let mut scrubbed = input.to_string();
830
831    for prefix in PREFIXES {
832        let mut search_from = 0;
833        while let Some(rel) = scrubbed[search_from..].find(prefix) {
834            let start = search_from + rel;
835            let content_start = start + prefix.len();
836            let end = token_end(&scrubbed, content_start);
837
838            // Bare prefixes like "sk-" should not stop future scans.
839            if end == content_start {
840                search_from = content_start;
841                continue;
842            }
843
844            scrubbed.replace_range(start..end, "[REDACTED]");
845            search_from = start + "[REDACTED]".len();
846        }
847    }
848
849    scrubbed
850}
851
852/// Sanitize API error text by scrubbing secrets and truncating length.
853pub fn sanitize_api_error(input: &str) -> String {
854    let scrubbed = scrub_secret_patterns(input);
855
856    if scrubbed.chars().count() <= MAX_API_ERROR_CHARS {
857        return scrubbed;
858    }
859
860    let mut end = MAX_API_ERROR_CHARS;
861    while end > 0 && !scrubbed.is_char_boundary(end) {
862        end -= 1;
863    }
864
865    format!("{}...", &scrubbed[..end])
866}
867
868/// Format an error including its full source chain and sanitize the result.
869pub fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
870    let mut formatted = String::new();
871    let _ = std::fmt::Write::write_fmt(&mut formatted, format_args!("{error}"));
872    let mut current = error.source();
873    while let Some(source) = current {
874        let _ = std::fmt::Write::write_fmt(&mut formatted, format_args!(": {source}"));
875        current = source.source();
876    }
877    sanitize_api_error(&formatted)
878}
879
880/// Build a sanitized model_provider error from a failed HTTP response.
881pub async fn api_error(model_provider: &str, response: reqwest::Response) -> anyhow::Error {
882    let status = response.status();
883    let body = response
884        .text()
885        .await
886        .unwrap_or_else(|_| "<failed to read model_provider error body>".to_string());
887    let sanitized = sanitize_api_error(&body);
888    ::zeroclaw_log::record!(
889        ERROR,
890        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
891            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
892            .with_attrs(::serde_json::json!({
893                "model_provider": model_provider,
894                "status": status.as_u16(),
895                "body": sanitized,
896            })),
897        "providers: API error"
898    );
899    anyhow::Error::msg(format!(
900        "{model_provider} API error ({status}): {sanitized}"
901    ))
902}
903
904/// Resolve API key for a model_provider from config and environment variables.
905///
906/// Return the typed-alias `api_key` field, trimmed. Env-var overrides land on
907/// the field at config-load via the `ZEROCLAW_*` schema-mirror grammar.
908fn resolve_model_provider_credential(
909    _name: &str,
910    credential_override: Option<&str>,
911) -> Option<String> {
912    credential_override
913        .map(str::trim)
914        .filter(|value| !value.is_empty())
915        .map(ToString::to_string)
916}
917
918/// Single source of truth for `(key_prefix, canonical_model_provider_family)`
919/// pairs used by `check_api_key_prefix`. Order matters: longer prefixes
920/// must come before shorter ones that share a head (`sk-ant-` and `sk-or-`
921/// must precede `sk-`).
922const KEY_PREFIX_MODEL_PROVIDERS: &[(&str, &str)] = &[
923    ("sk-ant-", "anthropic"),
924    ("sk-or-", "openrouter"),
925    ("sk-", "openai"),
926    ("gsk_", "groq"),
927    ("pplx-", "perplexity"),
928    ("xai-", "xai"),
929    ("nvapi-", "nvidia"),
930    ("KEY-", "telnyx"),
931];
932
933/// Check whether an API key's prefix matches the selected model model_provider.
934///
935/// Returns `Some("likely_model_provider")` when the key clearly belongs to a
936/// *different* model model_provider (cross-provider mismatch). Returns `None`
937/// when everything looks fine or the format is unrecognised.
938fn check_api_key_prefix(model_provider_name: &str, key: &str) -> Option<&'static str> {
939    let likely_model_provider = KEY_PREFIX_MODEL_PROVIDERS
940        .iter()
941        .find(|(prefix, _)| key.starts_with(prefix))
942        .map(|(_, name)| *name)?;
943
944    // Only flag mismatch when the configured `model_provider_name` is itself
945    // one whose key format we recognize — derived from the same table so the
946    // gate can never drift from the prefix detection above.
947    let recognized = KEY_PREFIX_MODEL_PROVIDERS
948        .iter()
949        .any(|(_, name)| *name == model_provider_name);
950    if !recognized {
951        return None;
952    }
953
954    if model_provider_name == likely_model_provider {
955        None
956    } else {
957        Some(likely_model_provider)
958    }
959}
960
961// `parse_custom_provider_url` was deleted in #6273. The legacy colon-URL form
962// (`custom:https://...` and `anthropic-custom:https://...`) is collapsed
963// at TOML load time by `normalize_model_provider_type` in `schema/v2.rs` into
964// `[providers.models.custom.<alias>] uri = "..."` (or
965// `[providers.models.anthropic.custom] uri = "..."`). The factory's
966// `"custom"` arm reads `uri` from the alias entry via
967// `options.provider_api_url`; URL parsing/validation now happens at
968// schema validation time, not at runtime construction.
969
970/// Factory: create the right model_provider from config (without custom URL).
971///
972/// Legacy entry point — no per-alias typed extras visible. Calls the
973/// `_for_alias` variant with default per-family config; suitable for
974/// tests and programmatic callers using compat families that don't read
975/// from the typed alias config struct. Production callers with agent
976/// context should use [`create_model_provider_for_alias`].
977pub fn create_model_provider(
978    name: &str,
979    api_key: Option<&str>,
980) -> anyhow::Result<Box<dyn ModelProvider>> {
981    create_model_provider_inner(
982        None,
983        name,
984        "default",
985        api_key,
986        None,
987        &ModelProviderRuntimeOptions::default(),
988    )
989}
990
991/// Factory: create model_provider with runtime options.
992///
993/// Legacy entry point — see [`create_model_provider`].
994pub fn create_model_provider_with_options(
995    name: &str,
996    api_key: Option<&str>,
997    options: &ModelProviderRuntimeOptions,
998) -> anyhow::Result<Box<dyn ModelProvider>> {
999    create_model_provider_inner(None, name, "default", api_key, None, options)
1000}
1001
1002/// Factory: create model_provider with optional custom base URL.
1003///
1004/// Legacy entry point — see [`create_model_provider`].
1005pub fn create_model_provider_with_url(
1006    name: &str,
1007    api_key: Option<&str>,
1008    api_url: Option<&str>,
1009) -> anyhow::Result<Box<dyn ModelProvider>> {
1010    create_model_provider_inner(
1011        None,
1012        name,
1013        "default",
1014        api_key,
1015        api_url,
1016        &ModelProviderRuntimeOptions::default(),
1017    )
1018}
1019
1020/// Factory: create model_provider with full alias context.
1021///
1022/// `(config, family, alias)` lets each family branch read its own typed
1023/// alias config (`config.providers.models.<family>.get(alias)`) directly
1024/// — no flat per-family extras dumping ground. Production callers with
1025/// agent context (delegate, llm_task, model routing, gateway) use this.
1026pub fn create_model_provider_for_alias(
1027    config: &zeroclaw_config::schema::Config,
1028    family: &str,
1029    alias: &str,
1030    api_key: Option<&str>,
1031    options: &ModelProviderRuntimeOptions,
1032) -> anyhow::Result<Box<dyn ModelProvider>> {
1033    create_model_provider_inner(Some(config), family, alias, api_key, None, options)
1034}
1035
1036/// Factory: create model_provider with alias context AND custom base URL.
1037pub fn create_model_provider_for_alias_with_url(
1038    config: &zeroclaw_config::schema::Config,
1039    family: &str,
1040    alias: &str,
1041    api_key: Option<&str>,
1042    api_url: Option<&str>,
1043    options: &ModelProviderRuntimeOptions,
1044) -> anyhow::Result<Box<dyn ModelProvider>> {
1045    create_model_provider_inner(Some(config), family, alias, api_key, api_url, options)
1046}
1047
1048/// Map a V2 model-provider family name (synonyms, regional variants, OAuth
1049/// suffixes) to its V3 canonical family. Production configs are normalised at
1050/// TOML load time by `normalize_provider_type` in
1051/// `zeroclaw-config/src/schema/v2.rs`. This helper duplicates the same table
1052/// at the runtime factory boundary so callers that bypass the schema
1053/// migration (programmatic factory invocations, tests, the
1054/// `create_model_provider_with_url` colon-URL legacy entry point) still
1055/// resolve. Inputs that are already canonical or unknown pass through
1056/// unchanged.
1057#[must_use]
1058pub fn canonicalize_v2_model_provider_name(name: &str) -> &str {
1059    match name {
1060        // Vendor-canonical synonyms.
1061        "azure_openai" | "azure-openai" => "azure",
1062        "grok" => "xai",
1063        "google" | "google-gemini" => "gemini",
1064        "together-ai" => "together",
1065        "fireworks-ai" => "fireworks",
1066        "vercel-ai" => "vercel",
1067        "cloudflare-ai" => "cloudflare",
1068        "nvidia-nim" | "build.nvidia.com" => "nvidia",
1069        "aws-bedrock" => "bedrock",
1070        "lm-studio" => "lmstudio",
1071        "lite-llm" => "litellm",
1072        "hf" => "huggingface",
1073        "01ai" | "lingyiwanwu" => "yi",
1074        "tencent" => "hunyuan",
1075        "baidu" => "qianfan",
1076        "github-copilot" => "copilot",
1077        "ovhcloud" => "ovh",
1078        "opencode-zen" => "opencode",
1079        "llama.cpp" => "llamacpp",
1080        "deep-myst" => "deepmyst",
1081        "silicon-flow" => "siliconflow",
1082        "deep-infra" => "deepinfra",
1083        "ai21-labs" => "ai21",
1084        "friendliai" => "friendli",
1085        "lepton-ai" => "lepton",
1086        "lambda-ai" => "lambda_ai",
1087        "github-models" => "github_models",
1088        "step" => "stepfun",
1089        // Moonshot / Kimi (regional + code variants fold to one family).
1090        "kimi" | "kimi-cn" | "kimi-intl" | "kimi-global" | "kimi-code" | "kimi_coding"
1091        | "kimi_for_coding" | "moonshot-cn" | "moonshot-intl" | "moonshot-global" => "moonshot",
1092        // Qwen / DashScope / Bailian.
1093        "qwen-cn"
1094        | "qwen-intl"
1095        | "qwen-us"
1096        | "qwen-international"
1097        | "qwen-code"
1098        | "qwen-oauth"
1099        | "qwen_oauth"
1100        | "dashscope"
1101        | "dashscope-cn"
1102        | "dashscope-intl"
1103        | "dashscope-us"
1104        | "dashscope-international"
1105        | "bailian"
1106        | "aliyun-bailian"
1107        | "aliyun" => "qwen",
1108        // GLM / Zhipu.
1109        "zhipu" | "glm-global" | "zhipu-global" | "glm-cn" | "zhipu-cn" | "bigmodel" => "glm",
1110        // Z.AI.
1111        "z.ai" | "zai-global" | "z.ai-global" | "zai-cn" | "z.ai-cn" => "zai",
1112        // Minimax (cn/intl + oauth).
1113        "minimax-intl"
1114        | "minimax-io"
1115        | "minimax-global"
1116        | "minimax-portal"
1117        | "minimax-portal-global"
1118        | "minimax-cn"
1119        | "minimaxi"
1120        | "minimax-portal-cn"
1121        | "minimax-oauth"
1122        | "minimax-oauth-global"
1123        | "minimax-oauth-cn" => "minimax",
1124        // Doubao / Volcengine.
1125        "volcengine" | "ark" | "doubao-cn" => "doubao",
1126        // Gemini CLI is its own typed slot (subprocess runtime).
1127        "gemini-cli" => "gemini_cli",
1128        // Stepfun-intl folds with a different uri at the schema layer.
1129        "stepfun-intl" | "step-intl" => "stepfun",
1130        // Anthropic special folds.
1131        "claude-code" | "anthropic-custom" => "anthropic",
1132        // OpenCode regional fold (alias differs at the schema layer).
1133        "opencode-go" => "opencode",
1134        // Already canonical, or a name the factory's match arms can reject
1135        // with a useful error.
1136        _ => name,
1137    }
1138}
1139
1140/// Split a V2 colon-URL family name (`custom:https://...`,
1141/// `anthropic-custom:https://...`) into a `(name, url)` pair. The V3 typed
1142/// schema stores custom endpoints as `[providers.models.<family>.<alias>]
1143/// uri = "..."`; this helper preserves runtime-factory compatibility for
1144/// callers that still pass the legacy single-token form.
1145fn split_v2_colon_url(name: &str) -> (&str, Option<&str>) {
1146    if let Some(idx) = name.find(':') {
1147        let (prefix, rest) = name.split_at(idx);
1148        let url = &rest[1..];
1149        if url.starts_with("http://") || url.starts_with("https://") {
1150            return (prefix, Some(url));
1151        }
1152    }
1153    (name, None)
1154}
1155
1156pub(crate) fn moonshot_code_base_url() -> &'static str {
1157    <zeroclaw_config::schema::MoonshotEndpoint as zeroclaw_config::schema::ModelEndpoint>::uri(
1158        &zeroclaw_config::schema::MoonshotEndpoint::Code,
1159    )
1160}
1161
1162fn is_legacy_kimi_code_alias(name: &str) -> bool {
1163    matches!(name, "kimi-code" | "kimi_coding" | "kimi_for_coding")
1164}
1165
1166/// Factory: create model_provider with optional base URL and runtime options.
1167#[allow(clippy::too_many_lines)]
1168fn create_model_provider_inner(
1169    config: Option<&zeroclaw_config::schema::Config>,
1170    raw_name: &str,
1171    alias: &str,
1172    api_key: Option<&str>,
1173    api_url: Option<&str>,
1174    options: &ModelProviderRuntimeOptions,
1175) -> anyhow::Result<Box<dyn ModelProvider>> {
1176    // Pre-normalise the family name for callers that bypass the schema
1177    // migration (tests, programmatic factory calls, V2 colon-URL form).
1178    // Detect the bare `custom:` and `anthropic-custom:` forms (colon present,
1179    // URL missing or malformed) and surface a useful error before falling
1180    // into the unknown-family arm.
1181    if let Some(idx) = raw_name.find(':') {
1182        let prefix = &raw_name[..idx];
1183        let url = raw_name[idx + 1..].trim();
1184        if matches!(prefix, "custom" | "anthropic-custom")
1185            && (url.is_empty() || !(url.starts_with("http://") || url.starts_with("https://")))
1186        {
1187            anyhow::bail!(
1188                "Custom model_provider `{prefix}:<url>` requires a URL beginning with http:// or https://. \
1189                 Set `[providers.models.custom.<alias>] uri = \"https://your-api.com\"` or pass a valid URL."
1190            );
1191        }
1192    }
1193    let (split_name, split_url) = split_v2_colon_url(raw_name);
1194    let legacy_kimi_code = is_legacy_kimi_code_alias(split_name);
1195    let api_url = api_url.or(split_url);
1196    let name = canonicalize_v2_model_provider_name(split_name);
1197    let provider_kind = options
1198        .provider_kind
1199        .as_deref()
1200        .map(str::trim)
1201        .filter(|value| !value.is_empty())
1202        .map(canonicalize_v2_model_provider_name)
1203        .unwrap_or(name);
1204
1205    // V2 spelled OpenAI Codex as `openai-codex` / `openai_codex` / `codex`.
1206    // V3 dispatches via `requires_openai_auth = true` on the typed alias, but
1207    // factory callers that pass the legacy spelling expect a working
1208    // construction here.
1209    if matches!(provider_kind, "openai-codex" | "openai_codex" | "codex") {
1210        return Ok(Box::new(openai_codex::OpenAiCodexModelProvider::new(
1211            alias, options, api_key,
1212        )?));
1213    }
1214    // Resolve credential and break static-analysis taint chain from the
1215    // `api_key` parameter so that downstream model_provider storage of the value
1216    // is not linked to the original sensitive-named source. Qwen OAuth
1217    // alias detection moved into `QwenModelProviderConfig::create_provider`
1218    // — the per-family impl owns its own credential-resolution logic.
1219    let resolved_credential = resolve_model_provider_credential(provider_kind, api_key)
1220        .map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
1221    #[allow(clippy::option_as_ref_deref)]
1222    let key = resolved_credential.as_ref().map(String::as_str);
1223
1224    // Pre-flight: catch obvious API-key / model_provider mismatches early.
1225    if let Some(key_value) = key {
1226        let is_custom =
1227            provider_kind.starts_with("custom:") || provider_kind.starts_with("anthropic-custom:");
1228        let has_custom_url = api_url.map(str::trim).filter(|u| !u.is_empty()).is_some();
1229        if !is_custom
1230            && !has_custom_url
1231            && let Some(likely_model_provider) = check_api_key_prefix(provider_kind, key_value)
1232        {
1233            let visible = &key_value[..key_value.len().min(8)];
1234            anyhow::bail!(
1235                "API key prefix mismatch: key \"{visible}...\" looks like a \
1236                     {likely_model_provider} key, but model_provider \"{provider_kind}\" is selected. \
1237                     Set the correct provider-specific env var or use `-p {likely_model_provider}`."
1238            );
1239        }
1240    }
1241
1242    // The factory dispatches by canonical model model_provider family name only —
1243    // legacy synonyms ("openai-codex", "azure-openai", "google", etc.) are
1244    // collapsed at TOML load time by `normalize_model_provider_type` in
1245    // `crates/zeroclaw-config/src/schema/v2.rs`. Multi-endpoint families
1246    // (moonshot/qwen/glm/minimax/zai) get their URI pre-resolved into
1247    // `options.provider_api_url` from the typed alias's `endpoint` field
1248    // by `provider_runtime_options_for_agent`. Local-only families
1249    // (lmstudio/llamacpp/sglang/vllm/osaurus) accept either an explicit
1250    // `api_url` operator override or fall back to the family's localhost
1251    // default. Codex variant routing is handled by `create_model_provider_with_options`
1252    // via `options.requires_openai_auth` before this function is called.
1253
1254    // Resolve the effective endpoint URL for the dispatch arms below.
1255    // Precedence: `api_url` parameter (operator-set base.uri), then
1256    // `options.provider_api_url` (pre-resolved family endpoint URI from the
1257    // typed alias's `*Endpoint::uri()` for multi-endpoint families).
1258    let resolved_url: Option<&str> =
1259        api_url
1260            .map(str::trim)
1261            .filter(|v| !v.is_empty())
1262            .or_else(|| {
1263                options
1264                    .provider_api_url
1265                    .as_deref()
1266                    .map(str::trim)
1267                    .filter(|v| !v.is_empty())
1268            });
1269
1270    if legacy_kimi_code {
1271        let base_url = match resolved_url {
1272            Some(url) => url,
1273            None => moonshot_code_base_url(),
1274        };
1275        return Ok(factory::apply_compat_options(
1276            factory::build_kimi_code_compat(alias, key, base_url),
1277            options,
1278        ));
1279    }
1280
1281    factory::dispatch_family_factory(config, provider_kind, alias, key, resolved_url, options)
1282}
1283
1284/// Wrap the primary model_provider in a retry/backoff harness, threading auth runtime options.
1285///
1286/// Legacy entry point — no per-alias typed extras. Codex routing now
1287/// happens inside `OpenAIModelProviderConfig::create_provider` driven by
1288/// the alias's `base.requires_openai_auth` flag. Production callers that
1289/// have agent context should use [`create_resilient_model_provider_for_alias`]
1290/// to surface family-specific config like Azure resource/deployment.
1291pub fn create_resilient_model_provider_with_options(
1292    primary_name: &str,
1293    api_key: Option<&str>,
1294    api_url: Option<&str>,
1295    reliability: &zeroclaw_config::schema::ReliabilityConfig,
1296    options: &ModelProviderRuntimeOptions,
1297) -> anyhow::Result<Box<dyn ModelProvider>> {
1298    let primary_model_provider =
1299        create_model_provider_inner(None, primary_name, "default", api_key, api_url, options)?;
1300
1301    let reliable = ReliableModelProvider::new(
1302        primary_name,
1303        vec![(primary_name.to_string(), primary_model_provider)],
1304        reliability.provider_retries,
1305        reliability.provider_backoff_ms,
1306    )
1307    .with_api_keys(reliability.api_keys.clone());
1308
1309    Ok(Box::new(reliable))
1310}
1311
1312/// Wrap the primary model_provider in a retry/backoff harness with full
1313/// alias context. Production callers (gateway, orchestrator) use this so
1314/// the dispatch sees the typed alias config and routes Azure/Codex/Gemini
1315/// extras correctly.
1316pub fn create_resilient_model_provider_for_alias(
1317    config: &zeroclaw_config::schema::Config,
1318    family: &str,
1319    alias: &str,
1320    api_key: Option<&str>,
1321    api_url: Option<&str>,
1322    reliability: &zeroclaw_config::schema::ReliabilityConfig,
1323    options: &ModelProviderRuntimeOptions,
1324) -> anyhow::Result<Box<dyn ModelProvider>> {
1325    let primary_model_provider =
1326        create_model_provider_inner(Some(config), family, alias, api_key, api_url, options)?;
1327
1328    let mut model_providers: Vec<(String, Box<dyn ModelProvider>)> = Vec::new();
1329    push_pinned_entries(
1330        &mut model_providers,
1331        config,
1332        family,
1333        alias,
1334        primary_model_provider,
1335    );
1336
1337    let mut visited: Vec<String> = vec![format!("{family}.{alias}")];
1338    if let Some(entry) = config.providers.models.find(family, alias) {
1339        append_fallback_chain(
1340            &mut model_providers,
1341            config,
1342            &entry.fallback,
1343            &mut visited,
1344            1,
1345        );
1346    }
1347
1348    let reliable = ReliableModelProvider::new(
1349        alias,
1350        model_providers,
1351        reliability.provider_retries,
1352        reliability.provider_backoff_ms,
1353    )
1354    .with_api_keys(reliability.api_keys.clone());
1355
1356    Ok(Box::new(reliable))
1357}
1358
1359/// Wrap a freshly-built provider in one model-pinned entry per model the alias
1360/// serves — its primary `model` first, then each `fallback_models` entry in
1361/// order — so the resilient loop tries every model on this provider before the
1362/// next alias. When the alias has no configured model, a single unpinned entry
1363/// is pushed and the requested model flows through unchanged.
1364fn push_pinned_entries(
1365    out: &mut Vec<(String, Box<dyn ModelProvider>)>,
1366    config: &zeroclaw_config::schema::Config,
1367    family: &str,
1368    alias: &str,
1369    built: Box<dyn ModelProvider>,
1370) {
1371    let entry = config.providers.models.find(family, alias);
1372    let primary_model = entry.and_then(|e| e.model.as_deref());
1373    let extra_models: &[String] = entry.map(|e| e.fallback_models.as_slice()).unwrap_or(&[]);
1374
1375    let Some(primary_model) = primary_model else {
1376        out.push((family.to_string(), built));
1377        return;
1378    };
1379
1380    let built: std::sync::Arc<dyn ModelProvider> = std::sync::Arc::from(built);
1381    out.push((
1382        family.to_string(),
1383        Box::new(crate::model_pin::ModelPinnedProvider::new(
1384            alias,
1385            primary_model,
1386            Box::new(std::sync::Arc::clone(&built)),
1387        )),
1388    ));
1389    for model in extra_models {
1390        if model.trim().is_empty() || model == primary_model {
1391            continue;
1392        }
1393        out.push((
1394            family.to_string(),
1395            Box::new(crate::model_pin::ModelPinnedProvider::new(
1396                alias,
1397                model,
1398                Box::new(std::sync::Arc::clone(&built)),
1399            )),
1400        ));
1401    }
1402}
1403
1404/// Depth-first walk of an alias's `fallback` refs. Each resolvable target is
1405/// built with its OWN credentials/endpoint/model and appended (model-pinned)
1406/// before descending into its own `fallback`. Dangling refs, cycles, and chains
1407/// deeper than `MAX_FALLBACK_DEPTH` are skipped — `Config::collect_warnings`
1408/// already surfaces all three to operators.
1409fn append_fallback_chain(
1410    out: &mut Vec<(String, Box<dyn ModelProvider>)>,
1411    config: &zeroclaw_config::schema::Config,
1412    refs: &[zeroclaw_config::providers::ModelProviderRef],
1413    visited: &mut Vec<String>,
1414    depth: usize,
1415) {
1416    if depth > zeroclaw_config::providers::MAX_FALLBACK_DEPTH {
1417        ::zeroclaw_log::record!(
1418            WARN,
1419            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1420                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1421                .with_attrs(::serde_json::json!({
1422                    "max_depth": zeroclaw_config::providers::MAX_FALLBACK_DEPTH
1423                })),
1424            "fallback chain exceeds max depth; pruning"
1425        );
1426        return;
1427    }
1428    for fallback_ref in refs {
1429        let raw = fallback_ref.as_str().trim();
1430        if raw.is_empty() {
1431            continue;
1432        }
1433        let Some((family, alias, entry)) = config.providers.models.find_by_name(raw) else {
1434            ::zeroclaw_log::record!(
1435                WARN,
1436                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1437                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1438                    .with_attrs(::serde_json::json!({"fallback": raw})),
1439                "fallback ref does not resolve; skipping"
1440            );
1441            continue;
1442        };
1443        let resolved = format!("{family}.{alias}");
1444        if visited.iter().any(|v| v == &resolved) {
1445            ::zeroclaw_log::record!(
1446                WARN,
1447                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1448                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1449                    .with_attrs(::serde_json::json!({"fallback": resolved})),
1450                "fallback ref closes a cycle; pruning"
1451            );
1452            continue;
1453        }
1454
1455        let opts = provider_runtime_options_for_alias(config, family, &alias);
1456        match create_model_provider_inner(
1457            Some(config),
1458            family,
1459            &alias,
1460            entry.api_key.as_deref(),
1461            entry.uri.as_deref(),
1462            &opts,
1463        ) {
1464            Ok(built) => push_pinned_entries(out, config, family, &alias, built),
1465            Err(e) => {
1466                ::zeroclaw_log::record!(
1467                    WARN,
1468                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1469                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1470                        .with_attrs(
1471                            ::serde_json::json!({"fallback": resolved, "error": format!("{e}")})
1472                        ),
1473                    "fallback provider failed to build; skipping"
1474                );
1475                continue;
1476            }
1477        }
1478
1479        visited.push(resolved.clone());
1480        append_fallback_chain(out, config, &entry.fallback, visited, depth + 1);
1481        visited.pop();
1482    }
1483}
1484
1485/// Build a resilient model provider from a name that may be either a bare
1486/// family (`"openai"`) or a dotted alias (`"openai.work"`). Dotted names
1487/// dispatch through the typed alias factory so endpoint URI, family
1488/// extras, and per-alias credentials from `[providers.models.<family>.<alias>]`
1489/// are honored; bare names route through the family factory directly.
1490pub fn create_resilient_model_provider_from_ref(
1491    config: &zeroclaw_config::schema::Config,
1492    name: &str,
1493    api_key: Option<&str>,
1494    api_url: Option<&str>,
1495    reliability: &zeroclaw_config::schema::ReliabilityConfig,
1496    options: &ModelProviderRuntimeOptions,
1497) -> anyhow::Result<Box<dyn ModelProvider>> {
1498    match name.split_once('.') {
1499        Some((family, alias)) => create_resilient_model_provider_for_alias(
1500            config,
1501            family,
1502            alias,
1503            api_key,
1504            api_url,
1505            reliability,
1506            options,
1507        ),
1508        None => create_resilient_model_provider_with_options(
1509            name,
1510            api_key,
1511            api_url,
1512            reliability,
1513            options,
1514        ),
1515    }
1516}
1517
1518/// Build a router fronted by `primary_name` plus one provider per unique
1519/// `model_routes` entry. Each dotted `<family>.<alias>` name resolves
1520/// through the typed `[providers.models.<family>.<alias>]` config (endpoint
1521/// URI, Azure resource, Gemini OAuth, etc.); bare family names use family
1522/// defaults.
1523pub fn create_routed_model_provider_with_options(
1524    config: &zeroclaw_config::schema::Config,
1525    primary_name: &str,
1526    api_key: Option<&str>,
1527    api_url: Option<&str>,
1528    reliability: &zeroclaw_config::schema::ReliabilityConfig,
1529    model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
1530    default_model: &str,
1531    options: &ModelProviderRuntimeOptions,
1532) -> anyhow::Result<Box<dyn ModelProvider>> {
1533    if model_routes.is_empty() {
1534        return create_resilient_model_provider_from_ref(
1535            config,
1536            primary_name,
1537            api_key,
1538            api_url,
1539            reliability,
1540            options,
1541        );
1542    }
1543
1544    // Collect unique model_provider names needed
1545    let mut needed: Vec<String> = vec![primary_name.to_string()];
1546    for route in model_routes {
1547        if !needed.iter().any(|n| n == &route.model_provider) {
1548            needed.push(route.model_provider.clone());
1549        }
1550    }
1551
1552    // Create each model_provider (with its own resilience wrapper). Each
1553    // entry's options come from its own typed alias block when dotted;
1554    // the primary inherits the caller's options (already alias-resolved
1555    // upstream for the owning agent).
1556    let mut model_providers: Vec<(String, Box<dyn ModelProvider>)> = Vec::new();
1557    for name in &needed {
1558        let routed_credential = model_routes
1559            .iter()
1560            .find(|r| &r.model_provider == name)
1561            .and_then(|r| {
1562                r.api_key.as_ref().and_then(|raw_key| {
1563                    let trimmed_key = raw_key.trim();
1564                    (!trimmed_key.is_empty()).then_some(trimmed_key)
1565                })
1566            });
1567        let key = routed_credential
1568            .or_else(|| {
1569                name.split_once('.')
1570                    .and_then(|(family, alias)| {
1571                        config
1572                            .providers
1573                            .models
1574                            .find(family, alias)
1575                            .and_then(|cfg| cfg.api_key.as_deref())
1576                    })
1577                    .and_then(|raw_key| {
1578                        let trimmed = raw_key.trim();
1579                        (!trimmed.is_empty()).then_some(trimmed)
1580                    })
1581            })
1582            .or(api_key);
1583        let url = if name == primary_name { api_url } else { None };
1584        let entry_options = if name == primary_name {
1585            options.clone()
1586        } else {
1587            options_for_provider_ref(config, name, options)
1588        };
1589
1590        match create_resilient_model_provider_from_ref(
1591            config,
1592            name,
1593            key,
1594            url,
1595            reliability,
1596            &entry_options,
1597        ) {
1598            Ok(model_provider) => model_providers.push((name.clone(), model_provider)),
1599            Err(e) => {
1600                if name == primary_name {
1601                    return Err(e);
1602                }
1603                ::zeroclaw_log::record!(
1604                    WARN,
1605                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1606                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1607                        .with_attrs(
1608                            ::serde_json::json!({"model_provider": name.as_str(), "error": format!("{}", e)})
1609                        ),
1610                    "Ignoring routed model_provider that failed to initialize"
1611                );
1612            }
1613        }
1614    }
1615
1616    // Build route table
1617    let routes: Vec<(String, router::Route)> = model_routes
1618        .iter()
1619        .map(|r| {
1620            (
1621                r.hint.clone(),
1622                router::Route {
1623                    provider_name: r.model_provider.clone(),
1624                    model: r.model.clone(),
1625                },
1626            )
1627        })
1628        .collect();
1629
1630    Ok(Box::new(router::RouterModelProvider::new(
1631        primary_name,
1632        model_providers,
1633        routes,
1634        default_model.to_string(),
1635    )))
1636}
1637
1638/// Information about a supported model model_provider for display purposes.
1639pub struct ModelProviderInfo {
1640    /// Canonical name used in config (e.g. `"openrouter"`)
1641    pub name: &'static str,
1642    /// Human-readable display name
1643    pub display_name: &'static str,
1644    /// Whether the model model_provider runs locally (no API key required)
1645    pub local: bool,
1646    /// Registry category, the grouping the CLI list and docs render by.
1647    pub category: ModelProviderCategory,
1648}
1649
1650/// Grouping for a model-provider family. Replaces the section comments in the
1651/// registry list with data so surfaces (CLI list, docs capability table) can
1652/// group families without re-typing the membership. Mirrors the registry's
1653/// own sections exactly; locality is the separate `local` flag, not a category.
1654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1655pub enum ModelProviderCategory {
1656    /// First-party / flagship vendor APIs.
1657    Primary,
1658    /// OpenAI-compatible HTTP endpoints, each with its own canonical slot.
1659    OpenAiCompatible,
1660    /// Low-latency inference endpoints.
1661    FastInference,
1662    /// Model-hosting / aggregation platforms.
1663    ModelHosting,
1664    /// Chinese AI model providers.
1665    ChineseAi,
1666    /// Cloud-vendor AI endpoints.
1667    CloudEndpoint,
1668}
1669
1670impl ModelProviderCategory {
1671    /// Stable identifier for this category, matching the Rust variant name.
1672    /// Surfaces address a category by this token (CLI filters, docs directives)
1673    /// without re-typing the variant set.
1674    #[must_use]
1675    pub fn as_str(self) -> &'static str {
1676        match self {
1677            Self::Primary => "Primary",
1678            Self::OpenAiCompatible => "OpenAiCompatible",
1679            Self::FastInference => "FastInference",
1680            Self::ModelHosting => "ModelHosting",
1681            Self::ChineseAi => "ChineseAi",
1682            Self::CloudEndpoint => "CloudEndpoint",
1683        }
1684    }
1685
1686    /// Every category, in registry display order. Lets surfaces walk the set
1687    /// instead of hardcoding it.
1688    #[must_use]
1689    pub fn all() -> &'static [ModelProviderCategory] {
1690        &[
1691            Self::Primary,
1692            Self::OpenAiCompatible,
1693            Self::FastInference,
1694            Self::ModelHosting,
1695            Self::ChineseAi,
1696            Self::CloudEndpoint,
1697        ]
1698    }
1699}
1700
1701/// Canonical base URL for `name`, mirroring what `create_model_provider`
1702/// would dial. `None` for families without a fixed default (Azure, custom,
1703/// multi-region, CLI shims).
1704#[must_use]
1705pub fn default_model_provider_url(name: &str) -> Option<&'static str> {
1706    use factory::CompatFamilySpec;
1707    use zeroclaw_config::schema::{
1708        Ai21ModelProviderConfig, AihubmixModelProviderConfig, AnyscaleModelProviderConfig,
1709        ArceeModelProviderConfig, AstraiModelProviderConfig, BaichuanModelProviderConfig,
1710        BasetenModelProviderConfig, CerebrasModelProviderConfig, CloudflareModelProviderConfig,
1711        CohereModelProviderConfig, DeepinfraModelProviderConfig, DeepseekModelProviderConfig,
1712        DoubaoModelProviderConfig, FeatherlessModelProviderConfig, FireworksModelProviderConfig,
1713        FriendliModelProviderConfig, GithubModelsModelProviderConfig,
1714        HuggingfaceModelProviderConfig, HyperbolicModelProviderConfig,
1715        InceptionModelProviderConfig, LambdaAiModelProviderConfig, LeptonModelProviderConfig,
1716        LitellmModelProviderConfig, MistralModelProviderConfig, MorphModelProviderConfig,
1717        NebiusModelProviderConfig, NovitaModelProviderConfig, NscaleModelProviderConfig,
1718        OpencodeModelProviderConfig, PerplexityModelProviderConfig, RekaModelProviderConfig,
1719        SambanovaModelProviderConfig, SglangModelProviderConfig, SiliconflowModelProviderConfig,
1720        SyntheticModelProviderConfig, TogetherModelProviderConfig, UpstageModelProviderConfig,
1721        VercelModelProviderConfig, VllmModelProviderConfig, YiModelProviderConfig,
1722    };
1723
1724    match name {
1725        "anthropic" => Some(anthropic::BASE_URL),
1726        "openai" => Some(openai::BASE_URL),
1727        "openrouter" => Some(openrouter::BASE_URL),
1728        "ollama" => Some(ollama::BASE_URL),
1729        "telnyx" => Some(telnyx::BASE_URL),
1730        "gemini" => Some(gemini::BASE_URL),
1731        "vercel" => Some(<VercelModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1732        "cloudflare" => Some(<CloudflareModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1733        "synthetic" => Some(<SyntheticModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1734        "opencode" => Some(<OpencodeModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1735        "doubao" => Some(<DoubaoModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1736        "mistral" => Some(<MistralModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1737        "deepseek" => Some(<DeepseekModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1738        "together" => Some(<TogetherModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1739        "fireworks" => Some(<FireworksModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1740        "novita" => Some(<NovitaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1741        "perplexity" => Some(<PerplexityModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1742        "cohere" => Some(<CohereModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1743        "sglang" => Some(<SglangModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1744        "vllm" => Some(<VllmModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1745        "astrai" => Some(<AstraiModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1746        "siliconflow" => Some(<SiliconflowModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1747        "aihubmix" => Some(<AihubmixModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1748        "litellm" => Some(<LitellmModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1749        "cerebras" => Some(<CerebrasModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1750        "sambanova" => Some(<SambanovaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1751        "hyperbolic" => Some(<HyperbolicModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1752        "deepinfra" => Some(<DeepinfraModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1753        "huggingface" => Some(<HuggingfaceModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1754        "ai21" => Some(<Ai21ModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1755        "reka" => Some(<RekaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1756        "baseten" => Some(<BasetenModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1757        "nscale" => Some(<NscaleModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1758        "anyscale" => Some(<AnyscaleModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1759        "nebius" => Some(<NebiusModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1760        "friendli" => Some(<FriendliModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1761        "lepton" => Some(<LeptonModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1762        "morph" => Some(<MorphModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1763        "github_models" => Some(<GithubModelsModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1764        "upstage" => Some(<UpstageModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1765        "featherless" => Some(<FeatherlessModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1766        "arcee" => Some(<ArceeModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1767        "lambda_ai" => Some(<LambdaAiModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1768        "inception" => Some(<InceptionModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1769        "baichuan" => Some(<BaichuanModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1770        "yi" => Some(<YiModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1771        _ => None,
1772    }
1773}
1774
1775/// Append a section of provider families under one category. DRY builder so the
1776/// registry lists `(name, display_name, local)` once per family and the category
1777/// is stamped from the section, not repeated on every entry.
1778fn push_family(
1779    out: &mut Vec<ModelProviderInfo>,
1780    category: ModelProviderCategory,
1781    families: &[(&'static str, &'static str, bool)],
1782) {
1783    out.extend(
1784        families
1785            .iter()
1786            .map(|&(name, display_name, local)| ModelProviderInfo {
1787                name,
1788                display_name,
1789                local,
1790                category,
1791            }),
1792    );
1793}
1794
1795/// Return the list of all known model_providers for display in `zeroclaw model_providers list`.
1796///
1797/// This is intentionally separate from the factory match in `create_model_provider`
1798/// (display concern vs. construction concern).
1799///
1800/// This handwritten list and the `for_each_model_provider_slot!` macro in
1801/// `zeroclaw-config` are a dual-maintenance surface: the macro carries the
1802/// canonical slot set, this list adds display-only fields (`display_name`,
1803/// `local`). The `listed_model_providers_match_canonical_slots` test enforces
1804/// that the two cover exactly the same slots, so a provider added to the macro
1805/// without a display entry here (or vice versa) fails `cargo test`.
1806pub fn list_model_providers() -> Vec<ModelProviderInfo> {
1807    let mut out: Vec<ModelProviderInfo> = Vec::new();
1808    push_family(
1809        &mut out,
1810        ModelProviderCategory::Primary,
1811        &[
1812            ("openrouter", "OpenRouter", false),
1813            ("anthropic", "Anthropic", false),
1814            ("openai", "OpenAI", false),
1815            ("telnyx", "Telnyx", false),
1816            ("azure", "Azure OpenAI", false),
1817            ("ollama", "Ollama", true),
1818            ("gemini", "Google Gemini", false),
1819        ],
1820    );
1821    push_family(
1822        &mut out,
1823        ModelProviderCategory::OpenAiCompatible,
1824        &[
1825            ("venice", "Venice", false),
1826            ("vercel", "Vercel AI Gateway", false),
1827            ("cloudflare", "Cloudflare AI", false),
1828            ("moonshot", "Moonshot", false),
1829            ("synthetic", "Synthetic", false),
1830            ("opencode", "OpenCode", false),
1831            ("zai", "Z.AI", false),
1832            ("glm", "GLM (Zhipu)", false),
1833            ("minimax", "MiniMax", false),
1834            ("bedrock", "Amazon Bedrock", false),
1835            ("qianfan", "Qianfan (Baidu)", false),
1836            ("doubao", "Doubao (Volcengine)", false),
1837            ("qwen", "Qwen (DashScope / Qwen Code OAuth)", false),
1838            ("groq", "Groq", false),
1839            ("mistral", "Mistral", false),
1840            ("xai", "xAI (Grok)", false),
1841            ("deepseek", "DeepSeek", false),
1842            ("together", "Together AI", false),
1843            ("fireworks", "Fireworks AI", false),
1844            ("novita", "Novita AI", false),
1845            ("perplexity", "Perplexity", false),
1846            ("cohere", "Cohere", false),
1847            ("copilot", "GitHub Copilot", false),
1848            ("gemini_cli", "Gemini CLI", true),
1849            ("kilocli", "KiloCLI", true),
1850            ("kilo", "Kilo", false),
1851            ("lmstudio", "LM Studio", true),
1852            ("llamacpp", "llama.cpp server", true),
1853            ("sglang", "SGLang", true),
1854            ("vllm", "vLLM", true),
1855            ("osaurus", "Osaurus", true),
1856            ("nvidia", "NVIDIA NIM", false),
1857            ("siliconflow", "SiliconFlow", false),
1858            ("aihubmix", "AiHubMix", false),
1859            ("litellm", "LiteLLM", false),
1860            ("atomic_chat", "Atomic Chat", true),
1861            ("astrai", "Astrai", false),
1862            ("deepmyst", "DeepMyst", false),
1863            ("morph", "Morph (Fast Apply)", false),
1864            ("github_models", "GitHub Models", false),
1865            ("upstage", "Upstage Solar", false),
1866            ("featherless", "Featherless AI", false),
1867            ("arcee", "Arcee AI", false),
1868            ("lambda_ai", "Lambda AI", false),
1869            ("inception", "Inception Labs (Mercury)", false),
1870            ("custom", "Custom (OpenAI-compatible)", false),
1871        ],
1872    );
1873    push_family(
1874        &mut out,
1875        ModelProviderCategory::FastInference,
1876        &[
1877            ("cerebras", "Cerebras", false),
1878            ("sambanova", "SambaNova", false),
1879            ("hyperbolic", "Hyperbolic", false),
1880        ],
1881    );
1882    push_family(
1883        &mut out,
1884        ModelProviderCategory::ModelHosting,
1885        &[
1886            ("deepinfra", "DeepInfra", false),
1887            ("huggingface", "Hugging Face", false),
1888            ("ai21", "AI21 Labs", false),
1889            ("reka", "Reka", false),
1890            ("baseten", "Baseten", false),
1891            ("nscale", "Nscale", false),
1892            ("anyscale", "Anyscale", false),
1893            ("nebius", "Nebius AI Studio", false),
1894            ("friendli", "Friendli AI", false),
1895            ("lepton", "Lepton AI", false),
1896        ],
1897    );
1898    push_family(
1899        &mut out,
1900        ModelProviderCategory::ChineseAi,
1901        &[
1902            ("stepfun", "Stepfun", false),
1903            ("baichuan", "Baichuan", false),
1904            ("yi", "01.AI (Yi)", false),
1905            ("hunyuan", "Tencent Hunyuan", false),
1906        ],
1907    );
1908    push_family(
1909        &mut out,
1910        ModelProviderCategory::CloudEndpoint,
1911        &[
1912            ("ovh", "OVHcloud AI Endpoints", false),
1913            ("avian", "Avian", false),
1914        ],
1915    );
1916    debug_assert_eq!(
1917        out.iter()
1918            .map(|p| p.name)
1919            .collect::<std::collections::BTreeSet<_>>(),
1920        canonical_model_provider_slots()
1921            .into_iter()
1922            .collect::<std::collections::BTreeSet<_>>(),
1923        "list_model_providers() drifted from for_each_model_provider_slot!: \
1924         every canonical slot needs exactly one display entry and vice versa"
1925    );
1926    out
1927}
1928
1929/// Canonical model-provider slot names, generated directly from the
1930/// `for_each_model_provider_slot!` macro in `zeroclaw-config`. This is the
1931/// single source of truth for *which* provider families exist; the display
1932/// metadata in [`list_model_providers`] is keyed against this set and a drift
1933/// guard fails loudly if the two diverge.
1934#[must_use]
1935pub fn canonical_model_provider_slots() -> Vec<&'static str> {
1936    macro_rules! collect_slot_names {
1937        ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
1938            vec![$($type_str),+]
1939        };
1940    }
1941    zeroclaw_config::for_each_model_provider_slot!(collect_slot_names)
1942}
1943
1944/// Shared test utilities for model_provider modules.
1945#[cfg(test)]
1946pub mod test_util {
1947    use std::sync::{Mutex, MutexGuard, OnceLock};
1948
1949    /// Process-wide lock for tests that mutate environment variables.
1950    pub fn env_lock() -> MutexGuard<'static, ()> {
1951        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1952        LOCK.get_or_init(|| Mutex::new(()))
1953            .lock()
1954            .expect("env lock poisoned")
1955    }
1956
1957    /// RAII guard that sets or unsets an env var and restores the original
1958    /// value on drop. Always acquire [`env_lock`] before creating guards.
1959    pub struct EnvGuard {
1960        key: String,
1961        original: Option<String>,
1962    }
1963
1964    impl EnvGuard {
1965        pub fn set(key: &str, value: Option<&str>) -> Self {
1966            let original = std::env::var(key).ok();
1967            match value {
1968                // SAFETY: test-only, single-threaded test runner.
1969                Some(v) => unsafe { std::env::set_var(key, v) },
1970                // SAFETY: test-only, single-threaded test runner.
1971                None => unsafe { std::env::remove_var(key) },
1972            }
1973            Self {
1974                key: key.to_string(),
1975                original,
1976            }
1977        }
1978    }
1979
1980    impl Drop for EnvGuard {
1981        fn drop(&mut self) {
1982            if let Some(original) = self.original.as_deref() {
1983                // SAFETY: test-only, single-threaded test runner.
1984                unsafe { std::env::set_var(&self.key, original) };
1985            } else {
1986                // SAFETY: test-only, single-threaded test runner.
1987                unsafe { std::env::remove_var(&self.key) };
1988            }
1989        }
1990    }
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995    use super::test_util::{EnvGuard, env_lock};
1996    use super::*;
1997
1998    // Compile-time proof that both reqwest TLS-root features are enabled.
1999    // `tls_built_in_webpki_certs` is gated on `rustls-tls-webpki-roots-no-provider`;
2000    // `tls_built_in_native_certs` is gated on `rustls-tls-native-roots-no-provider`.
2001    // If either feature were dropped, this test would fail to compile.
2002    #[test]
2003    fn provider_http_client_trusts_both_webpki_and_native_roots() {
2004        let _client = reqwest::Client::builder()
2005            .tls_built_in_webpki_certs(true)
2006            .tls_built_in_native_certs(true)
2007            .build()
2008            .expect("client builder should succeed with both root sets enabled");
2009    }
2010
2011    #[test]
2012    fn resolve_provider_credential_returns_trimmed_override() {
2013        let resolved = resolve_model_provider_credential("openrouter", Some("  explicit-key  "));
2014        assert_eq!(resolved, Some("explicit-key".to_string()));
2015    }
2016
2017    #[test]
2018    fn resolve_provider_credential_filters_empty_override() {
2019        assert!(resolve_model_provider_credential("openrouter", Some("   ")).is_none());
2020        assert!(resolve_model_provider_credential("openrouter", None).is_none());
2021    }
2022
2023    // V0.8.0: tests that exercised env-var-driven credential resolution and
2024    // OAuth env-var fallbacks (`MINIMAX_*`, `QWEN_OAUTH_*`, `ANTHROPIC_API_KEY`,
2025    // `BEDROCK_API_KEY`, `API_KEY`, etc.) were deleted alongside the env-var
2026    // match in `resolve_model_provider_credential`. See the comment above
2027    // that fn for the schema-mirror replacement grammar.
2028
2029    #[test]
2030    fn resolve_qwen_oauth_context_prefers_explicit_override() {
2031        let _env_lock = env_lock();
2032        let context = resolve_qwen_oauth_context(Some("  explicit-qwen-token  "));
2033        assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
2034        assert!(context.base_url.is_none());
2035    }
2036
2037    #[test]
2038    fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
2039        let _env_lock = env_lock();
2040        let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-file", std::process::id());
2041        let creds_dir = PathBuf::from(&fake_home).join(".qwen");
2042        std::fs::create_dir_all(&creds_dir).unwrap();
2043        let creds_path = creds_dir.join("oauth_creds.json");
2044        std::fs::write(
2045            &creds_path,
2046            r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
2047        )
2048        .unwrap();
2049
2050        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2051
2052        let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2053
2054        assert_eq!(context.credential.as_deref(), Some("cached-token"));
2055        assert_eq!(
2056            context.base_url.as_deref(),
2057            Some("https://resource.example.com/v1")
2058        );
2059    }
2060
2061    #[test]
2062    fn resolve_qwen_oauth_context_returns_none_without_cache() {
2063        let _env_lock = env_lock();
2064        let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-empty", std::process::id());
2065        let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2066
2067        let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2068        assert!(context.credential.is_none());
2069    }
2070
2071    #[test]
2072    fn regional_alias_predicates_cover_expected_variants() {
2073        assert!(is_moonshot_alias("moonshot"));
2074        assert!(is_moonshot_alias("kimi-global"));
2075        assert!(is_glm_alias("glm"));
2076        assert!(is_glm_alias("bigmodel"));
2077        assert!(is_minimax_alias("minimax-io"));
2078        assert!(is_minimax_alias("minimaxi"));
2079        assert!(is_minimax_alias("minimax-oauth"));
2080        assert!(is_minimax_alias("minimax-portal-cn"));
2081        assert!(is_qwen_alias("dashscope"));
2082        assert!(is_qwen_alias("qwen-us"));
2083        assert!(is_qwen_alias("qwen-code"));
2084        assert!(is_qwen_oauth_alias("qwen-code"));
2085        assert!(is_qwen_oauth_alias("qwen_oauth"));
2086        assert!(is_zai_alias("z.ai"));
2087        assert!(is_zai_alias("zai-cn"));
2088        assert!(is_qianfan_alias("qianfan"));
2089        assert!(is_qianfan_alias("baidu"));
2090        assert!(is_doubao_alias("doubao"));
2091        assert!(is_doubao_alias("volcengine"));
2092        assert!(is_doubao_alias("ark"));
2093        assert!(is_doubao_alias("doubao-cn"));
2094
2095        assert!(!is_moonshot_alias("openrouter"));
2096        assert!(!is_glm_alias("openai"));
2097        assert!(!is_qwen_alias("gemini"));
2098        assert!(!is_zai_alias("anthropic"));
2099        assert!(!is_qianfan_alias("cohere"));
2100        assert!(!is_doubao_alias("deepseek"));
2101    }
2102
2103    // Tests for the deleted `canonical_china_provider_name` function and
2104    // the `*_base_url(name)` lookup helpers were removed alongside their
2105    // subjects in #6273. Equivalent regional-collapse semantics are now
2106    // covered by the migration tests at
2107    // `crates/zeroclaw-config/tests/migration.rs` (`v2_model_providers_alias_wrapped`,
2108    // `claude_code_folded_under_anthropic`, etc.) which exercise
2109    // `normalize_model_provider_type` directly.
2110
2111    // ── Primary model_providers ────────────────────────────────────
2112
2113    #[test]
2114    fn factory_openrouter() {
2115        assert!(create_model_provider("openrouter", Some("provider-test-credential")).is_ok());
2116        assert!(create_model_provider("openrouter", None).is_ok());
2117    }
2118
2119    #[test]
2120    fn factory_anthropic() {
2121        assert!(create_model_provider("anthropic", Some("provider-test-credential")).is_ok());
2122    }
2123
2124    #[test]
2125    fn factory_openai() {
2126        assert!(create_model_provider("openai", Some("provider-test-credential")).is_ok());
2127    }
2128
2129    #[test]
2130    fn factory_openai_codex() {
2131        // Codex is now selected by the typed `base.requires_openai_auth`
2132        // flag on an `[providers.models.openai.codex]` alias entry — the
2133        // factory's legacy escape hatch for the bare "openai-codex" /
2134        // "openai_codex" / "codex" family names still routes through
2135        // `OpenAiCodexModelProvider::new` when a real Config + alias is
2136        // not in scope.
2137        let options = ModelProviderRuntimeOptions::default();
2138        assert!(create_model_provider_with_options("openai-codex", None, &options).is_ok());
2139    }
2140
2141    #[test]
2142    fn factory_ollama() {
2143        assert!(create_model_provider("ollama", None).is_ok());
2144        // Ollama may use API key when a remote endpoint is configured.
2145        assert!(create_model_provider("ollama", Some("dummy")).is_ok());
2146        assert!(create_model_provider("ollama", Some("any-value-here")).is_ok());
2147    }
2148
2149    #[test]
2150    fn factory_gemini() {
2151        assert!(create_model_provider("gemini", Some("test-key")).is_ok());
2152        // Should also work without key (will try CLI auth)
2153        assert!(create_model_provider("gemini", None).is_ok());
2154    }
2155
2156    #[test]
2157    fn factory_telnyx() {
2158        assert!(create_model_provider("telnyx", Some("test-key")).is_ok());
2159        assert!(create_model_provider("telnyx", None).is_ok());
2160    }
2161
2162    // ── OpenAI-compatible model_providers ──────────────────────────
2163
2164    #[test]
2165    fn factory_venice() {
2166        let model_provider = create_model_provider("venice", Some("vn-key")).unwrap();
2167        assert!(
2168            !model_provider.capabilities().native_tool_calling,
2169            "Venice should use prompt-guided tools, not native tool calling"
2170        );
2171    }
2172
2173    #[test]
2174    fn factory_vercel() {
2175        assert!(create_model_provider("vercel", Some("key")).is_ok());
2176    }
2177
2178    #[test]
2179    fn vercel_gateway_base_url_matches_public_gateway_endpoint() {
2180        assert_eq!(
2181            VERCEL_AI_GATEWAY_BASE_URL,
2182            "https://ai-gateway.vercel.sh/v1"
2183        );
2184    }
2185
2186    #[test]
2187    fn factory_cloudflare() {
2188        assert!(create_model_provider("cloudflare", Some("key")).is_ok());
2189    }
2190
2191    #[test]
2192    fn factory_moonshot() {
2193        assert!(create_model_provider("moonshot", Some("key")).is_ok());
2194    }
2195
2196    #[test]
2197    fn factory_kimi_code_supports_vision() {
2198        for alias in ["kimi-code", "kimi_coding", "kimi_for_coding"] {
2199            let provider = create_model_provider(alias, Some("key"))
2200                .expect("legacy kimi-code alias should build");
2201            assert!(
2202                provider.supports_vision(),
2203                "alias `{alias}` should report vision capability"
2204            );
2205            assert_eq!(
2206                moonshot_code_base_url(),
2207                "https://api.moonshot.cn/coder/v1",
2208                "alias `{alias}` should resolve to the Moonshot code endpoint"
2209            );
2210        }
2211    }
2212
2213    #[test]
2214    fn factory_kimi_code_preserves_semantics_with_url_overrides() {
2215        let custom_url = "https://proxy.example.test/v1";
2216
2217        let provider = create_model_provider_with_url("kimi-code", Some("key"), Some(custom_url))
2218            .expect("legacy kimi-code alias with custom URL should build");
2219        assert!(provider.supports_vision());
2220
2221        let provider = create_model_provider_with_options(
2222            "kimi-code",
2223            Some("key"),
2224            &ModelProviderRuntimeOptions {
2225                provider_api_url: Some(custom_url.to_string()),
2226                ..ModelProviderRuntimeOptions::default()
2227            },
2228        )
2229        .expect("legacy kimi-code alias with options URL should build");
2230        assert!(provider.supports_vision());
2231    }
2232
2233    #[test]
2234    fn moonshot_code_endpoint_supports_vision() {
2235        use zeroclaw_config::schema::{Config, MoonshotEndpoint, MoonshotModelProviderConfig};
2236
2237        let mut config = Config::default();
2238        config.providers.models.moonshot.insert(
2239            "code".to_string(),
2240            MoonshotModelProviderConfig {
2241                endpoint: MoonshotEndpoint::Code,
2242                ..MoonshotModelProviderConfig::default()
2243            },
2244        );
2245        let options = provider_runtime_options_for_alias(&config, "moonshot", "code");
2246        assert_eq!(
2247            options.provider_api_url.as_deref(),
2248            Some(moonshot_code_base_url())
2249        );
2250
2251        let provider =
2252            create_model_provider_for_alias(&config, "moonshot", "code", Some("key"), &options)
2253                .expect("moonshot code endpoint should build");
2254        assert!(provider.supports_vision());
2255    }
2256
2257    #[test]
2258    fn factory_synthetic() {
2259        assert!(create_model_provider("synthetic", Some("key")).is_ok());
2260    }
2261
2262    #[test]
2263    fn factory_opencode() {
2264        assert!(create_model_provider("opencode", Some("key")).is_ok());
2265    }
2266
2267    #[test]
2268    fn factory_opencode_go() {}
2269
2270    #[test]
2271    fn factory_zai() {
2272        assert!(create_model_provider("zai", Some("key")).is_ok());
2273    }
2274
2275    #[test]
2276    fn factory_glm() {
2277        assert!(create_model_provider("glm", Some("key")).is_ok());
2278    }
2279
2280    #[test]
2281    fn factory_minimax() {
2282        assert!(create_model_provider("minimax", Some("key")).is_ok());
2283    }
2284
2285    #[test]
2286    fn factory_minimax_supports_native_tool_calling() {
2287        let minimax =
2288            create_model_provider("minimax", Some("key")).expect("model_provider should resolve");
2289        assert!(minimax.supports_native_tools());
2290    }
2291
2292    #[test]
2293    fn factory_bedrock() {
2294        // Bedrock uses AWS env vars for credentials, not API key.
2295        assert!(create_model_provider("bedrock", None).is_ok());
2296        // Passing an api_key is harmless (ignored).
2297        assert!(create_model_provider("bedrock", Some("ignored")).is_ok());
2298    }
2299
2300    #[test]
2301    fn factory_qianfan() {
2302        assert!(create_model_provider("qianfan", Some("key")).is_ok());
2303    }
2304
2305    #[test]
2306    fn factory_doubao() {
2307        assert!(create_model_provider("doubao", Some("key")).is_ok());
2308    }
2309
2310    #[test]
2311    fn factory_qwen() {
2312        assert!(create_model_provider("qwen", Some("key")).is_ok());
2313    }
2314
2315    #[test]
2316    fn qwen_provider_supports_vision() {
2317        let model_provider =
2318            create_model_provider("qwen", Some("key")).expect("qwen model_provider should build");
2319        assert!(model_provider.supports_vision());
2320    }
2321
2322    #[test]
2323    fn glm_provider_supports_vision() {
2324        // GLM exposes vision-capable models (e.g. `glm-4.5v`). The provider
2325        // must therefore report `supports_vision()` so multimodal routing
2326        // can target it; the model field selects the actual variant.
2327        for alias in ["glm", "zhipu", "glm-cn", "zhipu-cn"] {
2328            let provider =
2329                create_model_provider(alias, Some("id.secret")).expect("glm provider should build");
2330            assert!(
2331                provider.supports_vision(),
2332                "alias `{alias}` should report vision capability"
2333            );
2334        }
2335    }
2336
2337    #[test]
2338    fn factory_lmstudio() {
2339        assert!(create_model_provider("lmstudio", Some("key")).is_ok());
2340        assert!(create_model_provider("lmstudio", None).is_ok());
2341    }
2342
2343    #[test]
2344    fn factory_llamacpp() {
2345        assert!(create_model_provider("llamacpp", Some("key")).is_ok());
2346        assert!(create_model_provider("llamacpp", None).is_ok());
2347    }
2348
2349    #[test]
2350    fn factory_sglang() {
2351        assert!(create_model_provider("sglang", None).is_ok());
2352        assert!(create_model_provider("sglang", Some("key")).is_ok());
2353    }
2354
2355    #[test]
2356    fn factory_vllm() {
2357        assert!(create_model_provider("vllm", None).is_ok());
2358        assert!(create_model_provider("vllm", Some("key")).is_ok());
2359    }
2360
2361    #[test]
2362    fn factory_osaurus() {
2363        // Osaurus works without an explicit key (defaults to "osaurus").
2364        assert!(create_model_provider("osaurus", None).is_ok());
2365        // Osaurus also works with an explicit key.
2366        assert!(create_model_provider("osaurus", Some("custom-key")).is_ok());
2367    }
2368
2369    #[test]
2370    fn factory_osaurus_uses_default_key_when_none() {
2371        // Verify that osaurus construction succeeds even without an API
2372        // key — the impl provides a default placeholder.
2373        let p = create_model_provider_with_url("osaurus", None, None);
2374        assert!(p.is_ok());
2375    }
2376
2377    #[test]
2378    fn factory_osaurus_custom_url() {
2379        // Verify that a custom api_url overrides the default localhost endpoint.
2380        let p = create_model_provider_with_url(
2381            "osaurus",
2382            Some("key"),
2383            Some("http://192.168.1.100:1337/v1"),
2384        );
2385        assert!(p.is_ok());
2386    }
2387
2388    #[test]
2389    fn resolve_provider_credential_osaurus_env_deleted() {}
2390
2391    #[test]
2392    fn resolve_provider_credential_doubao_volcengine_env_deleted() {}
2393
2394    #[test]
2395    fn resolve_provider_credential_aihubmix_env_deleted() {}
2396
2397    #[test]
2398    fn resolve_provider_credential_siliconflow_env_deleted() {}
2399
2400    #[test]
2401    fn factory_aihubmix() {
2402        assert!(create_model_provider("aihubmix", Some("key")).is_ok());
2403    }
2404
2405    #[test]
2406    fn factory_siliconflow() {
2407        assert!(create_model_provider("siliconflow", Some("key")).is_ok());
2408    }
2409
2410    #[test]
2411    fn factory_codex_dispatches_via_requires_openai_auth_flag() {
2412        // Codex selection: the typed alias's `base.requires_openai_auth`
2413        // routes through `OpenAIModelProviderConfig::create_model_provider`. The
2414        // legacy escape hatch on the bare "openai-codex" / "openai_codex" /
2415        // "codex" family names remains for callers without Config context
2416        // (this test).
2417        let options = ModelProviderRuntimeOptions::default();
2418        assert!(create_model_provider_with_options("openai-codex", None, &options).is_ok());
2419    }
2420
2421    #[test]
2422    fn factory_atomic_chat() {
2423        assert!(create_model_provider("atomic_chat", Some("key")).is_ok());
2424    }
2425
2426    #[test]
2427    fn factory_atomic_chat_allows_missing_key() {
2428        // Local provider — empty key is acceptable; the runtime still
2429        // attaches a placeholder Bearer header.
2430        assert!(create_model_provider("atomic_chat", None).is_ok());
2431    }
2432
2433    #[test]
2434    fn atomic_chat_is_listed_as_local_provider() {
2435        let providers = list_model_providers();
2436        let provider = providers
2437            .iter()
2438            .find(|p| p.name == "atomic_chat")
2439            .expect("atomic_chat must be listed");
2440        assert!(provider.local, "atomic_chat must be a local provider");
2441    }
2442
2443    // ── Extended ecosystem ───────────────────────────────────
2444
2445    #[test]
2446    fn factory_groq() {
2447        assert!(create_model_provider("groq", Some("key")).is_ok());
2448    }
2449
2450    #[test]
2451    fn factory_groq_disables_native_tools_by_default() {
2452        // Default behavior preserves the blanket disable: llama-family
2453        // Groq models reject native tool calls with HTTP 400.
2454        let model_provider = create_model_provider_with_options(
2455            "groq",
2456            Some("key"),
2457            &ModelProviderRuntimeOptions::default(),
2458        )
2459        .expect("groq factory must succeed");
2460        assert!(
2461            !model_provider.supports_native_tools(),
2462            "Groq must default to text-fallback for llama-family compatibility"
2463        );
2464    }
2465
2466    #[test]
2467    fn factory_groq_honors_native_tools_override_true() {
2468        // Operator opt-in via `[providers.models.groq.<alias>] native_tools = true`
2469        // skips the default disable so non-llama Groq models can use native
2470        // tool calling.
2471        let options = ModelProviderRuntimeOptions {
2472            native_tools: Some(true),
2473            ..Default::default()
2474        };
2475        let model_provider = create_model_provider_with_options("groq", Some("key"), &options)
2476            .expect("groq factory must succeed");
2477        assert!(
2478            model_provider.supports_native_tools(),
2479            "Groq with `native_tools = true` must enable native tool calling"
2480        );
2481    }
2482
2483    #[test]
2484    fn factory_groq_native_tools_override_false_keeps_disable() {
2485        // Explicit `native_tools = false` matches the default behavior; this
2486        // documents that the option is tri-state and `Some(false)` is not a
2487        // no-op surprise.
2488        let options = ModelProviderRuntimeOptions {
2489            native_tools: Some(false),
2490            ..Default::default()
2491        };
2492        let model_provider = create_model_provider_with_options("groq", Some("key"), &options)
2493            .expect("groq factory must succeed");
2494        assert!(
2495            !model_provider.supports_native_tools(),
2496            "Groq with explicit `native_tools = false` must remain text-fallback"
2497        );
2498    }
2499
2500    #[test]
2501    fn provider_runtime_options_from_config_propagates_native_tools() {
2502        // End-to-end path: setting `native_tools` on the first configured
2503        // model_provider entry must reach `ModelProviderRuntimeOptions` so the
2504        // Groq factory branch sees it. There is no global fallback; the
2505        // orchestrator resolves per-agent via explicit `<type>.<alias>`
2506        // resolution.
2507        use zeroclaw_config::schema::{GroqModelProviderConfig, ModelProviderConfig};
2508        let mut config = zeroclaw_config::schema::Config::default();
2509        config.providers.models.groq.insert(
2510            "default".to_string(),
2511            GroqModelProviderConfig {
2512                base: ModelProviderConfig {
2513                    uri: Some("https://api.groq.com/openai/v1".to_string()),
2514                    native_tools: Some(true),
2515                    ..Default::default()
2516                },
2517            },
2518        );
2519
2520        let entry = config.providers.models.find("groq", "default");
2521        let options = model_provider_runtime_options_from_model_provider_entry(&config, entry);
2522        assert_eq!(
2523            options.native_tools,
2524            Some(true),
2525            "native_tools must propagate from the active model_provider entry to runtime options"
2526        );
2527    }
2528
2529    #[test]
2530    fn provider_runtime_options_from_config_propagates_provider_kind() {
2531        use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
2532        let mut config = zeroclaw_config::schema::Config::default();
2533        config.providers.models.openai.insert(
2534            "primary".to_string(),
2535            OpenAIModelProviderConfig {
2536                base: ModelProviderConfig {
2537                    kind: Some("openai-compatible".to_string()),
2538                    uri: Some("http://primary.example/v1".to_string()),
2539                    ..Default::default()
2540                },
2541            },
2542        );
2543
2544        let options = provider_runtime_options_for_alias(&config, "openai", "primary");
2545        assert_eq!(options.provider_kind.as_deref(), Some("openai-compatible"));
2546        assert_eq!(
2547            options.provider_api_url.as_deref(),
2548            Some("http://primary.example/v1")
2549        );
2550    }
2551
2552    #[test]
2553    fn route_provider_options_clear_primary_only_state_for_bare_routes() {
2554        let inherited = ModelProviderRuntimeOptions {
2555            provider_kind: Some("openai-compatible".to_string()),
2556            provider_api_url: Some("http://primary.example/v1".to_string()),
2557            ..Default::default()
2558        };
2559        let config = zeroclaw_config::schema::Config::default();
2560
2561        let route_options = options_for_provider_ref(&config, "openrouter", &inherited);
2562
2563        assert_eq!(route_options.provider_kind, None);
2564        assert_eq!(route_options.provider_api_url, None);
2565    }
2566
2567    #[test]
2568    fn routed_bare_provider_does_not_inherit_primary_endpoint() {
2569        use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
2570        let mut config = zeroclaw_config::schema::Config::default();
2571        config.providers.models.openai.insert(
2572            "primary".to_string(),
2573            OpenAIModelProviderConfig {
2574                base: ModelProviderConfig {
2575                    kind: Some("openai-compatible".to_string()),
2576                    uri: Some("http://primary.example/v1".to_string()),
2577                    ..Default::default()
2578                },
2579            },
2580        );
2581        let options = provider_runtime_options_for_alias(&config, "openai", "primary");
2582        assert_eq!(
2583            options.provider_api_url.as_deref(),
2584            Some("http://primary.example/v1")
2585        );
2586
2587        let route_options = options_for_provider_ref(&config, "openrouter", &options);
2588
2589        assert_eq!(route_options.provider_kind, None);
2590        assert_eq!(route_options.provider_api_url, None);
2591    }
2592
2593    #[test]
2594    fn routed_primary_alias_kind_does_not_leak_to_canonical_route_provider() {
2595        use zeroclaw_config::schema::{
2596            ModelProviderConfig, ModelRouteConfig, OpenAIModelProviderConfig,
2597            OpenRouterModelProviderConfig,
2598        };
2599
2600        let mut config = zeroclaw_config::schema::Config::default();
2601        config.providers.models.openai.insert(
2602            "primary".to_string(),
2603            OpenAIModelProviderConfig {
2604                base: ModelProviderConfig {
2605                    kind: Some("openai-compatible".to_string()),
2606                    uri: Some("http://primary.example/v1".to_string()),
2607                    ..Default::default()
2608                },
2609            },
2610        );
2611        config.providers.models.openrouter.insert(
2612            "route".to_string(),
2613            OpenRouterModelProviderConfig {
2614                base: ModelProviderConfig::default(),
2615            },
2616        );
2617        let options = provider_runtime_options_for_alias(&config, "openai", "primary");
2618        assert_eq!(options.provider_kind.as_deref(), Some("openai-compatible"));
2619
2620        let provider = create_routed_model_provider_with_options(
2621            &config,
2622            "openai.primary",
2623            Some("sk-test"),
2624            None,
2625            &config.reliability,
2626            &[ModelRouteConfig {
2627                hint: "fast".to_string(),
2628                model_provider: "openrouter.route".to_string(),
2629                model: "openrouter/auto".to_string(),
2630                api_key: None,
2631            }],
2632            "gpt-test",
2633            &options,
2634        )
2635        .expect("primary alias kind should build without poisoning route provider kind");
2636
2637        assert!(
2638            provider.supports_vision(),
2639            "primary openai-compatible provider should remain the router default"
2640        );
2641    }
2642
2643    #[test]
2644    fn factory_mistral() {
2645        assert!(create_model_provider("mistral", Some("key")).is_ok());
2646    }
2647
2648    #[test]
2649    fn factory_xai() {
2650        assert!(create_model_provider("xai", Some("key")).is_ok());
2651    }
2652
2653    #[test]
2654    fn factory_deepseek() {
2655        assert!(create_model_provider("deepseek", Some("key")).is_ok());
2656    }
2657
2658    #[test]
2659    fn deepseek_provider_keeps_vision_disabled() {
2660        let model_provider = create_model_provider("deepseek", Some("key"))
2661            .expect("deepseek model_provider should build");
2662        assert!(!model_provider.supports_vision());
2663    }
2664
2665    #[test]
2666    fn factory_together() {
2667        assert!(create_model_provider("together", Some("key")).is_ok());
2668    }
2669
2670    #[test]
2671    fn factory_fireworks() {
2672        assert!(create_model_provider("fireworks", Some("key")).is_ok());
2673    }
2674
2675    #[test]
2676    fn factory_novita() {
2677        assert!(create_model_provider("novita", Some("key")).is_ok());
2678    }
2679
2680    #[test]
2681    fn factory_perplexity() {
2682        assert!(create_model_provider("perplexity", Some("key")).is_ok());
2683    }
2684
2685    #[test]
2686    fn factory_cohere() {
2687        assert!(create_model_provider("cohere", Some("key")).is_ok());
2688    }
2689
2690    #[test]
2691    fn factory_copilot() {
2692        assert!(create_model_provider("copilot", Some("key")).is_ok());
2693    }
2694
2695    #[test]
2696    fn factory_gemini_cli() {}
2697
2698    #[test]
2699    fn factory_kilocli() {
2700        assert!(create_model_provider("kilocli", None).is_ok());
2701    }
2702
2703    #[test]
2704    fn factory_kilo() {
2705        assert!(create_model_provider("kilo", Some("kilo-test-key")).is_ok());
2706    }
2707
2708    #[test]
2709    fn factory_nvidia() {
2710        assert!(create_model_provider("nvidia", Some("nvapi-test")).is_ok());
2711    }
2712
2713    // ── AI inference routers ─────────────────────────────────
2714
2715    #[test]
2716    fn factory_astrai() {
2717        assert!(create_model_provider("astrai", Some("sk-astrai-test")).is_ok());
2718    }
2719
2720    #[test]
2721    fn factory_avian() {
2722        assert!(create_model_provider("avian", Some("sk-avian-test")).is_ok());
2723    }
2724
2725    #[test]
2726    fn factory_deepmyst() {
2727        assert!(create_model_provider("deepmyst", Some("key")).is_ok());
2728    }
2729
2730    #[test]
2731    fn resolve_provider_credential_deepmyst_env_deleted() {}
2732
2733    // ── OpenAI-compatible aggregators & inference hosts ──────
2734
2735    #[test]
2736    fn factory_morph() {
2737        assert!(create_model_provider("morph", Some("sk-morph-test")).is_ok());
2738    }
2739
2740    #[test]
2741    fn factory_github_models() {
2742        assert!(create_model_provider("github_models", Some("ghp_test_token")).is_ok());
2743        // Hyphenated form canonicalizes to the underscore slot.
2744        assert!(create_model_provider("github-models", Some("ghp_test_token")).is_ok());
2745    }
2746
2747    #[test]
2748    fn factory_upstage() {
2749        assert!(create_model_provider("upstage", Some("up-test-key")).is_ok());
2750    }
2751
2752    #[test]
2753    fn factory_featherless() {
2754        assert!(create_model_provider("featherless", Some("featherless-test")).is_ok());
2755    }
2756
2757    #[test]
2758    fn factory_arcee() {
2759        assert!(create_model_provider("arcee", Some("arcee-test")).is_ok());
2760    }
2761
2762    #[test]
2763    fn factory_lambda_ai() {
2764        assert!(create_model_provider("lambda_ai", Some("lambda-test")).is_ok());
2765        // Hyphenated form canonicalizes to the underscore slot.
2766        assert!(create_model_provider("lambda-ai", Some("lambda-test")).is_ok());
2767    }
2768
2769    #[test]
2770    fn factory_inception() {
2771        assert!(create_model_provider("inception", Some("inception-test")).is_ok());
2772    }
2773
2774    #[test]
2775    fn default_url_matches_compat_spec_for_new_providers() {
2776        assert_eq!(
2777            default_model_provider_url("morph"),
2778            Some("https://api.morphllm.com/v1")
2779        );
2780        assert_eq!(
2781            default_model_provider_url("github_models"),
2782            Some("https://models.github.ai/inference")
2783        );
2784        assert_eq!(
2785            default_model_provider_url("upstage"),
2786            Some("https://api.upstage.ai/v1")
2787        );
2788        assert_eq!(
2789            default_model_provider_url("featherless"),
2790            Some("https://api.featherless.ai/v1")
2791        );
2792        // Arcee publishes at the non-standard `/api/v1` path.
2793        assert_eq!(
2794            default_model_provider_url("arcee"),
2795            Some("https://api.arcee.ai/api/v1")
2796        );
2797        assert_eq!(
2798            default_model_provider_url("lambda_ai"),
2799            Some("https://api.lambda.ai/v1")
2800        );
2801        assert_eq!(
2802            default_model_provider_url("inception"),
2803            Some("https://api.inceptionlabs.ai/v1")
2804        );
2805    }
2806
2807    // ── Custom / BYOP model model_provider ─────────────────────────
2808    //
2809    // The legacy colon-URL form ("custom:https://..." / "anthropic-custom:...")
2810    // and its in-process URL parser were deleted in #6273. The surface is
2811    // `[providers.models.custom.<alias>] uri = "https://..."` for OpenAI-
2812    // compatible endpoints (or `[providers.models.anthropic.<alias>] uri = ...`
2813    // for Anthropic-compatible). URL validation now happens at schema-load
2814    // time in `crates/zeroclaw-config/src/schema.rs::validate`, not at runtime
2815    // construction; tests for that validation belong with the schema, not here.
2816    //
2817    // Migration of legacy colon-URL configs is exercised by the integration
2818    // tests in `crates/zeroclaw-config/tests/migration.rs`
2819    // (`anthropic_custom_colon_url_default_provider_folds_under_anthropic`,
2820    // `custom_colon_url_default_provider_splits_into_uri`,
2821    // `agent_inline_brain_colon_url_provider_splits_into_uri`).
2822
2823    #[test]
2824    fn factory_custom_with_resolved_uri() {
2825        let options = ModelProviderRuntimeOptions {
2826            provider_api_url: Some("https://my-llm.example.com".to_string()),
2827            ..ModelProviderRuntimeOptions::default()
2828        };
2829        assert!(create_model_provider_with_options("custom", Some("key"), &options).is_ok());
2830    }
2831
2832    #[test]
2833    fn factory_custom_without_uri_errors() {
2834        match create_model_provider("custom", Some("key")) {
2835            Err(e) => assert!(
2836                e.to_string().contains("requires `uri`"),
2837                "Expected `uri` error, got: {e}"
2838            ),
2839            Ok(_) => {
2840                panic!("Expected error when custom model model_provider has no URI configured")
2841            }
2842        }
2843    }
2844
2845    // ── Error cases ──────────────────────────────────────────
2846
2847    #[test]
2848    fn factory_unknown_provider_errors() {
2849        let p = create_model_provider("nonexistent", None);
2850        assert!(p.is_err());
2851        let msg = p.err().unwrap().to_string();
2852        assert!(msg.contains("Unknown model_provider family"));
2853        assert!(msg.contains("nonexistent"));
2854    }
2855
2856    #[test]
2857    fn factory_empty_name_errors() {
2858        assert!(create_model_provider("", None).is_err());
2859    }
2860
2861    #[test]
2862    fn ollama_with_custom_url() {
2863        let model_provider =
2864            create_model_provider_with_url("ollama", None, Some("http://10.100.2.32:11434"));
2865        assert!(model_provider.is_ok());
2866    }
2867
2868    #[test]
2869    fn ollama_cloud_with_custom_url() {
2870        let model_provider = create_model_provider_with_url(
2871            "ollama",
2872            Some("ollama-key"),
2873            Some("https://ollama.com"),
2874        );
2875        assert!(model_provider.is_ok());
2876    }
2877
2878    #[tokio::test]
2879    async fn ollama_private_remote_cloud_request_omits_auth_and_preserves_model() {
2880        use axum::{
2881            Json, Router,
2882            extract::State,
2883            http::{HeaderMap, StatusCode},
2884            routing::post,
2885        };
2886        use serde_json::{Value, json};
2887        use std::sync::{Arc, Mutex};
2888
2889        type Capture = Arc<Mutex<Option<(Option<String>, String)>>>;
2890
2891        async fn capture_chat_request(
2892            State(capture): State<Capture>,
2893            headers: HeaderMap,
2894            Json(body): Json<Value>,
2895        ) -> (StatusCode, Json<Value>) {
2896            let auth = headers
2897                .get("authorization")
2898                .and_then(|value| value.to_str().ok())
2899                .map(str::to_string);
2900            let model = body
2901                .get("model")
2902                .and_then(Value::as_str)
2903                .unwrap_or_default()
2904                .to_string();
2905            *capture.lock().expect("capture lock poisoned") = Some((auth, model));
2906            (
2907                StatusCode::OK,
2908                Json(json!({
2909                    "choices": [{"message": {"content": "ok"}}]
2910                })),
2911            )
2912        }
2913
2914        let capture: Capture = Arc::new(Mutex::new(None));
2915        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2916            .await
2917            .expect("bind test server");
2918        let addr = listener.local_addr().expect("test server addr");
2919        let app = Router::new()
2920            .route("/v1/chat/completions", post(capture_chat_request))
2921            .with_state(capture.clone());
2922        let server = ::zeroclaw_spawn::spawn!(async move {
2923            axum::serve(listener, app).await.expect("serve test server");
2924        });
2925
2926        let base_url = format!("http://{addr}");
2927        let model_provider = create_model_provider_with_url("ollama", None, Some(&base_url))
2928            .expect("ollama provider should build");
2929        let response = model_provider
2930            .chat_with_system(None, "hello", "qwen3:cloud", Some(0.7))
2931            .await
2932            .expect("chat request should succeed");
2933
2934        assert_eq!(response, "ok");
2935        let (auth, model) = capture
2936            .lock()
2937            .expect("capture lock poisoned")
2938            .take()
2939            .expect("server should capture request");
2940        assert_eq!(auth, None);
2941        assert_eq!(model, "qwen3:cloud");
2942        server.abort();
2943    }
2944
2945    #[tokio::test]
2946    async fn ollama_private_remote_lists_models_without_auth() {
2947        use axum::{Json, Router, extract::State, http::HeaderMap, routing::get};
2948        use serde_json::{Value, json};
2949        use std::sync::{Arc, Mutex};
2950
2951        type Capture = Arc<Mutex<Option<Option<String>>>>;
2952
2953        async fn capture_models_request(
2954            State(capture): State<Capture>,
2955            headers: HeaderMap,
2956        ) -> Json<Value> {
2957            let auth = headers
2958                .get("authorization")
2959                .and_then(|value| value.to_str().ok())
2960                .map(str::to_string);
2961            *capture.lock().expect("capture lock poisoned") = Some(auth);
2962            Json(json!({
2963                "data": [{"id": "qwen3:cloud"}]
2964            }))
2965        }
2966
2967        let capture: Capture = Arc::new(Mutex::new(None));
2968        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2969            .await
2970            .expect("bind test server");
2971        let addr = listener.local_addr().expect("test server addr");
2972        let app = Router::new()
2973            .route("/v1/models", get(capture_models_request))
2974            .with_state(capture.clone());
2975        let server = ::zeroclaw_spawn::spawn!(async move {
2976            axum::serve(listener, app).await.expect("serve test server");
2977        });
2978
2979        let base_url = format!("http://{addr}");
2980        let model_provider = create_model_provider_with_url("ollama", None, Some(&base_url))
2981            .expect("ollama provider should build");
2982        let models = model_provider
2983            .list_models()
2984            .await
2985            .expect("model list should succeed");
2986
2987        assert_eq!(models, vec!["qwen3:cloud".to_string()]);
2988        let auth = capture
2989            .lock()
2990            .expect("capture lock poisoned")
2991            .take()
2992            .expect("server should capture request");
2993        assert_eq!(auth, None);
2994        server.abort();
2995    }
2996
2997    #[test]
2998    fn factory_all_canonical_model_providers_create_successfully() {
2999        // Canonical family names only — legacy synonyms are collapsed by
3000        // `normalize_model_provider_type` in `schema/v2.rs` and never reach
3001        // the runtime. `azure` is excluded (typed-config required, see
3002        // `listed_model_providers_are_constructible` skip list); `custom` is
3003        // excluded (URI required, covered by `factory_custom_*` tests).
3004        let canonical = [
3005            "openrouter",
3006            "anthropic",
3007            "openai",
3008            "ollama",
3009            "gemini",
3010            "venice",
3011            "vercel",
3012            "cloudflare",
3013            "moonshot",
3014            "synthetic",
3015            "opencode",
3016            "zai",
3017            "glm",
3018            "minimax",
3019            "bedrock",
3020            "qianfan",
3021            "doubao",
3022            "qwen",
3023            "lmstudio",
3024            "llamacpp",
3025            "sglang",
3026            "vllm",
3027            "osaurus",
3028            "telnyx",
3029            "groq",
3030            "mistral",
3031            "xai",
3032            "deepseek",
3033            "together",
3034            "fireworks",
3035            "novita",
3036            "perplexity",
3037            "cohere",
3038            "copilot",
3039            "gemini_cli",
3040            "kilocli",
3041            "nvidia",
3042            "astrai",
3043            "avian",
3044            "ovh",
3045        ];
3046        for name in canonical {
3047            assert!(
3048                create_model_provider(name, Some("test-key")).is_ok(),
3049                "Canonical model model_provider '{name}' should create successfully"
3050            );
3051        }
3052    }
3053
3054    #[test]
3055    fn listed_model_providers_have_unique_canonical_ids() {
3056        let model_providers = list_model_providers();
3057        let mut canonical_ids = std::collections::HashSet::new();
3058
3059        for model_provider in model_providers {
3060            assert!(
3061                canonical_ids.insert(model_provider.name),
3062                "Duplicate canonical model model_provider id: {}",
3063                model_provider.name
3064            );
3065        }
3066    }
3067
3068    /// `list_model_providers()` must cover exactly the canonical slot set the
3069    /// `for_each_model_provider_slot!` macro emits — no missing display entries
3070    /// (a constructible provider invisible in the list / docs / dashboard) and
3071    /// no phantom entries (a display row for a slot the factory can't build).
3072    /// Adding a slot to the macro without a matching display entry fails here.
3073    #[test]
3074    fn listed_model_providers_match_canonical_slots() {
3075        let listed: std::collections::BTreeSet<&str> =
3076            list_model_providers().iter().map(|p| p.name).collect();
3077        let canonical: std::collections::BTreeSet<&str> =
3078            canonical_model_provider_slots().into_iter().collect();
3079        let missing: Vec<&&str> = canonical.difference(&listed).collect();
3080        let phantom: Vec<&&str> = listed.difference(&canonical).collect();
3081        assert!(
3082            missing.is_empty() && phantom.is_empty(),
3083            "list_model_providers() drift — missing display entries: {missing:?}; \
3084             phantom entries (no factory slot): {phantom:?}"
3085        );
3086    }
3087
3088    #[test]
3089    fn listed_model_providers_are_constructible() {
3090        for model_provider in list_model_providers() {
3091            // Azure requires typed config (resource + deployment) per #6273.
3092            // create_model_provider with default options has no azure context — that's
3093            // by design (env-var fallback eradicated). Tests that exercise the
3094            // Azure factory pass a populated ModelProviderRuntimeOptions through
3095            // create_model_provider_with_options.
3096            if model_provider.name == "azure" {
3097                continue;
3098            }
3099            // The custom slot requires a uri (no family-default endpoint);
3100            // covered by dedicated factory tests.
3101            if model_provider.name == "custom" {
3102                continue;
3103            }
3104            assert!(
3105                create_model_provider(model_provider.name, Some("provider-test-credential"))
3106                    .is_ok(),
3107                "Canonical model model_provider id should be constructible: {}",
3108                model_provider.name
3109            );
3110        }
3111    }
3112
3113    // ── API error sanitization ───────────────────────────────
3114
3115    #[test]
3116    fn format_error_chain_includes_sources_and_sanitizes_output() {
3117        #[derive(Debug)]
3118        struct ChainError {
3119            message: &'static str,
3120            source: Option<Box<dyn std::error::Error + 'static>>,
3121        }
3122
3123        impl std::fmt::Display for ChainError {
3124            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3125                write!(f, "{}", self.message)
3126            }
3127        }
3128
3129        impl std::error::Error for ChainError {
3130            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
3131                self.source.as_deref()
3132            }
3133        }
3134
3135        let error = ChainError {
3136            message: "outer context",
3137            source: Some(Box::new(ChainError {
3138                message: "middle context",
3139                source: Some(Box::new(ChainError {
3140                    message: "inner source leaked sk-1234567890abcdef",
3141                    source: None,
3142                })),
3143            })),
3144        };
3145
3146        let result = format_error_chain(&error);
3147
3148        assert!(result.contains("outer context"));
3149        assert!(result.contains("middle context"));
3150        assert!(result.contains("inner source leaked [REDACTED]"));
3151        assert!(!result.contains("sk-1234567890abcdef"));
3152    }
3153
3154    #[test]
3155    fn sanitize_scrubs_sk_prefix() {
3156        let input = "request failed: sk-1234567890abcdef";
3157        let out = sanitize_api_error(input);
3158        assert!(!out.contains("sk-1234567890abcdef"));
3159        assert!(out.contains("[REDACTED]"));
3160    }
3161
3162    #[test]
3163    fn sanitize_scrubs_multiple_prefixes() {
3164        let input = "keys sk-abcdef xoxb-12345 xoxp-67890";
3165        let out = sanitize_api_error(input);
3166        assert!(!out.contains("sk-abcdef"));
3167        assert!(!out.contains("xoxb-12345"));
3168        assert!(!out.contains("xoxp-67890"));
3169    }
3170
3171    #[test]
3172    fn sanitize_short_prefix_then_real_key() {
3173        let input = "error with sk- prefix and key sk-1234567890";
3174        let result = sanitize_api_error(input);
3175        assert!(!result.contains("sk-1234567890"));
3176        assert!(result.contains("[REDACTED]"));
3177    }
3178
3179    #[test]
3180    fn sanitize_sk_proj_comment_then_real_key() {
3181        let input = "note: sk- then sk-proj-abc123def456";
3182        let result = sanitize_api_error(input);
3183        assert!(!result.contains("sk-proj-abc123def456"));
3184        assert!(result.contains("[REDACTED]"));
3185    }
3186
3187    #[test]
3188    fn sanitize_keeps_bare_prefix() {
3189        let input = "only prefix sk- present";
3190        let result = sanitize_api_error(input);
3191        assert!(result.contains("sk-"));
3192    }
3193
3194    #[test]
3195    fn sanitize_handles_json_wrapped_key() {
3196        let input = r#"{"error":"invalid key sk-abc123xyz"}"#;
3197        let result = sanitize_api_error(input);
3198        assert!(!result.contains("sk-abc123xyz"));
3199    }
3200
3201    #[test]
3202    fn sanitize_handles_delimiter_boundaries() {
3203        let input = "bad token xoxb-abc123}; next";
3204        let result = sanitize_api_error(input);
3205        assert!(!result.contains("xoxb-abc123"));
3206        assert!(result.contains("};"));
3207    }
3208
3209    #[test]
3210    fn sanitize_truncates_long_error() {
3211        let long = "a".repeat(600);
3212        let result = sanitize_api_error(&long);
3213        assert!(result.len() <= 503);
3214        assert!(result.ends_with("..."));
3215    }
3216
3217    #[test]
3218    fn sanitize_truncates_after_scrub() {
3219        let input = format!("{} sk-abcdef123456 {}", "a".repeat(290), "b".repeat(290));
3220        let result = sanitize_api_error(&input);
3221        assert!(!result.contains("sk-abcdef123456"));
3222        assert!(result.len() <= 503);
3223    }
3224
3225    #[test]
3226    fn sanitize_preserves_unicode_boundaries() {
3227        let input = format!("{} sk-abcdef123", "hello🙂".repeat(80));
3228        let result = sanitize_api_error(&input);
3229        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
3230        assert!(!result.contains("sk-abcdef123"));
3231    }
3232
3233    #[test]
3234    fn sanitize_no_secret_no_change() {
3235        let input = "simple upstream timeout";
3236        let result = sanitize_api_error(input);
3237        assert_eq!(result, input);
3238    }
3239
3240    #[test]
3241    fn scrub_github_personal_access_token() {
3242        let input = "auth failed with token ghp_abc123def456";
3243        let result = scrub_secret_patterns(input);
3244        assert_eq!(result, "auth failed with token [REDACTED]");
3245    }
3246
3247    #[test]
3248    fn scrub_github_oauth_token() {
3249        let input = "Bearer gho_1234567890abcdef";
3250        let result = scrub_secret_patterns(input);
3251        assert_eq!(result, "Bearer [REDACTED]");
3252    }
3253
3254    #[test]
3255    fn scrub_github_user_token() {
3256        let input = "token ghu_sessiontoken123";
3257        let result = scrub_secret_patterns(input);
3258        assert_eq!(result, "token [REDACTED]");
3259    }
3260
3261    #[test]
3262    fn scrub_github_fine_grained_pat() {
3263        let input = "failed: github_pat_11AABBC_xyzzy789";
3264        let result = scrub_secret_patterns(input);
3265        assert_eq!(result, "failed: [REDACTED]");
3266    }
3267
3268    // ── API key prefix pre-flight ───────────────────────────
3269
3270    #[test]
3271    fn api_key_prefix_cross_provider_mismatch() {
3272        // Anthropic key used with openrouter
3273        assert_eq!(
3274            check_api_key_prefix("openrouter", "sk-ant-api03-xyz"),
3275            Some("anthropic")
3276        );
3277        // OpenRouter key used with anthropic
3278        assert_eq!(
3279            check_api_key_prefix("anthropic", "sk-or-v1-xyz"),
3280            Some("openrouter")
3281        );
3282        // Anthropic key used with openai
3283        assert_eq!(
3284            check_api_key_prefix("openai", "sk-ant-xyz"),
3285            Some("anthropic")
3286        );
3287        // Groq key used with openai
3288        assert_eq!(check_api_key_prefix("openai", "gsk_xyz"), Some("groq"));
3289    }
3290
3291    #[test]
3292    fn api_key_prefix_correct_match() {
3293        assert_eq!(check_api_key_prefix("anthropic", "sk-ant-api03-xyz"), None);
3294        assert_eq!(check_api_key_prefix("openrouter", "sk-or-v1-xyz"), None);
3295        assert_eq!(check_api_key_prefix("openai", "sk-proj-xyz"), None);
3296        assert_eq!(check_api_key_prefix("groq", "gsk_xyz"), None);
3297    }
3298
3299    #[test]
3300    fn api_key_prefix_unknown_provider_skips() {
3301        // Providers without known key formats should never flag a mismatch.
3302        assert_eq!(check_api_key_prefix("deepseek", "sk-ant-xyz"), None);
3303        assert_eq!(check_api_key_prefix("ollama", "anything"), None);
3304    }
3305
3306    #[test]
3307    fn api_key_prefix_unknown_key_format_skips() {
3308        // Keys without a recognisable prefix should never flag a mismatch.
3309        assert_eq!(check_api_key_prefix("openai", "my-custom-key-123"), None);
3310        assert_eq!(check_api_key_prefix("anthropic", "some-random-key"), None);
3311    }
3312
3313    #[test]
3314    fn provider_runtime_options_default_has_empty_extra_headers() {
3315        let options = ModelProviderRuntimeOptions::default();
3316        assert!(options.extra_headers.is_empty());
3317    }
3318
3319    #[test]
3320    fn provider_runtime_options_extra_headers_passed_through() {
3321        let mut extra_headers = std::collections::HashMap::new();
3322        extra_headers.insert("X-Title".to_string(), "zeroclaw".to_string());
3323        let options = ModelProviderRuntimeOptions {
3324            extra_headers,
3325            ..ModelProviderRuntimeOptions::default()
3326        };
3327        assert_eq!(options.extra_headers.len(), 1);
3328        assert_eq!(options.extra_headers.get("X-Title").unwrap(), "zeroclaw");
3329    }
3330
3331    #[test]
3332    fn ollama_uses_resolved_url_from_runtime_options() {
3333        // V0.8.0: `ZEROCLAW_PROVIDER_URL` env-var override eradicated. Ollama
3334        // base URL flows through the typed alias's `api_url`/`uri` field which
3335        // pre-populates `provider_api_url` on `ModelProviderRuntimeOptions`.
3336        let model_provider =
3337            create_model_provider_with_url("ollama", None, Some("http://config-ollama:11434"));
3338        assert!(model_provider.is_ok());
3339    }
3340
3341    // ── Per-alias provider_runtime_options resolution ──
3342
3343    /// Build a `Config` with two `anthropic` aliases at different base_urls
3344    /// so the test can prove `provider_runtime_options_for_agent` selects
3345    /// the alias-specific entry via explicit `<type>.<alias>` resolution.
3346    fn config_with_two_anthropic_aliases() -> zeroclaw_config::schema::Config {
3347        use zeroclaw_config::schema::{
3348            AliasedAgentConfig, AnthropicModelProviderConfig, Config, ModelProviderConfig,
3349        };
3350        let mut config = Config::default();
3351        let default_alias = AnthropicModelProviderConfig {
3352            base: ModelProviderConfig {
3353                model: Some("claude-default".into()),
3354                api_key: Some("default-key".into()),
3355                uri: Some("https://api.default.example/v1/messages".into()),
3356                ..ModelProviderConfig::default()
3357            },
3358        };
3359        let work_alias = AnthropicModelProviderConfig {
3360            base: ModelProviderConfig {
3361                model: Some("claude-work".into()),
3362                api_key: Some("work-key".into()),
3363                uri: Some("https://work-proxy.example/v1/v1/anthropic/messages".into()),
3364                ..ModelProviderConfig::default()
3365            },
3366        };
3367        config
3368            .providers
3369            .models
3370            .anthropic
3371            .insert("default".to_string(), default_alias);
3372        config
3373            .providers
3374            .models
3375            .anthropic
3376            .insert("work".to_string(), work_alias);
3377        let work_agent = AliasedAgentConfig {
3378            model_provider: "anthropic.work".into(),
3379            ..AliasedAgentConfig::default()
3380        };
3381        config.agents.insert("work_agent".to_string(), work_agent);
3382        let default_agent = AliasedAgentConfig {
3383            model_provider: "anthropic.default".into(),
3384            ..AliasedAgentConfig::default()
3385        };
3386        config
3387            .agents
3388            .insert("default_agent".to_string(), default_agent);
3389        config
3390    }
3391
3392    #[test]
3393    fn provider_runtime_options_for_agent_resolves_alias_specific_uri() {
3394        let config = config_with_two_anthropic_aliases();
3395        let work = provider_runtime_options_for_agent(&config, "work_agent");
3396        let dflt = provider_runtime_options_for_agent(&config, "default_agent");
3397
3398        assert_eq!(
3399            work.provider_api_url.as_deref(),
3400            Some("https://work-proxy.example/v1/v1/anthropic/messages"),
3401            "work agent must resolve to the work alias's full uri (with merged path)"
3402        );
3403        assert_eq!(
3404            dflt.provider_api_url.as_deref(),
3405            Some("https://api.default.example/v1/messages"),
3406            "default agent must resolve to the default alias's full uri (with merged path)"
3407        );
3408    }
3409
3410    #[test]
3411    fn provider_runtime_options_for_agent_unknown_agent_returns_safe_defaults() {
3412        // Per HEAD's explicit-resolution policy (48a386f55 — delete
3413        // first_model_provider*), unknown agents do NOT fall back to a
3414        // first-configured provider. They return safe defaults (no URL) so
3415        // dispatch surfaces a setup error instead of silently routing to an
3416        // arbitrary provider the operator never bound to the agent.
3417        let config = config_with_two_anthropic_aliases();
3418        let opts = provider_runtime_options_for_agent(&config, "nonexistent");
3419        assert!(
3420            opts.provider_api_url.is_none(),
3421            "unknown agent must not silently inherit any configured provider; got `{:?}`",
3422            opts.provider_api_url
3423        );
3424    }
3425
3426    #[test]
3427    fn ollama_alias_tuning_fields_populate_tuning_struct() {
3428        let alias = zeroclaw_config::schema::OllamaModelProviderConfig {
3429            num_ctx: Some(16384),
3430            num_predict: Some(4096),
3431            temperature_override: Some(0.5),
3432            ..zeroclaw_config::schema::OllamaModelProviderConfig::default()
3433        };
3434
3435        let tuning = ollama::OllamaTuning::from_runtime_overrides(
3436            alias.num_ctx,
3437            alias.num_predict,
3438            alias.temperature_override,
3439        );
3440        assert_eq!(tuning.num_ctx, 16384);
3441        assert_eq!(tuning.num_predict, 4096);
3442        assert_eq!(tuning.temperature_override, Some(0.5));
3443
3444        let provider = ollama::OllamaModelProvider::new("test", None, None).with_tuning(tuning);
3445        assert_eq!(provider.tuning(), tuning);
3446    }
3447
3448    #[test]
3449    fn ollama_alias_tuning_defaults_leave_temperature_override_unset() {
3450        let alias = zeroclaw_config::schema::OllamaModelProviderConfig::default();
3451        let tuning = ollama::OllamaTuning::from_runtime_overrides(
3452            alias.num_ctx,
3453            alias.num_predict,
3454            alias.temperature_override,
3455        );
3456        assert!(tuning.temperature_override.is_none());
3457        assert_eq!(tuning.num_ctx, ollama::OLLAMA_DEFAULT_NUM_CTX);
3458        assert_eq!(tuning.num_predict, ollama::OLLAMA_DEFAULT_NUM_PREDICT);
3459    }
3460
3461    fn config_with_openai_alias() -> zeroclaw_config::schema::Config {
3462        use zeroclaw_config::schema::{
3463            AliasedAgentConfig, Config, ModelProviderConfig, OpenAIModelProviderConfig,
3464        };
3465        let mut config = Config::default();
3466        let alias = OpenAIModelProviderConfig {
3467            base: ModelProviderConfig {
3468                api_key: Some("openai-alias-key".into()),
3469                model: Some("gpt-4o".into()),
3470                ..ModelProviderConfig::default()
3471            },
3472        };
3473        config
3474            .providers
3475            .models
3476            .openai
3477            .insert("alias".to_string(), alias);
3478        let agent = AliasedAgentConfig {
3479            model_provider: "openai.alias".into(),
3480            ..AliasedAgentConfig::default()
3481        };
3482        config.agents.insert("test_agent".to_string(), agent);
3483        config
3484    }
3485
3486    #[test]
3487    fn routed_model_provider_credential_precedence_uses_route_key_first() {
3488        let config = config_with_openai_alias();
3489        let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3490        let routes = [zeroclaw_config::schema::ModelRouteConfig {
3491            hint: "test".into(),
3492            model_provider: "openai.alias".into(),
3493            model: "gpt-4o".into(),
3494            api_key: Some("route-key".into()),
3495        }];
3496
3497        let result = create_routed_model_provider_with_options(
3498            &config,
3499            "openai.alias",
3500            Some("fallback-key"),
3501            None,
3502            &reliability,
3503            &routes,
3504            "gpt-4o",
3505            &ModelProviderRuntimeOptions::default(),
3506        );
3507
3508        assert!(
3509            result.is_ok(),
3510            "route-key should succeed: {}",
3511            result.err().unwrap()
3512        );
3513    }
3514
3515    #[test]
3516    fn routed_model_provider_credential_precedence_uses_config_entry_key() {
3517        let config = config_with_openai_alias();
3518        let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3519        // Route has no api_key — should fall back to config entry key "openai-alias-key"
3520        let routes = [zeroclaw_config::schema::ModelRouteConfig {
3521            hint: "test".into(),
3522            model_provider: "openai.alias".into(),
3523            model: "gpt-4o".into(),
3524            api_key: None,
3525        }];
3526
3527        let result = create_routed_model_provider_with_options(
3528            &config,
3529            "openai.alias",
3530            Some("fallback-key"),
3531            None,
3532            &reliability,
3533            &routes,
3534            "gpt-4o",
3535            &ModelProviderRuntimeOptions::default(),
3536        );
3537
3538        assert!(
3539            result.is_ok(),
3540            "config-entry key should succeed: {}",
3541            result.err().unwrap()
3542        );
3543    }
3544
3545    #[test]
3546    fn routed_model_provider_credential_precedence_falls_back_to_api_key_param() {
3547        let config = zeroclaw_config::schema::Config::default(); // no entry in config.models
3548        let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3549        // Neither route nor config entry has api_key — should use the param "fallback-key"
3550        let routes = [zeroclaw_config::schema::ModelRouteConfig {
3551            hint: "test".into(),
3552            model_provider: "openai".into(),
3553            model: "gpt-4o".into(),
3554            api_key: None,
3555        }];
3556
3557        let result = create_routed_model_provider_with_options(
3558            &config,
3559            "openai",
3560            Some("fallback-key"),
3561            None,
3562            &reliability,
3563            &routes,
3564            "gpt-4o",
3565            &ModelProviderRuntimeOptions::default(),
3566        );
3567
3568        assert!(
3569            result.is_ok(),
3570            "fallback-key should succeed: {}",
3571            result.err().unwrap()
3572        );
3573    }
3574
3575    #[test]
3576    fn routed_model_provider_credential_skips_config_entry_for_non_dotted_name() {
3577        let config = zeroclaw_config::schema::Config::default();
3578        let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3579        // Non-dotted name "openai" — split_once('.') returns None, so config entry
3580        // lookup is skipped entirely. Falls back to api_key param.
3581        let routes = [zeroclaw_config::schema::ModelRouteConfig {
3582            hint: "test".into(),
3583            model_provider: "openai".into(),
3584            model: "gpt-4o".into(),
3585            api_key: None,
3586        }];
3587
3588        let result = create_routed_model_provider_with_options(
3589            &config,
3590            "openai",
3591            Some("direct-key"),
3592            None,
3593            &reliability,
3594            &routes,
3595            "gpt-4o",
3596            &ModelProviderRuntimeOptions::default(),
3597        );
3598
3599        assert!(
3600            result.is_ok(),
3601            "direct-key should succeed: {}",
3602            result.err().unwrap()
3603        );
3604    }
3605
3606    /// Regression test: any dotted alias name ("openai.<anything>") must route through
3607    /// the alias-aware factory path so the typed config's `requires_openai_auth = true`
3608    /// flag is visible to `OpenAIModelProviderConfig::create_provider`. Without this,
3609    /// the bare-family path is taken, `dispatch_family_factory` receives `config = None`,
3610    /// falls back to the default `OpenAIModelProviderConfig` (where
3611    /// `requires_openai_auth = false`), and routes to the standard OpenAI provider
3612    /// instead of `OpenAiCodexModelProvider`. The alias can be any user-chosen name —
3613    /// it is not hard-coded to "codex" or any other specific string.
3614    #[test]
3615    fn dotted_alias_routes_openai_codex_via_requires_openai_auth() {
3616        use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
3617
3618        // Use an intentionally arbitrary alias to prove the routing is alias-agnostic.
3619        let arbitrary_alias = "qwertfoozp";
3620
3621        let mut config = zeroclaw_config::schema::Config::default();
3622        config.providers.models.openai.insert(
3623            arbitrary_alias.to_string(),
3624            OpenAIModelProviderConfig {
3625                base: ModelProviderConfig {
3626                    requires_openai_auth: true,
3627                    ..Default::default()
3628                },
3629            },
3630        );
3631
3632        // Verify the alias-aware factory path sees `requires_openai_auth = true`
3633        // and routes to OpenAiCodexModelProvider. `dispatch_family_factory` is
3634        // called directly (no ReliableModelProvider wrapper) so `capabilities()`
3635        // reflects the inner provider's values.
3636        let result = factory::dispatch_family_factory(
3637            Some(&config),
3638            "openai",
3639            arbitrary_alias,
3640            None,
3641            None,
3642            &ModelProviderRuntimeOptions::default(),
3643        );
3644        assert!(
3645            result.is_ok(),
3646            "codex alias construction should succeed: {}",
3647            result.err().unwrap()
3648        );
3649        assert!(
3650            result.unwrap().capabilities().native_tool_calling,
3651            "openai.{arbitrary_alias} with requires_openai_auth=true must route to \
3652             OpenAiCodexModelProvider (native_tool_calling=true), not the standard provider"
3653        );
3654    }
3655
3656    #[test]
3657    fn resilient_alias_builds_with_fallback_chain() {
3658        use zeroclaw_config::schema::{Config, ModelProviderConfig, OpenAIModelProviderConfig};
3659
3660        let mut config = Config::default();
3661        config.providers.models.openai.insert(
3662            "primary".to_string(),
3663            OpenAIModelProviderConfig {
3664                base: ModelProviderConfig {
3665                    model: Some("gpt-4o".to_string()),
3666                    fallback_models: vec!["gpt-4o-mini".to_string()],
3667                    fallback: vec![zeroclaw_config::providers::ModelProviderRef::new(
3668                        "openai.backup",
3669                    )],
3670                    ..Default::default()
3671                },
3672            },
3673        );
3674        config.providers.models.openai.insert(
3675            "backup".to_string(),
3676            OpenAIModelProviderConfig {
3677                base: ModelProviderConfig {
3678                    model: Some("gpt-4.1".to_string()),
3679                    ..Default::default()
3680                },
3681            },
3682        );
3683
3684        let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3685        let result = create_resilient_model_provider_for_alias(
3686            &config,
3687            "openai",
3688            "primary",
3689            None,
3690            None,
3691            &reliability,
3692            &ModelProviderRuntimeOptions::default(),
3693        );
3694        assert!(
3695            result.is_ok(),
3696            "multi-alias fallback chain must build: {}",
3697            result.err().unwrap()
3698        );
3699    }
3700
3701    #[test]
3702    fn resilient_alias_dangling_fallback_does_not_abort_build() {
3703        use zeroclaw_config::schema::{Config, ModelProviderConfig, OpenAIModelProviderConfig};
3704
3705        let mut config = Config::default();
3706        config.providers.models.openai.insert(
3707            "primary".to_string(),
3708            OpenAIModelProviderConfig {
3709                base: ModelProviderConfig {
3710                    model: Some("gpt-4o".to_string()),
3711                    fallback: vec![zeroclaw_config::providers::ModelProviderRef::new(
3712                        "openai.ghost",
3713                    )],
3714                    ..Default::default()
3715                },
3716            },
3717        );
3718
3719        let result = create_resilient_model_provider_for_alias(
3720            &config,
3721            "openai",
3722            "primary",
3723            None,
3724            None,
3725            &zeroclaw_config::schema::ReliabilityConfig::default(),
3726            &ModelProviderRuntimeOptions::default(),
3727        );
3728        assert!(
3729            result.is_ok(),
3730            "a dangling fallback ref must be skipped, never abort the build"
3731        );
3732    }
3733
3734    #[test]
3735    fn resilient_alias_cyclic_fallback_does_not_loop_or_abort() {
3736        use zeroclaw_config::schema::{Config, ModelProviderConfig, OpenAIModelProviderConfig};
3737
3738        let mut config = Config::default();
3739        config.providers.models.openai.insert(
3740            "a".to_string(),
3741            OpenAIModelProviderConfig {
3742                base: ModelProviderConfig {
3743                    model: Some("gpt-4o".to_string()),
3744                    fallback: vec![zeroclaw_config::providers::ModelProviderRef::new(
3745                        "openai.b",
3746                    )],
3747                    ..Default::default()
3748                },
3749            },
3750        );
3751        config.providers.models.openai.insert(
3752            "b".to_string(),
3753            OpenAIModelProviderConfig {
3754                base: ModelProviderConfig {
3755                    model: Some("gpt-4.1".to_string()),
3756                    fallback: vec![zeroclaw_config::providers::ModelProviderRef::new(
3757                        "openai.a",
3758                    )],
3759                    ..Default::default()
3760                },
3761            },
3762        );
3763
3764        let result = create_resilient_model_provider_for_alias(
3765            &config,
3766            "openai",
3767            "a",
3768            None,
3769            None,
3770            &zeroclaw_config::schema::ReliabilityConfig::default(),
3771            &ModelProviderRuntimeOptions::default(),
3772        );
3773        assert!(
3774            result.is_ok(),
3775            "a fallback cycle must be pruned, never loop or abort the build"
3776        );
3777    }
3778
3779    #[test]
3780    fn resilient_alias_deep_acyclic_fallback_does_not_overflow() {
3781        use zeroclaw_config::schema::{Config, ModelProviderConfig, OpenAIModelProviderConfig};
3782
3783        let mut config = Config::default();
3784        let n = zeroclaw_config::providers::MAX_FALLBACK_DEPTH + 50;
3785        for i in 0..n {
3786            let fallback = if i + 1 < n {
3787                vec![zeroclaw_config::providers::ModelProviderRef::new(format!(
3788                    "openai.a{}",
3789                    i + 1
3790                ))]
3791            } else {
3792                vec![]
3793            };
3794            config.providers.models.openai.insert(
3795                format!("a{i}"),
3796                OpenAIModelProviderConfig {
3797                    base: ModelProviderConfig {
3798                        model: Some("gpt-4o".to_string()),
3799                        fallback,
3800                        ..Default::default()
3801                    },
3802                },
3803            );
3804        }
3805
3806        let result = create_resilient_model_provider_for_alias(
3807            &config,
3808            "openai",
3809            "a0",
3810            None,
3811            None,
3812            &zeroclaw_config::schema::ReliabilityConfig::default(),
3813            &ModelProviderRuntimeOptions::default(),
3814        );
3815        assert!(
3816            result.is_ok(),
3817            "a deep acyclic chain must be depth-capped, never overflow or abort the build"
3818        );
3819    }
3820}