Skip to main content

vtcode_config/core/
provider.rs

1use crate::core::advisor::AdvisorConfig;
2use serde::{Deserialize, Serialize};
3use vtcode_commons::reasoning::ReasoningEffortLevel;
4
5/// Controls how thinking content is returned in Anthropic API responses.
6#[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    /// Thinking blocks contain summarized thinking text (default on Claude 4 models).
11    Summarized,
12    /// Thinking blocks are returned with an empty `thinking` field (default on Claude Opus 4.7).
13    Omitted,
14    /// Only the short progress updates written between tool calls come back as
15    /// text; reasoning stays hidden (Claude Sonnet 5.5, Claude Opus 5.5,
16    /// Claude Fable 5.x). Sent with the `thinking-display-updates-2026-08-18`
17    /// beta.
18    Updates,
19    /// Catch-all for unknown display modes added by the Anthropic API.
20    #[serde(other)]
21    Unknown,
22}
23
24impl ThinkingDisplayMode {
25    /// Returns the string representation for the API wire format.
26    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/// Native OpenAI service tier selection.
37#[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/// How VT Code should provision OpenAI hosted shell environments.
70#[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/// Hosted shell network access policy.
95#[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/// Per-domain secret injected by the OpenAI hosted shell runtime.
114#[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/// Request-scoped network policy for OpenAI hosted shell environments.
141#[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/// Reserved keyword values for hosted skill version selection.
207#[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/// Hosted skill version selector for OpenAI Responses hosted shell mounts.
216#[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/// Hosted skill reference mounted into an OpenAI hosted shell environment.
243#[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    /// Reference to a pre-registered hosted skill.
248    SkillReference {
249        skill_id: String,
250        #[serde(default)]
251        version: OpenAIHostedSkillVersion,
252    },
253    /// Inline base64 zip bundle.
254    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/// OpenAI hosted shell configuration.
285#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
286#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
287pub struct OpenAIHostedShellConfig {
288    /// Enable OpenAI hosted shell instead of VT Code's local shell tool.
289    #[serde(default)]
290    pub enabled: bool,
291
292    /// Environment provisioning mode for hosted shell.
293    #[serde(default)]
294    pub environment: OpenAIHostedShellEnvironment,
295
296    /// Existing OpenAI container ID to reuse when `environment = "container_reference"`.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub container_id: Option<String>,
299
300    /// File IDs to mount when using `container_auto`.
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub file_ids: Vec<String>,
303
304    /// Hosted skills to mount when using `container_auto`.
305    #[serde(default, skip_serializing_if = "Vec::is_empty")]
306    pub skills: Vec<OpenAIHostedSkill>,
307
308    /// Request-scoped network policy for `container_auto` hosted shells.
309    #[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/// OpenAI hosted tool search configuration.
359#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
360#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
361pub struct OpenAIToolSearchConfig {
362    /// Enable hosted tool search for OpenAI Responses-compatible models.
363    #[serde(default = "default_tool_search_enabled")]
364    pub enabled: bool,
365
366    /// Automatically defer loading of all tools except the core always-on set.
367    #[serde(default = "default_defer_by_default")]
368    pub defer_by_default: bool,
369
370    /// Tool names that should never be deferred (always available).
371    #[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/// Manual compaction defaults for the native OpenAI `/responses/compact` flow.
386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
387#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
388pub struct OpenAIManualCompactionConfig {
389    /// Optional custom instructions appended to manual `/compact` requests.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub instructions: Option<String>,
392}
393
394/// OpenAI-specific provider configuration
395#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
396#[derive(Debug, Clone, Deserialize, Serialize, Default)]
397pub struct OpenAIConfig {
398    /// Enable persistent Responses API WebSocket generation and streaming on
399    /// native OpenAI and OpenAI-compatible Responses endpoints.
400    /// This is an opt-in path designed for long-running, tool-heavy workflows.
401    /// A persistent connection removes per-request HTTP handshake overhead, so
402    /// pair it with `service_tier = "ultrafast"` on `gpt-6-astra`/`gpt-6.1-sol`
403    /// for agentic tool-call bursts (Ultrafast docs strongly recommend WebSockets).
404    /// Streaming falls back to HTTP only before any output event is exposed.
405    #[serde(default)]
406    pub websocket_mode: bool,
407
408    /// Optional Responses API `store` flag.
409    /// Set to `false` to avoid server-side storage when using Responses-compatible models.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub responses_store: Option<bool>,
412
413    /// Optional Responses API `include` selectors.
414    /// Example: `["reasoning.encrypted_content"]` for encrypted reasoning continuity.
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub responses_include: Vec<String>,
417
418    /// Optional native OpenAI `service_tier` request parameter.
419    /// Leave unset to inherit the Project-level default service tier.
420    /// Options: "flex", "priority", "ultrafast".
421    /// "ultrafast" is the fastest tier: GA for `gpt-6-astra` and `gpt-6.1-sol`,
422    /// preview-only for `gpt-5.6-sol` (contact your OpenAI account team). It costs more,
423    /// starts at low TPM limits (Astra 500k/1M/5M, 6.1-sol 1M/4M/40M), supports US/global
424    /// processing on Astra and US/EU/global on 6.1-sol, and pairs best with `websocket_mode = true`.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub service_tier: Option<OpenAIServiceTier>,
427
428    /// Manual `/compact` defaults for the native OpenAI standalone compaction endpoint.
429    #[serde(default)]
430    pub manual_compaction: OpenAIManualCompactionConfig,
431
432    /// Optional hosted shell configuration for OpenAI native Responses models.
433    #[serde(default)]
434    pub hosted_shell: OpenAIHostedShellConfig,
435
436    /// Hosted tool search configuration for OpenAI Responses-compatible models.
437    #[serde(default)]
438    pub tool_search: OpenAIToolSearchConfig,
439}
440
441/// Anthropic-specific provider configuration
442#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
443#[derive(Debug, Clone, Deserialize, Serialize)]
444pub struct AnthropicConfig {
445    /// Native Claude speed (standard or fast). Fast uses premium pricing; unsupported routes keep this preference dormant.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub speed: Option<super::AnthropicSpeed>,
448
449    /// Enable adaptive or extended thinking for Anthropic models
450    /// When enabled, Claude uses internal reasoning before responding, providing
451    /// enhanced reasoning capabilities for complex tasks.
452    /// Only supported by Claude 4, Claude 4.5, and Claude 3.7 Sonnet models.
453    /// Claude Opus 4.7 uses adaptive thinking instead of budgeted extended thinking.
454    /// Note: Extended thinking is now auto-enabled by default (31,999 tokens).
455    /// Set MAX_THINKING_TOKENS=63999 environment variable for 2x budget on 64K models.
456    /// See: <https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking>
457    #[serde(default = "default_extended_thinking_enabled")]
458    pub extended_thinking_enabled: bool,
459
460    /// Beta header for interleaved thinking feature
461    #[serde(default = "default_interleaved_thinking_beta")]
462    pub interleaved_thinking_beta: String,
463
464    /// Budget tokens for extended thinking (minimum: 1024, default: 31999)
465    /// On 64K output models (Opus 4.5, Sonnet 4.5, Haiku 4.5): default 31,999, max 63,999
466    /// On 32K output models (Opus 4): max 31,999
467    /// Claude Opus 4.7 ignores this setting and uses adaptive thinking instead.
468    /// Use MAX_THINKING_TOKENS environment variable to override.
469    #[serde(default = "default_interleaved_thinking_budget_tokens")]
470    pub interleaved_thinking_budget_tokens: u32,
471
472    /// Type value for enabling interleaved thinking
473    #[serde(default = "default_interleaved_thinking_type")]
474    pub interleaved_thinking_type_enabled: String,
475
476    /// Tool search configuration for dynamic tool discovery (advanced-tool-use beta)
477    #[serde(default)]
478    pub tool_search: ToolSearchConfig,
479
480    /// Native Anthropic memory tool configuration.
481    #[serde(default)]
482    pub memory: AnthropicMemoryConfig,
483
484    /// Effort level for adaptive thinking/token usage (low, medium, high, xhigh, max)
485    /// Controls how many tokens Claude uses when responding, trading off between
486    /// response thoroughness and token efficiency.
487    /// Unset by default: each model then uses its own default effort (for example
488    /// `medium` on Claude Opus 5.5, `high` on Claude Opus 5). An explicit
489    /// `agent.reasoning_effort` or `/effort` selection takes precedence; a value the
490    /// active model does not support falls back to that model's default.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub effort: Option<ReasoningEffortLevel>,
493
494    /// Optional Anthropic task budget token total for Claude Fable 5/5.1, Opus 5,
495    /// and Opus 5.5 (not sent for other models).
496    /// When set, VT Code sends `output_config.task_budget = { type = "tokens", total = N }`
497    /// and the required beta header.
498    /// Anthropic currently requires a minimum of 20,000 tokens.
499    #[serde(default)]
500    pub task_budget_tokens: Option<u32>,
501
502    /// Beta header for Anthropic task budgets.
503    #[serde(default = "default_task_budget_beta")]
504    pub task_budget_beta: String,
505
506    /// Controls how thinking content is returned in API responses.
507    ///   - "summarized": Thinking blocks contain summarized text (default on Opus 4.6 and earlier).
508    ///   - "omitted": Thinking blocks have an empty `thinking` field (default on Opus 4.7+).
509    ///   - "updates": Only the progress updates between tool calls are returned as text
510    ///     (Claude Sonnet 5.5, Claude Opus 5.5 and Claude Fable 5.x; the VT Code
511    ///     default on Claude Sonnet 5.5 and Claude Opus 5.5).
512    ///     Ignored on models without progress updates.
513    ///
514    /// When set, this overrides the model-specific default.
515    /// See: <https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#controlling-thinking-display>
516    #[serde(default)]
517    pub thinking_display: Option<ThinkingDisplayMode>,
518
519    /// Enable token counting via the count_tokens endpoint
520    /// When enabled, the agent can estimate input token counts before making API calls
521    /// Useful for proactive management of rate limits and costs
522    #[serde(default = "default_count_tokens_enabled")]
523    pub count_tokens_enabled: bool,
524
525    /// Claude Advisor server-side tool configuration. The advisor pairs a faster
526    /// executor model with a higher-intelligence advisor model for strategic
527    /// guidance mid-generation. Only honored for Anthropic models and providers.
528    #[serde(default)]
529    pub advisor: AdvisorConfig,
530
531    /// Server-side refusal fallbacks (`fallbacks` request parameter).
532    ///   - "default": let Anthropic pick the recommended fallback model when the
533    ///     primary model declines on policy grounds (the VT Code default).
534    ///   - "off": never send `fallbacks`.
535    ///   - a list of 1-3 `{ model, max_tokens }` entries tried in order.
536    ///
537    /// Only sent to the first-party Claude API (`api.anthropic.com`) for models
538    /// whose capability profile supports server-side fallbacks (Claude Opus 5,
539    /// Opus 5.5, Fable 5 and Fable 5.1); other models and endpoints send nothing.
540    #[serde(default)]
541    pub fallbacks: AnthropicFallbacks,
542}
543
544/// Keyword forms of [`AnthropicFallbacks`].
545#[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    /// Use Anthropic's recommended fallback chain (`fallbacks: "default"`).
550    #[default]
551    Default,
552    /// Do not request server-side fallbacks.
553    Off,
554}
555
556/// One explicit server-side fallback entry.
557#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
558#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
559pub struct AnthropicFallbackTarget {
560    /// Fallback model id, for example `claude-opus-4-8`.
561    pub model: String,
562    /// Optional `max_tokens` override for this fallback attempt.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub max_tokens: Option<u32>,
565}
566
567/// Server-side refusal fallback selection: `"default"`, `"off"`, or an explicit
568/// list of fallback models.
569#[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    /// Maximum number of explicit fallback entries the API accepts.
585    pub const MAX_MODELS: usize = 3;
586
587    /// Returns a validation message when an explicit list is empty, too long,
588    /// has a blank model, or repeats a model.
589    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    /// Validates explicit fallback entries given as `(model, max_tokens)`
600    /// pairs, applying the same rules as the list form of
601    /// `provider.anthropic.fallbacks`. Shared with request-level fallback
602    /// lists so both paths reject the same payloads.
603    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/// OpenRouter app attribution (https://openrouter.ai/docs/app-attribution).
660///
661/// Opt-in only. When `enabled` is false no attribution headers are sent.
662/// When true, VT Code sends `HTTP-Referer`, `X-OpenRouter-Title`, and
663/// `X-OpenRouter-Categories: cli-agent`.
664#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
665#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
666pub struct OpenRouterAttributionConfig {
667    /// Master opt-in switch. Default false for privacy.
668    #[serde(default)]
669    pub enabled: bool,
670
671    /// Override app URL used as `HTTP-Referer`. Defaults to the VT Code repo.
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub referer: Option<String>,
674
675    /// Override display name used as `X-OpenRouter-Title`. Defaults to `VT Code`.
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub title: Option<String>,
678}
679
680impl OpenRouterAttributionConfig {
681    /// Default referer sent when attribution is enabled and no override is set.
682    pub const DEFAULT_REFERER: &'static str = "https://github.com/vinhnx/vtcode";
683    /// Default title sent when attribution is enabled and no override is set.
684    pub const DEFAULT_TITLE: &'static str = "VT Code";
685    /// Fixed marketplace category. `cli-agent` is the allowlisted terminal-agent category.
686    pub const CATEGORIES: &'static str = "cli-agent";
687
688    /// Resolve whether attribution headers should be sent.
689    ///
690    /// The environment layer wins when set to a recognized value
691    /// (`ConfigLayerStack` env > file); unrecognized or unset values fall
692    /// through to the file value. Delegates to the canonical tri-state
693    /// parser — do not fork the accepted-value set.
694    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    /// Header-safe override check: non-empty, bounded, no CTLs (reqwest
699    /// `HeaderValue` rejects `\r`/`\n`/control bytes; fall back to defaults).
700    fn header_safe(value: &str) -> bool {
701        !value.is_empty() && value.len() <= 512 && !value.bytes().any(|b| b < 0x20 || b == 0x7F)
702    }
703
704    /// Resolve effective referer, falling back to the default. Empty or
705    /// header-unsafe overrides are ignored.
706    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    /// Resolve effective title, falling back to the default. Empty or
715    /// header-unsafe overrides are ignored.
716    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/// OpenRouter-specific provider configuration.
726#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
727#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
728pub struct OpenRouterConfig {
729    /// App attribution opt-in (`[provider.openrouter.attribution]`).
730    #[serde(default)]
731    pub attribution: OpenRouterAttributionConfig,
732}
733
734#[inline]
735fn default_count_tokens_enabled() -> bool {
736    false
737}
738
739/// Tool search algorithm for Anthropic's advanced-tool-use beta
740#[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    /// Regex-based search using Python re.search() syntax
745    #[default]
746    Regex,
747    /// BM25-based natural language search
748    Bm25,
749    /// Forward-compatible catch-all for unknown algorithm values
750    #[serde(other)]
751    Unknown,
752}
753
754impl ToolSearchAlgorithm {
755    /// Returns the string representation of this algorithm variant.
756    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/// Configuration for Anthropic's tool search feature (advanced-tool-use beta)
772/// Enables dynamic tool discovery for large tool catalogs (up to 10k tools)
773#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
774#[derive(Debug, Clone, Deserialize, Serialize)]
775pub struct ToolSearchConfig {
776    /// Enable tool search feature (requires advanced-tool-use-2025-11-20 beta)
777    #[serde(default = "default_tool_search_enabled")]
778    pub enabled: bool,
779
780    /// Search algorithm: "regex" (Python regex patterns) or "bm25" (natural language)
781    #[serde(default = "default_tool_search_algorithm")]
782    pub algorithm: ToolSearchAlgorithm,
783
784    /// Automatically defer loading of all tools except core tools
785    #[serde(default = "default_defer_by_default")]
786    pub defer_by_default: bool,
787
788    /// Maximum number of tool search results to return
789    #[serde(default = "default_max_results")]
790    max_results: u32,
791
792    /// Tool names that should never be deferred (always available)
793    #[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    /// `OpenAIServiceTier` keeps its `as_str()`/`parse()`/serde paths in
876    /// lockstep; see [`crate::test_support::assert_string_enum_lockstep`].
877    #[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    /// Stash `VTCODE_OPENROUTER_ATTRIBUTION` for hermetic attribution tests
1305    /// (serialized via `serial` + the process-wide env lock; restored after).
1306    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        // Blank overrides fall back to defaults rather than sending empty headers.
1353        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        // Asymmetric both directions: env enables a disabled file, and env
1367        // disables an enabled file (env > file precedence).
1368        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        // Unrecognized or blank env falls through to the file value.
1376        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        // CRLF/CTL injection attempts fall back to defaults instead of
1385        // reaching reqwest header construction.
1386        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}