Skip to main content

zeroclaw_providers/
factory.rs

1//! Per-family construction dispatch for model providers.
2//!
3//! Each `<Family>ModelProviderConfig` typed slot from
4//! `zeroclaw-config::providers::ModelProviders` declares its own construction
5//! via one of two traits:
6//!
7//! - [`CompatFamilySpec`] for OpenAI-compatible families. Declare
8//!   `DISPLAY` / `DEFAULT_URL` / `AUTH`; the blanket
9//!   `impl<T: CompatFamilySpec> FamilyProviderFactory for T` produces the
10//!   provider. Families with minor modifiers (`.without_native_tools()`,
11//!   `.with_models_dev_key(...)`, multi-endpoint URI fallback) override
12//!   `build_compat` — still one place per family, no flat dispatch arm.
13//!
14//! - [`FamilyProviderFactory`] directly for bespoke families that wrap a
15//!   non-compat runtime provider (`azure`, `gemini`, `openrouter`,
16//!   `bedrock`, `anthropic`, …).
17//!
18//! Dispatch is generated by [`for_each_model_provider_slot!`] — the same
19//! macro that defines the typed slots is the only place the family list
20//! lives. Adding a family is one slot row plus one trait impl; missing the
21//! impl fails to compile when the dispatch is generated.
22//!
23//! [`for_each_model_provider_slot!`]: zeroclaw_config::providers::for_each_model_provider_slot
24
25use crate::ModelProviderRuntimeOptions;
26use crate::compatible::{AuthStyle, OpenAiCompatibleModelProvider};
27use crate::traits::ModelProvider;
28use anyhow::Result;
29
30/// Per-family construction trait. Implemented (directly or via the
31/// `CompatFamilySpec` blanket) by every typed `<Family>ModelProviderConfig`.
32///
33/// `&self` IS the typed alias config — implementations read their own
34/// per-alias fields directly instead of through a flat options dumping
35/// ground. `api_url` is the resolved endpoint URL (operator override or
36/// pre-resolved family default); `key` is the resolved API credential.
37pub trait FamilyProviderFactory {
38    fn create_provider(
39        &self,
40        alias: &str,
41        key: Option<&str>,
42        api_url: Option<&str>,
43        opts: &ModelProviderRuntimeOptions,
44    ) -> Result<Box<dyn ModelProvider>>;
45}
46
47/// Spec trait for OpenAI-compatible families. Implementing this gives a
48/// `FamilyProviderFactory` impl for free via the blanket below.
49///
50/// Override [`CompatFamilySpec::build_compat`] when the family needs minor
51/// modifiers (e.g. `.without_native_tools()`); otherwise the default
52/// `OpenAiCompatibleModelProvider::new` constructor is used.
53pub trait CompatFamilySpec {
54    const DISPLAY: &'static str;
55    const DEFAULT_URL: &'static str;
56    const AUTH: AuthStyle;
57
58    /// `models.dev` catalog key for this provider, when present in the
59    /// public catalog. Lets `list_models()` pre-populate the model
60    /// picker without a credential — the gateway and TUI both surface
61    /// the cataloged IDs even before the operator pastes their API key.
62    /// Set to `None` for providers that don't have a `models.dev`
63    /// entry; their picker stays empty until a credential unlocks the
64    /// live `/models` endpoint, which the dashboard already falls back
65    /// to a free-text input for.
66    const MODELS_DEV_KEY: Option<&'static str> = None;
67
68    /// OpenRouter vendor prefix used by `list_models` as a last-resort
69    /// fallback when this family has no `models.dev` entry and no live
70    /// credential. `None` when no OpenRouter prefix exists for this family
71    /// (e.g. Sambanova, Hyperbolic — no public catalog at all without a key).
72    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = None;
73
74    /// Build the base compat provider with both catalog consts applied. Use
75    /// this from inside `build_compat` overrides so the catalog hooks ride
76    /// along with any family-specific modifiers.
77    fn build_compat_base(
78        &self,
79        alias: &str,
80        key: Option<&str>,
81        api_url: Option<&str>,
82    ) -> OpenAiCompatibleModelProvider {
83        let mut p = OpenAiCompatibleModelProvider::new(
84            alias,
85            Self::DISPLAY,
86            api_url.unwrap_or(Self::DEFAULT_URL),
87            key,
88            Self::AUTH,
89        );
90        if let Some(catalog_key) = Self::MODELS_DEV_KEY {
91            p = p.with_models_dev_key(catalog_key);
92        }
93        if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
94            p = p.with_openrouter_vendor_prefix(prefix);
95        }
96        p
97    }
98
99    /// Build the underlying compat provider. Default just returns the base
100    /// from `build_compat_base`; override to chain family-specific
101    /// modifiers (e.g. `.without_native_tools()`, `.with_merge_system_into_user()`).
102    fn build_compat(
103        &self,
104        alias: &str,
105        key: Option<&str>,
106        api_url: Option<&str>,
107    ) -> OpenAiCompatibleModelProvider {
108        self.build_compat_base(alias, key, api_url)
109    }
110}
111
112impl<T: CompatFamilySpec> FamilyProviderFactory for T {
113    fn create_provider(
114        &self,
115        alias: &str,
116        key: Option<&str>,
117        api_url: Option<&str>,
118        opts: &ModelProviderRuntimeOptions,
119    ) -> Result<Box<dyn ModelProvider>> {
120        Ok(apply_compat_options(
121            self.build_compat(alias, key, api_url),
122            opts,
123        ))
124    }
125}
126
127/// Apply cross-cutting compat post-processing (timeout, headers, api_path,
128/// max_tokens, reasoning effort) to a freshly-constructed compat provider
129/// and box it for trait-object dispatch. Single source of the post-process
130/// chain — every compat impl funnels through here.
131pub fn apply_compat_options(
132    mut p: OpenAiCompatibleModelProvider,
133    opts: &ModelProviderRuntimeOptions,
134) -> Box<dyn ModelProvider> {
135    if let Some(t) = opts.provider_timeout_secs {
136        p = p.with_timeout_secs(t);
137    }
138    if let Some(ref effort) = opts.reasoning_effort {
139        p = p.with_reasoning_effort(Some(effort.clone()));
140    }
141    if !opts.extra_headers.is_empty() {
142        p = p.with_extra_headers(opts.extra_headers.clone());
143    }
144    if opts.api_path.is_some() {
145        p = p.with_api_path(opts.api_path.clone());
146    }
147    if let Some(mt) = opts.provider_max_tokens {
148        p = p.with_max_tokens(Some(mt));
149    }
150    Box::new(p)
151}
152
153pub(crate) fn build_kimi_code_compat(
154    alias: &str,
155    key: Option<&str>,
156    base_url: &str,
157) -> OpenAiCompatibleModelProvider {
158    OpenAiCompatibleModelProvider::new_with_user_agent_and_vision(
159        alias,
160        "Kimi Code",
161        base_url,
162        key,
163        AuthStyle::Bearer,
164        "KimiCLI/0.77",
165        true,
166    )
167    .with_models_dev_key("moonshotai")
168}
169
170/// Dispatch family construction by routing `(family, alias)` to the typed
171/// slot's `FamilyProviderFactory` impl. Generated from
172/// `for_each_model_provider_slot!` so the family list lives in exactly one
173/// place — adding a row to the slot macro requires a corresponding impl,
174/// caught at compile time when the macro expands.
175///
176/// `family` is the canonicalized family name (post-V2 synonym mapping);
177/// `alias` is the per-family entry key (`default`, `prod_v2`, …).
178///
179/// `config` is `Option` so legacy entry points (tests, programmatic
180/// factory calls without agent context) can dispatch without a real
181/// `Config` — those fall back to the family struct's `Default` impl,
182/// which gives compat-only families full functionality and bespoke
183/// families their unconfigured defaults (Azure errors helpfully on
184/// missing `resource`, etc.).
185pub fn dispatch_family_factory(
186    config: Option<&zeroclaw_config::schema::Config>,
187    family: &str,
188    alias: &str,
189    key: Option<&str>,
190    api_url: Option<&str>,
191    opts: &ModelProviderRuntimeOptions,
192) -> Result<Box<dyn ModelProvider>> {
193    macro_rules! emit_dispatch {
194        ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
195            match family {
196                "openai-compatible" | "openai_compatible" => {
197                    let default_cfg = zeroclaw_config::schema::ModelProviderConfig::default();
198                    let cfg = config
199                        .and_then(|c| c.providers.models.find("openai", alias))
200                        .unwrap_or(&default_cfg);
201                    cfg.create_provider(alias, key, api_url, opts)
202                }
203                $(
204                    $type_str => {
205                        let default_cfg: $cfg_ty;
206                        let cfg: &$cfg_ty = match config.and_then(|c| c.providers.models.$field.get(alias)) {
207                            Some(c) => c,
208                            None => {
209                                default_cfg = <$cfg_ty>::default();
210                                &default_cfg
211                            }
212                        };
213                        cfg.create_provider(alias, key, api_url, opts)
214                    }
215                )+
216                _ => {
217                    ::zeroclaw_log::record!(
218                        ERROR,
219                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
220                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
221                            .with_attrs(::serde_json::json!({"family": family})),
222                        "factory: unknown model_provider family"
223                    );
224                    Err(anyhow::Error::msg(format!(
225                        "Unknown model_provider family: {family}. After the V2 to typed-family migration, \
226                         only canonical family names are valid. Run `zeroclaw onboard` to reconfigure, \
227                         or set `[model_providers.custom.<alias>] uri = \"https://your-api.com\"` for \
228                         OpenAI-compatible custom endpoints."
229                    )))
230                },
231            }
232        }
233    }
234    zeroclaw_config::for_each_model_provider_slot!(emit_dispatch)
235}
236
237// ════════════════════════════════════════════════════════════════════════
238// Per-family impls — grouped by category. Adding a family means: one row
239// in `for_each_model_provider_slot!` (zeroclaw-config) plus one impl
240// here. Compiler enforces both via the slot-macro-driven dispatch above.
241// ════════════════════════════════════════════════════════════════════════
242
243use zeroclaw_config::schema::{
244    Ai21ModelProviderConfig, AihubmixModelProviderConfig, AnthropicModelProviderConfig,
245    AnyscaleModelProviderConfig, AstraiModelProviderConfig, AtomicChatModelProviderConfig,
246    AvianModelProviderConfig, AzureModelProviderConfig, BaichuanModelProviderConfig,
247    BasetenModelProviderConfig, BedrockModelProviderConfig, CerebrasModelProviderConfig,
248    CloudflareModelProviderConfig, CohereModelProviderConfig, CopilotModelProviderConfig,
249    CustomModelProviderConfig, DeepinfraModelProviderConfig, DeepmystModelProviderConfig,
250    DeepseekModelProviderConfig, DoubaoModelProviderConfig, FireworksModelProviderConfig,
251    FriendliModelProviderConfig, GeminiCliModelProviderConfig, GeminiModelProviderConfig,
252    GlmModelProviderConfig, GroqModelProviderConfig, HuggingfaceModelProviderConfig,
253    HunyuanModelProviderConfig, HyperbolicModelProviderConfig, KiloCliModelProviderConfig,
254    LeptonModelProviderConfig, LitellmModelProviderConfig, LlamacppModelProviderConfig,
255    LmstudioModelProviderConfig, MinimaxModelProviderConfig, MistralModelProviderConfig,
256    MoonshotEndpoint, MoonshotModelProviderConfig, NebiusModelProviderConfig,
257    NovitaModelProviderConfig, NscaleModelProviderConfig, NvidiaModelProviderConfig,
258    OllamaModelProviderConfig, OpenAIModelProviderConfig, OpenRouterModelProviderConfig,
259    OpencodeModelProviderConfig, OsaurusModelProviderConfig, OvhModelProviderConfig,
260    PerplexityModelProviderConfig, QianfanModelProviderConfig, QwenModelProviderConfig,
261    RekaModelProviderConfig, SambanovaModelProviderConfig, SglangModelProviderConfig,
262    SiliconflowModelProviderConfig, StepfunModelProviderConfig, SyntheticModelProviderConfig,
263    TelnyxModelProviderConfig, TogetherModelProviderConfig, VeniceModelProviderConfig,
264    VercelModelProviderConfig, VllmModelProviderConfig, XaiModelProviderConfig,
265    YiModelProviderConfig, ZaiModelProviderConfig,
266};
267
268// ── Pure-compat families ───────────────────────────────────────────────
269// `OpenAiCompatibleModelProvider::new(DISPLAY, DEFAULT_URL, key, AUTH)` —
270// no modifiers, no per-alias logic. The blanket impl supplies
271// `FamilyProviderFactory` automatically.
272
273impl CompatFamilySpec for VercelModelProviderConfig {
274    const DISPLAY: &'static str = "Vercel AI Gateway";
275    const DEFAULT_URL: &'static str = crate::VERCEL_AI_GATEWAY_BASE_URL;
276    const AUTH: AuthStyle = AuthStyle::Bearer;
277    const MODELS_DEV_KEY: Option<&'static str> = Some("vercel");
278}
279impl CompatFamilySpec for CloudflareModelProviderConfig {
280    const DISPLAY: &'static str = "Cloudflare AI Gateway";
281    const DEFAULT_URL: &'static str = "https://gateway.ai.cloudflare.com/v1";
282    const AUTH: AuthStyle = AuthStyle::Bearer;
283    const MODELS_DEV_KEY: Option<&'static str> = Some("cloudflare-ai-gateway");
284}
285impl CompatFamilySpec for SyntheticModelProviderConfig {
286    const DISPLAY: &'static str = "Synthetic";
287    const DEFAULT_URL: &'static str = "https://api.synthetic.new/openai/v1";
288    const AUTH: AuthStyle = AuthStyle::Bearer;
289    const MODELS_DEV_KEY: Option<&'static str> = Some("synthetic");
290}
291impl CompatFamilySpec for OpencodeModelProviderConfig {
292    const DISPLAY: &'static str = "OpenCode Zen";
293    const DEFAULT_URL: &'static str = "https://opencode.ai/zen/v1";
294    const AUTH: AuthStyle = AuthStyle::Bearer;
295    const MODELS_DEV_KEY: Option<&'static str> = Some("opencode");
296}
297impl CompatFamilySpec for DoubaoModelProviderConfig {
298    const DISPLAY: &'static str = "Doubao";
299    const DEFAULT_URL: &'static str = "https://ark.cn-beijing.volces.com/api/v3";
300    const AUTH: AuthStyle = AuthStyle::Bearer;
301    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("bytedance");
302}
303impl CompatFamilySpec for MistralModelProviderConfig {
304    const DISPLAY: &'static str = "Mistral";
305    const DEFAULT_URL: &'static str = "https://api.mistral.ai/v1";
306    const AUTH: AuthStyle = AuthStyle::Bearer;
307    const MODELS_DEV_KEY: Option<&'static str> = Some("mistral");
308}
309impl CompatFamilySpec for DeepseekModelProviderConfig {
310    const DISPLAY: &'static str = "DeepSeek";
311    const DEFAULT_URL: &'static str = "https://api.deepseek.com";
312    const AUTH: AuthStyle = AuthStyle::Bearer;
313    const MODELS_DEV_KEY: Option<&'static str> = Some("deepseek");
314}
315impl CompatFamilySpec for TogetherModelProviderConfig {
316    const DISPLAY: &'static str = "Together AI";
317    const DEFAULT_URL: &'static str = "https://api.together.xyz";
318    const AUTH: AuthStyle = AuthStyle::Bearer;
319    const MODELS_DEV_KEY: Option<&'static str> = Some("togetherai");
320}
321impl CompatFamilySpec for FireworksModelProviderConfig {
322    const DISPLAY: &'static str = "Fireworks AI";
323    const DEFAULT_URL: &'static str = "https://api.fireworks.ai/inference/v1";
324    const AUTH: AuthStyle = AuthStyle::Bearer;
325    const MODELS_DEV_KEY: Option<&'static str> = Some("fireworks-ai");
326}
327impl CompatFamilySpec for NovitaModelProviderConfig {
328    const DISPLAY: &'static str = "Novita AI";
329    const DEFAULT_URL: &'static str = "https://api.novita.ai/openai";
330    const AUTH: AuthStyle = AuthStyle::Bearer;
331    const MODELS_DEV_KEY: Option<&'static str> = Some("novita-ai");
332}
333impl CompatFamilySpec for PerplexityModelProviderConfig {
334    const DISPLAY: &'static str = "Perplexity";
335    const DEFAULT_URL: &'static str = "https://api.perplexity.ai";
336    const AUTH: AuthStyle = AuthStyle::Bearer;
337    const MODELS_DEV_KEY: Option<&'static str> = Some("perplexity");
338}
339impl CompatFamilySpec for CohereModelProviderConfig {
340    const DISPLAY: &'static str = "Cohere";
341    const DEFAULT_URL: &'static str = "https://api.cohere.com/compatibility";
342    const AUTH: AuthStyle = AuthStyle::Bearer;
343    const MODELS_DEV_KEY: Option<&'static str> = Some("cohere");
344}
345impl CompatFamilySpec for SglangModelProviderConfig {
346    const DISPLAY: &'static str = "SGLang";
347    const DEFAULT_URL: &'static str = "http://localhost:30000/v1";
348    const AUTH: AuthStyle = AuthStyle::Bearer;
349}
350impl CompatFamilySpec for VllmModelProviderConfig {
351    const DISPLAY: &'static str = "vLLM";
352    const DEFAULT_URL: &'static str = "http://localhost:8000/v1";
353    const AUTH: AuthStyle = AuthStyle::Bearer;
354}
355impl CompatFamilySpec for AstraiModelProviderConfig {
356    const DISPLAY: &'static str = "Astrai";
357    const DEFAULT_URL: &'static str = "https://as-trai.com/v1";
358    const AUTH: AuthStyle = AuthStyle::Bearer;
359}
360impl CompatFamilySpec for SiliconflowModelProviderConfig {
361    const DISPLAY: &'static str = "SiliconFlow";
362    const DEFAULT_URL: &'static str = "https://api.siliconflow.com/v1";
363    const AUTH: AuthStyle = AuthStyle::Bearer;
364    const MODELS_DEV_KEY: Option<&'static str> = Some("siliconflow");
365}
366impl CompatFamilySpec for AihubmixModelProviderConfig {
367    const DISPLAY: &'static str = "AiHubMix";
368    const DEFAULT_URL: &'static str = "https://aihubmix.com/v1";
369    const AUTH: AuthStyle = AuthStyle::Bearer;
370    const MODELS_DEV_KEY: Option<&'static str> = Some("aihubmix");
371}
372impl CompatFamilySpec for LitellmModelProviderConfig {
373    const DISPLAY: &'static str = "LiteLLM";
374    const DEFAULT_URL: &'static str = "http://localhost:4000/v1";
375    const AUTH: AuthStyle = AuthStyle::Bearer;
376}
377impl CompatFamilySpec for CerebrasModelProviderConfig {
378    const DISPLAY: &'static str = "Cerebras";
379    const DEFAULT_URL: &'static str = "https://api.cerebras.ai/v1";
380    const AUTH: AuthStyle = AuthStyle::Bearer;
381    const MODELS_DEV_KEY: Option<&'static str> = Some("cerebras");
382}
383impl CompatFamilySpec for SambanovaModelProviderConfig {
384    const DISPLAY: &'static str = "SambaNova";
385    const DEFAULT_URL: &'static str = "https://api.sambanova.ai/v1";
386    const AUTH: AuthStyle = AuthStyle::Bearer;
387    // No models.dev entry and no OpenRouter prefix — operator must paste a
388    // credential before `list_models` returns anything.
389}
390impl CompatFamilySpec for HyperbolicModelProviderConfig {
391    const DISPLAY: &'static str = "Hyperbolic";
392    const DEFAULT_URL: &'static str = "https://api.hyperbolic.xyz/v1";
393    const AUTH: AuthStyle = AuthStyle::Bearer;
394    // No models.dev entry and no OpenRouter prefix — operator must paste a
395    // credential before `list_models` returns anything.
396}
397impl CompatFamilySpec for DeepinfraModelProviderConfig {
398    const DISPLAY: &'static str = "DeepInfra";
399    const DEFAULT_URL: &'static str = "https://api.deepinfra.com/v1/openai";
400    const AUTH: AuthStyle = AuthStyle::Bearer;
401    const MODELS_DEV_KEY: Option<&'static str> = Some("deepinfra");
402}
403impl CompatFamilySpec for HuggingfaceModelProviderConfig {
404    const DISPLAY: &'static str = "Hugging Face";
405    const DEFAULT_URL: &'static str = "https://router.huggingface.co/v1";
406    const AUTH: AuthStyle = AuthStyle::Bearer;
407    const MODELS_DEV_KEY: Option<&'static str> = Some("huggingface");
408}
409impl CompatFamilySpec for Ai21ModelProviderConfig {
410    const DISPLAY: &'static str = "AI21 Labs";
411    const DEFAULT_URL: &'static str = "https://api.ai21.com/studio/v1";
412    const AUTH: AuthStyle = AuthStyle::Bearer;
413    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("ai21");
414}
415impl CompatFamilySpec for RekaModelProviderConfig {
416    const DISPLAY: &'static str = "Reka";
417    const DEFAULT_URL: &'static str = "https://api.reka.ai/v1";
418    const AUTH: AuthStyle = AuthStyle::Bearer;
419    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("rekaai");
420}
421impl CompatFamilySpec for BasetenModelProviderConfig {
422    const DISPLAY: &'static str = "Baseten";
423    const DEFAULT_URL: &'static str = "https://inference.baseten.co/v1";
424    const AUTH: AuthStyle = AuthStyle::Bearer;
425    const MODELS_DEV_KEY: Option<&'static str> = Some("baseten");
426}
427impl CompatFamilySpec for NscaleModelProviderConfig {
428    const DISPLAY: &'static str = "Nscale";
429    const DEFAULT_URL: &'static str = "https://inference.api.nscale.com/v1";
430    const AUTH: AuthStyle = AuthStyle::Bearer;
431}
432impl CompatFamilySpec for AnyscaleModelProviderConfig {
433    const DISPLAY: &'static str = "Anyscale";
434    const DEFAULT_URL: &'static str = "https://api.endpoints.anyscale.com/v1";
435    const AUTH: AuthStyle = AuthStyle::Bearer;
436}
437impl CompatFamilySpec for NebiusModelProviderConfig {
438    const DISPLAY: &'static str = "Nebius AI Studio";
439    const DEFAULT_URL: &'static str = "https://api.studio.nebius.ai/v1";
440    const AUTH: AuthStyle = AuthStyle::Bearer;
441    const MODELS_DEV_KEY: Option<&'static str> = Some("nebius");
442}
443impl CompatFamilySpec for FriendliModelProviderConfig {
444    const DISPLAY: &'static str = "Friendli AI";
445    const DEFAULT_URL: &'static str = "https://api.friendli.ai/serverless/v1";
446    const AUTH: AuthStyle = AuthStyle::Bearer;
447    const MODELS_DEV_KEY: Option<&'static str> = Some("friendli");
448}
449impl CompatFamilySpec for LeptonModelProviderConfig {
450    const DISPLAY: &'static str = "Lepton AI";
451    const DEFAULT_URL: &'static str = "https://llama3-1-405b.lepton.run/api/v1";
452    const AUTH: AuthStyle = AuthStyle::Bearer;
453}
454impl CompatFamilySpec for StepfunModelProviderConfig {
455    const DISPLAY: &'static str = "Stepfun";
456    const DEFAULT_URL: &'static str = "https://api.stepfun.com/v1";
457    const AUTH: AuthStyle = AuthStyle::Bearer;
458    const MODELS_DEV_KEY: Option<&'static str> = Some("stepfun");
459    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("stepfun");
460}
461impl CompatFamilySpec for BaichuanModelProviderConfig {
462    const DISPLAY: &'static str = "Baichuan";
463    const DEFAULT_URL: &'static str = "https://api.baichuan-ai.com/v1";
464    const AUTH: AuthStyle = AuthStyle::Bearer;
465}
466impl CompatFamilySpec for YiModelProviderConfig {
467    const DISPLAY: &'static str = "01.AI (Yi)";
468    const DEFAULT_URL: &'static str = "https://api.lingyiwanwu.com/v1";
469    const AUTH: AuthStyle = AuthStyle::Bearer;
470}
471impl CompatFamilySpec for HunyuanModelProviderConfig {
472    const DISPLAY: &'static str = "Tencent Hunyuan";
473    const DEFAULT_URL: &'static str = "https://api.hunyuan.cloud.tencent.com/v1";
474    const AUTH: AuthStyle = AuthStyle::Bearer;
475    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("tencent");
476}
477impl CompatFamilySpec for AvianModelProviderConfig {
478    const DISPLAY: &'static str = "Avian";
479    const DEFAULT_URL: &'static str = "https://api.avian.io/v1";
480    const AUTH: AuthStyle = AuthStyle::Bearer;
481}
482impl CompatFamilySpec for DeepmystModelProviderConfig {
483    const DISPLAY: &'static str = "DeepMyst";
484    const DEFAULT_URL: &'static str = "https://api.deepmyst.com/v1";
485    const AUTH: AuthStyle = AuthStyle::Bearer;
486}
487impl CompatFamilySpec for MoonshotModelProviderConfig {
488    const DISPLAY: &'static str = "Moonshot";
489    const DEFAULT_URL: &'static str = crate::MOONSHOT_INTL_BASE_URL;
490    const AUTH: AuthStyle = AuthStyle::Bearer;
491    const MODELS_DEV_KEY: Option<&'static str> = Some("moonshotai");
492
493    fn build_compat(
494        &self,
495        alias: &str,
496        key: Option<&str>,
497        api_url: Option<&str>,
498    ) -> OpenAiCompatibleModelProvider {
499        let base_url = api_url.unwrap_or(Self::DEFAULT_URL);
500        if self.endpoint == MoonshotEndpoint::Code || base_url == crate::moonshot_code_base_url() {
501            return build_kimi_code_compat(alias, key, base_url);
502        }
503
504        self.build_compat_base(alias, key, api_url)
505    }
506}
507
508// ── Compat families with build_compat overrides ────────────────────────
509// Need a constructor variant or modifier chain that can't be expressed
510// purely through (DISPLAY, DEFAULT_URL, AUTH).
511
512impl CompatFamilySpec for VeniceModelProviderConfig {
513    const DISPLAY: &'static str = "Venice";
514    const DEFAULT_URL: &'static str = "https://api.venice.ai";
515    const AUTH: AuthStyle = AuthStyle::Bearer;
516    const MODELS_DEV_KEY: Option<&'static str> = Some("venice");
517    fn build_compat(
518        &self,
519        alias: &str,
520        key: Option<&str>,
521        api_url: Option<&str>,
522    ) -> OpenAiCompatibleModelProvider {
523        self.build_compat_base(alias, key, api_url)
524            .without_native_tools()
525    }
526}
527impl CompatFamilySpec for AtomicChatModelProviderConfig {
528    const DISPLAY: &'static str = "Atomic Chat";
529    /// Default endpoint for the Jan / Atomic Chat local OpenAI-compatible
530    /// runtime (`jan.ai`). Operators override via `api_url` on the alias
531    /// entry when they run it on a non-default port.
532    const DEFAULT_URL: &'static str = "http://127.0.0.1:1337/v1";
533    const AUTH: AuthStyle = AuthStyle::Bearer;
534    const MODELS_DEV_KEY: Option<&'static str> = Some("atomic-chat");
535    fn build_compat(
536        &self,
537        alias: &str,
538        key: Option<&str>,
539        api_url: Option<&str>,
540    ) -> OpenAiCompatibleModelProvider {
541        self.build_compat_base(alias, key, api_url)
542            .without_native_tools()
543    }
544}
545
546impl CompatFamilySpec for XaiModelProviderConfig {
547    const DISPLAY: &'static str = "xAI";
548    const DEFAULT_URL: &'static str = "https://api.x.ai/v1";
549    const AUTH: AuthStyle = AuthStyle::Bearer;
550    const MODELS_DEV_KEY: Option<&'static str> = Some("xai");
551}
552
553impl FamilyProviderFactory for MinimaxModelProviderConfig {
554    fn create_provider(
555        &self,
556        alias: &str,
557        key: Option<&str>,
558        api_url: Option<&str>,
559        opts: &ModelProviderRuntimeOptions,
560    ) -> Result<Box<dyn ModelProvider>> {
561        // OAuth refresh path: when the operator supplied an
562        // `oauth_refresh_token`, exchange it for a short-lived access
563        // token before constructing the provider. Region picked from
564        // the typed `endpoint` enum (Cn/Intl). Operators preferring
565        // dashboard-generated long-lived API keys leave the refresh
566        // token unset and populate `api_key` directly.
567        let refreshed_key: Option<String> = self
568            .oauth_refresh_token
569            .as_deref()
570            .map(str::trim)
571            .filter(|s| !s.is_empty())
572            .map(|refresh_token| {
573                let client_id = self
574                    .oauth_client_id
575                    .as_deref()
576                    .map(str::trim)
577                    .filter(|s| !s.is_empty())
578                    .unwrap_or(crate::MINIMAX_OAUTH_DEFAULT_CLIENT_ID);
579                crate::refresh_minimax_oauth_access_token(refresh_token, client_id, self.endpoint)
580            })
581            .transpose()?;
582        let resolved_key = refreshed_key.as_deref().or(key);
583        let p = OpenAiCompatibleModelProvider::new(
584            alias,
585            "MiniMax",
586            api_url.unwrap_or(crate::MINIMAX_INTL_BASE_URL),
587            resolved_key,
588            AuthStyle::Bearer,
589        )
590        .with_merge_system_into_user();
591        Ok(apply_compat_options(p, opts))
592    }
593}
594
595impl CompatFamilySpec for ZaiModelProviderConfig {
596    const DISPLAY: &'static str = "Z.AI";
597    const DEFAULT_URL: &'static str = crate::ZAI_GLOBAL_BASE_URL;
598    const AUTH: AuthStyle = AuthStyle::ZhipuJwt;
599    const MODELS_DEV_KEY: Option<&'static str> = Some("zai");
600    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("z-ai");
601}
602
603impl CompatFamilySpec for GlmModelProviderConfig {
604    const DISPLAY: &'static str = "GLM";
605    const DEFAULT_URL: &'static str = crate::GLM_GLOBAL_BASE_URL;
606    const AUTH: AuthStyle = AuthStyle::ZhipuJwt;
607    const MODELS_DEV_KEY: Option<&'static str> = Some("zhipuai");
608    fn build_compat(
609        &self,
610        alias: &str,
611        key: Option<&str>,
612        api_url: Option<&str>,
613    ) -> OpenAiCompatibleModelProvider {
614        // GLM exposes vision-capable models (e.g. `glm-4.5v`). Compose the
615        // catalog-conf'd base with vision flag override via the constructor
616        // variant; we replay both consts manually since this constructor
617        // path doesn't fold through `build_compat_base`.
618        let mut p = OpenAiCompatibleModelProvider::new_with_vision(
619            alias,
620            Self::DISPLAY,
621            api_url.unwrap_or(Self::DEFAULT_URL),
622            key,
623            Self::AUTH,
624            true,
625        );
626        if let Some(catalog_key) = Self::MODELS_DEV_KEY {
627            p = p.with_models_dev_key(catalog_key);
628        }
629        if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
630            p = p.with_openrouter_vendor_prefix(prefix);
631        }
632        p
633    }
634}
635
636impl CompatFamilySpec for NvidiaModelProviderConfig {
637    const DISPLAY: &'static str = "NVIDIA NIM";
638    const DEFAULT_URL: &'static str = "https://integrate.api.nvidia.com/v1";
639    const AUTH: AuthStyle = AuthStyle::Bearer;
640    const MODELS_DEV_KEY: Option<&'static str> = Some("nvidia");
641    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("nvidia");
642}
643
644impl CompatFamilySpec for QianfanModelProviderConfig {
645    const DISPLAY: &'static str = "Qianfan";
646    // Default is meaningless — `build_compat` always computes via
647    // `qianfan_base_url(api_url)`. Use the helper's default fallback as
648    // a placeholder.
649    const DEFAULT_URL: &'static str = "https://qianfan.baidubce.com/v2";
650    const AUTH: AuthStyle = AuthStyle::Bearer;
651    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("baidu");
652    fn build_compat(
653        &self,
654        alias: &str,
655        key: Option<&str>,
656        api_url: Option<&str>,
657    ) -> OpenAiCompatibleModelProvider {
658        let base_url = crate::qianfan_base_url(api_url);
659        let computed_url = Some(base_url.as_str());
660        self.build_compat_base(alias, key, computed_url)
661    }
662}
663
664// ── Bespoke families ───────────────────────────────────────────────────
665// Construction reads typed alias fields directly off `&self`, or wraps a
666// non-compat runtime provider, or routes through extra runtime context
667// (auth services, key fallback defaults, conditional native_tools, …).
668
669impl FamilyProviderFactory for OpenRouterModelProviderConfig {
670    fn create_provider(
671        &self,
672        alias: &str,
673        key: Option<&str>,
674        _api_url: Option<&str>,
675        opts: &ModelProviderRuntimeOptions,
676    ) -> Result<Box<dyn ModelProvider>> {
677        let mut p =
678            crate::openrouter::OpenRouterModelProvider::new(alias, key, opts.provider_timeout_secs)
679                .with_max_tokens(opts.provider_max_tokens);
680        if let Some(extra) = opts.provider_extra.clone() {
681            p = p.with_extra_body(extra);
682        }
683        Ok(Box::new(p))
684    }
685}
686
687impl FamilyProviderFactory for AnthropicModelProviderConfig {
688    fn create_provider(
689        &self,
690        alias: &str,
691        key: Option<&str>,
692        api_url: Option<&str>,
693        opts: &ModelProviderRuntimeOptions,
694    ) -> Result<Box<dyn ModelProvider>> {
695        let mut p = crate::anthropic::AnthropicModelProvider::with_base_url(alias, key, api_url);
696        if let Some(mt) = opts.provider_max_tokens {
697            p = p.with_max_tokens(mt);
698        }
699        Ok(Box::new(p))
700    }
701}
702
703impl FamilyProviderFactory for OpenAIModelProviderConfig {
704    fn create_provider(
705        &self,
706        alias: &str,
707        key: Option<&str>,
708        api_url: Option<&str>,
709        opts: &ModelProviderRuntimeOptions,
710    ) -> Result<Box<dyn ModelProvider>> {
711        // Codex variant routing: the `requires_openai_auth` flag on the
712        // shared base flips OpenAI to its Codex-protocol cousin. Lives on
713        // the typed alias — operators set it via the schema-mirror grammar
714        // alongside any other OpenAI alias field.
715        if self.base.requires_openai_auth {
716            return Ok(Box::new(
717                crate::openai_codex::OpenAiCodexModelProvider::new(alias, opts, key)?,
718            ));
719        }
720        let mut p = crate::openai::OpenAiModelProvider::with_base_url(alias, api_url, key);
721        if let Some(mt) = opts.provider_max_tokens {
722            p = p.with_max_tokens(Some(mt));
723        }
724        Ok(Box::new(p))
725    }
726}
727
728fn normalize_ollama_compat_base_url(api_url: Option<&str>) -> String {
729    let raw = api_url
730        .map(str::trim)
731        .filter(|value| !value.is_empty())
732        .unwrap_or("http://localhost:11434/v1");
733
734    let Ok(mut url) = reqwest::Url::parse(raw) else {
735        return raw.trim_end_matches('/').to_string();
736    };
737
738    let path = url.path().trim_end_matches('/');
739    if path.is_empty() || matches!(path, "/" | "/api" | "/api/chat") {
740        url.set_path("/v1");
741        return url.to_string().trim_end_matches('/').to_string();
742    }
743
744    raw.trim_end_matches('/').to_string()
745}
746
747fn build_ollama_compat_provider(
748    alias: &str,
749    key: Option<&str>,
750    api_url: Option<&str>,
751    opts: &ModelProviderRuntimeOptions,
752) -> OpenAiCompatibleModelProvider {
753    let base_url = normalize_ollama_compat_base_url(api_url);
754    let ollama_key = key.map(str::trim).filter(|value| !value.is_empty());
755    let mut p = OpenAiCompatibleModelProvider::new_with_vision(
756        alias,
757        "Ollama",
758        &base_url,
759        ollama_key,
760        AuthStyle::Bearer,
761        true,
762    )
763    .with_local_model_tool_sanitize()
764    .with_unauthenticated_model_listing();
765    if opts.merge_system_into_user {
766        p = p.with_merge_system_into_user();
767    }
768    p
769}
770
771impl FamilyProviderFactory for OllamaModelProviderConfig {
772    fn create_provider(
773        &self,
774        alias: &str,
775        key: Option<&str>,
776        api_url: Option<&str>,
777        opts: &ModelProviderRuntimeOptions,
778    ) -> Result<Box<dyn ModelProvider>> {
779        Ok(apply_compat_options(
780            build_ollama_compat_provider(alias, key, api_url, opts),
781            opts,
782        ))
783    }
784}
785
786impl FamilyProviderFactory for GeminiModelProviderConfig {
787    fn create_provider(
788        &self,
789        alias: &str,
790        key: Option<&str>,
791        _api_url: Option<&str>,
792        opts: &ModelProviderRuntimeOptions,
793    ) -> Result<Box<dyn ModelProvider>> {
794        let state_dir = opts.zeroclaw_dir.clone().unwrap_or_else(|| {
795            directories::UserDirs::new().map_or_else(
796                || std::path::PathBuf::from(".zeroclaw"),
797                |dirs| dirs.home_dir().join(".zeroclaw"),
798            )
799        });
800        let auth_service = crate::auth::AuthService::new(&state_dir, opts.secrets_encrypt);
801        Ok(Box::new(crate::gemini::GeminiModelProvider::new_with_auth(
802            alias,
803            key,
804            auth_service,
805            opts.auth_profile_override.clone(),
806            self.oauth_project.clone(),
807            self.oauth_client_id.clone(),
808            self.oauth_client_secret.clone(),
809        )))
810    }
811}
812
813impl FamilyProviderFactory for TelnyxModelProviderConfig {
814    fn create_provider(
815        &self,
816        alias: &str,
817        key: Option<&str>,
818        _api_url: Option<&str>,
819        _opts: &ModelProviderRuntimeOptions,
820    ) -> Result<Box<dyn ModelProvider>> {
821        Ok(Box::new(crate::telnyx::TelnyxModelProvider::new(
822            alias, key,
823        )))
824    }
825}
826
827impl FamilyProviderFactory for AzureModelProviderConfig {
828    fn create_provider(
829        &self,
830        alias: &str,
831        key: Option<&str>,
832        _api_url: Option<&str>,
833        _opts: &ModelProviderRuntimeOptions,
834    ) -> Result<Box<dyn ModelProvider>> {
835        // Reads typed Azure alias fields directly. Operator sets these
836        // under `[model_providers.azure.<alias>]` or via the schema-mirror
837        // env grammar — env-var fallback eradicated.
838        let resource = self.resource.as_deref().ok_or_else(|| {
839            ::zeroclaw_log::record!(
840                ERROR,
841                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
842                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
843                    .with_attrs(::serde_json::json!({
844                        "family": "azure",
845                        "alias": alias,
846                        "missing": "resource",
847                    })),
848                "factory: azure provider missing resource"
849            );
850            anyhow::Error::msg(
851                "Azure model_provider requires `resource`: set \
852                 `[model_providers.azure.<alias>] resource = \"...\"` in config.toml.",
853            )
854        })?;
855        let deployment = self.deployment.as_deref().ok_or_else(|| {
856            ::zeroclaw_log::record!(
857                ERROR,
858                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
859                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
860                    .with_attrs(::serde_json::json!({
861                        "family": "azure",
862                        "alias": alias,
863                        "missing": "deployment",
864                    })),
865                "factory: azure provider missing deployment"
866            );
867            anyhow::Error::msg(
868                "Azure model_provider requires `deployment`: set \
869                 `[model_providers.azure.<alias>] deployment = \"...\"` in config.toml.",
870            )
871        })?;
872        let api_version = self.api_version.as_deref();
873        Ok(Box::new(
874            crate::azure_openai::AzureOpenAiModelProvider::new(
875                alias,
876                key,
877                resource,
878                deployment,
879                api_version,
880            ),
881        ))
882    }
883}
884
885impl FamilyProviderFactory for BedrockModelProviderConfig {
886    fn create_provider(
887        &self,
888        alias: &str,
889        key: Option<&str>,
890        _api_url: Option<&str>,
891        opts: &ModelProviderRuntimeOptions,
892    ) -> Result<Box<dyn ModelProvider>> {
893        let mut p = if let Some(api_key) = key {
894            crate::bedrock::BedrockModelProvider::with_bearer_token(alias, api_key)
895        } else {
896            crate::bedrock::BedrockModelProvider::new(alias)
897        };
898        if let Some(mt) = opts.provider_max_tokens {
899            p = p.with_max_tokens(mt);
900        }
901        Ok(Box::new(p))
902    }
903}
904
905impl FamilyProviderFactory for QwenModelProviderConfig {
906    fn create_provider(
907        &self,
908        alias: &str,
909        key: Option<&str>,
910        api_url: Option<&str>,
911        opts: &ModelProviderRuntimeOptions,
912    ) -> Result<Box<dyn ModelProvider>> {
913        // Per-alias OAuth refresh path: when `oauth_refresh_token` is set
914        // on this alias config, exchange it for a short-lived access
915        // token immediately. Operator override of the baked client_id
916        // and resource URL flow through the same path. When unset, fall
917        // through to the upstream `qwen login` file-cache integration.
918        let alias_oauth: Option<crate::QwenOauthCredentials> = self
919            .oauth_refresh_token
920            .as_deref()
921            .map(str::trim)
922            .filter(|s| !s.is_empty())
923            .map(|refresh_token| {
924                let client_id = self
925                    .oauth_client_id
926                    .as_deref()
927                    .map(str::trim)
928                    .filter(|s| !s.is_empty())
929                    .unwrap_or(crate::QWEN_OAUTH_DEFAULT_CLIENT_ID);
930                crate::refresh_qwen_oauth_access_token(refresh_token, client_id)
931            })
932            .transpose()?;
933
934        let oauth_context = if let Some(creds) = alias_oauth.as_ref() {
935            // Synthesize a context using the freshly-refreshed alias
936            // credentials, applying the operator's `oauth_resource_url`
937            // override if any.
938            crate::QwenOauthProviderContext {
939                credential: creds.access_token.clone(),
940                base_url: self
941                    .oauth_resource_url
942                    .as_deref()
943                    .map(str::trim)
944                    .filter(|s| !s.is_empty())
945                    .map(ToString::to_string)
946                    .or_else(|| creds.resource_url.clone()),
947            }
948        } else {
949            crate::resolve_qwen_oauth_context(key)
950        };
951
952        let resolved_key = oauth_context.credential.as_deref().or(key);
953        let base_url = api_url
954            .map(ToString::to_string)
955            .or_else(|| oauth_context.base_url.clone())
956            .unwrap_or_else(|| crate::QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
957        let p = if oauth_context.credential.is_some() {
958            OpenAiCompatibleModelProvider::new_with_user_agent_and_vision(
959                alias,
960                "Qwen Code",
961                &base_url,
962                resolved_key,
963                AuthStyle::Bearer,
964                "QwenCode/1.0",
965                true,
966            )
967        } else {
968            OpenAiCompatibleModelProvider::new_with_vision(
969                alias,
970                "Qwen",
971                &base_url,
972                resolved_key,
973                AuthStyle::Bearer,
974                true,
975            )
976        }
977        .with_models_dev_key("alibaba")
978        .with_openrouter_vendor_prefix("qwen");
979        Ok(apply_compat_options(p, opts))
980    }
981}
982
983impl FamilyProviderFactory for GroqModelProviderConfig {
984    fn create_provider(
985        &self,
986        alias: &str,
987        key: Option<&str>,
988        _api_url: Option<&str>,
989        opts: &ModelProviderRuntimeOptions,
990    ) -> Result<Box<dyn ModelProvider>> {
991        let mut p = OpenAiCompatibleModelProvider::new(
992            alias,
993            "Groq",
994            "https://api.groq.com/openai/v1",
995            key,
996            AuthStyle::Bearer,
997        )
998        .with_models_dev_key("groq");
999        // Groq's llama-family models reject native tool calls with HTTP
1000        // 400; default to text-fallback. Operators can override per-alias
1001        // via `[model_providers.groq.<alias>] native_tools = true`.
1002        if opts.native_tools != Some(true) {
1003            p = p.without_native_tools();
1004        }
1005        Ok(apply_compat_options(p, opts))
1006    }
1007}
1008
1009impl FamilyProviderFactory for CopilotModelProviderConfig {
1010    fn create_provider(
1011        &self,
1012        alias: &str,
1013        key: Option<&str>,
1014        _api_url: Option<&str>,
1015        _opts: &ModelProviderRuntimeOptions,
1016    ) -> Result<Box<dyn ModelProvider>> {
1017        Ok(Box::new(crate::copilot::CopilotModelProvider::new(
1018            alias, key,
1019        )))
1020    }
1021}
1022
1023impl FamilyProviderFactory for GeminiCliModelProviderConfig {
1024    fn create_provider(
1025        &self,
1026        alias: &str,
1027        _key: Option<&str>,
1028        _api_url: Option<&str>,
1029        _opts: &ModelProviderRuntimeOptions,
1030    ) -> Result<Box<dyn ModelProvider>> {
1031        Ok(Box::new(crate::gemini_cli::GeminiCliModelProvider::new(
1032            alias,
1033            self.binary_path.as_deref(),
1034        )))
1035    }
1036}
1037
1038impl FamilyProviderFactory for KiloCliModelProviderConfig {
1039    fn create_provider(
1040        &self,
1041        alias: &str,
1042        _key: Option<&str>,
1043        _api_url: Option<&str>,
1044        _opts: &ModelProviderRuntimeOptions,
1045    ) -> Result<Box<dyn ModelProvider>> {
1046        Ok(Box::new(crate::kilocli::KiloCliModelProvider::new(
1047            alias,
1048            self.binary_path.as_deref(),
1049        )))
1050    }
1051}
1052
1053impl FamilyProviderFactory for LmstudioModelProviderConfig {
1054    fn create_provider(
1055        &self,
1056        alias: &str,
1057        key: Option<&str>,
1058        api_url: Option<&str>,
1059        opts: &ModelProviderRuntimeOptions,
1060    ) -> Result<Box<dyn ModelProvider>> {
1061        let lm_studio_key = key
1062            .map(str::trim)
1063            .filter(|value| !value.is_empty())
1064            .unwrap_or("lm-studio");
1065        let p = OpenAiCompatibleModelProvider::new(
1066            alias,
1067            "LM Studio",
1068            api_url.unwrap_or("http://localhost:1234/v1"),
1069            Some(lm_studio_key),
1070            AuthStyle::Bearer,
1071        );
1072        Ok(apply_compat_options(p, opts))
1073    }
1074}
1075
1076impl FamilyProviderFactory for LlamacppModelProviderConfig {
1077    fn create_provider(
1078        &self,
1079        alias: &str,
1080        key: Option<&str>,
1081        api_url: Option<&str>,
1082        opts: &ModelProviderRuntimeOptions,
1083    ) -> Result<Box<dyn ModelProvider>> {
1084        let base_url = api_url.unwrap_or("http://localhost:8080/v1");
1085        let llama_cpp_key = key
1086            .map(str::trim)
1087            .filter(|value| !value.is_empty())
1088            .unwrap_or("llama.cpp");
1089        let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1090            alias,
1091            "llama.cpp",
1092            base_url,
1093            Some(llama_cpp_key),
1094            AuthStyle::Bearer,
1095            true,
1096        )
1097        .with_local_model_tool_sanitize();
1098        if opts.merge_system_into_user {
1099            p = p.with_merge_system_into_user();
1100        }
1101        Ok(apply_compat_options(p, opts))
1102    }
1103}
1104
1105impl FamilyProviderFactory for OsaurusModelProviderConfig {
1106    fn create_provider(
1107        &self,
1108        alias: &str,
1109        key: Option<&str>,
1110        api_url: Option<&str>,
1111        opts: &ModelProviderRuntimeOptions,
1112    ) -> Result<Box<dyn ModelProvider>> {
1113        let osaurus_key = key
1114            .map(str::trim)
1115            .filter(|value| !value.is_empty())
1116            .unwrap_or("osaurus");
1117        let p = OpenAiCompatibleModelProvider::new(
1118            alias,
1119            "Osaurus",
1120            api_url.unwrap_or("http://localhost:1337/v1"),
1121            Some(osaurus_key),
1122            AuthStyle::Bearer,
1123        );
1124        Ok(apply_compat_options(p, opts))
1125    }
1126}
1127
1128impl FamilyProviderFactory for OvhModelProviderConfig {
1129    fn create_provider(
1130        &self,
1131        alias: &str,
1132        key: Option<&str>,
1133        _api_url: Option<&str>,
1134        _opts: &ModelProviderRuntimeOptions,
1135    ) -> Result<Box<dyn ModelProvider>> {
1136        Ok(Box::new(crate::openai::OpenAiModelProvider::with_base_url(
1137            alias,
1138            Some("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"),
1139            key,
1140        )))
1141    }
1142}
1143
1144impl FamilyProviderFactory for CustomModelProviderConfig {
1145    fn create_provider(
1146        &self,
1147        alias: &str,
1148        key: Option<&str>,
1149        api_url: Option<&str>,
1150        opts: &ModelProviderRuntimeOptions,
1151    ) -> Result<Box<dyn ModelProvider>> {
1152        let base_url = api_url.ok_or_else(|| {
1153            ::zeroclaw_log::record!(
1154                ERROR,
1155                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1156                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1157                    .with_attrs(::serde_json::json!({
1158                        "family": "custom",
1159                        "alias": alias,
1160                        "missing": "uri",
1161                    })),
1162                "factory: custom provider missing uri"
1163            );
1164            anyhow::Error::msg(
1165                "Custom model_provider requires `uri`: set \
1166                 `[model_providers.custom.<alias>] uri = \"https://your-api.com\"` in config.toml.",
1167            )
1168        })?;
1169        let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1170            alias,
1171            "Custom",
1172            base_url,
1173            key,
1174            AuthStyle::Bearer,
1175            true,
1176        );
1177        if opts.merge_system_into_user {
1178            p = p.with_merge_system_into_user();
1179        }
1180        Ok(apply_compat_options(p, opts))
1181    }
1182}
1183
1184impl FamilyProviderFactory for zeroclaw_config::schema::ModelProviderConfig {
1185    fn create_provider(
1186        &self,
1187        alias: &str,
1188        key: Option<&str>,
1189        api_url: Option<&str>,
1190        opts: &ModelProviderRuntimeOptions,
1191    ) -> Result<Box<dyn ModelProvider>> {
1192        let base_url = api_url.ok_or_else(|| {
1193            anyhow::Error::msg(
1194                "OpenAI-compatible model_provider requires `uri`: set \
1195                 `[model_providers.<family>.<alias>] uri = \"https://your-api.com\"` in config.toml.",
1196            )
1197        })?;
1198        let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1199            alias,
1200            "OpenAI Compatible",
1201            base_url,
1202            key,
1203            AuthStyle::Bearer,
1204            true,
1205        );
1206        if opts.merge_system_into_user {
1207            p = p.with_merge_system_into_user();
1208        }
1209        Ok(apply_compat_options(p, opts))
1210    }
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215    use super::*;
1216    use zeroclaw_config::schema::ModelProviderConfig;
1217
1218    #[test]
1219    fn openai_factory_routes_to_codex_when_requires_openai_auth_true() {
1220        let cfg = OpenAIModelProviderConfig {
1221            base: ModelProviderConfig {
1222                requires_openai_auth: true,
1223                ..Default::default()
1224            },
1225        };
1226        let provider = cfg
1227            .create_provider("test", None, None, &ModelProviderRuntimeOptions::default())
1228            .unwrap();
1229        // OpenAiCodexModelProvider reports native_tool_calling; standard OpenAiModelProvider does not
1230        assert!(provider.capabilities().native_tool_calling);
1231    }
1232
1233    #[test]
1234    fn openai_factory_routes_to_standard_when_requires_openai_auth_false() {
1235        let cfg = OpenAIModelProviderConfig {
1236            base: ModelProviderConfig {
1237                requires_openai_auth: false,
1238                ..Default::default()
1239            },
1240        };
1241        let provider = cfg
1242            .create_provider("test", None, None, &ModelProviderRuntimeOptions::default())
1243            .unwrap();
1244        assert!(!provider.capabilities().native_tool_calling);
1245    }
1246
1247    #[tokio::test]
1248    async fn zai_and_glm_factory_path_honors_api_url_override() {
1249        use axum::{Json, Router, extract::State, http::Uri, routing::post};
1250        use serde_json::{Value, json};
1251        use std::sync::{Arc, Mutex};
1252
1253        type Capture = Arc<Mutex<Vec<String>>>;
1254
1255        async fn capture_chat_request(
1256            State(capture): State<Capture>,
1257            uri: Uri,
1258            Json(_body): Json<Value>,
1259        ) -> Json<Value> {
1260            capture
1261                .lock()
1262                .expect("capture lock poisoned")
1263                .push(uri.path().to_string());
1264            Json(json!({
1265                "choices": [{"message": {"content": "ok"}}]
1266            }))
1267        }
1268
1269        let capture: Capture = Arc::new(Mutex::new(Vec::new()));
1270        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1271            .await
1272            .expect("bind test server");
1273        let addr = listener.local_addr().expect("test server addr");
1274        let app = Router::new()
1275            .route(
1276                "/zai/api/paas/v4/chat/completions",
1277                post(capture_chat_request),
1278            )
1279            .route(
1280                "/glm/api/paas/v4/chat/completions",
1281                post(capture_chat_request),
1282            )
1283            .with_state(capture.clone());
1284        let server = tokio::spawn(async move {
1285            axum::serve(listener, app).await.expect("serve test server");
1286        });
1287
1288        let base_url = format!("http://{addr}");
1289        let zai_url = format!("{base_url}/zai/api/paas/v4");
1290        let zai = ZaiModelProviderConfig::default()
1291            .create_provider(
1292                "cn",
1293                Some("id.secret"),
1294                Some(&zai_url),
1295                &ModelProviderRuntimeOptions::default(),
1296            )
1297            .expect("zai provider should build");
1298        assert_eq!(
1299            zai.chat_with_system(None, "hello", "glm-5-turbo", Some(0.7))
1300                .await
1301                .expect("zai chat should use overridden URL"),
1302            "ok"
1303        );
1304
1305        let glm_url = format!("{base_url}/glm/api/paas/v4");
1306        let glm = GlmModelProviderConfig::default()
1307            .create_provider(
1308                "global",
1309                Some("id.secret"),
1310                Some(&glm_url),
1311                &ModelProviderRuntimeOptions::default(),
1312            )
1313            .expect("glm provider should build");
1314        assert!(glm.capabilities().vision);
1315        assert_eq!(
1316            glm.chat_with_system(None, "hello", "glm-4.5", Some(0.7))
1317                .await
1318                .expect("glm chat should use overridden URL"),
1319            "ok"
1320        );
1321
1322        let paths = capture.lock().expect("capture lock poisoned").clone();
1323        assert_eq!(
1324            paths,
1325            vec![
1326                "/zai/api/paas/v4/chat/completions".to_string(),
1327                "/glm/api/paas/v4/chat/completions".to_string(),
1328            ]
1329        );
1330        server.abort();
1331    }
1332
1333    #[test]
1334    fn ollama_factory_uses_no_credential_when_key_absent() {
1335        let provider = build_ollama_compat_provider(
1336            "default",
1337            None,
1338            Some("http://192.168.1.100:11434/v1"),
1339            &ModelProviderRuntimeOptions::default(),
1340        );
1341
1342        assert_eq!(provider.name, "Ollama");
1343        assert_eq!(provider.base_url, "http://192.168.1.100:11434/v1");
1344        assert!(provider.credential.is_none());
1345    }
1346
1347    #[test]
1348    fn ollama_factory_normalizes_host_root_to_openai_compat_base() {
1349        let provider = build_ollama_compat_provider(
1350            "default",
1351            None,
1352            Some("http://192.168.1.100:11434"),
1353            &ModelProviderRuntimeOptions::default(),
1354        );
1355
1356        assert_eq!(provider.base_url, "http://192.168.1.100:11434/v1");
1357    }
1358
1359    #[test]
1360    fn ollama_factory_normalizes_legacy_api_path_to_openai_compat_base() {
1361        let provider = build_ollama_compat_provider(
1362            "default",
1363            None,
1364            Some("https://ollama.com/api"),
1365            &ModelProviderRuntimeOptions::default(),
1366        );
1367
1368        assert_eq!(provider.base_url, "https://ollama.com/v1");
1369    }
1370
1371    #[test]
1372    fn ollama_factory_preserves_typed_api_key_for_official_cloud() {
1373        let provider = build_ollama_compat_provider(
1374            "default",
1375            Some("  ollama-key  "),
1376            Some("https://ollama.com/v1"),
1377            &ModelProviderRuntimeOptions::default(),
1378        );
1379
1380        assert_eq!(provider.credential.as_deref(), Some("ollama-key"));
1381    }
1382}