1use 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#[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 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 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 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
81pub(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}