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