1pub mod anthropic;
20pub mod azure_openai;
21pub mod bedrock;
22pub mod claude_code;
23pub mod compatible;
24pub mod copilot;
25pub mod gemini;
26pub mod gemini_cli;
27pub mod kilocli;
28pub mod ollama;
29pub mod openai;
30pub mod openai_codex;
31pub mod openrouter;
32pub mod reliable;
33pub mod router;
34pub mod telnyx;
35pub mod traits;
36
37#[allow(unused_imports)]
38pub use traits::{
39 ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ProviderCapabilityError,
40 ToolCall, ToolResultMessage,
41};
42
43use crate::auth::AuthService;
44use compatible::{AuthStyle, OpenAiCompatibleProvider};
45use reliable::ReliableProvider;
46use serde::Deserialize;
47use std::path::PathBuf;
48
49const MAX_API_ERROR_CHARS: usize = 500;
50const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
51const MINIMAX_CN_BASE_URL: &str = "https://api.minimaxi.com/v1";
52const MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT: &str = "https://api.minimax.io/oauth/token";
53const MINIMAX_OAUTH_CN_TOKEN_ENDPOINT: &str = "https://api.minimaxi.com/oauth/token";
54const MINIMAX_OAUTH_PLACEHOLDER: &str = "minimax-oauth";
55const MINIMAX_OAUTH_CN_PLACEHOLDER: &str = "minimax-oauth-cn";
56const MINIMAX_OAUTH_TOKEN_ENV: &str = "MINIMAX_OAUTH_TOKEN";
57const MINIMAX_API_KEY_ENV: &str = "MINIMAX_API_KEY";
58const MINIMAX_OAUTH_REFRESH_TOKEN_ENV: &str = "MINIMAX_OAUTH_REFRESH_TOKEN";
59const MINIMAX_OAUTH_REGION_ENV: &str = "MINIMAX_OAUTH_REGION";
60const MINIMAX_OAUTH_CLIENT_ID_ENV: &str = "MINIMAX_OAUTH_CLIENT_ID";
61const MINIMAX_OAUTH_DEFAULT_CLIENT_ID: &str = "78257093-7e40-4613-99e0-527b14b39113";
62const GLM_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
63const GLM_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
64const MOONSHOT_INTL_BASE_URL: &str = "https://api.moonshot.ai/v1";
65const MOONSHOT_CN_BASE_URL: &str = "https://api.moonshot.cn/v1";
66const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
67const QWEN_INTL_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
68const QWEN_US_BASE_URL: &str = "https://dashscope-us.aliyuncs.com/compatible-mode/v1";
69const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
70const BAILIAN_BASE_URL: &str = "https://coding.dashscope.aliyuncs.com/v1";
71const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
72const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
73const QWEN_OAUTH_TOKEN_ENV: &str = "QWEN_OAUTH_TOKEN";
74const QWEN_OAUTH_REFRESH_TOKEN_ENV: &str = "QWEN_OAUTH_REFRESH_TOKEN";
75const QWEN_OAUTH_RESOURCE_URL_ENV: &str = "QWEN_OAUTH_RESOURCE_URL";
76const QWEN_OAUTH_CLIENT_ID_ENV: &str = "QWEN_OAUTH_CLIENT_ID";
77const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
78const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
79const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
80const ZAI_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
81const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
82const VERCEL_AI_GATEWAY_BASE_URL: &str = "https://ai-gateway.vercel.sh/v1";
83
84pub(crate) fn is_minimax_intl_alias(name: &str) -> bool {
85 matches!(
86 name,
87 "minimax"
88 | "minimax-intl"
89 | "minimax-io"
90 | "minimax-global"
91 | "minimax-oauth"
92 | "minimax-portal"
93 | "minimax-oauth-global"
94 | "minimax-portal-global"
95 )
96}
97
98pub(crate) fn is_minimax_cn_alias(name: &str) -> bool {
99 matches!(
100 name,
101 "minimax-cn" | "minimaxi" | "minimax-oauth-cn" | "minimax-portal-cn"
102 )
103}
104
105pub(crate) fn is_minimax_alias(name: &str) -> bool {
106 is_minimax_intl_alias(name) || is_minimax_cn_alias(name)
107}
108
109pub(crate) fn is_glm_global_alias(name: &str) -> bool {
110 matches!(name, "glm" | "zhipu" | "glm-global" | "zhipu-global")
111}
112
113pub(crate) fn is_glm_cn_alias(name: &str) -> bool {
114 matches!(name, "glm-cn" | "zhipu-cn" | "bigmodel")
115}
116
117pub(crate) fn is_glm_alias(name: &str) -> bool {
118 is_glm_global_alias(name) || is_glm_cn_alias(name)
119}
120
121pub(crate) fn is_moonshot_intl_alias(name: &str) -> bool {
122 matches!(
123 name,
124 "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
125 )
126}
127
128pub(crate) fn is_moonshot_cn_alias(name: &str) -> bool {
129 matches!(name, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn")
130}
131
132pub(crate) fn is_moonshot_alias(name: &str) -> bool {
133 is_moonshot_intl_alias(name) || is_moonshot_cn_alias(name)
134}
135
136pub(crate) fn is_qwen_cn_alias(name: &str) -> bool {
137 matches!(name, "qwen" | "dashscope" | "qwen-cn" | "dashscope-cn")
138}
139
140pub(crate) fn is_qwen_intl_alias(name: &str) -> bool {
141 matches!(
142 name,
143 "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
144 )
145}
146
147pub(crate) fn is_qwen_us_alias(name: &str) -> bool {
148 matches!(name, "qwen-us" | "dashscope-us")
149}
150
151pub(crate) fn is_qwen_oauth_alias(name: &str) -> bool {
152 matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
153}
154
155pub(crate) fn is_bailian_alias(name: &str) -> bool {
156 matches!(name, "bailian" | "aliyun-bailian" | "aliyun")
157}
158
159pub(crate) fn is_qwen_alias(name: &str) -> bool {
160 is_qwen_cn_alias(name)
161 || is_qwen_intl_alias(name)
162 || is_qwen_us_alias(name)
163 || is_qwen_oauth_alias(name)
164}
165
166pub(crate) fn is_zai_global_alias(name: &str) -> bool {
167 matches!(name, "zai" | "z.ai" | "zai-global" | "z.ai-global")
168}
169
170pub(crate) fn is_zai_cn_alias(name: &str) -> bool {
171 matches!(name, "zai-cn" | "z.ai-cn")
172}
173
174pub(crate) fn is_zai_alias(name: &str) -> bool {
175 is_zai_global_alias(name) || is_zai_cn_alias(name)
176}
177
178pub(crate) fn is_qianfan_alias(name: &str) -> bool {
179 matches!(name, "qianfan" | "baidu")
180}
181
182fn qianfan_base_url(api_url: Option<&str>) -> String {
183 api_url
184 .map(str::trim)
185 .filter(|value| !value.is_empty())
186 .map(ToString::to_string)
187 .unwrap_or_else(|| QIANFAN_BASE_URL.to_string())
188}
189
190pub(crate) fn is_doubao_alias(name: &str) -> bool {
191 matches!(name, "doubao" | "volcengine" | "ark" | "doubao-cn")
192}
193
194#[derive(Clone, Copy, Debug)]
195enum MinimaxOauthRegion {
196 Global,
197 Cn,
198}
199
200impl MinimaxOauthRegion {
201 fn token_endpoint(self) -> &'static str {
202 match self {
203 Self::Global => MINIMAX_OAUTH_GLOBAL_TOKEN_ENDPOINT,
204 Self::Cn => MINIMAX_OAUTH_CN_TOKEN_ENDPOINT,
205 }
206 }
207}
208
209#[derive(Debug, Deserialize)]
210struct MinimaxOauthRefreshResponse {
211 #[serde(default)]
212 status: Option<String>,
213 #[serde(default)]
214 access_token: Option<String>,
215 #[serde(default)]
216 base_resp: Option<MinimaxOauthBaseResponse>,
217}
218
219#[derive(Debug, Deserialize)]
220struct MinimaxOauthBaseResponse {
221 #[serde(default)]
222 status_msg: Option<String>,
223}
224
225#[derive(Clone, Deserialize, Default)]
226struct QwenOauthCredentials {
227 #[serde(default)]
228 access_token: Option<String>,
229 #[serde(default)]
230 refresh_token: Option<String>,
231 #[serde(default)]
232 resource_url: Option<String>,
233 #[serde(default)]
234 expiry_date: Option<i64>,
235}
236
237impl std::fmt::Debug for QwenOauthCredentials {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 f.debug_struct("QwenOauthCredentials")
240 .field("resource_url", &self.resource_url)
241 .field("expiry_date", &self.expiry_date)
242 .finish_non_exhaustive()
243 }
244}
245
246#[derive(Debug, Deserialize)]
247struct QwenOauthTokenResponse {
248 #[serde(default)]
249 access_token: Option<String>,
250 #[serde(default)]
251 refresh_token: Option<String>,
252 #[serde(default)]
253 expires_in: Option<i64>,
254 #[serde(default)]
255 resource_url: Option<String>,
256 #[serde(default)]
257 error: Option<String>,
258 #[serde(default)]
259 error_description: Option<String>,
260}
261
262#[derive(Clone, Default)]
263struct QwenOauthProviderContext {
264 credential: Option<String>,
265 base_url: Option<String>,
266}
267
268impl std::fmt::Debug for QwenOauthProviderContext {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 f.debug_struct("QwenOauthProviderContext")
271 .field("base_url", &self.base_url)
272 .finish_non_exhaustive()
273 }
274}
275
276fn read_non_empty_env(name: &str) -> Option<String> {
277 std::env::var(name)
278 .ok()
279 .map(|value| value.trim().to_string())
280 .filter(|value| !value.is_empty())
281}
282
283fn is_minimax_oauth_placeholder(value: &str) -> bool {
284 value.eq_ignore_ascii_case(MINIMAX_OAUTH_PLACEHOLDER)
285 || value.eq_ignore_ascii_case(MINIMAX_OAUTH_CN_PLACEHOLDER)
286}
287
288fn minimax_oauth_region(name: &str) -> MinimaxOauthRegion {
289 if let Some(region) = read_non_empty_env(MINIMAX_OAUTH_REGION_ENV) {
290 let normalized = region.to_ascii_lowercase();
291 if matches!(normalized.as_str(), "cn" | "china") {
292 return MinimaxOauthRegion::Cn;
293 }
294 if matches!(normalized.as_str(), "global" | "intl" | "international") {
295 return MinimaxOauthRegion::Global;
296 }
297 }
298
299 if is_minimax_cn_alias(name) {
300 MinimaxOauthRegion::Cn
301 } else {
302 MinimaxOauthRegion::Global
303 }
304}
305
306fn minimax_oauth_client_id() -> String {
307 read_non_empty_env(MINIMAX_OAUTH_CLIENT_ID_ENV)
308 .unwrap_or_else(|| MINIMAX_OAUTH_DEFAULT_CLIENT_ID.to_string())
309}
310
311fn qwen_oauth_client_id() -> String {
312 read_non_empty_env(QWEN_OAUTH_CLIENT_ID_ENV)
313 .unwrap_or_else(|| QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string())
314}
315
316fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
317 std::env::var_os("HOME")
318 .map(PathBuf::from)
319 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
320 .map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
321}
322
323fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
324 let trimmed = raw.trim().trim_end_matches('/');
325 if trimmed.is_empty() {
326 return None;
327 }
328
329 let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
330 trimmed.to_string()
331 } else {
332 format!("https://{trimmed}")
333 };
334
335 let normalized = with_scheme.trim_end_matches('/').to_string();
336 if normalized.ends_with("/v1") {
337 Some(normalized)
338 } else {
339 Some(format!("{normalized}/v1"))
340 }
341}
342
343fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
344 let path = qwen_oauth_credentials_file_path()?;
345 let content = std::fs::read_to_string(path).ok()?;
346 serde_json::from_str::<QwenOauthCredentials>(&content).ok()
347}
348
349fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
350 if raw < 10_000_000_000 {
351 raw.saturating_mul(1000)
352 } else {
353 raw
354 }
355}
356
357fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
358 let Some(expiry) = credentials.expiry_date else {
359 return false;
360 };
361
362 let expiry_millis = normalized_qwen_expiry_millis(expiry);
363 let now_millis = std::time::SystemTime::now()
364 .duration_since(std::time::UNIX_EPOCH)
365 .ok()
366 .and_then(|duration| i64::try_from(duration.as_millis()).ok())
367 .unwrap_or(i64::MAX);
368
369 expiry_millis <= now_millis.saturating_add(30_000)
370}
371
372fn refresh_qwen_oauth_access_token(refresh_token: &str) -> anyhow::Result<QwenOauthCredentials> {
373 let client_id = qwen_oauth_client_id();
374 let client = reqwest::blocking::Client::builder()
375 .timeout(std::time::Duration::from_secs(15))
376 .connect_timeout(std::time::Duration::from_secs(5))
377 .build()
378 .unwrap_or_else(|_| reqwest::blocking::Client::new());
379
380 let response = client
381 .post(QWEN_OAUTH_TOKEN_ENDPOINT)
382 .header("Content-Type", "application/x-www-form-urlencoded")
383 .header("Accept", "application/json")
384 .form(&[
385 ("grant_type", "refresh_token"),
386 ("refresh_token", refresh_token),
387 ("client_id", client_id.as_str()),
388 ])
389 .send()
390 .map_err(|error| anyhow::anyhow!("Qwen OAuth refresh request failed: {error}"))?;
391
392 let status = response.status();
393 let body = response
394 .text()
395 .unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
396
397 let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
398
399 if !status.is_success() {
400 let detail = parsed
401 .as_ref()
402 .and_then(|payload| payload.error_description.as_deref())
403 .or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
404 .filter(|msg| !msg.trim().is_empty())
405 .unwrap_or(body.as_str());
406 anyhow::bail!("Qwen OAuth refresh failed (HTTP {status}): {detail}");
407 }
408
409 let payload =
410 parsed.ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response is not JSON"))?;
411
412 if let Some(error_code) = payload
413 .error
414 .as_deref()
415 .filter(|value| !value.trim().is_empty())
416 {
417 let detail = payload.error_description.as_deref().unwrap_or(error_code);
418 anyhow::bail!("Qwen OAuth refresh failed: {detail}");
419 }
420
421 let access_token = payload
422 .access_token
423 .as_deref()
424 .map(str::trim)
425 .filter(|token| !token.is_empty())
426 .ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response missing access_token"))?
427 .to_string();
428
429 let expiry_date = payload.expires_in.and_then(|seconds| {
430 let now_secs = std::time::SystemTime::now()
431 .duration_since(std::time::UNIX_EPOCH)
432 .ok()
433 .and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
434 now_secs
435 .checked_add(seconds)
436 .and_then(|unix_secs| unix_secs.checked_mul(1000))
437 });
438
439 Ok(QwenOauthCredentials {
440 access_token: Some(access_token),
441 refresh_token: payload
442 .refresh_token
443 .as_deref()
444 .map(str::trim)
445 .filter(|value| !value.is_empty())
446 .map(ToString::to_string),
447 resource_url: payload
448 .resource_url
449 .as_deref()
450 .map(str::trim)
451 .filter(|value| !value.is_empty())
452 .map(ToString::to_string),
453 expiry_date,
454 })
455}
456
457fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
458 let override_value = credential_override
459 .map(str::trim)
460 .filter(|value| !value.is_empty());
461 let placeholder_requested = override_value
462 .map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
463 .unwrap_or(false);
464
465 if let Some(explicit) = override_value {
466 if !placeholder_requested {
467 return QwenOauthProviderContext {
468 credential: Some(explicit.to_string()),
469 base_url: None,
470 };
471 }
472 }
473
474 let mut cached = read_qwen_oauth_cached_credentials();
475
476 let env_token = read_non_empty_env(QWEN_OAUTH_TOKEN_ENV);
477 let env_refresh_token = read_non_empty_env(QWEN_OAUTH_REFRESH_TOKEN_ENV);
478 let env_resource_url = read_non_empty_env(QWEN_OAUTH_RESOURCE_URL_ENV);
479
480 if env_token.is_none() {
481 let refresh_token = env_refresh_token.clone().or_else(|| {
482 cached
483 .as_ref()
484 .and_then(|credentials| credentials.refresh_token.clone())
485 });
486
487 let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
488 || cached
489 .as_ref()
490 .and_then(|credentials| credentials.access_token.as_deref())
491 .is_none_or(|value| value.trim().is_empty());
492
493 if should_refresh {
494 if let Some(refresh_token) = refresh_token.as_deref() {
495 match refresh_qwen_oauth_access_token(refresh_token) {
496 Ok(refreshed) => {
497 cached = Some(refreshed);
498 }
499 Err(error) => {
500 tracing::warn!(error = %error, "Qwen OAuth refresh failed");
501 }
502 }
503 }
504 }
505 }
506
507 let mut credential = env_token.or_else(|| {
508 cached
509 .as_ref()
510 .and_then(|credentials| credentials.access_token.clone())
511 });
512 credential = credential
513 .as_deref()
514 .map(str::trim)
515 .filter(|value| !value.is_empty())
516 .map(ToString::to_string);
517
518 if credential.is_none() && !placeholder_requested {
519 credential = read_non_empty_env("DASHSCOPE_API_KEY");
520 }
521
522 let base_url = env_resource_url
523 .as_deref()
524 .and_then(normalize_qwen_oauth_base_url)
525 .or_else(|| {
526 cached
527 .as_ref()
528 .and_then(|credentials| credentials.resource_url.as_deref())
529 .and_then(normalize_qwen_oauth_base_url)
530 });
531
532 QwenOauthProviderContext {
533 credential,
534 base_url,
535 }
536}
537
538fn resolve_minimax_static_credential() -> Option<String> {
539 read_non_empty_env(MINIMAX_OAUTH_TOKEN_ENV).or_else(|| read_non_empty_env(MINIMAX_API_KEY_ENV))
540}
541
542fn refresh_minimax_oauth_access_token(name: &str, refresh_token: &str) -> anyhow::Result<String> {
543 let region = minimax_oauth_region(name);
544 let endpoint = region.token_endpoint();
545 let client_id = minimax_oauth_client_id();
546 let client = reqwest::blocking::Client::builder()
547 .timeout(std::time::Duration::from_secs(15))
548 .connect_timeout(std::time::Duration::from_secs(5))
549 .build()
550 .unwrap_or_else(|_| reqwest::blocking::Client::new());
551
552 let response = client
553 .post(endpoint)
554 .header("Content-Type", "application/x-www-form-urlencoded")
555 .header("Accept", "application/json")
556 .form(&[
557 ("grant_type", "refresh_token"),
558 ("refresh_token", refresh_token),
559 ("client_id", client_id.as_str()),
560 ])
561 .send()
562 .map_err(|error| anyhow::anyhow!("MiniMax OAuth refresh request failed: {error}"))?;
563
564 let status = response.status();
565 let body = response
566 .text()
567 .unwrap_or_else(|_| "<failed to read MiniMax OAuth response body>".to_string());
568
569 let parsed = serde_json::from_str::<MinimaxOauthRefreshResponse>(&body).ok();
570
571 if !status.is_success() {
572 let detail = parsed
573 .as_ref()
574 .and_then(|payload| payload.base_resp.as_ref())
575 .and_then(|base| base.status_msg.as_deref())
576 .filter(|msg| !msg.trim().is_empty())
577 .unwrap_or(body.as_str());
578 anyhow::bail!("MiniMax OAuth refresh failed (HTTP {status}): {detail}");
579 }
580
581 if let Some(payload) = parsed {
582 if let Some(status_text) = payload.status.as_deref() {
583 if !status_text.eq_ignore_ascii_case("success") {
584 let detail = payload
585 .base_resp
586 .as_ref()
587 .and_then(|base| base.status_msg.as_deref())
588 .unwrap_or(status_text);
589 anyhow::bail!("MiniMax OAuth refresh failed: {detail}");
590 }
591 }
592
593 if let Some(token) = payload
594 .access_token
595 .as_deref()
596 .map(str::trim)
597 .filter(|token| !token.is_empty())
598 {
599 return Ok(token.to_string());
600 }
601 }
602
603 anyhow::bail!("MiniMax OAuth refresh response missing access_token");
604}
605
606fn resolve_minimax_oauth_refresh_token(name: &str) -> Option<String> {
607 let refresh_token = read_non_empty_env(MINIMAX_OAUTH_REFRESH_TOKEN_ENV)?;
608
609 match refresh_minimax_oauth_access_token(name, &refresh_token) {
610 Ok(token) => Some(token),
611 Err(error) => {
612 tracing::warn!(provider = name, error = %error, "MiniMax OAuth refresh failed");
613 None
614 }
615 }
616}
617
618pub(crate) fn canonical_china_provider_name(name: &str) -> Option<&'static str> {
619 if is_qwen_alias(name) {
620 Some("qwen")
621 } else if is_glm_alias(name) {
622 Some("glm")
623 } else if is_moonshot_alias(name) {
624 Some("moonshot")
625 } else if is_minimax_alias(name) {
626 Some("minimax")
627 } else if is_zai_alias(name) {
628 Some("zai")
629 } else if is_qianfan_alias(name) {
630 Some("qianfan")
631 } else if is_doubao_alias(name) {
632 Some("doubao")
633 } else if is_bailian_alias(name) {
634 Some("bailian")
635 } else {
636 None
637 }
638}
639
640fn minimax_base_url(name: &str) -> Option<&'static str> {
641 if is_minimax_cn_alias(name) {
642 Some(MINIMAX_CN_BASE_URL)
643 } else if is_minimax_intl_alias(name) {
644 Some(MINIMAX_INTL_BASE_URL)
645 } else {
646 None
647 }
648}
649
650fn glm_base_url(name: &str) -> Option<&'static str> {
651 if is_glm_cn_alias(name) {
652 Some(GLM_CN_BASE_URL)
653 } else if is_glm_global_alias(name) {
654 Some(GLM_GLOBAL_BASE_URL)
655 } else {
656 None
657 }
658}
659
660fn moonshot_base_url(name: &str) -> Option<&'static str> {
661 if is_moonshot_intl_alias(name) {
662 Some(MOONSHOT_INTL_BASE_URL)
663 } else if is_moonshot_cn_alias(name) {
664 Some(MOONSHOT_CN_BASE_URL)
665 } else {
666 None
667 }
668}
669
670fn qwen_base_url(name: &str) -> Option<&'static str> {
671 if is_qwen_cn_alias(name) || is_qwen_oauth_alias(name) {
672 Some(QWEN_CN_BASE_URL)
673 } else if is_qwen_intl_alias(name) {
674 Some(QWEN_INTL_BASE_URL)
675 } else if is_qwen_us_alias(name) {
676 Some(QWEN_US_BASE_URL)
677 } else {
678 None
679 }
680}
681
682fn zai_base_url(name: &str) -> Option<&'static str> {
683 if is_zai_cn_alias(name) {
684 Some(ZAI_CN_BASE_URL)
685 } else if is_zai_global_alias(name) {
686 Some(ZAI_GLOBAL_BASE_URL)
687 } else {
688 None
689 }
690}
691
692#[derive(Debug, Clone)]
693pub struct ProviderRuntimeOptions {
694 pub auth_profile_override: Option<String>,
695 pub provider_api_url: Option<String>,
696 pub construct_dir: Option<PathBuf>,
697 pub secrets_encrypt: bool,
698 pub reasoning_enabled: Option<bool>,
699 pub reasoning_effort: Option<String>,
700 pub provider_timeout_secs: Option<u64>,
703 pub extra_headers: std::collections::HashMap<String, String>,
706 pub api_path: Option<String>,
709 pub provider_max_tokens: Option<u32>,
712}
713
714impl Default for ProviderRuntimeOptions {
715 fn default() -> Self {
716 Self {
717 auth_profile_override: None,
718 provider_api_url: None,
719 construct_dir: None,
720 secrets_encrypt: true,
721 reasoning_enabled: None,
722 reasoning_effort: None,
723 provider_timeout_secs: None,
724 extra_headers: std::collections::HashMap::new(),
725 api_path: None,
726 provider_max_tokens: None,
727 }
728 }
729}
730
731pub fn provider_runtime_options_from_config(
732 config: &crate::config::Config,
733) -> ProviderRuntimeOptions {
734 ProviderRuntimeOptions {
735 auth_profile_override: None,
736 provider_api_url: config.api_url.clone(),
737 construct_dir: config.config_path.parent().map(PathBuf::from),
738 secrets_encrypt: config.secrets.encrypt,
739 reasoning_enabled: config.runtime.reasoning_enabled,
740 reasoning_effort: config.runtime.reasoning_effort.clone(),
741 provider_timeout_secs: Some(config.provider_timeout_secs),
742 extra_headers: config.extra_headers.clone(),
743 api_path: config.api_path.clone(),
744 provider_max_tokens: config.provider_max_tokens,
745 }
746}
747
748fn is_secret_char(c: char) -> bool {
749 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
750}
751
752fn token_end(input: &str, from: usize) -> usize {
753 let mut end = from;
754 for (i, c) in input[from..].char_indices() {
755 if is_secret_char(c) {
756 end = from + i + c.len_utf8();
757 } else {
758 break;
759 }
760 }
761 end
762}
763
764pub fn scrub_secret_patterns(input: &str) -> String {
769 const PREFIXES: [&str; 7] = [
770 "sk-",
771 "xoxb-",
772 "xoxp-",
773 "ghp_",
774 "gho_",
775 "ghu_",
776 "github_pat_",
777 ];
778
779 let mut scrubbed = input.to_string();
780
781 for prefix in PREFIXES {
782 let mut search_from = 0;
783 loop {
784 let Some(rel) = scrubbed[search_from..].find(prefix) else {
785 break;
786 };
787
788 let start = search_from + rel;
789 let content_start = start + prefix.len();
790 let end = token_end(&scrubbed, content_start);
791
792 if end == content_start {
794 search_from = content_start;
795 continue;
796 }
797
798 scrubbed.replace_range(start..end, "[REDACTED]");
799 search_from = start + "[REDACTED]".len();
800 }
801 }
802
803 scrubbed
804}
805
806pub fn sanitize_api_error(input: &str) -> String {
808 let scrubbed = scrub_secret_patterns(input);
809
810 if scrubbed.chars().count() <= MAX_API_ERROR_CHARS {
811 return scrubbed;
812 }
813
814 let mut end = MAX_API_ERROR_CHARS;
815 while end > 0 && !scrubbed.is_char_boundary(end) {
816 end -= 1;
817 }
818
819 format!("{}...", &scrubbed[..end])
820}
821
822pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::Error {
824 let status = response.status();
825 let body = response
826 .text()
827 .await
828 .unwrap_or_else(|_| "<failed to read provider error body>".to_string());
829 let sanitized = sanitize_api_error(&body);
830 anyhow::anyhow!("{provider} API error ({status}): {sanitized}")
831}
832
833pub(crate) fn resolve_provider_credential(
847 name: &str,
848 credential_override: Option<&str>,
849) -> Option<String> {
850 let mut minimax_oauth_placeholder_requested = false;
851
852 if let Some(raw_override) = credential_override {
853 let trimmed_override = raw_override.trim();
854 if !trimmed_override.is_empty() {
855 if is_minimax_alias(name) && is_minimax_oauth_placeholder(trimmed_override) {
856 minimax_oauth_placeholder_requested = true;
857 if let Some(credential) = resolve_minimax_static_credential() {
858 return Some(credential);
859 }
860 if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
861 return Some(credential);
862 }
863 } else if name == "anthropic" || name == "openai" || name == "groq" {
864 let env_candidates: &[&str] = match name {
869 "anthropic" => &["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
870 "openai" => &["OPENAI_API_KEY"],
871 "groq" => &["GROQ_API_KEY"],
872 _ => &[],
873 };
874 for env_var in env_candidates {
875 if let Ok(val) = std::env::var(env_var) {
876 let trimmed = val.trim().to_string();
877 if !trimmed.is_empty() {
878 return Some(trimmed);
879 }
880 }
881 }
882 return Some(trimmed_override.to_owned());
883 } else {
884 return Some(trimmed_override.to_owned());
885 }
886 }
887 }
888
889 let provider_env_candidates: Vec<&str> = match name {
890 "anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
891 "openrouter" => vec!["OPENROUTER_API_KEY"],
892 "openai" => vec!["OPENAI_API_KEY"],
893 "ollama" => vec!["OLLAMA_API_KEY"],
894 "venice" => vec!["VENICE_API_KEY"],
895 "groq" => vec!["GROQ_API_KEY"],
896 "mistral" => vec!["MISTRAL_API_KEY"],
897 "deepseek" => vec!["DEEPSEEK_API_KEY"],
898 "xai" | "grok" => vec!["XAI_API_KEY"],
899 "together" | "together-ai" => vec!["TOGETHER_API_KEY"],
900 "fireworks" | "fireworks-ai" => vec!["FIREWORKS_API_KEY"],
901 "novita" => vec!["NOVITA_API_KEY"],
902 "perplexity" => vec!["PERPLEXITY_API_KEY"],
903 "cohere" => vec!["COHERE_API_KEY"],
904 name if is_moonshot_alias(name) => vec!["MOONSHOT_API_KEY"],
905 "kimi-code" | "kimi_coding" | "kimi_for_coding" => {
906 vec!["KIMI_CODE_API_KEY", "MOONSHOT_API_KEY"]
907 }
908 name if is_glm_alias(name) => vec!["GLM_API_KEY"],
909 name if is_minimax_alias(name) => vec![MINIMAX_OAUTH_TOKEN_ENV, MINIMAX_API_KEY_ENV],
910 "bedrock" | "aws-bedrock" => {
914 if let Ok(val) = std::env::var("BEDROCK_API_KEY") {
915 let trimmed = val.trim().to_string();
916 if !trimmed.is_empty() {
917 return Some(trimmed);
918 }
919 }
920 return None;
921 }
922 name if is_qianfan_alias(name) => vec!["QIANFAN_API_KEY"],
923 name if is_doubao_alias(name) => {
924 vec!["ARK_API_KEY", "VOLCENGINE_API_KEY", "DOUBAO_API_KEY"]
925 }
926 name if is_qwen_alias(name) => vec!["DASHSCOPE_API_KEY"],
927 name if is_bailian_alias(name) => vec!["BAILIAN_API_KEY", "DASHSCOPE_API_KEY"],
928 name if is_zai_alias(name) => vec!["ZAI_API_KEY"],
929 "nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
930 "synthetic" => vec!["SYNTHETIC_API_KEY"],
931 "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
932 "opencode-go" => vec!["OPENCODE_GO_API_KEY"],
933 "vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
934 "cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
935 "ovhcloud" | "ovh" => vec!["OVH_AI_ENDPOINTS_ACCESS_TOKEN"],
936 "astrai" => vec!["ASTRAI_API_KEY"],
937 "avian" => vec!["AVIAN_API_KEY"],
938 "deepmyst" | "deep-myst" => vec!["DEEPMYST_API_KEY"],
939 "llamacpp" | "llama.cpp" => vec!["LLAMACPP_API_KEY"],
940 "sglang" => vec!["SGLANG_API_KEY"],
941 "vllm" => vec!["VLLM_API_KEY"],
942 "aihubmix" => vec!["AIHUBMIX_API_KEY"],
943 "siliconflow" | "silicon-flow" => vec!["SILICONFLOW_API_KEY"],
944 "osaurus" => vec!["OSAURUS_API_KEY"],
945 "telnyx" => vec!["TELNYX_API_KEY"],
946 "azure_openai" | "azure-openai" | "azure" => vec!["AZURE_OPENAI_API_KEY"],
947 _ => vec![],
948 };
949
950 for env_var in provider_env_candidates {
951 if let Ok(value) = std::env::var(env_var) {
952 let value = value.trim();
953 if !value.is_empty() {
954 return Some(value.to_string());
955 }
956 }
957 }
958
959 if is_minimax_alias(name) {
960 if let Some(credential) = resolve_minimax_oauth_refresh_token(name) {
961 return Some(credential);
962 }
963 }
964
965 if minimax_oauth_placeholder_requested && is_minimax_alias(name) {
966 return None;
967 }
968
969 for env_var in ["CONSTRUCT_API_KEY", "API_KEY"] {
970 if let Ok(value) = std::env::var(env_var) {
971 let value = value.trim();
972 if !value.is_empty() {
973 return Some(value.to_string());
974 }
975 }
976 }
977
978 None
979}
980
981fn check_api_key_prefix(provider_name: &str, key: &str) -> Option<&'static str> {
987 let likely_provider = if key.starts_with("sk-ant-") {
989 Some("anthropic")
990 } else if key.starts_with("sk-or-") {
991 Some("openrouter")
992 } else if key.starts_with("sk-") {
993 Some("openai")
994 } else if key.starts_with("gsk_") {
995 Some("groq")
996 } else if key.starts_with("pplx-") {
997 Some("perplexity")
998 } else if key.starts_with("xai-") {
999 Some("xai")
1000 } else if key.starts_with("nvapi-") {
1001 Some("nvidia")
1002 } else if key.starts_with("KEY-") {
1003 Some("telnyx")
1004 } else {
1005 None
1006 };
1007
1008 let expected = likely_provider?;
1009
1010 let matches = match provider_name {
1012 "anthropic" => expected == "anthropic",
1013 "openrouter" => expected == "openrouter",
1014 "openai" => expected == "openai",
1015 "groq" => expected == "groq",
1016 "perplexity" => expected == "perplexity",
1017 "xai" | "grok" => expected == "xai",
1018 "nvidia" | "nvidia-nim" | "build.nvidia.com" => expected == "nvidia",
1019 "telnyx" => expected == "telnyx",
1020 _ => return None, };
1022
1023 if matches { None } else { Some(expected) }
1024}
1025
1026fn parse_custom_provider_url(
1027 raw_url: &str,
1028 provider_label: &str,
1029 format_hint: &str,
1030) -> anyhow::Result<String> {
1031 let base_url = raw_url.trim();
1032
1033 if base_url.is_empty() {
1034 anyhow::bail!("{provider_label} requires a URL. Format: {format_hint}");
1035 }
1036
1037 let parsed = reqwest::Url::parse(base_url).map_err(|_| {
1038 anyhow::anyhow!("{provider_label} requires a valid URL. Format: {format_hint}")
1039 })?;
1040
1041 match parsed.scheme() {
1042 "http" | "https" => Ok(base_url.to_string()),
1043 _ => anyhow::bail!(
1044 "{provider_label} requires an http:// or https:// URL. Format: {format_hint}"
1045 ),
1046 }
1047}
1048
1049pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<dyn Provider>> {
1051 create_provider_with_options(name, api_key, &ProviderRuntimeOptions::default())
1052}
1053
1054pub fn create_provider_with_options(
1056 name: &str,
1057 api_key: Option<&str>,
1058 options: &ProviderRuntimeOptions,
1059) -> anyhow::Result<Box<dyn Provider>> {
1060 match name {
1061 "openai-codex" | "openai_codex" | "codex" => Ok(Box::new(
1062 openai_codex::OpenAiCodexProvider::new(options, api_key)?,
1063 )),
1064 _ => create_provider_with_url_and_options(name, api_key, None, options),
1065 }
1066}
1067
1068pub fn create_provider_with_url(
1070 name: &str,
1071 api_key: Option<&str>,
1072 api_url: Option<&str>,
1073) -> anyhow::Result<Box<dyn Provider>> {
1074 create_provider_with_url_and_options(name, api_key, api_url, &ProviderRuntimeOptions::default())
1075}
1076
1077#[allow(clippy::too_many_lines)]
1079fn create_provider_with_url_and_options(
1080 name: &str,
1081 api_key: Option<&str>,
1082 api_url: Option<&str>,
1083 options: &ProviderRuntimeOptions,
1084) -> anyhow::Result<Box<dyn Provider>> {
1085 let compat = {
1088 let timeout = options.provider_timeout_secs;
1089 let reasoning_effort = options.reasoning_effort.clone();
1090 let extra_headers = options.extra_headers.clone();
1091 let api_path = options.api_path.clone();
1092 let max_tokens = options.provider_max_tokens;
1093 move |p: OpenAiCompatibleProvider| -> Box<dyn Provider> {
1094 let mut p = p;
1095 if let Some(t) = timeout {
1096 p = p.with_timeout_secs(t);
1097 }
1098 if let Some(ref effort) = reasoning_effort {
1099 p = p.with_reasoning_effort(Some(effort.clone()));
1100 }
1101 if !extra_headers.is_empty() {
1102 p = p.with_extra_headers(extra_headers.clone());
1103 }
1104 if api_path.is_some() {
1105 p = p.with_api_path(api_path.clone());
1106 }
1107 if let Some(mt) = max_tokens {
1108 p = p.with_max_tokens(Some(mt));
1109 }
1110 Box::new(p)
1111 }
1112 };
1113
1114 let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key));
1115
1116 let resolved_credential = if let Some(context) = qwen_oauth_context.as_ref() {
1120 context.credential.clone()
1121 } else {
1122 resolve_provider_credential(name, api_key)
1123 }
1124 .map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
1125 #[allow(clippy::option_as_ref_deref)]
1126 let key = resolved_credential.as_ref().map(String::as_str);
1127
1128 if let Some(key_value) = key {
1130 let is_custom = name.starts_with("custom:") || name.starts_with("anthropic-custom:");
1131 let has_custom_url = api_url.map(str::trim).filter(|u| !u.is_empty()).is_some();
1132 if !is_custom && !has_custom_url {
1133 if let Some(likely_provider) = check_api_key_prefix(name, key_value) {
1134 let visible = &key_value[..key_value.len().min(8)];
1135 anyhow::bail!(
1136 "API key prefix mismatch: key \"{visible}...\" looks like a \
1137 {likely_provider} key, but provider \"{name}\" is selected. \
1138 Set the correct provider-specific env var or use `-p {likely_provider}`."
1139 );
1140 }
1141 }
1142 }
1143
1144 match name {
1145 "openai-codex" | "openai_codex" | "codex" => {
1146 let mut codex_options = options.clone();
1147 codex_options.provider_api_url = api_url
1148 .map(str::trim)
1149 .filter(|value| !value.is_empty())
1150 .map(ToString::to_string)
1151 .or_else(|| options.provider_api_url.clone());
1152 Ok(Box::new(openai_codex::OpenAiCodexProvider::new(
1153 &codex_options,
1154 key,
1155 )?))
1156 }
1157 "openrouter" => Ok(Box::new(
1159 openrouter::OpenRouterProvider::new(key, options.provider_timeout_secs)
1160 .with_max_tokens(options.provider_max_tokens),
1161 )),
1162 "anthropic" => {
1163 let mut p = anthropic::AnthropicProvider::new(key);
1164 if let Some(mt) = options.provider_max_tokens {
1165 p = p.with_max_tokens(mt);
1166 }
1167 Ok(Box::new(p))
1168 }
1169 "openai" => {
1170 let mut p = openai::OpenAiProvider::with_base_url(api_url, key);
1171 if let Some(mt) = options.provider_max_tokens {
1172 p = p.with_max_tokens(Some(mt));
1173 }
1174 Ok(Box::new(p))
1175 }
1176 "ollama" => {
1178 let env_url = std::env::var("CONSTRUCT_PROVIDER_URL").ok();
1179
1180 let api_url = env_url.as_deref().or(api_url);
1181
1182 Ok(Box::new(ollama::OllamaProvider::new_with_reasoning(
1183 api_url,
1184 key,
1185 options.reasoning_enabled,
1186 )))
1187 }
1188 "gemini" | "google" | "google-gemini" => {
1189 let state_dir = options.construct_dir.clone().unwrap_or_else(|| {
1190 directories::UserDirs::new().map_or_else(
1191 || PathBuf::from(".construct"),
1192 |dirs| dirs.home_dir().join(".construct"),
1193 )
1194 });
1195 let auth_service = AuthService::new(&state_dir, options.secrets_encrypt);
1196 Ok(Box::new(gemini::GeminiProvider::new_with_auth(
1197 key,
1198 auth_service,
1199 options.auth_profile_override.clone(),
1200 )))
1201 }
1202 "telnyx" => Ok(Box::new(telnyx::TelnyxProvider::new(key))),
1203
1204 "venice" => Ok(compat(
1206 OpenAiCompatibleProvider::new(
1207 "Venice",
1208 "https://api.venice.ai",
1209 key,
1210 AuthStyle::Bearer,
1211 )
1212 .without_native_tools(),
1213 )),
1214 "vercel" | "vercel-ai" => Ok(compat(OpenAiCompatibleProvider::new(
1215 "Vercel AI Gateway",
1216 VERCEL_AI_GATEWAY_BASE_URL,
1217 key,
1218 AuthStyle::Bearer,
1219 ))),
1220 "cloudflare" | "cloudflare-ai" => Ok(compat(OpenAiCompatibleProvider::new(
1221 "Cloudflare AI Gateway",
1222 "https://gateway.ai.cloudflare.com/v1",
1223 key,
1224 AuthStyle::Bearer,
1225 ))),
1226 name if moonshot_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new(
1227 "Moonshot",
1228 moonshot_base_url(name).expect("checked in guard"),
1229 key,
1230 AuthStyle::Bearer,
1231 ))),
1232 "kimi-code" | "kimi_coding" | "kimi_for_coding" => {
1233 Ok(compat(OpenAiCompatibleProvider::new_with_user_agent(
1234 "Kimi Code",
1235 "https://api.kimi.com/coding/v1",
1236 key,
1237 AuthStyle::Bearer,
1238 "KimiCLI/0.77",
1239 )))
1240 }
1241 "synthetic" => Ok(compat(OpenAiCompatibleProvider::new(
1242 "Synthetic",
1243 "https://api.synthetic.new/openai/v1",
1244 key,
1245 AuthStyle::Bearer,
1246 ))),
1247 "opencode" | "opencode-zen" => Ok(compat(OpenAiCompatibleProvider::new(
1248 "OpenCode Zen",
1249 "https://opencode.ai/zen/v1",
1250 key,
1251 AuthStyle::Bearer,
1252 ))),
1253 "opencode-go" => Ok(compat(OpenAiCompatibleProvider::new(
1254 "OpenCode Go",
1255 "https://opencode.ai/zen/go/v1",
1256 key,
1257 AuthStyle::Bearer,
1258 ))),
1259 name if zai_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new(
1260 "Z.AI",
1261 zai_base_url(name).expect("checked in guard"),
1262 key,
1263 AuthStyle::Bearer,
1264 ))),
1265 name if glm_base_url(name).is_some() => {
1266 Ok(compat(OpenAiCompatibleProvider::new_no_responses_fallback(
1267 "GLM",
1268 glm_base_url(name).expect("checked in guard"),
1269 key,
1270 AuthStyle::Bearer,
1271 )))
1272 }
1273 name if minimax_base_url(name).is_some() => Ok(compat(
1274 OpenAiCompatibleProvider::new_merge_system_into_user(
1275 "MiniMax",
1276 minimax_base_url(name).expect("checked in guard"),
1277 key,
1278 AuthStyle::Bearer,
1279 ),
1280 )),
1281 "azure_openai" | "azure-openai" | "azure" => {
1282 let resource = std::env::var("AZURE_OPENAI_RESOURCE")
1283 .unwrap_or_else(|_| "my-resource".to_string());
1284 let deployment =
1285 std::env::var("AZURE_OPENAI_DEPLOYMENT").unwrap_or_else(|_| "gpt-4o".to_string());
1286 let api_version = std::env::var("AZURE_OPENAI_API_VERSION").ok();
1287 Ok(Box::new(azure_openai::AzureOpenAiProvider::new(
1288 key,
1289 &resource,
1290 &deployment,
1291 api_version.as_deref(),
1292 )))
1293 }
1294 "bedrock" | "aws-bedrock" => {
1295 let mut p = if let Some(api_key) = key {
1296 bedrock::BedrockProvider::with_bearer_token(api_key)
1297 } else {
1298 bedrock::BedrockProvider::new()
1299 };
1300 if let Some(mt) = options.provider_max_tokens {
1301 p = p.with_max_tokens(mt);
1302 }
1303 Ok(Box::new(p))
1304 }
1305 name if is_qwen_oauth_alias(name) => {
1306 let base_url = api_url
1307 .map(str::trim)
1308 .filter(|value| !value.is_empty())
1309 .map(ToString::to_string)
1310 .or_else(|| {
1311 qwen_oauth_context
1312 .as_ref()
1313 .and_then(|context| context.base_url.clone())
1314 })
1315 .unwrap_or_else(|| QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
1316
1317 Ok(compat(
1318 OpenAiCompatibleProvider::new_with_user_agent_and_vision(
1319 "Qwen Code",
1320 &base_url,
1321 key,
1322 AuthStyle::Bearer,
1323 "QwenCode/1.0",
1324 true,
1325 ),
1326 ))
1327 }
1328 name if is_qianfan_alias(name) => {
1329 let base_url = qianfan_base_url(api_url);
1330 Ok(compat(OpenAiCompatibleProvider::new(
1331 "Qianfan",
1332 &base_url,
1333 key,
1334 AuthStyle::Bearer,
1335 )))
1336 }
1337 name if is_doubao_alias(name) => Ok(compat(OpenAiCompatibleProvider::new(
1338 "Doubao",
1339 "https://ark.cn-beijing.volces.com/api/v3",
1340 key,
1341 AuthStyle::Bearer,
1342 ))),
1343 name if is_bailian_alias(name) => Ok(Box::new(
1344 OpenAiCompatibleProvider::new_with_user_agent_and_vision(
1345 "Bailian",
1346 BAILIAN_BASE_URL,
1347 key,
1348 AuthStyle::Bearer,
1349 "openclaw",
1350 true,
1351 ),
1352 )),
1353 name if qwen_base_url(name).is_some() => {
1354 Ok(compat(OpenAiCompatibleProvider::new_with_vision(
1355 "Qwen",
1356 qwen_base_url(name).expect("checked in guard"),
1357 key,
1358 AuthStyle::Bearer,
1359 true,
1360 )))
1361 }
1362
1363 "groq" => Ok(compat(OpenAiCompatibleProvider::new(
1365 "Groq",
1366 "https://api.groq.com/openai/v1",
1367 key,
1368 AuthStyle::Bearer,
1369 ))),
1370 "mistral" => Ok(compat(OpenAiCompatibleProvider::new(
1371 "Mistral",
1372 "https://api.mistral.ai/v1",
1373 key,
1374 AuthStyle::Bearer,
1375 ))),
1376 "xai" | "grok" => Ok(compat(OpenAiCompatibleProvider::new(
1377 "xAI",
1378 "https://api.x.ai",
1379 key,
1380 AuthStyle::Bearer,
1381 ))),
1382 "deepseek" => Ok(compat(OpenAiCompatibleProvider::new(
1383 "DeepSeek",
1384 "https://api.deepseek.com",
1385 key,
1386 AuthStyle::Bearer,
1387 ))),
1388 "together" | "together-ai" => Ok(compat(OpenAiCompatibleProvider::new(
1389 "Together AI",
1390 "https://api.together.xyz",
1391 key,
1392 AuthStyle::Bearer,
1393 ))),
1394 "fireworks" | "fireworks-ai" => Ok(compat(OpenAiCompatibleProvider::new(
1395 "Fireworks AI",
1396 "https://api.fireworks.ai/inference/v1",
1397 key,
1398 AuthStyle::Bearer,
1399 ))),
1400 "novita" => Ok(compat(OpenAiCompatibleProvider::new(
1401 "Novita AI",
1402 "https://api.novita.ai/openai",
1403 key,
1404 AuthStyle::Bearer,
1405 ))),
1406 "perplexity" => Ok(compat(OpenAiCompatibleProvider::new(
1407 "Perplexity",
1408 "https://api.perplexity.ai",
1409 key,
1410 AuthStyle::Bearer,
1411 ))),
1412 "cohere" => Ok(compat(OpenAiCompatibleProvider::new(
1413 "Cohere",
1414 "https://api.cohere.com/compatibility",
1415 key,
1416 AuthStyle::Bearer,
1417 ))),
1418 "copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))),
1419 "claude-code" => Ok(Box::new(claude_code::ClaudeCodeProvider::new())),
1420 "gemini-cli" => Ok(Box::new(gemini_cli::GeminiCliProvider::new())),
1421 "kilocli" | "kilo" => Ok(Box::new(kilocli::KiloCliProvider::new())),
1422 "lmstudio" | "lm-studio" => {
1423 let lm_studio_key = key
1424 .map(str::trim)
1425 .filter(|value| !value.is_empty())
1426 .unwrap_or("lm-studio");
1427 Ok(compat(OpenAiCompatibleProvider::new(
1428 "LM Studio",
1429 "http://localhost:1234/v1",
1430 Some(lm_studio_key),
1431 AuthStyle::Bearer,
1432 )))
1433 }
1434 "llamacpp" | "llama.cpp" => {
1435 let base_url = api_url
1436 .map(str::trim)
1437 .filter(|value| !value.is_empty())
1438 .unwrap_or("http://localhost:8080/v1");
1439 let llama_cpp_key = key
1440 .map(str::trim)
1441 .filter(|value| !value.is_empty())
1442 .unwrap_or("llama.cpp");
1443 Ok(compat(OpenAiCompatibleProvider::new_with_vision(
1444 "llama.cpp",
1445 base_url,
1446 Some(llama_cpp_key),
1447 AuthStyle::Bearer,
1448 true,
1449 )))
1450 }
1451 "sglang" => {
1452 let base_url = api_url
1453 .map(str::trim)
1454 .filter(|value| !value.is_empty())
1455 .unwrap_or("http://localhost:30000/v1");
1456 Ok(compat(OpenAiCompatibleProvider::new(
1457 "SGLang",
1458 base_url,
1459 key,
1460 AuthStyle::Bearer,
1461 )))
1462 }
1463 "vllm" => {
1464 let base_url = api_url
1465 .map(str::trim)
1466 .filter(|value| !value.is_empty())
1467 .unwrap_or("http://localhost:8000/v1");
1468 Ok(compat(OpenAiCompatibleProvider::new(
1469 "vLLM",
1470 base_url,
1471 key,
1472 AuthStyle::Bearer,
1473 )))
1474 }
1475 "osaurus" => {
1476 let base_url = api_url
1477 .map(str::trim)
1478 .filter(|value| !value.is_empty())
1479 .unwrap_or("http://localhost:1337/v1");
1480 let osaurus_key = key
1481 .map(str::trim)
1482 .filter(|value| !value.is_empty())
1483 .unwrap_or("osaurus");
1484 Ok(compat(OpenAiCompatibleProvider::new(
1485 "Osaurus",
1486 base_url,
1487 Some(osaurus_key),
1488 AuthStyle::Bearer,
1489 )))
1490 }
1491 "nvidia" | "nvidia-nim" | "build.nvidia.com" => {
1492 Ok(compat(OpenAiCompatibleProvider::new_no_responses_fallback(
1493 "NVIDIA NIM",
1494 "https://integrate.api.nvidia.com/v1",
1495 key,
1496 AuthStyle::Bearer,
1497 )))
1498 }
1499
1500 "astrai" => Ok(compat(OpenAiCompatibleProvider::new(
1502 "Astrai",
1503 "https://as-trai.com/v1",
1504 key,
1505 AuthStyle::Bearer,
1506 ))),
1507 "siliconflow" | "silicon-flow" => Ok(compat(OpenAiCompatibleProvider::new(
1508 "SiliconFlow",
1509 "https://api.siliconflow.cn/v1",
1510 key,
1511 AuthStyle::Bearer,
1512 ))),
1513 "aihubmix" => Ok(compat(OpenAiCompatibleProvider::new(
1514 "AiHubMix",
1515 "https://aihubmix.com/v1",
1516 key,
1517 AuthStyle::Bearer,
1518 ))),
1519 "litellm" | "lite-llm" => {
1520 let base_url = api_url
1521 .map(str::trim)
1522 .filter(|value| !value.is_empty())
1523 .unwrap_or("http://localhost:4000/v1");
1524 Ok(compat(OpenAiCompatibleProvider::new(
1525 "LiteLLM",
1526 base_url,
1527 key,
1528 AuthStyle::Bearer,
1529 )))
1530 }
1531
1532 "cerebras" => Ok(compat(OpenAiCompatibleProvider::new(
1534 "Cerebras",
1535 "https://api.cerebras.ai/v1",
1536 key,
1537 AuthStyle::Bearer,
1538 ))),
1539 "sambanova" => Ok(compat(OpenAiCompatibleProvider::new(
1540 "SambaNova",
1541 "https://api.sambanova.ai/v1",
1542 key,
1543 AuthStyle::Bearer,
1544 ))),
1545 "hyperbolic" => Ok(compat(OpenAiCompatibleProvider::new(
1546 "Hyperbolic",
1547 "https://api.hyperbolic.xyz/v1",
1548 key,
1549 AuthStyle::Bearer,
1550 ))),
1551
1552 "deepinfra" | "deep-infra" => Ok(compat(OpenAiCompatibleProvider::new(
1554 "DeepInfra",
1555 "https://api.deepinfra.com/v1/openai",
1556 key,
1557 AuthStyle::Bearer,
1558 ))),
1559 "huggingface" | "hf" => Ok(compat(OpenAiCompatibleProvider::new(
1560 "Hugging Face",
1561 "https://router.huggingface.co/v1",
1562 key,
1563 AuthStyle::Bearer,
1564 ))),
1565 "ai21" | "ai21-labs" => Ok(compat(OpenAiCompatibleProvider::new(
1566 "AI21 Labs",
1567 "https://api.ai21.com/studio/v1",
1568 key,
1569 AuthStyle::Bearer,
1570 ))),
1571 "reka" => Ok(compat(OpenAiCompatibleProvider::new(
1572 "Reka",
1573 "https://api.reka.ai/v1",
1574 key,
1575 AuthStyle::Bearer,
1576 ))),
1577 "baseten" => Ok(compat(OpenAiCompatibleProvider::new(
1578 "Baseten",
1579 "https://inference.baseten.co/v1",
1580 key,
1581 AuthStyle::Bearer,
1582 ))),
1583 "nscale" => Ok(compat(OpenAiCompatibleProvider::new(
1584 "Nscale",
1585 "https://inference.api.nscale.com/v1",
1586 key,
1587 AuthStyle::Bearer,
1588 ))),
1589 "anyscale" => Ok(compat(OpenAiCompatibleProvider::new(
1590 "Anyscale",
1591 "https://api.endpoints.anyscale.com/v1",
1592 key,
1593 AuthStyle::Bearer,
1594 ))),
1595 "nebius" => Ok(compat(OpenAiCompatibleProvider::new(
1596 "Nebius AI Studio",
1597 "https://api.studio.nebius.ai/v1",
1598 key,
1599 AuthStyle::Bearer,
1600 ))),
1601 "friendli" | "friendliai" => Ok(compat(OpenAiCompatibleProvider::new(
1602 "Friendli AI",
1603 "https://api.friendli.ai/serverless/v1",
1604 key,
1605 AuthStyle::Bearer,
1606 ))),
1607 "lepton" | "lepton-ai" => {
1608 let base_url = api_url
1609 .map(str::trim)
1610 .filter(|value| !value.is_empty())
1611 .unwrap_or("https://llama3-1-405b.lepton.run/api/v1");
1612 Ok(compat(OpenAiCompatibleProvider::new(
1613 "Lepton AI",
1614 base_url,
1615 key,
1616 AuthStyle::Bearer,
1617 )))
1618 }
1619
1620 "stepfun" | "step" => Ok(compat(OpenAiCompatibleProvider::new(
1622 "Stepfun",
1623 "https://api.stepfun.com/v1",
1624 key,
1625 AuthStyle::Bearer,
1626 ))),
1627 "baichuan" => Ok(compat(OpenAiCompatibleProvider::new(
1628 "Baichuan",
1629 "https://api.baichuan-ai.com/v1",
1630 key,
1631 AuthStyle::Bearer,
1632 ))),
1633 "yi" | "01ai" | "lingyiwanwu" => Ok(compat(OpenAiCompatibleProvider::new(
1634 "01.AI (Yi)",
1635 "https://api.lingyiwanwu.com/v1",
1636 key,
1637 AuthStyle::Bearer,
1638 ))),
1639 "hunyuan" | "tencent" => Ok(compat(OpenAiCompatibleProvider::new(
1640 "Tencent Hunyuan",
1641 "https://api.hunyuan.cloud.tencent.com/v1",
1642 key,
1643 AuthStyle::Bearer,
1644 ))),
1645 "avian" => Ok(compat(OpenAiCompatibleProvider::new(
1646 "Avian",
1647 "https://api.avian.io/v1",
1648 key,
1649 AuthStyle::Bearer,
1650 ))),
1651 "deepmyst" | "deep-myst" => Ok(compat(OpenAiCompatibleProvider::new(
1652 "DeepMyst",
1653 "https://api.deepmyst.com/v1",
1654 key,
1655 AuthStyle::Bearer,
1656 ))),
1657
1658 "ovhcloud" | "ovh" => Ok(Box::new(openai::OpenAiProvider::with_base_url(
1660 Some("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"),
1661 key,
1662 ))),
1663
1664 name if name.starts_with("custom:") => {
1667 let base_url = parse_custom_provider_url(
1668 name.strip_prefix("custom:").unwrap_or(""),
1669 "Custom provider",
1670 "custom:https://your-api.com",
1671 )?;
1672 Ok(compat(OpenAiCompatibleProvider::new_with_vision(
1673 "Custom",
1674 &base_url,
1675 key,
1676 AuthStyle::Bearer,
1677 true,
1678 )))
1679 }
1680
1681 name if name.starts_with("anthropic-custom:") => {
1684 let base_url = parse_custom_provider_url(
1685 name.strip_prefix("anthropic-custom:").unwrap_or(""),
1686 "Anthropic-custom provider",
1687 "anthropic-custom:https://your-api.com",
1688 )?;
1689 Ok(Box::new(anthropic::AnthropicProvider::with_base_url(
1690 key,
1691 Some(&base_url),
1692 )))
1693 }
1694
1695 _ => anyhow::bail!(
1696 "Unknown provider: {name}. Check README for supported providers or run `construct onboard` to reconfigure.\n\
1697 Tip: Use \"custom:https://your-api.com\" for OpenAI-compatible endpoints.\n\
1698 Tip: Use \"anthropic-custom:https://your-api.com\" for Anthropic-compatible endpoints."
1699 ),
1700 }
1701}
1702
1703fn parse_provider_profile(s: &str) -> (&str, Option<&str>) {
1710 if s.starts_with("custom:") || s.starts_with("anthropic-custom:") {
1711 return (s, None);
1712 }
1713 match s.split_once(':') {
1714 Some((provider, profile)) if !profile.is_empty() => (provider, Some(profile)),
1715 _ => (s, None),
1716 }
1717}
1718
1719pub fn create_resilient_provider(
1721 primary_name: &str,
1722 api_key: Option<&str>,
1723 api_url: Option<&str>,
1724 reliability: &crate::config::ReliabilityConfig,
1725) -> anyhow::Result<Box<dyn Provider>> {
1726 create_resilient_provider_with_options(
1727 primary_name,
1728 api_key,
1729 api_url,
1730 reliability,
1731 &ProviderRuntimeOptions::default(),
1732 )
1733}
1734
1735pub fn create_resilient_provider_with_options(
1737 primary_name: &str,
1738 api_key: Option<&str>,
1739 api_url: Option<&str>,
1740 reliability: &crate::config::ReliabilityConfig,
1741 options: &ProviderRuntimeOptions,
1742) -> anyhow::Result<Box<dyn Provider>> {
1743 let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
1744
1745 let primary_provider = match primary_name {
1746 "openai-codex" | "openai_codex" | "codex" => {
1747 create_provider_with_options(primary_name, api_key, options)?
1748 }
1749 _ => create_provider_with_url_and_options(primary_name, api_key, api_url, options)?,
1750 };
1751 providers.push((primary_name.to_string(), primary_provider));
1752
1753 for fallback in &reliability.fallback_providers {
1754 if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) {
1755 continue;
1756 }
1757
1758 let (provider_name, profile_override) = parse_provider_profile(fallback);
1759
1760 let fallback_options = match profile_override {
1770 Some(profile) => {
1771 let mut opts = options.clone();
1772 opts.auth_profile_override = Some(profile.to_string());
1773 opts
1774 }
1775 None => options.clone(),
1776 };
1777
1778 match create_provider_with_options(provider_name, None, &fallback_options) {
1779 Ok(provider) => providers.push((fallback.clone(), provider)),
1780 Err(_error) => {
1781 tracing::warn!(
1782 fallback_provider = fallback,
1783 "Ignoring invalid fallback provider during initialization"
1784 );
1785 }
1786 }
1787 }
1788
1789 let reliable = ReliableProvider::new(
1790 providers,
1791 reliability.provider_retries,
1792 reliability.provider_backoff_ms,
1793 )
1794 .with_api_keys(reliability.api_keys.clone())
1795 .with_model_fallbacks(reliability.model_fallbacks.clone());
1796
1797 Ok(Box::new(reliable))
1798}
1799
1800pub fn create_routed_provider(
1804 primary_name: &str,
1805 api_key: Option<&str>,
1806 api_url: Option<&str>,
1807 reliability: &crate::config::ReliabilityConfig,
1808 model_routes: &[crate::config::ModelRouteConfig],
1809 default_model: &str,
1810) -> anyhow::Result<Box<dyn Provider>> {
1811 create_routed_provider_with_options(
1812 primary_name,
1813 api_key,
1814 api_url,
1815 reliability,
1816 model_routes,
1817 default_model,
1818 &ProviderRuntimeOptions::default(),
1819 )
1820}
1821
1822pub fn create_routed_provider_with_options(
1824 primary_name: &str,
1825 api_key: Option<&str>,
1826 api_url: Option<&str>,
1827 reliability: &crate::config::ReliabilityConfig,
1828 model_routes: &[crate::config::ModelRouteConfig],
1829 default_model: &str,
1830 options: &ProviderRuntimeOptions,
1831) -> anyhow::Result<Box<dyn Provider>> {
1832 if model_routes.is_empty() {
1833 return create_resilient_provider_with_options(
1834 primary_name,
1835 api_key,
1836 api_url,
1837 reliability,
1838 options,
1839 );
1840 }
1841
1842 let mut needed: Vec<String> = vec![primary_name.to_string()];
1844 for route in model_routes {
1845 if !needed.iter().any(|n| n == &route.provider) {
1846 needed.push(route.provider.clone());
1847 }
1848 }
1849
1850 let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
1852 for name in &needed {
1853 let routed_credential = model_routes
1854 .iter()
1855 .find(|r| &r.provider == name)
1856 .and_then(|r| {
1857 r.api_key.as_ref().and_then(|raw_key| {
1858 let trimmed_key = raw_key.trim();
1859 (!trimmed_key.is_empty()).then_some(trimmed_key)
1860 })
1861 });
1862 let key = routed_credential.or(api_key);
1863 let url = if name == primary_name { api_url } else { None };
1865 match create_resilient_provider_with_options(name, key, url, reliability, options) {
1866 Ok(provider) => providers.push((name.clone(), provider)),
1867 Err(e) => {
1868 if name == primary_name {
1869 return Err(e);
1870 }
1871 tracing::warn!(
1872 provider = name.as_str(),
1873 "Ignoring routed provider that failed to initialize"
1874 );
1875 }
1876 }
1877 }
1878
1879 let routes: Vec<(String, router::Route)> = model_routes
1881 .iter()
1882 .map(|r| {
1883 (
1884 r.hint.clone(),
1885 router::Route {
1886 provider_name: r.provider.clone(),
1887 model: r.model.clone(),
1888 },
1889 )
1890 })
1891 .collect();
1892
1893 Ok(Box::new(router::RouterProvider::new(
1894 providers,
1895 routes,
1896 default_model.to_string(),
1897 )))
1898}
1899
1900pub struct ProviderInfo {
1902 pub name: &'static str,
1904 pub display_name: &'static str,
1906 pub aliases: &'static [&'static str],
1908 pub local: bool,
1910}
1911
1912pub fn list_providers() -> Vec<ProviderInfo> {
1917 vec![
1918 ProviderInfo {
1920 name: "openrouter",
1921 display_name: "OpenRouter",
1922 aliases: &[],
1923 local: false,
1924 },
1925 ProviderInfo {
1926 name: "anthropic",
1927 display_name: "Anthropic",
1928 aliases: &[],
1929 local: false,
1930 },
1931 ProviderInfo {
1932 name: "openai",
1933 display_name: "OpenAI",
1934 aliases: &[],
1935 local: false,
1936 },
1937 ProviderInfo {
1938 name: "openai-codex",
1939 display_name: "OpenAI Codex (OAuth)",
1940 aliases: &["openai_codex", "codex"],
1941 local: false,
1942 },
1943 ProviderInfo {
1944 name: "telnyx",
1945 display_name: "Telnyx",
1946 aliases: &[],
1947 local: false,
1948 },
1949 ProviderInfo {
1950 name: "azure_openai",
1951 display_name: "Azure OpenAI",
1952 aliases: &["azure-openai", "azure"],
1953 local: false,
1954 },
1955 ProviderInfo {
1956 name: "ollama",
1957 display_name: "Ollama",
1958 aliases: &[],
1959 local: true,
1960 },
1961 ProviderInfo {
1962 name: "gemini",
1963 display_name: "Google Gemini",
1964 aliases: &["google", "google-gemini"],
1965 local: false,
1966 },
1967 ProviderInfo {
1969 name: "venice",
1970 display_name: "Venice",
1971 aliases: &[],
1972 local: false,
1973 },
1974 ProviderInfo {
1975 name: "vercel",
1976 display_name: "Vercel AI Gateway",
1977 aliases: &["vercel-ai"],
1978 local: false,
1979 },
1980 ProviderInfo {
1981 name: "cloudflare",
1982 display_name: "Cloudflare AI",
1983 aliases: &["cloudflare-ai"],
1984 local: false,
1985 },
1986 ProviderInfo {
1987 name: "moonshot",
1988 display_name: "Moonshot",
1989 aliases: &["kimi"],
1990 local: false,
1991 },
1992 ProviderInfo {
1993 name: "kimi-code",
1994 display_name: "Kimi Code",
1995 aliases: &["kimi_coding", "kimi_for_coding"],
1996 local: false,
1997 },
1998 ProviderInfo {
1999 name: "synthetic",
2000 display_name: "Synthetic",
2001 aliases: &[],
2002 local: false,
2003 },
2004 ProviderInfo {
2005 name: "opencode",
2006 display_name: "OpenCode Zen",
2007 aliases: &["opencode-zen"],
2008 local: false,
2009 },
2010 ProviderInfo {
2011 name: "opencode-go",
2012 display_name: "OpenCode Go",
2013 aliases: &[],
2014 local: false,
2015 },
2016 ProviderInfo {
2017 name: "zai",
2018 display_name: "Z.AI",
2019 aliases: &["z.ai"],
2020 local: false,
2021 },
2022 ProviderInfo {
2023 name: "glm",
2024 display_name: "GLM (Zhipu)",
2025 aliases: &["zhipu"],
2026 local: false,
2027 },
2028 ProviderInfo {
2029 name: "minimax",
2030 display_name: "MiniMax",
2031 aliases: &[
2032 "minimax-intl",
2033 "minimax-io",
2034 "minimax-global",
2035 "minimax-cn",
2036 "minimaxi",
2037 "minimax-oauth",
2038 "minimax-oauth-cn",
2039 "minimax-portal",
2040 "minimax-portal-cn",
2041 ],
2042 local: false,
2043 },
2044 ProviderInfo {
2045 name: "bedrock",
2046 display_name: "Amazon Bedrock",
2047 aliases: &["aws-bedrock"],
2048 local: false,
2049 },
2050 ProviderInfo {
2051 name: "qianfan",
2052 display_name: "Qianfan (Baidu)",
2053 aliases: &["baidu"],
2054 local: false,
2055 },
2056 ProviderInfo {
2057 name: "doubao",
2058 display_name: "Doubao (Volcengine)",
2059 aliases: &["volcengine", "ark", "doubao-cn"],
2060 local: false,
2061 },
2062 ProviderInfo {
2063 name: "qwen",
2064 display_name: "Qwen (DashScope / Qwen Code OAuth)",
2065 aliases: &[
2066 "dashscope",
2067 "qwen-intl",
2068 "dashscope-intl",
2069 "qwen-us",
2070 "dashscope-us",
2071 "qwen-code",
2072 "qwen-oauth",
2073 "qwen_oauth",
2074 ],
2075 local: false,
2076 },
2077 ProviderInfo {
2078 name: "bailian",
2079 display_name: "Bailian (Aliyun)",
2080 aliases: &["aliyun-bailian", "aliyun"],
2081 local: false,
2082 },
2083 ProviderInfo {
2084 name: "groq",
2085 display_name: "Groq",
2086 aliases: &[],
2087 local: false,
2088 },
2089 ProviderInfo {
2090 name: "mistral",
2091 display_name: "Mistral",
2092 aliases: &[],
2093 local: false,
2094 },
2095 ProviderInfo {
2096 name: "xai",
2097 display_name: "xAI (Grok)",
2098 aliases: &["grok"],
2099 local: false,
2100 },
2101 ProviderInfo {
2102 name: "deepseek",
2103 display_name: "DeepSeek",
2104 aliases: &[],
2105 local: false,
2106 },
2107 ProviderInfo {
2108 name: "together",
2109 display_name: "Together AI",
2110 aliases: &["together-ai"],
2111 local: false,
2112 },
2113 ProviderInfo {
2114 name: "fireworks",
2115 display_name: "Fireworks AI",
2116 aliases: &["fireworks-ai"],
2117 local: false,
2118 },
2119 ProviderInfo {
2120 name: "novita",
2121 display_name: "Novita AI",
2122 aliases: &[],
2123 local: false,
2124 },
2125 ProviderInfo {
2126 name: "perplexity",
2127 display_name: "Perplexity",
2128 aliases: &[],
2129 local: false,
2130 },
2131 ProviderInfo {
2132 name: "cohere",
2133 display_name: "Cohere",
2134 aliases: &[],
2135 local: false,
2136 },
2137 ProviderInfo {
2138 name: "copilot",
2139 display_name: "GitHub Copilot",
2140 aliases: &["github-copilot"],
2141 local: false,
2142 },
2143 ProviderInfo {
2144 name: "claude-code",
2145 display_name: "Claude Code (CLI)",
2146 aliases: &[],
2147 local: true,
2148 },
2149 ProviderInfo {
2150 name: "gemini-cli",
2151 display_name: "Gemini CLI",
2152 aliases: &[],
2153 local: true,
2154 },
2155 ProviderInfo {
2156 name: "kilocli",
2157 display_name: "KiloCLI",
2158 aliases: &["kilo"],
2159 local: true,
2160 },
2161 ProviderInfo {
2162 name: "lmstudio",
2163 display_name: "LM Studio",
2164 aliases: &["lm-studio"],
2165 local: true,
2166 },
2167 ProviderInfo {
2168 name: "llamacpp",
2169 display_name: "llama.cpp server",
2170 aliases: &["llama.cpp"],
2171 local: true,
2172 },
2173 ProviderInfo {
2174 name: "sglang",
2175 display_name: "SGLang",
2176 aliases: &[],
2177 local: true,
2178 },
2179 ProviderInfo {
2180 name: "vllm",
2181 display_name: "vLLM",
2182 aliases: &[],
2183 local: true,
2184 },
2185 ProviderInfo {
2186 name: "osaurus",
2187 display_name: "Osaurus",
2188 aliases: &[],
2189 local: true,
2190 },
2191 ProviderInfo {
2192 name: "nvidia",
2193 display_name: "NVIDIA NIM",
2194 aliases: &["nvidia-nim", "build.nvidia.com"],
2195 local: false,
2196 },
2197 ProviderInfo {
2198 name: "siliconflow",
2199 display_name: "SiliconFlow",
2200 aliases: &["silicon-flow"],
2201 local: false,
2202 },
2203 ProviderInfo {
2204 name: "aihubmix",
2205 display_name: "AiHubMix",
2206 aliases: &[],
2207 local: false,
2208 },
2209 ProviderInfo {
2210 name: "litellm",
2211 display_name: "LiteLLM",
2212 aliases: &["lite-llm"],
2213 local: false,
2214 },
2215 ProviderInfo {
2217 name: "cerebras",
2218 display_name: "Cerebras",
2219 aliases: &[],
2220 local: false,
2221 },
2222 ProviderInfo {
2223 name: "sambanova",
2224 display_name: "SambaNova",
2225 aliases: &[],
2226 local: false,
2227 },
2228 ProviderInfo {
2229 name: "hyperbolic",
2230 display_name: "Hyperbolic",
2231 aliases: &[],
2232 local: false,
2233 },
2234 ProviderInfo {
2236 name: "deepinfra",
2237 display_name: "DeepInfra",
2238 aliases: &["deep-infra"],
2239 local: false,
2240 },
2241 ProviderInfo {
2242 name: "huggingface",
2243 display_name: "Hugging Face",
2244 aliases: &["hf"],
2245 local: false,
2246 },
2247 ProviderInfo {
2248 name: "ai21",
2249 display_name: "AI21 Labs",
2250 aliases: &["ai21-labs"],
2251 local: false,
2252 },
2253 ProviderInfo {
2254 name: "reka",
2255 display_name: "Reka",
2256 aliases: &[],
2257 local: false,
2258 },
2259 ProviderInfo {
2260 name: "baseten",
2261 display_name: "Baseten",
2262 aliases: &[],
2263 local: false,
2264 },
2265 ProviderInfo {
2266 name: "nscale",
2267 display_name: "Nscale",
2268 aliases: &[],
2269 local: false,
2270 },
2271 ProviderInfo {
2272 name: "anyscale",
2273 display_name: "Anyscale",
2274 aliases: &[],
2275 local: false,
2276 },
2277 ProviderInfo {
2278 name: "nebius",
2279 display_name: "Nebius AI Studio",
2280 aliases: &[],
2281 local: false,
2282 },
2283 ProviderInfo {
2284 name: "friendli",
2285 display_name: "Friendli AI",
2286 aliases: &["friendliai"],
2287 local: false,
2288 },
2289 ProviderInfo {
2290 name: "lepton",
2291 display_name: "Lepton AI",
2292 aliases: &["lepton-ai"],
2293 local: false,
2294 },
2295 ProviderInfo {
2297 name: "stepfun",
2298 display_name: "Stepfun",
2299 aliases: &["step"],
2300 local: false,
2301 },
2302 ProviderInfo {
2303 name: "baichuan",
2304 display_name: "Baichuan",
2305 aliases: &[],
2306 local: false,
2307 },
2308 ProviderInfo {
2309 name: "yi",
2310 display_name: "01.AI (Yi)",
2311 aliases: &["01ai", "lingyiwanwu"],
2312 local: false,
2313 },
2314 ProviderInfo {
2315 name: "hunyuan",
2316 display_name: "Tencent Hunyuan",
2317 aliases: &["tencent"],
2318 local: false,
2319 },
2320 ProviderInfo {
2322 name: "ovhcloud",
2323 display_name: "OVHcloud AI Endpoints",
2324 aliases: &["ovh"],
2325 local: false,
2326 },
2327 ProviderInfo {
2328 name: "avian",
2329 display_name: "Avian",
2330 aliases: &[],
2331 local: false,
2332 },
2333 ]
2334}
2335
2336#[cfg(test)]
2337mod tests {
2338 use super::*;
2339 use std::sync::{Mutex, OnceLock};
2340
2341 struct EnvGuard {
2342 key: &'static str,
2343 original: Option<String>,
2344 }
2345
2346 impl EnvGuard {
2347 fn set(key: &'static str, value: Option<&str>) -> Self {
2348 let original = std::env::var(key).ok();
2349 match value {
2350 Some(next) => unsafe { std::env::set_var(key, next) },
2352 None => unsafe { std::env::remove_var(key) },
2354 }
2355
2356 Self { key, original }
2357 }
2358 }
2359
2360 impl Drop for EnvGuard {
2361 fn drop(&mut self) {
2362 if let Some(original) = self.original.as_deref() {
2363 unsafe { std::env::set_var(self.key, original) };
2365 } else {
2366 unsafe { std::env::remove_var(self.key) };
2368 }
2369 }
2370 }
2371
2372 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2373 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2374 LOCK.get_or_init(|| Mutex::new(()))
2375 .lock()
2376 .expect("env lock poisoned")
2377 }
2378
2379 #[test]
2380 fn resolve_provider_credential_prefers_explicit_argument() {
2381 let resolved = resolve_provider_credential("openrouter", Some(" explicit-key "));
2382 assert_eq!(resolved, Some("explicit-key".to_string()));
2383 }
2384
2385 #[test]
2386 fn resolve_provider_credential_uses_minimax_oauth_env_for_placeholder() {
2387 let _env_lock = env_lock();
2388 let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, Some("oauth-token"));
2389 let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
2390 let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
2391
2392 let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
2393
2394 assert_eq!(resolved.as_deref(), Some("oauth-token"));
2395 }
2396
2397 #[test]
2398 fn resolve_provider_credential_falls_back_to_minimax_api_key_for_placeholder() {
2399 let _env_lock = env_lock();
2400 let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
2401 let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, Some("api-key"));
2402 let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
2403
2404 let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
2405
2406 assert_eq!(resolved.as_deref(), Some("api-key"));
2407 }
2408
2409 #[test]
2410 fn resolve_provider_credential_placeholder_ignores_generic_api_key_fallback() {
2411 let _env_lock = env_lock();
2412 let _oauth_guard = EnvGuard::set(MINIMAX_OAUTH_TOKEN_ENV, None);
2413 let _api_guard = EnvGuard::set(MINIMAX_API_KEY_ENV, None);
2414 let _refresh_guard = EnvGuard::set(MINIMAX_OAUTH_REFRESH_TOKEN_ENV, None);
2415 let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
2416
2417 let resolved = resolve_provider_credential("minimax", Some(MINIMAX_OAUTH_PLACEHOLDER));
2418
2419 assert!(resolved.is_none());
2420 }
2421
2422 #[test]
2423 fn resolve_provider_credential_bedrock_uses_internal_credential_path() {
2424 let _generic_guard = EnvGuard::set("API_KEY", Some("generic-key"));
2425 let _override_guard = EnvGuard::set("OPENROUTER_API_KEY", Some("openrouter-key"));
2426 let _bedrock_guard = EnvGuard::set("BEDROCK_API_KEY", None);
2427
2428 assert_eq!(
2429 resolve_provider_credential("bedrock", Some("explicit")),
2430 Some("explicit".to_string())
2431 );
2432 assert!(resolve_provider_credential("bedrock", None).is_none());
2433 assert!(resolve_provider_credential("aws-bedrock", None).is_none());
2434 }
2435
2436 #[test]
2437 fn resolve_provider_credential_bedrock_returns_bearer_token_from_env() {
2438 let _bedrock_guard = EnvGuard::set("BEDROCK_API_KEY", Some("bedrock-bearer-token"));
2439
2440 assert_eq!(
2441 resolve_provider_credential("bedrock", None),
2442 Some("bedrock-bearer-token".to_string())
2443 );
2444 assert_eq!(
2445 resolve_provider_credential("aws-bedrock", None),
2446 Some("bedrock-bearer-token".to_string())
2447 );
2448 }
2449
2450 #[test]
2451 fn resolve_qwen_oauth_context_prefers_explicit_override() {
2452 let _env_lock = env_lock();
2453 let fake_home = format!("/tmp/construct-qwen-oauth-home-{}", std::process::id());
2454 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2455 let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
2456 let _resource_guard = EnvGuard::set(
2457 QWEN_OAUTH_RESOURCE_URL_ENV,
2458 Some("coding-intl.dashscope.aliyuncs.com"),
2459 );
2460
2461 let context = resolve_qwen_oauth_context(Some(" explicit-qwen-token "));
2462
2463 assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
2464 assert!(context.base_url.is_none());
2465 }
2466
2467 #[test]
2468 fn resolve_qwen_oauth_context_uses_env_token_and_resource_url() {
2469 let _env_lock = env_lock();
2470 let fake_home = format!("/tmp/construct-qwen-oauth-home-{}-env", std::process::id());
2471 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2472 let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
2473 let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
2474 let _resource_guard = EnvGuard::set(
2475 QWEN_OAUTH_RESOURCE_URL_ENV,
2476 Some("coding-intl.dashscope.aliyuncs.com"),
2477 );
2478 let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
2479
2480 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2481
2482 assert_eq!(context.credential.as_deref(), Some("oauth-token"));
2483 assert_eq!(
2484 context.base_url.as_deref(),
2485 Some("https://coding-intl.dashscope.aliyuncs.com/v1")
2486 );
2487 }
2488
2489 #[test]
2490 fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
2491 let _env_lock = env_lock();
2492 let fake_home = format!("/tmp/construct-qwen-oauth-home-{}-file", std::process::id());
2493 let creds_dir = PathBuf::from(&fake_home).join(".qwen");
2494 std::fs::create_dir_all(&creds_dir).unwrap();
2495 let creds_path = creds_dir.join("oauth_creds.json");
2496 std::fs::write(
2497 &creds_path,
2498 r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
2499 )
2500 .unwrap();
2501
2502 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2503 let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
2504 let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
2505 let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
2506 let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", None);
2507
2508 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2509
2510 assert_eq!(context.credential.as_deref(), Some("cached-token"));
2511 assert_eq!(
2512 context.base_url.as_deref(),
2513 Some("https://resource.example.com/v1")
2514 );
2515 }
2516
2517 #[test]
2518 fn resolve_qwen_oauth_context_placeholder_does_not_use_dashscope_fallback() {
2519 let _env_lock = env_lock();
2520 let fake_home = format!(
2521 "/tmp/construct-qwen-oauth-home-{}-placeholder",
2522 std::process::id()
2523 );
2524 let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
2525 let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
2526 let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
2527 let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
2528 let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
2529
2530 let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
2531
2532 assert!(context.credential.is_none());
2533 }
2534
2535 #[test]
2536 fn regional_alias_predicates_cover_expected_variants() {
2537 assert!(is_moonshot_alias("moonshot"));
2538 assert!(is_moonshot_alias("kimi-global"));
2539 assert!(is_glm_alias("glm"));
2540 assert!(is_glm_alias("bigmodel"));
2541 assert!(is_minimax_alias("minimax-io"));
2542 assert!(is_minimax_alias("minimaxi"));
2543 assert!(is_minimax_alias("minimax-oauth"));
2544 assert!(is_minimax_alias("minimax-portal-cn"));
2545 assert!(is_qwen_alias("dashscope"));
2546 assert!(is_qwen_alias("qwen-us"));
2547 assert!(is_qwen_alias("qwen-code"));
2548 assert!(is_qwen_oauth_alias("qwen-code"));
2549 assert!(is_qwen_oauth_alias("qwen_oauth"));
2550 assert!(is_zai_alias("z.ai"));
2551 assert!(is_zai_alias("zai-cn"));
2552 assert!(is_qianfan_alias("qianfan"));
2553 assert!(is_qianfan_alias("baidu"));
2554 assert!(is_doubao_alias("doubao"));
2555 assert!(is_doubao_alias("volcengine"));
2556 assert!(is_doubao_alias("ark"));
2557 assert!(is_doubao_alias("doubao-cn"));
2558
2559 assert!(!is_moonshot_alias("openrouter"));
2560 assert!(!is_glm_alias("openai"));
2561 assert!(!is_qwen_alias("gemini"));
2562 assert!(!is_zai_alias("anthropic"));
2563 assert!(!is_qianfan_alias("cohere"));
2564 assert!(!is_doubao_alias("deepseek"));
2565 }
2566
2567 #[test]
2568 fn canonical_china_provider_name_maps_regional_aliases() {
2569 assert_eq!(canonical_china_provider_name("moonshot"), Some("moonshot"));
2570 assert_eq!(canonical_china_provider_name("kimi-intl"), Some("moonshot"));
2571 assert_eq!(canonical_china_provider_name("glm"), Some("glm"));
2572 assert_eq!(canonical_china_provider_name("zhipu-cn"), Some("glm"));
2573 assert_eq!(canonical_china_provider_name("minimax"), Some("minimax"));
2574 assert_eq!(canonical_china_provider_name("minimax-cn"), Some("minimax"));
2575 assert_eq!(canonical_china_provider_name("qwen"), Some("qwen"));
2576 assert_eq!(canonical_china_provider_name("dashscope-us"), Some("qwen"));
2577 assert_eq!(canonical_china_provider_name("qwen-code"), Some("qwen"));
2578 assert_eq!(canonical_china_provider_name("zai"), Some("zai"));
2579 assert_eq!(canonical_china_provider_name("z.ai-cn"), Some("zai"));
2580 assert_eq!(canonical_china_provider_name("qianfan"), Some("qianfan"));
2581 assert_eq!(canonical_china_provider_name("baidu"), Some("qianfan"));
2582 assert_eq!(canonical_china_provider_name("doubao"), Some("doubao"));
2583 assert_eq!(canonical_china_provider_name("volcengine"), Some("doubao"));
2584 assert_eq!(canonical_china_provider_name("bailian"), Some("bailian"));
2585 assert_eq!(
2586 canonical_china_provider_name("aliyun-bailian"),
2587 Some("bailian")
2588 );
2589 assert_eq!(canonical_china_provider_name("aliyun"), Some("bailian"));
2590 assert_eq!(canonical_china_provider_name("openai"), None);
2591 }
2592
2593 #[test]
2594 fn regional_endpoint_aliases_map_to_expected_urls() {
2595 assert_eq!(minimax_base_url("minimax"), Some(MINIMAX_INTL_BASE_URL));
2596 assert_eq!(
2597 minimax_base_url("minimax-intl"),
2598 Some(MINIMAX_INTL_BASE_URL)
2599 );
2600 assert_eq!(minimax_base_url("minimax-cn"), Some(MINIMAX_CN_BASE_URL));
2601
2602 assert_eq!(glm_base_url("glm"), Some(GLM_GLOBAL_BASE_URL));
2603 assert_eq!(glm_base_url("glm-cn"), Some(GLM_CN_BASE_URL));
2604 assert_eq!(glm_base_url("bigmodel"), Some(GLM_CN_BASE_URL));
2605
2606 assert_eq!(moonshot_base_url("moonshot"), Some(MOONSHOT_CN_BASE_URL));
2607 assert_eq!(
2608 moonshot_base_url("moonshot-intl"),
2609 Some(MOONSHOT_INTL_BASE_URL)
2610 );
2611
2612 assert_eq!(qwen_base_url("qwen"), Some(QWEN_CN_BASE_URL));
2613 assert_eq!(qwen_base_url("qwen-cn"), Some(QWEN_CN_BASE_URL));
2614 assert_eq!(qwen_base_url("qwen-intl"), Some(QWEN_INTL_BASE_URL));
2615 assert_eq!(qwen_base_url("qwen-us"), Some(QWEN_US_BASE_URL));
2616 assert_eq!(qwen_base_url("qwen-code"), Some(QWEN_CN_BASE_URL));
2617
2618 assert_eq!(zai_base_url("zai"), Some(ZAI_GLOBAL_BASE_URL));
2619 assert_eq!(zai_base_url("z.ai"), Some(ZAI_GLOBAL_BASE_URL));
2620 assert_eq!(zai_base_url("zai-global"), Some(ZAI_GLOBAL_BASE_URL));
2621 assert_eq!(zai_base_url("z.ai-global"), Some(ZAI_GLOBAL_BASE_URL));
2622 assert_eq!(zai_base_url("zai-cn"), Some(ZAI_CN_BASE_URL));
2623 assert_eq!(zai_base_url("z.ai-cn"), Some(ZAI_CN_BASE_URL));
2624 }
2625
2626 #[test]
2629 fn factory_openrouter() {
2630 assert!(create_provider("openrouter", Some("provider-test-credential")).is_ok());
2631 assert!(create_provider("openrouter", None).is_ok());
2632 }
2633
2634 #[test]
2635 fn factory_anthropic() {
2636 assert!(create_provider("anthropic", Some("provider-test-credential")).is_ok());
2637 }
2638
2639 #[test]
2640 fn factory_openai() {
2641 assert!(create_provider("openai", Some("provider-test-credential")).is_ok());
2642 }
2643
2644 #[test]
2645 fn factory_openai_codex() {
2646 let options = ProviderRuntimeOptions::default();
2647 assert!(create_provider_with_options("openai-codex", None, &options).is_ok());
2648 }
2649
2650 #[test]
2651 fn factory_ollama() {
2652 assert!(create_provider("ollama", None).is_ok());
2653 assert!(create_provider("ollama", Some("dummy")).is_ok());
2655 assert!(create_provider("ollama", Some("any-value-here")).is_ok());
2656 }
2657
2658 #[test]
2659 fn factory_gemini() {
2660 assert!(create_provider("gemini", Some("test-key")).is_ok());
2661 assert!(create_provider("google", Some("test-key")).is_ok());
2662 assert!(create_provider("google-gemini", Some("test-key")).is_ok());
2663 assert!(create_provider("gemini", None).is_ok());
2665 }
2666
2667 #[test]
2668 fn factory_telnyx() {
2669 assert!(create_provider("telnyx", Some("test-key")).is_ok());
2670 assert!(create_provider("telnyx", None).is_ok());
2671 }
2672
2673 #[test]
2676 fn factory_venice() {
2677 let provider = create_provider("venice", Some("vn-key")).unwrap();
2678 assert!(
2679 !provider.capabilities().native_tool_calling,
2680 "Venice should use prompt-guided tools, not native tool calling"
2681 );
2682 }
2683
2684 #[test]
2685 fn factory_vercel() {
2686 assert!(create_provider("vercel", Some("key")).is_ok());
2687 assert!(create_provider("vercel-ai", Some("key")).is_ok());
2688 }
2689
2690 #[test]
2691 fn vercel_gateway_base_url_matches_public_gateway_endpoint() {
2692 assert_eq!(
2693 VERCEL_AI_GATEWAY_BASE_URL,
2694 "https://ai-gateway.vercel.sh/v1"
2695 );
2696 }
2697
2698 #[test]
2699 fn factory_cloudflare() {
2700 assert!(create_provider("cloudflare", Some("key")).is_ok());
2701 assert!(create_provider("cloudflare-ai", Some("key")).is_ok());
2702 }
2703
2704 #[test]
2705 fn factory_moonshot() {
2706 assert!(create_provider("moonshot", Some("key")).is_ok());
2707 assert!(create_provider("kimi", Some("key")).is_ok());
2708 assert!(create_provider("moonshot-intl", Some("key")).is_ok());
2709 assert!(create_provider("moonshot-cn", Some("key")).is_ok());
2710 assert!(create_provider("kimi-intl", Some("key")).is_ok());
2711 assert!(create_provider("kimi-cn", Some("key")).is_ok());
2712 }
2713
2714 #[test]
2715 fn factory_kimi_code() {
2716 assert!(create_provider("kimi-code", Some("key")).is_ok());
2717 assert!(create_provider("kimi_coding", Some("key")).is_ok());
2718 assert!(create_provider("kimi_for_coding", Some("key")).is_ok());
2719 }
2720
2721 #[test]
2722 fn factory_synthetic() {
2723 assert!(create_provider("synthetic", Some("key")).is_ok());
2724 }
2725
2726 #[test]
2727 fn factory_opencode() {
2728 assert!(create_provider("opencode", Some("key")).is_ok());
2729 assert!(create_provider("opencode-zen", Some("key")).is_ok());
2730 }
2731
2732 #[test]
2733 fn factory_opencode_go() {
2734 assert!(create_provider("opencode-go", Some("key")).is_ok());
2735 }
2736
2737 #[test]
2738 fn resolve_provider_credential_opencode_go_env() {
2739 let _env_lock = env_lock();
2740 let _provider_guard = EnvGuard::set("OPENCODE_GO_API_KEY", Some("go-test-key"));
2741 let _generic_guard = EnvGuard::set("API_KEY", None);
2742 let _construct_guard = EnvGuard::set("CONSTRUCT_API_KEY", None);
2743
2744 let resolved = resolve_provider_credential("opencode-go", None);
2745 assert_eq!(resolved.as_deref(), Some("go-test-key"));
2746 }
2747
2748 #[test]
2749 fn factory_zai() {
2750 assert!(create_provider("zai", Some("key")).is_ok());
2751 assert!(create_provider("z.ai", Some("key")).is_ok());
2752 assert!(create_provider("zai-global", Some("key")).is_ok());
2753 assert!(create_provider("z.ai-global", Some("key")).is_ok());
2754 assert!(create_provider("zai-cn", Some("key")).is_ok());
2755 assert!(create_provider("z.ai-cn", Some("key")).is_ok());
2756 }
2757
2758 #[test]
2759 fn factory_glm() {
2760 assert!(create_provider("glm", Some("key")).is_ok());
2761 assert!(create_provider("zhipu", Some("key")).is_ok());
2762 assert!(create_provider("glm-cn", Some("key")).is_ok());
2763 assert!(create_provider("zhipu-cn", Some("key")).is_ok());
2764 assert!(create_provider("glm-global", Some("key")).is_ok());
2765 assert!(create_provider("bigmodel", Some("key")).is_ok());
2766 }
2767
2768 #[test]
2769 fn factory_minimax() {
2770 assert!(create_provider("minimax", Some("key")).is_ok());
2771 assert!(create_provider("minimax-intl", Some("key")).is_ok());
2772 assert!(create_provider("minimax-io", Some("key")).is_ok());
2773 assert!(create_provider("minimax-global", Some("key")).is_ok());
2774 assert!(create_provider("minimax-cn", Some("key")).is_ok());
2775 assert!(create_provider("minimaxi", Some("key")).is_ok());
2776 assert!(create_provider("minimax-oauth", Some("key")).is_ok());
2777 assert!(create_provider("minimax-oauth-cn", Some("key")).is_ok());
2778 assert!(create_provider("minimax-portal", Some("key")).is_ok());
2779 assert!(create_provider("minimax-portal-cn", Some("key")).is_ok());
2780 }
2781
2782 #[test]
2783 fn factory_minimax_disables_native_tool_calling() {
2784 let minimax = create_provider("minimax", Some("key")).expect("provider should resolve");
2785 assert!(!minimax.supports_native_tools());
2786
2787 let minimax_cn =
2788 create_provider("minimax-cn", Some("key")).expect("provider should resolve");
2789 assert!(!minimax_cn.supports_native_tools());
2790 }
2791
2792 #[test]
2793 fn factory_bedrock() {
2794 assert!(create_provider("bedrock", None).is_ok());
2796 assert!(create_provider("aws-bedrock", None).is_ok());
2797 assert!(create_provider("bedrock", Some("ignored")).is_ok());
2799 }
2800
2801 #[test]
2802 fn factory_qianfan() {
2803 assert!(create_provider("qianfan", Some("key")).is_ok());
2804 assert!(create_provider("baidu", Some("key")).is_ok());
2805 }
2806
2807 #[test]
2808 fn factory_doubao() {
2809 assert!(create_provider("doubao", Some("key")).is_ok());
2810 assert!(create_provider("volcengine", Some("key")).is_ok());
2811 assert!(create_provider("ark", Some("key")).is_ok());
2812 assert!(create_provider("doubao-cn", Some("key")).is_ok());
2813 }
2814
2815 #[test]
2816 fn factory_qwen() {
2817 assert!(create_provider("qwen", Some("key")).is_ok());
2818 assert!(create_provider("dashscope", Some("key")).is_ok());
2819 assert!(create_provider("qwen-cn", Some("key")).is_ok());
2820 assert!(create_provider("dashscope-cn", Some("key")).is_ok());
2821 assert!(create_provider("qwen-intl", Some("key")).is_ok());
2822 assert!(create_provider("dashscope-intl", Some("key")).is_ok());
2823 assert!(create_provider("qwen-international", Some("key")).is_ok());
2824 assert!(create_provider("dashscope-international", Some("key")).is_ok());
2825 assert!(create_provider("qwen-us", Some("key")).is_ok());
2826 assert!(create_provider("dashscope-us", Some("key")).is_ok());
2827 assert!(create_provider("qwen-code", Some("key")).is_ok());
2828 assert!(create_provider("qwen-oauth", Some("key")).is_ok());
2829 }
2830
2831 #[test]
2832 fn qwen_provider_supports_vision() {
2833 let provider = create_provider("qwen", Some("key")).expect("qwen provider should build");
2834 assert!(provider.supports_vision());
2835
2836 let oauth_provider =
2837 create_provider("qwen-code", Some("key")).expect("qwen oauth provider should build");
2838 assert!(oauth_provider.supports_vision());
2839 }
2840
2841 #[test]
2842 fn factory_lmstudio() {
2843 assert!(create_provider("lmstudio", Some("key")).is_ok());
2844 assert!(create_provider("lm-studio", Some("key")).is_ok());
2845 assert!(create_provider("lmstudio", None).is_ok());
2846 }
2847
2848 #[test]
2849 fn factory_llamacpp() {
2850 assert!(create_provider("llamacpp", Some("key")).is_ok());
2851 assert!(create_provider("llama.cpp", Some("key")).is_ok());
2852 assert!(create_provider("llamacpp", None).is_ok());
2853 }
2854
2855 #[test]
2856 fn factory_sglang() {
2857 assert!(create_provider("sglang", None).is_ok());
2858 assert!(create_provider("sglang", Some("key")).is_ok());
2859 }
2860
2861 #[test]
2862 fn factory_vllm() {
2863 assert!(create_provider("vllm", None).is_ok());
2864 assert!(create_provider("vllm", Some("key")).is_ok());
2865 }
2866
2867 #[test]
2868 fn factory_osaurus() {
2869 assert!(create_provider("osaurus", None).is_ok());
2871 assert!(create_provider("osaurus", Some("custom-key")).is_ok());
2873 }
2874
2875 #[test]
2876 fn factory_osaurus_uses_default_key_when_none() {
2877 let options = ProviderRuntimeOptions::default();
2880 let p = create_provider_with_url_and_options("osaurus", None, None, &options);
2881 assert!(p.is_ok());
2882 }
2883
2884 #[test]
2885 fn factory_osaurus_custom_url() {
2886 let options = ProviderRuntimeOptions::default();
2888 let p = create_provider_with_url_and_options(
2889 "osaurus",
2890 Some("key"),
2891 Some("http://192.168.1.100:1337/v1"),
2892 &options,
2893 );
2894 assert!(p.is_ok());
2895 }
2896
2897 #[test]
2898 fn resolve_provider_credential_osaurus_env() {
2899 let _env_lock = env_lock();
2900 let _guard = EnvGuard::set("OSAURUS_API_KEY", Some("osaurus-test-key"));
2901 let resolved = resolve_provider_credential("osaurus", None);
2902 assert_eq!(resolved, Some("osaurus-test-key".to_string()));
2903 }
2904
2905 #[test]
2906 fn resolve_provider_credential_volcengine_env() {
2907 let _env_lock = env_lock();
2908 let _guard = EnvGuard::set("VOLCENGINE_API_KEY", Some("volc-test-key"));
2909 let resolved = resolve_provider_credential("volcengine", None);
2910 assert_eq!(resolved, Some("volc-test-key".to_string()));
2911 }
2912
2913 #[test]
2914 fn resolve_provider_credential_aihubmix_env() {
2915 let _env_lock = env_lock();
2916 let _guard = EnvGuard::set("AIHUBMIX_API_KEY", Some("aihubmix-test-key"));
2917 let resolved = resolve_provider_credential("aihubmix", None);
2918 assert_eq!(resolved, Some("aihubmix-test-key".to_string()));
2919 }
2920
2921 #[test]
2922 fn resolve_provider_credential_siliconflow_env() {
2923 let _env_lock = env_lock();
2924 let _guard = EnvGuard::set("SILICONFLOW_API_KEY", Some("sf-test-key"));
2925 let resolved = resolve_provider_credential("siliconflow", None);
2926 assert_eq!(resolved, Some("sf-test-key".to_string()));
2927 }
2928
2929 #[test]
2930 fn factory_aihubmix() {
2931 assert!(create_provider("aihubmix", Some("key")).is_ok());
2932 }
2933
2934 #[test]
2935 fn factory_siliconflow() {
2936 assert!(create_provider("siliconflow", Some("key")).is_ok());
2937 assert!(create_provider("silicon-flow", Some("key")).is_ok());
2938 }
2939
2940 #[test]
2941 fn factory_codex_oauth_aliases() {
2942 let options = ProviderRuntimeOptions::default();
2943 for alias in &["codex", "openai-codex", "openai_codex"] {
2944 assert!(
2945 create_provider_with_options(alias, None, &options).is_ok(),
2946 "codex alias '{alias}' should produce a provider"
2947 );
2948 }
2949 }
2950
2951 #[test]
2954 fn factory_groq() {
2955 assert!(create_provider("groq", Some("key")).is_ok());
2956 }
2957
2958 #[test]
2959 fn factory_mistral() {
2960 assert!(create_provider("mistral", Some("key")).is_ok());
2961 }
2962
2963 #[test]
2964 fn factory_xai() {
2965 assert!(create_provider("xai", Some("key")).is_ok());
2966 assert!(create_provider("grok", Some("key")).is_ok());
2967 }
2968
2969 #[test]
2970 fn factory_deepseek() {
2971 assert!(create_provider("deepseek", Some("key")).is_ok());
2972 }
2973
2974 #[test]
2975 fn deepseek_provider_keeps_vision_disabled() {
2976 let provider =
2977 create_provider("deepseek", Some("key")).expect("deepseek provider should build");
2978 assert!(!provider.supports_vision());
2979 }
2980
2981 #[test]
2982 fn factory_together() {
2983 assert!(create_provider("together", Some("key")).is_ok());
2984 assert!(create_provider("together-ai", Some("key")).is_ok());
2985 }
2986
2987 #[test]
2988 fn factory_fireworks() {
2989 assert!(create_provider("fireworks", Some("key")).is_ok());
2990 assert!(create_provider("fireworks-ai", Some("key")).is_ok());
2991 }
2992
2993 #[test]
2994 fn factory_novita() {
2995 assert!(create_provider("novita", Some("key")).is_ok());
2996 }
2997
2998 #[test]
2999 fn factory_perplexity() {
3000 assert!(create_provider("perplexity", Some("key")).is_ok());
3001 }
3002
3003 #[test]
3004 fn factory_cohere() {
3005 assert!(create_provider("cohere", Some("key")).is_ok());
3006 }
3007
3008 #[test]
3009 fn factory_copilot() {
3010 assert!(create_provider("copilot", Some("key")).is_ok());
3011 assert!(create_provider("github-copilot", Some("key")).is_ok());
3012 }
3013
3014 #[test]
3015 fn factory_claude_code() {
3016 assert!(create_provider("claude-code", None).is_ok());
3017 }
3018
3019 #[test]
3020 fn factory_gemini_cli() {
3021 assert!(create_provider("gemini-cli", None).is_ok());
3022 }
3023
3024 #[test]
3025 fn factory_kilocli() {
3026 assert!(create_provider("kilocli", None).is_ok());
3027 assert!(create_provider("kilo", None).is_ok());
3028 }
3029
3030 #[test]
3031 fn factory_nvidia() {
3032 assert!(create_provider("nvidia", Some("nvapi-test")).is_ok());
3033 assert!(create_provider("nvidia-nim", Some("nvapi-test")).is_ok());
3034 assert!(create_provider("build.nvidia.com", Some("nvapi-test")).is_ok());
3035 }
3036
3037 #[test]
3040 fn factory_astrai() {
3041 assert!(create_provider("astrai", Some("sk-astrai-test")).is_ok());
3042 }
3043
3044 #[test]
3045 fn factory_avian() {
3046 assert!(create_provider("avian", Some("sk-avian-test")).is_ok());
3047 }
3048
3049 #[test]
3050 fn factory_deepmyst() {
3051 assert!(create_provider("deepmyst", Some("key")).is_ok());
3052 assert!(create_provider("deep-myst", Some("key")).is_ok());
3053 }
3054
3055 #[test]
3056 fn resolve_provider_credential_deepmyst_env() {
3057 let _env_lock = env_lock();
3058 let _guard = EnvGuard::set("DEEPMYST_API_KEY", Some("dm-test-key"));
3059 let resolved = resolve_provider_credential("deepmyst", None);
3060 assert_eq!(resolved, Some("dm-test-key".to_string()));
3061 }
3062
3063 #[test]
3066 fn factory_custom_url() {
3067 let p = create_provider("custom:https://my-llm.example.com", Some("key"));
3068 assert!(p.is_ok());
3069 }
3070
3071 #[test]
3072 fn factory_custom_localhost() {
3073 let p = create_provider("custom:http://localhost:1234", Some("key"));
3074 assert!(p.is_ok());
3075 }
3076
3077 #[test]
3078 fn factory_custom_no_key() {
3079 let p = create_provider("custom:https://my-llm.example.com", None);
3080 assert!(p.is_ok());
3081 }
3082
3083 #[test]
3084 fn factory_custom_empty_url_errors() {
3085 match create_provider("custom:", None) {
3086 Err(e) => assert!(
3087 e.to_string().contains("requires a URL"),
3088 "Expected 'requires a URL', got: {e}"
3089 ),
3090 Ok(_) => panic!("Expected error for empty custom URL"),
3091 }
3092 }
3093
3094 #[test]
3095 fn factory_custom_invalid_url_errors() {
3096 match create_provider("custom:not-a-url", None) {
3097 Err(e) => assert!(
3098 e.to_string().contains("requires a valid URL"),
3099 "Expected 'requires a valid URL', got: {e}"
3100 ),
3101 Ok(_) => panic!("Expected error for invalid custom URL"),
3102 }
3103 }
3104
3105 #[test]
3106 fn factory_custom_unsupported_scheme_errors() {
3107 match create_provider("custom:ftp://example.com", None) {
3108 Err(e) => assert!(
3109 e.to_string().contains("http:// or https://"),
3110 "Expected scheme validation error, got: {e}"
3111 ),
3112 Ok(_) => panic!("Expected error for unsupported custom URL scheme"),
3113 }
3114 }
3115
3116 #[test]
3117 fn factory_custom_trims_whitespace() {
3118 let p = create_provider("custom: https://my-llm.example.com ", Some("key"));
3119 assert!(p.is_ok());
3120 }
3121
3122 #[test]
3125 fn factory_anthropic_custom_url() {
3126 let p = create_provider("anthropic-custom:https://api.example.com", Some("key"));
3127 assert!(p.is_ok());
3128 }
3129
3130 #[test]
3131 fn factory_anthropic_custom_trailing_slash() {
3132 let p = create_provider("anthropic-custom:https://api.example.com/", Some("key"));
3133 assert!(p.is_ok());
3134 }
3135
3136 #[test]
3137 fn factory_anthropic_custom_no_key() {
3138 let p = create_provider("anthropic-custom:https://api.example.com", None);
3139 assert!(p.is_ok());
3140 }
3141
3142 #[test]
3143 fn factory_anthropic_custom_empty_url_errors() {
3144 match create_provider("anthropic-custom:", None) {
3145 Err(e) => assert!(
3146 e.to_string().contains("requires a URL"),
3147 "Expected 'requires a URL', got: {e}"
3148 ),
3149 Ok(_) => panic!("Expected error for empty anthropic-custom URL"),
3150 }
3151 }
3152
3153 #[test]
3154 fn factory_anthropic_custom_invalid_url_errors() {
3155 match create_provider("anthropic-custom:not-a-url", None) {
3156 Err(e) => assert!(
3157 e.to_string().contains("requires a valid URL"),
3158 "Expected 'requires a valid URL', got: {e}"
3159 ),
3160 Ok(_) => panic!("Expected error for invalid anthropic-custom URL"),
3161 }
3162 }
3163
3164 #[test]
3165 fn factory_anthropic_custom_unsupported_scheme_errors() {
3166 match create_provider("anthropic-custom:ftp://example.com", None) {
3167 Err(e) => assert!(
3168 e.to_string().contains("http:// or https://"),
3169 "Expected scheme validation error, got: {e}"
3170 ),
3171 Ok(_) => panic!("Expected error for unsupported anthropic-custom URL scheme"),
3172 }
3173 }
3174
3175 #[test]
3178 fn factory_unknown_provider_errors() {
3179 let p = create_provider("nonexistent", None);
3180 assert!(p.is_err());
3181 let msg = p.err().unwrap().to_string();
3182 assert!(msg.contains("Unknown provider"));
3183 assert!(msg.contains("nonexistent"));
3184 }
3185
3186 #[test]
3187 fn factory_empty_name_errors() {
3188 assert!(create_provider("", None).is_err());
3189 }
3190
3191 #[test]
3192 fn resilient_provider_ignores_duplicate_and_invalid_fallbacks() {
3193 let reliability = crate::config::ReliabilityConfig {
3194 provider_retries: 1,
3195 provider_backoff_ms: 100,
3196 fallback_providers: vec![
3197 "openrouter".into(),
3198 "nonexistent-provider".into(),
3199 "openai".into(),
3200 "openai".into(),
3201 ],
3202 api_keys: Vec::new(),
3203 model_fallbacks: std::collections::HashMap::new(),
3204 channel_initial_backoff_secs: 2,
3205 channel_max_backoff_secs: 60,
3206 scheduler_poll_secs: 15,
3207 scheduler_retries: 2,
3208 };
3209
3210 let provider = create_resilient_provider(
3211 "openrouter",
3212 Some("provider-test-credential"),
3213 None,
3214 &reliability,
3215 );
3216 assert!(provider.is_ok());
3217 }
3218
3219 #[test]
3220 fn resilient_provider_errors_for_invalid_primary() {
3221 let reliability = crate::config::ReliabilityConfig::default();
3222 let provider = create_resilient_provider(
3223 "totally-invalid",
3224 Some("provider-test-credential"),
3225 None,
3226 &reliability,
3227 );
3228 assert!(provider.is_err());
3229 }
3230
3231 #[test]
3236 fn resilient_fallback_resolves_own_credential() {
3237 let reliability = crate::config::ReliabilityConfig {
3238 provider_retries: 1,
3239 provider_backoff_ms: 100,
3240 fallback_providers: vec!["lmstudio".into(), "ollama".into()],
3241 api_keys: Vec::new(),
3242 model_fallbacks: std::collections::HashMap::new(),
3243 channel_initial_backoff_secs: 2,
3244 channel_max_backoff_secs: 60,
3245 scheduler_poll_secs: 15,
3246 scheduler_retries: 2,
3247 };
3248
3249 let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
3252 assert!(provider.is_ok());
3253 }
3254
3255 #[test]
3258 fn resilient_fallback_supports_custom_url() {
3259 let reliability = crate::config::ReliabilityConfig {
3260 provider_retries: 1,
3261 provider_backoff_ms: 100,
3262 fallback_providers: vec!["custom:http://host.docker.internal:1234/v1".into()],
3263 api_keys: Vec::new(),
3264 model_fallbacks: std::collections::HashMap::new(),
3265 channel_initial_backoff_secs: 2,
3266 channel_max_backoff_secs: 60,
3267 scheduler_poll_secs: 15,
3268 scheduler_retries: 2,
3269 };
3270
3271 let provider =
3272 create_resilient_provider("openai", Some("openai-test-key"), None, &reliability);
3273 assert!(provider.is_ok());
3274 }
3275
3276 #[test]
3279 fn resilient_fallback_mixed_chain() {
3280 let reliability = crate::config::ReliabilityConfig {
3281 provider_retries: 1,
3282 provider_backoff_ms: 100,
3283 fallback_providers: vec![
3284 "deepseek".into(),
3285 "custom:http://localhost:8080/v1".into(),
3286 "nonexistent-provider".into(),
3287 "lmstudio".into(),
3288 ],
3289 api_keys: Vec::new(),
3290 model_fallbacks: std::collections::HashMap::new(),
3291 channel_initial_backoff_secs: 2,
3292 channel_max_backoff_secs: 60,
3293 scheduler_poll_secs: 15,
3294 scheduler_retries: 2,
3295 };
3296
3297 let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
3298 assert!(provider.is_ok());
3299 }
3300
3301 #[test]
3302 fn ollama_with_custom_url() {
3303 let provider = create_provider_with_url("ollama", None, Some("http://10.100.2.32:11434"));
3304 assert!(provider.is_ok());
3305 }
3306
3307 #[test]
3308 fn ollama_cloud_with_custom_url() {
3309 let provider =
3310 create_provider_with_url("ollama", Some("ollama-key"), Some("https://ollama.com"));
3311 assert!(provider.is_ok());
3312 }
3313
3314 #[test]
3316 fn resilient_fallback_includes_osaurus() {
3317 let reliability = crate::config::ReliabilityConfig {
3318 provider_retries: 1,
3319 provider_backoff_ms: 100,
3320 fallback_providers: vec!["osaurus".into(), "lmstudio".into()],
3321 api_keys: Vec::new(),
3322 model_fallbacks: std::collections::HashMap::new(),
3323 channel_initial_backoff_secs: 2,
3324 channel_max_backoff_secs: 60,
3325 scheduler_poll_secs: 15,
3326 scheduler_retries: 2,
3327 };
3328
3329 let provider = create_resilient_provider("zai", Some("zai-test-key"), None, &reliability);
3330 assert!(provider.is_ok());
3331 }
3332
3333 #[test]
3334 fn factory_all_providers_create_successfully() {
3335 let providers = [
3336 "openrouter",
3337 "anthropic",
3338 "openai",
3339 "ollama",
3340 "gemini",
3341 "venice",
3342 "vercel",
3343 "cloudflare",
3344 "moonshot",
3345 "moonshot-intl",
3346 "kimi-code",
3347 "moonshot-cn",
3348 "kimi-code",
3349 "synthetic",
3350 "opencode",
3351 "opencode-go",
3352 "zai",
3353 "zai-cn",
3354 "glm",
3355 "glm-cn",
3356 "minimax",
3357 "minimax-cn",
3358 "bedrock",
3359 "qianfan",
3360 "doubao",
3361 "qwen",
3362 "qwen-intl",
3363 "qwen-cn",
3364 "qwen-us",
3365 "qwen-code",
3366 "lmstudio",
3367 "llamacpp",
3368 "sglang",
3369 "vllm",
3370 "osaurus",
3371 "telnyx",
3372 "groq",
3373 "mistral",
3374 "xai",
3375 "deepseek",
3376 "together",
3377 "fireworks",
3378 "novita",
3379 "perplexity",
3380 "cohere",
3381 "copilot",
3382 "claude-code",
3383 "gemini-cli",
3384 "kilocli",
3385 "nvidia",
3386 "astrai",
3387 "avian",
3388 "ovhcloud",
3389 ];
3390 for name in providers {
3391 assert!(
3392 create_provider(name, Some("test-key")).is_ok(),
3393 "Provider '{name}' should create successfully"
3394 );
3395 }
3396 }
3397
3398 #[test]
3399 fn listed_providers_have_unique_ids_and_aliases() {
3400 let providers = list_providers();
3401 let mut canonical_ids = std::collections::HashSet::new();
3402 let mut aliases = std::collections::HashSet::new();
3403
3404 for provider in providers {
3405 assert!(
3406 canonical_ids.insert(provider.name),
3407 "Duplicate canonical provider id: {}",
3408 provider.name
3409 );
3410
3411 for alias in provider.aliases {
3412 assert_ne!(
3413 *alias, provider.name,
3414 "Alias must differ from canonical id: {}",
3415 provider.name
3416 );
3417 assert!(
3418 !canonical_ids.contains(alias),
3419 "Alias conflicts with canonical provider id: {}",
3420 alias
3421 );
3422 assert!(aliases.insert(alias), "Duplicate provider alias: {}", alias);
3423 }
3424 }
3425 }
3426
3427 #[test]
3428 fn listed_providers_and_aliases_are_constructible() {
3429 for provider in list_providers() {
3430 assert!(
3431 create_provider(provider.name, Some("provider-test-credential")).is_ok(),
3432 "Canonical provider id should be constructible: {}",
3433 provider.name
3434 );
3435
3436 for alias in provider.aliases {
3437 assert!(
3438 create_provider(alias, Some("provider-test-credential")).is_ok(),
3439 "Provider alias should be constructible: {} (for {})",
3440 alias,
3441 provider.name
3442 );
3443 }
3444 }
3445 }
3446
3447 #[test]
3450 fn sanitize_scrubs_sk_prefix() {
3451 let input = "request failed: sk-1234567890abcdef";
3452 let out = sanitize_api_error(input);
3453 assert!(!out.contains("sk-1234567890abcdef"));
3454 assert!(out.contains("[REDACTED]"));
3455 }
3456
3457 #[test]
3458 fn sanitize_scrubs_multiple_prefixes() {
3459 let input = "keys sk-abcdef xoxb-12345 xoxp-67890";
3460 let out = sanitize_api_error(input);
3461 assert!(!out.contains("sk-abcdef"));
3462 assert!(!out.contains("xoxb-12345"));
3463 assert!(!out.contains("xoxp-67890"));
3464 }
3465
3466 #[test]
3467 fn sanitize_short_prefix_then_real_key() {
3468 let input = "error with sk- prefix and key sk-1234567890";
3469 let result = sanitize_api_error(input);
3470 assert!(!result.contains("sk-1234567890"));
3471 assert!(result.contains("[REDACTED]"));
3472 }
3473
3474 #[test]
3475 fn sanitize_sk_proj_comment_then_real_key() {
3476 let input = "note: sk- then sk-proj-abc123def456";
3477 let result = sanitize_api_error(input);
3478 assert!(!result.contains("sk-proj-abc123def456"));
3479 assert!(result.contains("[REDACTED]"));
3480 }
3481
3482 #[test]
3483 fn sanitize_keeps_bare_prefix() {
3484 let input = "only prefix sk- present";
3485 let result = sanitize_api_error(input);
3486 assert!(result.contains("sk-"));
3487 }
3488
3489 #[test]
3490 fn sanitize_handles_json_wrapped_key() {
3491 let input = r#"{"error":"invalid key sk-abc123xyz"}"#;
3492 let result = sanitize_api_error(input);
3493 assert!(!result.contains("sk-abc123xyz"));
3494 }
3495
3496 #[test]
3497 fn sanitize_handles_delimiter_boundaries() {
3498 let input = "bad token xoxb-abc123}; next";
3499 let result = sanitize_api_error(input);
3500 assert!(!result.contains("xoxb-abc123"));
3501 assert!(result.contains("};"));
3502 }
3503
3504 #[test]
3505 fn sanitize_truncates_long_error() {
3506 let long = "a".repeat(600);
3507 let result = sanitize_api_error(&long);
3508 assert!(result.len() <= 503);
3509 assert!(result.ends_with("..."));
3510 }
3511
3512 #[test]
3513 fn sanitize_truncates_after_scrub() {
3514 let input = format!("{} sk-abcdef123456 {}", "a".repeat(290), "b".repeat(290));
3515 let result = sanitize_api_error(&input);
3516 assert!(!result.contains("sk-abcdef123456"));
3517 assert!(result.len() <= 503);
3518 }
3519
3520 #[test]
3521 fn sanitize_preserves_unicode_boundaries() {
3522 let input = format!("{} sk-abcdef123", "hello🙂".repeat(80));
3523 let result = sanitize_api_error(&input);
3524 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
3525 assert!(!result.contains("sk-abcdef123"));
3526 }
3527
3528 #[test]
3529 fn sanitize_no_secret_no_change() {
3530 let input = "simple upstream timeout";
3531 let result = sanitize_api_error(input);
3532 assert_eq!(result, input);
3533 }
3534
3535 #[test]
3536 fn scrub_github_personal_access_token() {
3537 let input = "auth failed with token ghp_abc123def456";
3538 let result = scrub_secret_patterns(input);
3539 assert_eq!(result, "auth failed with token [REDACTED]");
3540 }
3541
3542 #[test]
3543 fn scrub_github_oauth_token() {
3544 let input = "Bearer gho_1234567890abcdef";
3545 let result = scrub_secret_patterns(input);
3546 assert_eq!(result, "Bearer [REDACTED]");
3547 }
3548
3549 #[test]
3550 fn scrub_github_user_token() {
3551 let input = "token ghu_sessiontoken123";
3552 let result = scrub_secret_patterns(input);
3553 assert_eq!(result, "token [REDACTED]");
3554 }
3555
3556 #[test]
3557 fn scrub_github_fine_grained_pat() {
3558 let input = "failed: github_pat_11AABBC_xyzzy789";
3559 let result = scrub_secret_patterns(input);
3560 assert_eq!(result, "failed: [REDACTED]");
3561 }
3562
3563 #[test]
3566 fn parse_provider_profile_plain_name() {
3567 let (name, profile) = parse_provider_profile("gemini");
3568 assert_eq!(name, "gemini");
3569 assert_eq!(profile, None);
3570 }
3571
3572 #[test]
3573 fn parse_provider_profile_with_profile() {
3574 let (name, profile) = parse_provider_profile("openai-codex:second");
3575 assert_eq!(name, "openai-codex");
3576 assert_eq!(profile, Some("second"));
3577 }
3578
3579 #[test]
3580 fn parse_provider_profile_custom_url_not_split() {
3581 let input = "custom:https://my-api.example.com/v1";
3582 let (name, profile) = parse_provider_profile(input);
3583 assert_eq!(name, input);
3584 assert_eq!(profile, None);
3585 }
3586
3587 #[test]
3588 fn parse_provider_profile_anthropic_custom_not_split() {
3589 let input = "anthropic-custom:https://bedrock.example.com";
3590 let (name, profile) = parse_provider_profile(input);
3591 assert_eq!(name, input);
3592 assert_eq!(profile, None);
3593 }
3594
3595 #[test]
3596 fn parse_provider_profile_empty_profile_ignored() {
3597 let (name, profile) = parse_provider_profile("openai-codex:");
3598 assert_eq!(name, "openai-codex:");
3599 assert_eq!(profile, None);
3600 }
3601
3602 #[test]
3603 fn parse_provider_profile_extra_colons_kept() {
3604 let (name, profile) = parse_provider_profile("provider:profile:extra");
3605 assert_eq!(name, "provider");
3606 assert_eq!(profile, Some("profile:extra"));
3607 }
3608
3609 #[test]
3612 fn resilient_fallback_with_profile_syntax() {
3613 let _guard = env_lock();
3614
3615 let reliability = crate::config::ReliabilityConfig {
3616 provider_retries: 1,
3617 provider_backoff_ms: 100,
3618 fallback_providers: vec!["openai-codex:second".into()],
3619 api_keys: Vec::new(),
3620 model_fallbacks: std::collections::HashMap::new(),
3621 channel_initial_backoff_secs: 2,
3622 channel_max_backoff_secs: 60,
3623 scheduler_poll_secs: 15,
3624 scheduler_retries: 2,
3625 };
3626
3627 let provider = create_resilient_provider("lmstudio", None, None, &reliability);
3632 assert!(provider.is_ok());
3633 }
3634
3635 #[test]
3636 fn resilient_fallback_mixed_profiles_and_custom() {
3637 let _guard = env_lock();
3638
3639 let reliability = crate::config::ReliabilityConfig {
3640 provider_retries: 1,
3641 provider_backoff_ms: 100,
3642 fallback_providers: vec![
3643 "openai-codex:second".into(),
3644 "custom:http://localhost:8080/v1".into(),
3645 "lmstudio".into(),
3646 "nonexistent-provider".into(),
3647 ],
3648 api_keys: Vec::new(),
3649 model_fallbacks: std::collections::HashMap::new(),
3650 channel_initial_backoff_secs: 2,
3651 channel_max_backoff_secs: 60,
3652 scheduler_poll_secs: 15,
3653 scheduler_retries: 2,
3654 };
3655
3656 let provider = create_resilient_provider("ollama", None, None, &reliability);
3657 assert!(provider.is_ok());
3658 }
3659
3660 #[test]
3663 fn api_key_prefix_cross_provider_mismatch() {
3664 assert_eq!(
3666 check_api_key_prefix("openrouter", "sk-ant-api03-xyz"),
3667 Some("anthropic")
3668 );
3669 assert_eq!(
3671 check_api_key_prefix("anthropic", "sk-or-v1-xyz"),
3672 Some("openrouter")
3673 );
3674 assert_eq!(
3676 check_api_key_prefix("openai", "sk-ant-xyz"),
3677 Some("anthropic")
3678 );
3679 assert_eq!(check_api_key_prefix("openai", "gsk_xyz"), Some("groq"));
3681 }
3682
3683 #[test]
3684 fn api_key_prefix_correct_match() {
3685 assert_eq!(check_api_key_prefix("anthropic", "sk-ant-api03-xyz"), None);
3686 assert_eq!(check_api_key_prefix("openrouter", "sk-or-v1-xyz"), None);
3687 assert_eq!(check_api_key_prefix("openai", "sk-proj-xyz"), None);
3688 assert_eq!(check_api_key_prefix("groq", "gsk_xyz"), None);
3689 }
3690
3691 #[test]
3692 fn api_key_prefix_unknown_provider_skips() {
3693 assert_eq!(check_api_key_prefix("deepseek", "sk-ant-xyz"), None);
3695 assert_eq!(check_api_key_prefix("ollama", "anything"), None);
3696 }
3697
3698 #[test]
3699 fn api_key_prefix_unknown_key_format_skips() {
3700 assert_eq!(check_api_key_prefix("openai", "my-custom-key-123"), None);
3702 assert_eq!(check_api_key_prefix("anthropic", "some-random-key"), None);
3703 }
3704
3705 #[test]
3706 fn provider_runtime_options_default_has_empty_extra_headers() {
3707 let options = ProviderRuntimeOptions::default();
3708 assert!(options.extra_headers.is_empty());
3709 }
3710
3711 #[test]
3712 fn provider_runtime_options_extra_headers_passed_through() {
3713 let mut extra_headers = std::collections::HashMap::new();
3714 extra_headers.insert("X-Title".to_string(), "construct".to_string());
3715 let options = ProviderRuntimeOptions {
3716 extra_headers,
3717 ..ProviderRuntimeOptions::default()
3718 };
3719 assert_eq!(options.extra_headers.len(), 1);
3720 assert_eq!(options.extra_headers.get("X-Title").unwrap(), "construct");
3721 }
3722
3723 #[test]
3724 fn env_provider_url_overrides_api_url() {
3725 unsafe { std::env::set_var("CONSTRUCT_PROVIDER_URL", "http://env-ollama:11434") };
3727
3728 let options = ProviderRuntimeOptions::default();
3729
3730 let provider = create_provider_with_url_and_options(
3731 "ollama",
3732 Some("http://config-ollama:11434"),
3733 None,
3734 &options,
3735 );
3736
3737 assert!(provider.is_ok());
3738
3739 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER_URL") };
3741 }
3742}