Skip to main content

atomcode_core/config/
provider.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4pub struct ProviderConfig {
5    #[serde(rename = "type")]
6    pub provider_type: String,
7    #[serde(default, skip_serializing_if = "Option::is_none")]
8    pub api_key: Option<String>,
9    pub model: String,
10    pub base_url: Option<String>,
11    pub system_prompt: Option<String>,
12    /// Override User-Agent for this provider (useful when the upstream blocks generic UAs).
13    /// Defaults to `atomcode/<version>` if not set.
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub user_agent: Option<String>,
16    /// Maximum tokens to use for context (system prompt + messages).
17    /// The windowing algorithm fits messages within this budget,
18    /// condensing old tool results to save space.
19    /// Defaults vary by provider type; use `default_context_window_for` after deserialization.
20    #[serde(default = "default_context_window")]
21    pub context_window: usize,
22    /// Maximum tokens the model can output per response.
23    /// Larger values allow batching multiple write_file calls in one turn.
24    /// If not set, defaults to context_window / 4.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub max_tokens: Option<usize>,
27    /// Kimi K2.5 / K2.6 thinking control — emitted as `thinking.type`
28    /// in the request body. `"enabled"` | `"disabled"`. K2-thinking is
29    /// always on and ignores this. Unset = don't forward the field.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub thinking_type: Option<String>,
32    /// Kimi K2.6 Preserved Thinking — emitted as `thinking.keep` in the
33    /// request body. `"all"` to have the server reprocess historical
34    /// reasoning_content (more expensive). Unset = default behavior.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub thinking_keep: Option<String>,
37    /// Override the history-echo policy for `reasoning_content` on
38    /// historical assistant tool_call messages. `"include"` = always echo
39    /// the stored reasoning back (required by Moonshot Kimi K2 thinking,
40    /// DeepSeek V4 thinking mode); `"exclude"` = never echo (required by
41    /// DeepSeek V3 R1, safe default for plain OpenAI). Unset = use the
42    /// built-in auto-detect heuristic based on model name / base_url.
43    /// Lets users work around new provider quirks without a code change.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub reasoning_history: Option<String>,
46    /// Whether extended thinking is enabled for this provider.
47    /// For Claude: sends `thinking.type = "enabled"` in request body.
48    /// Default: not set (thinking disabled).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub thinking_enabled: Option<bool>,
51    /// Maximum tokens allocated to the thinking phase.
52    /// Claude: sent as `thinking.budget_tokens`.
53    /// Default: 10000 when thinking is enabled.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub thinking_budget: Option<u32>,
56    /// Skip TLS certificate verification for this provider.
57    /// Useful for self-signed certificates in enterprise/internal environments.
58    /// Default: false (TLS verification enabled).
59    /// WARNING: Setting this to true reduces security by accepting any certificate.
60    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
61    pub skip_tls_verify: bool,
62    /// If true, this provider was added at runtime (e.g. OAuth /login)
63    /// and should NOT be persisted to config.toml on save.
64    #[serde(skip)]
65    pub ephemeral: bool,
66}
67
68impl ProviderConfig {
69    /// True if this provider's active model can accept image inputs.
70    /// Driven entirely by the model-name heuristic in
71    /// `provider::model_name_suggests_vision` — if a future model isn't
72    /// recognised, extend the heuristic rather than threading a
73    /// per-provider config flag (no user-facing knob to discover).
74    pub fn accepts_images(&self) -> bool {
75        crate::provider::model_name_suggests_vision(&self.model)
76    }
77}
78
79fn default_context_window() -> usize {
80    128000
81}
82
83pub fn default_context_window_for(provider_type: &str) -> usize {
84    match provider_type {
85        "ollama" => 8000,
86        _ => 128000,
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn accepts_images_false_for_text_only_model() {
96        // Regression for the user's GLM-5.1 case: heuristic rejects
97        // text-only models so the TUI's Ctrl+V handler refuses image
98        // paste before sending a doomed request.
99        let toml_str = r#"
100            type = "openai"
101            model = "GLM-5.1"
102            api_key = "sk-test"
103            base_url = "https://api-ai.gitcode.com/v1"
104        "#;
105        let cfg: ProviderConfig = toml::from_str(toml_str).expect("parse");
106        assert!(!cfg.accepts_images());
107    }
108
109    #[test]
110    fn accepts_images_true_via_heuristic_on_known_vision_model() {
111        let toml_str = r#"
112            type = "claude"
113            model = "claude-sonnet-4-5"
114            api_key = "sk-test"
115        "#;
116        let cfg: ProviderConfig = toml::from_str(toml_str).expect("parse");
117        assert!(cfg.accepts_images());
118    }
119
120    #[test]
121    fn thinking_fields_default_to_none() {
122        let toml_str = r#"
123            type = "claude"
124            model = "claude-sonnet-4"
125            base_url = "https://api.anthropic.com"
126            context_window = 128000
127        "#;
128        let cfg: ProviderConfig = toml::from_str(toml_str).expect("parse");
129        assert!(cfg.thinking_enabled.is_none());
130        assert!(cfg.thinking_budget.is_none());
131    }
132
133    #[test]
134    fn thinking_fields_parse_correctly() {
135        let toml_str = r#"
136            type = "claude"
137            model = "claude-sonnet-4"
138            base_url = "https://api.anthropic.com"
139            context_window = 128000
140            thinking_enabled = true
141            thinking_budget = 20000
142        "#;
143        let cfg: ProviderConfig = toml::from_str(toml_str).expect("parse");
144        assert_eq!(cfg.thinking_enabled, Some(true));
145        assert_eq!(cfg.thinking_budget, Some(20000));
146    }
147
148    #[test]
149    fn skip_tls_verify_defaults_to_false() {
150        let toml_str = r#"
151            type = "openai"
152            model = "gpt-4o"
153            api_key = "sk-test"
154        "#;
155        let cfg: ProviderConfig = toml::from_str(toml_str).expect("parse");
156        assert!(!cfg.skip_tls_verify, "skip_tls_verify should default to false");
157    }
158
159    #[test]
160    fn skip_tls_verify_can_be_set_true() {
161        let toml_str = r#"
162            type = "openai"
163            model = "gpt-4o"
164            api_key = "sk-test"
165            base_url = "https://self-signed.example.com/v1"
166            skip_tls_verify = true
167        "#;
168        let cfg: ProviderConfig = toml::from_str(toml_str).expect("parse");
169        assert!(cfg.skip_tls_verify, "skip_tls_verify should be true");
170    }
171
172    #[test]
173    fn skip_tls_verify_not_serialized_when_false() {
174        let cfg = ProviderConfig {
175            provider_type: "openai".into(),
176            api_key: Some("sk-test".into()),
177            model: "gpt-4o".into(),
178            base_url: None,
179            system_prompt: None,
180            user_agent: None,
181            context_window: 128000,
182            max_tokens: None,
183            thinking_type: None,
184            thinking_keep: None,
185            reasoning_history: None,
186            thinking_enabled: None,
187            thinking_budget: None,
188            skip_tls_verify: false,
189            ephemeral: false,
190
191};
192        let serialized = toml::to_string(&cfg).expect("serialize");
193        assert!(
194            !serialized.contains("skip_tls_verify"),
195            "skip_tls_verify should not be serialized when false"
196        );
197    }
198
199    #[test]
200    fn skip_tls_verify_serialized_when_true() {
201        let cfg = ProviderConfig {
202            provider_type: "openai".into(),
203            api_key: Some("sk-test".into()),
204            model: "gpt-4o".into(),
205            base_url: Some("https://self-signed.example.com/v1".into()),
206            system_prompt: None,
207            user_agent: None,
208            context_window: 128000,
209            max_tokens: None,
210            thinking_type: None,
211            thinking_keep: None,
212            reasoning_history: None,
213            thinking_enabled: None,
214            thinking_budget: None,
215            skip_tls_verify: true,
216            ephemeral: false,
217
218};
219        let serialized = toml::to_string(&cfg).expect("serialize");
220        assert!(
221            serialized.contains("skip_tls_verify = true"),
222            "skip_tls_verify should be serialized when true"
223        );
224    }
225}