Skip to main content

vtcode_config/core/custom_provider/
mod.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 prompt_cache_options_tests;
658#[cfg(test)]
659mod tests;