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}