agent_trace/llm/providers/
resolver.rs1use 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 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 let resolved = resolve(&merged, &creds);
155 let _ = resolved.info();
157 }
158}