Skip to main content

agent_trace/llm/providers/
resolver.rs

1use super::http::HttpBackend;
2use super::ollama;
3use crate::config::{
4    CredentialsStore, MergedConfig, SynthesisConfig, SynthesisMode, SynthesisProvider,
5};
6use crate::llm::synthesis_engine::{DegradedBackend, SynthesisEngine};
7use std::sync::{LazyLock, Mutex};
8
9static DEGRADED_WARNED: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false));
10
11#[derive(Debug, Clone)]
12pub struct ResolvedBackendInfo {
13    pub label: String,
14    pub provider: SynthesisProvider,
15    pub model: String,
16    pub degraded: bool,
17}
18
19pub enum ResolvedBackend {
20    Http(HttpBackend),
21    Degraded(DegradedBackend),
22}
23
24impl ResolvedBackend {
25    pub fn info(&self) -> ResolvedBackendInfo {
26        match self {
27            Self::Http(b) => ResolvedBackendInfo {
28                label: b.label().to_string(),
29                provider: b.provider,
30                model: b.model.clone(),
31                degraded: false,
32            },
33            Self::Degraded(_) => ResolvedBackendInfo {
34                label: "degraded".into(),
35                provider: SynthesisProvider::Ollama,
36                model: "mechanical".into(),
37                degraded: true,
38            },
39        }
40    }
41
42    pub fn into_engine(self) -> Box<dyn SynthesisEngine> {
43        match self {
44            Self::Http(b) => Box::new(b),
45            Self::Degraded(b) => Box::new(b),
46        }
47    }
48}
49
50pub fn resolve(merged: &MergedConfig, creds: &CredentialsStore) -> ResolvedBackend {
51    let syn = &merged.synthesis;
52    match syn.mode {
53        SynthesisMode::Remote => try_remote(syn, creds)
54            .unwrap_or_else(|| warn_and_degraded("remote provider unavailable")),
55        SynthesisMode::Ollama => {
56            try_ollama(syn).unwrap_or_else(|| warn_and_degraded("ollama unavailable"))
57        }
58        // Legacy: treat Embedded mode same as Auto (Ollama-first)
59        SynthesisMode::Embedded | SynthesisMode::Auto => try_remote(syn, creds)
60            .or_else(|| try_ollama(syn))
61            .unwrap_or_else(|| warn_and_degraded("all synthesis backends unavailable")),
62    }
63}
64
65fn try_remote(syn: &SynthesisConfig, creds: &CredentialsStore) -> Option<ResolvedBackend> {
66    if matches!(
67        syn.provider,
68        SynthesisProvider::Ollama | SynthesisProvider::Embedded
69    ) {
70        return None;
71    }
72    let needs_key = SynthesisConfig::provider_needs_credentials(syn.provider);
73    let api_key = creds.api_key_for(syn.provider);
74    if needs_key && api_key.is_none() {
75        return None;
76    }
77    let backend = HttpBackend::from_config(
78        syn.provider,
79        syn,
80        api_key,
81        format!("{}/{}", syn.provider.slug(), syn.model),
82    );
83    backend.health_check().ok()?;
84    Some(ResolvedBackend::Http(backend))
85}
86
87fn try_ollama(syn: &SynthesisConfig) -> Option<ResolvedBackend> {
88    if !ollama::is_reachable(syn) {
89        return None;
90    }
91    let backend = ollama::make_backend(syn);
92    backend.health_check().ok()?;
93    Some(ResolvedBackend::Http(backend))
94}
95
96fn warn_and_degraded(reason: &str) -> ResolvedBackend {
97    let mut warned = DEGRADED_WARNED.lock().unwrap();
98    if !*warned {
99        tracing::warn!(
100            "Synthesis running in degraded mode ({reason}) — configure `agent-trace model setup`"
101        );
102        *warned = true;
103    }
104    ResolvedBackend::Degraded(DegradedBackend)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::config::{GlobalConfig, PollingConfig, StoreConfig, StoreInfo};
111
112    #[test]
113    fn auto_mode_falls_back_to_degraded_without_backends() {
114        let merged = MergedConfig::merge(
115            GlobalConfig {
116                synthesis: SynthesisConfig::for_unit_tests_degraded(),
117                ..Default::default()
118            },
119            StoreConfig {
120                store: StoreInfo::new("t".into()),
121                llm: None,
122                synthesis: None,
123                polling: PollingConfig::default(),
124            },
125        );
126        let creds = CredentialsStore::default();
127        let resolved = resolve(&merged, &creds);
128        assert!(resolved.info().degraded);
129    }
130
131    #[test]
132    fn auto_prefers_remote_when_creds_present() {
133        use crate::config::{SynthesisConfig, SynthesisMode, SynthesisProvider};
134        let mut creds = CredentialsStore::default();
135        creds.set_key(SynthesisProvider::Openai, "sk-fake-key".into());
136        let merged = MergedConfig::merge(
137            GlobalConfig {
138                synthesis: SynthesisConfig {
139                    mode: SynthesisMode::Auto,
140                    provider: SynthesisProvider::Openai,
141                    model: "gpt-4o-mini".into(),
142                    ..Default::default()
143                },
144                ..Default::default()
145            },
146            StoreConfig {
147                store: StoreInfo::new("t".into()),
148                llm: None,
149                synthesis: None,
150                polling: PollingConfig::default(),
151            },
152        );
153        // Remote will fail health check in unit test (no real API), so falls through to degraded
154        let resolved = resolve(&merged, &creds);
155        // Either remote (if reachable) or degraded — main thing: no embedded step
156        let _ = resolved.info();
157    }
158}