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, skip_serializing_if = "Option::is_none")]
447 pub speed: Option<super::AnthropicSpeed>,
448
449 #[serde(default = "default_extended_thinking_enabled")]
458 pub extended_thinking_enabled: bool,
459
460 #[serde(default = "default_interleaved_thinking_beta")]
462 pub interleaved_thinking_beta: String,
463
464 #[serde(default = "default_interleaved_thinking_budget_tokens")]
470 pub interleaved_thinking_budget_tokens: u32,
471
472 #[serde(default = "default_interleaved_thinking_type")]
474 pub interleaved_thinking_type_enabled: String,
475
476 #[serde(default)]
478 pub tool_search: ToolSearchConfig,
479
480 #[serde(default)]
482 pub memory: AnthropicMemoryConfig,
483
484 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub effort: Option<ReasoningEffortLevel>,
493
494 #[serde(default)]
500 pub task_budget_tokens: Option<u32>,
501
502 #[serde(default = "default_task_budget_beta")]
504 pub task_budget_beta: String,
505
506 #[serde(default)]
517 pub thinking_display: Option<ThinkingDisplayMode>,
518
519 #[serde(default = "default_count_tokens_enabled")]
523 pub count_tokens_enabled: bool,
524
525 #[serde(default)]
529 pub advisor: AdvisorConfig,
530
531 #[serde(default)]
541 pub fallbacks: AnthropicFallbacks,
542}
543
544#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
546#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
547#[serde(rename_all = "lowercase")]
548pub enum AnthropicFallbackMode {
549 #[default]
551 Default,
552 Off,
554}
555
556#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
558#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
559pub struct AnthropicFallbackTarget {
560 pub model: String,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub max_tokens: Option<u32>,
565}
566
567#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
570#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
571#[serde(untagged)]
572pub enum AnthropicFallbacks {
573 Mode(AnthropicFallbackMode),
574 Models(Vec<AnthropicFallbackTarget>),
575}
576
577impl Default for AnthropicFallbacks {
578 fn default() -> Self {
579 Self::Mode(AnthropicFallbackMode::Default)
580 }
581}
582
583impl AnthropicFallbacks {
584 pub const MAX_MODELS: usize = 3;
586
587 pub fn validation_error(&self, field_path: &str) -> Option<String> {
590 let Self::Models(models) = self else {
591 return None;
592 };
593 Self::entries_validation_error(
594 field_path,
595 models.iter().map(|target| (target.model.as_str(), target.max_tokens)),
596 )
597 }
598
599 pub fn entries_validation_error<'a>(
604 field_path: &str,
605 entries: impl ExactSizeIterator<Item = (&'a str, Option<u32>)>,
606 ) -> Option<String> {
607 let len = entries.len();
608 if len == 0 || len > Self::MAX_MODELS {
609 return Some(format!(
610 "`{field_path}` must list between 1 and {} fallback models, or be \"default\" or \"off\".",
611 Self::MAX_MODELS
612 ));
613 }
614 let mut seen = std::collections::HashSet::with_capacity(len);
615 for (model, max_tokens) in entries {
616 let model = model.trim();
617 if model.is_empty() {
618 return Some(format!("`{field_path}` entries must set a non-empty `model`."));
619 }
620 if !seen.insert(model) {
621 return Some(format!("`{field_path}` lists `{model}` more than once; entries must be distinct."));
622 }
623 if max_tokens == Some(0) {
624 return Some(format!("`{field_path}` entry `{model}` must use a positive `max_tokens`."));
625 }
626 }
627 None
628 }
629}
630
631impl Default for AnthropicConfig {
632 fn default() -> Self {
633 Self {
634 speed: None,
635 extended_thinking_enabled: default_extended_thinking_enabled(),
636 interleaved_thinking_beta: default_interleaved_thinking_beta(),
637 interleaved_thinking_budget_tokens: default_interleaved_thinking_budget_tokens(),
638 interleaved_thinking_type_enabled: default_interleaved_thinking_type(),
639 tool_search: ToolSearchConfig::default(),
640 memory: AnthropicMemoryConfig::default(),
641 effort: None,
642 task_budget_tokens: None,
643 task_budget_beta: default_task_budget_beta(),
644 thinking_display: None,
645 count_tokens_enabled: default_count_tokens_enabled(),
646 advisor: AdvisorConfig::default(),
647 fallbacks: AnthropicFallbacks::default(),
648 }
649 }
650}
651
652#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
653#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
654pub struct AnthropicMemoryConfig {
655 #[serde(default)]
656 pub enabled: bool,
657}
658
659#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
665#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
666pub struct OpenRouterAttributionConfig {
667 #[serde(default)]
669 pub enabled: bool,
670
671 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub referer: Option<String>,
674
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub title: Option<String>,
678}
679
680impl OpenRouterAttributionConfig {
681 pub const DEFAULT_REFERER: &'static str = "https://github.com/vinhnx/vtcode";
683 pub const DEFAULT_TITLE: &'static str = "VT Code";
685 pub const CATEGORIES: &'static str = "cli-agent";
687
688 pub fn is_enabled(&self) -> bool {
695 vtcode_commons::utils::parse_env_bool(crate::constants::env_vars::VTCODE_OPENROUTER_ATTRIBUTION, self.enabled)
696 }
697
698 fn header_safe(value: &str) -> bool {
701 !value.is_empty() && value.len() <= 512 && !value.bytes().any(|b| b < 0x20 || b == 0x7F)
702 }
703
704 pub fn resolved_referer(&self) -> &str {
707 self.referer
708 .as_deref()
709 .map(str::trim)
710 .filter(|s| Self::header_safe(s))
711 .unwrap_or(Self::DEFAULT_REFERER)
712 }
713
714 pub fn resolved_title(&self) -> &str {
717 self.title
718 .as_deref()
719 .map(str::trim)
720 .filter(|s| Self::header_safe(s))
721 .unwrap_or(Self::DEFAULT_TITLE)
722 }
723}
724
725#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
727#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
728pub struct OpenRouterConfig {
729 #[serde(default)]
731 pub attribution: OpenRouterAttributionConfig,
732}
733
734#[inline]
735fn default_count_tokens_enabled() -> bool {
736 false
737}
738
739#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
741#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
742#[serde(rename_all = "lowercase")]
743pub enum ToolSearchAlgorithm {
744 #[default]
746 Regex,
747 Bm25,
749 #[serde(other)]
751 Unknown,
752}
753
754impl ToolSearchAlgorithm {
755 fn as_str(&self) -> &str {
757 match self {
758 Self::Regex => "regex",
759 Self::Bm25 => "bm25",
760 Self::Unknown => "unknown",
761 }
762 }
763}
764
765impl std::fmt::Display for ToolSearchAlgorithm {
766 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767 f.write_str(self.as_str())
768 }
769}
770
771#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
774#[derive(Debug, Clone, Deserialize, Serialize)]
775pub struct ToolSearchConfig {
776 #[serde(default = "default_tool_search_enabled")]
778 pub enabled: bool,
779
780 #[serde(default = "default_tool_search_algorithm")]
782 pub algorithm: ToolSearchAlgorithm,
783
784 #[serde(default = "default_defer_by_default")]
786 pub defer_by_default: bool,
787
788 #[serde(default = "default_max_results")]
790 max_results: u32,
791
792 #[serde(default)]
794 pub always_available_tools: Vec<String>,
795}
796
797impl Default for ToolSearchConfig {
798 fn default() -> Self {
799 Self {
800 enabled: default_tool_search_enabled(),
801 algorithm: default_tool_search_algorithm(),
802 defer_by_default: default_defer_by_default(),
803 max_results: default_max_results(),
804 always_available_tools: vec![],
805 }
806 }
807}
808
809#[inline]
810fn default_tool_search_enabled() -> bool {
811 true
812}
813
814#[inline]
815fn default_tool_search_algorithm() -> ToolSearchAlgorithm {
816 ToolSearchAlgorithm::Regex
817}
818
819#[inline]
820fn default_defer_by_default() -> bool {
821 true
822}
823
824#[inline]
825fn default_max_results() -> u32 {
826 5
827}
828
829#[inline]
830fn default_extended_thinking_enabled() -> bool {
831 true
832}
833
834#[inline]
835fn default_interleaved_thinking_beta() -> String {
836 "interleaved-thinking-2025-05-14".to_string()
837}
838
839#[inline]
840fn default_interleaved_thinking_budget_tokens() -> u32 {
841 31999
842}
843
844#[inline]
845fn default_interleaved_thinking_type() -> String {
846 "enabled".to_string()
847}
848
849#[inline]
850fn default_task_budget_beta() -> String {
851 "task-budgets-2026-03-13".to_string()
852}
853
854#[cfg(test)]
855mod tests {
856 use super::{
857 AnthropicConfig, AnthropicFallbackMode, AnthropicFallbackTarget, AnthropicFallbacks, OpenAIConfig,
858 OpenAIHostedShellConfig, OpenAIHostedShellDomainSecret, OpenAIHostedShellEnvironment,
859 OpenAIHostedShellNetworkPolicy, OpenAIHostedShellNetworkPolicyType, OpenAIHostedSkill,
860 OpenAIHostedSkillVersion, OpenAIManualCompactionConfig, OpenAIServiceTier, OpenRouterAttributionConfig,
861 OpenRouterConfig, ToolSearchAlgorithm,
862 };
863
864 #[test]
865 fn anthropic_effort_is_unset_unless_configured() {
866 assert_eq!(AnthropicConfig::default().effort, None);
867
868 let parsed: AnthropicConfig = toml::from_str("").expect("empty anthropic config");
869 assert_eq!(parsed.effort, None);
870
871 let parsed: AnthropicConfig = toml::from_str("effort = \"high\"").expect("explicit effort");
872 assert_eq!(parsed.effort, Some(super::ReasoningEffortLevel::High));
873 }
874
875 #[test]
878 fn openai_service_tier_string_paths_stay_in_lockstep() {
879 use crate::test_support::assert_string_enum_lockstep;
880
881 assert_string_enum_lockstep!(
882 OpenAIServiceTier,
883 [
884 OpenAIServiceTier::Flex,
885 OpenAIServiceTier::Priority,
886 OpenAIServiceTier::Ultrafast,
887 ]
888 );
889 }
890
891 #[test]
892 fn anthropic_fallbacks_default_to_default_mode() {
893 let config = AnthropicConfig::default();
894 assert_eq!(config.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
895 let parsed: AnthropicConfig = toml::from_str("").expect("config should parse");
896 assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
897 }
898
899 #[test]
900 fn anthropic_fallbacks_parse_all_three_forms() {
901 let parsed: AnthropicConfig = toml::from_str("fallbacks = \"default\"").expect("default form");
902 assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
903
904 let parsed: AnthropicConfig = toml::from_str("fallbacks = \"off\"").expect("off form");
905 assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Off));
906
907 let parsed: AnthropicConfig = toml::from_str(
908 "fallbacks = [{ model = \"claude-opus-4-8\", max_tokens = 32000 }, { model = \"claude-opus-5\" }]",
909 )
910 .expect("list form");
911 assert_eq!(
912 parsed.fallbacks,
913 AnthropicFallbacks::Models(vec![
914 AnthropicFallbackTarget {
915 model: "claude-opus-4-8".to_string(),
916 max_tokens: Some(32_000),
917 },
918 AnthropicFallbackTarget {
919 model: "claude-opus-5".to_string(),
920 max_tokens: None,
921 },
922 ])
923 );
924 assert_eq!(parsed.fallbacks.validation_error("fallbacks"), None);
925
926 assert!(toml::from_str::<AnthropicConfig>("fallbacks = \"sometimes\"").is_err());
927 }
928
929 #[test]
930 fn anthropic_fallbacks_round_trip_through_serde() {
931 for fallbacks in [
932 AnthropicFallbacks::Mode(AnthropicFallbackMode::Default),
933 AnthropicFallbacks::Mode(AnthropicFallbackMode::Off),
934 AnthropicFallbacks::Models(vec![AnthropicFallbackTarget {
935 model: "claude-opus-4-8".to_string(),
936 max_tokens: None,
937 }]),
938 ] {
939 let json = serde_json::to_value(&fallbacks).expect("serialize");
940 let back: AnthropicFallbacks = serde_json::from_value(json).expect("deserialize");
941 assert_eq!(back, fallbacks);
942 }
943 assert_eq!(
944 serde_json::to_value(AnthropicFallbacks::default()).expect("serialize"),
945 serde_json::json!("default")
946 );
947 }
948
949 #[test]
950 fn anthropic_fallback_list_validation_rejects_bad_lists() {
951 let target = |model: &str| AnthropicFallbackTarget { model: model.to_string(), max_tokens: None };
952 for bad in [
953 AnthropicFallbacks::Models(vec![]),
954 AnthropicFallbacks::Models(vec![target("a"), target("b"), target("c"), target("d")]),
955 AnthropicFallbacks::Models(vec![target(" ")]),
956 AnthropicFallbacks::Models(vec![target("a"), target(" a ")]),
957 AnthropicFallbacks::Models(vec![AnthropicFallbackTarget { model: "a".to_string(), max_tokens: Some(0) }]),
958 ] {
959 assert!(bad.validation_error("fallbacks").is_some(), "{bad:?}");
960 }
961 }
962
963 #[test]
964 fn openai_config_defaults_to_websocket_mode_disabled() {
965 let config = OpenAIConfig::default();
966 assert!(!config.websocket_mode);
967 assert_eq!(config.responses_store, None);
968 assert!(config.responses_include.is_empty());
969 assert_eq!(config.service_tier, None);
970 assert_eq!(config.manual_compaction, OpenAIManualCompactionConfig::default());
971 assert_eq!(config.hosted_shell, OpenAIHostedShellConfig::default());
972 assert!(config.tool_search.enabled);
973 assert!(config.tool_search.defer_by_default);
974 assert!(config.tool_search.always_available_tools.is_empty());
975 }
976
977 #[test]
978 fn anthropic_config_defaults_native_memory_to_disabled() {
979 let config = AnthropicConfig::default();
980 assert!(!config.memory.enabled);
981 }
982
983 #[test]
984 fn anthropic_config_parses_native_memory_opt_in() {
985 let parsed: AnthropicConfig = toml::from_str("[memory]\nenabled = true").expect("config should parse");
986 assert!(parsed.memory.enabled);
987 }
988
989 #[test]
990 fn openai_config_parses_websocket_mode_opt_in() {
991 let parsed: OpenAIConfig = toml::from_str("websocket_mode = true").expect("config should parse");
992 assert!(parsed.websocket_mode);
993 assert_eq!(parsed.responses_store, None);
994 assert!(parsed.responses_include.is_empty());
995 assert_eq!(parsed.service_tier, None);
996 assert_eq!(parsed.manual_compaction, OpenAIManualCompactionConfig::default());
997 assert_eq!(parsed.hosted_shell, OpenAIHostedShellConfig::default());
998 assert_eq!(parsed.tool_search, super::OpenAIToolSearchConfig::default());
999 }
1000
1001 #[test]
1002 fn openai_config_parses_responses_options() {
1003 let parsed: OpenAIConfig = toml::from_str(
1004 r#"
1005responses_store = false
1006responses_include = ["reasoning.encrypted_content", "output_text.annotations"]
1007"#,
1008 )
1009 .expect("config should parse");
1010 assert_eq!(parsed.responses_store, Some(false));
1011 assert_eq!(
1012 parsed.responses_include,
1013 vec![
1014 "reasoning.encrypted_content".to_string(),
1015 "output_text.annotations".to_string()
1016 ]
1017 );
1018 assert_eq!(parsed.service_tier, None);
1019 assert_eq!(parsed.manual_compaction, OpenAIManualCompactionConfig::default());
1020 assert_eq!(parsed.hosted_shell, OpenAIHostedShellConfig::default());
1021 }
1022
1023 #[test]
1024 fn openai_config_parses_manual_compaction_defaults() {
1025 let parsed: OpenAIConfig = toml::from_str(
1026 r#"
1027[manual_compaction]
1028instructions = "Preserve the bug reproduction steps."
1029"#,
1030 )
1031 .expect("config should parse");
1032
1033 assert_eq!(parsed.manual_compaction.instructions.as_deref(), Some("Preserve the bug reproduction steps."));
1034 }
1035
1036 #[test]
1037 fn openai_config_parses_service_tier() {
1038 let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "priority""#).expect("config should parse");
1039 assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Priority));
1040 }
1041
1042 #[test]
1043 fn openai_config_parses_flex_service_tier() {
1044 let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "flex""#).expect("config should parse");
1045 assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Flex));
1046 }
1047
1048 #[test]
1049 fn openai_config_parses_ultrafast_service_tier() {
1050 let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "ultrafast""#).expect("config should parse");
1051 assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Ultrafast));
1052 assert_eq!(OpenAIServiceTier::Ultrafast.as_str(), "ultrafast");
1053 assert_eq!(OpenAIServiceTier::parse("ULTRAFAST"), Some(OpenAIServiceTier::Ultrafast));
1054 }
1055
1056 #[test]
1057 fn openai_config_parses_hosted_shell() {
1058 let parsed: OpenAIConfig = toml::from_str(
1059 r#"
1060[hosted_shell]
1061enabled = true
1062environment = "container_auto"
1063file_ids = ["file_123"]
1064
1065[[hosted_shell.skills]]
1066type = "skill_reference"
1067skill_id = "skill_123"
1068"#,
1069 )
1070 .expect("config should parse");
1071
1072 assert!(parsed.hosted_shell.enabled);
1073 assert_eq!(parsed.hosted_shell.environment, OpenAIHostedShellEnvironment::ContainerAuto);
1074 assert_eq!(parsed.hosted_shell.file_ids, vec!["file_123".to_string()]);
1075 assert_eq!(
1076 parsed.hosted_shell.skills,
1077 vec![OpenAIHostedSkill::SkillReference {
1078 skill_id: "skill_123".to_string(),
1079 version: OpenAIHostedSkillVersion::default(),
1080 }]
1081 );
1082 }
1083
1084 #[test]
1085 fn openai_config_parses_hosted_shell_pinned_version_and_inline_bundle() {
1086 let parsed: OpenAIConfig = toml::from_str(
1087 r#"
1088[hosted_shell]
1089enabled = true
1090
1091[[hosted_shell.skills]]
1092type = "skill_reference"
1093skill_id = "skill_123"
1094version = 2
1095
1096[[hosted_shell.skills]]
1097type = "inline"
1098bundle_b64 = "UEsFBgAAAAAAAA=="
1099sha256 = "deadbeef"
1100"#,
1101 )
1102 .expect("config should parse");
1103
1104 assert_eq!(
1105 parsed.hosted_shell.skills,
1106 vec![
1107 OpenAIHostedSkill::SkillReference {
1108 skill_id: "skill_123".to_string(),
1109 version: OpenAIHostedSkillVersion::Number(2),
1110 },
1111 OpenAIHostedSkill::Inline {
1112 bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
1113 sha256: Some("deadbeef".to_string()),
1114 },
1115 ]
1116 );
1117 }
1118
1119 #[test]
1120 fn openai_config_parses_hosted_shell_network_policy() {
1121 let parsed: OpenAIConfig = toml::from_str(
1122 r#"
1123[hosted_shell]
1124enabled = true
1125
1126[hosted_shell.network_policy]
1127type = "allowlist"
1128allowed_domains = ["httpbin.org"]
1129
1130[[hosted_shell.network_policy.domain_secrets]]
1131domain = "httpbin.org"
1132name = "API_KEY"
1133value = "debug-secret-123"
1134"#,
1135 )
1136 .expect("config should parse");
1137
1138 assert_eq!(
1139 parsed.hosted_shell.network_policy,
1140 OpenAIHostedShellNetworkPolicy {
1141 policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1142 allowed_domains: vec!["httpbin.org".to_string()],
1143 domain_secrets: vec![OpenAIHostedShellDomainSecret {
1144 domain: "httpbin.org".to_string(),
1145 name: "API_KEY".to_string(),
1146 value: "debug-secret-123".to_string(),
1147 }],
1148 }
1149 );
1150 }
1151
1152 #[test]
1153 fn openai_config_parses_tool_search() {
1154 let parsed: OpenAIConfig = toml::from_str(
1155 r#"
1156[tool_search]
1157enabled = false
1158defer_by_default = false
1159always_available_tools = ["code_search", "custom_tool"]
1160"#,
1161 )
1162 .expect("config should parse");
1163
1164 assert!(!parsed.tool_search.enabled);
1165 assert!(!parsed.tool_search.defer_by_default);
1166 assert_eq!(
1167 parsed.tool_search.always_available_tools,
1168 vec!["code_search".to_string(), "custom_tool".to_string()]
1169 );
1170 }
1171
1172 #[test]
1173 fn anthropic_tool_search_defaults_to_enabled() {
1174 let config = AnthropicConfig::default();
1175
1176 assert!(config.tool_search.enabled);
1177 assert!(config.tool_search.defer_by_default);
1178 assert_eq!(config.tool_search.algorithm, ToolSearchAlgorithm::Regex);
1179 assert!(config.tool_search.always_available_tools.is_empty());
1180 }
1181
1182 #[test]
1183 fn hosted_shell_container_reference_requires_non_empty_container_id() {
1184 let config = OpenAIHostedShellConfig {
1185 enabled: true,
1186 environment: OpenAIHostedShellEnvironment::ContainerReference,
1187 container_id: Some(" ".to_string()),
1188 file_ids: Vec::new(),
1189 skills: Vec::new(),
1190 network_policy: OpenAIHostedShellNetworkPolicy::default(),
1191 };
1192
1193 assert!(!config.has_valid_reference_target());
1194 assert!(config.container_id_ref().is_none());
1195 }
1196
1197 #[test]
1198 fn hosted_shell_reports_invalid_skill_reference_mounts() {
1199 let config = OpenAIHostedShellConfig {
1200 enabled: true,
1201 environment: OpenAIHostedShellEnvironment::ContainerAuto,
1202 container_id: None,
1203 file_ids: Vec::new(),
1204 skills: vec![OpenAIHostedSkill::SkillReference {
1205 skill_id: " ".to_string(),
1206 version: OpenAIHostedSkillVersion::default(),
1207 }],
1208 network_policy: OpenAIHostedShellNetworkPolicy::default(),
1209 };
1210
1211 let message = config.first_invalid_skill_message().expect("invalid mount should be reported");
1212
1213 assert!(message.contains("provider.openai.hosted_shell.skills[0].skill_id"));
1214 assert!(!config.has_valid_skill_mounts());
1215 assert!(!config.is_valid_for_runtime());
1216 }
1217
1218 #[test]
1219 fn hosted_shell_ignores_skill_validation_for_container_reference() {
1220 let config = OpenAIHostedShellConfig {
1221 enabled: true,
1222 environment: OpenAIHostedShellEnvironment::ContainerReference,
1223 container_id: Some("cntr_123".to_string()),
1224 file_ids: Vec::new(),
1225 skills: vec![OpenAIHostedSkill::Inline { bundle_b64: " ".to_string(), sha256: None }],
1226 network_policy: OpenAIHostedShellNetworkPolicy::default(),
1227 };
1228
1229 assert!(config.first_invalid_skill_message().is_none());
1230 assert!(config.has_valid_skill_mounts());
1231 assert!(config.is_valid_for_runtime());
1232 }
1233
1234 #[test]
1235 fn hosted_shell_reports_invalid_allowlist_without_domains() {
1236 let config = OpenAIHostedShellConfig {
1237 enabled: true,
1238 environment: OpenAIHostedShellEnvironment::ContainerAuto,
1239 container_id: None,
1240 file_ids: Vec::new(),
1241 skills: Vec::new(),
1242 network_policy: OpenAIHostedShellNetworkPolicy {
1243 policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1244 allowed_domains: Vec::new(),
1245 domain_secrets: Vec::new(),
1246 },
1247 };
1248
1249 let message = config
1250 .first_invalid_network_policy_message()
1251 .expect("invalid network policy should be reported");
1252
1253 assert!(message.contains("network_policy.allowed_domains"));
1254 assert!(!config.has_valid_network_policy());
1255 assert!(!config.is_valid_for_runtime());
1256 }
1257
1258 #[test]
1259 fn hosted_shell_reports_domain_secret_outside_allowlist() {
1260 let config = OpenAIHostedShellConfig {
1261 enabled: true,
1262 environment: OpenAIHostedShellEnvironment::ContainerAuto,
1263 container_id: None,
1264 file_ids: Vec::new(),
1265 skills: Vec::new(),
1266 network_policy: OpenAIHostedShellNetworkPolicy {
1267 policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1268 allowed_domains: vec!["pypi.org".to_string()],
1269 domain_secrets: vec![OpenAIHostedShellDomainSecret {
1270 domain: "httpbin.org".to_string(),
1271 name: "API_KEY".to_string(),
1272 value: "secret".to_string(),
1273 }],
1274 },
1275 };
1276
1277 let message = config
1278 .first_invalid_network_policy_message()
1279 .expect("invalid domain secret should be reported");
1280
1281 assert!(message.contains("domain_secrets[0].domain"));
1282 assert!(!config.has_valid_network_policy());
1283 }
1284
1285 #[test]
1286 fn hosted_shell_ignores_network_policy_validation_for_container_reference() {
1287 let config = OpenAIHostedShellConfig {
1288 enabled: true,
1289 environment: OpenAIHostedShellEnvironment::ContainerReference,
1290 container_id: Some("cntr_123".to_string()),
1291 file_ids: Vec::new(),
1292 skills: Vec::new(),
1293 network_policy: OpenAIHostedShellNetworkPolicy {
1294 policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1295 allowed_domains: Vec::new(),
1296 domain_secrets: Vec::new(),
1297 },
1298 };
1299
1300 assert!(config.first_invalid_network_policy_message().is_none());
1301 assert!(config.has_valid_network_policy());
1302 }
1303
1304 fn stash_attribution_env() -> (vtcode_commons::env_lock::EnvGuard, Option<std::ffi::OsString>) {
1307 let guard = vtcode_commons::env_lock::lock();
1308 let prior = std::env::var_os(crate::constants::env_vars::VTCODE_OPENROUTER_ATTRIBUTION);
1309 guard.remove_var(crate::constants::env_vars::VTCODE_OPENROUTER_ATTRIBUTION);
1310 (guard, prior)
1311 }
1312
1313 fn restore_attribution_env(guard: &vtcode_commons::env_lock::EnvGuard, prior: Option<std::ffi::OsString>) {
1314 guard.restore_var(crate::constants::env_vars::VTCODE_OPENROUTER_ATTRIBUTION, prior);
1315 }
1316
1317 #[test]
1318 #[serial_test::serial]
1319 fn openrouter_attribution_defaults_to_disabled() {
1320 let (env, prior) = stash_attribution_env();
1321 let config = OpenRouterAttributionConfig::default();
1322 assert!(!config.enabled);
1323 assert!(!config.is_enabled());
1324 assert_eq!(config.resolved_referer(), OpenRouterAttributionConfig::DEFAULT_REFERER);
1325 assert_eq!(config.resolved_title(), OpenRouterAttributionConfig::DEFAULT_TITLE);
1326 assert_eq!(OpenRouterAttributionConfig::CATEGORIES, "cli-agent");
1327
1328 let parsed: OpenRouterConfig = toml::from_str("").expect("empty openrouter config");
1329 assert!(!parsed.attribution.enabled);
1330 assert!(!parsed.attribution.is_enabled());
1331 restore_attribution_env(&env, prior);
1332 }
1333
1334 #[test]
1335 #[serial_test::serial]
1336 fn openrouter_attribution_parses_opt_in_with_overrides() {
1337 let (env, prior) = stash_attribution_env();
1338 let parsed: OpenRouterConfig = toml::from_str(
1339 r#"
1340[attribution]
1341enabled = true
1342referer = "https://example.com/my-fork"
1343title = "My Fork"
1344"#,
1345 )
1346 .expect("config should parse");
1347 assert!(parsed.attribution.enabled);
1348 assert!(parsed.attribution.is_enabled());
1349 assert_eq!(parsed.attribution.resolved_referer(), "https://example.com/my-fork");
1350 assert_eq!(parsed.attribution.resolved_title(), "My Fork");
1351
1352 let blank = OpenRouterAttributionConfig {
1354 enabled: true,
1355 referer: Some(" ".to_string()),
1356 title: Some("".to_string()),
1357 };
1358 assert_eq!(blank.resolved_referer(), OpenRouterAttributionConfig::DEFAULT_REFERER);
1359 assert_eq!(blank.resolved_title(), OpenRouterAttributionConfig::DEFAULT_TITLE);
1360 restore_attribution_env(&env, prior);
1361 }
1362
1363 #[test]
1364 #[serial_test::serial]
1365 fn openrouter_attribution_env_layer_wins_over_file() {
1366 let (env, prior) = stash_attribution_env();
1369 let disabled = OpenRouterAttributionConfig::default();
1370 env.set_var(crate::constants::env_vars::VTCODE_OPENROUTER_ATTRIBUTION, "yes");
1371 assert!(disabled.is_enabled());
1372 env.set_var(crate::constants::env_vars::VTCODE_OPENROUTER_ATTRIBUTION, "0");
1373 let enabled = OpenRouterAttributionConfig { enabled: true, referer: None, title: None };
1374 assert!(!enabled.is_enabled());
1375 env.set_var(crate::constants::env_vars::VTCODE_OPENROUTER_ATTRIBUTION, "maybe");
1377 assert!(enabled.is_enabled());
1378 assert!(!disabled.is_enabled());
1379 restore_attribution_env(&env, prior);
1380 }
1381
1382 #[test]
1383 fn openrouter_attribution_rejects_header_unsafe_overrides() {
1384 let injected = OpenRouterAttributionConfig {
1387 enabled: true,
1388 referer: Some("https://example.com/\r\nX-Injected: 1".to_string()),
1389 title: Some("bad\ttitle\x7F".to_string()),
1390 };
1391 assert_eq!(injected.resolved_referer(), OpenRouterAttributionConfig::DEFAULT_REFERER);
1392 assert_eq!(injected.resolved_title(), OpenRouterAttributionConfig::DEFAULT_TITLE);
1393
1394 let oversized = OpenRouterAttributionConfig {
1395 enabled: true,
1396 referer: Some(format!("https://example.com/{}", "a".repeat(600))),
1397 title: None,
1398 };
1399 assert_eq!(oversized.resolved_referer(), OpenRouterAttributionConfig::DEFAULT_REFERER);
1400 }
1401}