speccoder_runtime/substrate/
model_config.rs1use serde::Deserialize;
4
5pub 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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum PromptStyle {
53 Anthropic,
55 OpenAi,
58 Chat,
62}
63
64pub 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#[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
99pub 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 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#[derive(Debug, Clone, PartialEq)]
158pub struct ModelConfig {
159 pub provider: String,
161 pub model: String,
163 pub max_output_tokens: u32,
165 pub max_run_tokens: u64,
167 pub cache_ttl: CacheTtl,
169 pub prices: Option<Prices>,
171 pub base_url: Option<String>,
173 pub api_key_env: Option<String>,
176 pub structured_native: bool,
180 pub reasoning_summary: bool,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq)]
188pub struct Prices {
189 pub input: f64,
191 pub output: f64,
193 pub cache_read: f64,
195 pub cache_write: f64,
197 pub cache_write_1h: f64,
199 pub one_hour: bool,
202}
203
204impl Prices {
205 #[must_use]
211 pub fn usd(&self, input: u64, output: u64, cache_read: u64, cache_write: u64) -> f64 {
212 #[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)] let usd = pricing.price(&usage) as f64 / 1_000_000.0;
232 usd
233 }
234}
235
236pub 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 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: 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 assert!((m("").usd(1_000_000, 0, 0, 1_000_000) - 3.75).abs() < 1e-9);
328 assert!((m("cache_ttl = \"1h\"\n").usd(1_000_000, 0, 0, 1_000_000) - 6.0).abs() < 1e-9);
330 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}