1use serde::{Deserialize, Deserializer};
7use toml::Value;
8
9use super::ConfigError;
10
11#[derive(Clone, Debug, Default, PartialEq, Eq)]
13pub enum BackendKind {
14 #[default]
16 Http,
17 Codex,
19 Unknown(String),
21}
22
23impl BackendKind {
24 pub fn as_str(&self) -> &str {
26 match self {
27 Self::Http => "http",
28 Self::Codex => "codex",
29 Self::Unknown(value) => value,
30 }
31 }
32}
33
34impl<'de> Deserialize<'de> for BackendKind {
35 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
36 where
37 D: Deserializer<'de>,
38 {
39 let value = String::deserialize(deserializer)?;
40 Ok(match value.as_str() {
41 "http" => Self::Http,
42 "codex" => Self::Codex,
43 _ => Self::Unknown(value),
44 })
45 }
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum ReasoningEffort {
51 Minimal,
52 Low,
53 Medium,
54 High,
55 Xhigh,
56 Unknown(String),
58}
59
60impl ReasoningEffort {
61 pub fn as_str(&self) -> &str {
62 match self {
63 Self::Minimal => "minimal",
64 Self::Low => "low",
65 Self::Medium => "medium",
66 Self::High => "high",
67 Self::Xhigh => "xhigh",
68 Self::Unknown(value) => value,
69 }
70 }
71}
72
73impl<'de> Deserialize<'de> for ReasoningEffort {
74 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
75 where
76 D: Deserializer<'de>,
77 {
78 let value = String::deserialize(deserializer)?;
79 Ok(match value.as_str() {
80 "minimal" => Self::Minimal,
81 "low" => Self::Low,
82 "medium" => Self::Medium,
83 "high" => Self::High,
84 "xhigh" => Self::Xhigh,
85 _ => Self::Unknown(value),
86 })
87 }
88}
89
90#[derive(Deserialize)]
92#[serde(default)]
93pub struct LlmConfig {
94 pub enabled: bool,
95 pub backend: BackendKind,
96 pub endpoint: Option<String>,
97 pub model: Option<String>,
98 pub api_key: Option<String>,
99 pub protocol: Option<String>,
100 pub reasoning_effort: Option<ReasoningEffort>,
101 pub temperature: Option<f32>,
102 pub max_tokens: Option<u32>,
103 pub timeout_secs: u64,
104 pub max_retries: u32,
105 pub max_concurrent: usize,
106}
107
108impl std::fmt::Debug for LlmConfig {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 f.debug_struct("LlmConfig")
112 .field("enabled", &self.enabled)
113 .field("backend", &self.backend)
114 .field("endpoint", &self.endpoint)
115 .field("model", &self.model)
116 .field(
117 "api_key",
118 &self
119 .api_key
120 .as_ref()
121 .map(|_| "<redacted>")
122 .unwrap_or("None"),
123 )
124 .field("protocol", &self.protocol)
125 .field("reasoning_effort", &self.reasoning_effort)
126 .field("temperature", &self.temperature)
127 .field("max_tokens", &self.max_tokens)
128 .field("timeout_secs", &self.timeout_secs)
129 .field("max_retries", &self.max_retries)
130 .field("max_concurrent", &self.max_concurrent)
131 .finish()
132 }
133}
134
135impl Default for LlmConfig {
136 fn default() -> Self {
137 Self {
138 enabled: true,
139 backend: BackendKind::Http,
140 endpoint: None,
141 model: None,
142 api_key: None,
143 protocol: None,
144 reasoning_effort: None,
145 temperature: None,
146 max_tokens: None,
147 timeout_secs: 60,
148 max_retries: 3,
149 max_concurrent: 3,
150 }
151 }
152}
153
154#[derive(Clone, Copy, Default)]
159pub(super) struct ExplicitFields {
160 endpoint: bool,
161 api_key: bool,
162 protocol: bool,
163 reasoning_effort: bool,
164 temperature: bool,
165 max_tokens: bool,
166 max_retries: bool,
167}
168
169pub(super) fn explicit_fields(tree: &Value) -> Vec<ExplicitFields> {
170 tree.get("llm")
171 .and_then(Value::as_array)
172 .map(|entries| {
173 entries
174 .iter()
175 .map(|entry| ExplicitFields {
176 endpoint: entry.get("endpoint").is_some(),
177 api_key: entry.get("api_key").is_some(),
178 protocol: entry.get("protocol").is_some(),
179 reasoning_effort: entry.get("reasoning_effort").is_some(),
180 temperature: entry.get("temperature").is_some(),
181 max_tokens: entry.get("max_tokens").is_some(),
182 max_retries: entry.get("max_retries").is_some(),
183 })
184 .collect()
185 })
186 .unwrap_or_default()
187}
188
189pub(super) fn validate(
190 llm: &LlmConfig,
191 fields: ExplicitFields,
192 index: usize,
193) -> Result<(), ConfigError> {
194 if let BackendKind::Unknown(value) = &llm.backend {
195 return Err(ConfigError::UnknownBackend {
196 index,
197 value: value.clone(),
198 });
199 }
200 if let Some(ReasoningEffort::Unknown(value)) = &llm.reasoning_effort {
201 return Err(ConfigError::UnknownReasoningEffort {
202 index,
203 value: value.clone(),
204 });
205 }
206
207 match &llm.backend {
208 BackendKind::Http if fields.reasoning_effort => Err(ConfigError::BackendField {
209 index,
210 backend: "http",
211 field: "reasoning_effort",
212 }),
213 BackendKind::Http => Ok(()),
214 BackendKind::Codex
215 if llm
216 .model
217 .as_deref()
218 .is_none_or(|model| model.trim().is_empty()) =>
219 {
220 Err(ConfigError::BackendMissingField {
221 index,
222 backend: "codex",
223 field: "model",
224 })
225 }
226 BackendKind::Codex => {
227 for (present, field) in [
228 (fields.endpoint, "endpoint"),
229 (fields.api_key, "api_key"),
230 (fields.protocol, "protocol"),
231 (fields.temperature, "temperature"),
232 (fields.max_tokens, "max_tokens"),
233 (fields.max_retries, "max_retries"),
234 ] {
235 if present {
236 return Err(ConfigError::BackendField {
237 index,
238 backend: "codex",
239 field,
240 });
241 }
242 }
243 Ok(())
244 }
245 BackendKind::Unknown(_) => unreachable!("handled above"),
246 }
247}