1#![allow(clippy::to_string_in_format_args)]
2pub mod anthropic;
20pub mod auth;
21pub mod azure_openai;
22pub mod bedrock;
23pub mod catalog;
24pub mod compatible;
25pub mod copilot;
26pub mod factory;
27pub mod gemini;
28pub mod gemini_cli;
29pub mod kilocli;
31pub mod model_pin;
32pub mod models_dev;
33pub mod multimodal;
34pub mod ollama;
35pub mod openai;
36pub mod openai_codex;
37pub mod openrouter;
38pub mod openrouter_catalog;
39pub mod reliable;
40pub mod router;
41pub(crate) mod stream_guard;
42pub mod telnyx;
43pub mod traits;
44
45#[allow(unused_imports)]
46pub use traits::{
47 ChatMessage, ChatRequest, ChatResponse, ConversationMessage, ModelProvider,
48 ProviderCapabilityError, ToolCall, ToolResultMessage,
49};
50
51use reliable::ReliableModelProvider;
52use serde::Deserialize;
53use std::path::PathBuf;
54
55const MAX_API_ERROR_CHARS: usize = 500;
56const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
57const MINIMAX_OAUTH_DEFAULT_CLIENT_ID: &str = "78257093-7e40-4613-99e0-527b14b39113";
61const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
62const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1";
63const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
64const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
65const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
66const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
67const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
68const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
69const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
70const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
71const VERCEL_AI_GATEWAY_BASE_URL: &str = "https://ai-gateway.vercel.sh/v1";
72
73pub fn is_minimax_intl_alias(name: &str) -> bool {
74 matches!(
75 name,
76 "minimax"
77 | "minimax-intl"
78 | "minimax-io"
79 | "minimax-global"
80 | "minimax-oauth"
81 | "minimax-portal"
82 | "minimax-oauth-global"
83 | "minimax-portal-global"
84 )
85}
86pub fn is_minimax_cn_alias(name: &str) -> bool {
87 matches!(
88 name,
89 "minimax-cn" | "minimaxi" | "minimax-oauth-cn" | "minimax-portal-cn"
90 )
91}
92pub fn is_minimax_alias(name: &str) -> bool {
93 is_minimax_intl_alias(name) || is_minimax_cn_alias(name)
94}
95pub fn is_glm_global_alias(name: &str) -> bool {
96 matches!(name, "glm" | "zhipu" | "glm-global" | "zhipu-global")
97}
98
99pub fn is_glm_cn_alias(name: &str) -> bool {
100 matches!(name, "glm-cn" | "zhipu-cn" | "bigmodel")
101}
102
103pub fn is_glm_alias(name: &str) -> bool {
104 is_glm_global_alias(name) || is_glm_cn_alias(name)
105}
106
107pub fn is_moonshot_intl_alias(name: &str) -> bool {
108 matches!(
109 name,
110 "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
111 )
112}
113
114pub fn is_moonshot_cn_alias(name: &str) -> bool {
115 matches!(name, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn")
116}
117
118pub fn is_moonshot_alias(name: &str) -> bool {
119 is_moonshot_intl_alias(name) || is_moonshot_cn_alias(name)
120}
121
122pub fn is_qwen_cn_alias(name: &str) -> bool {
123 matches!(name, "qwen" | "dashscope" | "qwen-cn" | "dashscope-cn")
124}
125
126pub fn is_qwen_intl_alias(name: &str) -> bool {
127 matches!(
128 name,
129 "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
130 )
131}
132
133pub fn is_qwen_us_alias(name: &str) -> bool {
134 matches!(name, "qwen-us" | "dashscope-us")
135}
136
137pub fn is_qwen_oauth_alias(name: &str) -> bool {
138 matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
139}
140
141pub fn is_bailian_alias(name: &str) -> bool {
142 matches!(name, "bailian" | "aliyun-bailian" | "aliyun")
143}
144
145pub fn is_qwen_alias(name: &str) -> bool {
146 is_qwen_cn_alias(name)
147 || is_qwen_intl_alias(name)
148 || is_qwen_us_alias(name)
149 || is_qwen_oauth_alias(name)
150}
151
152pub fn is_zai_global_alias(name: &str) -> bool {
153 matches!(name, "zai" | "z.ai" | "zai-global" | "z.ai-global")
154}
155
156pub fn is_zai_cn_alias(name: &str) -> bool {
157 matches!(name, "zai-cn" | "z.ai-cn")
158}
159
160pub fn is_zai_alias(name: &str) -> bool {
161 is_zai_global_alias(name) || is_zai_cn_alias(name)
162}
163
164pub fn is_qianfan_alias(name: &str) -> bool {
165 matches!(name, "qianfan" | "baidu")
166}
167
168fn qianfan_base_url(api_url: Option<&str>) -> String {
169 api_url
170 .map(str::trim)
171 .filter(|value| !value.is_empty())
172 .map(ToString::to_string)
173 .unwrap_or_else(|| QIANFAN_BASE_URL.to_string())
174}
175
176pub fn is_doubao_alias(name: &str) -> bool {
177 matches!(name, "doubao" | "volcengine" | "ark" | "doubao-cn")
178}
179
180#[derive(Clone, Deserialize, Default)]
181pub(crate) struct QwenOauthCredentials {
182 #[serde(default)]
183 pub(crate) access_token: Option<String>,
184 #[serde(default)]
185 pub(crate) refresh_token: Option<String>,
186 #[serde(default)]
187 pub(crate) resource_url: Option<String>,
188 #[serde(default)]
189 pub(crate) expiry_date: Option<i64>,
190}
191
192impl std::fmt::Debug for QwenOauthCredentials {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 f.debug_struct("QwenOauthCredentials")
195 .field("resource_url", &self.resource_url)
196 .field("expiry_date", &self.expiry_date)
197 .finish_non_exhaustive()
198 }
199}
200
201#[derive(Debug, Deserialize)]
202struct QwenOauthTokenResponse {
203 #[serde(default)]
204 access_token: Option<String>,
205 #[serde(default)]
206 refresh_token: Option<String>,
207 #[serde(default)]
208 expires_in: Option<i64>,
209 #[serde(default)]
210 resource_url: Option<String>,
211 #[serde(default)]
212 error: Option<String>,
213 #[serde(default)]
214 error_description: Option<String>,
215}
216
217#[derive(Clone, Default)]
218pub(crate) struct QwenOauthProviderContext {
219 pub(crate) credential: Option<String>,
220 pub(crate) base_url: Option<String>,
221}
222
223impl std::fmt::Debug for QwenOauthProviderContext {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.debug_struct("QwenOauthProviderContext")
226 .field("base_url", &self.base_url)
227 .finish_non_exhaustive()
228 }
229}
230
231fn qwen_oauth_client_id() -> String {
232 QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string()
233}
234
235fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
236 std::env::var_os("HOME")
238 .map(PathBuf::from)
239 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
240 .map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
241}
242
243fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
244 let trimmed = raw.trim().trim_end_matches('/');
245 if trimmed.is_empty() {
246 return None;
247 }
248
249 let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
250 trimmed.to_string()
251 } else {
252 format!("https://{trimmed}")
253 };
254
255 let normalized = with_scheme.trim_end_matches('/').to_string();
256 if normalized.ends_with("/v1") {
257 Some(normalized)
258 } else {
259 Some(format!("{normalized}/v1"))
260 }
261}
262
263fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
264 let path = qwen_oauth_credentials_file_path()?;
265 let content = std::fs::read_to_string(path).ok()?;
266 serde_json::from_str::<QwenOauthCredentials>(&content).ok()
267}
268
269fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
270 if raw < 10_000_000_000 {
271 raw.saturating_mul(1000)
272 } else {
273 raw
274 }
275}
276
277fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
278 let Some(expiry) = credentials.expiry_date else {
279 return false;
280 };
281
282 let expiry_millis = normalized_qwen_expiry_millis(expiry);
283 let now_millis = std::time::SystemTime::now()
284 .duration_since(std::time::UNIX_EPOCH)
285 .ok()
286 .and_then(|duration| i64::try_from(duration.as_millis()).ok())
287 .unwrap_or(i64::MAX);
288
289 expiry_millis <= now_millis.saturating_add(30_000)
290}
291
292pub(crate) fn refresh_qwen_oauth_access_token(
293 refresh_token: &str,
294 client_id: &str,
295) -> anyhow::Result<QwenOauthCredentials> {
296 let client = reqwest::blocking::Client::builder()
297 .timeout(std::time::Duration::from_secs(15))
298 .connect_timeout(std::time::Duration::from_secs(5))
299 .build()
300 .unwrap_or_else(|_| reqwest::blocking::Client::new());
301
302 let response = client
303 .post(QWEN_OAUTH_TOKEN_ENDPOINT)
304 .header("Content-Type", "application/x-www-form-urlencoded")
305 .header("Accept", "application/json")
306 .form(&[
307 ("grant_type", "refresh_token"),
308 ("refresh_token", refresh_token),
309 ("client_id", client_id),
310 ])
311 .send()
312 .map_err(|error| {
313 ::zeroclaw_log::record!(
314 ERROR,
315 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
316 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
317 .with_attrs(::serde_json::json!({
318 "oauth_provider": "qwen",
319 "phase": "refresh_request",
320 "error": format!("{}", error),
321 })),
322 "qwen: OAuth refresh request failed"
323 );
324 anyhow::Error::msg(format!("OAuth refresh request failed: {error}"))
325 })?;
326
327 let status = response.status();
328 let body = response
329 .text()
330 .unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
331
332 let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
333
334 if !status.is_success() {
335 let detail = parsed
336 .as_ref()
337 .and_then(|payload| payload.error_description.as_deref())
338 .or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
339 .filter(|msg| !msg.trim().is_empty())
340 .unwrap_or(body.as_str());
341 anyhow::bail!("OAuth refresh failed (HTTP {status}): {detail}");
342 }
343
344 let payload = parsed.ok_or_else(|| {
345 ::zeroclaw_log::record!(
346 ERROR,
347 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
348 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
349 .with_attrs(::serde_json::json!({
350 "oauth_provider": "qwen",
351 "phase": "refresh_parse",
352 })),
353 "qwen: OAuth refresh response is not JSON"
354 );
355 anyhow::Error::msg("OAuth refresh response is not JSON")
356 })?;
357
358 if let Some(error_code) = payload
359 .error
360 .as_deref()
361 .filter(|value| !value.trim().is_empty())
362 {
363 let detail = payload.error_description.as_deref().unwrap_or(error_code);
364 anyhow::bail!("OAuth refresh failed: {detail}");
365 }
366
367 let access_token = payload
368 .access_token
369 .as_deref()
370 .map(str::trim)
371 .filter(|token| !token.is_empty())
372 .ok_or_else(|| {
373 ::zeroclaw_log::record!(
374 ERROR,
375 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
376 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
377 .with_attrs(::serde_json::json!({
378 "oauth_provider": "qwen",
379 "field": "access_token",
380 })),
381 "qwen: OAuth refresh response missing access_token"
382 );
383 anyhow::Error::msg("OAuth refresh response missing access_token")
384 })?
385 .to_string();
386
387 let expiry_date = payload.expires_in.and_then(|seconds| {
388 let now_secs = std::time::SystemTime::now()
389 .duration_since(std::time::UNIX_EPOCH)
390 .ok()
391 .and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
392 now_secs
393 .checked_add(seconds)
394 .and_then(|unix_secs| unix_secs.checked_mul(1000))
395 });
396
397 Ok(QwenOauthCredentials {
398 access_token: Some(access_token),
399 refresh_token: payload
400 .refresh_token
401 .as_deref()
402 .map(str::trim)
403 .filter(|value| !value.is_empty())
404 .map(ToString::to_string),
405 resource_url: payload
406 .resource_url
407 .as_deref()
408 .map(str::trim)
409 .filter(|value| !value.is_empty())
410 .map(ToString::to_string),
411 expiry_date,
412 })
413}
414
415#[derive(Debug, Deserialize)]
424struct MinimaxOauthRefreshResponse {
425 #[serde(default)]
426 status: Option<String>,
427 #[serde(default)]
428 access_token: Option<String>,
429 #[serde(default)]
430 base_resp: Option<MinimaxOauthBaseResponse>,
431}
432
433#[derive(Debug, Deserialize)]
434struct MinimaxOauthBaseResponse {
435 #[serde(default)]
436 status_msg: Option<String>,
437}
438
439pub(crate) fn refresh_minimax_oauth_access_token(
444 refresh_token: &str,
445 client_id: &str,
446 region: zeroclaw_config::schema::MinimaxEndpoint,
447) -> anyhow::Result<String> {
448 let endpoint = region.oauth_token_endpoint();
449 let client = reqwest::blocking::Client::builder()
450 .timeout(std::time::Duration::from_secs(15))
451 .connect_timeout(std::time::Duration::from_secs(5))
452 .build()
453 .unwrap_or_else(|_| reqwest::blocking::Client::new());
454
455 let response = client
456 .post(endpoint)
457 .header("Content-Type", "application/x-www-form-urlencoded")
458 .header("Accept", "application/json")
459 .form(&[
460 ("grant_type", "refresh_token"),
461 ("refresh_token", refresh_token),
462 ("client_id", client_id),
463 ])
464 .send()
465 .map_err(|error| {
466 ::zeroclaw_log::record!(
467 ERROR,
468 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
469 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
470 .with_attrs(::serde_json::json!({
471 "oauth_provider": "minimax",
472 "phase": "refresh_request",
473 "error": format!("{}", error),
474 })),
475 "minimax: OAuth refresh request failed"
476 );
477 anyhow::Error::msg(format!("MiniMax OAuth refresh request failed: {error}"))
478 })?;
479
480 let status = response.status();
481 let body = response
482 .text()
483 .unwrap_or_else(|_| "<failed to read MiniMax OAuth response body>".to_string());
484 let parsed = serde_json::from_str::<MinimaxOauthRefreshResponse>(&body).ok();
485
486 if !status.is_success() {
487 let detail = parsed
488 .as_ref()
489 .and_then(|payload| payload.base_resp.as_ref())
490 .and_then(|base| base.status_msg.as_deref())
491 .filter(|msg| !msg.trim().is_empty())
492 .unwrap_or(body.as_str());
493 anyhow::bail!("MiniMax OAuth refresh failed (HTTP {status}): {detail}");
494 }
495
496 if let Some(payload) = parsed {
497 if let Some(status_text) = payload.status.as_deref()
498 && !status_text.eq_ignore_ascii_case("success")
499 {
500 let detail = payload
501 .base_resp
502 .as_ref()
503 .and_then(|base| base.status_msg.as_deref())
504 .unwrap_or(status_text);
505 anyhow::bail!("MiniMax OAuth refresh failed: {detail}");
506 }
507 if let Some(token) = payload
508 .access_token
509 .as_deref()
510 .map(str::trim)
511 .filter(|token| !token.is_empty())
512 {
513 return Ok(token.to_string());
514 }
515 }
516 anyhow::bail!("MiniMax OAuth refresh response missing access_token")
517}
518
519fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
520 let override_value = credential_override
521 .map(str::trim)
522 .filter(|value| !value.is_empty());
523 let placeholder_requested = override_value
524 .map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
525 .unwrap_or(false);
526
527 if let Some(explicit) = override_value
528 && !placeholder_requested
529 {
530 return QwenOauthProviderContext {
531 credential: Some(explicit.to_string()),
532 base_url: None,
533 };
534 }
535
536 let mut cached = read_qwen_oauth_cached_credentials();
540
541 let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
542 || cached
543 .as_ref()
544 .and_then(|credentials| credentials.access_token.as_deref())
545 .is_none_or(|value| value.trim().is_empty());
546
547 if should_refresh
548 && let Some(refresh_token) = cached
549 .as_ref()
550 .and_then(|credentials| credentials.refresh_token.clone())
551 {
552 match refresh_qwen_oauth_access_token(&refresh_token, &qwen_oauth_client_id()) {
553 Ok(refreshed) => {
554 cached = Some(refreshed);
555 }
556 Err(error) => {
557 ::zeroclaw_log::record!(
558 WARN,
559 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
560 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
561 .with_attrs(::serde_json::json!({"error": format!("{}", error)})),
562 "OAuth refresh failed"
563 );
564 }
565 }
566 }
567
568 let credential = cached
569 .as_ref()
570 .and_then(|credentials| credentials.access_token.as_deref())
571 .map(str::trim)
572 .filter(|value| !value.is_empty())
573 .map(ToString::to_string);
574
575 let base_url = cached
576 .as_ref()
577 .and_then(|credentials| credentials.resource_url.as_deref())
578 .and_then(normalize_qwen_oauth_base_url);
579
580 QwenOauthProviderContext {
581 credential,
582 base_url,
583 }
584}
585
586#[derive(Debug, Clone)]
596pub struct ModelProviderRuntimeOptions {
597 pub auth_profile_override: Option<String>,
598 pub provider_kind: Option<String>,
601 pub provider_api_url: Option<String>,
602 pub zeroclaw_dir: Option<PathBuf>,
603 pub secrets_encrypt: bool,
604 pub reasoning_enabled: Option<bool>,
605 pub reasoning_effort: Option<String>,
606 pub provider_timeout_secs: Option<u64>,
609 pub extra_headers: std::collections::HashMap<String, String>,
611 pub api_path: Option<String>,
614 pub provider_max_tokens: Option<u32>,
617 pub merge_system_into_user: bool,
620 pub provider_extra: Option<serde_json::Value>,
623 pub native_tools: Option<bool>,
626 pub wire_api: Option<String>,
631 pub think: Option<bool>,
634 pub chat_template_kwargs: Option<serde_json::Value>,
636}
637
638impl Default for ModelProviderRuntimeOptions {
639 fn default() -> Self {
640 Self {
641 auth_profile_override: None,
642 provider_kind: None,
643 provider_api_url: None,
644 zeroclaw_dir: None,
645 secrets_encrypt: true,
646 reasoning_enabled: None,
647 reasoning_effort: None,
648 provider_timeout_secs: None,
649 extra_headers: std::collections::HashMap::new(),
650 api_path: None,
651 provider_max_tokens: None,
652 merge_system_into_user: false,
653 provider_extra: None,
654 native_tools: None,
655 wire_api: None,
656 think: None,
657 chat_template_kwargs: None,
658 }
659 }
660}
661
662pub fn model_provider_runtime_options_from_model_provider_entry(
671 config: &zeroclaw_config::schema::Config,
672 entry: Option<&zeroclaw_config::schema::ModelProviderConfig>,
673) -> ModelProviderRuntimeOptions {
674 let merge_system_into_user = entry
679 .and_then(|e| e.uri.as_deref())
680 .map(str::trim)
681 .filter(|u| !u.is_empty())
682 .and_then(|active_uri| {
683 config
684 .providers
685 .models
686 .iter_entries()
687 .map(|(_, _, base)| base)
688 .find(|p| {
689 p.uri
690 .as_deref()
691 .map(str::trim)
692 .filter(|u: &&str| !u.is_empty())
693 .map(|u: &str| u.trim_end_matches('/'))
694 == Some(active_uri.trim_end_matches('/'))
695 })
696 })
697 .map(|p| p.merge_system_into_user)
698 .unwrap_or(false);
699
700 ModelProviderRuntimeOptions {
701 auth_profile_override: None,
702 provider_kind: entry.and_then(|e| {
703 e.kind
704 .as_deref()
705 .map(str::trim)
706 .filter(|value| !value.is_empty())
707 .map(ToString::to_string)
708 }),
709 provider_api_url: entry.and_then(|e| e.uri.clone()),
710 zeroclaw_dir: config.config_path.parent().map(PathBuf::from),
711 secrets_encrypt: config.secrets.encrypt,
712 reasoning_enabled: config.runtime.reasoning_enabled,
713 reasoning_effort: config.runtime.reasoning_effort.clone(),
714 provider_timeout_secs: Some(entry.and_then(|e| e.timeout_secs).unwrap_or(120)),
715 extra_headers: entry.map(|e| e.extra_headers.clone()).unwrap_or_default(),
716 api_path: None,
717 provider_max_tokens: entry.and_then(|e| e.max_tokens),
718 merge_system_into_user,
719 provider_extra: entry.and_then(|e| e.provider_extra.clone()),
720 native_tools: entry.and_then(|e| e.native_tools),
721 wire_api: entry.and_then(|e| e.wire_api.map(|w| w.as_str().to_string())),
722 think: entry.and_then(|e| e.think),
723 chat_template_kwargs: entry.and_then(|e| e.chat_template_kwargs.clone()),
724 }
725}
726
727pub fn provider_runtime_options_for_agent(
731 config: &zeroclaw_config::schema::Config,
732 agent_alias: &str,
733) -> ModelProviderRuntimeOptions {
734 let entry = config.model_provider_for_agent(agent_alias);
735 let mut options = model_provider_runtime_options_from_model_provider_entry(config, entry);
736
737 if let Some(agent) = config.agents.get(agent_alias)
738 && let Some((family, alias)) = agent.model_provider.split_once('.')
739 {
740 if options.provider_api_url.is_none()
745 && let Some(uri) = config.providers.models.resolved_endpoint_uri(family, alias)
746 {
747 options.provider_api_url = Some(uri.to_string());
748 }
749 }
755
756 options
757}
758pub fn provider_runtime_options_for_alias(
764 config: &zeroclaw_config::schema::Config,
765 family: &str,
766 alias: &str,
767) -> ModelProviderRuntimeOptions {
768 let entry = config.providers.models.find(family, alias);
769 let mut options = model_provider_runtime_options_from_model_provider_entry(config, entry);
770 if options.provider_api_url.is_none()
771 && let Some(uri) = config.providers.models.resolved_endpoint_uri(family, alias)
772 {
773 options.provider_api_url = Some(uri.to_string());
774 }
775 options
776}
777
778pub fn options_for_provider_ref(
783 config: &zeroclaw_config::schema::Config,
784 name: &str,
785 fallback: &ModelProviderRuntimeOptions,
786) -> ModelProviderRuntimeOptions {
787 match name.split_once('.') {
788 Some((family, alias)) => provider_runtime_options_for_alias(config, family, alias),
789 None => {
790 let mut options = fallback.clone();
791 options.provider_kind = None;
792 options.provider_api_url = None;
793 options
794 }
795 }
796}
797
798fn is_secret_char(c: char) -> bool {
799 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
800}
801
802fn token_end(input: &str, from: usize) -> usize {
803 let mut end = from;
804 for (i, c) in input[from..].char_indices() {
805 if is_secret_char(c) {
806 end = from + i + c.len_utf8();
807 } else {
808 break;
809 }
810 }
811 end
812}
813
814pub fn scrub_secret_patterns(input: &str) -> String {
819 const PREFIXES: [&str; 7] = [
820 "sk-",
821 "xoxb-",
822 "xoxp-",
823 "ghp_",
824 "gho_",
825 "ghu_",
826 "github_pat_",
827 ];
828
829 let mut scrubbed = input.to_string();
830
831 for prefix in PREFIXES {
832 let mut search_from = 0;
833 while let Some(rel) = scrubbed[search_from..].find(prefix) {
834 let start = search_from + rel;
835 let content_start = start + prefix.len();
836 let end = token_end(&scrubbed, content_start);
837
838 if end == content_start {
840 search_from = content_start;
841 continue;
842 }
843
844 scrubbed.replace_range(start..end, "[REDACTED]");
845 search_from = start + "[REDACTED]".len();
846 }
847 }
848
849 scrubbed
850}
851
852pub fn sanitize_api_error(input: &str) -> String {
854 let scrubbed = scrub_secret_patterns(input);
855
856 if scrubbed.chars().count() <= MAX_API_ERROR_CHARS {
857 return scrubbed;
858 }
859
860 let mut end = MAX_API_ERROR_CHARS;
861 while end > 0 && !scrubbed.is_char_boundary(end) {
862 end -= 1;
863 }
864
865 format!("{}...", &scrubbed[..end])
866}
867
868pub fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
870 let mut formatted = String::new();
871 let _ = std::fmt::Write::write_fmt(&mut formatted, format_args!("{error}"));
872 let mut current = error.source();
873 while let Some(source) = current {
874 let _ = std::fmt::Write::write_fmt(&mut formatted, format_args!(": {source}"));
875 current = source.source();
876 }
877 sanitize_api_error(&formatted)
878}
879
880pub async fn api_error(model_provider: &str, response: reqwest::Response) -> anyhow::Error {
882 let status = response.status();
883 let body = response
884 .text()
885 .await
886 .unwrap_or_else(|_| "<failed to read model_provider error body>".to_string());
887 let sanitized = sanitize_api_error(&body);
888 ::zeroclaw_log::record!(
889 ERROR,
890 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
891 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
892 .with_attrs(::serde_json::json!({
893 "model_provider": model_provider,
894 "status": status.as_u16(),
895 "body": sanitized,
896 })),
897 "providers: API error"
898 );
899 anyhow::Error::msg(format!(
900 "{model_provider} API error ({status}): {sanitized}"
901 ))
902}
903
904fn resolve_model_provider_credential(
909 _name: &str,
910 credential_override: Option<&str>,
911) -> Option<String> {
912 credential_override
913 .map(str::trim)
914 .filter(|value| !value.is_empty())
915 .map(ToString::to_string)
916}
917
918const KEY_PREFIX_MODEL_PROVIDERS: &[(&str, &str)] = &[
923 ("sk-ant-", "anthropic"),
924 ("sk-or-", "openrouter"),
925 ("sk-", "openai"),
926 ("gsk_", "groq"),
927 ("pplx-", "perplexity"),
928 ("xai-", "xai"),
929 ("nvapi-", "nvidia"),
930 ("KEY-", "telnyx"),
931];
932
933fn check_api_key_prefix(model_provider_name: &str, key: &str) -> Option<&'static str> {
939 let likely_model_provider = KEY_PREFIX_MODEL_PROVIDERS
940 .iter()
941 .find(|(prefix, _)| key.starts_with(prefix))
942 .map(|(_, name)| *name)?;
943
944 let recognized = KEY_PREFIX_MODEL_PROVIDERS
948 .iter()
949 .any(|(_, name)| *name == model_provider_name);
950 if !recognized {
951 return None;
952 }
953
954 if model_provider_name == likely_model_provider {
955 None
956 } else {
957 Some(likely_model_provider)
958 }
959}
960
961pub fn create_model_provider(
978 name: &str,
979 api_key: Option<&str>,
980) -> anyhow::Result<Box<dyn ModelProvider>> {
981 create_model_provider_inner(
982 None,
983 name,
984 "default",
985 api_key,
986 None,
987 &ModelProviderRuntimeOptions::default(),
988 )
989}
990
991pub fn create_model_provider_with_options(
995 name: &str,
996 api_key: Option<&str>,
997 options: &ModelProviderRuntimeOptions,
998) -> anyhow::Result<Box<dyn ModelProvider>> {
999 create_model_provider_inner(None, name, "default", api_key, None, options)
1000}
1001
1002pub fn create_model_provider_with_url(
1006 name: &str,
1007 api_key: Option<&str>,
1008 api_url: Option<&str>,
1009) -> anyhow::Result<Box<dyn ModelProvider>> {
1010 create_model_provider_inner(
1011 None,
1012 name,
1013 "default",
1014 api_key,
1015 api_url,
1016 &ModelProviderRuntimeOptions::default(),
1017 )
1018}
1019
1020pub fn create_model_provider_for_alias(
1027 config: &zeroclaw_config::schema::Config,
1028 family: &str,
1029 alias: &str,
1030 api_key: Option<&str>,
1031 options: &ModelProviderRuntimeOptions,
1032) -> anyhow::Result<Box<dyn ModelProvider>> {
1033 create_model_provider_inner(Some(config), family, alias, api_key, None, options)
1034}
1035
1036pub fn create_model_provider_for_alias_with_url(
1038 config: &zeroclaw_config::schema::Config,
1039 family: &str,
1040 alias: &str,
1041 api_key: Option<&str>,
1042 api_url: Option<&str>,
1043 options: &ModelProviderRuntimeOptions,
1044) -> anyhow::Result<Box<dyn ModelProvider>> {
1045 create_model_provider_inner(Some(config), family, alias, api_key, api_url, options)
1046}
1047
1048#[must_use]
1058pub fn canonicalize_v2_model_provider_name(name: &str) -> &str {
1059 match name {
1060 "azure_openai" | "azure-openai" => "azure",
1062 "grok" => "xai",
1063 "google" | "google-gemini" => "gemini",
1064 "together-ai" => "together",
1065 "fireworks-ai" => "fireworks",
1066 "vercel-ai" => "vercel",
1067 "cloudflare-ai" => "cloudflare",
1068 "nvidia-nim" | "build.nvidia.com" => "nvidia",
1069 "aws-bedrock" => "bedrock",
1070 "lm-studio" => "lmstudio",
1071 "lite-llm" => "litellm",
1072 "hf" => "huggingface",
1073 "01ai" | "lingyiwanwu" => "yi",
1074 "tencent" => "hunyuan",
1075 "baidu" => "qianfan",
1076 "github-copilot" => "copilot",
1077 "ovhcloud" => "ovh",
1078 "opencode-zen" => "opencode",
1079 "llama.cpp" => "llamacpp",
1080 "deep-myst" => "deepmyst",
1081 "silicon-flow" => "siliconflow",
1082 "deep-infra" => "deepinfra",
1083 "ai21-labs" => "ai21",
1084 "friendliai" => "friendli",
1085 "lepton-ai" => "lepton",
1086 "lambda-ai" => "lambda_ai",
1087 "github-models" => "github_models",
1088 "step" => "stepfun",
1089 "kimi" | "kimi-cn" | "kimi-intl" | "kimi-global" | "kimi-code" | "kimi_coding"
1091 | "kimi_for_coding" | "moonshot-cn" | "moonshot-intl" | "moonshot-global" => "moonshot",
1092 "qwen-cn"
1094 | "qwen-intl"
1095 | "qwen-us"
1096 | "qwen-international"
1097 | "qwen-code"
1098 | "qwen-oauth"
1099 | "qwen_oauth"
1100 | "dashscope"
1101 | "dashscope-cn"
1102 | "dashscope-intl"
1103 | "dashscope-us"
1104 | "dashscope-international"
1105 | "bailian"
1106 | "aliyun-bailian"
1107 | "aliyun" => "qwen",
1108 "zhipu" | "glm-global" | "zhipu-global" | "glm-cn" | "zhipu-cn" | "bigmodel" => "glm",
1110 "z.ai" | "zai-global" | "z.ai-global" | "zai-cn" | "z.ai-cn" => "zai",
1112 "minimax-intl"
1114 | "minimax-io"
1115 | "minimax-global"
1116 | "minimax-portal"
1117 | "minimax-portal-global"
1118 | "minimax-cn"
1119 | "minimaxi"
1120 | "minimax-portal-cn"
1121 | "minimax-oauth"
1122 | "minimax-oauth-global"
1123 | "minimax-oauth-cn" => "minimax",
1124 "volcengine" | "ark" | "doubao-cn" => "doubao",
1126 "gemini-cli" => "gemini_cli",
1128 "stepfun-intl" | "step-intl" => "stepfun",
1130 "claude-code" | "anthropic-custom" => "anthropic",
1132 "opencode-go" => "opencode",
1134 _ => name,
1137 }
1138}
1139
1140fn split_v2_colon_url(name: &str) -> (&str, Option<&str>) {
1146 if let Some(idx) = name.find(':') {
1147 let (prefix, rest) = name.split_at(idx);
1148 let url = &rest[1..];
1149 if url.starts_with("http://") || url.starts_with("https://") {
1150 return (prefix, Some(url));
1151 }
1152 }
1153 (name, None)
1154}
1155
1156pub(crate) fn moonshot_code_base_url() -> &'static str {
1157 <zeroclaw_config::schema::MoonshotEndpoint as zeroclaw_config::schema::ModelEndpoint>::uri(
1158 &zeroclaw_config::schema::MoonshotEndpoint::Code,
1159 )
1160}
1161
1162fn is_legacy_kimi_code_alias(name: &str) -> bool {
1163 matches!(name, "kimi-code" | "kimi_coding" | "kimi_for_coding")
1164}
1165
1166#[allow(clippy::too_many_lines)]
1168fn create_model_provider_inner(
1169 config: Option<&zeroclaw_config::schema::Config>,
1170 raw_name: &str,
1171 alias: &str,
1172 api_key: Option<&str>,
1173 api_url: Option<&str>,
1174 options: &ModelProviderRuntimeOptions,
1175) -> anyhow::Result<Box<dyn ModelProvider>> {
1176 if let Some(idx) = raw_name.find(':') {
1182 let prefix = &raw_name[..idx];
1183 let url = raw_name[idx + 1..].trim();
1184 if matches!(prefix, "custom" | "anthropic-custom")
1185 && (url.is_empty() || !(url.starts_with("http://") || url.starts_with("https://")))
1186 {
1187 anyhow::bail!(
1188 "Custom model_provider `{prefix}:<url>` requires a URL beginning with http:// or https://. \
1189 Set `[providers.models.custom.<alias>] uri = \"https://your-api.com\"` or pass a valid URL."
1190 );
1191 }
1192 }
1193 let (split_name, split_url) = split_v2_colon_url(raw_name);
1194 let legacy_kimi_code = is_legacy_kimi_code_alias(split_name);
1195 let api_url = api_url.or(split_url);
1196 let name = canonicalize_v2_model_provider_name(split_name);
1197 let provider_kind = options
1198 .provider_kind
1199 .as_deref()
1200 .map(str::trim)
1201 .filter(|value| !value.is_empty())
1202 .map(canonicalize_v2_model_provider_name)
1203 .unwrap_or(name);
1204
1205 if matches!(provider_kind, "openai-codex" | "openai_codex" | "codex") {
1210 return Ok(Box::new(openai_codex::OpenAiCodexModelProvider::new(
1211 alias, options, api_key,
1212 )?));
1213 }
1214 let resolved_credential = resolve_model_provider_credential(provider_kind, api_key)
1220 .map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
1221 #[allow(clippy::option_as_ref_deref)]
1222 let key = resolved_credential.as_ref().map(String::as_str);
1223
1224 if let Some(key_value) = key {
1226 let is_custom =
1227 provider_kind.starts_with("custom:") || provider_kind.starts_with("anthropic-custom:");
1228 let has_custom_url = api_url.map(str::trim).filter(|u| !u.is_empty()).is_some();
1229 if !is_custom
1230 && !has_custom_url
1231 && let Some(likely_model_provider) = check_api_key_prefix(provider_kind, key_value)
1232 {
1233 let visible = &key_value[..key_value.len().min(8)];
1234 anyhow::bail!(
1235 "API key prefix mismatch: key \"{visible}...\" looks like a \
1236 {likely_model_provider} key, but model_provider \"{provider_kind}\" is selected. \
1237 Set the correct provider-specific env var or use `-p {likely_model_provider}`."
1238 );
1239 }
1240 }
1241
1242 let resolved_url: Option<&str> =
1259 api_url
1260 .map(str::trim)
1261 .filter(|v| !v.is_empty())
1262 .or_else(|| {
1263 options
1264 .provider_api_url
1265 .as_deref()
1266 .map(str::trim)
1267 .filter(|v| !v.is_empty())
1268 });
1269
1270 if legacy_kimi_code {
1271 let base_url = match resolved_url {
1272 Some(url) => url,
1273 None => moonshot_code_base_url(),
1274 };
1275 return Ok(factory::apply_compat_options(
1276 factory::build_kimi_code_compat(alias, key, base_url),
1277 options,
1278 ));
1279 }
1280
1281 factory::dispatch_family_factory(config, provider_kind, alias, key, resolved_url, options)
1282}
1283
1284pub fn create_resilient_model_provider_with_options(
1292 primary_name: &str,
1293 api_key: Option<&str>,
1294 api_url: Option<&str>,
1295 reliability: &zeroclaw_config::schema::ReliabilityConfig,
1296 options: &ModelProviderRuntimeOptions,
1297) -> anyhow::Result<Box<dyn ModelProvider>> {
1298 let primary_model_provider =
1299 create_model_provider_inner(None, primary_name, "default", api_key, api_url, options)?;
1300
1301 let reliable = ReliableModelProvider::new(
1302 primary_name,
1303 vec![(primary_name.to_string(), primary_model_provider)],
1304 reliability.provider_retries,
1305 reliability.provider_backoff_ms,
1306 )
1307 .with_api_keys(reliability.api_keys.clone());
1308
1309 Ok(Box::new(reliable))
1310}
1311
1312pub fn create_resilient_model_provider_for_alias(
1317 config: &zeroclaw_config::schema::Config,
1318 family: &str,
1319 alias: &str,
1320 api_key: Option<&str>,
1321 api_url: Option<&str>,
1322 reliability: &zeroclaw_config::schema::ReliabilityConfig,
1323 options: &ModelProviderRuntimeOptions,
1324) -> anyhow::Result<Box<dyn ModelProvider>> {
1325 let primary_model_provider =
1326 create_model_provider_inner(Some(config), family, alias, api_key, api_url, options)?;
1327
1328 let mut model_providers: Vec<(String, Box<dyn ModelProvider>)> = Vec::new();
1329 push_pinned_entries(
1330 &mut model_providers,
1331 config,
1332 family,
1333 alias,
1334 primary_model_provider,
1335 );
1336
1337 let mut visited: Vec<String> = vec![format!("{family}.{alias}")];
1338 if let Some(entry) = config.providers.models.find(family, alias) {
1339 append_fallback_chain(
1340 &mut model_providers,
1341 config,
1342 &entry.fallback,
1343 &mut visited,
1344 1,
1345 );
1346 }
1347
1348 let reliable = ReliableModelProvider::new(
1349 alias,
1350 model_providers,
1351 reliability.provider_retries,
1352 reliability.provider_backoff_ms,
1353 )
1354 .with_api_keys(reliability.api_keys.clone());
1355
1356 Ok(Box::new(reliable))
1357}
1358
1359fn push_pinned_entries(
1365 out: &mut Vec<(String, Box<dyn ModelProvider>)>,
1366 config: &zeroclaw_config::schema::Config,
1367 family: &str,
1368 alias: &str,
1369 built: Box<dyn ModelProvider>,
1370) {
1371 let entry = config.providers.models.find(family, alias);
1372 let primary_model = entry.and_then(|e| e.model.as_deref());
1373 let extra_models: &[String] = entry.map(|e| e.fallback_models.as_slice()).unwrap_or(&[]);
1374
1375 let Some(primary_model) = primary_model else {
1376 out.push((family.to_string(), built));
1377 return;
1378 };
1379
1380 let built: std::sync::Arc<dyn ModelProvider> = std::sync::Arc::from(built);
1381 out.push((
1382 family.to_string(),
1383 Box::new(crate::model_pin::ModelPinnedProvider::new(
1384 alias,
1385 primary_model,
1386 Box::new(std::sync::Arc::clone(&built)),
1387 )),
1388 ));
1389 for model in extra_models {
1390 if model.trim().is_empty() || model == primary_model {
1391 continue;
1392 }
1393 out.push((
1394 family.to_string(),
1395 Box::new(crate::model_pin::ModelPinnedProvider::new(
1396 alias,
1397 model,
1398 Box::new(std::sync::Arc::clone(&built)),
1399 )),
1400 ));
1401 }
1402}
1403
1404fn append_fallback_chain(
1410 out: &mut Vec<(String, Box<dyn ModelProvider>)>,
1411 config: &zeroclaw_config::schema::Config,
1412 refs: &[zeroclaw_config::providers::ModelProviderRef],
1413 visited: &mut Vec<String>,
1414 depth: usize,
1415) {
1416 if depth > zeroclaw_config::providers::MAX_FALLBACK_DEPTH {
1417 ::zeroclaw_log::record!(
1418 WARN,
1419 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1420 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1421 .with_attrs(::serde_json::json!({
1422 "max_depth": zeroclaw_config::providers::MAX_FALLBACK_DEPTH
1423 })),
1424 "fallback chain exceeds max depth; pruning"
1425 );
1426 return;
1427 }
1428 for fallback_ref in refs {
1429 let raw = fallback_ref.as_str().trim();
1430 if raw.is_empty() {
1431 continue;
1432 }
1433 let Some((family, alias, entry)) = config.providers.models.find_by_name(raw) else {
1434 ::zeroclaw_log::record!(
1435 WARN,
1436 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1437 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1438 .with_attrs(::serde_json::json!({"fallback": raw})),
1439 "fallback ref does not resolve; skipping"
1440 );
1441 continue;
1442 };
1443 let resolved = format!("{family}.{alias}");
1444 if visited.iter().any(|v| v == &resolved) {
1445 ::zeroclaw_log::record!(
1446 WARN,
1447 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1448 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1449 .with_attrs(::serde_json::json!({"fallback": resolved})),
1450 "fallback ref closes a cycle; pruning"
1451 );
1452 continue;
1453 }
1454
1455 let opts = provider_runtime_options_for_alias(config, family, &alias);
1456 match create_model_provider_inner(
1457 Some(config),
1458 family,
1459 &alias,
1460 entry.api_key.as_deref(),
1461 entry.uri.as_deref(),
1462 &opts,
1463 ) {
1464 Ok(built) => push_pinned_entries(out, config, family, &alias, built),
1465 Err(e) => {
1466 ::zeroclaw_log::record!(
1467 WARN,
1468 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1469 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1470 .with_attrs(
1471 ::serde_json::json!({"fallback": resolved, "error": format!("{e}")})
1472 ),
1473 "fallback provider failed to build; skipping"
1474 );
1475 continue;
1476 }
1477 }
1478
1479 visited.push(resolved.clone());
1480 append_fallback_chain(out, config, &entry.fallback, visited, depth + 1);
1481 visited.pop();
1482 }
1483}
1484
1485pub fn create_resilient_model_provider_from_ref(
1491 config: &zeroclaw_config::schema::Config,
1492 name: &str,
1493 api_key: Option<&str>,
1494 api_url: Option<&str>,
1495 reliability: &zeroclaw_config::schema::ReliabilityConfig,
1496 options: &ModelProviderRuntimeOptions,
1497) -> anyhow::Result<Box<dyn ModelProvider>> {
1498 match name.split_once('.') {
1499 Some((family, alias)) => create_resilient_model_provider_for_alias(
1500 config,
1501 family,
1502 alias,
1503 api_key,
1504 api_url,
1505 reliability,
1506 options,
1507 ),
1508 None => create_resilient_model_provider_with_options(
1509 name,
1510 api_key,
1511 api_url,
1512 reliability,
1513 options,
1514 ),
1515 }
1516}
1517
1518pub fn create_routed_model_provider_with_options(
1524 config: &zeroclaw_config::schema::Config,
1525 primary_name: &str,
1526 api_key: Option<&str>,
1527 api_url: Option<&str>,
1528 reliability: &zeroclaw_config::schema::ReliabilityConfig,
1529 model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
1530 default_model: &str,
1531 options: &ModelProviderRuntimeOptions,
1532) -> anyhow::Result<Box<dyn ModelProvider>> {
1533 if model_routes.is_empty() {
1534 return create_resilient_model_provider_from_ref(
1535 config,
1536 primary_name,
1537 api_key,
1538 api_url,
1539 reliability,
1540 options,
1541 );
1542 }
1543
1544 let mut needed: Vec<String> = vec![primary_name.to_string()];
1546 for route in model_routes {
1547 if !needed.iter().any(|n| n == &route.model_provider) {
1548 needed.push(route.model_provider.clone());
1549 }
1550 }
1551
1552 let mut model_providers: Vec<(String, Box<dyn ModelProvider>)> = Vec::new();
1557 for name in &needed {
1558 let routed_credential = model_routes
1559 .iter()
1560 .find(|r| &r.model_provider == name)
1561 .and_then(|r| {
1562 r.api_key.as_ref().and_then(|raw_key| {
1563 let trimmed_key = raw_key.trim();
1564 (!trimmed_key.is_empty()).then_some(trimmed_key)
1565 })
1566 });
1567 let key = routed_credential
1568 .or_else(|| {
1569 name.split_once('.')
1570 .and_then(|(family, alias)| {
1571 config
1572 .providers
1573 .models
1574 .find(family, alias)
1575 .and_then(|cfg| cfg.api_key.as_deref())
1576 })
1577 .and_then(|raw_key| {
1578 let trimmed = raw_key.trim();
1579 (!trimmed.is_empty()).then_some(trimmed)
1580 })
1581 })
1582 .or(api_key);
1583 let url = if name == primary_name { api_url } else { None };
1584 let entry_options = if name == primary_name {
1585 options.clone()
1586 } else {
1587 options_for_provider_ref(config, name, options)
1588 };
1589
1590 match create_resilient_model_provider_from_ref(
1591 config,
1592 name,
1593 key,
1594 url,
1595 reliability,
1596 &entry_options,
1597 ) {
1598 Ok(model_provider) => model_providers.push((name.clone(), model_provider)),
1599 Err(e) => {
1600 if name == primary_name {
1601 return Err(e);
1602 }
1603 ::zeroclaw_log::record!(
1604 WARN,
1605 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1606 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1607 .with_attrs(
1608 ::serde_json::json!({"model_provider": name.as_str(), "error": format!("{}", e)})
1609 ),
1610 "Ignoring routed model_provider that failed to initialize"
1611 );
1612 }
1613 }
1614 }
1615
1616 let routes: Vec<(String, router::Route)> = model_routes
1618 .iter()
1619 .map(|r| {
1620 (
1621 r.hint.clone(),
1622 router::Route {
1623 provider_name: r.model_provider.clone(),
1624 model: r.model.clone(),
1625 },
1626 )
1627 })
1628 .collect();
1629
1630 Ok(Box::new(router::RouterModelProvider::new(
1631 primary_name,
1632 model_providers,
1633 routes,
1634 default_model.to_string(),
1635 )))
1636}
1637
1638pub struct ModelProviderInfo {
1640 pub name: &'static str,
1642 pub display_name: &'static str,
1644 pub local: bool,
1646 pub category: ModelProviderCategory,
1648}
1649
1650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1655pub enum ModelProviderCategory {
1656 Primary,
1658 OpenAiCompatible,
1660 FastInference,
1662 ModelHosting,
1664 ChineseAi,
1666 CloudEndpoint,
1668}
1669
1670impl ModelProviderCategory {
1671 #[must_use]
1675 pub fn as_str(self) -> &'static str {
1676 match self {
1677 Self::Primary => "Primary",
1678 Self::OpenAiCompatible => "OpenAiCompatible",
1679 Self::FastInference => "FastInference",
1680 Self::ModelHosting => "ModelHosting",
1681 Self::ChineseAi => "ChineseAi",
1682 Self::CloudEndpoint => "CloudEndpoint",
1683 }
1684 }
1685
1686 #[must_use]
1689 pub fn all() -> &'static [ModelProviderCategory] {
1690 &[
1691 Self::Primary,
1692 Self::OpenAiCompatible,
1693 Self::FastInference,
1694 Self::ModelHosting,
1695 Self::ChineseAi,
1696 Self::CloudEndpoint,
1697 ]
1698 }
1699}
1700
1701#[must_use]
1705pub fn default_model_provider_url(name: &str) -> Option<&'static str> {
1706 use factory::CompatFamilySpec;
1707 use zeroclaw_config::schema::{
1708 Ai21ModelProviderConfig, AihubmixModelProviderConfig, AnyscaleModelProviderConfig,
1709 ArceeModelProviderConfig, AstraiModelProviderConfig, BaichuanModelProviderConfig,
1710 BasetenModelProviderConfig, CerebrasModelProviderConfig, CloudflareModelProviderConfig,
1711 CohereModelProviderConfig, DeepinfraModelProviderConfig, DeepseekModelProviderConfig,
1712 DoubaoModelProviderConfig, FeatherlessModelProviderConfig, FireworksModelProviderConfig,
1713 FriendliModelProviderConfig, GithubModelsModelProviderConfig,
1714 HuggingfaceModelProviderConfig, HyperbolicModelProviderConfig,
1715 InceptionModelProviderConfig, LambdaAiModelProviderConfig, LeptonModelProviderConfig,
1716 LitellmModelProviderConfig, MistralModelProviderConfig, MorphModelProviderConfig,
1717 NebiusModelProviderConfig, NovitaModelProviderConfig, NscaleModelProviderConfig,
1718 OpencodeModelProviderConfig, PerplexityModelProviderConfig, RekaModelProviderConfig,
1719 SambanovaModelProviderConfig, SglangModelProviderConfig, SiliconflowModelProviderConfig,
1720 SyntheticModelProviderConfig, TogetherModelProviderConfig, UpstageModelProviderConfig,
1721 VercelModelProviderConfig, VllmModelProviderConfig, YiModelProviderConfig,
1722 };
1723
1724 match name {
1725 "anthropic" => Some(anthropic::BASE_URL),
1726 "openai" => Some(openai::BASE_URL),
1727 "openrouter" => Some(openrouter::BASE_URL),
1728 "ollama" => Some(ollama::BASE_URL),
1729 "telnyx" => Some(telnyx::BASE_URL),
1730 "gemini" => Some(gemini::BASE_URL),
1731 "vercel" => Some(<VercelModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1732 "cloudflare" => Some(<CloudflareModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1733 "synthetic" => Some(<SyntheticModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1734 "opencode" => Some(<OpencodeModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1735 "doubao" => Some(<DoubaoModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1736 "mistral" => Some(<MistralModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1737 "deepseek" => Some(<DeepseekModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1738 "together" => Some(<TogetherModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1739 "fireworks" => Some(<FireworksModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1740 "novita" => Some(<NovitaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1741 "perplexity" => Some(<PerplexityModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1742 "cohere" => Some(<CohereModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1743 "sglang" => Some(<SglangModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1744 "vllm" => Some(<VllmModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1745 "astrai" => Some(<AstraiModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1746 "siliconflow" => Some(<SiliconflowModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1747 "aihubmix" => Some(<AihubmixModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1748 "litellm" => Some(<LitellmModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1749 "cerebras" => Some(<CerebrasModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1750 "sambanova" => Some(<SambanovaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1751 "hyperbolic" => Some(<HyperbolicModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1752 "deepinfra" => Some(<DeepinfraModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1753 "huggingface" => Some(<HuggingfaceModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1754 "ai21" => Some(<Ai21ModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1755 "reka" => Some(<RekaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1756 "baseten" => Some(<BasetenModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1757 "nscale" => Some(<NscaleModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1758 "anyscale" => Some(<AnyscaleModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1759 "nebius" => Some(<NebiusModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1760 "friendli" => Some(<FriendliModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1761 "lepton" => Some(<LeptonModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1762 "morph" => Some(<MorphModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1763 "github_models" => Some(<GithubModelsModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1764 "upstage" => Some(<UpstageModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1765 "featherless" => Some(<FeatherlessModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1766 "arcee" => Some(<ArceeModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1767 "lambda_ai" => Some(<LambdaAiModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1768 "inception" => Some(<InceptionModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1769 "baichuan" => Some(<BaichuanModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1770 "yi" => Some(<YiModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1771 _ => None,
1772 }
1773}
1774
1775fn push_family(
1779 out: &mut Vec<ModelProviderInfo>,
1780 category: ModelProviderCategory,
1781 families: &[(&'static str, &'static str, bool)],
1782) {
1783 out.extend(
1784 families
1785 .iter()
1786 .map(|&(name, display_name, local)| ModelProviderInfo {
1787 name,
1788 display_name,
1789 local,
1790 category,
1791 }),
1792 );
1793}
1794
1795pub fn list_model_providers() -> Vec<ModelProviderInfo> {
1807 let mut out: Vec<ModelProviderInfo> = Vec::new();
1808 push_family(
1809 &mut out,
1810 ModelProviderCategory::Primary,
1811 &[
1812 ("openrouter", "OpenRouter", false),
1813 ("anthropic", "Anthropic", false),
1814 ("openai", "OpenAI", false),
1815 ("telnyx", "Telnyx", false),
1816 ("azure", "Azure OpenAI", false),
1817 ("ollama", "Ollama", true),
1818 ("gemini", "Google Gemini", false),
1819 ],
1820 );
1821 push_family(
1822 &mut out,
1823 ModelProviderCategory::OpenAiCompatible,
1824 &[
1825 ("venice", "Venice", false),
1826 ("vercel", "Vercel AI Gateway", false),
1827 ("cloudflare", "Cloudflare AI", false),
1828 ("moonshot", "Moonshot", false),
1829 ("synthetic", "Synthetic", false),
1830 ("opencode", "OpenCode", false),
1831 ("zai", "Z.AI", false),
1832 ("glm", "GLM (Zhipu)", false),
1833 ("minimax", "MiniMax", false),
1834 ("bedrock", "Amazon Bedrock", false),
1835 ("qianfan", "Qianfan (Baidu)", false),
1836 ("doubao", "Doubao (Volcengine)", false),
1837 ("qwen", "Qwen (DashScope / Qwen Code OAuth)", false),
1838 ("groq", "Groq", false),
1839 ("mistral", "Mistral", false),
1840 ("xai", "xAI (Grok)", false),
1841 ("deepseek", "DeepSeek", false),
1842 ("together", "Together AI", false),
1843 ("fireworks", "Fireworks AI", false),
1844 ("novita", "Novita AI", false),
1845 ("perplexity", "Perplexity", false),
1846 ("cohere", "Cohere", false),
1847 ("copilot", "GitHub Copilot", false),
1848 ("gemini_cli", "Gemini CLI", true),
1849 ("kilocli", "KiloCLI", true),
1850 ("kilo", "Kilo", false),
1851 ("lmstudio", "LM Studio", true),
1852 ("llamacpp", "llama.cpp server", true),
1853 ("sglang", "SGLang", true),
1854 ("vllm", "vLLM", true),
1855 ("osaurus", "Osaurus", true),
1856 ("nvidia", "NVIDIA NIM", false),
1857 ("siliconflow", "SiliconFlow", false),
1858 ("aihubmix", "AiHubMix", false),
1859 ("litellm", "LiteLLM", false),
1860 ("atomic_chat", "Atomic Chat", true),
1861 ("astrai", "Astrai", false),
1862 ("deepmyst", "DeepMyst", false),
1863 ("morph", "Morph (Fast Apply)", false),
1864 ("github_models", "GitHub Models", false),
1865 ("upstage", "Upstage Solar", false),
1866 ("featherless", "Featherless AI", false),
1867 ("arcee", "Arcee AI", false),
1868 ("lambda_ai", "Lambda AI", false),
1869 ("inception", "Inception Labs (Mercury)", false),
1870 ("custom", "Custom (OpenAI-compatible)", false),
1871 ],
1872 );
1873 push_family(
1874 &mut out,
1875 ModelProviderCategory::FastInference,
1876 &[
1877 ("cerebras", "Cerebras", false),
1878 ("sambanova", "SambaNova", false),
1879 ("hyperbolic", "Hyperbolic", false),
1880 ],
1881 );
1882 push_family(
1883 &mut out,
1884 ModelProviderCategory::ModelHosting,
1885 &[
1886 ("deepinfra", "DeepInfra", false),
1887 ("huggingface", "Hugging Face", false),
1888 ("ai21", "AI21 Labs", false),
1889 ("reka", "Reka", false),
1890 ("baseten", "Baseten", false),
1891 ("nscale", "Nscale", false),
1892 ("anyscale", "Anyscale", false),
1893 ("nebius", "Nebius AI Studio", false),
1894 ("friendli", "Friendli AI", false),
1895 ("lepton", "Lepton AI", false),
1896 ],
1897 );
1898 push_family(
1899 &mut out,
1900 ModelProviderCategory::ChineseAi,
1901 &[
1902 ("stepfun", "Stepfun", false),
1903 ("baichuan", "Baichuan", false),
1904 ("yi", "01.AI (Yi)", false),
1905 ("hunyuan", "Tencent Hunyuan", false),
1906 ],
1907 );
1908 push_family(
1909 &mut out,
1910 ModelProviderCategory::CloudEndpoint,
1911 &[
1912 ("ovh", "OVHcloud AI Endpoints", false),
1913 ("avian", "Avian", false),
1914 ],
1915 );
1916 debug_assert_eq!(
1917 out.iter()
1918 .map(|p| p.name)
1919 .collect::<std::collections::BTreeSet<_>>(),
1920 canonical_model_provider_slots()
1921 .into_iter()
1922 .collect::<std::collections::BTreeSet<_>>(),
1923 "list_model_providers() drifted from for_each_model_provider_slot!: \
1924 every canonical slot needs exactly one display entry and vice versa"
1925 );
1926 out
1927}
1928
1929#[must_use]
1935pub fn canonical_model_provider_slots() -> Vec<&'static str> {
1936 macro_rules! collect_slot_names {
1937 ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
1938 vec![$($type_str),+]
1939 };
1940 }
1941 zeroclaw_config::for_each_model_provider_slot!(collect_slot_names)
1942}
1943
1944#[cfg(test)]
1946pub mod test_util {
1947 use std::sync::{Mutex, MutexGuard, OnceLock};
1948
1949 pub fn env_lock() -> MutexGuard<'static, ()> {
1951 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1952 LOCK.get_or_init(|| Mutex::new(()))
1953 .lock()
1954 .expect("env lock poisoned")
1955 }
1956
1957 pub struct EnvGuard {
1960 key: String,
1961 original: Option<String>,
1962 }
1963
1964 impl EnvGuard {
1965 pub fn set(key: &str, value: Option<&str>) -> Self {
1966 let original = std::env::var(key).ok();
1967 match value {
1968 Some(v) => unsafe { std::env::set_var(key, v) },
1970 None => unsafe { std::env::remove_var(key) },
1972 }
1973 Self {
1974 key: key.to_string(),
1975 original,
1976 }
1977 }
1978 }
1979
1980 impl Drop for EnvGuard {
1981 fn drop(&mut self) {
1982 if let Some(original) = self.original.as_deref() {
1983 unsafe { std::env::set_var(&self.key, original) };
1985 } else {
1986 unsafe { std::env::remove_var(&self.key) };
1988 }
1989 }
1990 }
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995 use super::test_util::{EnvGuard, env_lock};
1996 use super::*;
1997
1998 #[test]
2003 fn provider_http_client_trusts_both_webpki_and_native_roots() {
2004 let _client = reqwest::Client::builder()
2005 .tls_built_in_webpki_certs(true)
2006 .tls_built_in_native_certs(true)
2007 .build()
2008 .expect("client builder should succeed with both root sets enabled");
2009 }
2010
2011 #[test]
2012 fn resolve_provider_credential_returns_trimmed_override() {
2013 let resolved = resolve_model_provider_credential("openrouter", Some(" explicit-key "));
2014 assert_eq!(resolved, Some("explicit-key".to_string()));
2015 }
2016
2017 #[test]
2018 fn resolve_provider_credential_filters_empty_override() {
2019 assert!(resolve_model_provider_credential("openrouter", Some(" ")).is_none());
2020 assert!(resolve_model_provider_credential("openrouter", None).is_none());
2021 }
2022
2023 #[test]
2030 fn resolve_qwen_oauth_context_prefers_explicit_override() {
2031 let _env_lock = env_lock();
2032 let context = resolve_qwen_oauth_context(Some(" explicit-qwen-token "));
2033 assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
2034 assert!(context.base_url.is_none());
2035 }
2036
2037 #[test]
2038 fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
2039 let _env_lock = env_lock();
2040 let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-file", std::process::id());
2041 let creds_dir = PathBuf::from(&fake_home).join(".qwen");
2042 std::fs::create_dir_all(&creds_dir).unwrap();
2043 let creds_path = creds_dir.join("oauth_creds.json");
2044 std::fs::write(
2045 &creds_path,
2046 r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
2047 )
2048 .unwrap();
2049
2050 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2051
2052 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2053
2054 assert_eq!(context.credential.as_deref(), Some("cached-token"));
2055 assert_eq!(
2056 context.base_url.as_deref(),
2057 Some("https://resource.example.com/v1")
2058 );
2059 }
2060
2061 #[test]
2062 fn resolve_qwen_oauth_context_returns_none_without_cache() {
2063 let _env_lock = env_lock();
2064 let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-empty", std::process::id());
2065 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2066
2067 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2068 assert!(context.credential.is_none());
2069 }
2070
2071 #[test]
2072 fn regional_alias_predicates_cover_expected_variants() {
2073 assert!(is_moonshot_alias("moonshot"));
2074 assert!(is_moonshot_alias("kimi-global"));
2075 assert!(is_glm_alias("glm"));
2076 assert!(is_glm_alias("bigmodel"));
2077 assert!(is_minimax_alias("minimax-io"));
2078 assert!(is_minimax_alias("minimaxi"));
2079 assert!(is_minimax_alias("minimax-oauth"));
2080 assert!(is_minimax_alias("minimax-portal-cn"));
2081 assert!(is_qwen_alias("dashscope"));
2082 assert!(is_qwen_alias("qwen-us"));
2083 assert!(is_qwen_alias("qwen-code"));
2084 assert!(is_qwen_oauth_alias("qwen-code"));
2085 assert!(is_qwen_oauth_alias("qwen_oauth"));
2086 assert!(is_zai_alias("z.ai"));
2087 assert!(is_zai_alias("zai-cn"));
2088 assert!(is_qianfan_alias("qianfan"));
2089 assert!(is_qianfan_alias("baidu"));
2090 assert!(is_doubao_alias("doubao"));
2091 assert!(is_doubao_alias("volcengine"));
2092 assert!(is_doubao_alias("ark"));
2093 assert!(is_doubao_alias("doubao-cn"));
2094
2095 assert!(!is_moonshot_alias("openrouter"));
2096 assert!(!is_glm_alias("openai"));
2097 assert!(!is_qwen_alias("gemini"));
2098 assert!(!is_zai_alias("anthropic"));
2099 assert!(!is_qianfan_alias("cohere"));
2100 assert!(!is_doubao_alias("deepseek"));
2101 }
2102
2103 #[test]
2114 fn factory_openrouter() {
2115 assert!(create_model_provider("openrouter", Some("provider-test-credential")).is_ok());
2116 assert!(create_model_provider("openrouter", None).is_ok());
2117 }
2118
2119 #[test]
2120 fn factory_anthropic() {
2121 assert!(create_model_provider("anthropic", Some("provider-test-credential")).is_ok());
2122 }
2123
2124 #[test]
2125 fn factory_openai() {
2126 assert!(create_model_provider("openai", Some("provider-test-credential")).is_ok());
2127 }
2128
2129 #[test]
2130 fn factory_openai_codex() {
2131 let options = ModelProviderRuntimeOptions::default();
2138 assert!(create_model_provider_with_options("openai-codex", None, &options).is_ok());
2139 }
2140
2141 #[test]
2142 fn factory_ollama() {
2143 assert!(create_model_provider("ollama", None).is_ok());
2144 assert!(create_model_provider("ollama", Some("dummy")).is_ok());
2146 assert!(create_model_provider("ollama", Some("any-value-here")).is_ok());
2147 }
2148
2149 #[test]
2150 fn factory_gemini() {
2151 assert!(create_model_provider("gemini", Some("test-key")).is_ok());
2152 assert!(create_model_provider("gemini", None).is_ok());
2154 }
2155
2156 #[test]
2157 fn factory_telnyx() {
2158 assert!(create_model_provider("telnyx", Some("test-key")).is_ok());
2159 assert!(create_model_provider("telnyx", None).is_ok());
2160 }
2161
2162 #[test]
2165 fn factory_venice() {
2166 let model_provider = create_model_provider("venice", Some("vn-key")).unwrap();
2167 assert!(
2168 !model_provider.capabilities().native_tool_calling,
2169 "Venice should use prompt-guided tools, not native tool calling"
2170 );
2171 }
2172
2173 #[test]
2174 fn factory_vercel() {
2175 assert!(create_model_provider("vercel", Some("key")).is_ok());
2176 }
2177
2178 #[test]
2179 fn vercel_gateway_base_url_matches_public_gateway_endpoint() {
2180 assert_eq!(
2181 VERCEL_AI_GATEWAY_BASE_URL,
2182 "https://ai-gateway.vercel.sh/v1"
2183 );
2184 }
2185
2186 #[test]
2187 fn factory_cloudflare() {
2188 assert!(create_model_provider("cloudflare", Some("key")).is_ok());
2189 }
2190
2191 #[test]
2192 fn factory_moonshot() {
2193 assert!(create_model_provider("moonshot", Some("key")).is_ok());
2194 }
2195
2196 #[test]
2197 fn factory_kimi_code_supports_vision() {
2198 for alias in ["kimi-code", "kimi_coding", "kimi_for_coding"] {
2199 let provider = create_model_provider(alias, Some("key"))
2200 .expect("legacy kimi-code alias should build");
2201 assert!(
2202 provider.supports_vision(),
2203 "alias `{alias}` should report vision capability"
2204 );
2205 assert_eq!(
2206 moonshot_code_base_url(),
2207 "https://api.moonshot.cn/coder/v1",
2208 "alias `{alias}` should resolve to the Moonshot code endpoint"
2209 );
2210 }
2211 }
2212
2213 #[test]
2214 fn factory_kimi_code_preserves_semantics_with_url_overrides() {
2215 let custom_url = "https://proxy.example.test/v1";
2216
2217 let provider = create_model_provider_with_url("kimi-code", Some("key"), Some(custom_url))
2218 .expect("legacy kimi-code alias with custom URL should build");
2219 assert!(provider.supports_vision());
2220
2221 let provider = create_model_provider_with_options(
2222 "kimi-code",
2223 Some("key"),
2224 &ModelProviderRuntimeOptions {
2225 provider_api_url: Some(custom_url.to_string()),
2226 ..ModelProviderRuntimeOptions::default()
2227 },
2228 )
2229 .expect("legacy kimi-code alias with options URL should build");
2230 assert!(provider.supports_vision());
2231 }
2232
2233 #[test]
2234 fn moonshot_code_endpoint_supports_vision() {
2235 use zeroclaw_config::schema::{Config, MoonshotEndpoint, MoonshotModelProviderConfig};
2236
2237 let mut config = Config::default();
2238 config.providers.models.moonshot.insert(
2239 "code".to_string(),
2240 MoonshotModelProviderConfig {
2241 endpoint: MoonshotEndpoint::Code,
2242 ..MoonshotModelProviderConfig::default()
2243 },
2244 );
2245 let options = provider_runtime_options_for_alias(&config, "moonshot", "code");
2246 assert_eq!(
2247 options.provider_api_url.as_deref(),
2248 Some(moonshot_code_base_url())
2249 );
2250
2251 let provider =
2252 create_model_provider_for_alias(&config, "moonshot", "code", Some("key"), &options)
2253 .expect("moonshot code endpoint should build");
2254 assert!(provider.supports_vision());
2255 }
2256
2257 #[test]
2258 fn factory_synthetic() {
2259 assert!(create_model_provider("synthetic", Some("key")).is_ok());
2260 }
2261
2262 #[test]
2263 fn factory_opencode() {
2264 assert!(create_model_provider("opencode", Some("key")).is_ok());
2265 }
2266
2267 #[test]
2268 fn factory_opencode_go() {}
2269
2270 #[test]
2271 fn factory_zai() {
2272 assert!(create_model_provider("zai", Some("key")).is_ok());
2273 }
2274
2275 #[test]
2276 fn factory_glm() {
2277 assert!(create_model_provider("glm", Some("key")).is_ok());
2278 }
2279
2280 #[test]
2281 fn factory_minimax() {
2282 assert!(create_model_provider("minimax", Some("key")).is_ok());
2283 }
2284
2285 #[test]
2286 fn factory_minimax_supports_native_tool_calling() {
2287 let minimax =
2288 create_model_provider("minimax", Some("key")).expect("model_provider should resolve");
2289 assert!(minimax.supports_native_tools());
2290 }
2291
2292 #[test]
2293 fn factory_bedrock() {
2294 assert!(create_model_provider("bedrock", None).is_ok());
2296 assert!(create_model_provider("bedrock", Some("ignored")).is_ok());
2298 }
2299
2300 #[test]
2301 fn factory_qianfan() {
2302 assert!(create_model_provider("qianfan", Some("key")).is_ok());
2303 }
2304
2305 #[test]
2306 fn factory_doubao() {
2307 assert!(create_model_provider("doubao", Some("key")).is_ok());
2308 }
2309
2310 #[test]
2311 fn factory_qwen() {
2312 assert!(create_model_provider("qwen", Some("key")).is_ok());
2313 }
2314
2315 #[test]
2316 fn qwen_provider_supports_vision() {
2317 let model_provider =
2318 create_model_provider("qwen", Some("key")).expect("qwen model_provider should build");
2319 assert!(model_provider.supports_vision());
2320 }
2321
2322 #[test]
2323 fn glm_provider_supports_vision() {
2324 for alias in ["glm", "zhipu", "glm-cn", "zhipu-cn"] {
2328 let provider =
2329 create_model_provider(alias, Some("id.secret")).expect("glm provider should build");
2330 assert!(
2331 provider.supports_vision(),
2332 "alias `{alias}` should report vision capability"
2333 );
2334 }
2335 }
2336
2337 #[test]
2338 fn factory_lmstudio() {
2339 assert!(create_model_provider("lmstudio", Some("key")).is_ok());
2340 assert!(create_model_provider("lmstudio", None).is_ok());
2341 }
2342
2343 #[test]
2344 fn factory_llamacpp() {
2345 assert!(create_model_provider("llamacpp", Some("key")).is_ok());
2346 assert!(create_model_provider("llamacpp", None).is_ok());
2347 }
2348
2349 #[test]
2350 fn factory_sglang() {
2351 assert!(create_model_provider("sglang", None).is_ok());
2352 assert!(create_model_provider("sglang", Some("key")).is_ok());
2353 }
2354
2355 #[test]
2356 fn factory_vllm() {
2357 assert!(create_model_provider("vllm", None).is_ok());
2358 assert!(create_model_provider("vllm", Some("key")).is_ok());
2359 }
2360
2361 #[test]
2362 fn factory_osaurus() {
2363 assert!(create_model_provider("osaurus", None).is_ok());
2365 assert!(create_model_provider("osaurus", Some("custom-key")).is_ok());
2367 }
2368
2369 #[test]
2370 fn factory_osaurus_uses_default_key_when_none() {
2371 let p = create_model_provider_with_url("osaurus", None, None);
2374 assert!(p.is_ok());
2375 }
2376
2377 #[test]
2378 fn factory_osaurus_custom_url() {
2379 let p = create_model_provider_with_url(
2381 "osaurus",
2382 Some("key"),
2383 Some("http://192.168.1.100:1337/v1"),
2384 );
2385 assert!(p.is_ok());
2386 }
2387
2388 #[test]
2389 fn resolve_provider_credential_osaurus_env_deleted() {}
2390
2391 #[test]
2392 fn resolve_provider_credential_doubao_volcengine_env_deleted() {}
2393
2394 #[test]
2395 fn resolve_provider_credential_aihubmix_env_deleted() {}
2396
2397 #[test]
2398 fn resolve_provider_credential_siliconflow_env_deleted() {}
2399
2400 #[test]
2401 fn factory_aihubmix() {
2402 assert!(create_model_provider("aihubmix", Some("key")).is_ok());
2403 }
2404
2405 #[test]
2406 fn factory_siliconflow() {
2407 assert!(create_model_provider("siliconflow", Some("key")).is_ok());
2408 }
2409
2410 #[test]
2411 fn factory_codex_dispatches_via_requires_openai_auth_flag() {
2412 let options = ModelProviderRuntimeOptions::default();
2418 assert!(create_model_provider_with_options("openai-codex", None, &options).is_ok());
2419 }
2420
2421 #[test]
2422 fn factory_atomic_chat() {
2423 assert!(create_model_provider("atomic_chat", Some("key")).is_ok());
2424 }
2425
2426 #[test]
2427 fn factory_atomic_chat_allows_missing_key() {
2428 assert!(create_model_provider("atomic_chat", None).is_ok());
2431 }
2432
2433 #[test]
2434 fn atomic_chat_is_listed_as_local_provider() {
2435 let providers = list_model_providers();
2436 let provider = providers
2437 .iter()
2438 .find(|p| p.name == "atomic_chat")
2439 .expect("atomic_chat must be listed");
2440 assert!(provider.local, "atomic_chat must be a local provider");
2441 }
2442
2443 #[test]
2446 fn factory_groq() {
2447 assert!(create_model_provider("groq", Some("key")).is_ok());
2448 }
2449
2450 #[test]
2451 fn factory_groq_disables_native_tools_by_default() {
2452 let model_provider = create_model_provider_with_options(
2455 "groq",
2456 Some("key"),
2457 &ModelProviderRuntimeOptions::default(),
2458 )
2459 .expect("groq factory must succeed");
2460 assert!(
2461 !model_provider.supports_native_tools(),
2462 "Groq must default to text-fallback for llama-family compatibility"
2463 );
2464 }
2465
2466 #[test]
2467 fn factory_groq_honors_native_tools_override_true() {
2468 let options = ModelProviderRuntimeOptions {
2472 native_tools: Some(true),
2473 ..Default::default()
2474 };
2475 let model_provider = create_model_provider_with_options("groq", Some("key"), &options)
2476 .expect("groq factory must succeed");
2477 assert!(
2478 model_provider.supports_native_tools(),
2479 "Groq with `native_tools = true` must enable native tool calling"
2480 );
2481 }
2482
2483 #[test]
2484 fn factory_groq_native_tools_override_false_keeps_disable() {
2485 let options = ModelProviderRuntimeOptions {
2489 native_tools: Some(false),
2490 ..Default::default()
2491 };
2492 let model_provider = create_model_provider_with_options("groq", Some("key"), &options)
2493 .expect("groq factory must succeed");
2494 assert!(
2495 !model_provider.supports_native_tools(),
2496 "Groq with explicit `native_tools = false` must remain text-fallback"
2497 );
2498 }
2499
2500 #[test]
2501 fn provider_runtime_options_from_config_propagates_native_tools() {
2502 use zeroclaw_config::schema::{GroqModelProviderConfig, ModelProviderConfig};
2508 let mut config = zeroclaw_config::schema::Config::default();
2509 config.providers.models.groq.insert(
2510 "default".to_string(),
2511 GroqModelProviderConfig {
2512 base: ModelProviderConfig {
2513 uri: Some("https://api.groq.com/openai/v1".to_string()),
2514 native_tools: Some(true),
2515 ..Default::default()
2516 },
2517 },
2518 );
2519
2520 let entry = config.providers.models.find("groq", "default");
2521 let options = model_provider_runtime_options_from_model_provider_entry(&config, entry);
2522 assert_eq!(
2523 options.native_tools,
2524 Some(true),
2525 "native_tools must propagate from the active model_provider entry to runtime options"
2526 );
2527 }
2528
2529 #[test]
2530 fn provider_runtime_options_from_config_propagates_provider_kind() {
2531 use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
2532 let mut config = zeroclaw_config::schema::Config::default();
2533 config.providers.models.openai.insert(
2534 "primary".to_string(),
2535 OpenAIModelProviderConfig {
2536 base: ModelProviderConfig {
2537 kind: Some("openai-compatible".to_string()),
2538 uri: Some("http://primary.example/v1".to_string()),
2539 ..Default::default()
2540 },
2541 },
2542 );
2543
2544 let options = provider_runtime_options_for_alias(&config, "openai", "primary");
2545 assert_eq!(options.provider_kind.as_deref(), Some("openai-compatible"));
2546 assert_eq!(
2547 options.provider_api_url.as_deref(),
2548 Some("http://primary.example/v1")
2549 );
2550 }
2551
2552 #[test]
2553 fn route_provider_options_clear_primary_only_state_for_bare_routes() {
2554 let inherited = ModelProviderRuntimeOptions {
2555 provider_kind: Some("openai-compatible".to_string()),
2556 provider_api_url: Some("http://primary.example/v1".to_string()),
2557 ..Default::default()
2558 };
2559 let config = zeroclaw_config::schema::Config::default();
2560
2561 let route_options = options_for_provider_ref(&config, "openrouter", &inherited);
2562
2563 assert_eq!(route_options.provider_kind, None);
2564 assert_eq!(route_options.provider_api_url, None);
2565 }
2566
2567 #[test]
2568 fn routed_bare_provider_does_not_inherit_primary_endpoint() {
2569 use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
2570 let mut config = zeroclaw_config::schema::Config::default();
2571 config.providers.models.openai.insert(
2572 "primary".to_string(),
2573 OpenAIModelProviderConfig {
2574 base: ModelProviderConfig {
2575 kind: Some("openai-compatible".to_string()),
2576 uri: Some("http://primary.example/v1".to_string()),
2577 ..Default::default()
2578 },
2579 },
2580 );
2581 let options = provider_runtime_options_for_alias(&config, "openai", "primary");
2582 assert_eq!(
2583 options.provider_api_url.as_deref(),
2584 Some("http://primary.example/v1")
2585 );
2586
2587 let route_options = options_for_provider_ref(&config, "openrouter", &options);
2588
2589 assert_eq!(route_options.provider_kind, None);
2590 assert_eq!(route_options.provider_api_url, None);
2591 }
2592
2593 #[test]
2594 fn routed_primary_alias_kind_does_not_leak_to_canonical_route_provider() {
2595 use zeroclaw_config::schema::{
2596 ModelProviderConfig, ModelRouteConfig, OpenAIModelProviderConfig,
2597 OpenRouterModelProviderConfig,
2598 };
2599
2600 let mut config = zeroclaw_config::schema::Config::default();
2601 config.providers.models.openai.insert(
2602 "primary".to_string(),
2603 OpenAIModelProviderConfig {
2604 base: ModelProviderConfig {
2605 kind: Some("openai-compatible".to_string()),
2606 uri: Some("http://primary.example/v1".to_string()),
2607 ..Default::default()
2608 },
2609 },
2610 );
2611 config.providers.models.openrouter.insert(
2612 "route".to_string(),
2613 OpenRouterModelProviderConfig {
2614 base: ModelProviderConfig::default(),
2615 },
2616 );
2617 let options = provider_runtime_options_for_alias(&config, "openai", "primary");
2618 assert_eq!(options.provider_kind.as_deref(), Some("openai-compatible"));
2619
2620 let provider = create_routed_model_provider_with_options(
2621 &config,
2622 "openai.primary",
2623 Some("sk-test"),
2624 None,
2625 &config.reliability,
2626 &[ModelRouteConfig {
2627 hint: "fast".to_string(),
2628 model_provider: "openrouter.route".to_string(),
2629 model: "openrouter/auto".to_string(),
2630 api_key: None,
2631 }],
2632 "gpt-test",
2633 &options,
2634 )
2635 .expect("primary alias kind should build without poisoning route provider kind");
2636
2637 assert!(
2638 provider.supports_vision(),
2639 "primary openai-compatible provider should remain the router default"
2640 );
2641 }
2642
2643 #[test]
2644 fn factory_mistral() {
2645 assert!(create_model_provider("mistral", Some("key")).is_ok());
2646 }
2647
2648 #[test]
2649 fn factory_xai() {
2650 assert!(create_model_provider("xai", Some("key")).is_ok());
2651 }
2652
2653 #[test]
2654 fn factory_deepseek() {
2655 assert!(create_model_provider("deepseek", Some("key")).is_ok());
2656 }
2657
2658 #[test]
2659 fn deepseek_provider_keeps_vision_disabled() {
2660 let model_provider = create_model_provider("deepseek", Some("key"))
2661 .expect("deepseek model_provider should build");
2662 assert!(!model_provider.supports_vision());
2663 }
2664
2665 #[test]
2666 fn factory_together() {
2667 assert!(create_model_provider("together", Some("key")).is_ok());
2668 }
2669
2670 #[test]
2671 fn factory_fireworks() {
2672 assert!(create_model_provider("fireworks", Some("key")).is_ok());
2673 }
2674
2675 #[test]
2676 fn factory_novita() {
2677 assert!(create_model_provider("novita", Some("key")).is_ok());
2678 }
2679
2680 #[test]
2681 fn factory_perplexity() {
2682 assert!(create_model_provider("perplexity", Some("key")).is_ok());
2683 }
2684
2685 #[test]
2686 fn factory_cohere() {
2687 assert!(create_model_provider("cohere", Some("key")).is_ok());
2688 }
2689
2690 #[test]
2691 fn factory_copilot() {
2692 assert!(create_model_provider("copilot", Some("key")).is_ok());
2693 }
2694
2695 #[test]
2696 fn factory_gemini_cli() {}
2697
2698 #[test]
2699 fn factory_kilocli() {
2700 assert!(create_model_provider("kilocli", None).is_ok());
2701 }
2702
2703 #[test]
2704 fn factory_kilo() {
2705 assert!(create_model_provider("kilo", Some("kilo-test-key")).is_ok());
2706 }
2707
2708 #[test]
2709 fn factory_nvidia() {
2710 assert!(create_model_provider("nvidia", Some("nvapi-test")).is_ok());
2711 }
2712
2713 #[test]
2716 fn factory_astrai() {
2717 assert!(create_model_provider("astrai", Some("sk-astrai-test")).is_ok());
2718 }
2719
2720 #[test]
2721 fn factory_avian() {
2722 assert!(create_model_provider("avian", Some("sk-avian-test")).is_ok());
2723 }
2724
2725 #[test]
2726 fn factory_deepmyst() {
2727 assert!(create_model_provider("deepmyst", Some("key")).is_ok());
2728 }
2729
2730 #[test]
2731 fn resolve_provider_credential_deepmyst_env_deleted() {}
2732
2733 #[test]
2736 fn factory_morph() {
2737 assert!(create_model_provider("morph", Some("sk-morph-test")).is_ok());
2738 }
2739
2740 #[test]
2741 fn factory_github_models() {
2742 assert!(create_model_provider("github_models", Some("ghp_test_token")).is_ok());
2743 assert!(create_model_provider("github-models", Some("ghp_test_token")).is_ok());
2745 }
2746
2747 #[test]
2748 fn factory_upstage() {
2749 assert!(create_model_provider("upstage", Some("up-test-key")).is_ok());
2750 }
2751
2752 #[test]
2753 fn factory_featherless() {
2754 assert!(create_model_provider("featherless", Some("featherless-test")).is_ok());
2755 }
2756
2757 #[test]
2758 fn factory_arcee() {
2759 assert!(create_model_provider("arcee", Some("arcee-test")).is_ok());
2760 }
2761
2762 #[test]
2763 fn factory_lambda_ai() {
2764 assert!(create_model_provider("lambda_ai", Some("lambda-test")).is_ok());
2765 assert!(create_model_provider("lambda-ai", Some("lambda-test")).is_ok());
2767 }
2768
2769 #[test]
2770 fn factory_inception() {
2771 assert!(create_model_provider("inception", Some("inception-test")).is_ok());
2772 }
2773
2774 #[test]
2775 fn default_url_matches_compat_spec_for_new_providers() {
2776 assert_eq!(
2777 default_model_provider_url("morph"),
2778 Some("https://api.morphllm.com/v1")
2779 );
2780 assert_eq!(
2781 default_model_provider_url("github_models"),
2782 Some("https://models.github.ai/inference")
2783 );
2784 assert_eq!(
2785 default_model_provider_url("upstage"),
2786 Some("https://api.upstage.ai/v1")
2787 );
2788 assert_eq!(
2789 default_model_provider_url("featherless"),
2790 Some("https://api.featherless.ai/v1")
2791 );
2792 assert_eq!(
2794 default_model_provider_url("arcee"),
2795 Some("https://api.arcee.ai/api/v1")
2796 );
2797 assert_eq!(
2798 default_model_provider_url("lambda_ai"),
2799 Some("https://api.lambda.ai/v1")
2800 );
2801 assert_eq!(
2802 default_model_provider_url("inception"),
2803 Some("https://api.inceptionlabs.ai/v1")
2804 );
2805 }
2806
2807 #[test]
2824 fn factory_custom_with_resolved_uri() {
2825 let options = ModelProviderRuntimeOptions {
2826 provider_api_url: Some("https://my-llm.example.com".to_string()),
2827 ..ModelProviderRuntimeOptions::default()
2828 };
2829 assert!(create_model_provider_with_options("custom", Some("key"), &options).is_ok());
2830 }
2831
2832 #[test]
2833 fn factory_custom_without_uri_errors() {
2834 match create_model_provider("custom", Some("key")) {
2835 Err(e) => assert!(
2836 e.to_string().contains("requires `uri`"),
2837 "Expected `uri` error, got: {e}"
2838 ),
2839 Ok(_) => {
2840 panic!("Expected error when custom model model_provider has no URI configured")
2841 }
2842 }
2843 }
2844
2845 #[test]
2848 fn factory_unknown_provider_errors() {
2849 let p = create_model_provider("nonexistent", None);
2850 assert!(p.is_err());
2851 let msg = p.err().unwrap().to_string();
2852 assert!(msg.contains("Unknown model_provider family"));
2853 assert!(msg.contains("nonexistent"));
2854 }
2855
2856 #[test]
2857 fn factory_empty_name_errors() {
2858 assert!(create_model_provider("", None).is_err());
2859 }
2860
2861 #[test]
2862 fn ollama_with_custom_url() {
2863 let model_provider =
2864 create_model_provider_with_url("ollama", None, Some("http://10.100.2.32:11434"));
2865 assert!(model_provider.is_ok());
2866 }
2867
2868 #[test]
2869 fn ollama_cloud_with_custom_url() {
2870 let model_provider = create_model_provider_with_url(
2871 "ollama",
2872 Some("ollama-key"),
2873 Some("https://ollama.com"),
2874 );
2875 assert!(model_provider.is_ok());
2876 }
2877
2878 #[tokio::test]
2879 async fn ollama_private_remote_cloud_request_omits_auth_and_preserves_model() {
2880 use axum::{
2881 Json, Router,
2882 extract::State,
2883 http::{HeaderMap, StatusCode},
2884 routing::post,
2885 };
2886 use serde_json::{Value, json};
2887 use std::sync::{Arc, Mutex};
2888
2889 type Capture = Arc<Mutex<Option<(Option<String>, String)>>>;
2890
2891 async fn capture_chat_request(
2892 State(capture): State<Capture>,
2893 headers: HeaderMap,
2894 Json(body): Json<Value>,
2895 ) -> (StatusCode, Json<Value>) {
2896 let auth = headers
2897 .get("authorization")
2898 .and_then(|value| value.to_str().ok())
2899 .map(str::to_string);
2900 let model = body
2901 .get("model")
2902 .and_then(Value::as_str)
2903 .unwrap_or_default()
2904 .to_string();
2905 *capture.lock().expect("capture lock poisoned") = Some((auth, model));
2906 (
2907 StatusCode::OK,
2908 Json(json!({
2909 "choices": [{"message": {"content": "ok"}}]
2910 })),
2911 )
2912 }
2913
2914 let capture: Capture = Arc::new(Mutex::new(None));
2915 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2916 .await
2917 .expect("bind test server");
2918 let addr = listener.local_addr().expect("test server addr");
2919 let app = Router::new()
2920 .route("/v1/chat/completions", post(capture_chat_request))
2921 .with_state(capture.clone());
2922 let server = ::zeroclaw_spawn::spawn!(async move {
2923 axum::serve(listener, app).await.expect("serve test server");
2924 });
2925
2926 let base_url = format!("http://{addr}");
2927 let model_provider = create_model_provider_with_url("ollama", None, Some(&base_url))
2928 .expect("ollama provider should build");
2929 let response = model_provider
2930 .chat_with_system(None, "hello", "qwen3:cloud", Some(0.7))
2931 .await
2932 .expect("chat request should succeed");
2933
2934 assert_eq!(response, "ok");
2935 let (auth, model) = capture
2936 .lock()
2937 .expect("capture lock poisoned")
2938 .take()
2939 .expect("server should capture request");
2940 assert_eq!(auth, None);
2941 assert_eq!(model, "qwen3:cloud");
2942 server.abort();
2943 }
2944
2945 #[tokio::test]
2946 async fn ollama_private_remote_lists_models_without_auth() {
2947 use axum::{Json, Router, extract::State, http::HeaderMap, routing::get};
2948 use serde_json::{Value, json};
2949 use std::sync::{Arc, Mutex};
2950
2951 type Capture = Arc<Mutex<Option<Option<String>>>>;
2952
2953 async fn capture_models_request(
2954 State(capture): State<Capture>,
2955 headers: HeaderMap,
2956 ) -> Json<Value> {
2957 let auth = headers
2958 .get("authorization")
2959 .and_then(|value| value.to_str().ok())
2960 .map(str::to_string);
2961 *capture.lock().expect("capture lock poisoned") = Some(auth);
2962 Json(json!({
2963 "data": [{"id": "qwen3:cloud"}]
2964 }))
2965 }
2966
2967 let capture: Capture = Arc::new(Mutex::new(None));
2968 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2969 .await
2970 .expect("bind test server");
2971 let addr = listener.local_addr().expect("test server addr");
2972 let app = Router::new()
2973 .route("/v1/models", get(capture_models_request))
2974 .with_state(capture.clone());
2975 let server = ::zeroclaw_spawn::spawn!(async move {
2976 axum::serve(listener, app).await.expect("serve test server");
2977 });
2978
2979 let base_url = format!("http://{addr}");
2980 let model_provider = create_model_provider_with_url("ollama", None, Some(&base_url))
2981 .expect("ollama provider should build");
2982 let models = model_provider
2983 .list_models()
2984 .await
2985 .expect("model list should succeed");
2986
2987 assert_eq!(models, vec!["qwen3:cloud".to_string()]);
2988 let auth = capture
2989 .lock()
2990 .expect("capture lock poisoned")
2991 .take()
2992 .expect("server should capture request");
2993 assert_eq!(auth, None);
2994 server.abort();
2995 }
2996
2997 #[test]
2998 fn factory_all_canonical_model_providers_create_successfully() {
2999 let canonical = [
3005 "openrouter",
3006 "anthropic",
3007 "openai",
3008 "ollama",
3009 "gemini",
3010 "venice",
3011 "vercel",
3012 "cloudflare",
3013 "moonshot",
3014 "synthetic",
3015 "opencode",
3016 "zai",
3017 "glm",
3018 "minimax",
3019 "bedrock",
3020 "qianfan",
3021 "doubao",
3022 "qwen",
3023 "lmstudio",
3024 "llamacpp",
3025 "sglang",
3026 "vllm",
3027 "osaurus",
3028 "telnyx",
3029 "groq",
3030 "mistral",
3031 "xai",
3032 "deepseek",
3033 "together",
3034 "fireworks",
3035 "novita",
3036 "perplexity",
3037 "cohere",
3038 "copilot",
3039 "gemini_cli",
3040 "kilocli",
3041 "nvidia",
3042 "astrai",
3043 "avian",
3044 "ovh",
3045 ];
3046 for name in canonical {
3047 assert!(
3048 create_model_provider(name, Some("test-key")).is_ok(),
3049 "Canonical model model_provider '{name}' should create successfully"
3050 );
3051 }
3052 }
3053
3054 #[test]
3055 fn listed_model_providers_have_unique_canonical_ids() {
3056 let model_providers = list_model_providers();
3057 let mut canonical_ids = std::collections::HashSet::new();
3058
3059 for model_provider in model_providers {
3060 assert!(
3061 canonical_ids.insert(model_provider.name),
3062 "Duplicate canonical model model_provider id: {}",
3063 model_provider.name
3064 );
3065 }
3066 }
3067
3068 #[test]
3074 fn listed_model_providers_match_canonical_slots() {
3075 let listed: std::collections::BTreeSet<&str> =
3076 list_model_providers().iter().map(|p| p.name).collect();
3077 let canonical: std::collections::BTreeSet<&str> =
3078 canonical_model_provider_slots().into_iter().collect();
3079 let missing: Vec<&&str> = canonical.difference(&listed).collect();
3080 let phantom: Vec<&&str> = listed.difference(&canonical).collect();
3081 assert!(
3082 missing.is_empty() && phantom.is_empty(),
3083 "list_model_providers() drift — missing display entries: {missing:?}; \
3084 phantom entries (no factory slot): {phantom:?}"
3085 );
3086 }
3087
3088 #[test]
3089 fn listed_model_providers_are_constructible() {
3090 for model_provider in list_model_providers() {
3091 if model_provider.name == "azure" {
3097 continue;
3098 }
3099 if model_provider.name == "custom" {
3102 continue;
3103 }
3104 assert!(
3105 create_model_provider(model_provider.name, Some("provider-test-credential"))
3106 .is_ok(),
3107 "Canonical model model_provider id should be constructible: {}",
3108 model_provider.name
3109 );
3110 }
3111 }
3112
3113 #[test]
3116 fn format_error_chain_includes_sources_and_sanitizes_output() {
3117 #[derive(Debug)]
3118 struct ChainError {
3119 message: &'static str,
3120 source: Option<Box<dyn std::error::Error + 'static>>,
3121 }
3122
3123 impl std::fmt::Display for ChainError {
3124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3125 write!(f, "{}", self.message)
3126 }
3127 }
3128
3129 impl std::error::Error for ChainError {
3130 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
3131 self.source.as_deref()
3132 }
3133 }
3134
3135 let error = ChainError {
3136 message: "outer context",
3137 source: Some(Box::new(ChainError {
3138 message: "middle context",
3139 source: Some(Box::new(ChainError {
3140 message: "inner source leaked sk-1234567890abcdef",
3141 source: None,
3142 })),
3143 })),
3144 };
3145
3146 let result = format_error_chain(&error);
3147
3148 assert!(result.contains("outer context"));
3149 assert!(result.contains("middle context"));
3150 assert!(result.contains("inner source leaked [REDACTED]"));
3151 assert!(!result.contains("sk-1234567890abcdef"));
3152 }
3153
3154 #[test]
3155 fn sanitize_scrubs_sk_prefix() {
3156 let input = "request failed: sk-1234567890abcdef";
3157 let out = sanitize_api_error(input);
3158 assert!(!out.contains("sk-1234567890abcdef"));
3159 assert!(out.contains("[REDACTED]"));
3160 }
3161
3162 #[test]
3163 fn sanitize_scrubs_multiple_prefixes() {
3164 let input = "keys sk-abcdef xoxb-12345 xoxp-67890";
3165 let out = sanitize_api_error(input);
3166 assert!(!out.contains("sk-abcdef"));
3167 assert!(!out.contains("xoxb-12345"));
3168 assert!(!out.contains("xoxp-67890"));
3169 }
3170
3171 #[test]
3172 fn sanitize_short_prefix_then_real_key() {
3173 let input = "error with sk- prefix and key sk-1234567890";
3174 let result = sanitize_api_error(input);
3175 assert!(!result.contains("sk-1234567890"));
3176 assert!(result.contains("[REDACTED]"));
3177 }
3178
3179 #[test]
3180 fn sanitize_sk_proj_comment_then_real_key() {
3181 let input = "note: sk- then sk-proj-abc123def456";
3182 let result = sanitize_api_error(input);
3183 assert!(!result.contains("sk-proj-abc123def456"));
3184 assert!(result.contains("[REDACTED]"));
3185 }
3186
3187 #[test]
3188 fn sanitize_keeps_bare_prefix() {
3189 let input = "only prefix sk- present";
3190 let result = sanitize_api_error(input);
3191 assert!(result.contains("sk-"));
3192 }
3193
3194 #[test]
3195 fn sanitize_handles_json_wrapped_key() {
3196 let input = r#"{"error":"invalid key sk-abc123xyz"}"#;
3197 let result = sanitize_api_error(input);
3198 assert!(!result.contains("sk-abc123xyz"));
3199 }
3200
3201 #[test]
3202 fn sanitize_handles_delimiter_boundaries() {
3203 let input = "bad token xoxb-abc123}; next";
3204 let result = sanitize_api_error(input);
3205 assert!(!result.contains("xoxb-abc123"));
3206 assert!(result.contains("};"));
3207 }
3208
3209 #[test]
3210 fn sanitize_truncates_long_error() {
3211 let long = "a".repeat(600);
3212 let result = sanitize_api_error(&long);
3213 assert!(result.len() <= 503);
3214 assert!(result.ends_with("..."));
3215 }
3216
3217 #[test]
3218 fn sanitize_truncates_after_scrub() {
3219 let input = format!("{} sk-abcdef123456 {}", "a".repeat(290), "b".repeat(290));
3220 let result = sanitize_api_error(&input);
3221 assert!(!result.contains("sk-abcdef123456"));
3222 assert!(result.len() <= 503);
3223 }
3224
3225 #[test]
3226 fn sanitize_preserves_unicode_boundaries() {
3227 let input = format!("{} sk-abcdef123", "hello🙂".repeat(80));
3228 let result = sanitize_api_error(&input);
3229 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
3230 assert!(!result.contains("sk-abcdef123"));
3231 }
3232
3233 #[test]
3234 fn sanitize_no_secret_no_change() {
3235 let input = "simple upstream timeout";
3236 let result = sanitize_api_error(input);
3237 assert_eq!(result, input);
3238 }
3239
3240 #[test]
3241 fn scrub_github_personal_access_token() {
3242 let input = "auth failed with token ghp_abc123def456";
3243 let result = scrub_secret_patterns(input);
3244 assert_eq!(result, "auth failed with token [REDACTED]");
3245 }
3246
3247 #[test]
3248 fn scrub_github_oauth_token() {
3249 let input = "Bearer gho_1234567890abcdef";
3250 let result = scrub_secret_patterns(input);
3251 assert_eq!(result, "Bearer [REDACTED]");
3252 }
3253
3254 #[test]
3255 fn scrub_github_user_token() {
3256 let input = "token ghu_sessiontoken123";
3257 let result = scrub_secret_patterns(input);
3258 assert_eq!(result, "token [REDACTED]");
3259 }
3260
3261 #[test]
3262 fn scrub_github_fine_grained_pat() {
3263 let input = "failed: github_pat_11AABBC_xyzzy789";
3264 let result = scrub_secret_patterns(input);
3265 assert_eq!(result, "failed: [REDACTED]");
3266 }
3267
3268 #[test]
3271 fn api_key_prefix_cross_provider_mismatch() {
3272 assert_eq!(
3274 check_api_key_prefix("openrouter", "sk-ant-api03-xyz"),
3275 Some("anthropic")
3276 );
3277 assert_eq!(
3279 check_api_key_prefix("anthropic", "sk-or-v1-xyz"),
3280 Some("openrouter")
3281 );
3282 assert_eq!(
3284 check_api_key_prefix("openai", "sk-ant-xyz"),
3285 Some("anthropic")
3286 );
3287 assert_eq!(check_api_key_prefix("openai", "gsk_xyz"), Some("groq"));
3289 }
3290
3291 #[test]
3292 fn api_key_prefix_correct_match() {
3293 assert_eq!(check_api_key_prefix("anthropic", "sk-ant-api03-xyz"), None);
3294 assert_eq!(check_api_key_prefix("openrouter", "sk-or-v1-xyz"), None);
3295 assert_eq!(check_api_key_prefix("openai", "sk-proj-xyz"), None);
3296 assert_eq!(check_api_key_prefix("groq", "gsk_xyz"), None);
3297 }
3298
3299 #[test]
3300 fn api_key_prefix_unknown_provider_skips() {
3301 assert_eq!(check_api_key_prefix("deepseek", "sk-ant-xyz"), None);
3303 assert_eq!(check_api_key_prefix("ollama", "anything"), None);
3304 }
3305
3306 #[test]
3307 fn api_key_prefix_unknown_key_format_skips() {
3308 assert_eq!(check_api_key_prefix("openai", "my-custom-key-123"), None);
3310 assert_eq!(check_api_key_prefix("anthropic", "some-random-key"), None);
3311 }
3312
3313 #[test]
3314 fn provider_runtime_options_default_has_empty_extra_headers() {
3315 let options = ModelProviderRuntimeOptions::default();
3316 assert!(options.extra_headers.is_empty());
3317 }
3318
3319 #[test]
3320 fn provider_runtime_options_extra_headers_passed_through() {
3321 let mut extra_headers = std::collections::HashMap::new();
3322 extra_headers.insert("X-Title".to_string(), "zeroclaw".to_string());
3323 let options = ModelProviderRuntimeOptions {
3324 extra_headers,
3325 ..ModelProviderRuntimeOptions::default()
3326 };
3327 assert_eq!(options.extra_headers.len(), 1);
3328 assert_eq!(options.extra_headers.get("X-Title").unwrap(), "zeroclaw");
3329 }
3330
3331 #[test]
3332 fn ollama_uses_resolved_url_from_runtime_options() {
3333 let model_provider =
3337 create_model_provider_with_url("ollama", None, Some("http://config-ollama:11434"));
3338 assert!(model_provider.is_ok());
3339 }
3340
3341 fn config_with_two_anthropic_aliases() -> zeroclaw_config::schema::Config {
3347 use zeroclaw_config::schema::{
3348 AliasedAgentConfig, AnthropicModelProviderConfig, Config, ModelProviderConfig,
3349 };
3350 let mut config = Config::default();
3351 let default_alias = AnthropicModelProviderConfig {
3352 base: ModelProviderConfig {
3353 model: Some("claude-default".into()),
3354 api_key: Some("default-key".into()),
3355 uri: Some("https://api.default.example/v1/messages".into()),
3356 ..ModelProviderConfig::default()
3357 },
3358 };
3359 let work_alias = AnthropicModelProviderConfig {
3360 base: ModelProviderConfig {
3361 model: Some("claude-work".into()),
3362 api_key: Some("work-key".into()),
3363 uri: Some("https://work-proxy.example/v1/v1/anthropic/messages".into()),
3364 ..ModelProviderConfig::default()
3365 },
3366 };
3367 config
3368 .providers
3369 .models
3370 .anthropic
3371 .insert("default".to_string(), default_alias);
3372 config
3373 .providers
3374 .models
3375 .anthropic
3376 .insert("work".to_string(), work_alias);
3377 let work_agent = AliasedAgentConfig {
3378 model_provider: "anthropic.work".into(),
3379 ..AliasedAgentConfig::default()
3380 };
3381 config.agents.insert("work_agent".to_string(), work_agent);
3382 let default_agent = AliasedAgentConfig {
3383 model_provider: "anthropic.default".into(),
3384 ..AliasedAgentConfig::default()
3385 };
3386 config
3387 .agents
3388 .insert("default_agent".to_string(), default_agent);
3389 config
3390 }
3391
3392 #[test]
3393 fn provider_runtime_options_for_agent_resolves_alias_specific_uri() {
3394 let config = config_with_two_anthropic_aliases();
3395 let work = provider_runtime_options_for_agent(&config, "work_agent");
3396 let dflt = provider_runtime_options_for_agent(&config, "default_agent");
3397
3398 assert_eq!(
3399 work.provider_api_url.as_deref(),
3400 Some("https://work-proxy.example/v1/v1/anthropic/messages"),
3401 "work agent must resolve to the work alias's full uri (with merged path)"
3402 );
3403 assert_eq!(
3404 dflt.provider_api_url.as_deref(),
3405 Some("https://api.default.example/v1/messages"),
3406 "default agent must resolve to the default alias's full uri (with merged path)"
3407 );
3408 }
3409
3410 #[test]
3411 fn provider_runtime_options_for_agent_unknown_agent_returns_safe_defaults() {
3412 let config = config_with_two_anthropic_aliases();
3418 let opts = provider_runtime_options_for_agent(&config, "nonexistent");
3419 assert!(
3420 opts.provider_api_url.is_none(),
3421 "unknown agent must not silently inherit any configured provider; got `{:?}`",
3422 opts.provider_api_url
3423 );
3424 }
3425
3426 #[test]
3427 fn ollama_alias_tuning_fields_populate_tuning_struct() {
3428 let alias = zeroclaw_config::schema::OllamaModelProviderConfig {
3429 num_ctx: Some(16384),
3430 num_predict: Some(4096),
3431 temperature_override: Some(0.5),
3432 ..zeroclaw_config::schema::OllamaModelProviderConfig::default()
3433 };
3434
3435 let tuning = ollama::OllamaTuning::from_runtime_overrides(
3436 alias.num_ctx,
3437 alias.num_predict,
3438 alias.temperature_override,
3439 );
3440 assert_eq!(tuning.num_ctx, 16384);
3441 assert_eq!(tuning.num_predict, 4096);
3442 assert_eq!(tuning.temperature_override, Some(0.5));
3443
3444 let provider = ollama::OllamaModelProvider::new("test", None, None).with_tuning(tuning);
3445 assert_eq!(provider.tuning(), tuning);
3446 }
3447
3448 #[test]
3449 fn ollama_alias_tuning_defaults_leave_temperature_override_unset() {
3450 let alias = zeroclaw_config::schema::OllamaModelProviderConfig::default();
3451 let tuning = ollama::OllamaTuning::from_runtime_overrides(
3452 alias.num_ctx,
3453 alias.num_predict,
3454 alias.temperature_override,
3455 );
3456 assert!(tuning.temperature_override.is_none());
3457 assert_eq!(tuning.num_ctx, ollama::OLLAMA_DEFAULT_NUM_CTX);
3458 assert_eq!(tuning.num_predict, ollama::OLLAMA_DEFAULT_NUM_PREDICT);
3459 }
3460
3461 fn config_with_openai_alias() -> zeroclaw_config::schema::Config {
3462 use zeroclaw_config::schema::{
3463 AliasedAgentConfig, Config, ModelProviderConfig, OpenAIModelProviderConfig,
3464 };
3465 let mut config = Config::default();
3466 let alias = OpenAIModelProviderConfig {
3467 base: ModelProviderConfig {
3468 api_key: Some("openai-alias-key".into()),
3469 model: Some("gpt-4o".into()),
3470 ..ModelProviderConfig::default()
3471 },
3472 };
3473 config
3474 .providers
3475 .models
3476 .openai
3477 .insert("alias".to_string(), alias);
3478 let agent = AliasedAgentConfig {
3479 model_provider: "openai.alias".into(),
3480 ..AliasedAgentConfig::default()
3481 };
3482 config.agents.insert("test_agent".to_string(), agent);
3483 config
3484 }
3485
3486 #[test]
3487 fn routed_model_provider_credential_precedence_uses_route_key_first() {
3488 let config = config_with_openai_alias();
3489 let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3490 let routes = [zeroclaw_config::schema::ModelRouteConfig {
3491 hint: "test".into(),
3492 model_provider: "openai.alias".into(),
3493 model: "gpt-4o".into(),
3494 api_key: Some("route-key".into()),
3495 }];
3496
3497 let result = create_routed_model_provider_with_options(
3498 &config,
3499 "openai.alias",
3500 Some("fallback-key"),
3501 None,
3502 &reliability,
3503 &routes,
3504 "gpt-4o",
3505 &ModelProviderRuntimeOptions::default(),
3506 );
3507
3508 assert!(
3509 result.is_ok(),
3510 "route-key should succeed: {}",
3511 result.err().unwrap()
3512 );
3513 }
3514
3515 #[test]
3516 fn routed_model_provider_credential_precedence_uses_config_entry_key() {
3517 let config = config_with_openai_alias();
3518 let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3519 let routes = [zeroclaw_config::schema::ModelRouteConfig {
3521 hint: "test".into(),
3522 model_provider: "openai.alias".into(),
3523 model: "gpt-4o".into(),
3524 api_key: None,
3525 }];
3526
3527 let result = create_routed_model_provider_with_options(
3528 &config,
3529 "openai.alias",
3530 Some("fallback-key"),
3531 None,
3532 &reliability,
3533 &routes,
3534 "gpt-4o",
3535 &ModelProviderRuntimeOptions::default(),
3536 );
3537
3538 assert!(
3539 result.is_ok(),
3540 "config-entry key should succeed: {}",
3541 result.err().unwrap()
3542 );
3543 }
3544
3545 #[test]
3546 fn routed_model_provider_credential_precedence_falls_back_to_api_key_param() {
3547 let config = zeroclaw_config::schema::Config::default(); let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3549 let routes = [zeroclaw_config::schema::ModelRouteConfig {
3551 hint: "test".into(),
3552 model_provider: "openai".into(),
3553 model: "gpt-4o".into(),
3554 api_key: None,
3555 }];
3556
3557 let result = create_routed_model_provider_with_options(
3558 &config,
3559 "openai",
3560 Some("fallback-key"),
3561 None,
3562 &reliability,
3563 &routes,
3564 "gpt-4o",
3565 &ModelProviderRuntimeOptions::default(),
3566 );
3567
3568 assert!(
3569 result.is_ok(),
3570 "fallback-key should succeed: {}",
3571 result.err().unwrap()
3572 );
3573 }
3574
3575 #[test]
3576 fn routed_model_provider_credential_skips_config_entry_for_non_dotted_name() {
3577 let config = zeroclaw_config::schema::Config::default();
3578 let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3579 let routes = [zeroclaw_config::schema::ModelRouteConfig {
3582 hint: "test".into(),
3583 model_provider: "openai".into(),
3584 model: "gpt-4o".into(),
3585 api_key: None,
3586 }];
3587
3588 let result = create_routed_model_provider_with_options(
3589 &config,
3590 "openai",
3591 Some("direct-key"),
3592 None,
3593 &reliability,
3594 &routes,
3595 "gpt-4o",
3596 &ModelProviderRuntimeOptions::default(),
3597 );
3598
3599 assert!(
3600 result.is_ok(),
3601 "direct-key should succeed: {}",
3602 result.err().unwrap()
3603 );
3604 }
3605
3606 #[test]
3615 fn dotted_alias_routes_openai_codex_via_requires_openai_auth() {
3616 use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
3617
3618 let arbitrary_alias = "qwertfoozp";
3620
3621 let mut config = zeroclaw_config::schema::Config::default();
3622 config.providers.models.openai.insert(
3623 arbitrary_alias.to_string(),
3624 OpenAIModelProviderConfig {
3625 base: ModelProviderConfig {
3626 requires_openai_auth: true,
3627 ..Default::default()
3628 },
3629 },
3630 );
3631
3632 let result = factory::dispatch_family_factory(
3637 Some(&config),
3638 "openai",
3639 arbitrary_alias,
3640 None,
3641 None,
3642 &ModelProviderRuntimeOptions::default(),
3643 );
3644 assert!(
3645 result.is_ok(),
3646 "codex alias construction should succeed: {}",
3647 result.err().unwrap()
3648 );
3649 assert!(
3650 result.unwrap().capabilities().native_tool_calling,
3651 "openai.{arbitrary_alias} with requires_openai_auth=true must route to \
3652 OpenAiCodexModelProvider (native_tool_calling=true), not the standard provider"
3653 );
3654 }
3655
3656 #[test]
3657 fn resilient_alias_builds_with_fallback_chain() {
3658 use zeroclaw_config::schema::{Config, ModelProviderConfig, OpenAIModelProviderConfig};
3659
3660 let mut config = Config::default();
3661 config.providers.models.openai.insert(
3662 "primary".to_string(),
3663 OpenAIModelProviderConfig {
3664 base: ModelProviderConfig {
3665 model: Some("gpt-4o".to_string()),
3666 fallback_models: vec!["gpt-4o-mini".to_string()],
3667 fallback: vec![zeroclaw_config::providers::ModelProviderRef::new(
3668 "openai.backup",
3669 )],
3670 ..Default::default()
3671 },
3672 },
3673 );
3674 config.providers.models.openai.insert(
3675 "backup".to_string(),
3676 OpenAIModelProviderConfig {
3677 base: ModelProviderConfig {
3678 model: Some("gpt-4.1".to_string()),
3679 ..Default::default()
3680 },
3681 },
3682 );
3683
3684 let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3685 let result = create_resilient_model_provider_for_alias(
3686 &config,
3687 "openai",
3688 "primary",
3689 None,
3690 None,
3691 &reliability,
3692 &ModelProviderRuntimeOptions::default(),
3693 );
3694 assert!(
3695 result.is_ok(),
3696 "multi-alias fallback chain must build: {}",
3697 result.err().unwrap()
3698 );
3699 }
3700
3701 #[test]
3702 fn resilient_alias_dangling_fallback_does_not_abort_build() {
3703 use zeroclaw_config::schema::{Config, ModelProviderConfig, OpenAIModelProviderConfig};
3704
3705 let mut config = Config::default();
3706 config.providers.models.openai.insert(
3707 "primary".to_string(),
3708 OpenAIModelProviderConfig {
3709 base: ModelProviderConfig {
3710 model: Some("gpt-4o".to_string()),
3711 fallback: vec![zeroclaw_config::providers::ModelProviderRef::new(
3712 "openai.ghost",
3713 )],
3714 ..Default::default()
3715 },
3716 },
3717 );
3718
3719 let result = create_resilient_model_provider_for_alias(
3720 &config,
3721 "openai",
3722 "primary",
3723 None,
3724 None,
3725 &zeroclaw_config::schema::ReliabilityConfig::default(),
3726 &ModelProviderRuntimeOptions::default(),
3727 );
3728 assert!(
3729 result.is_ok(),
3730 "a dangling fallback ref must be skipped, never abort the build"
3731 );
3732 }
3733
3734 #[test]
3735 fn resilient_alias_cyclic_fallback_does_not_loop_or_abort() {
3736 use zeroclaw_config::schema::{Config, ModelProviderConfig, OpenAIModelProviderConfig};
3737
3738 let mut config = Config::default();
3739 config.providers.models.openai.insert(
3740 "a".to_string(),
3741 OpenAIModelProviderConfig {
3742 base: ModelProviderConfig {
3743 model: Some("gpt-4o".to_string()),
3744 fallback: vec![zeroclaw_config::providers::ModelProviderRef::new(
3745 "openai.b",
3746 )],
3747 ..Default::default()
3748 },
3749 },
3750 );
3751 config.providers.models.openai.insert(
3752 "b".to_string(),
3753 OpenAIModelProviderConfig {
3754 base: ModelProviderConfig {
3755 model: Some("gpt-4.1".to_string()),
3756 fallback: vec![zeroclaw_config::providers::ModelProviderRef::new(
3757 "openai.a",
3758 )],
3759 ..Default::default()
3760 },
3761 },
3762 );
3763
3764 let result = create_resilient_model_provider_for_alias(
3765 &config,
3766 "openai",
3767 "a",
3768 None,
3769 None,
3770 &zeroclaw_config::schema::ReliabilityConfig::default(),
3771 &ModelProviderRuntimeOptions::default(),
3772 );
3773 assert!(
3774 result.is_ok(),
3775 "a fallback cycle must be pruned, never loop or abort the build"
3776 );
3777 }
3778
3779 #[test]
3780 fn resilient_alias_deep_acyclic_fallback_does_not_overflow() {
3781 use zeroclaw_config::schema::{Config, ModelProviderConfig, OpenAIModelProviderConfig};
3782
3783 let mut config = Config::default();
3784 let n = zeroclaw_config::providers::MAX_FALLBACK_DEPTH + 50;
3785 for i in 0..n {
3786 let fallback = if i + 1 < n {
3787 vec![zeroclaw_config::providers::ModelProviderRef::new(format!(
3788 "openai.a{}",
3789 i + 1
3790 ))]
3791 } else {
3792 vec![]
3793 };
3794 config.providers.models.openai.insert(
3795 format!("a{i}"),
3796 OpenAIModelProviderConfig {
3797 base: ModelProviderConfig {
3798 model: Some("gpt-4o".to_string()),
3799 fallback,
3800 ..Default::default()
3801 },
3802 },
3803 );
3804 }
3805
3806 let result = create_resilient_model_provider_for_alias(
3807 &config,
3808 "openai",
3809 "a0",
3810 None,
3811 None,
3812 &zeroclaw_config::schema::ReliabilityConfig::default(),
3813 &ModelProviderRuntimeOptions::default(),
3814 );
3815 assert!(
3816 result.is_ok(),
3817 "a deep acyclic chain must be depth-capped, never overflow or abort the build"
3818 );
3819 }
3820}