Skip to main content

llm/catalog/
mod.rs

1#![doc = include_str!("../docs/catalog.md")]
2
3use crate::providers::local::discovery::discover_local_models;
4use crate::reasoning::ReasoningControl;
5use crate::{ReasoningDisabledSupport, ReasoningEffort};
6use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
8use std::borrow::Cow;
9use std::fmt;
10use std::str::FromStr;
11use std::sync::LazyLock;
12
13mod bedrock;
14mod model_spec;
15pub mod transport;
16
17pub use crate::usage::ModelPricing;
18pub use bedrock::BedrockModel;
19pub use model_spec::{ModelSpec, ModelSpecError, ReasoningEffortError, validate_reasoning_effort};
20pub use transport::ModelTransport;
21
22include!(concat!(env!("OUT_DIR"), "/generated.rs"));
23
24/// Returns models whose provider env var is set
25pub fn available_models() -> Vec<LlmModel> {
26    LlmModel::all()
27        .iter()
28        .filter(|m| m.required_env_var().is_none_or(|var| std::env::var(var).is_ok()))
29        .cloned()
30        .collect()
31}
32
33/// Returns available catalog models plus any locally discovered models.
34pub async fn get_local_models() -> Vec<LlmModel> {
35    let mut models = available_models();
36    let local = discover_local_models().await;
37    models.extend(local);
38    models
39}
40
41impl LlmModel {
42    pub(crate) fn parse_for(provider: Provider, model_id: &str) -> Result<Self, String> {
43        match provider {
44            Provider::Ollama => Ok(Self::Ollama(model_id.to_string())),
45            Provider::LlamaCpp => Ok(Self::LlamaCpp(model_id.to_string())),
46            Provider::Bedrock => model_id.parse().map(Self::Bedrock),
47            provider => Self::all()
48                .iter()
49                .find(|model| model.provider_enum() == provider && model.model_id() == model_id)
50                .cloned()
51                .ok_or_else(|| format!("Unknown {provider} model: '{model_id}'")),
52        }
53    }
54
55    /// Raw model ID (e.g. `claude-opus-4-6`, `llama3.2`)
56    pub fn model_id(&self) -> Cow<'static, str> {
57        match self.source() {
58            ModelSource::Catalog(entry) => Cow::Borrowed(entry.id),
59            ModelSource::Runtime(id) => Cow::Owned(id.to_string()),
60        }
61    }
62
63    /// Human-readable display name (e.g. `Claude Opus 4.6`)
64    pub fn display_name(&self) -> Cow<'static, str> {
65        match self.source() {
66            ModelSource::Catalog(entry) => Cow::Borrowed(entry.display_name),
67            ModelSource::Runtime(id) => Cow::Owned(format!("{} {id}", self.provider_display_name())),
68        }
69    }
70
71    /// Provider identifier (e.g. `anthropic`)
72    pub fn provider(&self) -> &'static str {
73        self.provider_enum().parser_name()
74    }
75
76    /// Human-readable provider name (e.g. `AWS Bedrock`)
77    pub fn provider_display_name(&self) -> &'static str {
78        self.provider_enum().display_name()
79    }
80
81    /// Context window size in tokens (None for dynamic providers)
82    pub fn context_window(&self) -> Option<u32> {
83        self.entry().map(|entry| entry.context_window)
84    }
85
86    /// Required env var for this model's provider (None for local providers)
87    pub fn required_env_var(&self) -> Option<&'static str> {
88        self.provider_enum().required_env_var()
89    }
90
91    /// OAuth provider ID if this model requires OAuth login (e.g. `"codex"`)
92    pub fn oauth_provider_id(&self) -> Option<&'static str> {
93        self.provider_enum().oauth_provider_id()
94    }
95
96    /// Reasoning levels supported by this model (empty if not a reasoning model)
97    pub fn reasoning_levels(&self) -> &'static [ReasoningEffort] {
98        self.entry().map_or(&[], |entry| entry.reasoning_levels)
99    }
100
101    pub fn reasoning_disabled_support(&self) -> ReasoningDisabledSupport {
102        self.entry().map_or(ReasoningDisabledSupport::Unsupported, |entry| entry.disabled_support)
103    }
104
105    /// Whether this model advertises an explicit off selection.
106    pub fn supports_reasoning_off(&self) -> bool {
107        self.reasoning_levels().contains(&ReasoningEffort::Disabled)
108    }
109
110    /// Whether this model's provider transport can turn reasoning off.
111    pub fn supports_reasoning_off_transport(&self) -> bool {
112        if !self.supports_reasoning_off() {
113            return false;
114        }
115        match self.provider_enum().info().reasoning_off {
116            ReasoningOff::Always => true,
117            ReasoningOff::Effort => self.reasoning_disabled_support() == ReasoningDisabledSupport::Effort,
118            ReasoningOff::ModelTransport => self.transport().is_some(),
119            ReasoningOff::Never => false,
120        }
121    }
122
123    /// Explicit choices executable by the current adapter. Default is always valid.
124    pub fn effective_reasoning_levels(&self) -> Vec<ReasoningEffort> {
125        self.reasoning_levels()
126            .iter()
127            .copied()
128            .filter(|effort| *effort != ReasoningEffort::Disabled || self.supports_reasoning_off_transport())
129            .collect()
130    }
131
132    pub fn validate_reasoning_effort(&self, effort: ReasoningEffort) -> Result<(), ReasoningEffortError> {
133        let supported = self.effective_reasoning_levels();
134        if effort == ReasoningEffort::Default || supported.contains(&effort) {
135            Ok(())
136        } else {
137            Err(ReasoningEffortError::Unsupported { model: self.to_string(), effort, supported })
138        }
139    }
140
141    /// Whether this model supports reasoning/extended thinking
142    pub fn supports_reasoning(&self) -> bool {
143        self.reasoning_levels().iter().any(|effort| effort.is_enabled())
144    }
145
146    /// Whether this model supports provider-side prompt caching
147    pub fn supports_prompt_caching(&self) -> bool {
148        self.entry().is_some_and(|entry| entry.supports_prompt_caching)
149    }
150
151    pub(crate) fn reasoning_control(&self) -> ReasoningControl {
152        self.entry().map(|entry| entry.reasoning_control).unwrap_or_default()
153    }
154
155    pub fn pricing(&self) -> Option<ModelPricing> {
156        self.entry().and_then(|entry| entry.pricing)
157    }
158
159    /// Whether this model supports image input
160    pub fn supports_image(&self) -> bool {
161        self.entry().is_some_and(|entry| entry.supports_image)
162    }
163
164    /// Whether this model supports audio input
165    pub fn supports_audio(&self) -> bool {
166        self.entry().is_some_and(|entry| entry.supports_audio)
167    }
168
169    /// Per-model transport override, when the model does not use its
170    /// provider's default endpoint and wire protocol.
171    pub fn transport(&self) -> Option<ModelTransport> {
172        self.entry().and_then(|entry| entry.transport)
173    }
174
175    fn entry(&self) -> Option<&'static CatalogEntry> {
176        match self.source() {
177            ModelSource::Catalog(entry) => Some(entry),
178            ModelSource::Runtime(_) => None,
179        }
180    }
181}
182
183impl fmt::Display for LlmModel {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        write!(f, "{}:{}", self.provider(), self.model_id())
186    }
187}
188
189impl FromStr for LlmModel {
190    type Err = String;
191
192    /// Parse a `provider:model` string into an `LlmModel`
193    fn from_str(s: &str) -> Result<Self, Self::Err> {
194        let (provider, model_id) = s.split_once(':').unwrap_or((s, ""));
195        Self::parse_for(provider.parse()?, model_id)
196    }
197}
198
199impl Serialize for LlmModel {
200    fn serialize<T: Serializer>(&self, serializer: T) -> Result<T::Ok, T::Error> {
201        serializer.collect_str(self)
202    }
203}
204
205impl<'de> Deserialize<'de> for LlmModel {
206    fn deserialize<T: Deserializer<'de>>(deserializer: T) -> Result<Self, T::Error> {
207        String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
208    }
209}
210
211impl JsonSchema for LlmModel {
212    fn schema_name() -> Cow<'static, str> {
213        "LlmModel".into()
214    }
215
216    fn json_schema(_: &mut SchemaGenerator) -> Schema {
217        json_schema!({ "type": "string", "description": "A `provider:model` identifier." })
218    }
219}
220
221impl Provider {
222    /// Parser name used in `provider:model` strings (e.g. `"anthropic"`).
223    pub fn parser_name(self) -> &'static str {
224        self.info().parser_name
225    }
226
227    /// OpenTelemetry `GenAI` semantic-convention provider name.
228    pub fn genai_provider_name(self) -> &'static str {
229        self.info().genai_provider_name
230    }
231
232    /// Human-readable provider name (e.g. `"AWS Bedrock"`).
233    pub fn display_name(self) -> &'static str {
234        self.info().display_name
235    }
236
237    /// API-key env var the provider requires, if any.
238    pub fn required_env_var(self) -> Option<&'static str> {
239        self.info().required_env_var
240    }
241
242    pub fn env_vars(self) -> Vec<&'static str> {
243        let overrides: &[&'static str] = match self {
244            Self::Ollama => &["OLLAMA_HOST"],
245            Self::LlamaCpp => &["LLAMA_CPP_HOST"],
246            Self::Bedrock => AWS_ENV_VARS,
247            _ => &[],
248        };
249        self.required_env_var().into_iter().chain(overrides.iter().copied()).collect()
250    }
251
252    /// OAuth provider ID if this provider authenticates via OAuth.
253    pub fn oauth_provider_id(self) -> Option<&'static str> {
254        self.info().oauth_provider_id
255    }
256
257    /// Local providers run models on the user's machine — there's no
258    /// remote API to call and no env var to satisfy.
259    pub fn is_local(self) -> bool {
260        self.info().is_local
261    }
262
263    fn info(self) -> &'static ProviderInfo {
264        &Self::INFO[self as usize]
265    }
266}
267
268impl fmt::Display for Provider {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        f.write_str(self.parser_name())
271    }
272}
273
274impl FromStr for Provider {
275    type Err = String;
276
277    fn from_str(s: &str) -> Result<Self, Self::Err> {
278        Self::ALL
279            .iter()
280            .copied()
281            .find(|provider| provider.parser_name() == s)
282            .ok_or_else(|| format!("Unknown provider: '{s}'"))
283    }
284}
285
286const AWS_ENV_VARS: &[&str] = &[
287    "AWS_ACCESS_KEY_ID",
288    "AWS_BEARER_TOKEN_BEDROCK",
289    "AWS_DEFAULT_REGION",
290    "AWS_PROFILE",
291    "AWS_REGION",
292    "AWS_SECRET_ACCESS_KEY",
293    "AWS_SESSION_TOKEN",
294];
295
296/// Where a model's metadata comes from.
297enum ModelSource<'a> {
298    /// A model in the generated catalog.
299    Catalog(&'static CatalogEntry),
300    /// A user-supplied model ID the catalog knows nothing about.
301    Runtime(&'a str),
302}
303
304/// Generated metadata for one catalog model.
305struct CatalogEntry {
306    id: &'static str,
307    display_name: &'static str,
308    context_window: u32,
309    reasoning_levels: &'static [ReasoningEffort],
310    disabled_support: ReasoningDisabledSupport,
311    supports_prompt_caching: bool,
312    reasoning_control: ReasoningControl,
313    pricing: Option<ModelPricing>,
314    supports_image: bool,
315    supports_audio: bool,
316    transport: Option<ModelTransport>,
317}
318
319/// Generated metadata for one provider.
320struct ProviderInfo {
321    parser_name: &'static str,
322    genai_provider_name: &'static str,
323    display_name: &'static str,
324    required_env_var: Option<&'static str>,
325    oauth_provider_id: Option<&'static str>,
326    is_local: bool,
327    reasoning_off: ReasoningOff,
328}
329
330/// Which models a provider's transport can turn reasoning off for.
331enum ReasoningOff {
332    /// Every model that advertises an off selection.
333    Always,
334    /// Models that turn reasoning off with a `none` effort.
335    Effort,
336    /// Models served from a dedicated transport.
337    ModelTransport,
338    /// None: the provider cannot turn reasoning off.
339    Never,
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn display_fromstr_roundtrip_all_catalog_models() {
348        for model in LlmModel::all() {
349            let s = model.to_string();
350            let parsed: LlmModel = s.parse().unwrap_or_else(|e| panic!("Failed to parse '{s}' back to LlmModel: {e}"));
351            assert_eq!(&parsed, model, "roundtrip failed for '{s}'");
352        }
353    }
354
355    #[test]
356    fn display_fromstr_roundtrip_dynamic_providers() {
357        let cases = [LlmModel::Ollama("llama3.2".to_string()), LlmModel::LlamaCpp("my-model".to_string())];
358        for model in &cases {
359            let s = model.to_string();
360            let parsed: LlmModel = s.parse().unwrap();
361            assert_eq!(&parsed, model);
362        }
363    }
364
365    #[test]
366    fn model_pricing_comes_from_provider_catalog() {
367        let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
368
369        assert_eq!(
370            model.pricing(),
371            Some(ModelPricing {
372                input_per_million: 3.0,
373                output_per_million: 15.0,
374                cache_read_per_million: Some(0.3),
375                cache_write_per_million: Some(3.75),
376            })
377        );
378    }
379
380    #[test]
381    fn models_without_usage_pricing_do_not_inherit_unrelated_prices() {
382        let codex: LlmModel = "codex:gpt-5.5".parse().unwrap();
383        let local: LlmModel = "ollama:local-model".parse().unwrap();
384        let bedrock_profile: LlmModel = "bedrock:us.anthropic.claude-future-model-v99:0".parse().unwrap();
385
386        assert_eq!(codex.pricing(), None);
387        assert_eq!(local.pricing(), None);
388        assert_eq!(bedrock_profile.pricing(), None);
389    }
390
391    #[test]
392    fn codex_gpt56_subscription_models_have_expected_metadata() {
393        let expected_levels = &[
394            ReasoningEffort::Low,
395            ReasoningEffort::Medium,
396            ReasoningEffort::High,
397            ReasoningEffort::Xhigh,
398            ReasoningEffort::Max,
399        ];
400        for id in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
401            let model: LlmModel = format!("codex:{id}").parse().unwrap();
402            assert_eq!(model.model_id(), id);
403            assert_eq!(model.context_window(), Some(272_000));
404            assert_eq!(model.reasoning_levels(), expected_levels);
405        }
406
407        assert!("codex:gpt-5.6".parse::<LlmModel>().is_err());
408        assert!("codex:gpt-5.1-codex".parse::<LlmModel>().is_err());
409        assert!("openai:gpt-5.6".parse::<LlmModel>().is_ok());
410    }
411
412    #[test]
413    fn codex_gpt6_models_are_available_with_subscription_metadata() {
414        let expected_levels = &[
415            ReasoningEffort::Low,
416            ReasoningEffort::Medium,
417            ReasoningEffort::High,
418            ReasoningEffort::Xhigh,
419            ReasoningEffort::Max,
420        ];
421        for id in ["gpt-6.1-sol", "gpt-6-sol", "gpt-6-astra", "gpt-6-luna"] {
422            let model: LlmModel = format!("codex:{id}").parse().unwrap();
423            assert_eq!(model.model_id(), id);
424            assert_eq!(model.context_window(), Some(272_000));
425            assert_eq!(model.reasoning_levels(), expected_levels);
426            assert_eq!(model.oauth_provider_id(), Some("codex"));
427        }
428    }
429
430    #[test]
431    fn codex_gpt55_uses_subscription_context_window() {
432        let model: LlmModel = "codex:gpt-5.5".parse().unwrap();
433        assert_eq!(model.context_window(), Some(272_000));
434    }
435
436    #[test]
437    fn openai_gpt55_keeps_api_context_window() {
438        let model: LlmModel = "openai:gpt-5.5".parse().unwrap();
439        assert_eq!(model.context_window(), Some(1_050_000));
440    }
441
442    #[test]
443    fn bedrock_foundation_model_parses() {
444        let model: LlmModel = "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0".parse().unwrap();
445
446        assert_eq!(model.to_string(), "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0");
447        assert_eq!(model.context_window(), Some(200_000));
448    }
449
450    #[test]
451    fn bedrock_prompt_caching_support_comes_from_catalog() {
452        let claude: LlmModel = "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0".parse().unwrap();
453        let nova: LlmModel = "bedrock:amazon.nova-lite-v1:0".parse().unwrap();
454        let profile: LlmModel = "bedrock:us.anthropic.claude-future-model-v99:0".parse().unwrap();
455
456        assert!(claude.supports_prompt_caching());
457        assert!(nova.supports_prompt_caching());
458        assert!(!profile.supports_prompt_caching());
459    }
460
461    #[test]
462    fn provider_display_name_returns_human_readable() {
463        let anthropic: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
464        assert_eq!(anthropic.provider_display_name(), "Anthropic");
465
466        let bedrock: LlmModel = "bedrock:anthropic.claude-haiku-4-5-20251001-v1:0".parse().unwrap();
467        assert_eq!(bedrock.provider_display_name(), "AWS Bedrock");
468
469        let zai: LlmModel = "zai:glm-4.5".parse().unwrap();
470        assert_eq!(zai.provider_display_name(), "ZAI");
471
472        let ollama = LlmModel::Ollama("llama3.2".to_string());
473        assert_eq!(ollama.provider_display_name(), "Ollama");
474    }
475
476    #[test]
477    fn oauth_provider_id_is_codex_only_for_codex_provider() {
478        let codex: LlmModel = "codex:gpt-5.5".parse().unwrap();
479        assert_eq!(codex.oauth_provider_id(), Some("codex"));
480
481        for non_oauth in
482            ["anthropic:claude-opus-4-6", "openai:gpt-5.5", "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0"]
483        {
484            let model: LlmModel = non_oauth.parse().unwrap();
485            assert_eq!(model.oauth_provider_id(), None, "{non_oauth} should not have OAuth");
486        }
487        assert_eq!(LlmModel::Ollama("foo".into()).oauth_provider_id(), None);
488    }
489
490    #[test]
491    fn required_env_var_matches_provider() {
492        let cases = [
493            ("anthropic:claude-opus-4-6", Some("ANTHROPIC_API_KEY")),
494            ("openai:gpt-5.5", Some("OPENAI_API_KEY")),
495            ("deepseek:deepseek-v4-flash", Some("DEEPSEEK_API_KEY")),
496            ("gemini:gemini-2.5-pro", Some("GEMINI_API_KEY")),
497            ("openrouter:anthropic/claude-opus-4.6", Some("OPENROUTER_API_KEY")),
498            ("zai:glm-4.5", Some("ZAI_API_KEY")),
499            ("xiaomi:mimo-v2.6-pro", Some("XIAOMI_API_KEY")),
500            ("codex:gpt-5.5", None),
501            ("bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0", None),
502        ];
503        for (input, expected) in cases {
504            let model: LlmModel = input.parse().unwrap();
505            assert_eq!(model.required_env_var(), expected, "{input}");
506        }
507        assert_eq!(LlmModel::Ollama("foo".into()).required_env_var(), None);
508        assert_eq!(LlmModel::LlamaCpp("foo".into()).required_env_var(), None);
509    }
510
511    #[test]
512    fn xiaomi_models_parse_with_catalog_metadata() {
513        let model: LlmModel = "xiaomi:mimo-v2.6-pro".parse().unwrap();
514        assert_eq!(model.model_id(), "mimo-v2.6-pro");
515        assert_eq!(model.context_window(), Some(1_048_576));
516        assert_eq!(model.provider_display_name(), "Xiaomi");
517        assert!(model.supports_reasoning());
518    }
519
520    #[test]
521    fn codex_reasoning_models_include_xhigh_level() {
522        let codex: LlmModel = "codex:gpt-5.5".parse().unwrap();
523        assert_eq!(
524            codex.reasoning_levels(),
525            &[ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Xhigh]
526        );
527        assert!(codex.supports_reasoning());
528    }
529
530    #[test]
531    fn anthropic_reasoning_models_use_catalog_levels() {
532        let claude: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
533        assert_eq!(
534            claude.reasoning_levels(),
535            &[ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Max,]
536        );
537    }
538
539    #[test]
540    fn dynamic_provider_models_have_no_reasoning_levels() {
541        assert_eq!(LlmModel::Ollama("llama3.2".into()).reasoning_levels(), []);
542        assert!(!LlmModel::Ollama("llama3.2".into()).supports_reasoning());
543    }
544
545    #[test]
546    fn supports_reasoning_matches_reasoning_levels_emptiness() {
547        for model in LlmModel::all() {
548            assert_eq!(model.supports_reasoning(), !model.reasoning_levels().is_empty(), "{model}");
549        }
550    }
551
552    #[test]
553    fn dynamic_providers_have_no_context_window() {
554        assert_eq!(LlmModel::Ollama("foo".into()).context_window(), None);
555        assert_eq!(LlmModel::LlamaCpp("foo".into()).context_window(), None);
556    }
557
558    #[test]
559    fn bedrock_responses_models_carry_their_endpoint_and_wire_shape() {
560        let cases = [
561            ("openai.gpt-6.1-sol", "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1"),
562            ("us.openai.gpt-6.1-sol", "https://bedrock-runtime.${AWS_REGION}.amazonaws.com/openai/v1"),
563            ("openai.gpt-5.6-luna", "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1"),
564            ("openai.gpt-5.6-sol", "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1"),
565            ("openai.gpt-5.6-terra", "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1"),
566            ("openai.gpt-6-astra", "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1"),
567            ("us.openai.gpt-6-luna", "https://bedrock-runtime.${AWS_REGION}.amazonaws.com/openai/v1"),
568            ("us.openai.gpt-6-sol", "https://bedrock-runtime.${AWS_REGION}.amazonaws.com/openai/v1"),
569            ("us.openai.gpt-6-astra", "https://bedrock-runtime.${AWS_REGION}.amazonaws.com/openai/v1"),
570            ("openai.gpt-5.5", "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1"),
571            ("openai.gpt-5.4", "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1"),
572            ("xai.grok-4.3", "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1"),
573            ("openai.gpt-oss-120b", "https://bedrock-mantle.${AWS_REGION}.api.aws/v1"),
574            ("openai.gpt-oss-20b", "https://bedrock-mantle.${AWS_REGION}.api.aws/v1"),
575        ];
576
577        for (id, expected_api) in cases {
578            let model: LlmModel = format!("bedrock:{id}").parse().unwrap();
579            assert_eq!(
580                model.transport(),
581                Some(ModelTransport::OpenAiResponses { base_url_template: expected_api }),
582                "{id}"
583            );
584        }
585    }
586
587    #[test]
588    fn bedrock_claude_models_use_the_messages_transport() {
589        for model in bedrock_claude_models() {
590            assert_eq!(model.transport(), Some(ModelTransport::AnthropicMessages), "{model}");
591        }
592    }
593
594    #[test]
595    fn bedrock_claude_models_turn_reasoning_off_when_the_catalog_allows_it() {
596        for model in bedrock_claude_models() {
597            assert_eq!(model.supports_reasoning_off_transport(), model.supports_reasoning_off(), "{model}");
598        }
599    }
600
601    #[test]
602    fn models_outside_the_catalog_default_to_effort_reasoning_control() {
603        let profile: LlmModel = "bedrock:us.anthropic.claude-future-model-v99:0".parse().unwrap();
604
605        assert_eq!(profile.reasoning_control(), ReasoningControl::Effort);
606        assert_eq!(LlmModel::Ollama("llama3.2".into()).reasoning_control(), ReasoningControl::Effort);
607    }
608
609    #[test]
610    fn converse_models_and_profiles_declare_no_transport_override() {
611        for id in [
612            "amazon.nova-lite-v1:0",
613            "us.anthropic.claude-future-model-v99:0",
614            "global.openai.gpt-6-luna",
615            "global.openai.gpt-6-sol",
616            "global.openai.gpt-6.1-sol",
617            "global.openai.gpt-6-astra",
618        ] {
619            let model: LlmModel = format!("bedrock:{id}").parse().unwrap();
620            assert_eq!(model.transport(), None, "{id}");
621        }
622        assert_eq!(LlmModel::Ollama("llama3.2".into()).transport(), None);
623        assert_eq!("anthropic:claude-opus-4-6".parse::<LlmModel>().unwrap().transport(), None);
624    }
625
626    #[test]
627    fn every_declared_transport_template_resolves() {
628        for model in LlmModel::all() {
629            let Some(ModelTransport::OpenAiResponses { base_url_template }) = model.transport() else {
630                continue;
631            };
632            let resolved = utils::variables::Vars::new()
633                .with_env_lookup(|_| Some("test-region".to_string()))
634                .expand(base_url_template)
635                .unwrap_or_else(|e| panic!("{model} has an unresolvable endpoint template: {e}"));
636            assert!(resolved.starts_with("https://"), "{model} resolved to '{resolved}'");
637            assert!(!resolved.contains("${"), "{model} left a placeholder in '{resolved}'");
638        }
639    }
640
641    #[test]
642    fn bedrock_profile_fallback_parses_arbitrary_id() {
643        let profile: LlmModel = "bedrock:us.anthropic.future-model-v99:0".parse().unwrap();
644        assert_eq!(profile.context_window(), None);
645        assert_eq!(profile.reasoning_levels(), []);
646        assert!(!profile.supports_prompt_caching());
647    }
648
649    fn bedrock_claude_models() -> Vec<&'static LlmModel> {
650        let models: Vec<_> = LlmModel::all()
651            .iter()
652            .filter(|model| {
653                model.provider_enum() == Provider::Bedrock && model.model_id().contains("anthropic.claude-")
654            })
655            .collect();
656        assert!(!models.is_empty(), "the catalog must list Bedrock Claude models");
657        models
658    }
659}