1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::types::ReasoningEffortLevel;
7
8fn default_auth_timeout_ms() -> u64 {
9 5_000
10}
11
12fn default_auth_refresh_interval_ms() -> u64 {
13 300_000
14}
15
16fn skip_serializing_custom_provider_api_format(api_format: &CustomProviderApiFormat) -> bool {
17 api_format.is_auto()
18}
19
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
23pub enum CustomProviderApiFormat {
24 #[default]
25 #[serde(rename = "auto")]
26 Auto,
27 #[serde(rename = "openai-chat")]
28 OpenAIChat,
29 #[serde(rename = "openai-responses")]
30 OpenAIResponses,
31 #[serde(rename = "anthropic-messages")]
32 AnthropicMessages,
33}
34
35impl CustomProviderApiFormat {
36 pub const fn as_str(self) -> &'static str {
37 match self {
38 Self::Auto => "auto",
39 Self::OpenAIChat => "openai-chat",
40 Self::OpenAIResponses => "openai-responses",
41 Self::AnthropicMessages => "anthropic-messages",
42 }
43 }
44
45 pub const fn is_auto(self) -> bool {
46 matches!(self, Self::Auto)
47 }
48
49 pub const fn resolved(self) -> Option<Self> {
50 match self {
51 Self::Auto => None,
52 other => Some(other),
53 }
54 }
55}
56
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
60pub struct CustomProviderProfileConfig {
61 #[serde(default, skip_serializing_if = "skip_serializing_custom_provider_api_format")]
63 pub api_format: CustomProviderApiFormat,
64
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub context_window: Option<usize>,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub temperature: Option<f32>,
72
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub top_p: Option<f32>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub top_k: Option<i32>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub presence_penalty: Option<f32>,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub frequency_penalty: Option<f32>,
88
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub max_tokens: Option<u32>,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub reasoning_effort: Option<ReasoningEffortLevel>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub supports_tools: Option<bool>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub supports_reasoning: Option<bool>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub supports_reasoning_effort: Option<bool>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub supports_vision: Option<bool>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub supports_structured_output: Option<bool>,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub supports_parallel_tool_calls: Option<bool>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub supports_context_caching: Option<bool>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub supports_responses_compaction: Option<bool>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub supports_prompt_cache_options: Option<bool>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub supports_context_edits: Option<bool>,
126}
127
128impl CustomProviderProfileConfig {
129 fn validate(&self, provider_name: &str, profile_key: &str) -> Result<(), String> {
130 if self.context_window == Some(0) {
131 return Err(format!(
132 "custom_providers[{provider_name}].profiles[{profile_key}]: `context_window` must be greater than 0"
133 ));
134 }
135
136 if let Some(temperature) = self.temperature
137 && !(0.0..=2.0).contains(&temperature)
138 {
139 return Err(format!(
140 "custom_providers[{provider_name}].profiles[{profile_key}]: `temperature` must be between 0.0 and 2.0"
141 ));
142 }
143
144 if let Some(top_p) = self.top_p
145 && !(0.0..=1.0).contains(&top_p)
146 {
147 return Err(format!(
148 "custom_providers[{provider_name}].profiles[{profile_key}]: `top_p` must be between 0.0 and 1.0"
149 ));
150 }
151
152 if let Some(top_k) = self.top_k
153 && top_k < 0
154 {
155 return Err(format!("custom_providers[{provider_name}].profiles[{profile_key}]: `top_k` must be >= 0"));
156 }
157
158 for (field, value) in [
159 ("`presence_penalty`", self.presence_penalty),
160 ("`frequency_penalty`", self.frequency_penalty),
161 ] {
162 if let Some(value) = value
163 && !(-2.0..=2.0).contains(&value)
164 {
165 return Err(format!(
166 "custom_providers[{provider_name}].profiles[{profile_key}]: {field} must be between -2.0 and 2.0"
167 ));
168 }
169 }
170
171 if self.max_tokens == Some(0) {
172 return Err(format!(
173 "custom_providers[{provider_name}].profiles[{profile_key}]: `max_tokens` must be greater than 0"
174 ));
175 }
176
177 if self.reasoning_effort == Some(ReasoningEffortLevel::Unknown) {
178 return Err(format!(
179 "custom_providers[{provider_name}].profiles[{profile_key}]: `reasoning_effort` is not a recognized level (use none, minimal, low, medium, high, xhigh, or max)"
180 ));
181 }
182
183 Ok(())
184 }
185}
186
187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[derive(Debug, Clone, Default, PartialEq)]
191pub struct ResolvedCustomProviderProfile {
192 pub api_format: Option<CustomProviderApiFormat>,
193 pub context_window: Option<usize>,
194 pub temperature: Option<f32>,
195 pub top_p: Option<f32>,
196 pub top_k: Option<i32>,
197 pub presence_penalty: Option<f32>,
198 pub frequency_penalty: Option<f32>,
199 pub max_tokens: Option<u32>,
200 pub reasoning_effort: Option<ReasoningEffortLevel>,
201 pub supports_tools: Option<bool>,
202 pub supports_reasoning: Option<bool>,
203 pub supports_reasoning_effort: Option<bool>,
204 pub supports_vision: Option<bool>,
205 pub supports_structured_output: Option<bool>,
206 pub supports_parallel_tool_calls: Option<bool>,
207 pub supports_context_caching: Option<bool>,
208 pub supports_responses_compaction: Option<bool>,
209 pub supports_prompt_cache_options: Option<bool>,
211 pub supports_context_edits: Option<bool>,
212}
213
214impl ResolvedCustomProviderProfile {
215 fn from_layers(defaults: &CustomProviderProfileConfig, profile: Option<&CustomProviderProfileConfig>) -> Self {
216 let fallback_profile;
217 let profile = match profile {
218 Some(profile) => profile,
219 None => {
220 fallback_profile = CustomProviderProfileConfig::default();
221 &fallback_profile
222 }
223 };
224
225 Self {
226 api_format: profile.api_format.resolved().or(defaults.api_format.resolved()),
227 context_window: profile.context_window.or(defaults.context_window),
228 temperature: profile.temperature.or(defaults.temperature),
229 top_p: profile.top_p.or(defaults.top_p),
230 top_k: profile.top_k.or(defaults.top_k),
231 presence_penalty: profile.presence_penalty.or(defaults.presence_penalty),
232 frequency_penalty: profile.frequency_penalty.or(defaults.frequency_penalty),
233 max_tokens: profile.max_tokens.or(defaults.max_tokens),
234 reasoning_effort: profile.reasoning_effort.or(defaults.reasoning_effort),
235 supports_tools: profile.supports_tools.or(defaults.supports_tools),
236 supports_reasoning: profile.supports_reasoning.or(defaults.supports_reasoning),
237 supports_reasoning_effort: profile.supports_reasoning_effort.or(defaults.supports_reasoning_effort),
238 supports_vision: profile.supports_vision.or(defaults.supports_vision),
239 supports_structured_output: profile.supports_structured_output.or(defaults.supports_structured_output),
240 supports_parallel_tool_calls: profile
241 .supports_parallel_tool_calls
242 .or(defaults.supports_parallel_tool_calls),
243 supports_context_caching: profile.supports_context_caching.or(defaults.supports_context_caching),
244 supports_prompt_cache_options: profile
245 .supports_prompt_cache_options
246 .or(defaults.supports_prompt_cache_options),
247 supports_responses_compaction: profile
248 .supports_responses_compaction
249 .or(defaults.supports_responses_compaction),
250 supports_context_edits: profile.supports_context_edits.or(defaults.supports_context_edits),
251 }
252 }
253}
254
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
258pub struct CustomProviderCommandAuthConfig {
259 pub command: String,
264
265 #[serde(default)]
267 pub args: Vec<String>,
268
269 #[serde(default)]
271 pub cwd: Option<PathBuf>,
272
273 #[serde(default = "default_auth_timeout_ms")]
275 pub timeout_ms: u64,
276
277 #[serde(default = "default_auth_refresh_interval_ms")]
279 pub refresh_interval_ms: u64,
280}
281
282impl Default for CustomProviderCommandAuthConfig {
283 fn default() -> Self {
284 Self {
285 command: String::new(),
286 args: Vec::new(),
287 cwd: None,
288 timeout_ms: default_auth_timeout_ms(),
289 refresh_interval_ms: default_auth_refresh_interval_ms(),
290 }
291 }
292}
293
294impl CustomProviderCommandAuthConfig {
295 fn validate(&self, provider_name: &str) -> Result<(), String> {
296 if self.command.trim().is_empty() {
297 return Err(format!("custom_providers[{provider_name}]: `auth.command` must not be empty"));
298 }
299
300 if self.timeout_ms == 0 {
301 return Err(format!("custom_providers[{provider_name}]: `auth.timeout_ms` must be greater than 0"));
302 }
303
304 if self.refresh_interval_ms == 0 {
305 return Err(format!(
306 "custom_providers[{provider_name}]: `auth.refresh_interval_ms` must be greater than 0"
307 ));
308 }
309
310 Ok(())
311 }
312}
313
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
320#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
321pub struct CustomProviderConfig {
322 pub name: String,
325
326 pub display_name: String,
329
330 pub base_url: String,
334
335 #[serde(default, skip_serializing_if = "skip_serializing_custom_provider_api_format")]
337 pub api_format: CustomProviderApiFormat,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub context_window: Option<usize>,
345
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub supports_tools: Option<bool>,
349
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub supports_reasoning: Option<bool>,
353
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub supports_reasoning_effort: Option<bool>,
357
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub supports_vision: Option<bool>,
361
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub supports_structured_output: Option<bool>,
365
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub supports_parallel_tool_calls: Option<bool>,
369
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub supports_context_caching: Option<bool>,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub supports_responses_compaction: Option<bool>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub supports_prompt_cache_options: Option<bool>,
380
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub supports_context_edits: Option<bool>,
384
385 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub temperature: Option<f32>,
389
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub top_p: Option<f32>,
393
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub top_k: Option<i32>,
397
398 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub presence_penalty: Option<f32>,
401
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub frequency_penalty: Option<f32>,
405
406 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub max_tokens: Option<u32>,
409
410 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub reasoning_effort: Option<ReasoningEffortLevel>,
414
415 #[serde(default)]
418 pub api_key_env: String,
419
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub auth: Option<CustomProviderCommandAuthConfig>,
423
424 #[serde(default)]
431 pub model: String,
432
433 #[serde(default)]
440 pub models: Vec<String>,
441
442 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
444 pub profiles: BTreeMap<String, CustomProviderProfileConfig>,
445}
446
447impl CustomProviderConfig {
448 pub fn resolved_api_key_env(&self) -> String {
453 if !self.api_key_env.trim().is_empty() {
454 return self.api_key_env.clone();
455 }
456
457 crate::api_keys::api_key_env_var(&self.name)
458 }
459
460 pub fn uses_command_auth(&self) -> bool {
461 self.auth.is_some()
462 }
463
464 pub fn effective_models(&self) -> Vec<String> {
471 if !self.models.is_empty() {
472 return self
473 .models
474 .iter()
475 .map(|m| m.trim().to_string())
476 .filter(|m| !m.is_empty())
477 .collect();
478 }
479 let trimmed = self.model.trim();
480 if trimmed.is_empty() {
481 Vec::new()
482 } else {
483 vec![trimmed.to_string()]
484 }
485 }
486
487 pub fn profile(&self, model: &str) -> Option<&CustomProviderProfileConfig> {
488 self.profiles.get(model)
489 }
490
491 pub fn resolved_profile(&self, model: &str) -> ResolvedCustomProviderProfile {
492 let defaults = self.provider_defaults_profile();
493 ResolvedCustomProviderProfile::from_layers(&defaults, self.profile(model))
494 }
495
496 pub fn provider_defaults_profile(&self) -> CustomProviderProfileConfig {
497 CustomProviderProfileConfig {
498 api_format: self.api_format,
499 context_window: self.context_window,
500 temperature: self.temperature,
501 top_p: self.top_p,
502 top_k: self.top_k,
503 presence_penalty: self.presence_penalty,
504 frequency_penalty: self.frequency_penalty,
505 max_tokens: self.max_tokens,
506 reasoning_effort: self.reasoning_effort,
507 supports_tools: self.supports_tools,
508 supports_reasoning: self.supports_reasoning,
509 supports_reasoning_effort: self.supports_reasoning_effort,
510 supports_vision: self.supports_vision,
511 supports_structured_output: self.supports_structured_output,
512 supports_parallel_tool_calls: self.supports_parallel_tool_calls,
513 supports_context_caching: self.supports_context_caching,
514 supports_responses_compaction: self.supports_responses_compaction,
515 supports_prompt_cache_options: self.supports_prompt_cache_options,
516 supports_context_edits: self.supports_context_edits,
517 }
518 }
519
520 pub fn validate(&self) -> Result<(), String> {
523 if self.name.trim().is_empty() {
524 return Err("custom_providers: `name` must not be empty".to_string());
525 }
526
527 if !is_valid_provider_name(&self.name) {
528 return Err(format!(
529 "custom_providers[{}]: `name` must use lowercase letters, digits, hyphens, or underscores",
530 self.name
531 ));
532 }
533
534 if self.display_name.trim().is_empty() {
535 return Err(format!("custom_providers[{}]: `display_name` must not be empty", self.name));
536 }
537
538 if self.base_url.trim().is_empty() {
539 return Err(format!("custom_providers[{}]: `base_url` must not be empty", self.name));
540 }
541
542 if self.context_window == Some(0) {
543 return Err(format!("custom_providers[{}]: `context_window` must be greater than 0", self.name));
544 }
545
546 if let Some(temperature) = self.temperature
547 && !(0.0..=2.0).contains(&temperature)
548 {
549 return Err(format!("custom_providers[{}]: `temperature` must be between 0.0 and 2.0", self.name));
550 }
551
552 if let Some(top_p) = self.top_p
553 && !(0.0..=1.0).contains(&top_p)
554 {
555 return Err(format!("custom_providers[{}]: `top_p` must be between 0.0 and 1.0", self.name));
556 }
557
558 if let Some(top_k) = self.top_k
559 && top_k < 0
560 {
561 return Err(format!("custom_providers[{}]: `top_k` must be >= 0", self.name));
562 }
563
564 for (field, value) in [
565 ("`presence_penalty`", self.presence_penalty),
566 ("`frequency_penalty`", self.frequency_penalty),
567 ] {
568 if let Some(value) = value
569 && !(-2.0..=2.0).contains(&value)
570 {
571 return Err(format!("custom_providers[{}]: {field} must be between -2.0 and 2.0", self.name));
572 }
573 }
574
575 if self.max_tokens == Some(0) {
576 return Err(format!("custom_providers[{}]: `max_tokens` must be greater than 0", self.name));
577 }
578
579 if self.reasoning_effort == Some(ReasoningEffortLevel::Unknown) {
580 return Err(format!(
581 "custom_providers[{}]: `reasoning_effort` is not a recognized level (use none, minimal, low, medium, high, xhigh, or max)",
582 self.name
583 ));
584 }
585
586 if let Some(auth) = &self.auth {
587 auth.validate(&self.name)?;
588 if !self.api_key_env.trim().is_empty() {
589 return Err(format!("custom_providers[{}]: `auth` cannot be combined with `api_key_env`", self.name));
590 }
591 }
592
593 if !self.api_key_env.trim().is_empty()
594 && let Err(err) = crate::auth::CredentialIdentity::new(&self.name, &self.api_key_env)
595 {
596 return Err(format!("custom_providers[{}]: invalid `api_key_env`: {err}", self.name));
597 }
598
599 if self.models.iter().any(|m| m.trim().is_empty()) {
600 return Err(format!("custom_providers[{}]: `models` entries must not be empty", self.name));
601 }
602
603 for (profile_key, profile) in &self.profiles {
604 if profile_key.trim().is_empty() || profile_key.trim() != profile_key {
605 return Err(format!(
606 "custom_providers[{}]: profile key `{profile_key}` must not be empty or contain surrounding whitespace",
607 self.name
608 ));
609 }
610
611 profile.validate(&self.name, profile_key)?;
612 }
613
614 let reserved = [
615 "openai",
616 "anthropic",
617 "gemini",
618 "copilot",
619 "deepseek",
620 "meta",
621 "meta-ai",
622 "openrouter",
623 "ollama",
624 "lmstudio",
625 "llamacpp",
626 "moonshot",
627 "zai",
628 "minimax",
629 "huggingface",
630 "openresponses",
631 ];
632 let lower = self.name.to_lowercase();
633 if reserved.contains(&lower.as_str()) {
634 return Err(format!("custom_providers[{}]: name collides with built-in provider", self.name));
635 }
636
637 Ok(())
638 }
639}
640
641fn is_valid_provider_name(name: &str) -> bool {
642 let bytes = name.as_bytes();
643 let Some(first) = bytes.first() else {
644 return false;
645 };
646 let Some(last) = bytes.last() else {
647 return false;
648 };
649
650 let is_valid_char = |ch: u8| matches!(ch, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_');
651 let is_alphanumeric = |ch: u8| matches!(ch, b'a'..=b'z' | b'0'..=b'9');
652
653 is_alphanumeric(*first) && is_alphanumeric(*last) && bytes.iter().copied().all(is_valid_char)
654}
655
656#[cfg(test)]
657mod prompt_cache_options_tests;
658#[cfg(test)]
659mod tests;