//! Per-family construction dispatch for model providers.
//!
//! Each `<Family>ModelProviderConfig` typed slot from
//! `zeroclaw-config::providers::ModelProviders` declares its own construction
//! via one of two traits:
//!
//! - [`CompatFamilySpec`] for OpenAI-compatible families. Declare
//!   `DISPLAY` / `DEFAULT_URL` / `AUTH`; the blanket
//!   `impl<T: CompatFamilySpec> FamilyProviderFactory for T` produces the
//!   provider. Families with minor modifiers (`.without_native_tools()`,
//!   `.models_dev_key(...)`, multi-endpoint URI fallback) override
//!   `build_compat` — still one place per family, no flat dispatch arm.
//!
//! - [`FamilyProviderFactory`] directly for bespoke families that wrap a
//!   non-compat runtime provider (`azure`, `gemini`, `openrouter`,
//!   `bedrock`, `anthropic`, …).
//!
//! Dispatch is generated by [`for_each_model_provider_slot!`] — the same
//! macro that defines the typed slots is the only place the family list
//! lives. Adding a family is one slot row plus one trait impl; missing the
//! impl fails to compile when the dispatch is generated.
//!
//! [`for_each_model_provider_slot!`]: zeroclaw_config::for_each_model_provider_slot

use crate::ModelProviderRuntimeOptions;
use crate::compatible::{AuthStyle, OpenAiCompatibleModelProvider};
use crate::traits::ModelProvider;
use anyhow::Result;

pub trait FamilyProviderFactory {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>>;

    fn fallback_auth_ready(&self, key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        has_api_key(key)
    }
}

/// Spec trait for OpenAI-compatible families. Implementing this gives a
/// `FamilyProviderFactory` impl for free via the blanket below.
///
/// Override [`CompatFamilySpec::build_compat`] when the family needs minor
/// modifiers (e.g. `.without_native_tools()`); otherwise the default
/// `OpenAiCompatibleModelProvider::builder` entry point is used.
pub trait CompatFamilySpec {
    const DISPLAY: &'static str;
    const DEFAULT_URL: &'static str;
    const AUTH: AuthStyle;
    const FALLBACK_ALLOWS_MISSING_API_KEY: bool = false;

    const MODELS_DEV_KEY: Option<&'static str> = None;

    /// OpenRouter vendor prefix used by `list_models` as a last-resort
    /// fallback when this family has no `models.dev` entry and no live
    /// credential. `None` when no OpenRouter prefix exists for this family
    /// (e.g. Sambanova, Hyperbolic — no public catalog at all without a key).
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = None;

    fn wire_api(&self) -> Option<zeroclaw_config::schema::WireApi> {
        None
    }

    const PUBLIC_MODEL_LISTING: bool = false;

    /// Build the base compat provider with both catalog consts applied. Use
    /// this from inside `build_compat` overrides so the catalog hooks ride
    /// along with any family-specific modifiers.
    fn build_compat_base(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
    ) -> crate::compatible::OpenAiCompatibleBuilder {
        let mut b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name(Self::DISPLAY)
            .base_url(api_url.unwrap_or(Self::DEFAULT_URL))
            .credential(key)
            .auth_style(Self::AUTH);
        if let Some(catalog_key) = Self::MODELS_DEV_KEY {
            b = b.models_dev_key(catalog_key);
        }
        if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
            b = b.openrouter_vendor_prefix(prefix);
        }
        if Self::PUBLIC_MODEL_LISTING {
            b = b.public_model_listing();
        }
        b
    }

    /// Build the underlying compat provider builder. Default just returns the
    /// base from `build_compat_base`; override to chain family-specific
    /// modifiers (e.g. `.without_native_tools()`, `.merge_system_into_user_preserving_native()`).
    fn build_compat(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
    ) -> crate::compatible::OpenAiCompatibleBuilder {
        self.build_compat_base(alias, key, api_url)
    }
}

impl<T: CompatFamilySpec> FamilyProviderFactory for T {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        if let Some(p) = build_responses_provider_if_requested(
            self.wire_api(),
            alias,
            api_url.or(Some(Self::DEFAULT_URL)),
            key,
            opts,
        ) {
            return Ok(p);
        }
        Ok(apply_compat_options(
            self.build_compat(alias, key, api_url),
            opts,
        ))
    }

    fn fallback_auth_ready(&self, key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        has_api_key(key) || T::FALLBACK_ALLOWS_MISSING_API_KEY
    }
}

fn has_api_key(key: Option<&str>) -> bool {
    has_non_empty_value(key)
}

fn has_non_empty_value(value: Option<&str>) -> bool {
    value.map(str::trim).is_some_and(|value| !value.is_empty())
}

/// Apply cross-cutting compat overrides (timeout, headers, api_path,
/// max_tokens, reasoning effort, TLS CA, `provider_extra`) to a compat
/// builder before calling `.build()` and boxing the trait object. Single
/// source of the override chain — every compat impl funnels through here.
pub fn apply_compat_options(
    mut b: crate::compatible::OpenAiCompatibleBuilder,
    opts: &ModelProviderRuntimeOptions,
) -> Box<dyn ModelProvider> {
    if let Some(t) = opts.provider_timeout_secs {
        b = b.timeout_secs(t);
    }
    if let Some(ref effort) = opts.reasoning_effort {
        b = b.reasoning_effort(Some(effort.clone()));
    }
    if !opts.extra_headers.is_empty() {
        b = b.extra_headers(opts.extra_headers.clone());
    }
    if opts.api_path.is_some() {
        b = b.api_path(opts.api_path.clone());
    }
    if let Some(mt) = opts.provider_max_tokens {
        b = b.max_tokens(Some(mt));
    }
    if let Some(ref cert_path) = opts.tls_ca_cert_path {
        b = b.tls_ca_cert_path(cert_path);
    }
    if opts.replay_assistant_reasoning == Some(false) {
        b = b.without_assistant_reasoning_replay();
    }
    // `provider_extra` alias is captured before `build()` because the WARN
    // path below reads it for logging. Only object-shaped JSON is threaded
    // through; other shapes produce a WARN and are ignored (matching the
    // pre-refactor behaviour).
    let extra_body_for_build = opts.provider_extra.as_ref().and_then(|extra| {
        if extra.is_object() {
            Some(extra.clone())
        } else {
            None
        }
    });
    if let Some(extra) = extra_body_for_build.clone() {
        b = b.extra_body(extra);
    }
    let p = b.build();
    if let Some(extra) = &opts.provider_extra
        && !extra.is_object()
    {
        let config_path = format!("[providers.models.{}].provider_extra", p.alias);
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({
                    "alias": p.alias,
                    "config_path": &config_path,
                })),
            "provider_extra must be a JSON object (use TOML inline \
             table syntax, not a JSON string). Got: {extra}. Config path: {config_path}",
        );
    }
    Box::new(p)
}

fn build_responses_provider_if_requested(
    wire_api: Option<zeroclaw_config::schema::WireApi>,
    alias: &str,
    base_url: Option<&str>,
    key: Option<&str>,
    opts: &ModelProviderRuntimeOptions,
) -> Option<Box<dyn ModelProvider>> {
    if wire_api != Some(zeroclaw_config::schema::WireApi::Responses) {
        return None;
    }
    let mut builder = crate::openai::OpenAiResponsesModelProvider::builder(alias).credential(key);
    if let Some(url) = base_url {
        builder = builder.api_url(url);
    }
    if let Some(t) = opts.provider_timeout_secs {
        builder = builder.timeout_secs(t);
    }
    if let Some(mt) = opts.provider_max_tokens {
        builder = builder.max_tokens(Some(mt));
    }
    if let Some(ref effort) = opts.reasoning_effort {
        builder = builder.reasoning_effort(Some(effort.clone()));
    }
    if !opts.extra_headers.is_empty() {
        builder = builder.extra_headers(opts.extra_headers.clone());
    }
    Some(Box::new(builder.build()))
}

pub(crate) fn build_kimi_code_compat(
    alias: &str,
    key: Option<&str>,
    base_url: &str,
) -> crate::compatible::OpenAiCompatibleBuilder {
    OpenAiCompatibleModelProvider::builder(alias)
        .display_name("Kimi Code")
        .base_url(base_url)
        .credential(key)
        .auth_style(AuthStyle::Bearer)
        .user_agent("KimiCLI/0.77")
        .vision(true)
        .models_dev_key("moonshotai")
}

pub fn dispatch_family_factory(
    config: Option<&zeroclaw_config::schema::Config>,
    family: &str,
    alias: &str,
    key: Option<&str>,
    api_url: Option<&str>,
    opts: &ModelProviderRuntimeOptions,
) -> Result<Box<dyn ModelProvider>> {
    // openai missing-entry fallback: see `openai_missing_entry_fallback_config`.
    // A persisted entry keeps its stored `wire_api`; an implicit dispatch with no
    // entry (bare `openai` ref or a dangling alias) stays on the chat-completions
    // wire so existing installs don't flip wire + tool-calling mode on upgrade.
    if family == "openai" {
        let default_cfg = openai_missing_entry_fallback_config();
        let cfg = config
            .and_then(|c| c.providers.models.openai.get(alias))
            .unwrap_or(&default_cfg);
        return cfg.create_provider(alias, key, api_url, opts);
    }
    macro_rules! emit_dispatch {
        ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
            match family {
                "openai-compatible" | "openai_compatible" => {
                    let default_cfg = zeroclaw_config::schema::ModelProviderConfig::default();
                    let cfg = config
                        .and_then(|c| c.providers.models.find("openai", alias))
                        .unwrap_or(&default_cfg);
                    cfg.create_provider(alias, key, api_url, opts)
                }
                $(
                    $type_str => {
                        let default_cfg: $cfg_ty;
                        let cfg: &$cfg_ty = match config.and_then(|c| c.providers.models.$field.get(alias)) {
                            Some(c) => c,
                            None => {
                                default_cfg = <$cfg_ty>::default();
                                &default_cfg
                            }
                        };
                        cfg.create_provider(alias, key, api_url, opts)
                    }
                )+
                _ => {
                    ::zeroclaw_log::record!(
                        ERROR,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({"family": family})),
                        "factory: unknown model_provider family"
                    );
                    Err(anyhow::Error::msg(format!(
                        "Unknown model_provider family: {family}. After the V2 to typed-family migration, \
                         only canonical family names are valid. Run `zeroclaw quickstart` to reconfigure, \
                         or set `[providers.models.custom.<alias>] uri = \"https://your-api.com\"` for \
                         OpenAI-compatible custom endpoints."
                    )))
                },
            }
        }
    }
    zeroclaw_config::for_each_model_provider_slot!(emit_dispatch)
}

pub(crate) fn fallback_auth_ready_for_alias(
    config: &zeroclaw_config::schema::Config,
    family: &str,
    alias: &str,
    key: Option<&str>,
    opts: &ModelProviderRuntimeOptions,
) -> bool {
    let provider_kind = opts
        .provider_kind
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or(family);

    // openai missing-entry fallback: keep construction symmetric with
    // `dispatch_family_factory`. `wire_api` does not influence auth-readiness,
    // but constructing the same chat-anchored fallback avoids any future
    // divergence if that changes.
    if provider_kind == "openai" {
        let default_cfg = openai_missing_entry_fallback_config();
        let cfg = config
            .providers
            .models
            .openai
            .get(alias)
            .unwrap_or(&default_cfg);
        return cfg.fallback_auth_ready(key, opts);
    }

    macro_rules! emit_auth_ready {
        ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
            match provider_kind {
                "openai-compatible" | "openai_compatible" => {
                    let default_cfg = zeroclaw_config::schema::ModelProviderConfig::default();
                    let cfg = config
                        .providers
                        .models
                        .find("openai", alias)
                        .unwrap_or(&default_cfg);
                    cfg.fallback_auth_ready(key, opts)
                }
                $(
                    $type_str => {
                        let default_cfg: $cfg_ty;
                        let cfg: &$cfg_ty = match config.providers.models.$field.get(alias) {
                            Some(c) => c,
                            None => {
                                default_cfg = <$cfg_ty>::default();
                                &default_cfg
                            }
                        };
                        cfg.fallback_auth_ready(key, opts)
                    }
                )+
                _ => has_api_key(key),
            }
        }
    }
    zeroclaw_config::for_each_model_provider_slot!(emit_auth_ready)
}

use zeroclaw_config::schema::{
    Ai21ModelProviderConfig, AihubmixModelProviderConfig, AnthropicModelProviderConfig,
    AnyscaleModelProviderConfig, ArceeModelProviderConfig, AstraiModelProviderConfig,
    AtomicChatModelProviderConfig, AuthMode, AvianModelProviderConfig, AzureModelProviderConfig,
    BaichuanModelProviderConfig, BasetenModelProviderConfig, BedrockModelProviderConfig,
    CerebrasModelProviderConfig, CloudflareModelProviderConfig, CohereModelProviderConfig,
    CopilotModelProviderConfig, CustomModelProviderConfig, DeepinfraModelProviderConfig,
    DeepmystModelProviderConfig, DeepseekModelProviderConfig, DoubaoModelProviderConfig,
    FeatherlessModelProviderConfig, FireworksModelProviderConfig, FriendliModelProviderConfig,
    GeminiCliModelProviderConfig, GeminiModelProviderConfig, GithubModelsModelProviderConfig,
    GlmModelProviderConfig, GroqModelProviderConfig, HuggingfaceModelProviderConfig,
    HunyuanModelProviderConfig, HyperbolicModelProviderConfig, InceptionModelProviderConfig,
    KiloCliModelProviderConfig, KiloModelProviderConfig, LambdaAiModelProviderConfig,
    LeptonModelProviderConfig, LitellmModelProviderConfig, LlamacppModelProviderConfig,
    LmstudioModelProviderConfig, ManifestModelProviderConfig, MinimaxModelProviderConfig,
    MistralModelProviderConfig, MoonshotEndpoint, MoonshotModelProviderConfig,
    MorphModelProviderConfig, NearaiModelProviderConfig, NebiusModelProviderConfig,
    NovitaModelProviderConfig, NscaleModelProviderConfig, NvidiaModelProviderConfig,
    OllamaModelProviderConfig, OpenAIModelProviderConfig, OpenRouterModelProviderConfig,
    OpencodeModelProviderConfig, OsaurusModelProviderConfig, OvhModelProviderConfig,
    PerplexityModelProviderConfig, QianfanModelProviderConfig, QwenModelProviderConfig,
    RekaModelProviderConfig, SambanovaModelProviderConfig, SglangModelProviderConfig,
    SiliconflowModelProviderConfig, StepfunModelProviderConfig, SyntheticModelProviderConfig,
    TelnyxModelProviderConfig, TogetherModelProviderConfig, UpstageModelProviderConfig,
    VeniceModelProviderConfig, VercelModelProviderConfig, VllmModelProviderConfig,
    XaiModelProviderConfig, YiModelProviderConfig, ZaiModelProviderConfig,
};

/// Get the default API URL for a provider type (matches CompatFamilySpec::DEFAULT_URL).
/// Returns None if the provider type is not an OpenAI-compatible family with a DEFAULT_URL const.
pub fn get_default_url(provider_type: &str) -> Option<&'static str> {
    Some(match provider_type {
        "groq" => "https://api.groq.com/openai/v1",
        "together" => <TogetherModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
        "fireworks" => <FireworksModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
        "deepinfra" => <DeepinfraModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
        "hyperbolic" => <HyperbolicModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
        "anyscale" => <AnyscaleModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
        "novita" => <NovitaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
        "nebius" => <NebiusModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
        "nvidia" => <NvidiaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
        _ => return None,
    })
}

// ── Pure-compat families ───────────────────────────────────────────────
// `OpenAiCompatibleModelProvider::builder(alias).display_name(DISPLAY).base_url(DEFAULT_URL).credential(key).auth_style(AUTH).build()` —
// no modifiers, no per-alias logic. The blanket impl supplies
// `FamilyProviderFactory` automatically.

impl CompatFamilySpec for VercelModelProviderConfig {
    const DISPLAY: &'static str = "Vercel AI Gateway";
    const DEFAULT_URL: &'static str = crate::VERCEL_AI_GATEWAY_BASE_URL;
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("vercel");
}
impl CompatFamilySpec for CloudflareModelProviderConfig {
    const DISPLAY: &'static str = "Cloudflare AI Gateway";
    const DEFAULT_URL: &'static str = "https://gateway.ai.cloudflare.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("cloudflare-ai-gateway");
}
impl CompatFamilySpec for SyntheticModelProviderConfig {
    const DISPLAY: &'static str = "Synthetic";
    const DEFAULT_URL: &'static str = "https://api.synthetic.new/openai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("synthetic");
}
impl CompatFamilySpec for OpencodeModelProviderConfig {
    const DISPLAY: &'static str = "OpenCode Zen";
    const DEFAULT_URL: &'static str = "https://opencode.ai/zen/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("opencode");

    fn wire_api(&self) -> Option<zeroclaw_config::schema::WireApi> {
        self.base.wire_api
    }
}
impl CompatFamilySpec for DoubaoModelProviderConfig {
    const DISPLAY: &'static str = "Doubao";
    const DEFAULT_URL: &'static str = "https://ark.cn-beijing.volces.com/api/v3";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("bytedance");
}
impl CompatFamilySpec for MistralModelProviderConfig {
    const DISPLAY: &'static str = "Mistral";
    const DEFAULT_URL: &'static str = "https://api.mistral.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("mistral");
}
impl CompatFamilySpec for DeepseekModelProviderConfig {
    const DISPLAY: &'static str = "DeepSeek";
    const DEFAULT_URL: &'static str = "https://api.deepseek.com";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("deepseek");
}
impl CompatFamilySpec for TogetherModelProviderConfig {
    const DISPLAY: &'static str = "Together AI";
    const DEFAULT_URL: &'static str = "https://api.together.xyz";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("togetherai");
}
impl CompatFamilySpec for FireworksModelProviderConfig {
    const DISPLAY: &'static str = "Fireworks AI";
    const DEFAULT_URL: &'static str = "https://api.fireworks.ai/inference/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("fireworks-ai");
}
impl CompatFamilySpec for NovitaModelProviderConfig {
    const DISPLAY: &'static str = "Novita AI";
    const DEFAULT_URL: &'static str = "https://api.novita.ai/openai";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("novita-ai");
}
impl CompatFamilySpec for PerplexityModelProviderConfig {
    const DISPLAY: &'static str = "Perplexity";
    const DEFAULT_URL: &'static str = "https://api.perplexity.ai";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("perplexity");
}
impl CompatFamilySpec for CohereModelProviderConfig {
    const DISPLAY: &'static str = "Cohere";
    const DEFAULT_URL: &'static str = "https://api.cohere.com/compatibility";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("cohere");
}
impl CompatFamilySpec for SglangModelProviderConfig {
    const DISPLAY: &'static str = "SGLang";
    const DEFAULT_URL: &'static str = "http://localhost:30000/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const FALLBACK_ALLOWS_MISSING_API_KEY: bool = true;
}
impl CompatFamilySpec for VllmModelProviderConfig {
    const DISPLAY: &'static str = "vLLM";
    const DEFAULT_URL: &'static str = "http://localhost:8000/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const FALLBACK_ALLOWS_MISSING_API_KEY: bool = true;
}
impl CompatFamilySpec for AstraiModelProviderConfig {
    const DISPLAY: &'static str = "Astrai";
    const DEFAULT_URL: &'static str = "https://as-trai.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for SiliconflowModelProviderConfig {
    const DISPLAY: &'static str = "SiliconFlow";
    const DEFAULT_URL: &'static str = "https://api.siliconflow.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("siliconflow");
}
impl CompatFamilySpec for AihubmixModelProviderConfig {
    const DISPLAY: &'static str = "AiHubMix";
    const DEFAULT_URL: &'static str = "https://aihubmix.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("aihubmix");
}
impl CompatFamilySpec for LitellmModelProviderConfig {
    const DISPLAY: &'static str = "LiteLLM";
    const DEFAULT_URL: &'static str = "http://localhost:4000/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const FALLBACK_ALLOWS_MISSING_API_KEY: bool = true;
}
impl CompatFamilySpec for CerebrasModelProviderConfig {
    const DISPLAY: &'static str = "Cerebras";
    const DEFAULT_URL: &'static str = "https://api.cerebras.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("cerebras");
}
impl CompatFamilySpec for SambanovaModelProviderConfig {
    const DISPLAY: &'static str = "SambaNova";
    const DEFAULT_URL: &'static str = "https://api.sambanova.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    // No models.dev entry and no OpenRouter prefix — operator must paste a
    // credential before `list_models` returns anything.
}
impl CompatFamilySpec for HyperbolicModelProviderConfig {
    const DISPLAY: &'static str = "Hyperbolic";
    const DEFAULT_URL: &'static str = "https://api.hyperbolic.xyz/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    // No models.dev entry and no OpenRouter prefix — operator must paste a
    // credential before `list_models` returns anything.
}
impl CompatFamilySpec for DeepinfraModelProviderConfig {
    const DISPLAY: &'static str = "DeepInfra";
    const DEFAULT_URL: &'static str = "https://api.deepinfra.com/v1/openai";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("deepinfra");
}
impl CompatFamilySpec for HuggingfaceModelProviderConfig {
    const DISPLAY: &'static str = "Hugging Face";
    const DEFAULT_URL: &'static str = "https://router.huggingface.co/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("huggingface");
}
impl CompatFamilySpec for Ai21ModelProviderConfig {
    const DISPLAY: &'static str = "AI21 Labs";
    const DEFAULT_URL: &'static str = "https://api.ai21.com/studio/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("ai21");
}
impl CompatFamilySpec for RekaModelProviderConfig {
    const DISPLAY: &'static str = "Reka";
    const DEFAULT_URL: &'static str = "https://api.reka.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("rekaai");
}
impl CompatFamilySpec for BasetenModelProviderConfig {
    const DISPLAY: &'static str = "Baseten";
    const DEFAULT_URL: &'static str = "https://inference.baseten.co/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("baseten");
}
impl CompatFamilySpec for NscaleModelProviderConfig {
    const DISPLAY: &'static str = "Nscale";
    const DEFAULT_URL: &'static str = "https://inference.api.nscale.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for AnyscaleModelProviderConfig {
    const DISPLAY: &'static str = "Anyscale";
    const DEFAULT_URL: &'static str = "https://api.endpoints.anyscale.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for NebiusModelProviderConfig {
    const DISPLAY: &'static str = "Nebius Token Factory";
    const DEFAULT_URL: &'static str = "https://api.tokenfactory.nebius.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("nebius");
}
impl CompatFamilySpec for FriendliModelProviderConfig {
    const DISPLAY: &'static str = "Friendli AI";
    const DEFAULT_URL: &'static str = "https://api.friendli.ai/serverless/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("friendli");
}
impl CompatFamilySpec for LeptonModelProviderConfig {
    const DISPLAY: &'static str = "Lepton AI";
    const DEFAULT_URL: &'static str = "https://llama3-1-405b.lepton.run/api/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for ManifestModelProviderConfig {
    const DISPLAY: &'static str = "Manifest";
    const DEFAULT_URL: &'static str = "https://app.manifest.build/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for MorphModelProviderConfig {
    const DISPLAY: &'static str = "Morph";
    const DEFAULT_URL: &'static str = "https://api.morphllm.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for GithubModelsModelProviderConfig {
    const DISPLAY: &'static str = "GitHub Models";
    const DEFAULT_URL: &'static str = "https://models.github.ai/inference";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for UpstageModelProviderConfig {
    const DISPLAY: &'static str = "Upstage Solar";
    // Current canonical base; the legacy `/v1/solar` path is deprecated.
    const DEFAULT_URL: &'static str = "https://api.upstage.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for FeatherlessModelProviderConfig {
    const DISPLAY: &'static str = "Featherless AI";
    const DEFAULT_URL: &'static str = "https://api.featherless.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for ArceeModelProviderConfig {
    const DISPLAY: &'static str = "Arcee AI";
    // Arcee's OpenAI-compatible API lives at `/api/v1`, not the usual `/v1`.
    const DEFAULT_URL: &'static str = "https://api.arcee.ai/api/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for LambdaAiModelProviderConfig {
    const DISPLAY: &'static str = "Lambda AI";
    const DEFAULT_URL: &'static str = "https://api.lambda.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for InceptionModelProviderConfig {
    const DISPLAY: &'static str = "Inception Labs";
    const DEFAULT_URL: &'static str = "https://api.inceptionlabs.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for StepfunModelProviderConfig {
    const DISPLAY: &'static str = "Stepfun";
    const DEFAULT_URL: &'static str = "https://api.stepfun.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("stepfun");
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("stepfun");
}
impl CompatFamilySpec for BaichuanModelProviderConfig {
    const DISPLAY: &'static str = "Baichuan";
    const DEFAULT_URL: &'static str = "https://api.baichuan-ai.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for YiModelProviderConfig {
    const DISPLAY: &'static str = "01.AI (Yi)";
    const DEFAULT_URL: &'static str = "https://api.lingyiwanwu.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for HunyuanModelProviderConfig {
    const DISPLAY: &'static str = "Tencent Hunyuan";
    const DEFAULT_URL: &'static str = "https://api.hunyuan.cloud.tencent.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("tencent");
}
impl CompatFamilySpec for AvianModelProviderConfig {
    const DISPLAY: &'static str = "Avian";
    const DEFAULT_URL: &'static str = "https://api.avian.io/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for DeepmystModelProviderConfig {
    const DISPLAY: &'static str = "DeepMyst";
    const DEFAULT_URL: &'static str = "https://api.deepmyst.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for MoonshotModelProviderConfig {
    const DISPLAY: &'static str = "Moonshot";
    const DEFAULT_URL: &'static str = crate::MOONSHOT_INTL_BASE_URL;
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("moonshotai");

    fn build_compat(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
    ) -> crate::compatible::OpenAiCompatibleBuilder {
        let base_url = api_url.unwrap_or(Self::DEFAULT_URL);
        if self.endpoint == MoonshotEndpoint::Code || base_url == crate::moonshot_code_base_url() {
            return build_kimi_code_compat(alias, key, base_url);
        }

        self.build_compat_base(alias, key, api_url)
    }
}

// ── Compat families with build_compat overrides ────────────────────────
// Need a constructor variant or modifier chain that can't be expressed
// purely through (DISPLAY, DEFAULT_URL, AUTH).

impl CompatFamilySpec for VeniceModelProviderConfig {
    const DISPLAY: &'static str = "Venice";
    const DEFAULT_URL: &'static str = "https://api.venice.ai";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("venice");
    fn build_compat(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
    ) -> crate::compatible::OpenAiCompatibleBuilder {
        self.build_compat_base(alias, key, api_url)
            .without_native_tools()
    }
}
impl CompatFamilySpec for NearaiModelProviderConfig {
    const DISPLAY: &'static str = "NEAR AI Cloud";
    const DEFAULT_URL: &'static str = "https://cloud-api.near.ai/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
}
impl CompatFamilySpec for AtomicChatModelProviderConfig {
    const DISPLAY: &'static str = "Atomic Chat";
    /// Default endpoint for the Jan / Atomic Chat local OpenAI-compatible
    /// runtime (`jan.ai`). Operators override via `api_url` on the alias
    /// entry when they run it on a non-default port.
    const DEFAULT_URL: &'static str = "http://127.0.0.1:1337/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const FALLBACK_ALLOWS_MISSING_API_KEY: bool = true;
    const MODELS_DEV_KEY: Option<&'static str> = Some("atomic-chat");
    fn build_compat(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
    ) -> crate::compatible::OpenAiCompatibleBuilder {
        self.build_compat_base(alias, key, api_url)
            .without_native_tools()
    }
}

impl FamilyProviderFactory for XaiModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        if let Some(p) = build_responses_provider_if_requested(
            self.base.wire_api,
            alias,
            api_url.or(Some("https://api.x.ai/v1")),
            key,
            opts,
        ) {
            return Ok(p);
        }

        let mut b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name("xAI")
            .base_url(api_url.unwrap_or("https://api.x.ai/v1"))
            .credential(key)
            .auth_style(AuthStyle::Bearer)
            .models_dev_key("xai");

        if !has_api_key(key) {
            let state_dir = opts.zeroclaw_dir.clone().unwrap_or_else(|| {
                directories::UserDirs::new().map_or_else(
                    || std::path::PathBuf::from(".zeroclaw"),
                    |dirs| dirs.home_dir().join(".zeroclaw"),
                )
            });
            let auth_service = crate::auth::AuthService::new(&state_dir, opts.secrets_encrypt);
            b = b.auth_profile("xai", auth_service, opts.auth_profile_override.clone());
        }

        Ok(apply_compat_options(b, opts))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for MinimaxModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let refreshed_key: Option<String> = self
            .oauth_refresh_token
            .as_deref()
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|refresh_token| {
                let client_id = self
                    .oauth_client_id
                    .as_deref()
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
                    .unwrap_or(crate::MINIMAX_OAUTH_DEFAULT_CLIENT_ID);
                crate::refresh_minimax_oauth_access_token(refresh_token, client_id, self.endpoint)
            })
            .transpose()?;
        let resolved_key = refreshed_key.as_deref().or(key);
        let b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name("MiniMax")
            .base_url(api_url.unwrap_or(crate::MINIMAX_INTL_BASE_URL))
            .credential(resolved_key)
            .auth_style(AuthStyle::Bearer)
            .merge_system_into_user_preserving_native();
        Ok(apply_compat_options(b, opts))
    }

    fn fallback_auth_ready(&self, key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        has_api_key(key) || has_non_empty_value(self.oauth_refresh_token.as_deref())
    }
}

impl CompatFamilySpec for ZaiModelProviderConfig {
    const DISPLAY: &'static str = "Z.AI";
    const DEFAULT_URL: &'static str = crate::ZAI_GLOBAL_BASE_URL;
    const AUTH: AuthStyle = AuthStyle::ZhipuJwt;
    const MODELS_DEV_KEY: Option<&'static str> = Some("zai");
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("z-ai");
}

impl CompatFamilySpec for GlmModelProviderConfig {
    const DISPLAY: &'static str = "GLM";
    const DEFAULT_URL: &'static str = crate::GLM_GLOBAL_BASE_URL;
    const AUTH: AuthStyle = AuthStyle::ZhipuJwt;
    const MODELS_DEV_KEY: Option<&'static str> = Some("zhipuai");
    fn build_compat(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
    ) -> crate::compatible::OpenAiCompatibleBuilder {
        // GLM exposes vision-capable models (e.g. `glm-4.5v`). Compose the
        // catalog-conf'd base with vision flag override via the constructor
        // variant; we replay both consts manually since this constructor
        // path doesn't fold through `build_compat_base`.
        let mut b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name(Self::DISPLAY)
            .base_url(api_url.unwrap_or(Self::DEFAULT_URL))
            .credential(key)
            .auth_style(Self::AUTH)
            .vision(true);
        if let Some(catalog_key) = Self::MODELS_DEV_KEY {
            b = b.models_dev_key(catalog_key);
        }
        if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
            b = b.openrouter_vendor_prefix(prefix);
        }
        b
    }
}

impl CompatFamilySpec for NvidiaModelProviderConfig {
    const DISPLAY: &'static str = "NVIDIA NIM";
    const DEFAULT_URL: &'static str = "https://integrate.api.nvidia.com/v1";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const MODELS_DEV_KEY: Option<&'static str> = Some("nvidia");
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("nvidia");

    fn build_compat(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
    ) -> crate::compatible::OpenAiCompatibleBuilder {
        // NVIDIA NIM exposes vision-capable models (e.g. `nvidia/llama-3.2-90av`,
        // `google/deepseek-r1`). Compose the catalog-conf'd base with vision flag
        // override via the constructor variant; we replay both consts manually
        // since this constructor path doesn't fold through `build_compat_base`.
        let mut b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name(Self::DISPLAY)
            .base_url(api_url.unwrap_or(Self::DEFAULT_URL))
            .credential(key)
            .auth_style(Self::AUTH)
            .vision(true);
        if let Some(catalog_key) = Self::MODELS_DEV_KEY {
            b = b.models_dev_key(catalog_key);
        }
        if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
            b = b.openrouter_vendor_prefix(prefix);
        }
        b
    }
}

impl CompatFamilySpec for QianfanModelProviderConfig {
    const DISPLAY: &'static str = "Qianfan";
    // Default is meaningless — `build_compat` always computes via
    // `qianfan_base_url(api_url)`. Use the helper's default fallback as
    // a placeholder.
    const DEFAULT_URL: &'static str = "https://qianfan.baidubce.com/v2";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("baidu");
    fn build_compat(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
    ) -> crate::compatible::OpenAiCompatibleBuilder {
        let base_url = crate::qianfan_base_url(api_url);
        let computed_url = Some(base_url.as_str());
        self.build_compat_base(alias, key, computed_url)
    }
}

// ── Bespoke families ───────────────────────────────────────────────────
// Construction reads typed alias fields directly off `&self`, or wraps a
// non-compat runtime provider, or routes through extra runtime context
// (auth services, key fallback defaults, conditional native_tools, …).

impl FamilyProviderFactory for OpenRouterModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        _api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let mut b = crate::openrouter::OpenRouterModelProvider::builder(alias)
            .credential(key)
            .max_tokens(opts.provider_max_tokens);
        if let Some(t) = opts.provider_timeout_secs {
            b = b.timeout_secs(t);
        }
        if let Some(extra) = opts.provider_extra.clone() {
            b = b.extra_body(extra);
        }
        Ok(Box::new(b.build()))
    }
}

impl FamilyProviderFactory for AnthropicModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let mut b = crate::anthropic::AnthropicModelProvider::builder(alias).credential(key);
        if let Some(url) = api_url {
            b = b.base_url(url);
        }
        if let Some(mt) = opts.provider_max_tokens {
            b = b.max_tokens(mt);
        }
        if let Some(ts) = opts.provider_timeout_secs {
            b = b.timeout_secs(ts);
        }
        Ok(Box::new(b.build()))
    }
}

/// Config used when the `openai` family is dispatched with **no persisted
/// entry** — a bare `model_provider = "openai"` reference or a dotted ref to a
/// nonexistent alias.
///
/// This deliberately differs from [`OpenAIModelProviderConfig::default`], which
/// selects the responses wire for *new persisted slots* created via
/// `create_map_key` / `ensure`. Implicit dispatch must instead leave `wire_api`
/// unset so it resolves to the historical chat-completions wire; otherwise an
/// existing install with a bare `openai` ref would silently flip both its wire
/// protocol and its tool-calling mode on upgrade, contradicting the
/// backward-compatibility guarantee. Persisted entries are never routed through
/// this helper — they keep whatever `wire_api` they stored.
fn openai_missing_entry_fallback_config() -> OpenAIModelProviderConfig {
    OpenAIModelProviderConfig {
        base: zeroclaw_config::schema::ModelProviderConfig {
            wire_api: None,
            ..zeroclaw_config::schema::ModelProviderConfig::default()
        },
    }
}

impl FamilyProviderFactory for OpenAIModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        // Codex variant routing: OAuth subscription auth → Codex responses protocol.
        if self.base.requires_openai_auth {
            return Ok(Box::new(
                crate::openai_codex::OpenAiCodexModelProvider::new(alias, opts, key)?,
            ));
        }
        // Responses wire protocol with standard API key — full streaming tool calls.
        // New OpenAI provider slots default wire_api to Responses via
        // OpenAIModelProviderConfig::default() (persisted-slot creation). Bare/
        // dangling dispatch uses openai_missing_entry_fallback_config() and stays
        // on the chat wire; unset/legacy configs also fall through below.
        if let Some(p) =
            build_responses_provider_if_requested(self.base.wire_api, alias, api_url, key, opts)
        {
            return Ok(p);
        }
        // Fallback: chat_completions wire (explicit opt-in or legacy unset configs).
        let mut b = crate::openai::OpenAiModelProvider::builder(alias).credential(key);
        if let Some(url) = api_url {
            b = b.base_url(url);
        }
        if let Some(t) = opts.provider_timeout_secs {
            b = b.timeout_secs(t);
        }
        if let Some(mt) = opts.provider_max_tokens {
            b = b.max_tokens(Some(mt));
        }
        Ok(Box::new(b.build()))
    }

    fn fallback_auth_ready(&self, key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        has_api_key(key) || self.base.requires_openai_auth
    }
}

fn normalize_ollama_compat_base_url(api_url: Option<&str>) -> String {
    let raw = api_url
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("http://localhost:11434/v1");

    let Ok(mut url) = reqwest::Url::parse(raw) else {
        return raw.trim_end_matches('/').to_string();
    };

    let path = url.path().trim_end_matches('/');
    if path.is_empty() || matches!(path, "/" | "/api" | "/api/chat") {
        url.set_path("/v1");
        return url.to_string().trim_end_matches('/').to_string();
    }

    raw.trim_end_matches('/').to_string()
}

fn build_ollama_compat_provider(
    alias: &str,
    key: Option<&str>,
    api_url: Option<&str>,
    opts: &ModelProviderRuntimeOptions,
) -> crate::compatible::OpenAiCompatibleBuilder {
    let base_url = normalize_ollama_compat_base_url(api_url);
    let ollama_key = key.map(str::trim).filter(|value| !value.is_empty());
    let mut b = OpenAiCompatibleModelProvider::builder(alias)
        .display_name("Ollama")
        .base_url(&base_url)
        .credential(ollama_key)
        .auth_style(AuthStyle::Bearer)
        .vision(true)
        .local_model_tool_sanitize()
        .public_model_listing();
    if opts.merge_system_into_user {
        b = b.merge_system_into_user_preserving_native();
    }
    b
}

impl FamilyProviderFactory for OllamaModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        Ok(apply_compat_options(
            build_ollama_compat_provider(alias, key, api_url, opts),
            opts,
        ))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for GeminiModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        _api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let state_dir = opts.zeroclaw_dir.clone().unwrap_or_else(|| {
            directories::UserDirs::new().map_or_else(
                || std::path::PathBuf::from(".zeroclaw"),
                |dirs| dirs.home_dir().join(".zeroclaw"),
            )
        });
        let auth_service = crate::auth::AuthService::new(&state_dir, opts.secrets_encrypt);
        Ok(Box::new(
            crate::gemini::GeminiModelProvider::builder(alias)
                .api_key(key)
                .managed_auth(auth_service, opts.auth_profile_override.clone())
                .oauth_project_seed(self.oauth_project.clone())
                .oauth_client(
                    self.oauth_client_id.clone(),
                    self.oauth_client_secret.clone(),
                )
                .build(),
        ))
    }

    fn fallback_auth_ready(&self, key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        has_api_key(key) || matches!(self.auth_mode, Some(AuthMode::OAuth))
    }
}

impl FamilyProviderFactory for TelnyxModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        _api_url: Option<&str>,
        _opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        Ok(Box::new(
            crate::telnyx::TelnyxModelProvider::builder(alias)
                .api_key(key)
                .build(),
        ))
    }
}

impl FamilyProviderFactory for AzureModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        _api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        // Reads typed Azure alias fields directly. Operator sets these
        // under `[providers.models.azure.<alias>]` or via the schema-mirror
        // env grammar — env-var fallback eradicated.
        let resource = self.resource.as_deref().ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "family": "azure",
                        "alias": alias,
                        "missing": "resource",
                    })),
                "factory: azure provider missing resource"
            );
            anyhow::Error::msg(
                "Azure model_provider requires `resource`: set \
                 `[providers.models.azure.<alias>] resource = \"...\"` in config.toml.",
            )
        })?;
        let deployment = self.deployment.as_deref().ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "family": "azure",
                        "alias": alias,
                        "missing": "deployment",
                    })),
                "factory: azure provider missing deployment"
            );
            anyhow::Error::msg(
                "Azure model_provider requires `deployment`: set \
                 `[providers.models.azure.<alias>] deployment = \"...\"` in config.toml.",
            )
        })?;
        let api_version = self.api_version.as_deref();
        Ok(Box::new(
            crate::azure_openai::AzureOpenAiModelProvider::builder(alias)
                .resource_name(resource)
                .deployment_name(deployment)
                .credential(key)
                .api_version(api_version)
                .reasoning_effort(opts.reasoning_effort.clone())
                .build(),
        ))
    }
}

impl FamilyProviderFactory for BedrockModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        _api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let builder = crate::bedrock::BedrockModelProvider::builder(alias);
        // Explicit-key path stays no-probe — do not run BEDROCK_API_KEY /
        // AwsCredentials::from_env / from_credential_process (which can
        // spawn a shell command) when the operator has already resolved
        // a Bedrock API key from config.
        let builder = if let Some(api_key) = key {
            builder.bearer_token(api_key)
        } else {
            builder
        };
        let builder = if let Some(mt) = opts.provider_max_tokens {
            builder.max_tokens(mt)
        } else {
            builder
        };
        Ok(Box::new(builder.build()))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for QwenModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let alias_oauth: Option<crate::QwenOauthCredentials> = self
            .oauth_refresh_token
            .as_deref()
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|refresh_token| {
                let client_id = self
                    .oauth_client_id
                    .as_deref()
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
                    .unwrap_or(crate::QWEN_OAUTH_DEFAULT_CLIENT_ID);
                crate::refresh_qwen_oauth_access_token(refresh_token, client_id)
            })
            .transpose()?;

        let oauth_context = if let Some(creds) = alias_oauth.as_ref() {
            // Synthesize a context using the freshly-refreshed alias
            // credentials, applying the operator's `oauth_resource_url`
            // override if any.
            crate::QwenOauthProviderContext {
                credential: creds.access_token.clone(),
                base_url: self
                    .oauth_resource_url
                    .as_deref()
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
                    .map(ToString::to_string)
                    .or_else(|| creds.resource_url.clone()),
            }
        } else {
            crate::resolve_qwen_oauth_context(key)
        };

        let resolved_key = oauth_context.credential.as_deref().or(key);
        let base_url = api_url
            .map(ToString::to_string)
            .or_else(|| oauth_context.base_url.clone())
            .unwrap_or_else(|| crate::QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
        let b = if oauth_context.credential.is_some() {
            OpenAiCompatibleModelProvider::builder(alias)
                .display_name("Qwen Code")
                .base_url(&base_url)
                .credential(resolved_key)
                .auth_style(AuthStyle::Bearer)
                .user_agent("QwenCode/1.0")
                .vision(true)
        } else {
            OpenAiCompatibleModelProvider::builder(alias)
                .display_name("Qwen")
                .base_url(&base_url)
                .credential(resolved_key)
                .auth_style(AuthStyle::Bearer)
                .vision(true)
        }
        .models_dev_key("alibaba")
        .openrouter_vendor_prefix("qwen");
        Ok(apply_compat_options(b, opts))
    }

    fn fallback_auth_ready(&self, key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        has_api_key(key)
            || has_non_empty_value(self.oauth_refresh_token.as_deref())
            || matches!(self.auth_mode, Some(AuthMode::OAuth))
    }
}

impl FamilyProviderFactory for GroqModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        _api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let mut b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name("Groq")
            .base_url("https://api.groq.com/openai/v1")
            .credential(key)
            .auth_style(AuthStyle::Bearer)
            .models_dev_key("groq")
            .without_assistant_reasoning_replay();
        // Groq's llama-family models reject native tool calls with HTTP
        // 400; default to text-fallback. Operators can override per-alias
        // via `[providers.models.groq.<alias>] native_tools = true`.
        if opts.native_tools != Some(true) {
            b = b.without_native_tools();
        }
        Ok(apply_compat_options(b, opts))
    }
}

impl FamilyProviderFactory for CopilotModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        _api_url: Option<&str>,
        _opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        Ok(Box::new(
            crate::copilot::CopilotModelProvider::builder(alias)
                .github_token(key)
                .build(),
        ))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for GeminiCliModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        _key: Option<&str>,
        _api_url: Option<&str>,
        _opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        Ok(Box::new(
            crate::gemini_cli::GeminiCliModelProvider::builder(alias)
                .binary_path(self.binary_path.as_deref())
                .build(),
        ))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for KiloCliModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        _key: Option<&str>,
        _api_url: Option<&str>,
        _opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        Ok(Box::new(
            crate::kilocli::KiloCliModelProvider::builder(alias)
                .binary_path(self.binary_path.as_deref())
                .build(),
        ))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

// ── Kilo AI Gateway (OpenAI-compatible) ────────────────────────────────

impl CompatFamilySpec for KiloModelProviderConfig {
    const DISPLAY: &'static str = "Kilo";
    // Canonical gateway host per https://kilo.ai/docs/gateway (api.kilo.ai;
    // app.kilo.ai is the web-app sign-in). Must stay in lockstep with
    // `KiloEndpoint::Gateway` in zeroclaw-config — see the
    // `kilo_gateway_default_url_matches_schema_endpoint` regression test.
    const DEFAULT_URL: &'static str = "https://api.kilo.ai/api/gateway";
    const AUTH: AuthStyle = AuthStyle::Bearer;
    const PUBLIC_MODEL_LISTING: bool = true;
}

impl FamilyProviderFactory for LmstudioModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let lm_studio_key = key
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("lm-studio");
        let b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name("LM Studio")
            .base_url(api_url.unwrap_or("http://localhost:1234/v1"))
            .credential(Some(lm_studio_key))
            .auth_style(AuthStyle::Bearer);
        Ok(apply_compat_options(b, opts))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for LlamacppModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let base_url = api_url.unwrap_or("http://localhost:8080/v1");
        let llama_cpp_key = key
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("llama.cpp");
        if let Some(p) = build_responses_provider_if_requested(
            self.base.wire_api,
            alias,
            Some(base_url),
            Some(llama_cpp_key),
            opts,
        ) {
            return Ok(p);
        }
        let mut b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name("llama.cpp")
            .base_url(base_url)
            .credential(Some(llama_cpp_key))
            .auth_style(AuthStyle::Bearer)
            .vision(true)
            .local_model_tool_sanitize();
        if opts.merge_system_into_user {
            b = b.merge_system_into_user_preserving_native();
        }
        Ok(apply_compat_options(b, opts))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for OsaurusModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let osaurus_key = key
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("osaurus");
        let b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name("Osaurus")
            .base_url(api_url.unwrap_or("http://localhost:1337/v1"))
            .credential(Some(osaurus_key))
            .auth_style(AuthStyle::Bearer);
        Ok(apply_compat_options(b, opts))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for OvhModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        _api_url: Option<&str>,
        _opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        Ok(Box::new(
            crate::openai::OpenAiModelProvider::builder(alias)
                .credential(key)
                .base_url("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1")
                .build(),
        ))
    }
}

impl FamilyProviderFactory for CustomModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let base_url = api_url.ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "family": "custom",
                        "alias": alias,
                        "missing": "uri",
                    })),
                "factory: custom provider missing uri"
            );
            anyhow::Error::msg(
                "Custom model_provider requires `uri`: set \
                 `[providers.models.custom.<alias>] uri = \"https://your-api.com\"` in config.toml.",
            )
        })?;
        if let Some(p) = build_responses_provider_if_requested(
            self.base.wire_api,
            alias,
            Some(base_url),
            key,
            opts,
        ) {
            return Ok(p);
        }
        let mut b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name("Custom")
            .base_url(base_url)
            .credential(key)
            .auth_style(AuthStyle::Bearer)
            .vision(true);
        if opts.native_tools != Some(true) {
            b = b.without_native_tools();
        }
        if opts.merge_system_into_user {
            b = b.merge_system_into_user_preserving_native();
        }
        Ok(apply_compat_options(b, opts))
    }

    fn fallback_auth_ready(&self, _key: Option<&str>, _opts: &ModelProviderRuntimeOptions) -> bool {
        true
    }
}

impl FamilyProviderFactory for zeroclaw_config::schema::ModelProviderConfig {
    fn create_provider(
        &self,
        alias: &str,
        key: Option<&str>,
        api_url: Option<&str>,
        opts: &ModelProviderRuntimeOptions,
    ) -> Result<Box<dyn ModelProvider>> {
        let base_url = api_url.ok_or_else(|| {
            anyhow::Error::msg(
                "OpenAI-compatible model_provider requires `uri`: set \
                 `[providers.models.<family>.<alias>] uri = \"https://your-api.com\"` in config.toml.",
            )
        })?;
        if let Some(p) =
            build_responses_provider_if_requested(self.wire_api, alias, Some(base_url), key, opts)
        {
            return Ok(p);
        }
        let mut b = OpenAiCompatibleModelProvider::builder(alias)
            .display_name("OpenAI Compatible")
            .base_url(base_url)
            .credential(key)
            .auth_style(AuthStyle::Bearer)
            .vision(true);
        if opts.merge_system_into_user {
            b = b.merge_system_into_user_preserving_native();
        }
        Ok(apply_compat_options(b, opts))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use zeroclaw_config::schema::{ModelProviderConfig, WireApi};

    #[test]
    fn kilo_gateway_default_url_matches_schema_endpoint() {
        use zeroclaw_config::schema::{KiloEndpoint, ModelEndpoint};
        assert_eq!(
            <KiloModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
            KiloEndpoint::default().uri(),
            "schema KiloEndpoint and factory DEFAULT_URL disagree on the Kilo Gateway URL"
        );
        assert_eq!(
            KiloEndpoint::default().uri(),
            "https://api.kilo.ai/api/gateway"
        );
    }

    #[test]
    fn openai_factory_routes_to_codex_when_requires_openai_auth_true() {
        let cfg = OpenAIModelProviderConfig {
            base: ModelProviderConfig {
                requires_openai_auth: true,
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider("test", None, None, &ModelProviderRuntimeOptions::default())
            .unwrap();
        // OpenAiCodexModelProvider reports native_tool_calling; standard OpenAiModelProvider does not
        assert!(provider.capabilities().native_tool_calling);
    }

    #[test]
    fn openai_factory_routes_to_standard_when_requires_openai_auth_false() {
        // Explicit chat_completions: OpenAIModelProviderConfig::default() now
        // selects the responses wire, so this regression pins the legacy path.
        let cfg = OpenAIModelProviderConfig {
            base: ModelProviderConfig {
                requires_openai_auth: false,
                wire_api: Some(WireApi::ChatCompletions),
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider("test", None, None, &ModelProviderRuntimeOptions::default())
            .unwrap();
        assert!(!provider.capabilities().native_tool_calling);
    }

    #[test]
    fn openai_factory_defaults_new_provider_to_responses_wire() {
        // Creating a new OpenAI provider slot (create_map_key / ensure) uses
        // OpenAIModelProviderConfig::default(), which must select responses.
        let provider = OpenAIModelProviderConfig::default()
            .create_provider(
                "test",
                Some("sk-test"),
                None,
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_eq!(provider.default_wire_api(), "responses");
        assert!(provider.capabilities().native_tool_calling);
    }

    #[test]
    fn openai_dispatch_missing_entry_stays_on_chat_wire() {
        // The responses default applies only to persisted slot creation. An implicit
        // dispatch with no config entry — a bare `model_provider = "openai"` ref or
        // a dangling alias —
        // must keep the historical chat-completions wire and prompt-guided tools,
        // so existing installs don't silently flip wire + tool-calling on upgrade.
        let provider = dispatch_family_factory(
            None,
            "openai",
            "default",
            Some("sk-test"),
            None,
            &ModelProviderRuntimeOptions::default(),
        )
        .expect("bare openai dispatch should build");
        assert_eq!(
            provider.default_wire_api(),
            "chat_completions",
            "missing-entry openai dispatch must not adopt the responses wire"
        );
        assert!(
            !provider.capabilities().native_tool_calling,
            "missing-entry openai dispatch must keep prompt-guided tool calling"
        );
    }

    #[test]
    fn openai_dispatch_persisted_entry_honors_responses_default() {
        // The counterpart to the missing-entry test: a persisted openai slot that
        // stored wire_api = responses (as create_map_key now writes) still routes
        // through the responses provider when dispatched.
        let mut config = zeroclaw_config::schema::Config::default();
        config.providers.models.openai.insert(
            "default".to_string(),
            OpenAIModelProviderConfig {
                base: ModelProviderConfig {
                    wire_api: Some(WireApi::Responses),
                    ..Default::default()
                },
            },
        );
        let provider = dispatch_family_factory(
            Some(&config),
            "openai",
            "default",
            Some("sk-test"),
            None,
            &ModelProviderRuntimeOptions::default(),
        )
        .expect("persisted openai dispatch should build");
        assert_eq!(provider.default_wire_api(), "responses");
        assert!(provider.capabilities().native_tool_calling);
    }

    #[tokio::test]
    async fn openai_factory_forwards_timeout_to_native_provider() {
        use axum::{Json, Router, routing::post};
        use serde_json::json;
        use tokio::time::{Duration, Instant};

        async fn slow_chat_completion() -> Json<serde_json::Value> {
            tokio::time::sleep(Duration::from_secs(3)).await;
            Json(json!({
                "choices": [{"message": {"content": "too late"}}]
            }))
        }

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind test server");
        let addr = listener.local_addr().expect("test server addr");
        let app = Router::new().route("/chat/completions", post(slow_chat_completion));
        let server = zeroclaw_spawn::spawn!(async move {
            axum::serve(listener, app).await.expect("serve test server");
        });

        let opts = ModelProviderRuntimeOptions {
            provider_timeout_secs: Some(1),
            ..Default::default()
        };
        // Pin the chat-completions path explicitly: OpenAI defaults to responses.
        let provider = OpenAIModelProviderConfig {
            base: ModelProviderConfig {
                wire_api: Some(WireApi::ChatCompletions),
                ..Default::default()
            },
        }
        .create_provider(
            "native",
            Some("test-key"),
            Some(&format!("http://{addr}")),
            &opts,
        )
        .expect("openai provider should build");

        let started = Instant::now();
        let result = provider
            .chat_with_system(None, "hello", "gpt-4o", Some(0.7))
            .await;
        let elapsed = started.elapsed();

        server.abort();

        assert!(
            result.is_err(),
            "slow response should time out when factory forwards provider_timeout_secs"
        );
        assert!(
            elapsed < Duration::from_secs(3),
            "request waited for the server response instead of using configured timeout: {elapsed:?}"
        );
    }

    #[tokio::test]
    async fn zai_and_glm_factory_path_honors_api_url_override() {
        use axum::{Json, Router, extract::State, http::Uri, routing::post};
        use serde_json::{Value, json};
        use std::sync::{Arc, Mutex};

        type Capture = Arc<Mutex<Vec<String>>>;

        async fn capture_chat_request(
            State(capture): State<Capture>,
            uri: Uri,
            Json(_body): Json<Value>,
        ) -> Json<Value> {
            capture
                .lock()
                .expect("capture lock poisoned")
                .push(uri.path().to_string());
            Json(json!({
                "choices": [{"message": {"content": "ok"}}]
            }))
        }

        let capture: Capture = Arc::new(Mutex::new(Vec::new()));
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind test server");
        let addr = listener.local_addr().expect("test server addr");
        let app = Router::new()
            .route(
                "/zai/api/paas/v4/chat/completions",
                post(capture_chat_request),
            )
            .route(
                "/glm/api/paas/v4/chat/completions",
                post(capture_chat_request),
            )
            .with_state(capture.clone());
        let server = zeroclaw_spawn::spawn!(async move {
            axum::serve(listener, app).await.expect("serve test server");
        });

        let base_url = format!("http://{addr}");
        let zai_url = format!("{base_url}/zai/api/paas/v4");
        let zai = ZaiModelProviderConfig::default()
            .create_provider(
                "cn",
                Some("id.secret"),
                Some(&zai_url),
                &ModelProviderRuntimeOptions::default(),
            )
            .expect("zai provider should build");
        assert_eq!(
            zai.chat_with_system(None, "hello", "glm-5-turbo", Some(0.7))
                .await
                .expect("zai chat should use overridden URL"),
            "ok"
        );

        let glm_url = format!("{base_url}/glm/api/paas/v4");
        let glm = GlmModelProviderConfig::default()
            .create_provider(
                "global",
                Some("id.secret"),
                Some(&glm_url),
                &ModelProviderRuntimeOptions::default(),
            )
            .expect("glm provider should build");
        assert!(glm.capabilities().vision);
        assert_eq!(
            glm.chat_with_system(None, "hello", "glm-4.5", Some(0.7))
                .await
                .expect("glm chat should use overridden URL"),
            "ok"
        );

        let paths = capture.lock().expect("capture lock poisoned").clone();
        assert_eq!(
            paths,
            vec![
                "/zai/api/paas/v4/chat/completions".to_string(),
                "/glm/api/paas/v4/chat/completions".to_string(),
            ]
        );
        server.abort();
    }

    #[test]
    fn ollama_factory_uses_no_credential_when_key_absent() {
        let provider = build_ollama_compat_provider(
            "default",
            None,
            Some("http://192.168.1.100:11434/v1"),
            &ModelProviderRuntimeOptions::default(),
        )
        .build();

        assert_eq!(provider.name, "Ollama");
        assert_eq!(provider.base_url, "http://192.168.1.100:11434/v1");
        assert!(provider.credential.is_none());
    }

    #[test]
    fn ollama_factory_normalizes_host_root_to_openai_compat_base() {
        let provider = build_ollama_compat_provider(
            "default",
            None,
            Some("http://192.168.1.100:11434"),
            &ModelProviderRuntimeOptions::default(),
        )
        .build();

        assert_eq!(provider.base_url, "http://192.168.1.100:11434/v1");
    }

    #[test]
    fn ollama_factory_normalizes_legacy_api_path_to_openai_compat_base() {
        let provider = build_ollama_compat_provider(
            "default",
            None,
            Some("https://ollama.com/api"),
            &ModelProviderRuntimeOptions::default(),
        )
        .build();

        assert_eq!(provider.base_url, "https://ollama.com/v1");
    }

    #[test]
    fn ollama_factory_preserves_typed_api_key_for_official_cloud() {
        let provider = build_ollama_compat_provider(
            "default",
            Some("  ollama-key  "),
            Some("https://ollama.com/v1"),
            &ModelProviderRuntimeOptions::default(),
        )
        .build();

        assert_eq!(provider.credential.as_deref(), Some("ollama-key"));
    }

    #[test]
    fn llamacpp_factory_routes_to_responses_provider_when_wire_api_responses() {
        use zeroclaw_config::schema::{LlamacppModelProviderConfig, ModelProviderConfig, WireApi};
        let cfg = LlamacppModelProviderConfig {
            base: ModelProviderConfig {
                wire_api: Some(WireApi::Responses),
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider(
                "default",
                None,
                Some("http://localhost:8080/v1"),
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_eq!(provider.default_wire_api(), "responses");
    }

    #[test]
    fn llamacpp_factory_defaults_to_chat_completions_without_wire_api() {
        use zeroclaw_config::schema::LlamacppModelProviderConfig;
        let cfg = LlamacppModelProviderConfig::default();
        let provider = cfg
            .create_provider(
                "default",
                None,
                Some("http://localhost:8080/v1"),
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_ne!(provider.default_wire_api(), "responses");
    }

    #[test]
    fn custom_factory_routes_to_responses_provider_when_wire_api_responses() {
        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig, WireApi};
        let cfg = CustomModelProviderConfig {
            base: ModelProviderConfig {
                uri: Some("http://10.0.0.15:8000/v1".to_string()),
                wire_api: Some(WireApi::Responses),
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider(
                "vllm",
                None,
                Some("http://10.0.0.15:8000/v1"),
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_eq!(provider.default_wire_api(), "responses");
    }

    #[test]
    fn custom_factory_defaults_to_chat_completions_without_wire_api() {
        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig};
        let cfg = CustomModelProviderConfig {
            base: ModelProviderConfig {
                uri: Some("http://10.0.0.15:8000/v1".to_string()),
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider(
                "vllm",
                None,
                Some("http://10.0.0.15:8000/v1"),
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_ne!(provider.default_wire_api(), "responses");
    }

    #[tokio::test]
    async fn responses_factory_forwards_timeout_secs_to_responses_provider() {
        use axum::{Json, Router, routing::post};
        use serde_json::json;
        use tokio::time::{Duration, Instant};
        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig, WireApi};

        async fn slow_responses() -> Json<serde_json::Value> {
            tokio::time::sleep(Duration::from_secs(3)).await;
            Json(json!({
                "id": "resp_slow",
                "object": "response",
                "output": [{"type": "message", "content": [{"type": "output_text", "text": "too late"}]}],
            }))
        }

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind test server");
        let addr = listener.local_addr().expect("test server addr");
        let app = Router::new().route("/v1/responses", post(slow_responses));
        let server = zeroclaw_spawn::spawn!(async move {
            axum::serve(listener, app).await.expect("serve test server");
        });

        let cfg = CustomModelProviderConfig {
            base: ModelProviderConfig {
                uri: Some(format!("http://{addr}/v1")),
                wire_api: Some(WireApi::Responses),
                ..Default::default()
            },
        };
        let opts = ModelProviderRuntimeOptions {
            provider_timeout_secs: Some(1),
            ..Default::default()
        };
        let provider = cfg
            .create_provider(
                "vllm",
                Some("test-key"),
                Some(&format!("http://{addr}/v1")),
                &opts,
            )
            .expect("openai responses provider should build");

        // Sanity-check routing + URL composition.
        assert_eq!(
            provider.default_wire_api(),
            "responses",
            "factory should route to the responses provider when wire_api=Responses"
        );
        assert_eq!(
            provider.default_base_url(),
            Some(format!("http://{addr}/v1/responses").as_str()),
            "factory-composed URL must end with /responses"
        );

        let started = Instant::now();
        let result = provider
            .chat_with_system(None, "hello", "gpt-5", Some(0.7))
            .await;
        let elapsed = started.elapsed();

        server.abort();

        assert!(
            result.is_err(),
            "slow response should time out when factory forwards provider_timeout_secs (1s)"
        );
        assert!(
            elapsed < Duration::from_secs(3),
            "request waited for the server response instead of using configured 1s timeout: {elapsed:?}"
        );
    }

    #[tokio::test]
    async fn responses_factory_forwards_extra_headers_to_responses_provider() {
        use axum::{Json, Router, extract::State, http::HeaderMap, routing::post};
        use serde_json::json;
        use std::collections::HashMap;
        use std::sync::{Arc, Mutex};
        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig, WireApi};

        let captured: Arc<Mutex<Option<HeaderMap>>> = Arc::new(Mutex::new(None));

        async fn capture_headers(
            State(captured): State<Arc<Mutex<Option<HeaderMap>>>>,
            headers: HeaderMap,
        ) -> Json<serde_json::Value> {
            *captured.lock().expect("captured headers mutex") = Some(headers);
            Json(json!({
                "id": "resp_ok",
                "object": "response",
                "output": [{"type": "message", "content": [{"type": "output_text", "text": "ok"}]}],
            }))
        }

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind test server");
        let addr = listener.local_addr().expect("test server addr");
        let app = Router::new()
            .route("/v1/responses", post(capture_headers))
            .with_state(Arc::clone(&captured));
        let server = zeroclaw_spawn::spawn!(async move {
            axum::serve(listener, app).await.expect("serve test server");
        });

        let cfg = CustomModelProviderConfig {
            base: ModelProviderConfig {
                uri: Some(format!("http://{addr}/v1")),
                wire_api: Some(WireApi::Responses),
                ..Default::default()
            },
        };
        let mut extra_headers = HashMap::new();
        // The two non-reserved custom headers we expect to see on the wire.
        extra_headers.insert("X-Trace-Id".to_string(), "trace-abc-123".to_string());
        extra_headers.insert("X-Tenant".to_string(), "acme".to_string());
        // The reserved-header case we expect the factory to drop — the
        // built-in `Bearer test-key` (from `Some("test-key")` below) must be
        // the only Authorization value on the request.
        extra_headers.insert(
            "Authorization".to_string(),
            "Bearer should-be-dropped".to_string(),
        );
        // And the case-insensitive variant — must be dropped by the same
        // case-insensitive check in `build_default_headers`.
        extra_headers.insert(
            "authorization".to_string(),
            "Bearer lower-case-should-also-be-dropped".to_string(),
        );
        let opts = ModelProviderRuntimeOptions {
            extra_headers,
            ..Default::default()
        };
        let provider = cfg
            .create_provider(
                "vllm",
                Some("test-key"),
                Some(&format!("http://{addr}/v1")),
                &opts,
            )
            .expect("openai responses provider should build");

        let result = provider
            .chat_with_system(None, "hello", "gpt-5", Some(0.7))
            .await;

        server.abort();

        assert!(result.is_ok(), "fast response should succeed: {result:?}");

        let captured_headers = captured
            .lock()
            .expect("captured headers mutex")
            .clone()
            .expect("server handler must have captured the request headers");

        // (a) configured custom headers arrive on the wire.
        assert_eq!(
            captured_headers
                .get("X-Trace-Id")
                .map(|v| v.to_str().unwrap()),
            Some("trace-abc-123"),
            "configured extra_headers['X-Trace-Id'] must reach the wire"
        );
        assert_eq!(
            captured_headers
                .get("X-Tenant")
                .map(|v| v.to_str().unwrap()),
            Some("acme"),
            "configured extra_headers['X-Tenant'] must reach the wire"
        );

        let auth_values: Vec<&str> = captured_headers
            .get_all("Authorization")
            .iter()
            .filter_map(|v| v.to_str().ok())
            .collect();
        assert_eq!(
            auth_values.len(),
            1,
            "exactly one Authorization header must reach the wire; got {auth_values:?}"
        );
        assert_eq!(
            auth_values[0], "Bearer test-key",
            "the surviving Authorization must be the built-in bearer credential, not the dropped extra_headers entry"
        );
    }

    #[test]
    fn custom_factory_disables_native_tools_by_default() {
        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig};
        let cfg = CustomModelProviderConfig {
            base: ModelProviderConfig {
                uri: Some("http://10.0.0.15:8000/v1".to_string()),
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider(
                "vllm",
                None,
                Some("http://10.0.0.15:8000/v1"),
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert!(
            !provider.supports_native_tools(),
            "custom OpenAI-compatible endpoints must default to prompt-guided tools"
        );
    }

    #[test]
    fn custom_factory_honors_native_tools_override_true() {
        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig};
        let cfg = CustomModelProviderConfig {
            base: ModelProviderConfig {
                uri: Some("http://10.0.0.15:8000/v1".to_string()),
                ..Default::default()
            },
        };
        let options = ModelProviderRuntimeOptions {
            native_tools: Some(true),
            ..Default::default()
        };
        let provider = cfg
            .create_provider("vllm", None, Some("http://10.0.0.15:8000/v1"), &options)
            .unwrap();
        assert!(
            provider.supports_native_tools(),
            "custom endpoints with `native_tools = true` must keep native tool calling available"
        );
    }

    #[test]
    fn openai_compatible_factory_routes_to_responses_when_wire_api_responses() {
        use zeroclaw_config::schema::{ModelProviderConfig, WireApi};
        let cfg = ModelProviderConfig {
            uri: Some("http://10.0.0.15:8000/v1".to_string()),
            wire_api: Some(WireApi::Responses),
            ..Default::default()
        };
        let provider = cfg
            .create_provider(
                "default",
                None,
                Some("http://10.0.0.15:8000/v1"),
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_eq!(provider.default_wire_api(), "responses");
    }

    #[test]
    fn compat_family_factory_routes_to_responses_when_wire_api_responses() {
        use zeroclaw_config::schema::{ModelProviderConfig, WireApi};
        let cfg = OpencodeModelProviderConfig {
            base: ModelProviderConfig {
                wire_api: Some(WireApi::Responses),
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider(
                "default",
                None,
                None,
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_eq!(provider.default_wire_api(), "responses");
        assert_eq!(
            provider.default_base_url(),
            Some("https://opencode.ai/zen/v1/responses")
        );
    }

    #[test]
    fn compat_family_factory_defaults_to_chat_completions_without_wire_api() {
        let cfg = OpencodeModelProviderConfig::default();
        let provider = cfg
            .create_provider(
                "default",
                None,
                None,
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_ne!(provider.default_wire_api(), "responses");
    }

    #[test]
    fn compat_family_without_wire_api_override_ignores_responses_setting() {
        // Scope guard: only families that override `wire_api()` opt into the
        // responses route. A compat family that does not override it (Doubao
        // here) must keep chat_completions even if the entry sets
        // `wire_api = "responses"`, so this fix stays scoped per family.
        use zeroclaw_config::schema::{ModelProviderConfig, WireApi};
        let cfg = DoubaoModelProviderConfig {
            base: ModelProviderConfig {
                wire_api: Some(WireApi::Responses),
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider(
                "default",
                None,
                None,
                &ModelProviderRuntimeOptions::default(),
            )
            .unwrap();
        assert_ne!(provider.default_wire_api(), "responses");
    }

    #[tokio::test]
    async fn compat_family_responses_posts_to_responses_path_on_configured_base() {
        use axum::{Json, Router, extract::State, http::Uri, routing::post};
        use serde_json::{Value, json};
        use std::sync::{Arc, Mutex};
        use zeroclaw_config::schema::{ModelProviderConfig, WireApi};

        type Capture = Arc<Mutex<Vec<String>>>;

        async fn capture_path(
            State(capture): State<Capture>,
            uri: Uri,
            Json(_body): Json<Value>,
        ) -> Json<Value> {
            capture
                .lock()
                .expect("capture lock poisoned")
                .push(uri.path().to_string());
            Json(json!({ "output": [], "output_text": "ok" }))
        }

        let capture: Capture = Arc::new(Mutex::new(Vec::new()));
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind test server");
        let addr = listener.local_addr().expect("test server addr");
        let app = Router::new()
            .route("/zen/v1/responses", post(capture_path))
            .with_state(capture.clone());
        let server = zeroclaw_spawn::spawn!(async move {
            axum::serve(listener, app).await.expect("serve test server");
        });

        let base_url = format!("http://{addr}/zen/v1");
        let cfg = OpencodeModelProviderConfig {
            base: ModelProviderConfig {
                wire_api: Some(WireApi::Responses),
                ..Default::default()
            },
        };
        let provider = cfg
            .create_provider(
                "default",
                Some("opencode-test-credential"),
                Some(&base_url),
                &ModelProviderRuntimeOptions::default(),
            )
            .expect("opencode responses provider should build");
        let _ = provider
            .chat_with_system(None, "hello", "big-pickle", Some(0.7))
            .await;

        server.abort();

        let paths = capture.lock().expect("capture lock poisoned").clone();
        assert_eq!(paths, vec!["/zen/v1/responses".to_string()]);
    }
}
