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