Skip to main content

speccoder_runtime/substrate/
model_config.rs

1//! `[model]` in `speccoder.toml`.
2
3use serde::Deserialize;
4
5/// Prompt-cache lifetime for breakpoints: agentplane's, read as `5m` or `1h`
6/// (one hour survives a run paused for an approval).
7pub use agentplane::model::CacheTtl;
8
9#[derive(Debug, Deserialize)]
10#[serde(deny_unknown_fields)]
11struct Raw {
12    provider: String,
13    model: String,
14    max_output_tokens: Option<u32>,
15    max_run_tokens: Option<u64>,
16    cache_ttl: Option<CacheTtl>,
17    price_input_per_mtok: Option<f64>,
18    price_output_per_mtok: Option<f64>,
19    price_cache_read_per_mtok: Option<f64>,
20    price_cache_write_per_mtok: Option<f64>,
21    price_cache_write_1h_per_mtok: Option<f64>,
22    base_url: Option<String>,
23    api_key_env: Option<String>,
24    structured: Option<String>,
25    reasoning_summary: Option<bool>,
26}
27
28impl ModelConfig {
29    /// The environment variable holding this provider's API key.
30    #[must_use]
31    pub fn key_variable(&self) -> &str {
32        match self.provider.as_str() {
33            "openai" => "OPENAI_API_KEY",
34            "openai-compatible" => self.api_key_env.as_deref().unwrap_or(""),
35            _ => "ANTHROPIC_API_KEY",
36        }
37    }
38
39    /// How prompts are laid out for this provider.
40    #[must_use]
41    pub fn prompt_style(&self) -> PromptStyle {
42        match self.provider.as_str() {
43            "openai" => PromptStyle::OpenAi,
44            "openai-compatible" => PromptStyle::Chat,
45            _ => PromptStyle::Anthropic,
46        }
47    }
48}
49
50/// Provider prompt dialects (same content, same stability order).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum PromptStyle {
53    /// Messages API: system blocks and content blocks with `cache_control` breakpoints.
54    Anthropic,
55    /// Responses API: `system` → `instructions` (a string), `input` items;
56    /// prefix caching is automatic, so the order alone does the work.
57    OpenAi,
58    /// Chat Completions (`openai-compatible`: Ollama, vLLM, llama.cpp, LM
59    /// Studio, …): the Anthropic layout without `cache_control` breakpoints,
60    /// which such servers do not know.
61    Chat,
62}
63
64/// Removes `cache_control` breakpoints from a prompt (for [`PromptStyle::Chat`]).
65pub fn strip_cache_control(v: &mut serde_json::Value) {
66    match v {
67        serde_json::Value::Object(o) => {
68            o.remove("cache_control");
69            o.values_mut().for_each(strip_cache_control);
70        }
71        serde_json::Value::Array(a) => a.iter_mut().for_each(strip_cache_control),
72        _ => {}
73    }
74}
75
76/// Why the model could not be opened, as a result: a missing key is
77/// `KEY-MISSING` with the exact `export` (and the other ways to get a
78/// model), anything else `CONFIG-INVALID`. Every command that calls a model
79/// says it the same way.
80#[must_use]
81pub fn refused(command: &str, error: &str) -> speccoder_core::CommandResult {
82    use speccoder_core::{CommandResult, CommandStatus};
83    let missing = error.split_whitespace().next().filter(|v| {
84        error.contains("is not set")
85            && v.chars()
86                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
87    });
88    match missing {
89        Some(variable) => CommandResult::new(command, CommandStatus::ConfigError, error)
90            .with_code("KEY-MISSING")
91            .with_fix(vec![
92                format!("export {variable}=…"),
93                "→ or another provider, or a local model: speccoder config set model.provider openai-compatible (see Settings › Model)".to_owned(),
94            ]),
95        None => CommandResult::new(command, CommandStatus::ConfigError, error).with_code("CONFIG-INVALID"),
96    }
97}
98
99/// The model driver for `model`, and the secret to redact from messages.
100/// Keys come only from the environment; a local `openai-compatible` server
101/// needs none unless `api_key_env` names one.
102pub fn open_driver(
103    model: &ModelConfig,
104) -> Result<
105    (
106        std::sync::Arc<dyn agentplane::model::ModelProvider>,
107        Option<String>,
108    ),
109    String,
110> {
111    let variable = model.key_variable().to_owned();
112    let key =
113        if variable.is_empty() {
114            None
115        } else {
116            Some(std::env::var(&variable).ok().filter(|k| !k.is_empty()).ok_or_else(|| {
117            format!("{variable} is not set; speccoder reads the key only from the environment")
118        })?)
119        };
120    let redact = |e: String| match &key {
121        Some(k) if !k.is_empty() => e.replace(k.as_str(), "[redacted]"),
122        _ => e,
123    };
124    let driver: std::sync::Arc<dyn agentplane::model::ModelProvider> = match model.provider.as_str()
125    {
126        "openai" => {
127            let mut driver =
128                agentplane::model::openai::OpenAi::new(key.clone().unwrap_or_default())
129                    .map_err(|e| redact(e.to_string()))?;
130            // A gateway in front of the Responses API.
131            if let Some(base) = &model.base_url {
132                driver = driver.base(base.trim_end_matches("/v1").to_owned());
133            }
134            std::sync::Arc::new(driver)
135        }
136        "openai-compatible" => {
137            let base = model.base_url.clone().unwrap_or_default();
138            let mut driver = agentplane::model::chat_completions::ChatCompletions::new(base)
139                .map_err(|e| redact(e.to_string()))?;
140            if let Some(k) = &key {
141                driver = driver.bearer(k.clone());
142            }
143            if model.structured_native {
144                driver = driver.structured_via(agentplane::model::SchemaMode::Native);
145            }
146            std::sync::Arc::new(driver)
147        }
148        _ => std::sync::Arc::new(
149            agentplane::model::anthropic::Anthropic::new(key.clone().unwrap_or_default())
150                .map_err(|e| redact(e.to_string()))?,
151        ),
152    };
153    Ok((driver, key))
154}
155
156/// The model speccoder calls.
157#[derive(Debug, Clone, PartialEq)]
158pub struct ModelConfig {
159    /// Driver name: `anthropic` or `openai`.
160    pub provider: String,
161    /// The provider's model id, pinned exactly.
162    pub model: String,
163    /// Per-call output ceiling.
164    pub max_output_tokens: u32,
165    /// Token budget of one run.
166    pub max_run_tokens: u64,
167    /// Lifetime of prompt-cache breakpoints.
168    pub cache_ttl: CacheTtl,
169    /// USD per million tokens, if configured (`speccoder report --cost`).
170    pub prices: Option<Prices>,
171    /// `openai-compatible`: the server, e.g. `http://localhost:11434/v1`.
172    pub base_url: Option<String>,
173    /// `openai-compatible`: the environment variable holding a key, if the
174    /// server wants one.
175    pub api_key_env: Option<String>,
176    /// `openai-compatible`: the server enforces `json_schema` output
177    /// (`structured = "native"`, the default); `"tool"` carries it in a
178    /// forced tool instead, which works only for steps without tools.
179    pub structured_native: bool,
180    /// Ask for a summary of the model's reasoning on each attempt turn
181    /// (`reasoning_summary`, default on): shown as its account, never as
182    /// evidence. Providers without a way to ask (Chat Completions) give none.
183    pub reasoning_summary: bool,
184}
185
186/// USD per million tokens.
187#[derive(Debug, Clone, Copy, PartialEq)]
188pub struct Prices {
189    /// Input tokens neither written to nor read from the cache.
190    pub input: f64,
191    /// Output tokens.
192    pub output: f64,
193    /// Input tokens read from the cache.
194    pub cache_read: f64,
195    /// Input tokens written to the five-minute cache.
196    pub cache_write: f64,
197    /// Input tokens written to the one-hour cache.
198    pub cache_write_1h: f64,
199    /// Whether this model's cache writes go to the one-hour tier
200    /// (`cache_ttl = "1h"`).
201    pub one_hour: bool,
202}
203
204impl Prices {
205    /// USD for these token counts (`input` counts every input token, cached
206    /// ones included), priced by agentplane's [`Pricing`]: rounded up to a
207    /// micro-dollar, cache writes at their own rate.
208    ///
209    /// [`Pricing`]: agentplane::model::Pricing
210    #[must_use]
211    pub fn usd(&self, input: u64, output: u64, cache_read: u64, cache_write: u64) -> f64 {
212        // Rates in micro-dollars per million tokens: integers, as agentplane prices.
213        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
214        let micro = |usd: f64| (usd * 1_000_000.0).round().max(0.0) as u64;
215        let pricing = agentplane::model::Pricing {
216            input: micro(self.input),
217            output: micro(self.output),
218            cache_read: micro(self.cache_read),
219            cache_write: micro(self.cache_write),
220            cache_write_1h: micro(self.cache_write_1h),
221        };
222        let usage = agentplane::model::Usage {
223            input_tokens: input,
224            output_tokens: output,
225            cache_read_tokens: cache_read,
226            cache_write_tokens: cache_write,
227            cache_write_1h_tokens: if self.one_hour { cache_write } else { 0 },
228            ..agentplane::model::Usage::default()
229        };
230        #[allow(clippy::cast_precision_loss)] // micro-dollars far below 2^52
231        let usd = pricing.price(&usage) as f64 / 1_000_000.0;
232        usd
233    }
234}
235
236/// Reads `[model]` from the configuration text; `None` when absent.
237pub fn parse(text: &str) -> Result<Option<ModelConfig>, String> {
238    let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
239    let Some(value) = table.get("model") else {
240        return Ok(None);
241    };
242    let raw: Raw = value
243        .clone()
244        .try_into()
245        .map_err(|e| format!("[model]: {e}"))?;
246    if !matches!(
247        raw.provider.as_str(),
248        "anthropic" | "openai" | "openai-compatible"
249    ) {
250        return Err(format!(
251            "[model]: provider `{}` is not supported (anthropic, openai and openai-compatible are)",
252            raw.provider
253        ));
254    }
255    if raw.provider == "openai-compatible" && raw.base_url.as_deref().is_none_or(str::is_empty) {
256        return Err(
257            "[model]: provider `openai-compatible` needs `base_url`, e.g. \"http://localhost:11434/v1\""
258                .to_owned(),
259        );
260    }
261    // Native by default: a forced-tool schema cannot be combined with the
262    // tools an attempt declares, so only tool-less steps could use it.
263    let structured_native = match raw.structured.as_deref() {
264        None | Some("native") => true,
265        Some("tool") => false,
266        Some(other) => {
267            return Err(format!(
268                "[model]: structured = \"{other}\" (native or tool)"
269            ));
270        }
271    };
272    if raw.model.trim().is_empty() {
273        return Err("[model]: `model` is empty".to_owned());
274    }
275    let anthropic = raw.provider == "anthropic";
276    Ok(Some(ModelConfig {
277        provider: raw.provider,
278        model: raw.model,
279        reasoning_summary: raw.reasoning_summary.unwrap_or(true),
280        max_output_tokens: raw.max_output_tokens.unwrap_or(4096),
281        max_run_tokens: raw.max_run_tokens.unwrap_or(200_000),
282        cache_ttl: raw.cache_ttl.unwrap_or(CacheTtl::FiveMinutes),
283        // Prices are the user's to state (they change, and differ by
284        // contract); without input and output prices, cost is tokens only.
285        // Unstated cache-write rates follow the provider's published rule:
286        // Anthropic bills 1.25× input for the five-minute cache and 2× for
287        // the hour; OpenAI and compatible servers bill writes as input.
288        prices: match (raw.price_input_per_mtok, raw.price_output_per_mtok) {
289            (Some(input), Some(output)) => Some(Prices {
290                input,
291                output,
292                cache_read: raw.price_cache_read_per_mtok.unwrap_or(input),
293                cache_write: raw.price_cache_write_per_mtok.unwrap_or(if anthropic {
294                    input * 1.25
295                } else {
296                    input
297                }),
298                cache_write_1h: raw.price_cache_write_1h_per_mtok.unwrap_or(if anthropic {
299                    input * 2.0
300                } else {
301                    input
302                }),
303                one_hour: raw.cache_ttl == Some(CacheTtl::OneHour),
304            }),
305            _ => None,
306        },
307        base_url: raw.base_url,
308        api_key_env: raw.api_key_env,
309        structured_native,
310    }))
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn cache_writes_are_priced_at_their_own_rate() {
319        let m = |extra: &str| {
320            parse(&format!("[model]\nprovider = \"anthropic\"\nmodel = \"m\"\nprice_input_per_mtok = 3.0\nprice_output_per_mtok = 15.0\nprice_cache_read_per_mtok = 0.3\n{extra}"))
321                .unwrap()
322                .unwrap()
323                .prices
324                .unwrap()
325        };
326        // 1M written to the five-minute cache: 1.25 × $3 (input × 1.25 unless stated).
327        assert!((m("").usd(1_000_000, 0, 0, 1_000_000) - 3.75).abs() < 1e-9);
328        // The one-hour tier: 2 × $3.
329        assert!((m("cache_ttl = \"1h\"\n").usd(1_000_000, 0, 0, 1_000_000) - 6.0).abs() < 1e-9);
330        // Stated rates win; reads and fresh input as before.
331        let p = m("price_cache_write_per_mtok = 4.0\n");
332        assert!(
333            (p.usd(2_000_000, 1_000_000, 500_000, 500_000) - (3.0 + 15.0 + 0.15 + 2.0)).abs()
334                < 1e-9
335        );
336    }
337
338    #[test]
339    fn parses_model_with_defaults() {
340        let m = parse("[model]\nprovider = \"anthropic\"\nmodel = \"claude-sonnet-5-5\"\n")
341            .unwrap()
342            .unwrap();
343        assert_eq!(m.model, "claude-sonnet-5-5");
344        assert_eq!(m.max_output_tokens, 4096);
345        assert_eq!(m.cache_ttl, CacheTtl::FiveMinutes);
346        let one_hour =
347            parse("[model]\nprovider = \"anthropic\"\nmodel = \"m\"\ncache_ttl = \"1h\"\n")
348                .unwrap()
349                .unwrap();
350        assert_eq!(one_hour.cache_ttl, CacheTtl::OneHour);
351    }
352
353    #[test]
354    fn rejects_unknown_keys_and_providers() {
355        assert!(parse("[model]\nprovider = \"mistral\"\nmodel = \"m\"\n").is_err());
356        let openai = parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
357            .unwrap()
358            .unwrap();
359        assert_eq!(openai.key_variable(), "OPENAI_API_KEY");
360        assert_eq!(openai.prompt_style(), PromptStyle::OpenAi);
361        assert!(parse("[model]\nprovider = \"anthropic\"\nmodel = \"m\"\nkey = \"x\"\n").is_err());
362        assert_eq!(parse("[checks.t]\nargv = [\"true\"]\n").unwrap(), None);
363    }
364
365    #[test]
366    fn an_openai_compatible_server_needs_a_base_url_and_a_key_only_if_named() {
367        let m = parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"qwen3\"\nbase_url = \"http://localhost:11434/v1\"\n")
368            .unwrap()
369            .unwrap();
370        assert_eq!(m.prompt_style(), PromptStyle::Chat);
371        assert!(m.structured_native, "native by default");
372        assert_eq!(m.key_variable(), "");
373        assert!(open_driver(&m).is_ok_and(|(_, key)| key.is_none()));
374        assert!(parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"x\"\n").is_err());
375        let keyed = parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"x\"\nbase_url = \"https://router.example/v1\"\napi_key_env = \"SPECCODER_TEST_NO_SUCH_KEY\"\n")
376            .unwrap()
377            .unwrap();
378        assert!(
379            open_driver(&keyed)
380                .err()
381                .unwrap_or_default()
382                .contains("SPECCODER_TEST_NO_SUCH_KEY is not set")
383        );
384        let mut v = serde_json::json!({ "messages": [{ "content": [{ "type": "text", "text": "x", "cache_control": { "type": "ephemeral" } }] }] });
385        strip_cache_control(&mut v);
386        assert!(!v.to_string().contains("cache_control"));
387    }
388}