1#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct FalkorConfig {
12 pub host: String,
13 pub port: u16,
14 pub password: Option<String>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct QdrantConfig {
22 pub url: Option<String>,
23 pub api_key: Option<String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct EmbeddingConfig {
29 pub api_base: String,
30 pub model: String,
31 pub api_key: Option<String>,
32 pub query_prefix: Option<String>,
33 pub timeout_seconds: u64,
34}
35
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
38pub enum AiRouting {
39 #[default]
40 Auto,
41 Daemon,
42 Direct,
43 Off,
44}
45
46impl std::str::FromStr for AiRouting {
47 type Err = ParseAiRoutingError;
48
49 fn from_str(value: &str) -> Result<Self, Self::Err> {
50 match value.trim() {
51 "auto" => Ok(Self::Auto),
52 "daemon" => Ok(Self::Daemon),
53 "direct" => Ok(Self::Direct),
54 "off" => Ok(Self::Off),
55 value => Err(ParseAiRoutingError {
56 value: value.to_string(),
57 }),
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ParseAiRoutingError {
64 value: String,
65}
66
67impl std::fmt::Display for ParseAiRoutingError {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 write!(f, "invalid AI routing `{}`", self.value)
70 }
71}
72
73impl std::error::Error for ParseAiRoutingError {}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub enum AiCapability {
78 Embed,
79 AudioTranscribe,
80 AudioTranslate,
81 VisionExtract,
82 TextGenerate,
83}
84
85impl AiCapability {
86 pub fn as_str(self) -> &'static str {
87 match self {
88 Self::Embed => "embed",
89 Self::AudioTranscribe => "audio_transcribe",
90 Self::AudioTranslate => "audio_translate",
91 Self::VisionExtract => "vision_extract",
92 Self::TextGenerate => "text_generate",
93 }
94 }
95
96 pub fn namespace(self) -> &'static str {
97 match self {
98 Self::Embed => ai_keys::EMBEDDINGS_NAMESPACE,
99 Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_NAMESPACE,
100 Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_NAMESPACE,
101 Self::VisionExtract => ai_keys::VISION_EXTRACT_NAMESPACE,
102 Self::TextGenerate => ai_keys::TEXT_GENERATE_NAMESPACE,
103 }
104 }
105
106 fn routing_key(self) -> &'static str {
107 match self {
108 Self::Embed => ai_keys::EMBEDDINGS_ROUTING,
109 Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_ROUTING,
110 Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_ROUTING,
111 Self::VisionExtract => ai_keys::VISION_EXTRACT_ROUTING,
112 Self::TextGenerate => ai_keys::TEXT_GENERATE_ROUTING,
113 }
114 }
115
116 fn transport_key(self) -> &'static str {
117 match self {
118 Self::Embed => ai_keys::EMBEDDINGS_TRANSPORT,
119 Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_TRANSPORT,
120 Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_TRANSPORT,
121 Self::VisionExtract => ai_keys::VISION_EXTRACT_TRANSPORT,
122 Self::TextGenerate => ai_keys::TEXT_GENERATE_TRANSPORT,
123 }
124 }
125
126 fn api_base_key(self) -> &'static str {
127 match self {
128 Self::Embed => ai_keys::EMBEDDINGS_API_BASE,
129 Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_API_BASE,
130 Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_API_BASE,
131 Self::VisionExtract => ai_keys::VISION_EXTRACT_API_BASE,
132 Self::TextGenerate => ai_keys::TEXT_GENERATE_API_BASE,
133 }
134 }
135
136 fn api_key_key(self) -> &'static str {
137 match self {
138 Self::Embed => ai_keys::EMBEDDINGS_API_KEY,
139 Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_API_KEY,
140 Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_API_KEY,
141 Self::VisionExtract => ai_keys::VISION_EXTRACT_API_KEY,
142 Self::TextGenerate => ai_keys::TEXT_GENERATE_API_KEY,
143 }
144 }
145
146 fn model_key(self) -> &'static str {
147 match self {
148 Self::Embed => ai_keys::EMBEDDINGS_MODEL,
149 Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_MODEL,
150 Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_MODEL,
151 Self::VisionExtract => ai_keys::VISION_EXTRACT_MODEL,
152 Self::TextGenerate => ai_keys::TEXT_GENERATE_MODEL,
153 }
154 }
155
156 fn provider_key(self) -> &'static str {
157 match self {
158 Self::Embed => ai_keys::EMBEDDINGS_PROVIDER,
159 Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_PROVIDER,
160 Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_PROVIDER,
161 Self::VisionExtract => ai_keys::VISION_EXTRACT_PROVIDER,
162 Self::TextGenerate => ai_keys::TEXT_GENERATE_PROVIDER,
163 }
164 }
165}
166
167impl std::str::FromStr for AiCapability {
168 type Err = ParseAiCapabilityError;
169
170 fn from_str(value: &str) -> Result<Self, Self::Err> {
171 match value.trim() {
172 "embed" | "embeddings" => Ok(Self::Embed),
173 "audio_transcribe" => Ok(Self::AudioTranscribe),
174 "audio_translate" => Ok(Self::AudioTranslate),
175 "vision_extract" => Ok(Self::VisionExtract),
176 "text_generate" => Ok(Self::TextGenerate),
177 value => Err(ParseAiCapabilityError {
178 value: value.to_string(),
179 }),
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct ParseAiCapabilityError {
186 value: String,
187}
188
189impl std::fmt::Display for ParseAiCapabilityError {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 write!(f, "invalid AI capability `{}`", self.value)
192 }
193}
194
195impl std::error::Error for ParseAiCapabilityError {}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct CapabilityBinding {
200 pub routing: AiRouting,
201 pub transport: Option<String>,
202 pub api_base: Option<String>,
203 pub api_key: Option<String>,
204 pub model: Option<String>,
205 pub provider: Option<String>,
206 pub task: Option<String>,
207 pub language: Option<String>,
208 pub target_lang: Option<String>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct AiTuning {
214 pub max_concurrency: u8,
215 pub keep_alive: Option<String>,
216}
217
218pub mod embedding_keys {
220 pub const AI_NAMESPACE: &str = "ai.embeddings";
221
222 pub const AI_PROVIDER: &str = "ai.embeddings.provider";
223 pub const AI_API_BASE: &str = "ai.embeddings.api_base";
224 pub const AI_MODEL: &str = "ai.embeddings.model";
225 pub const AI_API_KEY: &str = "ai.embeddings.api_key";
226 pub const AI_QUERY_PREFIX: &str = "ai.embeddings.query_prefix";
227 pub const AI_DIM: &str = "ai.embeddings.dim";
228 pub const AI_TIMEOUT_SECONDS: &str = "ai.embeddings.timeout_seconds";
229
230 const LEGACY_NAMESPACE: &str = "embeddings";
231 const LEGACY_KEY_SUFFIXES: &[&str] = &[
232 "provider",
233 "api_base",
234 "model",
235 "api_key",
236 "api_key_env",
237 "query_prefix",
238 "timeout_seconds",
239 "vector_dim",
240 ];
241
242 pub fn legacy_keys() -> Vec<String> {
243 LEGACY_KEY_SUFFIXES
244 .iter()
245 .map(|suffix| format!("{LEGACY_NAMESPACE}.{suffix}"))
246 .collect()
247 }
248}
249
250pub mod ai_keys {
252 pub const ROUTING: &str = "ai.routing";
253 pub const MAX_CONCURRENCY: &str = "ai.max_concurrency";
254 pub const KEEP_ALIVE: &str = "ai.keep_alive";
255
256 pub const EMBEDDINGS_NAMESPACE: &str = super::embedding_keys::AI_NAMESPACE;
257 pub const EMBEDDINGS_ROUTING: &str = "ai.embeddings.routing";
258 pub const EMBEDDINGS_TRANSPORT: &str = "ai.embeddings.transport";
259 pub const EMBEDDINGS_PROVIDER: &str = super::embedding_keys::AI_PROVIDER;
260 pub const EMBEDDINGS_API_BASE: &str = super::embedding_keys::AI_API_BASE;
261 pub const EMBEDDINGS_MODEL: &str = super::embedding_keys::AI_MODEL;
262 pub const EMBEDDINGS_API_KEY: &str = super::embedding_keys::AI_API_KEY;
263 pub const EMBEDDINGS_QUERY_PREFIX: &str = super::embedding_keys::AI_QUERY_PREFIX;
264 pub const EMBEDDINGS_DIM: &str = super::embedding_keys::AI_DIM;
265 pub const EMBEDDINGS_TIMEOUT_SECONDS: &str = super::embedding_keys::AI_TIMEOUT_SECONDS;
266
267 pub const AUDIO_TRANSCRIBE_NAMESPACE: &str = "ai.audio_transcribe";
268 pub const AUDIO_TRANSCRIBE_ROUTING: &str = "ai.audio_transcribe.routing";
269 pub const AUDIO_TRANSCRIBE_TRANSPORT: &str = "ai.audio_transcribe.transport";
270 pub const AUDIO_TRANSCRIBE_API_BASE: &str = "ai.audio_transcribe.api_base";
271 pub const AUDIO_TRANSCRIBE_API_KEY: &str = "ai.audio_transcribe.api_key";
272 pub const AUDIO_TRANSCRIBE_MODEL: &str = "ai.audio_transcribe.model";
273 pub const AUDIO_TRANSCRIBE_PROVIDER: &str = "ai.audio_transcribe.provider";
274 pub const AUDIO_TRANSCRIBE_TASK: &str = "ai.audio_transcribe.task";
275 pub const AUDIO_TRANSCRIBE_LANGUAGE: &str = "ai.audio_transcribe.language";
276
277 pub const AUDIO_TRANSLATE_NAMESPACE: &str = "ai.audio_translate";
278 pub const AUDIO_TRANSLATE_ROUTING: &str = "ai.audio_translate.routing";
279 pub const AUDIO_TRANSLATE_TRANSPORT: &str = "ai.audio_translate.transport";
280 pub const AUDIO_TRANSLATE_API_BASE: &str = "ai.audio_translate.api_base";
281 pub const AUDIO_TRANSLATE_API_KEY: &str = "ai.audio_translate.api_key";
282 pub const AUDIO_TRANSLATE_MODEL: &str = "ai.audio_translate.model";
283 pub const AUDIO_TRANSLATE_PROVIDER: &str = "ai.audio_translate.provider";
284 pub const AUDIO_TRANSLATE_TARGET_LANG: &str = "ai.audio_translate.target_lang";
285
286 pub const VISION_EXTRACT_NAMESPACE: &str = "ai.vision_extract";
287 pub const VISION_EXTRACT_ROUTING: &str = "ai.vision_extract.routing";
288 pub const VISION_EXTRACT_TRANSPORT: &str = "ai.vision_extract.transport";
289 pub const VISION_EXTRACT_API_BASE: &str = "ai.vision_extract.api_base";
290 pub const VISION_EXTRACT_API_KEY: &str = "ai.vision_extract.api_key";
291 pub const VISION_EXTRACT_MODEL: &str = "ai.vision_extract.model";
292 pub const VISION_EXTRACT_PROVIDER: &str = "ai.vision_extract.provider";
293
294 pub const TEXT_GENERATE_NAMESPACE: &str = "ai.text_generate";
295 pub const TEXT_GENERATE_ROUTING: &str = "ai.text_generate.routing";
296 pub const TEXT_GENERATE_TRANSPORT: &str = "ai.text_generate.transport";
297 pub const TEXT_GENERATE_API_BASE: &str = "ai.text_generate.api_base";
298 pub const TEXT_GENERATE_API_KEY: &str = "ai.text_generate.api_key";
299 pub const TEXT_GENERATE_MODEL: &str = "ai.text_generate.model";
300 pub const TEXT_GENERATE_PROVIDER: &str = "ai.text_generate.provider";
301
302 const ALL_KEYS: &[&str] = &[
303 ROUTING,
304 MAX_CONCURRENCY,
305 KEEP_ALIVE,
306 EMBEDDINGS_ROUTING,
307 EMBEDDINGS_TRANSPORT,
308 EMBEDDINGS_PROVIDER,
309 EMBEDDINGS_API_BASE,
310 EMBEDDINGS_MODEL,
311 EMBEDDINGS_API_KEY,
312 EMBEDDINGS_QUERY_PREFIX,
313 EMBEDDINGS_DIM,
314 EMBEDDINGS_TIMEOUT_SECONDS,
315 AUDIO_TRANSCRIBE_ROUTING,
316 AUDIO_TRANSCRIBE_TRANSPORT,
317 AUDIO_TRANSCRIBE_API_BASE,
318 AUDIO_TRANSCRIBE_API_KEY,
319 AUDIO_TRANSCRIBE_MODEL,
320 AUDIO_TRANSCRIBE_PROVIDER,
321 AUDIO_TRANSCRIBE_TASK,
322 AUDIO_TRANSCRIBE_LANGUAGE,
323 AUDIO_TRANSLATE_ROUTING,
324 AUDIO_TRANSLATE_TRANSPORT,
325 AUDIO_TRANSLATE_API_BASE,
326 AUDIO_TRANSLATE_API_KEY,
327 AUDIO_TRANSLATE_MODEL,
328 AUDIO_TRANSLATE_PROVIDER,
329 AUDIO_TRANSLATE_TARGET_LANG,
330 VISION_EXTRACT_ROUTING,
331 VISION_EXTRACT_TRANSPORT,
332 VISION_EXTRACT_API_BASE,
333 VISION_EXTRACT_API_KEY,
334 VISION_EXTRACT_MODEL,
335 VISION_EXTRACT_PROVIDER,
336 TEXT_GENERATE_ROUTING,
337 TEXT_GENERATE_TRANSPORT,
338 TEXT_GENERATE_API_BASE,
339 TEXT_GENERATE_API_KEY,
340 TEXT_GENERATE_MODEL,
341 TEXT_GENERATE_PROVIDER,
342 ];
343
344 pub fn all() -> &'static [&'static str] {
345 ALL_KEYS
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct EmbeddingConfigResolution {
351 pub config: EmbeddingConfig,
352 pub namespace: &'static str,
353}
354
355const FALKORDB_DEFAULT_PORT: u16 = 16379;
356const EMBEDDING_DEFAULT_MODEL: &str = "nomic-embed-text";
357const EMBEDDING_DEFAULT_TIMEOUT_SECONDS: u64 = 10;
358const AI_DEFAULT_MAX_CONCURRENCY: u8 = 1;
359
360#[cfg(test)]
361pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
362
363pub fn decode_config_value(raw: &str) -> Option<String> {
365 match serde_json::from_str::<serde_json::Value>(raw) {
366 Ok(serde_json::Value::String(value)) => Some(value),
367 Ok(value @ (serde_json::Value::Array(_) | serde_json::Value::Object(_))) => {
368 Some(serde_json::to_string(&value).unwrap_or_else(|_| raw.to_string()))
369 }
370 Ok(serde_json::Value::Null) => None,
371 Ok(value) => Some(value.to_string()),
372 Err(_) => Some(raw.to_string()),
373 }
374}
375
376pub fn resolve_env_pattern(value: &str) -> anyhow::Result<Option<String>> {
378 if !value.contains("${") {
379 return Ok(Some(value.to_string()));
380 }
381
382 let mut output = String::with_capacity(value.len());
383 let mut rest = value;
384 let mut unresolved = false;
385
386 while let Some(start) = rest.find("${") {
387 output.push_str(&rest[..start]);
388 let pattern = &rest[start + 2..];
389 let Some(end) = pattern.find('}') else {
390 anyhow::bail!("unterminated environment pattern in `{value}`");
391 };
392
393 let expression = &pattern[..end];
394 if expression.is_empty() {
395 anyhow::bail!("empty environment pattern in `{value}`");
396 }
397
398 let (name, default) = match expression.split_once(":-") {
399 Some((name, default)) => (name, Some(default)),
400 None => (expression, None),
401 };
402 if name.is_empty() {
403 anyhow::bail!("empty environment variable name in `{value}`");
404 }
405
406 match std::env::var(name) {
407 Ok(current) if !(current.is_empty() && default.is_some()) => {
408 output.push_str(¤t);
409 }
410 Ok(_) | Err(std::env::VarError::NotPresent) => match default {
411 Some(default) => output.push_str(default),
412 None => unresolved = true,
413 },
414 Err(std::env::VarError::NotUnicode(_)) => {
415 anyhow::bail!("environment variable `{name}` is not valid unicode");
416 }
417 }
418
419 rest = &pattern[end + 1..];
420 }
421
422 output.push_str(rest);
423 if unresolved {
424 Ok(None)
425 } else {
426 Ok(Some(output))
427 }
428}
429
430pub trait ConfigSource {
432 fn config_value(&mut self, key: &str) -> Option<String>;
434
435 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String>;
437}
438
439pub struct EnvOnlySource;
441
442impl ConfigSource for EnvOnlySource {
443 fn config_value(&mut self, _key: &str) -> Option<String> {
444 None
445 }
446
447 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
448 if value.contains("$secret:") {
449 anyhow::bail!("secret resolution requires a datastore-backed config source");
450 }
451 resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
452 }
453}
454
455pub fn resolve_falkordb_config(source: &mut impl ConfigSource) -> Option<FalkorConfig> {
457 let host = resolve_setting(source, "GOBBY_FALKORDB_HOST", "databases.falkordb.host")?;
458 let port = resolve_port(
459 source,
460 "GOBBY_FALKORDB_PORT",
461 "databases.falkordb.port",
462 FALKORDB_DEFAULT_PORT,
463 );
464 let password = resolve_setting(
465 source,
466 "GOBBY_FALKORDB_PASSWORD",
467 "databases.falkordb.requirepass",
468 );
469
470 Some(FalkorConfig {
471 host,
472 port,
473 password,
474 })
475}
476
477pub fn resolve_qdrant_config(source: &mut impl ConfigSource) -> Option<QdrantConfig> {
479 let url = resolve_setting(source, "GOBBY_QDRANT_URL", "databases.qdrant.url");
480 url.as_ref()?;
481 let api_key = resolve_setting(source, "GOBBY_QDRANT_API_KEY", "databases.qdrant.api_key");
482
483 Some(QdrantConfig { url, api_key })
484}
485
486pub fn resolve_embedding_config(source: &mut impl ConfigSource) -> Option<EmbeddingConfig> {
488 resolve_embedding_config_resolution(source).map(|resolution| resolution.config)
489}
490
491pub fn resolve_embedding_config_resolution(
493 source: &mut impl ConfigSource,
494) -> Option<EmbeddingConfigResolution> {
495 let binding = resolve_capability_binding(source, AiCapability::Embed);
496 let config = resolve_embedding_config_from_binding(source, &binding)?;
497
498 Some(EmbeddingConfigResolution {
499 config,
500 namespace: embedding_keys::AI_NAMESPACE,
501 })
502}
503
504pub fn resolve_embedding_config_from_binding(
506 source: &mut impl ConfigSource,
507 binding: &CapabilityBinding,
508) -> Option<EmbeddingConfig> {
509 let api_base = binding
510 .api_base
511 .as_deref()
512 .map(str::trim)
513 .filter(|value| !value.is_empty())?
514 .to_string();
515 let model = binding
516 .model
517 .as_deref()
518 .map(str::trim)
519 .filter(|value| !value.is_empty())
520 .map(ToString::to_string)
521 .unwrap_or_else(|| EMBEDDING_DEFAULT_MODEL.to_string());
522 let api_key = binding
523 .api_key
524 .as_deref()
525 .map(str::trim)
526 .filter(|value| !value.is_empty())
527 .map(ToString::to_string);
528 let query_prefix = resolve_embedding_setting(source, embedding_keys::AI_QUERY_PREFIX);
529 let timeout_seconds = resolve_embedding_setting(source, embedding_keys::AI_TIMEOUT_SECONDS)
530 .and_then(|value| value.parse::<u64>().ok())
531 .unwrap_or(EMBEDDING_DEFAULT_TIMEOUT_SECONDS);
532
533 Some(EmbeddingConfig {
534 api_base,
535 model,
536 api_key,
537 query_prefix,
538 timeout_seconds,
539 })
540}
541
542fn resolve_embedding_setting(source: &mut impl ConfigSource, config_key: &str) -> Option<String> {
543 resolve_ai_config_value(source, config_key)
544}
545
546pub fn resolve_capability_routing(
548 source: &mut impl ConfigSource,
549 capability: AiCapability,
550) -> AiRouting {
551 resolve_ai_routing_value(source, capability.routing_key())
552 .or_else(|| resolve_ai_routing_value(source, ai_keys::ROUTING))
553 .unwrap_or_default()
554}
555
556pub fn resolve_capability_binding(
558 source: &mut impl ConfigSource,
559 capability: AiCapability,
560) -> CapabilityBinding {
561 match capability {
562 AiCapability::AudioTranslate => resolve_audio_translate_binding(source),
563 capability => resolve_base_capability_binding(source, capability),
564 }
565}
566
567pub fn resolve_ai_tuning(source: &mut impl ConfigSource) -> AiTuning {
569 let max_concurrency = resolve_ai_config_value(source, ai_keys::MAX_CONCURRENCY)
570 .and_then(|value| value.trim().parse::<u8>().ok())
571 .filter(|value| *value > 0)
572 .unwrap_or(AI_DEFAULT_MAX_CONCURRENCY);
573 let keep_alive = resolve_ai_config_value(source, ai_keys::KEEP_ALIVE);
574
575 AiTuning {
576 max_concurrency,
577 keep_alive,
578 }
579}
580
581fn resolve_base_capability_binding(
582 source: &mut impl ConfigSource,
583 capability: AiCapability,
584) -> CapabilityBinding {
585 CapabilityBinding {
586 routing: resolve_capability_routing(source, capability),
587 transport: resolve_ai_config_value(source, capability.transport_key()),
588 api_base: resolve_ai_config_value(source, capability.api_base_key()),
589 api_key: resolve_ai_config_value(source, capability.api_key_key()),
590 model: resolve_ai_config_value(source, capability.model_key()),
591 provider: resolve_ai_config_value(source, capability.provider_key()),
592 task: match capability {
593 AiCapability::AudioTranscribe => {
594 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSCRIBE_TASK)
595 }
596 _ => None,
597 },
598 language: match capability {
599 AiCapability::AudioTranscribe => {
600 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSCRIBE_LANGUAGE)
601 }
602 _ => None,
603 },
604 target_lang: match capability {
605 AiCapability::AudioTranslate => {
606 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TARGET_LANG)
607 }
608 _ => None,
609 },
610 }
611}
612
613fn resolve_audio_translate_binding(source: &mut impl ConfigSource) -> CapabilityBinding {
614 let routing = resolve_ai_routing_value(source, ai_keys::AUDIO_TRANSLATE_ROUTING);
615 let transport = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TRANSPORT);
616 let api_base = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_API_BASE);
617 let api_key = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_API_KEY);
618 let model = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_MODEL);
619 let provider = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_PROVIDER);
620 let target_lang = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TARGET_LANG);
621 let inherited = resolve_base_capability_binding(source, AiCapability::AudioTranscribe);
622
623 CapabilityBinding {
624 routing: routing.unwrap_or(inherited.routing),
625 transport: transport.or(inherited.transport),
626 api_base: api_base.or(inherited.api_base),
627 api_key: api_key.or(inherited.api_key),
628 model: model.or(inherited.model),
629 provider: provider.or(inherited.provider),
630 task: None,
631 language: None,
632 target_lang,
633 }
634}
635
636fn resolve_ai_routing_value(source: &mut impl ConfigSource, config_key: &str) -> Option<AiRouting> {
637 resolve_ai_config_value(source, config_key).and_then(|value| value.parse().ok())
638}
639
640fn resolve_ai_config_value(source: &mut impl ConfigSource, config_key: &str) -> Option<String> {
641 let value = source.config_value(config_key)?;
642 resolve_ai_non_empty(source, &value)
643}
644
645fn resolve_ai_non_empty(source: &mut impl ConfigSource, value: &str) -> Option<String> {
651 let trimmed = value.trim();
652 if trimmed.is_empty() {
653 return None;
654 }
655 source.resolve_value(trimmed).ok().filter(|resolved| {
656 let resolved = resolved.trim();
657 !resolved.is_empty() && !contains_unresolved_env_pattern(resolved)
658 })
659}
660
661fn contains_unresolved_env_pattern(value: &str) -> bool {
662 value.contains("${")
663}
664
665fn resolve_setting(
666 source: &mut impl ConfigSource,
667 env_key: &str,
668 config_key: &str,
669) -> Option<String> {
670 let value = env_value(env_key).or_else(|| source.config_value(config_key))?;
671 resolve_non_empty(source, &value)
672}
673
674fn resolve_port(
675 source: &mut impl ConfigSource,
676 env_key: &str,
677 config_key: &str,
678 default: u16,
679) -> u16 {
680 let Some(raw_port) = env_value(env_key).or_else(|| source.config_value(config_key)) else {
681 return default;
682 };
683 let Some(resolved) = resolve_non_empty(source, &raw_port) else {
684 return default;
685 };
686 resolved.parse::<u16>().unwrap_or(default)
687}
688
689fn resolve_non_empty(source: &mut impl ConfigSource, value: &str) -> Option<String> {
690 if value.trim().is_empty() {
691 return None;
692 }
693 source
694 .resolve_value(value)
695 .ok()
696 .filter(|resolved| !resolved.trim().is_empty())
697}
698
699fn env_value(key: &str) -> Option<String> {
700 std::env::var(key)
701 .ok()
702 .filter(|value| !value.trim().is_empty())
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use std::collections::HashMap;
709 use std::sync::MutexGuard;
710
711 struct EnvGuard {
712 _lock: MutexGuard<'static, ()>,
713 }
714
715 impl EnvGuard {
716 fn new() -> Self {
717 let guard = Self {
718 _lock: TEST_ENV_LOCK
719 .lock()
720 .unwrap_or_else(|poisoned| poisoned.into_inner()),
721 };
722 guard.clear();
723 guard
724 }
725
726 fn clear(&self) {
727 for key in [
728 "GOBBY_FALKORDB_HOST",
729 "GOBBY_FALKORDB_PORT",
730 "GOBBY_FALKORDB_PASSWORD",
731 "GOBBY_QDRANT_URL",
732 "GOBBY_QDRANT_API_KEY",
733 "GOBBY_EMBEDDING_URL",
734 "GOBBY_EMBEDDING_MODEL",
735 "GOBBY_EMBEDDING_API_KEY",
736 "GOBBY_EMBEDDING_QUERY_PREFIX",
737 "GOBBY_EMBEDDING_TIMEOUT_SECONDS",
738 "GOBBY_AI_TEXT_GENERATE_API_BASE",
739 "GOBBY_TEST_PRESENT",
740 "GOBBY_TEST_MISSING",
741 ] {
742 unsafe { std::env::remove_var(key) };
745 }
746 }
747
748 fn set(&self, key: &str, value: &str) {
749 unsafe { std::env::set_var(key, value) };
750 }
751 }
752
753 impl Drop for EnvGuard {
754 fn drop(&mut self) {
755 self.clear();
756 }
757 }
758
759 #[derive(Default)]
760 struct TestSource {
761 values: HashMap<&'static str, String>,
762 resolved_values: Vec<String>,
763 }
764
765 impl TestSource {
766 fn with_values(values: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
767 Self {
768 values: values
769 .into_iter()
770 .map(|(key, value)| (key, value.to_string()))
771 .collect(),
772 resolved_values: Vec::new(),
773 }
774 }
775
776 fn with_raw_values(values: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
777 Self {
778 values: values
779 .into_iter()
780 .filter_map(|(key, value)| decode_config_value(value).map(|v| (key, v)))
781 .collect(),
782 resolved_values: Vec::new(),
783 }
784 }
785 }
786
787 impl ConfigSource for TestSource {
788 fn config_value(&mut self, key: &str) -> Option<String> {
789 self.values.get(key).cloned()
790 }
791
792 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
793 self.resolved_values.push(value.to_string());
794 if let Some(secret_name) = value.strip_prefix("$secret:") {
795 return Ok(format!("resolved-{secret_name}"));
796 }
797 Ok(resolve_env_pattern(value)?.unwrap_or_else(|| value.to_string()))
798 }
799 }
800
801 #[derive(Default)]
802 struct LayeredTestSource {
803 store: TestSource,
804 yaml: TestSource,
805 }
806
807 impl LayeredTestSource {
808 fn with_layers(
809 store_values: impl IntoIterator<Item = (&'static str, &'static str)>,
810 yaml_values: impl IntoIterator<Item = (&'static str, &'static str)>,
811 ) -> Self {
812 Self {
813 store: TestSource::with_values(store_values),
814 yaml: TestSource::with_values(yaml_values),
815 }
816 }
817 }
818
819 impl ConfigSource for LayeredTestSource {
820 fn config_value(&mut self, key: &str) -> Option<String> {
821 self.store
822 .config_value(key)
823 .or_else(|| self.yaml.config_value(key))
824 }
825
826 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
827 self.store.resolve_value(value)
828 }
829 }
830
831 #[test]
832 fn decode_config_value_handles_json_and_plain() {
833 assert_eq!(
834 decode_config_value("\"http://host:7474\""),
835 Some("http://host:7474".to_string())
836 );
837 assert_eq!(
838 decode_config_value(r#"["alpha",1,true]"#),
839 Some(r#"["alpha",1,true]"#.to_string())
840 );
841 assert_eq!(
842 decode_config_value(r#"{"host":"falkor.local","port":16379}"#),
843 Some(r#"{"host":"falkor.local","port":16379}"#.to_string())
844 );
845 assert_eq!(decode_config_value("42"), Some("42".to_string()));
846 assert_eq!(decode_config_value("true"), Some("true".to_string()));
847 assert_eq!(
848 decode_config_value("http://plain:7474"),
849 Some("http://plain:7474".to_string())
850 );
851 assert_eq!(decode_config_value("null"), None);
852 }
853
854 #[test]
855 fn resolve_env_pattern_with_defaults() {
856 let env = EnvGuard::new();
857 env.set("GOBBY_TEST_PRESENT", "present-value");
858
859 assert_eq!(
860 resolve_env_pattern("${GOBBY_TEST_PRESENT}").unwrap(),
861 Some("present-value".to_string())
862 );
863 assert_eq!(
864 resolve_env_pattern("prefix-${GOBBY_TEST_PRESENT}-suffix").unwrap(),
865 Some("prefix-present-value-suffix".to_string())
866 );
867 assert_eq!(
868 resolve_env_pattern("${GOBBY_TEST_MISSING:-fallback}").unwrap(),
869 Some("fallback".to_string())
870 );
871 assert_eq!(resolve_env_pattern("${GOBBY_TEST_MISSING}").unwrap(), None);
872 assert_eq!(
873 resolve_env_pattern("plain-value").unwrap(),
874 Some("plain-value".to_string())
875 );
876 }
877
878 #[test]
879 fn env_overrides_config_store() {
880 let env = EnvGuard::new();
881 env.set("GOBBY_FALKORDB_HOST", "env-falkor.local");
882 env.set("GOBBY_FALKORDB_PORT", "17000");
883 env.set("GOBBY_FALKORDB_PASSWORD", "env-pass");
884 env.set("GOBBY_QDRANT_URL", "http://env-qdrant:6333");
885 env.set("GOBBY_QDRANT_API_KEY", "env-qdrant-key");
886
887 let mut source = TestSource::with_values([
888 ("databases.falkordb.host", "stored-falkor.local"),
889 ("databases.falkordb.port", "16000"),
890 ("databases.falkordb.requirepass", "stored-pass"),
891 ("databases.qdrant.url", "http://stored-qdrant:6333"),
892 ("databases.qdrant.api_key", "stored-qdrant-key"),
893 ]);
894
895 let falkordb = resolve_falkordb_config(&mut source).expect("falkordb config");
896 let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
897
898 assert_eq!(falkordb.host, "env-falkor.local");
899 assert_eq!(falkordb.port, 17000);
900 assert_eq!(falkordb.password.as_deref(), Some("env-pass"));
901 assert_eq!(qdrant.url.as_deref(), Some("http://env-qdrant:6333"));
902 assert_eq!(qdrant.api_key.as_deref(), Some("env-qdrant-key"));
903 }
904
905 #[test]
906 fn config_source_handles_secrets() {
907 let _env = EnvGuard::new();
908 let mut source = TestSource::with_values([
909 ("databases.falkordb.host", "falkor.local"),
910 ("databases.falkordb.requirepass", "$secret:FALKOR_PASS"),
911 ]);
912
913 let config = resolve_falkordb_config(&mut source).expect("falkordb config");
914
915 assert_eq!(config.password.as_deref(), Some("resolved-FALKOR_PASS"));
916 assert!(
917 source
918 .resolved_values
919 .iter()
920 .any(|value| value == "$secret:FALKOR_PASS")
921 );
922 }
923
924 #[test]
925 fn env_only_source_rejects_secret_patterns() {
926 let _env = EnvGuard::new();
927 let mut source = EnvOnlySource;
928
929 let error = source
930 .resolve_value("$secret:FALKOR_PASS")
931 .expect_err("secret resolution should require a datastore-backed source");
932
933 assert!(error.to_string().contains("secret resolution"));
934 }
935
936 #[test]
937 fn ai_routing_per_capability_precedence() {
938 let _env = EnvGuard::new();
939 let mut source = TestSource::with_values([
940 (ai_keys::ROUTING, "daemon"),
941 (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "direct"),
942 ]);
943
944 assert_eq!(
945 resolve_capability_routing(&mut source, AiCapability::AudioTranscribe),
946 AiRouting::Direct
947 );
948
949 let mut source = TestSource::with_values([(ai_keys::ROUTING, "off")]);
950 assert_eq!(
951 resolve_capability_routing(&mut source, AiCapability::VisionExtract),
952 AiRouting::Off
953 );
954
955 let mut source = TestSource::default();
956 assert_eq!(
957 resolve_capability_routing(&mut source, AiCapability::TextGenerate),
958 AiRouting::Auto
959 );
960
961 let mut source = TestSource::with_values([
962 (ai_keys::TEXT_GENERATE_ROUTING, "unknown"),
963 (ai_keys::ROUTING, "direct"),
964 ]);
965 assert_eq!(
966 resolve_capability_routing(&mut source, AiCapability::TextGenerate),
967 AiRouting::Direct
968 );
969
970 assert_eq!("daemon".parse::<AiRouting>().ok(), Some(AiRouting::Daemon));
971 assert!("unknown".parse::<AiRouting>().is_err());
972 }
973
974 #[test]
975 fn ai_config_resolves_store_then_yaml_no_env() {
976 let env = EnvGuard::new();
977 env.set("GOBBY_EMBEDDING_URL", "http://env-embedding:11434/v1");
978 env.set(
979 "GOBBY_AI_TEXT_GENERATE_API_BASE",
980 "http://env-text:11434/v1",
981 );
982 env.set("GOBBY_TEST_PRESENT", "interpolated-text-model");
983
984 let mut source = LayeredTestSource::with_layers(
985 [
986 (
987 ai_keys::TEXT_GENERATE_API_BASE,
988 "http://store-text:11434/v1",
989 ),
990 (ai_keys::TEXT_GENERATE_API_KEY, "$secret:TEXT_KEY"),
991 (ai_keys::MAX_CONCURRENCY, "3"),
992 ],
993 [
994 (ai_keys::TEXT_GENERATE_API_BASE, "http://yaml-text:11434/v1"),
995 (ai_keys::TEXT_GENERATE_MODEL, "${GOBBY_TEST_PRESENT}"),
996 (ai_keys::TEXT_GENERATE_API_KEY, "yaml-local-key"),
997 (ai_keys::KEEP_ALIVE, "30s"),
998 ],
999 );
1000
1001 let binding = resolve_capability_binding(&mut source, AiCapability::TextGenerate);
1002 let tuning = resolve_ai_tuning(&mut source);
1003
1004 assert_eq!(
1005 binding.api_base.as_deref(),
1006 Some("http://store-text:11434/v1")
1007 );
1008 assert_eq!(binding.model.as_deref(), Some("interpolated-text-model"));
1009 assert_eq!(binding.api_key.as_deref(), Some("resolved-TEXT_KEY"));
1010 assert_eq!(tuning.max_concurrency, 3);
1011 assert_eq!(tuning.keep_alive.as_deref(), Some("30s"));
1012
1013 let mut standalone_source = LayeredTestSource::with_layers(
1014 [],
1015 [
1016 (
1017 ai_keys::EMBEDDINGS_API_BASE,
1018 "http://yaml-embedding:11434/v1",
1019 ),
1020 (ai_keys::EMBEDDINGS_API_KEY, "plaintext-local-key"),
1021 ],
1022 );
1023 let binding = resolve_capability_binding(&mut standalone_source, AiCapability::Embed);
1024 let embedding = resolve_embedding_config(&mut standalone_source).expect("embedding config");
1025
1026 assert_eq!(
1027 binding.api_base.as_deref(),
1028 Some("http://yaml-embedding:11434/v1")
1029 );
1030 assert_eq!(binding.api_key.as_deref(), Some("plaintext-local-key"));
1031 assert_eq!(embedding.api_base, "http://yaml-embedding:11434/v1");
1032
1033 let mut missing_env_source = LayeredTestSource::with_layers(
1034 [],
1035 [(ai_keys::TEXT_GENERATE_MODEL, "${GOBBY_TEST_MISSING}")],
1036 );
1037 let binding =
1038 resolve_capability_binding(&mut missing_env_source, AiCapability::TextGenerate);
1039 assert_eq!(binding.model, None);
1040
1041 let tuning = resolve_ai_tuning(&mut TestSource::default());
1042 assert_eq!(tuning.max_concurrency, 1);
1043 assert!(tuning.keep_alive.is_none());
1044 }
1045
1046 #[test]
1047 fn provider_and_translation_fields_resolve() {
1048 let _env = EnvGuard::new();
1049 let mut source = LayeredTestSource::with_layers(
1050 [
1051 (
1052 ai_keys::AUDIO_TRANSLATE_PROVIDER,
1053 "store-translate-provider",
1054 ),
1055 (ai_keys::AUDIO_TRANSLATE_TARGET_LANG, "fr"),
1056 ],
1057 [
1058 (ai_keys::AUDIO_TRANSLATE_PROVIDER, "yaml-translate-provider"),
1059 (ai_keys::AUDIO_TRANSLATE_TARGET_LANG, "de"),
1060 (ai_keys::AUDIO_TRANSCRIBE_LANGUAGE, "en"),
1061 ],
1062 );
1063
1064 let translate = resolve_capability_binding(&mut source, AiCapability::AudioTranslate);
1065 let transcribe = resolve_capability_binding(&mut source, AiCapability::AudioTranscribe);
1066
1067 assert_eq!(
1068 translate.provider.as_deref(),
1069 Some("store-translate-provider")
1070 );
1071 assert_eq!(translate.target_lang.as_deref(), Some("fr"));
1072 assert!(translate.task.is_none());
1073 assert!(translate.language.is_none());
1074
1075 assert_eq!(transcribe.language.as_deref(), Some("en"));
1076 assert!(transcribe.target_lang.is_none());
1077 assert!(
1078 ai_keys::all()
1079 .iter()
1080 .all(|key| !key.starts_with("ai.video."))
1081 );
1082 assert!(ai_keys::all().contains(&ai_keys::EMBEDDINGS_QUERY_PREFIX));
1083 assert!(ai_keys::all().contains(&ai_keys::EMBEDDINGS_DIM));
1084 assert!(ai_keys::all().contains(&ai_keys::EMBEDDINGS_TIMEOUT_SECONDS));
1085 }
1086
1087 #[test]
1088 fn audio_translate_inherits_transcribe_binding() {
1089 let _env = EnvGuard::new();
1090 let mut source = TestSource::with_values([
1091 (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "direct"),
1092 (ai_keys::AUDIO_TRANSCRIBE_TRANSPORT, "daemon_native"),
1093 (ai_keys::AUDIO_TRANSCRIBE_API_BASE, "http://stt:8080/v1"),
1094 (ai_keys::AUDIO_TRANSCRIBE_API_KEY, "$secret:STT_KEY"),
1095 (ai_keys::AUDIO_TRANSCRIBE_MODEL, "whisper-large-v3"),
1096 (ai_keys::AUDIO_TRANSCRIBE_PROVIDER, "faster-whisper"),
1097 (ai_keys::AUDIO_TRANSCRIBE_TASK, "transcribe"),
1098 (ai_keys::AUDIO_TRANSCRIBE_LANGUAGE, "en"),
1099 (ai_keys::AUDIO_TRANSLATE_MODEL, "translate-override"),
1100 (ai_keys::AUDIO_TRANSLATE_PROVIDER, "translate-provider"),
1101 (ai_keys::AUDIO_TRANSLATE_TARGET_LANG, "es"),
1102 ]);
1103
1104 let translate = resolve_capability_binding(&mut source, AiCapability::AudioTranslate);
1105 let transcribe = resolve_capability_binding(&mut source, AiCapability::AudioTranscribe);
1106
1107 assert_eq!(translate.routing, AiRouting::Direct);
1108 assert_eq!(translate.transport.as_deref(), Some("daemon_native"));
1109 assert_eq!(translate.api_base.as_deref(), Some("http://stt:8080/v1"));
1110 assert_eq!(translate.api_key.as_deref(), Some("resolved-STT_KEY"));
1111 assert_eq!(translate.model.as_deref(), Some("translate-override"));
1112 assert_eq!(translate.provider.as_deref(), Some("translate-provider"));
1113 assert_eq!(translate.target_lang.as_deref(), Some("es"));
1114 assert!(translate.task.is_none());
1115 assert!(translate.language.is_none());
1116
1117 assert_eq!(transcribe.task.as_deref(), Some("transcribe"));
1118 assert_eq!(transcribe.language.as_deref(), Some("en"));
1119 assert!(transcribe.target_lang.is_none());
1120
1121 let mut source = TestSource::with_values([
1122 (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "daemon"),
1123 (ai_keys::AUDIO_TRANSLATE_ROUTING, "off"),
1124 ]);
1125 let translate = resolve_capability_binding(&mut source, AiCapability::AudioTranslate);
1126 assert_eq!(translate.routing, AiRouting::Off);
1127 }
1128
1129 #[test]
1130 fn env_does_not_override_ai_embedding_keys() {
1131 let env = EnvGuard::new();
1132 env.set("GOBBY_EMBEDDING_URL", "http://env-embedding:11434");
1133 env.set("GOBBY_EMBEDDING_MODEL", "env-model");
1134 env.set("GOBBY_EMBEDDING_API_KEY", "env-key");
1135 env.set("GOBBY_EMBEDDING_QUERY_PREFIX", "env-prefix:");
1136 env.set("GOBBY_EMBEDDING_TIMEOUT_SECONDS", "7");
1137
1138 let mut source = TestSource::with_values([
1139 (embedding_keys::AI_API_BASE, "http://new-embedding:11434"),
1140 (embedding_keys::AI_MODEL, "new-model"),
1141 (embedding_keys::AI_API_KEY, "$secret:AI_KEY"),
1142 (embedding_keys::AI_QUERY_PREFIX, "new-query:"),
1143 (embedding_keys::AI_TIMEOUT_SECONDS, "12"),
1144 ]);
1145
1146 let resolution =
1147 resolve_embedding_config_resolution(&mut source).expect("embedding config");
1148 let config = resolution.config;
1149
1150 assert_eq!(resolution.namespace, embedding_keys::AI_NAMESPACE);
1151 assert_eq!(config.api_base, "http://new-embedding:11434");
1152 assert_eq!(config.model, "new-model");
1153 assert_eq!(config.api_key.as_deref(), Some("resolved-AI_KEY"));
1154 assert_eq!(config.query_prefix.as_deref(), Some("new-query:"));
1155 assert_eq!(config.timeout_seconds, 12);
1156 }
1157
1158 #[test]
1159 fn legacy_keys_not_honored() {
1160 let _env = EnvGuard::new();
1161 let legacy_keys = embedding_keys::legacy_keys();
1162 let mut source = TestSource::with_values([
1163 (
1164 leak_for_test(legacy_keys[1].clone()),
1165 "http://legacy-embedding:11434",
1166 ),
1167 (leak_for_test(legacy_keys[2].clone()), "legacy-model"),
1168 (leak_for_test(legacy_keys[3].clone()), "$secret:LEGACY_KEY"),
1169 (leak_for_test(legacy_keys[5].clone()), "legacy-query:"),
1170 ]);
1171
1172 assert!(resolve_embedding_config_resolution(&mut source).is_none());
1173 }
1174
1175 fn leak_for_test(value: String) -> &'static str {
1176 Box::leak(value.into_boxed_str())
1180 }
1181
1182 #[test]
1183 fn postgres_config_source_resolves_secrets() {
1184 let _env = EnvGuard::new();
1185
1186 struct ConnectionLike {
1187 values: HashMap<&'static str, String>,
1188 secret_reads: usize,
1189 }
1190
1191 struct PostgresConfigSource<'a> {
1192 conn: &'a mut ConnectionLike,
1193 }
1194
1195 impl ConfigSource for PostgresConfigSource<'_> {
1196 fn config_value(&mut self, key: &str) -> Option<String> {
1197 self.conn.values.get(key).cloned()
1198 }
1199
1200 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
1201 self.conn.secret_reads += 1;
1202 Ok(format!("secret::{value}"))
1203 }
1204 }
1205
1206 let mut conn = ConnectionLike {
1207 values: HashMap::from([
1208 (
1209 embedding_keys::AI_API_BASE,
1210 "http://stored-embedding:11434".to_string(),
1211 ),
1212 (
1213 embedding_keys::AI_API_KEY,
1214 "$secret:OPENAI_API_KEY".to_string(),
1215 ),
1216 ]),
1217 secret_reads: 0,
1218 };
1219 let config = {
1220 let mut source = PostgresConfigSource { conn: &mut conn };
1221 resolve_embedding_config(&mut source).expect("embedding config")
1222 };
1223
1224 assert_eq!(
1225 config.api_key.as_deref(),
1226 Some("secret::$secret:OPENAI_API_KEY")
1227 );
1228 assert_eq!(conn.secret_reads, 2);
1229 }
1230
1231 #[test]
1232 fn resolve_config_handles_json_encoded_store_values() {
1233 let _env = EnvGuard::new();
1234 let mut source = TestSource::with_raw_values([
1235 ("databases.falkordb.host", r#""json-falkor.local""#),
1236 ("databases.falkordb.port", r#""17001""#),
1237 ("databases.falkordb.requirepass", r#""$secret:FALKOR_PASS""#),
1238 ("databases.qdrant.url", r#""http://json-qdrant:6333""#),
1239 ("databases.qdrant.api_key", r#"["alpha",1]"#),
1240 (
1241 embedding_keys::AI_API_BASE,
1242 r#""http://json-embedding:11434""#,
1243 ),
1244 (embedding_keys::AI_MODEL, r#"["model",1]"#),
1245 ]);
1246
1247 let falkordb = resolve_falkordb_config(&mut source).expect("falkordb config");
1248 let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
1249 let embedding = resolve_embedding_config(&mut source).expect("embedding config");
1250
1251 assert_eq!(falkordb.host, "json-falkor.local");
1252 assert_eq!(falkordb.port, 17001);
1253 assert_eq!(falkordb.password.as_deref(), Some("resolved-FALKOR_PASS"));
1254 assert_eq!(qdrant.url.as_deref(), Some("http://json-qdrant:6333"));
1255 assert_eq!(qdrant.api_key.as_deref(), Some(r#"["alpha",1]"#));
1256 assert_eq!(embedding.api_base, "http://json-embedding:11434");
1257 assert_eq!(embedding.model, r#"["model",1]"#);
1258 }
1259
1260 #[test]
1261 fn qdrant_and_embedding_resolution_order() {
1262 {
1263 let env = EnvGuard::new();
1264 env.set("GOBBY_QDRANT_API_KEY", "env-qdrant-key");
1265 let mut source = TestSource::with_values([
1266 ("databases.qdrant.url", "http://stored-qdrant:6333"),
1267 ("databases.qdrant.api_key", "stored-qdrant-key"),
1268 (
1269 embedding_keys::AI_API_BASE,
1270 "http://stored-embedding:11434/v1",
1271 ),
1272 (embedding_keys::AI_MODEL, "stored-embedding-model"),
1273 (embedding_keys::AI_API_KEY, "$secret:EMBEDDING_KEY"),
1274 (embedding_keys::AI_QUERY_PREFIX, "stored-query-prefix:"),
1275 ]);
1276
1277 let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
1278 let embedding = resolve_embedding_config(&mut source).expect("embedding config");
1279
1280 assert_eq!(qdrant.url.as_deref(), Some("http://stored-qdrant:6333"));
1281 assert_eq!(qdrant.api_key.as_deref(), Some("env-qdrant-key"));
1282 assert_eq!(embedding.api_base, "http://stored-embedding:11434/v1");
1283 assert_eq!(embedding.model, "stored-embedding-model");
1284 assert_eq!(embedding.api_key.as_deref(), Some("resolved-EMBEDDING_KEY"));
1285 assert_eq!(
1286 embedding.query_prefix.as_deref(),
1287 Some("stored-query-prefix:")
1288 );
1289 assert_eq!(embedding.timeout_seconds, EMBEDDING_DEFAULT_TIMEOUT_SECONDS);
1290 }
1291
1292 let _env = EnvGuard::new();
1293 let mut default_source = TestSource::with_values([(
1294 embedding_keys::AI_API_BASE,
1295 "http://stored-embedding:11434/v1",
1296 )]);
1297 let default_embedding =
1298 resolve_embedding_config(&mut default_source).expect("embedding config");
1299
1300 assert_eq!(default_embedding.model, EMBEDDING_DEFAULT_MODEL);
1301 assert!(default_embedding.query_prefix.is_none());
1302 assert_eq!(
1303 default_embedding.timeout_seconds,
1304 EMBEDDING_DEFAULT_TIMEOUT_SECONDS
1305 );
1306 assert!(resolve_qdrant_config(&mut TestSource::default()).is_none());
1307 }
1308
1309 #[test]
1310 fn invalid_embedding_timeout_uses_default() {
1311 let _env = EnvGuard::new();
1312 let mut source = TestSource::with_values([
1313 (
1314 embedding_keys::AI_API_BASE,
1315 "http://stored-embedding:11434/v1",
1316 ),
1317 (embedding_keys::AI_TIMEOUT_SECONDS, "not-a-number"),
1318 ]);
1319
1320 let embedding = resolve_embedding_config(&mut source).expect("embedding config");
1321
1322 assert_eq!(embedding.timeout_seconds, EMBEDDING_DEFAULT_TIMEOUT_SECONDS);
1323 }
1324
1325 #[test]
1326 fn falkordb_config_has_no_domain_graph_name() {
1327 let config = FalkorConfig {
1328 host: "falkor.local".to_string(),
1329 port: 16379,
1330 password: None,
1331 };
1332
1333 assert!(!format!("{config:?}").contains("graph"));
1334 let forbidden = ["gobby", "_", "code"].concat();
1335 assert!(!include_str!("config.rs").contains(&forbidden));
1336 }
1337
1338 #[test]
1339 fn qdrant_config_has_no_domain_collection_prefix() {
1340 let config = QdrantConfig {
1341 url: Some("http://qdrant:6333".to_string()),
1342 api_key: None,
1343 };
1344
1345 assert!(!format!("{config:?}").contains("collection"));
1346 }
1347
1348 #[test]
1349 fn embedding_keys_centralized() {
1350 if std::env::var("RUN_SLOW_TESTS").is_err() {
1351 eprintln!("skipping slow workspace embedding key scan; set RUN_SLOW_TESTS=1 to run");
1352 return;
1353 }
1354
1355 let workspace = workspace_root();
1356 let offenders = embedding_key_literal_offenders(&workspace.join("crates"));
1357
1358 assert!(
1359 offenders.is_empty(),
1360 "embedding config key literals must stay in gobby_core::config::embedding_keys: {offenders:?}"
1361 );
1362 }
1363
1364 #[test]
1365 fn ci_guard_rejects_stray_literal() {
1366 let dir = tempfile::tempdir().expect("tempdir");
1367 let src = dir.path().join("src");
1368 std::fs::create_dir_all(&src).expect("create src");
1369 std::fs::write(
1370 src.join("bad.rs"),
1371 format!(r#"const BAD: &str = "{}";"#, embedding_keys::AI_API_BASE),
1372 )
1373 .expect("write bad source");
1374
1375 let offenders = embedding_key_literal_offenders(dir.path());
1376
1377 assert_eq!(offenders.len(), 1);
1378 assert!(offenders[0].ends_with("bad.rs"));
1379 }
1380
1381 #[test]
1382 fn ci_guard_rejects_legacy_namespace() {
1383 let dir = tempfile::tempdir().expect("tempdir");
1384 let src = dir.path().join("src");
1385 std::fs::create_dir_all(&src).expect("create src");
1386 std::fs::write(
1387 src.join("bad.rs"),
1388 format!(
1389 r#"const BAD: &str = "{}";"#,
1390 embedding_keys::legacy_keys()[1]
1391 ),
1392 )
1393 .expect("write bad source");
1394
1395 let offenders = embedding_key_literal_offenders(dir.path());
1396
1397 assert_eq!(offenders.len(), 1);
1398 assert!(offenders[0].ends_with("bad.rs"));
1399 }
1400
1401 fn workspace_root() -> std::path::PathBuf {
1402 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1403 .parent()
1404 .and_then(std::path::Path::parent)
1405 .expect("workspace root")
1406 .to_path_buf()
1407 }
1408
1409 fn embedding_key_literal_offenders(root: &std::path::Path) -> Vec<std::path::PathBuf> {
1410 let mut offenders = Vec::new();
1411 visit_embedding_key_literal_sources(root, &mut offenders);
1412 offenders
1413 }
1414
1415 fn visit_embedding_key_literal_sources(
1416 path: &std::path::Path,
1417 offenders: &mut Vec<std::path::PathBuf>,
1418 ) {
1419 let entries = match std::fs::read_dir(path) {
1420 Ok(entries) => entries,
1421 Err(_) => return,
1422 };
1423 for entry in entries {
1424 let entry = entry.expect("directory entry");
1425 let path = entry.path();
1426 if path.is_dir() {
1427 if should_skip_embedding_key_scan_dir(&path) {
1428 continue;
1429 }
1430 visit_embedding_key_literal_sources(&path, offenders);
1431 continue;
1432 }
1433 if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
1434 continue;
1435 }
1436 if embedding_key_literal_allowed_path(&path) {
1437 continue;
1438 }
1439 let source = std::fs::read_to_string(&path).expect("read source file");
1440 if guarded_embedding_keys()
1441 .iter()
1442 .any(|key| source.contains(key.as_str()))
1443 {
1444 offenders.push(path);
1445 }
1446 }
1447 }
1448
1449 fn should_skip_embedding_key_scan_dir(path: &std::path::Path) -> bool {
1450 matches!(
1451 path.file_name().and_then(|name| name.to_str()),
1452 Some(
1453 ".git"
1454 | "target"
1455 | "node_modules"
1456 | "dist"
1457 | "build"
1458 | ".venv"
1459 | "venv"
1460 | "__pycache__"
1461 )
1462 )
1463 }
1464
1465 fn guarded_embedding_keys() -> Vec<String> {
1466 let mut keys = vec![
1467 embedding_keys::AI_PROVIDER,
1468 embedding_keys::AI_API_BASE,
1469 embedding_keys::AI_MODEL,
1470 embedding_keys::AI_API_KEY,
1471 embedding_keys::AI_QUERY_PREFIX,
1472 embedding_keys::AI_DIM,
1473 embedding_keys::AI_TIMEOUT_SECONDS,
1474 ]
1475 .into_iter()
1476 .map(str::to_string)
1477 .collect::<Vec<_>>();
1478 keys.extend(embedding_keys::legacy_keys());
1479 keys
1480 }
1481
1482 fn embedding_key_literal_allowed_path(path: &std::path::Path) -> bool {
1483 let path = path.to_string_lossy();
1484 path.ends_with("crates/gcore/src/config.rs")
1485 || path.ends_with("tests.rs")
1486 || path.contains("/tests/")
1487 }
1488}