use serde::Deserialize;
pub use agentplane::model::CacheTtl;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Raw {
provider: String,
model: String,
max_output_tokens: Option<u32>,
max_run_tokens: Option<u64>,
cache_ttl: Option<CacheTtl>,
price_input_per_mtok: Option<f64>,
price_output_per_mtok: Option<f64>,
price_cache_read_per_mtok: Option<f64>,
price_cache_write_per_mtok: Option<f64>,
price_cache_write_1h_per_mtok: Option<f64>,
base_url: Option<String>,
api_key_env: Option<String>,
structured: Option<String>,
reasoning_summary: Option<bool>,
}
impl ModelConfig {
#[must_use]
pub fn key_variable(&self) -> &str {
match self.provider.as_str() {
"openai" => "OPENAI_API_KEY",
"openai-compatible" => self.api_key_env.as_deref().unwrap_or(""),
_ => "ANTHROPIC_API_KEY",
}
}
#[must_use]
pub fn prompt_style(&self) -> PromptStyle {
match self.provider.as_str() {
"openai" => PromptStyle::OpenAi,
"openai-compatible" => PromptStyle::Chat,
_ => PromptStyle::Anthropic,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptStyle {
Anthropic,
OpenAi,
Chat,
}
pub fn strip_cache_control(v: &mut serde_json::Value) {
match v {
serde_json::Value::Object(o) => {
o.remove("cache_control");
o.values_mut().for_each(strip_cache_control);
}
serde_json::Value::Array(a) => a.iter_mut().for_each(strip_cache_control),
_ => {}
}
}
#[must_use]
pub fn refused(command: &str, error: &str) -> speccoder_core::CommandResult {
use speccoder_core::{CommandResult, CommandStatus};
let missing = error.split_whitespace().next().filter(|v| {
error.contains("is not set")
&& v.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
});
match missing {
Some(variable) => CommandResult::new(command, CommandStatus::ConfigError, error)
.with_code("KEY-MISSING")
.with_fix(vec![
format!("export {variable}=…"),
"→ or another provider, or a local model: speccoder config set model.provider openai-compatible (see Settings › Model)".to_owned(),
]),
None => CommandResult::new(command, CommandStatus::ConfigError, error).with_code("CONFIG-INVALID"),
}
}
pub fn open_driver(
model: &ModelConfig,
) -> Result<
(
std::sync::Arc<dyn agentplane::model::ModelProvider>,
Option<String>,
),
String,
> {
let variable = model.key_variable().to_owned();
let key =
if variable.is_empty() {
None
} else {
Some(std::env::var(&variable).ok().filter(|k| !k.is_empty()).ok_or_else(|| {
format!("{variable} is not set; speccoder reads the key only from the environment")
})?)
};
let redact = |e: String| match &key {
Some(k) if !k.is_empty() => e.replace(k.as_str(), "[redacted]"),
_ => e,
};
let driver: std::sync::Arc<dyn agentplane::model::ModelProvider> = match model.provider.as_str()
{
"openai" => {
let mut driver =
agentplane::model::openai::OpenAi::new(key.clone().unwrap_or_default())
.map_err(|e| redact(e.to_string()))?;
if let Some(base) = &model.base_url {
driver = driver.base(base.trim_end_matches("/v1").to_owned());
}
std::sync::Arc::new(driver)
}
"openai-compatible" => {
let base = model.base_url.clone().unwrap_or_default();
let mut driver = agentplane::model::chat_completions::ChatCompletions::new(base)
.map_err(|e| redact(e.to_string()))?;
if let Some(k) = &key {
driver = driver.bearer(k.clone());
}
if model.structured_native {
driver = driver.structured_via(agentplane::model::SchemaMode::Native);
}
std::sync::Arc::new(driver)
}
_ => std::sync::Arc::new(
agentplane::model::anthropic::Anthropic::new(key.clone().unwrap_or_default())
.map_err(|e| redact(e.to_string()))?,
),
};
Ok((driver, key))
}
#[derive(Debug, Clone, PartialEq)]
pub struct ModelConfig {
pub provider: String,
pub model: String,
pub max_output_tokens: u32,
pub max_run_tokens: u64,
pub cache_ttl: CacheTtl,
pub prices: Option<Prices>,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
pub structured_native: bool,
pub reasoning_summary: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Prices {
pub input: f64,
pub output: f64,
pub cache_read: f64,
pub cache_write: f64,
pub cache_write_1h: f64,
pub one_hour: bool,
}
impl Prices {
#[must_use]
pub fn usd(&self, input: u64, output: u64, cache_read: u64, cache_write: u64) -> f64 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let micro = |usd: f64| (usd * 1_000_000.0).round().max(0.0) as u64;
let pricing = agentplane::model::Pricing {
input: micro(self.input),
output: micro(self.output),
cache_read: micro(self.cache_read),
cache_write: micro(self.cache_write),
cache_write_1h: micro(self.cache_write_1h),
};
let usage = agentplane::model::Usage {
input_tokens: input,
output_tokens: output,
cache_read_tokens: cache_read,
cache_write_tokens: cache_write,
cache_write_1h_tokens: if self.one_hour { cache_write } else { 0 },
..agentplane::model::Usage::default()
};
#[allow(clippy::cast_precision_loss)] let usd = pricing.price(&usage) as f64 / 1_000_000.0;
usd
}
}
pub fn parse(text: &str) -> Result<Option<ModelConfig>, String> {
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let Some(value) = table.get("model") else {
return Ok(None);
};
let raw: Raw = value
.clone()
.try_into()
.map_err(|e| format!("[model]: {e}"))?;
if !matches!(
raw.provider.as_str(),
"anthropic" | "openai" | "openai-compatible"
) {
return Err(format!(
"[model]: provider `{}` is not supported (anthropic, openai and openai-compatible are)",
raw.provider
));
}
if raw.provider == "openai-compatible" && raw.base_url.as_deref().is_none_or(str::is_empty) {
return Err(
"[model]: provider `openai-compatible` needs `base_url`, e.g. \"http://localhost:11434/v1\""
.to_owned(),
);
}
let structured_native = match raw.structured.as_deref() {
None | Some("native") => true,
Some("tool") => false,
Some(other) => {
return Err(format!(
"[model]: structured = \"{other}\" (native or tool)"
));
}
};
if raw.model.trim().is_empty() {
return Err("[model]: `model` is empty".to_owned());
}
let anthropic = raw.provider == "anthropic";
Ok(Some(ModelConfig {
provider: raw.provider,
model: raw.model,
reasoning_summary: raw.reasoning_summary.unwrap_or(true),
max_output_tokens: raw.max_output_tokens.unwrap_or(4096),
max_run_tokens: raw.max_run_tokens.unwrap_or(200_000),
cache_ttl: raw.cache_ttl.unwrap_or(CacheTtl::FiveMinutes),
prices: match (raw.price_input_per_mtok, raw.price_output_per_mtok) {
(Some(input), Some(output)) => Some(Prices {
input,
output,
cache_read: raw.price_cache_read_per_mtok.unwrap_or(input),
cache_write: raw.price_cache_write_per_mtok.unwrap_or(if anthropic {
input * 1.25
} else {
input
}),
cache_write_1h: raw.price_cache_write_1h_per_mtok.unwrap_or(if anthropic {
input * 2.0
} else {
input
}),
one_hour: raw.cache_ttl == Some(CacheTtl::OneHour),
}),
_ => None,
},
base_url: raw.base_url,
api_key_env: raw.api_key_env,
structured_native,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_writes_are_priced_at_their_own_rate() {
let m = |extra: &str| {
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}"))
.unwrap()
.unwrap()
.prices
.unwrap()
};
assert!((m("").usd(1_000_000, 0, 0, 1_000_000) - 3.75).abs() < 1e-9);
assert!((m("cache_ttl = \"1h\"\n").usd(1_000_000, 0, 0, 1_000_000) - 6.0).abs() < 1e-9);
let p = m("price_cache_write_per_mtok = 4.0\n");
assert!(
(p.usd(2_000_000, 1_000_000, 500_000, 500_000) - (3.0 + 15.0 + 0.15 + 2.0)).abs()
< 1e-9
);
}
#[test]
fn parses_model_with_defaults() {
let m = parse("[model]\nprovider = \"anthropic\"\nmodel = \"claude-sonnet-5-5\"\n")
.unwrap()
.unwrap();
assert_eq!(m.model, "claude-sonnet-5-5");
assert_eq!(m.max_output_tokens, 4096);
assert_eq!(m.cache_ttl, CacheTtl::FiveMinutes);
let one_hour =
parse("[model]\nprovider = \"anthropic\"\nmodel = \"m\"\ncache_ttl = \"1h\"\n")
.unwrap()
.unwrap();
assert_eq!(one_hour.cache_ttl, CacheTtl::OneHour);
}
#[test]
fn rejects_unknown_keys_and_providers() {
assert!(parse("[model]\nprovider = \"mistral\"\nmodel = \"m\"\n").is_err());
let openai = parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
.unwrap()
.unwrap();
assert_eq!(openai.key_variable(), "OPENAI_API_KEY");
assert_eq!(openai.prompt_style(), PromptStyle::OpenAi);
assert!(parse("[model]\nprovider = \"anthropic\"\nmodel = \"m\"\nkey = \"x\"\n").is_err());
assert_eq!(parse("[checks.t]\nargv = [\"true\"]\n").unwrap(), None);
}
#[test]
fn an_openai_compatible_server_needs_a_base_url_and_a_key_only_if_named() {
let m = parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"qwen3\"\nbase_url = \"http://localhost:11434/v1\"\n")
.unwrap()
.unwrap();
assert_eq!(m.prompt_style(), PromptStyle::Chat);
assert!(m.structured_native, "native by default");
assert_eq!(m.key_variable(), "");
assert!(open_driver(&m).is_ok_and(|(_, key)| key.is_none()));
assert!(parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"x\"\n").is_err());
let keyed = parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"x\"\nbase_url = \"https://router.example/v1\"\napi_key_env = \"SPECCODER_TEST_NO_SUCH_KEY\"\n")
.unwrap()
.unwrap();
assert!(
open_driver(&keyed)
.err()
.unwrap_or_default()
.contains("SPECCODER_TEST_NO_SUCH_KEY is not set")
);
let mut v = serde_json::json!({ "messages": [{ "content": [{ "type": "text", "text": "x", "cache_control": { "type": "ephemeral" } }] }] });
strip_cache_control(&mut v);
assert!(!v.to_string().contains("cache_control"));
}
}