1use super::*;
2
3const FALKORDB_DEFAULT_PORT: u16 = 16379;
4pub(crate) const EMBEDDING_DEFAULT_MODEL: &str = "nomic-embed-text";
5pub(crate) const EMBEDDING_DEFAULT_TIMEOUT_SECONDS: u64 = 10;
6const AI_DEFAULT_MAX_CONCURRENCY: u8 = 1;
7
8pub fn decode_config_value(raw: &str) -> Option<String> {
10 match serde_json::from_str::<serde_json::Value>(raw) {
11 Ok(serde_json::Value::String(value)) => Some(value),
12 Ok(value @ (serde_json::Value::Array(_) | serde_json::Value::Object(_))) => {
13 Some(serde_json::to_string(&value).unwrap_or_else(|_| raw.to_string()))
14 }
15 Ok(serde_json::Value::Null) => None,
16 Ok(value) => Some(value.to_string()),
17 Err(_) => Some(raw.to_string()),
18 }
19}
20
21pub fn resolve_env_pattern(value: &str) -> anyhow::Result<Option<String>> {
23 if !value.contains("${") {
24 return Ok(Some(value.to_string()));
25 }
26
27 let mut output = String::with_capacity(value.len());
28 let mut rest = value;
29 let mut unresolved = false;
30
31 while let Some(start) = rest.find("${") {
32 output.push_str(&rest[..start]);
33 let pattern = &rest[start + 2..];
34 let Some(end) = pattern.find('}') else {
35 anyhow::bail!("unterminated environment pattern in `{value}`");
36 };
37
38 let expression = &pattern[..end];
39 if expression.is_empty() {
40 anyhow::bail!("empty environment pattern in `{value}`");
41 }
42
43 let (name, default) = match expression.split_once(":-") {
44 Some((name, default)) => (name, Some(default)),
45 None => (expression, None),
46 };
47 if name.is_empty() {
48 anyhow::bail!("empty environment variable name in `{value}`");
49 }
50
51 match std::env::var(name) {
52 Ok(current) if !(current.is_empty() && default.is_some()) => {
53 output.push_str(¤t);
54 }
55 Ok(_) | Err(std::env::VarError::NotPresent) => match default {
56 Some(default) => output.push_str(default),
57 None => unresolved = true,
58 },
59 Err(std::env::VarError::NotUnicode(_)) => {
60 anyhow::bail!("environment variable `{name}` is not valid unicode");
61 }
62 }
63
64 rest = &pattern[end + 1..];
65 }
66
67 output.push_str(rest);
68 if unresolved {
69 Ok(None)
70 } else {
71 Ok(Some(output))
72 }
73}
74
75pub trait ConfigSource {
77 fn config_value(&mut self, key: &str) -> Option<String>;
79
80 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String>;
82}
83
84pub struct EnvOnlySource;
86
87impl ConfigSource for EnvOnlySource {
88 fn config_value(&mut self, _key: &str) -> Option<String> {
89 None
90 }
91
92 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
93 if value.contains("$secret:") {
94 anyhow::bail!("secret resolution requires a datastore-backed config source");
95 }
96 resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
97 }
98}
99
100pub fn resolve_falkordb_config(source: &mut impl ConfigSource) -> Option<FalkorConfig> {
102 let host = resolve_setting(source, "GOBBY_FALKORDB_HOST", "databases.falkordb.host")?;
103 let port = resolve_port(
104 source,
105 "GOBBY_FALKORDB_PORT",
106 "databases.falkordb.port",
107 FALKORDB_DEFAULT_PORT,
108 );
109 let password = resolve_setting(
110 source,
111 "GOBBY_FALKORDB_PASSWORD",
112 "databases.falkordb.requirepass",
113 );
114
115 Some(FalkorConfig {
116 host,
117 port,
118 password,
119 })
120}
121
122pub fn resolve_qdrant_config(source: &mut impl ConfigSource) -> Option<QdrantConfig> {
124 let url = resolve_setting(source, "GOBBY_QDRANT_URL", "databases.qdrant.url");
125 url.as_ref()?;
126 let api_key = resolve_setting(source, "GOBBY_QDRANT_API_KEY", "databases.qdrant.api_key");
127
128 Some(QdrantConfig { url, api_key })
129}
130
131pub fn resolve_embedding_config(source: &mut impl ConfigSource) -> Option<EmbeddingConfig> {
133 resolve_embedding_config_resolution(source).map(|resolution| resolution.config)
134}
135
136pub fn resolve_embedding_config_resolution(
138 source: &mut impl ConfigSource,
139) -> Option<EmbeddingConfigResolution> {
140 let binding = resolve_capability_binding(source, AiCapability::Embed);
141 let config = resolve_embedding_config_from_binding(source, &binding)?;
142
143 Some(EmbeddingConfigResolution {
144 config,
145 namespace: embedding_keys::AI_NAMESPACE,
146 })
147}
148
149pub fn resolve_embedding_config_from_binding(
151 source: &mut impl ConfigSource,
152 binding: &CapabilityBinding,
153) -> Option<EmbeddingConfig> {
154 let api_base = binding
155 .api_base
156 .as_deref()
157 .map(str::trim)
158 .filter(|value| !value.is_empty())?
159 .to_string();
160 let model = binding
161 .model
162 .as_deref()
163 .map(str::trim)
164 .filter(|value| !value.is_empty())
165 .map(ToString::to_string)
166 .unwrap_or_else(|| EMBEDDING_DEFAULT_MODEL.to_string());
167 let api_key = binding
168 .api_key
169 .as_deref()
170 .map(str::trim)
171 .filter(|value| !value.is_empty())
172 .map(ToString::to_string);
173 let query_prefix = resolve_embedding_setting(source, embedding_keys::AI_QUERY_PREFIX);
174 let timeout_seconds = resolve_embedding_setting(source, embedding_keys::AI_TIMEOUT_SECONDS)
175 .and_then(|value| value.parse::<u64>().ok())
176 .unwrap_or(EMBEDDING_DEFAULT_TIMEOUT_SECONDS);
177
178 Some(EmbeddingConfig {
179 api_base,
180 model,
181 api_key,
182 query_prefix,
183 timeout_seconds,
184 })
185}
186
187fn resolve_embedding_setting(source: &mut impl ConfigSource, config_key: &str) -> Option<String> {
188 resolve_ai_config_value(source, config_key)
189}
190
191pub fn resolve_capability_routing(
193 source: &mut impl ConfigSource,
194 capability: AiCapability,
195) -> AiRouting {
196 resolve_ai_routing_value(source, capability.routing_key())
197 .or_else(|| resolve_ai_routing_value(source, ai_keys::ROUTING))
198 .unwrap_or_default()
199}
200
201pub fn resolve_capability_binding(
203 source: &mut impl ConfigSource,
204 capability: AiCapability,
205) -> CapabilityBinding {
206 match capability {
207 AiCapability::AudioTranslate => resolve_audio_translate_binding(source),
208 capability => resolve_base_capability_binding(source, capability),
209 }
210}
211
212pub fn resolve_ai_tuning(source: &mut impl ConfigSource) -> AiTuning {
214 let max_concurrency = resolve_ai_config_value(source, ai_keys::MAX_CONCURRENCY)
215 .and_then(|value| value.trim().parse::<u8>().ok())
216 .filter(|value| *value > 0)
217 .unwrap_or(AI_DEFAULT_MAX_CONCURRENCY);
218 let keep_alive = resolve_ai_config_value(source, ai_keys::KEEP_ALIVE);
219
220 AiTuning {
221 max_concurrency,
222 keep_alive,
223 }
224}
225
226fn resolve_base_capability_binding(
227 source: &mut impl ConfigSource,
228 capability: AiCapability,
229) -> CapabilityBinding {
230 CapabilityBinding {
231 routing: resolve_capability_routing(source, capability),
232 transport: resolve_ai_config_value(source, capability.transport_key()),
233 api_base: resolve_ai_config_value(source, capability.api_base_key()),
234 api_key: resolve_ai_config_value(source, capability.api_key_key()),
235 model: resolve_ai_config_value(source, capability.model_key()),
236 provider: resolve_ai_config_value(source, capability.provider_key()),
237 task: match capability {
238 AiCapability::AudioTranscribe => {
239 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSCRIBE_TASK)
240 }
241 _ => None,
242 },
243 language: match capability {
244 AiCapability::AudioTranscribe => {
245 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSCRIBE_LANGUAGE)
246 }
247 _ => None,
248 },
249 target_lang: match capability {
250 AiCapability::AudioTranslate => {
251 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TARGET_LANG)
252 }
253 _ => None,
254 },
255 }
256}
257
258fn resolve_audio_translate_binding(source: &mut impl ConfigSource) -> CapabilityBinding {
259 let routing = resolve_ai_routing_value(source, ai_keys::AUDIO_TRANSLATE_ROUTING);
260 let transport = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TRANSPORT);
261 let api_base = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_API_BASE);
262 let api_key = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_API_KEY);
263 let model = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_MODEL);
264 let provider = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_PROVIDER);
265 let target_lang = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TARGET_LANG);
266 let inherited = resolve_base_capability_binding(source, AiCapability::AudioTranscribe);
267
268 CapabilityBinding {
269 routing: routing.unwrap_or(inherited.routing),
270 transport: transport.or(inherited.transport),
271 api_base: api_base.or(inherited.api_base),
272 api_key: api_key.or(inherited.api_key),
273 model: model.or(inherited.model),
274 provider: provider.or(inherited.provider),
275 task: None,
276 language: None,
277 target_lang,
278 }
279}
280
281fn resolve_ai_routing_value(source: &mut impl ConfigSource, config_key: &str) -> Option<AiRouting> {
282 resolve_ai_config_value(source, config_key).and_then(|value| value.parse().ok())
283}
284
285fn resolve_ai_config_value(source: &mut impl ConfigSource, config_key: &str) -> Option<String> {
286 let value = source.config_value(config_key)?;
287 resolve_ai_non_empty(source, &value)
288}
289
290fn resolve_ai_non_empty(source: &mut impl ConfigSource, value: &str) -> Option<String> {
296 let trimmed = value.trim();
297 if trimmed.is_empty() {
298 return None;
299 }
300 source.resolve_value(trimmed).ok().filter(|resolved| {
301 let resolved = resolved.trim();
302 !resolved.is_empty() && !contains_unresolved_env_pattern(resolved)
303 })
304}
305
306fn contains_unresolved_env_pattern(value: &str) -> bool {
307 value.contains("${")
308}
309
310fn resolve_setting(
311 source: &mut impl ConfigSource,
312 env_key: &str,
313 config_key: &str,
314) -> Option<String> {
315 let value = env_value(env_key).or_else(|| source.config_value(config_key))?;
316 resolve_non_empty(source, &value)
317}
318
319fn resolve_port(
320 source: &mut impl ConfigSource,
321 env_key: &str,
322 config_key: &str,
323 default: u16,
324) -> u16 {
325 let Some(raw_port) = env_value(env_key).or_else(|| source.config_value(config_key)) else {
326 return default;
327 };
328 let Some(resolved) = resolve_non_empty(source, &raw_port) else {
329 return default;
330 };
331 resolved.parse::<u16>().unwrap_or(default)
332}
333
334fn resolve_non_empty(source: &mut impl ConfigSource, value: &str) -> Option<String> {
335 if value.trim().is_empty() {
336 return None;
337 }
338 source
339 .resolve_value(value)
340 .ok()
341 .filter(|resolved| !resolved.trim().is_empty())
342}
343
344fn env_value(key: &str) -> Option<String> {
345 std::env::var(key)
346 .ok()
347 .filter(|value| !value.trim().is_empty())
348}