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    reasoning_summary: Option<bool>,
26    region: Option<String>,
27}
28
29impl ModelConfig {
30    /// The environment variable holding this provider's API key.
31    #[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            // The variable hints name; the AWS chain may use another source.
37            "bedrock" => "AWS_BEARER_TOKEN_BEDROCK",
38            _ => "ANTHROPIC_API_KEY",
39        }
40    }
41
42    /// Whether prompts carry cache breakpoints for this model: always, but
43    /// on Bedrock only for Claude models (other families take no cache
44    /// points).
45    #[must_use]
46    pub fn caches(&self) -> bool {
47        self.provider != "bedrock" || self.model.contains("anthropic.")
48    }
49
50    /// How prompts are laid out for this provider.
51    #[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/// Provider prompt dialects (same content, same stability order).
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum PromptStyle {
67    /// Messages API: system blocks and content blocks with `cache_control` breakpoints.
68    Anthropic,
69    /// Responses API: `system` → `instructions` (a string), `input` items;
70    /// prefix caching is automatic, so the order alone does the work.
71    OpenAi,
72    /// Chat Completions (`openai-compatible`: Ollama, vLLM, llama.cpp, LM
73    /// Studio, …): the Anthropic layout without `cache_control` breakpoints,
74    /// which such servers do not know.
75    Chat,
76    /// Bedrock's Converse: the Anthropic layout, converted by [`to_parts`]
77    /// to agentplane's shared text parts, whose `cache` marks become cache
78    /// points (`caches`: Claude models only).
79    Parts {
80        /// Whether breakpoints are kept as `cache` marks.
81        caches: bool,
82    },
83}
84
85/// Removes `cache_control` breakpoints from a prompt (for [`PromptStyle::Chat`]).
86pub 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
97/// Converts an Anthropic-layout prompt to agentplane's shared shape (for
98/// [`PromptStyle::Parts`]): `system` blocks and the first message's content
99/// become text parts, a `cache_control` breakpoint a `cache` mark — `"1h"`
100/// for the hour, `"5m"` otherwise — or none when `caches` is off. A string
101/// `system` stays as it is; a prompt without `messages` is left untouched.
102pub 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/// Why the model could not be opened, as a result: a missing key is
132/// `KEY-MISSING` with the exact `export` (and the other ways to get a
133/// model), anything else `CONFIG-INVALID`. Every command that calls a model
134/// says it the same way.
135#[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/// The `speccoder config …` command a `[model]` error ends with, if any.
166#[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/// What gets Bedrock credentials when none is found: a Bedrock API key, an
176/// `aws login` session, or another provider.
177#[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/// The fix for a provider refusing the account (`PROVIDER-REFUSED`): the
187/// catalog's, after — for Bedrock — the command that renews `source`, the
188/// credentials the calls were signed with.
189#[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
206/// How [`parse`]'s error for a Bedrock file without its region begins; that
207/// error is never reported while another one stands.
208pub const NEEDS_REGION: &str = "[model]: provider `bedrock` needs `region`";
209
210/// What to say when `model` is not a Bedrock model id (`vendor.model`,
211/// an inference profile `global.vendor.model`, or an ARN): a warning, since
212/// Bedrock alone knows its ids.
213#[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
229/// The model driver for `model`, and the secret to redact from messages.
230/// Keys come only from the environment; a local `openai-compatible` server
231/// needs none unless `api_key_env` names one, and Bedrock's come from the
232/// AWS credential chain (`AWS_BEARER_TOKEN_BEDROCK` or a profile).
233pub 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        // The AWS chain finds the credentials, from sources speccoder cannot
245        // see offline too (an instance role): the first call decides, and
246        // `doctor` gives the advice. An API key, when set, is still the
247        // secret to redact.
248        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            // A gateway in front of the Responses API.
267            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            // The AWS configuration loads asynchronously, and callers may sit
287            // inside a runtime: on a thread of its own, with a runtime of its
288            // own. The client makes its connections on the runtime of each
289            // later call.
290            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/// The model speccoder calls.
309#[derive(Debug, Clone, PartialEq)]
310pub struct ModelConfig {
311    /// Driver name: `anthropic`, `openai`, `openai-compatible` or `bedrock`.
312    pub provider: String,
313    /// The provider's model id, pinned exactly.
314    pub model: String,
315    /// Per-call output ceiling.
316    pub max_output_tokens: u32,
317    /// Token budget of one run.
318    pub max_run_tokens: u64,
319    /// Lifetime of prompt-cache breakpoints.
320    pub cache_ttl: CacheTtl,
321    /// USD per million tokens, if configured (`speccoder report --cost`).
322    pub prices: Option<Prices>,
323    /// `openai-compatible`: the server, e.g. `http://localhost:11434/v1`.
324    pub base_url: Option<String>,
325    /// `openai-compatible`: the environment variable holding a key, if the
326    /// server wants one.
327    pub api_key_env: Option<String>,
328    /// `openai-compatible`: the server enforces `json_schema` output
329    /// (`structured = "native"`, the default); `"tool"` carries it in a
330    /// forced tool instead, which works only for steps without tools.
331    pub structured_native: bool,
332    /// Ask for a summary of the model's reasoning on each attempt turn
333    /// (`reasoning_summary`, default on): shown as its account, never as
334    /// evidence. Providers without a way to ask (Chat Completions) give none.
335    pub reasoning_summary: bool,
336    /// `bedrock`: the AWS region calls go to (the journal records it).
337    pub region: Option<String>,
338}
339
340/// USD per million tokens.
341#[derive(Debug, Clone, Copy, PartialEq)]
342pub struct Prices {
343    /// Input tokens neither written to nor read from the cache.
344    pub input: f64,
345    /// Output tokens.
346    pub output: f64,
347    /// Input tokens read from the cache.
348    pub cache_read: f64,
349    /// Input tokens written to the five-minute cache.
350    pub cache_write: f64,
351    /// Input tokens written to the one-hour cache.
352    pub cache_write_1h: f64,
353    /// Whether this model's cache writes go to the one-hour tier
354    /// (`cache_ttl = "1h"`).
355    pub one_hour: bool,
356}
357
358impl Prices {
359    /// USD for these token counts (`input` counts every input token, cached
360    /// ones included), priced by agentplane's [`Pricing`]: rounded up to a
361    /// micro-dollar, cache writes at their own rate.
362    ///
363    /// [`Pricing`]: agentplane::model::Pricing
364    #[must_use]
365    pub fn usd(&self, input: u64, output: u64, cache_read: u64, cache_write: u64) -> f64 {
366        // Rates in micro-dollars per million tokens: integers, as agentplane prices.
367        #[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)] // micro-dollars far below 2^52
385        let usd = pricing.price(&usage) as f64 / 1_000_000.0;
386        usd
387    }
388}
389
390/// Reads `[model]` from the configuration text; `None` when absent.
391pub 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    // Native by default: a forced-tool schema cannot be combined with the
435    // tools an attempt declares, so only tool-less steps could use it.
436    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    // Credentials and the endpoint come from AWS; the region is the file's,
449    // so the journal attests where the calls went. Checked last: when this
450    // is the error, it is the only one (`config set` takes the provider
451    // before the region).
452    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    // Claude bills cache writes alike wherever it is served.
458    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(500_000),
465        cache_ttl: raw.cache_ttl.unwrap_or(CacheTtl::FiveMinutes),
466        // Prices are the user's to state (they change, and differ by
467        // contract); without input and output prices, cost is tokens only.
468        // Unstated cache-write rates follow the provider's published rule:
469        // Anthropic bills 1.25× input for the five-minute cache and 2× for
470        // the hour (Claude on Bedrock too); OpenAI, compatible servers and
471        // Bedrock's other families bill writes as input.
472        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        // 1M written to the five-minute cache: 1.25 × $3 (input × 1.25 unless stated).
512        assert!((m("").usd(1_000_000, 0, 0, 1_000_000) - 3.75).abs() < 1e-9);
513        // The one-hour tier: 2 × $3.
514        assert!((m("cache_ttl = \"1h\"\n").usd(1_000_000, 0, 0, 1_000_000) - 6.0).abs() < 1e-9);
515        // Stated rates win; reads and fresh input as before.
516        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        // Writes at the input rate: no inferred Claude rule.
626        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        // A string instruction stays; a prompt without messages is untouched.
670        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}