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}
26
27impl ModelConfig {
28 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PromptStyle {
52 Anthropic,
54 OpenAi,
57 Chat,
61}
62
63pub 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#[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
98pub 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 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#[derive(Debug, Clone, PartialEq)]
157pub struct ModelConfig {
158 pub provider: String,
160 pub model: String,
162 pub max_output_tokens: u32,
164 pub max_run_tokens: u64,
166 pub cache_ttl: CacheTtl,
168 pub prices: Option<Prices>,
170 pub base_url: Option<String>,
172 pub api_key_env: Option<String>,
175 pub structured_native: bool,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq)]
183pub struct Prices {
184 pub input: f64,
186 pub output: f64,
188 pub cache_read: f64,
190 pub cache_write: f64,
192 pub cache_write_1h: f64,
194 pub one_hour: bool,
197}
198
199impl Prices {
200 #[must_use]
206 pub fn usd(&self, input: u64, output: u64, cache_read: u64, cache_write: u64) -> f64 {
207 #[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)] let usd = pricing.price(&usage) as f64 / 1_000_000.0;
227 usd
228 }
229}
230
231pub 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 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: 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 assert!((m("").usd(1_000_000, 0, 0, 1_000_000) - 3.75).abs() < 1e-9);
322 assert!((m("cache_ttl = \"1h\"\n").usd(1_000_000, 0, 0, 1_000_000) - 6.0).abs() < 1e-9);
324 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}