Skip to main content

acorn/io/sync/
vscode.rs

1//! VS Code custom endpoint synchronization configuration types
2use super::{Options, RenderedOutput, SyncTarget};
3use crate::io::{read_file, ApiResult};
4use crate::prelude::PathBuf;
5use crate::schema::agent::ModelDetails;
6use crate::util::constants::app::DEFAULT_VSCODE_CONFIG_PATH;
7use crate::util::StringConversion;
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10use color_eyre::eyre::eyre;
11use directories::BaseDirs;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14use serde_with::skip_serializing_none;
15use validator::Validate;
16
17/// Configuration for synchronizing models into VS Code.
18#[skip_serializing_none]
19#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
20#[serde(rename_all = "camelCase")]
21pub struct Config {
22    /// Path to `chatLanguageModels.json` on disk.
23    #[serde(skip_serializing)]
24    #[validate(length(min = 1))]
25    pub path: Option<String>,
26    /// Full OpenAI-compatible chat completions endpoint URL.
27    #[serde(default = "default_url")]
28    #[validate(url)]
29    pub url: String,
30    /// Human-readable provider group name.
31    #[serde(default = "default_provider_name")]
32    #[validate(length(min = 1))]
33    pub provider_name: String,
34    /// API key or VS Code input variable used by the endpoint.
35    #[validate(length(min = 1))]
36    pub api_key: Option<String>,
37    /// Default maximum input tokens advertised for each model.
38    #[serde(default = "default_max_input_tokens")]
39    #[validate(range(min = 1))]
40    pub max_input_tokens: u64,
41    /// Default maximum output tokens advertised for each model.
42    #[serde(default = "default_max_output_tokens")]
43    #[validate(range(min = 1))]
44    pub max_output_tokens: u64,
45}
46impl Default for Config {
47    fn default() -> Self {
48        Self {
49            path: None,
50            url: default_url(),
51            provider_name: default_provider_name(),
52            api_key: None,
53            max_input_tokens: default_max_input_tokens(),
54            max_output_tokens: default_max_output_tokens(),
55        }
56    }
57}
58impl SyncTarget for Config {
59    const COMMAND: &'static str = "code";
60    fn merge(self, overrides: Self) -> Self {
61        Self {
62            path: overrides.path.or(self.path),
63            url: overrides.url,
64            provider_name: overrides.provider_name,
65            api_key: overrides.api_key.or(self.api_key),
66            max_input_tokens: overrides.max_input_tokens,
67            max_output_tokens: overrides.max_output_tokens,
68        }
69    }
70    fn merge_cli_overrides(self, overrides: Self) -> Self {
71        Self {
72            path: overrides.path.or(self.path),
73            ..self
74        }
75    }
76    fn resolve_path(explicit: Option<&str>) -> ApiResult<PathBuf> {
77        explicit.map(|path| PathBuf::from(path.to_string().to_cross_platform_path())).map_or_else(
78            || {
79                BaseDirs::new()
80                    .map(|directories| directories.config_dir().join(DEFAULT_VSCODE_CONFIG_PATH))
81                    .ok_or_else(|| eyre!("Failed to resolve platform configuration directory"))
82            },
83            Ok,
84        )
85    }
86    fn render(&self, options: Options<'_>) -> ApiResult<RenderedOutput> {
87        Self::resolve_path(self.path.as_deref()).and_then(|path| {
88            path.is_file()
89                .then(|| read_file(path.clone()))
90                .transpose()
91                .map(|content| content.unwrap_or_default())
92                .and_then(|before| {
93                    match before.is_empty() {
94                        | true => Ok(Value::Array(Vec::new())),
95                        | false => {
96                            serde_json::from_str(&before).map_err(|why| eyre!("Failed to parse existing VS Code language-model config: {why}"))
97                        }
98                    }
99                    .and_then(|existing| self.upsert(existing, options.models, options.prune))
100                    .and_then(|updated| {
101                        serde_json::to_string_pretty(&updated)
102                            .map(|content| format!("{content}\n"))
103                            .map_err(|why| eyre!("Failed to serialize VS Code language-model config: {why}"))
104                    })
105                    .map(|content| RenderedOutput {
106                        target: "VS Code",
107                        path,
108                        before,
109                        content,
110                    })
111                })
112        })
113    }
114}
115impl Config {
116    /// Upsert the managed custom endpoint provider while preserving unrelated providers and properties.
117    pub fn upsert(&self, existing: Value, models: &[ModelDetails], prune: bool) -> ApiResult<Value> {
118        match existing {
119            | Value::Array(providers) => Ok(providers),
120            | _ => Err(eyre!("VS Code language-model configuration must be a JSON array")),
121        }
122        .map(|mut providers| {
123            let managed_index = providers.iter().position(|provider| {
124                provider.get("vendor").and_then(Value::as_str) == Some("customendpoint")
125                    && provider.get("name").and_then(Value::as_str) == Some(self.provider_name.as_str())
126            });
127            let mut provider = managed_index
128                .and_then(|index| providers.get(index))
129                .and_then(Value::as_object)
130                .cloned()
131                .unwrap_or_default();
132            let current_ids = models
133                .iter()
134                .filter_map(|model| model.id.as_ref())
135                .map(String::as_str)
136                .collect::<Vec<_>>();
137            let retained = provider
138                .get("models")
139                .and_then(Value::as_array)
140                .into_iter()
141                .flatten()
142                .filter(|model| !prune && model.get("id").and_then(Value::as_str).is_some_and(|id| !current_ids.contains(&id)))
143                .cloned();
144            let entries = retained
145                .chain(models.iter().filter_map(|model| self.model_entry(model)))
146                .collect::<Vec<_>>();
147            provider.insert("name".to_string(), Value::String(self.provider_name.clone()));
148            provider.insert("vendor".to_string(), Value::String("customendpoint".to_string()));
149            provider.insert("apiType".to_string(), Value::String("chat-completions".to_string()));
150            provider.insert("models".to_string(), Value::Array(entries));
151            if let Some(api_key) = self.api_key.as_ref() {
152                provider.insert("apiKey".to_string(), Value::String(api_key.clone()));
153            } else if !provider.contains_key("apiKey") {
154                provider.insert("apiKey".to_string(), Value::String("none".to_string()));
155            }
156            let value = Value::Object(provider);
157            match managed_index.and_then(|index| providers.get_mut(index)) {
158                | Some(existing) => *existing = value,
159                | None => providers.push(value),
160            }
161            Value::Array(providers)
162        })
163    }
164    fn model_entry(&self, model: &ModelDetails) -> Option<Value> {
165        model.id.as_ref().map(|id| {
166            let output = model.limit.as_ref().and_then(|limit| limit.output).unwrap_or(self.max_output_tokens);
167            let input = model.limit.as_ref().map_or(self.max_input_tokens, |limit| {
168                limit.input.unwrap_or_else(|| limit.context.saturating_sub(output).max(1))
169            });
170            Value::Object(
171                [
172                    ("id".to_string(), Value::String(id.clone())),
173                    ("name".to_string(), Value::String(model.name.as_ref().unwrap_or(id).clone())),
174                    ("url".to_string(), Value::String(self.url.clone())),
175                    ("toolCalling".to_string(), Value::Bool(model.tool_call.unwrap_or(true))),
176                    ("vision".to_string(), Value::Bool(false)),
177                    ("maxInputTokens".to_string(), Value::Number(input.into())),
178                    ("maxOutputTokens".to_string(), Value::Number(output.into())),
179                ]
180                .into_iter()
181                .collect::<Map<_, _>>(),
182            )
183        })
184    }
185}
186fn default_url() -> String {
187    "http://localhost:8080/v1/chat/completions".to_string()
188}
189fn default_provider_name() -> String {
190    "Local (llama-swap)".to_string()
191}
192const fn default_max_input_tokens() -> u64 {
193    28_672
194}
195const fn default_max_output_tokens() -> u64 {
196    4_096
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_resolve_path_uses_platform_config_directory() {
205        let expected = BaseDirs::new()
206            .map(|directories| directories.config_dir().join(DEFAULT_VSCODE_CONFIG_PATH))
207            .unwrap();
208        assert_eq!(Config::resolve_path(None).unwrap(), expected);
209    }
210    #[test]
211    fn test_resolve_path_normalizes_explicit_path() {
212        let path = "custom/Code/User/chatLanguageModels.json";
213        assert_eq!(
214            Config::resolve_path(Some(path)).unwrap(),
215            PathBuf::from(path.to_string().to_cross_platform_path())
216        );
217    }
218    #[test]
219    fn test_upsert_preserves_unrelated_provider_and_prunes_managed_models() {
220        let existing = serde_json::json!([
221            {"name": "Other", "vendor": "openai", "models": []},
222            {"name": "Local (llama-swap)", "vendor": "customendpoint", "apiKey": "saved", "models": [{"id": "stale"}]}
223        ]);
224        let models = [ModelDetails::init().id("qwen").name("Qwen").build()];
225        let additive = Config::default().upsert(existing.clone(), &models, false).unwrap();
226        assert_eq!(additive.as_array().unwrap().len(), 2);
227        assert!(additive.pointer("/1/models/0/id").is_some_and(|id| id == "stale"));
228        assert_eq!(additive.pointer("/1/apiKey").and_then(Value::as_str), Some("saved"));
229        let pruned = Config::default().upsert(existing, &models, true).unwrap();
230        assert_eq!(pruned.pointer("/1/models/0/id").and_then(Value::as_str), Some("qwen"));
231        assert_eq!(pruned.pointer("/1/apiType").and_then(Value::as_str), Some("chat-completions"));
232    }
233}