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 api_key_command: Option<Vec<String>>,
104 pub protocol: Option<String>,
105 pub reasoning_effort: Option<ReasoningEffort>,
106 pub temperature: Option<f32>,
107 pub max_tokens: Option<u32>,
108 pub timeout_secs: u64,
109 pub max_retries: u32,
110 pub max_concurrent: usize,
111}
112
113impl std::fmt::Debug for LlmConfig {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct("LlmConfig")
117 .field("enabled", &self.enabled)
118 .field("backend", &self.backend)
119 .field("endpoint", &self.endpoint)
120 .field("model", &self.model)
121 .field(
122 "api_key",
123 &self
124 .api_key
125 .as_ref()
126 .map(|_| "<redacted>")
127 .unwrap_or("None"),
128 )
129 .field(
130 "api_key_command",
131 &self
132 .api_key_command
133 .as_deref()
134 .map(describe_command)
135 .unwrap_or_else(|| "None".to_owned()),
136 )
137 .field("protocol", &self.protocol)
138 .field("reasoning_effort", &self.reasoning_effort)
139 .field("temperature", &self.temperature)
140 .field("max_tokens", &self.max_tokens)
141 .field("timeout_secs", &self.timeout_secs)
142 .field("max_retries", &self.max_retries)
143 .field("max_concurrent", &self.max_concurrent)
144 .finish()
145 }
146}
147
148fn describe_command(argv: &[String]) -> String {
157 match argv.split_first() {
158 None => "[]".to_owned(),
159 Some((program, rest)) => format!("[{program}, {} argument(s) redacted]", rest.len()),
160 }
161}
162
163impl Default for LlmConfig {
164 fn default() -> Self {
165 Self {
166 enabled: true,
167 backend: BackendKind::Http,
168 endpoint: None,
169 model: None,
170 api_key: None,
171 api_key_command: None,
172 protocol: None,
173 reasoning_effort: None,
174 temperature: None,
175 max_tokens: None,
176 timeout_secs: 60,
177 max_retries: 3,
178 max_concurrent: 3,
179 }
180 }
181}
182
183#[derive(Clone, Copy, Default)]
188pub(super) struct ExplicitFields {
189 endpoint: bool,
190 api_key: bool,
191 api_key_command: bool,
192 protocol: bool,
193 reasoning_effort: bool,
194 temperature: bool,
195 max_tokens: bool,
196 max_retries: bool,
197}
198
199pub(super) fn explicit_fields(tree: &Value) -> Vec<ExplicitFields> {
200 tree.get("llm")
201 .and_then(Value::as_array)
202 .map(|entries| {
203 entries
204 .iter()
205 .map(|entry| ExplicitFields {
206 endpoint: entry.get("endpoint").is_some(),
207 api_key: entry.get("api_key").is_some(),
208 api_key_command: entry.get("api_key_command").is_some(),
209 protocol: entry.get("protocol").is_some(),
210 reasoning_effort: entry.get("reasoning_effort").is_some(),
211 temperature: entry.get("temperature").is_some(),
212 max_tokens: entry.get("max_tokens").is_some(),
213 max_retries: entry.get("max_retries").is_some(),
214 })
215 .collect()
216 })
217 .unwrap_or_default()
218}
219
220pub(super) fn validate(
221 llm: &LlmConfig,
222 fields: ExplicitFields,
223 index: usize,
224) -> Result<(), ConfigError> {
225 if let BackendKind::Unknown(value) = &llm.backend {
226 return Err(ConfigError::UnknownBackend {
227 index,
228 value: value.clone(),
229 });
230 }
231 if let Some(ReasoningEffort::Unknown(value)) = &llm.reasoning_effort {
232 return Err(ConfigError::UnknownReasoningEffort {
233 index,
234 value: value.clone(),
235 });
236 }
237
238 match &llm.backend {
239 BackendKind::Http if fields.reasoning_effort => Err(ConfigError::BackendField {
240 index,
241 backend: "http",
242 field: "reasoning_effort",
243 }),
244 BackendKind::Http => Ok(()),
245 BackendKind::Codex
246 if llm
247 .model
248 .as_deref()
249 .is_none_or(|model| model.trim().is_empty()) =>
250 {
251 Err(ConfigError::BackendMissingField {
252 index,
253 backend: "codex",
254 field: "model",
255 })
256 }
257 BackendKind::Codex => {
258 for (present, field) in [
259 (fields.endpoint, "endpoint"),
260 (fields.api_key, "api_key"),
261 (fields.api_key_command, "api_key_command"),
262 (fields.protocol, "protocol"),
263 (fields.temperature, "temperature"),
264 (fields.max_tokens, "max_tokens"),
265 (fields.max_retries, "max_retries"),
266 ] {
267 if present {
268 return Err(ConfigError::BackendField {
269 index,
270 backend: "codex",
271 field,
272 });
273 }
274 }
275 Ok(())
276 }
277 BackendKind::Unknown(_) => unreachable!("handled above"),
278 }
279}