Skip to main content

drep/llm/
backend.rs

1//! Backend-specific request machinery behind the provider-chain contract.
2
3use crate::config::{BackendKind, LlmConfig};
4use crate::llm::client::LlmClient;
5use crate::llm::codex::{CodexClient, CodexRuntime, CodexSettings};
6use crate::llm::error::LlmError;
7use crate::llm::json_parsing::Extracted;
8
9/// One provider's concrete execution backend.
10#[derive(Debug)]
11pub enum ProviderBackend {
12    Http(LlmClient),
13    Codex(CodexClient),
14}
15
16impl ProviderBackend {
17    pub fn model(&self) -> &str {
18        match self {
19            Self::Http(client) => client.model(),
20            Self::Codex(client) => client.model(),
21        }
22    }
23
24    /// Stable, non-personal identity used by the response cache.
25    pub fn identity(&self) -> String {
26        match self {
27            Self::Http(client) => format!("http:{}", client.endpoint()),
28            Self::Codex(client) => client.identity(),
29        }
30    }
31
32    /// Backend-neutral location shown in reports.
33    pub fn location(&self) -> &str {
34        match self {
35            Self::Http(client) => client.endpoint(),
36            Self::Codex(_) => "codex://chatgpt",
37        }
38    }
39
40    /// Request discriminator independent of provider identity and model.
41    pub fn request_identity(&self) -> &str {
42        match self {
43            Self::Http(client) => client.request_identity(),
44            Self::Codex(_) => "codex-jsonl-v1",
45        }
46    }
47
48    pub fn temperature(&self) -> Option<f32> {
49        match self {
50            Self::Http(client) => client.temperature(),
51            Self::Codex(_) => None,
52        }
53    }
54
55    pub async fn complete_json(
56        &self,
57        system_prompt: &str,
58        user_content: &str,
59    ) -> Result<Extracted, LlmError> {
60        match self {
61            Self::Http(client) => client.complete_json(system_prompt, user_content).await,
62            Self::Codex(client) => client.complete_json(system_prompt, user_content).await,
63        }
64    }
65
66    #[cfg(test)]
67    pub(crate) fn http_mut(&mut self) -> Option<&mut LlmClient> {
68        match self {
69            Self::Http(client) => Some(client),
70            Self::Codex(_) => None,
71        }
72    }
73}
74
75/// Builds all backends in a chain while sharing process-wide backend state.
76pub(crate) struct BackendFactory {
77    codex_runtime: Option<Result<CodexRuntime, LlmError>>,
78}
79
80impl BackendFactory {
81    pub(crate) fn new() -> Self {
82        Self {
83            codex_runtime: None,
84        }
85    }
86
87    pub(crate) fn build(&mut self, cfg: &LlmConfig) -> Result<ProviderBackend, LlmError> {
88        self.build_with(cfg, CodexRuntime::current)
89    }
90
91    fn build_with(
92        &mut self,
93        cfg: &LlmConfig,
94        load_codex: impl FnOnce() -> Result<CodexRuntime, LlmError>,
95    ) -> Result<ProviderBackend, LlmError> {
96        match cfg.backend {
97            BackendKind::Http => LlmClient::new(cfg).map(ProviderBackend::Http),
98            BackendKind::Codex => {
99                let settings = CodexSettings::from_config(cfg)?;
100                match self.codex_runtime.get_or_insert_with(load_codex) {
101                    Ok(runtime) => Ok(ProviderBackend::Codex(runtime.client(settings))),
102                    Err(err) => Err(err.clone()),
103                }
104            }
105            BackendKind::Unknown(ref name) => Err(LlmError::NotConfigured(format!(
106                "unknown LLM backend `{name}`"
107            ))),
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use std::cell::Cell;
115
116    use super::*;
117
118    #[test]
119    fn a_failed_codex_probe_is_reused_for_the_whole_chain() {
120        let cfg = LlmConfig {
121            backend: BackendKind::Codex,
122            model: Some("gpt-test".to_owned()),
123            ..LlmConfig::default()
124        };
125        let calls = Cell::new(0);
126        let mut factory = BackendFactory::new();
127
128        for _ in 0..2 {
129            let err = factory
130                .build_with(&cfg, || {
131                    calls.set(calls.get() + 1);
132                    Err(LlmError::NotConfigured("not logged in".to_owned()))
133                })
134                .expect_err("diagnostic fails");
135            assert!(err.to_string().contains("not logged in"));
136        }
137
138        assert_eq!(calls.get(), 1);
139    }
140
141    #[test]
142    fn invalid_codex_config_is_rejected_before_the_runtime_probe() {
143        let cfg = LlmConfig {
144            backend: BackendKind::Codex,
145            model: None,
146            ..LlmConfig::default()
147        };
148        let calls = Cell::new(0);
149        let mut factory = BackendFactory::new();
150
151        let err = factory
152            .build_with(&cfg, || {
153                calls.set(calls.get() + 1);
154                Err(LlmError::NotConfigured("probe should not run".to_owned()))
155            })
156            .expect_err("the missing model is invalid locally");
157
158        assert!(err.to_string().contains("model"), "got {err}");
159        assert_eq!(calls.get(), 0);
160    }
161}