Skip to main content

vv_agent/
config.rs

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