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 models_dev;
32pub mod multimodal;
33pub mod ollama;
34pub mod openai;
35pub mod openai_codex;
36pub mod openrouter;
37pub mod openrouter_catalog;
38pub mod reliable;
39pub mod router;
40pub mod telnyx;
41pub mod traits;
42
43#[allow(unused_imports)]
44pub use traits::{
45 ChatMessage, ChatRequest, ChatResponse, ConversationMessage, ModelProvider,
46 ProviderCapabilityError, ToolCall, ToolResultMessage,
47};
48
49use reliable::ReliableModelProvider;
50use serde::Deserialize;
51use std::path::PathBuf;
52
53const MAX_API_ERROR_CHARS: usize = 500;
54const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
55const MINIMAX_OAUTH_DEFAULT_CLIENT_ID: &str = "78257093-7e40-4613-99e0-527b14b39113";
59const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
60const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1";
61const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
62const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
63const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
64const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
65const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
66const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
67const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
68const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
69const VERCEL_AI_GATEWAY_BASE_URL: &str = "https://ai-gateway.vercel.sh/v1";
70
71pub fn is_minimax_intl_alias(name: &str) -> bool {
72 matches!(
73 name,
74 "minimax"
75 | "minimax-intl"
76 | "minimax-io"
77 | "minimax-global"
78 | "minimax-oauth"
79 | "minimax-portal"
80 | "minimax-oauth-global"
81 | "minimax-portal-global"
82 )
83}
84pub fn is_minimax_cn_alias(name: &str) -> bool {
85 matches!(
86 name,
87 "minimax-cn" | "minimaxi" | "minimax-oauth-cn" | "minimax-portal-cn"
88 )
89}
90pub fn is_minimax_alias(name: &str) -> bool {
91 is_minimax_intl_alias(name) || is_minimax_cn_alias(name)
92}
93pub fn is_glm_global_alias(name: &str) -> bool {
94 matches!(name, "glm" | "zhipu" | "glm-global" | "zhipu-global")
95}
96
97pub fn is_glm_cn_alias(name: &str) -> bool {
98 matches!(name, "glm-cn" | "zhipu-cn" | "bigmodel")
99}
100
101pub fn is_glm_alias(name: &str) -> bool {
102 is_glm_global_alias(name) || is_glm_cn_alias(name)
103}
104
105pub fn is_moonshot_intl_alias(name: &str) -> bool {
106 matches!(
107 name,
108 "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
109 )
110}
111
112pub fn is_moonshot_cn_alias(name: &str) -> bool {
113 matches!(name, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn")
114}
115
116pub fn is_moonshot_alias(name: &str) -> bool {
117 is_moonshot_intl_alias(name) || is_moonshot_cn_alias(name)
118}
119
120pub fn is_qwen_cn_alias(name: &str) -> bool {
121 matches!(name, "qwen" | "dashscope" | "qwen-cn" | "dashscope-cn")
122}
123
124pub fn is_qwen_intl_alias(name: &str) -> bool {
125 matches!(
126 name,
127 "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
128 )
129}
130
131pub fn is_qwen_us_alias(name: &str) -> bool {
132 matches!(name, "qwen-us" | "dashscope-us")
133}
134
135pub fn is_qwen_oauth_alias(name: &str) -> bool {
136 matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
137}
138
139pub fn is_bailian_alias(name: &str) -> bool {
140 matches!(name, "bailian" | "aliyun-bailian" | "aliyun")
141}
142
143pub fn is_qwen_alias(name: &str) -> bool {
144 is_qwen_cn_alias(name)
145 || is_qwen_intl_alias(name)
146 || is_qwen_us_alias(name)
147 || is_qwen_oauth_alias(name)
148}
149
150pub fn is_zai_global_alias(name: &str) -> bool {
151 matches!(name, "zai" | "z.ai" | "zai-global" | "z.ai-global")
152}
153
154pub fn is_zai_cn_alias(name: &str) -> bool {
155 matches!(name, "zai-cn" | "z.ai-cn")
156}
157
158pub fn is_zai_alias(name: &str) -> bool {
159 is_zai_global_alias(name) || is_zai_cn_alias(name)
160}
161
162pub fn is_qianfan_alias(name: &str) -> bool {
163 matches!(name, "qianfan" | "baidu")
164}
165
166fn qianfan_base_url(api_url: Option<&str>) -> String {
167 api_url
168 .map(str::trim)
169 .filter(|value| !value.is_empty())
170 .map(ToString::to_string)
171 .unwrap_or_else(|| QIANFAN_BASE_URL.to_string())
172}
173
174pub fn is_doubao_alias(name: &str) -> bool {
175 matches!(name, "doubao" | "volcengine" | "ark" | "doubao-cn")
176}
177
178#[derive(Clone, Deserialize, Default)]
179pub(crate) struct QwenOauthCredentials {
180 #[serde(default)]
181 pub(crate) access_token: Option<String>,
182 #[serde(default)]
183 pub(crate) refresh_token: Option<String>,
184 #[serde(default)]
185 pub(crate) resource_url: Option<String>,
186 #[serde(default)]
187 pub(crate) expiry_date: Option<i64>,
188}
189
190impl std::fmt::Debug for QwenOauthCredentials {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.debug_struct("QwenOauthCredentials")
193 .field("resource_url", &self.resource_url)
194 .field("expiry_date", &self.expiry_date)
195 .finish_non_exhaustive()
196 }
197}
198
199#[derive(Debug, Deserialize)]
200struct QwenOauthTokenResponse {
201 #[serde(default)]
202 access_token: Option<String>,
203 #[serde(default)]
204 refresh_token: Option<String>,
205 #[serde(default)]
206 expires_in: Option<i64>,
207 #[serde(default)]
208 resource_url: Option<String>,
209 #[serde(default)]
210 error: Option<String>,
211 #[serde(default)]
212 error_description: Option<String>,
213}
214
215#[derive(Clone, Default)]
216pub(crate) struct QwenOauthProviderContext {
217 pub(crate) credential: Option<String>,
218 pub(crate) base_url: Option<String>,
219}
220
221impl std::fmt::Debug for QwenOauthProviderContext {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 f.debug_struct("QwenOauthProviderContext")
224 .field("base_url", &self.base_url)
225 .finish_non_exhaustive()
226 }
227}
228
229fn qwen_oauth_client_id() -> String {
230 QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string()
231}
232
233fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
234 std::env::var_os("HOME")
236 .map(PathBuf::from)
237 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
238 .map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
239}
240
241fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
242 let trimmed = raw.trim().trim_end_matches('/');
243 if trimmed.is_empty() {
244 return None;
245 }
246
247 let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
248 trimmed.to_string()
249 } else {
250 format!("https://{trimmed}")
251 };
252
253 let normalized = with_scheme.trim_end_matches('/').to_string();
254 if normalized.ends_with("/v1") {
255 Some(normalized)
256 } else {
257 Some(format!("{normalized}/v1"))
258 }
259}
260
261fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
262 let path = qwen_oauth_credentials_file_path()?;
263 let content = std::fs::read_to_string(path).ok()?;
264 serde_json::from_str::<QwenOauthCredentials>(&content).ok()
265}
266
267fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
268 if raw < 10_000_000_000 {
269 raw.saturating_mul(1000)
270 } else {
271 raw
272 }
273}
274
275fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
276 let Some(expiry) = credentials.expiry_date else {
277 return false;
278 };
279
280 let expiry_millis = normalized_qwen_expiry_millis(expiry);
281 let now_millis = std::time::SystemTime::now()
282 .duration_since(std::time::UNIX_EPOCH)
283 .ok()
284 .and_then(|duration| i64::try_from(duration.as_millis()).ok())
285 .unwrap_or(i64::MAX);
286
287 expiry_millis <= now_millis.saturating_add(30_000)
288}
289
290pub(crate) fn refresh_qwen_oauth_access_token(
291 refresh_token: &str,
292 client_id: &str,
293) -> anyhow::Result<QwenOauthCredentials> {
294 let client = reqwest::blocking::Client::builder()
295 .timeout(std::time::Duration::from_secs(15))
296 .connect_timeout(std::time::Duration::from_secs(5))
297 .build()
298 .unwrap_or_else(|_| reqwest::blocking::Client::new());
299
300 let response = client
301 .post(QWEN_OAUTH_TOKEN_ENDPOINT)
302 .header("Content-Type", "application/x-www-form-urlencoded")
303 .header("Accept", "application/json")
304 .form(&[
305 ("grant_type", "refresh_token"),
306 ("refresh_token", refresh_token),
307 ("client_id", client_id),
308 ])
309 .send()
310 .map_err(|error| {
311 ::zeroclaw_log::record!(
312 ERROR,
313 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
314 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
315 .with_attrs(::serde_json::json!({
316 "oauth_provider": "qwen",
317 "phase": "refresh_request",
318 "error": format!("{}", error),
319 })),
320 "qwen: OAuth refresh request failed"
321 );
322 anyhow::Error::msg(format!("OAuth refresh request failed: {error}"))
323 })?;
324
325 let status = response.status();
326 let body = response
327 .text()
328 .unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
329
330 let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
331
332 if !status.is_success() {
333 let detail = parsed
334 .as_ref()
335 .and_then(|payload| payload.error_description.as_deref())
336 .or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
337 .filter(|msg| !msg.trim().is_empty())
338 .unwrap_or(body.as_str());
339 anyhow::bail!("OAuth refresh failed (HTTP {status}): {detail}");
340 }
341
342 let payload = parsed.ok_or_else(|| {
343 ::zeroclaw_log::record!(
344 ERROR,
345 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
346 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
347 .with_attrs(::serde_json::json!({
348 "oauth_provider": "qwen",
349 "phase": "refresh_parse",
350 })),
351 "qwen: OAuth refresh response is not JSON"
352 );
353 anyhow::Error::msg("OAuth refresh response is not JSON")
354 })?;
355
356 if let Some(error_code) = payload
357 .error
358 .as_deref()
359 .filter(|value| !value.trim().is_empty())
360 {
361 let detail = payload.error_description.as_deref().unwrap_or(error_code);
362 anyhow::bail!("OAuth refresh failed: {detail}");
363 }
364
365 let access_token = payload
366 .access_token
367 .as_deref()
368 .map(str::trim)
369 .filter(|token| !token.is_empty())
370 .ok_or_else(|| {
371 ::zeroclaw_log::record!(
372 ERROR,
373 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
374 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
375 .with_attrs(::serde_json::json!({
376 "oauth_provider": "qwen",
377 "field": "access_token",
378 })),
379 "qwen: OAuth refresh response missing access_token"
380 );
381 anyhow::Error::msg("OAuth refresh response missing access_token")
382 })?
383 .to_string();
384
385 let expiry_date = payload.expires_in.and_then(|seconds| {
386 let now_secs = std::time::SystemTime::now()
387 .duration_since(std::time::UNIX_EPOCH)
388 .ok()
389 .and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
390 now_secs
391 .checked_add(seconds)
392 .and_then(|unix_secs| unix_secs.checked_mul(1000))
393 });
394
395 Ok(QwenOauthCredentials {
396 access_token: Some(access_token),
397 refresh_token: payload
398 .refresh_token
399 .as_deref()
400 .map(str::trim)
401 .filter(|value| !value.is_empty())
402 .map(ToString::to_string),
403 resource_url: payload
404 .resource_url
405 .as_deref()
406 .map(str::trim)
407 .filter(|value| !value.is_empty())
408 .map(ToString::to_string),
409 expiry_date,
410 })
411}
412
413#[derive(Debug, Deserialize)]
422struct MinimaxOauthRefreshResponse {
423 #[serde(default)]
424 status: Option<String>,
425 #[serde(default)]
426 access_token: Option<String>,
427 #[serde(default)]
428 base_resp: Option<MinimaxOauthBaseResponse>,
429}
430
431#[derive(Debug, Deserialize)]
432struct MinimaxOauthBaseResponse {
433 #[serde(default)]
434 status_msg: Option<String>,
435}
436
437pub(crate) fn refresh_minimax_oauth_access_token(
442 refresh_token: &str,
443 client_id: &str,
444 region: zeroclaw_config::schema::MinimaxEndpoint,
445) -> anyhow::Result<String> {
446 let endpoint = region.oauth_token_endpoint();
447 let client = reqwest::blocking::Client::builder()
448 .timeout(std::time::Duration::from_secs(15))
449 .connect_timeout(std::time::Duration::from_secs(5))
450 .build()
451 .unwrap_or_else(|_| reqwest::blocking::Client::new());
452
453 let response = client
454 .post(endpoint)
455 .header("Content-Type", "application/x-www-form-urlencoded")
456 .header("Accept", "application/json")
457 .form(&[
458 ("grant_type", "refresh_token"),
459 ("refresh_token", refresh_token),
460 ("client_id", client_id),
461 ])
462 .send()
463 .map_err(|error| {
464 ::zeroclaw_log::record!(
465 ERROR,
466 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
467 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
468 .with_attrs(::serde_json::json!({
469 "oauth_provider": "minimax",
470 "phase": "refresh_request",
471 "error": format!("{}", error),
472 })),
473 "minimax: OAuth refresh request failed"
474 );
475 anyhow::Error::msg(format!("MiniMax OAuth refresh request failed: {error}"))
476 })?;
477
478 let status = response.status();
479 let body = response
480 .text()
481 .unwrap_or_else(|_| "<failed to read MiniMax OAuth response body>".to_string());
482 let parsed = serde_json::from_str::<MinimaxOauthRefreshResponse>(&body).ok();
483
484 if !status.is_success() {
485 let detail = parsed
486 .as_ref()
487 .and_then(|payload| payload.base_resp.as_ref())
488 .and_then(|base| base.status_msg.as_deref())
489 .filter(|msg| !msg.trim().is_empty())
490 .unwrap_or(body.as_str());
491 anyhow::bail!("MiniMax OAuth refresh failed (HTTP {status}): {detail}");
492 }
493
494 if let Some(payload) = parsed {
495 if let Some(status_text) = payload.status.as_deref()
496 && !status_text.eq_ignore_ascii_case("success")
497 {
498 let detail = payload
499 .base_resp
500 .as_ref()
501 .and_then(|base| base.status_msg.as_deref())
502 .unwrap_or(status_text);
503 anyhow::bail!("MiniMax OAuth refresh failed: {detail}");
504 }
505 if let Some(token) = payload
506 .access_token
507 .as_deref()
508 .map(str::trim)
509 .filter(|token| !token.is_empty())
510 {
511 return Ok(token.to_string());
512 }
513 }
514 anyhow::bail!("MiniMax OAuth refresh response missing access_token")
515}
516
517fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
518 let override_value = credential_override
519 .map(str::trim)
520 .filter(|value| !value.is_empty());
521 let placeholder_requested = override_value
522 .map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
523 .unwrap_or(false);
524
525 if let Some(explicit) = override_value
526 && !placeholder_requested
527 {
528 return QwenOauthProviderContext {
529 credential: Some(explicit.to_string()),
530 base_url: None,
531 };
532 }
533
534 let mut cached = read_qwen_oauth_cached_credentials();
538
539 let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
540 || cached
541 .as_ref()
542 .and_then(|credentials| credentials.access_token.as_deref())
543 .is_none_or(|value| value.trim().is_empty());
544
545 if should_refresh
546 && let Some(refresh_token) = cached
547 .as_ref()
548 .and_then(|credentials| credentials.refresh_token.clone())
549 {
550 match refresh_qwen_oauth_access_token(&refresh_token, &qwen_oauth_client_id()) {
551 Ok(refreshed) => {
552 cached = Some(refreshed);
553 }
554 Err(error) => {
555 ::zeroclaw_log::record!(
556 WARN,
557 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
558 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
559 .with_attrs(::serde_json::json!({"error": format!("{}", error)})),
560 "OAuth refresh failed"
561 );
562 }
563 }
564 }
565
566 let credential = cached
567 .as_ref()
568 .and_then(|credentials| credentials.access_token.as_deref())
569 .map(str::trim)
570 .filter(|value| !value.is_empty())
571 .map(ToString::to_string);
572
573 let base_url = cached
574 .as_ref()
575 .and_then(|credentials| credentials.resource_url.as_deref())
576 .and_then(normalize_qwen_oauth_base_url);
577
578 QwenOauthProviderContext {
579 credential,
580 base_url,
581 }
582}
583
584#[derive(Debug, Clone)]
594pub struct ModelProviderRuntimeOptions {
595 pub auth_profile_override: Option<String>,
596 pub provider_kind: Option<String>,
599 pub provider_api_url: Option<String>,
600 pub zeroclaw_dir: Option<PathBuf>,
601 pub secrets_encrypt: bool,
602 pub reasoning_enabled: Option<bool>,
603 pub reasoning_effort: Option<String>,
604 pub provider_timeout_secs: Option<u64>,
607 pub extra_headers: std::collections::HashMap<String, String>,
609 pub api_path: Option<String>,
612 pub provider_max_tokens: Option<u32>,
615 pub merge_system_into_user: bool,
618 pub provider_extra: Option<serde_json::Value>,
621 pub native_tools: Option<bool>,
624 pub wire_api: Option<String>,
629 pub think: Option<bool>,
632 pub chat_template_kwargs: Option<serde_json::Value>,
634}
635
636impl Default for ModelProviderRuntimeOptions {
637 fn default() -> Self {
638 Self {
639 auth_profile_override: None,
640 provider_kind: None,
641 provider_api_url: None,
642 zeroclaw_dir: None,
643 secrets_encrypt: true,
644 reasoning_enabled: None,
645 reasoning_effort: None,
646 provider_timeout_secs: None,
647 extra_headers: std::collections::HashMap::new(),
648 api_path: None,
649 provider_max_tokens: None,
650 merge_system_into_user: false,
651 provider_extra: None,
652 native_tools: None,
653 wire_api: None,
654 think: None,
655 chat_template_kwargs: None,
656 }
657 }
658}
659
660pub fn model_provider_runtime_options_from_model_provider_entry(
669 config: &zeroclaw_config::schema::Config,
670 entry: Option<&zeroclaw_config::schema::ModelProviderConfig>,
671) -> ModelProviderRuntimeOptions {
672 let merge_system_into_user = entry
677 .and_then(|e| e.uri.as_deref())
678 .map(str::trim)
679 .filter(|u| !u.is_empty())
680 .and_then(|active_uri| {
681 config
682 .providers
683 .models
684 .iter_entries()
685 .map(|(_, _, base)| base)
686 .find(|p| {
687 p.uri
688 .as_deref()
689 .map(str::trim)
690 .filter(|u: &&str| !u.is_empty())
691 .map(|u: &str| u.trim_end_matches('/'))
692 == Some(active_uri.trim_end_matches('/'))
693 })
694 })
695 .map(|p| p.merge_system_into_user)
696 .unwrap_or(false);
697
698 ModelProviderRuntimeOptions {
699 auth_profile_override: None,
700 provider_kind: entry.and_then(|e| {
701 e.kind
702 .as_deref()
703 .map(str::trim)
704 .filter(|value| !value.is_empty())
705 .map(ToString::to_string)
706 }),
707 provider_api_url: entry.and_then(|e| e.uri.clone()),
708 zeroclaw_dir: config.config_path.parent().map(PathBuf::from),
709 secrets_encrypt: config.secrets.encrypt,
710 reasoning_enabled: config.runtime.reasoning_enabled,
711 reasoning_effort: config.runtime.reasoning_effort.clone(),
712 provider_timeout_secs: Some(entry.and_then(|e| e.timeout_secs).unwrap_or(120)),
713 extra_headers: entry.map(|e| e.extra_headers.clone()).unwrap_or_default(),
714 api_path: None,
715 provider_max_tokens: entry.and_then(|e| e.max_tokens),
716 merge_system_into_user,
717 provider_extra: entry.and_then(|e| e.provider_extra.clone()),
718 native_tools: entry.and_then(|e| e.native_tools),
719 wire_api: entry.and_then(|e| e.wire_api.map(|w| w.as_str().to_string())),
720 think: entry.and_then(|e| e.think),
721 chat_template_kwargs: entry.and_then(|e| e.chat_template_kwargs.clone()),
722 }
723}
724
725pub fn provider_runtime_options_for_agent(
730 config: &zeroclaw_config::schema::Config,
731 agent_alias: &str,
732) -> ModelProviderRuntimeOptions {
733 let entry = config.model_provider_for_agent(agent_alias).or_else(|| {
734 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"agent": agent_alias})), "model_provider_for_agent returned None; falling back to model_providers.first_model_provider()");
735 config.first_model_provider()
736 });
737 let mut options = model_provider_runtime_options_from_model_provider_entry(config, entry);
738
739 if let Some(agent) = config.agents.get(agent_alias)
740 && let Some((family, alias)) = agent.model_provider.split_once('.')
741 {
742 if options.provider_api_url.is_none()
747 && let Some(uri) = config.providers.models.resolved_endpoint_uri(family, alias)
748 {
749 options.provider_api_url = Some(uri.to_string());
750 }
751 }
757
758 options
759}
760
761pub fn provider_runtime_options_from_config(
762 config: &zeroclaw_config::schema::Config,
763) -> ModelProviderRuntimeOptions {
764 model_provider_runtime_options_from_model_provider_entry(config, config.first_model_provider())
765}
766
767pub fn provider_runtime_options_for_alias(
773 config: &zeroclaw_config::schema::Config,
774 family: &str,
775 alias: &str,
776) -> ModelProviderRuntimeOptions {
777 let entry = config.providers.models.find(family, alias);
778 let mut options = model_provider_runtime_options_from_model_provider_entry(config, entry);
779 if options.provider_api_url.is_none()
780 && let Some(uri) = config.providers.models.resolved_endpoint_uri(family, alias)
781 {
782 options.provider_api_url = Some(uri.to_string());
783 }
784 options
785}
786
787pub fn options_for_provider_ref(
792 config: &zeroclaw_config::schema::Config,
793 name: &str,
794 fallback: &ModelProviderRuntimeOptions,
795) -> ModelProviderRuntimeOptions {
796 match name.split_once('.') {
797 Some((family, alias)) => provider_runtime_options_for_alias(config, family, alias),
798 None => {
799 let mut options = fallback.clone();
800 options.provider_kind = None;
801 options.provider_api_url = None;
802 options
803 }
804 }
805}
806
807fn is_secret_char(c: char) -> bool {
808 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
809}
810
811fn token_end(input: &str, from: usize) -> usize {
812 let mut end = from;
813 for (i, c) in input[from..].char_indices() {
814 if is_secret_char(c) {
815 end = from + i + c.len_utf8();
816 } else {
817 break;
818 }
819 }
820 end
821}
822
823pub fn scrub_secret_patterns(input: &str) -> String {
828 const PREFIXES: [&str; 7] = [
829 "sk-",
830 "xoxb-",
831 "xoxp-",
832 "ghp_",
833 "gho_",
834 "ghu_",
835 "github_pat_",
836 ];
837
838 let mut scrubbed = input.to_string();
839
840 for prefix in PREFIXES {
841 let mut search_from = 0;
842 while let Some(rel) = scrubbed[search_from..].find(prefix) {
843 let start = search_from + rel;
844 let content_start = start + prefix.len();
845 let end = token_end(&scrubbed, content_start);
846
847 if end == content_start {
849 search_from = content_start;
850 continue;
851 }
852
853 scrubbed.replace_range(start..end, "[REDACTED]");
854 search_from = start + "[REDACTED]".len();
855 }
856 }
857
858 scrubbed
859}
860
861pub fn sanitize_api_error(input: &str) -> String {
863 let scrubbed = scrub_secret_patterns(input);
864
865 if scrubbed.chars().count() <= MAX_API_ERROR_CHARS {
866 return scrubbed;
867 }
868
869 let mut end = MAX_API_ERROR_CHARS;
870 while end > 0 && !scrubbed.is_char_boundary(end) {
871 end -= 1;
872 }
873
874 format!("{}...", &scrubbed[..end])
875}
876
877pub fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
879 let mut formatted = String::new();
880 let _ = std::fmt::Write::write_fmt(&mut formatted, format_args!("{error}"));
881 let mut current = error.source();
882 while let Some(source) = current {
883 let _ = std::fmt::Write::write_fmt(&mut formatted, format_args!(": {source}"));
884 current = source.source();
885 }
886 sanitize_api_error(&formatted)
887}
888
889pub async fn api_error(model_provider: &str, response: reqwest::Response) -> anyhow::Error {
891 let status = response.status();
892 let body = response
893 .text()
894 .await
895 .unwrap_or_else(|_| "<failed to read model_provider error body>".to_string());
896 let sanitized = sanitize_api_error(&body);
897 ::zeroclaw_log::record!(
898 ERROR,
899 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
900 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
901 .with_attrs(::serde_json::json!({
902 "model_provider": model_provider,
903 "status": status.as_u16(),
904 "body": sanitized,
905 })),
906 "providers: API error"
907 );
908 anyhow::Error::msg(format!(
909 "{model_provider} API error ({status}): {sanitized}"
910 ))
911}
912
913fn resolve_model_provider_credential(
918 _name: &str,
919 credential_override: Option<&str>,
920) -> Option<String> {
921 credential_override
922 .map(str::trim)
923 .filter(|value| !value.is_empty())
924 .map(ToString::to_string)
925}
926
927const KEY_PREFIX_MODEL_PROVIDERS: &[(&str, &str)] = &[
932 ("sk-ant-", "anthropic"),
933 ("sk-or-", "openrouter"),
934 ("sk-", "openai"),
935 ("gsk_", "groq"),
936 ("pplx-", "perplexity"),
937 ("xai-", "xai"),
938 ("nvapi-", "nvidia"),
939 ("KEY-", "telnyx"),
940];
941
942fn check_api_key_prefix(model_provider_name: &str, key: &str) -> Option<&'static str> {
948 let likely_model_provider = KEY_PREFIX_MODEL_PROVIDERS
949 .iter()
950 .find(|(prefix, _)| key.starts_with(prefix))
951 .map(|(_, name)| *name)?;
952
953 let recognized = KEY_PREFIX_MODEL_PROVIDERS
957 .iter()
958 .any(|(_, name)| *name == model_provider_name);
959 if !recognized {
960 return None;
961 }
962
963 if model_provider_name == likely_model_provider {
964 None
965 } else {
966 Some(likely_model_provider)
967 }
968}
969
970pub fn create_model_provider(
987 name: &str,
988 api_key: Option<&str>,
989) -> anyhow::Result<Box<dyn ModelProvider>> {
990 create_model_provider_inner(
991 None,
992 name,
993 "default",
994 api_key,
995 None,
996 &ModelProviderRuntimeOptions::default(),
997 )
998}
999
1000pub fn create_model_provider_with_options(
1004 name: &str,
1005 api_key: Option<&str>,
1006 options: &ModelProviderRuntimeOptions,
1007) -> anyhow::Result<Box<dyn ModelProvider>> {
1008 create_model_provider_inner(None, name, "default", api_key, None, options)
1009}
1010
1011pub fn create_model_provider_with_url(
1015 name: &str,
1016 api_key: Option<&str>,
1017 api_url: Option<&str>,
1018) -> anyhow::Result<Box<dyn ModelProvider>> {
1019 create_model_provider_inner(
1020 None,
1021 name,
1022 "default",
1023 api_key,
1024 api_url,
1025 &ModelProviderRuntimeOptions::default(),
1026 )
1027}
1028
1029pub fn create_model_provider_for_alias(
1036 config: &zeroclaw_config::schema::Config,
1037 family: &str,
1038 alias: &str,
1039 api_key: Option<&str>,
1040 options: &ModelProviderRuntimeOptions,
1041) -> anyhow::Result<Box<dyn ModelProvider>> {
1042 create_model_provider_inner(Some(config), family, alias, api_key, None, options)
1043}
1044
1045pub fn create_model_provider_for_alias_with_url(
1047 config: &zeroclaw_config::schema::Config,
1048 family: &str,
1049 alias: &str,
1050 api_key: Option<&str>,
1051 api_url: Option<&str>,
1052 options: &ModelProviderRuntimeOptions,
1053) -> anyhow::Result<Box<dyn ModelProvider>> {
1054 create_model_provider_inner(Some(config), family, alias, api_key, api_url, options)
1055}
1056
1057#[must_use]
1067pub fn canonicalize_v2_model_provider_name(name: &str) -> &str {
1068 match name {
1069 "azure_openai" | "azure-openai" => "azure",
1071 "grok" => "xai",
1072 "google" | "google-gemini" => "gemini",
1073 "together-ai" => "together",
1074 "fireworks-ai" => "fireworks",
1075 "vercel-ai" => "vercel",
1076 "cloudflare-ai" => "cloudflare",
1077 "nvidia-nim" | "build.nvidia.com" => "nvidia",
1078 "aws-bedrock" => "bedrock",
1079 "lm-studio" => "lmstudio",
1080 "lite-llm" => "litellm",
1081 "hf" => "huggingface",
1082 "01ai" | "lingyiwanwu" => "yi",
1083 "tencent" => "hunyuan",
1084 "baidu" => "qianfan",
1085 "github-copilot" => "copilot",
1086 "ovhcloud" => "ovh",
1087 "opencode-zen" => "opencode",
1088 "llama.cpp" => "llamacpp",
1089 "deep-myst" => "deepmyst",
1090 "silicon-flow" => "siliconflow",
1091 "deep-infra" => "deepinfra",
1092 "ai21-labs" => "ai21",
1093 "friendliai" => "friendli",
1094 "lepton-ai" => "lepton",
1095 "step" => "stepfun",
1096 "kilo" => "kilocli",
1097 "kimi" | "kimi-cn" | "kimi-intl" | "kimi-global" | "kimi-code" | "kimi_coding"
1099 | "kimi_for_coding" | "moonshot-cn" | "moonshot-intl" | "moonshot-global" => "moonshot",
1100 "qwen-cn"
1102 | "qwen-intl"
1103 | "qwen-us"
1104 | "qwen-international"
1105 | "qwen-code"
1106 | "qwen-oauth"
1107 | "qwen_oauth"
1108 | "dashscope"
1109 | "dashscope-cn"
1110 | "dashscope-intl"
1111 | "dashscope-us"
1112 | "dashscope-international"
1113 | "bailian"
1114 | "aliyun-bailian"
1115 | "aliyun" => "qwen",
1116 "zhipu" | "glm-global" | "zhipu-global" | "glm-cn" | "zhipu-cn" | "bigmodel" => "glm",
1118 "z.ai" | "zai-global" | "z.ai-global" | "zai-cn" | "z.ai-cn" => "zai",
1120 "minimax-intl"
1122 | "minimax-io"
1123 | "minimax-global"
1124 | "minimax-portal"
1125 | "minimax-portal-global"
1126 | "minimax-cn"
1127 | "minimaxi"
1128 | "minimax-portal-cn"
1129 | "minimax-oauth"
1130 | "minimax-oauth-global"
1131 | "minimax-oauth-cn" => "minimax",
1132 "volcengine" | "ark" | "doubao-cn" => "doubao",
1134 "gemini-cli" => "gemini_cli",
1136 "stepfun-intl" | "step-intl" => "stepfun",
1138 "claude-code" | "anthropic-custom" => "anthropic",
1140 "opencode-go" => "opencode",
1142 _ => name,
1145 }
1146}
1147
1148fn split_v2_colon_url(name: &str) -> (&str, Option<&str>) {
1154 if let Some(idx) = name.find(':') {
1155 let (prefix, rest) = name.split_at(idx);
1156 let url = &rest[1..];
1157 if url.starts_with("http://") || url.starts_with("https://") {
1158 return (prefix, Some(url));
1159 }
1160 }
1161 (name, None)
1162}
1163
1164pub(crate) fn moonshot_code_base_url() -> &'static str {
1165 <zeroclaw_config::schema::MoonshotEndpoint as zeroclaw_config::schema::ModelEndpoint>::uri(
1166 &zeroclaw_config::schema::MoonshotEndpoint::Code,
1167 )
1168}
1169
1170fn is_legacy_kimi_code_alias(name: &str) -> bool {
1171 matches!(name, "kimi-code" | "kimi_coding" | "kimi_for_coding")
1172}
1173
1174#[allow(clippy::too_many_lines)]
1176fn create_model_provider_inner(
1177 config: Option<&zeroclaw_config::schema::Config>,
1178 raw_name: &str,
1179 alias: &str,
1180 api_key: Option<&str>,
1181 api_url: Option<&str>,
1182 options: &ModelProviderRuntimeOptions,
1183) -> anyhow::Result<Box<dyn ModelProvider>> {
1184 if let Some(idx) = raw_name.find(':') {
1190 let prefix = &raw_name[..idx];
1191 let url = raw_name[idx + 1..].trim();
1192 if matches!(prefix, "custom" | "anthropic-custom")
1193 && (url.is_empty() || !(url.starts_with("http://") || url.starts_with("https://")))
1194 {
1195 anyhow::bail!(
1196 "Custom model_provider `{prefix}:<url>` requires a URL beginning with http:// or https://. \
1197 Set `[model_providers.custom.<alias>] uri = \"https://your-api.com\"` or pass a valid URL."
1198 );
1199 }
1200 }
1201 let (split_name, split_url) = split_v2_colon_url(raw_name);
1202 let legacy_kimi_code = is_legacy_kimi_code_alias(split_name);
1203 let api_url = api_url.or(split_url);
1204 let name = canonicalize_v2_model_provider_name(split_name);
1205 let provider_kind = options
1206 .provider_kind
1207 .as_deref()
1208 .map(str::trim)
1209 .filter(|value| !value.is_empty())
1210 .map(canonicalize_v2_model_provider_name)
1211 .unwrap_or(name);
1212
1213 if matches!(provider_kind, "openai-codex" | "openai_codex" | "codex") {
1218 return Ok(Box::new(openai_codex::OpenAiCodexModelProvider::new(
1219 alias, options, api_key,
1220 )?));
1221 }
1222 let resolved_credential = resolve_model_provider_credential(provider_kind, api_key)
1228 .map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
1229 #[allow(clippy::option_as_ref_deref)]
1230 let key = resolved_credential.as_ref().map(String::as_str);
1231
1232 if let Some(key_value) = key {
1234 let is_custom =
1235 provider_kind.starts_with("custom:") || provider_kind.starts_with("anthropic-custom:");
1236 let has_custom_url = api_url.map(str::trim).filter(|u| !u.is_empty()).is_some();
1237 if !is_custom
1238 && !has_custom_url
1239 && let Some(likely_model_provider) = check_api_key_prefix(provider_kind, key_value)
1240 {
1241 let visible = &key_value[..key_value.len().min(8)];
1242 anyhow::bail!(
1243 "API key prefix mismatch: key \"{visible}...\" looks like a \
1244 {likely_model_provider} key, but model_provider \"{provider_kind}\" is selected. \
1245 Set the correct provider-specific env var or use `-p {likely_model_provider}`."
1246 );
1247 }
1248 }
1249
1250 let resolved_url: Option<&str> =
1267 api_url
1268 .map(str::trim)
1269 .filter(|v| !v.is_empty())
1270 .or_else(|| {
1271 options
1272 .provider_api_url
1273 .as_deref()
1274 .map(str::trim)
1275 .filter(|v| !v.is_empty())
1276 });
1277
1278 if legacy_kimi_code {
1279 let base_url = match resolved_url {
1280 Some(url) => url,
1281 None => moonshot_code_base_url(),
1282 };
1283 return Ok(factory::apply_compat_options(
1284 factory::build_kimi_code_compat(alias, key, base_url),
1285 options,
1286 ));
1287 }
1288
1289 factory::dispatch_family_factory(config, provider_kind, alias, key, resolved_url, options)
1290}
1291
1292pub fn create_resilient_model_provider_with_options(
1300 primary_name: &str,
1301 api_key: Option<&str>,
1302 api_url: Option<&str>,
1303 reliability: &zeroclaw_config::schema::ReliabilityConfig,
1304 options: &ModelProviderRuntimeOptions,
1305) -> anyhow::Result<Box<dyn ModelProvider>> {
1306 let primary_model_provider =
1307 create_model_provider_inner(None, primary_name, "default", api_key, api_url, options)?;
1308
1309 let reliable = ReliableModelProvider::new(
1310 primary_name,
1311 vec![(primary_name.to_string(), primary_model_provider)],
1312 reliability.provider_retries,
1313 reliability.provider_backoff_ms,
1314 )
1315 .with_api_keys(reliability.api_keys.clone());
1316
1317 Ok(Box::new(reliable))
1318}
1319
1320pub fn create_resilient_model_provider_for_alias(
1325 config: &zeroclaw_config::schema::Config,
1326 family: &str,
1327 alias: &str,
1328 api_key: Option<&str>,
1329 api_url: Option<&str>,
1330 reliability: &zeroclaw_config::schema::ReliabilityConfig,
1331 options: &ModelProviderRuntimeOptions,
1332) -> anyhow::Result<Box<dyn ModelProvider>> {
1333 let primary_model_provider =
1334 create_model_provider_inner(Some(config), family, alias, api_key, api_url, options)?;
1335
1336 let reliable = ReliableModelProvider::new(
1337 alias,
1338 vec![(family.to_string(), primary_model_provider)],
1339 reliability.provider_retries,
1340 reliability.provider_backoff_ms,
1341 )
1342 .with_api_keys(reliability.api_keys.clone());
1343
1344 Ok(Box::new(reliable))
1345}
1346
1347pub fn create_resilient_model_provider_from_ref(
1353 config: &zeroclaw_config::schema::Config,
1354 name: &str,
1355 api_key: Option<&str>,
1356 api_url: Option<&str>,
1357 reliability: &zeroclaw_config::schema::ReliabilityConfig,
1358 options: &ModelProviderRuntimeOptions,
1359) -> anyhow::Result<Box<dyn ModelProvider>> {
1360 match name.split_once('.') {
1361 Some((family, alias)) => create_resilient_model_provider_for_alias(
1362 config,
1363 family,
1364 alias,
1365 api_key,
1366 api_url,
1367 reliability,
1368 options,
1369 ),
1370 None => create_resilient_model_provider_with_options(
1371 name,
1372 api_key,
1373 api_url,
1374 reliability,
1375 options,
1376 ),
1377 }
1378}
1379
1380pub fn create_routed_model_provider_with_options(
1386 config: &zeroclaw_config::schema::Config,
1387 primary_name: &str,
1388 api_key: Option<&str>,
1389 api_url: Option<&str>,
1390 reliability: &zeroclaw_config::schema::ReliabilityConfig,
1391 model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
1392 default_model: &str,
1393 options: &ModelProviderRuntimeOptions,
1394) -> anyhow::Result<Box<dyn ModelProvider>> {
1395 if model_routes.is_empty() {
1396 return create_resilient_model_provider_from_ref(
1397 config,
1398 primary_name,
1399 api_key,
1400 api_url,
1401 reliability,
1402 options,
1403 );
1404 }
1405
1406 let mut needed: Vec<String> = vec![primary_name.to_string()];
1408 for route in model_routes {
1409 if !needed.iter().any(|n| n == &route.model_provider) {
1410 needed.push(route.model_provider.clone());
1411 }
1412 }
1413
1414 let mut model_providers: Vec<(String, Box<dyn ModelProvider>)> = Vec::new();
1419 for name in &needed {
1420 let routed_credential = model_routes
1421 .iter()
1422 .find(|r| &r.model_provider == name)
1423 .and_then(|r| {
1424 r.api_key.as_ref().and_then(|raw_key| {
1425 let trimmed_key = raw_key.trim();
1426 (!trimmed_key.is_empty()).then_some(trimmed_key)
1427 })
1428 });
1429 let key = routed_credential
1430 .or_else(|| {
1431 name.split_once('.')
1432 .and_then(|(family, alias)| {
1433 config
1434 .providers
1435 .models
1436 .find(family, alias)
1437 .and_then(|cfg| cfg.api_key.as_deref())
1438 })
1439 .and_then(|raw_key| {
1440 let trimmed = raw_key.trim();
1441 (!trimmed.is_empty()).then_some(trimmed)
1442 })
1443 })
1444 .or(api_key);
1445 let url = if name == primary_name { api_url } else { None };
1446 let entry_options = if name == primary_name {
1447 options.clone()
1448 } else {
1449 options_for_provider_ref(config, name, options)
1450 };
1451
1452 match create_resilient_model_provider_from_ref(
1453 config,
1454 name,
1455 key,
1456 url,
1457 reliability,
1458 &entry_options,
1459 ) {
1460 Ok(model_provider) => model_providers.push((name.clone(), model_provider)),
1461 Err(e) => {
1462 if name == primary_name {
1463 return Err(e);
1464 }
1465 ::zeroclaw_log::record!(
1466 WARN,
1467 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1468 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1469 .with_attrs(
1470 ::serde_json::json!({"model_provider": name.as_str(), "error": format!("{}", e)})
1471 ),
1472 "Ignoring routed model_provider that failed to initialize"
1473 );
1474 }
1475 }
1476 }
1477
1478 let routes: Vec<(String, router::Route)> = model_routes
1480 .iter()
1481 .map(|r| {
1482 (
1483 r.hint.clone(),
1484 router::Route {
1485 provider_name: r.model_provider.clone(),
1486 model: r.model.clone(),
1487 },
1488 )
1489 })
1490 .collect();
1491
1492 Ok(Box::new(router::RouterModelProvider::new(
1493 primary_name,
1494 model_providers,
1495 routes,
1496 default_model.to_string(),
1497 )))
1498}
1499
1500pub struct ModelProviderInfo {
1502 pub name: &'static str,
1504 pub display_name: &'static str,
1506 pub local: bool,
1508}
1509
1510#[must_use]
1514pub fn default_model_provider_url(name: &str) -> Option<&'static str> {
1515 use factory::CompatFamilySpec;
1516 use zeroclaw_config::schema::{
1517 Ai21ModelProviderConfig, AihubmixModelProviderConfig, AnyscaleModelProviderConfig,
1518 AstraiModelProviderConfig, BaichuanModelProviderConfig, BasetenModelProviderConfig,
1519 CerebrasModelProviderConfig, CloudflareModelProviderConfig, CohereModelProviderConfig,
1520 DeepinfraModelProviderConfig, DeepseekModelProviderConfig, DoubaoModelProviderConfig,
1521 FireworksModelProviderConfig, FriendliModelProviderConfig, HuggingfaceModelProviderConfig,
1522 HyperbolicModelProviderConfig, LeptonModelProviderConfig, LitellmModelProviderConfig,
1523 MistralModelProviderConfig, NebiusModelProviderConfig, NovitaModelProviderConfig,
1524 NscaleModelProviderConfig, OpencodeModelProviderConfig, PerplexityModelProviderConfig,
1525 RekaModelProviderConfig, SambanovaModelProviderConfig, SglangModelProviderConfig,
1526 SiliconflowModelProviderConfig, SyntheticModelProviderConfig, TogetherModelProviderConfig,
1527 VercelModelProviderConfig, VllmModelProviderConfig, YiModelProviderConfig,
1528 };
1529
1530 match name {
1531 "anthropic" => Some(anthropic::BASE_URL),
1532 "openai" => Some(openai::BASE_URL),
1533 "openrouter" => Some(openrouter::BASE_URL),
1534 "ollama" => Some(ollama::BASE_URL),
1535 "telnyx" => Some(telnyx::BASE_URL),
1536 "gemini" => Some(gemini::BASE_URL),
1537 "vercel" => Some(<VercelModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1538 "cloudflare" => Some(<CloudflareModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1539 "synthetic" => Some(<SyntheticModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1540 "opencode" => Some(<OpencodeModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1541 "doubao" => Some(<DoubaoModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1542 "mistral" => Some(<MistralModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1543 "deepseek" => Some(<DeepseekModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1544 "together" => Some(<TogetherModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1545 "fireworks" => Some(<FireworksModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1546 "novita" => Some(<NovitaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1547 "perplexity" => Some(<PerplexityModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1548 "cohere" => Some(<CohereModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1549 "sglang" => Some(<SglangModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1550 "vllm" => Some(<VllmModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1551 "astrai" => Some(<AstraiModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1552 "siliconflow" => Some(<SiliconflowModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1553 "aihubmix" => Some(<AihubmixModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1554 "litellm" => Some(<LitellmModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1555 "cerebras" => Some(<CerebrasModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1556 "sambanova" => Some(<SambanovaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1557 "hyperbolic" => Some(<HyperbolicModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1558 "deepinfra" => Some(<DeepinfraModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1559 "huggingface" => Some(<HuggingfaceModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1560 "ai21" => Some(<Ai21ModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1561 "reka" => Some(<RekaModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1562 "baseten" => Some(<BasetenModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1563 "nscale" => Some(<NscaleModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1564 "anyscale" => Some(<AnyscaleModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1565 "nebius" => Some(<NebiusModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1566 "friendli" => Some(<FriendliModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1567 "lepton" => Some(<LeptonModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1568 "baichuan" => Some(<BaichuanModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1569 "yi" => Some(<YiModelProviderConfig as CompatFamilySpec>::DEFAULT_URL),
1570 _ => None,
1571 }
1572}
1573
1574pub fn list_model_providers() -> Vec<ModelProviderInfo> {
1579 vec![
1580 ModelProviderInfo {
1582 name: "openrouter",
1583 display_name: "OpenRouter",
1584 local: false,
1585 },
1586 ModelProviderInfo {
1587 name: "anthropic",
1588 display_name: "Anthropic",
1589 local: false,
1590 },
1591 ModelProviderInfo {
1592 name: "openai",
1593 display_name: "OpenAI",
1594 local: false,
1595 },
1596 ModelProviderInfo {
1597 name: "telnyx",
1598 display_name: "Telnyx",
1599 local: false,
1600 },
1601 ModelProviderInfo {
1602 name: "azure",
1603 display_name: "Azure OpenAI",
1604 local: false,
1605 },
1606 ModelProviderInfo {
1607 name: "ollama",
1608 display_name: "Ollama",
1609 local: true,
1610 },
1611 ModelProviderInfo {
1612 name: "gemini",
1613 display_name: "Google Gemini",
1614 local: false,
1615 },
1616 ModelProviderInfo {
1618 name: "venice",
1619 display_name: "Venice",
1620 local: false,
1621 },
1622 ModelProviderInfo {
1623 name: "vercel",
1624 display_name: "Vercel AI Gateway",
1625 local: false,
1626 },
1627 ModelProviderInfo {
1628 name: "cloudflare",
1629 display_name: "Cloudflare AI",
1630 local: false,
1631 },
1632 ModelProviderInfo {
1633 name: "moonshot",
1634 display_name: "Moonshot",
1635 local: false,
1636 },
1637 ModelProviderInfo {
1638 name: "synthetic",
1639 display_name: "Synthetic",
1640 local: false,
1641 },
1642 ModelProviderInfo {
1643 name: "opencode",
1644 display_name: "OpenCode",
1645 local: false,
1646 },
1647 ModelProviderInfo {
1648 name: "zai",
1649 display_name: "Z.AI",
1650 local: false,
1651 },
1652 ModelProviderInfo {
1653 name: "glm",
1654 display_name: "GLM (Zhipu)",
1655 local: false,
1656 },
1657 ModelProviderInfo {
1658 name: "minimax",
1659 display_name: "MiniMax",
1660 local: false,
1661 },
1662 ModelProviderInfo {
1663 name: "bedrock",
1664 display_name: "Amazon Bedrock",
1665 local: false,
1666 },
1667 ModelProviderInfo {
1668 name: "qianfan",
1669 display_name: "Qianfan (Baidu)",
1670 local: false,
1671 },
1672 ModelProviderInfo {
1673 name: "doubao",
1674 display_name: "Doubao (Volcengine)",
1675 local: false,
1676 },
1677 ModelProviderInfo {
1678 name: "qwen",
1679 display_name: "Qwen (DashScope / Qwen Code OAuth)",
1680 local: false,
1681 },
1682 ModelProviderInfo {
1683 name: "groq",
1684 display_name: "Groq",
1685 local: false,
1686 },
1687 ModelProviderInfo {
1688 name: "mistral",
1689 display_name: "Mistral",
1690 local: false,
1691 },
1692 ModelProviderInfo {
1693 name: "xai",
1694 display_name: "xAI (Grok)",
1695 local: false,
1696 },
1697 ModelProviderInfo {
1698 name: "deepseek",
1699 display_name: "DeepSeek",
1700 local: false,
1701 },
1702 ModelProviderInfo {
1703 name: "together",
1704 display_name: "Together AI",
1705 local: false,
1706 },
1707 ModelProviderInfo {
1708 name: "fireworks",
1709 display_name: "Fireworks AI",
1710 local: false,
1711 },
1712 ModelProviderInfo {
1713 name: "novita",
1714 display_name: "Novita AI",
1715 local: false,
1716 },
1717 ModelProviderInfo {
1718 name: "perplexity",
1719 display_name: "Perplexity",
1720 local: false,
1721 },
1722 ModelProviderInfo {
1723 name: "cohere",
1724 display_name: "Cohere",
1725 local: false,
1726 },
1727 ModelProviderInfo {
1728 name: "copilot",
1729 display_name: "GitHub Copilot",
1730 local: false,
1731 },
1732 ModelProviderInfo {
1733 name: "gemini_cli",
1734 display_name: "Gemini CLI",
1735 local: true,
1736 },
1737 ModelProviderInfo {
1738 name: "kilocli",
1739 display_name: "KiloCLI",
1740 local: true,
1741 },
1742 ModelProviderInfo {
1743 name: "lmstudio",
1744 display_name: "LM Studio",
1745 local: true,
1746 },
1747 ModelProviderInfo {
1748 name: "llamacpp",
1749 display_name: "llama.cpp server",
1750 local: true,
1751 },
1752 ModelProviderInfo {
1753 name: "sglang",
1754 display_name: "SGLang",
1755 local: true,
1756 },
1757 ModelProviderInfo {
1758 name: "vllm",
1759 display_name: "vLLM",
1760 local: true,
1761 },
1762 ModelProviderInfo {
1763 name: "osaurus",
1764 display_name: "Osaurus",
1765 local: true,
1766 },
1767 ModelProviderInfo {
1768 name: "nvidia",
1769 display_name: "NVIDIA NIM",
1770 local: false,
1771 },
1772 ModelProviderInfo {
1773 name: "siliconflow",
1774 display_name: "SiliconFlow",
1775 local: false,
1776 },
1777 ModelProviderInfo {
1778 name: "aihubmix",
1779 display_name: "AiHubMix",
1780 local: false,
1781 },
1782 ModelProviderInfo {
1783 name: "litellm",
1784 display_name: "LiteLLM",
1785 local: false,
1786 },
1787 ModelProviderInfo {
1788 name: "atomic_chat",
1789 display_name: "Atomic Chat",
1790 local: true,
1791 },
1792 ModelProviderInfo {
1794 name: "cerebras",
1795 display_name: "Cerebras",
1796 local: false,
1797 },
1798 ModelProviderInfo {
1799 name: "sambanova",
1800 display_name: "SambaNova",
1801 local: false,
1802 },
1803 ModelProviderInfo {
1804 name: "hyperbolic",
1805 display_name: "Hyperbolic",
1806 local: false,
1807 },
1808 ModelProviderInfo {
1810 name: "deepinfra",
1811 display_name: "DeepInfra",
1812 local: false,
1813 },
1814 ModelProviderInfo {
1815 name: "huggingface",
1816 display_name: "Hugging Face",
1817 local: false,
1818 },
1819 ModelProviderInfo {
1820 name: "ai21",
1821 display_name: "AI21 Labs",
1822 local: false,
1823 },
1824 ModelProviderInfo {
1825 name: "reka",
1826 display_name: "Reka",
1827 local: false,
1828 },
1829 ModelProviderInfo {
1830 name: "baseten",
1831 display_name: "Baseten",
1832 local: false,
1833 },
1834 ModelProviderInfo {
1835 name: "nscale",
1836 display_name: "Nscale",
1837 local: false,
1838 },
1839 ModelProviderInfo {
1840 name: "anyscale",
1841 display_name: "Anyscale",
1842 local: false,
1843 },
1844 ModelProviderInfo {
1845 name: "nebius",
1846 display_name: "Nebius AI Studio",
1847 local: false,
1848 },
1849 ModelProviderInfo {
1850 name: "friendli",
1851 display_name: "Friendli AI",
1852 local: false,
1853 },
1854 ModelProviderInfo {
1855 name: "lepton",
1856 display_name: "Lepton AI",
1857 local: false,
1858 },
1859 ModelProviderInfo {
1861 name: "stepfun",
1862 display_name: "Stepfun",
1863 local: false,
1864 },
1865 ModelProviderInfo {
1866 name: "baichuan",
1867 display_name: "Baichuan",
1868 local: false,
1869 },
1870 ModelProviderInfo {
1871 name: "yi",
1872 display_name: "01.AI (Yi)",
1873 local: false,
1874 },
1875 ModelProviderInfo {
1876 name: "hunyuan",
1877 display_name: "Tencent Hunyuan",
1878 local: false,
1879 },
1880 ModelProviderInfo {
1882 name: "ovh",
1883 display_name: "OVHcloud AI Endpoints",
1884 local: false,
1885 },
1886 ModelProviderInfo {
1887 name: "avian",
1888 display_name: "Avian",
1889 local: false,
1890 },
1891 ]
1892}
1893
1894#[cfg(test)]
1896pub mod test_util {
1897 use std::sync::{Mutex, MutexGuard, OnceLock};
1898
1899 pub fn env_lock() -> MutexGuard<'static, ()> {
1901 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1902 LOCK.get_or_init(|| Mutex::new(()))
1903 .lock()
1904 .expect("env lock poisoned")
1905 }
1906
1907 pub struct EnvGuard {
1910 key: String,
1911 original: Option<String>,
1912 }
1913
1914 impl EnvGuard {
1915 pub fn set(key: &str, value: Option<&str>) -> Self {
1916 let original = std::env::var(key).ok();
1917 match value {
1918 Some(v) => unsafe { std::env::set_var(key, v) },
1920 None => unsafe { std::env::remove_var(key) },
1922 }
1923 Self {
1924 key: key.to_string(),
1925 original,
1926 }
1927 }
1928 }
1929
1930 impl Drop for EnvGuard {
1931 fn drop(&mut self) {
1932 if let Some(original) = self.original.as_deref() {
1933 unsafe { std::env::set_var(&self.key, original) };
1935 } else {
1936 unsafe { std::env::remove_var(&self.key) };
1938 }
1939 }
1940 }
1941}
1942
1943#[cfg(test)]
1944mod tests {
1945 use super::test_util::{EnvGuard, env_lock};
1946 use super::*;
1947
1948 #[test]
1953 fn provider_http_client_trusts_both_webpki_and_native_roots() {
1954 let _client = reqwest::Client::builder()
1955 .tls_built_in_webpki_certs(true)
1956 .tls_built_in_native_certs(true)
1957 .build()
1958 .expect("client builder should succeed with both root sets enabled");
1959 }
1960
1961 #[test]
1962 fn resolve_provider_credential_returns_trimmed_override() {
1963 let resolved = resolve_model_provider_credential("openrouter", Some(" explicit-key "));
1964 assert_eq!(resolved, Some("explicit-key".to_string()));
1965 }
1966
1967 #[test]
1968 fn resolve_provider_credential_filters_empty_override() {
1969 assert!(resolve_model_provider_credential("openrouter", Some(" ")).is_none());
1970 assert!(resolve_model_provider_credential("openrouter", None).is_none());
1971 }
1972
1973 #[test]
1980 fn resolve_qwen_oauth_context_prefers_explicit_override() {
1981 let _env_lock = env_lock();
1982 let context = resolve_qwen_oauth_context(Some(" explicit-qwen-token "));
1983 assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
1984 assert!(context.base_url.is_none());
1985 }
1986
1987 #[test]
1988 fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
1989 let _env_lock = env_lock();
1990 let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-file", std::process::id());
1991 let creds_dir = PathBuf::from(&fake_home).join(".qwen");
1992 std::fs::create_dir_all(&creds_dir).unwrap();
1993 let creds_path = creds_dir.join("oauth_creds.json");
1994 std::fs::write(
1995 &creds_path,
1996 r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
1997 )
1998 .unwrap();
1999
2000 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2001
2002 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2003
2004 assert_eq!(context.credential.as_deref(), Some("cached-token"));
2005 assert_eq!(
2006 context.base_url.as_deref(),
2007 Some("https://resource.example.com/v1")
2008 );
2009 }
2010
2011 #[test]
2012 fn resolve_qwen_oauth_context_returns_none_without_cache() {
2013 let _env_lock = env_lock();
2014 let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-empty", std::process::id());
2015 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2016
2017 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2018 assert!(context.credential.is_none());
2019 }
2020
2021 #[test]
2022 fn regional_alias_predicates_cover_expected_variants() {
2023 assert!(is_moonshot_alias("moonshot"));
2024 assert!(is_moonshot_alias("kimi-global"));
2025 assert!(is_glm_alias("glm"));
2026 assert!(is_glm_alias("bigmodel"));
2027 assert!(is_minimax_alias("minimax-io"));
2028 assert!(is_minimax_alias("minimaxi"));
2029 assert!(is_minimax_alias("minimax-oauth"));
2030 assert!(is_minimax_alias("minimax-portal-cn"));
2031 assert!(is_qwen_alias("dashscope"));
2032 assert!(is_qwen_alias("qwen-us"));
2033 assert!(is_qwen_alias("qwen-code"));
2034 assert!(is_qwen_oauth_alias("qwen-code"));
2035 assert!(is_qwen_oauth_alias("qwen_oauth"));
2036 assert!(is_zai_alias("z.ai"));
2037 assert!(is_zai_alias("zai-cn"));
2038 assert!(is_qianfan_alias("qianfan"));
2039 assert!(is_qianfan_alias("baidu"));
2040 assert!(is_doubao_alias("doubao"));
2041 assert!(is_doubao_alias("volcengine"));
2042 assert!(is_doubao_alias("ark"));
2043 assert!(is_doubao_alias("doubao-cn"));
2044
2045 assert!(!is_moonshot_alias("openrouter"));
2046 assert!(!is_glm_alias("openai"));
2047 assert!(!is_qwen_alias("gemini"));
2048 assert!(!is_zai_alias("anthropic"));
2049 assert!(!is_qianfan_alias("cohere"));
2050 assert!(!is_doubao_alias("deepseek"));
2051 }
2052
2053 #[test]
2064 fn factory_openrouter() {
2065 assert!(create_model_provider("openrouter", Some("provider-test-credential")).is_ok());
2066 assert!(create_model_provider("openrouter", None).is_ok());
2067 }
2068
2069 #[test]
2070 fn factory_anthropic() {
2071 assert!(create_model_provider("anthropic", Some("provider-test-credential")).is_ok());
2072 }
2073
2074 #[test]
2075 fn factory_openai() {
2076 assert!(create_model_provider("openai", Some("provider-test-credential")).is_ok());
2077 }
2078
2079 #[test]
2080 fn factory_openai_codex() {
2081 let options = ModelProviderRuntimeOptions::default();
2088 assert!(create_model_provider_with_options("openai-codex", None, &options).is_ok());
2089 }
2090
2091 #[test]
2092 fn factory_ollama() {
2093 assert!(create_model_provider("ollama", None).is_ok());
2094 assert!(create_model_provider("ollama", Some("dummy")).is_ok());
2096 assert!(create_model_provider("ollama", Some("any-value-here")).is_ok());
2097 }
2098
2099 #[test]
2100 fn factory_gemini() {
2101 assert!(create_model_provider("gemini", Some("test-key")).is_ok());
2102 assert!(create_model_provider("gemini", None).is_ok());
2104 }
2105
2106 #[test]
2107 fn factory_telnyx() {
2108 assert!(create_model_provider("telnyx", Some("test-key")).is_ok());
2109 assert!(create_model_provider("telnyx", None).is_ok());
2110 }
2111
2112 #[test]
2115 fn factory_venice() {
2116 let model_provider = create_model_provider("venice", Some("vn-key")).unwrap();
2117 assert!(
2118 !model_provider.capabilities().native_tool_calling,
2119 "Venice should use prompt-guided tools, not native tool calling"
2120 );
2121 }
2122
2123 #[test]
2124 fn factory_vercel() {
2125 assert!(create_model_provider("vercel", Some("key")).is_ok());
2126 }
2127
2128 #[test]
2129 fn vercel_gateway_base_url_matches_public_gateway_endpoint() {
2130 assert_eq!(
2131 VERCEL_AI_GATEWAY_BASE_URL,
2132 "https://ai-gateway.vercel.sh/v1"
2133 );
2134 }
2135
2136 #[test]
2137 fn factory_cloudflare() {
2138 assert!(create_model_provider("cloudflare", Some("key")).is_ok());
2139 }
2140
2141 #[test]
2142 fn factory_moonshot() {
2143 assert!(create_model_provider("moonshot", Some("key")).is_ok());
2144 }
2145
2146 #[test]
2147 fn factory_kimi_code_supports_vision() {
2148 for alias in ["kimi-code", "kimi_coding", "kimi_for_coding"] {
2149 let provider = create_model_provider(alias, Some("key"))
2150 .expect("legacy kimi-code alias should build");
2151 assert!(
2152 provider.supports_vision(),
2153 "alias `{alias}` should report vision capability"
2154 );
2155 assert_eq!(
2156 moonshot_code_base_url(),
2157 "https://api.moonshot.cn/coder/v1",
2158 "alias `{alias}` should resolve to the Moonshot code endpoint"
2159 );
2160 }
2161 }
2162
2163 #[test]
2164 fn factory_kimi_code_preserves_semantics_with_url_overrides() {
2165 let custom_url = "https://proxy.example.test/v1";
2166
2167 let provider = create_model_provider_with_url("kimi-code", Some("key"), Some(custom_url))
2168 .expect("legacy kimi-code alias with custom URL should build");
2169 assert!(provider.supports_vision());
2170
2171 let provider = create_model_provider_with_options(
2172 "kimi-code",
2173 Some("key"),
2174 &ModelProviderRuntimeOptions {
2175 provider_api_url: Some(custom_url.to_string()),
2176 ..ModelProviderRuntimeOptions::default()
2177 },
2178 )
2179 .expect("legacy kimi-code alias with options URL should build");
2180 assert!(provider.supports_vision());
2181 }
2182
2183 #[test]
2184 fn moonshot_code_endpoint_supports_vision() {
2185 use zeroclaw_config::schema::{Config, MoonshotEndpoint, MoonshotModelProviderConfig};
2186
2187 let mut config = Config::default();
2188 config.providers.models.moonshot.insert(
2189 "code".to_string(),
2190 MoonshotModelProviderConfig {
2191 endpoint: MoonshotEndpoint::Code,
2192 ..MoonshotModelProviderConfig::default()
2193 },
2194 );
2195 let options = provider_runtime_options_for_alias(&config, "moonshot", "code");
2196 assert_eq!(
2197 options.provider_api_url.as_deref(),
2198 Some(moonshot_code_base_url())
2199 );
2200
2201 let provider =
2202 create_model_provider_for_alias(&config, "moonshot", "code", Some("key"), &options)
2203 .expect("moonshot code endpoint should build");
2204 assert!(provider.supports_vision());
2205 }
2206
2207 #[test]
2208 fn factory_synthetic() {
2209 assert!(create_model_provider("synthetic", Some("key")).is_ok());
2210 }
2211
2212 #[test]
2213 fn factory_opencode() {
2214 assert!(create_model_provider("opencode", Some("key")).is_ok());
2215 }
2216
2217 #[test]
2218 fn factory_opencode_go() {}
2219
2220 #[test]
2221 fn factory_zai() {
2222 assert!(create_model_provider("zai", Some("key")).is_ok());
2223 }
2224
2225 #[test]
2226 fn factory_glm() {
2227 assert!(create_model_provider("glm", Some("key")).is_ok());
2228 }
2229
2230 #[test]
2231 fn factory_minimax() {
2232 assert!(create_model_provider("minimax", Some("key")).is_ok());
2233 }
2234
2235 #[test]
2236 fn factory_minimax_supports_native_tool_calling() {
2237 let minimax =
2238 create_model_provider("minimax", Some("key")).expect("model_provider should resolve");
2239 assert!(minimax.supports_native_tools());
2240 }
2241
2242 #[test]
2243 fn factory_bedrock() {
2244 assert!(create_model_provider("bedrock", None).is_ok());
2246 assert!(create_model_provider("bedrock", Some("ignored")).is_ok());
2248 }
2249
2250 #[test]
2251 fn factory_qianfan() {
2252 assert!(create_model_provider("qianfan", Some("key")).is_ok());
2253 }
2254
2255 #[test]
2256 fn factory_doubao() {
2257 assert!(create_model_provider("doubao", Some("key")).is_ok());
2258 }
2259
2260 #[test]
2261 fn factory_qwen() {
2262 assert!(create_model_provider("qwen", Some("key")).is_ok());
2263 }
2264
2265 #[test]
2266 fn qwen_provider_supports_vision() {
2267 let model_provider =
2268 create_model_provider("qwen", Some("key")).expect("qwen model_provider should build");
2269 assert!(model_provider.supports_vision());
2270 }
2271
2272 #[test]
2273 fn glm_provider_supports_vision() {
2274 for alias in ["glm", "zhipu", "glm-cn", "zhipu-cn"] {
2278 let provider =
2279 create_model_provider(alias, Some("id.secret")).expect("glm provider should build");
2280 assert!(
2281 provider.supports_vision(),
2282 "alias `{alias}` should report vision capability"
2283 );
2284 }
2285 }
2286
2287 #[test]
2288 fn factory_lmstudio() {
2289 assert!(create_model_provider("lmstudio", Some("key")).is_ok());
2290 assert!(create_model_provider("lmstudio", None).is_ok());
2291 }
2292
2293 #[test]
2294 fn factory_llamacpp() {
2295 assert!(create_model_provider("llamacpp", Some("key")).is_ok());
2296 assert!(create_model_provider("llamacpp", None).is_ok());
2297 }
2298
2299 #[test]
2300 fn factory_sglang() {
2301 assert!(create_model_provider("sglang", None).is_ok());
2302 assert!(create_model_provider("sglang", Some("key")).is_ok());
2303 }
2304
2305 #[test]
2306 fn factory_vllm() {
2307 assert!(create_model_provider("vllm", None).is_ok());
2308 assert!(create_model_provider("vllm", Some("key")).is_ok());
2309 }
2310
2311 #[test]
2312 fn factory_osaurus() {
2313 assert!(create_model_provider("osaurus", None).is_ok());
2315 assert!(create_model_provider("osaurus", Some("custom-key")).is_ok());
2317 }
2318
2319 #[test]
2320 fn factory_osaurus_uses_default_key_when_none() {
2321 let p = create_model_provider_with_url("osaurus", None, None);
2324 assert!(p.is_ok());
2325 }
2326
2327 #[test]
2328 fn factory_osaurus_custom_url() {
2329 let p = create_model_provider_with_url(
2331 "osaurus",
2332 Some("key"),
2333 Some("http://192.168.1.100:1337/v1"),
2334 );
2335 assert!(p.is_ok());
2336 }
2337
2338 #[test]
2339 fn resolve_provider_credential_osaurus_env_deleted() {}
2340
2341 #[test]
2342 fn resolve_provider_credential_doubao_volcengine_env_deleted() {}
2343
2344 #[test]
2345 fn resolve_provider_credential_aihubmix_env_deleted() {}
2346
2347 #[test]
2348 fn resolve_provider_credential_siliconflow_env_deleted() {}
2349
2350 #[test]
2351 fn factory_aihubmix() {
2352 assert!(create_model_provider("aihubmix", Some("key")).is_ok());
2353 }
2354
2355 #[test]
2356 fn factory_siliconflow() {
2357 assert!(create_model_provider("siliconflow", Some("key")).is_ok());
2358 }
2359
2360 #[test]
2361 fn factory_codex_dispatches_via_requires_openai_auth_flag() {
2362 let options = ModelProviderRuntimeOptions::default();
2368 assert!(create_model_provider_with_options("openai-codex", None, &options).is_ok());
2369 }
2370
2371 #[test]
2372 fn factory_atomic_chat() {
2373 assert!(create_model_provider("atomic_chat", Some("key")).is_ok());
2374 }
2375
2376 #[test]
2377 fn factory_atomic_chat_allows_missing_key() {
2378 assert!(create_model_provider("atomic_chat", None).is_ok());
2381 }
2382
2383 #[test]
2384 fn atomic_chat_is_listed_as_local_provider() {
2385 let providers = list_model_providers();
2386 let provider = providers
2387 .iter()
2388 .find(|p| p.name == "atomic_chat")
2389 .expect("atomic_chat must be listed");
2390 assert!(provider.local, "atomic_chat must be a local provider");
2391 }
2392
2393 #[test]
2396 fn factory_groq() {
2397 assert!(create_model_provider("groq", Some("key")).is_ok());
2398 }
2399
2400 #[test]
2401 fn factory_groq_disables_native_tools_by_default() {
2402 let model_provider = create_model_provider_with_options(
2405 "groq",
2406 Some("key"),
2407 &ModelProviderRuntimeOptions::default(),
2408 )
2409 .expect("groq factory must succeed");
2410 assert!(
2411 !model_provider.supports_native_tools(),
2412 "Groq must default to text-fallback for llama-family compatibility"
2413 );
2414 }
2415
2416 #[test]
2417 fn factory_groq_honors_native_tools_override_true() {
2418 let options = ModelProviderRuntimeOptions {
2422 native_tools: Some(true),
2423 ..Default::default()
2424 };
2425 let model_provider = create_model_provider_with_options("groq", Some("key"), &options)
2426 .expect("groq factory must succeed");
2427 assert!(
2428 model_provider.supports_native_tools(),
2429 "Groq with `native_tools = true` must enable native tool calling"
2430 );
2431 }
2432
2433 #[test]
2434 fn factory_groq_native_tools_override_false_keeps_disable() {
2435 let options = ModelProviderRuntimeOptions {
2439 native_tools: Some(false),
2440 ..Default::default()
2441 };
2442 let model_provider = create_model_provider_with_options("groq", Some("key"), &options)
2443 .expect("groq factory must succeed");
2444 assert!(
2445 !model_provider.supports_native_tools(),
2446 "Groq with explicit `native_tools = false` must remain text-fallback"
2447 );
2448 }
2449
2450 #[test]
2451 fn provider_runtime_options_from_config_propagates_native_tools() {
2452 use zeroclaw_config::schema::{GroqModelProviderConfig, ModelProviderConfig};
2458 let mut config = zeroclaw_config::schema::Config::default();
2459 config.providers.models.groq.insert(
2460 "default".to_string(),
2461 GroqModelProviderConfig {
2462 base: ModelProviderConfig {
2463 uri: Some("https://api.groq.com/openai/v1".to_string()),
2464 native_tools: Some(true),
2465 ..Default::default()
2466 },
2467 },
2468 );
2469
2470 let options = provider_runtime_options_from_config(&config);
2471 assert_eq!(
2472 options.native_tools,
2473 Some(true),
2474 "native_tools must propagate from the active model_provider entry to runtime options"
2475 );
2476 }
2477
2478 #[test]
2479 fn provider_runtime_options_from_config_propagates_provider_kind() {
2480 use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
2481 let mut config = zeroclaw_config::schema::Config::default();
2482 config.providers.models.openai.insert(
2483 "primary".to_string(),
2484 OpenAIModelProviderConfig {
2485 base: ModelProviderConfig {
2486 kind: Some("openai-compatible".to_string()),
2487 uri: Some("http://primary.example/v1".to_string()),
2488 ..Default::default()
2489 },
2490 },
2491 );
2492
2493 let options = provider_runtime_options_from_config(&config);
2494 assert_eq!(options.provider_kind.as_deref(), Some("openai-compatible"));
2495 assert_eq!(
2496 options.provider_api_url.as_deref(),
2497 Some("http://primary.example/v1")
2498 );
2499 }
2500
2501 #[test]
2502 fn route_provider_options_clear_primary_only_state_for_bare_routes() {
2503 let inherited = ModelProviderRuntimeOptions {
2504 provider_kind: Some("openai-compatible".to_string()),
2505 provider_api_url: Some("http://primary.example/v1".to_string()),
2506 ..Default::default()
2507 };
2508 let config = zeroclaw_config::schema::Config::default();
2509
2510 let route_options = options_for_provider_ref(&config, "openrouter", &inherited);
2511
2512 assert_eq!(route_options.provider_kind, None);
2513 assert_eq!(route_options.provider_api_url, None);
2514 }
2515
2516 #[test]
2517 fn routed_bare_provider_does_not_inherit_primary_endpoint() {
2518 use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
2519 let mut config = zeroclaw_config::schema::Config::default();
2520 config.providers.models.openai.insert(
2521 "primary".to_string(),
2522 OpenAIModelProviderConfig {
2523 base: ModelProviderConfig {
2524 kind: Some("openai-compatible".to_string()),
2525 uri: Some("http://primary.example/v1".to_string()),
2526 ..Default::default()
2527 },
2528 },
2529 );
2530 let options = provider_runtime_options_for_alias(&config, "openai", "primary");
2531 assert_eq!(
2532 options.provider_api_url.as_deref(),
2533 Some("http://primary.example/v1")
2534 );
2535
2536 let route_options = options_for_provider_ref(&config, "openrouter", &options);
2537
2538 assert_eq!(route_options.provider_kind, None);
2539 assert_eq!(route_options.provider_api_url, None);
2540 }
2541
2542 #[test]
2543 fn routed_primary_alias_kind_does_not_leak_to_canonical_route_provider() {
2544 use zeroclaw_config::schema::{
2545 ModelProviderConfig, ModelRouteConfig, OpenAIModelProviderConfig,
2546 OpenRouterModelProviderConfig,
2547 };
2548
2549 let mut config = zeroclaw_config::schema::Config::default();
2550 config.providers.models.openai.insert(
2551 "primary".to_string(),
2552 OpenAIModelProviderConfig {
2553 base: ModelProviderConfig {
2554 kind: Some("openai-compatible".to_string()),
2555 uri: Some("http://primary.example/v1".to_string()),
2556 ..Default::default()
2557 },
2558 },
2559 );
2560 config.providers.models.openrouter.insert(
2561 "route".to_string(),
2562 OpenRouterModelProviderConfig {
2563 base: ModelProviderConfig::default(),
2564 },
2565 );
2566 let options = provider_runtime_options_for_alias(&config, "openai", "primary");
2567 assert_eq!(options.provider_kind.as_deref(), Some("openai-compatible"));
2568
2569 let provider = create_routed_model_provider_with_options(
2570 &config,
2571 "openai.primary",
2572 Some("sk-test"),
2573 None,
2574 &config.reliability,
2575 &[ModelRouteConfig {
2576 hint: "fast".to_string(),
2577 model_provider: "openrouter.route".to_string(),
2578 model: "openrouter/auto".to_string(),
2579 api_key: None,
2580 }],
2581 "gpt-test",
2582 &options,
2583 )
2584 .expect("primary alias kind should build without poisoning route provider kind");
2585
2586 assert!(
2587 provider.supports_vision(),
2588 "primary openai-compatible provider should remain the router default"
2589 );
2590 }
2591
2592 #[test]
2593 fn factory_mistral() {
2594 assert!(create_model_provider("mistral", Some("key")).is_ok());
2595 }
2596
2597 #[test]
2598 fn factory_xai() {
2599 assert!(create_model_provider("xai", Some("key")).is_ok());
2600 }
2601
2602 #[test]
2603 fn factory_deepseek() {
2604 assert!(create_model_provider("deepseek", Some("key")).is_ok());
2605 }
2606
2607 #[test]
2608 fn deepseek_provider_keeps_vision_disabled() {
2609 let model_provider = create_model_provider("deepseek", Some("key"))
2610 .expect("deepseek model_provider should build");
2611 assert!(!model_provider.supports_vision());
2612 }
2613
2614 #[test]
2615 fn factory_together() {
2616 assert!(create_model_provider("together", Some("key")).is_ok());
2617 }
2618
2619 #[test]
2620 fn factory_fireworks() {
2621 assert!(create_model_provider("fireworks", Some("key")).is_ok());
2622 }
2623
2624 #[test]
2625 fn factory_novita() {
2626 assert!(create_model_provider("novita", Some("key")).is_ok());
2627 }
2628
2629 #[test]
2630 fn factory_perplexity() {
2631 assert!(create_model_provider("perplexity", Some("key")).is_ok());
2632 }
2633
2634 #[test]
2635 fn factory_cohere() {
2636 assert!(create_model_provider("cohere", Some("key")).is_ok());
2637 }
2638
2639 #[test]
2640 fn factory_copilot() {
2641 assert!(create_model_provider("copilot", Some("key")).is_ok());
2642 }
2643
2644 #[test]
2645 fn factory_gemini_cli() {}
2646
2647 #[test]
2648 fn factory_kilocli() {
2649 assert!(create_model_provider("kilocli", None).is_ok());
2650 }
2651
2652 #[test]
2653 fn factory_nvidia() {
2654 assert!(create_model_provider("nvidia", Some("nvapi-test")).is_ok());
2655 }
2656
2657 #[test]
2660 fn factory_astrai() {
2661 assert!(create_model_provider("astrai", Some("sk-astrai-test")).is_ok());
2662 }
2663
2664 #[test]
2665 fn factory_avian() {
2666 assert!(create_model_provider("avian", Some("sk-avian-test")).is_ok());
2667 }
2668
2669 #[test]
2670 fn factory_deepmyst() {
2671 assert!(create_model_provider("deepmyst", Some("key")).is_ok());
2672 }
2673
2674 #[test]
2675 fn resolve_provider_credential_deepmyst_env_deleted() {}
2676
2677 #[test]
2694 fn factory_custom_with_resolved_uri() {
2695 let options = ModelProviderRuntimeOptions {
2696 provider_api_url: Some("https://my-llm.example.com".to_string()),
2697 ..ModelProviderRuntimeOptions::default()
2698 };
2699 assert!(create_model_provider_with_options("custom", Some("key"), &options).is_ok());
2700 }
2701
2702 #[test]
2703 fn factory_custom_without_uri_errors() {
2704 match create_model_provider("custom", Some("key")) {
2705 Err(e) => assert!(
2706 e.to_string().contains("requires `uri`"),
2707 "Expected `uri` error, got: {e}"
2708 ),
2709 Ok(_) => {
2710 panic!("Expected error when custom model model_provider has no URI configured")
2711 }
2712 }
2713 }
2714
2715 #[test]
2718 fn factory_unknown_provider_errors() {
2719 let p = create_model_provider("nonexistent", None);
2720 assert!(p.is_err());
2721 let msg = p.err().unwrap().to_string();
2722 assert!(msg.contains("Unknown model_provider family"));
2723 assert!(msg.contains("nonexistent"));
2724 }
2725
2726 #[test]
2727 fn factory_empty_name_errors() {
2728 assert!(create_model_provider("", None).is_err());
2729 }
2730
2731 #[test]
2732 fn ollama_with_custom_url() {
2733 let model_provider =
2734 create_model_provider_with_url("ollama", None, Some("http://10.100.2.32:11434"));
2735 assert!(model_provider.is_ok());
2736 }
2737
2738 #[test]
2739 fn ollama_cloud_with_custom_url() {
2740 let model_provider = create_model_provider_with_url(
2741 "ollama",
2742 Some("ollama-key"),
2743 Some("https://ollama.com"),
2744 );
2745 assert!(model_provider.is_ok());
2746 }
2747
2748 #[tokio::test]
2749 async fn ollama_private_remote_cloud_request_omits_auth_and_preserves_model() {
2750 use axum::{
2751 Json, Router,
2752 extract::State,
2753 http::{HeaderMap, StatusCode},
2754 routing::post,
2755 };
2756 use serde_json::{Value, json};
2757 use std::sync::{Arc, Mutex};
2758
2759 type Capture = Arc<Mutex<Option<(Option<String>, String)>>>;
2760
2761 async fn capture_chat_request(
2762 State(capture): State<Capture>,
2763 headers: HeaderMap,
2764 Json(body): Json<Value>,
2765 ) -> (StatusCode, Json<Value>) {
2766 let auth = headers
2767 .get("authorization")
2768 .and_then(|value| value.to_str().ok())
2769 .map(str::to_string);
2770 let model = body
2771 .get("model")
2772 .and_then(Value::as_str)
2773 .unwrap_or_default()
2774 .to_string();
2775 *capture.lock().expect("capture lock poisoned") = Some((auth, model));
2776 (
2777 StatusCode::OK,
2778 Json(json!({
2779 "choices": [{"message": {"content": "ok"}}]
2780 })),
2781 )
2782 }
2783
2784 let capture: Capture = Arc::new(Mutex::new(None));
2785 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2786 .await
2787 .expect("bind test server");
2788 let addr = listener.local_addr().expect("test server addr");
2789 let app = Router::new()
2790 .route("/v1/chat/completions", post(capture_chat_request))
2791 .with_state(capture.clone());
2792 let server = tokio::spawn(async move {
2793 axum::serve(listener, app).await.expect("serve test server");
2794 });
2795
2796 let base_url = format!("http://{addr}");
2797 let model_provider = create_model_provider_with_url("ollama", None, Some(&base_url))
2798 .expect("ollama provider should build");
2799 let response = model_provider
2800 .chat_with_system(None, "hello", "qwen3:cloud", Some(0.7))
2801 .await
2802 .expect("chat request should succeed");
2803
2804 assert_eq!(response, "ok");
2805 let (auth, model) = capture
2806 .lock()
2807 .expect("capture lock poisoned")
2808 .take()
2809 .expect("server should capture request");
2810 assert_eq!(auth, None);
2811 assert_eq!(model, "qwen3:cloud");
2812 server.abort();
2813 }
2814
2815 #[tokio::test]
2816 async fn ollama_private_remote_lists_models_without_auth() {
2817 use axum::{Json, Router, extract::State, http::HeaderMap, routing::get};
2818 use serde_json::{Value, json};
2819 use std::sync::{Arc, Mutex};
2820
2821 type Capture = Arc<Mutex<Option<Option<String>>>>;
2822
2823 async fn capture_models_request(
2824 State(capture): State<Capture>,
2825 headers: HeaderMap,
2826 ) -> Json<Value> {
2827 let auth = headers
2828 .get("authorization")
2829 .and_then(|value| value.to_str().ok())
2830 .map(str::to_string);
2831 *capture.lock().expect("capture lock poisoned") = Some(auth);
2832 Json(json!({
2833 "data": [{"id": "qwen3:cloud"}]
2834 }))
2835 }
2836
2837 let capture: Capture = Arc::new(Mutex::new(None));
2838 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2839 .await
2840 .expect("bind test server");
2841 let addr = listener.local_addr().expect("test server addr");
2842 let app = Router::new()
2843 .route("/v1/models", get(capture_models_request))
2844 .with_state(capture.clone());
2845 let server = tokio::spawn(async move {
2846 axum::serve(listener, app).await.expect("serve test server");
2847 });
2848
2849 let base_url = format!("http://{addr}");
2850 let model_provider = create_model_provider_with_url("ollama", None, Some(&base_url))
2851 .expect("ollama provider should build");
2852 let models = model_provider
2853 .list_models()
2854 .await
2855 .expect("model list should succeed");
2856
2857 assert_eq!(models, vec!["qwen3:cloud".to_string()]);
2858 let auth = capture
2859 .lock()
2860 .expect("capture lock poisoned")
2861 .take()
2862 .expect("server should capture request");
2863 assert_eq!(auth, None);
2864 server.abort();
2865 }
2866
2867 #[test]
2868 fn factory_all_canonical_model_providers_create_successfully() {
2869 let canonical = [
2875 "openrouter",
2876 "anthropic",
2877 "openai",
2878 "ollama",
2879 "gemini",
2880 "venice",
2881 "vercel",
2882 "cloudflare",
2883 "moonshot",
2884 "synthetic",
2885 "opencode",
2886 "zai",
2887 "glm",
2888 "minimax",
2889 "bedrock",
2890 "qianfan",
2891 "doubao",
2892 "qwen",
2893 "lmstudio",
2894 "llamacpp",
2895 "sglang",
2896 "vllm",
2897 "osaurus",
2898 "telnyx",
2899 "groq",
2900 "mistral",
2901 "xai",
2902 "deepseek",
2903 "together",
2904 "fireworks",
2905 "novita",
2906 "perplexity",
2907 "cohere",
2908 "copilot",
2909 "gemini_cli",
2910 "kilocli",
2911 "nvidia",
2912 "astrai",
2913 "avian",
2914 "ovh",
2915 ];
2916 for name in canonical {
2917 assert!(
2918 create_model_provider(name, Some("test-key")).is_ok(),
2919 "Canonical model model_provider '{name}' should create successfully"
2920 );
2921 }
2922 }
2923
2924 #[test]
2925 fn listed_model_providers_have_unique_canonical_ids() {
2926 let model_providers = list_model_providers();
2927 let mut canonical_ids = std::collections::HashSet::new();
2928
2929 for model_provider in model_providers {
2930 assert!(
2931 canonical_ids.insert(model_provider.name),
2932 "Duplicate canonical model model_provider id: {}",
2933 model_provider.name
2934 );
2935 }
2936 }
2937
2938 #[test]
2939 fn listed_model_providers_are_constructible() {
2940 for model_provider in list_model_providers() {
2941 if model_provider.name == "azure" {
2947 continue;
2948 }
2949 if model_provider.name == "custom" {
2952 continue;
2953 }
2954 assert!(
2955 create_model_provider(model_provider.name, Some("provider-test-credential"))
2956 .is_ok(),
2957 "Canonical model model_provider id should be constructible: {}",
2958 model_provider.name
2959 );
2960 }
2961 }
2962
2963 #[test]
2966 fn format_error_chain_includes_sources_and_sanitizes_output() {
2967 #[derive(Debug)]
2968 struct ChainError {
2969 message: &'static str,
2970 source: Option<Box<dyn std::error::Error + 'static>>,
2971 }
2972
2973 impl std::fmt::Display for ChainError {
2974 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2975 write!(f, "{}", self.message)
2976 }
2977 }
2978
2979 impl std::error::Error for ChainError {
2980 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2981 self.source.as_deref()
2982 }
2983 }
2984
2985 let error = ChainError {
2986 message: "outer context",
2987 source: Some(Box::new(ChainError {
2988 message: "middle context",
2989 source: Some(Box::new(ChainError {
2990 message: "inner source leaked sk-1234567890abcdef",
2991 source: None,
2992 })),
2993 })),
2994 };
2995
2996 let result = format_error_chain(&error);
2997
2998 assert!(result.contains("outer context"));
2999 assert!(result.contains("middle context"));
3000 assert!(result.contains("inner source leaked [REDACTED]"));
3001 assert!(!result.contains("sk-1234567890abcdef"));
3002 }
3003
3004 #[test]
3005 fn sanitize_scrubs_sk_prefix() {
3006 let input = "request failed: sk-1234567890abcdef";
3007 let out = sanitize_api_error(input);
3008 assert!(!out.contains("sk-1234567890abcdef"));
3009 assert!(out.contains("[REDACTED]"));
3010 }
3011
3012 #[test]
3013 fn sanitize_scrubs_multiple_prefixes() {
3014 let input = "keys sk-abcdef xoxb-12345 xoxp-67890";
3015 let out = sanitize_api_error(input);
3016 assert!(!out.contains("sk-abcdef"));
3017 assert!(!out.contains("xoxb-12345"));
3018 assert!(!out.contains("xoxp-67890"));
3019 }
3020
3021 #[test]
3022 fn sanitize_short_prefix_then_real_key() {
3023 let input = "error with sk- prefix and key sk-1234567890";
3024 let result = sanitize_api_error(input);
3025 assert!(!result.contains("sk-1234567890"));
3026 assert!(result.contains("[REDACTED]"));
3027 }
3028
3029 #[test]
3030 fn sanitize_sk_proj_comment_then_real_key() {
3031 let input = "note: sk- then sk-proj-abc123def456";
3032 let result = sanitize_api_error(input);
3033 assert!(!result.contains("sk-proj-abc123def456"));
3034 assert!(result.contains("[REDACTED]"));
3035 }
3036
3037 #[test]
3038 fn sanitize_keeps_bare_prefix() {
3039 let input = "only prefix sk- present";
3040 let result = sanitize_api_error(input);
3041 assert!(result.contains("sk-"));
3042 }
3043
3044 #[test]
3045 fn sanitize_handles_json_wrapped_key() {
3046 let input = r#"{"error":"invalid key sk-abc123xyz"}"#;
3047 let result = sanitize_api_error(input);
3048 assert!(!result.contains("sk-abc123xyz"));
3049 }
3050
3051 #[test]
3052 fn sanitize_handles_delimiter_boundaries() {
3053 let input = "bad token xoxb-abc123}; next";
3054 let result = sanitize_api_error(input);
3055 assert!(!result.contains("xoxb-abc123"));
3056 assert!(result.contains("};"));
3057 }
3058
3059 #[test]
3060 fn sanitize_truncates_long_error() {
3061 let long = "a".repeat(600);
3062 let result = sanitize_api_error(&long);
3063 assert!(result.len() <= 503);
3064 assert!(result.ends_with("..."));
3065 }
3066
3067 #[test]
3068 fn sanitize_truncates_after_scrub() {
3069 let input = format!("{} sk-abcdef123456 {}", "a".repeat(290), "b".repeat(290));
3070 let result = sanitize_api_error(&input);
3071 assert!(!result.contains("sk-abcdef123456"));
3072 assert!(result.len() <= 503);
3073 }
3074
3075 #[test]
3076 fn sanitize_preserves_unicode_boundaries() {
3077 let input = format!("{} sk-abcdef123", "hello🙂".repeat(80));
3078 let result = sanitize_api_error(&input);
3079 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
3080 assert!(!result.contains("sk-abcdef123"));
3081 }
3082
3083 #[test]
3084 fn sanitize_no_secret_no_change() {
3085 let input = "simple upstream timeout";
3086 let result = sanitize_api_error(input);
3087 assert_eq!(result, input);
3088 }
3089
3090 #[test]
3091 fn scrub_github_personal_access_token() {
3092 let input = "auth failed with token ghp_abc123def456";
3093 let result = scrub_secret_patterns(input);
3094 assert_eq!(result, "auth failed with token [REDACTED]");
3095 }
3096
3097 #[test]
3098 fn scrub_github_oauth_token() {
3099 let input = "Bearer gho_1234567890abcdef";
3100 let result = scrub_secret_patterns(input);
3101 assert_eq!(result, "Bearer [REDACTED]");
3102 }
3103
3104 #[test]
3105 fn scrub_github_user_token() {
3106 let input = "token ghu_sessiontoken123";
3107 let result = scrub_secret_patterns(input);
3108 assert_eq!(result, "token [REDACTED]");
3109 }
3110
3111 #[test]
3112 fn scrub_github_fine_grained_pat() {
3113 let input = "failed: github_pat_11AABBC_xyzzy789";
3114 let result = scrub_secret_patterns(input);
3115 assert_eq!(result, "failed: [REDACTED]");
3116 }
3117
3118 #[test]
3121 fn api_key_prefix_cross_provider_mismatch() {
3122 assert_eq!(
3124 check_api_key_prefix("openrouter", "sk-ant-api03-xyz"),
3125 Some("anthropic")
3126 );
3127 assert_eq!(
3129 check_api_key_prefix("anthropic", "sk-or-v1-xyz"),
3130 Some("openrouter")
3131 );
3132 assert_eq!(
3134 check_api_key_prefix("openai", "sk-ant-xyz"),
3135 Some("anthropic")
3136 );
3137 assert_eq!(check_api_key_prefix("openai", "gsk_xyz"), Some("groq"));
3139 }
3140
3141 #[test]
3142 fn api_key_prefix_correct_match() {
3143 assert_eq!(check_api_key_prefix("anthropic", "sk-ant-api03-xyz"), None);
3144 assert_eq!(check_api_key_prefix("openrouter", "sk-or-v1-xyz"), None);
3145 assert_eq!(check_api_key_prefix("openai", "sk-proj-xyz"), None);
3146 assert_eq!(check_api_key_prefix("groq", "gsk_xyz"), None);
3147 }
3148
3149 #[test]
3150 fn api_key_prefix_unknown_provider_skips() {
3151 assert_eq!(check_api_key_prefix("deepseek", "sk-ant-xyz"), None);
3153 assert_eq!(check_api_key_prefix("ollama", "anything"), None);
3154 }
3155
3156 #[test]
3157 fn api_key_prefix_unknown_key_format_skips() {
3158 assert_eq!(check_api_key_prefix("openai", "my-custom-key-123"), None);
3160 assert_eq!(check_api_key_prefix("anthropic", "some-random-key"), None);
3161 }
3162
3163 #[test]
3164 fn provider_runtime_options_default_has_empty_extra_headers() {
3165 let options = ModelProviderRuntimeOptions::default();
3166 assert!(options.extra_headers.is_empty());
3167 }
3168
3169 #[test]
3170 fn provider_runtime_options_extra_headers_passed_through() {
3171 let mut extra_headers = std::collections::HashMap::new();
3172 extra_headers.insert("X-Title".to_string(), "zeroclaw".to_string());
3173 let options = ModelProviderRuntimeOptions {
3174 extra_headers,
3175 ..ModelProviderRuntimeOptions::default()
3176 };
3177 assert_eq!(options.extra_headers.len(), 1);
3178 assert_eq!(options.extra_headers.get("X-Title").unwrap(), "zeroclaw");
3179 }
3180
3181 #[test]
3182 fn ollama_uses_resolved_url_from_runtime_options() {
3183 let model_provider =
3187 create_model_provider_with_url("ollama", None, Some("http://config-ollama:11434"));
3188 assert!(model_provider.is_ok());
3189 }
3190
3191 fn config_with_two_anthropic_aliases() -> zeroclaw_config::schema::Config {
3197 use zeroclaw_config::schema::{
3198 AliasedAgentConfig, AnthropicModelProviderConfig, Config, ModelProviderConfig,
3199 };
3200 let mut config = Config::default();
3201 let default_alias = AnthropicModelProviderConfig {
3202 base: ModelProviderConfig {
3203 model: Some("claude-default".into()),
3204 api_key: Some("default-key".into()),
3205 uri: Some("https://api.default.example/v1/messages".into()),
3206 ..ModelProviderConfig::default()
3207 },
3208 };
3209 let work_alias = AnthropicModelProviderConfig {
3210 base: ModelProviderConfig {
3211 model: Some("claude-work".into()),
3212 api_key: Some("work-key".into()),
3213 uri: Some("https://work-proxy.example/v1/v1/anthropic/messages".into()),
3214 ..ModelProviderConfig::default()
3215 },
3216 };
3217 config
3218 .providers
3219 .models
3220 .anthropic
3221 .insert("default".to_string(), default_alias);
3222 config
3223 .providers
3224 .models
3225 .anthropic
3226 .insert("work".to_string(), work_alias);
3227 let work_agent = AliasedAgentConfig {
3228 model_provider: "anthropic.work".into(),
3229 ..AliasedAgentConfig::default()
3230 };
3231 config.agents.insert("work_agent".to_string(), work_agent);
3232 let default_agent = AliasedAgentConfig {
3233 model_provider: "anthropic.default".into(),
3234 ..AliasedAgentConfig::default()
3235 };
3236 config
3237 .agents
3238 .insert("default_agent".to_string(), default_agent);
3239 config
3240 }
3241
3242 #[test]
3243 fn provider_runtime_options_for_agent_resolves_alias_specific_uri() {
3244 let config = config_with_two_anthropic_aliases();
3245 let work = provider_runtime_options_for_agent(&config, "work_agent");
3246 let dflt = provider_runtime_options_for_agent(&config, "default_agent");
3247
3248 assert_eq!(
3249 work.provider_api_url.as_deref(),
3250 Some("https://work-proxy.example/v1/v1/anthropic/messages"),
3251 "work agent must resolve to the work alias's full uri (with merged path)"
3252 );
3253 assert_eq!(
3254 dflt.provider_api_url.as_deref(),
3255 Some("https://api.default.example/v1/messages"),
3256 "default agent must resolve to the default alias's full uri (with merged path)"
3257 );
3258 }
3259
3260 #[test]
3261 fn provider_runtime_options_for_agent_falls_back_to_first_provider_when_unknown_agent() {
3262 let config = config_with_two_anthropic_aliases();
3263 let opts = provider_runtime_options_for_agent(&config, "nonexistent");
3264 let url = opts.provider_api_url.as_deref().unwrap_or("");
3267 assert!(
3268 url == "https://work-proxy.example/v1/v1/anthropic/messages"
3269 || url == "https://api.default.example/v1/messages",
3270 "fallback must resolve to one of the configured anthropic aliases; got `{url}`"
3271 );
3272 }
3273
3274 #[test]
3275 fn ollama_alias_tuning_fields_populate_tuning_struct() {
3276 let alias = zeroclaw_config::schema::OllamaModelProviderConfig {
3277 num_ctx: Some(16384),
3278 num_predict: Some(4096),
3279 temperature_override: Some(0.5),
3280 ..zeroclaw_config::schema::OllamaModelProviderConfig::default()
3281 };
3282
3283 let tuning = ollama::OllamaTuning::from_runtime_overrides(
3284 alias.num_ctx,
3285 alias.num_predict,
3286 alias.temperature_override,
3287 );
3288 assert_eq!(tuning.num_ctx, 16384);
3289 assert_eq!(tuning.num_predict, 4096);
3290 assert_eq!(tuning.temperature_override, Some(0.5));
3291
3292 let provider = ollama::OllamaModelProvider::new("test", None, None).with_tuning(tuning);
3293 assert_eq!(provider.tuning(), tuning);
3294 }
3295
3296 #[test]
3297 fn ollama_alias_tuning_defaults_leave_temperature_override_unset() {
3298 let alias = zeroclaw_config::schema::OllamaModelProviderConfig::default();
3299 let tuning = ollama::OllamaTuning::from_runtime_overrides(
3300 alias.num_ctx,
3301 alias.num_predict,
3302 alias.temperature_override,
3303 );
3304 assert!(tuning.temperature_override.is_none());
3305 assert_eq!(tuning.num_ctx, ollama::OLLAMA_DEFAULT_NUM_CTX);
3306 assert_eq!(tuning.num_predict, ollama::OLLAMA_DEFAULT_NUM_PREDICT);
3307 }
3308
3309 fn config_with_openai_alias() -> zeroclaw_config::schema::Config {
3310 use zeroclaw_config::schema::{
3311 AliasedAgentConfig, Config, ModelProviderConfig, OpenAIModelProviderConfig,
3312 };
3313 let mut config = Config::default();
3314 let alias = OpenAIModelProviderConfig {
3315 base: ModelProviderConfig {
3316 api_key: Some("openai-alias-key".into()),
3317 model: Some("gpt-4o".into()),
3318 ..ModelProviderConfig::default()
3319 },
3320 };
3321 config
3322 .providers
3323 .models
3324 .openai
3325 .insert("alias".to_string(), alias);
3326 let agent = AliasedAgentConfig {
3327 model_provider: "openai.alias".into(),
3328 ..AliasedAgentConfig::default()
3329 };
3330 config.agents.insert("test_agent".to_string(), agent);
3331 config
3332 }
3333
3334 #[test]
3335 fn routed_model_provider_credential_precedence_uses_route_key_first() {
3336 let config = config_with_openai_alias();
3337 let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3338 let routes = [zeroclaw_config::schema::ModelRouteConfig {
3339 hint: "test".into(),
3340 model_provider: "openai.alias".into(),
3341 model: "gpt-4o".into(),
3342 api_key: Some("route-key".into()),
3343 }];
3344
3345 let result = create_routed_model_provider_with_options(
3346 &config,
3347 "openai.alias",
3348 Some("fallback-key"),
3349 None,
3350 &reliability,
3351 &routes,
3352 "gpt-4o",
3353 &ModelProviderRuntimeOptions::default(),
3354 );
3355
3356 assert!(
3357 result.is_ok(),
3358 "route-key should succeed: {}",
3359 result.err().unwrap()
3360 );
3361 }
3362
3363 #[test]
3364 fn routed_model_provider_credential_precedence_uses_config_entry_key() {
3365 let config = config_with_openai_alias();
3366 let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3367 let routes = [zeroclaw_config::schema::ModelRouteConfig {
3369 hint: "test".into(),
3370 model_provider: "openai.alias".into(),
3371 model: "gpt-4o".into(),
3372 api_key: None,
3373 }];
3374
3375 let result = create_routed_model_provider_with_options(
3376 &config,
3377 "openai.alias",
3378 Some("fallback-key"),
3379 None,
3380 &reliability,
3381 &routes,
3382 "gpt-4o",
3383 &ModelProviderRuntimeOptions::default(),
3384 );
3385
3386 assert!(
3387 result.is_ok(),
3388 "config-entry key should succeed: {}",
3389 result.err().unwrap()
3390 );
3391 }
3392
3393 #[test]
3394 fn routed_model_provider_credential_precedence_falls_back_to_api_key_param() {
3395 let config = zeroclaw_config::schema::Config::default(); let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3397 let routes = [zeroclaw_config::schema::ModelRouteConfig {
3399 hint: "test".into(),
3400 model_provider: "openai".into(),
3401 model: "gpt-4o".into(),
3402 api_key: None,
3403 }];
3404
3405 let result = create_routed_model_provider_with_options(
3406 &config,
3407 "openai",
3408 Some("fallback-key"),
3409 None,
3410 &reliability,
3411 &routes,
3412 "gpt-4o",
3413 &ModelProviderRuntimeOptions::default(),
3414 );
3415
3416 assert!(
3417 result.is_ok(),
3418 "fallback-key should succeed: {}",
3419 result.err().unwrap()
3420 );
3421 }
3422
3423 #[test]
3424 fn routed_model_provider_credential_skips_config_entry_for_non_dotted_name() {
3425 let config = zeroclaw_config::schema::Config::default();
3426 let reliability = zeroclaw_config::schema::ReliabilityConfig::default();
3427 let routes = [zeroclaw_config::schema::ModelRouteConfig {
3430 hint: "test".into(),
3431 model_provider: "openai".into(),
3432 model: "gpt-4o".into(),
3433 api_key: None,
3434 }];
3435
3436 let result = create_routed_model_provider_with_options(
3437 &config,
3438 "openai",
3439 Some("direct-key"),
3440 None,
3441 &reliability,
3442 &routes,
3443 "gpt-4o",
3444 &ModelProviderRuntimeOptions::default(),
3445 );
3446
3447 assert!(
3448 result.is_ok(),
3449 "direct-key should succeed: {}",
3450 result.err().unwrap()
3451 );
3452 }
3453
3454 #[test]
3463 fn dotted_alias_routes_openai_codex_via_requires_openai_auth() {
3464 use zeroclaw_config::schema::{ModelProviderConfig, OpenAIModelProviderConfig};
3465
3466 let arbitrary_alias = "qwertfoozp";
3468
3469 let mut config = zeroclaw_config::schema::Config::default();
3470 config.providers.models.openai.insert(
3471 arbitrary_alias.to_string(),
3472 OpenAIModelProviderConfig {
3473 base: ModelProviderConfig {
3474 requires_openai_auth: true,
3475 ..Default::default()
3476 },
3477 },
3478 );
3479
3480 let result = factory::dispatch_family_factory(
3485 Some(&config),
3486 "openai",
3487 arbitrary_alias,
3488 None,
3489 None,
3490 &ModelProviderRuntimeOptions::default(),
3491 );
3492 assert!(
3493 result.is_ok(),
3494 "codex alias construction should succeed: {}",
3495 result.err().unwrap()
3496 );
3497 assert!(
3498 result.unwrap().capabilities().native_tool_calling,
3499 "openai.{arbitrary_alias} with requires_openai_auth=true must route to \
3500 OpenAiCodexModelProvider (native_tool_calling=true), not the standard provider"
3501 );
3502 }
3503}