1use anyhow::{Context, Result};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Backend {
41 OpenAi,
43 Claude,
45 Opencode,
47 Codex,
49 Cursor,
51}
52
53impl Backend {
54 pub fn as_str(self) -> &'static str {
55 match self {
56 Backend::OpenAi => "openai",
57 Backend::Claude => "claude",
58 Backend::Opencode => "opencode",
59 Backend::Codex => "codex",
60 Backend::Cursor => "cursor-agent",
61 }
62 }
63
64 pub fn is_subprocess(self) -> bool {
66 !matches!(self, Backend::OpenAi)
67 }
68
69 pub fn from_value(value: &str) -> Backend {
73 match value.trim().to_ascii_lowercase().as_str() {
74 "claude" => Backend::Claude,
75 "codex" => Backend::Codex,
76 "opencode" => Backend::Opencode,
77 "cursor-agent" | "cursor" => Backend::Cursor,
78 _ => Backend::OpenAi,
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
84pub struct ModelConfig {
85 pub backend: Backend,
86 pub model_id: String,
89 pub base_url: String,
90 pub api_key: String,
91}
92
93impl ModelConfig {
94 pub fn same_endpoint(&self, other: &ModelConfig) -> bool {
98 self.backend == other.backend
99 && self.model_id == other.model_id
100 && self.base_url == other.base_url
101 }
102}
103
104#[derive(Debug, Clone)]
105pub struct ModelRoles {
106 pub planner: ModelConfig,
107 pub coding: ModelConfig,
108}
109
110fn normalize_base_url(raw: &str) -> String {
112 let mut s = raw.trim().trim_end_matches('/').to_string();
113 if let Some(without) = s.strip_suffix("/v1") {
114 s = without.trim_end_matches('/').to_string();
115 }
116 format!("{}/v1", s.trim_end_matches('/'))
117}
118
119fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
120 s.map(str::trim)
121 .filter(|s| !s.is_empty())
122 .map(ToString::to_string)
123}
124
125fn env_nonempty(key: &str) -> Option<String> {
127 std::env::var(key)
128 .ok()
129 .map(|s| s.trim().to_string())
130 .filter(|s| !s.is_empty())
131}
132
133fn normalize_checked(raw: &str) -> Result<String> {
135 let url = normalize_base_url(raw);
136 if url.is_empty() || url == "/v1" {
137 anyhow::bail!("base URL is set but empty after trim: {raw:?}");
138 }
139 Ok(url)
140}
141
142fn resolve_role(
151 role: &str,
152 backend_value: &str,
153 spec_model: Option<&str>,
154 api_key_env: &str,
155 fallback: Option<&ModelConfig>,
156) -> Result<ModelConfig> {
157 let backend = Backend::from_value(backend_value);
158
159 let model_id = trimmed_nonempty(spec_model).or_else(|| {
162 fallback
163 .filter(|f| f.backend == backend)
164 .map(|f| f.model_id.clone())
165 .filter(|s| !s.is_empty())
166 });
167
168 if backend.is_subprocess() {
169 return Ok(ModelConfig {
170 backend,
171 model_id: model_id.unwrap_or_default(),
172 base_url: String::new(),
173 api_key: String::new(),
174 });
175 }
176
177 let model_id = model_id
178 .with_context(|| format!("set the {role} model ([ai].model / [ai].coding_model)"))?;
179 let api_key = env_nonempty(api_key_env)
180 .or_else(|| fallback.map(|f| f.api_key.clone()))
181 .unwrap_or_default();
182 Ok(ModelConfig {
183 backend,
184 model_id,
185 base_url: normalize_checked(backend_value)?,
186 api_key,
187 })
188}
189
190pub fn resolve_roles_from_env(
199 spec_backend: Option<&str>,
200 spec_model: Option<&str>,
201 spec_coding_backend: Option<&str>,
202 spec_coding_model: Option<&str>,
203) -> Result<ModelRoles> {
204 let planner_backend = trimmed_nonempty(spec_backend)
205 .or_else(|| env_nonempty("SCX_FORGE_BACKEND"))
206 .context(
207 "set [ai].backend or $SCX_FORGE_BACKEND (an OpenAI-compatible API base URL, or one of: claude, codex, opencode, cursor-agent)",
208 )?;
209 let planner = resolve_role(
210 "planner",
211 &planner_backend,
212 spec_model,
213 "SCX_FORGE_API_KEY",
214 None,
215 )?;
216
217 let coding_backend = trimmed_nonempty(spec_coding_backend)
219 .or_else(|| env_nonempty("SCX_FORGE_CODING_BACKEND"))
220 .unwrap_or_else(|| planner_backend.clone());
221 let coding = resolve_role(
222 "coding",
223 &coding_backend,
224 spec_coding_model,
225 "SCX_FORGE_CODING_API_KEY",
226 Some(&planner),
227 )?;
228
229 Ok(ModelRoles { planner, coding })
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn from_value_selects_backend() {
238 assert_eq!(Backend::from_value("claude"), Backend::Claude);
239 assert_eq!(Backend::from_value(" CODEX "), Backend::Codex);
240 assert_eq!(Backend::from_value("opencode"), Backend::Opencode);
241 assert_eq!(Backend::from_value("cursor-agent"), Backend::Cursor);
242 assert_eq!(Backend::from_value("cursor"), Backend::Cursor);
243 assert_eq!(
244 Backend::from_value("http://localhost:11434/v1"),
245 Backend::OpenAi
246 );
247 }
248
249 #[test]
250 fn resolve_role_subprocess_passes_model_or_defaults() {
251 let cfg = resolve_role(
253 "planner",
254 "claude",
255 Some("haiku"),
256 "SCX_FORGE_API_KEY",
257 None,
258 )
259 .unwrap();
260 assert_eq!(cfg.backend, Backend::Claude);
261 assert_eq!(cfg.model_id, "haiku");
262 assert!(cfg.base_url.is_empty());
263
264 let cfg = resolve_role("planner", "codex", None, "SCX_FORGE_API_KEY", None).unwrap();
266 assert_eq!(cfg.backend, Backend::Codex);
267 assert!(cfg.model_id.is_empty());
268 }
269
270 #[test]
271 fn resolve_role_does_not_inherit_model_across_backends() {
272 let planner = resolve_role(
273 "planner",
274 "http://example.com",
275 Some("openai-model"),
276 "SCX_FORGE_API_KEY",
277 None,
278 )
279 .unwrap();
280 let coding = resolve_role(
282 "coding",
283 "claude",
284 None,
285 "SCX_FORGE_CODING_API_KEY",
286 Some(&planner),
287 )
288 .unwrap();
289 assert_eq!(coding.backend, Backend::Claude);
290 assert!(coding.model_id.is_empty());
291 }
292
293 #[test]
294 fn resolve_role_openai_resolves_url_and_inherits_model() {
295 let planner = resolve_role(
296 "planner",
297 "http://example.com",
298 Some("planner-model"),
299 "SCX_FORGE_API_KEY",
300 None,
301 )
302 .unwrap();
303 assert_eq!(planner.backend, Backend::OpenAi);
304 assert_eq!(planner.model_id, "planner-model");
305 assert_eq!(planner.base_url, "http://example.com/v1");
306
307 let coding = resolve_role(
309 "coding",
310 "http://coder.example.com/v1/",
311 None,
312 "SCX_FORGE_CODING_API_KEY",
313 Some(&planner),
314 )
315 .unwrap();
316 assert_eq!(coding.model_id, "planner-model");
317 assert_eq!(coding.base_url, "http://coder.example.com/v1");
318 }
319
320 #[test]
321 fn normalize_checked_appends_v1_and_rejects_empty() {
322 assert_eq!(
323 normalize_checked("https://example.com").unwrap(),
324 "https://example.com/v1"
325 );
326 assert_eq!(
327 normalize_checked("https://example.com/v1/").unwrap(),
328 "https://example.com/v1"
329 );
330 assert!(normalize_checked(" ").is_err());
331 }
332}