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}