Skip to main content

vv_agent/
config.rs

1use std::fs;
2use std::path::Path;
3
4use serde_json::Value;
5use thiserror::Error;
6
7use crate::types::AgentTask;
8
9mod api_keys;
10mod model_resolution;
11mod settings_literal;
12
13pub use api_keys::decode_api_key;
14pub use model_resolution::{
15    build_vv_llm_from_local_settings, build_vv_llm_settings, resolve_model_endpoint,
16};
17
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19pub struct EndpointConfig {
20    pub endpoint_id: String,
21    pub api_key: String,
22    pub api_base: String,
23    pub endpoint_type: String,
24}
25
26impl EndpointConfig {
27    pub fn new(
28        endpoint_id: impl Into<String>,
29        api_key: impl Into<String>,
30        api_base: impl Into<String>,
31    ) -> Self {
32        Self {
33            endpoint_id: endpoint_id.into(),
34            api_key: api_key.into(),
35            api_base: api_base.into(),
36            endpoint_type: "default".to_string(),
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct EndpointOption {
43    pub endpoint: EndpointConfig,
44    pub model_id: String,
45}
46
47impl EndpointOption {
48    pub fn new(endpoint: EndpointConfig, model_id: impl Into<String>) -> Self {
49        Self {
50            endpoint,
51            model_id: model_id.into(),
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57pub struct ResolvedModelConfig {
58    pub backend: String,
59    pub requested_model: String,
60    pub selected_model: String,
61    pub model_id: String,
62    pub context_length: Option<u64>,
63    pub max_output_tokens: Option<u64>,
64    #[serde(default)]
65    pub function_call_available: bool,
66    #[serde(default)]
67    pub response_format_available: bool,
68    #[serde(default)]
69    pub native_multimodal: bool,
70    pub endpoint_options: Vec<EndpointOption>,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct MemorySummaryDefaults {
75    pub backend: Option<String>,
76    pub model: Option<String>,
77}
78
79impl ResolvedModelConfig {
80    pub fn new(
81        backend: impl Into<String>,
82        requested_model: impl Into<String>,
83        selected_model: impl Into<String>,
84        model_id: impl Into<String>,
85        endpoint_options: Vec<EndpointOption>,
86    ) -> Self {
87        Self {
88            backend: backend.into(),
89            requested_model: requested_model.into(),
90            selected_model: selected_model.into(),
91            model_id: model_id.into(),
92            context_length: None,
93            max_output_tokens: None,
94            function_call_available: false,
95            response_format_available: false,
96            native_multimodal: false,
97            endpoint_options,
98        }
99    }
100
101    pub fn with_token_limits(
102        mut self,
103        context_length: Option<u64>,
104        max_output_tokens: Option<u64>,
105    ) -> Self {
106        self.context_length = context_length;
107        self.max_output_tokens = max_output_tokens;
108        self
109    }
110
111    pub fn with_capabilities(
112        mut self,
113        function_call_available: bool,
114        response_format_available: bool,
115        native_multimodal: bool,
116    ) -> Self {
117        self.function_call_available = function_call_available;
118        self.response_format_available = response_format_available;
119        self.native_multimodal = native_multimodal;
120        self
121    }
122
123    pub fn endpoint(&self) -> Option<&EndpointConfig> {
124        self.endpoint_options.first().map(|option| &option.endpoint)
125    }
126}
127
128pub fn apply_resolved_model_limits(task: &mut AgentTask, resolved: &ResolvedModelConfig) {
129    task.native_multimodal = resolved.native_multimodal;
130    if let Some(context_length) = resolved.context_length {
131        task.metadata
132            .entry("model_context_window".to_string())
133            .or_insert_with(|| Value::from(context_length));
134    }
135    if let Some(max_output_tokens) = resolved.max_output_tokens {
136        task.metadata
137            .entry("reserved_output_tokens".to_string())
138            .or_insert_with(|| Value::from(max_output_tokens));
139    }
140    task.metadata
141        .entry("function_call_available".to_string())
142        .or_insert_with(|| Value::Bool(resolved.function_call_available));
143    task.metadata
144        .entry("response_format_available".to_string())
145        .or_insert_with(|| Value::Bool(resolved.response_format_available));
146    task.metadata
147        .entry("native_multimodal".to_string())
148        .or_insert_with(|| Value::Bool(resolved.native_multimodal));
149}
150
151pub fn load_memory_summary_defaults_from_file(path: &Path) -> MemorySummaryDefaults {
152    let Ok(source) = fs::read_to_string(path) else {
153        return MemorySummaryDefaults::default();
154    };
155    MemorySummaryDefaults {
156        backend: parse_string_setting(
157            &source,
158            &[
159                "DEFAULT_USER_MEMORY_SUMMARIZE_BACKEND",
160                "DEFAULT_MEMORY_SUMMARIZE_BACKEND",
161                "VV_AGENT_MEMORY_SUMMARY_BACKEND",
162                "memory_summary_backend",
163                "compress_memory_summary_backend",
164            ],
165        ),
166        model: parse_string_setting(
167            &source,
168            &[
169                "DEFAULT_USER_MEMORY_SUMMARIZE_MODEL",
170                "DEFAULT_MEMORY_SUMMARIZE_MODEL",
171                "VV_AGENT_MEMORY_SUMMARY_MODEL",
172                "memory_summary_model",
173                "compress_memory_summary_model",
174            ],
175        ),
176    }
177}
178
179fn parse_string_setting(source: &str, targets: &[&str]) -> Option<String> {
180    parse_json_string_setting(source, targets)
181        .or_else(|| settings_literal::parse_string_assignment(source, targets))
182}
183
184fn parse_json_string_setting(source: &str, targets: &[&str]) -> Option<String> {
185    let value = serde_json::from_str::<Value>(source).ok()?;
186    for target in targets {
187        let Some(raw) = value.get(*target).and_then(Value::as_str) else {
188            continue;
189        };
190        let trimmed = raw.trim();
191        if !trimmed.is_empty() {
192            return Some(trimmed.to_string());
193        }
194    }
195    None
196}
197
198#[derive(Debug, Error)]
199pub enum ConfigError {
200    #[error("settings file not found: {0}")]
201    MissingSettingsFile(String),
202    #[error("failed to read settings file {path}: {source}")]
203    Io {
204        path: String,
205        #[source]
206        source: std::io::Error,
207    },
208    #[error("failed to parse settings file {path}: {source}")]
209    Parse {
210        path: String,
211        #[source]
212        source: Box<dyn std::error::Error + Send + Sync>,
213    },
214    #[error("invalid LLM settings: {0}")]
215    InvalidSettings(String),
216    #[error("unsupported chat backend: {0}")]
217    UnsupportedBackend(String),
218}
219
220pub fn load_llm_settings_from_file(path: impl AsRef<Path>) -> Result<Value, ConfigError> {
221    let path = path.as_ref();
222    if !path.exists() {
223        return Err(ConfigError::MissingSettingsFile(path.display().to_string()));
224    }
225
226    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
227        path: path.display().to_string(),
228        source,
229    })?;
230
231    match path.extension().and_then(|ext| ext.to_str()) {
232        Some("py") => settings_literal::parse_llm_settings_source(&content).map_err(|source| {
233            ConfigError::Parse {
234                path: path.display().to_string(),
235                source: Box::new(source),
236            }
237        }),
238        Some("json") => serde_json::from_str(&content).map_err(|source| ConfigError::Parse {
239            path: path.display().to_string(),
240            source: Box::new(source),
241        }),
242        Some("toml") => {
243            let value: toml::Value =
244                toml::from_str(&content).map_err(|source| ConfigError::Parse {
245                    path: path.display().to_string(),
246                    source: Box::new(source),
247                })?;
248            serde_json::to_value(value).map_err(|source| ConfigError::Parse {
249                path: path.display().to_string(),
250                source: Box::new(source),
251            })
252        }
253        _ => serde_json::from_str(&content).map_err(|source| ConfigError::Parse {
254            path: path.display().to_string(),
255            source: Box::new(source),
256        }),
257    }
258}