1use crate::ModelProviderRuntimeOptions;
26use crate::compatible::{AuthStyle, OpenAiCompatibleModelProvider};
27use crate::traits::ModelProvider;
28use anyhow::Result;
29
30pub 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
47pub trait CompatFamilySpec {
54 const DISPLAY: &'static str;
55 const DEFAULT_URL: &'static str;
56 const AUTH: AuthStyle;
57
58 const MODELS_DEV_KEY: Option<&'static str> = None;
67
68 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = None;
73
74 fn wire_api(&self) -> Option<zeroclaw_config::schema::WireApi> {
84 None
85 }
86
87 const PUBLIC_MODEL_LISTING: bool = false;
93
94 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 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
159pub 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
185fn 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
228pub 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
295use 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
328impl 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 }
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 }
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 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 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
609impl 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 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 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 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 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
765impl 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 if self.base.requires_openai_auth {
814 return Ok(Box::new(
815 crate::openai_codex::OpenAiCodexModelProvider::new(alias, opts, key)?,
816 ));
817 }
818 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 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 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 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 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 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
1158impl CompatFamilySpec for KiloModelProviderConfig {
1161 const DISPLAY: &'static str = "Kilo";
1162 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 #[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 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 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 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}