1pub mod anthropic;
20pub mod bedrock;
21pub mod compatible;
22pub mod copilot;
23pub mod gemini;
24pub mod ollama;
25pub mod openai;
26pub mod openai_codex;
27pub mod openrouter;
28pub mod reliable;
29pub mod router;
30pub mod telnyx;
31pub mod traits;
32
33#[allow(unused_imports)]
34pub use traits::{
35 ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ProviderCapabilityError,
36 ToolCall, ToolResultMessage,
37};
38
39use crate::auth::AuthService;
40use compatible::{AuthStyle, OpenAiCompatibleProvider};
41use reliable::ReliableProvider;
42use serde::Deserialize;
43use std::path::PathBuf;
44
45const MAX_API_ERROR_CHARS: usize = 200;
46const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
47const MINIMAX_CN_BASE_URL: &str = "https://api.minimaxi.com/v1";
48const MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT: &str = "https://api.minimax.io/oauth/token";
49const MINIMAX_OAUTH_CN_TOKEN_ENDPOINT: &str = "https://api.minimaxi.com/oauth/token";
50const MINIMAX_OAUTH_PLACEHOLDER: &str = "minimax-oauth";
51const MINIMAX_OAUTH_CN_PLACEHOLDER: &str = "minimax-oauth-cn";
52const MINIMAX_OAUTH_TOKEN_ENV: &str = "MINIMAX_OAUTH_TOKEN";
53const MINIMAX_API_KEY_ENV: &str = "MINIMAX_API_KEY";
54const MINIMAX_OAUTH_REFRESH_TOKEN_ENV: &str = "MINIMAX_OAUTH_REFRESH_TOKEN";
55const MINIMAX_OAUTH_REGION_ENV: &str = "MINIMAX_OAUTH_REGION";
56const MINIMAX_OAUTH_CLIENT_ID_ENV: &str = "MINIMAX_OAUTH_CLIENT_ID";
57const MINIMAX_OAUTH_DEFAULT_CLIENT_ID: &str = "78257093-7e40-4613-99e0-527b14b39113";
58const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
59const GLM_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
60const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1";
61const MOONSHOT_CN_BASE_URL: &str = "https://api.moonshot.cn/v1";
62const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
63const QWEN_INTL_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
64const QWEN_US_BASE_URL: &str = "https://dashscope-us.aliyuncs.com/compatible-mode/v1";
65const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
66const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
67const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
68const QWEN_OAUTH_TOKEN_ENV: &str = "QWEN_OAUTH_TOKEN";
69const QWEN_OAUTH_REFRESH_TOKEN_ENV: &str = "QWEN_OAUTH_REFRESH_TOKEN";
70const QWEN_OAUTH_RESOURCE_URL_ENV: &str = "QWEN_OAUTH_RESOURCE_URL";
71const QWEN_OAUTH_CLIENT_ID_ENV: &str = "QWEN_OAUTH_CLIENT_ID";
72const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
73const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
74const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
75const ZAI_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
76const VERCEL_AI_GATEWAY_BASE_URL: &str = "https://ai-gateway.vercel.sh/v1";
77
78pub(crate) fn is_minimax_intl_alias(name: &str) -> bool {
79 matches!(
80 name,
81 "minimax"
82 | "minimax-intl"
83 | "minimax-io"
84 | "minimax-global"
85 | "minimax-oauth"
86 | "minimax-portal"
87 | "minimax-oauth-global"
88 | "minimax-portal-global"
89 )
90}
91
92pub(crate) fn is_minimax_cn_alias(name: &str) -> bool {
93 matches!(
94 name,
95 "minimax-cn" | "minimaxi" | "minimax-oauth-cn" | "minimax-portal-cn"
96 )
97}
98
99pub(crate) fn is_minimax_alias(name: &str) -> bool {
100 is_minimax_intl_alias(name) || is_minimax_cn_alias(name)
101}
102
103pub(crate) fn is_glm_global_alias(name: &str) -> bool {
104 matches!(name, "glm" | "zhipu" | "glm-global" | "zhipu-global")
105}
106
107pub(crate) fn is_glm_cn_alias(name: &str) -> bool {
108 matches!(name, "glm-cn" | "zhipu-cn" | "bigmodel")
109}
110
111pub(crate) fn is_glm_alias(name: &str) -> bool {
112 is_glm_global_alias(name) || is_glm_cn_alias(name)
113}
114
115pub(crate) fn is_moonshot_intl_alias(name: &str) -> bool {
116 matches!(
117 name,
118 "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
119 )
120}
121
122pub(crate) fn is_moonshot_cn_alias(name: &str) -> bool {
123 matches!(name, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn")
124}
125
126pub(crate) fn is_moonshot_alias(name: &str) -> bool {
127 is_moonshot_intl_alias(name) || is_moonshot_cn_alias(name)
128}
129
130pub(crate) fn is_qwen_cn_alias(name: &str) -> bool {
131 matches!(name, "qwen" | "dashscope" | "qwen-cn" | "dashscope-cn")
132}
133
134pub(crate) fn is_qwen_intl_alias(name: &str) -> bool {
135 matches!(
136 name,
137 "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
138 )
139}
140
141pub(crate) fn is_qwen_us_alias(name: &str) -> bool {
142 matches!(name, "qwen-us" | "dashscope-us")
143}
144
145pub(crate) fn is_qwen_oauth_alias(name: &str) -> bool {
146 matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
147}
148
149pub(crate) fn is_qwen_alias(name: &str) -> bool {
150 is_qwen_cn_alias(name)
151 || is_qwen_intl_alias(name)
152 || is_qwen_us_alias(name)
153 || is_qwen_oauth_alias(name)
154}
155
156pub(crate) fn is_zai_global_alias(name: &str) -> bool {
157 matches!(name, "zai" | "z.ai" | "zai-global" | "z.ai-global")
158}
159
160pub(crate) fn is_zai_cn_alias(name: &str) -> bool {
161 matches!(name, "zai-cn" | "z.ai-cn")
162}
163
164pub(crate) fn is_zai_alias(name: &str) -> bool {
165 is_zai_global_alias(name) || is_zai_cn_alias(name)
166}
167
168pub(crate) fn is_qianfan_alias(name: &str) -> bool {
169 matches!(name, "qianfan" | "baidu")
170}
171
172pub(crate) fn is_doubao_alias(name: &str) -> bool {
173 matches!(name, "doubao" | "volcengine" | "ark" | "doubao-cn")
174}
175
176#[derive(Clone, Copy, Debug)]
177enum MinimaxOauthRegion {
178 Global,
179 Cn,
180}
181
182impl MinimaxOauthRegion {
183 fn token_endpoint(self) -> &'static str {
184 match self {
185 Self::Global => MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT,
186 Self::Cn => MINIMAX_OAUTH_CN_TOKEN_ENDPOINT,
187 }
188 }
189}
190
191#[derive(Debug, Deserialize)]
192struct MinimaxOauthRefreshResponse {
193 #[serde(default)]
194 status: Option<String>,
195 #[serde(default)]
196 access_token: Option<String>,
197 #[serde(default)]
198 base_resp: Option<MinimaxOauthBaseResponse>,
199}
200
201#[derive(Debug, Deserialize)]
202struct MinimaxOauthBaseResponse {
203 #[serde(default)]
204 status_msg: Option<String>,
205}
206
207#[derive(Clone, Deserialize, Default)]
208struct QwenOauthCredentials {
209 #[serde(default)]
210 access_token: Option<String>,
211 #[serde(default)]
212 refresh_token: Option<String>,
213 #[serde(default)]
214 resource_url: Option<String>,
215 #[serde(default)]
216 expiry_date: Option<i64>,
217}
218
219impl std::fmt::Debug for QwenOauthCredentials {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("QwenOauthCredentials")
222 .field("resource_url", &self.resource_url)
223 .field("expiry_date", &self.expiry_date)
224 .finish_non_exhaustive()
225 }
226}
227
228#[derive(Debug, Deserialize)]
229struct QwenOauthTokenResponse {
230 #[serde(default)]
231 access_token: Option<String>,
232 #[serde(default)]
233 refresh_token: Option<String>,
234 #[serde(default)]
235 expires_in: Option<i64>,
236 #[serde(default)]
237 resource_url: Option<String>,
238 #[serde(default)]
239 error: Option<String>,
240 #[serde(default)]
241 error_description: Option<String>,
242}
243
244#[derive(Clone, Default)]
245struct QwenOauthProviderContext {
246 credential: Option<String>,
247 base_url: Option<String>,
248}
249
250impl std::fmt::Debug for QwenOauthProviderContext {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 f.debug_struct("QwenOauthProviderContext")
253 .field("base_url", &self.base_url)
254 .finish_non_exhaustive()
255 }
256}
257
258fn read_non_empty_env(name: &str) -> Option<String> {
259 std::env::var(name)
260 .ok()
261 .map(|value| value.trim().to_string())
262 .filter(|value| !value.is_empty())
263}
264
265fn is_minimax_oauth_placeholder(value: &str) -> bool {
266 value.eq_ignore_ascii_case(MINIMAX_OAUTH_PLACEHOLDER)
267 || value.eq_ignore_ascii_case(MINIMAX_OAUTH_CN_PLACEHOLDER)
268}
269
270fn minimax_oauth_region(name: &str) -> MinimaxOauthRegion {
271 if let Some(region) = read_non_empty_env(MINIMAX_OAUTH_REGION_ENV) {
272 let normalized = region.to_ascii_lowercase();
273 if matches!(normalized.as_str(), "cn" | "china") {
274 return MinimaxOauthRegion::Cn;
275 }
276 if matches!(normalized.as_str(), "global" | "intl" | "international") {
277 return MinimaxOauthRegion::Global;
278 }
279 }
280
281 if is_minimax_cn_alias(name) {
282 MinimaxOauthRegion::Cn
283 } else {
284 MinimaxOauthRegion::Global
285 }
286}
287
288fn minimax_oauth_client_id() -> String {
289 read_non_empty_env(MINIMAX_OAUTH_CLIENT_ID_ENV)
290 .unwrap_or_else(|| MINIMAX_OAUTH_DEFAULT_CLIENT_ID.to_string())
291}
292
293fn qwen_oauth_client_id() -> String {
294 read_non_empty_env(QWEN_OAUTH_CLIENT_ID_ENV)
295 .unwrap_or_else(|| QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string())
296}
297
298fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
299 std::env::var_os("HOME")
300 .map(PathBuf::from)
301 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
302 .map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
303}
304
305fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
306 let trimmed = raw.trim().trim_end_matches('/');
307 if trimmed.is_empty() {
308 return None;
309 }
310
311 let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
312 trimmed.to_string()
313 } else {
314 format!("https://{trimmed}")
315 };
316
317 let normalized = with_scheme.trim_end_matches('/').to_string();
318 if normalized.ends_with("/v1") {
319 Some(normalized)
320 } else {
321 Some(format!("{normalized}/v1"))
322 }
323}
324
325fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
326 let path = qwen_oauth_credentials_file_path()?;
327 let content = std::fs::read_to_string(path).ok()?;
328 serde_json::from_str::<QwenOauthCredentials>(&content).ok()
329}
330
331fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
332 if raw < 10_000_000_000 {
333 raw.saturating_mul(1000)
334 } else {
335 raw
336 }
337}
338
339fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
340 let Some(expiry) = credentials.expiry_date else {
341 return false;
342 };
343
344 let expiry_millis = normalized_qwen_expiry_millis(expiry);
345 let now_millis = std::time::SystemTime::now()
346 .duration_since(std::time::UNIX_EPOCH)
347 .ok()
348 .and_then(|duration| i64::try_from(duration.as_millis()).ok())
349 .unwrap_or(i64::MAX);
350
351 expiry_millis <= now_millis.saturating_add(30_000)
352}
353
354fn refresh_qwen_oauth_access_token(refresh_token: &str) -> anyhow::Result<QwenOauthCredentials> {
355 let client_id = qwen_oauth_client_id();
356 let client = reqwest::blocking::Client::builder()
357 .timeout(std::time::Duration::from_secs(15))
358 .connect_timeout(std::time::Duration::from_secs(5))
359 .build()
360 .unwrap_or_else(|_| reqwest::blocking::Client::new());
361
362 let response = client
363 .post(QWEN_OAUTH_TOKEN_ENDPOINT)
364 .header("Content-Type", "application/x-www-form-urlencoded")
365 .header("Accept", "application/json")
366 .form(&[
367 ("grant_type", "refresh_token"),
368 ("refresh_token", refresh_token),
369 ("client_id", client_id.as_str()),
370 ])
371 .send()
372 .map_err(|error| anyhow::anyhow!("Qwen OAuth refresh request failed: {error}"))?;
373
374 let status = response.status();
375 let body = response
376 .text()
377 .unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
378
379 let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
380
381 if !status.is_success() {
382 let detail = parsed
383 .as_ref()
384 .and_then(|payload| payload.error_description.as_deref())
385 .or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
386 .filter(|msg| !msg.trim().is_empty())
387 .unwrap_or(body.as_str());
388 anyhow::bail!("Qwen OAuth refresh failed (HTTP {status}): {detail}");
389 }
390
391 let payload =
392 parsed.ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response is not JSON"))?;
393
394 if let Some(error_code) = payload
395 .error
396 .as_deref()
397 .filter(|value| !value.trim().is_empty())
398 {
399 let detail = payload.error_description.as_deref().unwrap_or(error_code);
400 anyhow::bail!("Qwen OAuth refresh failed: {detail}");
401 }
402
403 let access_token = payload
404 .access_token
405 .as_deref()
406 .map(str::trim)
407 .filter(|token| !token.is_empty())
408 .ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response missing access_token"))?
409 .to_string();
410
411 let expiry_date = payload.expires_in.and_then(|seconds| {
412 let now_secs = std::time::SystemTime::now()
413 .duration_since(std::time::UNIX_EPOCH)
414 .ok()
415 .and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
416 now_secs
417 .checked_add(seconds)
418 .and_then(|unix_secs| unix_secs.checked_mul(1000))
419 });
420
421 Ok(QwenOauthCredentials {
422 access_token: Some(access_token),
423 refresh_token: payload
424 .refresh_token
425 .as_deref()
426 .map(str::trim)
427 .filter(|value| !value.is_empty())
428 .map(ToString::to_string),
429 resource_url: payload
430 .resource_url
431 .as_deref()
432 .map(str::trim)
433 .filter(|value| !value.is_empty())
434 .map(ToString::to_string),
435 expiry_date,
436 })
437}
438
439fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
440 let override_value = credential_override
441 .map(str::trim)
442 .filter(|value| !value.is_empty());
443 let placeholder_requested = override_value
444 .map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
445 .unwrap_or(false);
446
447 if let Some(explicit) = override_value {
448 if !placeholder_requested {
449 return QwenOauthProviderContext {
450 credential: Some(explicit.to_string()),
451 base_url: None,
452 };
453 }
454 }
455
456 let mut cached = read_qwen_oauth_cached_credentials();
457
458 let env_token = read_non_empty_env(QWEN_OAUTH_TOKEN_ENV);
459 let env_refresh_token = read_non_empty_env(QWEN_OAUTH_REFRESH_TOKEN_ENV);
460 let env_resource_url = read_non_empty_env(QWEN_OAUTH_RESOURCE_URL_ENV);
461
462 if env_token.is_none() {
463 let refresh_token = env_refresh_token.clone().or_else(|| {
464 cached
465 .as_ref()
466 .and_then(|credentials| credentials.refresh_token.clone())
467 });
468
469 let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
470 || cached
471 .as_ref()
472 .and_then(|credentials| credentials.access_token.as_deref())
473 .is_none_or(|value| value.trim().is_empty());
474
475 if should_refresh {
476 if let Some(refresh_token) = refresh_token.as_deref() {
477 match refresh_qwen_oauth_access_token(refresh_token) {
478 Ok(refreshed) => {
479 cached = Some(refreshed);
480 }
481 Err(error) => {
482 tracing::warn!(error = %error, "Qwen OAuth refresh failed");
483 }
484 }
485 }
486 }
487 }
488
489 let mut credential = env_token.or_else(|| {
490 cached
491 .as_ref()
492 .and_then(|credentials| credentials.access_token.clone())
493 });
494 credential = credential
495 .as_deref()
496 .map(str::trim)
497 .filter(|value| !value.is_empty())
498 .map(ToString::to_string);
499
500 if credential.is_none() && !placeholder_requested {
501 credential = read_non_empty_env("DASHSCOPE_API_KEY");
502 }
503
504 let base_url = env_resource_url
505 .as_deref()
506 .and_then(normalize_qwen_oauth_base_url)
507 .or_else(|| {
508 cached
509 .as_ref()
510 .and_then(|credentials| credentials.resource_url.as_deref())
511 .and_then(normalize_qwen_oauth_base_url)
512 });
513
514 QwenOauthProviderContext {
515 credential,
516 base_url,
517 }
518}
519
520fn resolve_minimax_static_credential() -> Option<String> {
521 read_non_empty_env(MINIMAX_OAUTH_TOKEN_ENV).or_else(|| read_non_empty_env(MINIMAX_API_KEY_ENV))
522}
523
524fn refresh_minimax_oauth_access_token(name: &str, refresh_token: &str) -> anyhow::Result<String> {
525 let region = minimax_oauth_region(name);
526 let endpoint = region.token_endpoint();
527 let client_id = minimax_oauth_client_id();
528 let client = reqwest::blocking::Client::builder()
529 .timeout(std::time::Duration::from_secs(15))
530 .connect_timeout(std::time::Duration::from_secs(5))
531 .build()
532 .unwrap_or_else(|_| reqwest::blocking::Client::new());
533
534 let response = client
535 .post(endpoint)
536 .header("Content-Type", "application/x-www-form-urlencoded")
537 .header("Accept", "application/json")
538 .form(&[
539 ("grant_type", "refresh_token"),
540 ("refresh_token", refresh_token),
541 ("client_id", client_id.as_str()),
542 ])
543 .send()
544 .map_err(|error| anyhow::anyhow!("MiniMax OAuth refresh request failed: {error}"))?;
545
546 let status = response.status();
547 let body = response
548 .text()
549 .unwrap_or_else(|_| "<failed to read MiniMax OAuth response body>".to_string());
550
551 let parsed = serde_json::from_str::<MinimaxOauthRefreshResponse>(&body).ok();
552
553 if !status.is_success() {
554 let detail = parsed
555 .as_ref()
556 .and_then(|payload| payload.base_resp.as_ref())
557 .and_then(|base| base.status_msg.as_deref())
558 .filter(|msg| !msg.trim().is_empty())
559 .unwrap_or(body.as_str());
560 anyhow::bail!("MiniMax OAuth refresh failed (HTTP {status}): {detail}");
561 }
562
563 if let Some(payload) = parsed {
564 if let Some(status_text) = payload.status.as_deref() {
565 if !status_text.eq_ignore_ascii_case("success") {
566 let detail = payload
567 .base_resp
568 .as_ref()
569 .and_then(|base| base.status_msg.as_deref())
570 .unwrap_or(status_text);
571 anyhow::bail!("MiniMax OAuth refresh failed: {detail}");
572 }
573 }
574
575 if let Some(token) = payload
576 .access_token
577 .as_deref()
578 .map(str::trim)
579 .filter(|token| !token.is_empty())
580 {
581 return Ok(token.to_string());
582 }
583 }
584
585 anyhow::bail!("MiniMax OAuth refresh response missing access_token");
586}
587
588fn resolve_minimax_oauth_refresh_token(name: &str) -> Option<String> {
589 let refresh_token = read_non_empty_env(MINIMAX_OAUTH_REFRESH_TOKEN_ENV)?;
590
591 match refresh_minimax_oauth_access_token(name, &refresh_token) {
592 Ok(token) => Some(token),
593 Err(error) => {
594 tracing::warn!(provider = name, error = %error, "MiniMax OAuth refresh failed");
595 None
596 }
597 }
598}
599
600pub(crate) fn canonical_china_provider_name(name: &str) -> Option<&'static str> {
601 if is_qwen_alias(name) {
602 Some("qwen")
603 } else if is_glm_alias(name) {
604 Some("glm")
605 } else if is_moonshot_alias(name) {
606 Some("moonshot")
607 } else if is_minimax_alias(name) {
608 Some("minimax")
609 } else if is_zai_alias(name) {
610 Some("zai")
611 } else if is_qianfan_alias(name) {
612 Some("qianfan")
613 } else if is_doubao_alias(name) {
614 Some("doubao")
615 } else {
616 None
617 }
618}
619
620fn minimax_base_url(name: &str) -> Option<&'static str> {
621 if is_minimax_cn_alias(name) {
622 Some(MINIMAX_CN_BASE_URL)
623 } else if is_minimax_intl_alias(name) {
624 Some(MINIMAX_INTL_BASE_URL)
625 } else {
626 None
627 }
628}
629
630fn glm_base_url(name: &str) -> Option<&'static str> {
631 if is_glm_cn_alias(name) {
632 Some(GLM_CN_BASE_URL)
633 } else if is_glm_global_alias(name) {
634 Some(GLM_GLOBAL_BASE_URL)
635 } else {
636 None
637 }
638}
639
640fn moonshot_base_url(name: &str) -> Option<&'static str> {
641 if is_moonshot_intl_alias(name) {
642 Some(MOONSHOT_INTL_BASE_URL)
643 } else if is_moonshot_cn_alias(name) {
644 Some(MOONSHOT_CN_BASE_URL)
645 } else {
646 None
647 }
648}
649
650fn qwen_base_url(name: &str) -> Option<&'static str> {
651 if is_qwen_cn_alias(name) || is_qwen_oauth_alias(name) {
652 Some(QWEN_CN_BASE_URL)
653 } else if is_qwen_intl_alias(name) {
654 Some(QWEN_INTL_BASE_URL)
655 } else if is_qwen_us_alias(name) {
656 Some(QWEN_US_BASE_URL)
657 } else {
658 None
659 }
660}
661
662fn zai_base_url(name: &str) -> Option<&'static str> {
663 if is_zai_cn_alias(name) {
664 Some(ZAI_CN_BASE_URL)
665 } else if is_zai_global_alias(name) {
666 Some(ZAI_GLOBAL_BASE_URL)
667 } else {
668 None
669 }
670}
671
672#[derive(Debug, Clone)]
673pub struct ProviderRuntimeOptions {
674 pub auth_profile_override: Option<String>,
675 pub provider_api_url: Option<String>,
676 pub zeroclaw_dir: Option<PathBuf>,
677 pub secrets_encrypt: bool,
678 pub reasoning_enabled: Option<bool>,
679}
680
681impl Default for ProviderRuntimeOptions {
682 fn default() -> Self {
683 Self {
684 auth_profile_override: None,
685 provider_api_url: None,
686 zeroclaw_dir: None,
687 secrets_encrypt: true,
688 reasoning_enabled: None,
689 }
690 }
691}
692
693fn is_secret_char(c: char) -> bool {
694 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
695}
696
697fn token_end(input: &str, from: usize) -> usize {
698 let mut end = from;
699 for (i, c) in input[from..].char_indices() {
700 if is_secret_char(c) {
701 end = from + i + c.len_utf8();
702 } else {
703 break;
704 }
705 }
706 end
707}
708
709pub fn scrub_secret_patterns(input: &str) -> String {
714 const PREFIXES: [&str; 7] = [
715 "sk-",
716 "xoxb-",
717 "xoxp-",
718 "ghp_",
719 "gho_",
720 "ghu_",
721 "github_pat_",
722 ];
723
724 let mut scrubbed = input.to_string();
725
726 for prefix in PREFIXES {
727 let mut search_from = 0;
728 loop {
729 let Some(rel) = scrubbed[search_from..].find(prefix) else {
730 break;
731 };
732
733 let start = search_from + rel;
734 let content_start = start + prefix.len();
735 let end = token_end(&scrubbed, content_start);
736
737 if end == content_start {
739 search_from = content_start;
740 continue;
741 }
742
743 scrubbed.replace_range(start..end, "[REDACTED]");
744 search_from = start + "[REDACTED]".len();
745 }
746 }
747
748 scrubbed
749}
750
751pub fn sanitize_api_error(input: &str) -> String {
753 let scrubbed = scrub_secret_patterns(input);
754
755 if scrubbed.chars().count() <= MAX_API_ERROR_CHARS {
756 return scrubbed;
757 }
758
759 let mut end = MAX_API_ERROR_CHARS;
760 while end > 0 && !scrubbed.is_char_boundary(end) {
761 end -= 1;
762 }
763
764 format!("{}...", &scrubbed[..end])
765}
766
767pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::Error {
769 let status = response.status();
770 let body = response
771 .text()
772 .await
773 .unwrap_or_else(|_| "<failed to read provider error body>".to_string());
774 let sanitized = sanitize_api_error(&body);
775 anyhow::anyhow!("{provider} API error ({status}): {sanitized}")
776}
777
778fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> Option<String> {
792 let mut minimax_oauth_placeholder_requested = false;
793
794 if let Some(raw_override) = credential_override {
795 let trimmed_override = raw_override.trim();
796 if !trimmed_override.is_empty() {
797 if is_minimax_alias(name) && is_minimax_oauth_placeholder(trimmed_override) {
798 minimax_oauth_placeholder_requested = true;
799 if let Some(credential) = resolve_minimax_static_credential() {
800 return Some(credential);
801 }
802 if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
803 return Some(credential);
804 }
805 } else {
806 return Some(trimmed_override.to_owned());
807 }
808 }
809 }
810
811 let provider_env_candidates: Vec<&str> = match name {
812 "anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
813 "openrouter" => vec!["OPENROUTER_API_KEY"],
814 "openai" => vec!["OPENAI_API_KEY"],
815 "ollama" => vec!["OLLAMA_API_KEY"],
816 "venice" => vec!["VENICE_API_KEY"],
817 "groq" => vec!["GROQ_API_KEY"],
818 "mistral" => vec!["MISTRAL_API_KEY"],
819 "deepseek" => vec!["DEEPSEEK_API_KEY"],
820 "xai" | "grok" => vec!["XAI_API_KEY"],
821 "together" | "together-ai" => vec!["TOGETHER_API_KEY"],
822 "fireworks" | "fireworks-ai" => vec!["FIREWORKS_API_KEY"],
823 "novita" => vec!["NOVITA_API_KEY"],
824 "perplexity" => vec!["PERPLEXITY_API_KEY"],
825 "cohere" => vec!["COHERE_API_KEY"],
826 name if is_moonshot_alias(name) => vec!["MOONSHOT_API_KEY"],
827 "kimi-code" | "kimi_coding" | "kimi_for_coding" => {
828 vec!["KIMI_CODE_API_KEY", "MOONSHOT_API_KEY"]
829 }
830 name if is_glm_alias(name) => vec!["GLM_API_KEY"],
831 name if is_minimax_alias(name) => vec![MINIMAX_OAUTH_TOKEN_ENV, MINIMAX_API_KEY_ENV],
832 "bedrock" | "aws-bedrock" => return None,
835 name if is_qianfan_alias(name) => vec!["QIANFAN_API_KEY"],
836 name if is_doubao_alias(name) => vec!["ARK_API_KEY", "DOUBAO_API_KEY"],
837 name if is_qwen_alias(name) => vec!["DASHSCOPE_API_KEY"],
838 name if is_zai_alias(name) => vec!["ZAI_API_KEY"],
839 "nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
840 "synthetic" => vec!["SYNTHETIC_API_KEY"],
841 "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
842 "vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
843 "cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
844 "ovhcloud" | "ovh" => vec!["OVH_AI_ENDPOINTS_ACCESS_TOKEN"],
845 "astrai" => vec!["ASTRAI_API_KEY"],
846 "llamacpp" | "llama.cpp" => vec!["LLAMACPP_API_KEY"],
847 "sglang" => vec!["SGLANG_API_KEY"],
848 "vllm" => vec!["VLLM_API_KEY"],
849 "osaurus" => vec!["OSAURUS_API_KEY"],
850 "telnyx" => vec!["TELNYX_API_KEY"],
851 _ => vec![],
852 };
853
854 for env_var in provider_env_candidates {
855 if let Ok(value) = std::env::var(env_var) {
856 let value = value.trim();
857 if !value.is_empty() {
858 return Some(value.to_string());
859 }
860 }
861 }
862
863 if is_minimax_alias(name) {
864 if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
865 return Some(credential);
866 }
867 }
868
869 if minimax_oauth_placeholder_requested && is_minimax_alias(name) {
870 return None;
871 }
872
873 for env_var in ["ZEROCLAW_API_KEY", "API_KEY"] {
874 if let Ok(value) = std::env::var(env_var) {
875 let value = value.trim();
876 if !value.is_empty() {
877 return Some(value.to_string());
878 }
879 }
880 }
881
882 None
883}
884
885fn parse_custom_provider_url(
886 raw_url: &str,
887 provider_label: &str,
888 format_hint: &str,
889) -> anyhow::Result<String> {
890 let base_url = raw_url.trim();
891
892 if base_url.is_empty() {
893 anyhow::bail!("{provider_label} requires a URL. Format: {format_hint}");
894 }
895
896 let parsed = reqwest::Url::parse(base_url).map_err(|_| {
897 anyhow::anyhow!("{provider_label} requires a valid URL. Format: {format_hint}")
898 })?;
899
900 match parsed.scheme() {
901 "http" | "https" => Ok(base_url.to_string()),
902 _ => anyhow::bail!(
903 "{provider_label} requires an http:// or https:// URL. Format: {format_hint}"
904 ),
905 }
906}
907
908pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<dyn Provider>> {
910 create_provider_with_options(name, api_key, &ProviderRuntimeOptions::default())
911}
912
913pub fn create_provider_with_options(
915 name: &str,
916 api_key: Option<&str>,
917 options: &ProviderRuntimeOptions,
918) -> anyhow::Result<Box<dyn Provider>> {
919 match name {
920 "openai-codex" | "openai_codex" | "codex" => Ok(Box::new(
921 openai_codex::OpenAiCodexProvider::new(options, api_key)?,
922 )),
923 _ => create_provider_with_url_and_options(name, api_key, None, options),
924 }
925}
926
927pub fn create_provider_with_url(
929 name: &str,
930 api_key: Option<&str>,
931 api_url: Option<&str>,
932) -> anyhow::Result<Box<dyn Provider>> {
933 create_provider_with_url_and_options(name, api_key, api_url, &ProviderRuntimeOptions::default())
934}
935
936#[allow(clippy::too_many_lines)]
938fn create_provider_with_url_and_options(
939 name: &str,
940 api_key: Option<&str>,
941 api_url: Option<&str>,
942 options: &ProviderRuntimeOptions,
943) -> anyhow::Result<Box<dyn Provider>> {
944 let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key));
945
946 let resolved_credential = if let Some(context) = qwen_oauth_context.as_ref() {
950 context.credential.clone()
951 } else {
952 resolve_provider_credential(name, api_key)
953 }
954 .map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
955 #[allow(clippy::option_as_ref_deref)]
956 let key = resolved_credential.as_ref().map(String::as_str);
957 match name {
958 "openai-codex" | "openai_codex" | "codex" => {
959 let mut codex_options = options.clone();
960 codex_options.provider_api_url = api_url
961 .map(str::trim)
962 .filter(|value| !value.is_empty())
963 .map(ToString::to_string)
964 .or_else(|| options.provider_api_url.clone());
965 Ok(Box::new(openai_codex::OpenAiCodexProvider::new(
966 &codex_options,
967 key,
968 )?))
969 }
970 "openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))),
972 "anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))),
973 "openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))),
974 "ollama" => Ok(Box::new(ollama::OllamaProvider::new_with_reasoning(
976 api_url,
977 key,
978 options.reasoning_enabled,
979 ))),
980 "gemini" | "google" | "google-gemini" => {
981 let state_dir = options
982 .zeroclaw_dir
983 .clone()
984 .unwrap_or_else(|| {
985 directories::UserDirs::new().map_or_else(
986 || PathBuf::from(".zeroclaw"),
987 |dirs| dirs.home_dir().join(".zeroclaw"),
988 )
989 });
990 let auth_service = AuthService::new(&state_dir, options.secrets_encrypt);
991 Ok(Box::new(gemini::GeminiProvider::new_with_auth(
992 key,
993 auth_service,
994 options.auth_profile_override.clone(),
995 )))
996 }
997 "telnyx" => Ok(Box::new(telnyx::TelnyxProvider::new(key))),
998
999 "venice" => Ok(Box::new(OpenAiCompatibleProvider::new(
1001 "Venice", "https://api.venice.ai", key, AuthStyle::Bearer,
1002 ))),
1003 "vercel" | "vercel-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1004 "Vercel AI Gateway",
1005 VERCEL_AI_GATEWAY_BASE_URL,
1006 key,
1007 AuthStyle::Bearer,
1008 ))),
1009 "cloudflare" | "cloudflare-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1010 "Cloudflare AI Gateway",
1011 "https://gateway.ai.cloudflare.com/v1",
1012 key,
1013 AuthStyle::Bearer,
1014 ))),
1015 name if moonshot_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
1016 "Moonshot",
1017 moonshot_base_url(name).expect("checked in guard"),
1018 key,
1019 AuthStyle::Bearer,
1020 ))),
1021 "kimi-code" | "kimi_coding" | "kimi_for_coding" => Ok(Box::new(
1022 OpenAiCompatibleProvider::new_with_user_agent(
1023 "Kimi Code",
1024 "https://api.kimi.com/coding/v1",
1025 key,
1026 AuthStyle::Bearer,
1027 "KimiCLI/0.77",
1028 ),
1029 )),
1030 "synthetic" => Ok(Box::new(OpenAiCompatibleProvider::new(
1031 "Synthetic", "https://api.synthetic.new/openai/v1", key, AuthStyle::Bearer,
1032 ))),
1033 "opencode" | "opencode-zen" => Ok(Box::new(OpenAiCompatibleProvider::new(
1034 "OpenCode Zen", "https://opencode.ai/zen/v1", key, AuthStyle::Bearer,
1035 ))),
1036 name if zai_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
1037 "Z.AI",
1038 zai_base_url(name).expect("checked in guard"),
1039 key,
1040 AuthStyle::Bearer,
1041 ))),
1042 name if glm_base_url(name).is_some() => {
1043 Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback(
1044 "GLM",
1045 glm_base_url(name).expect("checked in guard"),
1046 key,
1047 AuthStyle::Bearer,
1048 )))
1049 }
1050 name if minimax_base_url(name).is_some() => Ok(Box::new(
1051 OpenAiCompatibleProvider::new_merge_system_into_user(
1052 "MiniMax",
1053 minimax_base_url(name).expect("checked in guard"),
1054 key,
1055 AuthStyle::Bearer,
1056 )
1057 )),
1058 "bedrock" | "aws-bedrock" => Ok(Box::new(bedrock::BedrockProvider::new())),
1059 name if is_qwen_oauth_alias(name) => {
1060 let base_url = api_url
1061 .map(str::trim)
1062 .filter(|value| !value.is_empty())
1063 .map(ToString::to_string)
1064 .or_else(|| qwen_oauth_context.as_ref().and_then(|context| context.base_url.clone()))
1065 .unwrap_or_else(|| QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
1066
1067 Ok(Box::new(
1068 OpenAiCompatibleProvider::new_with_user_agent_and_vision(
1069 "Qwen Code",
1070 &base_url,
1071 key,
1072 AuthStyle::Bearer,
1073 "QwenCode/1.0",
1074 true,
1075 )))
1076 }
1077 name if is_qianfan_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
1078 "Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer,
1079 ))),
1080 name if is_doubao_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
1081 "Doubao",
1082 "https://ark.cn-beijing.volces.com/api/v3",
1083 key,
1084 AuthStyle::Bearer,
1085 ))),
1086 name if qwen_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new_with_vision(
1087 "Qwen",
1088 qwen_base_url(name).expect("checked in guard"),
1089 key,
1090 AuthStyle::Bearer,
1091 true,
1092 ))),
1093
1094 "groq" => Ok(Box::new(OpenAiCompatibleProvider::new(
1096 "Groq", "https://api.groq.com/openai/v1", key, AuthStyle::Bearer,
1097 ))),
1098 "mistral" => Ok(Box::new(OpenAiCompatibleProvider::new(
1099 "Mistral", "https://api.mistral.ai/v1", key, AuthStyle::Bearer,
1100 ))),
1101 "xai" | "grok" => Ok(Box::new(OpenAiCompatibleProvider::new(
1102 "xAI", "https://api.x.ai", key, AuthStyle::Bearer,
1103 ))),
1104 "deepseek" => Ok(Box::new(OpenAiCompatibleProvider::new(
1105 "DeepSeek", "https://api.deepseek.com", key, AuthStyle::Bearer,
1106 ))),
1107 "together" | "together-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1108 "Together AI", "https://api.together.xyz", key, AuthStyle::Bearer,
1109 ))),
1110 "fireworks" | "fireworks-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1111 "Fireworks AI", "https://api.fireworks.ai/inference/v1", key, AuthStyle::Bearer,
1112 ))),
1113 "novita" => Ok(Box::new(OpenAiCompatibleProvider::new(
1114 "Novita AI", "https://api.novita.ai/openai", key, AuthStyle::Bearer,
1115 ))),
1116 "perplexity" => Ok(Box::new(OpenAiCompatibleProvider::new(
1117 "Perplexity", "https://api.perplexity.ai", key, AuthStyle::Bearer,
1118 ))),
1119 "cohere" => Ok(Box::new(OpenAiCompatibleProvider::new(
1120 "Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer,
1121 ))),
1122 "copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))),
1123 "lmstudio" | "lm-studio" => {
1124 let lm_studio_key = key
1125 .map(str::trim)
1126 .filter(|value| !value.is_empty())
1127 .unwrap_or("lm-studio");
1128 Ok(Box::new(OpenAiCompatibleProvider::new(
1129 "LM Studio",
1130 "http://localhost:1234/v1",
1131 Some(lm_studio_key),
1132 AuthStyle::Bearer,
1133 )))
1134 }
1135 "llamacpp" | "llama.cpp" => {
1136 let base_url = api_url
1137 .map(str::trim)
1138 .filter(|value| !value.is_empty())
1139 .unwrap_or("http://localhost:8080/v1");
1140 let llama_cpp_key = key
1141 .map(str::trim)
1142 .filter(|value| !value.is_empty())
1143 .unwrap_or("llama.cpp");
1144 Ok(Box::new(OpenAiCompatibleProvider::new(
1145 "llama.cpp",
1146 base_url,
1147 Some(llama_cpp_key),
1148 AuthStyle::Bearer,
1149 )))
1150 }
1151 "sglang" => {
1152 let base_url = api_url
1153 .map(str::trim)
1154 .filter(|value| !value.is_empty())
1155 .unwrap_or("http://localhost:30000/v1");
1156 Ok(Box::new(OpenAiCompatibleProvider::new(
1157 "SGLang",
1158 base_url,
1159 key,
1160 AuthStyle::Bearer,
1161 )))
1162 }
1163 "vllm" => {
1164 let base_url = api_url
1165 .map(str::trim)
1166 .filter(|value| !value.is_empty())
1167 .unwrap_or("http://localhost:8000/v1");
1168 Ok(Box::new(OpenAiCompatibleProvider::new(
1169 "vLLM",
1170 base_url,
1171 key,
1172 AuthStyle::Bearer,
1173 )))
1174 }
1175 "osaurus" => {
1176 let base_url = api_url
1177 .map(str::trim)
1178 .filter(|value| !value.is_empty())
1179 .unwrap_or("http://localhost:1337/v1");
1180 let osaurus_key = key
1181 .map(str::trim)
1182 .filter(|value| !value.is_empty())
1183 .unwrap_or("osaurus");
1184 Ok(Box::new(OpenAiCompatibleProvider::new(
1185 "Osaurus",
1186 base_url,
1187 Some(osaurus_key),
1188 AuthStyle::Bearer,
1189 )))
1190 }
1191 "nvidia" | "nvidia-nim" | "build.nvidia.com" => Ok(Box::new(
1192 OpenAiCompatibleProvider::new_no_responses_fallback(
1193 "NVIDIA NIM",
1194 "https://integrate.api.nvidia.com/v1",
1195 key,
1196 AuthStyle::Bearer,
1197 ),
1198 )),
1199
1200 "astrai" => Ok(Box::new(OpenAiCompatibleProvider::new(
1202 "Astrai", "https://as-trai.com/v1", key, AuthStyle::Bearer,
1203 ))),
1204
1205 "ovhcloud" | "ovh" => Ok(Box::new(openai::OpenAiProvider::with_base_url(
1207 Some("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"),
1208 key,
1209 ))),
1210
1211 name if name.starts_with("custom:") => {
1214 let base_url = parse_custom_provider_url(
1215 name.strip_prefix("custom:").unwrap_or(""),
1216 "Custom provider",
1217 "custom:https://your-api.com",
1218 )?;
1219 Ok(Box::new(OpenAiCompatibleProvider::new_with_vision(
1220 "Custom",
1221 &base_url,
1222 key,
1223 AuthStyle::Bearer,
1224 true,
1225 )))
1226 }
1227
1228 name if name.starts_with("anthropic-custom:") => {
1231 let base_url = parse_custom_provider_url(
1232 name.strip_prefix("anthropic-custom:").unwrap_or(""),
1233 "Anthropic-custom provider",
1234 "anthropic-custom:https://your-api.com",
1235 )?;
1236 Ok(Box::new(anthropic::AnthropicProvider::with_base_url(
1237 key,
1238 Some(&base_url),
1239 )))
1240 }
1241
1242 _ => anyhow::bail!(
1243 "Unknown provider: {name}. Check README for supported providers or run `zeroclaw onboard --interactive` to reconfigure.\n\
1244 Tip: Use \"custom:https://your-api.com\" for OpenAI-compatible endpoints.\n\
1245 Tip: Use \"anthropic-custom:https://your-api.com\" for Anthropic-compatible endpoints."
1246 ),
1247 }
1248}
1249
1250fn parse_provider_profile(s: &str) -> (&str, Option<&str>) {
1257 if s.starts_with("custom:") || s.starts_with("anthropic-custom:") {
1258 return (s, None);
1259 }
1260 match s.split_once(':') {
1261 Some((provider, profile)) if !profile.is_empty() => (provider, Some(profile)),
1262 _ => (s, None),
1263 }
1264}
1265
1266pub fn create_resilient_provider(
1268 primary_name: &str,
1269 api_key: Option<&str>,
1270 api_url: Option<&str>,
1271 reliability: &crate::config::ReliabilityConfig,
1272) -> anyhow::Result<Box<dyn Provider>> {
1273 create_resilient_provider_with_options(
1274 primary_name,
1275 api_key,
1276 api_url,
1277 reliability,
1278 &ProviderRuntimeOptions::default(),
1279 )
1280}
1281
1282pub fn create_resilient_provider_with_options(
1284 primary_name: &str,
1285 api_key: Option<&str>,
1286 api_url: Option<&str>,
1287 reliability: &crate::config::ReliabilityConfig,
1288 options: &ProviderRuntimeOptions,
1289) -> anyhow::Result<Box<dyn Provider>> {
1290 let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
1291
1292 let primary_provider = match primary_name {
1293 "openai-codex" | "openai_codex" | "codex" => {
1294 create_provider_with_options(primary_name, api_key, options)?
1295 }
1296 _ => create_provider_with_url_and_options(primary_name, api_key, api_url, options)?,
1297 };
1298 providers.push((primary_name.to_string(), primary_provider));
1299
1300 for fallback in &reliability.fallback_providers {
1301 if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) {
1302 continue;
1303 }
1304
1305 let (provider_name, profile_override) = parse_provider_profile(fallback);
1306
1307 let fallback_options = match profile_override {
1317 Some(profile) => {
1318 let mut opts = options.clone();
1319 opts.auth_profile_override = Some(profile.to_string());
1320 opts
1321 }
1322 None => options.clone(),
1323 };
1324
1325 match create_provider_with_options(provider_name, None, &fallback_options) {
1326 Ok(provider) => providers.push((fallback.clone(), provider)),
1327 Err(_error) => {
1328 tracing::warn!(
1329 fallback_provider = fallback,
1330 "Ignoring invalid fallback provider during initialization"
1331 );
1332 }
1333 }
1334 }
1335
1336 let reliable = ReliableProvider::new(
1337 providers,
1338 reliability.provider_retries,
1339 reliability.provider_backoff_ms,
1340 )
1341 .with_api_keys(reliability.api_keys.clone())
1342 .with_model_fallbacks(reliability.model_fallbacks.clone());
1343
1344 Ok(Box::new(reliable))
1345}
1346
1347pub fn create_routed_provider(
1351 primary_name: &str,
1352 api_key: Option<&str>,
1353 api_url: Option<&str>,
1354 reliability: &crate::config::ReliabilityConfig,
1355 model_routes: &[crate::config::ModelRouteConfig],
1356 default_model: &str,
1357) -> anyhow::Result<Box<dyn Provider>> {
1358 create_routed_provider_with_options(
1359 primary_name,
1360 api_key,
1361 api_url,
1362 reliability,
1363 model_routes,
1364 default_model,
1365 &ProviderRuntimeOptions::default(),
1366 )
1367}
1368
1369pub fn create_routed_provider_with_options(
1371 primary_name: &str,
1372 api_key: Option<&str>,
1373 api_url: Option<&str>,
1374 reliability: &crate::config::ReliabilityConfig,
1375 model_routes: &[crate::config::ModelRouteConfig],
1376 default_model: &str,
1377 options: &ProviderRuntimeOptions,
1378) -> anyhow::Result<Box<dyn Provider>> {
1379 if model_routes.is_empty() {
1380 return create_resilient_provider_with_options(
1381 primary_name,
1382 api_key,
1383 api_url,
1384 reliability,
1385 options,
1386 );
1387 }
1388
1389 let mut needed: Vec<String> = vec![primary_name.to_string()];
1391 for route in model_routes {
1392 if !needed.iter().any(|n| n == &route.provider) {
1393 needed.push(route.provider.clone());
1394 }
1395 }
1396
1397 let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
1399 for name in &needed {
1400 let routed_credential = model_routes
1401 .iter()
1402 .find(|r| &r.provider == name)
1403 .and_then(|r| {
1404 r.api_key.as_ref().and_then(|raw_key| {
1405 let trimmed_key = raw_key.trim();
1406 (!trimmed_key.is_empty()).then_some(trimmed_key)
1407 })
1408 });
1409 let key = routed_credential.or(api_key);
1410 let url = if name == primary_name { api_url } else { None };
1412 match create_resilient_provider_with_options(name, key, url, reliability, options) {
1413 Ok(provider) => providers.push((name.clone(), provider)),
1414 Err(e) => {
1415 if name == primary_name {
1416 return Err(e);
1417 }
1418 tracing::warn!(
1419 provider = name.as_str(),
1420 "Ignoring routed provider that failed to initialize"
1421 );
1422 }
1423 }
1424 }
1425
1426 let routes: Vec<(String, router::Route)> = model_routes
1428 .iter()
1429 .map(|r| {
1430 (
1431 r.hint.clone(),
1432 router::Route {
1433 provider_name: r.provider.clone(),
1434 model: r.model.clone(),
1435 },
1436 )
1437 })
1438 .collect();
1439
1440 Ok(Box::new(router::RouterProvider::new(
1441 providers,
1442 routes,
1443 default_model.to_string(),
1444 )))
1445}
1446
1447pub struct ProviderInfo {
1449 pub name: &'static str,
1451 pub display_name: &'static str,
1453 pub aliases: &'static [&'static str],
1455 pub local: bool,
1457}
1458
1459pub fn list_providers() -> Vec<ProviderInfo> {
1464 vec![
1465 ProviderInfo {
1467 name: "openrouter",
1468 display_name: "OpenRouter",
1469 aliases: &[],
1470 local: false,
1471 },
1472 ProviderInfo {
1473 name: "anthropic",
1474 display_name: "Anthropic",
1475 aliases: &[],
1476 local: false,
1477 },
1478 ProviderInfo {
1479 name: "openai",
1480 display_name: "OpenAI",
1481 aliases: &[],
1482 local: false,
1483 },
1484 ProviderInfo {
1485 name: "openai-codex",
1486 display_name: "OpenAI Codex (OAuth)",
1487 aliases: &["openai_codex", "codex"],
1488 local: false,
1489 },
1490 ProviderInfo {
1491 name: "ollama",
1492 display_name: "Ollama",
1493 aliases: &[],
1494 local: true,
1495 },
1496 ProviderInfo {
1497 name: "gemini",
1498 display_name: "Google Gemini",
1499 aliases: &["google", "google-gemini"],
1500 local: false,
1501 },
1502 ProviderInfo {
1504 name: "venice",
1505 display_name: "Venice",
1506 aliases: &[],
1507 local: false,
1508 },
1509 ProviderInfo {
1510 name: "vercel",
1511 display_name: "Vercel AI Gateway",
1512 aliases: &["vercel-ai"],
1513 local: false,
1514 },
1515 ProviderInfo {
1516 name: "cloudflare",
1517 display_name: "Cloudflare AI",
1518 aliases: &["cloudflare-ai"],
1519 local: false,
1520 },
1521 ProviderInfo {
1522 name: "moonshot",
1523 display_name: "Moonshot",
1524 aliases: &["kimi"],
1525 local: false,
1526 },
1527 ProviderInfo {
1528 name: "kimi-code",
1529 display_name: "Kimi Code",
1530 aliases: &["kimi_coding", "kimi_for_coding"],
1531 local: false,
1532 },
1533 ProviderInfo {
1534 name: "synthetic",
1535 display_name: "Synthetic",
1536 aliases: &[],
1537 local: false,
1538 },
1539 ProviderInfo {
1540 name: "opencode",
1541 display_name: "OpenCode Zen",
1542 aliases: &["opencode-zen"],
1543 local: false,
1544 },
1545 ProviderInfo {
1546 name: "zai",
1547 display_name: "Z.AI",
1548 aliases: &["z.ai"],
1549 local: false,
1550 },
1551 ProviderInfo {
1552 name: "glm",
1553 display_name: "GLM (Zhipu)",
1554 aliases: &["zhipu"],
1555 local: false,
1556 },
1557 ProviderInfo {
1558 name: "minimax",
1559 display_name: "MiniMax",
1560 aliases: &[
1561 "minimax-intl",
1562 "minimax-io",
1563 "minimax-global",
1564 "minimax-cn",
1565 "minimaxi",
1566 "minimax-oauth",
1567 "minimax-oauth-cn",
1568 "minimax-portal",
1569 "minimax-portal-cn",
1570 ],
1571 local: false,
1572 },
1573 ProviderInfo {
1574 name: "bedrock",
1575 display_name: "Amazon Bedrock",
1576 aliases: &["aws-bedrock"],
1577 local: false,
1578 },
1579 ProviderInfo {
1580 name: "qianfan",
1581 display_name: "Qianfan (Baidu)",
1582 aliases: &["baidu"],
1583 local: false,
1584 },
1585 ProviderInfo {
1586 name: "doubao",
1587 display_name: "Doubao (Volcengine)",
1588 aliases: &["volcengine", "ark", "doubao-cn"],
1589 local: false,
1590 },
1591 ProviderInfo {
1592 name: "qwen",
1593 display_name: "Qwen (DashScope / Qwen Code OAuth)",
1594 aliases: &[
1595 "dashscope",
1596 "qwen-intl",
1597 "dashscope-intl",
1598 "qwen-us",
1599 "dashscope-us",
1600 "qwen-code",
1601 "qwen-oauth",
1602 "qwen_oauth",
1603 ],
1604 local: false,
1605 },
1606 ProviderInfo {
1607 name: "groq",
1608 display_name: "Groq",
1609 aliases: &[],
1610 local: false,
1611 },
1612 ProviderInfo {
1613 name: "mistral",
1614 display_name: "Mistral",
1615 aliases: &[],
1616 local: false,
1617 },
1618 ProviderInfo {
1619 name: "xai",
1620 display_name: "xAI (Grok)",
1621 aliases: &["grok"],
1622 local: false,
1623 },
1624 ProviderInfo {
1625 name: "deepseek",
1626 display_name: "DeepSeek",
1627 aliases: &[],
1628 local: false,
1629 },
1630 ProviderInfo {
1631 name: "together",
1632 display_name: "Together AI",
1633 aliases: &["together-ai"],
1634 local: false,
1635 },
1636 ProviderInfo {
1637 name: "fireworks",
1638 display_name: "Fireworks AI",
1639 aliases: &["fireworks-ai"],
1640 local: false,
1641 },
1642 ProviderInfo {
1643 name: "novita",
1644 display_name: "Novita AI",
1645 aliases: &[],
1646 local: false,
1647 },
1648 ProviderInfo {
1649 name: "perplexity",
1650 display_name: "Perplexity",
1651 aliases: &[],
1652 local: false,
1653 },
1654 ProviderInfo {
1655 name: "cohere",
1656 display_name: "Cohere",
1657 aliases: &[],
1658 local: false,
1659 },
1660 ProviderInfo {
1661 name: "copilot",
1662 display_name: "GitHub Copilot",
1663 aliases: &["github-copilot"],
1664 local: false,
1665 },
1666 ProviderInfo {
1667 name: "lmstudio",
1668 display_name: "LM Studio",
1669 aliases: &["lm-studio"],
1670 local: true,
1671 },
1672 ProviderInfo {
1673 name: "llamacpp",
1674 display_name: "llama.cpp server",
1675 aliases: &["llama.cpp"],
1676 local: true,
1677 },
1678 ProviderInfo {
1679 name: "sglang",
1680 display_name: "SGLang",
1681 aliases: &[],
1682 local: true,
1683 },
1684 ProviderInfo {
1685 name: "vllm",
1686 display_name: "vLLM",
1687 aliases: &[],
1688 local: true,
1689 },
1690 ProviderInfo {
1691 name: "osaurus",
1692 display_name: "Osaurus",
1693 aliases: &[],
1694 local: true,
1695 },
1696 ProviderInfo {
1697 name: "nvidia",
1698 display_name: "NVIDIA NIM",
1699 aliases: &["nvidia-nim", "build.nvidia.com"],
1700 local: false,
1701 },
1702 ProviderInfo {
1703 name: "ovhcloud",
1704 display_name: "OVHcloud AI Endpoints",
1705 aliases: &["ovh"],
1706 local: false,
1707 },
1708 ]
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713 use super::*;
1714 use std::sync::{Mutex, OnceLock};
1715
1716 struct EnvGuard {
1717 key: &'static str,
1718 original: Option<String>,
1719 }
1720
1721 impl EnvGuard {
1722 fn set(key: &'static str, value: Option<&str>) -> Self {
1723 let original = std::env::var(key).ok();
1724 match value {
1725 Some(next) => std::env::set_var(key, next),
1726 None => std::env::remove_var(key),
1727 }
1728
1729 Self { key, original }
1730 }
1731 }
1732
1733 impl Drop for EnvGuard {
1734 fn drop(&mut self) {
1735 if let Some(original) = self.original.as_deref() {
1736 std::env::set_var(self.key, original);
1737 } else {
1738 std::env::remove_var(self.key);
1739 }
1740 }
1741 }
1742
1743 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1744 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1745 LOCK.get_or_init(|| Mutex::new(()))
1746 .lock()
1747 .expect("env lock poisoned")
1748 }
1749
1750 #[test]
1751 fn resolve_provider_credential_prefers_explicit_argument() {
1752 let resolved = resolve_provider_credential("openrouter", Some(" explicit-key "));
1753 assert_eq!(resolved, Some("explicit-key".to_string()));
1754 }
1755
1756 #[test]
1757 fn resolve_provider_credential_uses_minimax_oauth_env_for_placeholder() {
1758 let _env_lock = env_lock();
1759 let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, Some("oauth-token"));
1760 let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
1761 let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
1762
1763 let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
1764
1765 assert_eq!(resolved.as_deref(), Some("oauth-token"));
1766 }
1767
1768 #[test]
1769 fn resolve_provider_credential_falls_back_to_minimax_api_key_for_placeholder() {
1770 let _env_lock = env_lock();
1771 let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
1772 let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
1773 let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
1774
1775 let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
1776
1777 assert_eq!(resolved.as_deref(), Some("api-key"));
1778 }
1779
1780 #[test]
1781 fn resolve_provider_credential_placeholder_ignores_generic_api_key_fallback() {
1782 let _env_lock = env_lock();
1783 let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
1784 let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, None);
1785 let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
1786 let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
1787
1788 let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
1789
1790 assert!(resolved.is_none());
1791 }
1792
1793 #[test]
1794 fn resolve_provider_credential_bedrock_uses_internal_credential_path() {
1795 let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
1796 let _override_guard = EnvGuard::set("OPENROUTER_API_KEY", Some("openrouter-key"));
1797
1798 assert_eq!(
1799 resolve_provider_credential("bedrock", Some("explicit")),
1800 Some("explicit".to_string())
1801 );
1802 assert!(resolve_provider_credential("bedrock", None).is_none());
1803 assert!(resolve_provider_credential("aws-bedrock", None).is_none());
1804 }
1805
1806 #[test]
1807 fn resolve_qwen_oauth_context_prefers_explicit_override() {
1808 let _env_lock = env_lock();
1809 let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}", std::process::id());
1810 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
1811 let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
1812 let _resource_guard = EnvGuard::set(
1813 QWEN_OAUTH_RESOURCE_URL_ENV,
1814 Some("coding-intl.dashscope.aliyuncs.com"),
1815 );
1816
1817 let context = resolve_qwen_oauth_context(Some(" explicit-qwen-token "));
1818
1819 assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
1820 assert!(context.base_url.is_none());
1821 }
1822
1823 #[test]
1824 fn resolve_qwen_oauth_context_uses_env_token_and_resource_url() {
1825 let _env_lock = env_lock();
1826 let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-env", std::process::id());
1827 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
1828 let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
1829 let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
1830 let _resource_guard = EnvGuard::set(
1831 QWEN_OAUTH_RESOURCE_URL_ENV,
1832 Some("coding-intl.dashscope.aliyuncs.com"),
1833 );
1834 let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
1835
1836 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
1837
1838 assert_eq!(context.credential.as_deref(), Some("oauth-token"));
1839 assert_eq!(
1840 context.base_url.as_deref(),
1841 Some("https://coding-intl.dashscope.aliyuncs.com/v1")
1842 );
1843 }
1844
1845 #[test]
1846 fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
1847 let _env_lock = env_lock();
1848 let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-file", std::process::id());
1849 let creds_dir = PathBuf::from(&fake_home).join(".qwen");
1850 std::fs::create_dir_all(&creds_dir).unwrap();
1851 let creds_path = creds_dir.join("oauth_creds.json");
1852 std::fs::write(
1853 &creds_path,
1854 r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
1855 )
1856 .unwrap();
1857
1858 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
1859 let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
1860 let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
1861 let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
1862 let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", None);
1863
1864 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
1865
1866 assert_eq!(context.credential.as_deref(), Some("cached-token"));
1867 assert_eq!(
1868 context.base_url.as_deref(),
1869 Some("https://resource.example.com/v1")
1870 );
1871 }
1872
1873 #[test]
1874 fn resolve_qwen_oauth_context_placeholder_does_not_use_dashscope_fallback() {
1875 let _env_lock = env_lock();
1876 let fake_home = format!(
1877 "/tmp/zeroclaw-qwen-oauth-home-{}-placeholder",
1878 std::process::id()
1879 );
1880 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
1881 let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
1882 let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
1883 let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
1884 let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
1885
1886 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
1887
1888 assert!(context.credential.is_none());
1889 }
1890
1891 #[test]
1892 fn regional_alias_predicates_cover_expected_variants() {
1893 assert!(is_moonshot_alias("moonshot"));
1894 assert!(is_moonshot_alias("kimi-global"));
1895 assert!(is_glm_alias("glm"));
1896 assert!(is_glm_alias("bigmodel"));
1897 assert!(is_minimax_alias("minimax-io"));
1898 assert!(is_minimax_alias("minimaxi"));
1899 assert!(is_minimax_alias("minimax-oauth"));
1900 assert!(is_minimax_alias("minimax-portal-cn"));
1901 assert!(is_qwen_alias("dashscope"));
1902 assert!(is_qwen_alias("qwen-us"));
1903 assert!(is_qwen_alias("qwen-code"));
1904 assert!(is_qwen_oauth_alias("qwen-code"));
1905 assert!(is_qwen_oauth_alias("qwen_oauth"));
1906 assert!(is_zai_alias("z.ai"));
1907 assert!(is_zai_alias("zai-cn"));
1908 assert!(is_qianfan_alias("qianfan"));
1909 assert!(is_qianfan_alias("baidu"));
1910 assert!(is_doubao_alias("doubao"));
1911 assert!(is_doubao_alias("volcengine"));
1912 assert!(is_doubao_alias("ark"));
1913 assert!(is_doubao_alias("doubao-cn"));
1914
1915 assert!(!is_moonshot_alias("openrouter"));
1916 assert!(!is_glm_alias("openai"));
1917 assert!(!is_qwen_alias("gemini"));
1918 assert!(!is_zai_alias("anthropic"));
1919 assert!(!is_qianfan_alias("cohere"));
1920 assert!(!is_doubao_alias("deepseek"));
1921 }
1922
1923 #[test]
1924 fn canonical_china_provider_name_maps_regional_aliases() {
1925 assert_eq!(canonical_china_provider_name("moonshot"), Some("moonshot"));
1926 assert_eq!(canonical_china_provider_name("kimi-intl"), Some("moonshot"));
1927 assert_eq!(canonical_china_provider_name("glm"), Some("glm"));
1928 assert_eq!(canonical_china_provider_name("zhipu-cn"), Some("glm"));
1929 assert_eq!(canonical_china_provider_name("minimax"), Some("minimax"));
1930 assert_eq!(canonical_china_provider_name("minimax-cn"), Some("minimax"));
1931 assert_eq!(canonical_china_provider_name("qwen"), Some("qwen"));
1932 assert_eq!(canonical_china_provider_name("dashscope-us"), Some("qwen"));
1933 assert_eq!(canonical_china_provider_name("qwen-code"), Some("qwen"));
1934 assert_eq!(canonical_china_provider_name("zai"), Some("zai"));
1935 assert_eq!(canonical_china_provider_name("z.ai-cn"), Some("zai"));
1936 assert_eq!(canonical_china_provider_name("qianfan"), Some("qianfan"));
1937 assert_eq!(canonical_china_provider_name("baidu"), Some("qianfan"));
1938 assert_eq!(canonical_china_provider_name("doubao"), Some("doubao"));
1939 assert_eq!(canonical_china_provider_name("volcengine"), Some("doubao"));
1940 assert_eq!(canonical_china_provider_name("openai"), None);
1941 }
1942
1943 #[test]
1944 fn regional_endpoint_aliases_map_to_expected_urls() {
1945 assert_eq!(minimax_base_url("minimax"), Some(MINIMAX_INTL_BASE_URL));
1946 assert_eq!(
1947 minimax_base_url("minimax-intl"),
1948 Some(MINIMAX_INTL_BASE_URL)
1949 );
1950 assert_eq!(minimax_base_url("minimax-cn"), Some(MINIMAX_CN_BASE_URL));
1951
1952 assert_eq!(glm_base_url("glm"), Some(GLM_GLOBAL_BASE_URL));
1953 assert_eq!(glm_base_url("glm-cn"), Some(GLM_CN_BASE_URL));
1954 assert_eq!(glm_base_url("bigmodel"), Some(GLM_CN_BASE_URL));
1955
1956 assert_eq!(moonshot_base_url("moonshot"), Some(MOONSHOT_CN_BASE_URL));
1957 assert_eq!(
1958 moonshot_base_url("moonshot-intl"),
1959 Some(MOONSHOT_INTL_BASE_URL)
1960 );
1961
1962 assert_eq!(qwen_base_url("qwen"), Some(QWEN_CN_BASE_URL));
1963 assert_eq!(qwen_base_url("qwen-cn"), Some(QWEN_CN_BASE_URL));
1964 assert_eq!(qwen_base_url("qwen-intl"), Some(QWEN_INTL_BASE_URL));
1965 assert_eq!(qwen_base_url("qwen-us"), Some(QWEN_US_BASE_URL));
1966 assert_eq!(qwen_base_url("qwen-code"), Some(QWEN_CN_BASE_URL));
1967
1968 assert_eq!(zai_base_url("zai"), Some(ZAI_GLOBAL_BASE_URL));
1969 assert_eq!(zai_base_url("z.ai"), Some(ZAI_GLOBAL_BASE_URL));
1970 assert_eq!(zai_base_url("zai-global"), Some(ZAI_GLOBAL_BASE_URL));
1971 assert_eq!(zai_base_url("z.ai-global"), Some(ZAI_GLOBAL_BASE_URL));
1972 assert_eq!(zai_base_url("zai-cn"), Some(ZAI_CN_BASE_URL));
1973 assert_eq!(zai_base_url("z.ai-cn"), Some(ZAI_CN_BASE_URL));
1974 }
1975
1976 #[test]
1979 fn factory_openrouter() {
1980 assert!(create_provider("openrouter", Some("provider-test-credential")).is_ok());
1981 assert!(create_provider("openrouter", None).is_ok());
1982 }
1983
1984 #[test]
1985 fn factory_anthropic() {
1986 assert!(create_provider("anthropic", Some("provider-test-credential")).is_ok());
1987 }
1988
1989 #[test]
1990 fn factory_openai() {
1991 assert!(create_provider("openai", Some("provider-test-credential")).is_ok());
1992 }
1993
1994 #[test]
1995 fn factory_openai_codex() {
1996 let options = ProviderRuntimeOptions::default();
1997 assert!(create_provider_with_options("openai-codex", None, &options).is_ok());
1998 }
1999
2000 #[test]
2001 fn factory_ollama() {
2002 assert!(create_provider("ollama", None).is_ok());
2003 assert!(create_provider("ollama", Some("dummy")).is_ok());
2005 assert!(create_provider("ollama", Some("any-value-here")).is_ok());
2006 }
2007
2008 #[test]
2009 fn factory_gemini() {
2010 assert!(create_provider("gemini", Some("test-key")).is_ok());
2011 assert!(create_provider("google", Some("test-key")).is_ok());
2012 assert!(create_provider("google-gemini", Some("test-key")).is_ok());
2013 assert!(create_provider("gemini", None).is_ok());
2015 }
2016
2017 #[test]
2018 fn factory_telnyx() {
2019 assert!(create_provider("telnyx", Some("test-key")).is_ok());
2020 assert!(create_provider("telnyx", None).is_ok());
2021 }
2022
2023 #[test]
2026 fn factory_venice() {
2027 assert!(create_provider("venice", Some("vn-key")).is_ok());
2028 }
2029
2030 #[test]
2031 fn factory_vercel() {
2032 assert!(create_provider("vercel", Some("key")).is_ok());
2033 assert!(create_provider("vercel-ai", Some("key")).is_ok());
2034 }
2035
2036 #[test]
2037 fn vercel_gateway_base_url_matches_public_gateway_endpoint() {
2038 assert_eq!(
2039 VERCEL_AI_GATEWAY_BASE_URL,
2040 "https://ai-gateway.vercel.sh/v1"
2041 );
2042 }
2043
2044 #[test]
2045 fn factory_cloudflare() {
2046 assert!(create_provider("cloudflare", Some("key")).is_ok());
2047 assert!(create_provider("cloudflare-ai", Some("key")).is_ok());
2048 }
2049
2050 #[test]
2051 fn factory_moonshot() {
2052 assert!(create_provider("moonshot", Some("key")).is_ok());
2053 assert!(create_provider("kimi", Some("key")).is_ok());
2054 assert!(create_provider("moonshot-intl", Some("key")).is_ok());
2055 assert!(create_provider("moonshot-cn", Some("key")).is_ok());
2056 assert!(create_provider("kimi-intl", Some("key")).is_ok());
2057 assert!(create_provider("kimi-cn", Some("key")).is_ok());
2058 }
2059
2060 #[test]
2061 fn factory_kimi_code() {
2062 assert!(create_provider("kimi-code", Some("key")).is_ok());
2063 assert!(create_provider("kimi_coding", Some("key")).is_ok());
2064 assert!(create_provider("kimi_for_coding", Some("key")).is_ok());
2065 }
2066
2067 #[test]
2068 fn factory_synthetic() {
2069 assert!(create_provider("synthetic", Some("key")).is_ok());
2070 }
2071
2072 #[test]
2073 fn factory_opencode() {
2074 assert!(create_provider("opencode", Some("key")).is_ok());
2075 assert!(create_provider("opencode-zen", Some("key")).is_ok());
2076 }
2077
2078 #[test]
2079 fn factory_zai() {
2080 assert!(create_provider("zai", Some("key")).is_ok());
2081 assert!(create_provider("z.ai", Some("key")).is_ok());
2082 assert!(create_provider("zai-global", Some("key")).is_ok());
2083 assert!(create_provider("z.ai-global", Some("key")).is_ok());
2084 assert!(create_provider("zai-cn", Some("key")).is_ok());
2085 assert!(create_provider("z.ai-cn", Some("key")).is_ok());
2086 }
2087
2088 #[test]
2089 fn factory_glm() {
2090 assert!(create_provider("glm", Some("key")).is_ok());
2091 assert!(create_provider("zhipu", Some("key")).is_ok());
2092 assert!(create_provider("glm-cn", Some("key")).is_ok());
2093 assert!(create_provider("zhipu-cn", Some("key")).is_ok());
2094 assert!(create_provider("glm-global", Some("key")).is_ok());
2095 assert!(create_provider("bigmodel", Some("key")).is_ok());
2096 }
2097
2098 #[test]
2099 fn factory_minimax() {
2100 assert!(create_provider("minimax", Some("key")).is_ok());
2101 assert!(create_provider("minimax-intl", Some("key")).is_ok());
2102 assert!(create_provider("minimax-io", Some("key")).is_ok());
2103 assert!(create_provider("minimax-global", Some("key")).is_ok());
2104 assert!(create_provider("minimax-cn", Some("key")).is_ok());
2105 assert!(create_provider("minimaxi", Some("key")).is_ok());
2106 assert!(create_provider("minimax-oauth", Some("key")).is_ok());
2107 assert!(create_provider("minimax-oauth-cn", Some("key")).is_ok());
2108 assert!(create_provider("minimax-portal", Some("key")).is_ok());
2109 assert!(create_provider("minimax-portal-cn", Some("key")).is_ok());
2110 }
2111
2112 #[test]
2113 fn factory_minimax_disables_native_tool_calling() {
2114 let minimax = create_provider("minimax", Some("key")).expect("provider should resolve");
2115 assert!(!minimax.supports_native_tools());
2116
2117 let minimax_cn =
2118 create_provider("minimax-cn", Some("key")).expect("provider should resolve");
2119 assert!(!minimax_cn.supports_native_tools());
2120 }
2121
2122 #[test]
2123 fn factory_bedrock() {
2124 assert!(create_provider("bedrock", None).is_ok());
2126 assert!(create_provider("aws-bedrock", None).is_ok());
2127 assert!(create_provider("bedrock", Some("ignored")).is_ok());
2129 }
2130
2131 #[test]
2132 fn factory_qianfan() {
2133 assert!(create_provider("qianfan", Some("key")).is_ok());
2134 assert!(create_provider("baidu", Some("key")).is_ok());
2135 }
2136
2137 #[test]
2138 fn factory_doubao() {
2139 assert!(create_provider("doubao", Some("key")).is_ok());
2140 assert!(create_provider("volcengine", Some("key")).is_ok());
2141 assert!(create_provider("ark", Some("key")).is_ok());
2142 assert!(create_provider("doubao-cn", Some("key")).is_ok());
2143 }
2144
2145 #[test]
2146 fn factory_qwen() {
2147 assert!(create_provider("qwen", Some("key")).is_ok());
2148 assert!(create_provider("dashscope", Some("key")).is_ok());
2149 assert!(create_provider("qwen-cn", Some("key")).is_ok());
2150 assert!(create_provider("dashscope-cn", Some("key")).is_ok());
2151 assert!(create_provider("qwen-intl", Some("key")).is_ok());
2152 assert!(create_provider("dashscope-intl", Some("key")).is_ok());
2153 assert!(create_provider("qwen-international", Some("key")).is_ok());
2154 assert!(create_provider("dashscope-international", Some("key")).is_ok());
2155 assert!(create_provider("qwen-us", Some("key")).is_ok());
2156 assert!(create_provider("dashscope-us", Some("key")).is_ok());
2157 assert!(create_provider("qwen-code", Some("key")).is_ok());
2158 assert!(create_provider("qwen-oauth", Some("key")).is_ok());
2159 }
2160
2161 #[test]
2162 fn qwen_provider_supports_vision() {
2163 let provider = create_provider("qwen", Some("key")).expect("qwen provider should build");
2164 assert!(provider.supports_vision());
2165
2166 let oauth_provider =
2167 create_provider("qwen-code", Some("key")).expect("qwen oauth provider should build");
2168 assert!(oauth_provider.supports_vision());
2169 }
2170
2171 #[test]
2172 fn factory_lmstudio() {
2173 assert!(create_provider("lmstudio", Some("key")).is_ok());
2174 assert!(create_provider("lm-studio", Some("key")).is_ok());
2175 assert!(create_provider("lmstudio", None).is_ok());
2176 }
2177
2178 #[test]
2179 fn factory_llamacpp() {
2180 assert!(create_provider("llamacpp", Some("key")).is_ok());
2181 assert!(create_provider("llama.cpp", Some("key")).is_ok());
2182 assert!(create_provider("llamacpp", None).is_ok());
2183 }
2184
2185 #[test]
2186 fn factory_sglang() {
2187 assert!(create_provider("sglang", None).is_ok());
2188 assert!(create_provider("sglang", Some("key")).is_ok());
2189 }
2190
2191 #[test]
2192 fn factory_vllm() {
2193 assert!(create_provider("vllm", None).is_ok());
2194 assert!(create_provider("vllm", Some("key")).is_ok());
2195 }
2196
2197 #[test]
2198 fn factory_osaurus() {
2199 assert!(create_provider("osaurus", None).is_ok());
2201 assert!(create_provider("osaurus", Some("custom-key")).is_ok());
2203 }
2204
2205 #[test]
2206 fn factory_osaurus_uses_default_key_when_none() {
2207 let options = ProviderRuntimeOptions::default();
2210 let p = create_provider_with_url_and_options("osaurus", None, None, &options);
2211 assert!(p.is_ok());
2212 }
2213
2214 #[test]
2215 fn factory_osaurus_custom_url() {
2216 let options = ProviderRuntimeOptions::default();
2218 let p = create_provider_with_url_and_options(
2219 "osaurus",
2220 Some("key"),
2221 Some("http://192.168.1.100:1337/v1"),
2222 &options,
2223 );
2224 assert!(p.is_ok());
2225 }
2226
2227 #[test]
2228 fn resolve_provider_credential_osaurus_env() {
2229 let _env_lock = env_lock();
2230 let _guard = EnvGuard::set("OSAURUS_API_KEY", Some("osaurus-test-key"));
2231 let resolved = resolve_provider_credential("osaurus", None);
2232 assert_eq!(resolved, Some("osaurus-test-key".to_string()));
2233 }
2234
2235 #[test]
2238 fn factory_groq() {
2239 assert!(create_provider("groq", Some("key")).is_ok());
2240 }
2241
2242 #[test]
2243 fn factory_mistral() {
2244 assert!(create_provider("mistral", Some("key")).is_ok());
2245 }
2246
2247 #[test]
2248 fn factory_xai() {
2249 assert!(create_provider("xai", Some("key")).is_ok());
2250 assert!(create_provider("grok", Some("key")).is_ok());
2251 }
2252
2253 #[test]
2254 fn factory_deepseek() {
2255 assert!(create_provider("deepseek", Some("key")).is_ok());
2256 }
2257
2258 #[test]
2259 fn deepseek_provider_keeps_vision_disabled() {
2260 let provider =
2261 create_provider("deepseek", Some("key")).expect("deepseek provider should build");
2262 assert!(!provider.supports_vision());
2263 }
2264
2265 #[test]
2266 fn factory_together() {
2267 assert!(create_provider("together", Some("key")).is_ok());
2268 assert!(create_provider("together-ai", Some("key")).is_ok());
2269 }
2270
2271 #[test]
2272 fn factory_fireworks() {
2273 assert!(create_provider("fireworks", Some("key")).is_ok());
2274 assert!(create_provider("fireworks-ai", Some("key")).is_ok());
2275 }
2276
2277 #[test]
2278 fn factory_novita() {
2279 assert!(create_provider("novita", Some("key")).is_ok());
2280 }
2281
2282 #[test]
2283 fn factory_perplexity() {
2284 assert!(create_provider("perplexity", Some("key")).is_ok());
2285 }
2286
2287 #[test]
2288 fn factory_cohere() {
2289 assert!(create_provider("cohere", Some("key")).is_ok());
2290 }
2291
2292 #[test]
2293 fn factory_copilot() {
2294 assert!(create_provider("copilot", Some("key")).is_ok());
2295 assert!(create_provider("github-copilot", Some("key")).is_ok());
2296 }
2297
2298 #[test]
2299 fn factory_nvidia() {
2300 assert!(create_provider("nvidia", Some("nvapi-test")).is_ok());
2301 assert!(create_provider("nvidia-nim", Some("nvapi-test")).is_ok());
2302 assert!(create_provider("build.nvidia.com", Some("nvapi-test")).is_ok());
2303 }
2304
2305 #[test]
2308 fn factory_astrai() {
2309 assert!(create_provider("astrai", Some("sk-astrai-test")).is_ok());
2310 }
2311
2312 #[test]
2315 fn factory_custom_url() {
2316 let p = create_provider("custom:https://my-llm.example.com", Some("key"));
2317 assert!(p.is_ok());
2318 }
2319
2320 #[test]
2321 fn factory_custom_localhost() {
2322 let p = create_provider("custom:http://localhost:1234", Some("key"));
2323 assert!(p.is_ok());
2324 }
2325
2326 #[test]
2327 fn factory_custom_no_key() {
2328 let p = create_provider("custom:https://my-llm.example.com", None);
2329 assert!(p.is_ok());
2330 }
2331
2332 #[test]
2333 fn factory_custom_empty_url_errors() {
2334 match create_provider("custom:", None) {
2335 Err(e) => assert!(
2336 e.to_string().contains("requires a URL"),
2337 "Expected 'requires a URL', got: {e}"
2338 ),
2339 Ok(_) => panic!("Expected error for empty custom URL"),
2340 }
2341 }
2342
2343 #[test]
2344 fn factory_custom_invalid_url_errors() {
2345 match create_provider("custom:not-a-url", None) {
2346 Err(e) => assert!(
2347 e.to_string().contains("requires a valid URL"),
2348 "Expected 'requires a valid URL', got: {e}"
2349 ),
2350 Ok(_) => panic!("Expected error for invalid custom URL"),
2351 }
2352 }
2353
2354 #[test]
2355 fn factory_custom_unsupported_scheme_errors() {
2356 match create_provider("custom:ftp://example.com", None) {
2357 Err(e) => assert!(
2358 e.to_string().contains("http:// or https://"),
2359 "Expected scheme validation error, got: {e}"
2360 ),
2361 Ok(_) => panic!("Expected error for unsupported custom URL scheme"),
2362 }
2363 }
2364
2365 #[test]
2366 fn factory_custom_trims_whitespace() {
2367 let p = create_provider("custom: https://my-llm.example.com ", Some("key"));
2368 assert!(p.is_ok());
2369 }
2370
2371 #[test]
2374 fn factory_anthropic_custom_url() {
2375 let p = create_provider("anthropic-custom:https://api.example.com", Some("key"));
2376 assert!(p.is_ok());
2377 }
2378
2379 #[test]
2380 fn factory_anthropic_custom_trailing_slash() {
2381 let p = create_provider("anthropic-custom:https://api.example.com/", Some("key"));
2382 assert!(p.is_ok());
2383 }
2384
2385 #[test]
2386 fn factory_anthropic_custom_no_key() {
2387 let p = create_provider("anthropic-custom:https://api.example.com", None);
2388 assert!(p.is_ok());
2389 }
2390
2391 #[test]
2392 fn factory_anthropic_custom_empty_url_errors() {
2393 match create_provider("anthropic-custom:", None) {
2394 Err(e) => assert!(
2395 e.to_string().contains("requires a URL"),
2396 "Expected 'requires a URL', got: {e}"
2397 ),
2398 Ok(_) => panic!("Expected error for empty anthropic-custom URL"),
2399 }
2400 }
2401
2402 #[test]
2403 fn factory_anthropic_custom_invalid_url_errors() {
2404 match create_provider("anthropic-custom:not-a-url", None) {
2405 Err(e) => assert!(
2406 e.to_string().contains("requires a valid URL"),
2407 "Expected 'requires a valid URL', got: {e}"
2408 ),
2409 Ok(_) => panic!("Expected error for invalid anthropic-custom URL"),
2410 }
2411 }
2412
2413 #[test]
2414 fn factory_anthropic_custom_unsupported_scheme_errors() {
2415 match create_provider("anthropic-custom:ftp://example.com", None) {
2416 Err(e) => assert!(
2417 e.to_string().contains("http:// or https://"),
2418 "Expected scheme validation error, got: {e}"
2419 ),
2420 Ok(_) => panic!("Expected error for unsupported anthropic-custom URL scheme"),
2421 }
2422 }
2423
2424 #[test]
2427 fn factory_unknown_provider_errors() {
2428 let p = create_provider("nonexistent", None);
2429 assert!(p.is_err());
2430 let msg = p.err().unwrap().to_string();
2431 assert!(msg.contains("Unknown provider"));
2432 assert!(msg.contains("nonexistent"));
2433 }
2434
2435 #[test]
2436 fn factory_empty_name_errors() {
2437 assert!(create_provider("", None).is_err());
2438 }
2439
2440 #[test]
2441 fn resilient_provider_ignores_duplicate_and_invalid_fallbacks() {
2442 let reliability = crate::config::ReliabilityConfig {
2443 provider_retries: 1,
2444 provider_backoff_ms: 100,
2445 fallback_providers: vec![
2446 "openrouter".into(),
2447 "nonexistent-provider".into(),
2448 "openai".into(),
2449 "openai".into(),
2450 ],
2451 api_keys: Vec::new(),
2452 model_fallbacks: std::collections::HashMap::new(),
2453 channel_initial_backoff_secs: 2,
2454 channel_max_backoff_secs: 60,
2455 scheduler_poll_secs: 15,
2456 scheduler_retries: 2,
2457 };
2458
2459 let provider = create_resilient_provider(
2460 "openrouter",
2461 Some("provider-test-credential"),
2462 None,
2463 &reliability,
2464 );
2465 assert!(provider.is_ok());
2466 }
2467
2468 #[test]
2469 fn resilient_provider_errors_for_invalid_primary() {
2470 let reliability = crate::config::ReliabilityConfig::default();
2471 let provider = create_resilient_provider(
2472 "totally-invalid",
2473 Some("provider-test-credential"),
2474 None,
2475 &reliability,
2476 );
2477 assert!(provider.is_err());
2478 }
2479
2480 #[test]
2485 fn resilient_fallback_resolves_own_credential() {
2486 let reliability = crate::config::ReliabilityConfig {
2487 provider_retries: 1,
2488 provider_backoff_ms: 100,
2489 fallback_providers: vec!["lmstudio".into(), "ollama".into()],
2490 api_keys: Vec::new(),
2491 model_fallbacks: std::collections::HashMap::new(),
2492 channel_initial_backoff_secs: 2,
2493 channel_max_backoff_secs: 60,
2494 scheduler_poll_secs: 15,
2495 scheduler_retries: 2,
2496 };
2497
2498 let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
2501 assert!(provider.is_ok());
2502 }
2503
2504 #[test]
2507 fn resilient_fallback_supports_custom_url() {
2508 let reliability = crate::config::ReliabilityConfig {
2509 provider_retries: 1,
2510 provider_backoff_ms: 100,
2511 fallback_providers: vec!["custom:http://host.docker.internal:1234/v1".into()],
2512 api_keys: Vec::new(),
2513 model_fallbacks: std::collections::HashMap::new(),
2514 channel_initial_backoff_secs: 2,
2515 channel_max_backoff_secs: 60,
2516 scheduler_poll_secs: 15,
2517 scheduler_retries: 2,
2518 };
2519
2520 let provider =
2521 create_resilient_provider("openai", Some("openai-test-key"), None, &reliability);
2522 assert!(provider.is_ok());
2523 }
2524
2525 #[test]
2528 fn resilient_fallback_mixed_chain() {
2529 let reliability = crate::config::ReliabilityConfig {
2530 provider_retries: 1,
2531 provider_backoff_ms: 100,
2532 fallback_providers: vec![
2533 "deepseek".into(),
2534 "custom:http://localhost:8080/v1".into(),
2535 "nonexistent-provider".into(),
2536 "lmstudio".into(),
2537 ],
2538 api_keys: Vec::new(),
2539 model_fallbacks: std::collections::HashMap::new(),
2540 channel_initial_backoff_secs: 2,
2541 channel_max_backoff_secs: 60,
2542 scheduler_poll_secs: 15,
2543 scheduler_retries: 2,
2544 };
2545
2546 let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
2547 assert!(provider.is_ok());
2548 }
2549
2550 #[test]
2551 fn ollama_with_custom_url() {
2552 let provider = create_provider_with_url("ollama", None, Some("http://10.100.2.32:11434"));
2553 assert!(provider.is_ok());
2554 }
2555
2556 #[test]
2557 fn ollama_cloud_with_custom_url() {
2558 let provider =
2559 create_provider_with_url("ollama", Some("ollama-key"), Some("https://ollama.com"));
2560 assert!(provider.is_ok());
2561 }
2562
2563 #[test]
2565 fn resilient_fallback_includes_osaurus() {
2566 let reliability = crate::config::ReliabilityConfig {
2567 provider_retries: 1,
2568 provider_backoff_ms: 100,
2569 fallback_providers: vec!["osaurus".into(), "lmstudio".into()],
2570 api_keys: Vec::new(),
2571 model_fallbacks: std::collections::HashMap::new(),
2572 channel_initial_backoff_secs: 2,
2573 channel_max_backoff_secs: 60,
2574 scheduler_poll_secs: 15,
2575 scheduler_retries: 2,
2576 };
2577
2578 let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
2579 assert!(provider.is_ok());
2580 }
2581
2582 #[test]
2583 fn factory_all_providers_create_successfully() {
2584 let providers = [
2585 "openrouter",
2586 "anthropic",
2587 "openai",
2588 "ollama",
2589 "gemini",
2590 "venice",
2591 "vercel",
2592 "cloudflare",
2593 "moonshot",
2594 "moonshot-intl",
2595 "kimi-code",
2596 "moonshot-cn",
2597 "kimi-code",
2598 "synthetic",
2599 "opencode",
2600 "zai",
2601 "zai-cn",
2602 "glm",
2603 "glm-cn",
2604 "minimax",
2605 "minimax-cn",
2606 "bedrock",
2607 "qianfan",
2608 "doubao",
2609 "qwen",
2610 "qwen-intl",
2611 "qwen-cn",
2612 "qwen-us",
2613 "qwen-code",
2614 "lmstudio",
2615 "llamacpp",
2616 "sglang",
2617 "vllm",
2618 "osaurus",
2619 "telnyx",
2620 "groq",
2621 "mistral",
2622 "xai",
2623 "deepseek",
2624 "together",
2625 "fireworks",
2626 "novita",
2627 "perplexity",
2628 "cohere",
2629 "copilot",
2630 "nvidia",
2631 "astrai",
2632 "ovhcloud",
2633 ];
2634 for name in providers {
2635 assert!(
2636 create_provider(name, Some("test-key")).is_ok(),
2637 "Provider '{name}' should create successfully"
2638 );
2639 }
2640 }
2641
2642 #[test]
2643 fn listed_providers_have_unique_ids_and_aliases() {
2644 let providers = list_providers();
2645 let mut canonical_ids = std::collections::HashSet::new();
2646 let mut aliases = std::collections::HashSet::new();
2647
2648 for provider in providers {
2649 assert!(
2650 canonical_ids.insert(provider.name),
2651 "Duplicate canonical provider id: {}",
2652 provider.name
2653 );
2654
2655 for alias in provider.aliases {
2656 assert_ne!(
2657 *alias, provider.name,
2658 "Alias must differ from canonical id: {}",
2659 provider.name
2660 );
2661 assert!(
2662 !canonical_ids.contains(alias),
2663 "Alias conflicts with canonical provider id: {}",
2664 alias
2665 );
2666 assert!(aliases.insert(alias), "Duplicate provider alias: {}", alias);
2667 }
2668 }
2669 }
2670
2671 #[test]
2672 fn listed_providers_and_aliases_are_constructible() {
2673 for provider in list_providers() {
2674 assert!(
2675 create_provider(provider.name, Some("provider-test-credential")).is_ok(),
2676 "Canonical provider id should be constructible: {}",
2677 provider.name
2678 );
2679
2680 for alias in provider.aliases {
2681 assert!(
2682 create_provider(alias, Some("provider-test-credential")).is_ok(),
2683 "Provider alias should be constructible: {} (for {})",
2684 alias,
2685 provider.name
2686 );
2687 }
2688 }
2689 }
2690
2691 #[test]
2694 fn sanitize_scrubs_sk_prefix() {
2695 let input = "request failed: sk-1234567890abcdef";
2696 let out = sanitize_api_error(input);
2697 assert!(!out.contains("sk-1234567890abcdef"));
2698 assert!(out.contains("[REDACTED]"));
2699 }
2700
2701 #[test]
2702 fn sanitize_scrubs_multiple_prefixes() {
2703 let input = "keys sk-abcdef xoxb-12345 xoxp-67890";
2704 let out = sanitize_api_error(input);
2705 assert!(!out.contains("sk-abcdef"));
2706 assert!(!out.contains("xoxb-12345"));
2707 assert!(!out.contains("xoxp-67890"));
2708 }
2709
2710 #[test]
2711 fn sanitize_short_prefix_then_real_key() {
2712 let input = "error with sk- prefix and key sk-1234567890";
2713 let result = sanitize_api_error(input);
2714 assert!(!result.contains("sk-1234567890"));
2715 assert!(result.contains("[REDACTED]"));
2716 }
2717
2718 #[test]
2719 fn sanitize_sk_proj_comment_then_real_key() {
2720 let input = "note: sk- then sk-proj-abc123def456";
2721 let result = sanitize_api_error(input);
2722 assert!(!result.contains("sk-proj-abc123def456"));
2723 assert!(result.contains("[REDACTED]"));
2724 }
2725
2726 #[test]
2727 fn sanitize_keeps_bare_prefix() {
2728 let input = "only prefix sk- present";
2729 let result = sanitize_api_error(input);
2730 assert!(result.contains("sk-"));
2731 }
2732
2733 #[test]
2734 fn sanitize_handles_json_wrapped_key() {
2735 let input = r#"{"error":"invalid key sk-abc123xyz"}"#;
2736 let result = sanitize_api_error(input);
2737 assert!(!result.contains("sk-abc123xyz"));
2738 }
2739
2740 #[test]
2741 fn sanitize_handles_delimiter_boundaries() {
2742 let input = "bad token xoxb-abc123}; next";
2743 let result = sanitize_api_error(input);
2744 assert!(!result.contains("xoxb-abc123"));
2745 assert!(result.contains("};"));
2746 }
2747
2748 #[test]
2749 fn sanitize_truncates_long_error() {
2750 let long = "a".repeat(400);
2751 let result = sanitize_api_error(&long);
2752 assert!(result.len() <= 203);
2753 assert!(result.ends_with("..."));
2754 }
2755
2756 #[test]
2757 fn sanitize_truncates_after_scrub() {
2758 let input = format!("{} sk-abcdef123456 {}", "a".repeat(190), "b".repeat(190));
2759 let result = sanitize_api_error(&input);
2760 assert!(!result.contains("sk-abcdef123456"));
2761 assert!(result.len() <= 203);
2762 }
2763
2764 #[test]
2765 fn sanitize_preserves_unicode_boundaries() {
2766 let input = format!("{} sk-abcdef123", "hello🙂".repeat(80));
2767 let result = sanitize_api_error(&input);
2768 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
2769 assert!(!result.contains("sk-abcdef123"));
2770 }
2771
2772 #[test]
2773 fn sanitize_no_secret_no_change() {
2774 let input = "simple upstream timeout";
2775 let result = sanitize_api_error(input);
2776 assert_eq!(result, input);
2777 }
2778
2779 #[test]
2780 fn scrub_github_personal_access_token() {
2781 let input = "auth failed with token ghp_abc123def456";
2782 let result = scrub_secret_patterns(input);
2783 assert_eq!(result, "auth failed with token [REDACTED]");
2784 }
2785
2786 #[test]
2787 fn scrub_github_oauth_token() {
2788 let input = "Bearer gho_1234567890abcdef";
2789 let result = scrub_secret_patterns(input);
2790 assert_eq!(result, "Bearer [REDACTED]");
2791 }
2792
2793 #[test]
2794 fn scrub_github_user_token() {
2795 let input = "token ghu_sessiontoken123";
2796 let result = scrub_secret_patterns(input);
2797 assert_eq!(result, "token [REDACTED]");
2798 }
2799
2800 #[test]
2801 fn scrub_github_fine_grained_pat() {
2802 let input = "failed: github_pat_11AABBC_xyzzy789";
2803 let result = scrub_secret_patterns(input);
2804 assert_eq!(result, "failed: [REDACTED]");
2805 }
2806
2807 #[test]
2810 fn parse_provider_profile_plain_name() {
2811 let (name, profile) = parse_provider_profile("gemini");
2812 assert_eq!(name, "gemini");
2813 assert_eq!(profile, None);
2814 }
2815
2816 #[test]
2817 fn parse_provider_profile_with_profile() {
2818 let (name, profile) = parse_provider_profile("openai-codex:second");
2819 assert_eq!(name, "openai-codex");
2820 assert_eq!(profile, Some("second"));
2821 }
2822
2823 #[test]
2824 fn parse_provider_profile_custom_url_not_split() {
2825 let input = "custom:https://my-api.example.com/v1";
2826 let (name, profile) = parse_provider_profile(input);
2827 assert_eq!(name, input);
2828 assert_eq!(profile, None);
2829 }
2830
2831 #[test]
2832 fn parse_provider_profile_anthropic_custom_not_split() {
2833 let input = "anthropic-custom:https://bedrock.example.com";
2834 let (name, profile) = parse_provider_profile(input);
2835 assert_eq!(name, input);
2836 assert_eq!(profile, None);
2837 }
2838
2839 #[test]
2840 fn parse_provider_profile_empty_profile_ignored() {
2841 let (name, profile) = parse_provider_profile("openai-codex:");
2842 assert_eq!(name, "openai-codex:");
2843 assert_eq!(profile, None);
2844 }
2845
2846 #[test]
2847 fn parse_provider_profile_extra_colons_kept() {
2848 let (name, profile) = parse_provider_profile("provider:profile:extra");
2849 assert_eq!(name, "provider");
2850 assert_eq!(profile, Some("profile:extra"));
2851 }
2852
2853 #[test]
2856 fn resilient_fallback_with_profile_syntax() {
2857 let _guard = env_lock();
2858
2859 let reliability = crate::config::ReliabilityConfig {
2860 provider_retries: 1,
2861 provider_backoff_ms: 100,
2862 fallback_providers: vec!["openai-codex:second".into()],
2863 api_keys: Vec::new(),
2864 model_fallbacks: std::collections::HashMap::new(),
2865 channel_initial_backoff_secs: 2,
2866 channel_max_backoff_secs: 60,
2867 scheduler_poll_secs: 15,
2868 scheduler_retries: 2,
2869 };
2870
2871 let provider = create_resilient_provider("lmstudio", None, None, &reliability);
2876 assert!(provider.is_ok());
2877 }
2878
2879 #[test]
2880 fn resilient_fallback_mixed_profiles_and_custom() {
2881 let _guard = env_lock();
2882
2883 let reliability = crate::config::ReliabilityConfig {
2884 provider_retries: 1,
2885 provider_backoff_ms: 100,
2886 fallback_providers: vec![
2887 "openai-codex:second".into(),
2888 "custom:http://localhost:8080/v1".into(),
2889 "lmstudio".into(),
2890 "nonexistent-provider".into(),
2891 ],
2892 api_keys: Vec::new(),
2893 model_fallbacks: std::collections::HashMap::new(),
2894 channel_initial_backoff_secs: 2,
2895 channel_max_backoff_secs: 60,
2896 scheduler_poll_secs: 15,
2897 scheduler_retries: 2,
2898 };
2899
2900 let provider = create_resilient_provider("ollama", None, None, &reliability);
2901 assert!(provider.is_ok());
2902 }
2903}