Skip to main content

scx_forge_agent/
config.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! Model configuration from the spec and `SCX_FORGE_*` environment variables,
4//! and the backend the agent uses to drive edits.
5//!
6//! Each role's backend is selected by its `backend` field. `[ai].backend` (and
7//! `[ai].coding_backend`) accept either an OpenAI-compatible URL or one of the
8//! special keywords `claude`, `codex`, `opencode`, `cursor-agent`, which select
9//! that subprocess CLI for the role. The planner and coding roles resolve
10//! independently, so they can use different backends (e.g. an `openai` planner
11//! with a `claude` coder).
12//!
13//! For the `openai` backend the built-in HTTP tool loop talks to an
14//! OpenAI-compatible endpoint:
15//! - planner: `[ai].backend` (or `$SCX_FORGE_BACKEND`), `$SCX_FORGE_API_KEY`
16//!   (optional, omit for keyless local backends like Ollama), `[ai].model`.
17//! - coding:  `[ai].coding_backend` (or `$SCX_FORGE_CODING_BACKEND`, else the
18//!   planner backend), `$SCX_FORGE_CODING_API_KEY` (else `$SCX_FORGE_API_KEY`),
19//!   `[ai].coding_model` (else `[ai].model`).
20//!
21//! Spec values take precedence over the matching env var. The `SCX_FORGE_*`
22//! names are deliberately agent-specific so they do not collide with the
23//! `OPENAI_*` vars that subprocess CLIs (e.g. codex) read for their own auth.
24//!
25//! For the subprocess backends (`claude`, `opencode`, `codex`, `cursor-agent`)
26//! the agent shells out to that CLI to make the edits. Those CLIs use their own
27//! native auth/config (each reads whatever it documents, e.g. `OPENAI_*` for
28//! codex, `ANTHROPIC_*` for claude, `CURSOR_API_KEY` for cursor-agent) from the
29//! inherited environment. `[ai].model` /
30//! `[ai].coding_model` are still honored and passed to the CLI as its model id
31//! (e.g. `haiku` for claude); leave them unset to use the CLI's own default.
32//! When a subprocess planner and the coder are the same backend+model it plans
33//! and edits in one shot; when they differ the planner runs read-only in plan
34//! mode (see [`crate::agent_cli::Mode`]) and hands its plan to the coder.
35
36use anyhow::{Context, Result};
37
38/// Which mechanism the agent uses to produce each round's edits.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Backend {
41    /// Built-in HTTP chat/completions tool loop (default).
42    OpenAi,
43    /// Shell out to the `claude` CLI.
44    Claude,
45    /// Shell out to the `opencode` CLI (`opencode run`).
46    Opencode,
47    /// Shell out to the `codex` CLI (`codex exec`).
48    Codex,
49    /// Shell out to the `cursor-agent` CLI (`cursor-agent --print`).
50    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    /// True for the CLI-subprocess backends (everything except the built-in HTTP loop).
65    pub fn is_subprocess(self) -> bool {
66        !matches!(self, Backend::OpenAi)
67    }
68
69    /// Map a `backend` field value to a backend: the keywords `claude`, `codex`,
70    /// `opencode`, and `cursor-agent` (also `cursor`) select that subprocess CLI;
71    /// anything else (a URL) is the built-in `openai` backend.
72    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    /// Model id. Required for `openai`; optional for subprocess backends (empty =
87    /// let the CLI choose its own default).
88    pub model_id: String,
89    pub base_url: String,
90    pub api_key: String,
91}
92
93impl ModelConfig {
94    /// True when two roles resolve to the same backend, model, and endpoint. When
95    /// the planner and coder are identical there is no point running a separate
96    /// planning turn: a subprocess planner already plans and edits in one shot.
97    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
110/// Strip trailing slashes and an optional `/v1`, then append `/v1`.
111fn 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
125/// Read and trim an environment variable, returning `None` when unset or empty.
126fn 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
133/// Normalize a base URL and reject one that is empty after trimming.
134fn 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
142/// Resolve one role's [`ModelConfig`] from its `backend` field value.
143///
144/// The model id comes from `spec_model`, else it inherits `fallback` (the
145/// planner) when that role runs on the *same* backend. The model is optional for
146/// subprocess CLIs (empty -> the CLI's own default; a value like `haiku` is
147/// passed to the CLI) and required for the `openai` backend. For `openai` the
148/// URL and API key are also resolved; the coding API key falls back to the
149/// planner's.
150fn 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    // Inherit the planner's model only when this role uses the same backend; a
160    // model id is backend-specific, so it must not leak across backends.
161    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
190/// Resolve both model roles. Each role's backend is selected by its `backend`
191/// value (a URL -> `openai`; the keywords `claude`/`codex`/`opencode` -> that
192/// subprocess CLI), so the planner and coder can run on different backends.
193///
194/// - planner: `backend` (or `$SCX_FORGE_BACKEND`), `$SCX_FORGE_API_KEY`, `model`.
195/// - coding:  `coding_backend` (or `$SCX_FORGE_CODING_BACKEND`, else the planner
196///   backend value), `$SCX_FORGE_CODING_API_KEY` (else `$SCX_FORGE_API_KEY`),
197///   `coding_model` (else `model`).
198pub 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    // Coding backend value: spec, env, else inherit the planner's value.
218    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        // An explicit model id is forwarded to the CLI.
252        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        // No model id -> empty, i.e. the CLI's own default.
265        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        // A claude coder must not inherit the openai planner's model id.
281        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        // Coding inherits the planner's model when its own is unset.
308        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}