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 build_compat_base(
78 &self,
79 alias: &str,
80 key: Option<&str>,
81 api_url: Option<&str>,
82 ) -> OpenAiCompatibleModelProvider {
83 let mut p = OpenAiCompatibleModelProvider::new(
84 alias,
85 Self::DISPLAY,
86 api_url.unwrap_or(Self::DEFAULT_URL),
87 key,
88 Self::AUTH,
89 );
90 if let Some(catalog_key) = Self::MODELS_DEV_KEY {
91 p = p.with_models_dev_key(catalog_key);
92 }
93 if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
94 p = p.with_openrouter_vendor_prefix(prefix);
95 }
96 p
97 }
98
99 fn build_compat(
103 &self,
104 alias: &str,
105 key: Option<&str>,
106 api_url: Option<&str>,
107 ) -> OpenAiCompatibleModelProvider {
108 self.build_compat_base(alias, key, api_url)
109 }
110}
111
112impl<T: CompatFamilySpec> FamilyProviderFactory for T {
113 fn create_provider(
114 &self,
115 alias: &str,
116 key: Option<&str>,
117 api_url: Option<&str>,
118 opts: &ModelProviderRuntimeOptions,
119 ) -> Result<Box<dyn ModelProvider>> {
120 Ok(apply_compat_options(
121 self.build_compat(alias, key, api_url),
122 opts,
123 ))
124 }
125}
126
127pub fn apply_compat_options(
132 mut p: OpenAiCompatibleModelProvider,
133 opts: &ModelProviderRuntimeOptions,
134) -> Box<dyn ModelProvider> {
135 if let Some(t) = opts.provider_timeout_secs {
136 p = p.with_timeout_secs(t);
137 }
138 if let Some(ref effort) = opts.reasoning_effort {
139 p = p.with_reasoning_effort(Some(effort.clone()));
140 }
141 if !opts.extra_headers.is_empty() {
142 p = p.with_extra_headers(opts.extra_headers.clone());
143 }
144 if opts.api_path.is_some() {
145 p = p.with_api_path(opts.api_path.clone());
146 }
147 if let Some(mt) = opts.provider_max_tokens {
148 p = p.with_max_tokens(Some(mt));
149 }
150 Box::new(p)
151}
152
153pub(crate) fn build_kimi_code_compat(
154 alias: &str,
155 key: Option<&str>,
156 base_url: &str,
157) -> OpenAiCompatibleModelProvider {
158 OpenAiCompatibleModelProvider::new_with_user_agent_and_vision(
159 alias,
160 "Kimi Code",
161 base_url,
162 key,
163 AuthStyle::Bearer,
164 "KimiCLI/0.77",
165 true,
166 )
167 .with_models_dev_key("moonshotai")
168}
169
170pub fn dispatch_family_factory(
186 config: Option<&zeroclaw_config::schema::Config>,
187 family: &str,
188 alias: &str,
189 key: Option<&str>,
190 api_url: Option<&str>,
191 opts: &ModelProviderRuntimeOptions,
192) -> Result<Box<dyn ModelProvider>> {
193 macro_rules! emit_dispatch {
194 ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
195 match family {
196 "openai-compatible" | "openai_compatible" => {
197 let default_cfg = zeroclaw_config::schema::ModelProviderConfig::default();
198 let cfg = config
199 .and_then(|c| c.providers.models.find("openai", alias))
200 .unwrap_or(&default_cfg);
201 cfg.create_provider(alias, key, api_url, opts)
202 }
203 $(
204 $type_str => {
205 let default_cfg: $cfg_ty;
206 let cfg: &$cfg_ty = match config.and_then(|c| c.providers.models.$field.get(alias)) {
207 Some(c) => c,
208 None => {
209 default_cfg = <$cfg_ty>::default();
210 &default_cfg
211 }
212 };
213 cfg.create_provider(alias, key, api_url, opts)
214 }
215 )+
216 _ => {
217 ::zeroclaw_log::record!(
218 ERROR,
219 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
220 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
221 .with_attrs(::serde_json::json!({"family": family})),
222 "factory: unknown model_provider family"
223 );
224 Err(anyhow::Error::msg(format!(
225 "Unknown model_provider family: {family}. After the V2 to typed-family migration, \
226 only canonical family names are valid. Run `zeroclaw onboard` to reconfigure, \
227 or set `[model_providers.custom.<alias>] uri = \"https://your-api.com\"` for \
228 OpenAI-compatible custom endpoints."
229 )))
230 },
231 }
232 }
233 }
234 zeroclaw_config::for_each_model_provider_slot!(emit_dispatch)
235}
236
237use zeroclaw_config::schema::{
244 Ai21ModelProviderConfig, AihubmixModelProviderConfig, AnthropicModelProviderConfig,
245 AnyscaleModelProviderConfig, AstraiModelProviderConfig, AtomicChatModelProviderConfig,
246 AvianModelProviderConfig, AzureModelProviderConfig, BaichuanModelProviderConfig,
247 BasetenModelProviderConfig, BedrockModelProviderConfig, CerebrasModelProviderConfig,
248 CloudflareModelProviderConfig, CohereModelProviderConfig, CopilotModelProviderConfig,
249 CustomModelProviderConfig, DeepinfraModelProviderConfig, DeepmystModelProviderConfig,
250 DeepseekModelProviderConfig, DoubaoModelProviderConfig, FireworksModelProviderConfig,
251 FriendliModelProviderConfig, GeminiCliModelProviderConfig, GeminiModelProviderConfig,
252 GlmModelProviderConfig, GroqModelProviderConfig, HuggingfaceModelProviderConfig,
253 HunyuanModelProviderConfig, HyperbolicModelProviderConfig, KiloCliModelProviderConfig,
254 LeptonModelProviderConfig, LitellmModelProviderConfig, LlamacppModelProviderConfig,
255 LmstudioModelProviderConfig, MinimaxModelProviderConfig, MistralModelProviderConfig,
256 MoonshotEndpoint, MoonshotModelProviderConfig, NebiusModelProviderConfig,
257 NovitaModelProviderConfig, NscaleModelProviderConfig, NvidiaModelProviderConfig,
258 OllamaModelProviderConfig, OpenAIModelProviderConfig, OpenRouterModelProviderConfig,
259 OpencodeModelProviderConfig, OsaurusModelProviderConfig, OvhModelProviderConfig,
260 PerplexityModelProviderConfig, QianfanModelProviderConfig, QwenModelProviderConfig,
261 RekaModelProviderConfig, SambanovaModelProviderConfig, SglangModelProviderConfig,
262 SiliconflowModelProviderConfig, StepfunModelProviderConfig, SyntheticModelProviderConfig,
263 TelnyxModelProviderConfig, TogetherModelProviderConfig, VeniceModelProviderConfig,
264 VercelModelProviderConfig, VllmModelProviderConfig, XaiModelProviderConfig,
265 YiModelProviderConfig, ZaiModelProviderConfig,
266};
267
268impl CompatFamilySpec for VercelModelProviderConfig {
274 const DISPLAY: &'static str = "Vercel AI Gateway";
275 const DEFAULT_URL: &'static str = crate::VERCEL_AI_GATEWAY_BASE_URL;
276 const AUTH: AuthStyle = AuthStyle::Bearer;
277 const MODELS_DEV_KEY: Option<&'static str> = Some("vercel");
278}
279impl CompatFamilySpec for CloudflareModelProviderConfig {
280 const DISPLAY: &'static str = "Cloudflare AI Gateway";
281 const DEFAULT_URL: &'static str = "https://gateway.ai.cloudflare.com/v1";
282 const AUTH: AuthStyle = AuthStyle::Bearer;
283 const MODELS_DEV_KEY: Option<&'static str> = Some("cloudflare-ai-gateway");
284}
285impl CompatFamilySpec for SyntheticModelProviderConfig {
286 const DISPLAY: &'static str = "Synthetic";
287 const DEFAULT_URL: &'static str = "https://api.synthetic.new/openai/v1";
288 const AUTH: AuthStyle = AuthStyle::Bearer;
289 const MODELS_DEV_KEY: Option<&'static str> = Some("synthetic");
290}
291impl CompatFamilySpec for OpencodeModelProviderConfig {
292 const DISPLAY: &'static str = "OpenCode Zen";
293 const DEFAULT_URL: &'static str = "https://opencode.ai/zen/v1";
294 const AUTH: AuthStyle = AuthStyle::Bearer;
295 const MODELS_DEV_KEY: Option<&'static str> = Some("opencode");
296}
297impl CompatFamilySpec for DoubaoModelProviderConfig {
298 const DISPLAY: &'static str = "Doubao";
299 const DEFAULT_URL: &'static str = "https://ark.cn-beijing.volces.com/api/v3";
300 const AUTH: AuthStyle = AuthStyle::Bearer;
301 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("bytedance");
302}
303impl CompatFamilySpec for MistralModelProviderConfig {
304 const DISPLAY: &'static str = "Mistral";
305 const DEFAULT_URL: &'static str = "https://api.mistral.ai/v1";
306 const AUTH: AuthStyle = AuthStyle::Bearer;
307 const MODELS_DEV_KEY: Option<&'static str> = Some("mistral");
308}
309impl CompatFamilySpec for DeepseekModelProviderConfig {
310 const DISPLAY: &'static str = "DeepSeek";
311 const DEFAULT_URL: &'static str = "https://api.deepseek.com";
312 const AUTH: AuthStyle = AuthStyle::Bearer;
313 const MODELS_DEV_KEY: Option<&'static str> = Some("deepseek");
314}
315impl CompatFamilySpec for TogetherModelProviderConfig {
316 const DISPLAY: &'static str = "Together AI";
317 const DEFAULT_URL: &'static str = "https://api.together.xyz";
318 const AUTH: AuthStyle = AuthStyle::Bearer;
319 const MODELS_DEV_KEY: Option<&'static str> = Some("togetherai");
320}
321impl CompatFamilySpec for FireworksModelProviderConfig {
322 const DISPLAY: &'static str = "Fireworks AI";
323 const DEFAULT_URL: &'static str = "https://api.fireworks.ai/inference/v1";
324 const AUTH: AuthStyle = AuthStyle::Bearer;
325 const MODELS_DEV_KEY: Option<&'static str> = Some("fireworks-ai");
326}
327impl CompatFamilySpec for NovitaModelProviderConfig {
328 const DISPLAY: &'static str = "Novita AI";
329 const DEFAULT_URL: &'static str = "https://api.novita.ai/openai";
330 const AUTH: AuthStyle = AuthStyle::Bearer;
331 const MODELS_DEV_KEY: Option<&'static str> = Some("novita-ai");
332}
333impl CompatFamilySpec for PerplexityModelProviderConfig {
334 const DISPLAY: &'static str = "Perplexity";
335 const DEFAULT_URL: &'static str = "https://api.perplexity.ai";
336 const AUTH: AuthStyle = AuthStyle::Bearer;
337 const MODELS_DEV_KEY: Option<&'static str> = Some("perplexity");
338}
339impl CompatFamilySpec for CohereModelProviderConfig {
340 const DISPLAY: &'static str = "Cohere";
341 const DEFAULT_URL: &'static str = "https://api.cohere.com/compatibility";
342 const AUTH: AuthStyle = AuthStyle::Bearer;
343 const MODELS_DEV_KEY: Option<&'static str> = Some("cohere");
344}
345impl CompatFamilySpec for SglangModelProviderConfig {
346 const DISPLAY: &'static str = "SGLang";
347 const DEFAULT_URL: &'static str = "http://localhost:30000/v1";
348 const AUTH: AuthStyle = AuthStyle::Bearer;
349}
350impl CompatFamilySpec for VllmModelProviderConfig {
351 const DISPLAY: &'static str = "vLLM";
352 const DEFAULT_URL: &'static str = "http://localhost:8000/v1";
353 const AUTH: AuthStyle = AuthStyle::Bearer;
354}
355impl CompatFamilySpec for AstraiModelProviderConfig {
356 const DISPLAY: &'static str = "Astrai";
357 const DEFAULT_URL: &'static str = "https://as-trai.com/v1";
358 const AUTH: AuthStyle = AuthStyle::Bearer;
359}
360impl CompatFamilySpec for SiliconflowModelProviderConfig {
361 const DISPLAY: &'static str = "SiliconFlow";
362 const DEFAULT_URL: &'static str = "https://api.siliconflow.com/v1";
363 const AUTH: AuthStyle = AuthStyle::Bearer;
364 const MODELS_DEV_KEY: Option<&'static str> = Some("siliconflow");
365}
366impl CompatFamilySpec for AihubmixModelProviderConfig {
367 const DISPLAY: &'static str = "AiHubMix";
368 const DEFAULT_URL: &'static str = "https://aihubmix.com/v1";
369 const AUTH: AuthStyle = AuthStyle::Bearer;
370 const MODELS_DEV_KEY: Option<&'static str> = Some("aihubmix");
371}
372impl CompatFamilySpec for LitellmModelProviderConfig {
373 const DISPLAY: &'static str = "LiteLLM";
374 const DEFAULT_URL: &'static str = "http://localhost:4000/v1";
375 const AUTH: AuthStyle = AuthStyle::Bearer;
376}
377impl CompatFamilySpec for CerebrasModelProviderConfig {
378 const DISPLAY: &'static str = "Cerebras";
379 const DEFAULT_URL: &'static str = "https://api.cerebras.ai/v1";
380 const AUTH: AuthStyle = AuthStyle::Bearer;
381 const MODELS_DEV_KEY: Option<&'static str> = Some("cerebras");
382}
383impl CompatFamilySpec for SambanovaModelProviderConfig {
384 const DISPLAY: &'static str = "SambaNova";
385 const DEFAULT_URL: &'static str = "https://api.sambanova.ai/v1";
386 const AUTH: AuthStyle = AuthStyle::Bearer;
387 }
390impl CompatFamilySpec for HyperbolicModelProviderConfig {
391 const DISPLAY: &'static str = "Hyperbolic";
392 const DEFAULT_URL: &'static str = "https://api.hyperbolic.xyz/v1";
393 const AUTH: AuthStyle = AuthStyle::Bearer;
394 }
397impl CompatFamilySpec for DeepinfraModelProviderConfig {
398 const DISPLAY: &'static str = "DeepInfra";
399 const DEFAULT_URL: &'static str = "https://api.deepinfra.com/v1/openai";
400 const AUTH: AuthStyle = AuthStyle::Bearer;
401 const MODELS_DEV_KEY: Option<&'static str> = Some("deepinfra");
402}
403impl CompatFamilySpec for HuggingfaceModelProviderConfig {
404 const DISPLAY: &'static str = "Hugging Face";
405 const DEFAULT_URL: &'static str = "https://router.huggingface.co/v1";
406 const AUTH: AuthStyle = AuthStyle::Bearer;
407 const MODELS_DEV_KEY: Option<&'static str> = Some("huggingface");
408}
409impl CompatFamilySpec for Ai21ModelProviderConfig {
410 const DISPLAY: &'static str = "AI21 Labs";
411 const DEFAULT_URL: &'static str = "https://api.ai21.com/studio/v1";
412 const AUTH: AuthStyle = AuthStyle::Bearer;
413 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("ai21");
414}
415impl CompatFamilySpec for RekaModelProviderConfig {
416 const DISPLAY: &'static str = "Reka";
417 const DEFAULT_URL: &'static str = "https://api.reka.ai/v1";
418 const AUTH: AuthStyle = AuthStyle::Bearer;
419 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("rekaai");
420}
421impl CompatFamilySpec for BasetenModelProviderConfig {
422 const DISPLAY: &'static str = "Baseten";
423 const DEFAULT_URL: &'static str = "https://inference.baseten.co/v1";
424 const AUTH: AuthStyle = AuthStyle::Bearer;
425 const MODELS_DEV_KEY: Option<&'static str> = Some("baseten");
426}
427impl CompatFamilySpec for NscaleModelProviderConfig {
428 const DISPLAY: &'static str = "Nscale";
429 const DEFAULT_URL: &'static str = "https://inference.api.nscale.com/v1";
430 const AUTH: AuthStyle = AuthStyle::Bearer;
431}
432impl CompatFamilySpec for AnyscaleModelProviderConfig {
433 const DISPLAY: &'static str = "Anyscale";
434 const DEFAULT_URL: &'static str = "https://api.endpoints.anyscale.com/v1";
435 const AUTH: AuthStyle = AuthStyle::Bearer;
436}
437impl CompatFamilySpec for NebiusModelProviderConfig {
438 const DISPLAY: &'static str = "Nebius AI Studio";
439 const DEFAULT_URL: &'static str = "https://api.studio.nebius.ai/v1";
440 const AUTH: AuthStyle = AuthStyle::Bearer;
441 const MODELS_DEV_KEY: Option<&'static str> = Some("nebius");
442}
443impl CompatFamilySpec for FriendliModelProviderConfig {
444 const DISPLAY: &'static str = "Friendli AI";
445 const DEFAULT_URL: &'static str = "https://api.friendli.ai/serverless/v1";
446 const AUTH: AuthStyle = AuthStyle::Bearer;
447 const MODELS_DEV_KEY: Option<&'static str> = Some("friendli");
448}
449impl CompatFamilySpec for LeptonModelProviderConfig {
450 const DISPLAY: &'static str = "Lepton AI";
451 const DEFAULT_URL: &'static str = "https://llama3-1-405b.lepton.run/api/v1";
452 const AUTH: AuthStyle = AuthStyle::Bearer;
453}
454impl CompatFamilySpec for StepfunModelProviderConfig {
455 const DISPLAY: &'static str = "Stepfun";
456 const DEFAULT_URL: &'static str = "https://api.stepfun.com/v1";
457 const AUTH: AuthStyle = AuthStyle::Bearer;
458 const MODELS_DEV_KEY: Option<&'static str> = Some("stepfun");
459 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("stepfun");
460}
461impl CompatFamilySpec for BaichuanModelProviderConfig {
462 const DISPLAY: &'static str = "Baichuan";
463 const DEFAULT_URL: &'static str = "https://api.baichuan-ai.com/v1";
464 const AUTH: AuthStyle = AuthStyle::Bearer;
465}
466impl CompatFamilySpec for YiModelProviderConfig {
467 const DISPLAY: &'static str = "01.AI (Yi)";
468 const DEFAULT_URL: &'static str = "https://api.lingyiwanwu.com/v1";
469 const AUTH: AuthStyle = AuthStyle::Bearer;
470}
471impl CompatFamilySpec for HunyuanModelProviderConfig {
472 const DISPLAY: &'static str = "Tencent Hunyuan";
473 const DEFAULT_URL: &'static str = "https://api.hunyuan.cloud.tencent.com/v1";
474 const AUTH: AuthStyle = AuthStyle::Bearer;
475 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("tencent");
476}
477impl CompatFamilySpec for AvianModelProviderConfig {
478 const DISPLAY: &'static str = "Avian";
479 const DEFAULT_URL: &'static str = "https://api.avian.io/v1";
480 const AUTH: AuthStyle = AuthStyle::Bearer;
481}
482impl CompatFamilySpec for DeepmystModelProviderConfig {
483 const DISPLAY: &'static str = "DeepMyst";
484 const DEFAULT_URL: &'static str = "https://api.deepmyst.com/v1";
485 const AUTH: AuthStyle = AuthStyle::Bearer;
486}
487impl CompatFamilySpec for MoonshotModelProviderConfig {
488 const DISPLAY: &'static str = "Moonshot";
489 const DEFAULT_URL: &'static str = crate::MOONSHOT_INTL_BASE_URL;
490 const AUTH: AuthStyle = AuthStyle::Bearer;
491 const MODELS_DEV_KEY: Option<&'static str> = Some("moonshotai");
492
493 fn build_compat(
494 &self,
495 alias: &str,
496 key: Option<&str>,
497 api_url: Option<&str>,
498 ) -> OpenAiCompatibleModelProvider {
499 let base_url = api_url.unwrap_or(Self::DEFAULT_URL);
500 if self.endpoint == MoonshotEndpoint::Code || base_url == crate::moonshot_code_base_url() {
501 return build_kimi_code_compat(alias, key, base_url);
502 }
503
504 self.build_compat_base(alias, key, api_url)
505 }
506}
507
508impl CompatFamilySpec for VeniceModelProviderConfig {
513 const DISPLAY: &'static str = "Venice";
514 const DEFAULT_URL: &'static str = "https://api.venice.ai";
515 const AUTH: AuthStyle = AuthStyle::Bearer;
516 const MODELS_DEV_KEY: Option<&'static str> = Some("venice");
517 fn build_compat(
518 &self,
519 alias: &str,
520 key: Option<&str>,
521 api_url: Option<&str>,
522 ) -> OpenAiCompatibleModelProvider {
523 self.build_compat_base(alias, key, api_url)
524 .without_native_tools()
525 }
526}
527impl CompatFamilySpec for AtomicChatModelProviderConfig {
528 const DISPLAY: &'static str = "Atomic Chat";
529 const DEFAULT_URL: &'static str = "http://127.0.0.1:1337/v1";
533 const AUTH: AuthStyle = AuthStyle::Bearer;
534 const MODELS_DEV_KEY: Option<&'static str> = Some("atomic-chat");
535 fn build_compat(
536 &self,
537 alias: &str,
538 key: Option<&str>,
539 api_url: Option<&str>,
540 ) -> OpenAiCompatibleModelProvider {
541 self.build_compat_base(alias, key, api_url)
542 .without_native_tools()
543 }
544}
545
546impl CompatFamilySpec for XaiModelProviderConfig {
547 const DISPLAY: &'static str = "xAI";
548 const DEFAULT_URL: &'static str = "https://api.x.ai/v1";
549 const AUTH: AuthStyle = AuthStyle::Bearer;
550 const MODELS_DEV_KEY: Option<&'static str> = Some("xai");
551}
552
553impl FamilyProviderFactory for MinimaxModelProviderConfig {
554 fn create_provider(
555 &self,
556 alias: &str,
557 key: Option<&str>,
558 api_url: Option<&str>,
559 opts: &ModelProviderRuntimeOptions,
560 ) -> Result<Box<dyn ModelProvider>> {
561 let refreshed_key: Option<String> = self
568 .oauth_refresh_token
569 .as_deref()
570 .map(str::trim)
571 .filter(|s| !s.is_empty())
572 .map(|refresh_token| {
573 let client_id = self
574 .oauth_client_id
575 .as_deref()
576 .map(str::trim)
577 .filter(|s| !s.is_empty())
578 .unwrap_or(crate::MINIMAX_OAUTH_DEFAULT_CLIENT_ID);
579 crate::refresh_minimax_oauth_access_token(refresh_token, client_id, self.endpoint)
580 })
581 .transpose()?;
582 let resolved_key = refreshed_key.as_deref().or(key);
583 let p = OpenAiCompatibleModelProvider::new(
584 alias,
585 "MiniMax",
586 api_url.unwrap_or(crate::MINIMAX_INTL_BASE_URL),
587 resolved_key,
588 AuthStyle::Bearer,
589 )
590 .with_merge_system_into_user();
591 Ok(apply_compat_options(p, opts))
592 }
593}
594
595impl CompatFamilySpec for ZaiModelProviderConfig {
596 const DISPLAY: &'static str = "Z.AI";
597 const DEFAULT_URL: &'static str = crate::ZAI_GLOBAL_BASE_URL;
598 const AUTH: AuthStyle = AuthStyle::ZhipuJwt;
599 const MODELS_DEV_KEY: Option<&'static str> = Some("zai");
600 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("z-ai");
601}
602
603impl CompatFamilySpec for GlmModelProviderConfig {
604 const DISPLAY: &'static str = "GLM";
605 const DEFAULT_URL: &'static str = crate::GLM_GLOBAL_BASE_URL;
606 const AUTH: AuthStyle = AuthStyle::ZhipuJwt;
607 const MODELS_DEV_KEY: Option<&'static str> = Some("zhipuai");
608 fn build_compat(
609 &self,
610 alias: &str,
611 key: Option<&str>,
612 api_url: Option<&str>,
613 ) -> OpenAiCompatibleModelProvider {
614 let mut p = OpenAiCompatibleModelProvider::new_with_vision(
619 alias,
620 Self::DISPLAY,
621 api_url.unwrap_or(Self::DEFAULT_URL),
622 key,
623 Self::AUTH,
624 true,
625 );
626 if let Some(catalog_key) = Self::MODELS_DEV_KEY {
627 p = p.with_models_dev_key(catalog_key);
628 }
629 if let Some(prefix) = Self::OPENROUTER_VENDOR_PREFIX {
630 p = p.with_openrouter_vendor_prefix(prefix);
631 }
632 p
633 }
634}
635
636impl CompatFamilySpec for NvidiaModelProviderConfig {
637 const DISPLAY: &'static str = "NVIDIA NIM";
638 const DEFAULT_URL: &'static str = "https://integrate.api.nvidia.com/v1";
639 const AUTH: AuthStyle = AuthStyle::Bearer;
640 const MODELS_DEV_KEY: Option<&'static str> = Some("nvidia");
641 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("nvidia");
642}
643
644impl CompatFamilySpec for QianfanModelProviderConfig {
645 const DISPLAY: &'static str = "Qianfan";
646 const DEFAULT_URL: &'static str = "https://qianfan.baidubce.com/v2";
650 const AUTH: AuthStyle = AuthStyle::Bearer;
651 const OPENROUTER_VENDOR_PREFIX: Option<&'static str> = Some("baidu");
652 fn build_compat(
653 &self,
654 alias: &str,
655 key: Option<&str>,
656 api_url: Option<&str>,
657 ) -> OpenAiCompatibleModelProvider {
658 let base_url = crate::qianfan_base_url(api_url);
659 let computed_url = Some(base_url.as_str());
660 self.build_compat_base(alias, key, computed_url)
661 }
662}
663
664impl FamilyProviderFactory for OpenRouterModelProviderConfig {
670 fn create_provider(
671 &self,
672 alias: &str,
673 key: Option<&str>,
674 _api_url: Option<&str>,
675 opts: &ModelProviderRuntimeOptions,
676 ) -> Result<Box<dyn ModelProvider>> {
677 let mut p =
678 crate::openrouter::OpenRouterModelProvider::new(alias, key, opts.provider_timeout_secs)
679 .with_max_tokens(opts.provider_max_tokens);
680 if let Some(extra) = opts.provider_extra.clone() {
681 p = p.with_extra_body(extra);
682 }
683 Ok(Box::new(p))
684 }
685}
686
687impl FamilyProviderFactory for AnthropicModelProviderConfig {
688 fn create_provider(
689 &self,
690 alias: &str,
691 key: Option<&str>,
692 api_url: Option<&str>,
693 opts: &ModelProviderRuntimeOptions,
694 ) -> Result<Box<dyn ModelProvider>> {
695 let mut p = crate::anthropic::AnthropicModelProvider::with_base_url(alias, key, api_url);
696 if let Some(mt) = opts.provider_max_tokens {
697 p = p.with_max_tokens(mt);
698 }
699 Ok(Box::new(p))
700 }
701}
702
703impl FamilyProviderFactory for OpenAIModelProviderConfig {
704 fn create_provider(
705 &self,
706 alias: &str,
707 key: Option<&str>,
708 api_url: Option<&str>,
709 opts: &ModelProviderRuntimeOptions,
710 ) -> Result<Box<dyn ModelProvider>> {
711 if self.base.requires_openai_auth {
716 return Ok(Box::new(
717 crate::openai_codex::OpenAiCodexModelProvider::new(alias, opts, key)?,
718 ));
719 }
720 let mut p = crate::openai::OpenAiModelProvider::with_base_url(alias, api_url, key);
721 if let Some(mt) = opts.provider_max_tokens {
722 p = p.with_max_tokens(Some(mt));
723 }
724 Ok(Box::new(p))
725 }
726}
727
728fn normalize_ollama_compat_base_url(api_url: Option<&str>) -> String {
729 let raw = api_url
730 .map(str::trim)
731 .filter(|value| !value.is_empty())
732 .unwrap_or("http://localhost:11434/v1");
733
734 let Ok(mut url) = reqwest::Url::parse(raw) else {
735 return raw.trim_end_matches('/').to_string();
736 };
737
738 let path = url.path().trim_end_matches('/');
739 if path.is_empty() || matches!(path, "/" | "/api" | "/api/chat") {
740 url.set_path("/v1");
741 return url.to_string().trim_end_matches('/').to_string();
742 }
743
744 raw.trim_end_matches('/').to_string()
745}
746
747fn build_ollama_compat_provider(
748 alias: &str,
749 key: Option<&str>,
750 api_url: Option<&str>,
751 opts: &ModelProviderRuntimeOptions,
752) -> OpenAiCompatibleModelProvider {
753 let base_url = normalize_ollama_compat_base_url(api_url);
754 let ollama_key = key.map(str::trim).filter(|value| !value.is_empty());
755 let mut p = OpenAiCompatibleModelProvider::new_with_vision(
756 alias,
757 "Ollama",
758 &base_url,
759 ollama_key,
760 AuthStyle::Bearer,
761 true,
762 )
763 .with_local_model_tool_sanitize()
764 .with_unauthenticated_model_listing();
765 if opts.merge_system_into_user {
766 p = p.with_merge_system_into_user();
767 }
768 p
769}
770
771impl FamilyProviderFactory for OllamaModelProviderConfig {
772 fn create_provider(
773 &self,
774 alias: &str,
775 key: Option<&str>,
776 api_url: Option<&str>,
777 opts: &ModelProviderRuntimeOptions,
778 ) -> Result<Box<dyn ModelProvider>> {
779 Ok(apply_compat_options(
780 build_ollama_compat_provider(alias, key, api_url, opts),
781 opts,
782 ))
783 }
784}
785
786impl FamilyProviderFactory for GeminiModelProviderConfig {
787 fn create_provider(
788 &self,
789 alias: &str,
790 key: Option<&str>,
791 _api_url: Option<&str>,
792 opts: &ModelProviderRuntimeOptions,
793 ) -> Result<Box<dyn ModelProvider>> {
794 let state_dir = opts.zeroclaw_dir.clone().unwrap_or_else(|| {
795 directories::UserDirs::new().map_or_else(
796 || std::path::PathBuf::from(".zeroclaw"),
797 |dirs| dirs.home_dir().join(".zeroclaw"),
798 )
799 });
800 let auth_service = crate::auth::AuthService::new(&state_dir, opts.secrets_encrypt);
801 Ok(Box::new(crate::gemini::GeminiModelProvider::new_with_auth(
802 alias,
803 key,
804 auth_service,
805 opts.auth_profile_override.clone(),
806 self.oauth_project.clone(),
807 self.oauth_client_id.clone(),
808 self.oauth_client_secret.clone(),
809 )))
810 }
811}
812
813impl FamilyProviderFactory for TelnyxModelProviderConfig {
814 fn create_provider(
815 &self,
816 alias: &str,
817 key: Option<&str>,
818 _api_url: Option<&str>,
819 _opts: &ModelProviderRuntimeOptions,
820 ) -> Result<Box<dyn ModelProvider>> {
821 Ok(Box::new(crate::telnyx::TelnyxModelProvider::new(
822 alias, key,
823 )))
824 }
825}
826
827impl FamilyProviderFactory for AzureModelProviderConfig {
828 fn create_provider(
829 &self,
830 alias: &str,
831 key: Option<&str>,
832 _api_url: Option<&str>,
833 _opts: &ModelProviderRuntimeOptions,
834 ) -> Result<Box<dyn ModelProvider>> {
835 let resource = self.resource.as_deref().ok_or_else(|| {
839 ::zeroclaw_log::record!(
840 ERROR,
841 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
842 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
843 .with_attrs(::serde_json::json!({
844 "family": "azure",
845 "alias": alias,
846 "missing": "resource",
847 })),
848 "factory: azure provider missing resource"
849 );
850 anyhow::Error::msg(
851 "Azure model_provider requires `resource`: set \
852 `[model_providers.azure.<alias>] resource = \"...\"` in config.toml.",
853 )
854 })?;
855 let deployment = self.deployment.as_deref().ok_or_else(|| {
856 ::zeroclaw_log::record!(
857 ERROR,
858 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
859 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
860 .with_attrs(::serde_json::json!({
861 "family": "azure",
862 "alias": alias,
863 "missing": "deployment",
864 })),
865 "factory: azure provider missing deployment"
866 );
867 anyhow::Error::msg(
868 "Azure model_provider requires `deployment`: set \
869 `[model_providers.azure.<alias>] deployment = \"...\"` in config.toml.",
870 )
871 })?;
872 let api_version = self.api_version.as_deref();
873 Ok(Box::new(
874 crate::azure_openai::AzureOpenAiModelProvider::new(
875 alias,
876 key,
877 resource,
878 deployment,
879 api_version,
880 ),
881 ))
882 }
883}
884
885impl FamilyProviderFactory for BedrockModelProviderConfig {
886 fn create_provider(
887 &self,
888 alias: &str,
889 key: Option<&str>,
890 _api_url: Option<&str>,
891 opts: &ModelProviderRuntimeOptions,
892 ) -> Result<Box<dyn ModelProvider>> {
893 let mut p = if let Some(api_key) = key {
894 crate::bedrock::BedrockModelProvider::with_bearer_token(alias, api_key)
895 } else {
896 crate::bedrock::BedrockModelProvider::new(alias)
897 };
898 if let Some(mt) = opts.provider_max_tokens {
899 p = p.with_max_tokens(mt);
900 }
901 Ok(Box::new(p))
902 }
903}
904
905impl FamilyProviderFactory for QwenModelProviderConfig {
906 fn create_provider(
907 &self,
908 alias: &str,
909 key: Option<&str>,
910 api_url: Option<&str>,
911 opts: &ModelProviderRuntimeOptions,
912 ) -> Result<Box<dyn ModelProvider>> {
913 let alias_oauth: Option<crate::QwenOauthCredentials> = self
919 .oauth_refresh_token
920 .as_deref()
921 .map(str::trim)
922 .filter(|s| !s.is_empty())
923 .map(|refresh_token| {
924 let client_id = self
925 .oauth_client_id
926 .as_deref()
927 .map(str::trim)
928 .filter(|s| !s.is_empty())
929 .unwrap_or(crate::QWEN_OAUTH_DEFAULT_CLIENT_ID);
930 crate::refresh_qwen_oauth_access_token(refresh_token, client_id)
931 })
932 .transpose()?;
933
934 let oauth_context = if let Some(creds) = alias_oauth.as_ref() {
935 crate::QwenOauthProviderContext {
939 credential: creds.access_token.clone(),
940 base_url: self
941 .oauth_resource_url
942 .as_deref()
943 .map(str::trim)
944 .filter(|s| !s.is_empty())
945 .map(ToString::to_string)
946 .or_else(|| creds.resource_url.clone()),
947 }
948 } else {
949 crate::resolve_qwen_oauth_context(key)
950 };
951
952 let resolved_key = oauth_context.credential.as_deref().or(key);
953 let base_url = api_url
954 .map(ToString::to_string)
955 .or_else(|| oauth_context.base_url.clone())
956 .unwrap_or_else(|| crate::QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
957 let p = if oauth_context.credential.is_some() {
958 OpenAiCompatibleModelProvider::new_with_user_agent_and_vision(
959 alias,
960 "Qwen Code",
961 &base_url,
962 resolved_key,
963 AuthStyle::Bearer,
964 "QwenCode/1.0",
965 true,
966 )
967 } else {
968 OpenAiCompatibleModelProvider::new_with_vision(
969 alias,
970 "Qwen",
971 &base_url,
972 resolved_key,
973 AuthStyle::Bearer,
974 true,
975 )
976 }
977 .with_models_dev_key("alibaba")
978 .with_openrouter_vendor_prefix("qwen");
979 Ok(apply_compat_options(p, opts))
980 }
981}
982
983impl FamilyProviderFactory for GroqModelProviderConfig {
984 fn create_provider(
985 &self,
986 alias: &str,
987 key: Option<&str>,
988 _api_url: Option<&str>,
989 opts: &ModelProviderRuntimeOptions,
990 ) -> Result<Box<dyn ModelProvider>> {
991 let mut p = OpenAiCompatibleModelProvider::new(
992 alias,
993 "Groq",
994 "https://api.groq.com/openai/v1",
995 key,
996 AuthStyle::Bearer,
997 )
998 .with_models_dev_key("groq");
999 if opts.native_tools != Some(true) {
1003 p = p.without_native_tools();
1004 }
1005 Ok(apply_compat_options(p, opts))
1006 }
1007}
1008
1009impl FamilyProviderFactory for CopilotModelProviderConfig {
1010 fn create_provider(
1011 &self,
1012 alias: &str,
1013 key: Option<&str>,
1014 _api_url: Option<&str>,
1015 _opts: &ModelProviderRuntimeOptions,
1016 ) -> Result<Box<dyn ModelProvider>> {
1017 Ok(Box::new(crate::copilot::CopilotModelProvider::new(
1018 alias, key,
1019 )))
1020 }
1021}
1022
1023impl FamilyProviderFactory for GeminiCliModelProviderConfig {
1024 fn create_provider(
1025 &self,
1026 alias: &str,
1027 _key: Option<&str>,
1028 _api_url: Option<&str>,
1029 _opts: &ModelProviderRuntimeOptions,
1030 ) -> Result<Box<dyn ModelProvider>> {
1031 Ok(Box::new(crate::gemini_cli::GeminiCliModelProvider::new(
1032 alias,
1033 self.binary_path.as_deref(),
1034 )))
1035 }
1036}
1037
1038impl FamilyProviderFactory for KiloCliModelProviderConfig {
1039 fn create_provider(
1040 &self,
1041 alias: &str,
1042 _key: Option<&str>,
1043 _api_url: Option<&str>,
1044 _opts: &ModelProviderRuntimeOptions,
1045 ) -> Result<Box<dyn ModelProvider>> {
1046 Ok(Box::new(crate::kilocli::KiloCliModelProvider::new(
1047 alias,
1048 self.binary_path.as_deref(),
1049 )))
1050 }
1051}
1052
1053impl FamilyProviderFactory for LmstudioModelProviderConfig {
1054 fn create_provider(
1055 &self,
1056 alias: &str,
1057 key: Option<&str>,
1058 api_url: Option<&str>,
1059 opts: &ModelProviderRuntimeOptions,
1060 ) -> Result<Box<dyn ModelProvider>> {
1061 let lm_studio_key = key
1062 .map(str::trim)
1063 .filter(|value| !value.is_empty())
1064 .unwrap_or("lm-studio");
1065 let p = OpenAiCompatibleModelProvider::new(
1066 alias,
1067 "LM Studio",
1068 api_url.unwrap_or("http://localhost:1234/v1"),
1069 Some(lm_studio_key),
1070 AuthStyle::Bearer,
1071 );
1072 Ok(apply_compat_options(p, opts))
1073 }
1074}
1075
1076impl FamilyProviderFactory for LlamacppModelProviderConfig {
1077 fn create_provider(
1078 &self,
1079 alias: &str,
1080 key: Option<&str>,
1081 api_url: Option<&str>,
1082 opts: &ModelProviderRuntimeOptions,
1083 ) -> Result<Box<dyn ModelProvider>> {
1084 let base_url = api_url.unwrap_or("http://localhost:8080/v1");
1085 let llama_cpp_key = key
1086 .map(str::trim)
1087 .filter(|value| !value.is_empty())
1088 .unwrap_or("llama.cpp");
1089 let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1090 alias,
1091 "llama.cpp",
1092 base_url,
1093 Some(llama_cpp_key),
1094 AuthStyle::Bearer,
1095 true,
1096 )
1097 .with_local_model_tool_sanitize();
1098 if opts.merge_system_into_user {
1099 p = p.with_merge_system_into_user();
1100 }
1101 Ok(apply_compat_options(p, opts))
1102 }
1103}
1104
1105impl FamilyProviderFactory for OsaurusModelProviderConfig {
1106 fn create_provider(
1107 &self,
1108 alias: &str,
1109 key: Option<&str>,
1110 api_url: Option<&str>,
1111 opts: &ModelProviderRuntimeOptions,
1112 ) -> Result<Box<dyn ModelProvider>> {
1113 let osaurus_key = key
1114 .map(str::trim)
1115 .filter(|value| !value.is_empty())
1116 .unwrap_or("osaurus");
1117 let p = OpenAiCompatibleModelProvider::new(
1118 alias,
1119 "Osaurus",
1120 api_url.unwrap_or("http://localhost:1337/v1"),
1121 Some(osaurus_key),
1122 AuthStyle::Bearer,
1123 );
1124 Ok(apply_compat_options(p, opts))
1125 }
1126}
1127
1128impl FamilyProviderFactory for OvhModelProviderConfig {
1129 fn create_provider(
1130 &self,
1131 alias: &str,
1132 key: Option<&str>,
1133 _api_url: Option<&str>,
1134 _opts: &ModelProviderRuntimeOptions,
1135 ) -> Result<Box<dyn ModelProvider>> {
1136 Ok(Box::new(crate::openai::OpenAiModelProvider::with_base_url(
1137 alias,
1138 Some("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"),
1139 key,
1140 )))
1141 }
1142}
1143
1144impl FamilyProviderFactory for CustomModelProviderConfig {
1145 fn create_provider(
1146 &self,
1147 alias: &str,
1148 key: Option<&str>,
1149 api_url: Option<&str>,
1150 opts: &ModelProviderRuntimeOptions,
1151 ) -> Result<Box<dyn ModelProvider>> {
1152 let base_url = api_url.ok_or_else(|| {
1153 ::zeroclaw_log::record!(
1154 ERROR,
1155 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1156 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1157 .with_attrs(::serde_json::json!({
1158 "family": "custom",
1159 "alias": alias,
1160 "missing": "uri",
1161 })),
1162 "factory: custom provider missing uri"
1163 );
1164 anyhow::Error::msg(
1165 "Custom model_provider requires `uri`: set \
1166 `[model_providers.custom.<alias>] uri = \"https://your-api.com\"` in config.toml.",
1167 )
1168 })?;
1169 let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1170 alias,
1171 "Custom",
1172 base_url,
1173 key,
1174 AuthStyle::Bearer,
1175 true,
1176 );
1177 if opts.merge_system_into_user {
1178 p = p.with_merge_system_into_user();
1179 }
1180 Ok(apply_compat_options(p, opts))
1181 }
1182}
1183
1184impl FamilyProviderFactory for zeroclaw_config::schema::ModelProviderConfig {
1185 fn create_provider(
1186 &self,
1187 alias: &str,
1188 key: Option<&str>,
1189 api_url: Option<&str>,
1190 opts: &ModelProviderRuntimeOptions,
1191 ) -> Result<Box<dyn ModelProvider>> {
1192 let base_url = api_url.ok_or_else(|| {
1193 anyhow::Error::msg(
1194 "OpenAI-compatible model_provider requires `uri`: set \
1195 `[model_providers.<family>.<alias>] uri = \"https://your-api.com\"` in config.toml.",
1196 )
1197 })?;
1198 let mut p = OpenAiCompatibleModelProvider::new_with_vision(
1199 alias,
1200 "OpenAI Compatible",
1201 base_url,
1202 key,
1203 AuthStyle::Bearer,
1204 true,
1205 );
1206 if opts.merge_system_into_user {
1207 p = p.with_merge_system_into_user();
1208 }
1209 Ok(apply_compat_options(p, opts))
1210 }
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use super::*;
1216 use zeroclaw_config::schema::ModelProviderConfig;
1217
1218 #[test]
1219 fn openai_factory_routes_to_codex_when_requires_openai_auth_true() {
1220 let cfg = OpenAIModelProviderConfig {
1221 base: ModelProviderConfig {
1222 requires_openai_auth: true,
1223 ..Default::default()
1224 },
1225 };
1226 let provider = cfg
1227 .create_provider("test", None, None, &ModelProviderRuntimeOptions::default())
1228 .unwrap();
1229 assert!(provider.capabilities().native_tool_calling);
1231 }
1232
1233 #[test]
1234 fn openai_factory_routes_to_standard_when_requires_openai_auth_false() {
1235 let cfg = OpenAIModelProviderConfig {
1236 base: ModelProviderConfig {
1237 requires_openai_auth: false,
1238 ..Default::default()
1239 },
1240 };
1241 let provider = cfg
1242 .create_provider("test", None, None, &ModelProviderRuntimeOptions::default())
1243 .unwrap();
1244 assert!(!provider.capabilities().native_tool_calling);
1245 }
1246
1247 #[tokio::test]
1248 async fn zai_and_glm_factory_path_honors_api_url_override() {
1249 use axum::{Json, Router, extract::State, http::Uri, routing::post};
1250 use serde_json::{Value, json};
1251 use std::sync::{Arc, Mutex};
1252
1253 type Capture = Arc<Mutex<Vec<String>>>;
1254
1255 async fn capture_chat_request(
1256 State(capture): State<Capture>,
1257 uri: Uri,
1258 Json(_body): Json<Value>,
1259 ) -> Json<Value> {
1260 capture
1261 .lock()
1262 .expect("capture lock poisoned")
1263 .push(uri.path().to_string());
1264 Json(json!({
1265 "choices": [{"message": {"content": "ok"}}]
1266 }))
1267 }
1268
1269 let capture: Capture = Arc::new(Mutex::new(Vec::new()));
1270 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1271 .await
1272 .expect("bind test server");
1273 let addr = listener.local_addr().expect("test server addr");
1274 let app = Router::new()
1275 .route(
1276 "/zai/api/paas/v4/chat/completions",
1277 post(capture_chat_request),
1278 )
1279 .route(
1280 "/glm/api/paas/v4/chat/completions",
1281 post(capture_chat_request),
1282 )
1283 .with_state(capture.clone());
1284 let server = tokio::spawn(async move {
1285 axum::serve(listener, app).await.expect("serve test server");
1286 });
1287
1288 let base_url = format!("http://{addr}");
1289 let zai_url = format!("{base_url}/zai/api/paas/v4");
1290 let zai = ZaiModelProviderConfig::default()
1291 .create_provider(
1292 "cn",
1293 Some("id.secret"),
1294 Some(&zai_url),
1295 &ModelProviderRuntimeOptions::default(),
1296 )
1297 .expect("zai provider should build");
1298 assert_eq!(
1299 zai.chat_with_system(None, "hello", "glm-5-turbo", Some(0.7))
1300 .await
1301 .expect("zai chat should use overridden URL"),
1302 "ok"
1303 );
1304
1305 let glm_url = format!("{base_url}/glm/api/paas/v4");
1306 let glm = GlmModelProviderConfig::default()
1307 .create_provider(
1308 "global",
1309 Some("id.secret"),
1310 Some(&glm_url),
1311 &ModelProviderRuntimeOptions::default(),
1312 )
1313 .expect("glm provider should build");
1314 assert!(glm.capabilities().vision);
1315 assert_eq!(
1316 glm.chat_with_system(None, "hello", "glm-4.5", Some(0.7))
1317 .await
1318 .expect("glm chat should use overridden URL"),
1319 "ok"
1320 );
1321
1322 let paths = capture.lock().expect("capture lock poisoned").clone();
1323 assert_eq!(
1324 paths,
1325 vec![
1326 "/zai/api/paas/v4/chat/completions".to_string(),
1327 "/glm/api/paas/v4/chat/completions".to_string(),
1328 ]
1329 );
1330 server.abort();
1331 }
1332
1333 #[test]
1334 fn ollama_factory_uses_no_credential_when_key_absent() {
1335 let provider = build_ollama_compat_provider(
1336 "default",
1337 None,
1338 Some("http://192.168.1.100:11434/v1"),
1339 &ModelProviderRuntimeOptions::default(),
1340 );
1341
1342 assert_eq!(provider.name, "Ollama");
1343 assert_eq!(provider.base_url, "http://192.168.1.100:11434/v1");
1344 assert!(provider.credential.is_none());
1345 }
1346
1347 #[test]
1348 fn ollama_factory_normalizes_host_root_to_openai_compat_base() {
1349 let provider = build_ollama_compat_provider(
1350 "default",
1351 None,
1352 Some("http://192.168.1.100:11434"),
1353 &ModelProviderRuntimeOptions::default(),
1354 );
1355
1356 assert_eq!(provider.base_url, "http://192.168.1.100:11434/v1");
1357 }
1358
1359 #[test]
1360 fn ollama_factory_normalizes_legacy_api_path_to_openai_compat_base() {
1361 let provider = build_ollama_compat_provider(
1362 "default",
1363 None,
1364 Some("https://ollama.com/api"),
1365 &ModelProviderRuntimeOptions::default(),
1366 );
1367
1368 assert_eq!(provider.base_url, "https://ollama.com/v1");
1369 }
1370
1371 #[test]
1372 fn ollama_factory_preserves_typed_api_key_for_official_cloud() {
1373 let provider = build_ollama_compat_provider(
1374 "default",
1375 Some(" ollama-key "),
1376 Some("https://ollama.com/v1"),
1377 &ModelProviderRuntimeOptions::default(),
1378 );
1379
1380 assert_eq!(provider.credential.as_deref(), Some("ollama-key"));
1381 }
1382}