1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::types::ReasoningEffortLevel;
7
8fn default_auth_timeout_ms() -> u64 {
9 5_000
10}
11
12fn default_auth_refresh_interval_ms() -> u64 {
13 300_000
14}
15
16fn skip_serializing_custom_provider_api_format(api_format: &CustomProviderApiFormat) -> bool {
17 api_format.is_auto()
18}
19
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
23pub enum CustomProviderApiFormat {
24 #[default]
25 #[serde(rename = "auto")]
26 Auto,
27 #[serde(rename = "openai-chat")]
28 OpenAIChat,
29 #[serde(rename = "openai-responses")]
30 OpenAIResponses,
31 #[serde(rename = "anthropic-messages")]
32 AnthropicMessages,
33}
34
35impl CustomProviderApiFormat {
36 pub const fn as_str(self) -> &'static str {
37 match self {
38 Self::Auto => "auto",
39 Self::OpenAIChat => "openai-chat",
40 Self::OpenAIResponses => "openai-responses",
41 Self::AnthropicMessages => "anthropic-messages",
42 }
43 }
44
45 pub const fn is_auto(self) -> bool {
46 matches!(self, Self::Auto)
47 }
48
49 pub const fn resolved(self) -> Option<Self> {
50 match self {
51 Self::Auto => None,
52 other => Some(other),
53 }
54 }
55}
56
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
60pub struct CustomProviderProfileConfig {
61 #[serde(default, skip_serializing_if = "skip_serializing_custom_provider_api_format")]
63 pub api_format: CustomProviderApiFormat,
64
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub context_window: Option<usize>,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub temperature: Option<f32>,
72
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub top_p: Option<f32>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub top_k: Option<i32>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub presence_penalty: Option<f32>,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub frequency_penalty: Option<f32>,
88
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub max_tokens: Option<u32>,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub reasoning_effort: Option<ReasoningEffortLevel>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub supports_tools: Option<bool>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub supports_reasoning: Option<bool>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub supports_reasoning_effort: Option<bool>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub supports_vision: Option<bool>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub supports_structured_output: Option<bool>,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub supports_parallel_tool_calls: Option<bool>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub supports_context_caching: Option<bool>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub supports_responses_compaction: Option<bool>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub supports_prompt_cache_options: Option<bool>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub supports_context_edits: Option<bool>,
126}
127
128impl CustomProviderProfileConfig {
129 fn validate(&self, provider_name: &str, profile_key: &str) -> Result<(), String> {
130 if self.context_window == Some(0) {
131 return Err(format!(
132 "custom_providers[{provider_name}].profiles[{profile_key}]: `context_window` must be greater than 0"
133 ));
134 }
135
136 if let Some(temperature) = self.temperature
137 && !(0.0..=2.0).contains(&temperature)
138 {
139 return Err(format!(
140 "custom_providers[{provider_name}].profiles[{profile_key}]: `temperature` must be between 0.0 and 2.0"
141 ));
142 }
143
144 if let Some(top_p) = self.top_p
145 && !(0.0..=1.0).contains(&top_p)
146 {
147 return Err(format!(
148 "custom_providers[{provider_name}].profiles[{profile_key}]: `top_p` must be between 0.0 and 1.0"
149 ));
150 }
151
152 if let Some(top_k) = self.top_k
153 && top_k < 0
154 {
155 return Err(format!("custom_providers[{provider_name}].profiles[{profile_key}]: `top_k` must be >= 0"));
156 }
157
158 for (field, value) in [
159 ("`presence_penalty`", self.presence_penalty),
160 ("`frequency_penalty`", self.frequency_penalty),
161 ] {
162 if let Some(value) = value
163 && !(-2.0..=2.0).contains(&value)
164 {
165 return Err(format!(
166 "custom_providers[{provider_name}].profiles[{profile_key}]: {field} must be between -2.0 and 2.0"
167 ));
168 }
169 }
170
171 if self.max_tokens == Some(0) {
172 return Err(format!(
173 "custom_providers[{provider_name}].profiles[{profile_key}]: `max_tokens` must be greater than 0"
174 ));
175 }
176
177 if self.reasoning_effort == Some(ReasoningEffortLevel::Unknown) {
178 return Err(format!(
179 "custom_providers[{provider_name}].profiles[{profile_key}]: `reasoning_effort` is not a recognized level (use none, minimal, low, medium, high, xhigh, or max)"
180 ));
181 }
182
183 Ok(())
184 }
185}
186
187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[derive(Debug, Clone, Default, PartialEq)]
191pub struct ResolvedCustomProviderProfile {
192 pub api_format: Option<CustomProviderApiFormat>,
193 pub context_window: Option<usize>,
194 pub temperature: Option<f32>,
195 pub top_p: Option<f32>,
196 pub top_k: Option<i32>,
197 pub presence_penalty: Option<f32>,
198 pub frequency_penalty: Option<f32>,
199 pub max_tokens: Option<u32>,
200 pub reasoning_effort: Option<ReasoningEffortLevel>,
201 pub supports_tools: Option<bool>,
202 pub supports_reasoning: Option<bool>,
203 pub supports_reasoning_effort: Option<bool>,
204 pub supports_vision: Option<bool>,
205 pub supports_structured_output: Option<bool>,
206 pub supports_parallel_tool_calls: Option<bool>,
207 pub supports_context_caching: Option<bool>,
208 pub supports_responses_compaction: Option<bool>,
209 pub supports_prompt_cache_options: Option<bool>,
211 pub supports_context_edits: Option<bool>,
212}
213
214impl ResolvedCustomProviderProfile {
215 fn from_layers(defaults: &CustomProviderProfileConfig, profile: Option<&CustomProviderProfileConfig>) -> Self {
216 let fallback_profile;
217 let profile = match profile {
218 Some(profile) => profile,
219 None => {
220 fallback_profile = CustomProviderProfileConfig::default();
221 &fallback_profile
222 }
223 };
224
225 Self {
226 api_format: profile.api_format.resolved().or(defaults.api_format.resolved()),
227 context_window: profile.context_window.or(defaults.context_window),
228 temperature: profile.temperature.or(defaults.temperature),
229 top_p: profile.top_p.or(defaults.top_p),
230 top_k: profile.top_k.or(defaults.top_k),
231 presence_penalty: profile.presence_penalty.or(defaults.presence_penalty),
232 frequency_penalty: profile.frequency_penalty.or(defaults.frequency_penalty),
233 max_tokens: profile.max_tokens.or(defaults.max_tokens),
234 reasoning_effort: profile.reasoning_effort.or(defaults.reasoning_effort),
235 supports_tools: profile.supports_tools.or(defaults.supports_tools),
236 supports_reasoning: profile.supports_reasoning.or(defaults.supports_reasoning),
237 supports_reasoning_effort: profile.supports_reasoning_effort.or(defaults.supports_reasoning_effort),
238 supports_vision: profile.supports_vision.or(defaults.supports_vision),
239 supports_structured_output: profile.supports_structured_output.or(defaults.supports_structured_output),
240 supports_parallel_tool_calls: profile
241 .supports_parallel_tool_calls
242 .or(defaults.supports_parallel_tool_calls),
243 supports_context_caching: profile.supports_context_caching.or(defaults.supports_context_caching),
244 supports_prompt_cache_options: profile
245 .supports_prompt_cache_options
246 .or(defaults.supports_prompt_cache_options),
247 supports_responses_compaction: profile
248 .supports_responses_compaction
249 .or(defaults.supports_responses_compaction),
250 supports_context_edits: profile.supports_context_edits.or(defaults.supports_context_edits),
251 }
252 }
253}
254
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
258pub struct CustomProviderCommandAuthConfig {
259 pub command: String,
264
265 #[serde(default)]
267 pub args: Vec<String>,
268
269 #[serde(default)]
271 pub cwd: Option<PathBuf>,
272
273 #[serde(default = "default_auth_timeout_ms")]
275 pub timeout_ms: u64,
276
277 #[serde(default = "default_auth_refresh_interval_ms")]
279 pub refresh_interval_ms: u64,
280}
281
282impl Default for CustomProviderCommandAuthConfig {
283 fn default() -> Self {
284 Self {
285 command: String::new(),
286 args: Vec::new(),
287 cwd: None,
288 timeout_ms: default_auth_timeout_ms(),
289 refresh_interval_ms: default_auth_refresh_interval_ms(),
290 }
291 }
292}
293
294impl CustomProviderCommandAuthConfig {
295 fn validate(&self, provider_name: &str) -> Result<(), String> {
296 if self.command.trim().is_empty() {
297 return Err(format!("custom_providers[{provider_name}]: `auth.command` must not be empty"));
298 }
299
300 if self.timeout_ms == 0 {
301 return Err(format!("custom_providers[{provider_name}]: `auth.timeout_ms` must be greater than 0"));
302 }
303
304 if self.refresh_interval_ms == 0 {
305 return Err(format!(
306 "custom_providers[{provider_name}]: `auth.refresh_interval_ms` must be greater than 0"
307 ));
308 }
309
310 Ok(())
311 }
312}
313
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
320#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
321pub struct CustomProviderConfig {
322 pub name: String,
325
326 pub display_name: String,
329
330 pub base_url: String,
334
335 #[serde(default, skip_serializing_if = "skip_serializing_custom_provider_api_format")]
337 pub api_format: CustomProviderApiFormat,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub context_window: Option<usize>,
345
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub supports_tools: Option<bool>,
349
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub supports_reasoning: Option<bool>,
353
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub supports_reasoning_effort: Option<bool>,
357
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub supports_vision: Option<bool>,
361
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub supports_structured_output: Option<bool>,
365
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub supports_parallel_tool_calls: Option<bool>,
369
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub supports_context_caching: Option<bool>,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub supports_responses_compaction: Option<bool>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub supports_prompt_cache_options: Option<bool>,
380
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub supports_context_edits: Option<bool>,
384
385 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub temperature: Option<f32>,
389
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub top_p: Option<f32>,
393
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub top_k: Option<i32>,
397
398 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub presence_penalty: Option<f32>,
401
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub frequency_penalty: Option<f32>,
405
406 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub max_tokens: Option<u32>,
409
410 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub reasoning_effort: Option<ReasoningEffortLevel>,
414
415 #[serde(default)]
418 pub api_key_env: String,
419
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub auth: Option<CustomProviderCommandAuthConfig>,
423
424 #[serde(default)]
431 pub model: String,
432
433 #[serde(default)]
440 pub models: Vec<String>,
441
442 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
444 pub profiles: BTreeMap<String, CustomProviderProfileConfig>,
445}
446
447impl CustomProviderConfig {
448 pub fn resolved_api_key_env(&self) -> String {
453 if !self.api_key_env.trim().is_empty() {
454 return self.api_key_env.clone();
455 }
456
457 crate::api_keys::api_key_env_var(&self.name)
458 }
459
460 pub fn uses_command_auth(&self) -> bool {
461 self.auth.is_some()
462 }
463
464 pub fn effective_models(&self) -> Vec<String> {
471 if !self.models.is_empty() {
472 return self
473 .models
474 .iter()
475 .map(|m| m.trim().to_string())
476 .filter(|m| !m.is_empty())
477 .collect();
478 }
479 let trimmed = self.model.trim();
480 if trimmed.is_empty() {
481 Vec::new()
482 } else {
483 vec![trimmed.to_string()]
484 }
485 }
486
487 pub fn profile(&self, model: &str) -> Option<&CustomProviderProfileConfig> {
488 self.profiles.get(model)
489 }
490
491 pub fn resolved_profile(&self, model: &str) -> ResolvedCustomProviderProfile {
492 let defaults = self.provider_defaults_profile();
493 ResolvedCustomProviderProfile::from_layers(&defaults, self.profile(model))
494 }
495
496 pub fn provider_defaults_profile(&self) -> CustomProviderProfileConfig {
497 CustomProviderProfileConfig {
498 api_format: self.api_format,
499 context_window: self.context_window,
500 temperature: self.temperature,
501 top_p: self.top_p,
502 top_k: self.top_k,
503 presence_penalty: self.presence_penalty,
504 frequency_penalty: self.frequency_penalty,
505 max_tokens: self.max_tokens,
506 reasoning_effort: self.reasoning_effort,
507 supports_tools: self.supports_tools,
508 supports_reasoning: self.supports_reasoning,
509 supports_reasoning_effort: self.supports_reasoning_effort,
510 supports_vision: self.supports_vision,
511 supports_structured_output: self.supports_structured_output,
512 supports_parallel_tool_calls: self.supports_parallel_tool_calls,
513 supports_context_caching: self.supports_context_caching,
514 supports_responses_compaction: self.supports_responses_compaction,
515 supports_prompt_cache_options: self.supports_prompt_cache_options,
516 supports_context_edits: self.supports_context_edits,
517 }
518 }
519
520 pub fn validate(&self) -> Result<(), String> {
523 if self.name.trim().is_empty() {
524 return Err("custom_providers: `name` must not be empty".to_string());
525 }
526
527 if !is_valid_provider_name(&self.name) {
528 return Err(format!(
529 "custom_providers[{}]: `name` must use lowercase letters, digits, hyphens, or underscores",
530 self.name
531 ));
532 }
533
534 if self.display_name.trim().is_empty() {
535 return Err(format!("custom_providers[{}]: `display_name` must not be empty", self.name));
536 }
537
538 if self.base_url.trim().is_empty() {
539 return Err(format!("custom_providers[{}]: `base_url` must not be empty", self.name));
540 }
541
542 if self.context_window == Some(0) {
543 return Err(format!("custom_providers[{}]: `context_window` must be greater than 0", self.name));
544 }
545
546 if let Some(temperature) = self.temperature
547 && !(0.0..=2.0).contains(&temperature)
548 {
549 return Err(format!("custom_providers[{}]: `temperature` must be between 0.0 and 2.0", self.name));
550 }
551
552 if let Some(top_p) = self.top_p
553 && !(0.0..=1.0).contains(&top_p)
554 {
555 return Err(format!("custom_providers[{}]: `top_p` must be between 0.0 and 1.0", self.name));
556 }
557
558 if let Some(top_k) = self.top_k
559 && top_k < 0
560 {
561 return Err(format!("custom_providers[{}]: `top_k` must be >= 0", self.name));
562 }
563
564 for (field, value) in [
565 ("`presence_penalty`", self.presence_penalty),
566 ("`frequency_penalty`", self.frequency_penalty),
567 ] {
568 if let Some(value) = value
569 && !(-2.0..=2.0).contains(&value)
570 {
571 return Err(format!("custom_providers[{}]: {field} must be between -2.0 and 2.0", self.name));
572 }
573 }
574
575 if self.max_tokens == Some(0) {
576 return Err(format!("custom_providers[{}]: `max_tokens` must be greater than 0", self.name));
577 }
578
579 if self.reasoning_effort == Some(ReasoningEffortLevel::Unknown) {
580 return Err(format!(
581 "custom_providers[{}]: `reasoning_effort` is not a recognized level (use none, minimal, low, medium, high, xhigh, or max)",
582 self.name
583 ));
584 }
585
586 if let Some(auth) = &self.auth {
587 auth.validate(&self.name)?;
588 if !self.api_key_env.trim().is_empty() {
589 return Err(format!("custom_providers[{}]: `auth` cannot be combined with `api_key_env`", self.name));
590 }
591 }
592
593 if !self.api_key_env.trim().is_empty()
594 && let Err(err) = crate::auth::CredentialIdentity::new(&self.name, &self.api_key_env)
595 {
596 return Err(format!("custom_providers[{}]: invalid `api_key_env`: {err}", self.name));
597 }
598
599 if self.models.iter().any(|m| m.trim().is_empty()) {
600 return Err(format!("custom_providers[{}]: `models` entries must not be empty", self.name));
601 }
602
603 for (profile_key, profile) in &self.profiles {
604 if profile_key.trim().is_empty() || profile_key.trim() != profile_key {
605 return Err(format!(
606 "custom_providers[{}]: profile key `{profile_key}` must not be empty or contain surrounding whitespace",
607 self.name
608 ));
609 }
610
611 profile.validate(&self.name, profile_key)?;
612 }
613
614 let reserved = [
615 "openai",
616 "anthropic",
617 "gemini",
618 "copilot",
619 "deepseek",
620 "meta",
621 "meta-ai",
622 "openrouter",
623 "ollama",
624 "lmstudio",
625 "llamacpp",
626 "moonshot",
627 "zai",
628 "minimax",
629 "huggingface",
630 "openresponses",
631 ];
632 let lower = self.name.to_lowercase();
633 if reserved.contains(&lower.as_str()) {
634 return Err(format!("custom_providers[{}]: name collides with built-in provider", self.name));
635 }
636
637 Ok(())
638 }
639}
640
641fn is_valid_provider_name(name: &str) -> bool {
642 let bytes = name.as_bytes();
643 let Some(first) = bytes.first() else {
644 return false;
645 };
646 let Some(last) = bytes.last() else {
647 return false;
648 };
649
650 let is_valid_char = |ch: u8| matches!(ch, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_');
651 let is_alphanumeric = |ch: u8| matches!(ch, b'a'..=b'z' | b'0'..=b'9');
652
653 is_alphanumeric(*first) && is_alphanumeric(*last) && bytes.iter().copied().all(is_valid_char)
654}
655
656#[cfg(test)]
657mod tests {
658 use std::collections::BTreeMap;
659 use std::path::PathBuf;
660
661 use super::{
662 CustomProviderApiFormat, CustomProviderCommandAuthConfig, CustomProviderConfig, CustomProviderProfileConfig,
663 ResolvedCustomProviderProfile, default_auth_refresh_interval_ms, default_auth_timeout_ms,
664 };
665
666 #[test]
667 fn validate_accepts_lowercase_provider_name() {
668 let config = CustomProviderConfig {
669 temperature: None,
670 top_p: None,
671 top_k: None,
672 presence_penalty: None,
673 frequency_penalty: None,
674 max_tokens: None,
675 reasoning_effort: None,
676 name: "mycorp".to_string(),
677 display_name: "MyCorp".to_string(),
678 base_url: "https://llm.example/v1".to_string(),
679 api_format: CustomProviderApiFormat::Auto,
680 context_window: None,
681 supports_tools: None,
682 supports_reasoning: None,
683 supports_reasoning_effort: None,
684 supports_vision: None,
685 supports_structured_output: None,
686 supports_parallel_tool_calls: None,
687 supports_context_caching: None,
688 supports_responses_compaction: None,
689 supports_prompt_cache_options: None,
690 supports_context_edits: None,
691 api_key_env: String::new(),
692 auth: None,
693 model: "gpt-5-mini".to_string(),
694 models: Vec::new(),
695 profiles: BTreeMap::new(),
696 };
697
698 assert!(config.validate().is_ok());
699 assert_eq!(config.resolved_api_key_env(), "MYCORP_API_KEY");
700 }
701
702 #[test]
703 fn validate_rejects_invalid_provider_name() {
704 let config = CustomProviderConfig {
705 temperature: None,
706 top_p: None,
707 top_k: None,
708 presence_penalty: None,
709 frequency_penalty: None,
710 max_tokens: None,
711 reasoning_effort: None,
712 name: "My Corp".to_string(),
713 display_name: "My Corp".to_string(),
714 base_url: "https://llm.example/v1".to_string(),
715 api_format: CustomProviderApiFormat::Auto,
716 context_window: None,
717 supports_tools: None,
718 supports_reasoning: None,
719 supports_reasoning_effort: None,
720 supports_vision: None,
721 supports_structured_output: None,
722 supports_parallel_tool_calls: None,
723 supports_context_caching: None,
724 supports_responses_compaction: None,
725 supports_prompt_cache_options: None,
726 supports_context_edits: None,
727 api_key_env: String::new(),
728 auth: None,
729 model: "gpt-5-mini".to_string(),
730 models: Vec::new(),
731 profiles: BTreeMap::new(),
732 };
733
734 let err = config.validate().expect_err("invalid name should fail");
735 assert!(err.contains("must use lowercase letters, digits, hyphens, or underscores"));
736 }
737
738 #[test]
739 fn validate_rejects_auth_and_api_key_env_together() {
740 let config = CustomProviderConfig {
741 temperature: None,
742 top_p: None,
743 top_k: None,
744 presence_penalty: None,
745 frequency_penalty: None,
746 max_tokens: None,
747 reasoning_effort: None,
748 name: "mycorp".to_string(),
749 display_name: "MyCorp".to_string(),
750 base_url: "https://llm.example/v1".to_string(),
751 api_format: CustomProviderApiFormat::Auto,
752 context_window: None,
753 supports_tools: None,
754 supports_reasoning: None,
755 supports_reasoning_effort: None,
756 supports_vision: None,
757 supports_structured_output: None,
758 supports_parallel_tool_calls: None,
759 supports_context_caching: None,
760 supports_responses_compaction: None,
761 supports_prompt_cache_options: None,
762 supports_context_edits: None,
763 api_key_env: "MYCORP_API_KEY".to_string(),
764 auth: Some(CustomProviderCommandAuthConfig {
765 command: "print-token".to_string(),
766 args: Vec::new(),
767 cwd: None,
768 timeout_ms: default_auth_timeout_ms(),
769 refresh_interval_ms: default_auth_refresh_interval_ms(),
770 }),
771 model: "gpt-5-mini".to_string(),
772 models: Vec::new(),
773 profiles: BTreeMap::new(),
774 };
775
776 let err = config.validate().expect_err("conflicting auth should fail");
777 assert!(err.contains("`auth` cannot be combined with `api_key_env`"));
778 }
779
780 #[test]
781 fn validate_accepts_command_auth_without_static_env_key() {
782 let config = CustomProviderConfig {
783 temperature: None,
784 top_p: None,
785 top_k: None,
786 presence_penalty: None,
787 frequency_penalty: None,
788 max_tokens: None,
789 reasoning_effort: None,
790 name: "mycorp".to_string(),
791 display_name: "MyCorp".to_string(),
792 base_url: "https://llm.example/v1".to_string(),
793 api_format: CustomProviderApiFormat::Auto,
794 context_window: None,
795 supports_tools: None,
796 supports_reasoning: None,
797 supports_reasoning_effort: None,
798 supports_vision: None,
799 supports_structured_output: None,
800 supports_parallel_tool_calls: None,
801 supports_context_caching: None,
802 supports_responses_compaction: None,
803 supports_prompt_cache_options: None,
804 supports_context_edits: None,
805 api_key_env: String::new(),
806 auth: Some(CustomProviderCommandAuthConfig {
807 command: "print-token".to_string(),
808 args: vec!["--json".to_string()],
809 cwd: Some(PathBuf::from("/tmp")),
810 timeout_ms: 1_000,
811 refresh_interval_ms: 60_000,
812 }),
813 model: "gpt-5-mini".to_string(),
814 models: Vec::new(),
815 profiles: BTreeMap::new(),
816 };
817
818 assert!(config.validate().is_ok());
819 assert!(config.uses_command_auth());
820 }
821
822 #[test]
823 fn validate_rejects_empty_model_entry_in_models_list() {
824 let config = CustomProviderConfig {
825 temperature: None,
826 top_p: None,
827 top_k: None,
828 presence_penalty: None,
829 frequency_penalty: None,
830 max_tokens: None,
831 reasoning_effort: None,
832 name: "mycorp".to_string(),
833 display_name: "MyCorp".to_string(),
834 base_url: "https://llm.example/v1".to_string(),
835 api_format: CustomProviderApiFormat::Auto,
836 context_window: None,
837 supports_tools: None,
838 supports_reasoning: None,
839 supports_reasoning_effort: None,
840 supports_vision: None,
841 supports_structured_output: None,
842 supports_parallel_tool_calls: None,
843 supports_context_caching: None,
844 supports_responses_compaction: None,
845 supports_prompt_cache_options: None,
846 supports_context_edits: None,
847 api_key_env: "MYCORP_API_KEY".to_string(),
848 auth: None,
849 model: "gpt-5-mini".to_string(),
850 models: vec!["valid-model".to_string(), " ".to_string()],
851 profiles: BTreeMap::new(),
852 };
853
854 let err = config.validate().expect_err("blank models entry should fail");
855 assert!(err.contains("`models` entries must not be empty"));
856 }
857
858 #[test]
859 fn validate_rejects_zero_context_window() {
860 let config = CustomProviderConfig {
861 temperature: None,
862 top_p: None,
863 top_k: None,
864 presence_penalty: None,
865 frequency_penalty: None,
866 max_tokens: None,
867 reasoning_effort: None,
868 name: "mycorp".to_string(),
869 display_name: "MyCorp".to_string(),
870 base_url: "https://llm.example/v1".to_string(),
871 api_format: CustomProviderApiFormat::Auto,
872 context_window: Some(0),
873 supports_tools: None,
874 supports_reasoning: None,
875 supports_reasoning_effort: None,
876 supports_vision: None,
877 supports_structured_output: None,
878 supports_parallel_tool_calls: None,
879 supports_context_caching: None,
880 supports_responses_compaction: None,
881 supports_prompt_cache_options: None,
882 supports_context_edits: None,
883 api_key_env: String::new(),
884 auth: None,
885 model: "gpt-5-mini".to_string(),
886 models: Vec::new(),
887 profiles: BTreeMap::new(),
888 };
889
890 let err = config.validate().expect_err("zero context window should fail");
891 assert!(err.contains("`context_window` must be greater than 0"));
892 }
893
894 #[test]
895 fn validate_rejects_malformed_profile_key() {
896 let mut profiles = BTreeMap::new();
897 profiles.insert(
898 " gpt-5-mini ".to_string(),
899 CustomProviderProfileConfig {
900 temperature: None,
901 top_p: None,
902 top_k: None,
903 presence_penalty: None,
904 frequency_penalty: None,
905 max_tokens: None,
906 reasoning_effort: None,
907 api_format: CustomProviderApiFormat::Auto,
908 context_window: Some(128_000),
909 supports_tools: None,
910 supports_reasoning: None,
911 supports_reasoning_effort: None,
912 supports_vision: None,
913 supports_structured_output: None,
914 supports_parallel_tool_calls: None,
915 supports_context_caching: None,
916 supports_responses_compaction: None,
917 supports_prompt_cache_options: None,
918 supports_context_edits: None,
919 },
920 );
921
922 let config = CustomProviderConfig {
923 temperature: None,
924 top_p: None,
925 top_k: None,
926 presence_penalty: None,
927 frequency_penalty: None,
928 max_tokens: None,
929 reasoning_effort: None,
930 name: "mycorp".to_string(),
931 display_name: "MyCorp".to_string(),
932 base_url: "https://llm.example/v1".to_string(),
933 api_format: CustomProviderApiFormat::Auto,
934 context_window: None,
935 supports_tools: None,
936 supports_reasoning: None,
937 supports_reasoning_effort: None,
938 supports_vision: None,
939 supports_structured_output: None,
940 supports_parallel_tool_calls: None,
941 supports_context_caching: None,
942 supports_responses_compaction: None,
943 supports_prompt_cache_options: None,
944 supports_context_edits: None,
945 api_key_env: String::new(),
946 auth: None,
947 model: "gpt-5-mini".to_string(),
948 models: Vec::new(),
949 profiles,
950 };
951
952 let err = config.validate().expect_err("profile key with whitespace should fail");
953 assert!(err.contains("profile key"));
954 }
955
956 #[test]
957 fn effective_models_uses_models_list_when_present() {
958 let config = CustomProviderConfig {
959 temperature: None,
960 top_p: None,
961 top_k: None,
962 presence_penalty: None,
963 frequency_penalty: None,
964 max_tokens: None,
965 reasoning_effort: None,
966 name: "atlascloud".to_string(),
967 display_name: "Atlas Cloud".to_string(),
968 base_url: "https://api.atlascloud.ai/v1".to_string(),
969 api_format: CustomProviderApiFormat::Auto,
970 context_window: None,
971 supports_tools: None,
972 supports_reasoning: None,
973 supports_reasoning_effort: None,
974 supports_vision: None,
975 supports_structured_output: None,
976 supports_parallel_tool_calls: None,
977 supports_context_caching: None,
978 supports_responses_compaction: None,
979 supports_prompt_cache_options: None,
980 supports_context_edits: None,
981 api_key_env: "ATLASCLOUD_API_KEY".to_string(),
982 auth: None,
983 model: "deepseek-ai/deepseek-flash".to_string(),
984 models: vec![
985 "deepseek-ai/deepseek-flash".to_string(),
986 "deepseek-ai/deepseek-v4-pro".to_string(),
987 "deepseek-ai/DeepSeek-V3-0324".to_string(),
988 "qwen/qwen3.6-35b-a3b".to_string(),
989 "moonshotai/kimi-k2.7-code".to_string(),
990 "moonshotai/kimi-k2.6".to_string(),
991 "zai-org/glm-5.2".to_string(),
992 "minimaxai/minimax-m3".to_string(),
993 ],
994 profiles: BTreeMap::new(),
995 };
996
997 assert_eq!(
998 config.effective_models(),
999 vec![
1000 "deepseek-ai/deepseek-flash".to_string(),
1001 "deepseek-ai/deepseek-v4-pro".to_string(),
1002 "deepseek-ai/DeepSeek-V3-0324".to_string(),
1003 "qwen/qwen3.6-35b-a3b".to_string(),
1004 "moonshotai/kimi-k2.7-code".to_string(),
1005 "moonshotai/kimi-k2.6".to_string(),
1006 "zai-org/glm-5.2".to_string(),
1007 "minimaxai/minimax-m3".to_string(),
1008 ]
1009 );
1010 }
1011
1012 #[test]
1013 fn effective_models_falls_back_to_single_model_field() {
1014 let config = CustomProviderConfig {
1015 temperature: None,
1016 top_p: None,
1017 top_k: None,
1018 presence_penalty: None,
1019 frequency_penalty: None,
1020 max_tokens: None,
1021 reasoning_effort: None,
1022 model: "gpt-5-mini".to_string(),
1023 ..CustomProviderConfig::default()
1024 };
1025
1026 assert_eq!(config.effective_models(), vec!["gpt-5-mini".to_string()]);
1027 }
1028
1029 #[test]
1030 fn resolved_profile_prefers_exact_model_key() {
1031 let mut profiles = BTreeMap::new();
1032 profiles.insert(
1033 "gpt-5-mini".to_string(),
1034 CustomProviderProfileConfig {
1035 temperature: None,
1036 top_p: None,
1037 top_k: None,
1038 presence_penalty: None,
1039 frequency_penalty: None,
1040 max_tokens: None,
1041 reasoning_effort: None,
1042 api_format: CustomProviderApiFormat::OpenAIResponses,
1043 context_window: Some(128_000),
1044 supports_tools: Some(true),
1045 supports_reasoning: None,
1046 supports_reasoning_effort: None,
1047 supports_vision: None,
1048 supports_structured_output: None,
1049 supports_parallel_tool_calls: None,
1050 supports_context_caching: None,
1051 supports_responses_compaction: None,
1052 supports_prompt_cache_options: None,
1053 supports_context_edits: None,
1054 },
1055 );
1056
1057 let config = CustomProviderConfig {
1058 temperature: None,
1059 top_p: None,
1060 top_k: None,
1061 presence_penalty: None,
1062 frequency_penalty: None,
1063 max_tokens: None,
1064 reasoning_effort: None,
1065 name: "mycorp".to_string(),
1066 display_name: "MyCorp".to_string(),
1067 base_url: "https://llm.example/v1".to_string(),
1068 api_format: CustomProviderApiFormat::OpenAIChat,
1069 context_window: Some(256_000),
1070 supports_tools: Some(true),
1071 supports_reasoning: Some(true),
1072 supports_reasoning_effort: None,
1073 supports_vision: None,
1074 supports_structured_output: None,
1075 supports_parallel_tool_calls: None,
1076 supports_context_caching: None,
1077 supports_responses_compaction: None,
1078 supports_prompt_cache_options: None,
1079 supports_context_edits: None,
1080 api_key_env: String::new(),
1081 auth: None,
1082 model: "gpt-5-mini".to_string(),
1083 models: Vec::new(),
1084 profiles,
1085 };
1086
1087 let resolved = config.resolved_profile("gpt-5-mini");
1088 assert_eq!(
1089 resolved,
1090 ResolvedCustomProviderProfile {
1091 api_format: Some(CustomProviderApiFormat::OpenAIResponses),
1092 context_window: Some(128_000),
1093 temperature: None,
1094 top_p: None,
1095 top_k: None,
1096 presence_penalty: None,
1097 frequency_penalty: None,
1098 max_tokens: None,
1099 reasoning_effort: None,
1100 supports_tools: Some(true),
1101 supports_reasoning: Some(true),
1102 supports_reasoning_effort: None,
1103 supports_vision: None,
1104 supports_structured_output: None,
1105 supports_parallel_tool_calls: None,
1106 supports_context_caching: None,
1107 supports_responses_compaction: None,
1108 supports_prompt_cache_options: None,
1109 supports_context_edits: None,
1110 }
1111 );
1112 assert!(config.profile("gpt-5").is_none());
1113 }
1114
1115 #[test]
1116 fn sparse_inheritance_preserves_provider_defaults() {
1117 let mut profiles = BTreeMap::new();
1118 profiles.insert(
1119 "gpt-5-mini".to_string(),
1120 CustomProviderProfileConfig {
1121 temperature: None,
1122 top_p: None,
1123 top_k: None,
1124 presence_penalty: None,
1125 frequency_penalty: None,
1126 max_tokens: None,
1127 reasoning_effort: None,
1128 api_format: CustomProviderApiFormat::Auto,
1129 context_window: None,
1130 supports_tools: Some(false),
1131 supports_reasoning: None,
1132 supports_reasoning_effort: Some(true),
1133 supports_vision: None,
1134 supports_structured_output: None,
1135 supports_parallel_tool_calls: None,
1136 supports_context_caching: None,
1137 supports_responses_compaction: None,
1138 supports_prompt_cache_options: None,
1139 supports_context_edits: None,
1140 },
1141 );
1142
1143 let config = CustomProviderConfig {
1144 temperature: None,
1145 top_p: None,
1146 top_k: None,
1147 presence_penalty: None,
1148 frequency_penalty: None,
1149 max_tokens: None,
1150 reasoning_effort: None,
1151 name: "mycorp".to_string(),
1152 display_name: "MyCorp".to_string(),
1153 base_url: "https://llm.example/v1".to_string(),
1154 api_format: CustomProviderApiFormat::OpenAIChat,
1155 context_window: Some(256_000),
1156 supports_tools: Some(true),
1157 supports_reasoning: Some(false),
1158 supports_reasoning_effort: None,
1159 supports_vision: Some(true),
1160 supports_structured_output: None,
1161 supports_parallel_tool_calls: Some(true),
1162 supports_context_caching: Some(false),
1163 supports_responses_compaction: None,
1164 supports_prompt_cache_options: None,
1165 supports_context_edits: None,
1166 api_key_env: String::new(),
1167 auth: None,
1168 model: "gpt-5-mini".to_string(),
1169 models: Vec::new(),
1170 profiles,
1171 };
1172
1173 let resolved = config.resolved_profile("gpt-5-mini");
1174 assert_eq!(resolved.api_format, Some(CustomProviderApiFormat::OpenAIChat));
1175 assert_eq!(resolved.context_window, Some(256_000));
1176 assert_eq!(resolved.supports_tools, Some(false));
1177 assert_eq!(resolved.supports_reasoning, Some(false));
1178 assert_eq!(resolved.supports_reasoning_effort, Some(true));
1179 assert_eq!(resolved.supports_vision, Some(true));
1180 assert_eq!(resolved.supports_parallel_tool_calls, Some(true));
1181 assert_eq!(resolved.supports_context_caching, Some(false));
1182 assert_eq!(resolved.supports_responses_compaction, None);
1183 assert_eq!(resolved.supports_context_edits, None);
1184 }
1185
1186 #[test]
1187 fn legacy_default_behavior_retains_auto_and_empty_profiles() {
1188 let parsed: CustomProviderConfig = toml::from_str(
1189 r#"
1190name = "mycorp"
1191display_name = "MyCorp"
1192base_url = "https://llm.example/v1"
1193model = "gpt-5-mini"
1194"#,
1195 )
1196 .expect("legacy custom provider config should parse");
1197
1198 assert_eq!(parsed.api_format, CustomProviderApiFormat::Auto);
1199 assert!(parsed.profiles.is_empty());
1200 assert_eq!(parsed.resolved_profile("gpt-5-mini"), ResolvedCustomProviderProfile::default());
1201 }
1202
1203 #[test]
1204 fn deserialize_rejects_invalid_api_format() {
1205 let err = toml::from_str::<CustomProviderConfig>(
1206 r#"
1207name = "mycorp"
1208display_name = "MyCorp"
1209base_url = "https://llm.example/v1"
1210api_format = "openai-chatty"
1211"#,
1212 )
1213 .expect_err("invalid api_format should fail");
1214
1215 assert!(err.to_string().contains("openai-chatty"));
1216 }
1217}
1218
1219#[cfg(test)]
1220mod prompt_cache_options_tests {
1221 use super::*;
1222
1223 #[test]
1224 fn prompt_cache_options_capability_inherits_and_preserves_false() {
1225 let defaults = CustomProviderProfileConfig {
1226 supports_prompt_cache_options: Some(true),
1227 ..Default::default()
1228 };
1229 assert_eq!(
1230 ResolvedCustomProviderProfile::from_layers(&defaults, None).supports_prompt_cache_options,
1231 Some(true)
1232 );
1233 let profile = CustomProviderProfileConfig {
1234 supports_prompt_cache_options: Some(false),
1235 ..Default::default()
1236 };
1237 assert_eq!(
1238 ResolvedCustomProviderProfile::from_layers(&defaults, Some(&profile)).supports_prompt_cache_options,
1239 Some(false)
1240 );
1241 assert_eq!(
1242 ResolvedCustomProviderProfile::from_layers(&Default::default(), None).supports_prompt_cache_options,
1243 None
1244 );
1245 }
1246}