1use 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#[skip_serializing_none]
19#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
20#[serde(rename_all = "camelCase")]
21pub struct Config {
22 #[serde(skip_serializing)]
24 #[validate(length(min = 1))]
25 pub path: Option<String>,
26 #[serde(default = "default_url")]
28 #[validate(url)]
29 pub url: String,
30 #[serde(default = "default_provider_name")]
32 #[validate(length(min = 1))]
33 pub provider_name: String,
34 #[validate(length(min = 1))]
36 pub api_key: Option<String>,
37 #[serde(default = "default_max_input_tokens")]
39 #[validate(range(min = 1))]
40 pub max_input_tokens: u64,
41 #[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 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}