1use 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 region: Option<String>,
27}
28
29impl ModelConfig {
30 #[must_use]
32 pub fn key_variable(&self) -> &str {
33 match self.provider.as_str() {
34 "openai" => "OPENAI_API_KEY",
35 "openai-compatible" => self.api_key_env.as_deref().unwrap_or(""),
36 "bedrock" => "AWS_BEARER_TOKEN_BEDROCK",
38 _ => "ANTHROPIC_API_KEY",
39 }
40 }
41
42 #[must_use]
46 pub fn caches(&self) -> bool {
47 self.provider != "bedrock" || self.model.contains("anthropic.")
48 }
49
50 #[must_use]
52 pub fn prompt_style(&self) -> PromptStyle {
53 match self.provider.as_str() {
54 "openai" => PromptStyle::OpenAi,
55 "openai-compatible" => PromptStyle::Chat,
56 "bedrock" => PromptStyle::Parts {
57 caches: self.caches(),
58 },
59 _ => PromptStyle::Anthropic,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum PromptStyle {
67 Anthropic,
69 OpenAi,
72 Chat,
76 Parts {
80 caches: bool,
82 },
83}
84
85pub fn strip_cache_control(v: &mut serde_json::Value) {
87 match v {
88 serde_json::Value::Object(o) => {
89 o.remove("cache_control");
90 o.values_mut().for_each(strip_cache_control);
91 }
92 serde_json::Value::Array(a) => a.iter_mut().for_each(strip_cache_control),
93 _ => {}
94 }
95}
96
97pub fn to_parts(v: &mut serde_json::Value, caches: bool) {
103 use serde_json::{Value, json};
104 let Some(o) = v.as_object_mut() else {
105 return;
106 };
107 let Some(messages) = o.remove("messages") else {
108 return;
109 };
110 let part = |block: &Value| {
111 let text = block["text"].as_str().unwrap_or_default();
112 match block.get("cache_control").filter(|_| caches) {
113 Some(c) if c["ttl"] == "1h" => json!({ "type": "text", "text": text, "cache": "1h" }),
114 Some(_) => json!({ "type": "text", "text": text, "cache": "5m" }),
115 None => json!({ "type": "text", "text": text }),
116 }
117 };
118 if let Some(Value::Array(blocks)) = o.get_mut("system") {
119 *blocks = blocks.iter().map(part).collect();
120 }
121 let input: Vec<Value> = match &messages[0]["content"] {
122 Value::String(text) => vec![json!({ "type": "text", "text": text })],
123 content => content
124 .as_array()
125 .map(|blocks| blocks.iter().map(part).collect())
126 .unwrap_or_default(),
127 };
128 o.insert("input".to_owned(), Value::Array(input));
129}
130
131#[must_use]
136pub fn refused(command: &str, error: &str) -> speccoder_core::CommandResult {
137 use speccoder_core::{CommandResult, CommandStatus};
138 let missing = error.split_whitespace().next().filter(|v| {
139 error.contains("is not set")
140 && v.chars()
141 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
142 });
143 match missing {
144 Some(variable) => CommandResult::new(command, CommandStatus::ConfigError, error)
145 .with_code("KEY-MISSING")
146 .with_fix(if variable == crate::aws::API_KEY {
147 bedrock_fixes()
148 } else {
149 vec![
150 format!("export {variable}=…"),
151 "→ or another provider, or a local model: speccoder config set model.provider openai-compatible (see Settings › Model)".to_owned(),
152 ]
153 }),
154 None => {
155 let r = CommandResult::new(command, CommandStatus::ConfigError, error)
156 .with_code("CONFIG-INVALID");
157 match config_fix(error) {
158 Some(fix) => r.with_fix(vec![fix]),
159 None => r,
160 }
161 }
162 }
163}
164
165#[must_use]
167pub fn config_fix(error: &str) -> Option<String> {
168 error
169 .rsplit_once(" — ")
170 .map(|(_, fix)| fix.trim())
171 .filter(|fix| fix.starts_with("speccoder config "))
172 .map(str::to_owned)
173}
174
175#[must_use]
178pub fn bedrock_fixes() -> Vec<String> {
179 vec![
180 format!("export {}=…", crate::aws::API_KEY),
181 "aws login".to_owned(),
182 "→ or another provider: speccoder config set model.provider anthropic (see Settings › Model)".to_owned(),
183 ]
184}
185
186#[must_use]
190pub fn provider_refused_fix(
191 model: &ModelConfig,
192 source: Option<&crate::aws::Source>,
193 feature: Option<&str>,
194) -> Vec<String> {
195 let mut fix: Vec<String> = source
196 .filter(|_| model.provider == "bedrock")
197 .map(crate::aws::Source::renew)
198 .into_iter()
199 .collect();
200 if let Some(entry) = speccoder_core::codes::lookup("PROVIDER-REFUSED") {
201 fix.extend(speccoder_core::codes::fixes(entry, "run", None, feature));
202 }
203 fix
204}
205
206pub const NEEDS_REGION: &str = "[model]: provider `bedrock` needs `region`";
209
210#[must_use]
214pub fn bedrock_model_warning(model: &str) -> Option<String> {
215 let model = model.trim();
216 if model.is_empty() || model.contains('.') || model.starts_with("arn:") {
217 return None;
218 }
219 let suggested = if model.starts_with("claude-") {
220 format!("global.anthropic.{model}")
221 } else {
222 crate::init::BEDROCK_MODEL.to_owned()
223 };
224 Some(format!(
225 "`{model}` is not a Bedrock model id; Bedrock names models like {suggested} — speccoder config set model.model {suggested}"
226 ))
227}
228
229pub fn open_driver(
234 model: &ModelConfig,
235) -> Result<
236 (
237 std::sync::Arc<dyn agentplane::model::ModelProvider>,
238 Option<String>,
239 ),
240 String,
241> {
242 let variable = model.key_variable().to_owned();
243 let key = if model.provider == "bedrock" {
244 crate::aws::var(&variable)
249 } else if variable.is_empty() {
250 None
251 } else {
252 Some(crate::aws::var(&variable).ok_or_else(|| {
253 format!("{variable} is not set; speccoder reads the key only from the environment")
254 })?)
255 };
256 let redact = |e: String| match &key {
257 Some(k) if !k.is_empty() => e.replace(k.as_str(), "[redacted]"),
258 _ => e,
259 };
260 let driver: std::sync::Arc<dyn agentplane::model::ModelProvider> = match model.provider.as_str()
261 {
262 "openai" => {
263 let mut driver =
264 agentplane::model::openai::OpenAi::new(key.clone().unwrap_or_default())
265 .map_err(|e| redact(e.to_string()))?;
266 if let Some(base) = &model.base_url {
268 driver = driver.base(base.trim_end_matches("/v1").to_owned());
269 }
270 std::sync::Arc::new(driver)
271 }
272 "openai-compatible" => {
273 let base = model.base_url.clone().unwrap_or_default();
274 let mut driver = agentplane::model::chat_completions::ChatCompletions::new(base)
275 .map_err(|e| redact(e.to_string()))?;
276 if let Some(k) = &key {
277 driver = driver.bearer(k.clone());
278 }
279 if model.structured_native {
280 driver = driver.structured_via(agentplane::model::SchemaMode::Native);
281 }
282 std::sync::Arc::new(driver)
283 }
284 "bedrock" => {
285 let region = model.region.clone().unwrap_or_default();
286 let loaded = std::thread::scope(|s| {
291 s.spawn(|| {
292 crate::pipeline::tokio_runtime()?
293 .block_on(agentplane::model::bedrock::Bedrock::from_env(region))
294 })
295 .join()
296 .unwrap_or_else(|_| Err("loading the AWS configuration failed".to_owned()))
297 });
298 std::sync::Arc::new(loaded.map_err(|e| redact(format!("Amazon Bedrock: {e}")))?)
299 }
300 _ => std::sync::Arc::new(
301 agentplane::model::anthropic::Anthropic::new(key.clone().unwrap_or_default())
302 .map_err(|e| redact(e.to_string()))?,
303 ),
304 };
305 Ok((driver, key))
306}
307
308#[derive(Debug, Clone, PartialEq)]
310pub struct ModelConfig {
311 pub provider: String,
313 pub model: String,
315 pub max_output_tokens: u32,
317 pub max_run_tokens: u64,
319 pub cache_ttl: CacheTtl,
321 pub prices: Option<Prices>,
323 pub base_url: Option<String>,
325 pub api_key_env: Option<String>,
328 pub structured_native: bool,
332 pub reasoning_summary: bool,
336 pub region: Option<String>,
338}
339
340#[derive(Debug, Clone, Copy, PartialEq)]
342pub struct Prices {
343 pub input: f64,
345 pub output: f64,
347 pub cache_read: f64,
349 pub cache_write: f64,
351 pub cache_write_1h: f64,
353 pub one_hour: bool,
356}
357
358impl Prices {
359 #[must_use]
365 pub fn usd(&self, input: u64, output: u64, cache_read: u64, cache_write: u64) -> f64 {
366 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
368 let micro = |usd: f64| (usd * 1_000_000.0).round().max(0.0) as u64;
369 let pricing = agentplane::model::Pricing {
370 input: micro(self.input),
371 output: micro(self.output),
372 cache_read: micro(self.cache_read),
373 cache_write: micro(self.cache_write),
374 cache_write_1h: micro(self.cache_write_1h),
375 };
376 let usage = agentplane::model::Usage {
377 input_tokens: input,
378 output_tokens: output,
379 cache_read_tokens: cache_read,
380 cache_write_tokens: cache_write,
381 cache_write_1h_tokens: if self.one_hour { cache_write } else { 0 },
382 ..agentplane::model::Usage::default()
383 };
384 #[allow(clippy::cast_precision_loss)] let usd = pricing.price(&usage) as f64 / 1_000_000.0;
386 usd
387 }
388}
389
390pub fn parse(text: &str) -> Result<Option<ModelConfig>, String> {
392 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
393 let Some(value) = table.get("model") else {
394 return Ok(None);
395 };
396 let raw: Raw = value
397 .clone()
398 .try_into()
399 .map_err(|e| format!("[model]: {e}"))?;
400 if !matches!(
401 raw.provider.as_str(),
402 "anthropic" | "openai" | "openai-compatible" | "bedrock"
403 ) {
404 return Err(format!(
405 "[model]: provider `{}` is not supported (anthropic, openai, openai-compatible and bedrock are)",
406 raw.provider
407 ));
408 }
409 let bedrock = raw.provider == "bedrock";
410 if bedrock {
411 for (key, set) in [
412 ("base_url", raw.base_url.is_some()),
413 ("api_key_env", raw.api_key_env.is_some()),
414 ("structured", raw.structured.is_some()),
415 ] {
416 if set {
417 return Err(format!(
418 "[model]: `{key}` does not apply to provider `bedrock` (its credentials and endpoint come from AWS) — speccoder config unset model.{key}"
419 ));
420 }
421 }
422 } else if raw.region.is_some() {
423 return Err(format!(
424 "[model]: `region` applies only to provider `bedrock`, not `{}` — speccoder config unset model.region",
425 raw.provider
426 ));
427 }
428 if raw.provider == "openai-compatible" && raw.base_url.as_deref().is_none_or(str::is_empty) {
429 return Err(
430 "[model]: provider `openai-compatible` needs `base_url`, e.g. \"http://localhost:11434/v1\""
431 .to_owned(),
432 );
433 }
434 let structured_native = match raw.structured.as_deref() {
437 None | Some("native") => true,
438 Some("tool") => false,
439 Some(other) => {
440 return Err(format!(
441 "[model]: structured = \"{other}\" (native or tool)"
442 ));
443 }
444 };
445 if raw.model.trim().is_empty() {
446 return Err("[model]: `model` is empty".to_owned());
447 }
448 if bedrock && raw.region.as_deref().is_none_or(|r| r.trim().is_empty()) {
453 return Err(format!(
454 "{NEEDS_REGION}, e.g. \"eu-central-1\" — speccoder config set model.region <region>"
455 ));
456 }
457 let anthropic = raw.provider == "anthropic" || (bedrock && raw.model.contains("anthropic."));
459 Ok(Some(ModelConfig {
460 provider: raw.provider,
461 model: raw.model,
462 reasoning_summary: raw.reasoning_summary.unwrap_or(true),
463 max_output_tokens: raw.max_output_tokens.unwrap_or(4096),
464 max_run_tokens: raw.max_run_tokens.unwrap_or(200_000),
465 cache_ttl: raw.cache_ttl.unwrap_or(CacheTtl::FiveMinutes),
466 prices: match (raw.price_input_per_mtok, raw.price_output_per_mtok) {
473 (Some(input), Some(output)) => Some(Prices {
474 input,
475 output,
476 cache_read: raw.price_cache_read_per_mtok.unwrap_or(input),
477 cache_write: raw.price_cache_write_per_mtok.unwrap_or(if anthropic {
478 input * 1.25
479 } else {
480 input
481 }),
482 cache_write_1h: raw.price_cache_write_1h_per_mtok.unwrap_or(if anthropic {
483 input * 2.0
484 } else {
485 input
486 }),
487 one_hour: raw.cache_ttl == Some(CacheTtl::OneHour),
488 }),
489 _ => None,
490 },
491 base_url: raw.base_url,
492 api_key_env: raw.api_key_env,
493 structured_native,
494 region: raw.region.map(|r| r.trim().to_owned()),
495 }))
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn cache_writes_are_priced_at_their_own_rate() {
504 let m = |extra: &str| {
505 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}"))
506 .unwrap()
507 .unwrap()
508 .prices
509 .unwrap()
510 };
511 assert!((m("").usd(1_000_000, 0, 0, 1_000_000) - 3.75).abs() < 1e-9);
513 assert!((m("cache_ttl = \"1h\"\n").usd(1_000_000, 0, 0, 1_000_000) - 6.0).abs() < 1e-9);
515 let p = m("price_cache_write_per_mtok = 4.0\n");
517 assert!(
518 (p.usd(2_000_000, 1_000_000, 500_000, 500_000) - (3.0 + 15.0 + 0.15 + 2.0)).abs()
519 < 1e-9
520 );
521 }
522
523 #[test]
524 fn parses_model_with_defaults() {
525 let m = parse("[model]\nprovider = \"anthropic\"\nmodel = \"claude-sonnet-5-5\"\n")
526 .unwrap()
527 .unwrap();
528 assert_eq!(m.model, "claude-sonnet-5-5");
529 assert_eq!(m.max_output_tokens, 4096);
530 assert_eq!(m.cache_ttl, CacheTtl::FiveMinutes);
531 let one_hour =
532 parse("[model]\nprovider = \"anthropic\"\nmodel = \"m\"\ncache_ttl = \"1h\"\n")
533 .unwrap()
534 .unwrap();
535 assert_eq!(one_hour.cache_ttl, CacheTtl::OneHour);
536 }
537
538 #[test]
539 fn rejects_unknown_keys_and_providers() {
540 assert!(parse("[model]\nprovider = \"mistral\"\nmodel = \"m\"\n").is_err());
541 let openai = parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
542 .unwrap()
543 .unwrap();
544 assert_eq!(openai.key_variable(), "OPENAI_API_KEY");
545 assert_eq!(openai.prompt_style(), PromptStyle::OpenAi);
546 assert!(parse("[model]\nprovider = \"anthropic\"\nmodel = \"m\"\nkey = \"x\"\n").is_err());
547 assert_eq!(parse("[checks.t]\nargv = [\"true\"]\n").unwrap(), None);
548 }
549
550 #[test]
551 fn an_openai_compatible_server_needs_a_base_url_and_a_key_only_if_named() {
552 let m = parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"qwen3\"\nbase_url = \"http://localhost:11434/v1\"\n")
553 .unwrap()
554 .unwrap();
555 assert_eq!(m.prompt_style(), PromptStyle::Chat);
556 assert!(m.structured_native, "native by default");
557 assert_eq!(m.key_variable(), "");
558 assert!(open_driver(&m).is_ok_and(|(_, key)| key.is_none()));
559 assert!(parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"x\"\n").is_err());
560 let keyed = parse("[model]\nprovider = \"openai-compatible\"\nmodel = \"x\"\nbase_url = \"https://router.example/v1\"\napi_key_env = \"SPECCODER_TEST_NO_SUCH_KEY\"\n")
561 .unwrap()
562 .unwrap();
563 assert!(
564 open_driver(&keyed)
565 .err()
566 .unwrap_or_default()
567 .contains("SPECCODER_TEST_NO_SUCH_KEY is not set")
568 );
569 let mut v = serde_json::json!({ "messages": [{ "content": [{ "type": "text", "text": "x", "cache_control": { "type": "ephemeral" } }] }] });
570 strip_cache_control(&mut v);
571 assert!(!v.to_string().contains("cache_control"));
572 }
573
574 fn bedrock(extra: &str) -> Result<Option<ModelConfig>, String> {
575 parse(&format!(
576 "[model]\nprovider = \"bedrock\"\nmodel = \"global.anthropic.claude-sonnet-5-5\"\n{extra}"
577 ))
578 }
579
580 #[test]
581 fn bedrock_needs_a_region_and_takes_no_endpoint_or_key_settings() {
582 let m = bedrock("region = \"eu-central-1\"\n").unwrap().unwrap();
583 assert_eq!(m.region.as_deref(), Some("eu-central-1"));
584 assert_eq!(m.key_variable(), "AWS_BEARER_TOKEN_BEDROCK");
585 assert_eq!(m.prompt_style(), PromptStyle::Parts { caches: true });
586 for missing in ["", "region = \" \"\n"] {
587 let e = bedrock(missing).unwrap_err();
588 assert_eq!(
589 e,
590 "[model]: provider `bedrock` needs `region`, e.g. \"eu-central-1\" — speccoder config set model.region <region>"
591 );
592 assert_eq!(
593 refused("run", &e).code().map(|c| c.name),
594 Some("CONFIG-INVALID")
595 );
596 }
597 for key in [
598 "base_url = \"https://x\"",
599 "api_key_env = \"K\"",
600 "structured = \"tool\"",
601 ] {
602 let e = bedrock(&format!("region = \"us-east-1\"\n{key}\n")).unwrap_err();
603 assert!(e.contains("does not apply to provider `bedrock`"), "{e}");
604 }
605 let e = parse("[model]\nprovider = \"anthropic\"\nmodel = \"m\"\nregion = \"us-east-1\"\n")
606 .unwrap_err();
607 assert!(
608 e.contains("`region` applies only to provider `bedrock`"),
609 "{e}"
610 );
611 let e = parse("[model]\nprovider = \"mistral\"\nmodel = \"m\"\n").unwrap_err();
612 assert!(
613 e.contains("anthropic, openai, openai-compatible and bedrock are"),
614 "{e}"
615 );
616 }
617
618 #[test]
619 fn only_claude_on_bedrock_caches_and_prices_cache_writes_as_anthropic_does() {
620 let nova = parse("[model]\nprovider = \"bedrock\"\nmodel = \"amazon.nova-pro-v1:0\"\nregion = \"us-east-1\"\nprice_input_per_mtok = 1.0\nprice_output_per_mtok = 4.0\n")
621 .unwrap()
622 .unwrap();
623 assert!(!nova.caches());
624 assert_eq!(nova.prompt_style(), PromptStyle::Parts { caches: false });
625 assert!((nova.prices.unwrap().cache_write - 1.0).abs() < 1e-9);
627 let claude = bedrock(
628 "region = \"us-east-1\"\nprice_input_per_mtok = 3.0\nprice_output_per_mtok = 15.0\n",
629 )
630 .unwrap()
631 .unwrap();
632 assert!(claude.caches());
633 let p = claude.prices.unwrap();
634 assert!((p.cache_write - 3.75).abs() < 1e-9);
635 assert!((p.cache_write_1h - 6.0).abs() < 1e-9);
636 }
637
638 #[test]
639 fn the_anthropic_layout_becomes_text_parts_with_cache_marks() {
640 let layout = serde_json::json!({
641 "system": [
642 { "type": "text", "text": "policy" },
643 { "type": "text", "text": "constitution", "cache_control": { "type": "ephemeral", "ttl": "1h" } },
644 ],
645 "messages": [{ "role": "user", "content": [
646 { "type": "text", "text": "plan", "cache_control": { "type": "ephemeral" } },
647 { "type": "text", "text": "task" },
648 ] }],
649 });
650 let mut cached = layout.clone();
651 to_parts(&mut cached, true);
652 assert_eq!(
653 cached,
654 serde_json::json!({
655 "system": [
656 { "type": "text", "text": "policy" },
657 { "type": "text", "text": "constitution", "cache": "1h" },
658 ],
659 "input": [
660 { "type": "text", "text": "plan", "cache": "5m" },
661 { "type": "text", "text": "task" },
662 ],
663 })
664 );
665 let mut plain = layout;
666 to_parts(&mut plain, false);
667 assert!(!plain.to_string().contains("cache"), "{plain}");
668 assert_eq!(plain["input"][1]["text"], "task");
669 let mut bare =
671 serde_json::json!({ "system": "be brief", "input": [{ "type": "text", "text": "x" }] });
672 let before = bare.clone();
673 to_parts(&mut bare, true);
674 assert_eq!(bare, before);
675 }
676
677 #[test]
678 fn no_bedrock_credentials_is_key_missing_with_aws_s_ways_in() {
679 let r = refused(
680 "run",
681 "AWS_BEARER_TOKEN_BEDROCK is not set and no AWS profile was found",
682 );
683 assert_eq!(r.code().map(|c| c.name), Some("KEY-MISSING"));
684 assert_eq!(
685 r.fix(),
686 [
687 "export AWS_BEARER_TOKEN_BEDROCK=…",
688 "aws login",
689 "→ or another provider: speccoder config set model.provider anthropic (see Settings › Model)",
690 ]
691 );
692 }
693
694 #[test]
695 fn a_refused_bedrock_account_names_the_renewal_first() {
696 let m = bedrock("region = \"us-east-1\"\n").unwrap().unwrap();
697 let login = crate::aws::Source {
698 kind: crate::aws::Kind::Profile(crate::aws::ProfileKind::Login),
699 name: "dev".to_owned(),
700 };
701 let fix = provider_refused_fix(&m, Some(&login), Some("001-x"));
702 assert_eq!(fix[0], "aws login --profile dev");
703 assert_eq!(
704 fix.last().map(String::as_str),
705 Some("speccoder run --feature 001-x")
706 );
707 let other = parse("[model]\nprovider = \"openai\"\nmodel = \"m\"\n")
708 .unwrap()
709 .unwrap();
710 assert!(!provider_refused_fix(&other, Some(&login), None)[0].starts_with("aws"));
711 }
712}