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