Skip to main content

gobby_core/config/
resolve.rs

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
8/// Decode a config_store value from its stored representation.
9pub 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
21/// Resolve `${VAR}` and `${VAR:-default}` environment variable patterns.
22pub 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(&current);
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
75/// Source for config values and interpolation.
76pub trait ConfigSource {
77    /// Read a decoded config value by key.
78    fn config_value(&mut self, key: &str) -> Option<String>;
79
80    /// Resolve interpolation patterns in a config value.
81    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String>;
82}
83
84/// Environment-only source for consumers without database access.
85pub 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
100/// Resolve FalkorDB config from env, config_store, then defaults.
101pub 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
122/// Resolve Qdrant config from env and config_store.
123pub 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
131/// Resolve embedding API config from config_store/gcore.yaml.
132pub fn resolve_embedding_config(source: &mut impl ConfigSource) -> Option<EmbeddingConfig> {
133    resolve_embedding_config_resolution(source).map(|resolution| resolution.config)
134}
135
136/// Resolve embedding API config and report which namespace supplied api_base.
137pub 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
149/// Build OpenAI-compatible embedding client config from the shared embed binding.
150pub 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
191/// Resolve a capability's desired routing from config only.
192pub 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
201/// Resolve a capability binding from config only.
202pub 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
212/// Resolve shared AI tuning from config only.
213pub 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
290/// Resolve an AI config value and reject empty or still-unexpanded placeholders.
291///
292/// AI config resolves from `config_store`/gcore.yaml, but stored values may
293/// reference secrets or `${VAR}`. Unresolved placeholders must not masquerade as
294/// usable endpoints, models, or keys.
295fn 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}