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::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    /// Wire protocol selector for this entry, when the family reads it from
75    /// per-alias config. Defaults to `None` so families that only speak
76    /// chat_completions need no override.
77    ///
78    /// A family whose endpoint can serve the responses wire MUST override this
79    /// to return `self.base.wire_api`; otherwise a user's
80    /// `wire_api = "responses"` is silently ignored and routed through
81    /// chat_completions. The blanket `create_provider` consults this before
82    /// building the compat client.
83    fn wire_api(&self) -> Option<zeroclaw_config::schema::WireApi> {
84        None
85    }
86
87    /// Whether this provider's `/models` endpoint is accessible without an
88    /// API key. When `true`, `list_models()` and `list_models_with_pricing()`
89    /// will query the live endpoint even when no credential is configured.
90    /// Defaults to `false`; set to `true` for providers like Kilo Gateway
91    /// whose model catalog is public.
92    const PUBLIC_MODEL_LISTING: bool = false;
93
94    /// Build the base compat provider with both catalog consts applied. Use
95    /// this from inside `build_compat` overrides so the catalog hooks ride
96    /// along with any family-specific modifiers.
97    fn build_compat_base(
98        &self,
99        alias: &str,
100        key: Option<&str>,
101        api_url: Option<&str>,
102    ) -> OpenAiCompatibleModelProvider {
103        let mut p = OpenAiCompatibleModelProvider::new(
104            alias,
105            Self::DISPLAY,
106            api_url.unwrap_or(Self::DEFAULT_URL),
107            key,
108            Self::AUTH,
109        );
110        if let Some(catalog_key) = Self::MODELS_DEV_KEY {
111            p = p.with_models_dev_key(catalog_key);
112        }
113        if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
114            p = p.with_openrouter_vendor_prefix(prefix);
115        }
116        if Self::PUBLIC_MODEL_LISTING {
117            p = p.with_public_model_listing();
118        }
119        p
120    }
121
122    /// Build the underlying compat provider. Default just returns the base
123    /// from `build_compat_base`; override to chain family-specific
124    /// modifiers (e.g. `.without_native_tools()`, `.with_merge_system_into_user()`).
125    fn build_compat(
126        &self,
127        alias: &str,
128        key: Option<&str>,
129        api_url: Option<&str>,
130    ) -> OpenAiCompatibleModelProvider {
131        self.build_compat_base(alias, key, api_url)
132    }
133}
134
135impl<T: CompatFamilySpec> FamilyProviderFactory for T {
136    fn create_provider(
137        &self,
138        alias: &str,
139        key: Option<&str>,
140        api_url: Option<&str>,
141        opts: &ModelProviderRuntimeOptions,
142    ) -> Result<Box<dyn ModelProvider>> {
143        if let Some(p) = build_responses_provider_if_requested(
144            self.wire_api(),
145            alias,
146            api_url.or(Some(Self::DEFAULT_URL)),
147            key,
148            opts,
149        ) {
150            return Ok(p);
151        }
152        Ok(apply_compat_options(
153            self.build_compat(alias, key, api_url),
154            opts,
155        ))
156    }
157}
158
159/// Apply cross-cutting compat post-processing (timeout, headers, api_path,
160/// max_tokens, reasoning effort) to a freshly-constructed compat provider
161/// and box it for trait-object dispatch. Single source of the post-process
162/// chain — every compat impl funnels through here.
163pub fn apply_compat_options(
164    mut p: OpenAiCompatibleModelProvider,
165    opts: &ModelProviderRuntimeOptions,
166) -> Box<dyn ModelProvider> {
167    if let Some(t) = opts.provider_timeout_secs {
168        p = p.with_timeout_secs(t);
169    }
170    if let Some(ref effort) = opts.reasoning_effort {
171        p = p.with_reasoning_effort(Some(effort.clone()));
172    }
173    if !opts.extra_headers.is_empty() {
174        p = p.with_extra_headers(opts.extra_headers.clone());
175    }
176    if opts.api_path.is_some() {
177        p = p.with_api_path(opts.api_path.clone());
178    }
179    if let Some(mt) = opts.provider_max_tokens {
180        p = p.with_max_tokens(Some(mt));
181    }
182    Box::new(p)
183}
184
185/// Build an `OpenAiResponsesModelProvider` from the per-alias runtime options,
186/// applying the same `max_tokens` / `reasoning_effort` overrides every family
187/// that speaks the responses wire shares. Returns `None` unless `wire_api`
188/// selects the responses protocol, so a caller can route with a single
189/// `if let Some(p) = build_responses_provider_if_requested(..)` and fall
190/// through to its chat-completions build otherwise.
191fn build_responses_provider_if_requested(
192    wire_api: Option<zeroclaw_config::schema::WireApi>,
193    alias: &str,
194    base_url: Option<&str>,
195    key: Option<&str>,
196    opts: &ModelProviderRuntimeOptions,
197) -> Option<Box<dyn ModelProvider>> {
198    if wire_api != Some(zeroclaw_config::schema::WireApi::Responses) {
199        return None;
200    }
201    let mut p = crate::openai::OpenAiResponsesModelProvider::new(alias, base_url, key);
202    if let Some(mt) = opts.provider_max_tokens {
203        p = p.with_max_tokens(Some(mt));
204    }
205    if let Some(ref effort) = opts.reasoning_effort {
206        p = p.with_reasoning_effort(Some(effort.clone()));
207    }
208    Some(Box::new(p))
209}
210
211pub(crate) fn build_kimi_code_compat(
212    alias: &str,
213    key: Option<&str>,
214    base_url: &str,
215) -> OpenAiCompatibleModelProvider {
216    OpenAiCompatibleModelProvider::new_with_user_agent_and_vision(
217        alias,
218        "Kimi Code",
219        base_url,
220        key,
221        AuthStyle::Bearer,
222        "KimiCLI/0.77",
223        true,
224    )
225    .with_models_dev_key("moonshotai")
226}
227
228/// Dispatch family construction by routing `(family, alias)` to the typed
229/// slot's `FamilyProviderFactory` impl. Generated from
230/// `for_each_model_provider_slot!` so the family list lives in exactly one
231/// place — adding a row to the slot macro requires a corresponding impl,
232/// caught at compile time when the macro expands.
233///
234/// `family` is the canonicalized family name (post-V2 synonym mapping);
235/// `alias` is the per-family entry key (`default`, `prod_v2`, …).
236///
237/// `config` is `Option` so legacy entry points (tests, programmatic
238/// factory calls without agent context) can dispatch without a real
239/// `Config` — those fall back to the family struct's `Default` impl,
240/// which gives compat-only families full functionality and bespoke
241/// families their unconfigured defaults (Azure errors helpfully on
242/// missing `resource`, etc.).
243pub fn dispatch_family_factory(
244    config: Option<&zeroclaw_config::schema::Config>,
245    family: &str,
246    alias: &str,
247    key: Option<&str>,
248    api_url: Option<&str>,
249    opts: &ModelProviderRuntimeOptions,
250) -> Result<Box<dyn ModelProvider>> {
251    macro_rules! emit_dispatch {
252        ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
253            match family {
254                "openai-compatible" | "openai_compatible" => {
255                    let default_cfg = zeroclaw_config::schema::ModelProviderConfig::default();
256                    let cfg = config
257                        .and_then(|c| c.providers.models.find("openai", alias))
258                        .unwrap_or(&default_cfg);
259                    cfg.create_provider(alias, key, api_url, opts)
260                }
261                $(
262                    $type_str => {
263                        let default_cfg: $cfg_ty;
264                        let cfg: &$cfg_ty = match config.and_then(|c| c.providers.models.$field.get(alias)) {
265                            Some(c) => c,
266                            None => {
267                                default_cfg = <$cfg_ty>::default();
268                                &default_cfg
269                            }
270                        };
271                        cfg.create_provider(alias, key, api_url, opts)
272                    }
273                )+
274                _ => {
275                    ::zeroclaw_log::record!(
276                        ERROR,
277                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
278                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
279                            .with_attrs(::serde_json::json!({"family": family})),
280                        "factory: unknown model_provider family"
281                    );
282                    Err(anyhow::Error::msg(format!(
283                        "Unknown model_provider family: {family}. After the V2 to typed-family migration, \
284                         only canonical family names are valid. Run `zeroclaw quickstart` to reconfigure, \
285                         or set `[providers.models.custom.<alias>] uri = \"https://your-api.com\"` for \
286                         OpenAI-compatible custom endpoints."
287                    )))
288                },
289            }
290        }
291    }
292    zeroclaw_config::for_each_model_provider_slot!(emit_dispatch)
293}
294
295// ════════════════════════════════════════════════════════════════════════
296// Per-family impls — grouped by category. Adding a family means: one row
297// in `for_each_model_provider_slot!` (zeroclaw-config) plus one impl
298// here. Compiler enforces both via the slot-macro-driven dispatch above.
299// ════════════════════════════════════════════════════════════════════════
300
301use zeroclaw_config::schema::{
302    Ai21ModelProviderConfig, AihubmixModelProviderConfig, AnthropicModelProviderConfig,
303    AnyscaleModelProviderConfig, ArceeModelProviderConfig, AstraiModelProviderConfig,
304    AtomicChatModelProviderConfig, AvianModelProviderConfig, AzureModelProviderConfig,
305    BaichuanModelProviderConfig, BasetenModelProviderConfig, BedrockModelProviderConfig,
306    CerebrasModelProviderConfig, CloudflareModelProviderConfig, CohereModelProviderConfig,
307    CopilotModelProviderConfig, CustomModelProviderConfig, DeepinfraModelProviderConfig,
308    DeepmystModelProviderConfig, DeepseekModelProviderConfig, DoubaoModelProviderConfig,
309    FeatherlessModelProviderConfig, FireworksModelProviderConfig, FriendliModelProviderConfig,
310    GeminiCliModelProviderConfig, GeminiModelProviderConfig, GithubModelsModelProviderConfig,
311    GlmModelProviderConfig, GroqModelProviderConfig, HuggingfaceModelProviderConfig,
312    HunyuanModelProviderConfig, HyperbolicModelProviderConfig, InceptionModelProviderConfig,
313    KiloCliModelProviderConfig, KiloModelProviderConfig, LambdaAiModelProviderConfig,
314    LeptonModelProviderConfig, LitellmModelProviderConfig, LlamacppModelProviderConfig,
315    LmstudioModelProviderConfig, MinimaxModelProviderConfig, MistralModelProviderConfig,
316    MoonshotEndpoint, MoonshotModelProviderConfig, MorphModelProviderConfig,
317    NebiusModelProviderConfig, NovitaModelProviderConfig, NscaleModelProviderConfig,
318    NvidiaModelProviderConfig, OllamaModelProviderConfig, OpenAIModelProviderConfig,
319    OpenRouterModelProviderConfig, OpencodeModelProviderConfig, OsaurusModelProviderConfig,
320    OvhModelProviderConfig, PerplexityModelProviderConfig, QianfanModelProviderConfig,
321    QwenModelProviderConfig, RekaModelProviderConfig, SambanovaModelProviderConfig,
322    SglangModelProviderConfig, SiliconflowModelProviderConfig, StepfunModelProviderConfig,
323    SyntheticModelProviderConfig, TelnyxModelProviderConfig, TogetherModelProviderConfig,
324    UpstageModelProviderConfig, VeniceModelProviderConfig, VercelModelProviderConfig,
325    VllmModelProviderConfig, XaiModelProviderConfig, YiModelProviderConfig, ZaiModelProviderConfig,
326};
327
328// ── Pure-compat families ───────────────────────────────────────────────
329// `OpenAiCompatibleModelProvider::new(DISPLAY, DEFAULT_URL, key, AUTH)` —
330// no modifiers, no per-alias logic. The blanket impl supplies
331// `FamilyProviderFactory` automatically.
332
333impl CompatFamilySpec for VercelModelProviderConfig {
334    const DISPLAY: &'static str = "Vercel AI Gateway";
335    const DEFAULT_URL: &'static str = crate::VERCEL_AI_GATEWAY_BASE_URL;
336    const AUTH: AuthStyle = AuthStyle::Bearer;
337    const MODELS_DEV_KEY: Option<&'static str> = Some("vercel");
338}
339impl CompatFamilySpec for CloudflareModelProviderConfig {
340    const DISPLAY: &'static str = "Cloudflare AI Gateway";
341    const DEFAULT_URL: &'static str = "https://gateway.ai.cloudflare.com/v1";
342    const AUTH: AuthStyle = AuthStyle::Bearer;
343    const MODELS_DEV_KEY: Option<&'static str> = Some("cloudflare-ai-gateway");
344}
345impl CompatFamilySpec for SyntheticModelProviderConfig {
346    const DISPLAY: &'static str = "Synthetic";
347    const DEFAULT_URL: &'static str = "https://api.synthetic.new/openai/v1";
348    const AUTH: AuthStyle = AuthStyle::Bearer;
349    const MODELS_DEV_KEY: Option<&'static str> = Some("synthetic");
350}
351impl CompatFamilySpec for OpencodeModelProviderConfig {
352    const DISPLAY: &'static str = "OpenCode Zen";
353    const DEFAULT_URL: &'static str = "https://opencode.ai/zen/v1";
354    const AUTH: AuthStyle = AuthStyle::Bearer;
355    const MODELS_DEV_KEY: Option<&'static str> = Some("opencode");
356
357    fn wire_api(&self) -> Option<zeroclaw_config::schema::WireApi> {
358        self.base.wire_api
359    }
360}
361impl CompatFamilySpec for DoubaoModelProviderConfig {
362    const DISPLAY: &'static str = "Doubao";
363    const DEFAULT_URL: &'static str = "https://ark.cn-beijing.volces.com/api/v3";
364    const AUTH: AuthStyle = AuthStyle::Bearer;
365    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("bytedance");
366}
367impl CompatFamilySpec for MistralModelProviderConfig {
368    const DISPLAY: &'static str = "Mistral";
369    const DEFAULT_URL: &'static str = "https://api.mistral.ai/v1";
370    const AUTH: AuthStyle = AuthStyle::Bearer;
371    const MODELS_DEV_KEY: Option<&'static str> = Some("mistral");
372}
373impl CompatFamilySpec for DeepseekModelProviderConfig {
374    const DISPLAY: &'static str = "DeepSeek";
375    const DEFAULT_URL: &'static str = "https://api.deepseek.com";
376    const AUTH: AuthStyle = AuthStyle::Bearer;
377    const MODELS_DEV_KEY: Option<&'static str> = Some("deepseek");
378}
379impl CompatFamilySpec for TogetherModelProviderConfig {
380    const DISPLAY: &'static str = "Together AI";
381    const DEFAULT_URL: &'static str = "https://api.together.xyz";
382    const AUTH: AuthStyle = AuthStyle::Bearer;
383    const MODELS_DEV_KEY: Option<&'static str> = Some("togetherai");
384}
385impl CompatFamilySpec for FireworksModelProviderConfig {
386    const DISPLAY: &'static str = "Fireworks AI";
387    const DEFAULT_URL: &'static str = "https://api.fireworks.ai/inference/v1";
388    const AUTH: AuthStyle = AuthStyle::Bearer;
389    const MODELS_DEV_KEY: Option<&'static str> = Some("fireworks-ai");
390}
391impl CompatFamilySpec for NovitaModelProviderConfig {
392    const DISPLAY: &'static str = "Novita AI";
393    const DEFAULT_URL: &'static str = "https://api.novita.ai/openai";
394    const AUTH: AuthStyle = AuthStyle::Bearer;
395    const MODELS_DEV_KEY: Option<&'static str> = Some("novita-ai");
396}
397impl CompatFamilySpec for PerplexityModelProviderConfig {
398    const DISPLAY: &'static str = "Perplexity";
399    const DEFAULT_URL: &'static str = "https://api.perplexity.ai";
400    const AUTH: AuthStyle = AuthStyle::Bearer;
401    const MODELS_DEV_KEY: Option<&'static str> = Some("perplexity");
402}
403impl CompatFamilySpec for CohereModelProviderConfig {
404    const DISPLAY: &'static str = "Cohere";
405    const DEFAULT_URL: &'static str = "https://api.cohere.com/compatibility";
406    const AUTH: AuthStyle = AuthStyle::Bearer;
407    const MODELS_DEV_KEY: Option<&'static str> = Some("cohere");
408}
409impl CompatFamilySpec for SglangModelProviderConfig {
410    const DISPLAY: &'static str = "SGLang";
411    const DEFAULT_URL: &'static str = "http://localhost:30000/v1";
412    const AUTH: AuthStyle = AuthStyle::Bearer;
413}
414impl CompatFamilySpec for VllmModelProviderConfig {
415    const DISPLAY: &'static str = "vLLM";
416    const DEFAULT_URL: &'static str = "http://localhost:8000/v1";
417    const AUTH: AuthStyle = AuthStyle::Bearer;
418}
419impl CompatFamilySpec for AstraiModelProviderConfig {
420    const DISPLAY: &'static str = "Astrai";
421    const DEFAULT_URL: &'static str = "https://as-trai.com/v1";
422    const AUTH: AuthStyle = AuthStyle::Bearer;
423}
424impl CompatFamilySpec for SiliconflowModelProviderConfig {
425    const DISPLAY: &'static str = "SiliconFlow";
426    const DEFAULT_URL: &'static str = "https://api.siliconflow.com/v1";
427    const AUTH: AuthStyle = AuthStyle::Bearer;
428    const MODELS_DEV_KEY: Option<&'static str> = Some("siliconflow");
429}
430impl CompatFamilySpec for AihubmixModelProviderConfig {
431    const DISPLAY: &'static str = "AiHubMix";
432    const DEFAULT_URL: &'static str = "https://aihubmix.com/v1";
433    const AUTH: AuthStyle = AuthStyle::Bearer;
434    const MODELS_DEV_KEY: Option<&'static str> = Some("aihubmix");
435}
436impl CompatFamilySpec for LitellmModelProviderConfig {
437    const DISPLAY: &'static str = "LiteLLM";
438    const DEFAULT_URL: &'static str = "http://localhost:4000/v1";
439    const AUTH: AuthStyle = AuthStyle::Bearer;
440}
441impl CompatFamilySpec for CerebrasModelProviderConfig {
442    const DISPLAY: &'static str = "Cerebras";
443    const DEFAULT_URL: &'static str = "https://api.cerebras.ai/v1";
444    const AUTH: AuthStyle = AuthStyle::Bearer;
445    const MODELS_DEV_KEY: Option<&'static str> = Some("cerebras");
446}
447impl CompatFamilySpec for SambanovaModelProviderConfig {
448    const DISPLAY: &'static str = "SambaNova";
449    const DEFAULT_URL: &'static str = "https://api.sambanova.ai/v1";
450    const AUTH: AuthStyle = AuthStyle::Bearer;
451    // No models.dev entry and no OpenRouter prefix — operator must paste a
452    // credential before `list_models` returns anything.
453}
454impl CompatFamilySpec for HyperbolicModelProviderConfig {
455    const DISPLAY: &'static str = "Hyperbolic";
456    const DEFAULT_URL: &'static str = "https://api.hyperbolic.xyz/v1";
457    const AUTH: AuthStyle = AuthStyle::Bearer;
458    // No models.dev entry and no OpenRouter prefix — operator must paste a
459    // credential before `list_models` returns anything.
460}
461impl CompatFamilySpec for DeepinfraModelProviderConfig {
462    const DISPLAY: &'static str = "DeepInfra";
463    const DEFAULT_URL: &'static str = "https://api.deepinfra.com/v1/openai";
464    const AUTH: AuthStyle = AuthStyle::Bearer;
465    const MODELS_DEV_KEY: Option<&'static str> = Some("deepinfra");
466}
467impl CompatFamilySpec for HuggingfaceModelProviderConfig {
468    const DISPLAY: &'static str = "Hugging Face";
469    const DEFAULT_URL: &'static str = "https://router.huggingface.co/v1";
470    const AUTH: AuthStyle = AuthStyle::Bearer;
471    const MODELS_DEV_KEY: Option<&'static str> = Some("huggingface");
472}
473impl CompatFamilySpec for Ai21ModelProviderConfig {
474    const DISPLAY: &'static str = "AI21 Labs";
475    const DEFAULT_URL: &'static str = "https://api.ai21.com/studio/v1";
476    const AUTH: AuthStyle = AuthStyle::Bearer;
477    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("ai21");
478}
479impl CompatFamilySpec for RekaModelProviderConfig {
480    const DISPLAY: &'static str = "Reka";
481    const DEFAULT_URL: &'static str = "https://api.reka.ai/v1";
482    const AUTH: AuthStyle = AuthStyle::Bearer;
483    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("rekaai");
484}
485impl CompatFamilySpec for BasetenModelProviderConfig {
486    const DISPLAY: &'static str = "Baseten";
487    const DEFAULT_URL: &'static str = "https://inference.baseten.co/v1";
488    const AUTH: AuthStyle = AuthStyle::Bearer;
489    const MODELS_DEV_KEY: Option<&'static str> = Some("baseten");
490}
491impl CompatFamilySpec for NscaleModelProviderConfig {
492    const DISPLAY: &'static str = "Nscale";
493    const DEFAULT_URL: &'static str = "https://inference.api.nscale.com/v1";
494    const AUTH: AuthStyle = AuthStyle::Bearer;
495}
496impl CompatFamilySpec for AnyscaleModelProviderConfig {
497    const DISPLAY: &'static str = "Anyscale";
498    const DEFAULT_URL: &'static str = "https://api.endpoints.anyscale.com/v1";
499    const AUTH: AuthStyle = AuthStyle::Bearer;
500}
501impl CompatFamilySpec for NebiusModelProviderConfig {
502    const DISPLAY: &'static str = "Nebius AI Studio";
503    const DEFAULT_URL: &'static str = "https://api.studio.nebius.ai/v1";
504    const AUTH: AuthStyle = AuthStyle::Bearer;
505    const MODELS_DEV_KEY: Option<&'static str> = Some("nebius");
506}
507impl CompatFamilySpec for FriendliModelProviderConfig {
508    const DISPLAY: &'static str = "Friendli AI";
509    const DEFAULT_URL: &'static str = "https://api.friendli.ai/serverless/v1";
510    const AUTH: AuthStyle = AuthStyle::Bearer;
511    const MODELS_DEV_KEY: Option<&'static str> = Some("friendli");
512}
513impl CompatFamilySpec for LeptonModelProviderConfig {
514    const DISPLAY: &'static str = "Lepton AI";
515    const DEFAULT_URL: &'static str = "https://llama3-1-405b.lepton.run/api/v1";
516    const AUTH: AuthStyle = AuthStyle::Bearer;
517}
518impl CompatFamilySpec for MorphModelProviderConfig {
519    const DISPLAY: &'static str = "Morph";
520    const DEFAULT_URL: &'static str = "https://api.morphllm.com/v1";
521    const AUTH: AuthStyle = AuthStyle::Bearer;
522}
523impl CompatFamilySpec for GithubModelsModelProviderConfig {
524    const DISPLAY: &'static str = "GitHub Models";
525    const DEFAULT_URL: &'static str = "https://models.github.ai/inference";
526    const AUTH: AuthStyle = AuthStyle::Bearer;
527}
528impl CompatFamilySpec for UpstageModelProviderConfig {
529    const DISPLAY: &'static str = "Upstage Solar";
530    // Current canonical base; the legacy `/v1/solar` path is deprecated.
531    const DEFAULT_URL: &'static str = "https://api.upstage.ai/v1";
532    const AUTH: AuthStyle = AuthStyle::Bearer;
533}
534impl CompatFamilySpec for FeatherlessModelProviderConfig {
535    const DISPLAY: &'static str = "Featherless AI";
536    const DEFAULT_URL: &'static str = "https://api.featherless.ai/v1";
537    const AUTH: AuthStyle = AuthStyle::Bearer;
538}
539impl CompatFamilySpec for ArceeModelProviderConfig {
540    const DISPLAY: &'static str = "Arcee AI";
541    // Arcee's OpenAI-compatible API lives at `/api/v1`, not the usual `/v1`.
542    const DEFAULT_URL: &'static str = "https://api.arcee.ai/api/v1";
543    const AUTH: AuthStyle = AuthStyle::Bearer;
544}
545impl CompatFamilySpec for LambdaAiModelProviderConfig {
546    const DISPLAY: &'static str = "Lambda AI";
547    const DEFAULT_URL: &'static str = "https://api.lambda.ai/v1";
548    const AUTH: AuthStyle = AuthStyle::Bearer;
549}
550impl CompatFamilySpec for InceptionModelProviderConfig {
551    const DISPLAY: &'static str = "Inception Labs";
552    const DEFAULT_URL: &'static str = "https://api.inceptionlabs.ai/v1";
553    const AUTH: AuthStyle = AuthStyle::Bearer;
554}
555impl CompatFamilySpec for StepfunModelProviderConfig {
556    const DISPLAY: &'static str = "Stepfun";
557    const DEFAULT_URL: &'static str = "https://api.stepfun.com/v1";
558    const AUTH: AuthStyle = AuthStyle::Bearer;
559    const MODELS_DEV_KEY: Option<&'static str> = Some("stepfun");
560    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("stepfun");
561}
562impl CompatFamilySpec for BaichuanModelProviderConfig {
563    const DISPLAY: &'static str = "Baichuan";
564    const DEFAULT_URL: &'static str = "https://api.baichuan-ai.com/v1";
565    const AUTH: AuthStyle = AuthStyle::Bearer;
566}
567impl CompatFamilySpec for YiModelProviderConfig {
568    const DISPLAY: &'static str = "01.AI (Yi)";
569    const DEFAULT_URL: &'static str = "https://api.lingyiwanwu.com/v1";
570    const AUTH: AuthStyle = AuthStyle::Bearer;
571}
572impl CompatFamilySpec for HunyuanModelProviderConfig {
573    const DISPLAY: &'static str = "Tencent Hunyuan";
574    const DEFAULT_URL: &'static str = "https://api.hunyuan.cloud.tencent.com/v1";
575    const AUTH: AuthStyle = AuthStyle::Bearer;
576    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("tencent");
577}
578impl CompatFamilySpec for AvianModelProviderConfig {
579    const DISPLAY: &'static str = "Avian";
580    const DEFAULT_URL: &'static str = "https://api.avian.io/v1";
581    const AUTH: AuthStyle = AuthStyle::Bearer;
582}
583impl CompatFamilySpec for DeepmystModelProviderConfig {
584    const DISPLAY: &'static str = "DeepMyst";
585    const DEFAULT_URL: &'static str = "https://api.deepmyst.com/v1";
586    const AUTH: AuthStyle = AuthStyle::Bearer;
587}
588impl CompatFamilySpec for MoonshotModelProviderConfig {
589    const DISPLAY: &'static str = "Moonshot";
590    const DEFAULT_URL: &'static str = crate::MOONSHOT_INTL_BASE_URL;
591    const AUTH: AuthStyle = AuthStyle::Bearer;
592    const MODELS_DEV_KEY: Option<&'static str> = Some("moonshotai");
593
594    fn build_compat(
595        &self,
596        alias: &str,
597        key: Option<&str>,
598        api_url: Option<&str>,
599    ) -> OpenAiCompatibleModelProvider {
600        let base_url = api_url.unwrap_or(Self::DEFAULT_URL);
601        if self.endpoint == MoonshotEndpoint::Code || base_url == crate::moonshot_code_base_url() {
602            return build_kimi_code_compat(alias, key, base_url);
603        }
604
605        self.build_compat_base(alias, key, api_url)
606    }
607}
608
609// ── Compat families with build_compat overrides ────────────────────────
610// Need a constructor variant or modifier chain that can't be expressed
611// purely through (DISPLAY, DEFAULT_URL, AUTH).
612
613impl CompatFamilySpec for VeniceModelProviderConfig {
614    const DISPLAY: &'static str = "Venice";
615    const DEFAULT_URL: &'static str = "https://api.venice.ai";
616    const AUTH: AuthStyle = AuthStyle::Bearer;
617    const MODELS_DEV_KEY: Option<&'static str> = Some("venice");
618    fn build_compat(
619        &self,
620        alias: &str,
621        key: Option<&str>,
622        api_url: Option<&str>,
623    ) -> OpenAiCompatibleModelProvider {
624        self.build_compat_base(alias, key, api_url)
625            .without_native_tools()
626    }
627}
628impl CompatFamilySpec for AtomicChatModelProviderConfig {
629    const DISPLAY: &'static str = "Atomic Chat";
630    /// Default endpoint for the Jan / Atomic Chat local OpenAI-compatible
631    /// runtime (`jan.ai`). Operators override via `api_url` on the alias
632    /// entry when they run it on a non-default port.
633    const DEFAULT_URL: &'static str = "http://127.0.0.1:1337/v1";
634    const AUTH: AuthStyle = AuthStyle::Bearer;
635    const MODELS_DEV_KEY: Option<&'static str> = Some("atomic-chat");
636    fn build_compat(
637        &self,
638        alias: &str,
639        key: Option<&str>,
640        api_url: Option<&str>,
641    ) -> OpenAiCompatibleModelProvider {
642        self.build_compat_base(alias, key, api_url)
643            .without_native_tools()
644    }
645}
646
647impl CompatFamilySpec for XaiModelProviderConfig {
648    const DISPLAY: &'static str = "xAI";
649    const DEFAULT_URL: &'static str = "https://api.x.ai/v1";
650    const AUTH: AuthStyle = AuthStyle::Bearer;
651    const MODELS_DEV_KEY: Option<&'static str> = Some("xai");
652}
653
654impl FamilyProviderFactory for MinimaxModelProviderConfig {
655    fn create_provider(
656        &self,
657        alias: &str,
658        key: Option<&str>,
659        api_url: Option<&str>,
660        opts: &ModelProviderRuntimeOptions,
661    ) -> Result<Box<dyn ModelProvider>> {
662        // OAuth refresh path: when the operator supplied an
663        // `oauth_refresh_token`, exchange it for a short-lived access
664        // token before constructing the provider. Region picked from
665        // the typed `endpoint` enum (Cn/Intl). Operators preferring
666        // dashboard-generated long-lived API keys leave the refresh
667        // token unset and populate `api_key` directly.
668        let refreshed_key: Option<String> = self
669            .oauth_refresh_token
670            .as_deref()
671            .map(str::trim)
672            .filter(|s| !s.is_empty())
673            .map(|refresh_token| {
674                let client_id = self
675                    .oauth_client_id
676                    .as_deref()
677                    .map(str::trim)
678                    .filter(|s| !s.is_empty())
679                    .unwrap_or(crate::MINIMAX_OAUTH_DEFAULT_CLIENT_ID);
680                crate::refresh_minimax_oauth_access_token(refresh_token, client_id, self.endpoint)
681            })
682            .transpose()?;
683        let resolved_key = refreshed_key.as_deref().or(key);
684        let p = OpenAiCompatibleModelProvider::new(
685            alias,
686            "MiniMax",
687            api_url.unwrap_or(crate::MINIMAX_INTL_BASE_URL),
688            resolved_key,
689            AuthStyle::Bearer,
690        )
691        .with_merge_system_into_user();
692        Ok(apply_compat_options(p, opts))
693    }
694}
695
696impl CompatFamilySpec for ZaiModelProviderConfig {
697    const DISPLAY: &'static str = "Z.AI";
698    const DEFAULT_URL: &'static str = crate::ZAI_GLOBAL_BASE_URL;
699    const AUTH: AuthStyle = AuthStyle::ZhipuJwt;
700    const MODELS_DEV_KEY: Option<&'static str> = Some("zai");
701    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("z-ai");
702}
703
704impl CompatFamilySpec for GlmModelProviderConfig {
705    const DISPLAY: &'static str = "GLM";
706    const DEFAULT_URL: &'static str = crate::GLM_GLOBAL_BASE_URL;
707    const AUTH: AuthStyle = AuthStyle::ZhipuJwt;
708    const MODELS_DEV_KEY: Option<&'static str> = Some("zhipuai");
709    fn build_compat(
710        &self,
711        alias: &str,
712        key: Option<&str>,
713        api_url: Option<&str>,
714    ) -> OpenAiCompatibleModelProvider {
715        // GLM exposes vision-capable models (e.g. `glm-4.5v`). Compose the
716        // catalog-conf'd base with vision flag override via the constructor
717        // variant; we replay both consts manually since this constructor
718        // path doesn't fold through `build_compat_base`.
719        let mut p = OpenAiCompatibleModelProvider::new_with_vision(
720            alias,
721            Self::DISPLAY,
722            api_url.unwrap_or(Self::DEFAULT_URL),
723            key,
724            Self::AUTH,
725            true,
726        );
727        if let Some(catalog_key) = Self::MODELS_DEV_KEY {
728            p = p.with_models_dev_key(catalog_key);
729        }
730        if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
731            p = p.with_openrouter_vendor_prefix(prefix);
732        }
733        p
734    }
735}
736
737impl CompatFamilySpec for NvidiaModelProviderConfig {
738    const DISPLAY: &'static str = "NVIDIA NIM";
739    const DEFAULT_URL: &'static str = "https://integrate.api.nvidia.com/v1";
740    const AUTH: AuthStyle = AuthStyle::Bearer;
741    const MODELS_DEV_KEY: Option<&'static str> = Some("nvidia");
742    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("nvidia");
743}
744
745impl CompatFamilySpec for QianfanModelProviderConfig {
746    const DISPLAY: &'static str = "Qianfan";
747    // Default is meaningless — `build_compat` always computes via
748    // `qianfan_base_url(api_url)`. Use the helper's default fallback as
749    // a placeholder.
750    const DEFAULT_URL: &'static str = "https://qianfan.baidubce.com/v2";
751    const AUTH: AuthStyle = AuthStyle::Bearer;
752    const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("baidu");
753    fn build_compat(
754        &self,
755        alias: &str,
756        key: Option<&str>,
757        api_url: Option<&str>,
758    ) -> OpenAiCompatibleModelProvider {
759        let base_url = crate::qianfan_base_url(api_url);
760        let computed_url = Some(base_url.as_str());
761        self.build_compat_base(alias, key, computed_url)
762    }
763}
764
765// ── Bespoke families ───────────────────────────────────────────────────
766// Construction reads typed alias fields directly off `&self`, or wraps a
767// non-compat runtime provider, or routes through extra runtime context
768// (auth services, key fallback defaults, conditional native_tools, …).
769
770impl FamilyProviderFactory for OpenRouterModelProviderConfig {
771    fn create_provider(
772        &self,
773        alias: &str,
774        key: Option<&str>,
775        _api_url: Option<&str>,
776        opts: &ModelProviderRuntimeOptions,
777    ) -> Result<Box<dyn ModelProvider>> {
778        let mut p =
779            crate::openrouter::OpenRouterModelProvider::new(alias, key, opts.provider_timeout_secs)
780                .with_max_tokens(opts.provider_max_tokens);
781        if let Some(extra) = opts.provider_extra.clone() {
782            p = p.with_extra_body(extra);
783        }
784        Ok(Box::new(p))
785    }
786}
787
788impl FamilyProviderFactory for AnthropicModelProviderConfig {
789    fn create_provider(
790        &self,
791        alias: &str,
792        key: Option<&str>,
793        api_url: Option<&str>,
794        opts: &ModelProviderRuntimeOptions,
795    ) -> Result<Box<dyn ModelProvider>> {
796        let mut p = crate::anthropic::AnthropicModelProvider::with_base_url(alias, key, api_url);
797        if let Some(mt) = opts.provider_max_tokens {
798            p = p.with_max_tokens(mt);
799        }
800        Ok(Box::new(p))
801    }
802}
803
804impl FamilyProviderFactory for OpenAIModelProviderConfig {
805    fn create_provider(
806        &self,
807        alias: &str,
808        key: Option<&str>,
809        api_url: Option<&str>,
810        opts: &ModelProviderRuntimeOptions,
811    ) -> Result<Box<dyn ModelProvider>> {
812        // Codex variant routing: OAuth subscription auth → Codex responses protocol.
813        if self.base.requires_openai_auth {
814            return Ok(Box::new(
815                crate::openai_codex::OpenAiCodexModelProvider::new(alias, opts, key)?,
816            ));
817        }
818        // Responses wire protocol with standard API key — full streaming tool calls.
819        if let Some(p) =
820            build_responses_provider_if_requested(self.base.wire_api, alias, api_url, key, opts)
821        {
822            return Ok(p);
823        }
824        // Default: chat_completions wire with standard API key.
825        let mut p = crate::openai::OpenAiModelProvider::with_base_url(alias, api_url, key);
826        if let Some(mt) = opts.provider_max_tokens {
827            p = p.with_max_tokens(Some(mt));
828        }
829        Ok(Box::new(p))
830    }
831}
832
833fn normalize_ollama_compat_base_url(api_url: Option<&str>) -> String {
834    let raw = api_url
835        .map(str::trim)
836        .filter(|value| !value.is_empty())
837        .unwrap_or("http://localhost:11434/v1");
838
839    let Ok(mut url) = reqwest::Url::parse(raw) else {
840        return raw.trim_end_matches('/').to_string();
841    };
842
843    let path = url.path().trim_end_matches('/');
844    if path.is_empty() || matches!(path, "/" | "/api" | "/api/chat") {
845        url.set_path("/v1");
846        return url.to_string().trim_end_matches('/').to_string();
847    }
848
849    raw.trim_end_matches('/').to_string()
850}
851
852fn build_ollama_compat_provider(
853    alias: &str,
854    key: Option<&str>,
855    api_url: Option<&str>,
856    opts: &ModelProviderRuntimeOptions,
857) -> OpenAiCompatibleModelProvider {
858    let base_url = normalize_ollama_compat_base_url(api_url);
859    let ollama_key = key.map(str::trim).filter(|value| !value.is_empty());
860    let mut p = OpenAiCompatibleModelProvider::new_with_vision(
861        alias,
862        "Ollama",
863        &base_url,
864        ollama_key,
865        AuthStyle::Bearer,
866        true,
867    )
868    .with_local_model_tool_sanitize()
869    .with_public_model_listing();
870    if opts.merge_system_into_user {
871        p = p.with_merge_system_into_user();
872    }
873    p
874}
875
876impl FamilyProviderFactory for OllamaModelProviderConfig {
877    fn create_provider(
878        &self,
879        alias: &str,
880        key: Option<&str>,
881        api_url: Option<&str>,
882        opts: &ModelProviderRuntimeOptions,
883    ) -> Result<Box<dyn ModelProvider>> {
884        Ok(apply_compat_options(
885            build_ollama_compat_provider(alias, key, api_url, opts),
886            opts,
887        ))
888    }
889}
890
891impl FamilyProviderFactory for GeminiModelProviderConfig {
892    fn create_provider(
893        &self,
894        alias: &str,
895        key: Option<&str>,
896        _api_url: Option<&str>,
897        opts: &ModelProviderRuntimeOptions,
898    ) -> Result<Box<dyn ModelProvider>> {
899        let state_dir = opts.zeroclaw_dir.clone().unwrap_or_else(|| {
900            directories::UserDirs::new().map_or_else(
901                || std::path::PathBuf::from(".zeroclaw"),
902                |dirs| dirs.home_dir().join(".zeroclaw"),
903            )
904        });
905        let auth_service = crate::auth::AuthService::new(&state_dir, opts.secrets_encrypt);
906        Ok(Box::new(crate::gemini::GeminiModelProvider::new_with_auth(
907            alias,
908            key,
909            auth_service,
910            opts.auth_profile_override.clone(),
911            self.oauth_project.clone(),
912            self.oauth_client_id.clone(),
913            self.oauth_client_secret.clone(),
914        )))
915    }
916}
917
918impl FamilyProviderFactory for TelnyxModelProviderConfig {
919    fn create_provider(
920        &self,
921        alias: &str,
922        key: Option<&str>,
923        _api_url: Option<&str>,
924        _opts: &ModelProviderRuntimeOptions,
925    ) -> Result<Box<dyn ModelProvider>> {
926        Ok(Box::new(crate::telnyx::TelnyxModelProvider::new(
927            alias, key,
928        )))
929    }
930}
931
932impl FamilyProviderFactory for AzureModelProviderConfig {
933    fn create_provider(
934        &self,
935        alias: &str,
936        key: Option<&str>,
937        _api_url: Option<&str>,
938        _opts: &ModelProviderRuntimeOptions,
939    ) -> Result<Box<dyn ModelProvider>> {
940        // Reads typed Azure alias fields directly. Operator sets these
941        // under `[providers.models.azure.<alias>]` or via the schema-mirror
942        // env grammar — env-var fallback eradicated.
943        let resource = self.resource.as_deref().ok_or_else(|| {
944            ::zeroclaw_log::record!(
945                ERROR,
946                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
947                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
948                    .with_attrs(::serde_json::json!({
949                        "family": "azure",
950                        "alias": alias,
951                        "missing": "resource",
952                    })),
953                "factory: azure provider missing resource"
954            );
955            anyhow::Error::msg(
956                "Azure model_provider requires `resource`: set \
957                 `[providers.models.azure.<alias>] resource = \"...\"` in config.toml.",
958            )
959        })?;
960        let deployment = self.deployment.as_deref().ok_or_else(|| {
961            ::zeroclaw_log::record!(
962                ERROR,
963                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
964                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
965                    .with_attrs(::serde_json::json!({
966                        "family": "azure",
967                        "alias": alias,
968                        "missing": "deployment",
969                    })),
970                "factory: azure provider missing deployment"
971            );
972            anyhow::Error::msg(
973                "Azure model_provider requires `deployment`: set \
974                 `[providers.models.azure.<alias>] deployment = \"...\"` in config.toml.",
975            )
976        })?;
977        let api_version = self.api_version.as_deref();
978        Ok(Box::new(
979            crate::azure_openai::AzureOpenAiModelProvider::new(
980                alias,
981                key,
982                resource,
983                deployment,
984                api_version,
985            ),
986        ))
987    }
988}
989
990impl FamilyProviderFactory for BedrockModelProviderConfig {
991    fn create_provider(
992        &self,
993        alias: &str,
994        key: Option<&str>,
995        _api_url: Option<&str>,
996        opts: &ModelProviderRuntimeOptions,
997    ) -> Result<Box<dyn ModelProvider>> {
998        let mut p = if let Some(api_key) = key {
999            crate::bedrock::BedrockModelProvider::with_bearer_token(alias, api_key)
1000        } else {
1001            crate::bedrock::BedrockModelProvider::new(alias)
1002        };
1003        if let Some(mt) = opts.provider_max_tokens {
1004            p = p.with_max_tokens(mt);
1005        }
1006        Ok(Box::new(p))
1007    }
1008}
1009
1010impl FamilyProviderFactory for QwenModelProviderConfig {
1011    fn create_provider(
1012        &self,
1013        alias: &str,
1014        key: Option<&str>,
1015        api_url: Option<&str>,
1016        opts: &ModelProviderRuntimeOptions,
1017    ) -> Result<Box<dyn ModelProvider>> {
1018        // Per-alias OAuth refresh path: when `oauth_refresh_token` is set
1019        // on this alias config, exchange it for a short-lived access
1020        // token immediately. Operator override of the baked client_id
1021        // and resource URL flow through the same path. When unset, fall
1022        // through to the upstream `qwen login` file-cache integration.
1023        let alias_oauth: Option<crate::QwenOauthCredentials> = self
1024            .oauth_refresh_token
1025            .as_deref()
1026            .map(str::trim)
1027            .filter(|s| !s.is_empty())
1028            .map(|refresh_token| {
1029                let client_id = self
1030                    .oauth_client_id
1031                    .as_deref()
1032                    .map(str::trim)
1033                    .filter(|s| !s.is_empty())
1034                    .unwrap_or(crate::QWEN_OAUTH_DEFAULT_CLIENT_ID);
1035                crate::refresh_qwen_oauth_access_token(refresh_token, client_id)
1036            })
1037            .transpose()?;
1038
1039        let oauth_context = if let Some(creds) = alias_oauth.as_ref() {
1040            // Synthesize a context using the freshly-refreshed alias
1041            // credentials, applying the operator's `oauth_resource_url`
1042            // override if any.
1043            crate::QwenOauthProviderContext {
1044                credential: creds.access_token.clone(),
1045                base_url: self
1046                    .oauth_resource_url
1047                    .as_deref()
1048                    .map(str::trim)
1049                    .filter(|s| !s.is_empty())
1050                    .map(ToString::to_string)
1051                    .or_else(|| creds.resource_url.clone()),
1052            }
1053        } else {
1054            crate::resolve_qwen_oauth_context(key)
1055        };
1056
1057        let resolved_key = oauth_context.credential.as_deref().or(key);
1058        let base_url = api_url
1059            .map(ToString::to_string)
1060            .or_else(|| oauth_context.base_url.clone())
1061            .unwrap_or_else(|| crate::QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
1062        let p = if oauth_context.credential.is_some() {
1063            OpenAiCompatibleModelProvider::new_with_user_agent_and_vision(
1064                alias,
1065                "Qwen Code",
1066                &base_url,
1067                resolved_key,
1068                AuthStyle::Bearer,
1069                "QwenCode/1.0",
1070                true,
1071            )
1072        } else {
1073            OpenAiCompatibleModelProvider::new_with_vision(
1074                alias,
1075                "Qwen",
1076                &base_url,
1077                resolved_key,
1078                AuthStyle::Bearer,
1079                true,
1080            )
1081        }
1082        .with_models_dev_key("alibaba")
1083        .with_openrouter_vendor_prefix("qwen");
1084        Ok(apply_compat_options(p, opts))
1085    }
1086}
1087
1088impl FamilyProviderFactory for GroqModelProviderConfig {
1089    fn create_provider(
1090        &self,
1091        alias: &str,
1092        key: Option<&str>,
1093        _api_url: Option<&str>,
1094        opts: &ModelProviderRuntimeOptions,
1095    ) -> Result<Box<dyn ModelProvider>> {
1096        let mut p = OpenAiCompatibleModelProvider::new(
1097            alias,
1098            "Groq",
1099            "https://api.groq.com/openai/v1",
1100            key,
1101            AuthStyle::Bearer,
1102        )
1103        .with_models_dev_key("groq");
1104        // Groq's llama-family models reject native tool calls with HTTP
1105        // 400; default to text-fallback. Operators can override per-alias
1106        // via `[providers.models.groq.<alias>] native_tools = true`.
1107        if opts.native_tools != Some(true) {
1108            p = p.without_native_tools();
1109        }
1110        Ok(apply_compat_options(p, opts))
1111    }
1112}
1113
1114impl FamilyProviderFactory for CopilotModelProviderConfig {
1115    fn create_provider(
1116        &self,
1117        alias: &str,
1118        key: Option<&str>,
1119        _api_url: Option<&str>,
1120        _opts: &ModelProviderRuntimeOptions,
1121    ) -> Result<Box<dyn ModelProvider>> {
1122        Ok(Box::new(crate::copilot::CopilotModelProvider::new(
1123            alias, key,
1124        )))
1125    }
1126}
1127
1128impl FamilyProviderFactory for GeminiCliModelProviderConfig {
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::gemini_cli::GeminiCliModelProvider::new(
1137            alias,
1138            self.binary_path.as_deref(),
1139        )))
1140    }
1141}
1142
1143impl FamilyProviderFactory for KiloCliModelProviderConfig {
1144    fn create_provider(
1145        &self,
1146        alias: &str,
1147        _key: Option<&str>,
1148        _api_url: Option<&str>,
1149        _opts: &ModelProviderRuntimeOptions,
1150    ) -> Result<Box<dyn ModelProvider>> {
1151        Ok(Box::new(crate::kilocli::KiloCliModelProvider::new(
1152            alias,
1153            self.binary_path.as_deref(),
1154        )))
1155    }
1156}
1157
1158// ── Kilo AI Gateway (OpenAI-compatible) ────────────────────────────────
1159
1160impl CompatFamilySpec for KiloModelProviderConfig {
1161    const DISPLAY: &'static str = "Kilo";
1162    // Canonical gateway host per https://kilo.ai/docs/gateway (api.kilo.ai;
1163    // app.kilo.ai is the web-app sign-in). Must stay in lockstep with
1164    // `KiloEndpoint::Gateway` in zeroclaw-config — see the
1165    // `kilo_gateway_default_url_matches_schema_endpoint` regression test.
1166    const DEFAULT_URL: &'static str = "https://api.kilo.ai/api/gateway";
1167    const AUTH: AuthStyle = AuthStyle::Bearer;
1168    const PUBLIC_MODEL_LISTING: bool = true;
1169}
1170
1171impl FamilyProviderFactory for LmstudioModelProviderConfig {
1172    fn create_provider(
1173        &self,
1174        alias: &str,
1175        key: Option<&str>,
1176        api_url: Option<&str>,
1177        opts: &ModelProviderRuntimeOptions,
1178    ) -> Result<Box<dyn ModelProvider>> {
1179        let lm_studio_key = key
1180            .map(str::trim)
1181            .filter(|value| !value.is_empty())
1182            .unwrap_or("lm-studio");
1183        let p = OpenAiCompatibleModelProvider::new(
1184            alias,
1185            "LM Studio",
1186            api_url.unwrap_or("http://localhost:1234/v1"),
1187            Some(lm_studio_key),
1188            AuthStyle::Bearer,
1189        );
1190        Ok(apply_compat_options(p, opts))
1191    }
1192}
1193
1194impl FamilyProviderFactory for LlamacppModelProviderConfig {
1195    fn create_provider(
1196        &self,
1197        alias: &str,
1198        key: Option<&str>,
1199        api_url: Option<&str>,
1200        opts: &ModelProviderRuntimeOptions,
1201    ) -> Result<Box<dyn ModelProvider>> {
1202        let base_url = api_url.unwrap_or("http://localhost:8080/v1");
1203        let llama_cpp_key = key
1204            .map(str::trim)
1205            .filter(|value| !value.is_empty())
1206            .unwrap_or("llama.cpp");
1207        if let Some(p) = build_responses_provider_if_requested(
1208            self.base.wire_api,
1209            alias,
1210            Some(base_url),
1211            Some(llama_cpp_key),
1212            opts,
1213        ) {
1214            return Ok(p);
1215        }
1216        let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1217            alias,
1218            "llama.cpp",
1219            base_url,
1220            Some(llama_cpp_key),
1221            AuthStyle::Bearer,
1222            true,
1223        )
1224        .with_local_model_tool_sanitize();
1225        if opts.merge_system_into_user {
1226            p = p.with_merge_system_into_user();
1227        }
1228        Ok(apply_compat_options(p, opts))
1229    }
1230}
1231
1232impl FamilyProviderFactory for OsaurusModelProviderConfig {
1233    fn create_provider(
1234        &self,
1235        alias: &str,
1236        key: Option<&str>,
1237        api_url: Option<&str>,
1238        opts: &ModelProviderRuntimeOptions,
1239    ) -> Result<Box<dyn ModelProvider>> {
1240        let osaurus_key = key
1241            .map(str::trim)
1242            .filter(|value| !value.is_empty())
1243            .unwrap_or("osaurus");
1244        let p = OpenAiCompatibleModelProvider::new(
1245            alias,
1246            "Osaurus",
1247            api_url.unwrap_or("http://localhost:1337/v1"),
1248            Some(osaurus_key),
1249            AuthStyle::Bearer,
1250        );
1251        Ok(apply_compat_options(p, opts))
1252    }
1253}
1254
1255impl FamilyProviderFactory for OvhModelProviderConfig {
1256    fn create_provider(
1257        &self,
1258        alias: &str,
1259        key: Option<&str>,
1260        _api_url: Option<&str>,
1261        _opts: &ModelProviderRuntimeOptions,
1262    ) -> Result<Box<dyn ModelProvider>> {
1263        Ok(Box::new(crate::openai::OpenAiModelProvider::with_base_url(
1264            alias,
1265            Some("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"),
1266            key,
1267        )))
1268    }
1269}
1270
1271impl FamilyProviderFactory for CustomModelProviderConfig {
1272    fn create_provider(
1273        &self,
1274        alias: &str,
1275        key: Option<&str>,
1276        api_url: Option<&str>,
1277        opts: &ModelProviderRuntimeOptions,
1278    ) -> Result<Box<dyn ModelProvider>> {
1279        let base_url = api_url.ok_or_else(|| {
1280            ::zeroclaw_log::record!(
1281                ERROR,
1282                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1283                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1284                    .with_attrs(::serde_json::json!({
1285                        "family": "custom",
1286                        "alias": alias,
1287                        "missing": "uri",
1288                    })),
1289                "factory: custom provider missing uri"
1290            );
1291            anyhow::Error::msg(
1292                "Custom model_provider requires `uri`: set \
1293                 `[providers.models.custom.<alias>] uri = \"https://your-api.com\"` in config.toml.",
1294            )
1295        })?;
1296        if let Some(p) = build_responses_provider_if_requested(
1297            self.base.wire_api,
1298            alias,
1299            Some(base_url),
1300            key,
1301            opts,
1302        ) {
1303            return Ok(p);
1304        }
1305        let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1306            alias,
1307            "Custom",
1308            base_url,
1309            key,
1310            AuthStyle::Bearer,
1311            true,
1312        );
1313        if opts.native_tools != Some(true) {
1314            p = p.without_native_tools();
1315        }
1316        if opts.merge_system_into_user {
1317            p = p.with_merge_system_into_user();
1318        }
1319        Ok(apply_compat_options(p, opts))
1320    }
1321}
1322
1323impl FamilyProviderFactory for zeroclaw_config::schema::ModelProviderConfig {
1324    fn create_provider(
1325        &self,
1326        alias: &str,
1327        key: Option<&str>,
1328        api_url: Option<&str>,
1329        opts: &ModelProviderRuntimeOptions,
1330    ) -> Result<Box<dyn ModelProvider>> {
1331        let base_url = api_url.ok_or_else(|| {
1332            anyhow::Error::msg(
1333                "OpenAI-compatible model_provider requires `uri`: set \
1334                 `[providers.models.<family>.<alias>] uri = \"https://your-api.com\"` in config.toml.",
1335            )
1336        })?;
1337        if let Some(p) =
1338            build_responses_provider_if_requested(self.wire_api, alias, Some(base_url), key, opts)
1339        {
1340            return Ok(p);
1341        }
1342        let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1343            alias,
1344            "OpenAI Compatible",
1345            base_url,
1346            key,
1347            AuthStyle::Bearer,
1348            true,
1349        );
1350        if opts.merge_system_into_user {
1351            p = p.with_merge_system_into_user();
1352        }
1353        Ok(apply_compat_options(p, opts))
1354    }
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360    use zeroclaw_config::schema::ModelProviderConfig;
1361
1362    /// Regression for the #7136 review: the Kilo Gateway default exists in two
1363    /// places — the typed `KiloEndpoint` in zeroclaw-config and the factory's
1364    /// `CompatFamilySpec::DEFAULT_URL` — and they must never drift apart.
1365    /// kilo.ai/docs/gateway documents `api.kilo.ai` as the canonical API host.
1366    #[test]
1367    fn kilo_gateway_default_url_matches_schema_endpoint() {
1368        use zeroclaw_config::schema::{KiloEndpoint, ModelEndpoint};
1369        assert_eq!(
1370            <KiloModelProviderConfig as CompatFamilySpec>::DEFAULT_URL,
1371            KiloEndpoint::default().uri(),
1372            "schema KiloEndpoint and factory DEFAULT_URL disagree on the Kilo Gateway URL"
1373        );
1374        assert_eq!(
1375            KiloEndpoint::default().uri(),
1376            "https://api.kilo.ai/api/gateway"
1377        );
1378    }
1379
1380    #[test]
1381    fn openai_factory_routes_to_codex_when_requires_openai_auth_true() {
1382        let cfg = OpenAIModelProviderConfig {
1383            base: ModelProviderConfig {
1384                requires_openai_auth: true,
1385                ..Default::default()
1386            },
1387        };
1388        let provider = cfg
1389            .create_provider("test", None, None, &ModelProviderRuntimeOptions::default())
1390            .unwrap();
1391        // OpenAiCodexModelProvider reports native_tool_calling; standard OpenAiModelProvider does not
1392        assert!(provider.capabilities().native_tool_calling);
1393    }
1394
1395    #[test]
1396    fn openai_factory_routes_to_standard_when_requires_openai_auth_false() {
1397        let cfg = OpenAIModelProviderConfig {
1398            base: ModelProviderConfig {
1399                requires_openai_auth: false,
1400                ..Default::default()
1401            },
1402        };
1403        let provider = cfg
1404            .create_provider("test", None, None, &ModelProviderRuntimeOptions::default())
1405            .unwrap();
1406        assert!(!provider.capabilities().native_tool_calling);
1407    }
1408
1409    #[tokio::test]
1410    async fn zai_and_glm_factory_path_honors_api_url_override() {
1411        use axum::{Json, Router, extract::State, http::Uri, routing::post};
1412        use serde_json::{Value, json};
1413        use std::sync::{Arc, Mutex};
1414
1415        type Capture = Arc<Mutex<Vec<String>>>;
1416
1417        async fn capture_chat_request(
1418            State(capture): State<Capture>,
1419            uri: Uri,
1420            Json(_body): Json<Value>,
1421        ) -> Json<Value> {
1422            capture
1423                .lock()
1424                .expect("capture lock poisoned")
1425                .push(uri.path().to_string());
1426            Json(json!({
1427                "choices": [{"message": {"content": "ok"}}]
1428            }))
1429        }
1430
1431        let capture: Capture = Arc::new(Mutex::new(Vec::new()));
1432        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1433            .await
1434            .expect("bind test server");
1435        let addr = listener.local_addr().expect("test server addr");
1436        let app = Router::new()
1437            .route(
1438                "/zai/api/paas/v4/chat/completions",
1439                post(capture_chat_request),
1440            )
1441            .route(
1442                "/glm/api/paas/v4/chat/completions",
1443                post(capture_chat_request),
1444            )
1445            .with_state(capture.clone());
1446        let server = zeroclaw_spawn::spawn!(async move {
1447            axum::serve(listener, app).await.expect("serve test server");
1448        });
1449
1450        let base_url = format!("http://{addr}");
1451        let zai_url = format!("{base_url}/zai/api/paas/v4");
1452        let zai = ZaiModelProviderConfig::default()
1453            .create_provider(
1454                "cn",
1455                Some("id.secret"),
1456                Some(&zai_url),
1457                &ModelProviderRuntimeOptions::default(),
1458            )
1459            .expect("zai provider should build");
1460        assert_eq!(
1461            zai.chat_with_system(None, "hello", "glm-5-turbo", Some(0.7))
1462                .await
1463                .expect("zai chat should use overridden URL"),
1464            "ok"
1465        );
1466
1467        let glm_url = format!("{base_url}/glm/api/paas/v4");
1468        let glm = GlmModelProviderConfig::default()
1469            .create_provider(
1470                "global",
1471                Some("id.secret"),
1472                Some(&glm_url),
1473                &ModelProviderRuntimeOptions::default(),
1474            )
1475            .expect("glm provider should build");
1476        assert!(glm.capabilities().vision);
1477        assert_eq!(
1478            glm.chat_with_system(None, "hello", "glm-4.5", Some(0.7))
1479                .await
1480                .expect("glm chat should use overridden URL"),
1481            "ok"
1482        );
1483
1484        let paths = capture.lock().expect("capture lock poisoned").clone();
1485        assert_eq!(
1486            paths,
1487            vec![
1488                "/zai/api/paas/v4/chat/completions".to_string(),
1489                "/glm/api/paas/v4/chat/completions".to_string(),
1490            ]
1491        );
1492        server.abort();
1493    }
1494
1495    #[test]
1496    fn ollama_factory_uses_no_credential_when_key_absent() {
1497        let provider = build_ollama_compat_provider(
1498            "default",
1499            None,
1500            Some("http://192.168.1.100:11434/v1"),
1501            &ModelProviderRuntimeOptions::default(),
1502        );
1503
1504        assert_eq!(provider.name, "Ollama");
1505        assert_eq!(provider.base_url, "http://192.168.1.100:11434/v1");
1506        assert!(provider.credential.is_none());
1507    }
1508
1509    #[test]
1510    fn ollama_factory_normalizes_host_root_to_openai_compat_base() {
1511        let provider = build_ollama_compat_provider(
1512            "default",
1513            None,
1514            Some("http://192.168.1.100:11434"),
1515            &ModelProviderRuntimeOptions::default(),
1516        );
1517
1518        assert_eq!(provider.base_url, "http://192.168.1.100:11434/v1");
1519    }
1520
1521    #[test]
1522    fn ollama_factory_normalizes_legacy_api_path_to_openai_compat_base() {
1523        let provider = build_ollama_compat_provider(
1524            "default",
1525            None,
1526            Some("https://ollama.com/api"),
1527            &ModelProviderRuntimeOptions::default(),
1528        );
1529
1530        assert_eq!(provider.base_url, "https://ollama.com/v1");
1531    }
1532
1533    #[test]
1534    fn ollama_factory_preserves_typed_api_key_for_official_cloud() {
1535        let provider = build_ollama_compat_provider(
1536            "default",
1537            Some("  ollama-key  "),
1538            Some("https://ollama.com/v1"),
1539            &ModelProviderRuntimeOptions::default(),
1540        );
1541
1542        assert_eq!(provider.credential.as_deref(), Some("ollama-key"));
1543    }
1544
1545    #[test]
1546    fn llamacpp_factory_routes_to_responses_provider_when_wire_api_responses() {
1547        use zeroclaw_config::schema::{LlamacppModelProviderConfig, ModelProviderConfig, WireApi};
1548        let cfg = LlamacppModelProviderConfig {
1549            base: ModelProviderConfig {
1550                wire_api: Some(WireApi::Responses),
1551                ..Default::default()
1552            },
1553        };
1554        let provider = cfg
1555            .create_provider(
1556                "default",
1557                None,
1558                Some("http://localhost:8080/v1"),
1559                &ModelProviderRuntimeOptions::default(),
1560            )
1561            .unwrap();
1562        assert_eq!(provider.default_wire_api(), "responses");
1563    }
1564
1565    #[test]
1566    fn llamacpp_factory_defaults_to_chat_completions_without_wire_api() {
1567        use zeroclaw_config::schema::LlamacppModelProviderConfig;
1568        let cfg = LlamacppModelProviderConfig::default();
1569        let provider = cfg
1570            .create_provider(
1571                "default",
1572                None,
1573                Some("http://localhost:8080/v1"),
1574                &ModelProviderRuntimeOptions::default(),
1575            )
1576            .unwrap();
1577        assert_ne!(provider.default_wire_api(), "responses");
1578    }
1579
1580    #[test]
1581    fn custom_factory_routes_to_responses_provider_when_wire_api_responses() {
1582        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig, WireApi};
1583        let cfg = CustomModelProviderConfig {
1584            base: ModelProviderConfig {
1585                uri: Some("http://10.0.0.15:8000/v1".to_string()),
1586                wire_api: Some(WireApi::Responses),
1587                ..Default::default()
1588            },
1589        };
1590        let provider = cfg
1591            .create_provider(
1592                "vllm",
1593                None,
1594                Some("http://10.0.0.15:8000/v1"),
1595                &ModelProviderRuntimeOptions::default(),
1596            )
1597            .unwrap();
1598        assert_eq!(provider.default_wire_api(), "responses");
1599    }
1600
1601    #[test]
1602    fn custom_factory_defaults_to_chat_completions_without_wire_api() {
1603        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig};
1604        let cfg = CustomModelProviderConfig {
1605            base: ModelProviderConfig {
1606                uri: Some("http://10.0.0.15:8000/v1".to_string()),
1607                ..Default::default()
1608            },
1609        };
1610        let provider = cfg
1611            .create_provider(
1612                "vllm",
1613                None,
1614                Some("http://10.0.0.15:8000/v1"),
1615                &ModelProviderRuntimeOptions::default(),
1616            )
1617            .unwrap();
1618        assert_ne!(provider.default_wire_api(), "responses");
1619    }
1620
1621    #[test]
1622    fn custom_factory_disables_native_tools_by_default() {
1623        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig};
1624        let cfg = CustomModelProviderConfig {
1625            base: ModelProviderConfig {
1626                uri: Some("http://10.0.0.15:8000/v1".to_string()),
1627                ..Default::default()
1628            },
1629        };
1630        let provider = cfg
1631            .create_provider(
1632                "vllm",
1633                None,
1634                Some("http://10.0.0.15:8000/v1"),
1635                &ModelProviderRuntimeOptions::default(),
1636            )
1637            .unwrap();
1638        assert!(
1639            !provider.supports_native_tools(),
1640            "custom OpenAI-compatible endpoints must default to prompt-guided tools"
1641        );
1642    }
1643
1644    #[test]
1645    fn custom_factory_honors_native_tools_override_true() {
1646        use zeroclaw_config::schema::{CustomModelProviderConfig, ModelProviderConfig};
1647        let cfg = CustomModelProviderConfig {
1648            base: ModelProviderConfig {
1649                uri: Some("http://10.0.0.15:8000/v1".to_string()),
1650                ..Default::default()
1651            },
1652        };
1653        let options = ModelProviderRuntimeOptions {
1654            native_tools: Some(true),
1655            ..Default::default()
1656        };
1657        let provider = cfg
1658            .create_provider("vllm", None, Some("http://10.0.0.15:8000/v1"), &options)
1659            .unwrap();
1660        assert!(
1661            provider.supports_native_tools(),
1662            "custom endpoints with `native_tools = true` must keep native tool calling available"
1663        );
1664    }
1665
1666    #[test]
1667    fn openai_compatible_factory_routes_to_responses_when_wire_api_responses() {
1668        use zeroclaw_config::schema::{ModelProviderConfig, WireApi};
1669        let cfg = ModelProviderConfig {
1670            uri: Some("http://10.0.0.15:8000/v1".to_string()),
1671            wire_api: Some(WireApi::Responses),
1672            ..Default::default()
1673        };
1674        let provider = cfg
1675            .create_provider(
1676                "default",
1677                None,
1678                Some("http://10.0.0.15:8000/v1"),
1679                &ModelProviderRuntimeOptions::default(),
1680            )
1681            .unwrap();
1682        assert_eq!(provider.default_wire_api(), "responses");
1683    }
1684
1685    #[test]
1686    fn compat_family_factory_routes_to_responses_when_wire_api_responses() {
1687        use zeroclaw_config::schema::{ModelProviderConfig, WireApi};
1688        let cfg = OpencodeModelProviderConfig {
1689            base: ModelProviderConfig {
1690                wire_api: Some(WireApi::Responses),
1691                ..Default::default()
1692            },
1693        };
1694        let provider = cfg
1695            .create_provider(
1696                "default",
1697                None,
1698                None,
1699                &ModelProviderRuntimeOptions::default(),
1700            )
1701            .unwrap();
1702        assert_eq!(provider.default_wire_api(), "responses");
1703        // Security/privacy fallback pin: with no alias-level `uri`, the blanket
1704        // compat route must hand OpenCode's DEFAULT_URL to the responses
1705        // provider. If it stopped doing so, the provider would silently default
1706        // to OpenAI's /v1/responses and send OpenCode credentials to the wrong
1707        // host.
1708        assert_eq!(
1709            provider.default_base_url(),
1710            Some("https://opencode.ai/zen/v1/responses")
1711        );
1712    }
1713
1714    #[test]
1715    fn compat_family_factory_defaults_to_chat_completions_without_wire_api() {
1716        let cfg = OpencodeModelProviderConfig::default();
1717        let provider = cfg
1718            .create_provider(
1719                "default",
1720                None,
1721                None,
1722                &ModelProviderRuntimeOptions::default(),
1723            )
1724            .unwrap();
1725        assert_ne!(provider.default_wire_api(), "responses");
1726    }
1727
1728    #[test]
1729    fn compat_family_without_wire_api_override_ignores_responses_setting() {
1730        // Scope guard: only families that override `wire_api()` opt into the
1731        // responses route. A compat family that does not override it (Doubao
1732        // here) must keep chat_completions even if the entry sets
1733        // `wire_api = "responses"`, so this fix stays scoped per family.
1734        use zeroclaw_config::schema::{ModelProviderConfig, WireApi};
1735        let cfg = DoubaoModelProviderConfig {
1736            base: ModelProviderConfig {
1737                wire_api: Some(WireApi::Responses),
1738                ..Default::default()
1739            },
1740        };
1741        let provider = cfg
1742            .create_provider(
1743                "default",
1744                None,
1745                None,
1746                &ModelProviderRuntimeOptions::default(),
1747            )
1748            .unwrap();
1749        assert_ne!(provider.default_wire_api(), "responses");
1750    }
1751
1752    #[tokio::test]
1753    async fn compat_family_responses_posts_to_responses_path_on_configured_base() {
1754        use axum::{Json, Router, extract::State, http::Uri, routing::post};
1755        use serde_json::{Value, json};
1756        use std::sync::{Arc, Mutex};
1757        use zeroclaw_config::schema::{ModelProviderConfig, WireApi};
1758
1759        type Capture = Arc<Mutex<Vec<String>>>;
1760
1761        async fn capture_path(
1762            State(capture): State<Capture>,
1763            uri: Uri,
1764            Json(_body): Json<Value>,
1765        ) -> Json<Value> {
1766            capture
1767                .lock()
1768                .expect("capture lock poisoned")
1769                .push(uri.path().to_string());
1770            Json(json!({ "output": [], "output_text": "ok" }))
1771        }
1772
1773        let capture: Capture = Arc::new(Mutex::new(Vec::new()));
1774        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1775            .await
1776            .expect("bind test server");
1777        let addr = listener.local_addr().expect("test server addr");
1778        let app = Router::new()
1779            .route("/zen/v1/responses", post(capture_path))
1780            .with_state(capture.clone());
1781        let server = zeroclaw_spawn::spawn!(async move {
1782            axum::serve(listener, app).await.expect("serve test server");
1783        });
1784
1785        let base_url = format!("http://{addr}/zen/v1");
1786        let cfg = OpencodeModelProviderConfig {
1787            base: ModelProviderConfig {
1788                wire_api: Some(WireApi::Responses),
1789                ..Default::default()
1790            },
1791        };
1792        let provider = cfg
1793            .create_provider(
1794                "default",
1795                Some("opencode-test-credential"),
1796                Some(&base_url),
1797                &ModelProviderRuntimeOptions::default(),
1798            )
1799            .expect("opencode responses provider should build");
1800        let _ = provider
1801            .chat_with_system(None, "hello", "big-pickle", Some(0.7))
1802            .await;
1803
1804        server.abort();
1805
1806        let paths = capture.lock().expect("capture lock poisoned").clone();
1807        assert_eq!(paths, vec!["/zen/v1/responses".to_string()]);
1808    }
1809}