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    /// Enable adaptive or extended thinking for Anthropic models
446    /// When enabled, Claude uses internal reasoning before responding, providing
447    /// enhanced reasoning capabilities for complex tasks.
448    /// Only supported by Claude 4, Claude 4.5, and Claude 3.7 Sonnet models.
449    /// Claude Opus 4.7 uses adaptive thinking instead of budgeted extended thinking.
450    /// Note: Extended thinking is now auto-enabled by default (31,999 tokens).
451    /// Set MAX_THINKING_TOKENS=63999 environment variable for 2x budget on 64K models.
452    /// See: <https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking>
453    #[serde(default = "default_extended_thinking_enabled")]
454    pub extended_thinking_enabled: bool,
455
456    /// Beta header for interleaved thinking feature
457    #[serde(default = "default_interleaved_thinking_beta")]
458    pub interleaved_thinking_beta: String,
459
460    /// Budget tokens for extended thinking (minimum: 1024, default: 31999)
461    /// On 64K output models (Opus 4.5, Sonnet 4.5, Haiku 4.5): default 31,999, max 63,999
462    /// On 32K output models (Opus 4): max 31,999
463    /// Claude Opus 4.7 ignores this setting and uses adaptive thinking instead.
464    /// Use MAX_THINKING_TOKENS environment variable to override.
465    #[serde(default = "default_interleaved_thinking_budget_tokens")]
466    pub interleaved_thinking_budget_tokens: u32,
467
468    /// Type value for enabling interleaved thinking
469    #[serde(default = "default_interleaved_thinking_type")]
470    pub interleaved_thinking_type_enabled: String,
471
472    /// Tool search configuration for dynamic tool discovery (advanced-tool-use beta)
473    #[serde(default)]
474    pub tool_search: ToolSearchConfig,
475
476    /// Native Anthropic memory tool configuration.
477    #[serde(default)]
478    pub memory: AnthropicMemoryConfig,
479
480    /// Effort level for adaptive thinking/token usage (low, medium, high, xhigh, max)
481    /// Controls how many tokens Claude uses when responding, trading off between
482    /// response thoroughness and token efficiency.
483    /// Unset by default: each model then uses its own default effort (for example
484    /// `medium` on Claude Opus 5.5, `high` on Claude Opus 5). An explicit
485    /// `agent.reasoning_effort` or `/effort` selection takes precedence; a value the
486    /// active model does not support falls back to that model's default.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub effort: Option<ReasoningEffortLevel>,
489
490    /// Optional Anthropic task budget token total for Claude Fable 5/5.1, Opus 5,
491    /// and Opus 5.5 (not sent for other models).
492    /// When set, VT Code sends `output_config.task_budget = { type = "tokens", total = N }`
493    /// and the required beta header.
494    /// Anthropic currently requires a minimum of 20,000 tokens.
495    #[serde(default)]
496    pub task_budget_tokens: Option<u32>,
497
498    /// Beta header for Anthropic task budgets.
499    #[serde(default = "default_task_budget_beta")]
500    pub task_budget_beta: String,
501
502    /// Controls how thinking content is returned in API responses.
503    ///   - "summarized": Thinking blocks contain summarized text (default on Opus 4.6 and earlier).
504    ///   - "omitted": Thinking blocks have an empty `thinking` field (default on Opus 4.7+).
505    ///   - "updates": Only the progress updates between tool calls are returned as text
506    ///     (Claude Sonnet 5.5, Claude Opus 5.5 and Claude Fable 5.x; the VT Code
507    ///     default on Claude Sonnet 5.5 and Claude Opus 5.5).
508    ///     Ignored on models without progress updates.
509    ///
510    /// When set, this overrides the model-specific default.
511    /// See: <https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#controlling-thinking-display>
512    #[serde(default)]
513    pub thinking_display: Option<ThinkingDisplayMode>,
514
515    /// Enable token counting via the count_tokens endpoint
516    /// When enabled, the agent can estimate input token counts before making API calls
517    /// Useful for proactive management of rate limits and costs
518    #[serde(default = "default_count_tokens_enabled")]
519    pub count_tokens_enabled: bool,
520
521    /// Claude Advisor server-side tool configuration. The advisor pairs a faster
522    /// executor model with a higher-intelligence advisor model for strategic
523    /// guidance mid-generation. Only honored for Anthropic models and providers.
524    #[serde(default)]
525    pub advisor: AdvisorConfig,
526
527    /// Server-side refusal fallbacks (`fallbacks` request parameter).
528    ///   - "default": let Anthropic pick the recommended fallback model when the
529    ///     primary model declines on policy grounds (the VT Code default).
530    ///   - "off": never send `fallbacks`.
531    ///   - a list of 1-3 `{ model, max_tokens }` entries tried in order.
532    ///
533    /// Only sent to the first-party Claude API (`api.anthropic.com`) for models
534    /// whose capability profile supports server-side fallbacks (Claude Opus 5,
535    /// Opus 5.5, Fable 5 and Fable 5.1); other models and endpoints send nothing.
536    #[serde(default)]
537    pub fallbacks: AnthropicFallbacks,
538}
539
540/// Keyword forms of [`AnthropicFallbacks`].
541#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
542#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
543#[serde(rename_all = "lowercase")]
544pub enum AnthropicFallbackMode {
545    /// Use Anthropic's recommended fallback chain (`fallbacks: "default"`).
546    #[default]
547    Default,
548    /// Do not request server-side fallbacks.
549    Off,
550}
551
552/// One explicit server-side fallback entry.
553#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
554#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
555pub struct AnthropicFallbackTarget {
556    /// Fallback model id, for example `claude-opus-4-8`.
557    pub model: String,
558    /// Optional `max_tokens` override for this fallback attempt.
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub max_tokens: Option<u32>,
561}
562
563/// Server-side refusal fallback selection: `"default"`, `"off"`, or an explicit
564/// list of fallback models.
565#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
566#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
567#[serde(untagged)]
568pub enum AnthropicFallbacks {
569    Mode(AnthropicFallbackMode),
570    Models(Vec<AnthropicFallbackTarget>),
571}
572
573impl Default for AnthropicFallbacks {
574    fn default() -> Self {
575        Self::Mode(AnthropicFallbackMode::Default)
576    }
577}
578
579impl AnthropicFallbacks {
580    /// Maximum number of explicit fallback entries the API accepts.
581    pub const MAX_MODELS: usize = 3;
582
583    /// Returns a validation message when an explicit list is empty, too long,
584    /// has a blank model, or repeats a model.
585    pub fn validation_error(&self, field_path: &str) -> Option<String> {
586        let Self::Models(models) = self else {
587            return None;
588        };
589        Self::entries_validation_error(
590            field_path,
591            models.iter().map(|target| (target.model.as_str(), target.max_tokens)),
592        )
593    }
594
595    /// Validates explicit fallback entries given as `(model, max_tokens)`
596    /// pairs, applying the same rules as the list form of
597    /// `provider.anthropic.fallbacks`. Shared with request-level fallback
598    /// lists so both paths reject the same payloads.
599    pub fn entries_validation_error<'a>(
600        field_path: &str,
601        entries: impl ExactSizeIterator<Item = (&'a str, Option<u32>)>,
602    ) -> Option<String> {
603        let len = entries.len();
604        if len == 0 || len > Self::MAX_MODELS {
605            return Some(format!(
606                "`{field_path}` must list between 1 and {} fallback models, or be \"default\" or \"off\".",
607                Self::MAX_MODELS
608            ));
609        }
610        let mut seen = std::collections::HashSet::with_capacity(len);
611        for (model, max_tokens) in entries {
612            let model = model.trim();
613            if model.is_empty() {
614                return Some(format!("`{field_path}` entries must set a non-empty `model`."));
615            }
616            if !seen.insert(model) {
617                return Some(format!("`{field_path}` lists `{model}` more than once; entries must be distinct."));
618            }
619            if max_tokens == Some(0) {
620                return Some(format!("`{field_path}` entry `{model}` must use a positive `max_tokens`."));
621            }
622        }
623        None
624    }
625}
626
627impl Default for AnthropicConfig {
628    fn default() -> Self {
629        Self {
630            extended_thinking_enabled: default_extended_thinking_enabled(),
631            interleaved_thinking_beta: default_interleaved_thinking_beta(),
632            interleaved_thinking_budget_tokens: default_interleaved_thinking_budget_tokens(),
633            interleaved_thinking_type_enabled: default_interleaved_thinking_type(),
634            tool_search: ToolSearchConfig::default(),
635            memory: AnthropicMemoryConfig::default(),
636            effort: None,
637            task_budget_tokens: None,
638            task_budget_beta: default_task_budget_beta(),
639            thinking_display: None,
640            count_tokens_enabled: default_count_tokens_enabled(),
641            advisor: AdvisorConfig::default(),
642            fallbacks: AnthropicFallbacks::default(),
643        }
644    }
645}
646
647#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
648#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
649pub struct AnthropicMemoryConfig {
650    #[serde(default)]
651    pub enabled: bool,
652}
653
654#[inline]
655fn default_count_tokens_enabled() -> bool {
656    false
657}
658
659/// Tool search algorithm for Anthropic's advanced-tool-use beta
660#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
661#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
662#[serde(rename_all = "lowercase")]
663pub enum ToolSearchAlgorithm {
664    /// Regex-based search using Python re.search() syntax
665    #[default]
666    Regex,
667    /// BM25-based natural language search
668    Bm25,
669    /// Forward-compatible catch-all for unknown algorithm values
670    #[serde(other)]
671    Unknown,
672}
673
674impl ToolSearchAlgorithm {
675    /// Returns the string representation of this algorithm variant.
676    fn as_str(&self) -> &str {
677        match self {
678            Self::Regex => "regex",
679            Self::Bm25 => "bm25",
680            Self::Unknown => "unknown",
681        }
682    }
683}
684
685impl std::fmt::Display for ToolSearchAlgorithm {
686    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
687        f.write_str(self.as_str())
688    }
689}
690
691/// Configuration for Anthropic's tool search feature (advanced-tool-use beta)
692/// Enables dynamic tool discovery for large tool catalogs (up to 10k tools)
693#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
694#[derive(Debug, Clone, Deserialize, Serialize)]
695pub struct ToolSearchConfig {
696    /// Enable tool search feature (requires advanced-tool-use-2025-11-20 beta)
697    #[serde(default = "default_tool_search_enabled")]
698    pub enabled: bool,
699
700    /// Search algorithm: "regex" (Python regex patterns) or "bm25" (natural language)
701    #[serde(default = "default_tool_search_algorithm")]
702    pub algorithm: ToolSearchAlgorithm,
703
704    /// Automatically defer loading of all tools except core tools
705    #[serde(default = "default_defer_by_default")]
706    pub defer_by_default: bool,
707
708    /// Maximum number of tool search results to return
709    #[serde(default = "default_max_results")]
710    max_results: u32,
711
712    /// Tool names that should never be deferred (always available)
713    #[serde(default)]
714    pub always_available_tools: Vec<String>,
715}
716
717impl Default for ToolSearchConfig {
718    fn default() -> Self {
719        Self {
720            enabled: default_tool_search_enabled(),
721            algorithm: default_tool_search_algorithm(),
722            defer_by_default: default_defer_by_default(),
723            max_results: default_max_results(),
724            always_available_tools: vec![],
725        }
726    }
727}
728
729#[inline]
730fn default_tool_search_enabled() -> bool {
731    true
732}
733
734#[inline]
735fn default_tool_search_algorithm() -> ToolSearchAlgorithm {
736    ToolSearchAlgorithm::Regex
737}
738
739#[inline]
740fn default_defer_by_default() -> bool {
741    true
742}
743
744#[inline]
745fn default_max_results() -> u32 {
746    5
747}
748
749#[inline]
750fn default_extended_thinking_enabled() -> bool {
751    true
752}
753
754#[inline]
755fn default_interleaved_thinking_beta() -> String {
756    "interleaved-thinking-2025-05-14".to_string()
757}
758
759#[inline]
760fn default_interleaved_thinking_budget_tokens() -> u32 {
761    31999
762}
763
764#[inline]
765fn default_interleaved_thinking_type() -> String {
766    "enabled".to_string()
767}
768
769#[inline]
770fn default_task_budget_beta() -> String {
771    "task-budgets-2026-03-13".to_string()
772}
773
774#[cfg(test)]
775mod tests {
776    use super::{
777        AnthropicConfig, AnthropicFallbackMode, AnthropicFallbackTarget, AnthropicFallbacks, OpenAIConfig,
778        OpenAIHostedShellConfig, OpenAIHostedShellDomainSecret, OpenAIHostedShellEnvironment,
779        OpenAIHostedShellNetworkPolicy, OpenAIHostedShellNetworkPolicyType, OpenAIHostedSkill,
780        OpenAIHostedSkillVersion, OpenAIManualCompactionConfig, OpenAIServiceTier, ToolSearchAlgorithm,
781    };
782
783    #[test]
784    fn anthropic_effort_is_unset_unless_configured() {
785        assert_eq!(AnthropicConfig::default().effort, None);
786
787        let parsed: AnthropicConfig = toml::from_str("").expect("empty anthropic config");
788        assert_eq!(parsed.effort, None);
789
790        let parsed: AnthropicConfig = toml::from_str("effort = \"high\"").expect("explicit effort");
791        assert_eq!(parsed.effort, Some(super::ReasoningEffortLevel::High));
792    }
793
794    /// `OpenAIServiceTier` keeps its `as_str()`/`parse()`/serde paths in
795    /// lockstep; see [`crate::test_support::assert_string_enum_lockstep`].
796    #[test]
797    fn openai_service_tier_string_paths_stay_in_lockstep() {
798        use crate::test_support::assert_string_enum_lockstep;
799
800        assert_string_enum_lockstep!(
801            OpenAIServiceTier,
802            [
803                OpenAIServiceTier::Flex,
804                OpenAIServiceTier::Priority,
805                OpenAIServiceTier::Ultrafast,
806            ]
807        );
808    }
809
810    #[test]
811    fn anthropic_fallbacks_default_to_default_mode() {
812        let config = AnthropicConfig::default();
813        assert_eq!(config.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
814        let parsed: AnthropicConfig = toml::from_str("").expect("config should parse");
815        assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
816    }
817
818    #[test]
819    fn anthropic_fallbacks_parse_all_three_forms() {
820        let parsed: AnthropicConfig = toml::from_str("fallbacks = \"default\"").expect("default form");
821        assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Default));
822
823        let parsed: AnthropicConfig = toml::from_str("fallbacks = \"off\"").expect("off form");
824        assert_eq!(parsed.fallbacks, AnthropicFallbacks::Mode(AnthropicFallbackMode::Off));
825
826        let parsed: AnthropicConfig = toml::from_str(
827            "fallbacks = [{ model = \"claude-opus-4-8\", max_tokens = 32000 }, { model = \"claude-opus-5\" }]",
828        )
829        .expect("list form");
830        assert_eq!(
831            parsed.fallbacks,
832            AnthropicFallbacks::Models(vec![
833                AnthropicFallbackTarget {
834                    model: "claude-opus-4-8".to_string(),
835                    max_tokens: Some(32_000),
836                },
837                AnthropicFallbackTarget {
838                    model: "claude-opus-5".to_string(),
839                    max_tokens: None,
840                },
841            ])
842        );
843        assert_eq!(parsed.fallbacks.validation_error("fallbacks"), None);
844
845        assert!(toml::from_str::<AnthropicConfig>("fallbacks = \"sometimes\"").is_err());
846    }
847
848    #[test]
849    fn anthropic_fallbacks_round_trip_through_serde() {
850        for fallbacks in [
851            AnthropicFallbacks::Mode(AnthropicFallbackMode::Default),
852            AnthropicFallbacks::Mode(AnthropicFallbackMode::Off),
853            AnthropicFallbacks::Models(vec![AnthropicFallbackTarget {
854                model: "claude-opus-4-8".to_string(),
855                max_tokens: None,
856            }]),
857        ] {
858            let json = serde_json::to_value(&fallbacks).expect("serialize");
859            let back: AnthropicFallbacks = serde_json::from_value(json).expect("deserialize");
860            assert_eq!(back, fallbacks);
861        }
862        assert_eq!(
863            serde_json::to_value(AnthropicFallbacks::default()).expect("serialize"),
864            serde_json::json!("default")
865        );
866    }
867
868    #[test]
869    fn anthropic_fallback_list_validation_rejects_bad_lists() {
870        let target = |model: &str| AnthropicFallbackTarget { model: model.to_string(), max_tokens: None };
871        for bad in [
872            AnthropicFallbacks::Models(vec![]),
873            AnthropicFallbacks::Models(vec![target("a"), target("b"), target("c"), target("d")]),
874            AnthropicFallbacks::Models(vec![target("  ")]),
875            AnthropicFallbacks::Models(vec![target("a"), target(" a ")]),
876            AnthropicFallbacks::Models(vec![AnthropicFallbackTarget { model: "a".to_string(), max_tokens: Some(0) }]),
877        ] {
878            assert!(bad.validation_error("fallbacks").is_some(), "{bad:?}");
879        }
880    }
881
882    #[test]
883    fn openai_config_defaults_to_websocket_mode_disabled() {
884        let config = OpenAIConfig::default();
885        assert!(!config.websocket_mode);
886        assert_eq!(config.responses_store, None);
887        assert!(config.responses_include.is_empty());
888        assert_eq!(config.service_tier, None);
889        assert_eq!(config.manual_compaction, OpenAIManualCompactionConfig::default());
890        assert_eq!(config.hosted_shell, OpenAIHostedShellConfig::default());
891        assert!(config.tool_search.enabled);
892        assert!(config.tool_search.defer_by_default);
893        assert!(config.tool_search.always_available_tools.is_empty());
894    }
895
896    #[test]
897    fn anthropic_config_defaults_native_memory_to_disabled() {
898        let config = AnthropicConfig::default();
899        assert!(!config.memory.enabled);
900    }
901
902    #[test]
903    fn anthropic_config_parses_native_memory_opt_in() {
904        let parsed: AnthropicConfig = toml::from_str("[memory]\nenabled = true").expect("config should parse");
905        assert!(parsed.memory.enabled);
906    }
907
908    #[test]
909    fn openai_config_parses_websocket_mode_opt_in() {
910        let parsed: OpenAIConfig = toml::from_str("websocket_mode = true").expect("config should parse");
911        assert!(parsed.websocket_mode);
912        assert_eq!(parsed.responses_store, None);
913        assert!(parsed.responses_include.is_empty());
914        assert_eq!(parsed.service_tier, None);
915        assert_eq!(parsed.manual_compaction, OpenAIManualCompactionConfig::default());
916        assert_eq!(parsed.hosted_shell, OpenAIHostedShellConfig::default());
917        assert_eq!(parsed.tool_search, super::OpenAIToolSearchConfig::default());
918    }
919
920    #[test]
921    fn openai_config_parses_responses_options() {
922        let parsed: OpenAIConfig = toml::from_str(
923            r#"
924responses_store = false
925responses_include = ["reasoning.encrypted_content", "output_text.annotations"]
926"#,
927        )
928        .expect("config should parse");
929        assert_eq!(parsed.responses_store, Some(false));
930        assert_eq!(
931            parsed.responses_include,
932            vec![
933                "reasoning.encrypted_content".to_string(),
934                "output_text.annotations".to_string()
935            ]
936        );
937        assert_eq!(parsed.service_tier, None);
938        assert_eq!(parsed.manual_compaction, OpenAIManualCompactionConfig::default());
939        assert_eq!(parsed.hosted_shell, OpenAIHostedShellConfig::default());
940    }
941
942    #[test]
943    fn openai_config_parses_manual_compaction_defaults() {
944        let parsed: OpenAIConfig = toml::from_str(
945            r#"
946[manual_compaction]
947instructions = "Preserve the bug reproduction steps."
948"#,
949        )
950        .expect("config should parse");
951
952        assert_eq!(parsed.manual_compaction.instructions.as_deref(), Some("Preserve the bug reproduction steps."));
953    }
954
955    #[test]
956    fn openai_config_parses_service_tier() {
957        let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "priority""#).expect("config should parse");
958        assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Priority));
959    }
960
961    #[test]
962    fn openai_config_parses_flex_service_tier() {
963        let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "flex""#).expect("config should parse");
964        assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Flex));
965    }
966
967    #[test]
968    fn openai_config_parses_ultrafast_service_tier() {
969        let parsed: OpenAIConfig = toml::from_str(r#"service_tier = "ultrafast""#).expect("config should parse");
970        assert_eq!(parsed.service_tier, Some(OpenAIServiceTier::Ultrafast));
971        assert_eq!(OpenAIServiceTier::Ultrafast.as_str(), "ultrafast");
972        assert_eq!(OpenAIServiceTier::parse("ULTRAFAST"), Some(OpenAIServiceTier::Ultrafast));
973    }
974
975    #[test]
976    fn openai_config_parses_hosted_shell() {
977        let parsed: OpenAIConfig = toml::from_str(
978            r#"
979[hosted_shell]
980enabled = true
981environment = "container_auto"
982file_ids = ["file_123"]
983
984[[hosted_shell.skills]]
985type = "skill_reference"
986skill_id = "skill_123"
987"#,
988        )
989        .expect("config should parse");
990
991        assert!(parsed.hosted_shell.enabled);
992        assert_eq!(parsed.hosted_shell.environment, OpenAIHostedShellEnvironment::ContainerAuto);
993        assert_eq!(parsed.hosted_shell.file_ids, vec!["file_123".to_string()]);
994        assert_eq!(
995            parsed.hosted_shell.skills,
996            vec![OpenAIHostedSkill::SkillReference {
997                skill_id: "skill_123".to_string(),
998                version: OpenAIHostedSkillVersion::default(),
999            }]
1000        );
1001    }
1002
1003    #[test]
1004    fn openai_config_parses_hosted_shell_pinned_version_and_inline_bundle() {
1005        let parsed: OpenAIConfig = toml::from_str(
1006            r#"
1007[hosted_shell]
1008enabled = true
1009
1010[[hosted_shell.skills]]
1011type = "skill_reference"
1012skill_id = "skill_123"
1013version = 2
1014
1015[[hosted_shell.skills]]
1016type = "inline"
1017bundle_b64 = "UEsFBgAAAAAAAA=="
1018sha256 = "deadbeef"
1019"#,
1020        )
1021        .expect("config should parse");
1022
1023        assert_eq!(
1024            parsed.hosted_shell.skills,
1025            vec![
1026                OpenAIHostedSkill::SkillReference {
1027                    skill_id: "skill_123".to_string(),
1028                    version: OpenAIHostedSkillVersion::Number(2),
1029                },
1030                OpenAIHostedSkill::Inline {
1031                    bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
1032                    sha256: Some("deadbeef".to_string()),
1033                },
1034            ]
1035        );
1036    }
1037
1038    #[test]
1039    fn openai_config_parses_hosted_shell_network_policy() {
1040        let parsed: OpenAIConfig = toml::from_str(
1041            r#"
1042[hosted_shell]
1043enabled = true
1044
1045[hosted_shell.network_policy]
1046type = "allowlist"
1047allowed_domains = ["httpbin.org"]
1048
1049[[hosted_shell.network_policy.domain_secrets]]
1050domain = "httpbin.org"
1051name = "API_KEY"
1052value = "debug-secret-123"
1053"#,
1054        )
1055        .expect("config should parse");
1056
1057        assert_eq!(
1058            parsed.hosted_shell.network_policy,
1059            OpenAIHostedShellNetworkPolicy {
1060                policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1061                allowed_domains: vec!["httpbin.org".to_string()],
1062                domain_secrets: vec![OpenAIHostedShellDomainSecret {
1063                    domain: "httpbin.org".to_string(),
1064                    name: "API_KEY".to_string(),
1065                    value: "debug-secret-123".to_string(),
1066                }],
1067            }
1068        );
1069    }
1070
1071    #[test]
1072    fn openai_config_parses_tool_search() {
1073        let parsed: OpenAIConfig = toml::from_str(
1074            r#"
1075[tool_search]
1076enabled = false
1077defer_by_default = false
1078always_available_tools = ["code_search", "custom_tool"]
1079"#,
1080        )
1081        .expect("config should parse");
1082
1083        assert!(!parsed.tool_search.enabled);
1084        assert!(!parsed.tool_search.defer_by_default);
1085        assert_eq!(
1086            parsed.tool_search.always_available_tools,
1087            vec!["code_search".to_string(), "custom_tool".to_string()]
1088        );
1089    }
1090
1091    #[test]
1092    fn anthropic_tool_search_defaults_to_enabled() {
1093        let config = AnthropicConfig::default();
1094
1095        assert!(config.tool_search.enabled);
1096        assert!(config.tool_search.defer_by_default);
1097        assert_eq!(config.tool_search.algorithm, ToolSearchAlgorithm::Regex);
1098        assert!(config.tool_search.always_available_tools.is_empty());
1099    }
1100
1101    #[test]
1102    fn hosted_shell_container_reference_requires_non_empty_container_id() {
1103        let config = OpenAIHostedShellConfig {
1104            enabled: true,
1105            environment: OpenAIHostedShellEnvironment::ContainerReference,
1106            container_id: Some("   ".to_string()),
1107            file_ids: Vec::new(),
1108            skills: Vec::new(),
1109            network_policy: OpenAIHostedShellNetworkPolicy::default(),
1110        };
1111
1112        assert!(!config.has_valid_reference_target());
1113        assert!(config.container_id_ref().is_none());
1114    }
1115
1116    #[test]
1117    fn hosted_shell_reports_invalid_skill_reference_mounts() {
1118        let config = OpenAIHostedShellConfig {
1119            enabled: true,
1120            environment: OpenAIHostedShellEnvironment::ContainerAuto,
1121            container_id: None,
1122            file_ids: Vec::new(),
1123            skills: vec![OpenAIHostedSkill::SkillReference {
1124                skill_id: "   ".to_string(),
1125                version: OpenAIHostedSkillVersion::default(),
1126            }],
1127            network_policy: OpenAIHostedShellNetworkPolicy::default(),
1128        };
1129
1130        let message = config.first_invalid_skill_message().expect("invalid mount should be reported");
1131
1132        assert!(message.contains("provider.openai.hosted_shell.skills[0].skill_id"));
1133        assert!(!config.has_valid_skill_mounts());
1134        assert!(!config.is_valid_for_runtime());
1135    }
1136
1137    #[test]
1138    fn hosted_shell_ignores_skill_validation_for_container_reference() {
1139        let config = OpenAIHostedShellConfig {
1140            enabled: true,
1141            environment: OpenAIHostedShellEnvironment::ContainerReference,
1142            container_id: Some("cntr_123".to_string()),
1143            file_ids: Vec::new(),
1144            skills: vec![OpenAIHostedSkill::Inline { bundle_b64: "   ".to_string(), sha256: None }],
1145            network_policy: OpenAIHostedShellNetworkPolicy::default(),
1146        };
1147
1148        assert!(config.first_invalid_skill_message().is_none());
1149        assert!(config.has_valid_skill_mounts());
1150        assert!(config.is_valid_for_runtime());
1151    }
1152
1153    #[test]
1154    fn hosted_shell_reports_invalid_allowlist_without_domains() {
1155        let config = OpenAIHostedShellConfig {
1156            enabled: true,
1157            environment: OpenAIHostedShellEnvironment::ContainerAuto,
1158            container_id: None,
1159            file_ids: Vec::new(),
1160            skills: Vec::new(),
1161            network_policy: OpenAIHostedShellNetworkPolicy {
1162                policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1163                allowed_domains: Vec::new(),
1164                domain_secrets: Vec::new(),
1165            },
1166        };
1167
1168        let message = config
1169            .first_invalid_network_policy_message()
1170            .expect("invalid network policy should be reported");
1171
1172        assert!(message.contains("network_policy.allowed_domains"));
1173        assert!(!config.has_valid_network_policy());
1174        assert!(!config.is_valid_for_runtime());
1175    }
1176
1177    #[test]
1178    fn hosted_shell_reports_domain_secret_outside_allowlist() {
1179        let config = OpenAIHostedShellConfig {
1180            enabled: true,
1181            environment: OpenAIHostedShellEnvironment::ContainerAuto,
1182            container_id: None,
1183            file_ids: Vec::new(),
1184            skills: Vec::new(),
1185            network_policy: OpenAIHostedShellNetworkPolicy {
1186                policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1187                allowed_domains: vec!["pypi.org".to_string()],
1188                domain_secrets: vec![OpenAIHostedShellDomainSecret {
1189                    domain: "httpbin.org".to_string(),
1190                    name: "API_KEY".to_string(),
1191                    value: "secret".to_string(),
1192                }],
1193            },
1194        };
1195
1196        let message = config
1197            .first_invalid_network_policy_message()
1198            .expect("invalid domain secret should be reported");
1199
1200        assert!(message.contains("domain_secrets[0].domain"));
1201        assert!(!config.has_valid_network_policy());
1202    }
1203
1204    #[test]
1205    fn hosted_shell_ignores_network_policy_validation_for_container_reference() {
1206        let config = OpenAIHostedShellConfig {
1207            enabled: true,
1208            environment: OpenAIHostedShellEnvironment::ContainerReference,
1209            container_id: Some("cntr_123".to_string()),
1210            file_ids: Vec::new(),
1211            skills: Vec::new(),
1212            network_policy: OpenAIHostedShellNetworkPolicy {
1213                policy_type: OpenAIHostedShellNetworkPolicyType::Allowlist,
1214                allowed_domains: Vec::new(),
1215                domain_secrets: Vec::new(),
1216            },
1217        };
1218
1219        assert!(config.first_invalid_network_policy_message().is_none());
1220        assert!(config.has_valid_network_policy());
1221    }
1222}