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