Skip to main content

vtcode_config/core/
custom_provider.rs

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/// Typed API format used by custom providers.
21#[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/// Sparse per-provider or per-model capability/profile settings.
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
60pub struct CustomProviderProfileConfig {
61    /// Typed API format for this provider/profile.
62    #[serde(default, skip_serializing_if = "skip_serializing_custom_provider_api_format")]
63    pub api_format: CustomProviderApiFormat,
64
65    /// Optional context window size in tokens.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub context_window: Option<usize>,
68
69    /// Optional sampling temperature override (0.0-2.0) sent with requests.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub temperature: Option<f32>,
72
73    /// Optional nucleus-sampling override (0.0-1.0).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub top_p: Option<f32>,
76
77    /// Optional top-k override (>= 0).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub top_k: Option<i32>,
80
81    /// Optional presence penalty override (-2.0-2.0).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub presence_penalty: Option<f32>,
84
85    /// Optional frequency penalty override (-2.0-2.0).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub frequency_penalty: Option<f32>,
88
89    /// Optional max output tokens override (> 0).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub max_tokens: Option<u32>,
92
93    /// Optional reasoning effort override sent with requests for this model.
94    #[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/// Resolved capability/profile settings after applying provider defaults and
185/// exact model-specific overrides.
186#[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/// Command-backed bearer token configuration for a custom provider.
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
250pub struct CustomProviderCommandAuthConfig {
251    /// Command to execute. Bare names are resolved via `PATH`. Command-backed
252    /// auth is accepted only from trusted system/user or explicitly selected
253    /// configuration; repository-controlled workspace/project values are
254    /// rejected.
255    pub command: String,
256
257    /// Optional command arguments.
258    #[serde(default)]
259    pub args: Vec<String>,
260
261    /// Optional working directory for the token command.
262    #[serde(default)]
263    pub cwd: Option<PathBuf>,
264
265    /// Maximum time to wait for the command to complete successfully.
266    #[serde(default = "default_auth_timeout_ms")]
267    pub timeout_ms: u64,
268
269    /// Maximum age for the cached token before rerunning the command.
270    #[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/// Configuration for a user-defined OpenAI-compatible provider endpoint.
307///
308/// Allows users to define multiple named custom endpoints (e.g., corporate
309/// proxies) with distinct display names, so they can toggle between them
310/// and clearly see which endpoint is active.
311#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
312#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
313pub struct CustomProviderConfig {
314    /// Stable provider key used for routing and persistence (e.g., "mycorp").
315    /// Must be lowercase alphanumeric with optional hyphens/underscores.
316    pub name: String,
317
318    /// Human-friendly label shown in the TUI header, footer, and model picker
319    /// (e.g., "MyCorporateName").
320    pub display_name: String,
321
322    /// Base URL of the OpenAI-compatible API endpoint
323    /// (e.g., `<https://llm.corp.example/v1>`). Non-empty custom providers
324    /// from repository-controlled workspace/project layers are rejected.
325    pub base_url: String,
326
327    /// Typed API format for the provider's default profile.
328    #[serde(default, skip_serializing_if = "skip_serializing_custom_provider_api_format")]
329    pub api_format: CustomProviderApiFormat,
330
331    /// Optional context window size in tokens for models served by this endpoint.
332    ///
333    /// When omitted, the OpenAI-compatible provider uses its default context
334    /// window size.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub context_window: Option<usize>,
337
338    /// Optional support for tool calling.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub supports_tools: Option<bool>,
341
342    /// Optional support for reasoning.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub supports_reasoning: Option<bool>,
345
346    /// Optional support for reasoning effort.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub supports_reasoning_effort: Option<bool>,
349
350    /// Optional support for vision inputs.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub supports_vision: Option<bool>,
353
354    /// Optional support for structured output.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub supports_structured_output: Option<bool>,
357
358    /// Optional support for parallel tool calls.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub supports_parallel_tool_calls: Option<bool>,
361
362    /// Optional support for context caching.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub supports_context_caching: Option<bool>,
365
366    /// Optional support for responses compaction.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub supports_responses_compaction: Option<bool>,
369
370    /// Optional support for context edits.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub supports_context_edits: Option<bool>,
373
374    /// Optional sampling temperature default (0.0-2.0) for models served by
375    /// this endpoint unless a profile overrides it.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub temperature: Option<f32>,
378
379    /// Optional nucleus-sampling default (0.0-1.0).
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub top_p: Option<f32>,
382
383    /// Optional top-k default (>= 0).
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub top_k: Option<i32>,
386
387    /// Optional presence penalty default (-2.0-2.0).
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub presence_penalty: Option<f32>,
390
391    /// Optional frequency penalty default (-2.0-2.0).
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub frequency_penalty: Option<f32>,
394
395    /// Optional max output tokens default (> 0).
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub max_tokens: Option<u32>,
398
399    /// Optional reasoning effort default for models without a profile
400    /// override.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub reasoning_effort: Option<ReasoningEffortLevel>,
403
404    /// Environment variable name that holds the API key for this endpoint
405    /// (e.g., "MYCORP_API_KEY").
406    #[serde(default)]
407    pub api_key_env: String,
408
409    /// Optional command-backed bearer token configuration.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub auth: Option<CustomProviderCommandAuthConfig>,
412
413    /// Default model to use with this endpoint (e.g., "gpt-5-mini").
414    ///
415    /// When [`models`](Self::models) is empty, this single model is what the
416    /// `/model` picker offers for this provider. When [`models`](Self::models)
417    /// is non-empty, this field is used as the default selection but the
418    /// picker lists every entry in [`models`](Self::models).
419    #[serde(default)]
420    pub model: String,
421
422    /// Optional list of additional model identifiers offered by the provider.
423    ///
424    /// Useful for OpenAI-compatible aggregators such as Atlas Cloud that
425    /// expose many models behind a single endpoint. When set, the `/model`
426    /// picker shows one entry per model. When empty, the picker falls back to
427    /// the single [`model`](Self::model) field.
428    #[serde(default)]
429    pub models: Vec<String>,
430
431    /// Exact model-keyed sparse capability profiles.
432    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
433    pub profiles: BTreeMap<String, CustomProviderProfileConfig>,
434}
435
436impl CustomProviderConfig {
437    /// Resolve the API key environment variable used for this provider.
438    ///
439    /// Falls back to a derived `NAME_API_KEY`-style variable when the config
440    /// does not set `api_key_env`.
441    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    /// Return the list of models the `/model` picker should offer for this
454    /// provider.
455    ///
456    /// If `models` is non-empty, every entry is returned (trimmed). Otherwise
457    /// the single `model` field is returned as a one-element list. An empty
458    /// `model` field with no `models` list yields an empty result.
459    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    /// Validate that required fields are present and the name doesn't collide
509    /// with built-in provider keys.
510    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}