1use crate::core::advisor::AdvisorConfig;
2use serde::{Deserialize, Serialize};
3use vtcode_commons::reasoning::ReasoningEffortLevel;
4
5#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
8#[serde(rename_all = "lowercase")]
9pub enum ThinkingDisplayMode {
10 Summarized,
12 Omitted,
14 Updates,
19 #[serde(other)]
21 Unknown,
22}
23
24impl ThinkingDisplayMode {
25 pub fn as_str(self) -> &'static str {
27 match self {
28 Self::Summarized => "summarized",
29 Self::Omitted => "omitted",
30 Self::Updates => "updates",
31 Self::Unknown => "unknown",
32 }
33 }
34}
35
36#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum OpenAIServiceTier {
41 Flex,
42 Priority,
43 Ultrafast,
44}
45
46impl OpenAIServiceTier {
47 pub const fn as_str(self) -> &'static str {
48 match self {
49 Self::Flex => "flex",
50 Self::Priority => "priority",
51 Self::Ultrafast => "ultrafast",
52 }
53 }
54
55 pub fn parse(value: &str) -> Option<Self> {
56 let normalized = value.trim();
57 if normalized.eq_ignore_ascii_case("flex") {
58 Some(Self::Flex)
59 } else if normalized.eq_ignore_ascii_case("priority") {
60 Some(Self::Priority)
61 } else if normalized.eq_ignore_ascii_case("ultrafast") {
62 Some(Self::Ultrafast)
63 } else {
64 None
65 }
66 }
67}
68
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
72#[serde(rename_all = "snake_case")]
73pub enum OpenAIHostedShellEnvironment {
74 #[default]
75 ContainerAuto,
76 ContainerReference,
77}
78
79impl OpenAIHostedShellEnvironment {
80 pub const fn as_str(self) -> &'static str {
81 match self {
82 Self::ContainerAuto => "container_auto",
83 Self::ContainerReference => "container_reference",
84 }
85 }
86}
87
88impl OpenAIHostedShellEnvironment {
89 const fn uses_container_reference(self) -> bool {
90 matches!(self, Self::ContainerReference)
91 }
92}
93
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
97#[serde(rename_all = "snake_case")]
98pub enum OpenAIHostedShellNetworkPolicyType {
99 #[default]
100 Disabled,
101 Allowlist,
102}
103
104impl OpenAIHostedShellNetworkPolicyType {
105 pub const fn as_str(self) -> &'static str {
106 match self {
107 Self::Disabled => "disabled",
108 Self::Allowlist => "allowlist",
109 }
110 }
111}
112
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
116pub struct OpenAIHostedShellDomainSecret {
117 pub domain: String,
118 pub name: String,
119 pub value: String,
120}
121
122impl OpenAIHostedShellDomainSecret {
123 fn validation_error(&self, index: usize) -> Option<String> {
124 let base = format!("provider.openai.hosted_shell.network_policy.domain_secrets[{index}]");
125
126 if self.domain.trim().is_empty() {
127 return Some(format!("`{base}.domain` must not be empty when set."));
128 }
129 if self.name.trim().is_empty() {
130 return Some(format!("`{base}.name` must not be empty when set."));
131 }
132 if self.value.trim().is_empty() {
133 return Some(format!("`{base}.value` must not be empty when set."));
134 }
135
136 None
137 }
138}
139
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
143pub struct OpenAIHostedShellNetworkPolicy {
144 #[serde(rename = "type", default)]
145 pub policy_type: OpenAIHostedShellNetworkPolicyType,
146
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub allowed_domains: Vec<String>,
149
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub domain_secrets: Vec<OpenAIHostedShellDomainSecret>,
152}
153
154impl OpenAIHostedShellNetworkPolicy {
155 pub const fn is_allowlist(&self) -> bool {
156 matches!(self.policy_type, OpenAIHostedShellNetworkPolicyType::Allowlist)
157 }
158
159 fn first_invalid_message(&self) -> Option<String> {
160 match self.policy_type {
161 OpenAIHostedShellNetworkPolicyType::Disabled => {
162 if !self.allowed_domains.is_empty() || !self.domain_secrets.is_empty() {
163 return Some(
164 "`provider.openai.hosted_shell.network_policy.allowed_domains` and `provider.openai.hosted_shell.network_policy.domain_secrets` require `provider.openai.hosted_shell.network_policy.type = \"allowlist\"`."
165 .to_string(),
166 );
167 }
168 }
169 OpenAIHostedShellNetworkPolicyType::Allowlist => {
170 if let Some(index) = self.allowed_domains.iter().position(|value| value.trim().is_empty()) {
171 return Some(format!(
172 "`provider.openai.hosted_shell.network_policy.allowed_domains[{index}]` must not be empty when set."
173 ));
174 }
175
176 if self.allowed_domains.is_empty() {
177 return Some(
178 "`provider.openai.hosted_shell.network_policy.allowed_domains` must include at least one domain when `provider.openai.hosted_shell.network_policy.type = \"allowlist\"`."
179 .to_string(),
180 );
181 }
182
183 for (index, secret) in self.domain_secrets.iter().enumerate() {
184 if let Some(message) = secret.validation_error(index) {
185 return Some(message);
186 }
187
188 let secret_domain = secret.domain.trim();
189 if !self
190 .allowed_domains
191 .iter()
192 .any(|domain| domain.trim().eq_ignore_ascii_case(secret_domain))
193 {
194 return Some(format!(
195 "`provider.openai.hosted_shell.network_policy.domain_secrets[{index}].domain` must also appear in `provider.openai.hosted_shell.network_policy.allowed_domains`."
196 ));
197 }
198 }
199 }
200 }
201
202 None
203 }
204}
205
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
209#[serde(rename_all = "lowercase")]
210pub enum OpenAIHostedSkillVersionKeyword {
211 #[default]
212 Latest,
213}
214
215#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
218#[serde(untagged)]
219pub enum OpenAIHostedSkillVersion {
220 Latest(OpenAIHostedSkillVersionKeyword),
221 Number(u64),
222 String(String),
223}
224
225impl Default for OpenAIHostedSkillVersion {
226 fn default() -> Self {
227 Self::Latest(OpenAIHostedSkillVersionKeyword::Latest)
228 }
229}
230
231impl OpenAIHostedSkillVersion {
232 fn validation_error(&self, field_path: &str) -> Option<String> {
233 match self {
234 Self::String(value) if value.trim().is_empty() => {
235 Some(format!("`{field_path}` must not be empty when set."))
236 }
237 _ => None,
238 }
239 }
240}
241
242#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
245#[serde(tag = "type", rename_all = "snake_case")]
246pub enum OpenAIHostedSkill {
247 SkillReference {
249 skill_id: String,
250 #[serde(default)]
251 version: OpenAIHostedSkillVersion,
252 },
253 Inline {
255 bundle_b64: String,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 sha256: Option<String>,
258 },
259}
260
261impl OpenAIHostedSkill {
262 fn validation_error(&self, index: usize) -> Option<String> {
263 match self {
264 Self::SkillReference { skill_id, version } => {
265 let skill_id_path = format!("provider.openai.hosted_shell.skills[{index}].skill_id");
266 if skill_id.trim().is_empty() {
267 return Some(format!("`{skill_id_path}` must not be empty when `type = \"skill_reference\"`."));
268 }
269
270 let version_path = format!("provider.openai.hosted_shell.skills[{index}].version");
271 version.validation_error(&version_path)
272 }
273 Self::Inline { bundle_b64, .. } => {
274 let bundle_path = format!("provider.openai.hosted_shell.skills[{index}].bundle_b64");
275 if bundle_b64.trim().is_empty() {
276 return Some(format!("`{bundle_path}` must not be empty when `type = \"inline\"`."));
277 }
278 None
279 }
280 }
281 }
282}
283
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
286#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
287pub struct OpenAIHostedShellConfig {
288 #[serde(default)]
290 pub enabled: bool,
291
292 #[serde(default)]
294 pub environment: OpenAIHostedShellEnvironment,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub container_id: Option<String>,
299
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub file_ids: Vec<String>,
303
304 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub skills: Vec<OpenAIHostedSkill>,
307
308 #[serde(default)]
310 pub network_policy: OpenAIHostedShellNetworkPolicy,
311}
312
313impl OpenAIHostedShellConfig {
314 fn container_id_ref(&self) -> Option<&str> {
315 self.container_id.as_deref().map(str::trim).filter(|value| !value.is_empty())
316 }
317
318 pub const fn uses_container_reference(&self) -> bool {
319 self.environment.uses_container_reference()
320 }
321
322 pub fn first_invalid_skill_message(&self) -> Option<String> {
323 if self.uses_container_reference() {
324 return None;
325 }
326
327 self.skills
328 .iter()
329 .enumerate()
330 .find_map(|(index, skill)| skill.validation_error(index))
331 }
332
333 fn has_valid_skill_mounts(&self) -> bool {
334 self.first_invalid_skill_message().is_none()
335 }
336
337 pub fn first_invalid_network_policy_message(&self) -> Option<String> {
338 if self.uses_container_reference() {
339 return None;
340 }
341
342 self.network_policy.first_invalid_message()
343 }
344
345 fn has_valid_network_policy(&self) -> bool {
346 self.first_invalid_network_policy_message().is_none()
347 }
348
349 pub fn has_valid_reference_target(&self) -> bool {
350 !self.uses_container_reference() || self.container_id_ref().is_some()
351 }
352
353 pub fn is_valid_for_runtime(&self) -> bool {
354 self.has_valid_reference_target() && self.has_valid_skill_mounts() && self.has_valid_network_policy()
355 }
356}
357
358#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
360#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
361pub struct OpenAIToolSearchConfig {
362 #[serde(default = "default_tool_search_enabled")]
364 pub enabled: bool,
365
366 #[serde(default = "default_defer_by_default")]
368 pub defer_by_default: bool,
369
370 #[serde(default)]
372 pub always_available_tools: Vec<String>,
373}
374
375impl Default for OpenAIToolSearchConfig {
376 fn default() -> Self {
377 Self {
378 enabled: default_tool_search_enabled(),
379 defer_by_default: default_defer_by_default(),
380 always_available_tools: Vec::new(),
381 }
382 }
383}
384
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
387#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
388pub struct OpenAIManualCompactionConfig {
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub instructions: Option<String>,
392}
393
394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
396#[derive(Debug, Clone, Deserialize, Serialize, Default)]
397pub struct OpenAIConfig {
398 #[serde(default)]
406 pub websocket_mode: bool,
407
408 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub responses_store: Option<bool>,
412
413 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub responses_include: Vec<String>,
417
418 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub service_tier: Option<OpenAIServiceTier>,
427
428 #[serde(default)]
430 pub manual_compaction: OpenAIManualCompactionConfig,
431
432 #[serde(default)]
434 pub hosted_shell: OpenAIHostedShellConfig,
435
436 #[serde(default)]
438 pub tool_search: OpenAIToolSearchConfig,
439}
440
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
443#[derive(Debug, Clone, Deserialize, Serialize)]
444pub struct AnthropicConfig {
445 #[serde(default = "default_extended_thinking_enabled")]
454 pub extended_thinking_enabled: bool,
455
456 #[serde(default = "default_interleaved_thinking_beta")]
458 pub interleaved_thinking_beta: String,
459
460 #[serde(default = "default_interleaved_thinking_budget_tokens")]
466 pub interleaved_thinking_budget_tokens: u32,
467
468 #[serde(default = "default_interleaved_thinking_type")]
470 pub interleaved_thinking_type_enabled: String,
471
472 #[serde(default)]
474 pub tool_search: ToolSearchConfig,
475
476 #[serde(default)]
478 pub memory: AnthropicMemoryConfig,
479
480 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub effort: Option<ReasoningEffortLevel>,
489
490 #[serde(default)]
496 pub task_budget_tokens: Option<u32>,
497
498 #[serde(default = "default_task_budget_beta")]
500 pub task_budget_beta: String,
501
502 #[serde(default)]
513 pub thinking_display: Option<ThinkingDisplayMode>,
514
515 #[serde(default = "default_count_tokens_enabled")]
519 pub count_tokens_enabled: bool,
520
521 #[serde(default)]
525 pub advisor: AdvisorConfig,
526
527 #[serde(default)]
537 pub fallbacks: AnthropicFallbacks,
538}
539
540#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
542#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
543#[serde(rename_all = "lowercase")]
544pub enum AnthropicFallbackMode {
545 #[default]
547 Default,
548 Off,
550}
551
552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
554#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
555pub struct AnthropicFallbackTarget {
556 pub model: String,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub max_tokens: Option<u32>,
561}
562
563#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
566#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
567#[serde(untagged)]
568pub enum AnthropicFallbacks {
569 Mode(AnthropicFallbackMode),
570 Models(Vec<AnthropicFallbackTarget>),
571}
572
573impl Default for AnthropicFallbacks {
574 fn default() -> Self {
575 Self::Mode(AnthropicFallbackMode::Default)
576 }
577}
578
579impl AnthropicFallbacks {
580 pub const MAX_MODELS: usize = 3;
582
583 pub fn validation_error(&self, field_path: &str) -> Option<String> {
586 let Self::Models(models) = self else {
587 return None;
588 };
589 Self::entries_validation_error(
590 field_path,
591 models.iter().map(|target| (target.model.as_str(), target.max_tokens)),
592 )
593 }
594
595 pub fn entries_validation_error<'a>(
600 field_path: &str,
601 entries: impl ExactSizeIterator<Item = (&'a str, Option<u32>)>,
602 ) -> Option<String> {
603 let len = entries.len();
604 if len == 0 || len > Self::MAX_MODELS {
605 return Some(format!(
606 "`{field_path}` must list between 1 and {} fallback models, or be \"default\" or \"off\".",
607 Self::MAX_MODELS
608 ));
609 }
610 let mut seen = std::collections::HashSet::with_capacity(len);
611 for (model, max_tokens) in entries {
612 let model = model.trim();
613 if model.is_empty() {
614 return Some(format!("`{field_path}` entries must set a non-empty `model`."));
615 }
616 if !seen.insert(model) {
617 return Some(format!("`{field_path}` lists `{model}` more than once; entries must be distinct."));
618 }
619 if max_tokens == Some(0) {
620 return Some(format!("`{field_path}` entry `{model}` must use a positive `max_tokens`."));
621 }
622 }
623 None
624 }
625}
626
627impl Default for AnthropicConfig {
628 fn default() -> Self {
629 Self {
630 extended_thinking_enabled: default_extended_thinking_enabled(),
631 interleaved_thinking_beta: default_interleaved_thinking_beta(),
632 interleaved_thinking_budget_tokens: default_interleaved_thinking_budget_tokens(),
633 interleaved_thinking_type_enabled: default_interleaved_thinking_type(),
634 tool_search: ToolSearchConfig::default(),
635 memory: AnthropicMemoryConfig::default(),
636 effort: None,
637 task_budget_tokens: None,
638 task_budget_beta: default_task_budget_beta(),
639 thinking_display: None,
640 count_tokens_enabled: default_count_tokens_enabled(),
641 advisor: AdvisorConfig::default(),
642 fallbacks: AnthropicFallbacks::default(),
643 }
644 }
645}
646
647#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
648#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
649pub struct AnthropicMemoryConfig {
650 #[serde(default)]
651 pub enabled: bool,
652}
653
654#[inline]
655fn default_count_tokens_enabled() -> bool {
656 false
657}
658
659#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
661#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
662#[serde(rename_all = "lowercase")]
663pub enum ToolSearchAlgorithm {
664 #[default]
666 Regex,
667 Bm25,
669 #[serde(other)]
671 Unknown,
672}
673
674impl ToolSearchAlgorithm {
675 fn as_str(&self) -> &str {
677 match self {
678 Self::Regex => "regex",
679 Self::Bm25 => "bm25",
680 Self::Unknown => "unknown",
681 }
682 }
683}
684
685impl std::fmt::Display for ToolSearchAlgorithm {
686 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
687 f.write_str(self.as_str())
688 }
689}
690
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
694#[derive(Debug, Clone, Deserialize, Serialize)]
695pub struct ToolSearchConfig {
696 #[serde(default = "default_tool_search_enabled")]
698 pub enabled: bool,
699
700 #[serde(default = "default_tool_search_algorithm")]
702 pub algorithm: ToolSearchAlgorithm,
703
704 #[serde(default = "default_defer_by_default")]
706 pub defer_by_default: bool,
707
708 #[serde(default = "default_max_results")]
710 max_results: u32,
711
712 #[serde(default)]
714 pub always_available_tools: Vec<String>,
715}
716
717impl Default for ToolSearchConfig {
718 fn default() -> Self {
719 Self {
720 enabled: default_tool_search_enabled(),
721 algorithm: default_tool_search_algorithm(),
722 defer_by_default: default_defer_by_default(),
723 max_results: default_max_results(),
724 always_available_tools: vec![],
725 }
726 }
727}
728
729#[inline]
730fn default_tool_search_enabled() -> bool {
731 true
732}
733
734#[inline]
735fn default_tool_search_algorithm() -> ToolSearchAlgorithm {
736 ToolSearchAlgorithm::Regex
737}
738
739#[inline]
740fn default_defer_by_default() -> bool {
741 true
742}
743
744#[inline]
745fn default_max_results() -> u32 {
746 5
747}
748
749#[inline]
750fn default_extended_thinking_enabled() -> bool {
751 true
752}
753
754#[inline]
755fn default_interleaved_thinking_beta() -> String {
756 "interleaved-thinking-2025-05-14".to_string()
757}
758
759#[inline]
760fn default_interleaved_thinking_budget_tokens() -> u32 {
761 31999
762}
763
764#[inline]
765fn default_interleaved_thinking_type() -> String {
766 "enabled".to_string()
767}
768
769#[inline]
770fn default_task_budget_beta() -> String {
771 "task-budgets-2026-03-13".to_string()
772}
773
774#[cfg(test)]
775mod tests {
776 use super::{
777 AnthropicConfig, AnthropicFallbackMode, AnthropicFallbackTarget, AnthropicFallbacks, OpenAIConfig,
778 OpenAIHostedShellConfig, OpenAIHostedShellDomainSecret, OpenAIHostedShellEnvironment,
779 OpenAIHostedShellNetworkPolicy, OpenAIHostedShellNetworkPolicyType, OpenAIHostedSkill,
780 OpenAIHostedSkillVersion, OpenAIManualCompactionConfig, OpenAIServiceTier, ToolSearchAlgorithm,
781 };
782
783 #[test]
784 fn anthropic_effort_is_unset_unless_configured() {
785 assert_eq!(AnthropicConfig::default().effort, None);
786
787 let parsed: AnthropicConfig = toml::from_str("").expect("empty anthropic config");
788 assert_eq!(parsed.effort, None);
789
790 let parsed: AnthropicConfig = toml::from_str("effort = \"high\"").expect("explicit effort");
791 assert_eq!(parsed.effort, Some(super::ReasoningEffortLevel::High));
792 }
793
794 #[test]
797 fn openai_service_tier_string_paths_stay_in_lockstep() {
798 use crate::test_support::assert_string_enum_lockstep;
799
800 assert_string_enum_lockstep!(
801 OpenAIServiceTier,
802 [
803 OpenAIServiceTier::Flex,
804 OpenAIServiceTier::Priority,
805 OpenAIServiceTier::Ultrafast,
806 ]
807 );
808 }
809
810 #[test]
811 fn anthropic_fallbacks_default_to_default_mode() {
812 let config = AnthropicConfig::default();
813 assert_eq!(config.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
814 let parsed: AnthropicConfig = toml::from_str("").expect("config should parse");
815 assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
816 }
817
818 #[test]
819 fn anthropic_fallbacks_parse_all_three_forms() {
820 let parsed: AnthropicConfig = toml::from_str("fallbacks = \"default\"").expect("default form");
821 assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
822
823 let parsed: AnthropicConfig = toml::from_str("fallbacks = \"off\"").expect("off form");
824 assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Off));
825
826 let parsed: AnthropicConfig = toml::from_str(
827 "fallbacks = [{ model = \"claude-opus-4-8\", max_tokens = 32000 }, { model = \"claude-opus-5\" }]",
828 )
829 .expect("list form");
830 assert_eq!(
831 parsed.fallbacks,
832 AnthropicFallbacks::Models(vec![
833 AnthropicFallbackTarget {
834 model: "claude-opus-4-8".to_string(),
835 max_tokens: Some(32_000),
836 },
837 AnthropicFallbackTarget {
838 model: "claude-opus-5".to_string(),
839 max_tokens: None,
840 },
841 ])
842 );
843 assert_eq!(parsed.fallbacks.validation_error("fallbacks"), None);
844
845 assert!(toml::from_str::<AnthropicConfig>("fallbacks = \"sometimes\"").is_err());
846 }
847
848 #[test]
849 fn anthropic_fallbacks_round_trip_through_serde() {
850 for fallbacks in [
851 AnthropicFallbacks::Mode(AnthropicFallbackMode::Default),
852 AnthropicFallbacks::Mode(AnthropicFallbackMode::Off),
853 AnthropicFallbacks::Models(vec![AnthropicFallbackTarget {
854 model: "claude-opus-4-8".to_string(),
855 max_tokens: None,
856 }]),
857 ] {
858 let json = serde_json::to_value(&fallbacks).expect("serialize");
859 let back: AnthropicFallbacks = serde_json::from_value(json).expect("deserialize");
860 assert_eq!(back, fallbacks);
861 }
862 assert_eq!(
863 serde_json::to_value(AnthropicFallbacks::default()).expect("serialize"),
864 serde_json::json!("default")
865 );
866 }
867
868 #[test]
869 fn anthropic_fallback_list_validation_rejects_bad_lists() {
870 let target = |model: &str| AnthropicFallbackTarget { model: model.to_string(), max_tokens: None };
871 for bad in [
872 AnthropicFallbacks::Models(vec![]),
873 AnthropicFallbacks::Models(vec![target("a"), target("b"), target("c"), target("d")]),
874 AnthropicFallbacks::Models(vec![target(" ")]),
875 AnthropicFallbacks::Models(vec![target("a"), target(" a ")]),
876 AnthropicFallbacks::Models(vec![AnthropicFallbackTarget { model: "a".to_string(), max_tokens: Some(0) }]),
877 ] {
878 assert!(bad.validation_error("fallbacks").is_some(), "{bad:?}");
879 }
880 }
881
882 #[test]
883 fn openai_config_defaults_to_websocket_mode_disabled() {
884 let config = OpenAIConfig::default();
885 assert!(!config.websocket_mode);
886 assert_eq!(config.responses_store, None);
887 assert!(config.responses_include.is_empty());
888 assert_eq!(config.service_tier, None);
889 assert_eq!(config.manual_compaction, OpenAIManualCompactionConfig::default());
890 assert_eq!(config.hosted_shell, OpenAIHostedShellConfig::default());
891 assert!(config.tool_search.enabled);
892 assert!(config.tool_search.defer_by_default);
893 assert!(config.tool_search.always_available_tools.is_empty());
894 }
895
896 #[test]
897 fn anthropic_config_defaults_native_memory_to_disabled() {
898 let config = AnthropicConfig::default();
899 assert!(!config.memory.enabled);
900 }
901
902 #[test]
903 fn anthropic_config_parses_native_memory_opt_in() {
904 let parsed: AnthropicConfig = toml::from_str("[memory]\nenabled = true").expect("config should parse");
905 assert!(parsed.memory.enabled);
906 }
907
908 #[test]
909 fn openai_config_parses_websocket_mode_opt_in() {
910 let parsed: OpenAIConfig = toml::from_str("websocket_mode = true").expect("config should parse");
911 assert!(parsed.websocket_mode);
912 assert_eq!(parsed.responses_store, None);
913 assert!(parsed.responses_include.is_empty());
914 assert_eq!(parsed.service_tier, None);
915 assert_eq!(parsed.manual_compaction, OpenAIManualCompactionConfig::default());
916 assert_eq!(parsed.hosted_shell, OpenAIHostedShellConfig::default());
917 assert_eq!(parsed.tool_search, super::OpenAIToolSearchConfig::default());
918 }
919
920 #[test]
921 fn openai_config_parses_responses_options() {
922 let parsed: OpenAIConfig = toml::from_str(
923 r#"
924responses_store = false
925responses_include = ["reasoning.encrypted_content", "output_text.annotations"]
926"#,
927 )
928 .expect("config should parse");
929 assert_eq!(parsed.responses_store, Some(false));
930 assert_eq!(
931 parsed.responses_include,
932 vec![
933 "reasoning.encrypted_content".to_string(),
934 "output_text.annotations".to_string()
935 ]
936 );
937 assert_eq!(parsed.service_tier, None);
938 assert_eq!(parsed.manual_compaction, OpenAIManualCompactionConfig::default());
939 assert_eq!(parsed.hosted_shell, OpenAIHostedShellConfig::default());
940 }
941
942 #[test]
943 fn openai_config_parses_manual_compaction_defaults() {
944 let parsed: OpenAIConfig = toml::from_str(
945 r#"
946[manual_compaction]
947instructions = "Preserve the bug reproduction steps."
948"#,
949 )
950 .expect("config should parse");
951
952 assert_eq!(parsed.manual_compaction.instructions.as_deref(), Some("Preserve the bug reproduction steps."));
953 }
954
955 #[test]
956 fn openai_config_parses_service_tier() {
957 let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "priority""#).expect("config should parse");
958 assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Priority));
959 }
960
961 #[test]
962 fn openai_config_parses_flex_service_tier() {
963 let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "flex""#).expect("config should parse");
964 assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Flex));
965 }
966
967 #[test]
968 fn openai_config_parses_ultrafast_service_tier() {
969 let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "ultrafast""#).expect("config should parse");
970 assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Ultrafast));
971 assert_eq!(OpenAIServiceTier::Ultrafast.as_str(), "ultrafast");
972 assert_eq!(OpenAIServiceTier::parse("ULTRAFAST"), Some(OpenAIServiceTier::Ultrafast));
973 }
974
975 #[test]
976 fn openai_config_parses_hosted_shell() {
977 let parsed: OpenAIConfig = toml::from_str(
978 r#"
979[hosted_shell]
980enabled = true
981environment = "container_auto"
982file_ids = ["file_123"]
983
984[[hosted_shell.skills]]
985type = "skill_reference"
986skill_id = "skill_123"
987"#,
988 )
989 .expect("config should parse");
990
991 assert!(parsed.hosted_shell.enabled);
992 assert_eq!(parsed.hosted_shell.environment, OpenAIHostedShellEnvironment::ContainerAuto);
993 assert_eq!(parsed.hosted_shell.file_ids, vec!["file_123".to_string()]);
994 assert_eq!(
995 parsed.hosted_shell.skills,
996 vec![OpenAIHostedSkill::SkillReference {
997 skill_id: "skill_123".to_string(),
998 version: OpenAIHostedSkillVersion::default(),
999 }]
1000 );
1001 }
1002
1003 #[test]
1004 fn openai_config_parses_hosted_shell_pinned_version_and_inline_bundle() {
1005 let parsed: OpenAIConfig = toml::from_str(
1006 r#"
1007[hosted_shell]
1008enabled = true
1009
1010[[hosted_shell.skills]]
1011type = "skill_reference"
1012skill_id = "skill_123"
1013version = 2
1014
1015[[hosted_shell.skills]]
1016type = "inline"
1017bundle_b64 = "UEsFBgAAAAAAAA=="
1018sha256 = "deadbeef"
1019"#,
1020 )
1021 .expect("config should parse");
1022
1023 assert_eq!(
1024 parsed.hosted_shell.skills,
1025 vec![
1026 OpenAIHostedSkill::SkillReference {
1027 skill_id: "skill_123".to_string(),
1028 version: OpenAIHostedSkillVersion::Number(2),
1029 },
1030 OpenAIHostedSkill::Inline {
1031 bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
1032 sha256: Some("deadbeef".to_string()),
1033 },
1034 ]
1035 );
1036 }
1037
1038 #[test]
1039 fn openai_config_parses_hosted_shell_network_policy() {
1040 let parsed: OpenAIConfig = toml::from_str(
1041 r#"
1042[hosted_shell]
1043enabled = true
1044
1045[hosted_shell.network_policy]
1046type = "allowlist"
1047allowed_domains = ["httpbin.org"]
1048
1049[[hosted_shell.network_policy.domain_secrets]]
1050domain = "httpbin.org"
1051name = "API_KEY"
1052value = "debug-secret-123"
1053"#,
1054 )
1055 .expect("config should parse");
1056
1057 assert_eq!(
1058 parsed.hosted_shell.network_policy,
1059 OpenAIHostedShellNetworkPolicy {
1060 policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1061 allowed_domains: vec!["httpbin.org".to_string()],
1062 domain_secrets: vec![OpenAIHostedShellDomainSecret {
1063 domain: "httpbin.org".to_string(),
1064 name: "API_KEY".to_string(),
1065 value: "debug-secret-123".to_string(),
1066 }],
1067 }
1068 );
1069 }
1070
1071 #[test]
1072 fn openai_config_parses_tool_search() {
1073 let parsed: OpenAIConfig = toml::from_str(
1074 r#"
1075[tool_search]
1076enabled = false
1077defer_by_default = false
1078always_available_tools = ["code_search", "custom_tool"]
1079"#,
1080 )
1081 .expect("config should parse");
1082
1083 assert!(!parsed.tool_search.enabled);
1084 assert!(!parsed.tool_search.defer_by_default);
1085 assert_eq!(
1086 parsed.tool_search.always_available_tools,
1087 vec!["code_search".to_string(), "custom_tool".to_string()]
1088 );
1089 }
1090
1091 #[test]
1092 fn anthropic_tool_search_defaults_to_enabled() {
1093 let config = AnthropicConfig::default();
1094
1095 assert!(config.tool_search.enabled);
1096 assert!(config.tool_search.defer_by_default);
1097 assert_eq!(config.tool_search.algorithm, ToolSearchAlgorithm::Regex);
1098 assert!(config.tool_search.always_available_tools.is_empty());
1099 }
1100
1101 #[test]
1102 fn hosted_shell_container_reference_requires_non_empty_container_id() {
1103 let config = OpenAIHostedShellConfig {
1104 enabled: true,
1105 environment: OpenAIHostedShellEnvironment::ContainerReference,
1106 container_id: Some(" ".to_string()),
1107 file_ids: Vec::new(),
1108 skills: Vec::new(),
1109 network_policy: OpenAIHostedShellNetworkPolicy::default(),
1110 };
1111
1112 assert!(!config.has_valid_reference_target());
1113 assert!(config.container_id_ref().is_none());
1114 }
1115
1116 #[test]
1117 fn hosted_shell_reports_invalid_skill_reference_mounts() {
1118 let config = OpenAIHostedShellConfig {
1119 enabled: true,
1120 environment: OpenAIHostedShellEnvironment::ContainerAuto,
1121 container_id: None,
1122 file_ids: Vec::new(),
1123 skills: vec![OpenAIHostedSkill::SkillReference {
1124 skill_id: " ".to_string(),
1125 version: OpenAIHostedSkillVersion::default(),
1126 }],
1127 network_policy: OpenAIHostedShellNetworkPolicy::default(),
1128 };
1129
1130 let message = config.first_invalid_skill_message().expect("invalid mount should be reported");
1131
1132 assert!(message.contains("provider.openai.hosted_shell.skills[0].skill_id"));
1133 assert!(!config.has_valid_skill_mounts());
1134 assert!(!config.is_valid_for_runtime());
1135 }
1136
1137 #[test]
1138 fn hosted_shell_ignores_skill_validation_for_container_reference() {
1139 let config = OpenAIHostedShellConfig {
1140 enabled: true,
1141 environment: OpenAIHostedShellEnvironment::ContainerReference,
1142 container_id: Some("cntr_123".to_string()),
1143 file_ids: Vec::new(),
1144 skills: vec![OpenAIHostedSkill::Inline { bundle_b64: " ".to_string(), sha256: None }],
1145 network_policy: OpenAIHostedShellNetworkPolicy::default(),
1146 };
1147
1148 assert!(config.first_invalid_skill_message().is_none());
1149 assert!(config.has_valid_skill_mounts());
1150 assert!(config.is_valid_for_runtime());
1151 }
1152
1153 #[test]
1154 fn hosted_shell_reports_invalid_allowlist_without_domains() {
1155 let config = OpenAIHostedShellConfig {
1156 enabled: true,
1157 environment: OpenAIHostedShellEnvironment::ContainerAuto,
1158 container_id: None,
1159 file_ids: Vec::new(),
1160 skills: Vec::new(),
1161 network_policy: OpenAIHostedShellNetworkPolicy {
1162 policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1163 allowed_domains: Vec::new(),
1164 domain_secrets: Vec::new(),
1165 },
1166 };
1167
1168 let message = config
1169 .first_invalid_network_policy_message()
1170 .expect("invalid network policy should be reported");
1171
1172 assert!(message.contains("network_policy.allowed_domains"));
1173 assert!(!config.has_valid_network_policy());
1174 assert!(!config.is_valid_for_runtime());
1175 }
1176
1177 #[test]
1178 fn hosted_shell_reports_domain_secret_outside_allowlist() {
1179 let config = OpenAIHostedShellConfig {
1180 enabled: true,
1181 environment: OpenAIHostedShellEnvironment::ContainerAuto,
1182 container_id: None,
1183 file_ids: Vec::new(),
1184 skills: Vec::new(),
1185 network_policy: OpenAIHostedShellNetworkPolicy {
1186 policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1187 allowed_domains: vec!["pypi.org".to_string()],
1188 domain_secrets: vec![OpenAIHostedShellDomainSecret {
1189 domain: "httpbin.org".to_string(),
1190 name: "API_KEY".to_string(),
1191 value: "secret".to_string(),
1192 }],
1193 },
1194 };
1195
1196 let message = config
1197 .first_invalid_network_policy_message()
1198 .expect("invalid domain secret should be reported");
1199
1200 assert!(message.contains("domain_secrets[0].domain"));
1201 assert!(!config.has_valid_network_policy());
1202 }
1203
1204 #[test]
1205 fn hosted_shell_ignores_network_policy_validation_for_container_reference() {
1206 let config = OpenAIHostedShellConfig {
1207 enabled: true,
1208 environment: OpenAIHostedShellEnvironment::ContainerReference,
1209 container_id: Some("cntr_123".to_string()),
1210 file_ids: Vec::new(),
1211 skills: Vec::new(),
1212 network_policy: OpenAIHostedShellNetworkPolicy {
1213 policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1214 allowed_domains: Vec::new(),
1215 domain_secrets: Vec::new(),
1216 },
1217 };
1218
1219 assert!(config.first_invalid_network_policy_message().is_none());
1220 assert!(config.has_valid_network_policy());
1221 }
1222}