1use 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#[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 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 pub fn location(&self) -> &str {
34 match self {
35 Self::Http(client) => client.endpoint(),
36 Self::Codex(_) => "codex://chatgpt",
37 }
38 }
39
40 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
75pub(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}