Skip to main content

drep/llm/
backend.rs

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